Changeset fc40410 in Klonkt
- Timestamp:
- 07/28/2026 07:43:47 AM (6 weeks ago)
- Branches:
- main
- Children:
- b258a79
- Parents:
- 6bc2e31b
- Files:
-
- 1 added
- 5 edited
-
src/config/database.js (modified) (2 diffs)
-
src/routes/activitypub.js (modified) (2 diffs)
-
src/services/ActivityPubService.js (modified) (4 diffs)
-
src/services/guardianship/index.js (modified) (1 diff)
-
src/services/guardianship/notes.js (modified) (2 diffs)
-
test/external-embeds.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
r6bc2e31b rfc40410 132 132 ensureColumn('sites', 'show_search', 'INTEGER DEFAULT 1'); 133 133 ensureColumn('sites', 'show_archive_link', 'INTEGER DEFAULT 1'); 134 ensureColumn('sites', 'og_theme', 'TEXT'); // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark' 134 // Gated feature (FEP-633c): may external (non-fediverse) embeds be shown to 135 // this account? NULL = auto, which means OFF for a ward and ON for anyone 136 // else. The guardians flip it; the gate itself lives server-side, so a ward 137 // never even receives the thumbnail it is not allowed to see. 138 ensureColumn('sites', 'external_embeds', 'INTEGER'); 139 ensureColumn('sites', 'og_theme', 'TEXT'); // OG share-card variant: NULL=auto (follow site theme) | 'light' | 'dark' 135 140 136 141 // Per-post noindex + type … … 550 555 ensureColumn('ap_timeline', 'link_json', 'TEXT'); // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag` 551 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) 552 558 ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the author's display name (shaer:author.emojis) 553 559 ensureColumn('ap_timeline', 'reblog_emoji_json', 'TEXT'); // FEP-9098 custom emojis in the booster's display name (shaer:booster.emojis) -
src/routes/activitypub.js
r6bc2e31b rfc40410 145 145 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end(); 146 146 const base = baseUrl(req); 147 // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's 148 // world outside the fediverse is the guardians' call. The gate is applied 149 // here, at serialisation: a blocked embed is never sent, because an embed the 150 // client merely hides has still been delivered to the device. 151 const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })(); 152 const embedsAllowed = Guardianship.externalEmbedsAllowed(auth.site.external_embeds, isWard); 147 153 const items = AP.getTimeline(auth.site.slug, 60).map((t) => ({ 148 154 id: `${t.id}#create`, … … 195 201 'shaer:liked': !!t.liked, 196 202 'shaer:boosted': !!t.boosted, 203 // An external (non-fediverse) embed, thumbnail-only and never an iframe. 204 // Omitted entirely when the gate is closed (see above). 205 'shaer:embed': embedsAllowed ? AP.timelineEmbed(t.embed_json) : undefined, 197 206 }, 198 207 })); -
src/services/ActivityPubService.js
r6bc2e31b rfc40410 22 22 import HtmlSanitizerService from './HtmlSanitizerService.js'; 23 23 import AudioEmbedService from './AudioEmbedService.js'; 24 import EmbedResolver from './EmbedResolver.js'; 24 25 import Push from './PushService.js'; 25 26 import { getTenancy } from './SettingsService.js'; … … 1575 1576 if (!qj) return; 1576 1577 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 */ } } 1578 }).catch(() => { /* best-effort */ }); 1579 } else { 1580 // No fediverse quote: try an EXTERNAL embed (oEmbed / known provider), 1581 // thumbnail-only. Also out of band, and stored for everyone; the gate 1582 // that decides who may SEE it is applied at serve time (§5.3-style 1583 // gated feature, see the inbox read). 1584 const slugs = subs.map((s) => s.slug); 1585 resolveExternalEmbed(o.content).then((ej) => { 1586 if (!ej) return; 1587 for (const sl of slugs) { try { db.prepare('UPDATE ap_timeline SET embed_json = ? WHERE id = ? AND slug = ?').run(ej, o.id, sl); } catch { /* ignore */ } } 1577 1588 }).catch(() => { /* best-effort */ }); 1578 1589 } … … 2837 2848 } 2838 2849 2850 // The first external (non-fediverse) link in a note, resolved to the same card 2851 // shape as a quote: THUMBNAIL ONLY, never the provider's iframe. An arbitrary 2852 // third-party frame inside a kid-safe app is a hole you cannot close again, so 2853 // the embed carries an image and a title and nothing executable. 2854 // Returns the JSON to store, or null when there is nothing worth showing. 2855 export async function resolveExternalEmbed(html) { 2856 const first = firstExternalUrl(html); 2857 if (!first) return null; 2858 const io = EmbedResolver.liveIO({ 2859 safeFetch, 2860 detectProvider: (u) => AudioEmbedService.detectProvider(u), 2861 fetchActor, 2862 actorInfo, 2863 }); 2864 const card = await EmbedResolver.resolveEmbed(first, io).catch(() => null); 2865 // 'ap' is handled by the quote path; a bare 'link' is not worth a card. 2866 if (!card || card.kind === 'ap' || card.kind === 'link') return null; 2867 const thumb = (card.media || []).find((m) => m && m.url); 2868 if (!thumb && !card.title) return null; 2869 return JSON.stringify({ 2870 url: card.url, 2871 kind: card.kind, // 'provider' | 'oembed' 2872 provider: card.provider || null, 2873 title: card.title || null, 2874 author: card.author || null, 2875 media: thumb ? [thumb] : [], // thumbnail only, no html/iframe 2876 }); 2877 } 2878 2879 /** The first http(s) link in sanitized note HTML that is not a mention/hashtag. */ 2880 export function firstExternalUrl(html) { 2881 if (!html || typeof html !== 'string') return null; 2882 for (const m of html.matchAll(/<a\b[^>]*href=["']([^"']+)["'][^>]*>/gi)) { 2883 const tag = m[0]; 2884 if (/\b(mention|hashtag|u-url)\b/i.test(tag) && /mention|hashtag/i.test(tag)) continue; 2885 const href = m[1]; 2886 if (/^https?:\/\//i.test(href)) return href; 2887 } 2888 return null; 2889 } 2890 2891 /** The stored external-embed card, for the C2S read. */ 2892 export function timelineEmbed(embedJson) { 2893 try { const e = embedJson ? JSON.parse(embedJson) : null; return (e && typeof e === 'object' && e.url) ? e : undefined; } 2894 catch { return undefined; } 2895 } 2896 2839 2897 // FEP-044f embedded quote card: resolve the quoted post to a compact, sanitised 2840 2898 // snapshot { url, author{name,handle,icon}, content, published, media } so the … … 3576 3634 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote, 3577 3635 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote, 3578 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, sendInteraction, voteOnPoll, voteOnRemotePoll,3636 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, sendInteraction, voteOnPoll, voteOnRemotePoll, 3579 3637 acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision, 3580 3638 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs, -
src/services/guardianship/index.js
r6bc2e31b rfc40410 16 16 */ 17 17 export { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship } from './context.js'; 18 export { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians } from './notes.js';18 export { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed } from './notes.js'; 19 19 export { wireDelivery, c2sVisibility, deliverDirectNote } from './delivery.js'; 20 20 export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship } from './handshake.js'; -
src/services/guardianship/notes.js
r6bc2e31b rfc40410 15 15 try { return (slug && listGuardians(slug).length) ? { 'shaer:hasGuardians': true } : {}; } 16 16 catch { return {}; } 17 } 18 19 /** 20 * May EXTERNAL (non-fediverse) embeds be shown to this account? 21 * 22 * A gated feature in the FEP-633c sense: a ward's world outside the fediverse 23 * is the guardians' call. `setting` is `sites.external_embeds`: 24 * null/undefined → auto: off for a ward, on for anyone else 25 * 0 → off, 1 → on (the guardians decided) 26 * 27 * Pure, so the rule is testable on its own. The gate is applied SERVER-side: 28 * a blocked embed is never serialised into the feed, because an embed that the 29 * client merely hides has still been delivered. 30 */ 31 export function externalEmbedsAllowed(setting, isWard) { 32 if (setting === 0 || setting === 1) return setting === 1; 33 return !isWard; 17 34 } 18 35 … … 48 65 } 49 66 50 export default { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians };67 export default { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed };
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)