@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
|
@@ -1,31 +1,27 @@
|
|
|
1
1
|
import type { Context, Hono } from "hono";
|
|
2
2
|
import { and, count, desc, eq, sql } from "drizzle-orm";
|
|
3
3
|
import {
|
|
4
|
-
activities,
|
|
5
4
|
communities,
|
|
6
5
|
communityJoinRequests,
|
|
7
|
-
communityMembers,
|
|
8
6
|
follows,
|
|
7
|
+
runBatch,
|
|
8
|
+
type D1Statement,
|
|
9
9
|
} from "../../../db/index.ts";
|
|
10
10
|
import type { Env, Variables } from "../../types.ts";
|
|
11
|
+
import { formatUsername, isLocal } from "../../federation-helpers.ts";
|
|
11
12
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
} from "../../federation-helpers.ts";
|
|
17
|
-
import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
|
|
13
|
+
isActorBlockedStrict,
|
|
14
|
+
operatorActorNotBlockedSql,
|
|
15
|
+
} from "../../lib/blocklist.ts";
|
|
16
|
+
import { prepareResponseIfRemote } from "../follow-helpers.ts";
|
|
18
17
|
import {
|
|
19
|
-
addMemberAtomic,
|
|
20
18
|
batchLoadActorInfo,
|
|
21
19
|
fetchCommunityId,
|
|
22
|
-
|
|
20
|
+
prepareAddMemberStatements,
|
|
21
|
+
prepareUnbanMemberStatement,
|
|
23
22
|
requireManager,
|
|
24
|
-
unbanMember,
|
|
25
23
|
} from "./membership-shared.ts";
|
|
26
24
|
|
|
27
|
-
const AS_CONTEXT = "https://www.w3.org/ns/activitystreams";
|
|
28
|
-
|
|
29
25
|
export function registerMembershipRequestRoutes(
|
|
30
26
|
communitiesRouter: Hono<{ Bindings: Env; Variables: Variables }>,
|
|
31
27
|
) {
|
|
@@ -61,6 +57,7 @@ export function registerMembershipRequestRoutes(
|
|
|
61
57
|
and(
|
|
62
58
|
eq(communityJoinRequests.communityApId, community.apId),
|
|
63
59
|
eq(communityJoinRequests.status, "pending"),
|
|
60
|
+
operatorActorNotBlockedSql(sql`${communityJoinRequests.actorApId}`),
|
|
64
61
|
),
|
|
65
62
|
)
|
|
66
63
|
.orderBy(desc(communityJoinRequests.createdAt))
|
|
@@ -75,6 +72,7 @@ export function registerMembershipRequestRoutes(
|
|
|
75
72
|
and(
|
|
76
73
|
eq(follows.followingApId, community.apId),
|
|
77
74
|
eq(follows.status, "pending"),
|
|
75
|
+
operatorActorNotBlockedSql(sql`${follows.followerApId}`),
|
|
78
76
|
),
|
|
79
77
|
)
|
|
80
78
|
.orderBy(desc(follows.createdAt))
|
|
@@ -96,7 +94,7 @@ export function registerMembershipRequestRoutes(
|
|
|
96
94
|
const actorInfo = actorInfoMap.get(apId);
|
|
97
95
|
return {
|
|
98
96
|
ap_id: apId,
|
|
99
|
-
username: formatUsername(apId),
|
|
97
|
+
username: formatUsername(apId, actorInfo?.preferredUsername),
|
|
100
98
|
preferred_username: actorInfo?.preferredUsername || null,
|
|
101
99
|
name: actorInfo?.name || null,
|
|
102
100
|
icon_url: actorInfo?.iconUrl || null,
|
|
@@ -132,6 +130,9 @@ export function registerMembershipRequestRoutes(
|
|
|
132
130
|
if (!manager) {
|
|
133
131
|
return c.json({ error: "Forbidden" }, 403);
|
|
134
132
|
}
|
|
133
|
+
if (await isActorBlockedStrict(db, body.actor_ap_id)) {
|
|
134
|
+
return c.json({ error: "Join request not found" }, 404);
|
|
135
|
+
}
|
|
135
136
|
|
|
136
137
|
// A pending request is EITHER a community_join_requests row (local
|
|
137
138
|
// approval-join) OR a pending follows edge to the Group (remote
|
|
@@ -165,22 +166,32 @@ export function registerMembershipRequestRoutes(
|
|
|
165
166
|
const now = new Date().toISOString();
|
|
166
167
|
|
|
167
168
|
if (isLocal(body.actor_ap_id, c.env.APP_URL)) {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
.from(communityMembers)
|
|
171
|
-
.where(memberWhere(community.apId, body.actor_ap_id))
|
|
172
|
-
.get();
|
|
173
|
-
if (!existingMember) {
|
|
174
|
-
// Atomic insert + guarded increment so a crash between them, or a
|
|
175
|
-
// concurrent double-accept, can't leave the count under/over the truth.
|
|
176
|
-
await addMemberAtomic(
|
|
169
|
+
const statements: D1Statement[] = [
|
|
170
|
+
...prepareAddMemberStatements(
|
|
177
171
|
db,
|
|
178
172
|
community.apId,
|
|
179
173
|
body.actor_ap_id,
|
|
180
174
|
"member",
|
|
181
175
|
now,
|
|
176
|
+
),
|
|
177
|
+
// Accepting a join request is an explicit re-admission.
|
|
178
|
+
prepareUnbanMemberStatement(db, community.apId, body.actor_ap_id),
|
|
179
|
+
];
|
|
180
|
+
if (localRequest) {
|
|
181
|
+
statements.push(
|
|
182
|
+
db
|
|
183
|
+
.update(communityJoinRequests)
|
|
184
|
+
.set({ status: "accepted", processedAt: now })
|
|
185
|
+
.where(
|
|
186
|
+
and(
|
|
187
|
+
eq(communityJoinRequests.communityApId, community.apId),
|
|
188
|
+
eq(communityJoinRequests.actorApId, body.actor_ap_id),
|
|
189
|
+
eq(communityJoinRequests.status, "pending"),
|
|
190
|
+
),
|
|
191
|
+
) as D1Statement,
|
|
182
192
|
);
|
|
183
193
|
}
|
|
194
|
+
await runBatch(db, statements as [D1Statement, ...D1Statement[]]);
|
|
184
195
|
} else {
|
|
185
196
|
// A REMOTE member's membership IS the pending follows edge to the Group
|
|
186
197
|
// actor — NOT a communityMembers row (which would diverge from how the
|
|
@@ -189,51 +200,51 @@ export function registerMembershipRequestRoutes(
|
|
|
189
200
|
// was approved and our handleGroupCreate (which requires status=accepted)
|
|
190
201
|
// starts relaying its posts. The pending edge carries the original Follow
|
|
191
202
|
// activity id the Accept must reference.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
203
|
+
const preparedResponse = pendingEdge?.activityApId
|
|
204
|
+
? await prepareResponseIfRemote(
|
|
205
|
+
c.env,
|
|
206
|
+
db,
|
|
207
|
+
c.env.APP_URL,
|
|
208
|
+
"Accept",
|
|
209
|
+
community.apId,
|
|
210
|
+
body.actor_ap_id,
|
|
211
|
+
pendingEdge.activityApId,
|
|
212
|
+
)
|
|
213
|
+
: null;
|
|
214
|
+
const statements: D1Statement[] = [
|
|
215
|
+
db
|
|
216
|
+
.update(follows)
|
|
217
|
+
.set({ status: "accepted", acceptedAt: now })
|
|
218
|
+
.where(
|
|
219
|
+
and(
|
|
220
|
+
eq(follows.followerApId, body.actor_ap_id),
|
|
221
|
+
eq(follows.followingApId, community.apId),
|
|
222
|
+
eq(follows.status, "pending"),
|
|
223
|
+
),
|
|
224
|
+
) as D1Statement,
|
|
225
|
+
];
|
|
226
|
+
if (preparedResponse) statements.push(...preparedResponse.statements);
|
|
227
|
+
statements.push(
|
|
228
|
+
prepareUnbanMemberStatement(db, community.apId, body.actor_ap_id),
|
|
229
|
+
);
|
|
230
|
+
if (localRequest) {
|
|
231
|
+
statements.push(
|
|
232
|
+
db
|
|
233
|
+
.update(communityJoinRequests)
|
|
234
|
+
.set({ status: "accepted", processedAt: now })
|
|
235
|
+
.where(
|
|
236
|
+
and(
|
|
237
|
+
eq(communityJoinRequests.communityApId, community.apId),
|
|
238
|
+
eq(communityJoinRequests.actorApId, body.actor_ap_id),
|
|
239
|
+
eq(communityJoinRequests.status, "pending"),
|
|
240
|
+
),
|
|
241
|
+
) as D1Statement,
|
|
200
242
|
);
|
|
201
|
-
if (pendingEdge?.activityApId) {
|
|
202
|
-
const acceptId = activityApId(c.env.APP_URL, generateId());
|
|
203
|
-
const acceptActivity = {
|
|
204
|
-
"@context": AS_CONTEXT,
|
|
205
|
-
id: acceptId,
|
|
206
|
-
type: "Accept",
|
|
207
|
-
actor: community.apId,
|
|
208
|
-
object: pendingEdge.activityApId,
|
|
209
|
-
};
|
|
210
|
-
await db.insert(activities).values({
|
|
211
|
-
apId: acceptId,
|
|
212
|
-
type: "Accept",
|
|
213
|
-
actorApId: community.apId,
|
|
214
|
-
objectApId: pendingEdge.activityApId,
|
|
215
|
-
rawJson: JSON.stringify(acceptActivity),
|
|
216
|
-
direction: "outbound",
|
|
217
|
-
});
|
|
218
|
-
// Delivery resolves the community's signing key from actor=community.apId.
|
|
219
|
-
await enqueueDeliveryToActor(c.env, acceptId, body.actor_ap_id);
|
|
220
243
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
// Mark the local join-request row processed (a remote accept has none).
|
|
227
|
-
if (localRequest) {
|
|
228
|
-
await db
|
|
229
|
-
.update(communityJoinRequests)
|
|
230
|
-
.set({ status: "accepted", processedAt: now })
|
|
231
|
-
.where(
|
|
232
|
-
and(
|
|
233
|
-
eq(communityJoinRequests.communityApId, community.apId),
|
|
234
|
-
eq(communityJoinRequests.actorApId, body.actor_ap_id),
|
|
235
|
-
),
|
|
236
|
-
);
|
|
244
|
+
await runBatch(db, statements as [D1Statement, ...D1Statement[]]);
|
|
245
|
+
// The durable intent is committed above; Queue is only a best-effort
|
|
246
|
+
// wakeup, so an outage cannot turn a committed approval into a 500.
|
|
247
|
+
await preparedResponse?.publish();
|
|
237
248
|
}
|
|
238
249
|
|
|
239
250
|
return c.json({ success: true });
|
|
@@ -265,7 +276,7 @@ export function registerMembershipRequestRoutes(
|
|
|
265
276
|
return c.json({ error: "Forbidden" }, 403);
|
|
266
277
|
}
|
|
267
278
|
|
|
268
|
-
const
|
|
279
|
+
const localRequest = await db
|
|
269
280
|
.select()
|
|
270
281
|
.from(communityJoinRequests)
|
|
271
282
|
.where(
|
|
@@ -276,19 +287,73 @@ export function registerMembershipRequestRoutes(
|
|
|
276
287
|
),
|
|
277
288
|
)
|
|
278
289
|
.get();
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
await db
|
|
284
|
-
.update(communityJoinRequests)
|
|
285
|
-
.set({ status: "rejected", processedAt: new Date().toISOString() })
|
|
290
|
+
const pendingEdge = await db
|
|
291
|
+
.select({ activityApId: follows.activityApId })
|
|
292
|
+
.from(follows)
|
|
286
293
|
.where(
|
|
287
294
|
and(
|
|
288
|
-
eq(
|
|
289
|
-
eq(
|
|
295
|
+
eq(follows.followerApId, body.actor_ap_id),
|
|
296
|
+
eq(follows.followingApId, community.apId),
|
|
297
|
+
eq(follows.status, "pending"),
|
|
290
298
|
),
|
|
299
|
+
)
|
|
300
|
+
.get();
|
|
301
|
+
if (!localRequest && !pendingEdge) {
|
|
302
|
+
return c.json({ error: "Join request not found" }, 404);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const now = new Date().toISOString();
|
|
306
|
+
const statements: D1Statement[] = [];
|
|
307
|
+
|
|
308
|
+
if (pendingEdge) {
|
|
309
|
+
statements.push(
|
|
310
|
+
db
|
|
311
|
+
.delete(follows)
|
|
312
|
+
.where(
|
|
313
|
+
and(
|
|
314
|
+
eq(follows.followerApId, body.actor_ap_id),
|
|
315
|
+
eq(follows.followingApId, community.apId),
|
|
316
|
+
eq(follows.status, "pending"),
|
|
317
|
+
),
|
|
318
|
+
) as D1Statement,
|
|
291
319
|
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// A remote approval request exists as a pending Follow edge. Reject it
|
|
323
|
+
// with the Group actor and commit the edge removal, Activity, and durable
|
|
324
|
+
// delivery intent together so the local and remote states cannot diverge.
|
|
325
|
+
const preparedResponse = pendingEdge
|
|
326
|
+
? await prepareResponseIfRemote(
|
|
327
|
+
c.env,
|
|
328
|
+
db,
|
|
329
|
+
c.env.APP_URL,
|
|
330
|
+
"Reject",
|
|
331
|
+
community.apId,
|
|
332
|
+
body.actor_ap_id,
|
|
333
|
+
pendingEdge.activityApId,
|
|
334
|
+
)
|
|
335
|
+
: null;
|
|
336
|
+
if (preparedResponse) statements.push(...preparedResponse.statements);
|
|
337
|
+
|
|
338
|
+
if (localRequest) {
|
|
339
|
+
statements.push(
|
|
340
|
+
db
|
|
341
|
+
.update(communityJoinRequests)
|
|
342
|
+
.set({ status: "rejected", processedAt: now })
|
|
343
|
+
.where(
|
|
344
|
+
and(
|
|
345
|
+
eq(communityJoinRequests.communityApId, community.apId),
|
|
346
|
+
eq(communityJoinRequests.actorApId, body.actor_ap_id),
|
|
347
|
+
eq(communityJoinRequests.status, "pending"),
|
|
348
|
+
),
|
|
349
|
+
) as D1Statement,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
await runBatch(db, statements as [D1Statement, ...D1Statement[]]);
|
|
354
|
+
// The durable intent is committed above; Queue is only a best-effort
|
|
355
|
+
// wakeup, so an outage cannot turn a committed rejection into a 500.
|
|
356
|
+
await preparedResponse?.publish();
|
|
292
357
|
|
|
293
358
|
return c.json({ success: true });
|
|
294
359
|
},
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import type { Context } from "hono";
|
|
2
|
-
import { and, eq, gt, inArray, or, sql } from "drizzle-orm";
|
|
3
|
-
import type { Database } from "../../../db/index.ts";
|
|
2
|
+
import { and, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
|
|
4
3
|
import {
|
|
5
4
|
actorCache,
|
|
6
5
|
actors,
|
|
7
6
|
communities,
|
|
8
7
|
communityBans,
|
|
9
8
|
communityMembers,
|
|
9
|
+
follows,
|
|
10
|
+
runBatch,
|
|
11
|
+
type D1Statement,
|
|
12
|
+
type Database,
|
|
10
13
|
} from "../../../db/index.ts";
|
|
11
14
|
import type { Env, Variables } from "../../types.ts";
|
|
12
15
|
import { communityApId } from "../../federation-helpers.ts";
|
|
13
16
|
import { chunkForInClause } from "../../lib/chunk.ts";
|
|
17
|
+
import { activityPubActorIdentityMatchesSql } from "../../lib/activitypub-actor-identity-sql.ts";
|
|
14
18
|
|
|
15
19
|
export const managerRoles = new Set(["owner", "moderator"]);
|
|
16
20
|
|
|
@@ -23,10 +27,27 @@ export async function banMember(
|
|
|
23
27
|
communityApIdVal: string,
|
|
24
28
|
bannedApId: string,
|
|
25
29
|
): Promise<void> {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
const retainedBans = activityPubActorIdentityMatchesSql(
|
|
31
|
+
sql`
|
|
32
|
+
SELECT ${communityBans.bannedApId}
|
|
33
|
+
FROM ${communityBans}
|
|
34
|
+
WHERE ${communityBans.communityApId} = ${communityApIdVal}
|
|
35
|
+
`,
|
|
36
|
+
bannedApId,
|
|
37
|
+
);
|
|
38
|
+
await db.run(sql`
|
|
39
|
+
INSERT INTO ${communityBans} (
|
|
40
|
+
community_ap_id, banned_ap_id, created_at
|
|
41
|
+
)
|
|
42
|
+
SELECT ${communityApIdVal}, ${bannedApId}, ${new Date().toISOString()}
|
|
43
|
+
WHERE NOT EXISTS (
|
|
44
|
+
SELECT 1
|
|
45
|
+
FROM ${communityBans}
|
|
46
|
+
WHERE ${communityBans.communityApId} = ${communityApIdVal}
|
|
47
|
+
AND ${communityBans.bannedApId} IN (${retainedBans})
|
|
48
|
+
)
|
|
49
|
+
ON CONFLICT DO NOTHING
|
|
50
|
+
`);
|
|
30
51
|
}
|
|
31
52
|
|
|
32
53
|
/**
|
|
@@ -38,14 +59,33 @@ export async function unbanMember(
|
|
|
38
59
|
communityApIdVal: string,
|
|
39
60
|
bannedApId: string,
|
|
40
61
|
): Promise<void> {
|
|
41
|
-
await db
|
|
62
|
+
await runBatch(db, [
|
|
63
|
+
prepareUnbanMemberStatement(db, communityApIdVal, bannedApId),
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Prepare explicit re-admission so callers can co-commit its authority state. */
|
|
68
|
+
export function prepareUnbanMemberStatement(
|
|
69
|
+
db: Database,
|
|
70
|
+
communityApIdVal: string,
|
|
71
|
+
bannedApId: string,
|
|
72
|
+
): D1Statement {
|
|
73
|
+
const retainedBans = activityPubActorIdentityMatchesSql(
|
|
74
|
+
sql`
|
|
75
|
+
SELECT ${communityBans.bannedApId}
|
|
76
|
+
FROM ${communityBans}
|
|
77
|
+
WHERE ${communityBans.communityApId} = ${communityApIdVal}
|
|
78
|
+
`,
|
|
79
|
+
bannedApId,
|
|
80
|
+
);
|
|
81
|
+
return db
|
|
42
82
|
.delete(communityBans)
|
|
43
83
|
.where(
|
|
44
84
|
and(
|
|
45
85
|
eq(communityBans.communityApId, communityApIdVal),
|
|
46
|
-
|
|
86
|
+
sql`${communityBans.bannedApId} IN (${retainedBans})`,
|
|
47
87
|
),
|
|
48
|
-
);
|
|
88
|
+
) as D1Statement;
|
|
49
89
|
}
|
|
50
90
|
|
|
51
91
|
/** Whether `actorApId` is banned from the community. */
|
|
@@ -64,7 +104,28 @@ export async function isMemberBanned(
|
|
|
64
104
|
),
|
|
65
105
|
)
|
|
66
106
|
.get();
|
|
67
|
-
return
|
|
107
|
+
if (row) return true;
|
|
108
|
+
|
|
109
|
+
const retainedBans = activityPubActorIdentityMatchesSql(
|
|
110
|
+
sql`
|
|
111
|
+
SELECT ${communityBans.bannedApId}
|
|
112
|
+
FROM ${communityBans}
|
|
113
|
+
WHERE ${communityBans.communityApId} = ${communityApIdVal}
|
|
114
|
+
`,
|
|
115
|
+
actorApId,
|
|
116
|
+
);
|
|
117
|
+
const retained = await db
|
|
118
|
+
.select({ bannedApId: communityBans.bannedApId })
|
|
119
|
+
.from(communityBans)
|
|
120
|
+
.where(
|
|
121
|
+
and(
|
|
122
|
+
eq(communityBans.communityApId, communityApIdVal),
|
|
123
|
+
sql`${communityBans.bannedApId} IN (${retainedBans})`,
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
.limit(1)
|
|
127
|
+
.get();
|
|
128
|
+
return retained !== undefined;
|
|
68
129
|
}
|
|
69
130
|
|
|
70
131
|
// `Database` is a union whose `.batch` lives only on the concrete D1/libsql
|
|
@@ -102,6 +163,68 @@ export async function removeMemberAtomic(
|
|
|
102
163
|
]);
|
|
103
164
|
}
|
|
104
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Atomically remove a LOCAL member, reconcile memberCount, and record the
|
|
168
|
+
* durable community ban that makes a moderator removal stick. This is distinct
|
|
169
|
+
* from removeMemberAtomic because a voluntary leave must not ban the actor.
|
|
170
|
+
*
|
|
171
|
+
* Put the idempotent ban insert inside the same D1 batch as the removal. If the
|
|
172
|
+
* ban cannot commit, neither the member row nor the count is changed, avoiding
|
|
173
|
+
* an expelled-but-immediately-rejoinable partial state.
|
|
174
|
+
*/
|
|
175
|
+
export async function removeMemberAndBanAtomic(
|
|
176
|
+
db: Database,
|
|
177
|
+
communityApIdVal: string,
|
|
178
|
+
actorApIdVal: string,
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
const memberExists = sql`EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.actorApId} = ${actorApIdVal})`;
|
|
181
|
+
await (db as unknown as Batchable).batch([
|
|
182
|
+
db
|
|
183
|
+
.update(communities)
|
|
184
|
+
.set({ memberCount: sql`${communities.memberCount} - 1` })
|
|
185
|
+
.where(
|
|
186
|
+
and(
|
|
187
|
+
eq(communities.apId, communityApIdVal),
|
|
188
|
+
gt(communities.memberCount, 0),
|
|
189
|
+
memberExists,
|
|
190
|
+
),
|
|
191
|
+
),
|
|
192
|
+
db
|
|
193
|
+
.insert(communityBans)
|
|
194
|
+
.values({ communityApId: communityApIdVal, bannedApId: actorApIdVal })
|
|
195
|
+
.onConflictDoNothing(),
|
|
196
|
+
db
|
|
197
|
+
.delete(communityMembers)
|
|
198
|
+
.where(memberWhere(communityApIdVal, actorApIdVal)),
|
|
199
|
+
]);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Prepare removal of a REMOTE community member's accepted/pending Follow and
|
|
204
|
+
* the same durable ban used for local removals. The route composes these with
|
|
205
|
+
* the peer-facing Reject Activity and delivery intent in one D1 batch.
|
|
206
|
+
*/
|
|
207
|
+
export function prepareRemoveRemoteMemberAndBanStatements(
|
|
208
|
+
db: Database,
|
|
209
|
+
communityApIdVal: string,
|
|
210
|
+
actorApIdVal: string,
|
|
211
|
+
): [D1Statement, D1Statement] {
|
|
212
|
+
return [
|
|
213
|
+
db
|
|
214
|
+
.insert(communityBans)
|
|
215
|
+
.values({ communityApId: communityApIdVal, bannedApId: actorApIdVal })
|
|
216
|
+
.onConflictDoNothing() as D1Statement,
|
|
217
|
+
db
|
|
218
|
+
.delete(follows)
|
|
219
|
+
.where(
|
|
220
|
+
and(
|
|
221
|
+
eq(follows.followerApId, actorApIdVal),
|
|
222
|
+
eq(follows.followingApId, communityApIdVal),
|
|
223
|
+
),
|
|
224
|
+
) as D1Statement,
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
|
|
105
228
|
/**
|
|
106
229
|
* Atomically remove an OWNER only if ANOTHER owner still remains. Returns false
|
|
107
230
|
* (nothing removed) when the actor is the last owner.
|
|
@@ -113,14 +236,15 @@ export async function removeMemberAtomic(
|
|
|
113
236
|
* statement closes the window: D1 serializes the two deletes, so the second one
|
|
114
237
|
* to execute sees the first already gone and matches 0 rows.
|
|
115
238
|
*/
|
|
116
|
-
|
|
239
|
+
async function removeOwnerIfAnotherExistsInternal(
|
|
117
240
|
db: Database,
|
|
118
241
|
communityApIdVal: string,
|
|
119
242
|
actorApIdVal: string,
|
|
243
|
+
banOnRemove: boolean,
|
|
120
244
|
): Promise<boolean> {
|
|
121
245
|
const anotherOwnerExists = sql`EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.role} = 'owner' AND ${communityMembers.actorApId} <> ${actorApIdVal})`;
|
|
122
246
|
const memberExists = sql`EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.actorApId} = ${actorApIdVal})`;
|
|
123
|
-
|
|
247
|
+
const statements: unknown[] = [
|
|
124
248
|
db
|
|
125
249
|
.update(communities)
|
|
126
250
|
.set({ memberCount: sql`${communities.memberCount} - 1` })
|
|
@@ -132,12 +256,35 @@ export async function removeOwnerIfAnotherExists(
|
|
|
132
256
|
anotherOwnerExists,
|
|
133
257
|
),
|
|
134
258
|
),
|
|
259
|
+
];
|
|
260
|
+
if (banOnRemove) {
|
|
261
|
+
// The ban must be conditional on the SAME last-owner predicate as the
|
|
262
|
+
// delete. Insert it before the delete so the target row still exists for
|
|
263
|
+
// the SELECT. Under D1's atomic batch serialization, a concurrent removal
|
|
264
|
+
// that would leave zero owners matches neither this insert nor the delete.
|
|
265
|
+
const banCandidate = db
|
|
266
|
+
.select({
|
|
267
|
+
communityApId: sql<string>`${communityApIdVal}`.as("community_ap_id"),
|
|
268
|
+
bannedApId: sql<string>`${actorApIdVal}`.as("banned_ap_id"),
|
|
269
|
+
createdAt: sql<string>`${new Date().toISOString()}`.as("created_at"),
|
|
270
|
+
})
|
|
271
|
+
.from(communityMembers)
|
|
272
|
+
.where(
|
|
273
|
+
and(memberWhere(communityApIdVal, actorApIdVal), anotherOwnerExists),
|
|
274
|
+
)
|
|
275
|
+
.limit(1);
|
|
276
|
+
statements.push(
|
|
277
|
+
db.insert(communityBans).select(banCandidate).onConflictDoNothing(),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
statements.push(
|
|
135
281
|
db
|
|
136
282
|
.delete(communityMembers)
|
|
137
283
|
.where(
|
|
138
284
|
and(memberWhere(communityApIdVal, actorApIdVal), anotherOwnerExists),
|
|
139
285
|
),
|
|
140
|
-
|
|
286
|
+
);
|
|
287
|
+
await (db as unknown as Batchable).batch(statements);
|
|
141
288
|
// Re-read whether the row is gone rather than trusting a batch affected-row
|
|
142
289
|
// count (its shape differs between D1 and the libsql test driver). The member
|
|
143
290
|
// row is keyed by (community, actor) and only this actor's leave touches it,
|
|
@@ -151,6 +298,37 @@ export async function removeOwnerIfAnotherExists(
|
|
|
151
298
|
return !stillMember;
|
|
152
299
|
}
|
|
153
300
|
|
|
301
|
+
export async function removeOwnerIfAnotherExists(
|
|
302
|
+
db: Database,
|
|
303
|
+
communityApIdVal: string,
|
|
304
|
+
actorApIdVal: string,
|
|
305
|
+
): Promise<boolean> {
|
|
306
|
+
return removeOwnerIfAnotherExistsInternal(
|
|
307
|
+
db,
|
|
308
|
+
communityApIdVal,
|
|
309
|
+
actorApIdVal,
|
|
310
|
+
false,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Moderation counterpart to removeOwnerIfAnotherExists: remove a co-owner only
|
|
316
|
+
* when another owner survives, and atomically record the durable ban. A failed
|
|
317
|
+
* last-owner guard records no ban and leaves the owner untouched.
|
|
318
|
+
*/
|
|
319
|
+
export async function removeOwnerAndBanIfAnotherExists(
|
|
320
|
+
db: Database,
|
|
321
|
+
communityApIdVal: string,
|
|
322
|
+
actorApIdVal: string,
|
|
323
|
+
): Promise<boolean> {
|
|
324
|
+
return removeOwnerIfAnotherExistsInternal(
|
|
325
|
+
db,
|
|
326
|
+
communityApIdVal,
|
|
327
|
+
actorApIdVal,
|
|
328
|
+
true,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
154
332
|
/**
|
|
155
333
|
* Atomically demote an OWNER to `newRole` only if ANOTHER owner (besides this
|
|
156
334
|
* target) still remains, preserving the ">=1 owner" invariant. Returns true when
|
|
@@ -200,12 +378,34 @@ export async function addMemberAtomic(
|
|
|
200
378
|
role: string,
|
|
201
379
|
joinedAt: string,
|
|
202
380
|
): Promise<void> {
|
|
381
|
+
await runBatch(
|
|
382
|
+
db,
|
|
383
|
+
prepareAddMemberStatements(
|
|
384
|
+
db,
|
|
385
|
+
communityApIdVal,
|
|
386
|
+
actorApIdVal,
|
|
387
|
+
role,
|
|
388
|
+
joinedAt,
|
|
389
|
+
),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Prepare retry-safe member insertion and its guarded count increment. */
|
|
394
|
+
export function prepareAddMemberStatements(
|
|
395
|
+
db: Database,
|
|
396
|
+
communityApIdVal: string,
|
|
397
|
+
actorApIdVal: string,
|
|
398
|
+
role: string,
|
|
399
|
+
joinedAt: string,
|
|
400
|
+
): readonly [D1Statement, D1Statement] {
|
|
203
401
|
const memberAbsent = sql`NOT EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.actorApId} = ${actorApIdVal})`;
|
|
204
|
-
|
|
402
|
+
return [
|
|
205
403
|
db
|
|
206
404
|
.update(communities)
|
|
207
405
|
.set({ memberCount: sql`${communities.memberCount} + 1` })
|
|
208
|
-
.where(
|
|
406
|
+
.where(
|
|
407
|
+
and(eq(communities.apId, communityApIdVal), memberAbsent),
|
|
408
|
+
) as D1Statement,
|
|
209
409
|
db
|
|
210
410
|
.insert(communityMembers)
|
|
211
411
|
.values({
|
|
@@ -214,8 +414,8 @@ export async function addMemberAtomic(
|
|
|
214
414
|
role,
|
|
215
415
|
joinedAt,
|
|
216
416
|
})
|
|
217
|
-
.onConflictDoNothing(),
|
|
218
|
-
]
|
|
417
|
+
.onConflictDoNothing() as D1Statement,
|
|
418
|
+
];
|
|
219
419
|
}
|
|
220
420
|
|
|
221
421
|
export function resolveCommunityApId(
|
|
@@ -227,11 +427,18 @@ export function resolveCommunityApId(
|
|
|
227
427
|
: communityApId(baseUrl, identifier);
|
|
228
428
|
}
|
|
229
429
|
|
|
230
|
-
/**
|
|
430
|
+
/**
|
|
431
|
+
* Shared WHERE clause for looking up a live community by identifier or AP-ID.
|
|
432
|
+
* Soft-deleted communities are absent from every ordinary route, including
|
|
433
|
+
* direct identifier calls that bypass discovery.
|
|
434
|
+
*/
|
|
231
435
|
export function communityWhere(apId: string, identifier: string) {
|
|
232
|
-
return
|
|
233
|
-
|
|
234
|
-
|
|
436
|
+
return and(
|
|
437
|
+
or(
|
|
438
|
+
eq(communities.apId, apId),
|
|
439
|
+
eq(communities.preferredUsername, identifier),
|
|
440
|
+
),
|
|
441
|
+
isNull(communities.deletedAt),
|
|
235
442
|
);
|
|
236
443
|
}
|
|
237
444
|
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
memberWhere,
|
|
42
42
|
resolveCommunityApId,
|
|
43
43
|
} from "./membership-shared.ts";
|
|
44
|
+
import { operatorActorNotBlockedSql } from "../../lib/blocklist.ts";
|
|
44
45
|
|
|
45
46
|
const MAX_COMMUNITY_MESSAGE_LENGTH = 5000;
|
|
46
47
|
const MAX_COMMUNITY_MESSAGES_LIMIT = 100;
|
|
@@ -158,6 +159,9 @@ messagesRouter.get("/:identifier/messages", async (c) => {
|
|
|
158
159
|
eq(objectRecipients.type, "audience"),
|
|
159
160
|
eq(objects.type, "Note"),
|
|
160
161
|
isNull(objects.communityApId),
|
|
162
|
+
// A partial purge may retain remote chat objects. Defederation must hide
|
|
163
|
+
// them before cursor/limit so they cannot consume or shorten the page.
|
|
164
|
+
operatorActorNotBlockedSql(sql`${objects.attributedTo}`),
|
|
161
165
|
];
|
|
162
166
|
// Composite (published, apId) cursor so same-millisecond messages aren't
|
|
163
167
|
// skipped on a load-older boundary (see lib/feed-cursor.ts).
|
|
@@ -191,7 +195,10 @@ messagesRouter.get("/:identifier/messages", async (c) => {
|
|
|
191
195
|
id: msg.apId,
|
|
192
196
|
sender: {
|
|
193
197
|
ap_id: msg.attributedTo,
|
|
194
|
-
username: formatUsername(
|
|
198
|
+
username: formatUsername(
|
|
199
|
+
msg.attributedTo,
|
|
200
|
+
senderInfo?.preferredUsername,
|
|
201
|
+
),
|
|
195
202
|
preferred_username: senderInfo?.preferredUsername || null,
|
|
196
203
|
name: senderInfo?.name || null,
|
|
197
204
|
icon_url: senderInfo?.iconUrl || null,
|
|
@@ -418,7 +425,7 @@ messagesRouter.post(
|
|
|
418
425
|
id: objectApId,
|
|
419
426
|
sender: {
|
|
420
427
|
ap_id: actor.ap_id,
|
|
421
|
-
username: formatUsername(actor.ap_id),
|
|
428
|
+
username: formatUsername(actor.ap_id, actor.preferred_username),
|
|
422
429
|
preferred_username: actor.preferred_username,
|
|
423
430
|
name: actor.name,
|
|
424
431
|
icon_url: actor.icon_url,
|