Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 024f4f8c0acb305dd19077f639d2e96c787dd63c)
+++ src/routes/activitypub.js	(revision e6c6e6f74ea19581f6f61944c055f61510b149dd)
@@ -19,4 +19,9 @@
 import { apEnabled } from '../services/SettingsService.js';
 import OAuth from '../services/OAuthService.js';
+import multer from 'multer';
+import path from 'path';
+import fs from 'fs';
+import { fileURLToPath } from 'url';
+import { randomUUID } from 'crypto';
 
 const router = express.Router();
@@ -118,4 +123,46 @@
 });
 
+// ── uploadMedia (owner only, AP C2S) ──────────────────────────────
+// The actor advertises endpoints.uploadMedia; this implements it. A bearer
+// scoped to this site uploads one image/audio/video (multipart field "file",
+// AP convention) into the same store the reply editor uses, and gets back
+// { url, mediaType, name } to attach on a note (e.g. the help-buoy capture).
+const AP_MEDIA_DIR = path.resolve(
+  process.env.REPLY_MEDIA_PATH ||
+  path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'storage', 'media', 'reply-media')
+);
+fs.mkdirSync(AP_MEDIA_DIR, { recursive: true });
+const AP_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']);
+const apMediaUpload = multer({
+  storage: multer.diskStorage({
+    destination: (req, file, cb) => cb(null, AP_MEDIA_DIR),
+    filename: (req, file, cb) => cb(null, `${randomUUID()}${path.extname(file.originalname || '').toLowerCase()}`),
+  }),
+  limits: { fileSize: 32 * 1024 * 1024 },
+  fileFilter: (req, file, cb) => {
+    const ext = path.extname(file.originalname || '').toLowerCase();
+    if (!AP_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
+    cb(null, true);
+  },
+});
+router.post('/ap/users/:slug/uploadMedia', (req, res) => {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+  apMediaUpload.single('file')(req, res, (err) => {
+    if (err) return res.status(400).json({ error: err.message });
+    if (!req.file) return res.status(400).json({ error: 'No file' });
+    const mime = String(req.file.mimetype || '');
+    if (!/^(image|audio|video)\//.test(mime)) {
+      try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
+      return res.status(400).json({ error: 'Media must be an image, audio or video file' });
+    }
+    res.status(201).json({
+      url: '/media/reply-media/' + req.file.filename,
+      mediaType: mime,
+      name: String(req.file.originalname || '').slice(0, 120),
+    });
+  });
+});
+
 // ── Followers (count-only public, full for the owner) ─────────────
 // A C2S bearer scoped to this site (the account owner) gets the real actor
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 024f4f8c0acb305dd19077f639d2e96c787dd63c)
+++ src/services/ActivityPubService.js	(revision e6c6e6f74ea19581f6f61944c055f61510b149dd)
@@ -1845,5 +1845,15 @@
             .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC);
           if (!recipients.length) return { status: 400, error: 'no_recipients' };
-          const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null });
+          // AS2 attachments (e.g. the help-buoy capture, uploaded via
+          // uploadMedia): normalize our own absolute /media/ URLs to relative
+          // so the deliverReply-style validation applies unchanged.
+          const atts = (Array.isArray(object.attachment) ? object.attachment : [])
+            .map((a) => a && typeof a === 'object' ? {
+              url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
+              mediaType: String(a.mediaType || ''),
+              name: String(a.name || '').slice(0, 120),
+            } : null)
+            .filter(Boolean);
+          const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts });
           if (!r || !r.id) return { status: 502, error: 'direct_failed' };
           return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
@@ -1961,5 +1971,5 @@
 // The same S2S leg a Mastodon DM takes, so a guardian on any instance
 // receives it as a private mention (the ward call-for-help path).
-export async function deliverDirectNote(site, { recipients, text, language, inReplyTo }) {
+export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments }) {
   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
   const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
@@ -1981,8 +1991,15 @@
   const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
   const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
+  // Attachments: same rules as deliverReply (own /media/ uploads only,
+  // image/audio/video, max 4) — the help-buoy capture rides this.
+  const media = (Array.isArray(attachments) ? attachments : [])
+    .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
+      && /^(image|audio|video)\//.test(String(a.mediaType || '')))
+    .slice(0, 4)
+    .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
   const id = crypto.randomUUID();
   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)
               VALUES (?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
-    .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, null, 'direct', JSON.stringify(resolved.map((r) => r.uri)));
+    .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)));
   const row = iStmts().getO.get(id);
   const note = buildReplyNote(base, site, row);
