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"
"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 == "" {
+1 -1
View File
@@ -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 != "" {
+3 -2
View File
@@ -8,8 +8,9 @@ import (
// RequireAdminToken returns a Gin middleware that validates Bearer <token> 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
+31 -5
View File
@@ -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<boolean | null>(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") {
+18 -5
View File
@@ -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<T>(
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,
+19 -2
View File
@@ -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<T> {
@@ -142,7 +158,8 @@ async function apiGet<T>(path: string): Promise<T | null> {
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",
});