@takosjp/yurucommu-core 3.0.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/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- package/src/runtime/site-worker.ts +10 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import type { Context, Hono } from "hono";
|
|
2
|
+
import { and, count, desc, eq, sql } from "drizzle-orm";
|
|
3
|
+
import {
|
|
4
|
+
activities,
|
|
5
|
+
communities,
|
|
6
|
+
communityJoinRequests,
|
|
7
|
+
communityMembers,
|
|
8
|
+
follows,
|
|
9
|
+
} from "../../../db/index.ts";
|
|
10
|
+
import type { Env, Variables } from "../../types.ts";
|
|
11
|
+
import {
|
|
12
|
+
activityApId,
|
|
13
|
+
formatUsername,
|
|
14
|
+
generateId,
|
|
15
|
+
isLocal,
|
|
16
|
+
} from "../../federation-helpers.ts";
|
|
17
|
+
import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
|
|
18
|
+
import {
|
|
19
|
+
addMemberAtomic,
|
|
20
|
+
batchLoadActorInfo,
|
|
21
|
+
fetchCommunityId,
|
|
22
|
+
memberWhere,
|
|
23
|
+
requireManager,
|
|
24
|
+
unbanMember,
|
|
25
|
+
} from "./membership-shared.ts";
|
|
26
|
+
|
|
27
|
+
const AS_CONTEXT = "https://www.w3.org/ns/activitystreams";
|
|
28
|
+
|
|
29
|
+
export function registerMembershipRequestRoutes(
|
|
30
|
+
communitiesRouter: Hono<{ Bindings: Env; Variables: Variables }>,
|
|
31
|
+
) {
|
|
32
|
+
// GET /api/communities/:identifier/requests - List pending join requests
|
|
33
|
+
communitiesRouter.get(
|
|
34
|
+
"/:identifier/requests",
|
|
35
|
+
async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
|
|
36
|
+
const actor = c.get("actor");
|
|
37
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
38
|
+
|
|
39
|
+
const identifier = c.req.param("identifier")!;
|
|
40
|
+
const db = c.get("db");
|
|
41
|
+
|
|
42
|
+
const { community } = await fetchCommunityId(c, identifier);
|
|
43
|
+
if (!community) {
|
|
44
|
+
return c.json({ error: "Community not found" }, 404);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const manager = await requireManager(db, community.apId, actor.ap_id);
|
|
48
|
+
if (!manager) {
|
|
49
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Pending join requests come from TWO sources, merged (newest-first, capped
|
|
53
|
+
// so the response can't grow unbounded): local approval-joins recorded in
|
|
54
|
+
// community_join_requests, AND remote approval-joins which exist ONLY as a
|
|
55
|
+
// PENDING follows edge to the Group actor (a remote follower has no `actors`
|
|
56
|
+
// row, so it can't be mirrored into community_join_requests).
|
|
57
|
+
const localRequests = await db
|
|
58
|
+
.select()
|
|
59
|
+
.from(communityJoinRequests)
|
|
60
|
+
.where(
|
|
61
|
+
and(
|
|
62
|
+
eq(communityJoinRequests.communityApId, community.apId),
|
|
63
|
+
eq(communityJoinRequests.status, "pending"),
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
.orderBy(desc(communityJoinRequests.createdAt))
|
|
67
|
+
.limit(200);
|
|
68
|
+
const pendingEdges = await db
|
|
69
|
+
.select({
|
|
70
|
+
actorApId: follows.followerApId,
|
|
71
|
+
createdAt: follows.createdAt,
|
|
72
|
+
})
|
|
73
|
+
.from(follows)
|
|
74
|
+
.where(
|
|
75
|
+
and(
|
|
76
|
+
eq(follows.followingApId, community.apId),
|
|
77
|
+
eq(follows.status, "pending"),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
.orderBy(desc(follows.createdAt))
|
|
81
|
+
.limit(200);
|
|
82
|
+
|
|
83
|
+
const byActor = new Map<string, string>(); // actorApId -> createdAt
|
|
84
|
+
for (const e of pendingEdges) byActor.set(e.actorApId, e.createdAt);
|
|
85
|
+
for (const r of localRequests) byActor.set(r.actorApId, r.createdAt);
|
|
86
|
+
const merged = [...byActor.entries()]
|
|
87
|
+
.sort((a, b) => (a[1] < b[1] ? 1 : -1))
|
|
88
|
+
.slice(0, 200);
|
|
89
|
+
|
|
90
|
+
const actorInfoMap = await batchLoadActorInfo(
|
|
91
|
+
db,
|
|
92
|
+
merged.map(([apId]) => apId),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const result = merged.map(([apId, createdAt]) => {
|
|
96
|
+
const actorInfo = actorInfoMap.get(apId);
|
|
97
|
+
return {
|
|
98
|
+
ap_id: apId,
|
|
99
|
+
username: formatUsername(apId),
|
|
100
|
+
preferred_username: actorInfo?.preferredUsername || null,
|
|
101
|
+
name: actorInfo?.name || null,
|
|
102
|
+
icon_url: actorInfo?.iconUrl || null,
|
|
103
|
+
created_at: createdAt,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return c.json({ requests: result });
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// POST /api/communities/:identifier/requests/accept - Accept join request
|
|
112
|
+
communitiesRouter.post(
|
|
113
|
+
"/:identifier/requests/accept",
|
|
114
|
+
async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
|
|
115
|
+
const actor = c.get("actor");
|
|
116
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
117
|
+
|
|
118
|
+
const identifier = c.req.param("identifier")!;
|
|
119
|
+
const db = c.get("db");
|
|
120
|
+
const body = await c.req.json<{ actor_ap_id: string }>();
|
|
121
|
+
|
|
122
|
+
if (!body.actor_ap_id) {
|
|
123
|
+
return c.json({ error: "actor_ap_id required" }, 400);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { community } = await fetchCommunityId(c, identifier);
|
|
127
|
+
if (!community) {
|
|
128
|
+
return c.json({ error: "Community not found" }, 404);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const manager = await requireManager(db, community.apId, actor.ap_id);
|
|
132
|
+
if (!manager) {
|
|
133
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A pending request is EITHER a community_join_requests row (local
|
|
137
|
+
// approval-join) OR a pending follows edge to the Group (remote
|
|
138
|
+
// approval-join — no join-request row, since a remote has no `actors` row).
|
|
139
|
+
const localRequest = await db
|
|
140
|
+
.select()
|
|
141
|
+
.from(communityJoinRequests)
|
|
142
|
+
.where(
|
|
143
|
+
and(
|
|
144
|
+
eq(communityJoinRequests.communityApId, community.apId),
|
|
145
|
+
eq(communityJoinRequests.actorApId, body.actor_ap_id),
|
|
146
|
+
eq(communityJoinRequests.status, "pending"),
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
.get();
|
|
150
|
+
const pendingEdge = await db
|
|
151
|
+
.select({ activityApId: follows.activityApId })
|
|
152
|
+
.from(follows)
|
|
153
|
+
.where(
|
|
154
|
+
and(
|
|
155
|
+
eq(follows.followerApId, body.actor_ap_id),
|
|
156
|
+
eq(follows.followingApId, community.apId),
|
|
157
|
+
eq(follows.status, "pending"),
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
.get();
|
|
161
|
+
if (!localRequest && !pendingEdge) {
|
|
162
|
+
return c.json({ error: "Join request not found" }, 404);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const now = new Date().toISOString();
|
|
166
|
+
|
|
167
|
+
if (isLocal(body.actor_ap_id, c.env.APP_URL)) {
|
|
168
|
+
const existingMember = await db
|
|
169
|
+
.select()
|
|
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(
|
|
177
|
+
db,
|
|
178
|
+
community.apId,
|
|
179
|
+
body.actor_ap_id,
|
|
180
|
+
"member",
|
|
181
|
+
now,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
// A REMOTE member's membership IS the pending follows edge to the Group
|
|
186
|
+
// actor — NOT a communityMembers row (which would diverge from how the
|
|
187
|
+
// rest of the system resolves remote membership). Flip that edge to
|
|
188
|
+
// accepted and emit the community-signed Accept so the remote learns it
|
|
189
|
+
// was approved and our handleGroupCreate (which requires status=accepted)
|
|
190
|
+
// starts relaying its posts. The pending edge carries the original Follow
|
|
191
|
+
// activity id the Accept must reference.
|
|
192
|
+
await db
|
|
193
|
+
.update(follows)
|
|
194
|
+
.set({ status: "accepted", acceptedAt: now })
|
|
195
|
+
.where(
|
|
196
|
+
and(
|
|
197
|
+
eq(follows.followerApId, body.actor_ap_id),
|
|
198
|
+
eq(follows.followingApId, community.apId),
|
|
199
|
+
),
|
|
200
|
+
);
|
|
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
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Accepting a join request is an explicit re-admission — lift any ban.
|
|
224
|
+
await unbanMember(db, community.apId, body.actor_ap_id);
|
|
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
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return c.json({ success: true });
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// POST /api/communities/:identifier/requests/reject - Reject join request
|
|
244
|
+
communitiesRouter.post(
|
|
245
|
+
"/:identifier/requests/reject",
|
|
246
|
+
async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
|
|
247
|
+
const actor = c.get("actor");
|
|
248
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
249
|
+
|
|
250
|
+
const identifier = c.req.param("identifier")!;
|
|
251
|
+
const db = c.get("db");
|
|
252
|
+
const body = await c.req.json<{ actor_ap_id: string }>();
|
|
253
|
+
|
|
254
|
+
if (!body.actor_ap_id) {
|
|
255
|
+
return c.json({ error: "actor_ap_id required" }, 400);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const { community } = await fetchCommunityId(c, identifier);
|
|
259
|
+
if (!community) {
|
|
260
|
+
return c.json({ error: "Community not found" }, 404);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const manager = await requireManager(db, community.apId, actor.ap_id);
|
|
264
|
+
if (!manager) {
|
|
265
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const request = await db
|
|
269
|
+
.select()
|
|
270
|
+
.from(communityJoinRequests)
|
|
271
|
+
.where(
|
|
272
|
+
and(
|
|
273
|
+
eq(communityJoinRequests.communityApId, community.apId),
|
|
274
|
+
eq(communityJoinRequests.actorApId, body.actor_ap_id),
|
|
275
|
+
eq(communityJoinRequests.status, "pending"),
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
.get();
|
|
279
|
+
if (!request) {
|
|
280
|
+
return c.json({ error: "Join request not found" }, 404);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await db
|
|
284
|
+
.update(communityJoinRequests)
|
|
285
|
+
.set({ status: "rejected", processedAt: new Date().toISOString() })
|
|
286
|
+
.where(
|
|
287
|
+
and(
|
|
288
|
+
eq(communityJoinRequests.communityApId, community.apId),
|
|
289
|
+
eq(communityJoinRequests.actorApId, body.actor_ap_id),
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
return c.json({ success: true });
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
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";
|
|
4
|
+
import {
|
|
5
|
+
actorCache,
|
|
6
|
+
actors,
|
|
7
|
+
communities,
|
|
8
|
+
communityBans,
|
|
9
|
+
communityMembers,
|
|
10
|
+
} from "../../../db/index.ts";
|
|
11
|
+
import type { Env, Variables } from "../../types.ts";
|
|
12
|
+
import { communityApId } from "../../federation-helpers.ts";
|
|
13
|
+
import { chunkForInClause } from "../../lib/chunk.ts";
|
|
14
|
+
|
|
15
|
+
export const managerRoles = new Set(["owner", "moderator"]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Record a durable ban so a removed member cannot immediately re-join an OPEN
|
|
19
|
+
* community (local re-join or a remote re-Follow). Idempotent.
|
|
20
|
+
*/
|
|
21
|
+
export async function banMember(
|
|
22
|
+
db: Database,
|
|
23
|
+
communityApIdVal: string,
|
|
24
|
+
bannedApId: string,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
await db
|
|
27
|
+
.insert(communityBans)
|
|
28
|
+
.values({ communityApId: communityApIdVal, bannedApId })
|
|
29
|
+
.onConflictDoNothing();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lift a ban on explicit re-admission (approve a join request / accept an
|
|
34
|
+
* invite / add a member). No-op when no ban exists.
|
|
35
|
+
*/
|
|
36
|
+
export async function unbanMember(
|
|
37
|
+
db: Database,
|
|
38
|
+
communityApIdVal: string,
|
|
39
|
+
bannedApId: string,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
await db
|
|
42
|
+
.delete(communityBans)
|
|
43
|
+
.where(
|
|
44
|
+
and(
|
|
45
|
+
eq(communityBans.communityApId, communityApIdVal),
|
|
46
|
+
eq(communityBans.bannedApId, bannedApId),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether `actorApId` is banned from the community. */
|
|
52
|
+
export async function isMemberBanned(
|
|
53
|
+
db: Database,
|
|
54
|
+
communityApIdVal: string,
|
|
55
|
+
actorApId: string,
|
|
56
|
+
): Promise<boolean> {
|
|
57
|
+
const row = await db
|
|
58
|
+
.select({ bannedApId: communityBans.bannedApId })
|
|
59
|
+
.from(communityBans)
|
|
60
|
+
.where(
|
|
61
|
+
and(
|
|
62
|
+
eq(communityBans.communityApId, communityApIdVal),
|
|
63
|
+
eq(communityBans.bannedApId, actorApId),
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
.get();
|
|
67
|
+
return !!row;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// `Database` is a union whose `.batch` lives only on the concrete D1/libsql
|
|
71
|
+
// subclasses; reach it through a narrow structural cast (matching membership-join).
|
|
72
|
+
type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Atomically remove a member and decrement memberCount in ONE batch. The
|
|
76
|
+
* decrement runs BEFORE the delete and is guarded by `EXISTS(member)` (so a
|
|
77
|
+
* duplicate concurrent removal — whose member row is already gone — cannot
|
|
78
|
+
* double-decrement) and `memberCount > 0` (underflow). Mirrors the federated
|
|
79
|
+
* undoFollowEdge pattern; replaces the previous non-atomic delete-then-`-1` that
|
|
80
|
+
* could tear on crash, underflow negative, or double-decrement under a race.
|
|
81
|
+
*/
|
|
82
|
+
export async function removeMemberAtomic(
|
|
83
|
+
db: Database,
|
|
84
|
+
communityApIdVal: string,
|
|
85
|
+
actorApIdVal: string,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
const memberExists = sql`EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.actorApId} = ${actorApIdVal})`;
|
|
88
|
+
await (db as unknown as Batchable).batch([
|
|
89
|
+
db
|
|
90
|
+
.update(communities)
|
|
91
|
+
.set({ memberCount: sql`${communities.memberCount} - 1` })
|
|
92
|
+
.where(
|
|
93
|
+
and(
|
|
94
|
+
eq(communities.apId, communityApIdVal),
|
|
95
|
+
gt(communities.memberCount, 0),
|
|
96
|
+
memberExists,
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
db
|
|
100
|
+
.delete(communityMembers)
|
|
101
|
+
.where(memberWhere(communityApIdVal, actorApIdVal)),
|
|
102
|
+
]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Atomically remove an OWNER only if ANOTHER owner still remains. Returns false
|
|
107
|
+
* (nothing removed) when the actor is the last owner.
|
|
108
|
+
*
|
|
109
|
+
* A plain `count(owners) > 1` check followed by a separate delete is a TOCTOU:
|
|
110
|
+
* two concurrent last-owner leaves both read count=2, both pass, both delete →
|
|
111
|
+
* the community is orphaned with ZERO owners. Conditioning the delete (and its
|
|
112
|
+
* memberCount decrement) on `EXISTS(another owner)` evaluated INSIDE the
|
|
113
|
+
* statement closes the window: D1 serializes the two deletes, so the second one
|
|
114
|
+
* to execute sees the first already gone and matches 0 rows.
|
|
115
|
+
*/
|
|
116
|
+
export async function removeOwnerIfAnotherExists(
|
|
117
|
+
db: Database,
|
|
118
|
+
communityApIdVal: string,
|
|
119
|
+
actorApIdVal: string,
|
|
120
|
+
): Promise<boolean> {
|
|
121
|
+
const anotherOwnerExists = sql`EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.role} = 'owner' AND ${communityMembers.actorApId} <> ${actorApIdVal})`;
|
|
122
|
+
const memberExists = sql`EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.actorApId} = ${actorApIdVal})`;
|
|
123
|
+
await (db as unknown as Batchable).batch([
|
|
124
|
+
db
|
|
125
|
+
.update(communities)
|
|
126
|
+
.set({ memberCount: sql`${communities.memberCount} - 1` })
|
|
127
|
+
.where(
|
|
128
|
+
and(
|
|
129
|
+
eq(communities.apId, communityApIdVal),
|
|
130
|
+
gt(communities.memberCount, 0),
|
|
131
|
+
memberExists,
|
|
132
|
+
anotherOwnerExists,
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
db
|
|
136
|
+
.delete(communityMembers)
|
|
137
|
+
.where(
|
|
138
|
+
and(memberWhere(communityApIdVal, actorApIdVal), anotherOwnerExists),
|
|
139
|
+
),
|
|
140
|
+
]);
|
|
141
|
+
// Re-read whether the row is gone rather than trusting a batch affected-row
|
|
142
|
+
// count (its shape differs between D1 and the libsql test driver). The member
|
|
143
|
+
// row is keyed by (community, actor) and only this actor's leave touches it,
|
|
144
|
+
// so this read is reliable. Absent → removed (another owner existed); present
|
|
145
|
+
// → not removed (the actor was the last owner).
|
|
146
|
+
const stillMember = await db
|
|
147
|
+
.select({ actorApId: communityMembers.actorApId })
|
|
148
|
+
.from(communityMembers)
|
|
149
|
+
.where(memberWhere(communityApIdVal, actorApIdVal))
|
|
150
|
+
.get();
|
|
151
|
+
return !stillMember;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Atomically demote an OWNER to `newRole` only if ANOTHER owner (besides this
|
|
156
|
+
* target) still remains, preserving the ">=1 owner" invariant. Returns true when
|
|
157
|
+
* the target is no longer an owner (demotion applied), false when the target was
|
|
158
|
+
* the last owner and was therefore left untouched.
|
|
159
|
+
*
|
|
160
|
+
* Mirrors removeOwnerIfAnotherExists for the role-change path: a
|
|
161
|
+
* count(owners)>1 check followed by a separate UPDATE is a TOCTOU two owners who
|
|
162
|
+
* demote EACH OTHER concurrently can both pass, orphaning the community with
|
|
163
|
+
* zero owners. Conditioning the UPDATE on `EXISTS(another owner)` evaluated
|
|
164
|
+
* INSIDE the statement closes the window — D1 serializes the two updates, so the
|
|
165
|
+
* second to execute sees the first already demoted and matches 0 rows. The
|
|
166
|
+
* EXISTS is keyed on the TARGET (not the caller) so it equally covers demoting
|
|
167
|
+
* yourself and demoting a fellow owner.
|
|
168
|
+
*/
|
|
169
|
+
export async function demoteOwnerIfAnotherExists(
|
|
170
|
+
db: Database,
|
|
171
|
+
communityApIdVal: string,
|
|
172
|
+
targetApId: string,
|
|
173
|
+
newRole: string,
|
|
174
|
+
): Promise<boolean> {
|
|
175
|
+
const anotherOwnerExists = sql`EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.role} = 'owner' AND ${communityMembers.actorApId} <> ${targetApId})`;
|
|
176
|
+
await db
|
|
177
|
+
.update(communityMembers)
|
|
178
|
+
.set({ role: newRole })
|
|
179
|
+
.where(and(memberWhere(communityApIdVal, targetApId), anotherOwnerExists));
|
|
180
|
+
// Re-read (driver-agnostic — no reliance on batch affected-row counts). Absent
|
|
181
|
+
// or no-longer-owner → demotion held; still owner → it was the last owner.
|
|
182
|
+
const after = await db
|
|
183
|
+
.select({ role: communityMembers.role })
|
|
184
|
+
.from(communityMembers)
|
|
185
|
+
.where(memberWhere(communityApIdVal, targetApId))
|
|
186
|
+
.get();
|
|
187
|
+
return after?.role !== "owner";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Atomically add a member and increment memberCount in ONE batch. The increment
|
|
192
|
+
* is guarded by `NOT EXISTS(member)` so a duplicate concurrent add (or a retry)
|
|
193
|
+
* cannot double-count; the insert is onConflictDoNothing. Mirrors the open-join
|
|
194
|
+
* batch and the federated handleFollow pattern.
|
|
195
|
+
*/
|
|
196
|
+
export async function addMemberAtomic(
|
|
197
|
+
db: Database,
|
|
198
|
+
communityApIdVal: string,
|
|
199
|
+
actorApIdVal: string,
|
|
200
|
+
role: string,
|
|
201
|
+
joinedAt: string,
|
|
202
|
+
): Promise<void> {
|
|
203
|
+
const memberAbsent = sql`NOT EXISTS (SELECT 1 FROM ${communityMembers} WHERE ${communityMembers.communityApId} = ${communityApIdVal} AND ${communityMembers.actorApId} = ${actorApIdVal})`;
|
|
204
|
+
await (db as unknown as Batchable).batch([
|
|
205
|
+
db
|
|
206
|
+
.update(communities)
|
|
207
|
+
.set({ memberCount: sql`${communities.memberCount} + 1` })
|
|
208
|
+
.where(and(eq(communities.apId, communityApIdVal), memberAbsent)),
|
|
209
|
+
db
|
|
210
|
+
.insert(communityMembers)
|
|
211
|
+
.values({
|
|
212
|
+
communityApId: communityApIdVal,
|
|
213
|
+
actorApId: actorApIdVal,
|
|
214
|
+
role,
|
|
215
|
+
joinedAt,
|
|
216
|
+
})
|
|
217
|
+
.onConflictDoNothing(),
|
|
218
|
+
]);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function resolveCommunityApId(
|
|
222
|
+
baseUrl: string,
|
|
223
|
+
identifier: string,
|
|
224
|
+
): string {
|
|
225
|
+
return identifier.startsWith("http")
|
|
226
|
+
? identifier
|
|
227
|
+
: communityApId(baseUrl, identifier);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Shared WHERE clause for looking up a community by identifier or apId. */
|
|
231
|
+
export function communityWhere(apId: string, identifier: string) {
|
|
232
|
+
return or(
|
|
233
|
+
eq(communities.apId, apId),
|
|
234
|
+
eq(communities.preferredUsername, identifier),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function fetchCommunityDetails(
|
|
239
|
+
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
240
|
+
identifier: string,
|
|
241
|
+
) {
|
|
242
|
+
const db = c.get("db");
|
|
243
|
+
const apId = resolveCommunityApId(c.env.APP_URL, identifier);
|
|
244
|
+
const community = await db
|
|
245
|
+
.select()
|
|
246
|
+
.from(communities)
|
|
247
|
+
.where(communityWhere(apId, identifier))
|
|
248
|
+
.get();
|
|
249
|
+
return { apId, community };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function fetchCommunityId(
|
|
253
|
+
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
254
|
+
identifier: string,
|
|
255
|
+
) {
|
|
256
|
+
const db = c.get("db");
|
|
257
|
+
const apId = resolveCommunityApId(c.env.APP_URL, identifier);
|
|
258
|
+
const community = await db
|
|
259
|
+
.select({ apId: communities.apId })
|
|
260
|
+
.from(communities)
|
|
261
|
+
.where(communityWhere(apId, identifier))
|
|
262
|
+
.get();
|
|
263
|
+
return { apId, community };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Compound key condition for CommunityMember / CommunityJoinRequest lookups. */
|
|
267
|
+
export function memberWhere(communityApIdVal: string, actorApId: string) {
|
|
268
|
+
return and(
|
|
269
|
+
eq(communityMembers.communityApId, communityApIdVal),
|
|
270
|
+
eq(communityMembers.actorApId, actorApId),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Require the actor to be a manager (owner or moderator) of the community.
|
|
276
|
+
* Returns the membership record on success, or null if unauthorized.
|
|
277
|
+
*/
|
|
278
|
+
export async function requireManager(
|
|
279
|
+
db: Database,
|
|
280
|
+
communityApIdVal: string,
|
|
281
|
+
actorApId: string,
|
|
282
|
+
) {
|
|
283
|
+
const member = await db
|
|
284
|
+
.select()
|
|
285
|
+
.from(communityMembers)
|
|
286
|
+
.where(memberWhere(communityApIdVal, actorApId))
|
|
287
|
+
.get();
|
|
288
|
+
if (!member || !managerRoles.has(member.role)) return null;
|
|
289
|
+
return member;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Batch load actor display info from both local actors and cached actors.
|
|
294
|
+
* Returns a single Map keyed by apId with the merged results (local takes priority).
|
|
295
|
+
*/
|
|
296
|
+
export async function batchLoadActorInfo(
|
|
297
|
+
db: Database,
|
|
298
|
+
apIds: string[],
|
|
299
|
+
includeIcon = true,
|
|
300
|
+
) {
|
|
301
|
+
if (apIds.length === 0) {
|
|
302
|
+
return new Map<
|
|
303
|
+
string,
|
|
304
|
+
{
|
|
305
|
+
preferredUsername: string | null;
|
|
306
|
+
name: string | null;
|
|
307
|
+
iconUrl?: string | null;
|
|
308
|
+
}
|
|
309
|
+
>();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
type ActorInfo = {
|
|
313
|
+
preferredUsername: string | null;
|
|
314
|
+
name: string | null;
|
|
315
|
+
iconUrl?: string | null;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const selectLocalBase = {
|
|
319
|
+
apId: actors.apId,
|
|
320
|
+
preferredUsername: actors.preferredUsername,
|
|
321
|
+
name: actors.name,
|
|
322
|
+
iconUrl: actors.iconUrl,
|
|
323
|
+
} as const;
|
|
324
|
+
const selectCachedBase = {
|
|
325
|
+
apId: actorCache.apId,
|
|
326
|
+
preferredUsername: actorCache.preferredUsername,
|
|
327
|
+
name: actorCache.name,
|
|
328
|
+
iconUrl: actorCache.iconUrl,
|
|
329
|
+
} as const;
|
|
330
|
+
|
|
331
|
+
// Chunk the IN(...) lookups: a community roster page can carry up to ~90
|
|
332
|
+
// member ids and D1 caps a query at 100 bound parameters. Chunks are disjoint
|
|
333
|
+
// id slices, so per-chunk maps merge collision-free; cached-then-local
|
|
334
|
+
// ordering still gives local-wins within each chunk.
|
|
335
|
+
const map = new Map<string, ActorInfo>();
|
|
336
|
+
for (const ids of chunkForInClause(apIds)) {
|
|
337
|
+
const [localActors, cachedActors] = await Promise.all([
|
|
338
|
+
db.select(selectLocalBase).from(actors).where(inArray(actors.apId, ids)),
|
|
339
|
+
db
|
|
340
|
+
.select(selectCachedBase)
|
|
341
|
+
.from(actorCache)
|
|
342
|
+
.where(inArray(actorCache.apId, ids)),
|
|
343
|
+
]);
|
|
344
|
+
|
|
345
|
+
// Cached first so local overrides
|
|
346
|
+
for (const a of cachedActors) {
|
|
347
|
+
const info: ActorInfo = {
|
|
348
|
+
preferredUsername: a.preferredUsername,
|
|
349
|
+
name: a.name,
|
|
350
|
+
};
|
|
351
|
+
if (includeIcon) info.iconUrl = a.iconUrl;
|
|
352
|
+
map.set(a.apId, info);
|
|
353
|
+
}
|
|
354
|
+
for (const a of localActors) {
|
|
355
|
+
const info: ActorInfo = {
|
|
356
|
+
preferredUsername: a.preferredUsername,
|
|
357
|
+
name: a.name,
|
|
358
|
+
};
|
|
359
|
+
if (includeIcon) info.iconUrl = a.iconUrl;
|
|
360
|
+
map.set(a.apId, info);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return map;
|
|
364
|
+
}
|