diff --git a/src/app/api/patches/[id]/download/route.ts b/src/app/api/patches/[id]/download/route.ts index 2295479..d9a0602 100644 --- a/src/app/api/patches/[id]/download/route.ts +++ b/src/app/api/patches/[id]/download/route.ts @@ -1,5 +1,6 @@ import { NextRequest } from "next/server"; import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server"; +import { buildPatchDownloadUrl } from "@/utils/patches/patch-download-url"; import { createClient } from "@/utils/supabase/server"; export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { @@ -45,6 +46,11 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: return new Response("Not found", { status: 404 }); } + const workerUrl = buildPatchDownloadUrl(patch.filename); + if (workerUrl) { + return Response.redirect(workerUrl, 302); + } + const client = getMinioClient(); const bucket = patch.bucket || PATCHES_BUCKET; const url = await client.presignedGetObject(bucket, patch.filename, 60 * 5); diff --git a/src/app/hack/[slug]/actions.ts b/src/app/hack/[slug]/actions.ts index 572e648..c73d6a7 100644 --- a/src/app/hack/[slug]/actions.ts +++ b/src/app/hack/[slug]/actions.ts @@ -2,6 +2,7 @@ import { createClient, createServiceClient } from "@/utils/supabase/server"; import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server"; +import { buildPatchDownloadUrl } from "@/utils/patches/patch-download-url"; import { isInformationalArchiveHack, canEditAsCreator, canEditAsAdmin } from "@/utils/hack"; import { sendDiscordMessageEmbed } from "@/utils/discord"; import { headers } from "next/headers"; @@ -245,8 +246,11 @@ export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: return { ok: false, error: "Patch not found" }; } - // Sign the URL server-side try { + const workerUrl = buildPatchDownloadUrl(patch.filename); + if (workerUrl) { + return { ok: true, url: workerUrl }; + } const client = getMinioClient(); const bucket = patch.bucket || PATCHES_BUCKET; const signedUrl = await client.presignedGetObject(bucket, patch.filename, 60 * 5); @@ -433,6 +437,10 @@ export async function getPatchDownloadUrl(patchId: number): Promise<{ ok: true; } try { + const workerUrl = buildPatchDownloadUrl(patch.filename); + if (workerUrl) { + return { ok: true, url: workerUrl }; + } const client = getMinioClient(); const bucket = patch.bucket || PATCHES_BUCKET; const signedUrl = await client.presignedGetObject(bucket, patch.filename, 60 * 5); diff --git a/src/utils/patches/patch-download-url.ts b/src/utils/patches/patch-download-url.ts new file mode 100644 index 0000000..f8e924d --- /dev/null +++ b/src/utils/patches/patch-download-url.ts @@ -0,0 +1,39 @@ +import { createHmac } from "node:crypto"; + +/** Aligned with Cloudflare Worker `patchTokenJsonPayload` (key order f, e). */ +export function patchTokenJsonPayload(filename: string, expiresAtMs: number): string { + return JSON.stringify({ f: filename, e: expiresAtMs }); +} + +const PATCH_DOWNLOAD_TTL_MS = 5 * 60 * 1000; + +/** Wire: base64url(utf8 JSON payload).hex(32-byte HMAC-SHA256), matching Worker `mintPatchToken`. */ +export function mintPatchDownloadToken( + filename: string, + expiresAtMs: number, + secret: string +): string { + const buf = Buffer.from(patchTokenJsonPayload(filename, expiresAtMs), "utf8"); + const hexMac = createHmac("sha256", secret).update(buf).digest("hex"); + return `${buf.toString("base64url")}.${hexMac}`; +} + +function trimTrailingSlash(s: string): string { + return s.replace(/\/+$/, ""); +} + +/** + * If `PATCH_TOKEN_SECRET` and `PATCHES_DOWNLOAD_BASE_URL` are set, returns a Worker URL with `token`. + * Otherwise `null` — caller should use Minio `presignedGetObject`. + */ +export function buildPatchDownloadUrl(filename: string): string | null { + const secret = process.env.PATCH_TOKEN_SECRET?.trim(); + const base = process.env.PATCHES_DOWNLOAD_BASE_URL?.trim(); + if (!secret || !base) return null; + const expiresAtMs = Date.now() + PATCH_DOWNLOAD_TTL_MS; + const token = mintPatchDownloadToken(filename, expiresAtMs, secret); + const u = new URL(filename, `${trimTrailingSlash(base)}/`); + u.searchParams.set("token", token); + console.log("buildPatchDownloadUrl", u.href); + return u.href; +}