Changeset 5a93ac0 in Klonkt


Ignore:
Timestamp:
06/24/2026 11:16:28 AM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
eb852c5
Parents:
065452a
Message:

feat(activitypub): federate images as attachments

Cover image + inline <img> are emitted as AP attachment (Document) with absolute
URLs + mediaType; <img> stripped from content (Mastodon strips it anyway). Outbox
+ post-create delivery now include cover_image_url.

Co-Authored-By: Claude <noreply@…>

Location:
src
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    r065452a r5a93ac0  
    5454  if (!site) return res.status(404).end();
    5555  const posts = db.prepare(
    56     `SELECT id, slug, title, content, published_at, created_at
     56    `SELECT id, slug, title, content, cover_image_url, published_at, created_at
    5757     FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
    5858     ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
  • src/routes/posts.js

    r065452a r5a93ac0  
    236236      ActivityPubService.deliverCreate(site, {
    237237        id: postId, slug: finalSlug, title: title || finalSlug,
    238         content: cleanContent, published_at: publishedAt, created_at: now,
     238        content: cleanContent, cover_image_url: cover_image_url || null,
     239        published_at: publishedAt, created_at: now,
    239240      }).catch(() => { /* best-effort */ });
    240241    }
  • src/services/ActivityPubService.js

    r065452a r5a93ac0  
    106106  const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
    107107  const titleHtml = post.title ? `<p><strong>${escTitle}</strong></p>` : '';
    108   const html = titleHtml + (post.content || '');
    109   return {
     108
     109  // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect
     110  // the cover + any inline <img>, make absolute, then strip <img> from the content
     111  // to avoid duplicate rendering on clients that DO keep them.
     112  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
     113  const mediaType = (u) => {
     114    const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
     115    return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif' })[(e || '').toLowerCase()] || 'image/jpeg';
     116  };
     117  const urls = [];
     118  if (post.cover_image_url) urls.push(abs(post.cover_image_url));
     119  let body = post.content || '';
     120  for (const m of body.matchAll(/<img\b[^>]*\bsrc="([^"]+)"[^>]*>/gi)) urls.push(abs(m[1]));
     121  body = body.replace(/<img\b[^>]*>/gi, '');
     122  const seen = new Set();
     123  const attachment = urls.filter(Boolean)
     124    .filter((u) => { if (seen.has(u)) return false; seen.add(u); return true; })
     125    .map((u) => ({ type: 'Document', mediaType: mediaType(u), url: u }));
     126
     127  const note = {
    110128    id,
    111129    type: 'Note',
    112130    attributedTo: aId,
    113     content: html,
     131    content: titleHtml + body,
    114132    url: human,
    115133    published: new Date(post.published_at || post.created_at || Date.now()).toISOString(),
     
    118136    tag: Array.isArray(post.tags) ? post.tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/\s+/g, '') })) : [],
    119137  };
     138  if (attachment.length) note.attachment = attachment;
     139  return note;
    120140}
    121141
Note: See TracChangeset for help on using the changeset viewer.