@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,208 @@
|
|
|
1
|
+
import type { Context, Hono } from "hono";
|
|
2
|
+
import { and, count, desc, eq, gt, isNull, or } from "drizzle-orm";
|
|
3
|
+
import { communityInvites } from "../../../db/index.ts";
|
|
4
|
+
import type { Env, Variables } from "../../types.ts";
|
|
5
|
+
import { formatUsername, generateId } from "../../federation-helpers.ts";
|
|
6
|
+
import {
|
|
7
|
+
batchLoadActorInfo,
|
|
8
|
+
fetchCommunityId,
|
|
9
|
+
requireManager,
|
|
10
|
+
} from "./membership-shared.ts";
|
|
11
|
+
|
|
12
|
+
// Newest-first cap on the manager invite list (bounds response size).
|
|
13
|
+
const INVITE_LIST_MAX = 200;
|
|
14
|
+
// Cap on outstanding (unused, unexpired) invites per community so a manager
|
|
15
|
+
// can't grow community_invites without bound.
|
|
16
|
+
const MAX_OUTSTANDING_INVITES = 200;
|
|
17
|
+
|
|
18
|
+
export function registerMembershipInviteRoutes(
|
|
19
|
+
communitiesRouter: Hono<{ Bindings: Env; Variables: Variables }>,
|
|
20
|
+
) {
|
|
21
|
+
// GET /api/communities/:identifier/invites - List invites
|
|
22
|
+
communitiesRouter.get(
|
|
23
|
+
"/:identifier/invites",
|
|
24
|
+
async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
|
|
25
|
+
const actor = c.get("actor");
|
|
26
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
27
|
+
|
|
28
|
+
const identifier = c.req.param("identifier")!;
|
|
29
|
+
const db = c.get("db");
|
|
30
|
+
|
|
31
|
+
const { community } = await fetchCommunityId(c, identifier);
|
|
32
|
+
if (!community) {
|
|
33
|
+
return c.json({ error: "Community not found" }, 404);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const manager = await requireManager(db, community.apId, actor.ap_id);
|
|
37
|
+
if (!manager) {
|
|
38
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Cap the manager invite list (newest-first) so the response can't grow
|
|
42
|
+
// unbounded with invite-row count.
|
|
43
|
+
const invites = await db
|
|
44
|
+
.select()
|
|
45
|
+
.from(communityInvites)
|
|
46
|
+
.where(eq(communityInvites.communityApId, community.apId))
|
|
47
|
+
.orderBy(desc(communityInvites.createdAt))
|
|
48
|
+
.limit(INVITE_LIST_MAX);
|
|
49
|
+
|
|
50
|
+
const invitedByApIds = [
|
|
51
|
+
...new Set(invites.map((inv) => inv.invitedByApId)),
|
|
52
|
+
];
|
|
53
|
+
const actorInfoMap = await batchLoadActorInfo(db, invitedByApIds, false);
|
|
54
|
+
|
|
55
|
+
const result = invites.map((inv) => {
|
|
56
|
+
const invitedByInfo = actorInfoMap.get(inv.invitedByApId);
|
|
57
|
+
return {
|
|
58
|
+
id: inv.id,
|
|
59
|
+
invited_ap_id: inv.invitedApId,
|
|
60
|
+
invited_by: {
|
|
61
|
+
ap_id: inv.invitedByApId,
|
|
62
|
+
username: formatUsername(inv.invitedByApId),
|
|
63
|
+
preferred_username: invitedByInfo?.preferredUsername || null,
|
|
64
|
+
name: invitedByInfo?.name || null,
|
|
65
|
+
},
|
|
66
|
+
created_at: inv.createdAt,
|
|
67
|
+
expires_at: inv.expiresAt,
|
|
68
|
+
used_at: inv.usedAt,
|
|
69
|
+
used_by_ap_id: inv.usedByApId,
|
|
70
|
+
is_valid:
|
|
71
|
+
!inv.usedAt &&
|
|
72
|
+
(!inv.expiresAt || new Date(inv.expiresAt) > new Date()),
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return c.json({ invites: result });
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// POST /api/communities/:identifier/invites - Create invite
|
|
81
|
+
communitiesRouter.post(
|
|
82
|
+
"/:identifier/invites",
|
|
83
|
+
async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
|
|
84
|
+
const actor = c.get("actor");
|
|
85
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
86
|
+
|
|
87
|
+
const identifier = c.req.param("identifier")!;
|
|
88
|
+
const db = c.get("db");
|
|
89
|
+
let invitedApId: string | null = null;
|
|
90
|
+
let expiresInHours: number | null = null;
|
|
91
|
+
try {
|
|
92
|
+
const body = await c.req.json<{
|
|
93
|
+
invited_ap_id?: string;
|
|
94
|
+
expires_in_hours?: number;
|
|
95
|
+
}>();
|
|
96
|
+
invitedApId =
|
|
97
|
+
typeof body.invited_ap_id === "string"
|
|
98
|
+
? body.invited_ap_id.trim() || null
|
|
99
|
+
: null;
|
|
100
|
+
// Only accept a finite POSITIVE number. A non-numeric value (the JSON
|
|
101
|
+
// type isn't enforced at runtime) would flow into `new Date(now + h*…)`
|
|
102
|
+
// below as NaN and throw a RangeError on .toISOString() → 500.
|
|
103
|
+
expiresInHours =
|
|
104
|
+
typeof body.expires_in_hours === "number" &&
|
|
105
|
+
Number.isFinite(body.expires_in_hours) &&
|
|
106
|
+
body.expires_in_hours > 0
|
|
107
|
+
? body.expires_in_hours
|
|
108
|
+
: null;
|
|
109
|
+
} catch {
|
|
110
|
+
invitedApId = null;
|
|
111
|
+
expiresInHours = null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { community } = await fetchCommunityId(c, identifier);
|
|
115
|
+
if (!community) {
|
|
116
|
+
return c.json({ error: "Community not found" }, 404);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const manager = await requireManager(db, community.apId, actor.ap_id);
|
|
120
|
+
if (!manager) {
|
|
121
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Bound outstanding (unused, unexpired) invites per community so a manager
|
|
125
|
+
// can't loop create and grow community_invites without limit.
|
|
126
|
+
const nowIso = new Date().toISOString();
|
|
127
|
+
const outstanding = await db
|
|
128
|
+
.select({ n: count() })
|
|
129
|
+
.from(communityInvites)
|
|
130
|
+
.where(
|
|
131
|
+
and(
|
|
132
|
+
eq(communityInvites.communityApId, community.apId),
|
|
133
|
+
isNull(communityInvites.usedAt),
|
|
134
|
+
or(
|
|
135
|
+
isNull(communityInvites.expiresAt),
|
|
136
|
+
gt(communityInvites.expiresAt, nowIso),
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
.get();
|
|
141
|
+
if ((outstanding?.n ?? 0) >= MAX_OUTSTANDING_INVITES) {
|
|
142
|
+
return c.json({ error: "Too many outstanding invites" }, 429);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const inviteId = generateId();
|
|
146
|
+
const now = new Date();
|
|
147
|
+
const expiresAt = expiresInHours
|
|
148
|
+
? new Date(
|
|
149
|
+
now.getTime() + expiresInHours * 60 * 60 * 1000,
|
|
150
|
+
).toISOString()
|
|
151
|
+
: null;
|
|
152
|
+
|
|
153
|
+
await db.insert(communityInvites).values({
|
|
154
|
+
id: inviteId,
|
|
155
|
+
communityApId: community.apId,
|
|
156
|
+
invitedByApId: actor.ap_id,
|
|
157
|
+
invitedApId,
|
|
158
|
+
createdAt: now.toISOString(),
|
|
159
|
+
expiresAt,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return c.json({ invite_id: inviteId, expires_at: expiresAt });
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// DELETE /api/communities/:identifier/invites/:inviteId - Revoke invite
|
|
167
|
+
communitiesRouter.delete(
|
|
168
|
+
"/:identifier/invites/:inviteId",
|
|
169
|
+
async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
|
|
170
|
+
const actor = c.get("actor");
|
|
171
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
172
|
+
|
|
173
|
+
const identifier = c.req.param("identifier")!;
|
|
174
|
+
const inviteId = c.req.param("inviteId")!;
|
|
175
|
+
const db = c.get("db");
|
|
176
|
+
|
|
177
|
+
const { community } = await fetchCommunityId(c, identifier);
|
|
178
|
+
if (!community) {
|
|
179
|
+
return c.json({ error: "Community not found" }, 404);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const manager = await requireManager(db, community.apId, actor.ap_id);
|
|
183
|
+
if (!manager) {
|
|
184
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const invite = await db
|
|
188
|
+
.select()
|
|
189
|
+
.from(communityInvites)
|
|
190
|
+
.where(
|
|
191
|
+
and(
|
|
192
|
+
eq(communityInvites.id, inviteId),
|
|
193
|
+
eq(communityInvites.communityApId, community.apId),
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
.get();
|
|
197
|
+
if (!invite) {
|
|
198
|
+
return c.json({ error: "Invite not found" }, 404);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await db
|
|
202
|
+
.delete(communityInvites)
|
|
203
|
+
.where(eq(communityInvites.id, inviteId));
|
|
204
|
+
|
|
205
|
+
return c.json({ success: true });
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import type { Context, Hono } from "hono";
|
|
2
|
+
import { and, eq, gt, isNull, or, sql } from "drizzle-orm";
|
|
3
|
+
import {
|
|
4
|
+
affectedRowCount,
|
|
5
|
+
communities,
|
|
6
|
+
communityInvites,
|
|
7
|
+
communityJoinRequests,
|
|
8
|
+
communityMembers,
|
|
9
|
+
} from "../../../db/index.ts";
|
|
10
|
+
import type { Env, Variables } from "../../types.ts";
|
|
11
|
+
import {
|
|
12
|
+
fetchCommunityDetails,
|
|
13
|
+
isMemberBanned,
|
|
14
|
+
memberWhere,
|
|
15
|
+
removeMemberAtomic,
|
|
16
|
+
removeOwnerIfAnotherExists,
|
|
17
|
+
unbanMember,
|
|
18
|
+
} from "./membership-shared.ts";
|
|
19
|
+
import { isUniqueConstraintError } from "../../lib/parse-helpers.ts";
|
|
20
|
+
import { logger } from "../../lib/logger.ts";
|
|
21
|
+
|
|
22
|
+
const log = logger.child({ component: "communities.join" });
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Narrow view over the concrete D1/libsql drizzle client's atomic batch API.
|
|
26
|
+
* The shared `Database` union type does not surface `batch` (it lives on the
|
|
27
|
+
* concrete subclasses), so we reach it through a structural cast at the one
|
|
28
|
+
* call site that needs an atomic multi-statement write.
|
|
29
|
+
*/
|
|
30
|
+
type Batchable = {
|
|
31
|
+
batch(statements: readonly unknown[]): Promise<unknown>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function registerMembershipJoinRoutes(
|
|
35
|
+
communitiesRouter: Hono<{ Bindings: Env; Variables: Variables }>,
|
|
36
|
+
) {
|
|
37
|
+
// POST /api/communities/:identifier/join - Join a community
|
|
38
|
+
communitiesRouter.post(
|
|
39
|
+
"/:identifier/join",
|
|
40
|
+
async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
|
|
41
|
+
const actor = c.get("actor");
|
|
42
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
43
|
+
|
|
44
|
+
const identifier = c.req.param("identifier")!;
|
|
45
|
+
const db = c.get("db");
|
|
46
|
+
|
|
47
|
+
let inviteId: string | undefined;
|
|
48
|
+
try {
|
|
49
|
+
const body = await c.req.json<{ invite_id?: string }>();
|
|
50
|
+
inviteId = body.invite_id?.trim() || undefined;
|
|
51
|
+
} catch {
|
|
52
|
+
inviteId = undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { community } = await fetchCommunityDetails(c, identifier);
|
|
56
|
+
if (!community) {
|
|
57
|
+
return c.json({ error: "Community not found" }, 404);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check if already member
|
|
61
|
+
const existing = await db
|
|
62
|
+
.select()
|
|
63
|
+
.from(communityMembers)
|
|
64
|
+
.where(memberWhere(community.apId, actor.ap_id))
|
|
65
|
+
.get();
|
|
66
|
+
if (existing) {
|
|
67
|
+
return c.json({ error: "Already a member" }, 409);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const now = new Date().toISOString();
|
|
71
|
+
|
|
72
|
+
// A PRIVATE community must never be OPENLY self-joinable — open join would
|
|
73
|
+
// let ANY logged-in actor join and thereby read its members-only content,
|
|
74
|
+
// defeating the privacy. If an owner left joinPolicy="open" while flipping
|
|
75
|
+
// the community private, treat the join as "approval" (held pending) so the
|
|
76
|
+
// owner still gates who gets in. Invite-policy is unaffected.
|
|
77
|
+
const effectiveJoinPolicy =
|
|
78
|
+
community.visibility === "private" && community.joinPolicy === "open"
|
|
79
|
+
? "approval"
|
|
80
|
+
: community.joinPolicy;
|
|
81
|
+
|
|
82
|
+
if (effectiveJoinPolicy === "approval") {
|
|
83
|
+
// Upsert: check if exists, then insert or update
|
|
84
|
+
const existingRequest = await db
|
|
85
|
+
.select()
|
|
86
|
+
.from(communityJoinRequests)
|
|
87
|
+
.where(
|
|
88
|
+
and(
|
|
89
|
+
eq(communityJoinRequests.communityApId, community.apId),
|
|
90
|
+
eq(communityJoinRequests.actorApId, actor.ap_id),
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
.get();
|
|
94
|
+
|
|
95
|
+
if (existingRequest) {
|
|
96
|
+
await db
|
|
97
|
+
.update(communityJoinRequests)
|
|
98
|
+
.set({ status: "pending", createdAt: now, processedAt: null })
|
|
99
|
+
.where(
|
|
100
|
+
and(
|
|
101
|
+
eq(communityJoinRequests.communityApId, community.apId),
|
|
102
|
+
eq(communityJoinRequests.actorApId, actor.ap_id),
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
} else {
|
|
106
|
+
// onConflictDoNothing so two concurrent first-time join requests by
|
|
107
|
+
// the same actor (double-click / retry) don't 500 the loser on the
|
|
108
|
+
// (communityApId, actorApId) composite PK — the request is idempotent
|
|
109
|
+
// (matches the invite-join / open-join branches' unique handling).
|
|
110
|
+
await db
|
|
111
|
+
.insert(communityJoinRequests)
|
|
112
|
+
.values({
|
|
113
|
+
communityApId: community.apId,
|
|
114
|
+
actorApId: actor.ap_id,
|
|
115
|
+
status: "pending",
|
|
116
|
+
createdAt: now,
|
|
117
|
+
})
|
|
118
|
+
.onConflictDoNothing();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return c.json({ success: true, status: "pending" });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (effectiveJoinPolicy === "invite") {
|
|
125
|
+
if (!inviteId) {
|
|
126
|
+
return c.json(
|
|
127
|
+
{ error: "Invite required", status: "invite_required" },
|
|
128
|
+
403,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
// Find the invite
|
|
134
|
+
const invite = await db
|
|
135
|
+
.select()
|
|
136
|
+
.from(communityInvites)
|
|
137
|
+
.where(
|
|
138
|
+
and(
|
|
139
|
+
eq(communityInvites.id, inviteId),
|
|
140
|
+
eq(communityInvites.communityApId, community.apId),
|
|
141
|
+
isNull(communityInvites.usedAt),
|
|
142
|
+
or(
|
|
143
|
+
isNull(communityInvites.expiresAt),
|
|
144
|
+
gt(communityInvites.expiresAt, now),
|
|
145
|
+
),
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
.get();
|
|
149
|
+
|
|
150
|
+
if (!invite) {
|
|
151
|
+
return c.json(
|
|
152
|
+
{
|
|
153
|
+
error: "Invalid or expired invite",
|
|
154
|
+
status: "invite_required",
|
|
155
|
+
},
|
|
156
|
+
403,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (invite.invitedApId && invite.invitedApId !== actor.ap_id) {
|
|
160
|
+
return c.json(
|
|
161
|
+
{
|
|
162
|
+
error: "Invite not for this account",
|
|
163
|
+
status: "invite_required",
|
|
164
|
+
},
|
|
165
|
+
403,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Claim the invite FIRST — this conditional single-use UPDATE is the
|
|
170
|
+
// race GATE. Only the writer that flips usedAt from NULL (affecting
|
|
171
|
+
// exactly one row) is allowed to materialize the membership; a racing
|
|
172
|
+
// loser sees 0 rows affected and is rejected, so it never leaves a
|
|
173
|
+
// phantom member row or an inflated memberCount behind.
|
|
174
|
+
const claimResult = await db
|
|
175
|
+
.update(communityInvites)
|
|
176
|
+
.set({ usedByApId: actor.ap_id, usedAt: now })
|
|
177
|
+
.where(
|
|
178
|
+
and(
|
|
179
|
+
eq(communityInvites.id, inviteId),
|
|
180
|
+
eq(communityInvites.communityApId, community.apId),
|
|
181
|
+
isNull(communityInvites.usedAt),
|
|
182
|
+
or(
|
|
183
|
+
isNull(communityInvites.expiresAt),
|
|
184
|
+
gt(communityInvites.expiresAt, now),
|
|
185
|
+
),
|
|
186
|
+
or(
|
|
187
|
+
isNull(communityInvites.invitedApId),
|
|
188
|
+
eq(communityInvites.invitedApId, actor.ap_id),
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
if (affectedRowCount(claimResult) !== 1) {
|
|
193
|
+
// Lost the single-use claim race (or invite was used/expired
|
|
194
|
+
// between the read above and this write). Reject without touching
|
|
195
|
+
// membership or the count.
|
|
196
|
+
return c.json(
|
|
197
|
+
{
|
|
198
|
+
error: "Invalid or expired invite",
|
|
199
|
+
status: "invite_required",
|
|
200
|
+
},
|
|
201
|
+
403,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Won the claim: create the member + bump the count atomically.
|
|
206
|
+
// D1 has no interactive transactions; group the member insert and
|
|
207
|
+
// the counter increment into a single batch so they cannot diverge.
|
|
208
|
+
// The `Database` union type does not surface `batch` (it is only on
|
|
209
|
+
// the concrete D1/libsql subclasses), so reach it through a narrow
|
|
210
|
+
// structural cast.
|
|
211
|
+
const memberInsert = db.insert(communityMembers).values({
|
|
212
|
+
communityApId: community.apId,
|
|
213
|
+
actorApId: actor.ap_id,
|
|
214
|
+
role: "member",
|
|
215
|
+
joinedAt: now,
|
|
216
|
+
});
|
|
217
|
+
const countBump = db
|
|
218
|
+
.update(communities)
|
|
219
|
+
.set({ memberCount: sql`${communities.memberCount} + 1` })
|
|
220
|
+
.where(eq(communities.apId, community.apId));
|
|
221
|
+
await (db as unknown as Batchable).batch([memberInsert, countBump]);
|
|
222
|
+
|
|
223
|
+
// Consuming a valid invite is an explicit re-admission — lift any ban.
|
|
224
|
+
await unbanMember(db, community.apId, actor.ap_id);
|
|
225
|
+
return c.json({ success: true, status: "joined" });
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (isUniqueConstraintError(error)) {
|
|
228
|
+
return c.json({ error: "Already a member" }, 409);
|
|
229
|
+
}
|
|
230
|
+
log.error("Failed to join with invite", {
|
|
231
|
+
event: "communities.join.invite_failed",
|
|
232
|
+
community: community.apId,
|
|
233
|
+
actor: actor.ap_id,
|
|
234
|
+
error,
|
|
235
|
+
});
|
|
236
|
+
return c.json({ error: "Failed to join community" }, 500);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Durable ban: a removed member cannot self-rejoin via OPEN join (the
|
|
241
|
+
// auto-admit path the kick was meant to stop). Approval (a mod decides)
|
|
242
|
+
// and invite (explicit re-admission) paths above stay open and lift the
|
|
243
|
+
// ban on success, so a moderator can always let someone back in.
|
|
244
|
+
if (await isMemberBanned(db, community.apId, actor.ap_id)) {
|
|
245
|
+
return c.json(
|
|
246
|
+
{
|
|
247
|
+
error: "You have been removed from this community",
|
|
248
|
+
status: "banned",
|
|
249
|
+
},
|
|
250
|
+
403,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Open join — create the member + bump the count atomically. D1 has no
|
|
255
|
+
// interactive transactions; group the member insert and the counter
|
|
256
|
+
// increment into a single batch so they cannot diverge on a mid-request
|
|
257
|
+
// failure (matching the invite-join path). The `Database` union type does
|
|
258
|
+
// not surface `batch` (it is only on the concrete D1/libsql subclasses),
|
|
259
|
+
// so reach it through a narrow structural cast.
|
|
260
|
+
try {
|
|
261
|
+
const memberInsert = db.insert(communityMembers).values({
|
|
262
|
+
communityApId: community.apId,
|
|
263
|
+
actorApId: actor.ap_id,
|
|
264
|
+
role: "member",
|
|
265
|
+
joinedAt: now,
|
|
266
|
+
});
|
|
267
|
+
const countBump = db
|
|
268
|
+
.update(communities)
|
|
269
|
+
.set({ memberCount: sql`${communities.memberCount} + 1` })
|
|
270
|
+
.where(eq(communities.apId, community.apId));
|
|
271
|
+
await (db as unknown as Batchable).batch([memberInsert, countBump]);
|
|
272
|
+
|
|
273
|
+
return c.json({ success: true, status: "joined" });
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (isUniqueConstraintError(error)) {
|
|
276
|
+
return c.json({ error: "Already a member" }, 409);
|
|
277
|
+
}
|
|
278
|
+
log.error("Failed to join community", {
|
|
279
|
+
event: "communities.join.failed",
|
|
280
|
+
community: community.apId,
|
|
281
|
+
actor: actor.ap_id,
|
|
282
|
+
error,
|
|
283
|
+
});
|
|
284
|
+
return c.json({ error: "Failed to join community" }, 500);
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// POST /api/communities/:identifier/leave - Leave a community
|
|
290
|
+
communitiesRouter.post(
|
|
291
|
+
"/:identifier/leave",
|
|
292
|
+
async (c: Context<{ Bindings: Env; Variables: Variables }>) => {
|
|
293
|
+
const actor = c.get("actor");
|
|
294
|
+
if (!actor) return c.json({ error: "Unauthorized" }, 401);
|
|
295
|
+
|
|
296
|
+
const identifier = c.req.param("identifier")!;
|
|
297
|
+
const db = c.get("db");
|
|
298
|
+
|
|
299
|
+
const { community } = await fetchCommunityDetails(c, identifier);
|
|
300
|
+
if (!community) {
|
|
301
|
+
return c.json({ error: "Community not found" }, 404);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const membership = await db
|
|
305
|
+
.select()
|
|
306
|
+
.from(communityMembers)
|
|
307
|
+
.where(memberWhere(community.apId, actor.ap_id))
|
|
308
|
+
.get();
|
|
309
|
+
if (!membership) {
|
|
310
|
+
return c.json({ error: "Not a member" }, 400);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Don't allow the last owner to leave. Enforced ATOMICALLY rather than as
|
|
314
|
+
// a count(owners) > 1 check then a separate delete — that is a TOCTOU two
|
|
315
|
+
// concurrent owners can both pass, leaving the community with ZERO owners.
|
|
316
|
+
// removeOwnerIfAnotherExists conditions the delete on another owner still
|
|
317
|
+
// existing and reports whether it removed.
|
|
318
|
+
if (membership.role === "owner") {
|
|
319
|
+
const removed = await removeOwnerIfAnotherExists(
|
|
320
|
+
db,
|
|
321
|
+
community.apId,
|
|
322
|
+
actor.ap_id,
|
|
323
|
+
);
|
|
324
|
+
if (!removed) {
|
|
325
|
+
return c.json({ error: "Cannot leave: you are the only owner" }, 400);
|
|
326
|
+
}
|
|
327
|
+
return c.json({ success: true });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
await removeMemberAtomic(db, community.apId, actor.ap_id);
|
|
331
|
+
|
|
332
|
+
return c.json({ success: true });
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
}
|