feat: /portfolio/[token] 공유 URL + /admin/hidden 관리자 대시보드

- lib/admin-auth: HMAC 서명 포트폴리오 토큰 발급/검증 (1~365일)
- /api/admin/portfolio-token: 관리자 쿠키 인증 후 토큰 발급
- /portfolio/[token]: 위시캣 제출용 게이트웨이 (noindex, 만료 시 404)
- /admin/hidden: 숨김 페이지 바로가기 + 토큰 발급 UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 01:01:24 +09:00
parent 5cc224a743
commit 03340c64a6
5 changed files with 369 additions and 0 deletions

View File

@@ -31,3 +31,47 @@ export function checkAdminCredentials(id: string, password: string): boolean {
if (!adminId || !adminPassword) return false;
return id === adminId && password === adminPassword;
}
/* ─── 포트폴리오 공유 토큰 (위시캣 등 외부 제출용) ──────────────── */
export interface PortfolioTokenPayload {
kind: 'portfolio';
memo: string;
iat: number;
exp: number;
}
export function createPortfolioToken(memo: string, ttlDays = 30): string {
const secret = process.env.ADMIN_JWT_SECRET!;
const now = Date.now();
const payload: PortfolioTokenPayload = {
kind: 'portfolio',
memo: memo.slice(0, 100),
iat: now,
exp: now + ttlDays * 24 * 60 * 60 * 1000,
};
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = createHmac('sha256', secret).update(encoded).digest('base64url');
return `${encoded}.${sig}`;
}
export function verifyPortfolioTokenNode(
token: string
): PortfolioTokenPayload | null {
try {
const secret = process.env.ADMIN_JWT_SECRET;
if (!secret) return null;
const [encoded, sig] = token.split('.');
if (!encoded || !sig) return null;
const expected = createHmac('sha256', secret).update(encoded).digest('base64url');
if (sig !== expected) return null;
const payload = JSON.parse(
Buffer.from(encoded, 'base64url').toString()
) as PortfolioTokenPayload;
if (payload.kind !== 'portfolio') return null;
if (Date.now() >= payload.exp) return null;
return payload;
} catch {
return null;
}
}