Changeset e6c6e6f in Klonkt


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@…>

Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    r024f4f8 re6c6e6f  
    1919import { apEnabled } from '../services/SettingsService.js';
    2020import OAuth from '../services/OAuthService.js';
     21import multer from 'multer';
     22import path from 'path';
     23import fs from 'fs';
     24import { fileURLToPath } from 'url';
     25import { randomUUID } from 'crypto';
    2126
    2227const router = express.Router();
     
    118123});
    119124
     125// ── uploadMedia (owner only, AP C2S) ──────────────────────────────
     126// The actor advertises endpoints.uploadMedia; this implements it. A bearer
     127// scoped to this site uploads one image/audio/video (multipart field "file",
     128// AP convention) into the same store the reply editor uses, and gets back
     129// { url, mediaType, name } to attach on a note (e.g. the help-buoy capture).
     130const AP_MEDIA_DIR = path.resolve(
     131  process.env.REPLY_MEDIA_PATH ||
     132  path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'storage', 'media', 'reply-media')
     133);
     134fs.mkdirSync(AP_MEDIA_DIR, { recursive: true });
     135const AP_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']);
     136const apMediaUpload = multer({
     137  storage: multer.diskStorage({
     138    destination: (req, file, cb) => cb(null, AP_MEDIA_DIR),
     139    filename: (req, file, cb) => cb(null, `${randomUUID()}${path.extname(file.originalname || '').toLowerCase()}`),
     140  }),
     141  limits: { fileSize: 32 * 1024 * 1024 },
     142  fileFilter: (req, file, cb) => {
     143    const ext = path.extname(file.originalname || '').toLowerCase();
     144    if (!AP_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
     145    cb(null, true);
     146  },
     147});
     148router.post('/ap/users/:slug/uploadMedia', (req, res) => {
     149  const auth = OAuth.verifyBearer(req.headers.authorization);
     150  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
     151  apMediaUpload.single('file')(req, res, (err) => {
     152    if (err) return res.status(400).json({ error: err.message });
     153    if (!req.file) return res.status(400).json({ error: 'No file' });
     154    const mime = String(req.file.mimetype || '');
     155    if (!/^(image|audio|video)\//.test(mime)) {
     156      try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
     157      return res.status(400).json({ error: 'Media must be an image, audio or video file' });
     158    }
     159    res.status(201).json({
     160      url: '/media/reply-media/' + req.file.filename,
     161      mediaType: mime,
     162      name: String(req.file.originalname || '').slice(0, 120),
     163    });
     164  });
     165});
     166
    120167// ── Followers (count-only public, full for the owner) ─────────────
    121168// A C2S bearer scoped to this site (the account owner) gets the real actor
  • 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);
  • test/c2s-direct.test.js

    r024f4f8 re6c6e6f  
    2727});
    2828
     29test('a direct note carries its attachments (help-buoy capture)', () => {
     30  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, visibility, to_actors, attachments, created_at)
     31    VALUES ('d2','me','',NULL,NULL,'https://r.test/u/g','@g@r.test','<p>kijk</p>','direct','["https://r.test/u/g"]','[{"url":"/media/reply-media/x.png","mediaType":"image/png","name":"capture"}]',CURRENT_TIMESTAMP)`).run();
     32  const row = db.prepare('SELECT * FROM ap_outbox WHERE id = ?').get('d2');
     33  const note = AP.buildReplyNote('https://test.example', site, row);
     34  assert.equal(note.attachment.length, 1);
     35  assert.equal(note.attachment[0].type, 'Image');
     36  assert.ok(note.attachment[0].url.endsWith('/media/reply-media/x.png'));
     37  assert.deepEqual(note.cc, []);   // still direct
     38});
     39
    2940test('direct without any real recipient is refused (400 no_recipients)', async () => {
    3041  const r = await AP.ingestOutboxActivity(site, user, {
Note: See TracChangeset for help on using the changeset viewer.