@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.
Files changed (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,624 @@
1
+ import { Hono } from "hono";
2
+ import {
3
+ and,
4
+ asc,
5
+ count,
6
+ desc,
7
+ eq,
8
+ inArray,
9
+ isNull,
10
+ or,
11
+ sql,
12
+ } from "drizzle-orm";
13
+ import {
14
+ communities,
15
+ communityJoinRequests,
16
+ communityMembers,
17
+ mediaUploads,
18
+ objects,
19
+ } from "../../../db/index.ts";
20
+ import type { Database } from "../../../db/index.ts";
21
+ import type { Env, Variables } from "../../types.ts";
22
+ import {
23
+ communityApId,
24
+ generateKeyPair,
25
+ parseLimit,
26
+ parseOffset,
27
+ } from "../../federation-helpers.ts";
28
+ import {
29
+ fetchCommunityId,
30
+ memberWhere,
31
+ requireManager,
32
+ } from "./membership-shared.ts";
33
+ import { isUniqueConstraintError } from "../../lib/parse-helpers.ts";
34
+ import { communityRequiresMembership } from "../../lib/community-visibility.ts";
35
+ import { reapReplacedMediaUrl } from "../posts/delete-cascade.ts";
36
+
37
+ /**
38
+ * Narrow view over the concrete D1/libsql drizzle client's atomic batch API.
39
+ * The shared `Database` union type does not surface `batch` (it lives on the
40
+ * concrete subclasses), so we reach it through a structural cast at the call
41
+ * sites that need an atomic multi-statement write.
42
+ */
43
+ type Batchable = {
44
+ batch(statements: readonly unknown[]): Promise<unknown>;
45
+ };
46
+
47
+ const communitiesRouter = new Hono<{ Bindings: Env; Variables: Variables }>();
48
+
49
+ function isValidCommunityIconUrl(value: string): boolean {
50
+ const trimmed = value.trim();
51
+ if (trimmed.startsWith("/media/")) return true;
52
+ try {
53
+ const parsed = new URL(trimmed);
54
+ return parsed.protocol === "https:";
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * A local `/media/...` icon URL must reference an upload the SETTER owns.
62
+ * Otherwise a user could point their community's icon at another user's
63
+ * (possibly private) blob, and the media-authorization layer would treat the
64
+ * icon reference as a public grant — a cross-user media IDOR. External
65
+ * (https) URLs are not local blobs, so this gate only applies to `/media/`.
66
+ * Mirrors reapReplacedMediaUrl's uploader-scoped lookup.
67
+ */
68
+ async function isOwnLocalMediaUrl(
69
+ db: Database,
70
+ url: string,
71
+ ownerApId: string,
72
+ ): Promise<boolean> {
73
+ if (!url.startsWith("/media/")) return false;
74
+ const filename = url.slice("/media/".length);
75
+ if (!filename || filename.includes("/") || filename.includes("..")) {
76
+ return false;
77
+ }
78
+ const r2Key = `uploads/${filename}`;
79
+ const owned = await db
80
+ .select({ id: mediaUploads.id })
81
+ .from(mediaUploads)
82
+ .where(
83
+ and(
84
+ eq(mediaUploads.r2Key, r2Key),
85
+ eq(mediaUploads.uploaderApId, ownerApId),
86
+ ),
87
+ )
88
+ .get();
89
+ return !!owned;
90
+ }
91
+
92
+ const RESERVED_NAMES = new Set([
93
+ "admin",
94
+ "administrator",
95
+ "system",
96
+ "root",
97
+ "moderator",
98
+ "mod",
99
+ "community",
100
+ "communities",
101
+ "group",
102
+ "groups",
103
+ "user",
104
+ "users",
105
+ "api",
106
+ "ap",
107
+ "activitypub",
108
+ "webfinger",
109
+ "well_known",
110
+ "settings",
111
+ "config",
112
+ "configuration",
113
+ "help",
114
+ "support",
115
+ "about",
116
+ "terms",
117
+ "privacy",
118
+ "legal",
119
+ "dmca",
120
+ "copyright",
121
+ "login",
122
+ "logout",
123
+ "register",
124
+ "signup",
125
+ "signin",
126
+ "auth",
127
+ "null",
128
+ "undefined",
129
+ "true",
130
+ "false",
131
+ "test",
132
+ "demo",
133
+ ]);
134
+
135
+ function validateCommunityName(name: string | undefined): string | null {
136
+ if (!name || name.trim().length < 2) {
137
+ return "Name must be at least 2 characters";
138
+ }
139
+ const trimmed = name.trim();
140
+ if (trimmed.length > 32) return "Name must be at most 32 characters";
141
+ if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) {
142
+ return "Name can only contain letters, numbers, and underscores";
143
+ }
144
+ if (RESERVED_NAMES.has(trimmed.toLowerCase())) return "This name is reserved";
145
+ if (/^\d+$/.test(trimmed)) return "Name cannot be all numbers";
146
+ if (trimmed.startsWith("_") || trimmed.endsWith("_")) {
147
+ return "Name cannot start or end with underscore";
148
+ }
149
+ return null;
150
+ }
151
+
152
+ // Cosmetic community profile caps. The handle (`name`/preferredUsername) is
153
+ // strictly validated above, but display_name and summary were stored raw with
154
+ // no server-side bound — a direct API caller could persist an arbitrarily large
155
+ // summary that then bloats every federated actor fetch. Mirror the client
156
+ // CreateScopeModal maxlengths (64 / 500) so create and update agree.
157
+ const MAX_COMMUNITY_DISPLAY_NAME_LENGTH = 64;
158
+ const MAX_COMMUNITY_SUMMARY_LENGTH = 500;
159
+
160
+ function validateCommunityProfile(
161
+ displayName: string | undefined,
162
+ summary: string | undefined,
163
+ ): string | null {
164
+ if (
165
+ displayName !== undefined &&
166
+ displayName.length > MAX_COMMUNITY_DISPLAY_NAME_LENGTH
167
+ ) {
168
+ return `Display name must be at most ${MAX_COMMUNITY_DISPLAY_NAME_LENGTH} characters`;
169
+ }
170
+ if (summary !== undefined && summary.length > MAX_COMMUNITY_SUMMARY_LENGTH) {
171
+ return `Summary must be at most ${MAX_COMMUNITY_SUMMARY_LENGTH} characters`;
172
+ }
173
+ return null;
174
+ }
175
+
176
+ // GET /api/communities - List all communities
177
+ communitiesRouter.get("/", async (c) => {
178
+ const actor = c.get("actor");
179
+ const db = c.get("db");
180
+ // Capped at 90: this page's communityApIds are re-queried via `inArray` for
181
+ // membership/pending-join enrichment, and Cloudflare D1 allows at most 100
182
+ // bound parameters per query (the unclamped fallback must be <=90 too). Offset
183
+ // paginates the rest.
184
+ const limit = parseLimit(c.req.query("limit"), 90, 90);
185
+ const offset = parseOffset(c.req.query("offset"), 0, 10000);
186
+
187
+ const actorApIdVal = actor?.ap_id || "";
188
+
189
+ // Discovery list visibility: never expose deleted communities, and never leak
190
+ // a PRIVATE community (its name/summary/member-count/existence) to someone who
191
+ // is not a member — `visibility !== 'public'` is the members-only gate enforced
192
+ // on content/member-list reads elsewhere, so it must gate discovery too. A
193
+ // logged-in viewer additionally sees the private communities they belong to;
194
+ // anonymous callers see only public ones.
195
+ const memberCommunityIds = db
196
+ .select({ id: communityMembers.communityApId })
197
+ .from(communityMembers)
198
+ .where(eq(communityMembers.actorApId, actorApIdVal));
199
+ const visibilityFilter = actorApIdVal
200
+ ? or(
201
+ eq(communities.visibility, "public"),
202
+ inArray(communities.apId, memberCommunityIds),
203
+ )
204
+ : eq(communities.visibility, "public");
205
+
206
+ // Project only the columns the response renders — never pull the public/
207
+ // PRIVATE key PEM material (communities.publicKeyPem/privateKeyPem) into the
208
+ // worker on this hot list path.
209
+ const communitiesList = await db
210
+ .select({
211
+ apId: communities.apId,
212
+ preferredUsername: communities.preferredUsername,
213
+ name: communities.name,
214
+ summary: communities.summary,
215
+ iconUrl: communities.iconUrl,
216
+ visibility: communities.visibility,
217
+ joinPolicy: communities.joinPolicy,
218
+ postPolicy: communities.postPolicy,
219
+ memberCount: communities.memberCount,
220
+ createdAt: communities.createdAt,
221
+ lastMessageAt: communities.lastMessageAt,
222
+ })
223
+ .from(communities)
224
+ .where(and(isNull(communities.deletedAt), visibilityFilter))
225
+ .orderBy(
226
+ sql`CASE WHEN ${communities.lastMessageAt} IS NULL THEN 1 ELSE 0 END`,
227
+ desc(communities.lastMessageAt),
228
+ asc(communities.createdAt),
229
+ )
230
+ .limit(limit)
231
+ .offset(offset);
232
+
233
+ // Batch load membership (with role) and join request status for current actor
234
+ // to avoid N+1. member_role is needed so the client can build accurate scopes
235
+ // (atoms/scope.ts) instead of asserting a bogus role for joined communities.
236
+ const communityApIds = communitiesList.map((c) => c.apId);
237
+ const memberRoleMap = new Map<string, string>();
238
+ const pendingRequestSet = new Set<string>();
239
+
240
+ if (actorApIdVal && communityApIds.length > 0) {
241
+ const [memberships, joinRequests] = await Promise.all([
242
+ db
243
+ .select({
244
+ communityApId: communityMembers.communityApId,
245
+ role: communityMembers.role,
246
+ })
247
+ .from(communityMembers)
248
+ .where(
249
+ and(
250
+ eq(communityMembers.actorApId, actorApIdVal),
251
+ inArray(communityMembers.communityApId, communityApIds),
252
+ ),
253
+ ),
254
+ db
255
+ .select({ communityApId: communityJoinRequests.communityApId })
256
+ .from(communityJoinRequests)
257
+ .where(
258
+ and(
259
+ eq(communityJoinRequests.actorApId, actorApIdVal),
260
+ inArray(communityJoinRequests.communityApId, communityApIds),
261
+ eq(communityJoinRequests.status, "pending"),
262
+ ),
263
+ ),
264
+ ]);
265
+
266
+ for (const m of memberships) memberRoleMap.set(m.communityApId, m.role);
267
+ for (const r of joinRequests) pendingRequestSet.add(r.communityApId);
268
+ }
269
+
270
+ const result = communitiesList.map((community) => {
271
+ const memberRole = memberRoleMap.get(community.apId) ?? null;
272
+ const isMember = memberRole !== null;
273
+ const joinStatus =
274
+ !isMember && pendingRequestSet.has(community.apId) ? "pending" : null;
275
+
276
+ return {
277
+ ap_id: community.apId,
278
+ name: community.preferredUsername,
279
+ display_name: community.name,
280
+ summary: community.summary,
281
+ icon_url: community.iconUrl,
282
+ visibility: community.visibility,
283
+ join_policy: community.joinPolicy,
284
+ post_policy: community.postPolicy,
285
+ member_count: community.memberCount,
286
+ created_at: community.createdAt,
287
+ last_message_at: community.lastMessageAt,
288
+ is_member: isMember,
289
+ member_role: memberRole,
290
+ join_status: joinStatus,
291
+ };
292
+ });
293
+
294
+ return c.json({ communities: result });
295
+ });
296
+
297
+ // POST /api/communities - Create a new community
298
+ communitiesRouter.post("/", async (c) => {
299
+ const actor = c.get("actor");
300
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
301
+
302
+ const db = c.get("db");
303
+
304
+ const body = await c.req.json<{
305
+ name: string;
306
+ display_name?: string;
307
+ summary?: string;
308
+ }>();
309
+
310
+ // Type-check before trimming: `?.` only guards null/undefined, so a non-string
311
+ // `name` (e.g. a JSON number) would throw a TypeError → 500. undefined falls
312
+ // through to validateCommunityName, which rejects it with a 400.
313
+ const name = typeof body.name === "string" ? body.name.trim() : undefined;
314
+ const nameError = validateCommunityName(name);
315
+ if (nameError) return c.json({ error: nameError }, 400);
316
+ const profileError = validateCommunityProfile(
317
+ body.display_name,
318
+ body.summary,
319
+ );
320
+ if (profileError) return c.json({ error: profileError }, 400);
321
+
322
+ // name is guaranteed non-null after validateCommunityName passes
323
+ const validName = name!;
324
+ const baseUrl = c.env.APP_URL;
325
+ const apId = communityApId(baseUrl, validName);
326
+ const now = new Date().toISOString();
327
+
328
+ const inboxUrl = `${apId}/inbox`;
329
+ const outbox = `${apId}/outbox`;
330
+ const followersUrl = `${apId}/followers`;
331
+
332
+ const { publicKeyPem, privateKeyPem } = await generateKeyPair();
333
+
334
+ // Create community and owner member. D1 has no interactive transactions, so
335
+ // group the community insert (which carries memberCount: 1) and the owner
336
+ // membership insert into a single atomic batch — otherwise a mid-write failure
337
+ // could leave an owner-less community (or a member row without its community).
338
+ // The `Database` union type does not surface `batch` (it is only on the
339
+ // concrete D1/libsql subclasses), so reach it through a narrow structural cast.
340
+ try {
341
+ const communityInsert = db.insert(communities).values({
342
+ apId,
343
+ preferredUsername: validName,
344
+ name: body.display_name || validName,
345
+ summary: body.summary || "",
346
+ inbox: inboxUrl,
347
+ outbox,
348
+ followersUrl,
349
+ publicKeyPem,
350
+ privateKeyPem,
351
+ visibility: "public",
352
+ joinPolicy: "open",
353
+ postPolicy: "members",
354
+ memberCount: 1,
355
+ createdBy: actor.ap_id,
356
+ createdAt: now,
357
+ });
358
+
359
+ const ownerMemberInsert = db.insert(communityMembers).values({
360
+ communityApId: apId,
361
+ actorApId: actor.ap_id,
362
+ role: "owner",
363
+ joinedAt: now,
364
+ });
365
+
366
+ await (db as unknown as Batchable).batch([
367
+ communityInsert,
368
+ ownerMemberInsert,
369
+ ]);
370
+ } catch (error) {
371
+ if (isUniqueConstraintError(error)) {
372
+ return c.json({ error: "Community name already taken" }, 409);
373
+ }
374
+ throw error;
375
+ }
376
+
377
+ return c.json(
378
+ {
379
+ community: {
380
+ ap_id: apId,
381
+ name: body.name,
382
+ display_name: body.display_name || body.name,
383
+ summary: body.summary || "",
384
+ icon_url: null,
385
+ visibility: "public",
386
+ join_policy: "open",
387
+ post_policy: "members",
388
+ member_count: 1,
389
+ created_at: now,
390
+ is_member: true,
391
+ },
392
+ },
393
+ 201,
394
+ );
395
+ });
396
+
397
+ // GET /api/communities/:name - Get community by name or ap_id
398
+ communitiesRouter.get("/:identifier", async (c) => {
399
+ const identifier = c.req.param("identifier");
400
+ const actor = c.get("actor");
401
+ const db = c.get("db");
402
+ const baseUrl = c.env.APP_URL;
403
+
404
+ const apId = identifier.startsWith("http")
405
+ ? identifier
406
+ : communityApId(baseUrl, identifier);
407
+
408
+ const community = await db
409
+ .select()
410
+ .from(communities)
411
+ .where(
412
+ or(
413
+ eq(communities.apId, apId),
414
+ eq(communities.preferredUsername, identifier),
415
+ ),
416
+ )
417
+ .get();
418
+
419
+ if (!community || community.deletedAt) {
420
+ return c.json({ error: "Community not found" }, 404);
421
+ }
422
+
423
+ // Check membership and join status
424
+ let isMember = false;
425
+ let memberRole: string | null = null;
426
+ let joinStatus: string | null = null;
427
+
428
+ if (actor) {
429
+ const membership = await db
430
+ .select()
431
+ .from(communityMembers)
432
+ .where(memberWhere(community.apId, actor.ap_id))
433
+ .get();
434
+ if (membership) {
435
+ isMember = true;
436
+ memberRole = membership.role;
437
+ } else {
438
+ const joinRequest = await db
439
+ .select()
440
+ .from(communityJoinRequests)
441
+ .where(
442
+ and(
443
+ eq(communityJoinRequests.communityApId, community.apId),
444
+ eq(communityJoinRequests.actorApId, actor.ap_id),
445
+ ),
446
+ )
447
+ .get();
448
+ if (joinRequest?.status === "pending") {
449
+ joinStatus = "pending";
450
+ }
451
+ }
452
+ }
453
+
454
+ // A private community reveals only its IDENTITY (name/display/icon/policy) to a
455
+ // non-member — enough to render the "private, join with an invite" page — but
456
+ // never its size / activity / summary / owner. This matches the discovery list,
457
+ // which hides private communities from non-members entirely; without it, anyone
458
+ // who guesses the name could read a private community's member/post counts and
459
+ // description by direct fetch.
460
+ const restricted =
461
+ communityRequiresMembership(community.visibility) && !isMember;
462
+
463
+ // member_count is the maintained `communities.memberCount` column (kept atomic
464
+ // by addMemberAtomic/removeMemberAtomic). post_count has no denorm column, so it
465
+ // is counted (indexed range via objects_comm_published_idx) — but only when the
466
+ // viewer is allowed to see it.
467
+ const postCount = restricted
468
+ ? 0
469
+ : (
470
+ await db
471
+ .select({ count: count() })
472
+ .from(objects)
473
+ .where(eq(objects.communityApId, community.apId))
474
+ .get()
475
+ )?.count || 0;
476
+
477
+ return c.json({
478
+ community: {
479
+ ap_id: community.apId,
480
+ name: community.preferredUsername,
481
+ display_name: community.name,
482
+ summary: restricted ? null : community.summary,
483
+ icon_url: community.iconUrl,
484
+ visibility: community.visibility,
485
+ join_policy: community.joinPolicy,
486
+ post_policy: community.postPolicy,
487
+ member_count: restricted ? 0 : community.memberCount || 0,
488
+ post_count: postCount,
489
+ created_by: restricted ? null : community.createdBy,
490
+ created_at: community.createdAt,
491
+ is_member: isMember,
492
+ member_role: memberRole,
493
+ join_status: joinStatus,
494
+ },
495
+ });
496
+ });
497
+
498
+ // PATCH /api/communities/:identifier/settings - Update community settings
499
+ communitiesRouter.patch("/:identifier/settings", async (c) => {
500
+ const actor = c.get("actor");
501
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
502
+
503
+ const identifier = c.req.param("identifier");
504
+ const db = c.get("db");
505
+
506
+ const { community } = await fetchCommunityId(c, identifier);
507
+ if (!community) {
508
+ return c.json({ error: "Community not found" }, 404);
509
+ }
510
+
511
+ const manager = await requireManager(db, community.apId, actor.ap_id);
512
+ if (!manager) {
513
+ return c.json({ error: "Forbidden" }, 403);
514
+ }
515
+
516
+ const body = await c.req.json<{
517
+ display_name?: string;
518
+ summary?: string;
519
+ icon_url?: string;
520
+ visibility?: "public" | "private";
521
+ join_policy?: "open" | "approval" | "invite";
522
+ post_policy?: "anyone" | "members" | "mods" | "owners";
523
+ }>();
524
+
525
+ const profileError = validateCommunityProfile(
526
+ body.display_name,
527
+ body.summary,
528
+ );
529
+ if (profileError) return c.json({ error: profileError }, 400);
530
+
531
+ const updates: Record<string, string | null> = {};
532
+
533
+ if (body.display_name !== undefined) {
534
+ updates.name = body.display_name;
535
+ }
536
+ if (body.summary !== undefined) {
537
+ updates.summary = body.summary;
538
+ }
539
+ if (body.icon_url !== undefined) {
540
+ if (
541
+ body.icon_url === null ||
542
+ (typeof body.icon_url === "string" && body.icon_url.trim().length === 0)
543
+ ) {
544
+ updates.iconUrl = null;
545
+ } else if (typeof body.icon_url !== "string") {
546
+ return c.json({ error: "Invalid icon_url" }, 400);
547
+ } else if (!isValidCommunityIconUrl(body.icon_url)) {
548
+ return c.json({ error: "Invalid icon_url scheme" }, 400);
549
+ } else if (
550
+ body.icon_url.trim().startsWith("/media/") &&
551
+ !(await isOwnLocalMediaUrl(db, body.icon_url.trim(), actor.ap_id))
552
+ ) {
553
+ // Reject pointing the icon at a /media blob the setter does not own
554
+ // (cross-user media IDOR — see isOwnLocalMediaUrl).
555
+ return c.json(
556
+ { error: "icon_url must reference your own uploaded media" },
557
+ 400,
558
+ );
559
+ } else {
560
+ updates.iconUrl = body.icon_url.trim();
561
+ }
562
+ }
563
+ if (body.visibility !== undefined) {
564
+ if (!["public", "private"].includes(body.visibility)) {
565
+ return c.json({ error: "Invalid visibility" }, 400);
566
+ }
567
+ updates.visibility = body.visibility;
568
+ }
569
+ if (body.join_policy !== undefined) {
570
+ if (!["open", "approval", "invite"].includes(body.join_policy)) {
571
+ return c.json({ error: "Invalid join_policy" }, 400);
572
+ }
573
+ updates.joinPolicy = body.join_policy;
574
+ }
575
+ if (body.post_policy !== undefined) {
576
+ if (!["anyone", "members", "mods", "owners"].includes(body.post_policy)) {
577
+ return c.json({ error: "Invalid post_policy" }, 400);
578
+ }
579
+ updates.postPolicy = body.post_policy;
580
+ }
581
+
582
+ if (Object.keys(updates).length === 0) {
583
+ return c.json({ error: "No fields to update" }, 400);
584
+ }
585
+
586
+ // Governance fields (visibility / join_policy / post_policy) change who can
587
+ // read, join, and post — flipping private→public exposes all member-only
588
+ // content + the full roster. Restrict these to the OWNER (a moderator may
589
+ // still edit the cosmetic name / summary / icon), mirroring role changes
590
+ // which are owner-only.
591
+ const changesGovernance =
592
+ updates.visibility !== undefined ||
593
+ updates.joinPolicy !== undefined ||
594
+ updates.postPolicy !== undefined;
595
+ if (changesGovernance && manager.role !== "owner") {
596
+ return c.json({ error: "Owner role required" }, 403);
597
+ }
598
+
599
+ // Capture the prior icon URL (if it is being replaced) so its now-orphaned
600
+ // /media blob can be reaped after the update — community icons attach to no
601
+ // object and no GC path otherwise reclaims a replaced one.
602
+ let priorIconUrl: string | null = null;
603
+ if (updates.iconUrl !== undefined) {
604
+ const current = await db
605
+ .select({ iconUrl: communities.iconUrl })
606
+ .from(communities)
607
+ .where(eq(communities.apId, community.apId))
608
+ .get();
609
+ priorIconUrl = current?.iconUrl ?? null;
610
+ }
611
+
612
+ await db
613
+ .update(communities)
614
+ .set(updates)
615
+ .where(eq(communities.apId, community.apId));
616
+
617
+ if (priorIconUrl && priorIconUrl !== updates.iconUrl) {
618
+ await reapReplacedMediaUrl(db, priorIconUrl, actor.ap_id, c.env.MEDIA);
619
+ }
620
+
621
+ return c.json({ success: true });
622
+ });
623
+
624
+ export default communitiesRouter;
@@ -0,0 +1,21 @@
1
+ import { Hono } from "hono";
2
+ import type { Env, Variables } from "../types.ts";
3
+ import baseRoutes from "./communities/routes.ts";
4
+ import { registerMembershipInviteRoutes } from "./communities/membership-invites.ts";
5
+ import { registerMembershipJoinRoutes } from "./communities/membership-join.ts";
6
+ import { registerMembershipMemberRoutes } from "./communities/membership-members.ts";
7
+ import { registerMembershipRequestRoutes } from "./communities/membership-requests.ts";
8
+ import messageRoutes from "./communities/messages.ts";
9
+
10
+ const communities = new Hono<{ Bindings: Env; Variables: Variables }>();
11
+
12
+ communities.route("/", baseRoutes);
13
+
14
+ registerMembershipJoinRoutes(communities);
15
+ registerMembershipRequestRoutes(communities);
16
+ registerMembershipInviteRoutes(communities);
17
+ registerMembershipMemberRoutes(communities);
18
+
19
+ communities.route("/", messageRoutes);
20
+
21
+ export default communities;