Changeset 984e0c6 in Klonkt for src/services/ActivityPubService.js
- Timestamp:
- 08/24/2026 04:36:12 PM (2 weeks ago)
- Branches:
- main
- Children:
- db81e56
- Parents:
- 4d64a4b
- File:
-
- 1 edited
-
src/services/ActivityPubService.js (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
r4d64a4b r984e0c6 98 98 followActor, resolveRemoteActor, unfollowActor, 99 99 }; 100 // Stap 8 (shaer-drc): de peilingen wonen in ap-polls.js. parsePoll, 101 // applyPollToNote en recordPollBallot komen terug voor de inbox, buildNote en 102 // de backfill, maar blijven naar buiten toe prive zoals ze waren. 103 import { 104 wirePolls, 105 parsePoll, applyPollToNote, recordPollBallot, 106 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, 107 voteOnPoll, voteOnRemotePoll, 108 } from './ap-polls.js'; 109 export { 110 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, 111 voteOnPoll, voteOnRemotePoll, 112 }; 100 113 // Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken. 101 114 export { AP_CONTEXT, actorId, noteId, guessMediaType }; … … 1723 1736 } 1724 1737 1725 // Parse a fediverse poll (an ActivityStreams `Question` — the Mastodon-standard poll form)1726 // into our compact shape. `oneOf` = single choice, `anyOf` = multiple; each option is a Note1727 // with a `name` and a `replies` collection whose `totalItems` is that option's vote count.1728 function parsePoll(o) {1729 if (!o || o.type !== 'Question') return null;1730 const raw = Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null);1731 if (!raw || !raw.length) return null;1732 const options = raw.slice(0, 12).map((opt) => ({1733 name: String((opt && opt.name) || '').slice(0, 300),1734 count: Math.max(0, Number(opt && opt.replies && opt.replies.totalItems) || 0),1735 })).filter((x) => x.name);1736 if (!options.length) return null;1737 const endTime = o.endTime || (typeof o.closed === 'string' ? o.closed : null);1738 const closed = !!o.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);1739 return { multiple: Array.isArray(o.anyOf), options, endTime, closed, voters: Number(o.votersCount) || null, voted: null };1740 }1741 1742 // ── Polls WE host (a local post with a poll) ──────────────────────1743 // Parse the poll definition stored on our own post (posts.poll_json). Counts are1744 // NOT stored here — they're derived from the poll_votes ballots so a re-render always1745 // reflects the authoritative tally.1746 export function parseOwnPoll(pollJson) {1747 if (!pollJson) return null;1748 let d; try { d = typeof pollJson === 'string' ? JSON.parse(pollJson) : pollJson; } catch { return null; }1749 if (!d || !Array.isArray(d.options)) return null;1750 const options = d.options.map((o) => ({ name: String((o && o.name != null ? o.name : o) || '').slice(0, 300) })).filter((o) => o.name);1751 if (options.length < 2) return null;1752 const endTime = d.endTime || null;1753 const closed = !!d.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);1754 return { multiple: !!d.multiple, options, endTime, closed };1755 }1756 1757 // Live tally of a hosted poll from its ballots: per-option counts + unique voters.1758 export function pollTally(postId) {1759 const counts = {}; let voters = 0;1760 try {1761 for (const r of db.prepare('SELECT choice, COUNT(*) AS n FROM poll_votes WHERE post_id = ? GROUP BY choice').all(postId)) counts[r.choice] = r.n;1762 voters = db.prepare('SELECT COUNT(DISTINCT actor_uri) AS n FROM poll_votes WHERE post_id = ?').get(postId).n || 0;1763 } catch { /* table may not exist yet */ }1764 return { counts, voters };1765 }1766 1767 // Render-ready view of a hosted poll (options with counts + percentages, totals, state).1768 // Voting is fediverse-only, so this is display-only on the site.1769 export function ownPollView(post) {1770 const poll = parseOwnPoll(post && post.poll_json);1771 if (!poll) return null;1772 const { counts, voters } = pollTally(post.id);1773 const total = Object.values(counts).reduce((a, b) => a + b, 0);1774 const denom = poll.multiple ? voters : total; // multiple-choice %: share of voters (can sum >100%)1775 const options = poll.options.map((o) => {1776 const count = counts[o.name] || 0;1777 return { name: o.name, count, pct: denom ? Math.round((count / denom) * 100) : 0 };1778 });1779 return { multiple: poll.multiple, options, total, voters, endTime: poll.endTime, closed: poll.closed };1780 }1781 1782 // Attach the AS2 Question shape to a note built for a hosted poll. Mastodon renders a1783 // status with either media OR a poll (never both), so a poll federates as content +1784 // options with no media attachment. oneOf = single choice, anyOf = multiple.1785 function applyPollToNote(note, postId, poll) {1786 const { counts, voters } = pollTally(postId);1787 const opts = poll.options.map((o) => ({1788 type: 'Note',1789 name: o.name,1790 replies: { type: 'Collection', totalItems: counts[o.name] || 0 },1791 }));1792 note.type = 'Question';1793 note[poll.multiple ? 'anyOf' : 'oneOf'] = opts;1794 if (poll.endTime) note.endTime = new Date(poll.endTime).toISOString();1795 // Once closed, Mastodon expects a `closed` timestamp (the effective end).1796 if (poll.closed) note.closed = poll.endTime ? new Date(poll.endTime).toISOString() : new Date().toISOString();1797 note.votersCount = voters;1798 delete note.attachment; // media ATTACHMENTS + a poll are mutually exclusive on Mastodon1799 // Keep note.image: it's the cover, which Mastodon ignores on a Question anyway1800 // (same as on any Note) but Klonkt reads to show the cover in feeds/the Cirkel.1801 // Deleting it stripped the cover off every boosted poll.1802 return note;1803 }1804 1805 // Record an inbound ballot on one of OUR polls. A vote arrives as a Create(Note) whose1806 // `name` is the chosen option and `inReplyTo` is our poll note — the Mastodon-standard1807 // vote form. Returns { handled } — handled=true means it was addressed to a poll (so the1808 // caller must NOT also store it as a reply), false means "not a poll, fall through".1809 function recordPollBallot(postId, actorUri, rawChoice) {1810 const choice = String(rawChoice == null ? '' : rawChoice).slice(0, 300);1811 if (!choice) return { handled: false };1812 let post; try { post = db.prepare('SELECT poll_json FROM posts WHERE id = ?').get(postId); } catch { return { handled: false }; }1813 const poll = post && parseOwnPoll(post.poll_json);1814 if (!poll) return { handled: false }; // not a poll → let the reply logic handle it1815 if (poll.closed) return { handled: true }; // voting closed → drop1816 if (!poll.options.some((o) => o.name === choice)) return { handled: true }; // unknown option → drop1817 try {1818 // Single choice = one ballot per actor: ignore a later/different vote. Multiple choice1819 // allows one ballot per distinct option (the UNIQUE(post,actor,choice) dedupes repeats).1820 if (!poll.multiple && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? AND actor_uri = ? LIMIT 1').get(postId, actorUri)) return { handled: true };1821 db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?, ?, ?)').run(postId, actorUri, choice);1822 } catch { return { handled: true }; }1823 schedulePollUpdate(postId);1824 return { handled: true };1825 }1826 1827 // Coalesce a burst of votes into ONE Update(Question) per poll: the first vote schedules a1828 // refresh ~15s out; further votes in that window ride the same pending update (which carries1829 // the accumulated tally). Non-follower voters re-fetch the Question (live tally) themselves.1830 const _pollUpdTimers = new Map();1831 function schedulePollUpdate(postId) {1832 if (_pollUpdTimers.has(postId)) return;1833 const t = setTimeout(() => { _pollUpdTimers.delete(postId); deliverPollUpdate(postId).catch(() => { /* best-effort */ }); }, 15000);1834 if (t.unref) t.unref();1835 _pollUpdTimers.set(postId, t);1836 }1837 1838 // Push the fresh poll tally (or closed state) to followers as Update(Question).1839 export async function deliverPollUpdate(postId) {1840 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');1841 if (!base || !postId) return;1842 let post, site;1843 try {1844 post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);1845 if (!post || !post.poll_json) return;1846 site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);1847 } catch { return; }1848 if (site) await deliverUpdate(site, post);1849 }1850 1738 1851 1739 // ── Web push to the owner (docs/webpush-design.md, slice 3) ───────── … … 4972 4860 4973 4861 // True if an actor (or its whole domain) is blocked anywhere on this instance. 4974 // Vote on a remote fediverse poll (a cached Question). A ballot = a Create(Note) carrying only a4975 // `name` (the chosen option) + inReplyTo the Question, addressed to the poll's author — the4976 // Mastodon-standard vote. Records our choice locally + optimistically bumps the counts; the4977 // author's Update(Question) refreshes the authoritative totals when it arrives.4978 export async function voteOnPoll(site, questionId, choices) {4979 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');4980 if (!base || !site || !site.slug || !questionId) return { error: 'config' };4981 let row; try { row = db.prepare('SELECT author_uri, poll_json FROM ap_timeline WHERE id = ? AND slug = ? LIMIT 1').get(questionId, site.slug); } catch { /* ignore */ }4982 if (!row || !row.poll_json) return { error: 'not_found' };4983 let poll; try { poll = JSON.parse(row.poll_json); } catch { return { error: 'not_found' }; }4984 if (poll.closed) return { error: 'closed' };4985 if (poll.voted) return { error: 'already' };4986 const valid = new Set(poll.options.map((o) => o.name));4987 const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));4988 if (!picks.length) return { error: 'invalid' };4989 const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];4990 const me = actorId(base, site.slug);4991 const keys = getOrCreateKeys(site.slug);4992 const authorUri = row.author_uri || null;4993 const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;4994 const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));4995 if (!inbox) return { error: 'unreachable' };4996 for (const name of chosen) {4997 const nid = `${me}/votes/${Date.now()}-${rid()}`;4998 const note = { id: nid, type: 'Note', attributedTo: me, to: authorUri ? [authorUri] : [], name, inReplyTo: questionId, published: new Date().toISOString() };4999 const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };5000 deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);5001 }5002 // Local optimistic update (authoritative counts arrive via the author's Update(Question)).5003 poll.voted = poll.multiple ? chosen : chosen[0];5004 for (const o of poll.options) if (chosen.includes(o.name)) o.count = (o.count || 0) + 1;5005 if (poll.voters != null) poll.voters += 1;5006 try { db.prepare('UPDATE ap_timeline SET poll_json = ? WHERE id = ? AND slug = ?').run(JSON.stringify(poll), questionId, site.slug); } catch { /* ignore */ }5007 return { ok: true };5008 }5009 5010 // Vote on ANY fediverse poll by URL (the interact page) — no timeline cache needed. Fetches5011 // the Question fresh, validates the choice(s), and casts the Mastodon-standard ballot (a5012 // Create(Note) with `name` + inReplyTo) straight to the poll's author. Used for polls you find5013 // by URL, not just ones from accounts you follow (which go through voteOnPoll via /news).5014 export async function voteOnRemotePoll(site, questionUrl, choices) {5015 const _mv = movedRefusal(site, 'poll-vote'); if (_mv) return _mv;5016 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');5017 if (!base || !site || !site.slug || !/^https?:\/\//i.test(String(questionUrl || ''))) return { error: 'config' };5018 const q = await fetchActor(questionUrl).catch(() => null); // AP GET (SSRF-guarded)5019 if (!q || q.type !== 'Question' || !q.id) return { error: 'not_found' };5020 const poll = parsePoll(q);5021 if (!poll) return { error: 'not_found' };5022 if (poll.closed) return { error: 'closed' };5023 const valid = new Set(poll.options.map((o) => o.name));5024 const picks = (Array.isArray(choices) ? choices : [choices]).map(String).filter((c) => valid.has(c));5025 if (!picks.length) return { error: 'invalid' };5026 const chosen = poll.multiple ? [...new Set(picks)] : [picks[0]];5027 const authorUri = actorUriOf(q.attributedTo);5028 const author = authorUri ? await fetchActor(authorUri).catch(() => null) : null;5029 const inbox = author && (author.inbox || (author.endpoints && author.endpoints.sharedInbox));5030 if (!inbox) return { error: 'unreachable' };5031 const me = actorId(base, site.slug);5032 const keys = getOrCreateKeys(site.slug);5033 for (const name of chosen) {5034 const nid = `${me}/votes/${Date.now()}-${rid()}`;5035 const note = { id: nid, type: 'Note', attributedTo: me, to: [authorUri], name, inReplyTo: q.id, published: new Date().toISOString() };5036 const create = { '@context': AP_CONTEXT, id: `${nid}/activity`, type: 'Create', actor: me, to: note.to, object: note };5037 deliverWithRetry(site.slug, inbox, create, `${me}#main-key`, keys.private_pem);5038 }5039 return { ok: true };5040 }5041 4862 5042 4863 // Report a remote post or account to its home instance (moderation). Sends the Mastodon-standard … … 5351 5172 // §5.3-poortwachter, de actorlezer, de id-staart en de twee bezorgers. 5352 5173 wireFollowing({ movedRefusal, gateOutgoingFollow, actorInfo, rid, backfillFromOutbox, deliverToActor }); 5174 // De peilingen hun vier werktuigen (stap 8): de Update-bezorging voor de 5175 // telling, de id-staart, de verhuisweigering en de attributedTo-lezer. 5176 wirePolls({ deliverUpdate, rid, movedRefusal, actorUriOf }); 5353 5177 5354 5178 export default {
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)