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
This commit is contained in:
2026-08-25 11:42:40 +09:00
parent 0a589bd3a2
commit 5f87631530
6 changed files with 74 additions and 16 deletions
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/hex" "encoding/hex"
"flag" "flag"
"log" "log"
"strings"
"time" "time"
"git.godopu.com/lab/landing_page/backend/internal/config" "git.godopu.com/lab/landing_page/backend/internal/config"
@@ -37,7 +38,7 @@ func main() {
cfg.GinMode = *ginModeFlag cfg.GinMode = *ginModeFlag
} }
if *adminTokenFlag != "" { if *adminTokenFlag != "" {
cfg.AdminToken = *adminTokenFlag cfg.AdminToken = strings.Trim(*adminTokenFlag, " \"'\r\n\t")
} }
if cfg.AdminToken == "" { if cfg.AdminToken == "" {
+1 -1
View File
@@ -94,7 +94,7 @@ func Load() *Config {
} }
if token := os.Getenv("ADMIN_TOKEN"); token != "" { 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 != "" { if autoMigrateStr := os.Getenv("AUTO_MIGRATE"); autoMigrateStr != "" {
+3 -2
View File
@@ -8,8 +8,9 @@ import (
// RequireAdminToken returns a Gin middleware that validates Bearer <token> against the configured admin token. // RequireAdminToken returns a Gin middleware that validates Bearer <token> against the configured admin token.
func RequireAdminToken(adminToken string) gin.HandlerFunc { func RequireAdminToken(adminToken string) gin.HandlerFunc {
expectedToken := strings.Trim(adminToken, " \"'\r\n\t")
return func(c *gin.Context) { return func(c *gin.Context) {
if adminToken == "" { if expectedToken == "" {
Unauthorized(c, "Admin API is disabled: ADMIN_TOKEN is not configured") Unauthorized(c, "Admin API is disabled: ADMIN_TOKEN is not configured")
c.Abort() c.Abort()
return return
@@ -23,7 +24,7 @@ func RequireAdminToken(adminToken string) gin.HandlerFunc {
} }
parts := strings.SplitN(auth, " ", 2) 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") Unauthorized(c, "Invalid admin bearer token")
c.Abort() c.Abort()
return return
+27 -1
View File
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { getClientToken, removeClientToken } from "./_components/AdminComponents"; import { getClientToken, removeClientToken } from "./_components/AdminComponents";
import { adminVerifyToken } from "../../lib/adminApi";
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
@@ -11,12 +12,37 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null); const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
useEffect(() => { useEffect(() => {
let isMounted = true;
async function checkAuth() {
const token = getClientToken(); const token = getClientToken();
if (!token && pathname !== "/console/login") { if (!token) {
if (pathname !== "/console/login") {
router.push("/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 { } else {
setIsAuthenticated(true); setIsAuthenticated(true);
} }
}
checkAuth();
return () => {
isMounted = false;
};
}, [pathname, router]); }, [pathname, router]);
if (pathname === "/console/login") { if (pathname === "/console/login") {
+16 -3
View File
@@ -3,10 +3,22 @@
* Preserves database IDs and handles authenticated mutating requests (POST, PUT, DELETE). * Preserves database IDs and handles authenticated mutating requests (POST, PUT, DELETE).
*/ */
const API_BASE_URL = export function getAdminApiBaseUrl(): string {
if (typeof window !== "undefined") {
// Browser / CSR context: use public endpoint
return (
process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL ||
process.env.API_BASE_URL || process.env.API_BASE_URL ||
"http://localhost:8080/api/v1"; "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 { export interface AdminResearchProject {
id: number; id: number;
@@ -143,7 +155,8 @@ async function request<T>(
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
} }
const url = `${API_BASE_URL}${endpoint}`; const baseUrl = getAdminApiBaseUrl();
const url = `${baseUrl}${endpoint}`;
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
headers, headers,
+19 -2
View File
@@ -10,7 +10,23 @@ import type {
DocStatus, DocStatus,
} from "../content/types"; } 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; const FETCH_TIMEOUT_MS = 3000;
interface APIResponse<T> { interface APIResponse<T> {
@@ -142,7 +158,8 @@ async function apiGet<T>(path: string): Promise<T | null> {
try { try {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); 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, signal: controller.signal,
cache: "no-store", cache: "no-store",
}); });