feat(packs): lib helpers — pack-files supabase 쿼리 + web-backend HMAC 클라이언트
- 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) <noreply@anthropic.com>
This commit is contained in:
54
lib/supabase/pack-files.ts
Normal file
54
lib/supabase/pack-files.ts
Normal file
@@ -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<PackTier, PackTier[]> = {
|
||||||
|
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<PackFile[]> {
|
||||||
|
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<PackFile | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
91
lib/web-backend.ts
Normal file
91
lib/web-backend.ts
Normal file
@@ -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<string, unknown> = {
|
||||||
|
...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<string, unknown>);
|
||||||
|
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<unknown[]> {
|
||||||
|
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<void> {
|
||||||
|
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 실패');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user