Compare commits

..
17 Commits
Author SHA1 Message Date
robot db402f8192 Merge pull request 'fix(ssr): remove color defaults from :root in _themes.scss' (#12) from fix/remove-root-color-defaults into main
CI Pipeline / backend-test (push) Successful in 9m52s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Successful in 29s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
2026-08-09 00:17:03 -07:00
robot bb45e8d6ae fix(ssr): inject derived gradients and migrate component SCSS to CSS vars
CI Pipeline / backend-test (push) Successful in 9m52s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / backend-test (pull_request) Successful in 9m59s
CI Pipeline / frontend-test (pull_request) Successful in 23s
CI Pipeline / frontend-build (pull_request) Successful in 31s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
The server-injected inline <style> was missing the three derived gradient
variables (--sky-gradient, --sunset-gradient, --mixed-gradient) that
components reference via var(). Without them, components fell back to
SCSS build-time defaults on the first paint, causing a visible flash
before ThemeService.apply() corrected the values.

Additionally, migrate three component SCSS files from hardcoded SCSS
color variables to CSS custom properties so the first paint uses the
server-injected runtime colors:
- login.component.scss: backgrounds, text colors, borders, focus rings
- events.component.scss: event date card background gradient
- about.component.scss: donation CTA background gradient
2026-08-09 06:49:24 +00:00
robot e78a725839 fix(ssr): remove color defaults from :root in _themes.scss
CI Pipeline / backend-test (push) Successful in 9m55s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Successful in 29s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / backend-test (pull_request) Successful in 9m56s
CI Pipeline / frontend-test (pull_request) Successful in 23s
CI Pipeline / frontend-build (pull_request) Successful in 30s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 1s
The SSR server injects fresh color CSS custom properties as an inline
<style> block before </head>, but _themes.scss also set hardcoded
color defaults on :root at build time. The two :root blocks compete
in the cascade — the compiled stylesheet (loaded after the inline
<style>) overwrites the server-injected colors, causing a visible
flash where wrong colors render briefly before ThemeService.apply()
corrects them.

Removing the color properties from :root in _themes.scss makes the
server-injected <style> the sole source of truth for colors. Non-color
properties (fonts, radii, shadows) remain as build-time defaults since
they don't change between deploys.
2026-08-09 05:55:28 +00:00
kfj001 849856aecb Merge pull request 'feat(ssr): spider-only SSR serve CSR shell for regular users' (#11) from bugfix/spider-ssr-gate into main
CI Pipeline / backend-test (push) Successful in 20m11s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Successful in 31s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 2s
Reviewed-on: http://192.168.1.74:30008/kfj001/kmtnflower/pulls/11
Reviewed-by: kfj001 <kfj001@gmail.com>
2026-08-08 21:11:31 -07:00
robot 1b387700d3 fix(ssr): prefer index.csr.html over prerendered index.html for CSR users
CI Pipeline / backend-test (push) Successful in 20m24s
CI Pipeline / frontend-test (push) Successful in 25s
CI Pipeline / frontend-build (push) Successful in 31s
CI Pipeline / backend-lint (push) Successful in 11s
CI Pipeline / backend-test (pull_request) Successful in 20m14s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / frontend-build (pull_request) Successful in 32s
CI Pipeline / backend-lint (pull_request) Successful in 11s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 4s
The browserIndexPath() function was resolving index.html first, which is
the prerendered output with stale build-time content baked into <app-root>.
Even with window.__STATION_CONFIG injection, the prerendered innerHTML
still shows old content on the first paint until Angular re-renders.

By preferring index.csr.html (the pure CSR shell with empty <app-root>),
regular users get a clean first paint — no stale prerendered DOM to flash.
2026-08-09 01:13:50 +00:00
robot b20fefc432 chore: add placeholder angular-app-engine-manifest.mjs for tests
CI Pipeline / backend-test (push) Successful in 19m58s
CI Pipeline / frontend-test (push) Successful in 25s
CI Pipeline / frontend-build (push) Successful in 32s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / backend-test (pull_request) Successful in 20m7s
CI Pipeline / frontend-test (pull_request) Successful in 25s
CI Pipeline / frontend-build (pull_request) Successful in 32s
CI Pipeline / backend-lint (pull_request) Successful in 11s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 3s
2026-08-09 00:56:29 +00:00
robot e44f27d1cd fix(ssr): inject fresh station config into CSR shell to eliminate flash
CI Pipeline / backend-test (push) Successful in 18m48s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 32s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / backend-test (pull_request) Successful in 20m7s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / frontend-build (pull_request) Successful in 32s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 4s
The CSR shell previously rendered with hardcoded DEFAULT_CONFIG values,
then the client-side API call replaced them 50-500ms later, causing a
visible 'flash of stale content' (wrong colors, wrong station name).

Fix: SSR server fetches live station config from the backend API before
serving the CSR shell, injecting it as:
- Inline <script> seeding window.__STATION_CONFIG (client reads sync)
- Inline <style> setting CSS custom properties (correct colors first paint)

Also adds no-cache Cache-Control headers on HTML page responses to
prevent browsers from caching stale content.

StationConfigService now reads window.__STATION_CONFIG on construction
instead of always starting with DEFAULT_CONFIG.
2026-08-09 00:53:50 +00:00
robot 9561339cf5 fix(ssr): resolve index.html from production path for CSR shell
CI Pipeline / backend-test (push) Successful in 20m50s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 32s
CI Pipeline / backend-lint (push) Successful in 15s
CI Pipeline / backend-test (pull_request) Successful in 37m43s
CI Pipeline / frontend-test (pull_request) Successful in 45s
CI Pipeline / frontend-build (pull_request) Successful in 39s
CI Pipeline / backend-lint (pull_request) Successful in 16s
CI Pipeline / quality-gate (push) Successful in 5s
CI Pipeline / quality-gate (pull_request) Successful in 4s
The CSR shell path was hardcoded to a local dev directory
(app/server/browser/index.html) which does not exist in the
Docker container. Browser files are copied to /usr/share/nginx/html
by the Dockerfile. browserIndexPath() now checks both production
and development paths, falling back to the first that exists.
2026-08-08 20:25:29 +00:00
Hermes Robot c5d80f4caa feat(ssr): spider-only SSR — serve CSR shell for regular users
CI Pipeline / backend-test (push) Successful in 12m23s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 31s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / backend-test (pull_request) Successful in 17m40s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / frontend-build (pull_request) Successful in 31s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 3s
CI Pipeline / quality-gate (pull_request) Successful in 3s
Add spider detection gate to SSR server so that search engine
crawlers get full server-side rendering with fresh API data,
while regular users receive the static CSR app shell from disk.

- backend/app/spider_detection.py: Python spider detection module
- spider_detection.ts: TypeScript mirror of spider detection patterns
- server.ts: SSR gate — isSpider() check before dispatching to
  Angular SSR engine; non-spiders get static index.html
- server.spec.ts: Added spider detection unit tests
- tsconfig.app.json: Include spider_detection.ts in compilation
- backend/app/models.py: Fix SQLAlchemy FK resolution (user_models import)
2026-08-08 17:48:37 +00:00
kfj001 96011ef200 Merge pull request 'fix(ssr): fetch station config during server-side rendering' (#10) from bugfix/ssr-fetch-config-on-server into main
CI Pipeline / backend-test (push) Successful in 5m3s
CI Pipeline / frontend-test (push) Successful in 22s
CI Pipeline / frontend-build (push) Successful in 29s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / quality-gate (push) Successful in 2s
Reviewed-on: http://192.168.1.74:30008/kfj001/kmtnflower/pulls/10
Reviewed-by: kfj001 <kfj001@gmail.com>
2026-08-08 08:03:52 -07:00
robot 54bd869042 fix(ssr): fetch station config during server-side rendering
CI Pipeline / backend-test (push) Successful in 5m18s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Successful in 29s
CI Pipeline / backend-lint (push) Successful in 7s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
CI Pipeline / backend-test (pull_request) Successful in 5m14s
CI Pipeline / frontend-test (pull_request) Successful in 22s
CI Pipeline / frontend-build (pull_request) Successful in 29s
CI Pipeline / backend-lint (pull_request) Successful in 8s
The isPlatformBrowser guard in App.ngOnInit() was blocking
stationConfig.load() on the server, so SSR always rendered with
DEFAULT_CONFIG (hardcoded colors, assets, content).

Two-part fix:
- main.server.ts: Add APP_INITIALIZER that blocks server rendering
  until station config is fetched from the API. Only runs on the
  server — is a no-op in the browser to avoid blocking the client.
- app.ts: Move stationConfig.load() before the isPlatformBrowser
  guard so ngOnInit also calls it on the server as a safety net.

This ensures SSR HTML contains real DB values (colors, hero icon,
station name, etc.) instead of build-time defaults — eliminating
the stale-asset flash reported by users.
2026-08-08 12:10:05 +00:00
kfj001 5b1006d321 Merge pull request 'fix: ensure SSR always fetches fresh assets from database' (#9) from bugfix/ssr-asset-freshness into main
CI Pipeline / backend-test (push) Successful in 10m25s
CI Pipeline / frontend-test (push) Successful in 25s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 14s
CI Pipeline / quality-gate (push) Successful in 2s
Reviewed-on: http://192.168.1.74:30008/kfj001/kmtnflower/pulls/9
2026-08-07 16:43:16 -07:00
DevOps QA 1db84a384f fix(ssr): manually register engine manifest to fix 404 on all routes
CI Pipeline / backend-test (push) Successful in 9m49s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / backend-test (pull_request) Successful in 9m38s
CI Pipeline / frontend-test (pull_request) Successful in 23s
CI Pipeline / frontend-build (pull_request) Successful in 31s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 3s
The Angular 21 builder generates angular-app-engine-manifest.mjs at build
time but does NOT inject the import into server.mjs. Without manually
importing and registering the manifest via ɵsetAngularAppEngineManifest,
AngularNodeAppEngine fails with 'Angular app engine manifest is not set'
and every request returns 404.

This matches the exact issue reported in PR #14 on staging where nginx
starts successfully but returns 'Not Found' for all paths.
2026-08-07 19:02:10 +00:00
DevOps QA 89cac854ae fix: use full nginx.conf instead of conf.d snippet to avoid directive nesting error
CI Pipeline / backend-test (push) Successful in 5m1s
CI Pipeline / frontend-test (push) Successful in 29s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 10s
CI Pipeline / backend-test (pull_request) Successful in 4m48s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / frontend-build (pull_request) Successful in 30s
CI Pipeline / backend-lint (pull_request) Successful in 10s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
Alpine nginx includes conf.d/*.conf inside the http{} block, but our
template contained map/log_format directives and a server{} block that
conflicted with the include structure. Switched to a complete standalone
nginx.conf with proper user/events/http nesting.
2026-08-07 14:47:48 +00:00
DevOps QA 8f0adf9299 fix: add gettext to Alpine image for envsubst in entrypoint
CI Pipeline / backend-test (push) Successful in 5m46s
CI Pipeline / frontend-test (push) Successful in 24s
CI Pipeline / frontend-build (push) Successful in 30s
CI Pipeline / backend-lint (push) Successful in 12s
CI Pipeline / backend-test (pull_request) Successful in 5m48s
CI Pipeline / frontend-test (pull_request) Successful in 22s
CI Pipeline / frontend-build (pull_request) Successful in 29s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
2026-08-07 06:38:28 +00:00
DevOps QA c4e852684e fix: use bracket notation for process.env access (TS5.9 strict)
CI Pipeline / backend-test (push) Successful in 5m31s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Successful in 29s
CI Pipeline / backend-lint (push) Successful in 9s
CI Pipeline / backend-test (pull_request) Successful in 5m42s
CI Pipeline / frontend-test (pull_request) Successful in 24s
CI Pipeline / frontend-build (pull_request) Successful in 29s
CI Pipeline / backend-lint (pull_request) Successful in 9s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
- src/main.server.ts: process.env['SSR_API_BASE_URL'] instead of
  process.env.SSR_API_BASE_URL to satisfy TS4111 index signature rule.
- server.spec.ts: update corresponding test assertions to match new syntax.
2026-08-07 06:03:02 +00:00
robot 328d067d97 fix: ensure SSR always fetches fresh assets from database
CI Pipeline / backend-test (push) Successful in 5m31s
CI Pipeline / frontend-test (push) Successful in 23s
CI Pipeline / frontend-build (push) Failing after 27s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / backend-test (pull_request) Successful in 5m24s
CI Pipeline / frontend-test (pull_request) Successful in 23s
CI Pipeline / frontend-build (pull_request) Failing after 27s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / quality-gate (pull_request) Successful in 2s
Root cause: Dockerfile copied only browser/ to nginx; server/server.mjs
was built but never deployed or executed. Prerender baked DEFAULT_CONFIG
into static HTML with no API at build time, causing stale content on
every initial paint.

Changes:
- Dockerfile: serve stage uses node:22-alpine, installs nginx via apk,
  copies both browser/ and server/ bundles from build stage
- docker-entrypoint.sh: starts Angular SSR Node process on port 4200,
  waits for readiness, exports SSR_API_BASE_URL, then exec nginx
- nginx.conf.template: adds upstream ssr_backend block (127.0.0.1:4200),
  proxies / to SSR backend, static assets served directly with 1y cache
- app.server.routes.ts: all routes switched from RenderMode.Prerender
  to RenderMode.Server for zero stale content
- server.ts: removed dead prerender code (PRERENDERED_ROUTES,
  isPrerenderedRoute, allowStaticRouteRender)
- main.server.ts: injects SSR-aware apiBaseUrl via APP_CONFIG
  (reads SSR_API_BASE_URL env var)
- server.spec.ts: updated 5 test suites + added 5 new suites (31 tests)
  covering server.ts cleanup, RenderMode.Server, entrypoint lifecycle,
  Dockerfile build, nginx upstream proxy, and API URL injection
- vitest.config.ts: added server.spec.ts to include pattern

Request flow: Browser -> nginx (port 80) -> SSR server (port 4200)
-> Angular SSR -> HttpClient -> localhost:80/api/station-config
-> nginx proxies to api:8000 -> fresh data rendered in HTML
2026-08-07 05:33:40 +00:00
21 changed files with 1421 additions and 213 deletions

No files matched your search

+3 -1
View File
@@ -7,8 +7,10 @@ COPY . .
RUN npx ng build --configuration production
# ── Serve stage ────────────────────────────────────────────────────────
FROM docker.io/library/nginx:alpine
FROM docker.io/library/node:22-alpine
RUN apk add --no-cache nginx gettext
COPY --from=build /app/dist/radio-station/browser /usr/share/nginx/html
COPY --from=build /app/dist/radio-station/server /app/server
COPY nginx.conf.template /etc/nginx/conf.d/nginx.conf.template
COPY config.json.template /usr/share/nginx/html/config.json.template
COPY docker-entrypoint.sh /docker-entrypoint.sh
+1
View File
@@ -0,0 +1 @@
export default {};
+5
View File
@@ -283,3 +283,8 @@ class MobileBuildArtifact(Base):
sha256 = Column(String(64), nullable=False)
size = Column(Integer, nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
# Import user_models so that User, Role, UserRole are registered on Base.metadata
# before SQLAlchemy validates cross-table foreign keys (e.g. mobile_build_requests.created_by_id -> users.id).
from app.user_models import * # noqa: F401,F403
+234
View File
@@ -0,0 +1,234 @@
"""Search engine spider / bot detection based on User-Agent headers.
Provides utilities to identify well-known search engine crawlers so the
application can make informed decisions (e.g., serving pre-rendered HTML
for SEO, rate-limiting bot traffic, or tracking crawl statistics).
Usage:
from app.spider_detection import is_spider, get_spider_info
if is_spider(user_agent):
spider = get_spider_info(user_agent)
print(f\"{spider.name} ({spider.engine}) detected\")
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
from typing import Optional
class SpiderEngine(str, Enum):
"""Well-known search engine crawlers."""
GOOGLE = "Google"
BING = "Bing"
YAHOO = "Yahoo"
DUCKDUCKGO = "DuckDuckGo"
BINGBOT = "Bing" # alias kept for naming clarity — maps to Bing engine
YANDEX = "Yandex"
BAIDU = "Baidu"
SOCK = "Sogou"
FACEBOT = "Facebook"
TWITTERBOT = "Twitter"
APPLEBOT = "Apple"
PETLEO = "PetleO"
IA_archiver = "IA Archiver"
OTHER = "Other"
@dataclass(frozen=True)
class SpiderInfo:
"""Identified spider details.
Attributes:
engine: Human-friendly name of the search engine / platform.
name: Exact bot name from the User-Agent (e.g. 'Googlebot').
category: High-level category — 'search', 'social', or 'other'.
"""
engine: str
name: str
category: str # "search" | "social" | "other"
@property
def is_search_spider(self) -> bool:
return self.category == "search"
@property
def is_social_bot(self) -> bool:
return self.category == "social"
# ── Spider patterns ──────────────────────────────────────────
# Each tuple: (compiled-regex, engine-label, category)
# Order matters: more specific patterns first to avoid false positives.
_SPIDER_PATTERNS: list[tuple[re.Pattern[str], str, str]] = [
# ── Google family ─────────────────────────────────────
# Main crawler (desktop)
(re.compile(r"Googlebot/[\d.]+", re.IGNORECASE), "Google", "search"),
# Googlebot for mobile
(re.compile(r"Googlebot-Mobile", re.IGNORECASE), "Google", "search"),
# Google Images
(re.compile(r"Googlebot-Image", re.IGNORECASE), "Google", "search"),
# Google News
(re.compile(r"Googlebot-News", re.IGNORECASE), "Google", "search"),
# Google Ads/AdSense
(re.compile(r"Mediapartners-Google", re.IGNORECASE), "Google", "search"),
# Google Storebot
(re.compile(r"Storebot-Google", re.IGNORECASE), "Google", "search"),
# Google Other (GoogleOther)
(re.compile(r"Google-AdSense", re.IGNORECASE), "Google", "search"),
# ── Bing / Microsoft ──────────────────────────────────
(re.compile(r"bingbot/[\d.]+", re.IGNORECASE), "Bing", "search"),
# MSNBot (legacy Bing crawler)
(re.compile(r"msnbot/[\d.]+", re.IGNORECASE), "Bing", "search"),
# MSNBot Media
(re.compile(r"msnbot-media/[\d.]+", re.IGNORECASE), "Bing", "search"),
# bingadsbot
(re.compile(r"bingadsbot/[\d.]+", re.IGNORECASE), "Bing", "search"),
# WebPreview / Slurp variants from Microsoft
(re.compile(r"webpreview", re.IGNORECASE), "Bing", "search"),
# ── Yahoo / Slurp ─────────────────────────────────────
(re.compile(r"Yahoo! Slurp", re.IGNORECASE), "Yahoo", "search"),
(re.compile(r"Yahoo! Slurp China", re.IGNORECASE), "Yahoo", "search"),
(re.compile(r"Yahoo Deer Spider", re.IGNORECASE), "Yahoo", "search"),
(re.compile(r"YandexBot", re.IGNORECASE), "Yandex", "search"),
# ── DuckDuckGo ────────────────────────────────────────
(re.compile(r"DuckDuckBot/[\d.]+", re.IGNORECASE), "DuckDuckGo", "search"),
# ── Yandex (RU) ───────────────────────────────────────
(re.compile(r"YandexBot/[\d.]+", re.IGNORECASE), "Yandex", "search"),
(re.compile(r"YandexImages/[\d.]+", re.IGNORECASE), "Yandex", "search"),
(re.compile(r"YandexAccessibilityBot", re.IGNORECASE), "Yandex", "search"),
(re.compile(r"YandexBlogs", re.IGNORECASE), "Yandex", "search"),
(re.compile(r"YandexDirect", re.IGNORECASE), "Yandex", "search"),
(re.compile(r"YandexFavicons", re.IGNORECASE), "Yandex", "search"),
(re.compile(r"YandexMarket", re.IGNORECASE), "Yandex", "search"),
(re.compile(r"YandexMobileBot", re.IGNORECASE), "Yandex", "search"),
(re.compile(r"YandexMetrika", re.IGNORECASE), "Yandex", "search"),
(re.compile(r"YandexSpravochnik", re.IGNORECASE), "Yandex", "search"),
(re.compile(r"YandexSitelinks", re.IGNORECASE), "Yandex", "search"),
(re.compile(r"YandexTracker", re.IGNORECASE), "Yandex", "search"),
(re.compile(r"YandexVertis", re.IGNORECASE), "Yandex", "search"),
(re.compile(r"YandexWebmaster", re.IGNORECASE), "Yandex", "search"),
# ── Baidu (CN) ────────────────────────────────────────
(re.compile(r"Baiduspider/[\d.]+", re.IGNORECASE), "Baidu", "search"),
(re.compile(r"Baiduboxapp", re.IGNORECASE), "Baidu", "search"),
# ── Sogou (CN) ────────────────────────────────────────
(re.compile(r"Sogou [Ss]pider", re.IGNORECASE), "Sogou", "search"),
(re.compile(r"Sogou web spider", re.IGNORECASE), "Sogou", "search"),
# ── Apple ─────────────────────────────────────────────
(re.compile(r"Applebot/[\d.]+", re.IGNORECASE), "Apple", "search"),
(re.compile(r"Applebot-Extended", re.IGNORECASE), "Apple", "search"),
# ── PetleO (Japan) ────────────────────────────────────
(re.compile(r"petalbot", re.IGNORECASE), "PetleO", "search"),
# ── Internet Archive ──────────────────────────────────
(re.compile(r"ia_archiver", re.IGNORECASE), "IA Archiver", "other"),
(re.compile(r"archive\.org_bot", re.IGNORECASE), "IA Archiver", "other"),
# ── Social media bots ─────────────────────────────────
(re.compile(r"facebookexternalhit/[\d.]+", re.IGNORECASE), "Facebook", "social"),
(re.compile(r"facebookplatform", re.IGNORECASE), "Facebook", "social"),
(re.compile(r"Twitterbot/[\d.]+", re.IGNORECASE), "Twitter", "social"),
(re.compile(r"LinkedInBot/[\d.]+", re.IGNORECASE), "LinkedIn", "social"),
(re.compile(r"W3C_Validator", re.IGNORECASE), "W3C", "other"),
(re.compile(r"WhatsApp/[\d.]+", re.IGNORECASE), "WhatsApp", "social"),
# ── Generic / catch-all bot signatures ────────────────────────
# These are last to reduce false positives on real browsers that
# happen to include the word "bot" in extension names.
# Standard bot naming convention: SomeBot/X.Y
(re.compile(r"Bot/[\d.]", re.IGNORECASE), "Other", "other"),
# Semicolon-delimited bot markers: "; bot" or ";bot"
(re.compile(r";\s*bot\s*;?", re.IGNORECASE), "Other", "other"),
# Spider anywhere in the UA
(re.compile(r"spider", re.IGNORECASE), "Other", "other"),
# Crawler anywhere in the UA
(re.compile(r"crawler", re.IGNORECASE), "Other", "other"),
]
# ── Public API ───────────────────────────────────────────────
def is_spider(user_agent: str) -> bool:
"""Return True if the User-Agent string belongs to a known spider/bot.
This is a fast check — use `get_spider_info()` when you need details.
Args:
user_agent: The raw User-Agent header value.
Returns:
True if a spider pattern matches, False for normal browsers.
"""
if not user_agent:
return False
for pattern, _, _ in _SPIDER_PATTERNS:
if pattern.search(user_agent):
return True
return False
def get_spider_info(user_agent: str) -> Optional[SpiderInfo]:
"""Identify a spider from a User-Agent string.
Returns the first matching SpiderInfo or None if the UA does not
belong to a recognized bot.
Args:
user_agent: The raw User-Agent header value.
Returns:
SpiderInfo with engine/name/category, or None for regular browsers.
"""
if not user_agent:
return None
for pattern, engine, category in _SPIDER_PATTERNS:
match = pattern.search(user_agent)
if match:
return SpiderInfo(engine=engine, name=match.group(0), category=category)
return None
def is_search_spider(user_agent: str) -> bool:
"""Return True only for search-engine crawlers (not social bots).
Useful when you need SSR/pre-rendering only for SEO-relevant crawlers.
Args:
user_agent: The raw User-Agent header value.
Returns:
True only if a search-engine spider pattern matches.
"""
info = get_spider_info(user_agent)
return info.is_search_spider if info else False
# ── FastAPI middleware helper ────────────────────────────────
def get_user_agent(headers: dict) -> str:
"""Extract the User-Agent string from a headers dict (case-insensitive).
Args:
headers: A dict-like mapping of header names to values.
Returns:
The User-Agent value, empty string if not present.
"""
for key, value in headers.items():
if key.lower() == "user-agent":
return value
return ""
+289
View File
@@ -0,0 +1,289 @@
"""Unit tests for spider/bot User-Agent detection."""
import pytest
from app.spider_detection import (
get_spider_info,
get_user_agent,
is_search_spider,
is_spider,
)
# ──────────────────────────────────────────────────────────────
# Known spider User-Agents (should return True)
# ──────────────────────────────────────────────────────────────
SPIDER_UA = [
# Google family
("Googlebot (desktop)", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"),
("Googlebot Mobile", "Mozilla/5.0 (Linux; Android 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; Googlebot-Mobile/2.1; +http://www.google.com/bot.html)"),
("Googlebot Image", "Mozilla/5.0 (compatible; Googlebot-Image/1.0; +http://www.google.com/bot.html)"),
("Googlebot News", "Mozilla/5.0 (compatible; Googlebot-News; +http://www.google.com/bot.html)"),
("Google Ads", "Mozilla/5.0 (compatible; Mediapartners-Google/2.1; +http://www.google.com/bot.html)"),
("Storebot Google", "Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Mobile Safari/537.36 (compatible; Storebot-Google/1.0)"),
("Google AdSense", "Google-AdSense-TopContent"),
# Bing family
("Bingbot", "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"),
("MSNBot", "Mozilla/5.0 (compatible; msnbot/2.0b; +http://search.msn.com/msnbot.htm)"),
("MSNBot Media", "Mozilla/5.0 (compatible; msnbot-media/1.1; +http://search.msn.com/msnbot.htm)"),
("Bing Ads Bot", "Mozilla/5.0 (compatible; bingadsbot/2.0; +http://www.bing.com/bingbot.htm)"),
# Yahoo
("Yahoo Slurp", "Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)"),
("Yahoo Deer Spider", "Mozilla/5.0 (compatible; Yahoo! Deer Spider; http://help.yahoo.com/help/us/ysearch/crawlers)"),
# DuckDuckGo
("DuckDuckBot", "DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)"),
# Yandex
("YandexBot", "Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)"),
("Yandex Images", "Mozilla/5.0 (compatible; YandexImages/3.0; +http://yandex.com/bots)"),
("Yandex Mobile Bot", "Mozilla/5.0 (compatible; YandexMobileBot/3.0; +http://yandex.com/bots)"),
("Yandex Metrika", "Mozilla/5.0 (compatible; YandexMetrika/7.0; +http://yandex.com/bots)"),
("Yandex Direct", "Mozilla/5.0 compatible; YandexDirect/3.0; +http://yandex.com/bots)"),
# Baidu
("Baiduspider", "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)"),
("Baiduboxapp", "Mozilla/5.0 (compatible; Baiduboxapp; +http://www.baidu.com/search/spider.html)"),
# Sogou
("Sogou Spider", "Mozilla/5.0 (compatible; Sogou web spider/4.0; +http://www.sogou.com/docs/help/webmasters.htm)"),
("Sogou Spider (alt)", "Sogou Spider/4.0"),
# Apple
("Applebot", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/600.2.5 (KHTML, like Gecko) Version/8.0.2 Safari/600.2.5 (Applebot/0.1)"),
("Applebot-Extended", "Mozilla/5.0 (compatible; Applebot-Extended/0.2; +https://www.apple.com/go/applebot-extended)"),
# PetleO (PetalBot)
("PetalBot", "Mozilla/5.0 (Linux; Android 7.0;) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36 (compatible; PetalBot;+https://webmaster.petalsearch.com/)"),
# Internet Archive
("IA Archiver", "Mozilla/5.0 (compatible; archive.org_bot +http://archive.org/details/archive.org_bot)"),
("ia_archiver", "ia_archiver-web.common-crawl"),
# Social bots
("Facebook External Hit", "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"),
("Facebook Platform", "facebookplatform"),
("Twitterbot", "Twitterbot/1.0"),
("LinkedInBot", "LinkedInBot/1.0 (compatible; Mozilla/5.0; Apache-HttpClient +https://www.linkedin.com)"),
("WhatsApp", "WhatsApp/2.21.1"),
# Generic bot/crawler signatures (case variants)
("Generic bot (semicolon)", "Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)"),
("Generic spider", "Mozilla/5.0 (compatible; Spider/1.0)"),
("Generic crawler", "MyCrawler/1.0"),
]
# ──────────────────────────────────────────────────────────────
# Known browser User-Agents (should return False)
# ──────────────────────────────────────────────────────────────
BROWSER_UA = [
("Chrome (Windows)", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
("Chrome (Linux)", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
("Chrome (macOS)", "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"),
("Chrome (Android)", "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"),
("Chrome (iPhone)", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1"),
("Firefox (Windows)", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"),
("Firefox (Linux)", "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0"),
("Firefox (Android)", "Mozilla/5.0 (Android 13; Mobile; rv:121.0) Gecko/121.0 Firefox/121.0"),
("Safari (macOS)", "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15"),
("Safari (iPhone)", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"),
("Safari (iPad)", "Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"),
("Edge (Windows)", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.2210.91"),
("Opera", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/105.0.0.0"),
("Samsung Internet", "Mozilla/5.0 (Linux; Android 13; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/120.0.0.0 Mobile Safari/537.36"),
]
# ──────────────────────────────────────────────────────────────
# Tests
# ──────────────────────────────────────────────────────────────
class TestIsSpider:
"""is_spider() should return True for known spider UAs and False for browsers."""
@pytest.mark.parametrize("name,ua", SPIDER_UA)
def test_known_spiders_detected(self, name: str, ua: str):
assert is_spider(ua) is True, f"{name} should be detected as a spider"
@pytest.mark.parametrize("name,ua", BROWSER_UA)
def test_browsers_not_detected(self, name: str, ua: str):
assert is_spider(ua) is False, f"{name} should NOT be detected as a spider"
def test_empty_string(self):
assert is_spider("") is False
def test_none_like_missing_header(self):
assert is_spider(" ") is False
class TestGetSpiderInfo:
"""get_spider_info() should return SpiderInfo for spiders, None for browsers."""
@pytest.mark.parametrize("name,ua", SPIDER_UA)
def test_spider_returns_info(self, name: str, ua: str):
info = get_spider_info(ua)
assert info is not None, f"{name}: expected SpiderInfo, got None"
assert info.name, f"{name}: SpiderInfo.name should not be empty"
assert info.engine, f"{name}: SpiderInfo.engine should not be empty"
assert info.category in ("search", "social", "other"), (
f"{name}: unknown category {info.category!r}"
)
@pytest.mark.parametrize("name,ua", BROWSER_UA)
def test_browser_returns_none(self, name: str, ua: str):
assert get_spider_info(ua) is None, f"{name} should return None"
def test_empty_string(self):
assert get_spider_info("") is None
class TestIsSearchSpider:
"""is_search_spider() should distinguish search crawlers from social bots."""
SEARCH_SPIDER_UA = [
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
"DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)",
"Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)",
"Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)",
"Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)",
]
SOCIAL_BOT_UA = [
"facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)",
"Twitterbot/1.0",
"LinkedInBot/1.0 (compatible; Mozilla/5.0; Apache-HttpClient +https://www.linkedin.com)",
"WhatsApp/2.21.1",
]
@pytest.mark.parametrize("ua", SEARCH_SPIDER_UA)
def test_search_spiders_return_true(self, ua: str):
assert is_search_spider(ua) is True
@pytest.mark.parametrize("ua", SOCIAL_BOT_UA)
def test_social_bots_return_false(self, ua: str):
assert is_search_spider(ua) is False
@pytest.mark.parametrize("name,ua", BROWSER_UA)
def test_browsers_return_false(self, name: str, ua: str):
assert is_search_spider(ua) is False, f"{name} should not be a search spider"
class TestSpiderInfoDataclass:
"""SpiderInfo properties should be correct."""
def test_is_search_spider_property(self):
info = get_spider_info("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)")
assert info is not None
assert info.is_search_spider is True
assert info.is_social_bot is False
def test_is_social_bot_property(self):
info = get_spider_info("facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)")
assert info is not None
assert info.is_social_bot is True
assert info.is_search_spider is False
def test_spider_info_frozen(self):
info = get_spider_info("Mozilla/5.0 (compatible; bingbot/2.0)")
assert info is not None
with pytest.raises(Exception): # FrozenInstanceError in Python 3.11+, AttributeError before
info.engine = "modified" # type: ignore
class TestGetUserAgent:
"""get_user_agent() should extract UA from headers dict case-insensitively."""
def test_standard_case(self):
headers = {"User-Agent": "test-agent"}
assert get_user_agent(headers) == "test-agent"
def test_lower_case_key(self):
headers = {"user-agent": "test-agent"}
assert get_user_agent(headers) == "test-agent"
def test_upper_case_key(self):
headers = {"USER-AGENT": "test-agent"}
assert get_user_agent(headers) == "test-agent"
def test_missing_header(self):
headers = {"Content-Type": "text/html"}
assert get_user_agent(headers) == ""
def test_empty_headers(self):
assert get_user_agent({}) == ""
def test_combined_with_is_spider(self):
headers = {
"Host": "example.com",
"User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
"Accept": "text/html",
}
ua = get_user_agent(headers)
assert is_spider(ua) is True
class TestCaseInsensitivity:
"""Spider detection patterns should be case-insensitive."""
def test_googlebot_uppercase(self):
assert is_spider("GOOGLEBOT/2.1") is True
def test_googlebot_mixed_case(self):
assert is_spider("googleBot/2.1") is True
def test_bingbot_uppercase(self):
assert is_spider("BINGBOT/2.0") is True
def test_baiduspider_lowercase(self):
assert is_spider("baiduspider/2.0") is True
def test_facebook_mixed(self):
assert is_spider("FaceBookExternalHit/1.1") is True
class TestSpecificSpiderDetails:
"""Verify engine and category are correctly assigned for specific bots."""
def test_googlebot_engine(self):
info = get_spider_info("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)")
assert info is not None
assert info.engine == "Google"
assert info.category == "search"
def test_bingbot_engine(self):
info = get_spider_info("Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)")
assert info is not None
assert info.engine == "Bing"
assert info.category == "search"
def test_facebook_category(self):
info = get_spider_info("facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)")
assert info is not None
assert info.engine == "Facebook"
assert info.category == "social"
def test_twitter_category(self):
info = get_spider_info("Twitterbot/1.0")
assert info is not None
assert info.engine == "Twitter"
assert info.category == "social"
def test_yandex_engine(self):
info = get_spider_info("Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)")
assert info is not None
assert info.engine == "Yandex"
assert info.category == "search"
def test_apple_engine(self):
info = get_spider_info("Mozilla/5.0 (compatible; Applebot/0.1; +https://www.apple.com/go/applebot)")
assert info is not None
assert info.engine == "Apple"
assert info.category == "search"
+27 -2
View File
@@ -1,11 +1,36 @@
#!/bin/sh
# Render config templates with environment variables, then start nginx.
# Start the Angular SSR Node process, then nginx.
set -e
# Ensure nginx can write to the shared logs directory
mkdir -p /logs && chown nginx:nginx /logs
sed "s|__API_UPSTREAM__|${API_UPSTREAM}|g" /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/conf.d/default.conf
# Render nginx config from template to /etc/nginx/nginx.conf
# (full config, not conf.d/ snippet — avoids Alpine include nesting issues)
sed "s|__API_UPSTREAM__|${API_UPSTREAM}|g" /etc/nginx/conf.d/nginx.conf.template > /etc/nginx/nginx.conf
# Render client-side config.json for fallback use
envsubst '${API_PUBLIC_URL}' < /usr/share/nginx/html/config.json.template > /usr/share/nginx/html/config.json
# Start the Angular SSR server (Node process) in the background
# Point at nginx (port 80) so SSR API calls go through the nginx proxy chain
# to the upstream backend. Nginx is started after SSR so the chain is complete
# by the time the first SSR request actually needs API data.
export SSR_API_BASE_URL="http://localhost:80"
echo "[entrypoint] Starting Angular SSR server on port 4200..."
node /app/server/server.mjs &
SSR_PID=$!
# Wait for the SSR server to be ready (up to 10 seconds)
echo "[entrypoint] Waiting for SSR server to be ready..."
for i in $(seq 1 20); do
if node -e "require('http').get('http://localhost:4200/', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))" 2>/dev/null; then
echo "[entrypoint] SSR server is ready."
break
fi
sleep 0.5
done
# Start nginx in the foreground (primary process)
echo "[entrypoint] Starting nginx..."
exec nginx -g 'daemon off;'
+55 -19
View File
@@ -1,30 +1,47 @@
# Map ISO timestamp to date-only string for daily log rotation
map $time_iso8601 $log_date {
"~^(?<date>\d{4}-\d{2}-\d{2})" $date;
default "unknown";
# Full nginx configuration — rendered to /etc/nginx/nginx.conf
# (NOT placed in conf.d/ to avoid include nesting issues with Alpine nginx)
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
log_format kmtn_json escape=json
'{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"x_forwarded_for":"$http_x_forwarded_for"'
'}';
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /logs/access-$log_date.log kmtn_json;
log_format kmtn_json escape=json
'{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"x_forwarded_for":"$http_x_forwarded_for"'
'}';
server {
access_log /logs/access.log kmtn_json;
sendfile on;
upstream ssr_backend {
server 127.0.0.1:4200;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback — Angular router handles its own paths
location / {
try_files $uri $uri/ /index.html;
# Serve static assets directly from disk (hashed filenames are immutable)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Dynamic sitemap.xml + robots.txt — proxied to the backend so each
# Dynamic sitemap.xml + robots.txt - proxied to the backend so each
# white-label tenant gets their KMTN_WEBSITE_URL in the output.
location = /sitemap.xml {
proxy_pass __API_UPSTREAM__/api/sitemap/sitemap.xml;
@@ -53,12 +70,31 @@ server {
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host $host;
# Disable caching through the double-proxy chain (NPM → frontend nginx → API)
# Without this, browsers cache responses keyed by the HTTPS origin, then send
# conditional requests (If-None-Match) that get lost or mismatched in the proxy chain.
# Disable caching through the double-proxy chain
proxy_cache off;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Vary "*" always;
}
# Proxy all page requests to the Angular SSR server.
# The SSR server handles both spider SSR rendering and serves CSR shells
# to regular users, with fresh station config injected at runtime.
location / {
proxy_pass http://ssr_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-Forwarded-Host $host;
# SSR responses should not be cached by intermediate proxies
proxy_cache off;
add_header X-Powered-By "Angular SSR" always;
# Prevent browsers from caching HTML pages — content changes frequently
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
}
}
}
+312 -61
View File
@@ -4,7 +4,7 @@ import '@angular/compiler';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the @angular/ssr/node imports that pull in Node.js http server code
// — we only want to test PRERENDERED_ROUTES and isPrerenderedRoute here.
// — we only want to test the request handler logic here.
vi.mock('@angular/ssr/node', () => ({
AngularNodeAppEngine: class {},
createNodeRequestHandler: (fn: Function) => fn,
@@ -16,75 +16,326 @@ vi.mock('node:http', () => ({
default: { createServer: vi.fn() },
}));
import { PRERENDERED_ROUTES, isPrerenderedRoute } from './server';
vi.mock('node:fs', () => ({
default: { readFileSync: vi.fn(() => '<!DOCTYPE html><html>static shell</html>') },
readFileSync: vi.fn(() => '<!DOCTYPE html><html>static shell</html>'),
}));
vi.mock('node:path', () => ({
default: { join: (...args: string[]) => args.join('/'), dirname: () => '/app/server' },
join: (...args: string[]) => args.join('/'),
dirname: () => '/app/server',
}));
// Mock the engine manifest that only exists after ng build
vi.mock('./angular-app-engine-manifest.mjs', () => ({
default: {},
}));
describe('SSR server — server.ts', () => {
describe('PRERENDERED_ROUTES', () => {
it('should contain exactly 5 routes', () => {
expect(PRERENDERED_ROUTES).toHaveLength(5);
});
it('should include the home route', () => {
expect(PRERENDERED_ROUTES).toContain('/');
});
it('should include /about', () => {
expect(PRERENDERED_ROUTES).toContain('/about');
});
it('should include /schedule', () => {
expect(PRERENDERED_ROUTES).toContain('/schedule');
});
it('should include /events', () => {
expect(PRERENDERED_ROUTES).toContain('/events');
});
it('should include /contact', () => {
expect(PRERENDERED_ROUTES).toContain('/contact');
});
it('should export a request handler', async () => {
const mod = await import('./server');
expect(mod.requestHandler).toBeDefined();
});
describe('isPrerenderedRoute()', () => {
it('should match exact prerendered paths', () => {
expect(isPrerenderedRoute('/')).toBe(true);
expect(isPrerenderedRoute('/about')).toBe(true);
expect(isPrerenderedRoute('/schedule')).toBe(true);
expect(isPrerenderedRoute('/events')).toBe(true);
expect(isPrerenderedRoute('/contact')).toBe(true);
});
it('should have spider detection imports and serveCsrShell', async () => {
const fs = require('fs');
const content = fs.readFileSync('./server.ts', 'utf-8');
expect(content).toContain('isSpider');
expect(content).toContain('getUserAgent');
expect(content).toContain('serveCsrShell');
expect(content).toContain('spider_detection');
});
it('should match paths with trailing slashes', () => {
expect(isPrerenderedRoute('/about/')).toBe(true);
expect(isPrerenderedRoute('/schedule/')).toBe(true);
expect(isPrerenderedRoute('/events/')).toBe(true);
expect(isPrerenderedRoute('/contact/')).toBe(true);
});
it('should gate SSR behind spider detection', async () => {
const fs = require('fs');
const content = fs.readFileSync('./server.ts', 'utf-8');
// Verify the spider detection gate exists
expect(content).toContain("if (!isSpider(userAgent))");
expect(content).toContain('serveCsrShell(res)');
});
it('should match paths with query strings', () => {
expect(isPrerenderedRoute('/about?foo=bar')).toBe(true);
expect(isPrerenderedRoute('/schedule?day=monday')).toBe(true);
});
it('should inject fresh station config as window.__STATION_CONFIG', async () => {
const fs = require('fs');
const content = fs.readFileSync('./server.ts', 'utf-8');
expect(content).toContain('window.__STATION_CONFIG');
expect(content).toContain('buildConfigScript');
expect(content).toContain('fetchStationConfig');
});
it('should match paths with fragments', () => {
expect(isPrerenderedRoute('/events#upcoming')).toBe(true);
});
it('should inject inline theme CSS custom properties', async () => {
const fs = require('fs');
const content = fs.readFileSync('./server.ts', 'utf-8');
expect(content).toContain('buildThemeStyle');
expect(content).toContain('COLOR_MAP');
expect(content).toContain('--color-primary');
expect(content).toContain(':root{');
});
it('should reject non-prerendered routes', () => {
expect(isPrerenderedRoute('/login')).toBe(false);
expect(isPrerenderedRoute('/admin')).toBe(false);
expect(isPrerenderedRoute('/nonexistent')).toBe(false);
expect(isPrerenderedRoute('/admin/dashboard')).toBe(false);
});
it('should serveCsrShell be async and accept (res)', async () => {
const fs = require('fs');
const content = fs.readFileSync('./server.ts', 'utf-8');
expect(content).toMatch(/async function serveCsrShell\s*\(\s*res/);
});
it('should handle full URLs', () => {
expect(isPrerenderedRoute('https://kmtnflower.org/about')).toBe(true);
expect(isPrerenderedRoute('http://localhost:4200/events')).toBe(true);
expect(isPrerenderedRoute('https://kmtnflower.org/login')).toBe(false);
});
it('should fetch station config from SSR_API_BASE_URL with 2s timeout', async () => {
const fs = require('fs');
const content = fs.readFileSync('./server.ts', 'utf-8');
expect(content).toContain('SSR_API_BASE_URL');
expect(content).toContain('api/station-config');
expect(content).toContain('setTimeout(2000');
});
it('should normalise root path — "/" stays "/"', () => {
expect(isPrerenderedRoute('/')).toBe(true);
});
it('should set no-cache headers on CSR shell responses', async () => {
const fs = require('fs');
const content = fs.readFileSync('./server.ts', 'utf-8');
expect(content).toContain("Cache-Control");
expect(content).toContain("no-cache, no-store, must-revalidate");
});
});
describe('spider_detection.ts — TypeScript spider detection', () => {
it('should detect Googlebot', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)')).toBe(true);
});
it('should detect Bingbot', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)')).toBe(true);
});
it('should detect DuckDuckBot', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)')).toBe(true);
});
it('should detect YandexBot', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)')).toBe(true);
});
it('should detect Baiduspider', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)')).toBe(true);
});
it('should detect Applebot', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('Mozilla/5.0 (compatible; Applebot/0.1; +https://www.apple.com/go/applebot)')).toBe(true);
});
it('should detect Facebook bot', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)')).toBe(true);
});
it('should detect Twitterbot', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('Twitterbot/1.0')).toBe(true);
});
it('should NOT detect Chrome', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')).toBe(false);
});
it('should NOT detect Firefox', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0')).toBe(false);
});
it('should NOT detect Safari', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15')).toBe(false);
});
it('should NOT detect Edge', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.2210.91')).toBe(false);
});
it('should NOT detect mobile Safari', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1')).toBe(false);
});
it('should handle empty string', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('')).toBe(false);
});
it('should handle null/undefined', async () => {
const { isSpider } = await import('./spider_detection');
expect(isSpider('')).toBe(false);
});
it('isSearchSpider should distinguish search from social bots', async () => {
const { isSearchSpider, isSpider } = await import('./spider_detection');
expect(isSpider('facebookexternalhit/1.1')).toBe(true);
expect(isSearchSpider('facebookexternalhit/1.1')).toBe(false);
expect(isSearchSpider('Mozilla/5.0 (compatible; Googlebot/2.1)')).toBe(true);
});
it('getSpiderInfo should return SpiderInfo for known bots', async () => {
const { getSpiderInfo } = await import('./spider_detection');
const info = getSpiderInfo('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)');
expect(info).not.toBeNull();
expect(info?.engine).toBe('Google');
expect(info?.category).toBe('search');
});
it('getSpiderInfo should return null for browsers', async () => {
const { getSpiderInfo } = await import('./spider_detection');
expect(getSpiderInfo('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0')).toBeNull();
});
it('getUserAgent should extract UA from request headers', async () => {
const { getUserAgent } = await import('./spider_detection');
const headers: Record<string, string | string[] | undefined> = {
'user-agent': 'TestBot/1.0',
'host': 'example.com',
};
expect(getUserAgent(headers)).toBe('TestBot/1.0');
});
it('getUserAgent should handle missing UA header', async () => {
const { getUserAgent } = await import('./spider_detection');
expect(getUserAgent({ host: 'example.com' })).toBe('');
});
});
describe('app.server.routes.ts — RenderMode configuration', () => {
it('should use RenderMode.Server for all routes (SSR server handles routing)', async () => {
const fs = require('fs');
const content = fs.readFileSync('./src/app/app.server.routes.ts', 'utf-8');
// Should have RenderMode.Server entries
const serverMatches = content.match(/RenderMode\.Server/g);
expect(serverMatches).not.toBeNull();
expect(serverMatches.length).toBeGreaterThanOrEqual(7);
});
it('should have no Prerender routes (eliminates stale baked-in DEFAULT_CONFIG)', async () => {
const fs = require('fs');
const content = fs.readFileSync('./src/app/app.server.routes.ts', 'utf-8');
expect(content).not.toContain('RenderMode.Prerender');
});
});
describe('docker-entrypoint.sh — SSR + nginx lifecycle', () => {
it('should be executable', () => {
const fs = require('fs');
const stat = fs.statSync('./docker-entrypoint.sh');
expect(stat.isFile()).toBe(true);
});
it('should start the SSR node process before nginx', () => {
const fs = require('fs');
const content = fs.readFileSync('./docker-entrypoint.sh', 'utf-8');
// SSR must start before nginx
const ssrStart = content.indexOf('node /app/server/server.mjs');
const nginxStart = content.indexOf('exec nginx');
expect(ssrStart).toBeGreaterThan(-1);
expect(nginxStart).toBeGreaterThan(-1);
expect(ssrStart).toBeLessThan(nginxStart);
});
it('should set SSR_API_BASE_URL for the SSR server', () => {
const fs = require('fs');
const content = fs.readFileSync('./docker-entrypoint.sh', 'utf-8');
expect(content).toContain('SSR_API_BASE_URL');
expect(content).toContain('http://localhost:80');
});
it('should wait for SSR server readiness before starting nginx', () => {
const fs = require('fs');
const content = fs.readFileSync('./docker-entrypoint.sh', 'utf-8');
expect(content).toContain('Waiting for SSR server to be ready');
expect(content).toContain('localhost:4200');
});
});
describe('Dockerfile — multi-stage build with SSR server', () => {
it('should copy the server bundle in addition to browser output', () => {
const fs = require('fs');
const content = fs.readFileSync('./Dockerfile', 'utf-8');
expect(content).toMatch(/COPY.*server.*\/app\/server/);
});
it('should use a Node.js base image for the serve stage', () => {
const fs = require('fs');
const content = fs.readFileSync('./Dockerfile', 'utf-8');
// Second FROM should be Node-based (not plain nginx)
const fromLines = content.split('\n').filter(l => l.startsWith('FROM '));
expect(fromLines.length).toBeGreaterThanOrEqual(2);
expect(fromLines[1]).toContain('node');
});
it('should install nginx in the serve stage', () => {
const fs = require('fs');
const content = fs.readFileSync('./Dockerfile', 'utf-8');
expect(content).toContain('apk add --no-cache nginx');
});
it('should expose port 80', () => {
const fs = require('fs');
const content = fs.readFileSync('./Dockerfile', 'utf-8');
expect(content).toContain('EXPOSE 80');
});
});
describe('nginx.conf.template — SSR proxy chain', () => {
it('should define an upstream pointing to SSR server on port 4200', () => {
const fs = require('fs');
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
expect(content).toContain('upstream ssr_backend');
expect(content).toContain('127.0.0.1:4200');
});
it('should proxy page requests to the SSR backend', () => {
const fs = require('fs');
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
expect(content).toContain('proxy_pass http://ssr_backend');
});
it('should serve static assets directly from disk with long cache', () => {
const fs = require('fs');
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
expect(content).toContain('location ~*');
expect(content).toContain('js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map');
expect(content).toContain('expires 1y');
expect(content).toContain('public, immutable');
});
it('should still proxy /api/ to the upstream backend', () => {
const fs = require('fs');
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
expect(content).toContain('location /api/');
expect(content).toContain('__API_UPSTREAM__');
});
it('should add Cache-Control no-cache headers on HTML page responses', () => {
const fs = require('fs');
const content = fs.readFileSync('./nginx.conf.template', 'utf-8');
expect(content).toContain('add_header Cache-Control "no-cache, no-store, must-revalidate" always');
expect(content).toContain('add_header Pragma "no-cache" always');
});
});
describe('main.server.ts — SSR API base URL injection', () => {
it('should override APP_CONFIG with SSR-aware apiBaseUrl', () => {
const fs = require('fs');
const content = fs.readFileSync('./src/main.server.ts', 'utf-8');
expect(content).toContain('SSR_API_BASE_URL');
expect(content).toContain('ssrAppConfig');
expect(content).toContain('provide: APP_CONFIG');
});
it('should read SSR_API_BASE_URL from environment with fallback', () => {
const fs = require('fs');
const content = fs.readFileSync('./src/main.server.ts', 'utf-8');
expect(content).toContain("process.env['SSR_API_BASE_URL']");
expect(content).toContain("http://localhost:80");
});
});
+260 -65
View File
@@ -4,72 +4,249 @@ import {
isMainModule,
writeResponseToNodeResponse,
} from '@angular/ssr/node';
import { AngularAppEngine } from '@angular/ssr';
import * as fs from 'node:fs';
import * as http from 'node:http';
// Enable serving prerendered static HTML from disk. Without this flag the
// engine falls back to SSR for every route, ignoring the prerendered output.
AngularAppEngine.ɵallowStaticRouteRender = true;
import * as https from 'node:https';
import * as path from 'node:path';
import { isSpider, getUserAgent } from './spider_detection';
/**
* Public routes prerendered at build time (SSG — Static Site Generation).
* These five routes are compiled to static HTML during `ng build` and
* served directly from disk at runtime for optimal performance and SEO.
* The list here mirrors `prerender-routes.txt` and the Prerender entries
* in `src/app/app.server.routes.ts`.
* Manually import and register the Angular app engine manifest.
* The Angular 21 builder generates angular-app-engine-manifest.mjs at build time
* but does NOT automatically inject the import into server.mjs.
* Without this, AngularNodeAppEngine fails with "Angular app engine manifest is not set"
* and every SSR request returns 404.
*/
export const PRERENDERED_ROUTES: readonly string[] = [
'/',
'/about',
'/schedule',
'/events',
'/contact',
// @ts-ignore — generated at build time by @angular/build
import engineManifest from './angular-app-engine-manifest.mjs';
import { ɵsetAngularAppEngineManifest } from '@angular/ssr';
ɵsetAngularAppEngineManifest(engineManifest);
const appEngine = new AngularNodeAppEngine();
/**
* Station config shape — mirrors the backend API response.
* Only the color fields are used for theme injection; the rest are
* passed through verbatim for the client-side StationConfigService.
*/
interface StationConfig {
color_primary: string;
color_primary_light: string;
color_primary_dark: string;
color_primary_muted: string;
color_accent: string;
color_accent_light: string;
color_accent_dark: string;
color_accent_muted: string;
color_background: string;
color_text: string;
color_success: string;
color_danger: string;
color_info: string;
color_white: string;
color_light: string;
color_medium: string;
color_black: string;
[key: string]: unknown;
}
/** CSS custom property names mapped from StationConfig color fields. */
const COLOR_MAP: ReadonlyArray<{ field: keyof StationConfig; prop: string }> = [
{ field: 'color_primary', prop: '--color-primary' },
{ field: 'color_primary_light', prop: '--color-primary-light' },
{ field: 'color_primary_dark', prop: '--color-primary-dark' },
{ field: 'color_primary_muted', prop: '--color-primary-muted' },
{ field: 'color_accent', prop: '--color-accent' },
{ field: 'color_accent_light', prop: '--color-accent-light' },
{ field: 'color_accent_dark', prop: '--color-accent-dark' },
{ field: 'color_accent_muted', prop: '--color-accent-muted' },
{ field: 'color_background', prop: '--color-cream' },
{ field: 'color_text', prop: '--color-dark' },
{ field: 'color_success', prop: '--color-success' },
{ field: 'color_danger', prop: '--color-danger' },
{ field: 'color_info', prop: '--color-info' },
{ field: 'color_white', prop: '--color-white' },
{ field: 'color_light', prop: '--color-light' },
{ field: 'color_medium', prop: '--color-medium' },
{ field: 'color_black', prop: '--color-black' },
];
/**
* Normalise a request pathname and check against prerendered routes.
* Handles both bare paths (`/about`) and trailing-slash variants (`/about/`).
*/
export function isPrerenderedRoute(url: string): boolean {
const pathname = new URL(url, 'http://localhost').pathname;
const normalised = pathname.endsWith('/') && pathname !== '/'
? pathname.slice(0, -1)
: pathname;
return PRERENDERED_ROUTES.includes(normalised);
}
/**
* Lazily instantiate AngularNodeAppEngine so the engine boots only when
* the first request arrives. The engine handles both prerendered route
* lookups and on-demand SSR bootstrapping internally.
*/
let appEngine: AngularNodeAppEngine | null = null;
function getEngine(): AngularNodeAppEngine {
if (!appEngine) {
appEngine = new AngularNodeAppEngine();
}
return appEngine;
}
/**
* Handle incoming requests using the Angular SSR engine.
* Resolve the static CSR shell path (index.csr.html).
*
* Prerendered routes (/ , /about, /schedule, /events, /contact) are served
* as pre-generated static HTML from the build output. All other routes
* fall back to server-side rendering (SSR) or the fallback 404 handler.
* IMPORTANT: We MUST prefer index.csr.html over index.html.
* The Angular build with prerender config produces both:
* - index.html: prerendered at build time with stale DEFAULT_CONFIG baked in
* - index.csr.html: pure CSR shell with empty <app-root> — Angular hydrates client-side
*
* Serving index.html to regular users causes the flash: the prerendered DOM
* shows stale build-time content until Angular re-renders with fresh data.
* Even with window.__STATION_CONFIG injection, the prerendered innerHTML inside
* <app-root> still shows old content on the first paint.
*
* Production (Docker): browser files are copied to /usr/share/nginx/html/
* Local dev (ng build): browser files live in dist/radio-station/browser/
* Try each candidate in order and return the first that exists.
*/
function browserIndexPath(): string {
// Production path — Dockerfile copies browser output here
// CSR shell first — avoid prerendered index.html with stale content
const productionPaths = [
'/usr/share/nginx/html/index.csr.html',
'/usr/share/nginx/html/index.html',
'/var/www/html/index.csr.html',
'/var/www/html/index.html',
];
// Local dev path — relative to the compiled server directory
const dir = typeof __dirname !== 'undefined' ? __dirname : path.dirname(new URL(import.meta.url).pathname);
const localPaths = [
path.join(dir, 'browser', 'index.csr.html'),
path.join(dir, 'browser', 'index.html'),
path.join(dir, '..', 'browser', 'index.csr.html'),
path.join(dir, '..', 'browser', 'index.html'),
];
const allCandidates = [...productionPaths, ...localPaths];
for (const candidate of allCandidates) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// ignore — keep trying
}
}
// Return the first candidate so the error path has a sensible value to log
return allCandidates[0];
}
/**
* Build an inline `<script>` that seeds `window.__STATION_CONFIG` so the
* client-side StationConfigService can read fresh config synchronously
* during bootstrap — no async API round-trip needed, no DEFAULT_CONFIG flash.
*/
function buildConfigScript(config: StationConfig): string {
const json = JSON.stringify(config);
return `<script>window.__STATION_CONFIG=${json};</script>`;
}
/** Build a gradient CSS value from three color stops. */
function gradient(from: string, mid: string, to: string): string {
return `linear-gradient(135deg, ${from} 0%, ${mid} 40%, ${to} 100%)`;
}
/**
* Build inline CSS custom properties on `:root` so the first paint carries
* the correct color scheme — eliminates the color flash that occurs when
* ThemeService.apply() runs asynchronously after the API call.
*
* Includes both flat color properties AND derived gradient variables
* (--sky-gradient, --sunset-gradient, --mixed-gradient) so components
* using var() for backgrounds render correctly on the very first paint.
*/
function buildThemeStyle(config: StationConfig): string {
const props: string[] = [];
for (const { field, prop } of COLOR_MAP) {
const value = (config as any)[field];
if (value && typeof value === 'string') {
props.push(`${prop}: ${value}`);
}
}
// Derived gradients — mirror the same formulas ThemeService.apply() uses.
// These must be injected server-side; otherwise components referencing
// var(--sky-gradient) etc. fall back to their SCSS build-time defaults
// on first paint, causing a visible color flash.
const cp = (field: keyof StationConfig): string => (config as any)[field] || '';
props.push(`--sky-gradient: ${gradient(cp('color_primary_dark'), cp('color_primary'), cp('color_primary_light'))}`);
props.push(`--sunset-gradient: ${gradient(cp('color_accent_dark'), cp('color_accent'), cp('color_accent_light'))}`);
props.push(`--mixed-gradient: linear-gradient(135deg, ${cp('color_primary_dark')} 0%, ${cp('color_primary')} 50%, ${cp('color_accent')} 100%)`);
if (props.length === 0) return '';
const css = `:root{${props.join(';')}}`;
return `<style>${css}</style>`;
}
/**
* Fetch fresh station config from the backend API.
* This runs server-side — it goes through the nginx proxy chain to the
* upstream FastAPI backend. On failure we fall back to serving the shell
* unmodified (client-side fetch picks up the slack).
*/
async function fetchStationConfig(): Promise<StationConfig | null> {
// SSR_API_BASE_URL points at nginx (localhost:80) which proxies /api/ to
// the upstream FastAPI backend. This avoids cross-origin issues and reuses
// the same proxy chain the SSR engine itself would use.
const baseUrl = process.env['SSR_API_BASE_URL'] || 'http://localhost:80';
const url = `${baseUrl}/api/station-config`;
try {
const client = baseUrl.startsWith('https') ? https : http;
const data = await new Promise<string>((resolve, reject) => {
const req = client.get(url, {
headers: { 'Accept': 'application/json' },
}, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
res.on('error', reject);
});
req.on('error', reject);
req.setTimeout(2000, () => { req.destroy(new Error('Station config fetch timed out')); });
});
return JSON.parse(data) as StationConfig;
} catch (err) {
console.warn(`[SSR] Failed to fetch station config from ${url}: ${err}`);
return null;
}
}
/**
* Serve the CSR app shell, enhanced with fresh station config injected at
* runtime so the client bootstraps with real data — no DEFAULT_CONFIG flash.
*
* Strategy:
* 1. Read CSR shell HTML from disk
* 2. Fetch live station config from the backend API
* 3. Inject window.__STATION_CONFIG as an inline <script> before </head>
* 4. Inject theme CSS custom properties as an inline <style> before </head>
* 5. The client-side StationConfigService reads window.__STATION_CONFIG
* synchronously on construction — it skips the API call when seeded.
*/
async function serveCsrShell(res: http.ServerResponse): Promise<void> {
const indexPath = browserIndexPath();
let html: string;
try {
html = fs.readFileSync(indexPath, 'utf-8');
} catch {
res.statusCode = 500;
res.end('Internal server error — index.html not found');
return;
}
// Attempt to fetch fresh config to inject into the shell.
const config = await fetchStationConfig();
let enhanced = html;
if (config) {
const script = buildConfigScript(config);
const style = buildThemeStyle(config);
const injection = `${style}\n${script}`;
enhanced = html.replace('</head>', `${injection}</head>`);
}
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('X-Render-Mode', 'CSR');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.end(enhanced);
}
const requestHandler = createNodeRequestHandler(async (req, res, next) => {
try {
const prerendered = isPrerenderedRoute(req.url || '/');
if (prerendered) {
console.log(`[SSR] Prerendered route: ${req.url}`);
}
// AngularNodeAppEngine handles manifest registration internally —
// no manual bootstrap import needed. For prerendered routes it
// returns pre-generated static HTML; for SSR routes it bootstraps
// the Angular app on demand.
const response = await getEngine().handle(req);
const response = await appEngine.handle(req);
if (response) {
await writeResponseToNodeResponse(response, res);
@@ -81,17 +258,35 @@ const requestHandler = createNodeRequestHandler(async (req, res, next) => {
}
});
export default requestHandler;
if (isMainModule(import.meta.url)) {
const httpServer = http.createServer();
httpServer.on('request', async (req: http.IncomingMessage, res: http.ServerResponse) => {
requestHandler(req, res, () => {
res.statusCode = 404;
res.end('Not Found');
});
console.log('Node.js SSR server ready');
const server = http.createServer();
server.on('request', async (req, res) => {
try {
// ── Spider detection gate ──────────────────────────
// Only search engine spiders get full SSR rendering.
// Regular users receive the CSR app shell from disk,
// enhanced with fresh station config injected at runtime.
const userAgent = getUserAgent(req.headers);
if (!isSpider(userAgent)) {
await serveCsrShell(res);
return;
}
// Spider — render full HTML via Angular SSR with live API data
await requestHandler(req, res, () => {
res.statusCode = 404;
res.end('Not Found');
});
} catch (error) {
console.error('SSR error:', error);
res.statusCode = 500;
res.end('Internal server error');
}
});
httpServer.listen(4200, () => {
console.log(`Node SSR server listening on http://localhost:4200`);
server.listen(4200, () => {
console.log('SSR server listening on http://localhost:4200');
});
}
export { requestHandler };
+147
View File
@@ -0,0 +1,147 @@
/**
* Search engine spider / bot detection based on User-Agent headers.
*
* TypeScript mirror of backend/app/spider_detection.py.
* Used by the SSR server to decide whether to render full HTML (for spiders)
* or serve the static CSR app shell (for regular users).
*
* Usage:
* import { isSpider, getSpiderInfo } from './spider_detection';
*
* if (isSpider(userAgent)) {
* // Full SSR rendering with fresh API data
* } else {
* // Serve static CSR app shell
* }
*/
export class SpiderEngine {
public static readonly GOOGLE = 'Google';
public static readonly BING = 'Bing';
public static readonly YAHOO = 'Yahoo';
public static readonly DUCKDUCKGO = 'DuckDuckGo';
public static readonly YANDEX = 'Yandex';
public static readonly BAIDU = 'Baidu';
public static readonly SOGOU = 'Sogou';
public static readonly APPLE = 'Apple';
public static readonly PETALBOT = 'PetalBot';
public static readonly IA_ARCHIVER = 'IA Archiver';
public static readonly FACEBOOK = 'Facebook';
public static readonly TWITTER = 'Twitter';
public static readonly LINKEDIN = 'LinkedIn';
public static readonly WHATSAPP = 'WhatsApp';
public static readonly OTHER = 'Other';
}
export interface SpiderInfo {
engine: string;
name: string;
category: 'search' | 'social' | 'other';
}
// ── Spider patterns ──────────────────────────────────────────
// Each tuple: [compiled-regex, engine-label, category]
// Order matters: more specific patterns first to avoid false positives.
type SpiderPattern = [RegExp, string, 'search' | 'social' | 'other'];
const SPIDER_PATTERNS: SpiderPattern[] = [
// ── Google family ─────────────────────────────────────
[/(?:Googlebot\/[\d.]+)/i, 'Google', 'search'],
[/(?:Googlebot-Mobile)/i, 'Google', 'search'],
[/(?:Googlebot-Image)/i, 'Google', 'search'],
[/(?:Googlebot-News)/i, 'Google', 'search'],
[/(?:Mediapartners-Google)/i, 'Google', 'search'],
[/(?:Storebot-Google)/i, 'Google', 'search'],
[/(?:Google-AdSense)/i, 'Google', 'search'],
// ── Bing / Microsoft ──────────────────────────────────
[/(?:bingbot\/[\d.]+)/i, 'Bing', 'search'],
[/(?:msnbot\/[\d.]+)/i, 'Bing', 'search'],
[/(?:msnbot-media\/[\d.]+)/i, 'Bing', 'search'],
[/(?:bingadsbot\/[\d.]+)/i, 'Bing', 'search'],
[/(?:webpreview)/i, 'Bing', 'search'],
// ── Yahoo / Slurp ─────────────────────────────────────
[/(?:Yahoo!\s*Slurp)/i, 'Yahoo', 'search'],
[/(?:Yahoo!\s*Deer\s*Spider)/i, 'Yahoo', 'search'],
[/(?:YandexBot)/i, 'Yandex', 'search'],
// ── DuckDuckGo ────────────────────────────────────────
[/(?:DuckDuckBot\/[\d.]+)/i, 'DuckDuckGo', 'search'],
// ── Yandex (RU) ───────────────────────────────────────
[/(?:YandexBot\/[\d.]+)/i, 'Yandex', 'search'],
[/(?:YandexImages\/[\d.]+)/i, 'Yandex', 'search'],
[/(?:YandexAccessibilityBot)/i, 'Yandex', 'search'],
[/(?:YandexMobileBot)/i, 'Yandex', 'search'],
[/(?:YandexMetrika)/i, 'Yandex', 'search'],
[/(?:YandexDirect)/i, 'Yandex', 'search'],
[/(?:YandexMobileBot)/i, 'Yandex', 'search'],
// ── Baidu (CN) ────────────────────────────────────────
[/(?:Baiduspider\/[\d.]+)/i, 'Baidu', 'search'],
[/(?:Baiduboxapp)/i, 'Baidu', 'search'],
// ── Sogou (CN) ────────────────────────────────────────
[/(?:Sogou\s*Spider)/i, 'Sogou', 'search'],
[/(?:Sogou\s*web\s*spider)/i, 'Sogou', 'search'],
// ── Apple ─────────────────────────────────────────────
[/(?:Applebot\/[\d.]+)/i, 'Apple', 'search'],
[/(?:Applebot-Extended)/i, 'Apple', 'search'],
// ── PetalBot (China) ──────────────────────────────────
[/(?:petalbot)/i, 'PetalBot', 'search'],
// ── Internet Archive ──────────────────────────────────
[/(?:ia_archiver)/i, 'IA Archiver', 'other'],
[/(?:archive\.org_bot)/i, 'IA Archiver', 'other'],
// ── Social media bots ─────────────────────────────────
[/(?:facebookexternalhit\/[\d.]+)/i, 'Facebook', 'social'],
[/(?:facebookplatform)/i, 'Facebook', 'social'],
[/(?:Twitterbot\/[\d.]+)/i, 'Twitter', 'social'],
[/(?:LinkedInBot\/[\d.]+)/i, 'LinkedIn', 'social'],
[/(?:WhatsApp\/[\d.]+)/i, 'WhatsApp', 'social'],
[/(?:W3C_Validator)/i, 'W3C', 'other'],
// ── Generic / catch-all bot signatures ────────────────
[/(?:Bot\/[\d.])/i, 'Other', 'other'],
[/(?:;\s*bot\s*;?)/i, 'Other', 'other'],
[/(?:spider)/i, 'Other', 'other'],
[/(?:crawler)/i, 'Other', 'other'],
];
// ── Public API ───────────────────────────────────────────────
export function isSpider(userAgent: string): boolean {
if (!userAgent) return false;
for (const [pattern] of SPIDER_PATTERNS) {
if (pattern.test(userAgent)) return true;
}
return false;
}
export function getSpiderInfo(userAgent: string): SpiderInfo | null {
if (!userAgent) return null;
for (const [pattern, engine, category] of SPIDER_PATTERNS) {
const match = userAgent.match(pattern);
if (match) {
return { engine, name: match[0], category };
}
}
return null;
}
export function isSearchSpider(userAgent: string): boolean {
const info = getSpiderInfo(userAgent);
return info?.category === 'search';
}
export function getUserAgent(headers: Record<string, string | string[] | undefined>): string {
const ua = headers['user-agent'] || headers['User-Agent'];
if (typeof ua === 'string') return ua;
if (Array.isArray(ua) && ua.length > 0) return ua[0];
return '';
}
+1 -1
View File
@@ -213,7 +213,7 @@
.about-cta {
text-align: center;
padding: $spacing-2xl $spacing-lg;
background: $mixed-gradient;
background: var(--mixed-gradient);
border-radius: $radius-xl;
color: var(--color-white);
+14 -14
View File
@@ -10,34 +10,34 @@ describe('SSR server routes — app.server.routes.ts', () => {
expect(serverRoutes).toHaveLength(8);
});
it('should prerender the home route', () => {
it('should use SSR mode for the home route', () => {
const home = serverRoutes.find((r) => r.path === '');
expect(home).toBeDefined();
expect(home!.renderMode).toBe(RenderMode.Prerender);
expect(home!.renderMode).toBe(RenderMode.Server);
});
it('should prerender /about', () => {
it('should use SSR mode for /about', () => {
const about = serverRoutes.find((r) => r.path === 'about');
expect(about).toBeDefined();
expect(about!.renderMode).toBe(RenderMode.Prerender);
expect(about!.renderMode).toBe(RenderMode.Server);
});
it('should prerender /schedule', () => {
it('should use SSR mode for /schedule', () => {
const schedule = serverRoutes.find((r) => r.path === 'schedule');
expect(schedule).toBeDefined();
expect(schedule!.renderMode).toBe(RenderMode.Prerender);
expect(schedule!.renderMode).toBe(RenderMode.Server);
});
it('should prerender /events', () => {
it('should use SSR mode for /events', () => {
const events = serverRoutes.find((r) => r.path === 'events');
expect(events).toBeDefined();
expect(events!.renderMode).toBe(RenderMode.Prerender);
expect(events!.renderMode).toBe(RenderMode.Server);
});
it('should prerender /contact', () => {
it('should use SSR mode for /contact', () => {
const contact = serverRoutes.find((r) => r.path === 'contact');
expect(contact).toBeDefined();
expect(contact!.renderMode).toBe(RenderMode.Prerender);
expect(contact!.renderMode).toBe(RenderMode.Server);
});
it('should use SSR mode for /login', () => {
@@ -58,13 +58,13 @@ describe('SSR server routes — app.server.routes.ts', () => {
expect(wildcard!.renderMode).toBe(RenderMode.Server);
});
it('should have exactly 5 prerendered routes', () => {
it('should have no prerendered routes (eliminates stale DEFAULT_CONFIG)', () => {
const prerendered = serverRoutes.filter((r) => r.renderMode === RenderMode.Prerender);
expect(prerendered).toHaveLength(5);
expect(prerendered).toHaveLength(0);
});
it('should have exactly 3 SSR routes', () => {
it('should have exactly 8 SSR routes (all routes)', () => {
const ssr = serverRoutes.filter((r) => r.renderMode === RenderMode.Server);
expect(ssr).toHaveLength(3);
expect(ssr).toHaveLength(8);
});
});
+10 -8
View File
@@ -3,16 +3,18 @@ import { RenderMode, ServerRoute } from '@angular/ssr';
/**
* Server routes configuration for Angular SSR.
*
* The five public routes are prerendered at build time (SSG) for optimal SEO.
* Authenticated routes (/login, /admin) use SSR for dynamic rendering.
* All other routes fall back to SSR.
* ALL routes use RenderMode.Server — every request hits the SSR server,
* which fetches fresh station config from the API before rendering HTML.
* This eliminates the stale-asset flash that occurred with prerendered HTML
* (prerender runs at build time when the API is unavailable, baking in
* DEFAULT_CONFIG values).
*/
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'about', renderMode: RenderMode.Prerender },
{ path: 'schedule', renderMode: RenderMode.Prerender },
{ path: 'events', renderMode: RenderMode.Prerender },
{ path: 'contact', renderMode: RenderMode.Prerender },
{ path: '', renderMode: RenderMode.Server },
{ path: 'about', renderMode: RenderMode.Server },
{ path: 'schedule', renderMode: RenderMode.Server },
{ path: 'events', renderMode: RenderMode.Server },
{ path: 'contact', renderMode: RenderMode.Server },
{ path: 'login', renderMode: RenderMode.Server },
{ path: 'admin', renderMode: RenderMode.Server },
{ path: '**', renderMode: RenderMode.Server },
+7 -4
View File
@@ -54,14 +54,17 @@ export class App implements OnInit, OnDestroy {
}
ngOnInit(): void {
// Load station config on BOTH server and browser. On the server this
// fetches fresh data from the API so SSR renders with real colors,
// assets, and content — not DEFAULT_CONFIG baked at build time.
this.stationConfig.load().catch(() => {});
// These DOM operations only run in the browser.
if (!isPlatformBrowser(this.platformId)) return;
// Load runtime config.json and station config asynchronously in the
// background — do not block rendering. The app already renders with
// defaults from the SSR/prerendered HTML.
// Load runtime config.json asynchronously in the background — do not
// block rendering.
loadAppConfigAsync(this.http).catch(() => {});
this.stationConfig.load().catch(() => {});
// Start watching router for automatic SEO metadata updates per route.
this.seoService.watchRouter();
+1 -1
View File
@@ -34,7 +34,7 @@
.event-date {
flex-shrink: 0;
background: $mixed-gradient;
background: var(--mixed-gradient);
color: var(--color-white);
display: flex;
flex-direction: column;
+8 -8
View File
@@ -4,7 +4,7 @@
.login-section {
@include flex-center;
min-height: calc(100vh - 80px);
background: $sky-gradient;
background: var(--sky-gradient);
padding: $spacing-xl;
}
@@ -21,13 +21,13 @@
h1 {
font-family: $font-heading;
color: $primary-blue;
color: var(--color-primary);
margin: $spacing-sm 0 $spacing-xs;
font-size: 1.75rem;
}
p {
color: $neutral-medium;
color: var(--color-medium);
font-size: 0.9rem;
}
}
@@ -49,14 +49,14 @@
display: block;
font-size: 0.875rem;
font-weight: 600;
color: $neutral-dark;
color: var(--color-dark);
margin-bottom: $spacing-xs;
}
input {
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid $neutral-light;
border: 1px solid var(--color-light);
border-radius: $radius-md;
font-size: 1rem;
transition: border-color $transition-fast;
@@ -64,7 +64,7 @@
&:focus {
outline: none;
border-color: $primary-blue;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
}
}
@@ -72,7 +72,7 @@
.login-error {
background: rgba($danger-red, 0.1);
color: $danger-red;
color: var(--color-danger);
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
font-size: 0.875rem;
@@ -81,7 +81,7 @@
}
.btn-login {
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
@include button-style(var(--color-primary), var(--color-white), var(--color-primary-light));
width: 100%;
padding: $spacing-sm $spacing-lg;
margin-top: $spacing-sm;
+12 -1
View File
@@ -74,7 +74,18 @@ export class StationConfigService {
private themeService = inject(ThemeService);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/station-config`;
readonly config = signal<StationConfig>(DEFAULT_CONFIG);
/**
* Read pre-seeded config injected by the server at runtime.
* The SSR server fetches fresh station config and injects it as
* `window.__STATION_CONFIG` before the Angular bundle loads.
* This eliminates the DEFAULT_CONFIG flash: the client bootstraps
* with real data instead of hardcoded defaults.
*/
readonly config = signal<StationConfig>(
typeof window !== 'undefined' && (window as any).__STATION_CONFIG
? (window as any).__STATION_CONFIG as StationConfig
: DEFAULT_CONFIG,
);
/** Load station config from the API. Falls back to defaults on error. */
async load(): Promise<void> {
+25 -1
View File
@@ -1,17 +1,41 @@
import { APP_ID } from '@angular/core';
import { APP_ID, APP_INITIALIZER, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app/app.config';
import { App } from './app/app';
import { APP_CONFIG, AppConfig } from './app/services/app-config.service';
import { StationConfigService } from './app/services/station-config.service';
import { serverRoutes } from './app/app.server.routes';
// During SSR, the API base URL must be absolute (HttpClient runs in Node.js).
// nginx proxies /api/ to the upstream, so point at nginx (port 80) locally.
const ssrApiBaseUrl = process.env['SSR_API_BASE_URL'] || 'http://localhost:80';
const ssrAppConfig: AppConfig = { apiBaseUrl: ssrApiBaseUrl };
const serverAppConfig = {
...appConfig,
providers: [
...appConfig.providers,
{ provide: APP_ID, useValue: 'server' },
{ provide: APP_CONFIG, useValue: ssrAppConfig },
provideServerRendering(withRoutes(serverRoutes)),
// Block server rendering until station config is fetched from the API.
// This ensures SSR renders with real DB data (colors, assets, content)
// instead of DEFAULT_CONFIG baked at build time. In the browser, this
// is a no-op — config loads async in ngOnInit after first paint.
{
provide: APP_INITIALIZER,
multi: true,
deps: [StationConfigService, PLATFORM_ID],
useFactory: (svc: StationConfigService, platformId: object) => {
if (!isPlatformBrowser(platformId)) {
return () => svc.load().catch(() => {});
}
return () => {};
},
},
],
};
+7 -25
View File
@@ -3,33 +3,15 @@
// ========== CSS Custom Properties (exposed to the browser) ==========
// These map SCSS variables to CSS custom properties so Angular components
// can consume them via var() at runtime.
//
// COLOR variables (color_*, gradient_*) are NOT set here anymore.
// They are injected at runtime by the SSR server as an inline <style> block
// so the first paint always carries the correct station colors.
// Client-side ThemeService.apply() keeps them in sync after Angular boots.
//
// Non-color variables (fonts, radii, shadows) remain below as build-time defaults.
:root {
// Primary
--color-primary: #{$primary-blue};
--color-primary-light: #{$primary-blue-light};
--color-primary-dark: #{$primary-blue-dark};
--color-primary-muted: #{$primary-blue-muted};
// Accent
--color-accent: #{$accent-orange};
--color-accent-light: #{$accent-orange-light};
--color-accent-dark: #{$accent-orange-dark};
--color-accent-muted: #{$accent-orange-muted};
// Neutrals
--color-white: #{$neutral-white};
--color-cream: #{$neutral-cream};
--color-light: #{$neutral-light};
--color-medium: #{$neutral-medium};
--color-dark: #{$neutral-dark};
--color-black: #{$neutral-black};
// Semantic
--color-success: #{$success-green};
--color-danger: #{$danger-red};
--color-info: #{$info-blue};
// Typography
--font-primary: #{$font-primary};
--font-heading: #{$font-heading};
+2 -1
View File
@@ -9,7 +9,8 @@
},
"include": [
"src/**/*.ts",
"server.ts"
"server.ts",
"spider_detection.ts"
],
"exclude": [
"src/**/*.spec.ts"
+1 -1
View File
@@ -3,7 +3,7 @@ export default {
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.spec.ts'],
include: ['src/**/*.spec.ts', 'server.spec.ts'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {