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