From 5f87631530eaf184762991e893c48f6f93688f31 Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 25 Aug 2026 11:42:40 +0900 Subject: [PATCH] fix(auth,api): resolve SSR vs CSR API URL routing and sanitize ADMIN_TOKEN parsing - Implement getApiBaseUrl() and getAdminApiBaseUrl() to dynamically distinguish between Server/SSR (process.env.API_BASE_URL) and Client/CSR (process.env.NEXT_PUBLIC_API_BASE_URL) - Fix missing data on main landing page in containerized environment - Sanitize and trim ADMIN_TOKEN parsing in Go backend to prevent whitespace/quote mismatch - Real-time token validation in AdminLayout with auto-redirection on token mismatch - All unit and E2E test gates passed clean with unanimous PASS reviews --- backend/cmd/api/main.go | 3 +- backend/internal/config/config.go | 2 +- backend/internal/httpx/auth.go | 5 ++-- refer_landing_page/app/console/layout.tsx | 36 +++++++++++++++++++---- refer_landing_page/lib/adminApi.ts | 23 +++++++++++---- refer_landing_page/lib/api.ts | 21 +++++++++++-- 6 files changed, 74 insertions(+), 16 deletions(-) diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index e1864a6..6d394fd 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "flag" "log" + "strings" "time" "git.godopu.com/lab/landing_page/backend/internal/config" @@ -37,7 +38,7 @@ func main() { cfg.GinMode = *ginModeFlag } if *adminTokenFlag != "" { - cfg.AdminToken = *adminTokenFlag + cfg.AdminToken = strings.Trim(*adminTokenFlag, " \"'\r\n\t") } if cfg.AdminToken == "" { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index bbbc138..6daf59d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -94,7 +94,7 @@ func Load() *Config { } if token := os.Getenv("ADMIN_TOKEN"); token != "" { - cfg.AdminToken = token + cfg.AdminToken = strings.Trim(token, " \"'\r\n\t") } if autoMigrateStr := os.Getenv("AUTO_MIGRATE"); autoMigrateStr != "" { diff --git a/backend/internal/httpx/auth.go b/backend/internal/httpx/auth.go index 19892e7..0fae1ce 100644 --- a/backend/internal/httpx/auth.go +++ b/backend/internal/httpx/auth.go @@ -8,8 +8,9 @@ import ( // RequireAdminToken returns a Gin middleware that validates Bearer against the configured admin token. func RequireAdminToken(adminToken string) gin.HandlerFunc { + expectedToken := strings.Trim(adminToken, " \"'\r\n\t") return func(c *gin.Context) { - if adminToken == "" { + if expectedToken == "" { Unauthorized(c, "Admin API is disabled: ADMIN_TOKEN is not configured") c.Abort() return @@ -23,7 +24,7 @@ func RequireAdminToken(adminToken string) gin.HandlerFunc { } parts := strings.SplitN(auth, " ", 2) - if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] != adminToken { + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) != expectedToken { Unauthorized(c, "Invalid admin bearer token") c.Abort() return diff --git a/refer_landing_page/app/console/layout.tsx b/refer_landing_page/app/console/layout.tsx index 2b9b2b2..c950248 100644 --- a/refer_landing_page/app/console/layout.tsx +++ b/refer_landing_page/app/console/layout.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { getClientToken, removeClientToken } from "./_components/AdminComponents"; +import { adminVerifyToken } from "../../lib/adminApi"; export default function AdminLayout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); @@ -11,12 +12,37 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) const [isAuthenticated, setIsAuthenticated] = useState(null); useEffect(() => { - const token = getClientToken(); - if (!token && pathname !== "/console/login") { - router.push("/console/login"); - } else { - setIsAuthenticated(true); + let isMounted = true; + async function checkAuth() { + const token = getClientToken(); + if (!token) { + if (pathname !== "/console/login") { + router.push("/console/login"); + } else { + setIsAuthenticated(false); + } + return; + } + + const isValid = await adminVerifyToken(token); + if (!isMounted) return; + + if (!isValid) { + removeClientToken(); + if (pathname !== "/console/login") { + router.push("/console/login"); + } else { + setIsAuthenticated(false); + } + } else { + setIsAuthenticated(true); + } } + + checkAuth(); + return () => { + isMounted = false; + }; }, [pathname, router]); if (pathname === "/console/login") { diff --git a/refer_landing_page/lib/adminApi.ts b/refer_landing_page/lib/adminApi.ts index 087226b..4eff543 100644 --- a/refer_landing_page/lib/adminApi.ts +++ b/refer_landing_page/lib/adminApi.ts @@ -3,10 +3,22 @@ * Preserves database IDs and handles authenticated mutating requests (POST, PUT, DELETE). */ -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL || - process.env.API_BASE_URL || - "http://localhost:8080/api/v1"; +export function getAdminApiBaseUrl(): string { + if (typeof window !== "undefined") { + // Browser / CSR context: use public endpoint + return ( + process.env.NEXT_PUBLIC_API_BASE_URL || + process.env.API_BASE_URL || + "http://localhost:8080/api/v1" + ); + } + // Server / SSR context: prefer internal container DNS if available, fallback to NEXT_PUBLIC or localhost + return ( + process.env.API_BASE_URL || + process.env.NEXT_PUBLIC_API_BASE_URL || + "http://localhost:8080/api/v1" + ); +} export interface AdminResearchProject { id: number; @@ -143,7 +155,8 @@ async function request( headers.set("Content-Type", "application/json"); } - const url = `${API_BASE_URL}${endpoint}`; + const baseUrl = getAdminApiBaseUrl(); + const url = `${baseUrl}${endpoint}`; const response = await fetch(url, { ...options, headers, diff --git a/refer_landing_page/lib/api.ts b/refer_landing_page/lib/api.ts index f022ce6..4ccb10a 100644 --- a/refer_landing_page/lib/api.ts +++ b/refer_landing_page/lib/api.ts @@ -10,7 +10,23 @@ import type { DocStatus, } from "../content/types"; -const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:8080/api/v1"; +export function getApiBaseUrl(): string { + if (typeof window !== "undefined") { + // Browser / CSR context: use public endpoint + return ( + process.env.NEXT_PUBLIC_API_BASE_URL || + process.env.API_BASE_URL || + "http://localhost:8080/api/v1" + ); + } + // Server / SSR context: prefer internal container DNS if available, fallback to NEXT_PUBLIC or localhost + return ( + process.env.API_BASE_URL || + process.env.NEXT_PUBLIC_API_BASE_URL || + "http://localhost:8080/api/v1" + ); +} + const FETCH_TIMEOUT_MS = 3000; interface APIResponse { @@ -142,7 +158,8 @@ async function apiGet(path: string): Promise { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - const res = await fetch(`${API_BASE_URL}${path}`, { + const baseUrl = getApiBaseUrl(); + const res = await fetch(`${baseUrl}${path}`, { signal: controller.signal, cache: "no-store", });