From 801386a8edcaee84bc376dc893e76f1557ebf7cc Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 17 Sep 2026 21:55:16 +0700 Subject: [PATCH] feat : overwite buzz.sudloh.com/guide --- .gitea/workflows/ci.yml | 52 ++++++++++ Dockerfile | 4 + README.md | 15 ++- app/[character]/[page]/page.tsx | 4 +- app/[character]/page.tsx | 2 + app/layout.tsx | 3 + app/page.tsx | 6 ++ app/robots.ts | 3 + app/thanks/page.tsx | 7 +- components/deployment-update-notifier.tsx | 3 +- components/live-refresh.tsx | 3 +- components/public/constellation-image.tsx | 3 +- components/public/guide-export-branding.tsx | 3 +- components/public/material-poster.tsx | 3 +- components/public/site-header.tsx | 4 +- components/public/structured-guide.tsx | 13 +-- hooks/use-presence.ts | 3 +- k8s.md | 6 ++ k8s/base/ci-rbac.yaml | 2 +- k8s/base/ingress.yaml | 10 ++ k8s/base/kustomization.yaml | 7 ++ k8s/base/mirror-deployment.yaml | 100 ++++++++++++++++++++ k8s/base/mirror-hpa.yaml | 31 ++++++ k8s/base/mirror-pdb.yaml | 12 +++ k8s/base/mirror-service.yaml | 17 ++++ lib/base-path.ts | 5 + lib/navigation.ts | 14 +-- next.config.ts | 1 + package.json | 2 +- proxy.ts | 19 ++++ tests/deployment-contract.test.ts | 42 +++++++- 31 files changed, 372 insertions(+), 27 deletions(-) create mode 100644 k8s/base/mirror-deployment.yaml create mode 100644 k8s/base/mirror-hpa.yaml create mode 100644 k8s/base/mirror-pdb.yaml create mode 100644 k8s/base/mirror-service.yaml create mode 100644 lib/base-path.ts create mode 100644 proxy.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 8255476..7a7bd9e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -16,8 +16,40 @@ env: KUBE_API_SERVER: https://100.112.189.33:6443/ jobs: + verify: + name: Verify application and manifests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Test + run: bun run test + + - name: Typecheck + run: bun run typecheck + + - name: Lint + run: bun run lint + + - name: Set up kubectl + uses: azure/setup-kubectl@v4 + + - name: Validate Kubernetes manifests + run: kubectl kustomize k8s/ >/dev/null + build-and-deploy: name: Build immutable images and deploy + needs: verify if: >- gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' && @@ -40,6 +72,20 @@ jobs: . docker push ${{ env.REGISTRY_IMAGE }}:${{ gitea.sha }} + - name: Build and push public mirror image + run: | + docker build \ + --target app \ + --build-arg BASE_URL="$BASE_URL" \ + --build-arg NEXT_PUBLIC_BASE_PATH=/guide \ + --build-arg NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID="$NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID" \ + --build-arg PUBLIC_MIRROR=true \ + --build-arg NEXT_DEPLOYMENT_ID=${{ gitea.sha }} \ + --build-arg VCS_REF=${{ gitea.sha }} \ + --tag ${{ env.REGISTRY_IMAGE }}:mirror-${{ gitea.sha }} \ + . + docker push ${{ env.REGISTRY_IMAGE }}:mirror-${{ gitea.sha }} + - name: Build and push migration image run: | docker build \ @@ -138,6 +184,9 @@ jobs: kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \ deployment/buzz-sheet \ app="$REGISTRY_IMAGE:$REVISION" + kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \ + deployment/buzz-sheet-mirror \ + app="$REGISTRY_IMAGE:mirror-$REVISION" kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \ deployment/buzz-sheet-worker \ worker="$REGISTRY_IMAGE:$REVISION" @@ -147,6 +196,9 @@ jobs: kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \ deployment/buzz-sheet \ --timeout=10m + kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \ + deployment/buzz-sheet-mirror \ + --timeout=10m kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \ deployment/buzz-sheet-worker \ --timeout=10m diff --git a/Dockerfile b/Dockerfile index 4f44637..1537f93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,10 +23,14 @@ FROM node:22-bookworm-slim AS next-builder WORKDIR /app ARG NEXT_DEPLOYMENT_ID=container-build ARG BASE_URL +ARG NEXT_PUBLIC_BASE_PATH ARG NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID +ARG PUBLIC_MIRROR=false ENV BASE_URL=${BASE_URL} ENV NEXT_DEPLOYMENT_ID=${NEXT_DEPLOYMENT_ID} +ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH} ENV NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID} +ENV PUBLIC_MIRROR=${PUBLIC_MIRROR} ENV NEXT_TELEMETRY_DISABLED=1 COPY --from=dependencies /app/node_modules ./node_modules COPY . . diff --git a/README.md b/README.md index a504708..974d02b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ Buzz Sheet is a Thai-first guide CMS for turning structured character, build, team, rotation, and comparison data into responsive game-guide pages. -Production target: `https://guide.sudloh.com` +Production targets: `https://guide.sudloh.com` and the public-only mirror at +`https://buzz.sudloh.com/guide`. This project is an independent community tool for Genshin Impact players. It is not affiliated with or endorsed by HoYoverse. @@ -181,7 +182,8 @@ Kustomize resources live in `k8s/` and define: - Namespace `buzz-sheet`. - Two rolling web replicas, one outbox worker, and one Discord catalog worker. -- ClusterIP port 3000 and Traefik ingress for `guide.sudloh.com`. +- Separate root and `/guide` web workloads with Traefik ingress for + `guide.sudloh.com` and `buzz.sudloh.com/guide`. - Cloudflare edge TLS with the cluster's standard HTTP Traefik origin route. - Startup, liveness, and dependency-aware readiness probes. - Web requests of 500m CPU/1 GiB and limits of 1 CPU/2 GiB. @@ -219,12 +221,21 @@ Every page also subscribes to `/api/active`. When a serving pod reports a newer deployment ID than the browser's current build, a persistent Thai notification offers a full reload into the new release. +The `buzz.sudloh.com/guide` build sets `NEXT_PUBLIC_BASE_PATH=/guide` and +`PUBLIC_MIRROR=true`. It shares public data with the canonical site but returns +404 for admin, registration, authentication, and admin API routes. The original +`guide.sudloh.com` build and its admin routes remain unchanged. + The base manifests contain deliberate `replace-me` image and deployment values. For initial bootstrap, the platform operator applies `k8s/base` through an environment overlay that supplies an existing immutable image revision. The aggregate `k8s/` kustomization includes the migration resource for rendering and validation; normal releases create their own revision-named migration Job. +The public mirror adds new Deployment, Service, HPA, PDB, and Ingress resources. +A cluster administrator must apply those base resources once before the first CI +release that attempts to roll out `deployment/buzz-sheet-mirror`. + Use this command for local manifest validation only: ```bash diff --git a/app/[character]/[page]/page.tsx b/app/[character]/[page]/page.tsx index 3fee79f..8271a40 100644 --- a/app/[character]/[page]/page.tsx +++ b/app/[character]/[page]/page.tsx @@ -4,18 +4,20 @@ import { notFound } from "next/navigation"; import { GoogleAdsense } from "@/components/public/google-adsense"; import { publicSections, StructuredGuideShell } from "@/components/public/structured-guide"; import { getCachedPublicGuide } from "@/lib/cache/public"; +import { siteUrl } from "@/lib/site-url"; type Params = Promise<{ character: string; page: string }>; export const instant = false; export async function generateMetadata({ params }: { params: Params }): Promise { - const { character } = await params; + const { character, page } = await params; const guide = await getCachedPublicGuide(character); return guide ? { title: `${guide.name}`, description: guide.overview, + alternates: { canonical: `${siteUrl}/${encodeURIComponent(guide.slug)}/${encodeURIComponent(page)}` }, openGraph: { images: [guide.coverUrl] }, twitter: { images: [guide.coverUrl] }, } diff --git a/app/[character]/page.tsx b/app/[character]/page.tsx index f70b63f..88b0d69 100644 --- a/app/[character]/page.tsx +++ b/app/[character]/page.tsx @@ -4,6 +4,7 @@ import { notFound } from "next/navigation"; import { GoogleAdsense } from "@/components/public/google-adsense"; import { StructuredGuideShell } from "@/components/public/structured-guide"; import { getCachedPublicGuide } from "@/lib/cache/public"; +import { siteUrl } from "@/lib/site-url"; type Params = Promise<{ character: string }>; @@ -19,6 +20,7 @@ export async function generateMetadata({ ? { title: `${guide.name}`, description: guide.overview, + alternates: { canonical: `${siteUrl}/${encodeURIComponent(guide.slug)}` }, openGraph: { images: [guide.coverUrl] }, twitter: { images: [guide.coverUrl] }, } diff --git a/app/layout.tsx b/app/layout.tsx index 5f9006f..9a6c2b4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -27,6 +27,9 @@ export const metadata: Metadata = { }, description: "ไกด์ตัวละครจากช่องเกนชินไม่ใช่เกมมือถือ", keywords: ["เกนชิน", "เกนชินไม่ใช่เกมมือถือ", "Buzz", "ไกด์", "สุดหล่อ"], + robots: process.env.PUBLIC_MIRROR === "true" + ? { index: false, follow: false } + : undefined, }; export default function RootLayout({ children }: LayoutProps<"/">) { diff --git a/app/page.tsx b/app/page.tsx index 0ab4767..7064164 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,6 +7,12 @@ import { SiteFooter } from "@/components/public/site-footer"; import { StructuredDirectory } from "@/components/public/structured-directory"; import { SiteHeader } from "@/components/public/site-header"; import { getCachedPublicGuideCards } from "@/lib/cache/public"; +import { siteUrl } from "@/lib/site-url"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + alternates: { canonical: siteUrl }, +}; async function DirectoryContent() { await connection(); diff --git a/app/robots.ts b/app/robots.ts index 96aa6f5..c817498 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -3,6 +3,9 @@ import type { MetadataRoute } from "next"; import { siteUrl } from "@/lib/site-url"; export default function robots(): MetadataRoute.Robots { + if (process.env.PUBLIC_MIRROR === "true") { + return { rules: { userAgent: "*", disallow: "/" } }; + } return { rules: { userAgent: "*", diff --git a/app/thanks/page.tsx b/app/thanks/page.tsx index 1fb89e5..380e524 100644 --- a/app/thanks/page.tsx +++ b/app/thanks/page.tsx @@ -10,6 +10,8 @@ import { } from "@/components/ui/avatar"; import { SiteFooter } from "@/components/public/site-footer"; import { Button } from "@/components/ui/button"; +import { withBasePath } from "@/lib/base-path"; +import { siteUrl } from "@/lib/site-url"; import { Card, CardContent, @@ -57,6 +59,7 @@ const specialThanks: ThanksProfile[] = [ export const metadata: Metadata = { title: "Special Thanks", description: "ขอบคุณทุกคนที่มีส่วนช่วยให้ Buzz Guide เกิดขึ้นได้", + alternates: { canonical: `${siteUrl}/thanks` }, }; function getInitials(username: string) { @@ -73,7 +76,7 @@ export default function ThanksPage() { className="flex min-h-11 items-center gap-3 rounded-lg font-heading font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > Astral Development
 { - const source = new EventSource("/api/active"); + const source = new EventSource(withBasePath("/api/active")); const receiveDeployment = (event: Event) => { if ( updateAvailable.current || diff --git a/components/live-refresh.tsx b/components/live-refresh.tsx index 350561c..c3ebe3f 100644 --- a/components/live-refresh.tsx +++ b/components/live-refresh.tsx @@ -5,13 +5,14 @@ import { useEffect } from "react"; import { parseInvalidationEvent } from "@/lib/events/invalidation"; import { createRefreshScheduler } from "@/lib/events/refresh"; +import { withBasePath } from "@/lib/base-path"; export function LiveRefresh({ endpoint }: { endpoint: string }) { const router = useRouter(); useEffect(() => { const scheduler = createRefreshScheduler(() => router.refresh()); - const source = new EventSource(endpoint); + const source = new EventSource(withBasePath(endpoint)); const connected = () => scheduler.connected(); const notified = (message: Event) => { if (!(message instanceof MessageEvent) || typeof message.data !== "string") { diff --git a/components/public/constellation-image.tsx b/components/public/constellation-image.tsx index d42381d..11e5621 100644 --- a/components/public/constellation-image.tsx +++ b/components/public/constellation-image.tsx @@ -1,4 +1,5 @@ import Image from "next/image"; +import { withBasePath } from "@/lib/base-path"; import { GuideExportBranding } from "@/components/public/guide-export-branding"; import { ExportText } from "@/components/public/export-language"; @@ -27,7 +28,7 @@ export function ConstellationImage({
) : null} - + ไกด์เกนชินไม่ใช่เกมมือถือ