Changeset 6fd0e20 in Klonkt
- Timestamp:
- 07/26/2026 08:20:58 AM (6 weeks ago)
- Branches:
- main
- Children:
- af6e085
- Parents:
- cc3cf7d
- Files:
-
- 4 edited
-
src/config/database.js (modified) (1 diff)
-
src/routes/activitypub.js (modified) (1 diff)
-
src/services/ActivityPubService.js (modified) (8 diffs)
-
test/object-links.test.js (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
rcc3cf7d r6fd0e20 549 549 ensureColumn('ap_timeline', 'emoji_json', 'TEXT'); // FEP-9098 custom emoji Emoji tags from the inbound note, served back as `tag` 550 550 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 551 552 ensureColumn('ap_timeline', 'reblog_name', 'TEXT'); // a followed account boosted this → "X boosted" 552 553 ensureColumn('ap_timeline', 'reblog_handle', 'TEXT'); // the booster's @handle -
src/routes/activitypub.js
rcc3cf7d r6fd0e20 170 170 return tags.length ? tags : undefined; 171 171 })(), 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), 172 176 }, 173 177 })); -
src/services/ActivityPubService.js
rcc3cf7d r6fd0e20 1542 1542 { 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 */ } } } 1543 1543 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 */ }); 1544 1553 } 1545 1554 console.log('[AP] timeline +', actorUri, 'x' + subs.length); … … 2635 2644 } 2636 2645 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. 2648 export 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. 2662 export 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 2637 2667 // ── Cirkel = posts from the accounts you auto-boost ("feature an artist") ── 2638 2668 let _abCount, _cirkelPosts, _cirkelMembers; … … 2731 2761 // during a flux window, e.g. a fleet-wide update), and drops notes that are gone 2732 2762 // (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync. 2733 const SELFHEAL_VERSION = 1 0; // v10: also capture FEP-044f object-level quotes (quote/quoteUrl/quoteUri/_misskey_quote) into link_json2763 const SELFHEAL_VERSION = 11; // v11: also resolve the FEP-044f embedded quote card (quote_json) onto already-cached posts 2734 2764 async function fetchNoteAP(url) { 2735 2765 try { … … 2748 2778 } 2749 2779 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. 2788 async 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); 2750 2804 } 2751 2805 // A generic SSRF-safe AP GET (collections / pages). … … 2794 2848 // FEP-e232 + FEP-044f: keep object-link/quote tags from backfilled posts too. 2795 2849 { 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 */ } } } 2796 2852 // Set poll_json if this is a poll and we don't already have it (COALESCE preserves a vote). 2797 2853 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 */ } } … … 2914 2970 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots 2915 2971 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 */ } 2917 2973 let healed = 0, failed = 0; 2918 2974 for (const r of rows) { … … 2928 2984 const emoji = extractEmojiTags(note.tag); // FEP-9098: re-capture custom-emoji tags (v8) 2929 2985 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); 2932 2991 healed++; 2933 2992 } … … 3433 3492 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote, 3434 3493 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, 3436 3495 acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision, 3437 3496 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs, -
test/object-links.test.js
rcc3cf7d r6fd0e20 6 6 const dbMod = await import('../src/config/database.js'); 7 7 dbMod.initializeDatabase(); 8 const { extractObjectLinkTags, timelineObjectLinks, extractQuoteUrl, extractLinkJson } = await import('../src/services/ActivityPubService.js');9 const AP = { extractObjectLinkTags, timelineObjectLinks, extractQuoteUrl, extractLinkJson };8 const { extractObjectLinkTags, timelineObjectLinks, extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote } = await import('../src/services/ActivityPubService.js'); 9 const AP = { extractObjectLinkTags, timelineObjectLinks, extractQuoteUrl, extractLinkJson, quoteHrefOf, timelineQuote }; 10 10 11 11 test('extractObjectLinkTags keeps AS2-profiled ld+json and activity+json Links; drops plain links and mentions', () => { … … 59 59 assert.equal(arr[0].href, 'https://s/objects/9'); 60 60 }); 61 62 // FEP-044f embedded quote card: the quoted-post URL feeds the resolver. 63 test('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 73 test('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.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)