Changeset e6c6e6f in Klonkt for src/services


Ignore:
Timestamp:
07/20/2026 10:27:19 PM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
155c24e
Parents:
024f4f8
git-author:
Robin <roboburr@…> (07/20/2026 10:27:18 PM)
git-committer:
Robin <roboburr@…> (07/20/2026 10:27:19 PM)
Message:

Feature: uploadMedia endpoint + attachments on direct notes

The actor has been advertising endpoints.uploadMedia without a route
behind it; this implements it. A bearer scoped to the site POSTs one
image/audio/video (multipart field "file", the AP C2S convention) into
the reply-media store and gets { url, mediaType, name } back. Direct
notes (private mentions) now carry attachments through the same
deliverReply-style validation (own /media/ uploads only, max 4), so
the help-buoy capture rides a DM to the guardians while the note stays
direct: recipients only, empty cc, unboostable.

Changed files:
src/routes/activitypub.js

  • POST /ap/users/:slug/uploadMedia (bearer-gated, multer, 32MB)

src/services/ActivityPubService.js

  • ingest passes AS2 attachments into the direct path (absolute own-base URLs normalized to relative)
  • deliverDirectNote validates + stores attachments

New file: (none)
test/c2s-direct.test.js

  • direct note renders its attachment, addressing stays direct

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r024f4f8 re6c6e6f  
    18451845            .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC);
    18461846          if (!recipients.length) return { status: 400, error: 'no_recipients' };
    1847           const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null });
     1847          // AS2 attachments (e.g. the help-buoy capture, uploaded via
     1848          // uploadMedia): normalize our own absolute /media/ URLs to relative
     1849          // so the deliverReply-style validation applies unchanged.
     1850          const atts = (Array.isArray(object.attachment) ? object.attachment : [])
     1851            .map((a) => a && typeof a === 'object' ? {
     1852              url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
     1853              mediaType: String(a.mediaType || ''),
     1854              name: String(a.name || '').slice(0, 120),
     1855            } : null)
     1856            .filter(Boolean);
     1857          const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts });
    18481858          if (!r || !r.id) return { status: 502, error: 'direct_failed' };
    18491859          return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
     
    19611971// The same S2S leg a Mastodon DM takes, so a guardian on any instance
    19621972// receives it as a private mention (the ward call-for-help path).
    1963 export async function deliverDirectNote(site, { recipients, text, language, inReplyTo }) {
     1973export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments }) {
    19641974  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    19651975  const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
     
    19811991  const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
    19821992  const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
     1993  // Attachments: same rules as deliverReply (own /media/ uploads only,
     1994  // image/audio/video, max 4) — the help-buoy capture rides this.
     1995  const media = (Array.isArray(attachments) ? attachments : [])
     1996    .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
     1997      && /^(image|audio|video)\//.test(String(a.mediaType || '')))
     1998    .slice(0, 4)
     1999    .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
    19832000  const id = crypto.randomUUID();
    19842001  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, created_at)
    19852002              VALUES (?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
    1986     .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, null, 'direct', JSON.stringify(resolved.map((r) => r.uri)));
     2003    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)));
    19872004  const row = iStmts().getO.get(id);
    19882005  const note = buildReplyNote(base, site, row);
Note: See TracChangeset for help on using the changeset viewer.