Changeset 024f4f8 in Klonkt
- Timestamp:
- 07/20/2026 10:08:44 PM (7 weeks ago)
- Branches:
- main
- Children:
- e6c6e6f
- Parents:
- 81b2e1e
- git-author:
- Robin <roboburr@…> (07/20/2026 10:08:43 PM)
- git-committer:
- Robin <roboburr@…> (07/20/2026 10:08:44 PM)
- Files:
-
- 1 added
- 2 edited
-
src/config/database.js (modified) (1 diff)
-
src/services/ActivityPubService.js (modified) (6 diffs)
-
test/c2s-direct.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
r81b2e1e r024f4f8 441 441 ensureColumn('ap_outbox', 'attachments', 'TEXT'); 442 442 ensureColumn('posts', 'ap_visibility', 'TEXT'); // public|quiet|friends|direct (C2S addressing, shaer-60b) 443 ensureColumn('ap_outbox', 'visibility', 'TEXT'); // 'direct' = private mention, never Public (shaer-tqc) 444 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes 443 445 } 444 446 -
src/services/ActivityPubService.js
r81b2e1e r024f4f8 252 252 url: post.post_slug ? `${base}/${encodeURIComponent(post.post_slug)}` : undefined, 253 253 published: toISO(post.created_at), 254 to: post.to_actor ? [post.to_actor] : [PUBLIC], 255 cc: [PUBLIC, `${meR}/followers`], 254 // A direct note (private mention, shaer-tqc) addresses ONLY its 255 // recipients: no Public anywhere, so it cannot be boosted and never 256 // shows in public timelines (the Mastodon DM model). 257 to: post.visibility === 'direct' 258 ? (JSON.parse(post.to_actors || '[]')) 259 : (post.to_actor ? [post.to_actor] : [PUBLIC]), 260 cc: post.visibility === 'direct' ? [] : [PUBLIC, `${meR}/followers`], 256 261 tag: [ 257 262 ...mentionTags(post.content), … … 1395 1400 const pid = postIdFromNoteUrl(objUrl, base); 1396 1401 if (pid && actorUri && !isLocalActor && localPostExists(pid)) { 1402 // A boost/like of a non-public post is dropped, not stored: nobody 1403 // outside the audience should even hold it (shaer-tqc hardening). 1404 const vp = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(pid); 1405 if (vp && (vp.fan_only || vp.ap_visibility === 'direct' || vp.ap_visibility === 'friends')) { 1406 console.log('[AP] dropped', type, 'on non-public post', pid); 1407 return; 1408 } 1397 1409 const ai = actorInfo(await resolveActor(actorUri), actorUri); 1398 1410 iStmts().ins.run(type.toLowerCase(), pid, '', actorUri, ai.name, ai.handle, ai.url, ai.icon, null, null, null, noteVisibility(act)); … … 1826 1838 const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || ''); 1827 1839 if (!plain.trim() && !object.content) return { status: 400, error: 'empty_note' }; 1840 // Direct (private mention, shaer-tqc): NOT a post. Delivered over the 1841 // outbox machinery to the addressed inboxes only; shows under Messages. 1842 if (c2sVisibility(object) === 'direct') { 1843 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string'); 1844 const recipients = [...new Set([...arr(object.to), ...arr(object.cc)])] 1845 .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC); 1846 if (!recipients.length) return { status: 400, error: 'no_recipients' }; 1847 const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null }); 1848 if (!r || !r.id) return { status: 502, error: 'direct_failed' }; 1849 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` }; 1850 } 1828 1851 if (object.inReplyTo) { 1829 1852 const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo)).catch(() => null); … … 1839 1862 const targetUri = c2sIdOf(object); 1840 1863 if (!targetUri) return { status: 400, error: 'missing_object' }; 1864 // A non-public local note cannot be boosted or liked into the open 1865 // (shaer-tqc hardening; the Mastodon 422 equivalent). 1866 const localPid = postIdFromNoteUrl(targetUri, base); 1867 if (localPid) { 1868 const p = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(localPid); 1869 if (p && (p.fan_only || p.ap_visibility === 'direct' || p.ap_visibility === 'friends')) { 1870 return { status: 403, error: 'not_public' }; 1871 } 1872 } 1841 1873 const note = await resolveRemoteNote(targetUri).catch(() => null); 1842 1874 const objUri = (note && note.object_uri) || targetUri; … … 1921 1953 if (!to.length && !cc.length) return 'public'; // no addressing at all: legacy client, keep old behavior 1922 1954 return 'direct'; 1955 } 1956 1957 // A direct note (private mention, shaer-tqc): a NEW conversation (or a direct 1958 // reply) addressed to specific actors only. Stored in ap_outbox with 1959 // visibility 'direct' + the recipient list, delivered to exactly those 1960 // inboxes: no followers fan-out, no Public, so no boosts and no timelines. 1961 // The same S2S leg a Mastodon DM takes, so a guardian on any instance 1962 // receives it as a private mention (the ward call-for-help path). 1963 export async function deliverDirectNote(site, { recipients, text, language, inReplyTo }) { 1964 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 1965 const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8); 1966 if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null; 1967 const me = actorId(base, site.slug); 1968 // Resolve every recipient for a mention anchor + a delivery inbox. 1969 const resolved = []; 1970 for (const uri of list) { 1971 const a = await fetchActor(uri).catch(() => null); 1972 if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue; 1973 resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, handle: deriveHandle(uri), url: a.url || uri }); 1974 } 1975 if (!resolved.length) return null; 1976 const mention = resolved.map((r) => { 1977 const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || ''); 1978 return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `; 1979 }).join(''); 1980 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>'); 1981 const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`; 1982 const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null; 1983 const id = crypto.randomUUID(); 1984 db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, created_at) 1985 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`) 1986 .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, null, 'direct', JSON.stringify(resolved.map((r) => r.uri))); 1987 const row = iStmts().getO.get(id); 1988 const note = buildReplyNote(base, site, row); 1989 const create = { 1990 '@context': AP_CONTEXT, 1991 id: note.id + '#create', type: 'Create', actor: me, 1992 published: note.published, to: note.to, cc: note.cc, object: note, 1993 }; 1994 const keys = getOrCreateKeys(site.slug); 1995 const keyId = `${me}#main-key`; 1996 let delivered = 0; 1997 for (const inbox of [...new Set(resolved.map((r) => r.inbox))]) { 1998 let ok = false; 1999 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; } 2000 if (ok) delivered++; 2001 else enqueueDelivery(site.slug, inbox, create); 2002 } 2003 console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered); 2004 return { id, content, delivered }; 1923 2005 } 1924 2006 … … 2921 3003 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins, 2922 3004 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, deliverReply, resolveRemoteNote, 2923 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, 3005 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote, 2924 3006 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll, voteOnRemotePoll, 2925 3007 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
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)