Changeset 6089c53 in Klonkt
- Timestamp:
- 07/30/2026 07:02:58 AM (6 weeks ago)
- Branches:
- main
- Children:
- 97bcf7e
- Parents:
- 7a93fcf
- Files:
-
- 1 added
- 2 edited
-
src/config/database.js (modified) (1 diff)
-
src/services/ActivityPubService.js (modified) (5 diffs)
-
test/c2s-compose.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
r7a93fcf r6089c53 695 695 ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms) 696 696 ensureColumn('ap_gated_offers', 'proposer', 'TEXT'); // who proposed (5.6): the settle-answer goes back to them 697 ensureColumn('posts', 'c2s_attachments', 'TEXT'); // media a C2S Note carried (JSON [{url,mediaType,name}]); buildNote federates them 697 698 ensureColumn('ap_mentions', 'wave', 'INTEGER'); // inbound guardian wave 698 699 // FEP-633c §2.2: object hint that the author is a ward. Register-only for now; -
src/services/ActivityPubService.js
r7a93fcf r6089c53 374 374 if (post.cover_video_url && !noImages) urls.push({ url: abs(post.cover_video_url), name: post.cover_alt || '' }); 375 375 else if (post.cover_image_url && !noImages) urls.push({ url: abs(post.cover_image_url), name: post.cover_alt || '' }); 376 // Media a C2S composer attached (shaer-j3uh): federate with their REAL 377 // mediaType, because the extension map below knows no audio and would call 378 // an m4a an Image. Images also live inline in the content, so the dedupe 379 // by URL keeps them single. 380 try { 381 for (const a of JSON.parse(post.c2s_attachments || '[]')) { 382 if (a && a.url) urls.push({ url: abs(a.url), name: a.name || '', mt: a.mediaType }); 383 } 384 } catch { /* malformed never blocks the Note */ } 376 385 let body = post.content || ''; 377 386 // Only federate inline images we can actually serve: absolute http(s) URLs, or our own … … 467 476 const attachment = urls.filter((x) => x && x.url) 468 477 .filter((x) => { if (seen.has(x.url)) return false; seen.add(x.url); return true; }) 469 .map((x) => { const mt = mediaType(x.url); // specific AS2 subtype (Image/Audio/Video) over generic Document478 .map((x) => { const mt = x.mt || mediaType(x.url); // the stored type wins; the extension map is the fallback 470 479 const ty = /^image\//i.test(mt) ? 'Image' : /^video\//i.test(mt) ? 'Video' : /^audio\//i.test(mt) ? 'Audio' : 'Document'; 471 480 const a = { type: ty, mediaType: mt, url: x.url }; … … 2237 2246 // re-escapes, so it needs plain text; a top-level post keeps sanitized HTML. 2238 2247 const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || ''); 2239 if (!plain.trim() && !object.content) return { status: 400, error: 'empty_note' }; 2248 // A picture (or a recording) can be the whole message: media-only 2249 // notes pass here; c2sCreatePost validates the attachments themselves. 2250 if (!plain.trim() && !object.content && !(Array.isArray(object.attachment) && object.attachment.length)) { 2251 return { status: 400, error: 'empty_note' }; 2252 } 2240 2253 // Direct (private mention, shaer-tqc): NOT a post. Delivered over the 2241 2254 // outbox machinery to the addressed inboxes only; shows under Messages. … … 2359 2372 async function c2sCreatePost(base, site, user, object) { 2360 2373 const html = HtmlSanitizerService.sanitize(object.content || (object.source && object.source.content) || ''); 2361 if (!html.trim()) return { status: 400, error: 'empty_note' }; 2374 // Media on a top-level post (shaer-j3uh/-oqxk/-df3i): same rules as 2375 // deliverReply — only our OWN uploads, image/audio/video, max 4. They used 2376 // to be silently dropped here, so a photo post from the app arrived naked. 2377 const media = (Array.isArray(object.attachment) ? object.attachment : []) 2378 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url) 2379 && /^(image|audio|video)\//.test(String(a.mediaType || ''))) 2380 .slice(0, 4) 2381 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) })); 2382 if (!html.trim() && !media.length) return { status: 400, error: 'empty_note' }; 2383 // The web reads the post's content, so the media goes IN it (we build these 2384 // tags ourselves from validated paths, after the sanitizer). buildNote 2385 // strips <img> back out into AS2 attachments; audio/video tags stay for the 2386 // web player and federate via c2s_attachments below. 2387 const esc = (t) => String(t).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<'); 2388 const mediaHtml = media.map((a) => { 2389 if (a.mediaType.startsWith('image/')) return `<p><img src="${a.url}" alt="${esc(a.name)}"></p>`; 2390 if (a.mediaType.startsWith('audio/')) return `<p><audio controls preload="metadata" src="${a.url}"></audio></p>`; 2391 return `<p><video controls playsinline src="${a.url}"></video></p>`; 2392 }).join(''); 2362 2393 const postId = crypto.randomUUID(); 2363 2394 const slug = 'n-' + postId.slice(0, 8); … … 2372 2403 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, type, language, fan_only, ap_visibility, created_at, updated_at, published_at) 2373 2404 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) 2374 .run(postId, site.id, slug, user.id, '', html, '', 'published', 'post', object.language || 'nl', fanOnly, vis, now, now, now); 2375 try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html), postId); } catch { /* render fallback covers it */ } 2376 bakePostContentWithMentions(html).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {}); 2405 .run(postId, site.id, slug, user.id, '', html + mediaHtml, '', 'published', 'post', object.language || 'nl', fanOnly, vis, now, now, now); 2406 if (media.length) { try { db.prepare('UPDATE posts SET c2s_attachments = ? WHERE id = ?').run(JSON.stringify(media), postId); } catch { /* column exists via ensureColumn */ } } 2407 try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html + mediaHtml), postId); } catch { /* render fallback covers it */ } 2408 bakePostContentWithMentions(html + mediaHtml).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {}); 2377 2409 try { db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?,?,?,?)').run(HtmlSanitizerService.toPlainText(html), '', user.username || '', postId); } catch { /* FTS non-fatal */ } 2378 2410 if (vis !== 'direct') { 2379 deliverCreate(site, { id: postId, slug, title: '', content: html , published_at: now, created_at: now, fan_only: fanOnly, ap_visibility: vis}).catch(() => { /* best-effort */ });2411 deliverCreate(site, { id: postId, slug, title: '', content: html + mediaHtml, published_at: now, created_at: now, fan_only: fanOnly, ap_visibility: vis, c2s_attachments: media.length ? JSON.stringify(media) : null }).catch(() => { /* best-effort */ }); 2380 2412 } 2381 2413 return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
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)