diff --git a/.env.example b/.env.example
index d4bfe39..7ae63bd 100644
--- a/.env.example
+++ b/.env.example
@@ -1,9 +1,6 @@
# Public site origin used for SEO metadata, sitemap, and robots.txt.
BASE_URL=https://guide.sudloh.com
-# Public AdSense publisher client ID. Leave unset to disable ads locally.
-NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=ca-pub-9687404323559597
-
# PostgreSQL
DATABASE_URL=postgresql://buzz_sheet:replace-me@postgres.example.internal:5432/buzz_sheet?sslmode=require
DATABASE_POOL_SIZE=10
@@ -12,7 +9,7 @@ DATABASE_POOL_SIZE=10
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes
# Comma-separated site origins; also used for auth callback hosts and API mutations.
-# Production: https://guide.sudloh.com,https://sudloh.com
+# Production: https://guide.sudloh.com
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000
# Optional for local development; required by the production deployment.
GOOGLE_CLIENT_ID=replace-with-google-oauth-client-id
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 7f7756c..c6570a4 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -7,8 +7,7 @@ on:
env:
BASE_URL: https://guide.sudloh.com
- BETTER_AUTH_TRUSTED_ORIGINS: https://guide.sudloh.com,https://sudloh.com
- NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID: ca-pub-9687404323559597
+ BETTER_AUTH_TRUSTED_ORIGINS: https://guide.sudloh.com
REGISTRY_IMAGE: registry.neko-piranha.ts.net/astral/buzz-sheet
DEPLOY_NAMESPACE: buzz-sheet
KUBE_API_SERVER: https://100.112.189.33:6443/
@@ -69,7 +68,6 @@ jobs:
docker build \
--target app \
--build-arg BASE_URL="$BASE_URL" \
- --build-arg NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID="$NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID" \
--build-arg NEXT_DEPLOYMENT_ID=${{ gitea.sha }} \
--build-arg VCS_REF=${{ gitea.sha }} \
--tag ${{ env.REGISTRY_IMAGE }}:${{ gitea.sha }} \
diff --git a/Dockerfile b/Dockerfile
index df6b74a..d833cce 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -26,10 +26,8 @@ FROM node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e97188
WORKDIR /app
ARG NEXT_DEPLOYMENT_ID=container-build
ARG BASE_URL
-ARG NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID
ENV BASE_URL=${BASE_URL}
ENV NEXT_DEPLOYMENT_ID=${NEXT_DEPLOYMENT_ID}
-ENV NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID}
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
diff --git a/README.md b/README.md
index 9f9806b..7dc648d 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
A Thai-first Genshin Impact guide CMS for publishing character builds, teams,
rotations, and comparisons from structured data.
-**Live site:** [guide.sudloh.com](https://guide.sudloh.com) and [sudloh.com](https://sudloh.com)
+**Live site:** [guide.sudloh.com](https://guide.sudloh.com)
Buzz Guide is an independent community project and is not affiliated with or
endorsed by HoYoverse. The repository and deployment resources retain the
@@ -296,7 +296,7 @@ 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` and `sudloh.com`.
+- ClusterIP port 3000 and Traefik ingress for `guide.sudloh.com`.
- 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.
@@ -352,15 +352,13 @@ ConfigMap or commit them to this repository.
Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in the application environment.
In Google Cloud, register `{BETTER_AUTH_URL}/api/auth/callback/google` as an
authorized redirect URI for each environment (for production,
-`https://guide.sudloh.com/api/auth/callback/google` and
-`https://sudloh.com/api/auth/callback/google`).
+`https://guide.sudloh.com/api/auth/callback/google`).
-Both production domains serve the same application. `BASE_URL` remains
-`https://guide.sudloh.com` for canonical metadata and generated links.
-`BETTER_AUTH_TRUSTED_ORIGINS` lists both HTTPS origins so sign-in callbacks and
-API mutations work on either domain. Sessions use separate host cookies.
-Point `sudloh.com` at the same Cloudflare/Traefik origin as `guide.sudloh.com`,
-enable edge TLS, and allow both hostnames in the Cloudflare Turnstile widget.
+The production application is served only at `guide.sudloh.com`. `BASE_URL` and
+`BETTER_AUTH_TRUSTED_ORIGINS` use `https://guide.sudloh.com` for canonical metadata,
+generated links, sign-in callbacks, and API mutations. Point `guide.sudloh.com`
+at the Cloudflare/Traefik origin, enable edge TLS, and allow that hostname in the
+Cloudflare Turnstile widget. The bare `sudloh.com` domain is not routed to this app.
For an existing cluster, apply the updated CI role once before the next release:
`kubectl apply --namespace buzz-sheet --filename k8s/base/ci-rbac.yaml`.
CI updates the existing ingress and trusted origins during each rollout.
diff --git a/app/[character]/[page]/page.tsx b/app/[character]/[page]/page.tsx
index 0794904..1bc2c08 100644
--- a/app/[character]/[page]/page.tsx
+++ b/app/[character]/[page]/page.tsx
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
-import { GoogleAdsense } from "@/components/public/google-adsense";
import { publicSections, StructuredGuideShell } from "@/components/public/structured-guide";
import { getCachedPublicGuide, getCachedPublicGuideSummary } from "@/lib/cache/public";
@@ -26,10 +25,5 @@ export default async function GuideSectionPage({ params }: { params: Params }) {
const { character, page } = await params;
const guide = await getCachedPublicGuide(character, page);
if (!guide || !publicSections(guide).some((section) => section.slug === page)) notFound();
- return (
- <>
-
-
- >
- );
+ return ;
}
diff --git a/app/[character]/page.tsx b/app/[character]/page.tsx
index ce98877..6fd3bf9 100644
--- a/app/[character]/page.tsx
+++ b/app/[character]/page.tsx
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
-import { GoogleAdsense } from "@/components/public/google-adsense";
import { StructuredGuideShell } from "@/components/public/structured-guide";
import { getCachedPublicGuide, getCachedPublicGuideSummary } from "@/lib/cache/public";
@@ -32,10 +31,5 @@ export default async function GuideOverviewPage({
}) {
const guide = await getCachedPublicGuide((await params).character);
if (!guide) notFound();
- return (
- <>
-
-
- >
- );
+ return ;
}
diff --git a/app/layout.tsx b/app/layout.tsx
index ed7338e..94cbe04 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -29,7 +29,6 @@ export const metadata: Metadata = {
},
description: "ไกด์ตัวละครจากช่องเกนชินไม่ใช่เกมมือถือ",
keywords: ["เกนชิน", "เกนชินไม่ใช่เกมมือถือ", "Buzz", "ไกด์", "สุดหล่อ"],
- other: { "google-adsense-account": "ca-pub-9687404323559597" },
icons: { apple: "/icon-192.png" },
appleWebApp: { capable: true, title: "Buzz Guide" },
};
diff --git a/app/page.tsx b/app/page.tsx
index 2fc7992..982dc94 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -3,7 +3,6 @@ import { Suspense } from "react";
import { AccountLinkErrorRedirect } from "@/components/auth/account-link-error-redirect";
import { LiveRefresh } from "@/components/live-refresh";
-import { GoogleAdsense } from "@/components/public/google-adsense";
import { DirectorySkeleton } from "@/components/public/directory-skeleton";
import { SiteFooter } from "@/components/public/site-footer";
import { StructuredDirectory } from "@/components/public/structured-directory";
@@ -19,7 +18,6 @@ async function DirectoryContent() {
export default function HomePage() {
return (
-
diff --git a/components/public/google-adsense.tsx b/components/public/google-adsense.tsx
deleted file mode 100644
index 6bbad9c..0000000
--- a/components/public/google-adsense.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-const clientId = process.env.NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID?.trim();
-
-export function GoogleAdsense() {
- if (process.env.NODE_ENV !== "production" || !clientId) {
- return null;
- }
-
- return (
-
- );
-}
diff --git a/k8s/base/configmap.yaml b/k8s/base/configmap.yaml
index af56c03..a4ed8ad 100644
--- a/k8s/base/configmap.yaml
+++ b/k8s/base/configmap.yaml
@@ -7,7 +7,7 @@ metadata:
data:
BASE_URL: https://guide.sudloh.com
BETTER_AUTH_URL: https://guide.sudloh.com
- BETTER_AUTH_TRUSTED_ORIGINS: https://guide.sudloh.com,https://sudloh.com
+ BETTER_AUTH_TRUSTED_ORIGINS: https://guide.sudloh.com
# Traefik supplies X-Forwarded-For and does not trust incoming forwarded headers.
# The network policy allows only Traefik to reach the app.
TRUSTED_CLIENT_IP_HEADER: x-forwarded-for
diff --git a/k8s/base/ingress.yaml b/k8s/base/ingress.yaml
index ccb93e4..ce71ad3 100644
--- a/k8s/base/ingress.yaml
+++ b/k8s/base/ingress.yaml
@@ -9,16 +9,6 @@ metadata:
spec:
ingressClassName: traefik
rules:
- - host: sudloh.com
- http:
- paths:
- - path: /
- pathType: Prefix
- backend:
- service:
- name: buzz-sheet
- port:
- number: 3000
- host: guide.sudloh.com
http:
paths:
diff --git a/lib/security/headers.ts b/lib/security/headers.ts
index b9b674f..7730d40 100644
--- a/lib/security/headers.ts
+++ b/lib/security/headers.ts
@@ -1,16 +1,14 @@
export function securityHeaders(production: boolean, emojiImageCdn: string) {
const commentStorageOrigin = process.env.S3_PUBLIC_URL ? new URL(process.env.S3_PUBLIC_URL).origin : "";
- const ads = "https://*.googlesyndication.com https://*.doubleclick.net https://*.googleadservices.com https://*.googletagservices.com https://www.google.com";
const cloudflareAnalytics = "https://static.cloudflareinsights.com";
- const adConnections = "https://*.adtrafficquality.google";
const policy = [
"default-src 'self'",
- `script-src 'self' 'unsafe-inline' ${production ? "" : "'unsafe-eval'"} https://challenges.cloudflare.com ${cloudflareAnalytics} ${adConnections} ${ads}`,
+ `script-src 'self' 'unsafe-inline' ${production ? "" : "'unsafe-eval'"} https://challenges.cloudflare.com ${cloudflareAnalytics}`,
"style-src 'self' 'unsafe-inline'",
- `img-src 'self' data: blob: https://buzz-cdn.astrxl.dev ${commentStorageOrigin} ${emojiImageCdn} ${ads}`,
+ `img-src 'self' data: blob: https://buzz-cdn.astrxl.dev ${commentStorageOrigin} ${emojiImageCdn}`,
"font-src 'self' data:",
- `connect-src 'self' https://buzz-cdn.astrxl.dev https://challenges.cloudflare.com ${adConnections} ${ads}${production ? "" : " ws: wss:"}`,
- `frame-src https://challenges.cloudflare.com https://www.youtube-nocookie.com ${ads}`,
+ `connect-src 'self' https://buzz-cdn.astrxl.dev https://challenges.cloudflare.com${production ? "" : " ws: wss:"}`,
+ "frame-src https://challenges.cloudflare.com https://www.youtube-nocookie.com",
"media-src 'self' blob:",
"worker-src 'self' blob:",
"object-src 'none'",
diff --git a/public/ads.txt b/public/ads.txt
deleted file mode 100644
index 1ea23ef..0000000
--- a/public/ads.txt
+++ /dev/null
@@ -1 +0,0 @@
-google.com, pub-9687404323559597, DIRECT, f08c47fec0942fa0
diff --git a/tests/deployment-contract.test.ts b/tests/deployment-contract.test.ts
index 4ad2e79..552ad97 100644
--- a/tests/deployment-contract.test.ts
+++ b/tests/deployment-contract.test.ts
@@ -25,10 +25,12 @@ describe("production deployment contract", () => {
expect(deployment).toContain('drop: ["ALL"]');
});
- it("exposes only the app through both edge-TLS hosts", async () => {
- const [service, ingress] = await Promise.all([
+ it("exposes the app only through the guide edge-TLS host", async () => {
+ const [service, ingress, config, workflow] = await Promise.all([
repositoryFile("k8s/base/service.yaml"),
repositoryFile("k8s/base/ingress.yaml"),
+ repositoryFile("k8s/base/configmap.yaml"),
+ repositoryFile(".gitea/workflows/ci.yml"),
]);
expect(service).toContain("type: ClusterIP");
@@ -36,7 +38,10 @@ describe("production deployment contract", () => {
expect(service).toContain("targetPort: http");
expect(ingress).toContain("ingressClassName: traefik");
expect(ingress).toContain("host: guide.sudloh.com");
- expect(ingress).toContain("host: sudloh.com");
+ expect(ingress).not.toContain("host: sudloh.com");
+ for (const configuration of [config, workflow]) {
+ expect(configuration).toMatch(/^ BETTER_AUTH_TRUSTED_ORIGINS: https:\/\/guide\.sudloh\.com$/mu);
+ }
expect(ingress).toContain(
'traefik.ingress.kubernetes.io/read-timeout: "200"',
);
@@ -100,10 +105,7 @@ describe("production deployment contract", () => {
expect(dockerfile).toContain('CMD ["bun", "server.js"]');
expect(dockerfile).toContain("ARG BASE_URL");
expect(dockerfile).toContain("ENV BASE_URL=${BASE_URL}");
- expect(dockerfile).toContain("ARG NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID");
- expect(dockerfile).toContain(
- "ENV NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID}",
- );
+ expect(dockerfile).not.toContain("NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID");
expect(dockerfile).toContain("backend/discord.ts");
expect(dockerfile).toMatch(
/bun build scripts\/outbox-worker\.ts[\s\S]*?--conditions=react-server[\s\S]*?--outfile=dist\/outbox-worker\.js/u,
@@ -137,9 +139,7 @@ describe("production deployment contract", () => {
expect(workflow).toContain("--insecure-skip-tls-verify=true");
expect(workflow).toContain("--target app");
expect(workflow).toContain('--build-arg BASE_URL="$BASE_URL"');
- expect(workflow).toContain(
- '--build-arg NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID="$NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID"',
- );
+ expect(workflow).not.toContain("NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID");
expect(workflow).toContain('\\"BASE_URL\\":\\"$BASE_URL\\"');
expect(workflow).toContain("--target migration");
expect(workflow).not.toContain("platforms: linux/arm64");
@@ -228,9 +228,7 @@ describe("environment template contract", () => {
expect(environmentExample).toMatch(new RegExp(`^${key}=`, "mu"));
}
expect(environmentExample).not.toContain("BUZZ_DEMO_MODE");
- expect(environmentExample).toContain(
- "NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=ca-pub-9687404323559597",
- );
+ expect(environmentExample).not.toContain("NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID");
});
it("runs Discord catalog sync as an isolated worker", async () => {
diff --git a/tests/public-performance.test.ts b/tests/public-performance.test.ts
index 06842db..d3a2aca 100644
--- a/tests/public-performance.test.ts
+++ b/tests/public-performance.test.ts
@@ -59,34 +59,25 @@ describe("public rendering performance", () => {
});
});
- it("loads AdSense for every visitor, including signed-in admins", async () => {
- const [adsense, home, guide, guideSection, adsTxt] = await Promise.all([
- repositoryFile("components/public/google-adsense.tsx"),
+ it("keeps public pages free of AdSense", async () => {
+ const pages = await Promise.all([
+ repositoryFile("app/layout.tsx"),
repositoryFile("app/page.tsx"),
repositoryFile("app/[character]/page.tsx"),
repositoryFile("app/[character]/[page]/page.tsx"),
- repositoryFile("public/ads.txt"),
]);
- expect(adsense).not.toContain("authClient.useSession()");
- expect(adsense).not.toContain('from "@/lib/auth/client"');
- expect(adsense).toContain('process.env.NODE_ENV !== "production"');
- expect(adsense).toContain("pagead2.googlesyndication.com");
- expect(adsense).toContain("");
- expect(guide).toContain("");
- expect(guideSection).toContain("");
- expect(adsTxt.trim()).toBe(
- "google.com, pub-9687404323559597, DIRECT, f08c47fec0942fa0",
- );
+ for (const page of pages) {
+ expect(page).not.toMatch(/adsense|googlesyndication|ca-pub-/iu);
+ }
+ await expect(repositoryFile("public/ads.txt")).rejects.toMatchObject({ code: "ENOENT" });
});
- it("allows the production analytics and advertising endpoints", async () => {
+ it("allows production analytics without advertising endpoints", async () => {
const headers = await repositoryFile("lib/security/headers.ts");
expect(headers).toContain("https://static.cloudflareinsights.com");
- expect(headers).toContain("https://*.adtrafficquality.google");
+ expect(headers).not.toMatch(/googlesyndication|doubleclick|googleadservices|googletagservices|adtrafficquality/u);
});
it("accepts Next.js deployment metadata on the document root", async () => {