@takosjp/yurucommu-core 3.4.3 → 3.4.4
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/migrations/0023_delivery_resolution_outbox.sql +33 -0
- package/migrations/0024_delivery_fanout_outbox.sql +35 -0
- package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
- package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
- package/migrations/0027_remote_actor_tombstones.sql +12 -0
- package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
- package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/communities.ts +2 -0
- package/packages/api/src/lib/api/fetch.ts +69 -13
- package/packages/api/src/lib/api/normalize.ts +32 -10
- package/packages/api/src/lib/api/notifications.ts +4 -1
- package/packages/api/src/lib/rtc-client.ts +127 -29
- package/src/backend/federation-helpers.ts +1 -0
- package/src/backend/index.ts +16 -3
- package/src/backend/lib/account-migration.ts +340 -0
- package/src/backend/lib/activity-delete-cascade.ts +193 -0
- package/src/backend/lib/activitypub-actor-cache.ts +615 -48
- package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
- package/src/backend/lib/activitypub-actor-identity.ts +39 -0
- package/src/backend/lib/activitypub-validators.ts +62 -4
- package/src/backend/lib/ap-ids.ts +36 -6
- package/src/backend/lib/ap-verify.ts +7 -17
- package/src/backend/lib/blocklist-purge.ts +676 -42
- package/src/backend/lib/blocklist.ts +278 -39
- package/src/backend/lib/community-visibility.ts +47 -33
- package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
- package/src/backend/lib/delivery/planner.ts +16 -12
- package/src/backend/lib/delivery/queue-batching.ts +389 -145
- package/src/backend/lib/delivery/queue-delivery.ts +47 -25
- package/src/backend/lib/delivery/queue.ts +479 -73
- package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
- package/src/backend/lib/delivery/types.ts +31 -7
- package/src/backend/lib/feed-exclude.ts +40 -28
- package/src/backend/lib/follow-edge-mutations.ts +217 -0
- package/src/backend/lib/notification-eligibility.ts +15 -9
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oidc-id-token.ts +66 -0
- package/src/backend/lib/personal-actor-moderation.ts +239 -0
- package/src/backend/lib/post-visibility.ts +79 -16
- package/src/backend/lib/remote-activity-id.ts +61 -0
- package/src/backend/lib/unread-counts.ts +3 -0
- package/src/backend/retention.ts +38 -1
- package/src/backend/routes/account-teardown.ts +422 -156
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
- package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
- package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
- package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
- package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
- package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
- package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
- package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
- package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
- package/src/backend/routes/activitypub/inbox-types.ts +14 -2
- package/src/backend/routes/activitypub/inbox.ts +81 -105
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub.ts +6 -5
- package/src/backend/routes/actors-helpers.ts +34 -8
- package/src/backend/routes/actors.ts +328 -164
- package/src/backend/routes/auth-helpers.ts +57 -9
- package/src/backend/routes/auth.ts +10 -2
- package/src/backend/routes/communities/membership-invites.ts +4 -1
- package/src/backend/routes/communities/membership-members.ts +201 -73
- package/src/backend/routes/communities/membership-requests.ts +141 -76
- package/src/backend/routes/communities/membership-shared.ts +228 -21
- package/src/backend/routes/communities/messages.ts +9 -2
- package/src/backend/routes/communities/routes.ts +48 -13
- package/src/backend/routes/dm/contacts.ts +29 -41
- package/src/backend/routes/dm/conversations-helpers.ts +9 -1
- package/src/backend/routes/dm/messages.ts +36 -42
- package/src/backend/routes/dm/read-archive.ts +4 -2
- package/src/backend/routes/dm/requests.ts +62 -54
- package/src/backend/routes/follow-helpers.ts +200 -60
- package/src/backend/routes/follow.ts +146 -140
- package/src/backend/routes/media.ts +20 -94
- package/src/backend/routes/moderation.ts +72 -4
- package/src/backend/routes/notes.ts +3 -4
- package/src/backend/routes/notifications.ts +209 -108
- package/src/backend/routes/posts/delete-cascade.ts +253 -89
- package/src/backend/routes/posts/federation.ts +373 -0
- package/src/backend/routes/posts/interactions.ts +160 -184
- package/src/backend/routes/posts/like-mutation.ts +240 -0
- package/src/backend/routes/posts/post-helpers.ts +112 -162
- package/src/backend/routes/posts/queries.ts +150 -54
- package/src/backend/routes/posts/routes.ts +169 -329
- package/src/backend/routes/posts/transformers.ts +61 -6
- package/src/backend/routes/recommendations.ts +37 -44
- package/src/backend/routes/rtc/index.ts +26 -6
- package/src/backend/routes/search.ts +39 -55
- package/src/backend/routes/stories/interactions.ts +22 -6
- package/src/backend/routes/stories/query-helpers.ts +28 -41
- package/src/backend/routes/stories/routes.ts +125 -108
- package/src/backend/routes/takos-tools/dm.ts +17 -17
- package/src/backend/routes/takos-tools/posts.ts +189 -106
- package/src/backend/routes/takos-tools/search.ts +13 -4
- package/src/backend/routes/takos-tools/timeline.ts +35 -27
- package/src/backend/routes/takos-tools-response.ts +6 -2
- package/src/backend/routes/timeline.ts +5 -5
- package/src/backend/runtime/call-signaling-do.ts +35 -4
- package/src/backend/runtime/one-time-ticket.ts +116 -0
- package/src/backend/runtime/realtime-stream-do.ts +5 -61
- package/src/backend/runtime/signaling-hub.ts +36 -3
- package/src/backend/server.ts +95 -80
- package/src/db/d1-write.ts +67 -43
- package/src/db/schema/federation.ts +42 -1
- package/src/db/schema/messaging.ts +110 -2
- package/src/db/schema.ts +1 -1
|
@@ -19,6 +19,8 @@ import type {
|
|
|
19
19
|
HubToClientFrame,
|
|
20
20
|
IceServerConfig,
|
|
21
21
|
} from "../types/call.ts";
|
|
22
|
+
import { apiPost } from "./api/fetch.ts";
|
|
23
|
+
import { getYurucommuApiTransport } from "./transport.ts";
|
|
22
24
|
|
|
23
25
|
export type CallUiState =
|
|
24
26
|
| "idle"
|
|
@@ -45,7 +47,7 @@ export interface CallClientEvents {
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export interface CallClientOptions {
|
|
48
|
-
/**
|
|
50
|
+
/** Explicit server-origin override; otherwise the configured API transport owns it. */
|
|
49
51
|
origin?: string;
|
|
50
52
|
/** WebSocket reconnect backoff ceiling (ms). */
|
|
51
53
|
maxBackoffMs?: number;
|
|
@@ -67,12 +69,15 @@ interface ActiveCall {
|
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
const CANDIDATE_FLUSH_MS = 200;
|
|
72
|
+
const MAX_PENDING_SOCKET_FRAMES = 64;
|
|
70
73
|
|
|
71
74
|
export class CallClient {
|
|
72
75
|
private ws: WebSocket | null = null;
|
|
73
76
|
private wantConnected = false;
|
|
74
77
|
private backoff = 500;
|
|
75
78
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
79
|
+
private openingSocket = false;
|
|
80
|
+
private readonly pendingFrames: ClientToHubFrame[] = [];
|
|
76
81
|
private readonly listeners = new Map<keyof CallClientEvents, Set<Listener>>();
|
|
77
82
|
private call: ActiveCall | null = null;
|
|
78
83
|
private localStream: MediaStream | null = null;
|
|
@@ -114,16 +119,24 @@ export class CallClient {
|
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
// --- connection -----------------------------------------------------------
|
|
117
|
-
private
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
(typeof location !== "undefined" ? location.origin : "")
|
|
121
|
-
);
|
|
122
|
+
private apiUrl(path: string): string {
|
|
123
|
+
const origin = this.options.origin;
|
|
124
|
+
return origin ? new URL(path, `${origin}/`).toString() : path;
|
|
122
125
|
}
|
|
123
126
|
|
|
124
|
-
private socketUrl(): string {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
+
private socketUrl(actorApId: string, ticket: string): string {
|
|
128
|
+
const transport = getYurucommuApiTransport();
|
|
129
|
+
const resolved = transport.resolveUrl(this.apiUrl("/api/rtc/socket"));
|
|
130
|
+
const base =
|
|
131
|
+
this.options.origin ??
|
|
132
|
+
(typeof location !== "undefined" ? location.origin : "http://localhost");
|
|
133
|
+
const url = new URL(resolved, base);
|
|
134
|
+
if (url.protocol === "http:") url.protocol = "ws:";
|
|
135
|
+
else if (url.protocol === "https:") url.protocol = "wss:";
|
|
136
|
+
else throw new Error("call socket URL must use HTTP(S)");
|
|
137
|
+
url.searchParams.set("actor", actorApId);
|
|
138
|
+
url.searchParams.set("ticket", ticket);
|
|
139
|
+
return url.toString();
|
|
127
140
|
}
|
|
128
141
|
|
|
129
142
|
connect(): void {
|
|
@@ -137,13 +150,46 @@ export class CallClient {
|
|
|
137
150
|
this.reconnectTimer = null;
|
|
138
151
|
this.ws?.close();
|
|
139
152
|
this.ws = null;
|
|
153
|
+
this.pendingFrames.length = 0;
|
|
140
154
|
}
|
|
141
155
|
|
|
142
156
|
private openSocket(): void {
|
|
143
157
|
if (this.ws && this.ws.readyState <= WebSocket.OPEN) return;
|
|
158
|
+
if (this.openingSocket) return;
|
|
159
|
+
this.openingSocket = true;
|
|
160
|
+
void this.openSocketWithTicket().finally(() => {
|
|
161
|
+
this.openingSocket = false;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async openSocketWithTicket(): Promise<void> {
|
|
166
|
+
let actorApId: string;
|
|
167
|
+
let ticket: string;
|
|
168
|
+
try {
|
|
169
|
+
const response = await apiPost(this.apiUrl("/api/rtc/ticket"));
|
|
170
|
+
if (!response.ok) throw new Error(`ticket ${response.status}`);
|
|
171
|
+
const body = (await response.json()) as {
|
|
172
|
+
actor_ap_id?: unknown;
|
|
173
|
+
ticket?: unknown;
|
|
174
|
+
};
|
|
175
|
+
if (
|
|
176
|
+
typeof body.actor_ap_id !== "string" ||
|
|
177
|
+
typeof body.ticket !== "string"
|
|
178
|
+
) {
|
|
179
|
+
throw new Error("bad ticket response");
|
|
180
|
+
}
|
|
181
|
+
actorApId = body.actor_ap_id;
|
|
182
|
+
ticket = body.ticket;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
this.emit("error", "socket_ticket_failed", String(err));
|
|
185
|
+
this.scheduleReconnect();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!this.wantConnected) return;
|
|
189
|
+
|
|
144
190
|
let socket: WebSocket;
|
|
145
191
|
try {
|
|
146
|
-
socket = new WebSocket(this.socketUrl());
|
|
192
|
+
socket = new WebSocket(this.socketUrl(actorApId, ticket));
|
|
147
193
|
} catch (err) {
|
|
148
194
|
this.emit("error", "socket_open_failed", String(err));
|
|
149
195
|
this.scheduleReconnect();
|
|
@@ -152,8 +198,22 @@ export class CallClient {
|
|
|
152
198
|
this.ws = socket;
|
|
153
199
|
socket.onopen = () => {
|
|
154
200
|
this.backoff = 500;
|
|
155
|
-
|
|
156
|
-
|
|
201
|
+
socket.send(JSON.stringify({ t: "hello" } satisfies ClientToHubFrame));
|
|
202
|
+
const pending = this.pendingFrames.splice(0);
|
|
203
|
+
const hasInitialInvite =
|
|
204
|
+
this.call !== null &&
|
|
205
|
+
pending.some(
|
|
206
|
+
(frame) => frame.t === "invite" && frame.callId === this.call?.callId,
|
|
207
|
+
);
|
|
208
|
+
if (this.call && !hasInitialInvite) {
|
|
209
|
+
socket.send(
|
|
210
|
+
JSON.stringify({
|
|
211
|
+
t: "resume",
|
|
212
|
+
callId: this.call.callId,
|
|
213
|
+
} satisfies ClientToHubFrame),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
for (const frame of pending) socket.send(JSON.stringify(frame));
|
|
157
217
|
};
|
|
158
218
|
socket.onmessage = (ev) => {
|
|
159
219
|
void this.onFrame(ev.data);
|
|
@@ -181,7 +241,14 @@ export class CallClient {
|
|
|
181
241
|
private send(frame: ClientToHubFrame): void {
|
|
182
242
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
183
243
|
this.ws.send(JSON.stringify(frame));
|
|
244
|
+
return;
|
|
184
245
|
}
|
|
246
|
+
if (!this.wantConnected) return;
|
|
247
|
+
if (this.pendingFrames.length >= MAX_PENDING_SOCKET_FRAMES) {
|
|
248
|
+
this.emit("error", "socket_queue_full");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
this.pendingFrames.push(frame);
|
|
185
252
|
}
|
|
186
253
|
|
|
187
254
|
// --- public call control --------------------------------------------------
|
|
@@ -189,11 +256,9 @@ export class CallClient {
|
|
|
189
256
|
/** Place an outgoing call. Fetches a callId + ICE, then rings the peer. */
|
|
190
257
|
async startCall(peer: string, media: CallMediaKind): Promise<void> {
|
|
191
258
|
if (this.call) throw new Error("already in a call");
|
|
192
|
-
const res = await
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
headers: { "Content-Type": "application/json" },
|
|
196
|
-
body: JSON.stringify({ to: peer, media }),
|
|
259
|
+
const res = await apiPost(this.apiUrl("/api/rtc/calls"), {
|
|
260
|
+
to: peer,
|
|
261
|
+
media,
|
|
197
262
|
});
|
|
198
263
|
if (!res.ok) {
|
|
199
264
|
const code = res.status === 403 ? "blocked" : "start_failed";
|
|
@@ -204,18 +269,19 @@ export class CallClient {
|
|
|
204
269
|
callId: string;
|
|
205
270
|
iceServers: IceServerConfig[];
|
|
206
271
|
};
|
|
207
|
-
|
|
272
|
+
const call = this.newCall(
|
|
208
273
|
data.callId,
|
|
209
274
|
peer,
|
|
210
275
|
media,
|
|
211
276
|
"caller",
|
|
212
277
|
data.iceServers,
|
|
213
278
|
);
|
|
279
|
+
this.call = call;
|
|
214
280
|
this.setState("calling");
|
|
215
281
|
this.connect();
|
|
216
282
|
this.send({ t: "invite", callId: data.callId, to: peer, media });
|
|
217
|
-
await this.setupMedia(
|
|
218
|
-
await this.makeOffer(
|
|
283
|
+
if (!(await this.setupMedia(call))) return;
|
|
284
|
+
await this.makeOffer(call);
|
|
219
285
|
}
|
|
220
286
|
|
|
221
287
|
/** Accept the current incoming call. */
|
|
@@ -224,11 +290,14 @@ export class CallClient {
|
|
|
224
290
|
if (!call || call.role !== "callee") return;
|
|
225
291
|
this.setState("connecting");
|
|
226
292
|
this.send({ t: "accept", callId: call.callId });
|
|
227
|
-
await this.setupMedia(call);
|
|
293
|
+
if (!(await this.setupMedia(call))) return;
|
|
228
294
|
// The offer was already applied on arrival; create + send the answer.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
await
|
|
295
|
+
const pc = call.pc;
|
|
296
|
+
if (pc && call.remoteDescriptionSet && this.isActiveCall(call)) {
|
|
297
|
+
const answer = await pc.createAnswer();
|
|
298
|
+
if (!this.isActivePeer(call, pc)) return;
|
|
299
|
+
await pc.setLocalDescription(answer);
|
|
300
|
+
if (!this.isActivePeer(call, pc)) return;
|
|
232
301
|
this.send({ t: "answer", callId: call.callId, sdp: answer.sdp ?? "" });
|
|
233
302
|
}
|
|
234
303
|
}
|
|
@@ -412,24 +481,45 @@ export class CallClient {
|
|
|
412
481
|
}
|
|
413
482
|
|
|
414
483
|
// --- media + peer connection ---------------------------------------------
|
|
415
|
-
private
|
|
484
|
+
private isActiveCall(call: ActiveCall): boolean {
|
|
485
|
+
return this.call === call;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private isActivePeer(call: ActiveCall, pc: RTCPeerConnection): boolean {
|
|
489
|
+
return this.isActiveCall(call) && call.pc === pc;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private async setupMedia(call: ActiveCall): Promise<boolean> {
|
|
493
|
+
if (!this.isActiveCall(call)) return false;
|
|
416
494
|
if (!this.localStream) {
|
|
495
|
+
let stream: MediaStream;
|
|
417
496
|
try {
|
|
418
|
-
|
|
497
|
+
stream = await navigator.mediaDevices.getUserMedia({
|
|
419
498
|
audio: call.media.audio,
|
|
420
499
|
video: call.media.video,
|
|
421
500
|
});
|
|
422
501
|
} catch (err) {
|
|
502
|
+
if (!this.isActiveCall(call)) return false;
|
|
423
503
|
this.emit("error", "media_denied", String(err));
|
|
424
504
|
this.hangup("media_denied");
|
|
425
|
-
return;
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
if (!this.isActiveCall(call)) {
|
|
508
|
+
// Permission prompts resolve asynchronously. If the user cancelled or
|
|
509
|
+
// another call replaced this one while the prompt was open, the newly
|
|
510
|
+
// acquired tracks have no owner and must be stopped immediately.
|
|
511
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
512
|
+
return false;
|
|
426
513
|
}
|
|
514
|
+
this.localStream = stream;
|
|
427
515
|
this.emit("localstream", this.localStream);
|
|
428
516
|
}
|
|
517
|
+
if (!this.isActiveCall(call)) return false;
|
|
429
518
|
if (!call.pc) this.buildPeerConnection(call);
|
|
430
519
|
for (const track of this.localStream.getTracks()) {
|
|
431
520
|
call.pc?.addTrack(track, this.localStream);
|
|
432
521
|
}
|
|
522
|
+
return this.isActiveCall(call);
|
|
433
523
|
}
|
|
434
524
|
|
|
435
525
|
private buildPeerConnection(call: ActiveCall): void {
|
|
@@ -441,13 +531,16 @@ export class CallClient {
|
|
|
441
531
|
})),
|
|
442
532
|
});
|
|
443
533
|
pc.onicecandidate = (ev) => {
|
|
534
|
+
if (!this.isActivePeer(call, pc)) return;
|
|
444
535
|
if (ev.candidate) this.bufferCandidate(call, ev.candidate.toJSON());
|
|
445
536
|
else this.flushCandidates(call);
|
|
446
537
|
};
|
|
447
538
|
pc.ontrack = (ev) => {
|
|
539
|
+
if (!this.isActivePeer(call, pc)) return;
|
|
448
540
|
this.emit("remotestream", ev.streams[0] ?? null);
|
|
449
541
|
};
|
|
450
542
|
pc.onconnectionstatechange = () => {
|
|
543
|
+
if (!this.isActivePeer(call, pc)) return;
|
|
451
544
|
if (pc.connectionState === "connected") this.setState("connected");
|
|
452
545
|
else if (
|
|
453
546
|
pc.connectionState === "failed" ||
|
|
@@ -460,9 +553,14 @@ export class CallClient {
|
|
|
460
553
|
}
|
|
461
554
|
|
|
462
555
|
private async makeOffer(call: ActiveCall): Promise<void> {
|
|
556
|
+
if (!this.isActiveCall(call)) return;
|
|
463
557
|
if (!call.pc) this.buildPeerConnection(call);
|
|
464
|
-
const
|
|
465
|
-
|
|
558
|
+
const pc = call.pc;
|
|
559
|
+
if (!pc) return;
|
|
560
|
+
const offer = await pc.createOffer();
|
|
561
|
+
if (!this.isActivePeer(call, pc)) return;
|
|
562
|
+
await pc.setLocalDescription(offer);
|
|
563
|
+
if (!this.isActivePeer(call, pc)) return;
|
|
466
564
|
this.send({ t: "offer", callId: call.callId, sdp: offer.sdp ?? "" });
|
|
467
565
|
}
|
|
468
566
|
|
package/src/backend/index.ts
CHANGED
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
import { logger } from "./lib/logger.ts";
|
|
48
48
|
|
|
49
49
|
const log = logger.child({ component: "backend.index" });
|
|
50
|
-
let
|
|
50
|
+
let lastOutboxRecoverySweep = 0;
|
|
51
51
|
import type { IQueueBatch } from "./runtime/queue.ts";
|
|
52
52
|
import type {
|
|
53
53
|
D1Database,
|
|
@@ -63,9 +63,12 @@ import type {
|
|
|
63
63
|
DeliveryQueueMessageV1,
|
|
64
64
|
} from "./lib/delivery/types.ts";
|
|
65
65
|
import {
|
|
66
|
+
enqueuePendingDeliveryEndpointJobs,
|
|
66
67
|
handleDeliveryDlqBatch,
|
|
67
68
|
handleDeliveryQueueBatch,
|
|
68
69
|
} from "./lib/delivery/queue.ts";
|
|
70
|
+
import { enqueuePendingDeliveryResolutionJobs } from "./lib/delivery/resolution-outbox.ts";
|
|
71
|
+
import { enqueuePendingDeliveryFanoutJobs } from "./lib/delivery/fanout-outbox.ts";
|
|
69
72
|
import { enqueuePendingNotificationPushJobs } from "./lib/notification-push.ts";
|
|
70
73
|
import { runYurucommuRetention } from "./retention.ts";
|
|
71
74
|
|
|
@@ -620,10 +623,20 @@ function applyGlobalMiddleware(
|
|
|
620
623
|
const method = c.req.method.toUpperCase();
|
|
621
624
|
const now = Date.now();
|
|
622
625
|
const mutating = !["GET", "HEAD", "OPTIONS"].includes(method);
|
|
623
|
-
const recoveryDue = now -
|
|
626
|
+
const recoveryDue = now - lastOutboxRecoverySweep >= 60_000;
|
|
624
627
|
if (mutating || recoveryDue) {
|
|
625
|
-
if (recoveryDue)
|
|
628
|
+
if (recoveryDue) lastOutboxRecoverySweep = now;
|
|
626
629
|
const sweep = (async () => {
|
|
630
|
+
try {
|
|
631
|
+
await enqueuePendingDeliveryFanoutJobs(c.env);
|
|
632
|
+
await enqueuePendingDeliveryEndpointJobs(c.env);
|
|
633
|
+
await enqueuePendingDeliveryResolutionJobs(c.env);
|
|
634
|
+
} catch (error) {
|
|
635
|
+
log.error("Failed to enqueue durable federation outbox", {
|
|
636
|
+
event: "delivery.outbox.enqueue_failed",
|
|
637
|
+
error,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
627
640
|
try {
|
|
628
641
|
await enqueuePendingNotificationPushJobs(c.env);
|
|
629
642
|
} catch (error) {
|
|
@@ -9,6 +9,14 @@ import { signRequest } from "./ap-signing.ts";
|
|
|
9
9
|
import type { RemoteFetchSigner } from "./activitypub-actor-cache.ts";
|
|
10
10
|
import { parseWebFinger } from "./activitypub-validators.ts";
|
|
11
11
|
import { isSafeRemoteUrl, normalizeRemoteDomain } from "./ssrf.ts";
|
|
12
|
+
import type { Env } from "../types.ts";
|
|
13
|
+
import type { Database, D1Statement } from "../../db/index.ts";
|
|
14
|
+
import { activities, actors, follows, runBatch } from "../../db/index.ts";
|
|
15
|
+
import { and, asc, eq, exists, gt, ne, notExists, sql } from "drizzle-orm";
|
|
16
|
+
import { alias } from "drizzle-orm/sqlite-core";
|
|
17
|
+
import { activityApId } from "./ap-ids.ts";
|
|
18
|
+
import { enqueueDeliveriesToActor } from "./delivery/queue.ts";
|
|
19
|
+
import { sha256Hex } from "./delivery/transformers.ts";
|
|
12
20
|
|
|
13
21
|
const ALIAS_FETCH_TIMEOUT_MS = 15000;
|
|
14
22
|
|
|
@@ -104,3 +112,335 @@ export async function destinationDeclaresAlias(
|
|
|
104
112
|
return false;
|
|
105
113
|
}
|
|
106
114
|
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Inbound Move follow-graph rewrite
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
const MOVE_REFOLLOW_PAGE_SIZE = 100;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Stable namespace for the outbound Follow activities produced by one inbound
|
|
124
|
+
* Move. The inbox stamps a fixed-size, signer-bound internal activity id before
|
|
125
|
+
* dispatch; the old/new ids are mixed in as defense-in-depth and to keep direct
|
|
126
|
+
* handler tests deterministic when no inbox id is supplied.
|
|
127
|
+
*/
|
|
128
|
+
export async function moveRefollowPrefix(args: {
|
|
129
|
+
readonly baseUrl: string;
|
|
130
|
+
readonly inboundActivityId?: string;
|
|
131
|
+
readonly oldActorApId: string;
|
|
132
|
+
readonly newActorApId: string;
|
|
133
|
+
}): Promise<string> {
|
|
134
|
+
const digest = await sha256Hex(
|
|
135
|
+
`${args.inboundActivityId ?? "synthetic-move"}\0${args.oldActorApId}\0${args.newActorApId}`,
|
|
136
|
+
);
|
|
137
|
+
return activityApId(args.baseUrl, `move-refollow-${digest}-`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Re-enqueue every durable local re-Follow created for one Move. Returns the
|
|
142
|
+
* number of persisted activities found, so the handler can resume a graph
|
|
143
|
+
* rewrite that committed before a Queue send failed without re-fetching the
|
|
144
|
+
* destination alias or trying to reconstruct already-deleted old edges.
|
|
145
|
+
*/
|
|
146
|
+
export async function enqueuePersistedMoveRefollows(args: {
|
|
147
|
+
readonly db: Database;
|
|
148
|
+
readonly env: Env;
|
|
149
|
+
readonly newActorApId: string;
|
|
150
|
+
readonly refollowPrefix: string;
|
|
151
|
+
}): Promise<number> {
|
|
152
|
+
let cursor: string | null = null;
|
|
153
|
+
let found = 0;
|
|
154
|
+
|
|
155
|
+
while (true) {
|
|
156
|
+
const page = await args.db
|
|
157
|
+
.select({ apId: activities.apId })
|
|
158
|
+
.from(activities)
|
|
159
|
+
.where(
|
|
160
|
+
and(
|
|
161
|
+
eq(activities.type, "Follow"),
|
|
162
|
+
eq(activities.objectApId, args.newActorApId),
|
|
163
|
+
eq(activities.direction, "outbound"),
|
|
164
|
+
// `LIKE prefix%` would hit D1's short LIKE-pattern complexity limit
|
|
165
|
+
// because the stable prefix contains a full URL + SHA-256 digest.
|
|
166
|
+
sql`instr(${activities.apId}, ${args.refollowPrefix}) = 1`,
|
|
167
|
+
cursor ? gt(activities.apId, cursor) : undefined,
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
.orderBy(asc(activities.apId))
|
|
171
|
+
.limit(MOVE_REFOLLOW_PAGE_SIZE);
|
|
172
|
+
|
|
173
|
+
if (page.length === 0) break;
|
|
174
|
+
await enqueueDeliveriesToActor(
|
|
175
|
+
args.env,
|
|
176
|
+
page.map((row) => row.apId),
|
|
177
|
+
args.newActorApId,
|
|
178
|
+
);
|
|
179
|
+
found += page.length;
|
|
180
|
+
cursor = page[page.length - 1]!.apId;
|
|
181
|
+
if (page.length < MOVE_REFOLLOW_PAGE_SIZE) break;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return found;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Atomically rewrite every edge that names a moved remote actor.
|
|
189
|
+
*
|
|
190
|
+
* The statement count is fixed at eight regardless of graph size. Each INSERT
|
|
191
|
+
* reads the old graph with INSERT ... SELECT, so it binds only the handful of
|
|
192
|
+
* actor/prefix values rather than six parameters per edge. Both counter repairs
|
|
193
|
+
* are set-based UPDATEs. This is deliberately not implemented as insertMany()
|
|
194
|
+
* chunks: a large graph would eventually exceed the 50-statement atomic-batch
|
|
195
|
+
* ceiling, while splitting the delete/insert work across batches would expose
|
|
196
|
+
* a permanent half-migrated graph after a crash.
|
|
197
|
+
*
|
|
198
|
+
* Outbound local re-Follow activities are written in the same batch as their
|
|
199
|
+
* pending edges. If Queue delivery throws after commit, the stable prefix lets
|
|
200
|
+
* a retry call enqueuePersistedMoveRefollows() before touching the graph.
|
|
201
|
+
*/
|
|
202
|
+
export async function rewriteMovedFollowGraph(args: {
|
|
203
|
+
readonly db: Database;
|
|
204
|
+
readonly oldActorApId: string;
|
|
205
|
+
readonly newActorApId: string;
|
|
206
|
+
readonly refollowPrefix: string;
|
|
207
|
+
}): Promise<void> {
|
|
208
|
+
const oldAsFollower = alias(follows, "move_old_as_follower");
|
|
209
|
+
const oldAsFollowee = alias(follows, "move_old_as_followee");
|
|
210
|
+
const existingFollowerTarget = alias(
|
|
211
|
+
follows,
|
|
212
|
+
"move_existing_follower_target",
|
|
213
|
+
);
|
|
214
|
+
const existingFollowingSource = alias(
|
|
215
|
+
follows,
|
|
216
|
+
"move_existing_following_source",
|
|
217
|
+
);
|
|
218
|
+
const localFollowerActor = alias(actors, "move_local_follower_actor");
|
|
219
|
+
|
|
220
|
+
// These aliases belong to the counter statements. Keep them separate from
|
|
221
|
+
// the INSERT aliases so each generated statement is unambiguous to SQLite.
|
|
222
|
+
const acceptedOldFollowee = alias(follows, "move_accepted_old_followee");
|
|
223
|
+
const acceptedOldFollower = alias(follows, "move_accepted_old_follower");
|
|
224
|
+
const duplicateNewFollower = alias(follows, "move_duplicate_new_follower");
|
|
225
|
+
|
|
226
|
+
const decrementLocalFollowers = args.db
|
|
227
|
+
.update(actors)
|
|
228
|
+
.set({ followingCount: sql`${actors.followingCount} - 1` })
|
|
229
|
+
.where(
|
|
230
|
+
and(
|
|
231
|
+
gt(actors.followingCount, 0),
|
|
232
|
+
exists(
|
|
233
|
+
args.db
|
|
234
|
+
.select({ one: sql`1` })
|
|
235
|
+
.from(acceptedOldFollowee)
|
|
236
|
+
.where(
|
|
237
|
+
and(
|
|
238
|
+
eq(acceptedOldFollowee.followingApId, args.oldActorApId),
|
|
239
|
+
eq(acceptedOldFollowee.followerApId, actors.apId),
|
|
240
|
+
eq(acceptedOldFollowee.status, "accepted"),
|
|
241
|
+
),
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const decrementDroppedLocalFollowees = args.db
|
|
248
|
+
.update(actors)
|
|
249
|
+
.set({ followerCount: sql`${actors.followerCount} - 1` })
|
|
250
|
+
.where(
|
|
251
|
+
and(
|
|
252
|
+
gt(actors.followerCount, 0),
|
|
253
|
+
exists(
|
|
254
|
+
args.db
|
|
255
|
+
.select({ one: sql`1` })
|
|
256
|
+
.from(acceptedOldFollower)
|
|
257
|
+
.where(
|
|
258
|
+
and(
|
|
259
|
+
eq(acceptedOldFollower.followerApId, args.oldActorApId),
|
|
260
|
+
eq(acceptedOldFollower.followingApId, actors.apId),
|
|
261
|
+
eq(acceptedOldFollower.status, "accepted"),
|
|
262
|
+
),
|
|
263
|
+
),
|
|
264
|
+
),
|
|
265
|
+
sql`(
|
|
266
|
+
${actors.apId} = ${args.newActorApId}
|
|
267
|
+
OR ${exists(
|
|
268
|
+
args.db
|
|
269
|
+
.select({ one: sql`1` })
|
|
270
|
+
.from(duplicateNewFollower)
|
|
271
|
+
.where(
|
|
272
|
+
and(
|
|
273
|
+
eq(duplicateNewFollower.followerApId, args.newActorApId),
|
|
274
|
+
eq(duplicateNewFollower.followingApId, actors.apId),
|
|
275
|
+
),
|
|
276
|
+
),
|
|
277
|
+
)}
|
|
278
|
+
)`,
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const rewriteOldAsFollower = args.db
|
|
283
|
+
.insert(follows)
|
|
284
|
+
.select(
|
|
285
|
+
args.db
|
|
286
|
+
.select({
|
|
287
|
+
followerApId: sql<string>`${args.newActorApId}`.as("follower_ap_id"),
|
|
288
|
+
followingApId: oldAsFollower.followingApId,
|
|
289
|
+
status: oldAsFollower.status,
|
|
290
|
+
activityApId: oldAsFollower.activityApId,
|
|
291
|
+
createdAt: oldAsFollower.createdAt,
|
|
292
|
+
acceptedAt: oldAsFollower.acceptedAt,
|
|
293
|
+
})
|
|
294
|
+
.from(oldAsFollower)
|
|
295
|
+
.where(
|
|
296
|
+
and(
|
|
297
|
+
eq(oldAsFollower.followerApId, args.oldActorApId),
|
|
298
|
+
ne(oldAsFollower.followingApId, args.newActorApId),
|
|
299
|
+
notExists(
|
|
300
|
+
args.db
|
|
301
|
+
.select({ one: sql`1` })
|
|
302
|
+
.from(existingFollowerTarget)
|
|
303
|
+
.where(
|
|
304
|
+
and(
|
|
305
|
+
eq(existingFollowerTarget.followerApId, args.newActorApId),
|
|
306
|
+
eq(
|
|
307
|
+
existingFollowerTarget.followingApId,
|
|
308
|
+
oldAsFollower.followingApId,
|
|
309
|
+
),
|
|
310
|
+
),
|
|
311
|
+
),
|
|
312
|
+
),
|
|
313
|
+
),
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
.onConflictDoNothing();
|
|
317
|
+
|
|
318
|
+
const localFollowerExists = exists(
|
|
319
|
+
args.db
|
|
320
|
+
.select({ one: sql`1` })
|
|
321
|
+
.from(localFollowerActor)
|
|
322
|
+
.where(eq(localFollowerActor.apId, oldAsFollowee.followerApId)),
|
|
323
|
+
);
|
|
324
|
+
const targetEdgeDoesNotExist = notExists(
|
|
325
|
+
args.db
|
|
326
|
+
.select({ one: sql`1` })
|
|
327
|
+
.from(existingFollowingSource)
|
|
328
|
+
.where(
|
|
329
|
+
and(
|
|
330
|
+
eq(existingFollowingSource.followerApId, oldAsFollowee.followerApId),
|
|
331
|
+
eq(existingFollowingSource.followingApId, args.newActorApId),
|
|
332
|
+
),
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const rewriteLocalFollowers = args.db
|
|
337
|
+
.insert(follows)
|
|
338
|
+
.select(
|
|
339
|
+
args.db
|
|
340
|
+
.select({
|
|
341
|
+
followerApId: oldAsFollowee.followerApId,
|
|
342
|
+
followingApId: sql<string>`${args.newActorApId}`.as(
|
|
343
|
+
"following_ap_id",
|
|
344
|
+
),
|
|
345
|
+
status: sql<string>`'pending'`.as("status"),
|
|
346
|
+
activityApId:
|
|
347
|
+
sql<string>`${args.refollowPrefix} || lower(hex(randomblob(32)))`.as(
|
|
348
|
+
"activity_ap_id",
|
|
349
|
+
),
|
|
350
|
+
createdAt: oldAsFollowee.createdAt,
|
|
351
|
+
acceptedAt: sql<null>`NULL`.as("accepted_at"),
|
|
352
|
+
})
|
|
353
|
+
.from(oldAsFollowee)
|
|
354
|
+
.where(
|
|
355
|
+
and(
|
|
356
|
+
eq(oldAsFollowee.followingApId, args.oldActorApId),
|
|
357
|
+
ne(oldAsFollowee.followerApId, args.newActorApId),
|
|
358
|
+
targetEdgeDoesNotExist,
|
|
359
|
+
localFollowerExists,
|
|
360
|
+
),
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
.onConflictDoNothing();
|
|
364
|
+
|
|
365
|
+
const rewriteRemoteFollowers = args.db
|
|
366
|
+
.insert(follows)
|
|
367
|
+
.select(
|
|
368
|
+
args.db
|
|
369
|
+
.select({
|
|
370
|
+
followerApId: oldAsFollowee.followerApId,
|
|
371
|
+
followingApId: sql<string>`${args.newActorApId}`.as(
|
|
372
|
+
"following_ap_id",
|
|
373
|
+
),
|
|
374
|
+
status: oldAsFollowee.status,
|
|
375
|
+
activityApId: oldAsFollowee.activityApId,
|
|
376
|
+
createdAt: oldAsFollowee.createdAt,
|
|
377
|
+
acceptedAt: oldAsFollowee.acceptedAt,
|
|
378
|
+
})
|
|
379
|
+
.from(oldAsFollowee)
|
|
380
|
+
.where(
|
|
381
|
+
and(
|
|
382
|
+
eq(oldAsFollowee.followingApId, args.oldActorApId),
|
|
383
|
+
ne(oldAsFollowee.followerApId, args.newActorApId),
|
|
384
|
+
targetEdgeDoesNotExist,
|
|
385
|
+
notExists(
|
|
386
|
+
args.db
|
|
387
|
+
.select({ one: sql`1` })
|
|
388
|
+
.from(localFollowerActor)
|
|
389
|
+
.where(eq(localFollowerActor.apId, oldAsFollowee.followerApId)),
|
|
390
|
+
),
|
|
391
|
+
),
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
.onConflictDoNothing();
|
|
395
|
+
|
|
396
|
+
const migratedLocalFollow = alias(follows, "move_migrated_local_follow");
|
|
397
|
+
const recordLocalRefollows = args.db
|
|
398
|
+
.insert(activities)
|
|
399
|
+
.select(
|
|
400
|
+
args.db
|
|
401
|
+
.select({
|
|
402
|
+
apId: migratedLocalFollow.activityApId,
|
|
403
|
+
type: sql<string>`'Follow'`.as("type"),
|
|
404
|
+
actorApId: migratedLocalFollow.followerApId,
|
|
405
|
+
objectApId: sql<string>`${args.newActorApId}`.as("object_ap_id"),
|
|
406
|
+
objectJson: sql<null>`NULL`.as("object_json"),
|
|
407
|
+
targetApId: sql<null>`NULL`.as("target_ap_id"),
|
|
408
|
+
rawJson: sql<string>`json_object(
|
|
409
|
+
'@context', 'https://www.w3.org/ns/activitystreams',
|
|
410
|
+
'id', ${migratedLocalFollow.activityApId},
|
|
411
|
+
'type', 'Follow',
|
|
412
|
+
'actor', ${migratedLocalFollow.followerApId},
|
|
413
|
+
'object', ${args.newActorApId}
|
|
414
|
+
)`.as("raw_json"),
|
|
415
|
+
direction: sql<string>`'outbound'`.as("direction"),
|
|
416
|
+
processed: sql<number>`0`.as("processed"),
|
|
417
|
+
createdAt: sql<string>`datetime('now')`.as("created_at"),
|
|
418
|
+
})
|
|
419
|
+
.from(migratedLocalFollow)
|
|
420
|
+
.where(
|
|
421
|
+
and(
|
|
422
|
+
eq(migratedLocalFollow.followingApId, args.newActorApId),
|
|
423
|
+
sql`instr(${migratedLocalFollow.activityApId}, ${args.refollowPrefix}) = 1`,
|
|
424
|
+
),
|
|
425
|
+
),
|
|
426
|
+
)
|
|
427
|
+
.onConflictDoNothing();
|
|
428
|
+
|
|
429
|
+
const deleteOldAsFollower = args.db
|
|
430
|
+
.delete(follows)
|
|
431
|
+
.where(eq(follows.followerApId, args.oldActorApId));
|
|
432
|
+
const deleteOldAsFollowee = args.db
|
|
433
|
+
.delete(follows)
|
|
434
|
+
.where(eq(follows.followingApId, args.oldActorApId));
|
|
435
|
+
|
|
436
|
+
await runBatch(args.db, [
|
|
437
|
+
decrementLocalFollowers,
|
|
438
|
+
decrementDroppedLocalFollowees,
|
|
439
|
+
rewriteOldAsFollower,
|
|
440
|
+
rewriteLocalFollowers,
|
|
441
|
+
rewriteRemoteFollowers,
|
|
442
|
+
recordLocalRefollows,
|
|
443
|
+
deleteOldAsFollower,
|
|
444
|
+
deleteOldAsFollowee,
|
|
445
|
+
] as unknown as [D1Statement, ...D1Statement[]]);
|
|
446
|
+
}
|