From 3e64030239e09089486ff7ea252d7deb70ad3b52 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 2 May 2026 09:00:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(packs):=20lib=20helpers=20=E2=80=94=20pack?= =?UTF-8?q?-files=20supabase=20=EC=BF=BC=EB=A6=AC=20+=20web-backend=20HMAC?= =?UTF-8?q?=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pack-files.ts: tier hierarchy + getPackFilesForTiers + getPackFileById - web-backend.ts: signLink (HMAC sig) + mintUploadToken (일회성 jti, 15분 만료) + listPackFilesViaBackend + deletePackFileViaBackend Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/supabase/pack-files.ts | 54 ++++++++++++++++++++++ lib/web-backend.ts | 91 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 lib/supabase/pack-files.ts create mode 100644 lib/web-backend.ts diff --git a/lib/supabase/pack-files.ts b/lib/supabase/pack-files.ts new file mode 100644 index 0000000..0ba8375 --- /dev/null +++ b/lib/supabase/pack-files.ts @@ -0,0 +1,54 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { PackTier } from '../pack-assets'; + +export interface PackFile { + id: string; + min_tier: PackTier; + label: string; + file_path: string; + filename: string; + size_bytes: number; + sort_order: number; + uploaded_at: string; + deleted_at: string | null; +} + +const TIER_HIERARCHY: Record = { + starter: ['starter'], + pro: ['starter', 'pro'], + master: ['starter', 'pro', 'master'], +}; + +export function tierIncludes(userTier: PackTier): PackTier[] { + return TIER_HIERARCHY[userTier]; +} + +export async function getPackFilesForTiers( + supabase: SupabaseClient, + tiers: PackTier[], +): Promise { + if (tiers.length === 0) return []; + const { data, error } = await supabase + .from('pack_files') + .select('*') + .in('min_tier', tiers) + .is('deleted_at', null) + .order('min_tier') + .order('sort_order'); + if (error) throw error; + return (data ?? []) as PackFile[]; +} + +export async function getPackFileById( + supabase: SupabaseClient, + id: string, +): Promise { + const { data, error } = await supabase + .from('pack_files') + .select('*') + .eq('id', id) + .is('deleted_at', null) + .maybeSingle(); + if (error) throw error; + return (data as PackFile) ?? null; +} diff --git a/lib/web-backend.ts b/lib/web-backend.ts new file mode 100644 index 0000000..1e9d3d9 --- /dev/null +++ b/lib/web-backend.ts @@ -0,0 +1,91 @@ +import crypto from 'crypto'; + +const BACKEND_BASE = process.env.WEB_BACKEND_BASE ?? 'https://gahusb.synology.me'; +const SECRET = process.env.BACKEND_HMAC_SECRET ?? ''; + +if (!SECRET && process.env.NODE_ENV === 'production') { + console.warn('BACKEND_HMAC_SECRET 미설정 — web-backend 호출 실패할 것임'); +} + +function sign(payload: Buffer): string { + return crypto.createHmac('sha256', SECRET).update(payload).digest('hex'); +} + +interface SignLinkPayload { + file_path: string; + expires_in_seconds: number; +} + +export async function signLink(payload: SignLinkPayload): Promise<{ url: string; expires_at: string }> { + const body = Buffer.from(JSON.stringify(payload)); + const ts = String(Math.floor(Date.now() / 1000)); + const sig = sign(Buffer.concat([Buffer.from(`${ts}.`), body])); + + const res = await fetch(`${BACKEND_BASE}/api/packs/sign-link`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Timestamp': ts, + 'X-Signature': sig, + }, + body, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`web-backend sign-link 실패 (${res.status}): ${text}`); + } + return res.json(); +} + +interface UploadTokenPayload { + tier: 'starter' | 'pro' | 'master'; + label: string; + filename: string; + size_bytes: number; +} + +export function mintUploadToken(input: UploadTokenPayload): { token: string; uploadUrl: string; expiresAt: number } { + const expiresAt = Math.floor(Date.now() / 1000) + 15 * 60; // 15분 + const payload: Record = { + ...input, + expires_at: expiresAt, + jti: crypto.randomUUID(), + }; + // web-backend 의 verify_upload_token 이 sort_keys=True + separators=(",", ":") 로 검증하므로 + // 키 알파벳 순서로 정렬한 객체를 JSON.stringify (JS 기본도 공백 없는 직렬화 → 호환) + const orderedPayload = Object.keys(payload).sort().reduce((acc, k) => { + acc[k] = payload[k]; + return acc; + }, {} as Record); + const body = Buffer.from(JSON.stringify(orderedPayload)); + const sig = sign(body); + const token = body.toString('base64url') + '.' + sig; + return { + token, + uploadUrl: `${BACKEND_BASE}/api/packs/upload`, + expiresAt, + }; +} + +export async function listPackFilesViaBackend(): Promise { + const body = Buffer.alloc(0); + const ts = String(Math.floor(Date.now() / 1000)); + const sig = sign(Buffer.concat([Buffer.from(`${ts}.`), body])); + const res = await fetch(`${BACKEND_BASE}/api/packs/list`, { + method: 'GET', + headers: { 'X-Timestamp': ts, 'X-Signature': sig }, + }); + if (!res.ok) throw new Error('list 실패'); + return res.json(); +} + +export async function deletePackFileViaBackend(id: string): Promise { + const body = Buffer.alloc(0); + const ts = String(Math.floor(Date.now() / 1000)); + const sig = sign(Buffer.concat([Buffer.from(`${ts}.`), body])); + const res = await fetch(`${BACKEND_BASE}/api/packs/${id}`, { + method: 'DELETE', + headers: { 'X-Timestamp': ts, 'X-Signature': sig }, + }); + if (!res.ok) throw new Error('delete 실패'); +}