Skip to content

Commit d7f0a7f

Browse files
committed
feat(storage): migrate assets to Cloudflare R2
1 parent b1df731 commit d7f0a7f

21 files changed

Lines changed: 1078 additions & 130 deletions

File tree

‎apps/web/.env.example‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,17 @@ WECHAT_SERVICE_ACCOUNT_APP_ID=
5151
WECHAT_SERVICE_ACCOUNT_APP_SECRET=
5252

5353
# ============================================
54-
# Storage (Tencent Cloud COS / S3-compatible)
54+
# Storage (Cloudflare R2 / S3-compatible)
5555
# ============================================
5656

5757
S3_ACCESS_KEY_ID=
5858
S3_SECRET_ACCESS_KEY=
5959
S3_ENDPOINT=
60-
S3_REGION=
60+
S3_REGION=auto
61+
S3_BUCKET_PUBLIC=
6162

62-
# Public S3 bucket configuration (Tencent Cloud COS)
63+
# Public S3 bucket configuration
6364
# Bucket has public read, private write permissions
64-
NEXT_PUBLIC_BUCKET_NAME=
6565
NEXT_PUBLIC_S3_ENDPOINT=
6666

6767
# ============================================

‎apps/web/content/docs/dev-guide/configuration.zh.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ NEXT_PUBLIC_SITE_URL="http://localhost:3000"
2424
### 可选配置
2525

2626
```bash
27-
# S3 兼容存储(腾讯云 COS)
28-
NEXT_PUBLIC_BUCKET_NAME=""
29-
NEXT_PUBLIC_S3_ENDPOINT=""
27+
# Cloudflare R2(S3 兼容)
28+
S3_ENDPOINT="https://<account-id>.r2.cloudflarestorage.com"
29+
S3_REGION="auto"
30+
S3_BUCKET_PUBLIC="hackathonweekly-assets-prod"
31+
NEXT_PUBLIC_S3_ENDPOINT="https://assets.hackathonweekly.com"
3032

3133
# OAuth 社交登录
3234
GITHUB_CLIENT_ID=""

‎apps/web/next.config.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ const nextConfig: NextConfig = {
8080
protocol: "https",
8181
hostname: "avatars.githubusercontent.com",
8282
},
83+
{
84+
// Cloudflare R2 public assets
85+
protocol: "https",
86+
hostname: "assets.hackathonweekly.com",
87+
},
8388
{
8489
// project screenshots and assets
8590
protocol: "https",

‎apps/web/src/app/(main)/(public)/events/[eventId]/photos/page.tsx‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ export default function EventPhotosPage() {
387387
const searchParams = new URLSearchParams({
388388
bucket: config.storage.bucketNames.public,
389389
path: fileName,
390+
size: String(file.size),
390391
});
391392

392393
if (file.type) {
@@ -422,11 +423,7 @@ export default function EventPhotosPage() {
422423

423424
const fileUrl =
424425
publicUrl ??
425-
buildPublicUrl(
426-
fileName,
427-
config.storage.endpoints.public,
428-
signedUrl,
429-
);
426+
buildPublicUrl(fileName, config.storage.endpoints.public);
430427

431428
// Then submit to photos API
432429
const submitRes = await fetch(`/api/events/${eventId}/photos`, {

‎apps/web/src/modules/account/events/components/EventCreateForm.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,8 @@ export function EventCreateForm({
428428
// Check if current cover image is a template image (from the configured template URL base)
429429
const isTemplateImage =
430430
currentCoverImage?.includes("hackweek-public") ||
431-
currentCoverImage?.includes("myqcloud.com");
431+
currentCoverImage?.includes("myqcloud.com") ||
432+
currentCoverImage?.includes("assets.hackathonweekly.com");
432433

433434
// If user has manually selected an image, don't auto-update
434435
if (userHasSelectedImage) {

‎apps/web/src/modules/account/events/components/submissions/AttachmentUploader.tsx‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,11 @@ async function uploadFileWithProgress(
155155
path: string,
156156
onProgress: (progress: number) => void,
157157
) {
158-
const searchParams = new URLSearchParams({ bucket, path });
158+
const searchParams = new URLSearchParams({
159+
bucket,
160+
path,
161+
size: String(file.size),
162+
});
159163
if (file.type) {
160164
searchParams.set("contentType", file.type);
161165
}
@@ -256,11 +260,8 @@ async function uploadFileWithProgress(
256260
xhr.send(file);
257261
});
258262

259-
// 优先使用后端返回的完整地址,兜底用端点或签名URL推导
260-
return (
261-
publicUrl ??
262-
buildPublicUrl(path, config.storage.endpoints.public, signedUrl)
263-
);
263+
// 优先使用后端返回的完整地址,兜底使用显式公共端点
264+
return publicUrl ?? buildPublicUrl(path, config.storage.endpoints.public);
264265
}
265266

266267
export function AttachmentUploader({
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import {
4+
DEFAULT_MAX_UPLOAD_SIZE,
5+
isUploadSizeAllowed,
6+
resolveMaxUploadSize,
7+
} from "../uploads-policy";
8+
9+
test("upload size policy defaults to 200 MB", () => {
10+
assert.equal(DEFAULT_MAX_UPLOAD_SIZE, 200 * 1024 * 1024);
11+
assert.equal(resolveMaxUploadSize(), DEFAULT_MAX_UPLOAD_SIZE);
12+
assert.equal(resolveMaxUploadSize("invalid"), DEFAULT_MAX_UPLOAD_SIZE);
13+
});
14+
15+
test("upload size policy accepts the limit and rejects invalid sizes", () => {
16+
const limit = resolveMaxUploadSize("1024");
17+
assert.equal(isUploadSizeAllowed(1024, limit), true);
18+
assert.equal(isUploadSizeAllowed(1025, limit), false);
19+
assert.equal(isUploadSizeAllowed(0, limit), false);
20+
assert.equal(isUploadSizeAllowed(Number.NaN, limit), false);
21+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export const DEFAULT_MAX_UPLOAD_SIZE = 200 * 1024 * 1024;
2+
3+
export const resolveMaxUploadSize = (configuredValue?: string): number => {
4+
if (!configuredValue) return DEFAULT_MAX_UPLOAD_SIZE;
5+
6+
const parsed = Number(configuredValue);
7+
return Number.isSafeInteger(parsed) && parsed > 0
8+
? parsed
9+
: DEFAULT_MAX_UPLOAD_SIZE;
10+
};
11+
12+
export const isUploadSizeAllowed = (
13+
fileSize: number,
14+
maxUploadSize: number,
15+
): boolean =>
16+
Number.isSafeInteger(fileSize) && fileSize > 0 && fileSize <= maxUploadSize;

‎apps/web/src/server/routes/uploads.ts‎

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { resolver, validator } from "hono-openapi/zod";
1212
import { HTTPException } from "hono/http-exception";
1313
import { z } from "zod";
1414
import { authMiddleware } from "../middleware/auth";
15+
import { isUploadSizeAllowed, resolveMaxUploadSize } from "./uploads-policy";
1516

1617
const logger = createModuleLogger("uploads");
1718

@@ -87,6 +88,7 @@ const ALLOWED_CONTENT_TYPES = new Map([
8788

8889
// Maximum file path length
8990
const MAX_PATH_LENGTH = 512;
91+
const MAX_UPLOAD_SIZE = resolveMaxUploadSize(process.env.MAX_FILE_SIZE);
9092

9193
// Validate file path for security
9294
function validateFilePath(path: string): boolean {
@@ -160,26 +162,16 @@ function validateContentType(
160162
return true;
161163
}
162164

163-
// 构建可公开访问的 URL,优先使用配置端点,兜底用签名链接的域名
164-
const buildPublicUrl = (path: string, signedUrl?: string): string => {
165+
// 构建可公开访问的 URL;不能回退到私有 S3 API 端点
166+
const buildPublicUrl = (path: string): string => {
165167
const normalizedPath = path.replace(/^\/+/, "");
166168

167169
const candidates = [
168170
config.storage.endpoints.public,
169171
process.env.NEXT_PUBLIC_S3_ENDPOINT,
170172
process.env.S3_PUBLIC_ENDPOINT,
171-
process.env.S3_ENDPOINT,
172173
];
173174

174-
if (signedUrl) {
175-
try {
176-
const origin = new URL(signedUrl).origin;
177-
candidates.push(origin);
178-
} catch {
179-
// ignore invalid URL
180-
}
181-
}
182-
183175
const base = candidates
184176
.filter(Boolean)
185177
.map((b) => (b as string).trim().replace(/\/+$/, ""))
@@ -258,6 +250,11 @@ export const uploadsRouter = new Hono<{
258250
ALLOWED_CONTENT_TYPES.has(normalized)
259251
);
260252
}, "Content type not allowed"),
253+
size: z.coerce
254+
.number()
255+
.int()
256+
.positive()
257+
.max(MAX_UPLOAD_SIZE),
261258
})
262259
.refine(
263260
(data) => {
@@ -299,7 +296,7 @@ export const uploadsRouter = new Hono<{
299296
},
300297
}),
301298
async (c) => {
302-
const { bucket, path, contentType } = c.req.valid("query");
299+
const { bucket, path, contentType, size } = c.req.valid("query");
303300
const user = c.get("user");
304301
const resolvedBucket = resolveBucketName(bucket);
305302

@@ -314,6 +311,10 @@ export const uploadsRouter = new Hono<{
314311
});
315312
}
316313

314+
if (!isUploadSizeAllowed(size, MAX_UPLOAD_SIZE)) {
315+
throw new HTTPException(413, { message: "File is too large" });
316+
}
317+
317318
// Check if bucket is allowed
318319
if (!ALLOWED_BUCKETS.has(resolvedBucket)) {
319320
console.error("Bucket not allowed:", {
@@ -340,7 +341,7 @@ export const uploadsRouter = new Hono<{
340341
bucket: resolvedBucket,
341342
contentType,
342343
});
343-
const publicUrl = buildPublicUrl(path, signedUrl);
344+
const publicUrl = buildPublicUrl(path);
344345
return c.json({ signedUrl, publicUrl });
345346
} catch (error) {
346347
console.error("Failed to generate signed upload URL:", error);
@@ -398,6 +399,12 @@ export const uploadsRouter = new Hono<{
398399
throw new HTTPException(400, { message: "File is required" });
399400
}
400401

402+
if (!isUploadSizeAllowed(file.size, MAX_UPLOAD_SIZE)) {
403+
throw new HTTPException(413, {
404+
message: `File exceeds the ${MAX_UPLOAD_SIZE}-byte upload limit`,
405+
});
406+
}
407+
401408
if (!bucket) {
402409
throw new HTTPException(400, { message: "Bucket is required" });
403410
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
## Context
2+
3+
The application already uses the AWS SDK against an S3-compatible endpoint, but the implementation is coupled to Tencent COS:
4+
5+
- `packages/config/src/index.ts` hard-codes `hackweek-public-1303088253` and the COS public host.
6+
- `packages/lib-server/src/storage/index.ts` injects Tencent's `Appid` header.
7+
- Production data stores absolute COS URLs in ordinary text, rich text, JSON, and array columns. Absolute URLs are returned unchanged, so changing only environment variables would leave historical traffic on Tencent.
8+
- The browser limits attachments to 200 MB, but the direct-upload fallback can buffer the full payload in application memory.
9+
10+
### Live inventory (2026-07-16)
11+
12+
| Surface | Evidence |
13+
|---|---:|
14+
| Tencent COS bucket | 1,789 objects / 16.818 GiB |
15+
| MP4 + MOV | 228 objects / 15.10 GiB / 89.8% of bytes |
16+
| Distinct keys referenced by the database or committed templates | 522 objects / 4.807 GiB |
17+
| Unreferenced submission objects approved for removal | 297 objects / 11.431 GiB |
18+
| Unreferenced submission videos in that removal set | 150 objects / 10.798 GiB |
19+
| Conservative migration set | 1,492 objects / 5.387 GiB |
20+
| Referenced keys missing from COS | 0 |
21+
22+
The migration set excludes only objects under `events/<eventId>/submissions/` that are not referenced by the current production database. Every other object is copied, including small unreferenced templates, event content, QR codes, and historical public assets, to bias toward site stability.
23+
24+
Cloudflare account checks confirmed that `hackathonweekly.com` is an active zone in the same account, `assets.hackathonweekly.com` is unused, and R2 access is available. An old `hackweek` R2 bucket contains one small object and is attached to the unrelated, currently broken `r2.hackweek.cn` custom domain; it will not be reused.
25+
26+
## Goals / Non-Goals
27+
28+
- Goals:
29+
- Stop new writes and public reads through Tencent COS.
30+
- Preserve every referenced object and conservatively retain non-submission assets.
31+
- Make `assets.hackathonweekly.com` the stable, provider-independent public asset origin.
32+
- Keep the retained R2 set within the Standard free tier where possible.
33+
- Preserve current video playback behavior and prioritize event-day stability.
34+
- Produce a reversible and auditable cutover.
35+
- Non-Goals:
36+
- Removing Tencent Cloud SMS or content-moderation integrations; this change retires object storage only.
37+
- Migrating abandoned submission files that have no production database reference.
38+
- Adding Cloudflare Stream or an FFmpeg transcoding service without evidence that its recurring/storage/compute cost is justified.
39+
- Changing the attachment data model or playback UI.
40+
41+
## Decisions
42+
43+
### Decision: Create a dedicated R2 Standard bucket
44+
45+
Create `hackathonweekly-assets-prod` with an APAC location hint and Standard storage class.
46+
47+
- The 5.387 GiB conservative migration set is below the current 10 GB Standard free tier.
48+
- R2 direct egress is free, so rare event-day playback does not create bandwidth charges.
49+
- Infrequent Access is rejected because the free tier does not apply and reads add retrieval charges.
50+
- The legacy `hackweek` bucket remains untouched.
51+
52+
### Decision: Store video directly in R2 without transcoding
53+
54+
Video traffic is concentrated around competition day and is otherwise close to zero. At the measured retained footprint:
55+
56+
- R2 storage remains free under the current Standard allowance.
57+
- Cloudflare Stream adds a minimum storage purchase based on video minutes even when videos are not watched.
58+
- Browser-side FFmpeg is unreliable for 100-200 MB mobile uploads, and server-side FFmpeg adds queues, CPU pressure, temporary storage, and another failure path on the busiest day.
59+
60+
Therefore new and historical active videos remain ordinary R2 objects with the current 200 MB upload limit. The custom domain cache and byte-range behavior are verified against representative MP4/MOV files. Before a competition, an operations command may warm the current event's referenced videos.
61+
62+
Reconsider asynchronous transcoding only when retained R2 usage reaches 8 GB, individual videos regularly exceed 200 MB, or playback metrics show an event-day experience problem.
63+
64+
### Decision: Separate upload and read origins
65+
66+
- S3 API endpoint for presigned uploads: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`.
67+
- Public read origin: `https://assets.hackathonweekly.com`.
68+
- Presigned URLs cannot use the custom domain, so public URLs must never be derived from the signed URL origin.
69+
- Bucket name, API endpoint, public endpoint, and region are deployment configuration. No COS host or bucket remains as a runtime fallback.
70+
71+
### Decision: Restrictive CORS and cache policy
72+
73+
- Allow browser upload/read methods only from `https://hackathonweekly.com` and configured preview/local development origins.
74+
- Do not enable the public `r2.dev` hostname.
75+
- Bind R2 directly to `assets.hackathonweekly.com`; do not create a CNAME to `r2.dev`.
76+
- Uploads use the signed S3 API hostname; the custom domain serves `GET`/`HEAD` only.
77+
- Enable caching for immutable keys and verify `Range`, `Content-Type`, `ETag`, and CORS behavior for images, documents, audio, MP4, and MOV.
78+
79+
### Decision: Selective migration with an explicit exclusion manifest
80+
81+
1. Export a full COS manifest containing key, size, last-modified timestamp, content type, and ETag.
82+
2. Export every current database/repository-referenced COS key.
83+
3. Define the exclusion set as unreferenced keys matching `events/<eventId>/submissions/`; the audited set contains 297 objects and 11.431 GiB.
84+
4. Copy the remaining 1,492 objects and about 5.387 GiB to R2 with identical keys and metadata.
85+
5. Verify destination count/bytes, `HEAD` all 522 known referenced objects, and run full/range reads across representative file types and sizes.
86+
6. Freeze uploads briefly, regenerate the manifests, copy the final delta, and block cutover if any new key is unclassified.
87+
7. Switch writes/public URLs to R2 and backfill the 15 database columns containing the old COS hostname.
88+
89+
The exclusion manifest remains in the migration evidence. Excluded objects stay in COS only during the rollback window; when the old bucket is deleted, those abandoned files are deleted too.
90+
91+
### Decision: Observable rollback before Tencent retirement
92+
93+
- Keep COS readable but stop application writes for seven days after cutover.
94+
- Alert on application responses, database values, or browser requests containing the old COS hostname.
95+
- Compare asset 404s, upload failures, and video playback errors with the pre-cutover baseline.
96+
- After seven clean days, revoke the COS storage credentials, disable public access, export a final manifest, and delete the old bucket including the exclusion set.
97+
98+
## Rollout and Rollback
99+
100+
### Rollout gates
101+
102+
1. R2 bucket, custom domain, TLS, CORS, cache rules, and scoped credentials are active.
103+
2. Source and destination manifests match the 1,492-object inclusion set by key and size.
104+
3. All 522 known referenced keys return successfully through `assets.hackathonweekly.com`.
105+
4. MP4/MOV byte-range playback succeeds through the custom domain.
106+
5. Production and Mainland China probes meet the agreed success-rate threshold.
107+
6. Database backfill leaves zero old-host references.
108+
7. Authenticated upload smoke tests pass for image, document, audio, MP4, and MOV.
109+
110+
### Rollback
111+
112+
- Restore Tencent endpoint credentials and the previous public asset origin.
113+
- Reverse the hostname-only database replacement from the retained pre-change report.
114+
- Leave R2 data intact for diagnosis.
115+
- Rollback remains available until COS credentials and public access are revoked after the seven-day observation period.
116+
117+
## Risks / Trade-offs
118+
119+
- Ordinary R2 buckets are not located in Mainland China, and R2 custom domains are not supported inside Cloudflare China Network. APAC placement is only a best-effort hint, so domestic probes are a hard cutover gate.
120+
- Direct MP4/MOV playback transfers more bytes than adaptive streaming, but current playback frequency is too low for that to create R2 egress cost. The simpler path is more stable.
121+
- Database reachability cannot prove that an abandoned submission URL was never shared externally. The user has explicitly accepted deletion of abandoned submissions; the manifest preserves an audit trail until COS retirement.
122+
- Super Slurper supports generic S3-compatible sources, but Tencent COS is not listed among Cloudflare's explicitly tested providers. A test prefix must pass before bulk transfer; a local streaming copier is the fallback.
123+
124+
## References
125+
126+
- [Cloudflare R2 pricing](https://developers.cloudflare.com/r2/pricing/)
127+
- [R2 public buckets and custom domains](https://developers.cloudflare.com/r2/buckets/public-buckets/)
128+
- [R2 presigned URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/)
129+
- [R2 Super Slurper](https://developers.cloudflare.com/r2/data-migration/super-slurper/)
130+
- [Cloudflare China Network product availability](https://developers.cloudflare.com/china-network/reference/available-products/)

0 commit comments

Comments
 (0)