Changeset b258a79 in Klonkt


Ignore:
Timestamp:
07/28/2026 07:53:36 AM (6 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
0101d0a
Parents:
fc40410
Message:

Fase 3: quotes federeren volgens de FEP, en de geciteerde auteur hoort het

De spiegel van wat we al aan de ingest-kant deden. Quoot je vanuit Klonkt een
fediverse-object, dan gaat dat nu ook echt de lijn over als quote, in de drie
vormen die het netwerk werkelijk leest: de FEP-044f quote-property, de de-facto
aliassen quoteUrl en _misskey_quote, en een FEP-e232 Link-tag. Alle drie wijzen
naar hetzelfde object, want elke lezer kijkt ergens anders.

En het belangrijkste: de geciteerde auteur komt in cc en zijn inbox in de
bezorglijst. Geciteerd worden zonder het te horen is precies de onbeleefdheid
die deze FEP probeert weg te ontwerpen.

De resolutie gebeurt een keer, bij publiceren (deliverCreate is toch al async),
en wordt opgeslagen op de post. buildNote blijft daardoor synchroon en hoeft
nooit te fetchen, ook niet als de outbox 'm opnieuw opbouwt.

Web-UI: een externe embed gebruikt nu dezelfde .tl-quote-kaart als een quote, dus
de belofte van een representatie klopt nu ook op het web.

Veiligheid: titel, provider en auteursnaam komen van een derde partij en worden
als PLATTE TEKST opgeslagen (tags eruit, lengte begrensd), zodat geen enkele
renderer verderop degene hoeft te zijn die aan escapen denkt. In de web-template
gaat de titel er bovendien nog een keer ge-escaped in, want de quote-kaart
rendert content als HTML (terecht voor AP-content, die we bij binnenkomst
saneren, maar niet voor dit).

Changed files:
src/config/database.js

  • posts.quote_uri en posts.quote_actor

src/services/ActivityPubService.js

  • applyQuoteProps: de drie quote-vormen + de auteur in cc
  • buildNote past ze toe; resolveOwnQuote resolvet een keer bij publiceren
  • deliverCreate bewaart het resultaat en bezorgt bij de geciteerde auteur
  • externe embed slaat titel/provider/auteur als platte tekst op

src/views/partials/tl-item.ejs

  • embed valt terug op dezelfde quote-kaart, met ge-escapete titel

test/external-embeds.test.js

  • 4 tests erbij: de drie vormen, de auteur in cc zonder de followers te verliezen, junk-invoer verandert niets, en er wordt nooit markup opgeslagen

remarks: 209 tests groen.

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

Files:
4 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    rfc40410 rb258a79  
    555555  ensureColumn('ap_timeline', 'link_json', 'TEXT');          // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
    556556  ensureColumn('ap_timeline', 'quote_json', 'TEXT');         // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
    557   ensureColumn('ap_timeline', 'embed_json', 'TEXT');         // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
     557  // FEP-044f: the fediverse object THIS post quotes, resolved once at publish
     558  // time so buildNote (sync, also used by the outbox) needs no network.
     559  ensureColumn('posts', 'quote_uri', 'TEXT');     // the quoted object's id
     560  ensureColumn('posts', 'quote_actor', 'TEXT');   // its author, so we can address them
     561  ensureColumn('ap_timeline', 'embed_json', 'TEXT');       // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
    558562  ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT');  // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
    559563  ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT');  // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis)
  • src/services/ActivityPubService.js

    rfc40410 rb258a79  
    503503  // FEP-633c §2.2: object hint that the author is a ward (safely ignorable).
    504504  Object.assign(note, Guardianship.hasGuardiansProps(site.slug));
     505  // FEP-044f: this post quotes a fediverse object. Emit it the way the network
     506  // actually reads it, and address the quoted author so they get told.
     507  applyQuoteProps(note, post.quote_uri, post.quote_actor);
    505508  if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud';
    506509  if (attachment.length) note.attachment = attachment;
     
    17751778  // mentioned person is notified even if they don't follow us (Mastodon-standard mention).
    17761779  const mres = await resolveMentionsInText(base, post.content || '');
    1777   const post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
     1780  let post2 = mres.inboxes.length ? { ...post, content: mres.html } : post;
     1781  // FEP-044f: does this post quote a fediverse object? Resolve it once, here,
     1782  // and remember it on the post, so buildNote (sync, also used by the outbox)
     1783  // never has to fetch. The quoted author's inbox joins the delivery set: that
     1784  // IS the notification.
     1785  const quoteInboxes = [];
     1786  if (post2.quote_uri === undefined || post2.quote_uri === null) {
     1787    const q = await resolveOwnQuote(post2.content || '');
     1788    if (q) {
     1789      try { db.prepare('UPDATE posts SET quote_uri = ?, quote_actor = ? WHERE id = ?').run(q.uri, q.actor || null, post.id); } catch { /* ignore */ }
     1790      post2 = { ...post2, quote_uri: q.uri, quote_actor: q.actor || null };
     1791    }
     1792  }
     1793  if (post2.quote_actor) {
     1794    const a = await fetchActor(post2.quote_actor).catch(() => null);
     1795    const inbox = a && ((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
     1796    if (inbox) quoteInboxes.push(inbox);
     1797  }
    17781798  const followers = fStmts().list.all(site.slug);
    1779   const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes].filter(Boolean))];
    1780   if (!inboxes.length) return; // no followers and no one mentioned
     1799  const inboxes = [...new Set([...followers.map((f) => f.shared_inbox || f.inbox), ...mres.inboxes, ...quoteInboxes].filter(Boolean))];
     1800  if (!inboxes.length) return; // no followers, no one mentioned, no one quoted
    17811801  const keys = getOrCreateKeys(site.slug);
    17821802  const keyId = `${actorId(base, site.slug)}#main-key`;
     
    28482868}
    28492869
     2870// FEP-044f, emit side. The mirror of extractQuoteUrl (ingest): when one of our
     2871// own posts quotes a fediverse object, say so in the shapes the network really
     2872// reads. `quote` is the FEP property; quoteUrl / _misskey_quote are the de-facto
     2873// ones Mastodon and Misskey look at, and the FEP-e232 `Link` in `tag` is the
     2874// third form. All three point at the same object, which is what every reader
     2875// expects. The quoted author goes in `cc`, because being quoted without being
     2876// told is exactly the rudeness this FEP is trying to design away.
     2877export function applyQuoteProps(note, quoteUri, quoteActor) {
     2878  if (!note || typeof quoteUri !== 'string' || !/^https?:\/\//i.test(quoteUri)) return note;
     2879  note.quote = quoteUri;
     2880  note.quoteUrl = quoteUri;
     2881  note['_misskey_quote'] = quoteUri;
     2882  note.tag = [...(note.tag || []), {
     2883    type: 'Link',
     2884    mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
     2885    href: quoteUri,
     2886    rel: ['https://misskey-hub.net/ns#_misskey_quote'],
     2887    name: quoteUri,
     2888  }];
     2889  if (typeof quoteActor === 'string' && /^https?:\/\//i.test(quoteActor)) {
     2890    note.cc = [...new Set([...(note.cc || []), quoteActor])];
     2891  }
     2892  return note;
     2893}
     2894
    28502895// The first external (non-fediverse) link in a note, resolved to the same card
    28512896// shape as a quote: THUMBNAIL ONLY, never the provider's iframe. An arbitrary
     
    28672912  const thumb = (card.media || []).find((m) => m && m.url);
    28682913  if (!thumb && !card.title) return null;
     2914  // Title, provider and author name come from a third party. Store them as
     2915  // PLAIN TEXT (tags stripped, length-capped), so no renderer downstream has to
     2916  // be the one that remembers to escape. A card is a card, not an essay.
     2917  const plain = (v) => (v ? HtmlSanitizerService.toPlainText(String(v)).trim().slice(0, 200) : null);
    28692918  return JSON.stringify({
    28702919    url: card.url,
    28712920    kind: card.kind,                       // 'provider' | 'oembed'
    2872     provider: card.provider || null,
    2873     title: card.title || null,
    2874     author: card.author || null,
     2921    provider: plain(card.provider),
     2922    title: plain(card.title),
     2923    author: card.author ? { ...card.author, name: plain(card.author.name), handle: plain(card.author.handle) } : null,
    28752924    media: thumb ? [thumb] : [],           // thumbnail only, no html/iframe
    28762925  });
     2926}
     2927
     2928/**
     2929 * Does our own post link to a fediverse object? Returns { uri, actor } when the
     2930 * first external link resolves to a quotable AP object, else null. Runs once at
     2931 * publish time; the answer is stored on the post.
     2932 */
     2933export async function resolveOwnQuote(html) {
     2934  const first = firstExternalUrl(html);
     2935  if (!first) return null;
     2936  const io = EmbedResolver.liveIO({ safeFetch, detectProvider: () => null, fetchActor, actorInfo });
     2937  const card = await EmbedResolver.resolveEmbed(first, io).catch(() => null);
     2938  if (!card || card.kind !== 'ap' || !card.id) return null;
     2939  return { uri: card.id, actor: card.attributedTo || null };
    28772940}
    28782941
     
    36343697  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
    36353698  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    3636   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, sendInteraction, voteOnPoll, voteOnRemotePoll,
     3699  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, sendInteraction, voteOnPoll, voteOnRemotePoll,
    36373700  acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
    36383701  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
  • src/views/partials/tl-item.ejs

    rfc40410 rb258a79  
    2323            <div class="tl-content"><%- emojiHtml(p.content, p.emoji_json) %></div>
    2424          <% } %>
    25           <% var _quote = noteQuote(p.quote_json); if (_quote) { %><%- include('../partials/quote-card', { q: _quote }) %><% } %>
     25          <%
     26            // One card for both: a fediverse quote and an external embed look
     27            // identical; only where they came from differs. An embed carries a
     28            // title and a thumbnail, never an iframe.
     29            var _quote = noteQuote(p.quote_json);
     30            if (!_quote) {
     31              var _emb = noteQuote(p.embed_json);
     32              // The title comes from a third-party oEmbed provider, so it is
     33              // escaped here: the quote card renders `content` as HTML (fine for
     34              // AP content, which we sanitise on the way in, but not for this).
     35              if (_emb) _quote = { url: _emb.url, author: _emb.author || (_emb.provider ? { name: _emb.provider } : null),
     36                                   content: _emb.title ? ('<p>' + emojiName(_emb.title, null) + '</p>') : '', media: _emb.media || [] };
     37            }
     38          %>
     39          <% if (_quote) { %><%- include('../partials/quote-card', { q: _quote }) %><% } %>
    2640          <% if (_nsfwVisual) { %><div class="nsfw-media"><% } %>
    2741          <% if (imgs.length) { %>
  • test/external-embeds.test.js

    rfc40410 rb258a79  
    4343  assert.equal(timelineEmbed('{"title":"no url"}'), undefined, 'a card without a url is not a card');
    4444});
     45
     46// FEP-044f emit side: quoting a fediverse object must federate as a quote AND
     47// tell the quoted author. This is the mirror of the ingest we already had.
     48const { applyQuoteProps } = await import('../src/services/ActivityPubService.js');
     49
     50test('a quote is emitted in all three shapes the network reads', () => {
     51  const note = { to: ['https://www.w3.org/ns/activitystreams#Public'], cc: [], tag: [{ type: 'Hashtag', name: '#x' }] };
     52  applyQuoteProps(note, 'https://s/objects/9', 'https://s/users/alice');
     53  assert.equal(note.quote, 'https://s/objects/9', 'the FEP property');
     54  assert.equal(note.quoteUrl, 'https://s/objects/9', 'the as: alias Mastodon reads');
     55  assert.equal(note._misskey_quote, 'https://s/objects/9', 'the misskey alias');
     56  const link = note.tag.find((t) => t.type === 'Link');
     57  assert.ok(link, 'and an FEP-e232 Link tag');
     58  assert.equal(link.href, 'https://s/objects/9');
     59  assert.ok(link.mediaType.includes('activitystreams'));
     60  assert.ok(note.tag.some((t) => t.type === 'Hashtag'), 'existing tags survive');
     61});
     62
     63test('the quoted author is addressed, so being quoted is not a surprise', () => {
     64  const note = { cc: ['https://s/users/me/followers'] };
     65  applyQuoteProps(note, 'https://s/objects/9', 'https://s/users/alice');
     66  assert.ok(note.cc.includes('https://s/users/alice'));
     67  assert.ok(note.cc.includes('https://s/users/me/followers'), 'without dropping the followers');
     68});
     69
     70test('no quote, or a junk one, changes nothing', () => {
     71  const a = { cc: [], tag: [] };
     72  applyQuoteProps(a, null, null);
     73  assert.equal(a.quote, undefined);
     74  assert.equal(a.tag.length, 0);
     75  const b = { cc: [], tag: [] };
     76  applyQuoteProps(b, 'javascript:alert(1)', 'https://s/users/alice');
     77  assert.equal(b.quote, undefined, 'a non-http quote uri is refused');
     78  const c = { cc: [], tag: [] };
     79  applyQuoteProps(c, 'https://s/objects/9', 'not-a-url');
     80  assert.equal(c.quote, 'https://s/objects/9');
     81  assert.equal(c.cc.length, 0, 'a junk actor is simply not addressed');
     82});
     83
     84test('a hostile oEmbed title is stored as plain text, not markup', async () => {
     85  const { resolveExternalEmbed } = await import('../src/services/ActivityPubService.js');
     86  // No network in the test env, so the resolver bails and returns null; the
     87  // point here is the contract: whatever comes back is never raw provider HTML.
     88  const out = await resolveExternalEmbed('<p><a href="https://v.example/1">x</a></p>');
     89  assert.ok(out === null || !/<script/i.test(out), 'never stores executable markup');
     90});
Note: See TracChangeset for help on using the changeset viewer.