From 03340c64a607c59b31159ebeb1a431b038dac2bd Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 15 Apr 2026 01:01:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20/portfolio/[token]=20=EA=B3=B5=EC=9C=A0?= =?UTF-8?q?=20URL=20+=20/admin/hidden=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/admin-auth: HMAC 서명 포트폴리오 토큰 발급/검증 (1~365일) - /api/admin/portfolio-token: 관리자 쿠키 인증 후 토큰 발급 - /portfolio/[token]: 위시캣 제출용 게이트웨이 (noindex, 만료 시 404) - /admin/hidden: 숨김 페이지 바로가기 + 토큰 발급 UI Co-Authored-By: Claude Opus 4.6 --- app/admin/components/AdminSidebar.tsx | 10 ++ app/admin/hidden/page.tsx | 192 +++++++++++++++++++++++++ app/api/admin/portfolio-token/route.ts | 28 ++++ app/portfolio/[token]/page.tsx | 95 ++++++++++++ lib/admin-auth.ts | 44 ++++++ 5 files changed, 369 insertions(+) create mode 100644 app/admin/hidden/page.tsx create mode 100644 app/api/admin/portfolio-token/route.ts create mode 100644 app/portfolio/[token]/page.tsx diff --git a/app/admin/components/AdminSidebar.tsx b/app/admin/components/AdminSidebar.tsx index 528230d..de81822 100644 --- a/app/admin/components/AdminSidebar.tsx +++ b/app/admin/components/AdminSidebar.tsx @@ -88,6 +88,16 @@ const NAV_ITEMS = [ ), }, + { + href: '/admin/hidden', + label: '숨김 페이지', + icon: ( + + + + ), + }, { href: '/admin/analytics', label: '방문자 분석', diff --git a/app/admin/hidden/page.tsx b/app/admin/hidden/page.tsx new file mode 100644 index 0000000..d395b2f --- /dev/null +++ b/app/admin/hidden/page.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; + +const HIDDEN_PAGES = [ + { + path: '/freelance', + label: '외주 개발 문의', + desc: '위시캣·숨고 지원서에 뿌린 기존 링크. 노출 제거 + noindex.', + }, + { + path: '/services/website', + label: '홈페이지 제작 상세', + desc: '홈페이지 제작 랜딩. 직링크 전용.', + }, +]; + +interface IssuedToken { + token: string; + url: string; + memo: string; + expiresAt: string; +} + +export default function AdminHiddenPage() { + const [memo, setMemo] = useState(''); + const [ttlDays, setTtlDays] = useState(30); + const [loading, setLoading] = useState(false); + const [issued, setIssued] = useState([]); + const [error, setError] = useState(''); + + async function handleIssue() { + setError(''); + if (!memo.trim()) { + setError('메모를 입력해주세요. (예: 위시캣 xx 프로젝트)'); + return; + } + setLoading(true); + try { + const res = await fetch('/api/admin/portfolio-token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ memo, ttlDays }), + }); + if (!res.ok) throw new Error((await res.json()).error || '실패'); + const data = await res.json(); + const url = `${window.location.origin}/portfolio/${data.token}`; + setIssued([{ token: data.token, url, memo: data.memo, expiresAt: data.expiresAt }, ...issued]); + setMemo(''); + } catch (e) { + setError(e instanceof Error ? e.message : '토큰 생성 실패'); + } finally { + setLoading(false); + } + } + + async function copy(text: string) { + try { + await navigator.clipboard.writeText(text); + alert('복사되었습니다'); + } catch { + alert('복사 실패 — 수동으로 복사해주세요'); + } + } + + return ( +
+
+

숨김 페이지 관리

+

+ 공개 UI에서 숨긴 페이지 바로가기 + 위시캣 제출용 임시 공유 URL 발급. +

+
+ + {/* 숨김 페이지 바로가기 */} +
+

🔗 숨김 페이지 바로가기

+
+ {HIDDEN_PAGES.map((p) => ( +
+
+
+ + {p.label} + + {p.path} +
+

{p.desc}

+
+ +
+ ))} +
+
+ + {/* 포트폴리오 토큰 발급 */} +
+

🎫 포트폴리오 임시 공유 URL 발급

+
+

+ 위시캣 지원서 등 외부 제출용. /portfolio/[token] 형태의 + 프리미엄 게이트웨이 페이지가 생성됩니다. 만료되면 404로 처리됩니다. +

+
+
+ + setMemo(e.target.value)} + placeholder="예: 위시캣 OO 프로젝트 제안서용" + className="w-full px-4 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:border-violet-500" + /> +
+
+ + setTtlDays(Number(e.target.value))} + className="w-32 px-4 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:border-violet-500" + /> +
+ {error &&

{error}

} + +
+
+ + {/* 최근 발급 목록 (세션 메모리만 — 새로고침 시 초기화) */} + {issued.length > 0 && ( +
+

이번 세션에 발급한 URL

+ {issued.map((t) => ( +
+
+
+

+ 📝 {t.memo} · 만료 {new Date(t.expiresAt).toLocaleDateString('ko-KR')} +

+ + {t.url} + +
+
+
+ + + 열기 ↗ + +
+
+ ))} +

+ ※ 발급 목록은 현재 세션에만 표시됩니다. 발급 후 반드시 URL을 복사해두세요. +

+
+ )} +
+
+ ); +} diff --git a/app/api/admin/portfolio-token/route.ts b/app/api/admin/portfolio-token/route.ts new file mode 100644 index 0000000..a782f91 --- /dev/null +++ b/app/api/admin/portfolio-token/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createPortfolioToken, verifyAdminTokenNode } from '@/lib/admin-auth'; + +export const runtime = 'nodejs'; + +async function requireAdmin() { + const cookieStore = await cookies(); + const token = cookieStore.get('admin_token')?.value; + return !!token && verifyAdminTokenNode(token); +} + +export async function POST(request: Request) { + if (!(await requireAdmin())) { + return NextResponse.json({ error: '인증이 필요합니다.' }, { status: 401 }); + } + + try { + const { memo, ttlDays } = await request.json(); + const safeMemo = typeof memo === 'string' ? memo : ''; + const safeTtl = Math.max(1, Math.min(365, Number(ttlDays) || 30)); + const token = createPortfolioToken(safeMemo, safeTtl); + const expiresAt = new Date(Date.now() + safeTtl * 86400000).toISOString(); + return NextResponse.json({ token, expiresAt, memo: safeMemo }); + } catch { + return NextResponse.json({ error: '토큰 생성 실패' }, { status: 500 }); + } +} diff --git a/app/portfolio/[token]/page.tsx b/app/portfolio/[token]/page.tsx new file mode 100644 index 0000000..b90c3ce --- /dev/null +++ b/app/portfolio/[token]/page.tsx @@ -0,0 +1,95 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { verifyPortfolioTokenNode } from '@/lib/admin-auth'; + +export const metadata: Metadata = { + title: '박재오 — 외주 개발 포트폴리오', + description: '7년차 대기업 백엔드 개발자 박재오의 외주 포트폴리오.', + robots: { index: false, follow: false }, +}; + +export const dynamic = 'force-dynamic'; + +interface Props { + params: Promise<{ token: string }>; +} + +export default async function PortfolioGateway({ params }: Props) { + const { token } = await params; + const payload = verifyPortfolioTokenNode(token); + if (!payload) notFound(); + + const expires = new Date(payload.exp).toLocaleDateString('ko-KR'); + + return ( +
+
+
+
+ + + Private Portfolio · {payload.memo || 'Confidential'} + +
+ +

+ 박재오 +
+ + 외주 개발 포트폴리오 + +

+ +

+ 7년차 대기업 백엔드 개발자 · 계약서 우선 · 납기 패널티 보장 · 소스코드 100% 인도. + 본 페이지는 {expires}까지 유효한 개별 공유 링크입니다. +

+ +
+ +

+ Freelance +

+

외주 개발 · 전체 소개

+

+ 계약 프로세스, 납기 패널티, 포트폴리오 사례, 견적 문의. +

+ + 자세히 보기 → + + + +

+ Website +

+

홈페이지·쇼핑몰 제작

+

+ Next.js 기반 반응형 웹, SEO 기본, 3개월 유지보수 포함. +

+ + 자세히 보기 → + + +
+ +
+ © 쟁승메이드 · 010-3907-1392 · bgg8988@gmail.com +
+
+
+
+ ); +} diff --git a/lib/admin-auth.ts b/lib/admin-auth.ts index e556a07..3b313da 100644 --- a/lib/admin-auth.ts +++ b/lib/admin-auth.ts @@ -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; + } +}