@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,692 @@
1
+ import { type Context, Hono } from "hono";
2
+ import { and, eq, isNull, or, sql } from "drizzle-orm";
3
+ import type { Env, Variables } from "../types.ts";
4
+ import type { Database } from "../../db/index.ts";
5
+ import {
6
+ actors,
7
+ communities,
8
+ communityMembers,
9
+ follows,
10
+ mediaUploads,
11
+ objects,
12
+ } from "../../db/index.ts";
13
+ import { generateId, safeJsonParse } from "../federation-helpers.ts";
14
+ import { canViewerReadObject } from "../lib/community-visibility.ts";
15
+ import { isExplicitRecipient } from "../lib/post-visibility.ts";
16
+ import { stripImageMetadata } from "../lib/strip-image-metadata.ts";
17
+ import { logger } from "../lib/logger.ts";
18
+
19
+ const log = logger.child({ component: "media" });
20
+
21
+ const media = new Hono<{ Bindings: Env; Variables: Variables }>();
22
+ type MediaContext = Context<{ Bindings: Env; Variables: Variables }>;
23
+
24
+ function errorMessage(error: unknown): string {
25
+ return error instanceof Error ? error.message : "Unknown error";
26
+ }
27
+
28
+ const ALLOWED_TYPES = [
29
+ "image/jpeg",
30
+ "image/png",
31
+ "image/gif",
32
+ "image/webp",
33
+ "video/mp4",
34
+ "video/webm",
35
+ ];
36
+
37
+ // Limits are chosen to stay within the Cloudflare Workers ~128MB memory budget.
38
+ // The upload path reads only a small header slice into memory for magic-byte
39
+ // validation and streams the body straight to R2 (no full-file ArrayBuffer), so
40
+ // the dominant cost is `c.req.formData()` materializing the multipart field.
41
+ // We keep video at 40MB so that even a transient full-buffer (~40MB) plus
42
+ // multipart/runtime overhead stays comfortably under budget on large uploads.
43
+ const MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20MB
44
+ const MAX_VIDEO_SIZE = 40 * 1024 * 1024; // 40MB (was 100MB; lowered to fit Worker memory budget)
45
+
46
+ // Number of leading bytes to read for magic-byte validation. All supported
47
+ // signatures (incl. WebP RIFF/WEBP at offset 8-11 and MP4 ftyp at offset 4-7)
48
+ // are decided within the first 12 bytes; read a small slice for headroom.
49
+ const MAGIC_BYTES_HEADER_LEN = 64;
50
+
51
+ // Cache durations for served media
52
+ const CACHE_MAX_AGE_IMAGE = 31536000; // 1 year for immutable content
53
+ const CACHE_MAX_AGE_VIDEO = 604800; // 1 week for videos
54
+
55
+ // Magic bytes signatures for file type validation
56
+ // These are the first bytes of valid files - used to verify actual content type
57
+ const MAGIC_BYTES: Record<string, { bytes: number[]; mask?: number[] }[]> = {
58
+ "image/jpeg": [
59
+ { bytes: [0xff, 0xd8, 0xff] }, // JPEG/JFIF
60
+ ],
61
+ "image/png": [
62
+ { bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }, // PNG
63
+ ],
64
+ "image/gif": [
65
+ { bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] }, // GIF87a
66
+ { bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] }, // GIF89a
67
+ ],
68
+ "image/webp": [
69
+ // RIFF....WEBP - check first 4 bytes (RIFF) and bytes 8-11 (WEBP)
70
+ { bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF header (we'll also check WEBP below)
71
+ ],
72
+ "video/mp4": [
73
+ // ftyp box at offset 4 (bytes 4-7 are 'ftyp')
74
+ // Common MP4 signatures: ftypisom, ftypmp42, ftypMSNV, ftypM4V, etc.
75
+ { bytes: [0x66, 0x74, 0x79, 0x70] }, // 'ftyp' at various offsets
76
+ ],
77
+ "video/webm": [
78
+ { bytes: [0x1a, 0x45, 0xdf, 0xa3] }, // EBML/WebM/Matroska
79
+ ],
80
+ };
81
+
82
+ /**
83
+ * Validates file content type by checking magic bytes
84
+ * Returns true if the file content matches the expected MIME type
85
+ */
86
+ function validateMagicBytes(buffer: ArrayBuffer, mimeType: string): boolean {
87
+ const signatures = MAGIC_BYTES[mimeType];
88
+ if (!signatures) return false;
89
+
90
+ const bytes = new Uint8Array(buffer);
91
+
92
+ // Special handling for WebP - need to check both RIFF header and WEBP marker
93
+ if (mimeType === "image/webp") {
94
+ // Check RIFF header (bytes 0-3) and WEBP marker (bytes 8-11)
95
+ const riff = [0x52, 0x49, 0x46, 0x46];
96
+ const webp = [0x57, 0x45, 0x42, 0x50];
97
+ if (bytes.length < 12) return false;
98
+ const hasRiff = riff.every((b, i) => bytes[i] === b);
99
+ const hasWebp = webp.every((b, i) => bytes[8 + i] === b);
100
+ return hasRiff && hasWebp;
101
+ }
102
+
103
+ // Special handling for MP4 - ftyp can be at offset 4 or 0
104
+ if (mimeType === "video/mp4") {
105
+ const ftyp = [0x66, 0x74, 0x79, 0x70]; // 'ftyp'
106
+ if (bytes.length < 12) return false;
107
+ // Check for ftyp at offset 4 (most common)
108
+ const hasftypAt4 = ftyp.every((b, i) => bytes[4 + i] === b);
109
+ // Some MP4 files have ftyp at offset 0
110
+ const hasftypAt0 = ftyp.every((b, i) => bytes[i] === b);
111
+ return hasftypAt4 || hasftypAt0;
112
+ }
113
+
114
+ // Standard magic bytes check
115
+ for (const sig of signatures) {
116
+ if (bytes.length < sig.bytes.length) continue;
117
+
118
+ const matches = sig.bytes.every((byte, index) => {
119
+ if (sig.mask) {
120
+ return (bytes[index] & sig.mask[index]) === byte;
121
+ }
122
+ return bytes[index] === byte;
123
+ });
124
+
125
+ if (matches) return true;
126
+ }
127
+
128
+ return false;
129
+ }
130
+
131
+ // MIME type to file extension mapping (acts as both whitelist and lookup)
132
+ const MIME_TO_EXTENSION: Record<string, string> = {
133
+ "image/jpeg": "jpg",
134
+ "image/png": "png",
135
+ "image/gif": "gif",
136
+ "image/webp": "webp",
137
+ "video/mp4": "mp4",
138
+ "video/webm": "webm",
139
+ };
140
+
141
+ function getExtensionFromMimeType(mimeType: string): string | null {
142
+ return MIME_TO_EXTENSION[mimeType] || null;
143
+ }
144
+
145
+ // Validate filename: lowercase hex ID + allowed extension, no path traversal
146
+ const VALID_MEDIA_FILENAME = /^[a-f0-9]+\.(jpg|png|gif|webp|mp4|webm)$/;
147
+
148
+ function isValidMediaFilename(filename: string): boolean {
149
+ if (!VALID_MEDIA_FILENAME.test(filename)) return false;
150
+ // Defense in depth: reject path traversal characters
151
+ if (
152
+ filename.includes("..") ||
153
+ filename.includes("/") ||
154
+ filename.includes("\\") ||
155
+ filename.includes("\x00")
156
+ )
157
+ return false;
158
+ return true;
159
+ }
160
+
161
+ // Upload media file to R2
162
+ media.post("/upload", async (c) => {
163
+ try {
164
+ const actor = c.get("actor");
165
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
166
+
167
+ // Parse multipart in its own try so a non-multipart / malformed body is a
168
+ // 400, not a 500 from the outer catch.
169
+ let formData: FormData;
170
+ try {
171
+ formData = await c.req.formData();
172
+ } catch {
173
+ return c.json({ error: "Invalid multipart form data" }, 400);
174
+ }
175
+ const file = formData.get("file") as File;
176
+ if (!file) return c.json({ error: "No file provided" }, 400);
177
+
178
+ const contentType = file.type;
179
+ if (!ALLOWED_TYPES.includes(contentType)) {
180
+ return c.json(
181
+ { error: "Invalid file type", allowed: ALLOWED_TYPES },
182
+ 400,
183
+ );
184
+ }
185
+
186
+ const isVideo = contentType.startsWith("video/");
187
+ const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE;
188
+ if (file.size > maxSize) {
189
+ const maxMB = maxSize / 1024 / 1024;
190
+ return c.json(
191
+ {
192
+ error: `File too large. Maximum size is ${maxMB}MB for ${
193
+ isVideo ? "videos" : "images"
194
+ }`,
195
+ },
196
+ 413,
197
+ );
198
+ }
199
+
200
+ // Read only the leading header bytes for magic-byte validation instead of
201
+ // buffering the entire file into Worker memory. This avoids the ~2x
202
+ // (formData + arrayBuffer) memory blow-up that risked OOM on large uploads.
203
+ const headerBuffer = await file
204
+ .slice(0, MAGIC_BYTES_HEADER_LEN)
205
+ .arrayBuffer();
206
+
207
+ // Validate actual content against declared MIME type via magic bytes
208
+ if (!validateMagicBytes(headerBuffer, contentType)) {
209
+ return c.json(
210
+ {
211
+ error: "File content does not match declared type",
212
+ hint: "The file appears to be a different format than specified",
213
+ },
214
+ 400,
215
+ );
216
+ }
217
+
218
+ const id = generateId();
219
+ const ext = getExtensionFromMimeType(contentType);
220
+ // Defense in depth: reject if MIME type is somehow not in extension map
221
+ if (!ext) return c.json({ error: "Unsupported file type" }, 400);
222
+
223
+ const filename = `${id}.${ext}`;
224
+ const r2Key = `uploads/${filename}`;
225
+
226
+ const media = c.env.MEDIA;
227
+ if (!media) {
228
+ return c.json({ error: "Object storage unavailable" }, 503);
229
+ }
230
+ // Images: buffer the (size-capped, <=20MB) bytes and STRIP privacy metadata
231
+ // (EXIF GPS / timestamp / camera serial, IPTC, XMP) before storing — these
232
+ // are served verbatim under a 1-year public cache, so an unstripped geotag
233
+ // would leak the poster's location. Pixels are untouched (byte-surgery, no
234
+ // re-encode). Videos stay STREAMED: metadata stripping there needs a
235
+ // transcode pipeline, and buffering a 40MB video would pressure the Worker
236
+ // memory budget.
237
+ if (isVideo) {
238
+ await media.put(r2Key, file.stream(), {
239
+ httpMetadata: { contentType },
240
+ });
241
+ } else {
242
+ const original = new Uint8Array(await file.arrayBuffer());
243
+ const cleaned = stripImageMetadata(original, contentType);
244
+ // Hand R2 a tightly-sized ArrayBuffer (the strip may return a view over a
245
+ // larger backing buffer, or the original `file` buffer on pass-through).
246
+ const cleanedBuffer = cleaned.buffer.slice(
247
+ cleaned.byteOffset,
248
+ cleaned.byteOffset + cleaned.byteLength,
249
+ ) as ArrayBuffer;
250
+ await media.put(r2Key, cleanedBuffer, {
251
+ httpMetadata: { contentType },
252
+ });
253
+ }
254
+
255
+ // Record ownership AFTER the blob lands. If the DB write fails, delete the
256
+ // now-unreferenced blob: the media GC reaps R2 by iterating media_uploads
257
+ // rows, so a blob with NO DB record can never be reaped and would leak
258
+ // forever. (Previously the put + insert ran in Promise.all, so a DB failure
259
+ // while the put succeeded orphaned the blob.)
260
+ const db = c.get("db");
261
+ try {
262
+ await db.insert(mediaUploads).values({
263
+ id,
264
+ r2Key,
265
+ uploaderApId: actor.ap_id,
266
+ contentType,
267
+ size: file.size,
268
+ });
269
+ } catch (dbError) {
270
+ await media.delete(r2Key).catch(() => {});
271
+ throw dbError;
272
+ }
273
+
274
+ const url = `/media/${filename}`;
275
+
276
+ return c.json({
277
+ url,
278
+ r2_key: r2Key,
279
+ content_type: contentType,
280
+ id,
281
+ });
282
+ } catch (error) {
283
+ // Log error internally but don't expose details to client
284
+ log.error("Media upload failed", {
285
+ event: "media.upload.failed",
286
+ reason: errorMessage(error),
287
+ error,
288
+ });
289
+ return c.json({ error: "Upload failed" }, 500);
290
+ }
291
+ });
292
+
293
+ type MediaAuthResult = { allowed: boolean; reason?: string; isPublic: boolean };
294
+ const DENY_AUTH_REQUIRED: MediaAuthResult = {
295
+ allowed: false,
296
+ reason: "Authentication required",
297
+ isPublic: false,
298
+ };
299
+ const DENY_NOT_AUTHORIZED: MediaAuthResult = {
300
+ allowed: false,
301
+ reason: "Not authorized",
302
+ isPublic: false,
303
+ };
304
+ const ALLOW_PUBLIC: MediaAuthResult = { allowed: true, isPublic: true };
305
+ const ALLOW_PRIVATE: MediaAuthResult = { allowed: true, isPublic: false };
306
+
307
+ type ReferencingObject = {
308
+ apId: string;
309
+ type: string;
310
+ attributedTo: string;
311
+ visibility: string;
312
+ toJson: string;
313
+ ccJson: string;
314
+ communityApId: string | null;
315
+ endTime: string | null;
316
+ };
317
+
318
+ // Locate the object that attached this media using ONLY indexed lookups.
319
+ //
320
+ // Media is uploaded by an actor (media_uploads.uploader_ap_id, indexed) and
321
+ // then attached to that actor's own object. The referencing object is found by
322
+ // scanning that uploader's objects (objects.attributed_to, indexed) and
323
+ // substring-matching the media reference in application code — instead of a
324
+ // leading-wildcard LIKE over the full objects table, which is unindexable and
325
+ // scans every row on every GET /media/:id.
326
+ //
327
+ // `r2Key` is the unique, indexed media identity (media_uploads_r2_key_idx); the
328
+ // uploaderApId comes from that record. The author-scoped object set is small and
329
+ // served by objects_attributed_to_idx.
330
+ function attachmentMatches(
331
+ attachmentsJson: string,
332
+ mediaUrl: string,
333
+ r2Key: string,
334
+ ): boolean {
335
+ // Same substring semantics as the previous LIKE("%...%") match, evaluated in
336
+ // app code over the candidate (already indexed-narrowed) rows.
337
+ return attachmentsJson.includes(mediaUrl) || attachmentsJson.includes(r2Key);
338
+ }
339
+
340
+ async function findReferencingObject(
341
+ db: Database,
342
+ uploaderApId: string,
343
+ mediaUrl: string,
344
+ r2Key: string,
345
+ ): Promise<ReferencingObject | null> {
346
+ // Push the substring match into SQL (LIKE) instead of materializing EVERY
347
+ // object the uploader has ever authored and substring-matching each in app
348
+ // code. A prolific uploader has thousands of objects, almost none of which
349
+ // reference this media; the old `eq(attributedTo).all()` loaded all of their
350
+ // attachmentsJson per media request. The LIKE narrows to the (usually 0-1)
351
+ // rows that actually contain the URL/key, gated by the indexed attributedTo.
352
+ // Substring-match in SQL via `instr()` (not `LIKE '%needle%'`), gated by the
353
+ // indexed attributed_to. `attachmentMatches` re-checks every survivor EXACTLY,
354
+ // so this only needs to narrow. Why instr() and not LIKE: a `%<64-char r2 key>%`
355
+ // LIKE pattern trips D1's "LIKE or GLOB pattern too complex" limit
356
+ // (SQLITE_ERROR 7500), which 500'd EVERY media fetch since the SQL-match
357
+ // optimization landed (~commit d9f0823f). instr() is a plain literal substring
358
+ // search — no wildcards, no escaping, no pattern-complexity limit.
359
+ const candidates = await db
360
+ .select({
361
+ apId: objects.apId,
362
+ type: objects.type,
363
+ attributedTo: objects.attributedTo,
364
+ visibility: objects.visibility,
365
+ toJson: objects.toJson,
366
+ ccJson: objects.ccJson,
367
+ communityApId: objects.communityApId,
368
+ endTime: objects.endTime,
369
+ attachmentsJson: objects.attachmentsJson,
370
+ })
371
+ .from(objects)
372
+ .where(
373
+ and(
374
+ eq(objects.attributedTo, uploaderApId),
375
+ or(
376
+ sql`instr(${objects.attachmentsJson}, ${mediaUrl}) > 0`,
377
+ sql`instr(${objects.attachmentsJson}, ${r2Key}) > 0`,
378
+ ),
379
+ ),
380
+ )
381
+ .all();
382
+
383
+ for (const row of candidates) {
384
+ if (attachmentMatches(row.attachmentsJson, mediaUrl, r2Key)) {
385
+ return {
386
+ apId: row.apId,
387
+ type: row.type,
388
+ attributedTo: row.attributedTo,
389
+ visibility: row.visibility,
390
+ toJson: row.toJson,
391
+ ccJson: row.ccJson,
392
+ communityApId: row.communityApId,
393
+ endTime: row.endTime,
394
+ };
395
+ }
396
+ }
397
+ return null;
398
+ }
399
+
400
+ // Check if user can access media based on associated object visibility
401
+ async function checkMediaAuthorization(
402
+ db: Database,
403
+ mediaUrl: string,
404
+ currentActorApId: string | null,
405
+ r2Key: string,
406
+ ): Promise<MediaAuthResult> {
407
+ // Resolve media identity by its unique, indexed r2Key (media_uploads_r2_key_idx).
408
+ const uploadRecord = await db
409
+ .select({ uploaderApId: mediaUploads.uploaderApId })
410
+ .from(mediaUploads)
411
+ .where(eq(mediaUploads.r2Key, r2Key))
412
+ .get();
413
+
414
+ // Profile media (an actor's icon / header) is not attached to any object, yet
415
+ // it is part of the public actor document served to anyone — including
416
+ // unauthenticated federation peers fetching /ap/users/:name. So media that the
417
+ // uploader references as their own avatar/header is world-readable. Scoped to
418
+ // the uploader's own indexed actor row (you can only set your own profile).
419
+ //
420
+ // Checked BEFORE findReferencingObject: an avatar/header is the hot public
421
+ // path (every federation peer rendering the actor) and references no object,
422
+ // so resolving it here short-circuits the object scan entirely.
423
+ if (uploadRecord) {
424
+ const profileRef = await db
425
+ .select({ apId: actors.apId })
426
+ .from(actors)
427
+ .where(
428
+ and(
429
+ eq(actors.apId, uploadRecord.uploaderApId),
430
+ or(eq(actors.iconUrl, mediaUrl), eq(actors.headerUrl, mediaUrl)),
431
+ ),
432
+ )
433
+ .get();
434
+ if (profileRef) return ALLOW_PUBLIC;
435
+
436
+ // Community icon set via a local /media upload. Unlike an actor avatar it is
437
+ // stored on the `communities` table (not an actors row) and is attached to
438
+ // no object, so without this branch it falls through to the uploader-only
439
+ // `!obj` deny below — every community avatar would 401/403 for federation
440
+ // peers and non-uploader members (broken image), even though the Group actor
441
+ // document at /ap/groups/:name publishes a PUBLIC community's icon to anyone.
442
+ // A PUBLIC community's icon is world-readable; a PRIVATE community's stays
443
+ // members-only (mirrors the Group-doc / community-post gates).
444
+ const communityRef = await db
445
+ .select({
446
+ visibility: communities.visibility,
447
+ apId: communities.apId,
448
+ createdBy: communities.createdBy,
449
+ })
450
+ .from(communities)
451
+ .where(
452
+ and(eq(communities.iconUrl, mediaUrl), isNull(communities.deletedAt)),
453
+ )
454
+ .get();
455
+ // Bind the icon reference to the blob's uploader, the same way the actor
456
+ // avatar branch is scoped to the uploader's own actor row. An iconUrl is an
457
+ // attacker-controllable cosmetic string: any user can point THEIR public
458
+ // community's icon at a victim's PRIVATE blob. Without this binding that
459
+ // alone would serve the victim's private media as world-readable (cross-user
460
+ // media IDOR). Only honor the icon branch when the uploader controls this
461
+ // community (its creator or a current member, the analog of "you can only
462
+ // set your own profile"); otherwise the reference proves nothing and we fall
463
+ // through to the real per-attachment / uploader-only gates below.
464
+ if (communityRef) {
465
+ const uploaderControlsCommunity =
466
+ communityRef.createdBy === uploadRecord.uploaderApId ||
467
+ !!(await db
468
+ .select({ actorApId: communityMembers.actorApId })
469
+ .from(communityMembers)
470
+ .where(
471
+ and(
472
+ eq(communityMembers.communityApId, communityRef.apId),
473
+ eq(communityMembers.actorApId, uploadRecord.uploaderApId),
474
+ ),
475
+ )
476
+ .get());
477
+ if (uploaderControlsCommunity) {
478
+ if (communityRef.visibility === "public") return ALLOW_PUBLIC;
479
+ const allowed = await canViewerReadObject(
480
+ db,
481
+ { communityApId: communityRef.apId },
482
+ currentActorApId,
483
+ );
484
+ if (allowed) return ALLOW_PRIVATE;
485
+ return currentActorApId ? DENY_NOT_AUTHORIZED : DENY_AUTH_REQUIRED;
486
+ }
487
+ }
488
+ }
489
+
490
+ const obj = uploadRecord
491
+ ? await findReferencingObject(
492
+ db,
493
+ uploadRecord.uploaderApId,
494
+ mediaUrl,
495
+ r2Key,
496
+ )
497
+ : null;
498
+
499
+ // Unattached media (no upload record, or no referencing object found): only
500
+ // the uploader may access. Authorize against the indexed media_uploads row.
501
+ if (!obj) {
502
+ if (!currentActorApId) return DENY_AUTH_REQUIRED;
503
+
504
+ const isUploader = !!(
505
+ uploadRecord && uploadRecord.uploaderApId === currentActorApId
506
+ );
507
+ return isUploader
508
+ ? ALLOW_PRIVATE
509
+ : {
510
+ allowed: false,
511
+ reason: "Not authorized to access this media",
512
+ isPublic: false,
513
+ };
514
+ }
515
+
516
+ // Author can always access their own media (even after leaving a community).
517
+ if (currentActorApId && obj.attributedTo === currentActorApId) {
518
+ return ALLOW_PRIVATE;
519
+ }
520
+
521
+ // Stories are ephemeral (24h endTime). Once expired, the blob must not be
522
+ // served to anyone but the author (handled just above) — mirror the feed /
523
+ // single-object gates (gt(endTime, now)) so the media lifetime matches the
524
+ // content lifetime instead of lingering until the best-effort reap fires.
525
+ if (
526
+ obj.type === "Story" &&
527
+ obj.endTime &&
528
+ obj.endTime <= new Date().toISOString()
529
+ ) {
530
+ return currentActorApId ? DENY_NOT_AUTHORIZED : DENY_AUTH_REQUIRED;
531
+ }
532
+
533
+ // Community-scoped media (a Story / community post is stored
534
+ // `visibility = "public"` but addressed to a community): a PRIVATE community's
535
+ // blob must stay members-only, so the world-readable `ALLOW_PUBLIC` below
536
+ // would leak it. `canViewerReadObject` short-circuits to true for
537
+ // public / non-community objects (never widening access) and gates a private
538
+ // community on membership. Served PRIVATE (no shared cache) so a
539
+ // member-fetched private blob is never replayed to a non-member from CDN cache.
540
+ if (obj.communityApId) {
541
+ const allowed = await canViewerReadObject(
542
+ db,
543
+ { communityApId: obj.communityApId },
544
+ currentActorApId,
545
+ );
546
+ if (!allowed) {
547
+ return currentActorApId ? DENY_NOT_AUTHORIZED : DENY_AUTH_REQUIRED;
548
+ }
549
+ // Community membership is necessary but NOT sufficient: a community post
550
+ // created with visibility=followers/direct must ALSO pass that per-post gate
551
+ // so this blob matches the post-detail / outbox / feed gates (which all
552
+ // follower-gate such a post). Public / unlisted / Story community posts are
553
+ // member-readable; followers/direct fall through to the gates below.
554
+ if (obj.visibility !== "followers" && obj.visibility !== "direct") {
555
+ return ALLOW_PRIVATE;
556
+ }
557
+ }
558
+
559
+ // A personal Story is stored visibility="public" but its REACH is the author's
560
+ // followers (addressed to=<actor>/followers; it only surfaces in followers'
561
+ // story feed). The public short-circuit below would make its media blob
562
+ // world-readable to anyone with the URL, so gate it on follower status like a
563
+ // followers-only post. (Community stories were gated by the branch above; the
564
+ // author by the branch above that.)
565
+ if (obj.type === "Story") {
566
+ if (!currentActorApId) return DENY_AUTH_REQUIRED;
567
+ const follow = await db
568
+ .select()
569
+ .from(follows)
570
+ .where(
571
+ and(
572
+ eq(follows.followerApId, currentActorApId),
573
+ eq(follows.followingApId, obj.attributedTo),
574
+ eq(follows.status, "accepted"),
575
+ ),
576
+ )
577
+ .get();
578
+ return follow ? ALLOW_PRIVATE : DENY_NOT_AUTHORIZED;
579
+ }
580
+
581
+ if (obj.visibility === "public" || obj.visibility === "unlisted") {
582
+ return ALLOW_PUBLIC;
583
+ }
584
+
585
+ // Non-public content requires authentication
586
+ if (!currentActorApId) return DENY_AUTH_REQUIRED;
587
+
588
+ if (obj.visibility === "followers") {
589
+ // An explicitly-addressed (to/cc) recipient — e.g. a mention — reads it even
590
+ // without a follow edge, matching the canonical canViewerReadObjectFull gate
591
+ // (this branch previously ignored to/cc and 403'd the blob for a legit
592
+ // recipient the post-detail view showed).
593
+ if (isExplicitRecipient(obj, currentActorApId)) return ALLOW_PRIVATE;
594
+ const follow = await db
595
+ .select()
596
+ .from(follows)
597
+ .where(
598
+ and(
599
+ eq(follows.followerApId, currentActorApId),
600
+ eq(follows.followingApId, obj.attributedTo),
601
+ eq(follows.status, "accepted"),
602
+ ),
603
+ )
604
+ .get();
605
+ return follow ? ALLOW_PRIVATE : DENY_NOT_AUTHORIZED;
606
+ }
607
+
608
+ if (obj.visibility === "direct") {
609
+ // Check both to AND cc (the canonical gate does), not toJson alone.
610
+ return isExplicitRecipient(obj, currentActorApId)
611
+ ? ALLOW_PRIVATE
612
+ : DENY_NOT_AUTHORIZED;
613
+ }
614
+
615
+ // Unknown visibility - deny by default
616
+ return DENY_NOT_AUTHORIZED;
617
+ }
618
+
619
+ async function serveMediaByR2Key(c: MediaContext, r2Key: string) {
620
+ try {
621
+ if (!r2Key.startsWith("uploads/")) return c.notFound();
622
+ const filename = r2Key.slice("uploads/".length);
623
+ if (!filename || !isValidMediaFilename(filename)) return c.notFound();
624
+
625
+ const mediaUrl = `/media/${filename}`;
626
+
627
+ // Defense in depth: ensure resolved path stays within uploads/
628
+ if (!r2Key.startsWith("uploads/") || r2Key.includes("..")) {
629
+ return c.notFound();
630
+ }
631
+
632
+ const actor = c.get("actor");
633
+ const db = c.get("db");
634
+ const authResult = await checkMediaAuthorization(
635
+ db,
636
+ mediaUrl,
637
+ actor?.ap_id || null,
638
+ r2Key,
639
+ );
640
+ if (!authResult.allowed) {
641
+ // Never cache an authorization denial: media URLs are content-addressed
642
+ // and long-lived, but who may read one changes over time (a follow is
643
+ // accepted, a profile sets the image as its public avatar). A cached 403
644
+ // on the immutable URL would otherwise mask the later-granted access.
645
+ c.header("Cache-Control", "no-store");
646
+ return c.json({ error: authResult.reason || "Forbidden" }, 403);
647
+ }
648
+
649
+ const media = c.env.MEDIA;
650
+ if (!media) {
651
+ return c.json({ error: "Object storage unavailable" }, 503);
652
+ }
653
+ const object = await media.get(r2Key);
654
+ if (!object) return c.notFound();
655
+
656
+ const contentType =
657
+ object.httpMetadata?.contentType || "application/octet-stream";
658
+ const cacheScope = authResult.isPublic ? "public" : "private";
659
+ const maxAge = contentType.startsWith("video/")
660
+ ? CACHE_MAX_AGE_VIDEO
661
+ : CACHE_MAX_AGE_IMAGE;
662
+ const etag = object.httpEtag;
663
+
664
+ if (!object.body) {
665
+ return c.body(null, 200, {
666
+ "Content-Type": contentType,
667
+ "Cache-Control": `${cacheScope}, max-age=${maxAge}`,
668
+ ...(etag ? { ETag: etag } : {}),
669
+ });
670
+ }
671
+ return c.body(object.body, 200, {
672
+ "Content-Type": contentType,
673
+ "Cache-Control": `${cacheScope}, max-age=${maxAge}`,
674
+ ...(etag ? { ETag: etag } : {}),
675
+ });
676
+ } catch (error) {
677
+ log.error("Media fetch failed", {
678
+ event: "media.fetch.failed",
679
+ reason: errorMessage(error),
680
+ error,
681
+ });
682
+ return c.json({ error: "Failed to fetch media" }, 500);
683
+ }
684
+ }
685
+
686
+ // Serve media files from R2 with cache headers.
687
+ media.get("/:id", async (c) => {
688
+ const id = c.req.param("id");
689
+ return serveMediaByR2Key(c, `uploads/${id}`);
690
+ });
691
+
692
+ export default media;