Changeset 6fd0e20 in Klonkt


Ignore:
Timestamp:
07/26/2026 08:20:58 AM (6 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
af6e085
Parents:
cc3cf7d
Message:

FEP-044f: Klonkt resolvet de geciteerde post voor een ingebedde quote-kaart

De client kan remote AP-objecten niet gesigneerd ophalen, dus Klonkt resolvet de
quote emit-side tot een compacte, gesaneerde snapshot en serveert die als
shaer:quote op de C2S inbox-read. De client rendert daarmee een geneste kaart
(auteur + avatar + tekst + evt. thumbnail) i.p.v. alleen de link-chip.

resolveQuote(note) haalt de quoted post op (SSRF-safe apGetJson), pakt de auteur
(fetchActor + actorInfo) en saneert de content met dezelfde sanitizer als elke
andere note (kindveilig blijft gelden). Best-effort: faalt de fetch, dan blijft
quote_json leeg en valt de client terug op de chip.

Bron-URL via quoteHrefOf: object-level quote (FEP-044f) of een quote-rel
FEP-e232 Link. Resolutie op inbound (fire-and-forget, blokkeert de inbox-response
niet), op outbox-backfill (await) en in self-heal (v10 -> v11, met COALESCE-gedrag
zodat een tijdelijk onbereikbare quoted post de cache niet leegt).

Changed files:
src/config/database.js

  • ap_timeline.quote_json kolom

src/services/ActivityPubService.js

  • quoteHrefOf(note): quoted-post-URL uit object-quote of quote-rel Link
  • resolveQuote(note): async snapshot {url, author, content, published, media}
  • timelineQuote(quoteJson): snapshot terug voor de inbox-read
  • inbound (fire-and-forget), backfill (await), self-heal v11 (await + COALESCE)
  • timelineQuote in de default-export

src/routes/activitypub.js

  • inbox-read serveert shaer:quote (de geresolvede snapshot)

test/object-links.test.js

  • quoteHrefOf (object-quote + quote-rel Link + geen) en timelineQuote round-trip

remarks: 180 tests groen (was 178). resolveQuote zelf is netwerk, niet in de unit-test.

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

Files:
4 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    rcc3cf7d r6fd0e20  
    549549  ensureColumn('ap_timeline', 'emoji_json', 'TEXT');         // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag`
    550550  ensureColumn('ap_timeline', 'link_json', 'TEXT');          // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
     551  ensureColumn('ap_timeline', 'quote_json', 'TEXT');         // FEP-044f resolved quoted-post snapshot (author + content), for the embedded quote card
    551552  ensureColumn('ap_timeline', 'reblog_name', 'TEXT');        // a followed account boosted this → "X boosted"
    552553  ensureColumn('ap_timeline', 'reblog_handle', 'TEXT');      //   the booster's @handle
  • src/routes/activitypub.js

    rcc3cf7d r6fd0e20  
    170170        return tags.length ? tags : undefined;
    171171      })(),
     172      // FEP-044f: the resolved quoted post (author + content), so the client
     173      // renders an embedded quote card instead of a bare link. Omitted when the
     174      // note has no quote or the quoted post could not be resolved.
     175      'shaer:quote': AP.timelineQuote(t.quote_json),
    172176    },
    173177  }));
  • src/services/ActivityPubService.js

    rcc3cf7d r6fd0e20  
    15421542          { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, s.slug); } catch { /* ignore */ } } }
    15431543          if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, s.slug); } catch { /* ignore */ } }
     1544        }
     1545        // FEP-044f embedded quote card: resolve the quoted post out of band so
     1546        // the inbox response is not blocked on a remote fetch. Best-effort.
     1547        if (quoteHrefOf(o)) {
     1548          const slugs = subs.map((s) => s.slug);
     1549          resolveQuote(o).then((qj) => {
     1550            if (!qj) return;
     1551            for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, o.id, sl); } catch { /* ignore */ } }
     1552          }).catch(() => { /* best-effort */ });
    15441553        }
    15451554        console.log('[AP] timeline +', actorUri, 'x' + subs.length);
     
    26352644}
    26362645
     2646// The URL of the quoted post, from either an object-level quote (FEP-044f) or a
     2647// quote-rel FEP-e232 Link tag. Used to resolve the embedded quote card.
     2648export function quoteHrefOf(note) {
     2649  const direct = extractQuoteUrl(note);
     2650  if (direct) return direct;
     2651  const arr = Array.isArray(note && note.tag) ? note.tag : (note && note.tag ? [note.tag] : []);
     2652  for (const t of arr) {
     2653    if (!t || (Array.isArray(t.type) ? t.type[0] : t.type) !== 'Link' || typeof t.href !== 'string') continue;
     2654    const rel = Array.isArray(t.rel) ? t.rel : (t.rel ? [t.rel] : []);
     2655    if (rel.some((r) => /quote/i.test(String(r)))) return t.href;
     2656  }
     2657  return null;
     2658}
     2659
     2660// Turn the stored quote snapshot back into the object the C2S inbox read serves
     2661// as `shaer:quote`, so the client can render the embedded quote card.
     2662export function timelineQuote(quoteJson) {
     2663  try { const q = quoteJson ? JSON.parse(quoteJson) : null; return (q && typeof q === 'object') ? q : undefined; }
     2664  catch { return undefined; }
     2665}
     2666
    26372667// ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ──
    26382668let _abCount, _cirkelPosts, _cirkelMembers;
     
    27312761// during a flux window, e.g. a fleet-wide update), and drops notes that are gone
    27322762// (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
    2733 const SELFHEAL_VERSION = 10; // v10: also capture FEP-044f object-level quotes (quote/quoteUrl/quoteUri/_misskey_quote) into link_json
     2763const SELFHEAL_VERSION = 11; // v11: also resolve the FEP-044f embedded quote card (quote_json) onto already-cached posts
    27342764async function fetchNoteAP(url) {
    27352765  try {
     
    27482778  }
    27492779  return JSON.stringify(atts);
     2780}
     2781
     2782// FEP-044f embedded quote card: resolve the quoted post to a compact, sanitised
     2783// snapshot { url, author{name,handle,icon}, content, published, media } so the
     2784// client can render it as a nested card instead of a bare link. Best-effort and
     2785// SSRF-safe (apGetJson): returns null on any failure, and the client falls back
     2786// to the object-link chip. The content goes through the same sanitiser as every
     2787// other note, so the kid-safe guarantees hold.
     2788async function resolveQuote(note) {
     2789  const url = quoteHrefOf(note);
     2790  if (!url) return null;
     2791  const q = await apGetJson(url);
     2792  if (!q || typeof q !== 'object') return null;
     2793  const authorUri = typeof q.attributedTo === 'string' ? q.attributedTo
     2794    : (q.attributedTo && typeof q.attributedTo.id === 'string' ? q.attributedTo.id : null);
     2795  const ai = authorUri ? actorInfo(await fetchActor(authorUri), authorUri) : null;
     2796  const snapshot = {
     2797    url: safeUrl(q.url || q.id || url) || url,
     2798    author: ai ? { name: ai.name, handle: ai.handle, icon: ai.icon } : null,
     2799    content: HtmlSanitizerService.sanitize(q.content || ''),
     2800    published: q.published || null,
     2801    media: mediaFromNote(q),
     2802  };
     2803  return JSON.stringify(snapshot);
    27502804}
    27512805// A generic SSRF-safe AP GET (collections / pages).
     
    27942848        // FEP-e232 + FEP-044f: keep object-link/quote tags from backfilled posts too.
    27952849        { const lj = extractLinkJson(o); if (lj) { try { db.prepare('UPDATE ap_timeline SET link_json = ? WHERE id = ? AND slug = ?').run(lj, o.id, slug); } catch { /* ignore */ } } }
     2850        // FEP-044f: resolve the embedded quote card for backfilled posts too.
     2851        if (quoteHrefOf(o)) { const qj = await resolveQuote(o); if (qj) { try { db.prepare('UPDATE ap_timeline SET quote_json = ? WHERE id = ? AND slug = ?').run(qj, o.id, slug); } catch { /* ignore */ } } }
    27962852        // Set poll_json if this is a poll and we don't already have it (COALESCE preserves a vote).
    27972853        if (poll) { try { db.prepare('UPDATE ap_timeline SET poll_json = COALESCE(poll_json, ?) WHERE id = ? AND slug = ?').run(JSON.stringify(poll), o.id, slug); } catch { /* ignore */ } }
     
    29142970    if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
    29152971    let rows = [];
    2916     try { rows = db.prepare('SELECT id, content, media_json, nsfw, cw, url, emoji_json, link_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
     2972    try { rows = db.prepare('SELECT id, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
    29172973    let healed = 0, failed = 0;
    29182974    for (const r of rows) {
     
    29282984        const emoji = extractEmojiTags(note.tag);   // FEP-9098: re-capture custom-emoji tags (v8)
    29292985        const link = extractLinkJson(note);   // FEP-e232 + FEP-044f: re-capture object-link/quote tags (v9)
    2930         if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '') || (url && url !== r.url) || (emoji || '') !== (r.emoji_json || '') || (link || '') !== (r.link_json || '')) {
    2931           db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url), emoji_json = ?, link_json = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, url, emoji, link, r.id);
     2986        // FEP-044f: resolve the embedded quote card (v11). COALESCE-style: keep a
     2987        // cached snapshot if the quoted post is momentarily unreachable now.
     2988        const quote = quoteHrefOf(note) ? (await resolveQuote(note)) || r.quote_json || null : null;
     2989        if ((html && html !== r.content) || media !== (r.media_json || '[]') || nsfw !== (r.nsfw || 0) || (cw || '') !== (r.cw || '') || (url && url !== r.url) || (emoji || '') !== (r.emoji_json || '') || (link || '') !== (r.link_json || '') || (quote || '') !== (r.quote_json || '')) {
     2990          db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url), emoji_json = ?, link_json = ?, quote_json = ? WHERE id = ?').run(html || r.content, media, nsfw, cw, url, emoji, link, quote, r.id);
    29322991          healed++;
    29332992        }
     
    34333492  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
    34343493  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    3435   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, timelineEmojis, timelineObjectLinks, sendInteraction, voteOnPoll, voteOnRemotePoll,
     3494  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, sendInteraction, voteOnPoll, voteOnRemotePoll,
    34363495  acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
    34373496  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
  • test/object-links.test.js

    rcc3cf7d r6fd0e20  
    66const dbMod = await import('../src/config/database.js');
    77dbMod.initializeDatabase();
    8 const { extractObjectLinkTags, timelineObjectLinks, extractQuoteUrl, extractLinkJson } = await import('../src/services/ActivityPubService.js');
    9 const AP = { extractObjectLinkTags, timelineObjectLinks, extractQuoteUrl, extractLinkJson };
     8const { extractObjectLinkTags, timelineObjectLinks, extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote } = await import('../src/services/ActivityPubService.js');
     9const AP = { extractObjectLinkTags, timelineObjectLinks, extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote };
    1010
    1111test('extractObjectLinkTags keeps AS2-profiled ld+json and activity+json Links; drops plain links and mentions', () => {
     
    5959  assert.equal(arr[0].href, 'https://s/objects/9');
    6060});
     61
     62// FEP-044f embedded quote card: the quoted-post URL feeds the resolver.
     63test('quoteHrefOf reads an object-level quote or a quote-rel FEP-e232 Link', () => {
     64  assert.equal(AP.quoteHrefOf({ quoteUrl: 'https://s/q1' }), 'https://s/q1');
     65  assert.equal(AP.quoteHrefOf({
     66    tag: [{ type: 'Link', href: 'https://s/q2', rel: 'https://misskey-hub.net/ns#_misskey_quote' }],
     67  }), 'https://s/q2');
     68  assert.equal(AP.quoteHrefOf({ content: 'plain, no quote' }), null);
     69  // a plain (non-quote) FEP-e232 reference is not a quote target
     70  assert.equal(AP.quoteHrefOf({ tag: [{ type: 'Link', href: 'https://s/ref', rel: 'mention' }] }), null);
     71});
     72
     73test('timelineQuote round-trips the stored snapshot; junk → undefined', () => {
     74  const snap = JSON.stringify({ url: 'https://s/q', author: { name: 'A', handle: '@a@s', icon: null }, content: '<p>hi</p>' });
     75  const back = AP.timelineQuote(snap);
     76  assert.equal(back.url, 'https://s/q');
     77  assert.equal(back.author.handle, '@a@s');
     78  assert.equal(AP.timelineQuote(null), undefined);
     79  assert.equal(AP.timelineQuote('not json'), undefined);
     80});
Note: See TracChangeset for help on using the changeset viewer.