Changeset e6c6e6f in Klonkt
- Timestamp:
- 07/20/2026 10:27:19 PM (7 weeks ago)
- 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)
- Files:
-
- 3 edited
-
src/routes/activitypub.js (modified) (2 diffs)
-
src/services/ActivityPubService.js (modified) (3 diffs)
-
test/c2s-direct.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/activitypub.js
r024f4f8 re6c6e6f 19 19 import { apEnabled } from '../services/SettingsService.js'; 20 20 import OAuth from '../services/OAuthService.js'; 21 import multer from 'multer'; 22 import path from 'path'; 23 import fs from 'fs'; 24 import { fileURLToPath } from 'url'; 25 import { randomUUID } from 'crypto'; 21 26 22 27 const router = express.Router(); … … 118 123 }); 119 124 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). 130 const 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 ); 134 fs.mkdirSync(AP_MEDIA_DIR, { recursive: true }); 135 const AP_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']); 136 const 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 }); 148 router.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 120 167 // ── Followers (count-only public, full for the owner) ───────────── 121 168 // A C2S bearer scoped to this site (the account owner) gets the real actor -
src/services/ActivityPubService.js
r024f4f8 re6c6e6f 1845 1845 .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC); 1846 1846 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 }); 1848 1858 if (!r || !r.id) return { status: 502, error: 'direct_failed' }; 1849 1859 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` }; … … 1961 1971 // The same S2S leg a Mastodon DM takes, so a guardian on any instance 1962 1972 // receives it as a private mention (the ward call-for-help path). 1963 export async function deliverDirectNote(site, { recipients, text, language, inReplyTo }) {1973 export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments }) { 1964 1974 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 1965 1975 const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8); … … 1981 1991 const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`; 1982 1992 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) })); 1983 2000 const id = crypto.randomUUID(); 1984 2001 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) 1985 2002 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))); 1987 2004 const row = iStmts().getO.get(id); 1988 2005 const note = buildReplyNote(base, site, row); -
test/c2s-direct.test.js
r024f4f8 re6c6e6f 27 27 }); 28 28 29 test('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 29 40 test('direct without any real recipient is refused (400 no_recipients)', async () => { 30 41 const r = await AP.ingestOutboxActivity(site, user, {
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)