Changeset e6c6e6f in Klonkt for src/routes/activitypub.js


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/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
Note: See TracChangeset for help on using the changeset viewer.