fedipod-server 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/claims.js +11 -1
- package/lib/admin.mjs +11 -1
- package/lib/c2s.mjs +131 -6
- package/lib/embed.mjs +1 -0
- package/lib/intake.mjs +23 -1
- package/lib/mastoapi.mjs +274 -14
- package/lib/polls.mjs +105 -0
- package/lib/publisher.mjs +266 -15
- package/lib/social.mjs +2 -0
- package/lib/storage.mjs +8 -2
- package/lib/wire.mjs +57 -3
- package/package.json +1 -1
- package/run-agent.mjs +10 -0
package/lib/publisher.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import fs from 'node:fs';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import * as wire from './wire.mjs';
|
|
10
|
+
import * as polls from './polls.mjs';
|
|
10
11
|
import { USER_AGENT } from './ua.mjs';
|
|
11
12
|
import { HTTP_TIMEOUT_MS } from './safefetch.mjs';
|
|
12
13
|
|
|
@@ -16,6 +17,11 @@ const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="http
|
|
|
16
17
|
// still gets everything, so a missed call site degrades to the old cost rather
|
|
17
18
|
// than silently publishing nothing.
|
|
18
19
|
const REBUILD_MAX_PER_RUN = 200;
|
|
20
|
+
// How long a poll gathers votes before its Question is rewritten. Every vote
|
|
21
|
+
// changes a number other servers re-read, and rewriting per vote would make a
|
|
22
|
+
// busy poll a steady write stream against the pod. A burst costs one rewrite
|
|
23
|
+
// and one Update instead.
|
|
24
|
+
const POLL_REWRITE_MS = 10_000;
|
|
19
25
|
const ALL_COLLECTIONS = { followers: true, following: true, outbox: true, acls: true,
|
|
20
26
|
pending: true, blocked: true };
|
|
21
27
|
const AGENT_VERSION = JSON.parse(fs.readFileSync(
|
|
@@ -23,7 +29,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(
|
|
|
23
29
|
|
|
24
30
|
export class Publisher {
|
|
25
31
|
constructor({ config, remote, local, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
|
|
26
|
-
probeFetch = null, resolveMention = null, privateOnPod = true,
|
|
32
|
+
probeFetch = null, resolveMention = null, privateOnPod = true, clientOrigin = null,
|
|
27
33
|
}) {
|
|
28
34
|
this.config = config;
|
|
29
35
|
this.remote = remote;
|
|
@@ -32,6 +38,10 @@ export class Publisher {
|
|
|
32
38
|
this.deliverer = deliverer;
|
|
33
39
|
this.publicKeyPem = publicKeyPem;
|
|
34
40
|
this.assertionKey = assertionKey; // Ed25519 public half, multibase; null = no proofs
|
|
41
|
+
// Where this identity's client surface answers, when that is an address a
|
|
42
|
+
// stranger can reach. Null on a laptop, where the surface is on loopback
|
|
43
|
+
// and advertising it to the world would name somewhere nobody can go.
|
|
44
|
+
this.clientOrigin = clientOrigin;
|
|
35
45
|
// A fronted identity (config.gateway.frontActor) advertises its ids on a
|
|
36
46
|
// shared domain; the map tells RemotePod where each writes on the pod.
|
|
37
47
|
const publicBase = config.gateway?.frontActor
|
|
@@ -44,6 +54,8 @@ export class Publisher {
|
|
|
44
54
|
this.probeFetch = probeFetch || ((u, i) => this.remote.probe(u, i));
|
|
45
55
|
this.resolveMention = resolveMention;
|
|
46
56
|
this.privateOnPod = privateOnPod;
|
|
57
|
+
// Per-poll rewrite windows, keyed by question id. See POLL_REWRITE_MS.
|
|
58
|
+
this.pollTimers = new Map();
|
|
47
59
|
this.log = log;
|
|
48
60
|
}
|
|
49
61
|
|
|
@@ -88,6 +100,13 @@ export class Publisher {
|
|
|
88
100
|
pendingFollowing: priv ? urls.pendingFollowing : null,
|
|
89
101
|
blocked: priv ? urls.blocked : null,
|
|
90
102
|
inbox: gwActive ? gw.url : null,
|
|
103
|
+
// The agent's own outbox endpoint, where it is reachable: a client
|
|
104
|
+
// following the actor must arrive somewhere that will take a write.
|
|
105
|
+
outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : null,
|
|
106
|
+
// How a client-to-server client finds the way in with nothing configured
|
|
107
|
+
// by hand. Advertised only where the surface is publicly reachable.
|
|
108
|
+
oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
|
|
109
|
+
oauthToken: this.clientOrigin ? `${this.clientOrigin}oauth/token` : null,
|
|
91
110
|
});
|
|
92
111
|
const surface = crypto.createHash('sha256').update(JSON.stringify({
|
|
93
112
|
actor: actorDoc, handle: this.config.handle, host,
|
|
@@ -872,20 +891,13 @@ export class Publisher {
|
|
|
872
891
|
}
|
|
873
892
|
|
|
874
893
|
// Compose → wire note on remote pod + RDF truth locally + deliver Create.
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
const published = new Date().toISOString();
|
|
883
|
-
const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
884
|
-
// A mention nobody can resolve stays plain text rather than failing the post.
|
|
885
|
-
// The reply's own text decides who is mentioned: trim a handle out and that
|
|
886
|
-
// person is not notified, which is what every fediverse client leads people
|
|
887
|
-
// to expect. A Group named in the parent is the one thing carried forward
|
|
888
|
-
// regardless — drop it and the group stops carrying the thread.
|
|
894
|
+
// Who this text mentions, resolved. A mention nobody can resolve stays plain
|
|
895
|
+
// text rather than failing the post. The text itself decides: trim a handle
|
|
896
|
+
// out and that person is not notified, which is what every fediverse client
|
|
897
|
+
// leads people to expect. A Group named in the parent is the one thing
|
|
898
|
+
// carried forward regardless — drop it and the group stops carrying the
|
|
899
|
+
// thread.
|
|
900
|
+
async _mentionsFor(content, inReplyTo) {
|
|
889
901
|
const inText = new Set(wire.mentionsIn(content));
|
|
890
902
|
const carried = inReplyTo
|
|
891
903
|
? (this.store.getStatuses().find(s => s.noteId === inReplyTo)?.mentions || [])
|
|
@@ -899,6 +911,19 @@ export class Publisher {
|
|
|
899
911
|
if (!inText.has(handle) && doc.type !== 'Group') continue; // author trimmed them out
|
|
900
912
|
mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: doc.endpoints?.sharedInbox || doc.inbox });
|
|
901
913
|
}
|
|
914
|
+
return mentions;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async publishNote(content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
|
|
918
|
+
const { urls } = this;
|
|
919
|
+
const priv = visibility === 'private' || visibility === 'direct';
|
|
920
|
+
if (priv) {
|
|
921
|
+
const ready = await this.privateReady();
|
|
922
|
+
if (ready !== true) throw new Error(ready);
|
|
923
|
+
}
|
|
924
|
+
const published = new Date().toISOString();
|
|
925
|
+
const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
926
|
+
const mentions = await this._mentionsFor(content, inReplyTo);
|
|
902
927
|
const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
|
|
903
928
|
visibility, summary: spoilerText, container: priv ? urls.privateNotes : urls.notes });
|
|
904
929
|
|
|
@@ -951,6 +976,232 @@ export class Publisher {
|
|
|
951
976
|
return note;
|
|
952
977
|
}
|
|
953
978
|
|
|
979
|
+
// --- polls ---------------------------------------------------------------
|
|
980
|
+
//
|
|
981
|
+
// A poll is published as a Question and answered by ordinary replies naming
|
|
982
|
+
// an option, so the count is ours to keep and ours to republish. The roster
|
|
983
|
+
// of who chose what lives in agent state (polls.VOTES_DOC); the tallies on
|
|
984
|
+
// the status row and in the pod document are always derived from it.
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Publish a poll. `options` is a list of choice titles, `multiple` lets a
|
|
988
|
+
* voter pick more than one, and `expiresAt` is when voting stops.
|
|
989
|
+
*/
|
|
990
|
+
async publishQuestion(content, { options = [], multiple = false, expiresAt = null,
|
|
991
|
+
inReplyTo = undefined, visibility = 'public', spoilerText = null } = {}) {
|
|
992
|
+
const { urls } = this;
|
|
993
|
+
const priv = visibility === 'private' || visibility === 'direct';
|
|
994
|
+
if (priv) {
|
|
995
|
+
const ready = await this.privateReady();
|
|
996
|
+
if (ready !== true) throw new Error(ready);
|
|
997
|
+
}
|
|
998
|
+
const titles = [].concat(options).map(o => String(o ?? '').trim()).filter(Boolean);
|
|
999
|
+
if (titles.length < 2) throw new Error('a poll needs at least two options');
|
|
1000
|
+
// Options are matched BY NAME when a vote arrives — that is the whole of
|
|
1001
|
+
// the convention — so two options reading the same are one option that
|
|
1002
|
+
// cannot be told apart.
|
|
1003
|
+
if (new Set(titles).size !== titles.length) throw new Error('a poll’s options must differ from one another');
|
|
1004
|
+
// Bounded here as well as at the client API, because the outbox is a
|
|
1005
|
+
// second way in and an unbounded poll is a document a stranger sizes.
|
|
1006
|
+
if (titles.length > polls.MAX_OPTIONS) throw new Error(`a poll takes at most ${polls.MAX_OPTIONS} options`);
|
|
1007
|
+
if (titles.some(t => t.length > polls.MAX_OPTION_CHARS)) {
|
|
1008
|
+
throw new Error(`a poll option is at most ${polls.MAX_OPTION_CHARS} characters`);
|
|
1009
|
+
}
|
|
1010
|
+
if (expiresAt) {
|
|
1011
|
+
const ends = Date.parse(expiresAt);
|
|
1012
|
+
if (!Number.isFinite(ends)) throw new Error('the closing time is not a date');
|
|
1013
|
+
const seconds = (ends - Date.now()) / 1000;
|
|
1014
|
+
if (seconds < polls.MIN_SECONDS || seconds > polls.MAX_SECONDS) {
|
|
1015
|
+
throw new Error(`a poll runs between ${polls.MIN_SECONDS} and ${polls.MAX_SECONDS} seconds`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const published = new Date().toISOString();
|
|
1020
|
+
const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
1021
|
+
const mentions = await this._mentionsFor(content, inReplyTo);
|
|
1022
|
+
const poll = {
|
|
1023
|
+
multiple: !!multiple,
|
|
1024
|
+
expiresAt: expiresAt || null,
|
|
1025
|
+
closed: null,
|
|
1026
|
+
options: titles.map(title => ({ title, votes: 0 })),
|
|
1027
|
+
votersCount: 0,
|
|
1028
|
+
// Resolved once, here: a tally rewrite must not cost a webfinger lookup
|
|
1029
|
+
// per vote for people the poll named.
|
|
1030
|
+
mentionInboxes: [...new Set(mentions.map(m => m.inbox).filter(Boolean))],
|
|
1031
|
+
};
|
|
1032
|
+
const question = wire.questionDoc({
|
|
1033
|
+
urls, slug, content, published, inReplyTo, attachments: [], mentions,
|
|
1034
|
+
visibility, summary: spoilerText,
|
|
1035
|
+
container: priv ? urls.privateNotes : urls.notes,
|
|
1036
|
+
options: poll.options, multiple: poll.multiple, endTime: poll.expiresAt, votersCount: 0,
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
await this.remote.putJson(question.id, question);
|
|
1040
|
+
// Empty, but present: a dangling `replies` that 404s is worse than none.
|
|
1041
|
+
await this.remote.putJson(wire.repliesId(question.id), wire.collection(wire.repliesId(question.id), []));
|
|
1042
|
+
if (!priv) await this.recordOutbox(question.id);
|
|
1043
|
+
await this.local.writeNote('posts', slug, {
|
|
1044
|
+
noteId: question.id, actor: urls.actor, published, content: question.content,
|
|
1045
|
+
inReplyTo, attachments: [],
|
|
1046
|
+
});
|
|
1047
|
+
this.store.addStatus({
|
|
1048
|
+
noteId: question.id, actor: urls.actor, content: question.content, published,
|
|
1049
|
+
kind: 'post', slug, text: content, visibility, poll, inReplyTo,
|
|
1050
|
+
...(spoilerText ? { spoiler: spoilerText } : {}),
|
|
1051
|
+
...(question.tag?.length ? { mentions: question.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
const create = this._pollActivity('Create', question, wire.createActivityId(question.id));
|
|
1055
|
+
await this.remote.putJson(create.id, create);
|
|
1056
|
+
const contacts = this.store.getContacts();
|
|
1057
|
+
const inboxes = [...new Set([
|
|
1058
|
+
...(visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
|
|
1059
|
+
...poll.mentionInboxes,
|
|
1060
|
+
].filter(Boolean))];
|
|
1061
|
+
await this.deliverer.deliverToAll(inboxes, create);
|
|
1062
|
+
this.log(`poll published: ${question.id} (${titles.length} options) → ${inboxes.length} inbox(es)`);
|
|
1063
|
+
return question;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// The Create or Update carrying a Question. The context is hoisted onto the
|
|
1067
|
+
// activity and the embedded object keeps none: a nested @context is legal
|
|
1068
|
+
// JSON-LD but not every server reads one, and votersCount is declared there.
|
|
1069
|
+
_pollActivity(type, question, id) {
|
|
1070
|
+
const { '@context': ctx, ...object } = question;
|
|
1071
|
+
return {
|
|
1072
|
+
'@context': ctx, id, type,
|
|
1073
|
+
actor: this.urls.actor,
|
|
1074
|
+
published: question.published,
|
|
1075
|
+
to: question.to, cc: question.cc,
|
|
1076
|
+
object,
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* One vote on one of OUR polls, named by the option's title. Returns true
|
|
1082
|
+
* when it counted — a second answer to a single-choice poll, an option we do
|
|
1083
|
+
* not offer, or a poll that has closed all count for nothing.
|
|
1084
|
+
*/
|
|
1085
|
+
async recordVote(questionId, actor, optionName) {
|
|
1086
|
+
const s = this.store.getStatuses().find(x => x.noteId === questionId);
|
|
1087
|
+
if (!s?.poll || s.kind !== 'post' || s.actor !== this.urls.actor) return false;
|
|
1088
|
+
if (polls.pollClosed(s.poll)) return false;
|
|
1089
|
+
const index = polls.optionIndex(s.poll, optionName);
|
|
1090
|
+
if (index < 0) return false;
|
|
1091
|
+
const all = this.store.read(polls.VOTES_DOC, {});
|
|
1092
|
+
const { roster, changed } = polls.addVote(all[questionId] || {}, polls.voterKey(actor), index,
|
|
1093
|
+
{ multiple: !!s.poll.multiple });
|
|
1094
|
+
if (!changed) return false;
|
|
1095
|
+
this.store.write(polls.VOTES_DOC, { ...all, [questionId]: roster });
|
|
1096
|
+
this.store.updateStatus(questionId, { poll: polls.withTally(s.poll, roster) });
|
|
1097
|
+
this._pollDirty(questionId);
|
|
1098
|
+
return true;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Open the rewrite window for a poll whose count moved. Already open is
|
|
1102
|
+
// already enough: the whole point is that a burst costs one rewrite.
|
|
1103
|
+
_pollDirty(questionId) {
|
|
1104
|
+
if (this.pollTimers.has(questionId)) return;
|
|
1105
|
+
const t = setTimeout(() => {
|
|
1106
|
+
this.pollTimers.delete(questionId);
|
|
1107
|
+
this.republishPoll(questionId).catch(e => this.log(`poll rewrite: ${e.message}`));
|
|
1108
|
+
}, POLL_REWRITE_MS);
|
|
1109
|
+
t.unref?.();
|
|
1110
|
+
this.pollTimers.set(questionId, t);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Write the poll's current count back to the pod and tell everyone who has
|
|
1115
|
+
* it. `closing` stamps it shut, which is a one-way door.
|
|
1116
|
+
*/
|
|
1117
|
+
async republishPoll(questionId, { closing = null } = {}) {
|
|
1118
|
+
const s = this.store.getStatuses().find(x => x.noteId === questionId);
|
|
1119
|
+
if (!s?.poll) return null;
|
|
1120
|
+
const { urls } = this;
|
|
1121
|
+
const roster = this.store.read(polls.VOTES_DOC, {})[questionId] || {};
|
|
1122
|
+
// A shut poll whose roster has been retired keeps the counts on its row:
|
|
1123
|
+
// deriving them from an empty roster would publish a poll nobody voted in.
|
|
1124
|
+
const shut = closing || s.poll.closed;
|
|
1125
|
+
const counted = shut && !Object.keys(roster).length ? s.poll : polls.withTally(s.poll, roster);
|
|
1126
|
+
const poll = { ...counted, ...(closing ? { closed: closing } : {}) };
|
|
1127
|
+
const container = String(s.noteId).startsWith(urls.privateNotes) ? urls.privateNotes : urls.notes;
|
|
1128
|
+
const slug = s.slug || String(s.noteId).slice(container.length);
|
|
1129
|
+
const mentions = (s.mentions || []).map(m => ({
|
|
1130
|
+
handle: String(m.name || '').replace(/^@/, ''), actor: m.href, page: null, inbox: null,
|
|
1131
|
+
}));
|
|
1132
|
+
const question = wire.questionDoc({
|
|
1133
|
+
urls, slug, content: s.text ?? '', published: s.published, inReplyTo: s.inReplyTo,
|
|
1134
|
+
attachments: [], mentions,
|
|
1135
|
+
visibility: s.visibility || 'public', summary: s.spoiler || null, container,
|
|
1136
|
+
options: poll.options, multiple: !!poll.multiple, endTime: poll.expiresAt,
|
|
1137
|
+
closed: poll.closed, votersCount: poll.votersCount || 0,
|
|
1138
|
+
});
|
|
1139
|
+
await this.remote.putJson(question.id, question);
|
|
1140
|
+
// The Create is overwritten too, so a group's Announce resolves to the
|
|
1141
|
+
// current count rather than to the one the poll opened with.
|
|
1142
|
+
await this.remote.putJson(wire.createActivityId(question.id),
|
|
1143
|
+
this._pollActivity('Create', question, wire.createActivityId(question.id)));
|
|
1144
|
+
this.store.updateStatus(questionId, { poll });
|
|
1145
|
+
|
|
1146
|
+
// Not `updated`: a changed count is not an edit, and stamping one would
|
|
1147
|
+
// have every client show the poll as edited each time somebody voted.
|
|
1148
|
+
//
|
|
1149
|
+
// The Update is named after the STATE it carries rather than the moment it
|
|
1150
|
+
// was sent. A clock only tells two rewrites apart when they fall in
|
|
1151
|
+
// different milliseconds, and a receiving server that has seen an activity
|
|
1152
|
+
// id drops the next one wearing it — which would quietly freeze the count.
|
|
1153
|
+
// Naming the state means an id changes exactly when there is something new
|
|
1154
|
+
// to say, and two sends of the same numbers are the duplicate they look
|
|
1155
|
+
// like.
|
|
1156
|
+
const stamp = crypto.createHash('sha256').update(JSON.stringify([
|
|
1157
|
+
poll.options.map(o => o.votes || 0), poll.votersCount || 0, poll.closed || '',
|
|
1158
|
+
])).digest('hex').slice(0, 12);
|
|
1159
|
+
const update = this._pollActivity('Update', question, `${question.id}#poll-${stamp}`);
|
|
1160
|
+
const contacts = this.store.getContacts();
|
|
1161
|
+
const inboxes = [...new Set([
|
|
1162
|
+
...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
|
|
1163
|
+
...(s.poll.mentionInboxes || []),
|
|
1164
|
+
].filter(Boolean))];
|
|
1165
|
+
await this.deliverer.deliverToAll(inboxes, update);
|
|
1166
|
+
this.log(`poll ${closing ? 'closed' : 'count published'}: ${question.id} → ${inboxes.length} inbox(es)`);
|
|
1167
|
+
return poll;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Shut any poll whose time is up. Called from the agent's sweep. Closing is
|
|
1172
|
+
* recorded BEFORE the republish, so a failed republish cannot leave a poll
|
|
1173
|
+
* open and collecting votes it has already refused.
|
|
1174
|
+
*/
|
|
1175
|
+
async closeDuePolls(now = Date.now()) {
|
|
1176
|
+
const due = this.store.getStatuses().filter(s => s.kind === 'post' && s.poll
|
|
1177
|
+
&& !s.poll.closed && s.poll.expiresAt && Date.parse(s.poll.expiresAt) <= now);
|
|
1178
|
+
for (const s of due) {
|
|
1179
|
+
const closed = new Date(Math.min(now, Date.parse(s.poll.expiresAt) || now)).toISOString();
|
|
1180
|
+
clearTimeout(this.pollTimers.get(s.noteId));
|
|
1181
|
+
this.pollTimers.delete(s.noteId);
|
|
1182
|
+
this.store.updateStatus(s.noteId, { poll: { ...s.poll, closed } });
|
|
1183
|
+
const done = await this.republishPoll(s.noteId, { closing: closed })
|
|
1184
|
+
.catch(e => { this.log(`poll close ${s.noteId}: ${e.message}`); return null; });
|
|
1185
|
+
// The roster only ever answered one question — has this person already
|
|
1186
|
+
// voted — and a shut poll has stopped asking it. Dropping it keeps a
|
|
1187
|
+
// document we serialize whole from carrying every poll's voters forever.
|
|
1188
|
+
if (done) {
|
|
1189
|
+
const all = this.store.read(polls.VOTES_DOC, {});
|
|
1190
|
+
if (all[s.noteId]) {
|
|
1191
|
+
delete all[s.noteId];
|
|
1192
|
+
this.store.write(polls.VOTES_DOC, all);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
return due.length;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** Stop the pending rewrite windows. Called at shutdown. */
|
|
1200
|
+
stopPolls() {
|
|
1201
|
+
for (const t of this.pollTimers.values()) clearTimeout(t);
|
|
1202
|
+
this.pollTimers.clear();
|
|
1203
|
+
}
|
|
1204
|
+
|
|
954
1205
|
// The pinned posts, as the actor's featured collection — the one document a
|
|
955
1206
|
// remote server reads when it shows this profile's pins.
|
|
956
1207
|
async publishFeatured() {
|
package/lib/social.mjs
CHANGED
|
@@ -317,6 +317,8 @@ export async function votePoll(agent, s, choices) {
|
|
|
317
317
|
return { ok: false, error: 'this poll has closed' };
|
|
318
318
|
}
|
|
319
319
|
if (poll.voted) return { ok: false, error: 'already voted' };
|
|
320
|
+
// Your own poll is the one you are counting, not one you are answering.
|
|
321
|
+
if (s.actor === urls.actor) return { ok: false, error: 'this is your own poll' };
|
|
320
322
|
const picks = [...new Set(choices)].filter(i => i >= 0 && i < opts.length);
|
|
321
323
|
if (!picks.length) return { ok: false, error: 'invalid choice' };
|
|
322
324
|
if (!poll.multiple && picks.length > 1) return { ok: false, error: 'this poll takes one choice' };
|
package/lib/storage.mjs
CHANGED
|
@@ -61,9 +61,15 @@ export class HttpStorage {
|
|
|
61
61
|
return { notModified: false, names, etag: res.headers.get('etag') };
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// `accept` is for callers reading something that is RDF but is wanted as it
|
|
65
|
+
// was written: asking turtle-first for a JSON-LD document gets turtle back,
|
|
66
|
+
// because the server is entitled to convert between two RDF syntaxes.
|
|
67
|
+
async read(p, { etag, accept } = {}) {
|
|
65
68
|
const res = await this.fetchImpl(this._url(p), {
|
|
66
|
-
headers: {
|
|
69
|
+
headers: {
|
|
70
|
+
accept: accept || 'text/turtle, application/json;q=0.9, */*;q=0.8',
|
|
71
|
+
...(etag ? { 'if-none-match': etag } : {}),
|
|
72
|
+
},
|
|
67
73
|
});
|
|
68
74
|
if (res.status === 304) return { ok: true, notModified: true, status: 304, body: null, etag };
|
|
69
75
|
if (res.status >= 400) return { ok: false, notModified: false, status: res.status, body: null, etag: null };
|
package/lib/wire.mjs
CHANGED
|
@@ -108,7 +108,7 @@ export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
|
|
|
108
108
|
export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null, movedTo = null, kind = 'person',
|
|
109
109
|
approveJoins = false, summary = null, icon = null, image = null, fields = [],
|
|
110
110
|
webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
|
|
111
|
-
blocked = null, inbox = null }) {
|
|
111
|
+
blocked = null, inbox = null, outbox = null, oauthAuthorize = null, oauthToken = null }) {
|
|
112
112
|
// manuallyApprovesFollowers is NOT in the base AS2 context, so it is declared
|
|
113
113
|
// inline exactly as Mastodon declares it — and only when we actually use it.
|
|
114
114
|
// It is what makes a client show "Request to follow" rather than "Follow" and
|
|
@@ -188,8 +188,23 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
|
|
|
188
188
|
inbox: inbox || urls.inbox,
|
|
189
189
|
// Every Mastodon actor publishes one. With a single actor per pod ours is
|
|
190
190
|
// just the inbox, but its absence is the non-standard thing.
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
//
|
|
192
|
+
// The two OAuth entries are how a client-to-server client finds its way in
|
|
193
|
+
// without being told anything by hand (ActivityPub 4.1). They appear only
|
|
194
|
+
// where this identity's client surface answers on an address a stranger
|
|
195
|
+
// can reach, which is why the caller supplies them rather than this
|
|
196
|
+
// building them from the pod URL.
|
|
197
|
+
endpoints: {
|
|
198
|
+
sharedInbox: inbox || urls.inbox,
|
|
199
|
+
...(oauthAuthorize ? { oauthAuthorizationEndpoint: oauthAuthorize } : {}),
|
|
200
|
+
...(oauthToken ? { oauthTokenEndpoint: oauthToken } : {}),
|
|
201
|
+
},
|
|
202
|
+
// Where a client sends what this actor writes, which the protocol says is
|
|
203
|
+
// this same address. The pod holds the collection and answers reads of it;
|
|
204
|
+
// a client-to-server write has to reach the agent, and the pod cannot take
|
|
205
|
+
// one. So where the agent is reachable it is named here, and a read of it
|
|
206
|
+
// is sent straight on to the pod's own document.
|
|
207
|
+
outbox: outbox || urls.outbox,
|
|
193
208
|
// The pinned posts, as the collection other servers read when they show
|
|
194
209
|
// this profile. Mastodon's term, declared the way Mastodon declares it.
|
|
195
210
|
featured: urls.featured,
|
|
@@ -645,6 +660,45 @@ export function noteDoc({ urls, slug, content, published, inReplyTo, attachments
|
|
|
645
660
|
return note;
|
|
646
661
|
}
|
|
647
662
|
|
|
663
|
+
// A poll is a note that asks something: the same addressing, the same
|
|
664
|
+
// mentions, the same replies collection, with the choices in `oneOf` (pick
|
|
665
|
+
// one) or `anyOf` (pick several). Each choice carries the count as the
|
|
666
|
+
// totalItems of its own replies collection, which is where every server that
|
|
667
|
+
// shows a poll reads it from.
|
|
668
|
+
//
|
|
669
|
+
// `endTime` is when voting stops and `closed` is the stamp saying it has —
|
|
670
|
+
// both plain AS2. `votersCount` is Mastodon's: PEOPLE rather than answers,
|
|
671
|
+
// which differ only once a poll takes several answers each. It is not in the
|
|
672
|
+
// base context, so it is declared inline exactly as Mastodon declares it, and
|
|
673
|
+
// only when we actually carry it.
|
|
674
|
+
export function questionDoc({ options = [], multiple = false, endTime = null,
|
|
675
|
+
closed = null, votersCount = null, ...rest }) {
|
|
676
|
+
const note = noteDoc(rest);
|
|
677
|
+
const choices = options.map(o => ({
|
|
678
|
+
type: 'Note',
|
|
679
|
+
name: String(o.title),
|
|
680
|
+
replies: { type: 'Collection', totalItems: Number(o.votes) || 0 },
|
|
681
|
+
}));
|
|
682
|
+
const question = {
|
|
683
|
+
...note,
|
|
684
|
+
type: 'Question',
|
|
685
|
+
[multiple ? 'anyOf' : 'oneOf']: choices,
|
|
686
|
+
};
|
|
687
|
+
if (endTime) question.endTime = endTime;
|
|
688
|
+
if (closed) question.closed = closed;
|
|
689
|
+
if (votersCount !== null && votersCount !== undefined) {
|
|
690
|
+
question['@context'] = [AS_CTX, {
|
|
691
|
+
toot: 'http://joinmastodon.org/ns#',
|
|
692
|
+
votersCount: {
|
|
693
|
+
'@id': 'toot:votersCount',
|
|
694
|
+
'@type': 'http://www.w3.org/2001/XMLSchema#nonNegativeInteger',
|
|
695
|
+
},
|
|
696
|
+
}];
|
|
697
|
+
question.votersCount = votersCount;
|
|
698
|
+
}
|
|
699
|
+
return question;
|
|
700
|
+
}
|
|
701
|
+
|
|
648
702
|
// The id is a document of its own, not `note.id + '#create'`. A group wraps this
|
|
649
703
|
// whole activity in its Announce (FEP-1b12), and the receiver resolves it by
|
|
650
704
|
// dereferencing this id — a fragment would just serve the Note back under a
|
package/package.json
CHANGED
package/run-agent.mjs
CHANGED
|
@@ -324,6 +324,7 @@ export class Agent {
|
|
|
324
324
|
this.deliverer?.stop();
|
|
325
325
|
this.tagfeed?.stop();
|
|
326
326
|
clearInterval(this.schedTimer);
|
|
327
|
+
this.publisher?.stopPolls();
|
|
327
328
|
this.deliverer = new Deliverer({
|
|
328
329
|
store: this.store, rsaPrivate: keys.rsaPrivate, keyId: this.urls.actor + '#main-key',
|
|
329
330
|
actorId: this.urls.actor, edPrivate: keys.edPrivate,
|
|
@@ -338,6 +339,10 @@ export class Agent {
|
|
|
338
339
|
// Whether the fediverse tree is on the pod at all, so the ACL check does
|
|
339
340
|
// not probe for something the default layout keeps on local disk.
|
|
340
341
|
privateOnPod: !cred.privateRoot,
|
|
342
|
+
// Inside a pod server the client surface answers on the pod's own
|
|
343
|
+
// origin, so it can be advertised. Standalone it is on loopback, and
|
|
344
|
+
// naming it in a world-readable actor would send clients nowhere.
|
|
345
|
+
clientOrigin: this.embedded ? this.urls.base : null,
|
|
341
346
|
});
|
|
342
347
|
// Intake is constructed even for viewers — its signed fetchAP powers
|
|
343
348
|
// search/deref; start() (draining) is active-only.
|
|
@@ -543,6 +548,10 @@ export class Agent {
|
|
|
543
548
|
}).then(() => this.log(`scheduled post published (${e.id})`))
|
|
544
549
|
.catch(err => this.log(`scheduled post ${e.id} failed: ${err.message} — dropped`));
|
|
545
550
|
}
|
|
551
|
+
// A poll whose time is up is shut on the same sweep: it stops taking
|
|
552
|
+
// answers here, and everyone holding it is told once.
|
|
553
|
+
this.publisher.closeDuePolls()
|
|
554
|
+
.catch(err => this.log(`closing polls: ${err.message}`));
|
|
546
555
|
}, 30_000);
|
|
547
556
|
this.schedTimer.unref();
|
|
548
557
|
// A CSV import interrupted by a restart or a handoff picks back up here.
|
|
@@ -647,6 +656,7 @@ export class Agent {
|
|
|
647
656
|
this.deliverer?.stop();
|
|
648
657
|
this.importer?.stop();
|
|
649
658
|
clearInterval(this.schedTimer);
|
|
659
|
+
this.publisher?.stopPolls();
|
|
650
660
|
// The lease too: standing down means standing down. Left renewing, a viewer
|
|
651
661
|
// keeps writing to the pod on the active agent's behalf and can win the
|
|
652
662
|
// lease back on a conditional PUT it had no business making.
|