Changeset 6089c53 in Klonkt


Ignore:
Timestamp:
07/30/2026 07:02:58 AM (6 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
97bcf7e
Parents:
7a93fcf
Message:

Een C2S-post draagt zijn media, en een foto mag het hele bericht zijn

De composer-uitbreiding van de apps (Robins opdracht, 30-7) liep meteen op een
servergat: c2sCreatePost las alleen de content, dus een top-level post met
attachments kwam naakt aan, terwijl dezelfde attachments op replies en DM's
gewoon werkten.

Nu: de media wordt gevalideerd zoals bij deliverReply (alleen eigen
/media-uploads, image/audio/video, max 4), in de post-HTML gevouwen zodat het
web hem toont en afspeelt, en opgeslagen in posts.c2s_attachments zodat
buildNote hem federeert met zijn ECHTE mediaType: de extensiemap kent geen
audio en noemde een m4a anders een Image. Beelden staan ook inline in de
content; de dedup op URL houdt ze enkel.

En een media-only note is voortaan een post in plaats van een
empty_note-fout: een foto kan het hele bericht zijn.

Changed files:
src/services/ActivityPubService.js

  • c2sCreatePost: attachments valideren, in de HTML vouwen, opslaan
  • buildNote: c2s_attachments mee-federeren, opgeslagen mediaType wint
  • de empty-note-poort laat media-only door

src/config/database.js

  • kolom posts.c2s_attachments

New file:
test/c2s-compose.test.js

  • media in de web-content en als AS2-attachments, met dedup en het juiste type; een vreemde URL komt er niet in; media-only mag

remarks: 336 tests groen. De app-kant (panel-composer met pickers) volgt in
de app-repos.

-robo
Co-Authored-By: Claude Opus 5 <noreply@…>

Files:
1 added
2 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r7a93fcf r6089c53  
    695695  ensureColumn('ap_outbox', 'away_until', 'INTEGER'); // FEP-633c 3.6.1 shaer:away + endTime (epoch ms)
    696696  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
    697698  ensureColumn('ap_mentions', 'wave', 'INTEGER');  // inbound guardian wave
    698699  // FEP-633c §2.2: object hint that the author is a ward. Register-only for now;
  • src/services/ActivityPubService.js

    r7a93fcf r6089c53  
    374374  if (post.cover_video_url && !noImages) urls.push({ url: abs(post.cover_video_url), name: post.cover_alt || '' });
    375375  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 */ }
    376385  let body = post.content || '';
    377386  // Only federate inline images we can actually serve: absolute http(s) URLs, or our own
     
    467476  const attachment = urls.filter((x) => x && x.url)
    468477    .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 Document
     478    .map((x) => { const mt = x.mt || mediaType(x.url); // the stored type wins; the extension map is the fallback
    470479      const ty = /^image\//i.test(mt) ? 'Image' : /^video\//i.test(mt) ? 'Video' : /^audio\//i.test(mt) ? 'Audio' : 'Document';
    471480      const a = { type: ty, mediaType: mt, url: x.url };
     
    22372246        // re-escapes, so it needs plain text; a top-level post keeps sanitized HTML.
    22382247        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        }
    22402253        // Direct (private mention, shaer-tqc): NOT a post. Delivered over the
    22412254        // outbox machinery to the addressed inboxes only; shows under Messages.
     
    23592372async function c2sCreatePost(base, site, user, object) {
    23602373  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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
     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('');
    23622393  const postId = crypto.randomUUID();
    23632394  const slug = 'n-' + postId.slice(0, 8);
     
    23722403  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)
    23732404              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(() => {});
    23772409  try { db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?,?,?,?)').run(HtmlSanitizerService.toPlainText(html), '', user.username || '', postId); } catch { /* FTS non-fatal */ }
    23782410  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 */ });
    23802412  }
    23812413  return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
Note: See TracChangeset for help on using the changeset viewer.