Changeset b258a79 in Klonkt
- Timestamp:
- 07/28/2026 07:53:36 AM (6 weeks ago)
- Branches:
- main
- Children:
- 0101d0a
- Parents:
- fc40410
- Files:
-
- 4 edited
-
src/config/database.js (modified) (1 diff)
-
src/services/ActivityPubService.js (modified) (5 diffs)
-
src/views/partials/tl-item.ejs (modified) (1 diff)
-
test/external-embeds.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
rfc40410 rb258a79 555 555 ensureColumn('ap_timeline', 'link_json', 'TEXT'); // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag` 556 556 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) 558 562 ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the author's display name (shaer:author.emojis) 559 563 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 503 503 // FEP-633c §2.2: object hint that the author is a ward (safely ignorable). 504 504 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); 505 508 if (post.nsfw) note.summary = post.content_warning || 'Gevoelige inhoud'; 506 509 if (attachment.length) note.attachment = attachment; … … 1775 1778 // mentioned person is notified even if they don't follow us (Mastodon-standard mention). 1776 1779 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 } 1778 1798 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 mentioned1799 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 1781 1801 const keys = getOrCreateKeys(site.slug); 1782 1802 const keyId = `${actorId(base, site.slug)}#main-key`; … … 2848 2868 } 2849 2869 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. 2877 export 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 2850 2895 // The first external (non-fediverse) link in a note, resolved to the same card 2851 2896 // shape as a quote: THUMBNAIL ONLY, never the provider's iframe. An arbitrary … … 2867 2912 const thumb = (card.media || []).find((m) => m && m.url); 2868 2913 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); 2869 2918 return JSON.stringify({ 2870 2919 url: card.url, 2871 2920 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, 2875 2924 media: thumb ? [thumb] : [], // thumbnail only, no html/iframe 2876 2925 }); 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 */ 2933 export 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 }; 2877 2940 } 2878 2941 … … 3634 3697 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote, 3635 3698 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, 3637 3700 acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision, 3638 3701 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs, -
src/views/partials/tl-item.ejs
rfc40410 rb258a79 23 23 <div class="tl-content"><%- emojiHtml(p.content, p.emoji_json) %></div> 24 24 <% } %> 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 }) %><% } %> 26 40 <% if (_nsfwVisual) { %><div class="nsfw-media"><% } %> 27 41 <% if (imgs.length) { %> -
test/external-embeds.test.js
rfc40410 rb258a79 43 43 assert.equal(timelineEmbed('{"title":"no url"}'), undefined, 'a card without a url is not a card'); 44 44 }); 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. 48 const { applyQuoteProps } = await import('../src/services/ActivityPubService.js'); 49 50 test('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 63 test('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 70 test('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 84 test('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.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)