@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,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;
|