Jiseoup/showmycodePublic
EN|KO
  • 코드
  • 커밋
  • 풀 리퀘스트
← 목록으로

feat: add share token authentication

JiseoupJiseoup · 2026년 4월 3일57c8a4b

변경된 파일4개+113 -4

변경된 파일

+113 -4 · 4개

@@ -0,0 +1,40 @@
+"use client";
+
+import { useRef } from "react";
+import { useRouter } from "next/navigation";
+
+// Standalone page — outside [lang] layout, so no i18n context available.
+export default function UnauthorizedPage() {
+ const inputRef = useRef<HTMLInputElement>(null);
+ const router = useRouter();
+
+ const handleSubmit = (e: React.SyntheticEvent) => {
+ e.preventDefault();
+ const token = inputRef.current?.value.trim();
+ if (token) router.push(`/?token=${encodeURIComponent(token)}`);
+ };
+
+ return (
+ <div className="flex flex-col items-center justify-center min-h-screen gap-4">
+ <p className="text-base font-semibold">Access restricted</p>
+ <p className="text-sm text-muted-foreground">
+ Enter your access token to continue.
+ </p>
+ <form onSubmit={handleSubmit} className="flex items-center gap-2">
+ <input
+ ref={inputRef}
+ type="password"
+ placeholder="Access token"
+ autoFocus
+ className="text-sm px-3 py-1.5 w-56 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-border"
+ />
+ <button
+ type="submit"
+ className="text-sm px-3 py-1.5 rounded border border-border bg-muted hover:bg-accent transition-colors cursor-pointer"
+ >
+ Enter
+ </button>
+ </form>
+ </div>
+ );
+}
@@ -7,3 +7,8 @@ GITHUB_OWNER=
# 공개할 레포 이름 (쉼표로 구분)
GITHUB_REPOS=
+
+# Access token for the share link (e.g. a long random string)
+# Share URL: https://your-domain.com/?token=<SHARE_TOKEN>
+# Leave empty to block all access.
+SHARE_TOKEN=
@@ -43,7 +43,7 @@ The goal of showmycode is to let **anyone** securely share private GitHub reposi
### Routing
-All pages are under `app/[lang]/` for internationalization (KO/EN). The proxy (Next.js 16 middleware) in `proxy.ts` detects `Accept-Language` and redirects to the appropriate locale.
+All pages are under `app/[lang]/` for internationalization (KO/EN). `proxy.ts` (Next.js 16's replacement for `middleware.ts`, runs on Node.js runtime) handles auth token validation and locale detection.
```
/[lang]/ → Repository listing
@@ -54,6 +54,16 @@ All pages are under `app/[lang]/` for internationalization (KO/EN). The proxy (N
/[lang]/repository/[owner]/[repo]/pulls/[number] → PR detail (Overview / Commits / Files changed tabs)
```
+### Access Control
+
+All pages are protected by a share token set in `SHARE_TOKEN` env var. The flow:
+
+1. First visit: append `?token=<SHARE_TOKEN>` to any URL → middleware validates, sets a 30-day `httpOnly` cookie, redirects without the token in the URL.
+2. Subsequent visits: cookie is checked automatically.
+3. Invalid/missing token → redirected to `/unauthorized` (token entry page).
+
+If `SHARE_TOKEN` is not set, all access is blocked. The token is never exposed to the client.
+
### GitHub API Security Model
The GitHub PAT never reaches the client. All GitHub API calls go through a server-side proxy:
@@ -2,14 +2,24 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { locales, defaultLocale, hasLocale } from "@/lib/i18n";
-export function proxy(request: NextRequest) {
+const COOKIE_NAME = "smc_auth";
+
+// Paths that are accessible without a valid auth cookie.
+const PUBLIC_PATHS = ["/unauthorized"];
+
+function isPublicPath(pathname: string): boolean {
+ return PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
+}
+
+// Redirect to the correct locale if not already present in the path.
+function redirectToLocale(request: NextRequest): NextResponse | undefined {
const { pathname } = request.nextUrl;
// Pass through if locale prefix already present.
- const pathnameHasLocale = locales.some(
+ const hasLocalePrefix = locales.some(
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
);
- if (pathnameHasLocale) return;
+ if (hasLocalePrefix) return;
// Detect locale from Accept-Language header.
const acceptLang = request.headers.get("accept-language") ?? "";
@@ -20,6 +30,50 @@ export function proxy(request: NextRequest) {
return NextResponse.redirect(request.nextUrl);
}
+export function proxy(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+
+ // Always allow public paths through without auth.
+ if (isPublicPath(pathname)) return NextResponse.next();
+
+ const token = process.env.SHARE_TOKEN;
+
+ // If SHARE_TOKEN is not set, block all access to prevent accidental exposure.
+ if (!token) {
+ return NextResponse.redirect(new URL("/unauthorized", request.url));
+ }
+
+ // If ?token= query param is present and valid, set auth cookie and redirect without the param.
+ // This allows sharing a plain URL like https://example.com/?token=xxx.
+ const queryToken = request.nextUrl.searchParams.get("token");
+ if (queryToken !== null) {
+ if (queryToken === token) {
+ const url = new URL(request.url);
+ url.searchParams.delete("token");
+ const response = NextResponse.redirect(url);
+ response.cookies.set(COOKIE_NAME, token, {
+ httpOnly: true,
+ sameSite: "lax",
+ secure: process.env.NODE_ENV === "production",
+ path: "/",
+ maxAge: 60 * 60 * 24 * 30, // 30 days
+ });
+ return response;
+ }
+ // Invalid token in query param — deny access.
+ return NextResponse.redirect(new URL("/unauthorized", request.url));
+ }
+
+ // Check auth cookie for subsequent requests.
+ const cookie = request.cookies.get(COOKIE_NAME);
+ if (cookie?.value === token) {
+ return redirectToLocale(request) ?? NextResponse.next();
+ }
+
+ // No valid auth — redirect to unauthorized page.
+ return NextResponse.redirect(new URL("/unauthorized", request.url));
+}
+
export const config = {
matcher: ["/((?!_next|api|.*\\..*).*)"],
};