@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,872 @@
|
|
|
1
|
+
import { Hono, type Context } from "hono";
|
|
2
|
+
import { MOBILE_PUSH_REGISTRATION_PATH } from "./lib/mobile-contract.ts";
|
|
3
|
+
import type { Env, EnvVars, Variables } from "./types.ts";
|
|
4
|
+
import { extractActorFromSession } from "./lib/session-actor.ts";
|
|
5
|
+
import { isBackendPath } from "./lib/backend-paths.ts";
|
|
6
|
+
import { wrapCloudflareBindings } from "./runtime/cloudflare.ts";
|
|
7
|
+
import {
|
|
8
|
+
getOidcClientCredentials,
|
|
9
|
+
getOidcIssuerUrl,
|
|
10
|
+
} from "./lib/oauth-providers.ts";
|
|
11
|
+
|
|
12
|
+
import authRoutes from "./routes/auth.ts";
|
|
13
|
+
import actorsRoutes from "./routes/actors.ts";
|
|
14
|
+
import followRoutes from "./routes/follow.ts";
|
|
15
|
+
import timelineRoutes from "./routes/timeline.ts";
|
|
16
|
+
import postsRoutes from "./routes/posts.ts";
|
|
17
|
+
import notificationsRoutes from "./routes/notifications.ts";
|
|
18
|
+
import storiesRoutes from "./routes/stories.ts";
|
|
19
|
+
import searchRoutes from "./routes/search.ts";
|
|
20
|
+
import communitiesRoutes from "./routes/communities.ts";
|
|
21
|
+
import dmRoutes from "./routes/dm.ts";
|
|
22
|
+
import mediaRoutes from "./routes/media.ts";
|
|
23
|
+
import activitypubRoutes from "./routes/activitypub.ts";
|
|
24
|
+
import takosToolsRoutes from "./routes/takos-tools.ts";
|
|
25
|
+
import recommendationsRoutes from "./routes/recommendations.ts";
|
|
26
|
+
import { moderationRoutes } from "./routes/moderation.ts";
|
|
27
|
+
import { appsApiRoutes, appsServeRoutes } from "./routes/apps.ts";
|
|
28
|
+
import mobileRoutes from "./routes/mobile.ts";
|
|
29
|
+
|
|
30
|
+
import { rateLimit, RateLimitConfigs } from "./middleware/rate-limit.ts";
|
|
31
|
+
import { csrfProtection } from "./middleware/csrf.ts";
|
|
32
|
+
import { createErrorMiddleware } from "./middleware/error-handler.ts";
|
|
33
|
+
import {
|
|
34
|
+
bodyLimit,
|
|
35
|
+
DEFAULT_BODY_LIMIT_BYTES,
|
|
36
|
+
} from "./middleware/body-limit.ts";
|
|
37
|
+
import { logger } from "./lib/logger.ts";
|
|
38
|
+
|
|
39
|
+
const log = logger.child({ component: "backend.index" });
|
|
40
|
+
import type { MessageBatch } from "@cloudflare/workers-types";
|
|
41
|
+
import type {
|
|
42
|
+
DeliveryDlqMessageV1,
|
|
43
|
+
DeliveryQueueMessageV1,
|
|
44
|
+
} from "./lib/delivery/types.ts";
|
|
45
|
+
import {
|
|
46
|
+
handleDeliveryDlqBatch,
|
|
47
|
+
handleDeliveryQueueBatch,
|
|
48
|
+
} from "./lib/delivery/queue.ts";
|
|
49
|
+
|
|
50
|
+
type YurucommuApp = Hono<{ Bindings: Env; Variables: Variables }>;
|
|
51
|
+
|
|
52
|
+
export const YURUCOMMU_BACKEND_PLUGIN_API_VERSION = 1 as const;
|
|
53
|
+
|
|
54
|
+
export interface BackendPluginContextV1 {
|
|
55
|
+
app: YurucommuApp;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface YurucommuBackendPluginV1 {
|
|
59
|
+
apiVersion: typeof YURUCOMMU_BACKEND_PLUGIN_API_VERSION;
|
|
60
|
+
name: string;
|
|
61
|
+
setup?: (ctx: BackendPluginContextV1) => void;
|
|
62
|
+
beforeRoutes?: (ctx: BackendPluginContextV1) => void;
|
|
63
|
+
afterRoutes?: (ctx: BackendPluginContextV1) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CreateYurucommuBackendAppOptionsV1 {
|
|
67
|
+
plugins?: YurucommuBackendPluginV1[];
|
|
68
|
+
discovery?: YurucommuBackendDiscoveryOptionsV1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface YurucommuBackendDiscoveryClientV1 {
|
|
72
|
+
id: string;
|
|
73
|
+
name: string;
|
|
74
|
+
defaultEntry: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface YurucommuBackendDiscoveryOptionsV1 {
|
|
78
|
+
product?: string;
|
|
79
|
+
name?: string;
|
|
80
|
+
serverId?: string;
|
|
81
|
+
serverName?: string;
|
|
82
|
+
clients?: YurucommuBackendDiscoveryClientV1[];
|
|
83
|
+
capabilities?: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const DEFAULT_DISCOVERY_OPTIONS = {
|
|
87
|
+
product: "yurucommu",
|
|
88
|
+
name: "Yurucommu",
|
|
89
|
+
serverId: "yurucommu-server",
|
|
90
|
+
serverName: "Yurucommu Server",
|
|
91
|
+
clients: [
|
|
92
|
+
{
|
|
93
|
+
id: "yurucommu",
|
|
94
|
+
name: "Yurucommu",
|
|
95
|
+
defaultEntry: "feed",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: "yurume",
|
|
99
|
+
name: "Yurumeet",
|
|
100
|
+
defaultEntry: "messages",
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
capabilities: [
|
|
104
|
+
"api.social.v1",
|
|
105
|
+
"activitypub.server.v1",
|
|
106
|
+
"client.yurucommu.feed.v1",
|
|
107
|
+
"client.yurume.messages.v1",
|
|
108
|
+
],
|
|
109
|
+
} satisfies Required<YurucommuBackendDiscoveryOptionsV1>;
|
|
110
|
+
|
|
111
|
+
const MIME_TYPES: Record<string, string> = {
|
|
112
|
+
".html": "text/html; charset=utf-8",
|
|
113
|
+
".css": "text/css; charset=utf-8",
|
|
114
|
+
".js": "application/javascript; charset=utf-8",
|
|
115
|
+
".json": "application/json; charset=utf-8",
|
|
116
|
+
".png": "image/png",
|
|
117
|
+
".jpg": "image/jpeg",
|
|
118
|
+
".jpeg": "image/jpeg",
|
|
119
|
+
".gif": "image/gif",
|
|
120
|
+
".svg": "image/svg+xml",
|
|
121
|
+
".ico": "image/x-icon",
|
|
122
|
+
".woff": "font/woff",
|
|
123
|
+
".woff2": "font/woff2",
|
|
124
|
+
".ttf": "font/ttf",
|
|
125
|
+
".eot": "application/vnd.ms-fontobject",
|
|
126
|
+
".webp": "image/webp",
|
|
127
|
+
".wasm": "application/wasm",
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
function getMimeType(path: string): string {
|
|
131
|
+
const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
|
|
132
|
+
return MIME_TYPES[ext] || "application/octet-stream";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Hard runtime preconditions: without these the worker cannot serve a request
|
|
136
|
+
// correctly, so /readyz must 503 when any is missing. A correctly-provisioned
|
|
137
|
+
// fresh install (Capsule supplies APP_URL + ENCRYPTION_KEY + an auth method,
|
|
138
|
+
// platform supplies DB + KV) satisfies all of these and is therefore ready.
|
|
139
|
+
function collectMissingRequiredBindings(env: Env): string[] {
|
|
140
|
+
const missing: string[] = [];
|
|
141
|
+
const hasValue = (value: string | undefined): boolean => !!value?.trim();
|
|
142
|
+
if (!env.DB_INSTANCE) missing.push("DB");
|
|
143
|
+
if (!env.KV) missing.push("KV");
|
|
144
|
+
if (!hasValue(env.APP_URL)) missing.push("APP_URL");
|
|
145
|
+
if (!hasValue(env.ENCRYPTION_KEY)) missing.push("ENCRYPTION_KEY");
|
|
146
|
+
const hasPassword = hasValue(env.AUTH_PASSWORD_HASH);
|
|
147
|
+
const hasGoogle =
|
|
148
|
+
hasValue(env.GOOGLE_CLIENT_ID) && hasValue(env.GOOGLE_CLIENT_SECRET);
|
|
149
|
+
const hasX = hasValue(env.X_CLIENT_ID) && hasValue(env.X_CLIENT_SECRET);
|
|
150
|
+
const oidcCredentials = getOidcClientCredentials(env);
|
|
151
|
+
// The client SECRET is optional — Takosumi materializes a PUBLIC (PKCE-only,
|
|
152
|
+
// no-secret) OIDC client for auto-provisioned Capsules. issuer + client_id is
|
|
153
|
+
// a usable auth method (mirrors getAuthConfig's provider gate).
|
|
154
|
+
const hasAccountsOidc =
|
|
155
|
+
hasValue(getOidcIssuerUrl(env) ?? undefined) &&
|
|
156
|
+
hasValue(oidcCredentials.clientId);
|
|
157
|
+
if (!hasPassword && !hasGoogle && !hasX && !hasAccountsOidc) {
|
|
158
|
+
missing.push("AUTH_METHOD");
|
|
159
|
+
}
|
|
160
|
+
return missing;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Optional capabilities the worker can run WITHOUT and still be "ready":
|
|
164
|
+
// - MEDIA: media storage; uploads are unavailable but the rest of the app
|
|
165
|
+
// (timeline, posts, federation reads) serves normally. MEDIA is optional in
|
|
166
|
+
// the Env type and may be replaced by a STORAGE-backed asset path.
|
|
167
|
+
// - DELIVERY_QUEUE / DELIVERY_DLQ: outbound federation delivery. When unbound,
|
|
168
|
+
// enqueued activities are buffered/persisted and re-fire once the bindings
|
|
169
|
+
// appear (see lib/delivery/queue.ts); local dev only attaches them behind
|
|
170
|
+
// YURUCOMMU_ENABLE_LOCAL_DELIVERY_QUEUE. Treating these as hard-required
|
|
171
|
+
// would 503 a perfectly serviceable install, so they are reported as
|
|
172
|
+
// degraded info on /healthz but do not fail /readyz.
|
|
173
|
+
function collectMissingOptionalBindings(env: Env): string[] {
|
|
174
|
+
const missing: string[] = [];
|
|
175
|
+
if (!env.MEDIA) missing.push("MEDIA");
|
|
176
|
+
if (!env.DELIVERY_QUEUE) missing.push("DELIVERY_QUEUE");
|
|
177
|
+
if (!env.DELIVERY_DLQ) missing.push("DELIVERY_DLQ");
|
|
178
|
+
return missing;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Full binding report in the original declaration order (DB, MEDIA, KV,
|
|
182
|
+
// DELIVERY_QUEUE, DELIVERY_DLQ, APP_URL, ENCRYPTION_KEY, AUTH_METHOD). Used by
|
|
183
|
+
// /healthz and surfaced in /readyz's missingBindings for visibility; /readyz's
|
|
184
|
+
// status/code is driven by the required subset only (see collectMissingRequiredBindings).
|
|
185
|
+
function collectMissingRuntimeBindings(env: Env): string[] {
|
|
186
|
+
const missing: string[] = [];
|
|
187
|
+
const hasValue = (value: string | undefined): boolean => !!value?.trim();
|
|
188
|
+
if (!env.DB_INSTANCE) missing.push("DB");
|
|
189
|
+
if (!env.MEDIA) missing.push("MEDIA");
|
|
190
|
+
if (!env.KV) missing.push("KV");
|
|
191
|
+
if (!env.DELIVERY_QUEUE) missing.push("DELIVERY_QUEUE");
|
|
192
|
+
if (!env.DELIVERY_DLQ) missing.push("DELIVERY_DLQ");
|
|
193
|
+
if (!hasValue(env.APP_URL)) missing.push("APP_URL");
|
|
194
|
+
if (!hasValue(env.ENCRYPTION_KEY)) missing.push("ENCRYPTION_KEY");
|
|
195
|
+
const hasPassword = hasValue(env.AUTH_PASSWORD_HASH);
|
|
196
|
+
const hasGoogle =
|
|
197
|
+
hasValue(env.GOOGLE_CLIENT_ID) && hasValue(env.GOOGLE_CLIENT_SECRET);
|
|
198
|
+
const hasX = hasValue(env.X_CLIENT_ID) && hasValue(env.X_CLIENT_SECRET);
|
|
199
|
+
const oidcCredentials = getOidcClientCredentials(env);
|
|
200
|
+
// The client SECRET is optional — Takosumi materializes a PUBLIC (PKCE-only,
|
|
201
|
+
// no-secret) OIDC client for auto-provisioned Capsules. issuer + client_id is
|
|
202
|
+
// a usable auth method (mirrors getAuthConfig's provider gate).
|
|
203
|
+
const hasAccountsOidc =
|
|
204
|
+
hasValue(getOidcIssuerUrl(env) ?? undefined) &&
|
|
205
|
+
hasValue(oidcCredentials.clientId);
|
|
206
|
+
if (!hasPassword && !hasGoogle && !hasX && !hasAccountsOidc) {
|
|
207
|
+
missing.push("AUTH_METHOD");
|
|
208
|
+
}
|
|
209
|
+
return missing;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function buildSocialServerDiscovery(
|
|
213
|
+
appUrl: string,
|
|
214
|
+
issuer: string,
|
|
215
|
+
options: YurucommuBackendDiscoveryOptionsV1 = {},
|
|
216
|
+
) {
|
|
217
|
+
const discovery = {
|
|
218
|
+
...DEFAULT_DISCOVERY_OPTIONS,
|
|
219
|
+
...options,
|
|
220
|
+
clients: options.clients ?? DEFAULT_DISCOVERY_OPTIONS.clients,
|
|
221
|
+
capabilities: options.capabilities ?? DEFAULT_DISCOVERY_OPTIONS.capabilities,
|
|
222
|
+
};
|
|
223
|
+
return {
|
|
224
|
+
product: discovery.product,
|
|
225
|
+
name: discovery.name,
|
|
226
|
+
server: {
|
|
227
|
+
id: discovery.serverId,
|
|
228
|
+
name: discovery.serverName,
|
|
229
|
+
canonicalOrigin: appUrl,
|
|
230
|
+
activitypubOrigin: appUrl,
|
|
231
|
+
},
|
|
232
|
+
clients: discovery.clients,
|
|
233
|
+
issuer,
|
|
234
|
+
apiBaseUrl: appUrl,
|
|
235
|
+
activitypubOrigin: appUrl,
|
|
236
|
+
mediaOrigin: `${appUrl}/media`,
|
|
237
|
+
socialServerCapabilitiesUrl: `${appUrl}/.well-known/social-server`,
|
|
238
|
+
capabilities: discovery.capabilities,
|
|
239
|
+
endpoints: {
|
|
240
|
+
api: `${appUrl}/api`,
|
|
241
|
+
authProviders: `${appUrl}/api/auth/providers`,
|
|
242
|
+
currentUser: `${appUrl}/api/auth/me`,
|
|
243
|
+
timeline: `${appUrl}/api/timeline`,
|
|
244
|
+
conversations: `${appUrl}/api/dm/contacts`,
|
|
245
|
+
notifications: `${appUrl}/api/notifications`,
|
|
246
|
+
mobilePushRegistrations: `${appUrl}${MOBILE_PUSH_REGISTRATION_PATH}`,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isStrictReadinessEnabled(env: Env): boolean {
|
|
252
|
+
const value = env.YURUCOMMU_STRICT_READINESS?.trim().toLowerCase();
|
|
253
|
+
return value === "1" || value === "true" || value === "yes";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function mountReadinessRoutes(
|
|
257
|
+
app: YurucommuApp,
|
|
258
|
+
discovery: YurucommuBackendDiscoveryOptionsV1 = {},
|
|
259
|
+
): void {
|
|
260
|
+
app.get("/healthz", (c) => {
|
|
261
|
+
// /healthz reports the full picture (required + optional capabilities) so
|
|
262
|
+
// operators can see degraded-but-serving states. The 503 gate stays opt-in
|
|
263
|
+
// via YURUCOMMU_STRICT_READINESS for a hard all-or-nothing health check.
|
|
264
|
+
const missing = collectMissingRuntimeBindings(c.env);
|
|
265
|
+
const strict = isStrictReadinessEnabled(c.env);
|
|
266
|
+
return c.json(
|
|
267
|
+
{
|
|
268
|
+
status:
|
|
269
|
+
missing.length === 0 ? "ok" : strict ? "misconfigured" : "degraded",
|
|
270
|
+
service: "yurucommu",
|
|
271
|
+
missingBindings: missing,
|
|
272
|
+
},
|
|
273
|
+
strict && missing.length > 0 ? 503 : 200,
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
app.get("/readyz", (c) => {
|
|
278
|
+
// Readiness keys off the HARD preconditions only. Optional capabilities
|
|
279
|
+
// (MEDIA, DELIVERY_QUEUE, DELIVERY_DLQ) are still reported in
|
|
280
|
+
// missingBindings for visibility, but their absence does NOT flip the
|
|
281
|
+
// worker to not-ready: a correctly-provisioned fresh install with
|
|
282
|
+
// APP_URL + ENCRYPTION_KEY + an auth method (and DB + KV) is ready even
|
|
283
|
+
// before media storage / federation delivery queues are bound. The status
|
|
284
|
+
// and HTTP code are therefore driven solely by the required set, while
|
|
285
|
+
// missingBindings still surfaces every gap.
|
|
286
|
+
const missingRequired = collectMissingRequiredBindings(c.env);
|
|
287
|
+
const missing = collectMissingRuntimeBindings(c.env);
|
|
288
|
+
return c.json(
|
|
289
|
+
{
|
|
290
|
+
status: missingRequired.length === 0 ? "ok" : "misconfigured",
|
|
291
|
+
service: "yurucommu",
|
|
292
|
+
missingBindings: missing,
|
|
293
|
+
},
|
|
294
|
+
missingRequired.length === 0 ? 200 : 503,
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Operator-sensible crawler defaults. Allow the public landing / actor
|
|
299
|
+
// profile HTML to be indexed, but keep the JSON API and the machine-only
|
|
300
|
+
// ActivityPub federation surface out of search crawlers. Mounted alongside
|
|
301
|
+
// the readiness probes so they answer BEFORE body-size / payload validation
|
|
302
|
+
// middleware and stay reachable in any runtime mode.
|
|
303
|
+
app.get("/robots.txt", (c) => {
|
|
304
|
+
const body = [
|
|
305
|
+
"User-agent: *",
|
|
306
|
+
"Disallow: /api/",
|
|
307
|
+
"Disallow: /ap/",
|
|
308
|
+
"Disallow: /.takos/",
|
|
309
|
+
"Disallow: /hosted/",
|
|
310
|
+
"Disallow: /media/",
|
|
311
|
+
"Allow: /",
|
|
312
|
+
"",
|
|
313
|
+
].join("\n");
|
|
314
|
+
return c.body(body, 200, {
|
|
315
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
316
|
+
"Cache-Control": "public, max-age=3600",
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const wellKnownSocialServer = (
|
|
321
|
+
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
322
|
+
) => {
|
|
323
|
+
const appUrl = normalizeOrigin(c.env.APP_URL, c.req.url);
|
|
324
|
+
const issuer = getOidcIssuerUrl(c.env) ?? appUrl;
|
|
325
|
+
return c.json(buildSocialServerDiscovery(appUrl, issuer, discovery), 200, {
|
|
326
|
+
"Cache-Control": "public, max-age=300",
|
|
327
|
+
});
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
app.get("/.well-known/yurucommu", wellKnownSocialServer);
|
|
331
|
+
app.get("/.well-known/social-server", wellKnownSocialServer);
|
|
332
|
+
|
|
333
|
+
// Minimal RFC 9116 security.txt. The contact points operators at the
|
|
334
|
+
// instance admin; APP_URL (when configured) makes the policy line concrete.
|
|
335
|
+
app.get("/.well-known/security.txt", (c) => {
|
|
336
|
+
// RFC 9116: `Contact` and `Expires` are REQUIRED. The previous file used a
|
|
337
|
+
// placeholder `mailto:security@yurucommu.invalid` (the .invalid TLD is
|
|
338
|
+
// non-routable, so vulnerabilities could not actually be reported), pointed
|
|
339
|
+
// `Policy` circularly at this very file, and omitted `Expires` entirely —
|
|
340
|
+
// making the document non-compliant (scanners reject it). Default `Contact`
|
|
341
|
+
// to the upstream project's working security-advisory channel, overridable
|
|
342
|
+
// by an operator via the SECURITY_CONTACT env (their own mailto:/https
|
|
343
|
+
// report path), and always emit a future `Expires`.
|
|
344
|
+
const contact =
|
|
345
|
+
(c.env as { SECURITY_CONTACT?: string }).SECURITY_CONTACT?.trim() ||
|
|
346
|
+
"https://github.com/tako0614/yurucommu/security/advisories/new";
|
|
347
|
+
// Kept ~1 year out (recomputed per request, cached 1h) — well within the
|
|
348
|
+
// RFC's "less than a year" recommendation and never stale.
|
|
349
|
+
const expires = new Date(
|
|
350
|
+
Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
351
|
+
).toISOString();
|
|
352
|
+
const lines = [
|
|
353
|
+
`Contact: ${contact}`,
|
|
354
|
+
`Expires: ${expires}`,
|
|
355
|
+
"Policy: https://github.com/tako0614/yurucommu/security/policy",
|
|
356
|
+
"Preferred-Languages: en, ja",
|
|
357
|
+
"",
|
|
358
|
+
];
|
|
359
|
+
return c.body(lines.join("\n"), 200, {
|
|
360
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
361
|
+
"Cache-Control": "public, max-age=3600",
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function normalizeOrigin(
|
|
367
|
+
appUrl: string | undefined,
|
|
368
|
+
requestUrl: string,
|
|
369
|
+
): string {
|
|
370
|
+
const raw = appUrl?.trim() || new URL(requestUrl).origin;
|
|
371
|
+
const url = new URL(raw);
|
|
372
|
+
url.pathname = "";
|
|
373
|
+
url.search = "";
|
|
374
|
+
url.hash = "";
|
|
375
|
+
return url.toString().replace(/\/+$/g, "");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// media.ts advertises MAX_VIDEO_SIZE = 40MB (and MAX_IMAGE_SIZE = 20MB) and
|
|
379
|
+
// returns a friendly 413 citing those numbers. The pre-route body cap MUST sit
|
|
380
|
+
// at or above the largest advertised media size, otherwise an upload between
|
|
381
|
+
// the body cap and the advertised limit is rejected by the cap FIRST with a
|
|
382
|
+
// generic error, making the advertised limit unreachable and the friendly 413
|
|
383
|
+
// dead. media.ts buffers the whole file in Worker memory (formData() +
|
|
384
|
+
// arrayBuffer(), ~2x the file size), so the cap is also the memory ceiling:
|
|
385
|
+
// it is sized to just cover MAX_VIDEO_SIZE = 40MB (40 * 1024 * 1024) plus
|
|
386
|
+
// multipart/form-data envelope overhead (= 48 MiB), keeping peak buffering
|
|
387
|
+
// well under the Workers ~128MB per-request memory budget while leaving the
|
|
388
|
+
// friendly per-size 413 in media.ts reachable.
|
|
389
|
+
const MEDIA_UPLOAD_BODY_LIMIT_BYTES = 48 * 1024 * 1024; // 48 MiB
|
|
390
|
+
const INBOX_BODY_LIMIT_BYTES = 512 * 1024; // 512 KiB
|
|
391
|
+
|
|
392
|
+
// Every ActivityPub inbox handler (inbox.ts): the shared inbox `/ap/inbox`, the
|
|
393
|
+
// per-actor singleton `/ap/actor/inbox`, and the two-segment per-recipient
|
|
394
|
+
// inboxes `/ap/users/:username/inbox` + `/ap/groups/:name/inbox`. Hono's single
|
|
395
|
+
// `*` matches EXACTLY ONE path segment, so `/ap/*/inbox` covers `/ap/actor/inbox`
|
|
396
|
+
// but NOT the two-segment user/group inboxes — those must be listed explicitly,
|
|
397
|
+
// or they silently fall through to the lax global default cap + miss the per-IP
|
|
398
|
+
// inbox rate limit. Every inbox is unauthenticated and verifies an HTTP
|
|
399
|
+
// signature + touches the DB *before* any throttle runs, so each one needs BOTH
|
|
400
|
+
// the strict pre-auth body cap AND the dedicated per-IP `inbox` rate limit; this
|
|
401
|
+
// single list is the source of truth for both (applyBodyLimits +
|
|
402
|
+
// applyGlobalMiddleware) so they can never drift out of coverage.
|
|
403
|
+
const INBOX_PATH_PATTERNS = [
|
|
404
|
+
"/ap/inbox",
|
|
405
|
+
"/ap/*/inbox",
|
|
406
|
+
"/ap/users/*/inbox",
|
|
407
|
+
"/ap/groups/*/inbox",
|
|
408
|
+
] as const;
|
|
409
|
+
|
|
410
|
+
function applyBodyLimits(app: YurucommuApp): void {
|
|
411
|
+
// Per-route caps are registered BEFORE the global default cap. The stricter
|
|
412
|
+
// inbox cap (512 KiB) wins for /ap/*/inbox; the LARGER media-upload cap
|
|
413
|
+
// (48 MiB) wins for /api/media/*. ActivityPub inbox is unauthenticated and
|
|
414
|
+
// federation peers can hammer it, so its cap matches the rate-limit
|
|
415
|
+
// assumption (small JSON activities).
|
|
416
|
+
//
|
|
417
|
+
// IMPORTANT: Hono runs every matching `use()` middleware in registration
|
|
418
|
+
// order, and bodyLimit always calls next() when the request is within its
|
|
419
|
+
// own cap. That means the trailing default `*` cap below ALSO runs on
|
|
420
|
+
// /api/media/* and /ap/*/inbox. For the inbox routes that is harmless — the
|
|
421
|
+
// stricter 512 KiB cap already rejected anything the 1 MiB default would,
|
|
422
|
+
// and a body that passed 512 KiB trivially passes 1 MiB. For media it is
|
|
423
|
+
// NOT harmless: a 30 MB upload that passes the 48 MiB media cap would then
|
|
424
|
+
// be rejected by the 1 MiB default cap with a generic `body_too_large`,
|
|
425
|
+
// making the friendly per-size 413 in routes/media.ts (which advertises
|
|
426
|
+
// MAX_VIDEO_SIZE = 40MB / MAX_IMAGE_SIZE = 20MB) unreachable. So the default
|
|
427
|
+
// cap is registered with a path guard that SKIPS the media prefix, leaving
|
|
428
|
+
// /api/media/* governed solely by its own 48 MiB cap.
|
|
429
|
+
//
|
|
430
|
+
// The inbox cap runs pre-auth, so a chunked body with no `Content-Length`
|
|
431
|
+
// could otherwise bypass the declared-length check entirely. We require
|
|
432
|
+
// `Content-Length` there and reject with 411 when it is missing — a
|
|
433
|
+
// conformant ActivityPub delivery always sets it, so this only refuses
|
|
434
|
+
// chunked-only senders (which are the DoS vector). Legitimate authenticated
|
|
435
|
+
// upload routes keep the default streaming cap instead so chunked uploads
|
|
436
|
+
// are not broken.
|
|
437
|
+
// Apply the strict pre-auth body cap to EVERY inbox route (shared, per-actor,
|
|
438
|
+
// and the two-segment user/group inboxes). See INBOX_PATH_PATTERNS: a single
|
|
439
|
+
// `*` only matches one segment, so the user/group inboxes must be listed
|
|
440
|
+
// explicitly or they fall through to the lax 1 MiB default cap (which also
|
|
441
|
+
// omits requireContentLength).
|
|
442
|
+
for (const pattern of INBOX_PATH_PATTERNS) {
|
|
443
|
+
app.use(
|
|
444
|
+
pattern,
|
|
445
|
+
bodyLimit({
|
|
446
|
+
maxBytes: INBOX_BODY_LIMIT_BYTES,
|
|
447
|
+
requireContentLength: true,
|
|
448
|
+
}),
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
// Media uploads carry binary payloads (images, short videos). The cap covers
|
|
452
|
+
// the largest advertised media size so routes/media.ts owns the friendly,
|
|
453
|
+
// per-size 413; see the MEDIA_UPLOAD_BODY_LIMIT_BYTES note above.
|
|
454
|
+
// mediaRoutes is mounted at BOTH /api/media and the bare /media prefix
|
|
455
|
+
// (the latter is the public serve path used by AP/HTML), but media.ts also
|
|
456
|
+
// registers POST /upload. That means /media/upload exists too and MUST get
|
|
457
|
+
// the same large media cap — otherwise an advertised-size upload posted to
|
|
458
|
+
// /media/upload would be rejected by the 1 MiB default cap with a generic
|
|
459
|
+
// 413, a dead path. Apply the identical 48 MiB cap to /media/* so the cap
|
|
460
|
+
// is consistent across both mounts.
|
|
461
|
+
for (const prefix of ["/api/media/*", "/media/*"]) {
|
|
462
|
+
app.use(prefix, bodyLimit({ maxBytes: MEDIA_UPLOAD_BODY_LIMIT_BYTES }));
|
|
463
|
+
}
|
|
464
|
+
// Default global cap: 1 MiB covers JSON-shaped API traffic. It must NOT also
|
|
465
|
+
// clamp the media prefixes (whose intended cap is larger), so guard both.
|
|
466
|
+
const defaultCap = bodyLimit({ maxBytes: DEFAULT_BODY_LIMIT_BYTES });
|
|
467
|
+
app.use("*", async (c, next) => {
|
|
468
|
+
const path = c.req.path;
|
|
469
|
+
if (path.startsWith("/api/media/") || path.startsWith("/media/")) {
|
|
470
|
+
return next();
|
|
471
|
+
}
|
|
472
|
+
return defaultCap(c, next);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function applyGlobalMiddleware(app: YurucommuApp): void {
|
|
477
|
+
app.onError(createErrorMiddleware());
|
|
478
|
+
|
|
479
|
+
app.use("*", async (c, next) => {
|
|
480
|
+
await next();
|
|
481
|
+
|
|
482
|
+
const preserveRouteSecurityHeaders = c.req.path.startsWith("/hosted/");
|
|
483
|
+
const setSecurityHeader = (name: string, value: string) => {
|
|
484
|
+
if (preserveRouteSecurityHeaders && c.res.headers.has(name)) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
c.header(name, value);
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
setSecurityHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
491
|
+
setSecurityHeader("Cross-Origin-Embedder-Policy", "credentialless");
|
|
492
|
+
|
|
493
|
+
const takosUrl = c.env.TAKOS_URL?.trim();
|
|
494
|
+
const oidcIssuer = getOidcIssuerUrl(c.env);
|
|
495
|
+
// unpkg.com is only used by the official client to fetch @ffmpeg/core
|
|
496
|
+
// assets (JS + WASM). The fetched body is wrapped in a blob: URL via
|
|
497
|
+
// toBlobURL before being imported, so script-src does NOT need unpkg —
|
|
498
|
+
// only connect-src (for fetch) and blob: in script-src (for the wrapped
|
|
499
|
+
// worker script). Pinning unpkg in script-src would make any compromised
|
|
500
|
+
// npm package directly executable on this origin.
|
|
501
|
+
const connectSrc = ["'self'", "https://unpkg.com", "wss:"];
|
|
502
|
+
const formAction = ["'self'"];
|
|
503
|
+
if (takosUrl) {
|
|
504
|
+
connectSrc.push(takosUrl);
|
|
505
|
+
formAction.push(takosUrl);
|
|
506
|
+
}
|
|
507
|
+
if (oidcIssuer) {
|
|
508
|
+
connectSrc.push(oidcIssuer);
|
|
509
|
+
formAction.push(oidcIssuer);
|
|
510
|
+
}
|
|
511
|
+
const csp = [
|
|
512
|
+
"default-src 'self'",
|
|
513
|
+
"script-src 'self' blob: https://static.cloudflareinsights.com",
|
|
514
|
+
"style-src 'self' 'unsafe-inline'",
|
|
515
|
+
"img-src 'self' data: blob: https:",
|
|
516
|
+
"media-src 'self' data: blob:",
|
|
517
|
+
"font-src 'self' data:",
|
|
518
|
+
`connect-src ${connectSrc.join(" ")}`,
|
|
519
|
+
"worker-src 'self' blob:",
|
|
520
|
+
"frame-ancestors 'none'",
|
|
521
|
+
`form-action ${formAction.join(" ")}`,
|
|
522
|
+
"base-uri 'self'",
|
|
523
|
+
].join("; ");
|
|
524
|
+
setSecurityHeader("Content-Security-Policy", csp);
|
|
525
|
+
|
|
526
|
+
setSecurityHeader("X-Content-Type-Options", "nosniff");
|
|
527
|
+
setSecurityHeader("X-Frame-Options", "DENY");
|
|
528
|
+
setSecurityHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
529
|
+
setSecurityHeader(
|
|
530
|
+
"Permissions-Policy",
|
|
531
|
+
"camera=(), microphone=(), geolocation=()",
|
|
532
|
+
);
|
|
533
|
+
// HSTS: once a client has reached this host over HTTPS, keep it on HTTPS
|
|
534
|
+
// (defeats SSL-strip / downgrade). Sent unconditionally — browsers ignore it
|
|
535
|
+
// when delivered over plain HTTP, so it is harmless for an HTTP-only
|
|
536
|
+
// self-host, and correct behind a TLS-terminating proxy where the worker
|
|
537
|
+
// sees HTTP but the client is on HTTPS. Deliberately NO includeSubDomains /
|
|
538
|
+
// preload: this is a self-hostable app and must not force HTTPS onto sibling
|
|
539
|
+
// subdomains the operator may serve over HTTP.
|
|
540
|
+
setSecurityHeader("Strict-Transport-Security", "max-age=31536000");
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
app.use("*", async (c, next) => {
|
|
544
|
+
c.set("db", c.env.DB_INSTANCE);
|
|
545
|
+
await next();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
app.use("/api/*", async (c, next) => {
|
|
549
|
+
await extractActorFromSession(c);
|
|
550
|
+
await next();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// The bare /media/* serve path (the URL stored in every attachment + actor
|
|
554
|
+
// icon/header, embedded in AP docs and rendered by the SPA) also needs the
|
|
555
|
+
// session: media authorization gates non-public blobs (followers-only /
|
|
556
|
+
// direct / private-community story) and must recognize an authenticated
|
|
557
|
+
// in-app viewer. A federation peer or logged-out visitor sends no session
|
|
558
|
+
// cookie, so `extractActorFromSession` early-returns and they stay anonymous —
|
|
559
|
+
// seeing only public media, exactly as before. No CSRF (these are GET reads),
|
|
560
|
+
// and public media still returns a public/cacheable response.
|
|
561
|
+
app.use("/media/*", async (c, next) => {
|
|
562
|
+
await extractActorFromSession(c);
|
|
563
|
+
await next();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// Takos tools endpoints may be called from the browser (same-origin) and rely on
|
|
567
|
+
// the same session cookie auth as the rest of the API.
|
|
568
|
+
app.use("/.takos/tools/*", async (c, next) => {
|
|
569
|
+
await extractActorFromSession(c);
|
|
570
|
+
await next();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
app.use("/api/*", csrfProtection());
|
|
574
|
+
app.use("/.takos/tools/*", csrfProtection());
|
|
575
|
+
// mediaRoutes is double-mounted at /api/media AND the bare /media, and it
|
|
576
|
+
// registers POST /upload — so /media/upload is a session-cookie-authenticated
|
|
577
|
+
// state-changing write (R2 + DB) that, without this, would bypass the CSRF
|
|
578
|
+
// control its /api/media/upload alias enforces. csrfProtection only guards
|
|
579
|
+
// POST/PUT/DELETE/PATCH, so the public GET /media/* serve path is unaffected.
|
|
580
|
+
// (Inert today behind the SameSite=Strict session cookie, but this keeps the
|
|
581
|
+
// two upload aliases under the same Origin/Referer check regardless of any
|
|
582
|
+
// future cookie-policy change.)
|
|
583
|
+
app.use("/media/*", csrfProtection());
|
|
584
|
+
|
|
585
|
+
app.use("/api/*", rateLimit(RateLimitConfigs.general));
|
|
586
|
+
app.use("/.takos/tools/*", rateLimit(RateLimitConfigs.general));
|
|
587
|
+
app.use("/api/auth/*", rateLimit(RateLimitConfigs.auth));
|
|
588
|
+
app.use("/api/search/*", rateLimit(RateLimitConfigs.search));
|
|
589
|
+
// The remote resolver makes an attacker-controlled outbound fetch to an
|
|
590
|
+
// arbitrary host; throttle it far tighter than the general search budget,
|
|
591
|
+
// like the other federation-discovery endpoints.
|
|
592
|
+
app.use(
|
|
593
|
+
"/api/search/remote",
|
|
594
|
+
rateLimit(RateLimitConfigs.federationDiscovery),
|
|
595
|
+
);
|
|
596
|
+
app.use("/api/media/*", rateLimit(RateLimitConfigs.mediaUpload));
|
|
597
|
+
// The bare /media mount serves media and also exposes POST /media/upload;
|
|
598
|
+
// throttle it with the same media budget as /api/media/* for consistency.
|
|
599
|
+
app.use("/media/*", rateLimit(RateLimitConfigs.mediaUpload));
|
|
600
|
+
app.use("/api/dm/*", rateLimit(RateLimitConfigs.dm));
|
|
601
|
+
app.post("/api/posts", rateLimit(RateLimitConfigs.postCreate));
|
|
602
|
+
// Like/repost are federated WRITES (they sign + deliver activities to remote
|
|
603
|
+
// inboxes), so bound them at the write budget rather than the general read
|
|
604
|
+
// budget to limit mass-interaction delivery storms.
|
|
605
|
+
app.post("/api/posts/:id/like", rateLimit(RateLimitConfigs.postCreate));
|
|
606
|
+
app.post("/api/posts/:id/repost", rateLimit(RateLimitConfigs.postCreate));
|
|
607
|
+
// Community creation generates an RSA keypair + actor; bound it at the write
|
|
608
|
+
// budget rather than the general read budget.
|
|
609
|
+
app.post("/api/communities", rateLimit(RateLimitConfigs.postCreate));
|
|
610
|
+
// Follow / unfollow / accept / reject are federated WRITES (they sign + deliver
|
|
611
|
+
// Follow / Undo / Accept / Reject to remote inboxes), so bound them at the
|
|
612
|
+
// write budget like like/repost — a follow-toggle loop otherwise drives ~1000
|
|
613
|
+
// signed remote deliveries/min at the general read budget.
|
|
614
|
+
app.post("/api/follow", rateLimit(RateLimitConfigs.postCreate));
|
|
615
|
+
app.delete("/api/follow", rateLimit(RateLimitConfigs.postCreate));
|
|
616
|
+
app.post("/api/follow/accept", rateLimit(RateLimitConfigs.postCreate));
|
|
617
|
+
app.post("/api/follow/reject", rateLimit(RateLimitConfigs.postCreate));
|
|
618
|
+
// Apply the dedicated per-IP `inbox` budget (1k/min) to EVERY inbox route.
|
|
619
|
+
// The two-segment user/group inboxes are NOT matched by `/ap/*/inbox` and the
|
|
620
|
+
// user inbox would otherwise be throttled only by the much tighter 60/min
|
|
621
|
+
// `/ap/users/*` discovery limiter below (wrongly 429'ing legitimate inbound
|
|
622
|
+
// federation), while the group inbox would get NO per-IP throttle at all (an
|
|
623
|
+
// unauthenticated per-IP DoS forcing an unthrottled DB lookup + signature
|
|
624
|
+
// verify per request). See INBOX_PATH_PATTERNS.
|
|
625
|
+
for (const pattern of INBOX_PATH_PATTERNS) {
|
|
626
|
+
app.use(pattern, rateLimit(RateLimitConfigs.inbox));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Federation discovery endpoints are unauthenticated and can be probed by
|
|
630
|
+
// any remote actor. Throttle them per-IP to mitigate enumeration / DoS.
|
|
631
|
+
app.use(
|
|
632
|
+
"/.well-known/webfinger",
|
|
633
|
+
rateLimit(RateLimitConfigs.federationDiscovery),
|
|
634
|
+
);
|
|
635
|
+
app.use(
|
|
636
|
+
"/.well-known/nodeinfo",
|
|
637
|
+
rateLimit(RateLimitConfigs.federationDiscovery),
|
|
638
|
+
);
|
|
639
|
+
app.use("/nodeinfo/*", rateLimit(RateLimitConfigs.federationDiscovery));
|
|
640
|
+
// `/ap/users/*` covers actor docs, outbox, followers, etc. — but NOT the user
|
|
641
|
+
// inbox, which gets the dedicated 1k/min `inbox` budget above. Both limiters
|
|
642
|
+
// use separate buckets (distinct keyPrefix), so without this skip an inbox
|
|
643
|
+
// delivery would increment BOTH and stay capped at the stricter 60/min — the
|
|
644
|
+
// exact over-throttling the dedicated inbox budget exists to avoid.
|
|
645
|
+
const fedDiscoveryLimiter = rateLimit(RateLimitConfigs.federationDiscovery);
|
|
646
|
+
app.use("/ap/users/*", async (c, next) => {
|
|
647
|
+
if (c.req.path.endsWith("/inbox")) {
|
|
648
|
+
return next();
|
|
649
|
+
}
|
|
650
|
+
return fedDiscoveryLimiter(c, next);
|
|
651
|
+
});
|
|
652
|
+
app.use("/ap/objects/*", rateLimit(RateLimitConfigs.federationDiscovery));
|
|
653
|
+
app.use(
|
|
654
|
+
"/ap/users/*/outbox",
|
|
655
|
+
rateLimit(RateLimitConfigs.federationDiscovery),
|
|
656
|
+
);
|
|
657
|
+
// Parity for the structurally-identical Group + instance-actor collection
|
|
658
|
+
// endpoints (/ap/groups/:name/{outbox,followers,moderators}, /ap/actor/{outbox,
|
|
659
|
+
// followers}). Without this they were the only unauthenticated AP discovery
|
|
660
|
+
// GETs with NO per-IP throttle, while /ap/users/*/outbox was capped at 60/min.
|
|
661
|
+
// Skip the inbox sub-routes so the dedicated 1k/min inbox budget still governs
|
|
662
|
+
// them (the same skip the /ap/users/* limiter uses), avoiding double-counting.
|
|
663
|
+
app.use("/ap/groups/*", async (c, next) => {
|
|
664
|
+
if (c.req.path.endsWith("/inbox")) return next();
|
|
665
|
+
return fedDiscoveryLimiter(c, next);
|
|
666
|
+
});
|
|
667
|
+
app.use("/ap/actor/*", async (c, next) => {
|
|
668
|
+
if (c.req.path.endsWith("/inbox")) return next();
|
|
669
|
+
return fedDiscoveryLimiter(c, next);
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function mountCoreRoutes(app: YurucommuApp): void {
|
|
674
|
+
app.route("/api/auth", authRoutes);
|
|
675
|
+
app.route("/api/actors", actorsRoutes);
|
|
676
|
+
app.route("/api/follow", followRoutes);
|
|
677
|
+
app.route("/api/timeline", timelineRoutes);
|
|
678
|
+
app.route("/api/posts", postsRoutes);
|
|
679
|
+
|
|
680
|
+
app.get("/api/bookmarks", async (c) => {
|
|
681
|
+
const url = new URL(c.req.url);
|
|
682
|
+
url.pathname = "/api/posts/bookmarks";
|
|
683
|
+
const newReq = new Request(url.toString(), c.req.raw);
|
|
684
|
+
return app.fetch(newReq, c.env, c.executionCtx);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
app.route("/api/notifications", notificationsRoutes);
|
|
688
|
+
app.route("/api/mobile", mobileRoutes);
|
|
689
|
+
app.route("/api/stories", storiesRoutes);
|
|
690
|
+
app.route("/api/search", searchRoutes);
|
|
691
|
+
app.route("/api/communities", communitiesRoutes);
|
|
692
|
+
app.route("/api/dm", dmRoutes);
|
|
693
|
+
app.route("/api/media", mediaRoutes);
|
|
694
|
+
app.route("/media", mediaRoutes);
|
|
695
|
+
app.route("/.takos/tools", takosToolsRoutes);
|
|
696
|
+
app.route("/api/recommendations", recommendationsRoutes);
|
|
697
|
+
app.route("/api/moderation", moderationRoutes);
|
|
698
|
+
app.route("/api/apps", appsApiRoutes);
|
|
699
|
+
app.route("/hosted", appsServeRoutes);
|
|
700
|
+
app.route("/", activitypubRoutes);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function mountStaticFallback(app: YurucommuApp): void {
|
|
704
|
+
app.all("*", async (c) => {
|
|
705
|
+
// A request that reaches the static fallback under a backend route prefix
|
|
706
|
+
// means no API / AP / media route matched it — return a genuine JSON 404
|
|
707
|
+
// instead of the SPA HTML shell. Without this, the Cloudflare ASSETS binding
|
|
708
|
+
// (single-page-application mode) served index.html with a 200 for unmatched
|
|
709
|
+
// /api/* paths, so an API client (or our own fetch) got HTML 200 instead of
|
|
710
|
+
// a 404 — the Bun runtime already guarded this; share one source of truth.
|
|
711
|
+
if (isBackendPath(new URL(c.req.url).pathname)) {
|
|
712
|
+
return c.json({ error: "Not Found", code: "NOT_FOUND" }, 404);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (c.env.ASSETS) {
|
|
716
|
+
return c.env.ASSETS.fetch(c.req.raw);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const storage = (c.env as { STORAGE?: R2Bucket }).STORAGE;
|
|
720
|
+
if (storage) {
|
|
721
|
+
const url = new URL(c.req.url);
|
|
722
|
+
let assetPath = url.pathname;
|
|
723
|
+
|
|
724
|
+
if (assetPath === "/" || assetPath === "") {
|
|
725
|
+
assetPath = "/index.html";
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const r2Key = `_assets${assetPath}`;
|
|
729
|
+
|
|
730
|
+
try {
|
|
731
|
+
const object = await storage.get(r2Key);
|
|
732
|
+
if (object) {
|
|
733
|
+
const headers = new Headers();
|
|
734
|
+
headers.set("Content-Type", getMimeType(assetPath));
|
|
735
|
+
headers.set(
|
|
736
|
+
"Cache-Control",
|
|
737
|
+
assetPath.includes("/assets/")
|
|
738
|
+
? "public, max-age=31536000, immutable"
|
|
739
|
+
: "public, max-age=3600",
|
|
740
|
+
);
|
|
741
|
+
if (object.httpEtag) {
|
|
742
|
+
headers.set("ETag", object.httpEtag);
|
|
743
|
+
}
|
|
744
|
+
return new Response(object.body as unknown as BodyInit, { headers });
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (!assetPath.includes(".")) {
|
|
748
|
+
const indexObject = await storage.get("_assets/index.html");
|
|
749
|
+
if (indexObject) {
|
|
750
|
+
const headers = new Headers();
|
|
751
|
+
headers.set("Content-Type", "text/html; charset=utf-8");
|
|
752
|
+
headers.set("Cache-Control", "no-cache");
|
|
753
|
+
return new Response(indexObject.body as unknown as BodyInit, {
|
|
754
|
+
headers,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
} catch (err) {
|
|
759
|
+
log.error("Failed to serve asset from R2", {
|
|
760
|
+
event: "assets.r2.serve_failed",
|
|
761
|
+
error: err,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return c.json(
|
|
767
|
+
{
|
|
768
|
+
error: "Static assets not configured",
|
|
769
|
+
message:
|
|
770
|
+
"This instance is running in API-only mode. Frontend assets are not available.",
|
|
771
|
+
hint: "Access /api/* endpoints for API functionality.",
|
|
772
|
+
},
|
|
773
|
+
503,
|
|
774
|
+
);
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export function createYurucommuBackendApp(
|
|
779
|
+
options: CreateYurucommuBackendAppOptionsV1 = {},
|
|
780
|
+
): YurucommuApp {
|
|
781
|
+
const app = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
782
|
+
const plugins = options.plugins ?? [];
|
|
783
|
+
const pluginContext: BackendPluginContextV1 = { app };
|
|
784
|
+
|
|
785
|
+
for (const plugin of plugins) {
|
|
786
|
+
if (plugin.apiVersion !== YURUCOMMU_BACKEND_PLUGIN_API_VERSION) {
|
|
787
|
+
throw new Error(
|
|
788
|
+
`[yurucommu] backend plugin "${plugin.name}" uses unsupported apiVersion=${plugin.apiVersion}. ` +
|
|
789
|
+
`Expected ${YURUCOMMU_BACKEND_PLUGIN_API_VERSION}.`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
mountReadinessRoutes(app, options.discovery);
|
|
795
|
+
// Body-size cap must run BEFORE any handler reads the body or executes
|
|
796
|
+
// expensive auth / rate-limit logic. Mounted after readiness probes so
|
|
797
|
+
// /healthz and /readyz stay reachable even when payload validation
|
|
798
|
+
// misbehaves.
|
|
799
|
+
applyBodyLimits(app);
|
|
800
|
+
applyGlobalMiddleware(app);
|
|
801
|
+
for (const plugin of plugins) {
|
|
802
|
+
plugin.setup?.(pluginContext);
|
|
803
|
+
}
|
|
804
|
+
for (const plugin of plugins) {
|
|
805
|
+
plugin.beforeRoutes?.(pluginContext);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
mountCoreRoutes(app);
|
|
809
|
+
for (const plugin of plugins) {
|
|
810
|
+
plugin.afterRoutes?.(pluginContext);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
mountStaticFallback(app);
|
|
814
|
+
return app;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const app = createYurucommuBackendApp();
|
|
818
|
+
|
|
819
|
+
export const backendApp = app;
|
|
820
|
+
|
|
821
|
+
export async function handleYurucommuQueueBatch(
|
|
822
|
+
batch: MessageBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
|
|
823
|
+
env: Env,
|
|
824
|
+
): Promise<void> {
|
|
825
|
+
const deliveryQueueName = env.DELIVERY_QUEUE_NAME ?? "yurucommu-delivery";
|
|
826
|
+
const deliveryDlqName = env.DELIVERY_DLQ_NAME ?? "yurucommu-delivery-dlq";
|
|
827
|
+
|
|
828
|
+
if (batch.queue === deliveryQueueName) {
|
|
829
|
+
return handleDeliveryQueueBatch(
|
|
830
|
+
batch as MessageBatch<DeliveryQueueMessageV1>,
|
|
831
|
+
env,
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
if (batch.queue === deliveryDlqName) {
|
|
835
|
+
return handleDeliveryDlqBatch(
|
|
836
|
+
batch as MessageBatch<DeliveryDlqMessageV1>,
|
|
837
|
+
env,
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
log.warn("Unknown queue", {
|
|
842
|
+
event: "queue.unknown",
|
|
843
|
+
queue: batch.queue,
|
|
844
|
+
});
|
|
845
|
+
batch.ackAll();
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
type WorkerBindings = EnvVars & {
|
|
849
|
+
DB: D1Database;
|
|
850
|
+
MEDIA?: R2Bucket;
|
|
851
|
+
KV: KVNamespace;
|
|
852
|
+
ASSETS?: Fetcher;
|
|
853
|
+
DELIVERY_QUEUE?: Queue<DeliveryQueueMessageV1>;
|
|
854
|
+
DELIVERY_DLQ?: Queue<DeliveryDlqMessageV1>;
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
export default {
|
|
858
|
+
async fetch(
|
|
859
|
+
request: Request,
|
|
860
|
+
bindings: WorkerBindings,
|
|
861
|
+
ctx: ExecutionContext,
|
|
862
|
+
): Promise<Response> {
|
|
863
|
+
return app.fetch(request, wrapCloudflareBindings(bindings), ctx);
|
|
864
|
+
},
|
|
865
|
+
|
|
866
|
+
async queue(
|
|
867
|
+
batch: MessageBatch<DeliveryQueueMessageV1 | DeliveryDlqMessageV1>,
|
|
868
|
+
bindings: WorkerBindings,
|
|
869
|
+
): Promise<void> {
|
|
870
|
+
return handleYurucommuQueueBatch(batch, wrapCloudflareBindings(bindings));
|
|
871
|
+
},
|
|
872
|
+
};
|