Changeset fc40410 in Klonkt


Ignore:
Timestamp:
07/28/2026 07:43:47 AM (6 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
b258a79
Parents:
6bc2e31b
Message:

Externe embeds: thumbnail-only, en gated aan de serverkant

Fase 2 van shaer-277. Een note zonder fediverse-quote maar met een externe link
levert nu een embed-kaart op, via dezelfde resolver (oEmbed of een bekende
provider). Twee besluiten zitten erin verankerd:

THUMBNAIL-ONLY. Nooit de iframe van de aanbieder. Een willekeurig
derde-partij-frame in een kindveilige app is een gat dat je niet meer dicht
krijgt, dus de kaart draagt een afbeelding en een titel en niets uitvoerbaars.

GATED AAN DE SERVERKANT. Externe embeds zijn een gated feature: de wereld van
een ward buiten de fediverse is aan de guardians. Cruciaal is WAAR die gate zit:
bij het serialiseren, niet in de client. Een embed die de client alleen maar
verbergt, is wel degelijk al op het toestel afgeleverd. Staat de gate dicht, dan
gaat shaer:embed simpelweg niet mee.

De regel zelf (externalEmbedsAllowed) is puur en apart getest: null = auto, wat
uit staat voor een ward en aan voor ieder ander; een expliciet guardian-besluit
wint beide kanten op.

Changed files:
src/config/database.js

  • ap_timeline.embed_json en sites.external_embeds (NULL = auto)

src/services/guardianship/notes.js

  • externalEmbedsAllowed(setting, isWard), puur en geexporteerd

src/services/guardianship/index.js

  • doorgeexporteerd

src/services/ActivityPubService.js

  • resolveExternalEmbed + firstExternalUrl + timelineEmbed
  • inbound: geen quote maar wel een externe link -> embed resolven (out of band)

src/routes/activitypub.js

  • inbox-read past de gate toe en serveert shaer:embed alleen als die open staat

New file:
test/external-embeds.test.js

  • 5 tests: de auto-regel, het expliciete besluit, link-selectie (mentions en hashtags overslaan), en dat een kaart zonder url geen kaart is

remarks: 205 tests groen. Nog te doen: de clients laten shaer:embed renderen met
de bestaande QuoteCard, en een UI voor guardians om de gate te bedienen (hoort
bij shaer-3kp).

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

Files:
1 added
5 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r6bc2e31b rfc40410  
    132132  ensureColumn('sites', 'show_search',     'INTEGER DEFAULT 1');
    133133  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'
    135140
    136141  // Per-post noindex + type
     
    550555  ensureColumn('ap_timeline', 'link_json', 'TEXT');          // FEP-e232 object-link (quote/ref) tags from the inbound note, served back as `tag`
    551556  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)
    552558  ensureColumn('ap_timeline', 'author_emoji_json', 'TEXT');  // FEP-9098 custom emojis in the author's display name (shaer:author.emojis)
    553559  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  
    145145  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
    146146  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);
    147153  const items = AP.getTimeline(auth.site.slug, 60).map((t) => ({
    148154    id: `${t.id}#create`,
     
    195201      'shaer:liked': !!t.liked,
    196202      '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,
    197206    },
    198207  }));
  • src/services/ActivityPubService.js

    r6bc2e31b rfc40410  
    2222import HtmlSanitizerService from './HtmlSanitizerService.js';
    2323import AudioEmbedService from './AudioEmbedService.js';
     24import EmbedResolver from './EmbedResolver.js';
    2425import Push from './PushService.js';
    2526import { getTenancy } from './SettingsService.js';
     
    15751576            if (!qj) return;
    15761577            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 */ } }
    15771588          }).catch(() => { /* best-effort */ });
    15781589        }
     
    28372848}
    28382849
     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.
     2855export 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. */
     2880export 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. */
     2892export 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
    28392897// FEP-044f embedded quote card: resolve the quoted post to a compact, sanitised
    28402898// snapshot { url, author{name,handle,icon}, content, published, media } so the
     
    35763634  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote,
    35773635  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,
    35793637  acceptGatedFollow, rejectGatedFollow, isWardGuardian, sendFollowDecision,
    35803638  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
  • src/services/guardianship/index.js

    r6bc2e31b rfc40410  
    1616 */
    1717export { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship } from './context.js';
    18 export { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians } from './notes.js';
     18export { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed } from './notes.js';
    1919export { wireDelivery, c2sVisibility, deliverDirectNote } from './delivery.js';
    2020export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship } from './handshake.js';
  • src/services/guardianship/notes.js

    r6bc2e31b rfc40410  
    1515  try { return (slug && listGuardians(slug).length) ? { 'shaer:hasGuardians': true } : {}; }
    1616  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 */
     31export function externalEmbedsAllowed(setting, isWard) {
     32  if (setting === 0 || setting === 1) return setting === 1;
     33  return !isWard;
    1734}
    1835
     
    4865}
    4966
    50 export default { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians };
     67export default { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed };
Note: See TracChangeset for help on using the changeset viewer.