From 3f78f8f8808957341f4ec99b1ef923f2e3744f2b Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Fri, 31 Jul 2026 00:41:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E8=A1=A5=E9=BD=90=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E5=88=86=E5=8F=91=E6=9C=8D=E5=8A=A1=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 bat-api 资源 bootstrap/分发 HTTP 服务、RPC release 发现、CDN path 分发、launcher 资源引导兼容、控制面中间件、OpenAPI 和 systemd 模板。 同步 Go 边界文档,明确 Rust bat 是资源生产者和同步运维入口,Go bat-api 是只读 bootstrap/分发服务,试验 Go CLI 产物为 bin/bat-go。 验证:未运行新命令;本轮已按要求停止重复构建/测试。 --- Makefile | 45 +- api/README.md | 3 + api/openapi/README.md | 9 + api/openapi/bat-api.yaml | 138 +++ api/proto/README.md | 4 + cmd/bat-api/.env.example | 32 + cmd/bat-api/main.go | 143 +++ cmd/bat/main.go | 13 +- deployments/systemd/bat-api.env.example | 41 + .../bluearchive-toolkit-bat-api.service | 43 + .../BAT_API_CONTRACT_FIXTURE_HANDOFF.md | 196 ++++ docs/reports/GO_STATUS.md | 151 +++ internal/api/admin.go | 29 + internal/api/api_test.go | 962 ++++++++++++++++++ internal/api/cdn.go | 87 ++ internal/api/config.go | 372 +++++++ internal/api/launcher.go | 208 ++++ internal/api/middleware.go | 259 +++++ internal/api/openapi.go | 161 +++ internal/api/pathsafe.go | 93 ++ internal/api/release_index.go | 292 ++++++ internal/api/responses.go | 174 ++++ internal/api/rpc_release.go | 269 +++++ internal/api/server.go | 483 +++++++++ internal/api/serverinfo.go | 125 +++ .../release/official-download-manifest.json | 17 + .../release/official-sync-snapshot.json | 25 + .../TableBundles/TableCatalog.bytes | 1 + .../TableBundles/TableCatalog.hash | 1 + .../r93_fixture.json | 11 + internal/api/testdata/server-info.json | 11 + internal/config/README.md | 4 + internal/downloader/README.md | 4 + internal/extractor/README.md | 4 + internal/manifest/README.md | 4 + internal/storage/README.md | 4 + pkg/README.md | 3 + pkg/cas/README.md | 4 + pkg/translator/README.md | 4 + pkg/types/README.md | 4 + web/README.md | 4 + web/admin/README.md | 10 + web/shared/README.md | 3 + 43 files changed, 4427 insertions(+), 23 deletions(-) create mode 100644 api/README.md create mode 100644 api/openapi/README.md create mode 100644 api/openapi/bat-api.yaml create mode 100644 api/proto/README.md create mode 100644 cmd/bat-api/.env.example create mode 100644 cmd/bat-api/main.go create mode 100644 deployments/systemd/bat-api.env.example create mode 100644 deployments/systemd/bluearchive-toolkit-bat-api.service create mode 100644 docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md create mode 100644 docs/reports/GO_STATUS.md create mode 100644 internal/api/admin.go create mode 100644 internal/api/api_test.go create mode 100644 internal/api/cdn.go create mode 100644 internal/api/config.go create mode 100644 internal/api/launcher.go create mode 100644 internal/api/middleware.go create mode 100644 internal/api/openapi.go create mode 100644 internal/api/pathsafe.go create mode 100644 internal/api/release_index.go create mode 100644 internal/api/responses.go create mode 100644 internal/api/rpc_release.go create mode 100644 internal/api/server.go create mode 100644 internal/api/serverinfo.go create mode 100644 internal/api/testdata/release/official-download-manifest.json create mode 100644 internal/api/testdata/release/official-sync-snapshot.json create mode 100644 internal/api/testdata/release/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes create mode 100644 internal/api/testdata/release/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash create mode 100644 internal/api/testdata/release/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json create mode 100644 internal/api/testdata/server-info.json create mode 100644 internal/config/README.md create mode 100644 internal/downloader/README.md create mode 100644 internal/extractor/README.md create mode 100644 internal/manifest/README.md create mode 100644 internal/storage/README.md create mode 100644 pkg/README.md create mode 100644 pkg/cas/README.md create mode 100644 pkg/translator/README.md create mode 100644 pkg/types/README.md create mode 100644 web/README.md create mode 100644 web/admin/README.md create mode 100644 web/shared/README.md diff --git a/Makefile b/Makefile index b76d169..3781e4c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build build-ffi test clean check fmt lint install dev docker-build docker-up docker-down official-smoke +.PHONY: help build build-ffi test clean check fmt lint install dev docker-build docker-up docker-down official-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all # 默认目标 .DEFAULT_GOAL := help @@ -28,21 +28,25 @@ build-ffi: ## 构建 bat-ffi release 库(cgo 链接依赖) @echo "$(BLUE)Building bat-ffi (release)...$(NC)" cargo build --release -p bat-ffi -build-go: build-ffi ## 构建 Go 组件 - @echo "$(BLUE)Building Go CLI...$(NC)" +build-go: build-go-api ## 构建 Go 默认产物(bat-api bootstrap/分发;同步 CLI 请用 Rust bat) + +build-go-api: ## 构建 bat-api(资源 bootstrap/分发 HTTP,无 FFI) + @echo "$(BLUE)Building bat-api (resource bootstrap + distribution)...$(NC)" + @mkdir -p bin + go build -o bin/bat-api ./cmd/bat-api + +build-go-cli: build-ffi ## 构建试验性 Go CLI → bin/bat-go(禁止命名为 bat) + @echo "$(BLUE)Building experimental Go CLI as bin/bat-go...$(NC)" + @mkdir -p bin @if [ -f cmd/bat/main.go ]; then \ - go build -o bin/bat ./cmd/bat; \ + go build -o bin/bat-go ./cmd/bat; \ else \ - echo "$(YELLOW)Go CLI entrypoint not implemented yet, skipping...$(NC)"; \ + echo "$(YELLOW)experimental cmd/bat missing, skipping...$(NC)"; \ fi -install: ## 安装到本地 - @echo "$(BLUE)Installing bat CLI...$(NC)" - @if [ -f cmd/bat/main.go ]; then \ - go install ./cmd/bat; \ - else \ - echo "$(YELLOW)Go CLI entrypoint not implemented yet, skipping...$(NC)"; \ - fi +install: build-go-api ## 安装 bat-api 到 GOPATH/bin(不安装名为 bat 的 Go 二进制) + @echo "$(BLUE)Installing bat-api...$(NC)" + go install ./cmd/bat-api # ============================================================================ # 测试相关 @@ -54,14 +58,17 @@ test-rust: ## 运行 Rust 测试 @echo "$(BLUE)Running Rust tests...$(NC)" cargo test --workspace -test-go: build-ffi ## 运行 Go 测试 - @echo "$(BLUE)Running Go tests...$(NC)" - @if [ -n "$$(go list ./... 2>/dev/null)" ]; then \ - go test -v ./...; \ - else \ - echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \ - fi +test-go: test-go-api ## 默认 Go 门禁(无 FFI;见 GO_STATUS.md) +test-go-api: ## 纯 Go 测试:internal/api + backendrpc + @echo "$(BLUE)Running pure Go tests (api + backendrpc)...$(NC)" + go test ./internal/api/... ./internal/backendrpc/... + +test-go-ffi: build-ffi ## 含 FFI/试验 CLI 的 Go 测试 + @echo "$(BLUE)Running Go tests including FFI packages...$(NC)" + go test ./... + +test-go-all: test-go-api test-go-ffi ## 全部 Go 测试 bench: ## 运行性能基准测试 @echo "$(BLUE)Running benchmarks...$(NC)" cargo bench --workspace diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..0f09925 --- /dev/null +++ b/api/README.md @@ -0,0 +1,3 @@ +# Reserved empty directory + +Placeholder only. **Not implemented.** See `docs/reports/GO_STATUS.md`. diff --git a/api/openapi/README.md b/api/openapi/README.md new file mode 100644 index 0000000..a50afa6 --- /dev/null +++ b/api/openapi/README.md @@ -0,0 +1,9 @@ +# bat-api OpenAPI + +`bat-api.yaml` describes the current resource bootstrap / read-only distribution +HTTP surface. The running service also exposes the same contract at +`GET /openapi.yaml`. + +This contract covers resource bootstrap, launcher resource compatibility, +server-info rewrite, CDN-shaped resource bytes, auth schemes, and the reserved +admin panel entry. It does not describe a full game business API. diff --git a/api/openapi/bat-api.yaml b/api/openapi/bat-api.yaml new file mode 100644 index 0000000..d00b4c3 --- /dev/null +++ b/api/openapi/bat-api.yaml @@ -0,0 +1,138 @@ +openapi: 3.0.3 +info: + title: BlueArchive Toolkit bat-api + version: 0.1.0 + description: Resource bootstrap and read-only distribution API. +servers: + - url: http://127.0.0.1:18080 +security: + - bearerAuth: [] + - queryToken: [] +paths: + /healthz: + get: + summary: Liveness and refresh diagnostics + responses: + "200": + description: Service is alive. + /readyz: + get: + summary: Release readiness + responses: + "200": + description: A distributable release is available. + "503": + description: No distributable release is available. + /v1/bootstrap: + get: + summary: Startup resource bootstrap + responses: + "200": + description: Resource bootstrap response. + "503": + description: Release is not ready. + /v1/launcher/bootstrap: + get: + summary: Launcher-shaped resource bootstrap + responses: + "200": + description: Launcher bootstrap response. + "503": + description: Release is not ready. + /api/launcher/game/config: + get: + summary: Resource-only launcher game config compatibility + responses: + "200": + description: Launcher envelope with resource metadata. + /api/launcher/game/config/json: + get: + summary: Resource-only launcher manifest URL compatibility + parameters: + - name: version + in: query + schema: + type: string + - name: file_path + in: query + schema: + type: string + responses: + "200": + description: Launcher envelope pointing to resource bootstrap JSON. + /api/launcher/advanced/game/download/cdn: + get: + summary: Resource-only launcher CDN compatibility + responses: + "200": + description: Launcher envelope with public base URL as CDN root. + /v1/release: + get: + summary: Current release summary + responses: + "200": + description: Release summary. + /v1/resources: + get: + summary: Paginated resource manifest entries + parameters: + - name: offset + in: query + schema: + type: integer + minimum: 0 + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 1000 + responses: + "200": + description: Resource list page. + /v1/server-info: + get: + summary: Rewritten server-info document + responses: + "200": + description: Server-info JSON with AddressablesCatalogUrlRoot rewritten. + /openapi.yaml: + get: + summary: OpenAPI document + responses: + "200": + description: OpenAPI YAML. + /admin/: + get: + summary: Reserved admin panel entry + responses: + "200": + description: Admin panel placeholder and links. + /prod-clientpatch.bluearchiveyostar.com/{path}: + get: + summary: CDN-shaped resource bytes + parameters: + - name: path + in: path + required: true + schema: + type: string + responses: + "200": + description: Resource bytes. + "206": + description: Partial resource bytes. + head: + summary: CDN-shaped resource metadata + responses: + "200": + description: Resource headers. +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + queryToken: + type: apiKey + in: query + name: bat_token diff --git a/api/proto/README.md b/api/proto/README.md new file mode 100644 index 0000000..0b1d102 --- /dev/null +++ b/api/proto/README.md @@ -0,0 +1,4 @@ +# Reserved empty directory + +This path is a monorepo placeholder and is **not implemented**. +Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`. diff --git a/cmd/bat-api/.env.example b/cmd/bat-api/.env.example new file mode 100644 index 0000000..85c6721 --- /dev/null +++ b/cmd/bat-api/.env.example @@ -0,0 +1,32 @@ +# bat-api configuration example (copy to .env next to the binary or export) +# Priority: CLI flags > process environment > .env > built-in defaults. +# +# Boundary: +# - Rust bat: resource auto-discover / pull / verify / publish / daemon RPC +# - bat-api: resource bootstrap + read-only distribution (official CDN-shaped paths) +# + management APIs + +BAT_API_LISTEN=:18080 +BAT_API_PUBLIC_BASE_URL=http://127.0.0.1:18080 + +# Primary discovery: bat daemon JSON-RPC socket file +BAT_API_STATE_DIR=/tmp/bat-pid +# BAT_API_SOCKET=/tmp/bat-pid/bat.sock + +# Optional release root override (local fixtures / emergency read-only diagnostics only). +# Production obtains resource_root from BAT_API_SOCKET RPC; do not set this there. +# BAT_API_RESOURCE_ROOT= + +# BAT_API_SERVER_INFO_FILE= +BAT_API_REQUIRE_INDEXED=true +BAT_API_VERIFY_SIZE=true +BAT_API_RPC_TIMEOUT=30s +# Periodically re-read bat.sock so bat-api follows Rust bat release switches. +# Set to 0 in fixture-only local development. +BAT_API_REFRESH_INTERVAL=1m + +# Reserved for future API persistence +# BAT_API_DATABASE_URL=postgres://bat:@127.0.0.1:5432/bat?sslmode=disable +# BAT_API_DATABASE_PASSWORD= +# BAT_API_REDIS_URL=redis://127.0.0.1:6379/0 +# BAT_API_REDIS_PASSWORD= diff --git a/cmd/bat-api/main.go b/cmd/bat-api/main.go new file mode 100644 index 0000000..c1702f2 --- /dev/null +++ b/cmd/bat-api/main.go @@ -0,0 +1,143 @@ +// Command bat-api is the resource bootstrap and distribution HTTP service for BlueArchiveToolkit. +// +// Responsibility boundary: +// - bat (Rust): official resource auto-discover, pull, verify, publish, daemon RPC +// - bat-api (Go): startup resource bootstrap, server-info rewrite, +// read-only distribution of published resources (CDN-shaped paths), release +// inspection APIs, and normal process configuration (.env / flags for listen +// port, RPC socket, reserved database/redis settings) +// +// bat-api discovers and periodically refreshes the current release through the +// bat.sock JSON-RPC contract (daemon.status first, then daemon.doctor, then +// catalog/resource methods). The production resource root comes from RPC; the +// resource-root override is for local fixtures or emergency diagnostics. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "bat-api/internal/api" + "bat-api/internal/backendrpc" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lmsgprefix) + log.SetPrefix("bat-api ") + + cfg := api.DefaultConfig() + if os.Getenv("BAT_API_SKIP_ENV_FILE") != "1" { + envPath := envFilePath() + if err := ensureEnvTemplate(envPath); err != nil { + log.Printf("warn: env template: %v", err) + } + if err := api.LoadEnvFile(envPath); err != nil { + log.Fatalf("load .env: %v", err) + } + } + api.ApplyEnv(&cfg) + + listen := flag.String("listen", cfg.Listen, "HTTP listen address") + publicBase := flag.String("public-base-url", cfg.PublicBaseURL, "public base URL for Addressables rewrite") + stateDir := flag.String("state-dir", cfg.StateDir, "bat daemon state dir (derives default socket)") + socket := flag.String("socket", cfg.SocketPath, "path to bat.sock JSON-RPC socket (primary discovery)") + resourceRoot := flag.String("resource-root", cfg.ResourceRoot, "override published release root (tests/emergency)") + serverInfo := flag.String("server-info-file", cfg.ServerInfoFile, "optional server-info JSON path") + requireIndexed := flag.Bool("require-indexed", cfg.RequireIndexed, "only serve files present in the release index") + verifySize := flag.Bool("verify-size", cfg.VerifySize, "reject CDN files whose size differs from the index") + rpcTimeout := flag.Duration("rpc-timeout", cfg.RPCTimeout, "daemon RPC timeout") + refreshInterval := flag.Duration("refresh-interval", cfg.RefreshInterval, "periodic release discovery interval (0 disables)") + authQueryParam := flag.String("auth-query-param", cfg.AuthQueryParam, "query parameter accepted for token auth fallback") + authExemptPaths := flag.String("auth-exempt-paths", strings.Join(cfg.AuthExemptPaths, ","), "comma-separated auth-exempt exact paths or slash-prefixes") + trustProxyHeaders := flag.Bool("trust-proxy-headers", cfg.TrustProxyHeaders, "trust X-Forwarded-For and X-Real-IP from reverse proxy") + accessLog := flag.Bool("access-log", cfg.AccessLog, "enable per-request access logs without query strings") + rateLimitRPS := flag.Float64("rate-limit-rps", cfg.RateLimitRPS, "per-client request rate limit; 0 disables") + rateLimitBurst := flag.Int("rate-limit-burst", cfg.RateLimitBurst, "per-client rate limit burst") + maxResourceLimit := flag.Int("max-resource-limit", cfg.MaxResourcePageLimit, "maximum /v1/resources page size") + flag.Parse() + + cfg.Listen = *listen + cfg.PublicBaseURL = *publicBase + cfg.StateDir = *stateDir + cfg.SocketPath = *socket + cfg.ResourceRoot = *resourceRoot + cfg.ServerInfoFile = *serverInfo + cfg.RequireIndexed = *requireIndexed + cfg.VerifySize = *verifySize + cfg.RPCTimeout = *rpcTimeout + cfg.RefreshInterval = *refreshInterval + cfg.AuthQueryParam = *authQueryParam + cfg.AuthExemptPaths = splitFlagCSV(*authExemptPaths) + cfg.TrustProxyHeaders = *trustProxyHeaders + cfg.AccessLog = *accessLog + cfg.RateLimitRPS = *rateLimitRPS + cfg.RateLimitBurst = *rateLimitBurst + cfg.MaxResourcePageLimit = *maxResourceLimit + // If socket still empty after flags, derive from state-dir. + if cfg.SocketPath == "" { + cfg.SocketPath = filepath.Join(cfg.StateDir, "bat.sock") + } + if err := cfg.Normalize(); err != nil { + log.Fatalf("config: %v", err) + } + + var backend api.Backend + client := backendrpc.New(cfg.SocketPath) + client.Timeout = cfg.RPCTimeout + backend = api.RPCClient{Client: client} + + server := api.NewServer(cfg, backend, log.Default()) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + refreshCtx, refreshCancel := context.WithTimeout(ctx, cfg.RPCTimeout+5*time.Second) + if err := server.Refresh(refreshCtx); err != nil { + log.Printf("initial discover failed: %v (serving with empty/partial index)", err) + } + refreshCancel() + server.StartRefreshLoop(ctx) + + if err := server.ListenAndServe(ctx); err != nil && err != context.Canceled { + log.Fatalf("serve: %v", err) + } +} + +func envFilePath() string { + exe, err := os.Executable() + if err != nil { + return api.EnvFileName + } + return filepath.Join(filepath.Dir(exe), api.EnvFileName) +} + +func ensureEnvTemplate(path string) error { + if _, err := os.Stat(path); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + if err := os.WriteFile(path, []byte(api.EnvTemplate), 0o600); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + log.Printf("wrote config template %s", path) + return nil +} + +func splitFlagCSV(raw string) []string { + var out []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} diff --git a/cmd/bat/main.go b/cmd/bat/main.go index 82b10e4..913dc32 100644 --- a/cmd/bat/main.go +++ b/cmd/bat/main.go @@ -34,10 +34,15 @@ func main() { } func printUsage() { - fmt.Println("bat - BlueArchiveToolkit CLI") + fmt.Println("bat-go - experimental Go helper (NOT the product CLI)") + fmt.Println() + fmt.Println("Product sync/ops CLI is the Rust binary `bat` (nearly fully automatic).") + fmt.Println("Product resource HTTP service is `bat-api` (see docs/reports/GO_STATUS.md).") + fmt.Println() + fmt.Println("This binary is experimental FFI demos only. Build output must be bin/bat-go.") fmt.Println() fmt.Println("Usage:") - fmt.Println(" bat doctor") - fmt.Println(" bat manifest inspect ") - fmt.Println(" bat sync plan [previous-json]") + fmt.Println(" bat-go doctor") + fmt.Println(" bat-go manifest inspect ") + fmt.Println(" bat-go sync plan [previous-json]") } diff --git a/deployments/systemd/bat-api.env.example b/deployments/systemd/bat-api.env.example new file mode 100644 index 0000000..7c34d84 --- /dev/null +++ b/deployments/systemd/bat-api.env.example @@ -0,0 +1,41 @@ +# Optional overrides for bluearchive-toolkit-bat-api.service. +# +# Install as: +# sudo install -o root -g root -m 0644 deployments/systemd/bat-api.env.example /etc/bluearchive-toolkit/bat-api.env +# +# Production contract: +# - bat-api runs in the same server/container environment as Rust bat. +# - The current resource_root comes from bat.sock RPC. +# - Do not set BAT_API_RESOURCE_ROOT in production; it is only for local +# fixtures or emergency read-only diagnostics when RPC is unavailable. +# - Publish HTTP through a reverse proxy/TLS if exposed publicly; never expose +# bat.sock outside the host. +# - Player-facing deployments should set BAT_API_AUTH_TOKEN through a secret +# manager or process environment, not in a committed file. + +BAT_API_LISTEN=127.0.0.1:18080 +BAT_API_PUBLIC_BASE_URL=http://127.0.0.1:18080 +BAT_API_STATE_DIR=/var/lib/bluearchive-toolkit/daemon-state +BAT_API_SOCKET=/var/lib/bluearchive-toolkit/daemon-state/bat.sock +BAT_API_REQUIRE_INDEXED=true +BAT_API_VERIFY_SIZE=true +BAT_API_RPC_TIMEOUT=30s +BAT_API_REFRESH_INTERVAL=1m +BAT_API_SKIP_ENV_FILE=1 +BAT_API_AUTH_QUERY_PARAM=bat_token +# BAT_API_AUTH_TOKEN= +# BAT_API_AUTH_EXEMPT_PATHS=/healthz,/readyz +BAT_API_TRUST_PROXY_HEADERS=false +BAT_API_ACCESS_LOG=true +BAT_API_RATE_LIMIT_RPS=30 +BAT_API_RATE_LIMIT_BURST=120 +BAT_API_MAX_RESOURCE_LIMIT=1000 + +# Local fixture / emergency only: +# BAT_API_RESOURCE_ROOT=/var/lib/bluearchive-toolkit/official/current + +# Reserved for future API persistence: +# BAT_API_DATABASE_URL=postgres://bat:@127.0.0.1:5432/bat?sslmode=disable +# BAT_API_DATABASE_PASSWORD= +# BAT_API_REDIS_URL=redis://127.0.0.1:6379/0 +# BAT_API_REDIS_PASSWORD= diff --git a/deployments/systemd/bluearchive-toolkit-bat-api.service b/deployments/systemd/bluearchive-toolkit-bat-api.service new file mode 100644 index 0000000..e9f594e --- /dev/null +++ b/deployments/systemd/bluearchive-toolkit-bat-api.service @@ -0,0 +1,43 @@ +[Unit] +Description=BlueArchiveToolkit bat-api resource bootstrap and distribution +Documentation=https://github.com/Yuyi-Oak/BlueArchiveToolkit +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=bat +Group=bat +WorkingDirectory=/var/lib/bluearchive-toolkit +Environment=BAT_API_LISTEN=127.0.0.1:18080 +Environment=BAT_API_PUBLIC_BASE_URL=http://127.0.0.1:18080 +Environment=BAT_API_STATE_DIR=/var/lib/bluearchive-toolkit/daemon-state +Environment=BAT_API_SOCKET=/var/lib/bluearchive-toolkit/daemon-state/bat.sock +Environment=BAT_API_REQUIRE_INDEXED=true +Environment=BAT_API_VERIFY_SIZE=true +Environment=BAT_API_RPC_TIMEOUT=30s +Environment=BAT_API_REFRESH_INTERVAL=1m +Environment=BAT_API_SKIP_ENV_FILE=1 +EnvironmentFile=-/etc/bluearchive-toolkit/bat-api.env +ExecStart=/opt/bluearchive-toolkit/bin/bat-api +Restart=on-failure +RestartSec=10 +TimeoutStopSec=30 +KillSignal=SIGTERM +StandardOutput=journal +StandardError=journal +RuntimeDirectory=bluearchive-toolkit-bat-api +RuntimeDirectoryMode=0750 +LogsDirectory=bluearchive-toolkit +LogsDirectoryMode=0750 +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadOnlyPaths=/var/lib/bluearchive-toolkit +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +LockPersonality=true +MemoryDenyWriteExecute=true + +[Install] +WantedBy=multi-user.target diff --git a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md new file mode 100644 index 0000000..7ccd5cf --- /dev/null +++ b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md @@ -0,0 +1,196 @@ +# bat-api / Rust bat Contract Fixture Handoff + +更新时间:2026-07-28 + +本文用于两个 Codex 窗口之间间接联调 `bat-api` 与 Rust `bat` 的跨语言 contract fixture。它只定义协作协议和验收标准,不包含已审核 fixture。 + +## 最小上下文包 + +另一个窗口不需要知道本窗口的完整对话,只需要遵守以下上下文: + +- 本次联调对象是 Rust `bat` RPC / snapshot JSON 与 Go `bat-api` mirror struct 的 contract fixture。 +- 联调不要求本地运行全量长期服务端 `bat`;允许 Rust 侧使用 fixture root 或临时目录走真实代码路径导出 JSON。 +- fixture 审核前只能放在 `/tmp/bat-contract-fixture/`,不能直接提交到仓库。 +- Go 侧已经实现 player-facing HTTP 鉴权、限流、访问日志、反代适配、OpenAPI 和 `/admin/` 预留;contract fixture 是剩余跨语言强契约工作。 +- Go 侧当前相关代码入口: + - `internal/api/rpc_release.go` + - `internal/api/release_index.go` + - `internal/api/responses.go` + - `internal/backendrpc/` + +## 背景 + +- Rust `bat` 是资源同步、状态发布和 `bat.sock` RPC 的权威实现。 +- Go `bat-api` 是只读 HTTP bootstrap / 分发服务,消费 Rust RPC 输出和已发布资源目录。 +- contract fixture 不能由任一侧手写猜测;必须由 Rust 侧真实输出,经归一化和用户审核后,再由 Go 侧固化测试。 + +## 非目标 + +- 不引入真实玩家账号、登录、网关、鉴权绕过或游戏业务 API fixture。 +- 不写入开发机绝对资源路径,例如 `/home/wanye/D/BlueArchive`。 +- 不把当前某个真实版本号、日期、远程目录或本地目录写成长期契约。 +- 不让 Go fixture 反向约束 Rust 内部实现;只约束对外 JSON contract。 + +## 建议共享目录 + +联调前使用临时目录交换未审核产物: + +```text +/tmp/bat-contract-fixture/ + rust/ + catalog-status.available.raw.json + catalog-status.unavailable.raw.json + resource-manifest.page0.raw.json + official-sync-snapshot.raw.json + normalized/ + catalog-status.available.json + catalog-status.unavailable.json + resource-manifest.page0.json + official-sync-snapshot.json + notes.md +``` + +只有用户审核通过后,才允许把归一化 fixture 落入仓库,例如: + +```text +internal/api/testdata/contract/ +``` + +## Rust 侧需要产出 + +Rust 窗口请基于当前真实代码生成或导出以下 JSON: + +1. `catalog.status` available=true 响应。 +2. `catalog.status` available=false 响应。 +3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。 +4. 对应 release 的 `official-sync-snapshot.json`。 + +输出应来自 Rust 代码路径,而不是手写 JSON。允许使用 fixture resource root 或临时目录,但不能依赖开发机真实资源目录。 + +## 归一化规则 + +归一化只允许处理环境相关值,不改变 schema: + +- 绝对路径归一化为 `${RESOURCE_ROOT}` 或 `${STATE_DIR}`。 +- 版本 id 归一化为 `${VERSION_ID}`。 +- 时间戳可归一化为固定小整数或 `${COMPLETED_UNIX_SECONDS}`。 +- 真实 URL host 保留;路径中若含具体 release token,可归一化为 `{addressables-root}` / `{manifest-path}`。 +- 字段名、字段类型、字段层级、null / missing / array / number 语义不得修改。 + +## Go 侧验证范围 + +Go 窗口读取归一化后的 JSON,验证: + +1. `parseCatalogStatus` 能解析 `available=true`,并正确映射: + - `app_version` + - `bundle_version` + - `connection_group_name` + - `addressables_root` + - `version.id` + - `version.completed_unix_seconds` + - `version.resource_root` + - `launcher_metadata` + - `game_main_config` +2. `parseCatalogStatus` 对 `available=false` 返回不可用而不是错误。 +3. `resource.manifest` entry 字段能映射为 Go `ResourceManifestEntry`: + - `url` + - `destination` + - `bytes` + - `blake3` +4. 本地 snapshot fixture 使用 `game_main_config_bootstrap`,RPC `catalog.status` 使用 `game_main_config`。 +5. `bat-api` bootstrap 和 launcher bootstrap 不泄露归一化前的开发机路径。 + +Go 侧审核通过后的落地建议: + +- `internal/api/testdata/contract/catalog-status.available.json` +- `internal/api/testdata/contract/catalog-status.unavailable.json` +- `internal/api/testdata/contract/resource-manifest.page0.json` +- `internal/api/testdata/contract/official-sync-snapshot.json` +- `internal/api/contract_fixture_test.go` + +测试不应依赖 `/tmp/bat-contract-fixture/`;该目录只用于两窗口交接未审核产物。 + +## 必须覆盖的 optional 语义 + +至少需要两组 Rust 输出或派生 fixture 覆盖: + +1. optional 字段非空: + - `launcher_metadata.game_lowest_version` + - `launcher_metadata.game_start_exe_name` + - `launcher_metadata.manifest_source` + - `game_main_config.server_info_data_url` + - `game_main_config.default_connection_group` +2. optional 字段为 null 或缺省: + - Go mirror 不应崩溃。 + - HTTP response 中按当前 Go struct `omitempty` 策略输出。 + +## 用户审核点 + +落仓库前请用户审核: + +- 归一化是否过度改变 Rust 真实输出。 +- fixture 是否意外绑定真实版本、日期、本机路径或私有部署路径。 +- `game_main_config` 与 `game_main_config_bootstrap` 的 RPC / snapshot 差异是否符合预期。 +- optional 字段覆盖是否足够。 + +## notes.md 模板 + +Rust 侧生成 `/tmp/bat-contract-fixture/notes.md` 时建议使用以下结构: + +```markdown +# bat contract fixture notes + +## 生成命令 + +- catalog.status available=true: ... +- catalog.status available=false: ... +- resource.manifest page0: ... +- official-sync-snapshot: ... + +## 原始输出来源 + +- Rust commit / working tree: ... +- 使用的 fixture root 或临时目录: ... +- 是否依赖真实开发机资源目录: 否 + +## 归一化 + +- `${RESOURCE_ROOT}`: ... +- `${STATE_DIR}`: ... +- `${VERSION_ID}`: ... +- `${COMPLETED_UNIX_SECONDS}`: ... +- URL 路径占位符: ... + +## 需要用户审核 + +- ... +``` + +## 完成判定 + +contract fixture 工作只有在以下条件同时满足时才算完成: + +1. Rust 侧原始 JSON 来自真实 Rust 代码路径。 +2. 归一化 JSON 经过用户审核。 +3. Go 侧测试读取归一化 fixture 并验证 mirror struct / launcher bootstrap 行为。 +4. Go 测试不依赖开发机资源目录、远程长期运行 `bat` 或 `/tmp` 中的交接目录。 +5. 文档记录 fixture 覆盖的风险和仍未覆盖的字段。 + +## 建议给另一个窗口的短指令 + +```text +请读取 docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md。 +你负责 Rust bat 侧 contract fixture 原始输出: +1. catalog.status available=true +2. catalog.status available=false +3. resource.manifest page0 +4. 对应 official-sync-snapshot.json +请输出到 /tmp/bat-contract-fixture/rust/,不要手写 JSON,不要引用开发机真实资源目录。 +输出后在 /tmp/bat-contract-fixture/notes.md 说明生成命令、是否做过归一化、哪些字段需要用户审核。 +``` + +## 当前状态 + +- Go `bat-api` 已具备消费 `launcher_metadata` / `game_main_config` 的 mirror struct。 +- Go `bat-api` 已具备 player-facing HTTP 控制面、OpenAPI 和管理面板预留。 +- contract fixture 尚未落仓库,等待 Rust 侧真实输出与用户审核。 diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md new file mode 100644 index 0000000..8911943 --- /dev/null +++ b/docs/reports/GO_STATUS.md @@ -0,0 +1,151 @@ +# Go 侧进度与边界(权威) + +- **更新时间**:2026-07-27 +- **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。 +- **关联**:issue #19 / G-009(资源 bootstrap/分发)、G-008(已决策关闭)、`docs/architecture/official-resource-backend.md` §7 + +--- + +## 1. 三个入口分别是什么 + +| 名称 | 路径 / 产物 | 角色 | 是否产品入口 | +|---|---|---|---| +| **Rust `bat`** | `infrastructure` bin → 正式同步二进制 | 官方资源**自动**发现 / 拉取 / 校验 / 发布 / watch·daemon / 运维子命令 | **是(同步与运维命令行)** | +| **Go `bat-api`** | `cmd/bat-api` → `bin/bat-api` | **资源 bootstrap + 分发 HTTP 服务**(官方 CDN path 形态)+ release 观察 API | **是(bootstrap/分发服务)** | +| **Go 试验 CLI** | `cmd/bat` → `bin/bat-go`(不得再叫 `bin/bat`) | FFI 演示骨架 | **否** | + +### 1.1 「同步命令行 = Rust `bat`」的含义 + +人类做资源同步与运维时,正式命令行是 **Rust 编译的 `bat`**(近乎全自动:`--auto-discover`、`--watch` / `--daemon` 后只需偶发 `status` / `refresh` / `repair`,不需要持久手操维护)。 + +这**不是**说整个项目只有 Rust,也**不是**取消 Go 入口: + +- Go 的正式产品入口是 **`bat-api` 服务进程**(给客户端/工具提供启动前资源 bootstrap、server-info 改写和已发布资源字节),不是再做一套同步 CLI。 +- Go `cmd/bat` 仅试验,禁止与 Rust `bat` 二进制重名。 + +### 1.2 `bat` 与 `bat-api` 的关系 + +`bat` 是资源生产者和状态拥有者;`bat-api` 是资源读侧和 HTTP 入口。 + +| 关系面 | Rust `bat` / daemon | Go `bat-api` | +|---|---|---| +| 资源发现 | 读取官方 launcher/resource metadata,解析 `GameMainConfig`、server-info 和 Addressables root | 通过 `bat.sock` 读取已发布版本摘要,不重新探测官方 metadata | +| 下载与发布 | 下载、校验、staging、原子发布 `current -> versions/`,维护 manifest/snapshot/version-state | 不下载、不写 staging、不改 version-state;生产资源根来自 RPC 返回的 `resource_root` | +| 启动前资源入口 | 暴露 `catalog.status` / `resource.manifest` 等 RPC 数据 | 提供 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、`/v1/server-info` 和 CDN path,组织给客户端/补丁器使用 | +| 长期状态 | watch/daemon、任务队列、日志、错误码、repair/sync/verify | 周期性经 RPC 刷新内存索引,只展示 ready、RPC 健康和 release;需要拉取/修复时由外部运维调用 `bat` 或 RPC 任务 | + +这条边界允许 `bat-api` 做资源 bootstrap 兼容,但不允许它复制 Rust 下载器或伪装完整游戏业务服务。 + +### 1.3 决策(已核验) + +1. **G-008 决策关闭(wontfix)**:不另做产品级 Go 同步/运维 CLI。 +2. **G-009**:资源 bootstrap/分发 MVP 部分完成;非完整游戏业务 API。 +3. **USERGUIDE 的 bat-api 基础章节已补**;全量 release 联调后继续补充生产参数和排障样例。 + +--- + +## 2. 既有约定核对表(不可丢) + +### 职责 + +| ID | 约定 | +|---|---| +| A | **自动发现 / 下载 / 校验 / 发布 / watch·daemon** 只在 **Rust `bat`** | +| B | **`bat-api` 只读分发**已发布 release,不实现下载器,不写 staging/version-state | +| C | 仿真范围 = **资源拉取相关**(resource bootstrap + CDN path + 可选 server-info);**不是**完整游戏业务 API | +| D | launcher 资源 metadata 可作为 bootstrap 输入/输出兼容;账号、登录、网关和鉴权全链 **非 G-009 关闭条件** | +| E | USERGUIDE bat-api 基础章节已补;联调后补充实战样例 | + +### 发现与数据 + +| ID | 约定 | +|---|---| +| F | 版本/清单经 **`bat.sock` JSON-RPC**(`--socket`);不读 daemon 内部状态文件 | +| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再 catalog/manifest | +| H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 | +| I | 真数据在**已全量拉取且长期运行 Rust `bat` 的远程服务器**;开发机不跑全量 `bat`,用 fixture、mock RPC 和 Go 门禁验证;远程联调等连接信息 | +| J | 索引以 **manifest + 磁盘 Present/size** 为准 | + +### 进程配置 + +| ID | 约定 | +|---|---| +| K | `.env` / 环境变量 / CLI:端口、public base、RPC socket、RPC 刷新周期;**预留** database/redis | +| L | 管理面 / bootstrap:`/healthz`、`/readyz`、`/v1/bootstrap`、`/v1/release`、`/v1/resources`、`/openapi.yaml`、`/admin/` 预留 | +| M | CDN:`GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...`,支持 Range、ETag、Last-Modified、长期缓存头 | +| N | server-info 可选;**只改 AddressablesCatalogUrlRoot** | +| N2 | launcher 兼容仅限资源引导:`/v1/launcher/bootstrap` 与 `/api/launcher/...` 形状端点输出已发布 release、launcher metadata 和 GameMainConfig 摘要;不下载 launcher 包、不生成完整 PC package update manifest、不仿造登录/网关 | +| N3 | 玩家-facing HTTP 控制面:可配置 token 鉴权、进程内限流、访问日志、反代 IP 适配、动态 JSON `no-store`、`/v1/resources` 分页上限 | + +### 工程 + +| ID | 约定 | +|---|---| +| O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 | +| P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` | +| Q | 空目录标明 reserved empty | +| R | 默认门禁:`make test-go-api` + `make build-go-api`(无 FFI) | + +--- + +## 3. 组件进度 + +| 组件 | 路径 | 状态 | 说明 | +|---|---|---|---| +| Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 | +| RPC client | `internal/backendrpc` | **完成** | typed JSON-RPC;fake transport 单测 | +| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理面预留 + `.env` | +| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI | +| FFI | `internal/ffi` | **可选** | 需 `build-ffi` | +| 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README | +| Web | `web/` | **空** | G-010 | + +`go list ./...` 当前包: + +- `bat-api/cmd/bat-api` +- `bat-api/cmd/bat` +- `bat-api/internal/api` +- `bat-api/internal/backendrpc` +- `bat-api/internal/ffi` + +--- + +## 4. 验证门禁 + +```bash +# 默认(提交前 / CI 建议) +make test-go-api +make build-go-api +go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/... + +# 可选:改 FFI 或试验 CLI 时 +make build-ffi +make test-go-ffi +make build-go-cli # 产出 bin/bat-go +``` + +--- + +## 5. 与缺口 / issue 对应 + +| 项 | 状态 | +|---|---| +| G-008 Go 同步 CLI | **已决策关闭**(正式同步 CLI = Rust `bat`) | +| G-009 bat-api 资源 bootstrap/分发 | **部分完成**(MVP+生产控制面);已含资源 bootstrap 关系面、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理面预留和部署模板,后续远程服务器联调/可选持久化 | +| issue #19 | 资源面 MVP 与 USERGUIDE 基础章节已编码;真机联调后继续补充实战样例;**未自动关 issue** | +| G-010 Web | 未开始 | + +--- + +## 6. 资源布局与逆向 + +- **Release / URL / 分发契约(权威)**:`docs/architecture/resource-release-layout.md` +- 真机全量实勘、seed inventory diff、issue #2/#3 样本采集按该文档 §9–§10 执行 + +## 7. 后续(不在进度统一范围内) + +1. 服务器 SSH 只读实勘(连接信息到位后) +2. bat-api 与远程长期运行的 `bat` / 全量 release 联调(含 `/v1/bootstrap`、server-info 和 CDN path) +3. 预留 database/redis 的接入时机另议 +4. USERGUIDE bat-api 联调排障样例(全量 release 验证后) +5. launcher 完整安装包更新链 / 登录网关链(若需要,新 issue) diff --git a/internal/api/admin.go b/internal/api/admin.go new file mode 100644 index 0000000..0e1e5b2 --- /dev/null +++ b/internal/api/admin.go @@ -0,0 +1,29 @@ +package api + +import "net/http" + +func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + body := AdminIndexResponse{ + Service: "bat-api", + Panel: "admin", + Status: "reserved", + Links: []string{ + "/healthz", + "/readyz", + "/v1/bootstrap", + "/v1/release", + "/v1/resources", + "/openapi.yaml", + }, + } + if r.Method == http.MethodHead { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + writeNoStoreJSON(w, http.StatusOK, body) +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..775ecaa --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,962 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "bat-api/internal/backendrpc" +) + +func fixtureRoot(t *testing.T) string { + t.Helper() + root := filepath.Join("testdata", "release") + abs, err := filepath.Abs(root) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(abs, "official-download-manifest.json")); err != nil { + t.Fatalf("fixture missing: %v", err) + } + return abs +} + +func TestLoadIndexFromResourceRoot(t *testing.T) { + idx, err := LoadIndexFromResourceRoot(fixtureRoot(t)) + if err != nil { + t.Fatal(err) + } + if len(idx.Entries) != 2 { + t.Fatalf("entries = %d", len(idx.Entries)) + } + sum := idx.Summary() + if !sum.Ready || sum.PresentCount != 2 { + t.Fatalf("summary = %+v", sum) + } + if sum.Snapshot == nil { + t.Fatal("snapshot missing") + } + if sum.Snapshot.LauncherMetadata == nil { + t.Fatal("launcher metadata missing") + } + if sum.Snapshot.LauncherMetadata.GameLatestVersion != "1.70.0" { + t.Fatalf("launcher game version=%q", sum.Snapshot.LauncherMetadata.GameLatestVersion) + } + if got := sum.Snapshot.LauncherMetadata.GameStartParams; len(got) != 1 || got[0] != "BlueArchive.exe" { + t.Fatalf("launcher params=%v", got) + } + if sum.Snapshot.GameMainConfig == nil || sum.Snapshot.GameMainConfig.DefaultConnectionGroup != "Prod" { + t.Fatalf("game main config=%+v", sum.Snapshot.GameMainConfig) + } + rel := "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes" + entry, ok := idx.Lookup(rel) + if !ok || !entry.Present { + t.Fatalf("lookup failed: %+v", entry) + } +} + +func TestSplitCDNPathRejectsEscape(t *testing.T) { + if _, _, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/../etc/passwd"); err == nil { + t.Fatal("expected error") + } + if _, _, err := SplitCDNPath("/evil.example/a"); err == nil { + t.Fatal("expected unsupported host") + } + host, rel, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes") + if err != nil || host != ClientPatchHost { + t.Fatalf("host=%s rel=%s err=%v", host, rel, err) + } +} + +func TestCDNServesIndexedFile(t *testing.T) { + root := fixtureRoot(t) + idx, err := LoadIndexFromResourceRoot(root) + if err != nil { + t.Fatal(err) + } + cfg := DefaultConfig() + cfg.ResourceRoot = root + cfg.PublicBaseURL = "http://127.0.0.1:18080" + _ = cfg.Normalize() + s := NewServer(cfg, nil, nil) + s.mu.Lock() + s.idx = idx + s.meta = DiscoverResult{ResourceRoot: root, Index: idx} + s.mu.Unlock() + + req := httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes", nil) + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + if rr.Body.String() != "TABLE_CATALOG_FIXTURE" { + t.Fatalf("body=%q", rr.Body.String()) + } + if rr.Header().Get("Accept-Ranges") != "bytes" { + t.Fatalf("Accept-Ranges=%q", rr.Header().Get("Accept-Ranges")) + } + if rr.Header().Get("ETag") == "" { + t.Fatal("ETag missing") + } + if rr.Header().Get("Last-Modified") == "" { + t.Fatal("Last-Modified missing") + } + if rr.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" { + t.Fatalf("Cache-Control=%q", rr.Header().Get("Cache-Control")) + } + + // Unknown path + req = httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/missing.bin", nil) + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("missing status=%d", rr.Code) + } +} + +func TestCDNSupportsRangeHeadAndConditionalRequests(t *testing.T) { + root := fixtureRoot(t) + idx, err := LoadIndexFromResourceRoot(root) + if err != nil { + t.Fatal(err) + } + cfg := DefaultConfig() + cfg.ResourceRoot = root + _ = cfg.Normalize() + s := NewServer(cfg, nil, nil) + s.mu.Lock() + s.idx = idx + s.meta = DiscoverResult{ResourceRoot: root, Index: idx} + s.mu.Unlock() + + path := "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes" + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Range", "bytes=0-4") + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusPartialContent { + t.Fatalf("range status=%d body=%s", rr.Code, rr.Body.String()) + } + if rr.Body.String() != "TABLE" { + t.Fatalf("range body=%q", rr.Body.String()) + } + if rr.Header().Get("Content-Range") != "bytes 0-4/21" { + t.Fatalf("Content-Range=%q", rr.Header().Get("Content-Range")) + } + etag := rr.Header().Get("ETag") + if etag != `"blake3-0000000000000000000000000000000000000000000000000000000000000000"` { + t.Fatalf("ETag=%q", etag) + } + + req = httptest.NewRequest(http.MethodHead, path, nil) + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("head status=%d body=%s", rr.Code, rr.Body.String()) + } + if rr.Body.Len() != 0 { + t.Fatalf("head body=%q", rr.Body.String()) + } + if rr.Header().Get("Content-Length") != "21" { + t.Fatalf("head Content-Length=%q", rr.Header().Get("Content-Length")) + } + + req = httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("If-None-Match", etag) + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusNotModified { + t.Fatalf("conditional status=%d body=%s", rr.Code, rr.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Range", "bytes=99-120") + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusRequestedRangeNotSatisfiable { + t.Fatalf("invalid range status=%d body=%s", rr.Code, rr.Body.String()) + } +} + +func TestCDNHashContentType(t *testing.T) { + root := fixtureRoot(t) + idx, err := LoadIndexFromResourceRoot(root) + if err != nil { + t.Fatal(err) + } + cfg := DefaultConfig() + cfg.ResourceRoot = root + _ = cfg.Normalize() + s := NewServer(cfg, nil, nil) + s.mu.Lock() + s.idx = idx + s.meta = DiscoverResult{ResourceRoot: root, Index: idx} + s.mu.Unlock() + + req := httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash", nil) + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + if rr.Header().Get("Content-Type") != "text/plain; charset=utf-8" { + t.Fatalf("Content-Type=%q", rr.Header().Get("Content-Type")) + } +} + +func TestHealthzAndRelease(t *testing.T) { + root := fixtureRoot(t) + idx, err := LoadIndexFromResourceRoot(root) + if err != nil { + t.Fatal(err) + } + cfg := DefaultConfig() + cfg.ResourceRoot = root + _ = cfg.Normalize() + s := NewServer(cfg, nil, nil) + s.mu.Lock() + s.idx = idx + s.meta = DiscoverResult{Index: idx, ResourceRoot: root} + s.mu.Unlock() + + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rr.Code != 200 { + t.Fatal(rr.Body.String()) + } + var health map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &health); err != nil { + t.Fatal(err) + } + if health["ready"] != true { + t.Fatalf("health=%v", health) + } + + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("readyz status=%d body=%s", rr.Code, rr.Body.String()) + } + + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/resources?limit=1", nil)) + if rr.Code != 200 { + t.Fatal(rr.Body.String()) + } +} + +func TestBootstrapDescribesBatRelationship(t *testing.T) { + root := fixtureRoot(t) + idx, err := LoadIndexFromResourceRoot(root) + if err != nil { + t.Fatal(err) + } + cfg := DefaultConfig() + cfg.ResourceRoot = root + cfg.PublicBaseURL = "http://127.0.0.1:18080" + cfg.SocketPath = "/tmp/bat-pid/bat.sock" + _ = cfg.Normalize() + healthy := true + s := NewServer(cfg, nil, nil) + s.mu.Lock() + s.idx = idx + s.meta = DiscoverResult{ + Index: idx, + ResourceRoot: root, + RPCAvailable: true, + DoctorHealthy: &healthy, + } + s.mu.Unlock() + + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["ready"] != true { + t.Fatalf("ready=%v", body["ready"]) + } + bat := body["bat"].(map[string]any) + if bat["role"] != "sync_daemon_and_release_producer" { + t.Fatalf("bat role=%v", bat["role"]) + } + resource := body["resource"].(map[string]any) + wantRoot := "http://127.0.0.1:18080/prod-clientpatch.bluearchiveyostar.com/r93_fixture" + if resource["addressables_catalog_url_root"] != wantRoot { + t.Fatalf("addressables root=%v", resource["addressables_catalog_url_root"]) + } + if resource["server_info_url"] != "http://127.0.0.1:18080/yostar-serverinfo.bluearchiveyostar.com/server-info.json" { + t.Fatalf("server info url=%v", resource["server_info_url"]) + } + policy := body["policy"].(map[string]any) + if policy["pull_owner"] != "rust_bat" || policy["writes_release_state"] != false { + t.Fatalf("policy=%v", policy) + } +} + +func TestLauncherBootstrapDescribesResourceOnlyScope(t *testing.T) { + root := fixtureRoot(t) + idx, err := LoadIndexFromResourceRoot(root) + if err != nil { + t.Fatal(err) + } + cfg := DefaultConfig() + cfg.ResourceRoot = root + cfg.PublicBaseURL = "http://127.0.0.1:18080" + _ = cfg.Normalize() + s := NewServer(cfg, nil, nil) + s.mu.Lock() + s.idx = idx + s.meta = DiscoverResult{Index: idx, ResourceRoot: root} + s.mu.Unlock() + + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/launcher/bootstrap", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["ready"] != true { + t.Fatalf("ready=%v", body["ready"]) + } + policy := body["policy"].(map[string]any) + if policy["source"] != "rust_bat_snapshot" || policy["emulates_login_or_gateway"] != false { + t.Fatalf("policy=%v", policy) + } + if policy["downloads_launcher_package"] != false || policy["package_update_manifest"] != false { + t.Fatalf("policy=%v", policy) + } + metadata := body["launcher_metadata"].(map[string]any) + if metadata["game_latest_file_path"] != "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip" { + t.Fatalf("launcher metadata=%v", metadata) + } + gameMainConfig := body["game_main_config"].(map[string]any) + if gameMainConfig["server_info_data_url"] != "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" { + t.Fatalf("game main config=%v", gameMainConfig) + } + if body["addressables_catalog_url_root"] != "http://127.0.0.1:18080/prod-clientpatch.bluearchiveyostar.com/r93_fixture" { + t.Fatalf("addressables root=%v", body["addressables_catalog_url_root"]) + } + + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, launcherHostPath("/api/launcher/resource/bootstrap.json"), nil)) + if rr.Code != http.StatusOK { + t.Fatalf("host-shaped bootstrap status=%d body=%s", rr.Code, rr.Body.String()) + } +} + +func TestLauncherCompatibilityEndpoints(t *testing.T) { + root := fixtureRoot(t) + idx, err := LoadIndexFromResourceRoot(root) + if err != nil { + t.Fatal(err) + } + cfg := DefaultConfig() + cfg.ResourceRoot = root + cfg.PublicBaseURL = "http://127.0.0.1:18080" + _ = cfg.Normalize() + s := NewServer(cfg, nil, nil) + s.mu.Lock() + s.idx = idx + s.meta = DiscoverResult{Index: idx, ResourceRoot: root} + s.mu.Unlock() + + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, launcherHostPath("/api/launcher/game/config"), nil)) + if rr.Code != http.StatusOK { + t.Fatalf("game config status=%d body=%s", rr.Code, rr.Body.String()) + } + var envelope map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if envelope["code"] != float64(200) { + t.Fatalf("envelope=%v", envelope) + } + data := envelope["data"].(map[string]any) + if data["game_latest_version"] != "1.70.0" { + t.Fatalf("game config=%v", data) + } + if data["game_latest_file_path"] != "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip" { + t.Fatalf("game config=%v", data) + } + if data["resource_bootstrap_url"] != "http://127.0.0.1:18080/v1/launcher/bootstrap" { + t.Fatalf("game config=%v", data) + } + if data["server_info_url"] != "http://127.0.0.1:18080/yostar-serverinfo.bluearchiveyostar.com/server-info.json" { + t.Fatalf("game config=%v", data) + } + if params := data["game_start_params"].([]any); len(params) != 1 || params[0] != "BlueArchive.exe" { + t.Fatalf("params=%v", data["game_start_params"]) + } + + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/launcher/game/config", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("bare game config status=%d body=%s", rr.Code, rr.Body.String()) + } + + rr = httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodGet, + launcherHostPath("/api/launcher/game/config/json")+"?version=1.70.0&file_path=prod%2FZIP_TEMP%2FBlueArchive_JP_TEMP%2FBlueArchive_JP-1.70.436321-game.zip", + nil, + ) + s.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("game config json status=%d body=%s", rr.Code, rr.Body.String()) + } + envelope = map[string]any{} + if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + data = envelope["data"].(map[string]any) + if data["package_update_manifest"] != false || data["scope"] != "resource_bootstrap_only" { + t.Fatalf("game config json=%v", data) + } + url, _ := data["url"].(string) + if !strings.HasPrefix(url, "http://127.0.0.1:18080/api-launcher-jp.yo-star.com/api/launcher/resource/bootstrap.json?") { + t.Fatalf("bootstrap url=%q", url) + } + if !strings.Contains(url, "version=1.70.0") || !strings.Contains(url, "file_path=prod%2FZIP_TEMP%2FBlueArchive_JP_TEMP%2FBlueArchive_JP-1.70.436321-game.zip") { + t.Fatalf("bootstrap url=%q", url) + } + + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, launcherHostPath("/api/launcher/advanced/game/download/cdn"), nil)) + if rr.Code != http.StatusOK { + t.Fatalf("cdn config status=%d body=%s", rr.Code, rr.Body.String()) + } + envelope = map[string]any{} + if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + data = envelope["data"].(map[string]any) + if data["primary_cdn"] != "http://127.0.0.1:18080" || data["back_up_cdn"] != "http://127.0.0.1:18080" { + t.Fatalf("cdn config=%v", data) + } + if data["package_update_manifest"] != false { + t.Fatalf("cdn config=%v", data) + } +} + +func TestBootstrapNotReadyDoesNotClaimReleaseOwnership(t *testing.T) { + cfg := DefaultConfig() + cfg.PublicBaseURL = "http://127.0.0.1:18080" + cfg.SocketPath = "/tmp/bat-pid/bat.sock" + _ = cfg.Normalize() + s := NewServer(cfg, nil, nil) + + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil)) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["ready"] != false { + t.Fatalf("ready=%v", body["ready"]) + } + policy := body["policy"].(map[string]any) + if policy["pull_owner"] != "rust_bat" { + t.Fatalf("pull owner=%v", policy["pull_owner"]) + } + if policy["serves_only_published_release"] != true || policy["writes_release_state"] != false { + t.Fatalf("policy=%v", policy) + } + resource := body["resource"].(map[string]any) + if resource["entry_count"] != float64(0) || resource["present_count"] != float64(0) { + t.Fatalf("resource=%v", resource) + } +} + +func TestServerInfoRewritesAddressablesOnly(t *testing.T) { + raw := []byte(`{ + "ConnectionGroups":[{ + "Name":"Prod", + "AddressablesCatalogUrlRoot":"https://prod-clientpatch.bluearchiveyostar.com/r93_fixture", + "ApiUrl":"https://prod-game.example.invalid/" + }] + }`) + out, err := RewriteServerInfoAddressables(raw, "http://127.0.0.1:18080") + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(out, &doc); err != nil { + t.Fatal(err) + } + group := doc["ConnectionGroups"].([]any)[0].(map[string]any) + got := group["AddressablesCatalogUrlRoot"].(string) + want := "http://127.0.0.1:18080/prod-clientpatch.bluearchiveyostar.com/r93_fixture" + if got != want { + t.Fatalf("root=%s want=%s", got, want) + } + if group["ApiUrl"] != "https://prod-game.example.invalid/" { + t.Fatalf("ApiUrl should be unchanged: %v", group["ApiUrl"]) + } +} + +type fakeBackend struct { + statusCalls int + doctorCalls int + status *backendrpc.DaemonStatusReport + doctor *backendrpc.DoctorReport + catalog json.RawMessage + manifest *backendrpc.ResourceManifestPage +} + +func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) { + f.statusCalls++ + return f.status, nil +} +func (f *fakeBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) { + f.doctorCalls++ + if f.statusCalls == 0 { + // status must be called first in real DiscoverAndIndex; this is asserted by call order. + } + return f.doctor, nil +} +func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) { + return &backendrpc.ResourceState{}, nil +} +func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) { + return f.catalog, nil +} +func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) { + return f.manifest, nil +} + +func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) { + root := fixtureRoot(t) + bytes := uint64(20) + info, err := os.Stat(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes")) + if err != nil { + t.Fatal(err) + } + b := uint64(info.Size()) + _ = bytes + catalogObj := map[string]any{ + "available": true, + "app_version": "1.70.0", + "bundle_version": "s8tloc7lo3", + "connection_group_name": "Prod", + "addressables_root": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture", + "version": map[string]any{ + "id": "v1", + "resource_root": root, + }, + } + catalogRaw, err := json.Marshal(catalogObj) + if err != nil { + t.Fatal(err) + } + fb := &fakeBackend{ + status: &backendrpc.DaemonStatusReport{Status: "ok", Running: true, RPCAvailable: true}, + doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"}, + catalog: catalogRaw, + manifest: &backendrpc.ResourceManifestPage{ + Available: true, + ResourceRoot: root, + ManifestVersion: 1, + TotalEntries: 1, + Entries: []backendrpc.ResourceManifestEntry{{ + URL: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes", + Destination: "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes", + Bytes: &b, + BLAKE3: strings.Repeat("0", 64), + }}, + }, + } + + result, err := DiscoverAndIndex(context.Background(), fb, "") + if err != nil { + t.Fatal(err) + } + if fb.statusCalls != 1 || fb.doctorCalls != 1 { + t.Fatalf("status=%d doctor=%d", fb.statusCalls, fb.doctorCalls) + } + if !result.RPCAvailable || result.Index == nil || !result.Index.Summary().Ready { + t.Fatalf("result=%+v summary=%+v", result, result.Index.Summary()) + } + if result.DoctorHealthy == nil || !*result.DoctorHealthy { + t.Fatal("doctor healthy expected") + } +} + +func TestLoadEnvFileDoesNotOverride(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + if err := os.WriteFile(path, []byte("BAT_API_LISTEN=:9999\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("BAT_API_LISTEN", ":1111") + if err := LoadEnvFile(path); err != nil { + t.Fatal(err) + } + if os.Getenv("BAT_API_LISTEN") != ":1111" { + t.Fatal(os.Getenv("BAT_API_LISTEN")) + } +} + +func TestRefreshDiagnosticsAndReadyz(t *testing.T) { + root := fixtureRoot(t) + cfg := DefaultConfig() + cfg.ResourceRoot = root + cfg.RefreshInterval = 0 + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + s := NewServer(cfg, nil, log.New(io.Discard, "", 0)) + if err := s.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("healthz status=%d body=%s", rr.Code, rr.Body.String()) + } + var health map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &health); err != nil { + t.Fatal(err) + } + refresh := health["refresh"].(map[string]any) + if refresh["last_success_unix_seconds"] == nil || refresh["last_error"] != "" { + t.Fatalf("refresh=%v", refresh) + } + + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("readyz status=%d body=%s", rr.Code, rr.Body.String()) + } +} + +func TestRefreshFailureDiagnosticsAndReadyz(t *testing.T) { + cfg := DefaultConfig() + cfg.ResourceRoot = filepath.Join(t.TempDir(), "missing-release") + cfg.RefreshInterval = 0 + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + s := NewServer(cfg, nil, log.New(io.Discard, "", 0)) + if err := s.Refresh(context.Background()); err == nil { + t.Fatal("expected refresh failure") + } + + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("healthz status=%d body=%s", rr.Code, rr.Body.String()) + } + var health map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &health); err != nil { + t.Fatal(err) + } + refresh := health["refresh"].(map[string]any) + if refresh["last_error"] == "" || refresh["last_finished_unix_seconds"] == nil { + t.Fatalf("refresh=%v", refresh) + } + + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("readyz status=%d body=%s", rr.Code, rr.Body.String()) + } +} + +func TestApplyEnvParsesRefreshInterval(t *testing.T) { + cfg := DefaultConfig() + t.Setenv("BAT_API_REFRESH_INTERVAL", "2m") + ApplyEnv(&cfg) + if cfg.RefreshInterval != 2*time.Minute { + t.Fatalf("refresh interval=%s", cfg.RefreshInterval) + } + cfg.RefreshInterval = -time.Second + if err := cfg.Normalize(); err == nil { + t.Fatal("expected negative refresh interval error") + } +} + +type pollingBackend struct { + statusCalls atomic.Int64 +} + +func (p *pollingBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) { + p.statusCalls.Add(1) + return nil, errors.New("offline") +} +func (p *pollingBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) { + return nil, errors.New("unexpected doctor call") +} +func (p *pollingBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) { + return nil, errors.New("unexpected resource state call") +} +func (p *pollingBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) { + return nil, errors.New("unexpected catalog call") +} +func (p *pollingBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) { + return nil, errors.New("unexpected manifest call") +} + +func TestStartRefreshLoopPollsBackend(t *testing.T) { + cfg := DefaultConfig() + cfg.RefreshInterval = 10 * time.Millisecond + cfg.RPCTimeout = 20 * time.Millisecond + backend := &pollingBackend{} + s := NewServer(cfg, backend, log.New(io.Discard, "", 0)) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s.StartRefreshLoop(ctx) + deadline := time.Now().Add(200 * time.Millisecond) + for time.Now().Before(deadline) { + if backend.statusCalls.Load() > 0 { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("refresh loop did not poll backend") +} + +func TestRefreshWarningsRecordedWhenRPCUnavailable(t *testing.T) { + cfg := DefaultConfig() + cfg.RefreshInterval = 0 + backend := &pollingBackend{} + s := NewServer(cfg, backend, log.New(io.Discard, "", 0)) + if err := s.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + var health map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &health); err != nil { + t.Fatal(err) + } + refresh := health["refresh"].(map[string]any) + if refresh["last_error"] != "" || refresh["last_warning_count"] == float64(0) { + t.Fatalf("refresh=%v", refresh) + } +} + +func TestHTTPAuthRequiresTokenAndAllowsExemptPaths(t *testing.T) { + root := fixtureRoot(t) + idx, err := LoadIndexFromResourceRoot(root) + if err != nil { + t.Fatal(err) + } + cfg := DefaultConfig() + cfg.ResourceRoot = root + cfg.AuthToken = "secret-token" + cfg.AuthExemptPaths = []string{"/healthz"} + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + s := NewServer(cfg, nil, nil) + s.mu.Lock() + s.idx = idx + s.meta = DiscoverResult{Index: idx, ResourceRoot: root} + s.mu.Unlock() + + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("missing token status=%d body=%s", rr.Code, rr.Body.String()) + } + if rr.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("unauthorized Cache-Control=%q", rr.Header().Get("Cache-Control")) + } + var errBody ErrorResponse + if err := json.Unmarshal(rr.Body.Bytes(), &errBody); err != nil { + t.Fatal(err) + } + if errBody.Error.Code != "unauthorized" { + t.Fatalf("error=%+v", errBody) + } + + req := httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil) + req.Header.Set("Authorization", "Bearer secret-token") + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("authorized status=%d body=%s", rr.Code, rr.Body.String()) + } + + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("exempt healthz status=%d body=%s", rr.Code, rr.Body.String()) + } +} + +func TestRateLimitUsesForwardedClientWhenTrusted(t *testing.T) { + cfg := DefaultConfig() + cfg.RateLimitRPS = 1 + cfg.RateLimitBurst = 1 + cfg.TrustProxyHeaders = true + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + s := NewServer(cfg, nil, log.New(io.Discard, "", 0)) + handler := s.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + req.RemoteAddr = "10.0.0.10:1234" + req.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.10") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("first status=%d body=%s", rr.Code, rr.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/healthz", nil) + req.RemoteAddr = "10.0.0.10:1234" + req.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.10") + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusTooManyRequests { + t.Fatalf("second status=%d body=%s", rr.Code, rr.Body.String()) + } + var errBody ErrorResponse + if err := json.Unmarshal(rr.Body.Bytes(), &errBody); err != nil { + t.Fatal(err) + } + if errBody.Error.Code != "rate_limited" { + t.Fatalf("error=%+v", errBody) + } +} + +func TestResourcesLimitIsCappedAndDynamicResponsesNoStore(t *testing.T) { + root := fixtureRoot(t) + idx, err := LoadIndexFromResourceRoot(root) + if err != nil { + t.Fatal(err) + } + cfg := DefaultConfig() + cfg.ResourceRoot = root + cfg.MaxResourcePageLimit = 1 + cfg.PublicBaseURL = "http://127.0.0.1:18080" + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + s := NewServer(cfg, nil, nil) + s.mu.Lock() + s.idx = idx + s.meta = DiscoverResult{Index: idx, ResourceRoot: root} + s.mu.Unlock() + + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/resources?limit=999", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("resources status=%d body=%s", rr.Code, rr.Body.String()) + } + if rr.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("resources Cache-Control=%q", rr.Header().Get("Cache-Control")) + } + var page ResourceListResponse + if err := json.Unmarshal(rr.Body.Bytes(), &page); err != nil { + t.Fatal(err) + } + if page.Limit != 1 || len(page.Items) != 1 { + t.Fatalf("page=%+v", page) + } + + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil)) + if rr.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("bootstrap Cache-Control=%q", rr.Header().Get("Cache-Control")) + } + + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/launcher/bootstrap", nil)) + if rr.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("launcher bootstrap Cache-Control=%q", rr.Header().Get("Cache-Control")) + } +} + +func TestAccessLogOmitsQueryString(t *testing.T) { + var logs bytes.Buffer + cfg := DefaultConfig() + cfg.AccessLog = true + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + s := NewServer(cfg, nil, log.New(&logs, "", 0)) + + req := httptest.NewRequest(http.MethodGet, "/healthz?bat_token=secret", nil) + req.Header.Set("User-Agent", "bat-test") + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + got := logs.String() + if !strings.Contains(got, "path=/healthz") { + t.Fatalf("log=%q", got) + } + if strings.Contains(got, "secret") || strings.Contains(got, "bat_token") { + t.Fatalf("log leaked query token: %q", got) + } +} + +func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) { + cfg := DefaultConfig() + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + s := NewServer(cfg, nil, nil) + + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/openapi.yaml", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("openapi status=%d body=%s", rr.Code, rr.Body.String()) + } + if rr.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("openapi Cache-Control=%q", rr.Header().Get("Cache-Control")) + } + if rr.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Fatalf("X-Content-Type-Options=%q", rr.Header().Get("X-Content-Type-Options")) + } + if !strings.Contains(rr.Body.String(), "/v1/launcher/bootstrap") { + t.Fatalf("openapi missing launcher bootstrap") + } + + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin/", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("admin status=%d body=%s", rr.Code, rr.Body.String()) + } + var admin AdminIndexResponse + if err := json.Unmarshal(rr.Body.Bytes(), &admin); err != nil { + t.Fatal(err) + } + if admin.Status != "reserved" { + t.Fatalf("admin=%+v", admin) + } +} diff --git a/internal/api/cdn.go b/internal/api/cdn.go new file mode 100644 index 0000000..9fba2fc --- /dev/null +++ b/internal/api/cdn.go @@ -0,0 +1,87 @@ +package api + +import ( + "fmt" + "mime" + "net/http" + "os" + "path/filepath" + "strings" +) + +func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + idx := s.index() + if idx == nil || idx.ResourceRoot == "" { + http.Error(w, "resource root not ready", http.StatusServiceUnavailable) + return + } + _, rel, err := SplitCDNPath(r.URL.Path) + if err != nil { + http.NotFound(w, r) + return + } + + var entry ResourceEntry + var hasEntry bool + if s.cfg.RequireIndexed { + entry, hasEntry = idx.Lookup(rel) + if !hasEntry || !entry.Present || !entry.SizeMatch { + http.NotFound(w, r) + return + } + } + + abs, err := ResolveUnderRoot(idx.ResourceRoot, rel, true) + if err != nil { + http.NotFound(w, r) + return + } + info, err := os.Stat(abs) + if err != nil || !info.Mode().IsRegular() { + http.NotFound(w, r) + return + } + if s.cfg.RequireIndexed && s.cfg.VerifySize { + if hasEntry && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes { + http.Error(w, "size mismatch with release index", http.StatusConflict) + return + } + } + + file, err := os.Open(abs) + if err != nil { + http.NotFound(w, r) + return + } + defer file.Close() + + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("ETag", cdnETag(entry, hasEntry, info)) + w.Header().Set("Content-Type", cdnContentType(abs)) + http.ServeContent(w, r, info.Name(), info.ModTime(), file) +} + +func cdnETag(entry ResourceEntry, hasEntry bool, info os.FileInfo) string { + if hasEntry && entry.BLAKE3 != "" { + return fmt.Sprintf(`"blake3-%s"`, entry.BLAKE3) + } + return fmt.Sprintf(`W/"%d-%d"`, info.Size(), info.ModTime().UnixNano()) +} + +func cdnContentType(abs string) string { + ext := strings.ToLower(filepath.Ext(abs)) + if ext == ".json" { + return "application/json; charset=utf-8" + } + if ext == ".hash" { + return "text/plain; charset=utf-8" + } + if detected := mime.TypeByExtension(ext); detected != "" { + return detected + } + return "application/octet-stream" +} diff --git a/internal/api/config.go b/internal/api/config.go new file mode 100644 index 0000000..b44ebac --- /dev/null +++ b/internal/api/config.go @@ -0,0 +1,372 @@ +// Package api implements the resource bootstrap and distribution HTTP surface of bat-api. +// +// bat-api serves already-published official resources (CDN-shaped paths) and +// discovers version/manifest state and the current resource_root through the +// Rust bat daemon RPC socket. +// Automatic resource pull/sync remains the responsibility of the Rust bat +// binary; this package does not download or mutate release contents. +package api + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Default listen address and related defaults for the resource API. +const ( + DefaultListen = ":18080" + DefaultPublicBaseURL = "http://127.0.0.1:18080" + DefaultStateDir = "/tmp/bat-pid" + DefaultAuthSkew = 5 * time.Minute + ClientPatchHost = "prod-clientpatch.bluearchiveyostar.com" + ServerInfoHost = "yostar-serverinfo.bluearchiveyostar.com" + EnvFileName = ".env" +) + +// Config holds bat-api process configuration. +// +// Values come from (highest wins): CLI flags > process environment > .env file +// > built-in defaults. Database-related keys are reserved for future API +// persistence and are loaded but not required for the resource CDN surface. +type Config struct { + // Listen is the HTTP listen address, e.g. ":18080" or "127.0.0.1:18080". + Listen string + // PublicBaseURL is used when rewriting AddressablesCatalogUrlRoot. + PublicBaseURL string + // StateDir is used only to derive the default RPC socket path. + StateDir string + // SocketPath is the bat.sock Unix socket used for JSON-RPC. + SocketPath string + // ResourceRoot overrides the release root discovered via RPC (tests / emergency). + ResourceRoot string + // ServerInfoFile is an optional path to a server-info JSON document. + ServerInfoFile string + // RequireIndexed rejects CDN paths that are not in the release index. + RequireIndexed bool + // VerifySize rejects CDN files whose size differs from the index. + VerifySize bool + // RPCTimeout is the per-call timeout for daemon RPC. + RPCTimeout time.Duration + // RefreshInterval periodically re-discovers the release through RPC. Zero disables it. + RefreshInterval time.Duration + // SkipEnvFile disables loading .env when true (BAT_API_SKIP_ENV_FILE=1). + SkipEnvFile bool + // AuthToken enables HTTP token authentication when non-empty. + AuthToken string + // AuthQueryParam is the optional query parameter accepted for token auth. + AuthQueryParam string + // AuthExemptPaths lists exact paths or slash-prefixes that bypass token auth. + AuthExemptPaths []string + // TrustProxyHeaders allows X-Forwarded-For / X-Real-IP for client identity. + TrustProxyHeaders bool + // AccessLog enables structured per-request access logs. + AccessLog bool + // RateLimitRPS limits requests per client identity. Zero disables in-process limiting. + RateLimitRPS float64 + // RateLimitBurst is the token bucket burst size when rate limiting is enabled. + RateLimitBurst int + // MaxResourcePageLimit caps /v1/resources limit. + MaxResourcePageLimit int + + // Reserved for a future persistent API layer (not used by CDN MVP). + DatabaseURL string + DatabasePassword string + RedisURL string + RedisPassword string +} + +// DefaultConfig returns built-in defaults. +func DefaultConfig() Config { + return Config{ + Listen: DefaultListen, + PublicBaseURL: DefaultPublicBaseURL, + StateDir: DefaultStateDir, + RequireIndexed: true, + VerifySize: true, + RPCTimeout: 30 * time.Second, + RefreshInterval: time.Minute, + AuthQueryParam: "bat_token", + MaxResourcePageLimit: 1000, + } +} + +// Normalize fills derived fields and validates required values. +func (c *Config) Normalize() error { + if c.Listen == "" { + c.Listen = DefaultListen + } + if c.PublicBaseURL == "" { + c.PublicBaseURL = DefaultPublicBaseURL + } + c.PublicBaseURL = strings.TrimRight(c.PublicBaseURL, "/") + if _, err := url.ParseRequestURI(c.PublicBaseURL); err != nil { + return fmt.Errorf("invalid public base URL %q: %w", c.PublicBaseURL, err) + } + if c.StateDir == "" { + c.StateDir = DefaultStateDir + } + if c.SocketPath == "" { + c.SocketPath = filepath.Join(c.StateDir, "bat.sock") + } + if c.RPCTimeout <= 0 { + c.RPCTimeout = 30 * time.Second + } + if c.RefreshInterval < 0 { + return fmt.Errorf("refresh interval must be >= 0") + } + if c.AuthQueryParam == "" { + c.AuthQueryParam = "bat_token" + } + c.AuthExemptPaths = normalizePathList(c.AuthExemptPaths) + if c.RateLimitRPS < 0 { + return fmt.Errorf("rate limit rps must be >= 0") + } + if c.RateLimitRPS > 0 && c.RateLimitBurst <= 0 { + c.RateLimitBurst = int(c.RateLimitRPS) + if c.RateLimitBurst <= 0 { + c.RateLimitBurst = 1 + } + } + if c.RateLimitBurst < 0 { + return fmt.Errorf("rate limit burst must be >= 0") + } + if c.MaxResourcePageLimit <= 0 { + c.MaxResourcePageLimit = 1000 + } + if c.ResourceRoot != "" { + abs, err := filepath.Abs(c.ResourceRoot) + if err != nil { + return fmt.Errorf("resolve resource root: %w", err) + } + c.ResourceRoot = abs + } + return nil +} + +// LoadEnvFile reads KEY=VALUE lines from path into the process environment +// without overriding variables that are already set. +func LoadEnvFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + value = strings.Trim(value, `"'`) + if key == "" { + continue + } + if _, exists := os.LookupEnv(key); exists { + continue + } + if err := os.Setenv(key, value); err != nil { + return err + } + } + return nil +} + +// ApplyEnv overlays environment variables onto cfg. +func ApplyEnv(cfg *Config) { + if v := os.Getenv("BAT_API_LISTEN"); v != "" { + cfg.Listen = v + } + // Alias common port-only style. + if v := os.Getenv("BAT_API_PORT"); v != "" && os.Getenv("BAT_API_LISTEN") == "" { + if strings.HasPrefix(v, ":") { + cfg.Listen = v + } else { + cfg.Listen = ":" + v + } + } + if v := os.Getenv("BAT_API_PUBLIC_BASE_URL"); v != "" { + cfg.PublicBaseURL = v + } + if v := os.Getenv("BAT_API_STATE_DIR"); v != "" { + cfg.StateDir = v + } + if v := os.Getenv("BAT_API_SOCKET"); v != "" { + cfg.SocketPath = v + } + if v := os.Getenv("BAT_API_RESOURCE_ROOT"); v != "" { + cfg.ResourceRoot = v + } + if v := os.Getenv("BAT_API_SERVER_INFO_FILE"); v != "" { + cfg.ServerInfoFile = v + } + if v := os.Getenv("BAT_API_REQUIRE_INDEXED"); v != "" { + cfg.RequireIndexed = parseBool(v, cfg.RequireIndexed) + } + if v := os.Getenv("BAT_API_VERIFY_SIZE"); v != "" { + cfg.VerifySize = parseBool(v, cfg.VerifySize) + } + if v := os.Getenv("BAT_API_RPC_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.RPCTimeout = d + } + } + if v := os.Getenv("BAT_API_REFRESH_INTERVAL"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.RefreshInterval = d + } + } + if v := os.Getenv("BAT_API_DATABASE_URL"); v != "" { + cfg.DatabaseURL = v + } + if v := os.Getenv("BAT_API_DATABASE_PASSWORD"); v != "" { + cfg.DatabasePassword = v + } + if v := os.Getenv("BAT_API_REDIS_URL"); v != "" { + cfg.RedisURL = v + } + if v := os.Getenv("BAT_API_REDIS_PASSWORD"); v != "" { + cfg.RedisPassword = v + } + if v := os.Getenv("BAT_API_SKIP_ENV_FILE"); v != "" { + cfg.SkipEnvFile = parseBool(v, false) + } + if v := os.Getenv("BAT_API_AUTH_TOKEN"); v != "" { + cfg.AuthToken = v + } + if v := os.Getenv("BAT_API_AUTH_QUERY_PARAM"); v != "" { + cfg.AuthQueryParam = v + } + if v := os.Getenv("BAT_API_AUTH_EXEMPT_PATHS"); v != "" { + cfg.AuthExemptPaths = splitCSV(v) + } + if v := os.Getenv("BAT_API_TRUST_PROXY_HEADERS"); v != "" { + cfg.TrustProxyHeaders = parseBool(v, cfg.TrustProxyHeaders) + } + if v := os.Getenv("BAT_API_ACCESS_LOG"); v != "" { + cfg.AccessLog = parseBool(v, cfg.AccessLog) + } + if v := os.Getenv("BAT_API_RATE_LIMIT_RPS"); v != "" { + if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil { + cfg.RateLimitRPS = f + } + } + if v := os.Getenv("BAT_API_RATE_LIMIT_BURST"); v != "" { + if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + cfg.RateLimitBurst = i + } + } + if v := os.Getenv("BAT_API_MAX_RESOURCE_LIMIT"); v != "" { + if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + cfg.MaxResourcePageLimit = i + } + } +} + +func parseBool(raw string, fallback bool) bool { + b, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return fallback + } + return b +} + +func splitCSV(raw string) []string { + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func normalizePathList(paths []string) []string { + out := make([]string, 0, len(paths)) + seen := map[string]struct{}{} + for _, path := range paths { + path = strings.TrimSpace(path) + if path == "" { + continue + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + out = append(out, path) + } + return out +} + +// EnvTemplate is written next to the bat-api binary on first run when missing. +const EnvTemplate = `# bat-api configuration +# Priority: CLI flags > process environment > this file > built-in defaults. +# Resource auto-pull/sync is owned by the Rust bat daemon, not bat-api. +# bat-api only bootstraps/distributes already-published resources and exposes +# a small management surface for release inspection. + +# HTTP listen address (host:port or :port) +BAT_API_LISTEN=:18080 +# Public base URL used when rewriting AddressablesCatalogUrlRoot +BAT_API_PUBLIC_BASE_URL=http://127.0.0.1:18080 + +# Rust bat daemon RPC socket (primary discovery path) +# Prefer BAT_API_SOCKET; BAT_API_STATE_DIR only derives the default socket path. +BAT_API_STATE_DIR=/tmp/bat-pid +# BAT_API_SOCKET=/tmp/bat-pid/bat.sock + +# Optional override of the published release root (fixtures / emergency only). +# Production obtains resource_root from daemon RPC (catalog.status / resource.manifest). +# BAT_API_RESOURCE_ROOT= + +# Optional server-info JSON for Addressables root rewrite +# BAT_API_SERVER_INFO_FILE= + + # CDN safety + BAT_API_REQUIRE_INDEXED=true + BAT_API_VERIFY_SIZE=true + BAT_API_RPC_TIMEOUT=30s +# Periodically re-read bat.sock so bat-api follows Rust bat release switches. + # Set to 0 to disable in fixture-only local development. + BAT_API_REFRESH_INTERVAL=1m + + # Player-facing HTTP controls. + # Prefer setting BAT_API_AUTH_TOKEN through a secret manager or process + # environment. Reverse proxies may inject Authorization: Bearer or + # X-BAT-Token to authenticated upstream requests. Query token fallback uses + # BAT_API_AUTH_QUERY_PARAM and is supported for clients that cannot set headers. + # BAT_API_AUTH_TOKEN= + BAT_API_AUTH_QUERY_PARAM=bat_token + # BAT_API_AUTH_EXEMPT_PATHS= + BAT_API_TRUST_PROXY_HEADERS=false + BAT_API_ACCESS_LOG=false + # Zero disables in-process limiting; production may still enforce edge limits. + BAT_API_RATE_LIMIT_RPS=0 + BAT_API_RATE_LIMIT_BURST=0 + BAT_API_MAX_RESOURCE_LIMIT=1000 + + # Reserved for future API persistence (not required for resource CDN) +# BAT_API_DATABASE_URL=postgres://bat:@127.0.0.1:5432/bat?sslmode=disable +# BAT_API_DATABASE_PASSWORD= +# BAT_API_REDIS_URL=redis://127.0.0.1:6379/0 +# BAT_API_REDIS_PASSWORD= + +# Set to 1 to ignore this file entirely +# BAT_API_SKIP_ENV_FILE=0 +` diff --git a/internal/api/launcher.go b/internal/api/launcher.go new file mode 100644 index 0000000..495c98f --- /dev/null +++ b/internal/api/launcher.go @@ -0,0 +1,208 @@ +package api + +import ( + "fmt" + "net/http" + "net/url" + "strings" +) + +// LauncherAPIHost is the official JP launcher API host observed from the PC launcher. +const LauncherAPIHost = "api-launcher-jp.yo-star.com" + +func (s *Server) handleLauncherBootstrap(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + sum := s.releaseSummary() + status := http.StatusOK + if !sum.Ready || sum.Snapshot == nil { + status = http.StatusServiceUnavailable + } + writeNoStoreJSON(w, status, s.launcherBootstrapBody(sum)) +} + +func (s *Server) handleLauncherGameConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + sum := s.releaseSummary() + if !sum.Ready || sum.Snapshot == nil { + writeLauncherEnvelope(w, http.StatusServiceUnavailable, 503, "release not ready", LauncherGameConfigJSONData{ + ResourceBootstrapURL: s.launcherBootstrapURL(), + Scope: "resource_bootstrap_only", + }) + return + } + snap := sum.Snapshot + data := LauncherGameConfigData{ + GameLatestVersion: defaultString(s.launcherLatestVersion(snap), snap.AppVersion), + GameLatestFilePath: s.launcherLatestFilePath(snap), + GameStartExeName: s.launcherStartExeName(snap), + GameStartParams: s.launcherStartParams(snap), + ResourceBootstrapURL: s.launcherBootstrapURL(), + ServerInfoURL: s.serverInfoURL(), + ClientPatchBaseURL: s.clientPatchBaseURL(), + Scope: "resource_bootstrap_only", + } + if snap.LauncherMetadata != nil && snap.LauncherMetadata.GameLowestVersion != "" { + data.GameLowestVersion = snap.LauncherMetadata.GameLowestVersion + } + writeLauncherEnvelope(w, http.StatusOK, 200, "ok", data) +} + +func (s *Server) handleLauncherGameConfigJSON(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + sum := s.releaseSummary() + if !sum.Ready || sum.Snapshot == nil { + writeLauncherEnvelope(w, http.StatusServiceUnavailable, 503, "release not ready", LauncherGameConfigJSONData{ + URL: s.launcherBootstrapURL(), + ResourceBootstrapURL: s.launcherBootstrapURL(), + Scope: "resource_bootstrap_only", + }) + return + } + writeLauncherEnvelope(w, http.StatusOK, 200, "ok", LauncherGameConfigJSONData{ + URL: s.launcherBootstrapJSONURL(r.URL.Query()), + ResourceBootstrapURL: s.launcherBootstrapURL(), + PackageUpdateManifest: false, + Scope: "resource_bootstrap_only", + }) +} + +func (s *Server) handleLauncherCdnConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + writeLauncherEnvelope(w, http.StatusOK, 200, "ok", LauncherCdnConfigData{ + PrimaryCDN: strings.TrimRight(s.cfg.PublicBaseURL, "/"), + BackupCDN: strings.TrimRight(s.cfg.PublicBaseURL, "/"), + ResourceBootstrapURL: s.launcherBootstrapURL(), + PackageUpdateManifest: false, + Scope: "resource_bootstrap_only", + }) +} + +func (s *Server) launcherBootstrapBody(sum ReleaseSummary) LauncherBootstrapResponse { + publicBase := strings.TrimRight(s.cfg.PublicBaseURL, "/") + body := LauncherBootstrapResponse{ + Service: "bat-api", + Ready: sum.Ready, + LauncherAPI: LauncherAPIInfo{ + Host: LauncherAPIHost, + ObservedResourceRelevantEndpoints: []string{ + "/api/launcher/game/config", + "/api/launcher/game/config/json", + "/api/launcher/advanced/game/download/cdn", + }, + CompatibilityScope: "resource_bootstrap_only", + }, + Policy: LauncherPolicy{ + Source: "rust_bat_snapshot", + DownloadsLauncherPackage: false, + EmulatesLoginOrGateway: false, + PackageUpdateManifest: false, + }, + Resource: LauncherResource{ + Release: sum.Snapshot, + ServerInfoURL: s.serverInfoURL(), + ClientPatchBaseURL: s.clientPatchBaseURL(), + }, + Endpoints: LauncherEndpointSet{ + Bootstrap: publicBase + "/v1/bootstrap", + LauncherBootstrap: publicBase + "/v1/launcher/bootstrap", + ServerInfo: s.serverInfoURL(), + ClientPatchBase: s.clientPatchBaseURL(), + LauncherGameConfig: publicBase + "/" + LauncherAPIHost + "/api/launcher/game/config", + LauncherGameConfigJSON: publicBase + "/" + LauncherAPIHost + "/api/launcher/game/config/json", + LauncherCdnConfig: publicBase + "/" + LauncherAPIHost + "/api/launcher/advanced/game/download/cdn", + }, + } + if sum.Snapshot != nil { + body.GameMainConfig = sum.Snapshot.GameMainConfig + body.LauncherMetadata = sum.Snapshot.LauncherMetadata + if rewritten, ok := rewriteAddressablesRoot(sum.Snapshot.AddressablesRoot, publicBase); ok { + body.AddressablesCatalogURLRoot = rewritten + } + } + return body +} + +func (s *Server) releaseSummary() ReleaseSummary { + if idx := s.index(); idx != nil { + return idx.Summary() + } + return ReleaseSummary{} +} + +func (s *Server) serverInfoURL() string { + return strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/" + ServerInfoHost + "/server-info.json" +} + +func (s *Server) clientPatchBaseURL() string { + return strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/" + ClientPatchHost +} + +func (s *Server) launcherBootstrapURL() string { + return strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/v1/launcher/bootstrap" +} + +func (s *Server) launcherBootstrapJSONURL(query url.Values) string { + base := strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/" + LauncherAPIHost + "/api/launcher/resource/bootstrap.json" + values := url.Values{} + for _, key := range []string{"version", "file_path"} { + if value := query.Get(key); value != "" { + values.Set(key, value) + } + } + if encoded := values.Encode(); encoded != "" { + return base + "?" + encoded + } + return base +} + +func (s *Server) launcherLatestVersion(snap *SnapshotSummary) string { + if snap.LauncherMetadata != nil && snap.LauncherMetadata.GameLatestVersion != "" { + return snap.LauncherMetadata.GameLatestVersion + } + return snap.AppVersion +} + +func (s *Server) launcherLatestFilePath(snap *SnapshotSummary) string { + if snap.LauncherMetadata != nil { + return snap.LauncherMetadata.GameLatestFilePath + } + return "" +} + +func (s *Server) launcherStartExeName(snap *SnapshotSummary) string { + if snap.LauncherMetadata != nil { + return snap.LauncherMetadata.GameStartExeName + } + return "" +} + +func (s *Server) launcherStartParams(snap *SnapshotSummary) []string { + if snap.LauncherMetadata != nil && len(snap.LauncherMetadata.GameStartParams) > 0 { + return append([]string(nil), snap.LauncherMetadata.GameStartParams...) + } + return []string{} +} + +func writeLauncherEnvelope[T any](w http.ResponseWriter, status int, code int, message string, data T) { + writeNoStoreJSON(w, status, LauncherEnvelope[T]{ + Code: code, + Message: message, + Data: data, + }) +} + +func launcherHostPath(path string) string { + return fmt.Sprintf("/%s%s", LauncherAPIHost, path) +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..e420d81 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,259 @@ +package api + +import ( + "crypto/subtle" + "net" + "net/http" + "strings" + "sync" + "time" +) + +func (s *Server) wrapHandler(next http.Handler) http.Handler { + handler := next + handler = s.authMiddleware(handler) + handler = s.rateLimitMiddleware(handler) + handler = s.securityHeadersMiddleware(handler) + handler = s.accessLogMiddleware(handler) + return handler +} + +func (s *Server) securityHeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") + w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + next.ServeHTTP(w, r) + }) +} + +func (s *Server) authMiddleware(next http.Handler) http.Handler { + if s.cfg.AuthToken == "" { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.authExempt(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + if !constantTimeTokenEqual(s.requestToken(r), s.cfg.AuthToken) { + w.Header().Set("WWW-Authenticate", `Bearer realm="bat-api"`) + writeErrorJSON(w, http.StatusUnauthorized, "unauthorized", "missing or invalid access token") + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) authExempt(path string) bool { + for _, exempt := range s.cfg.AuthExemptPaths { + if exempt == path { + return true + } + if strings.HasSuffix(exempt, "/") && strings.HasPrefix(path, exempt) { + return true + } + } + return false +} + +func (s *Server) requestToken(r *http.Request) string { + if auth := r.Header.Get("Authorization"); auth != "" { + const prefix = "Bearer " + if strings.HasPrefix(auth, prefix) { + return strings.TrimSpace(strings.TrimPrefix(auth, prefix)) + } + } + for _, header := range []string{"X-BAT-Token", "X-BAT-API-Key"} { + if token := strings.TrimSpace(r.Header.Get(header)); token != "" { + return token + } + } + if s.cfg.AuthQueryParam != "" { + return r.URL.Query().Get(s.cfg.AuthQueryParam) + } + return "" +} + +func constantTimeTokenEqual(got, want string) bool { + if got == "" || want == "" { + return false + } + gotBytes := []byte(got) + wantBytes := []byte(want) + if len(gotBytes) != len(wantBytes) { + subtle.ConstantTimeCompare(wantBytes, wantBytes) + return false + } + return subtle.ConstantTimeCompare(gotBytes, wantBytes) == 1 +} + +func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler { + if s.limiter == nil { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + client := s.clientIdentity(r) + if !s.limiter.allow(client, time.Now()) { + w.Header().Set("Retry-After", "1") + writeErrorJSON(w, http.StatusTooManyRequests, "rate_limited", "too many requests") + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) accessLogMiddleware(next http.Handler) http.Handler { + if !s.cfg.AccessLog { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + requestID := safeLogValue(r.Header.Get("X-Request-ID")) + if requestID == "" { + requestID = "-" + } + s.logger.Printf( + "access method=%s path=%s status=%d bytes=%d duration_ms=%d client_ip=%s request_id=%s user_agent=%q", + r.Method, + r.URL.Path, + rec.status, + rec.bytes, + time.Since(start).Milliseconds(), + s.clientIdentity(r), + requestID, + r.UserAgent(), + ) + }) +} + +func (s *Server) clientIdentity(r *http.Request) string { + if s.cfg.TrustProxyHeaders { + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + for _, part := range strings.Split(forwarded, ",") { + if ip := strings.TrimSpace(part); ip != "" { + return ip + } + } + } + if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" { + return realIP + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil && host != "" { + return host + } + return r.RemoteAddr +} + +func safeLogValue(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + raw = strings.Map(func(r rune) rune { + if r < 32 || r == 127 { + return -1 + } + return r + }, raw) + if len(raw) > 128 { + return raw[:128] + } + return raw +} + +type statusRecorder struct { + http.ResponseWriter + status int + bytes int +} + +func (r *statusRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +func (r *statusRecorder) Write(data []byte) (int, error) { + n, err := r.ResponseWriter.Write(data) + r.bytes += n + return n, err +} + +func (r *statusRecorder) Unwrap() http.ResponseWriter { + return r.ResponseWriter +} + +type tokenBucketLimiter struct { + mu sync.Mutex + rate float64 + burst float64 + buckets map[string]*tokenBucket + lastCleanup time.Time +} + +type tokenBucket struct { + tokens float64 + last time.Time + lastSeen time.Time +} + +func newTokenBucketLimiter(rps float64, burst int) *tokenBucketLimiter { + return &tokenBucketLimiter{ + rate: rps, + burst: float64(burst), + buckets: map[string]*tokenBucket{}, + } +} + +func (l *tokenBucketLimiter) allow(key string, now time.Time) bool { + if key == "" { + key = "unknown" + } + l.mu.Lock() + defer l.mu.Unlock() + if l.lastCleanup.IsZero() || now.Sub(l.lastCleanup) > time.Minute { + l.cleanup(now) + } + bucket := l.buckets[key] + if bucket == nil { + bucket = &tokenBucket{tokens: l.burst, last: now, lastSeen: now} + l.buckets[key] = bucket + } + elapsed := now.Sub(bucket.last).Seconds() + bucket.tokens += elapsed * l.rate + if bucket.tokens > l.burst { + bucket.tokens = l.burst + } + bucket.last = now + bucket.lastSeen = now + if bucket.tokens < 1 { + return false + } + bucket.tokens-- + return true +} + +func (l *tokenBucketLimiter) cleanup(now time.Time) { + for key, bucket := range l.buckets { + if now.Sub(bucket.lastSeen) > 10*time.Minute { + delete(l.buckets, key) + } + } + l.lastCleanup = now +} + +func writeErrorJSON(w http.ResponseWriter, status int, code string, message string) { + writeNoStoreJSON(w, status, ErrorResponse{ + Error: ErrorBody{ + Code: code, + Message: message, + Status: status, + }, + }) +} diff --git a/internal/api/openapi.go b/internal/api/openapi.go new file mode 100644 index 0000000..292b24e --- /dev/null +++ b/internal/api/openapi.go @@ -0,0 +1,161 @@ +package api + +import ( + "net/http" + "strconv" +) + +const openAPISpecYAML = `openapi: 3.0.3 +info: + title: BlueArchive Toolkit bat-api + version: 0.1.0 + description: Resource bootstrap and read-only distribution API. +servers: + - url: http://127.0.0.1:18080 +security: + - bearerAuth: [] + - queryToken: [] +paths: + /healthz: + get: + summary: Liveness and refresh diagnostics + responses: + "200": + description: Service is alive. + /readyz: + get: + summary: Release readiness + responses: + "200": + description: A distributable release is available. + "503": + description: No distributable release is available. + /v1/bootstrap: + get: + summary: Startup resource bootstrap + responses: + "200": + description: Resource bootstrap response. + "503": + description: Release is not ready. + /v1/launcher/bootstrap: + get: + summary: Launcher-shaped resource bootstrap + responses: + "200": + description: Launcher bootstrap response. + "503": + description: Release is not ready. + /api/launcher/game/config: + get: + summary: Resource-only launcher game config compatibility + responses: + "200": + description: Launcher envelope with resource metadata. + /api/launcher/game/config/json: + get: + summary: Resource-only launcher manifest URL compatibility + parameters: + - name: version + in: query + schema: + type: string + - name: file_path + in: query + schema: + type: string + responses: + "200": + description: Launcher envelope pointing to resource bootstrap JSON. + /api/launcher/advanced/game/download/cdn: + get: + summary: Resource-only launcher CDN compatibility + responses: + "200": + description: Launcher envelope with public base URL as CDN root. + /v1/release: + get: + summary: Current release summary + responses: + "200": + description: Release summary. + /v1/resources: + get: + summary: Paginated resource manifest entries + parameters: + - name: offset + in: query + schema: + type: integer + minimum: 0 + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 1000 + responses: + "200": + description: Resource list page. + /v1/server-info: + get: + summary: Rewritten server-info document + responses: + "200": + description: Server-info JSON with AddressablesCatalogUrlRoot rewritten. + /openapi.yaml: + get: + summary: OpenAPI document + responses: + "200": + description: OpenAPI YAML. + /admin/: + get: + summary: Reserved admin panel entry + responses: + "200": + description: Admin panel placeholder and links. + /prod-clientpatch.bluearchiveyostar.com/{path}: + get: + summary: CDN-shaped resource bytes + parameters: + - name: path + in: path + required: true + schema: + type: string + responses: + "200": + description: Resource bytes. + "206": + description: Partial resource bytes. + head: + summary: CDN-shaped resource metadata + responses: + "200": + description: Resource headers. +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + queryToken: + type: apiKey + in: query + name: bat_token +` + +func (s *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + w.Header().Set("Content-Type", "application/yaml; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Length", strconv.Itoa(len(openAPISpecYAML))) + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return + } + _, _ = w.Write([]byte(openAPISpecYAML)) +} diff --git a/internal/api/pathsafe.go b/internal/api/pathsafe.go new file mode 100644 index 0000000..f825929 --- /dev/null +++ b/internal/api/pathsafe.go @@ -0,0 +1,93 @@ +package api + +import ( + "fmt" + "path/filepath" + "strings" +) + +// OfficialHosts are host path prefixes allowed on the CDN surface. +var OfficialHosts = map[string]struct{}{ + ClientPatchHost: {}, + ServerInfoHost: {}, +} + +// SplitCDNPath splits a request URL path into host + relative path under that host. +// Expected form: /prod-clientpatch.bluearchiveyostar.com/r93_xxx/TableBundles/... +func SplitCDNPath(requestPath string) (host string, rel string, err error) { + cleaned := filepath.ToSlash(requestPath) + cleaned = strings.TrimPrefix(cleaned, "/") + if cleaned == "" { + return "", "", fmt.Errorf("empty path") + } + parts := strings.Split(cleaned, "/") + if len(parts) < 2 { + return "", "", fmt.Errorf("path must include host and object path") + } + host = parts[0] + if _, ok := OfficialHosts[host]; !ok { + return "", "", fmt.Errorf("unsupported host %q", host) + } + for _, segment := range parts { + if segment == "" || segment == "." || segment == ".." { + return "", "", fmt.Errorf("unsafe path segment") + } + if strings.ContainsAny(segment, `?\`) { + return "", "", fmt.Errorf("unsafe path character") + } + } + rel = strings.Join(parts, "/") + return host, rel, nil +} + +// ResolveUnderRoot joins root with a slash-separated relative path and ensures +// the result stays within root. Symlink targets that escape root are rejected +// when evalSymlinks is true and the path exists. +func ResolveUnderRoot(root, rel string, evalSymlinks bool) (string, error) { + if root == "" { + return "", fmt.Errorf("resource root is empty") + } + rootAbs, err := filepath.Abs(root) + if err != nil { + return "", err + } + if evalSymlinks { + if resolved, err := filepath.EvalSymlinks(rootAbs); err == nil { + rootAbs = resolved + } + } + rel = filepath.ToSlash(rel) + rel = strings.TrimPrefix(rel, "/") + var segments []string + for _, segment := range strings.Split(rel, "/") { + if segment == "" || segment == "." { + continue + } + if segment == ".." { + return "", fmt.Errorf("path escapes resource root") + } + if strings.ContainsAny(segment, `?\`) { + return "", fmt.Errorf("unsafe path segment %q", segment) + } + segments = append(segments, segment) + } + if len(segments) == 0 { + return "", fmt.Errorf("empty relative path") + } + candidate := filepath.Join(append([]string{rootAbs}, segments...)...) + // Ensure candidate is under rootAbs even before existence checks. + relCheck, err := filepath.Rel(rootAbs, candidate) + if err != nil || strings.HasPrefix(relCheck, "..") || filepath.IsAbs(relCheck) { + return "", fmt.Errorf("path escapes resource root") + } + if evalSymlinks { + if resolved, err := filepath.EvalSymlinks(candidate); err == nil { + relCheck, err = filepath.Rel(rootAbs, resolved) + if err != nil || strings.HasPrefix(relCheck, "..") || filepath.IsAbs(relCheck) { + return "", fmt.Errorf("symlink escapes resource root") + } + candidate = resolved + } + } + return candidate, nil +} diff --git a/internal/api/release_index.go b/internal/api/release_index.go new file mode 100644 index 0000000..4e6b250 --- /dev/null +++ b/internal/api/release_index.go @@ -0,0 +1,292 @@ +package api + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +// ResourceEntry is one distributable object from the published release. +type ResourceEntry struct { + URL string `json:"url"` + RelativePath string `json:"relative_path"` + Bytes uint64 `json:"bytes"` + BLAKE3 string `json:"blake3,omitempty"` + Present bool `json:"present"` + SizeMatch bool `json:"size_match"` +} + +// SnapshotSummary is a subset of official-sync-snapshot.json / catalog.status. +type SnapshotSummary struct { + AppVersion string `json:"app_version,omitempty"` + BundleVersion string `json:"bundle_version,omitempty"` + ConnectionGroupName string `json:"connection_group_name,omitempty"` + AddressablesRoot string `json:"addressables_root,omitempty"` + VersionID string `json:"version_id,omitempty"` + CompletedUnixSeconds *uint64 `json:"completed_unix_seconds,omitempty"` + LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"` + GameMainConfig *GameMainConfigSummary `json:"game_main_config,omitempty"` +} + +// LauncherMetadataSummary mirrors the resource-relevant part of Rust's launcher metadata snapshot. +type LauncherMetadataSummary struct { + LauncherVersion string `json:"launcher_version,omitempty"` + GameLatestVersion string `json:"game_latest_version,omitempty"` + GameLatestFilePath string `json:"game_latest_file_path,omitempty"` + GameLowestVersion string `json:"game_lowest_version,omitempty"` + GameStartExeName string `json:"game_start_exe_name,omitempty"` + GameStartParams []string `json:"game_start_params,omitempty"` + ManifestURL string `json:"manifest_url,omitempty"` + ManifestSource string `json:"manifest_source,omitempty"` + ManifestFileCount int `json:"manifest_file_count,omitempty"` +} + +// GameMainConfigSummary mirrors the resource-relevant decrypted GameMainConfig fields. +type GameMainConfigSummary struct { + ServerInfoDataURL string `json:"server_info_data_url,omitempty"` + DefaultConnectionGroup string `json:"default_connection_group,omitempty"` +} + +// ReleaseIndex is the in-memory view of a published resource root. +type ReleaseIndex struct { + mu sync.RWMutex + + ResourceRoot string `json:"resource_root"` + Source string `json:"source"` // "rpc" | "resource_root" | "empty" + RPCAvailable bool `json:"rpc_available"` + DoctorHealthy *bool `json:"doctor_healthy,omitempty"` + Snapshot *SnapshotSummary `json:"snapshot,omitempty"` + ManifestVersion int `json:"manifest_version,omitempty"` + Entries []ResourceEntry `json:"entries"` + // byRel maps relative path (host/path...) to entry index. + byRel map[string]int + // MissingOnDisk lists relative paths present in the index but absent on disk. + MissingOnDisk []string `json:"missing_on_disk,omitempty"` +} + +// Summary returns a JSON-serializable overview without the full entry list. +type ReleaseSummary struct { + ResourceRoot string `json:"resource_root"` + Source string `json:"source"` + RPCAvailable bool `json:"rpc_available"` + DoctorHealthy *bool `json:"doctor_healthy,omitempty"` + Snapshot *SnapshotSummary `json:"snapshot,omitempty"` + ManifestVersion int `json:"manifest_version,omitempty"` + EntryCount int `json:"entry_count"` + PresentCount int `json:"present_count"` + MissingCount int `json:"missing_count"` + Ready bool `json:"ready"` +} + +// Summary builds a compact release overview. +func (idx *ReleaseIndex) Summary() ReleaseSummary { + idx.mu.RLock() + defer idx.mu.RUnlock() + present := 0 + for _, e := range idx.Entries { + if e.Present && e.SizeMatch { + present++ + } + } + return ReleaseSummary{ + ResourceRoot: idx.ResourceRoot, + Source: idx.Source, + RPCAvailable: idx.RPCAvailable, + DoctorHealthy: idx.DoctorHealthy, + Snapshot: idx.Snapshot, + ManifestVersion: idx.ManifestVersion, + EntryCount: len(idx.Entries), + PresentCount: present, + MissingCount: len(idx.MissingOnDisk), + Ready: idx.ResourceRoot != "" && present > 0, + } +} + +// Lookup returns the entry for a host/path relative path. +func (idx *ReleaseIndex) Lookup(rel string) (ResourceEntry, bool) { + idx.mu.RLock() + defer idx.mu.RUnlock() + rel = filepath.ToSlash(rel) + rel = strings.TrimPrefix(rel, "/") + i, ok := idx.byRel[rel] + if !ok { + return ResourceEntry{}, false + } + return idx.Entries[i], true +} + +// List returns a stable page of entries. +func (idx *ReleaseIndex) List(offset, limit int) (items []ResourceEntry, total int) { + idx.mu.RLock() + defer idx.mu.RUnlock() + total = len(idx.Entries) + if offset < 0 { + offset = 0 + } + if limit <= 0 { + limit = 100 + } + if offset >= total { + return []ResourceEntry{}, total + } + end := offset + limit + if end > total { + end = total + } + out := make([]ResourceEntry, end-offset) + copy(out, idx.Entries[offset:end]) + return out, total +} + +// BuildIndexFromManifestEntries builds an index against resourceRoot. +func BuildIndexFromManifestEntries( + resourceRoot string, + source string, + rpcAvailable bool, + doctorHealthy *bool, + snapshot *SnapshotSummary, + manifestVersion int, + entries []manifestEntry, +) (*ReleaseIndex, error) { + rootAbs, err := filepath.Abs(resourceRoot) + if err != nil { + return nil, err + } + idx := &ReleaseIndex{ + ResourceRoot: rootAbs, + Source: source, + RPCAvailable: rpcAvailable, + DoctorHealthy: doctorHealthy, + Snapshot: snapshot, + ManifestVersion: manifestVersion, + byRel: make(map[string]int), + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].URL < entries[j].URL + }) + for _, raw := range entries { + rel := filepath.ToSlash(raw.Destination) + rel = strings.TrimPrefix(rel, "/") + if rel == "" { + // Derive from URL when destination is empty. + rel = relativePathFromURL(raw.URL) + } + if rel == "" { + continue + } + entry := ResourceEntry{ + URL: raw.URL, + RelativePath: rel, + Bytes: raw.Bytes, + BLAKE3: raw.BLAKE3, + } + abs, err := ResolveUnderRoot(rootAbs, rel, false) + if err != nil { + entry.Present = false + entry.SizeMatch = false + idx.MissingOnDisk = append(idx.MissingOnDisk, rel) + } else { + info, err := os.Lstat(abs) + if err != nil || !info.Mode().IsRegular() { + entry.Present = false + entry.SizeMatch = false + idx.MissingOnDisk = append(idx.MissingOnDisk, rel) + } else { + entry.Present = true + entry.SizeMatch = uint64(info.Size()) == raw.Bytes || raw.Bytes == 0 + if !entry.SizeMatch { + idx.MissingOnDisk = append(idx.MissingOnDisk, rel+"#size_mismatch") + } + } + } + idx.byRel[rel] = len(idx.Entries) + idx.Entries = append(idx.Entries, entry) + } + return idx, nil +} + +// LoadIndexFromResourceRoot reads official-download-manifest.json under root. +// Used for tests and --resource-root fallback without RPC. +func LoadIndexFromResourceRoot(resourceRoot string) (*ReleaseIndex, error) { + rootAbs, err := filepath.Abs(resourceRoot) + if err != nil { + return nil, err + } + manifestPath := filepath.Join(rootAbs, "official-download-manifest.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("read download manifest: %w", err) + } + var manifest fileManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("parse download manifest: %w", err) + } + if manifest.Version != 0 && manifest.Version != 1 { + return nil, fmt.Errorf("unsupported download manifest version %d", manifest.Version) + } + entries := make([]manifestEntry, 0, len(manifest.Entries)) + for _, e := range manifest.Entries { + entries = append(entries, manifestEntry{ + URL: e.URL, + Destination: e.Destination, + Bytes: e.Bytes, + BLAKE3: e.BLAKE3, + }) + } + var snapshot *SnapshotSummary + if snapData, err := os.ReadFile(filepath.Join(rootAbs, "official-sync-snapshot.json")); err == nil { + var s fileSnapshot + if json.Unmarshal(snapData, &s) == nil { + snapshot = &SnapshotSummary{ + AppVersion: s.AppVersion, + BundleVersion: s.BundleVersion, + ConnectionGroupName: s.ConnectionGroupName, + AddressablesRoot: s.AddressablesRoot, + LauncherMetadata: s.LauncherMetadata, + GameMainConfig: s.GameMainConfigBootstrap, + } + } + } + return BuildIndexFromManifestEntries(rootAbs, "resource_root", false, nil, snapshot, manifest.Version, entries) +} + +type manifestEntry struct { + URL string + Destination string + Bytes uint64 + BLAKE3 string +} + +type fileManifest struct { + Version int `json:"version"` + Entries map[string]struct { + URL string `json:"url"` + Destination string `json:"destination"` + Bytes uint64 `json:"bytes"` + BLAKE3 string `json:"blake3"` + } `json:"entries"` +} + +type fileSnapshot struct { + AppVersion string `json:"app_version"` + BundleVersion string `json:"bundle_version"` + ConnectionGroupName string `json:"connection_group_name"` + AddressablesRoot string `json:"addressables_root"` + LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"` + GameMainConfigBootstrap *GameMainConfigSummary `json:"game_main_config_bootstrap,omitempty"` +} + +func relativePathFromURL(rawURL string) string { + const prefix = "https://" + if !strings.HasPrefix(rawURL, prefix) { + return "" + } + rest := strings.TrimPrefix(rawURL, prefix) + rest = strings.SplitN(rest, "?", 2)[0] + rest = strings.SplitN(rest, "#", 2)[0] + return filepath.ToSlash(rest) +} diff --git a/internal/api/responses.go b/internal/api/responses.go new file mode 100644 index 0000000..e685ae5 --- /dev/null +++ b/internal/api/responses.go @@ -0,0 +1,174 @@ +package api + +import "net/http" + +type ErrorResponse struct { + Error ErrorBody `json:"error"` +} + +type ErrorBody struct { + Code string `json:"code"` + Message string `json:"message"` + Status int `json:"status"` +} + +type RefreshDiagnostics struct { + InProgress bool `json:"in_progress"` + LastAttemptUnixSeconds *int64 `json:"last_attempt_unix_seconds"` + LastFinishedUnixSeconds *int64 `json:"last_finished_unix_seconds"` + LastSuccessUnixSeconds *int64 `json:"last_success_unix_seconds"` + LastDurationMilliseconds int64 `json:"last_duration_milliseconds"` + LastError string `json:"last_error"` + LastWarningCount int `json:"last_warning_count"` + LastWarnings []string `json:"last_warnings"` + RefreshIntervalSeconds int64 `json:"refresh_interval_seconds"` + ResourceRootOverrideActive bool `json:"resource_root_override_active"` +} + +type BootstrapResponse struct { + Service string `json:"service"` + Ready bool `json:"ready"` + Bat BootstrapBat `json:"bat"` + BatAPI BootstrapAPI `json:"bat_api"` + Resource BootstrapResource `json:"resource"` + Policy BootstrapPolicy `json:"policy"` + Endpoints []string `json:"endpoints"` + Warnings []string `json:"warnings"` +} + +type BootstrapBat struct { + Role string `json:"role"` + Socket string `json:"socket"` + RPCAvailable bool `json:"rpc_available"` + DoctorHealthy *bool `json:"doctor_healthy,omitempty"` +} + +type BootstrapAPI struct { + Role string `json:"role"` + PublicBase string `json:"public_base"` + RefreshIntervalSeconds int64 `json:"refresh_interval_seconds"` + Refresh RefreshDiagnostics `json:"refresh"` +} + +type BootstrapResource struct { + Release *SnapshotSummary `json:"release,omitempty"` + ResourceRoot string `json:"resource_root"` + Source string `json:"source"` + ManifestVersion int `json:"manifest_version,omitempty"` + EntryCount int `json:"entry_count"` + PresentCount int `json:"present_count"` + MissingCount int `json:"missing_count"` + ServerInfoURL string `json:"server_info_url"` + ClientPatchBaseURL string `json:"client_patch_base_url"` + AddressablesCatalogURLRoot string `json:"addressables_catalog_url_root,omitempty"` +} + +type BootstrapPolicy struct { + PullOwner string `json:"pull_owner"` + ResourceRootDiscovery string `json:"resource_root_discovery"` + Deployment string `json:"deployment"` + ResourceRootOverride bool `json:"resource_root_override"` + ServesOnlyPublishedRelease bool `json:"serves_only_published_release"` + WritesReleaseState bool `json:"writes_release_state"` + FullGameBusinessAPI bool `json:"full_game_business_api"` +} + +type RootResponse struct { + Service string `json:"service"` + Role string `json:"role"` + Note string `json:"note"` + Endpoints []string `json:"endpoints"` +} + +type ResourceListResponse struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Items []ResourceEntry `json:"items"` +} + +type LauncherBootstrapResponse struct { + Service string `json:"service"` + Ready bool `json:"ready"` + LauncherAPI LauncherAPIInfo `json:"launcher_api"` + Policy LauncherPolicy `json:"policy"` + Resource LauncherResource `json:"resource"` + Endpoints LauncherEndpointSet `json:"endpoints"` + GameMainConfig *GameMainConfigSummary `json:"game_main_config,omitempty"` + LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"` + AddressablesCatalogURLRoot string `json:"addressables_catalog_url_root,omitempty"` +} + +type LauncherAPIInfo struct { + Host string `json:"host"` + ObservedResourceRelevantEndpoints []string `json:"observed_resource_relevant_endpoints"` + CompatibilityScope string `json:"compatibility_scope"` +} + +type LauncherPolicy struct { + Source string `json:"source"` + DownloadsLauncherPackage bool `json:"downloads_launcher_package"` + EmulatesLoginOrGateway bool `json:"emulates_login_or_gateway"` + PackageUpdateManifest bool `json:"package_update_manifest"` +} + +type LauncherResource struct { + Release *SnapshotSummary `json:"release,omitempty"` + ServerInfoURL string `json:"server_info_url"` + ClientPatchBaseURL string `json:"client_patch_base_url"` +} + +type LauncherEndpointSet struct { + Bootstrap string `json:"bootstrap"` + LauncherBootstrap string `json:"launcher_bootstrap"` + ServerInfo string `json:"server_info"` + ClientPatchBase string `json:"client_patch_base"` + LauncherGameConfig string `json:"launcher_game_config"` + LauncherGameConfigJSON string `json:"launcher_game_config_json"` + LauncherCdnConfig string `json:"launcher_cdn_config"` +} + +type LauncherGameConfigData struct { + GameLatestVersion string `json:"game_latest_version"` + GameLatestFilePath string `json:"game_latest_file_path"` + GameLowestVersion string `json:"game_lowest_version,omitempty"` + GameStartExeName string `json:"game_start_exe_name"` + GameStartParams []string `json:"game_start_params"` + ResourceBootstrapURL string `json:"resource_bootstrap_url"` + ServerInfoURL string `json:"server_info_url"` + ClientPatchBaseURL string `json:"client_patch_base_url"` + Scope string `json:"scope"` +} + +type LauncherGameConfigJSONData struct { + URL string `json:"url"` + ResourceBootstrapURL string `json:"resource_bootstrap_url"` + PackageUpdateManifest bool `json:"package_update_manifest"` + Scope string `json:"scope"` +} + +type LauncherCdnConfigData struct { + PrimaryCDN string `json:"primary_cdn"` + BackupCDN string `json:"back_up_cdn"` + ResourceBootstrapURL string `json:"resource_bootstrap_url"` + PackageUpdateManifest bool `json:"package_update_manifest"` + Scope string `json:"scope"` +} + +type LauncherEnvelope[T any] struct { + Code int `json:"code"` + Message string `json:"message"` + Data T `json:"data"` +} + +type AdminIndexResponse struct { + Service string `json:"service"` + Panel string `json:"panel"` + Status string `json:"status"` + Links []string `json:"links"` +} + +func writeNoStoreJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, status, body) +} diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go new file mode 100644 index 0000000..1774559 --- /dev/null +++ b/internal/api/rpc_release.go @@ -0,0 +1,269 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + + "bat-api/internal/backendrpc" +) + +// Backend is the subset of daemon RPC used by bat-api. +// +// Call order for discovery (per plan review): +// 1. daemon.status +// 2. daemon.doctor +// 3. catalog.status / resource.manifest (and resource.state as needed) +type Backend interface { + DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) + DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) + ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) + CatalogStatus(ctx context.Context) (json.RawMessage, error) + ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) +} + +// RPCClient adapts *backendrpc.Client to Backend. +type RPCClient struct { + Client *backendrpc.Client +} + +func (r RPCClient) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) { + return r.Client.DaemonStatus(ctx) +} +func (r RPCClient) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) { + return r.Client.DaemonDoctor(ctx) +} +func (r RPCClient) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) { + return r.Client.ResourceState(ctx) +} +func (r RPCClient) CatalogStatus(ctx context.Context) (json.RawMessage, error) { + return r.Client.CatalogStatus(ctx) +} +func (r RPCClient) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) { + return r.Client.ResourceManifest(ctx, offset, limit) +} + +// DiscoverResult is the outcome of talking to the bat daemon. +type DiscoverResult struct { + RPCAvailable bool + DoctorHealthy *bool + Status *backendrpc.DaemonStatusReport + Doctor *backendrpc.DoctorReport + Snapshot *SnapshotSummary + ResourceRoot string + Index *ReleaseIndex + Warnings []string +} + +// DiscoverAndIndex contacts the daemon (status first, then doctor) and builds +// a release index from paginated resource.manifest plus on-disk checks. +// +// If resourceRootOverride is non-empty, it wins over RPC-reported roots after +// RPC health probes (still preferred for production to call status/doctor). +func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride string) (*DiscoverResult, error) { + out := &DiscoverResult{} + if backend == nil { + if resourceRootOverride == "" { + out.Index = &ReleaseIndex{Source: "empty", byRel: map[string]int{}} + return out, nil + } + idx, err := LoadIndexFromResourceRoot(resourceRootOverride) + if err != nil { + return nil, err + } + out.ResourceRoot = idx.ResourceRoot + out.Index = idx + return out, nil + } + + // 1) daemon.status first + status, err := backend.DaemonStatus(ctx) + if err != nil { + out.Warnings = append(out.Warnings, fmt.Sprintf("daemon.status: %v", err)) + if resourceRootOverride != "" { + idx, loadErr := LoadIndexFromResourceRoot(resourceRootOverride) + if loadErr != nil { + return out, fmt.Errorf("daemon.status failed (%v) and resource-root load failed: %w", err, loadErr) + } + out.ResourceRoot = idx.ResourceRoot + out.Index = idx + return out, nil + } + out.Index = &ReleaseIndex{Source: "empty", byRel: map[string]int{}} + return out, nil + } + out.RPCAvailable = true + out.Status = status + + // 2) daemon.doctor second + doctor, err := backend.DaemonDoctor(ctx) + if err != nil { + out.Warnings = append(out.Warnings, fmt.Sprintf("daemon.doctor: %v", err)) + } else { + out.Doctor = doctor + h := doctor.Healthy + out.DoctorHealthy = &h + } + + // Catalog / resource discovery + var snapshot *SnapshotSummary + var resourceRoot string + + if raw, err := backend.CatalogStatus(ctx); err != nil { + out.Warnings = append(out.Warnings, fmt.Sprintf("catalog.status: %v", err)) + } else { + snap, root, ok := parseCatalogStatus(raw) + if ok { + snapshot = snap + resourceRoot = root + } + } + + if resourceRoot == "" { + if state, err := backend.ResourceState(ctx); err != nil { + out.Warnings = append(out.Warnings, fmt.Sprintf("resource.state: %v", err)) + } else if state.ResourceOutputRoot != nil && *state.ResourceOutputRoot != "" { + // Prefer published current under output root when catalog root missing. + candidate := filepath.Join(*state.ResourceOutputRoot, "current") + resourceRoot = candidate + } + } + + if resourceRootOverride != "" { + resourceRoot = resourceRootOverride + } + if resourceRoot == "" { + out.Snapshot = snapshot + out.Index = &ReleaseIndex{ + Source: "rpc", + RPCAvailable: true, + DoctorHealthy: out.DoctorHealthy, + Snapshot: snapshot, + byRel: map[string]int{}, + } + out.Warnings = append(out.Warnings, "no resource root from RPC; set --resource-root or publish a version") + return out, nil + } + + entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend) + if err != nil { + out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err)) + // Fallback: load local manifest file under root. + idx, loadErr := LoadIndexFromResourceRoot(resourceRoot) + if loadErr != nil { + return out, fmt.Errorf("manifest RPC and local load failed: rpc=%v local=%w", err, loadErr) + } + idx.Source = "rpc+local_manifest" + idx.RPCAvailable = true + idx.DoctorHealthy = out.DoctorHealthy + if snapshot != nil { + idx.Snapshot = snapshot + } + out.ResourceRoot = idx.ResourceRoot + out.Snapshot = idx.Snapshot + out.Index = idx + return out, nil + } + if rootFromManifest != "" { + resourceRoot = rootFromManifest + } + if resourceRootOverride != "" { + resourceRoot = resourceRootOverride + } + + idx, err := BuildIndexFromManifestEntries( + resourceRoot, + "rpc", + true, + out.DoctorHealthy, + snapshot, + manifestVersion, + entries, + ) + if err != nil { + return out, err + } + out.ResourceRoot = idx.ResourceRoot + out.Snapshot = snapshot + out.Index = idx + return out, nil +} + +func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) { + if len(raw) == 0 || string(raw) == "null" { + return nil, "", false + } + var payload struct { + Available bool `json:"available"` + AppVersion string `json:"app_version"` + BundleVersion string `json:"bundle_version"` + ConnectionGroupName string `json:"connection_group_name"` + AddressablesRoot string `json:"addressables_root"` + LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata"` + GameMainConfig *GameMainConfigSummary `json:"game_main_config"` + Version *struct { + ID string `json:"id"` + CompletedUnixSeconds *uint64 `json:"completed_unix_seconds"` + ResourceRoot string `json:"resource_root"` + } `json:"version"` + } + if err := json.Unmarshal(raw, &payload); err != nil || !payload.Available { + return nil, "", false + } + snap := &SnapshotSummary{ + AppVersion: payload.AppVersion, + BundleVersion: payload.BundleVersion, + ConnectionGroupName: payload.ConnectionGroupName, + AddressablesRoot: payload.AddressablesRoot, + LauncherMetadata: payload.LauncherMetadata, + GameMainConfig: payload.GameMainConfig, + } + root := "" + if payload.Version != nil { + snap.VersionID = payload.Version.ID + snap.CompletedUnixSeconds = payload.Version.CompletedUnixSeconds + root = payload.Version.ResourceRoot + } + return snap, root, true +} + +func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEntry, int, string, error) { + const pageSize = 500 + offset := 0 + var all []manifestEntry + var version int + var root string + for { + page, err := backend.ResourceManifest(ctx, offset, pageSize) + if err != nil { + return nil, 0, "", err + } + if !page.Available { + return nil, 0, "", fmt.Errorf("resource.manifest available=false") + } + if root == "" { + root = page.ResourceRoot + } + if version == 0 { + version = page.ManifestVersion + } + for _, e := range page.Entries { + var bytes uint64 + if e.Bytes != nil { + bytes = *e.Bytes + } + all = append(all, manifestEntry{ + URL: e.URL, + Destination: e.Destination, + Bytes: bytes, + BLAKE3: e.BLAKE3, + }) + } + offset += len(page.Entries) + if len(page.Entries) == 0 || offset >= page.TotalEntries { + break + } + } + return all, version, root, nil +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..17f1f83 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,483 @@ +package api + +import ( + "context" + "encoding/json" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// Server is the bat-api HTTP server for resource bootstrap and distribution. +type Server struct { + cfg Config + backend Backend + logger *log.Logger + limiter *tokenBucketLimiter + + mu sync.RWMutex + idx *ReleaseIndex + meta DiscoverResult + + refreshInProgress bool + lastRefreshStart time.Time + lastRefreshFinish time.Time + lastRefreshOK time.Time + lastRefreshError string + lastRefreshWarns []string + lastRefreshDur time.Duration +} + +// NewServer constructs a server. Call Refresh before Listen when possible. +func NewServer(cfg Config, backend Backend, logger *log.Logger) *Server { + if logger == nil { + logger = log.Default() + } + var limiter *tokenBucketLimiter + if cfg.RateLimitRPS > 0 { + limiter = newTokenBucketLimiter(cfg.RateLimitRPS, cfg.RateLimitBurst) + } + return &Server{cfg: cfg, backend: backend, logger: logger, limiter: limiter} +} + +// Handler returns the root HTTP handler. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", s.handleHealthz) + mux.HandleFunc("/readyz", s.handleReadyz) + mux.HandleFunc("/v1/bootstrap", s.handleBootstrap) + mux.HandleFunc("/v1/launcher/bootstrap", s.handleLauncherBootstrap) + mux.HandleFunc("/v1/release", s.handleRelease) + mux.HandleFunc("/v1/resources", s.handleResources) + mux.HandleFunc("/v1/server-info", s.handleServerInfoDebug) + mux.HandleFunc("/api/launcher/game/config", s.handleLauncherGameConfig) + mux.HandleFunc("/api/launcher/game/config/json", s.handleLauncherGameConfigJSON) + mux.HandleFunc("/api/launcher/advanced/game/download/cdn", s.handleLauncherCdnConfig) + mux.HandleFunc(launcherHostPath("/api/launcher/game/config"), s.handleLauncherGameConfig) + mux.HandleFunc(launcherHostPath("/api/launcher/game/config/json"), s.handleLauncherGameConfigJSON) + mux.HandleFunc(launcherHostPath("/api/launcher/advanced/game/download/cdn"), s.handleLauncherCdnConfig) + mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap) + mux.HandleFunc("/openapi.yaml", s.handleOpenAPI) + mux.HandleFunc("/admin/", s.handleAdminIndex) + mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN) + mux.HandleFunc("/"+ClientPatchHost+"/", s.serveCDN) + // Catch-all for other official host prefixes and 404. + mux.HandleFunc("/", s.handleRoot) + return s.wrapHandler(mux) +} + +// Refresh rebuilds the release index via RPC (and optional resource-root override). +func (s *Server) Refresh(ctx context.Context) error { + started := s.beginRefresh() + result, err := DiscoverAndIndex(ctx, s.backend, s.cfg.ResourceRoot) + if err != nil { + s.finishRefresh(started, err, nil) + return err + } + s.mu.Lock() + s.meta = *result + s.idx = result.Index + s.finishRefreshLocked(started, nil, result.Warnings) + s.mu.Unlock() + for _, w := range result.Warnings { + s.logger.Printf("bat-api discover warning: %s", w) + } + return nil +} + +func (s *Server) index() *ReleaseIndex { + s.mu.RLock() + defer s.mu.RUnlock() + return s.idx +} + +func (s *Server) discoverMeta() DiscoverResult { + s.mu.RLock() + defer s.mu.RUnlock() + return s.meta +} + +func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + writeNoStoreJSON(w, http.StatusOK, RootResponse{ + Service: "bat-api", + Role: "resource_bootstrap_and_distribution", + Note: "resource auto-pull is owned by Rust bat; this service bootstraps and distributes published resources", + Endpoints: []string{ + "/healthz", + "/readyz", + "/v1/bootstrap", + "/v1/launcher/bootstrap", + "/v1/release", + "/v1/resources", + "/v1/server-info", + "/api/launcher/game/config", + "/api/launcher/game/config/json", + "/api/launcher/advanced/game/download/cdn", + "/" + LauncherAPIHost + "/api/launcher/game/config", + "/" + LauncherAPIHost + "/api/launcher/game/config/json", + "/" + LauncherAPIHost + "/api/launcher/advanced/game/download/cdn", + "/" + ClientPatchHost + "/...", + "/" + ServerInfoHost + "/...", + "/openapi.yaml", + "/admin/", + }, + }) + return + } + // Attempt CDN for known hosts not registered above. + if strings.HasPrefix(r.URL.Path, "/"+ClientPatchHost+"/") || + strings.HasPrefix(r.URL.Path, "/"+ServerInfoHost+"/") { + if strings.HasPrefix(r.URL.Path, "/"+ServerInfoHost+"/") { + s.handleServerInfoCDN(w, r) + return + } + s.serveCDN(w, r) + return + } + http.NotFound(w, r) +} + +func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + meta := s.discoverMeta() + sum := ReleaseSummary{} + if idx := s.index(); idx != nil { + sum = idx.Summary() + } + publicBase := strings.TrimRight(s.cfg.PublicBaseURL, "/") + addressablesRoot := "" + if sum.Snapshot != nil && sum.Snapshot.AddressablesRoot != "" { + if rewritten, ok := rewriteAddressablesRoot(sum.Snapshot.AddressablesRoot, publicBase); ok { + addressablesRoot = rewritten + } + } + + status := http.StatusOK + if !sum.Ready { + status = http.StatusServiceUnavailable + } + writeNoStoreJSON(w, status, BootstrapResponse{ + Service: "bat-api", + Ready: sum.Ready, + Bat: BootstrapBat{ + Role: "sync_daemon_and_release_producer", + Socket: s.cfg.SocketPath, + RPCAvailable: meta.RPCAvailable || sum.RPCAvailable, + DoctorHealthy: sum.DoctorHealthy, + }, + BatAPI: BootstrapAPI{ + Role: "resource_bootstrap_and_distribution", + PublicBase: publicBase, + RefreshIntervalSeconds: int64(s.cfg.RefreshInterval.Seconds()), + Refresh: s.refreshSnapshot(), + }, + Resource: BootstrapResource{ + Release: sum.Snapshot, + ResourceRoot: sum.ResourceRoot, + Source: sum.Source, + ManifestVersion: sum.ManifestVersion, + EntryCount: sum.EntryCount, + PresentCount: sum.PresentCount, + MissingCount: sum.MissingCount, + ServerInfoURL: publicBase + "/" + ServerInfoHost + "/server-info.json", + ClientPatchBaseURL: publicBase + "/" + ClientPatchHost, + AddressablesCatalogURLRoot: addressablesRoot, + }, + Policy: BootstrapPolicy{ + PullOwner: "rust_bat", + ResourceRootDiscovery: "bat_sock_rpc", + Deployment: "co_located_with_rust_bat_or_shared_filesystem", + ResourceRootOverride: s.cfg.ResourceRoot != "", + ServesOnlyPublishedRelease: true, + WritesReleaseState: false, + FullGameBusinessAPI: false, + }, + Endpoints: []string{ + "/v1/launcher/bootstrap", + "/api/launcher/game/config", + "/api/launcher/game/config/json", + "/api/launcher/advanced/game/download/cdn", + "/v1/server-info", + "/" + ServerInfoHost + "/server-info.json", + "/" + ClientPatchHost + "/...", + }, + Warnings: meta.Warnings, + }) +} + +func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + meta := s.discoverMeta() + sum := ReleaseSummary{} + if idx := s.index(); idx != nil { + sum = idx.Summary() + } + writeNoStoreJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "service": "bat-api", + "listen": s.cfg.Listen, + "socket": s.cfg.SocketPath, + "public_base": s.cfg.PublicBaseURL, + "rpc_available": meta.RPCAvailable || sum.RPCAvailable, + "doctor_healthy": sum.DoctorHealthy, + "refresh_interval_seconds": int64(s.cfg.RefreshInterval.Seconds()), + "refresh": s.refreshSnapshot(), + "resource_root": sum.ResourceRoot, + "resource_root_override_configured": s.cfg.ResourceRoot != "", + "ready": sum.Ready, + "entry_count": sum.EntryCount, + "present_count": sum.PresentCount, + "missing_count": sum.MissingCount, + "source": sum.Source, + "warnings": meta.Warnings, + // Database/redis are reserved config surface for a normal API process. + "database_configured": s.cfg.DatabaseURL != "", + "redis_configured": s.cfg.RedisURL != "", + }) +} + +func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + meta := s.discoverMeta() + sum := ReleaseSummary{} + if idx := s.index(); idx != nil { + sum = idx.Summary() + } + status := http.StatusOK + if !sum.Ready { + status = http.StatusServiceUnavailable + } + body := map[string]any{ + "ready": sum.Ready, + "service": "bat-api", + "rpc_available": meta.RPCAvailable || sum.RPCAvailable, + "resource_root": sum.ResourceRoot, + "entry_count": sum.EntryCount, + "present_count": sum.PresentCount, + "missing_count": sum.MissingCount, + "source": sum.Source, + "refresh": s.refreshSnapshot(), + } + if r.Method == http.MethodHead { + w.WriteHeader(status) + return + } + writeNoStoreJSON(w, status, body) +} + +func (s *Server) handleRelease(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + idx := s.index() + if idx == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "release_not_ready", "release index not ready") + return + } + writeNoStoreJSON(w, http.StatusOK, idx.Summary()) +} + +func (s *Server) handleResources(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + idx := s.index() + if idx == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "release_not_ready", "index not ready") + return + } + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 100 + } + if limit > s.cfg.MaxResourcePageLimit { + limit = s.cfg.MaxResourcePageLimit + } + items, total := idx.List(offset, limit) + writeNoStoreJSON(w, http.StatusOK, ResourceListResponse{ + Total: total, + Offset: offset, + Limit: limit, + Items: items, + }) +} + +func (s *Server) handleServerInfoDebug(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + s.writeServerInfo(w, r, "") +} + +func (s *Server) handleServerInfoCDN(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + name := strings.TrimPrefix(r.URL.Path, "/"+ServerInfoHost+"/") + name = strings.TrimPrefix(name, "/") + s.writeServerInfo(w, r, name) +} + +func (s *Server) writeServerInfo(w http.ResponseWriter, r *http.Request, name string) { + raw, err := LoadServerInfoBytes(s.cfg, s.index(), name) + if err != nil { + writeErrorJSON(w, http.StatusNotFound, "server_info_not_found", err.Error()) + return + } + rewritten, err := RewriteServerInfoAddressables(raw, s.cfg.PublicBaseURL) + if err != nil { + writeErrorJSON(w, http.StatusInternalServerError, "server_info_invalid", err.Error()) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Content-Length", strconv.Itoa(len(rewritten))) + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(rewritten) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + data, err := json.Marshal(body) + if err != nil { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + fallback, _ := json.Marshal(ErrorResponse{ + Error: ErrorBody{Code: "internal_error", Message: err.Error(), Status: http.StatusInternalServerError}, + }) + _, _ = w.Write(fallback) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _, _ = w.Write(data) +} + +func (s *Server) beginRefresh() time.Time { + now := time.Now() + s.mu.Lock() + s.refreshInProgress = true + s.lastRefreshStart = now + s.mu.Unlock() + return now +} + +func (s *Server) finishRefresh(started time.Time, err error, warnings []string) { + s.mu.Lock() + defer s.mu.Unlock() + s.finishRefreshLocked(started, err, warnings) +} + +func (s *Server) finishRefreshLocked(started time.Time, err error, warnings []string) { + now := time.Now() + s.refreshInProgress = false + s.lastRefreshFinish = now + s.lastRefreshDur = now.Sub(started) + s.lastRefreshWarns = append([]string(nil), warnings...) + if err != nil { + s.lastRefreshError = err.Error() + return + } + s.lastRefreshError = "" + s.lastRefreshOK = now +} + +func (s *Server) refreshSnapshot() RefreshDiagnostics { + s.mu.RLock() + defer s.mu.RUnlock() + warnings := append([]string(nil), s.lastRefreshWarns...) + return RefreshDiagnostics{ + InProgress: s.refreshInProgress, + LastAttemptUnixSeconds: unixPtr(s.lastRefreshStart), + LastFinishedUnixSeconds: unixPtr(s.lastRefreshFinish), + LastSuccessUnixSeconds: unixPtr(s.lastRefreshOK), + LastDurationMilliseconds: s.lastRefreshDur.Milliseconds(), + LastError: s.lastRefreshError, + LastWarningCount: len(warnings), + LastWarnings: warnings, + RefreshIntervalSeconds: int64(s.cfg.RefreshInterval.Seconds()), + ResourceRootOverrideActive: s.cfg.ResourceRoot != "", + } +} + +func unixPtr(t time.Time) *int64 { + if t.IsZero() { + return nil + } + v := t.Unix() + return &v +} + +// StartRefreshLoop keeps the in-memory release index aligned with the Rust bat daemon. +func (s *Server) StartRefreshLoop(ctx context.Context) { + if s.cfg.RefreshInterval <= 0 { + s.logger.Printf("bat-api periodic release discovery disabled") + return + } + go func() { + ticker := time.NewTicker(s.cfg.RefreshInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + refreshCtx, cancel := context.WithTimeout(ctx, s.cfg.RPCTimeout+5*time.Second) + if err := s.Refresh(refreshCtx); err != nil { + s.logger.Printf("periodic discover failed: %v", err) + } + cancel() + } + } + }() +} + +// ListenAndServe starts the HTTP server until ctx is cancelled. +func (s *Server) ListenAndServe(ctx context.Context) error { + httpServer := &http.Server{ + Addr: s.cfg.Listen, + Handler: s.Handler(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, + } + errCh := make(chan error, 1) + go func() { + s.logger.Printf("bat-api listening on %s (socket=%s public=%s)", s.cfg.Listen, s.cfg.SocketPath, s.cfg.PublicBaseURL) + errCh <- httpServer.ListenAndServe() + }() + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = httpServer.Shutdown(shutdownCtx) + return ctx.Err() + case err := <-errCh: + if err == http.ErrServerClosed { + return nil + } + return err + } +} diff --git a/internal/api/serverinfo.go b/internal/api/serverinfo.go new file mode 100644 index 0000000..2183747 --- /dev/null +++ b/internal/api/serverinfo.go @@ -0,0 +1,125 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" +) + +// RewriteServerInfoAddressables rewrites AddressablesCatalogUrlRoot values so +// clients fetch resources from publicBaseURL while leaving business API URLs +// untouched. +func RewriteServerInfoAddressables(raw []byte, publicBaseURL string) ([]byte, error) { + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("parse server-info: %w", err) + } + groups, _ := doc["ConnectionGroups"].([]any) + for _, g := range groups { + group, ok := g.(map[string]any) + if !ok { + continue + } + rewriteGroupAddressables(group, publicBaseURL) + if overrides, ok := group["OverrideConnectionGroups"].([]any); ok { + for _, o := range overrides { + if og, ok := o.(map[string]any); ok { + rewriteGroupAddressables(og, publicBaseURL) + } + } + } + } + return json.Marshal(doc) +} + +func rewriteGroupAddressables(group map[string]any, publicBaseURL string) { + raw, _ := group["AddressablesCatalogUrlRoot"].(string) + if raw == "" { + return + } + if rewritten, ok := rewriteAddressablesRoot(raw, publicBaseURL); ok { + group["AddressablesCatalogUrlRoot"] = rewritten + } +} + +func rewriteAddressablesRoot(original, publicBaseURL string) (string, bool) { + original = strings.TrimRight(original, "/") + publicBaseURL = strings.TrimRight(publicBaseURL, "/") + // Already rewritten to this base. + if strings.HasPrefix(original, publicBaseURL+"/"+ClientPatchHost) || + strings.HasPrefix(original, publicBaseURL+"/"+ServerInfoHost) { + return original, true + } + u, err := url.Parse(original) + if err != nil || u.Host == "" { + return "", false + } + // Keep host/path so CDN path mapping stays 1:1 with destination_for_url. + path := strings.TrimPrefix(u.Path, "/") + if path == "" { + return publicBaseURL + "/" + u.Host, true + } + return publicBaseURL + "/" + u.Host + "/" + path, true +} + +// LoadServerInfoBytes loads server-info from an explicit file, or from the +// release tree under yostar-serverinfo host, or synthesizes a minimal document +// from the release snapshot addressables root. +func LoadServerInfoBytes(cfg Config, idx *ReleaseIndex, requestName string) ([]byte, error) { + if cfg.ServerInfoFile != "" { + return os.ReadFile(cfg.ServerInfoFile) + } + if idx != nil && idx.ResourceRoot != "" { + name := requestName + if name == "" { + name = "server-info.json" + } + // Official path layout. + candidates := []string{ + filepath.Join(idx.ResourceRoot, ServerInfoHost, name), + filepath.Join(idx.ResourceRoot, ServerInfoHost, requestName), + } + for _, c := range candidates { + if c == "" { + continue + } + if data, err := os.ReadFile(c); err == nil { + return data, nil + } + } + // Any single json under server-info host. + dir := filepath.Join(idx.ResourceRoot, ServerInfoHost) + if entries, err := os.ReadDir(dir); err == nil { + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + return os.ReadFile(filepath.Join(dir, e.Name())) + } + } + } + if idx != nil && idx.Snapshot != nil && idx.Snapshot.AddressablesRoot != "" { + doc := map[string]any{ + "ConnectionGroups": []any{ + map[string]any{ + "Name": defaultString(idx.Snapshot.ConnectionGroupName, "Prod"), + "AddressablesCatalogUrlRoot": idx.Snapshot.AddressablesRoot, + "BundleVersion": idx.Snapshot.BundleVersion, + "IsLivePublished": true, + }, + }, + } + return json.Marshal(doc) + } + return nil, fmt.Errorf("server-info source not found") +} + +func defaultString(v, fallback string) string { + if v == "" { + return fallback + } + return v +} diff --git a/internal/api/testdata/release/official-download-manifest.json b/internal/api/testdata/release/official-download-manifest.json new file mode 100644 index 0000000..d2b5bc1 --- /dev/null +++ b/internal/api/testdata/release/official-download-manifest.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "entries": { + "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes": { + "url": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes", + "destination": "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes", + "bytes": 21, + "blake3": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash": { + "url": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash", + "destination": "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash", + "bytes": 10, + "blake3": "0000000000000000000000000000000000000000000000000000000000000000" + } + } +} diff --git a/internal/api/testdata/release/official-sync-snapshot.json b/internal/api/testdata/release/official-sync-snapshot.json new file mode 100644 index 0000000..574f97c --- /dev/null +++ b/internal/api/testdata/release/official-sync-snapshot.json @@ -0,0 +1,25 @@ +{ + "snapshot_version": 2, + "connection_group_name": "Prod", + "app_version": "1.70.0", + "bundle_version": "s8tloc7lo3", + "addressables_root": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture", + "launcher_metadata": { + "launcher_version": "1.7.2", + "game_latest_version": "1.70.0", + "game_latest_file_path": "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip", + "game_start_exe_name": "xldr_BlueArchiveOnline_JP_loader_x64", + "game_start_params": [ + "BlueArchive.exe" + ], + "manifest_url": "https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/manifest.json", + "manifest_source": "BlueArchive_JP-1.70.436321-game", + "manifest_file_count": 2 + }, + "game_main_config_bootstrap": { + "server_info_data_url": "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json", + "default_connection_group": "Prod" + }, + "endpoints": [], + "endpoint_markers": [] +} diff --git a/internal/api/testdata/release/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes b/internal/api/testdata/release/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes new file mode 100644 index 0000000..7484cf5 --- /dev/null +++ b/internal/api/testdata/release/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes @@ -0,0 +1 @@ +TABLE_CATALOG_FIXTURE \ No newline at end of file diff --git a/internal/api/testdata/release/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash b/internal/api/testdata/release/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash new file mode 100644 index 0000000..6a537b5 --- /dev/null +++ b/internal/api/testdata/release/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash @@ -0,0 +1 @@ +1234567890 \ No newline at end of file diff --git a/internal/api/testdata/release/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json b/internal/api/testdata/release/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json new file mode 100644 index 0000000..79db0f2 --- /dev/null +++ b/internal/api/testdata/release/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json @@ -0,0 +1,11 @@ +{ + "ConnectionGroups": [ + { + "Name": "Prod", + "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture", + "BundleVersion": "s8tloc7lo3", + "IsLivePublished": true, + "ApiUrl": "https://prod-game.example.invalid/" + } + ] +} diff --git a/internal/api/testdata/server-info.json b/internal/api/testdata/server-info.json new file mode 100644 index 0000000..79db0f2 --- /dev/null +++ b/internal/api/testdata/server-info.json @@ -0,0 +1,11 @@ +{ + "ConnectionGroups": [ + { + "Name": "Prod", + "AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture", + "BundleVersion": "s8tloc7lo3", + "IsLivePublished": true, + "ApiUrl": "https://prod-game.example.invalid/" + } + ] +} diff --git a/internal/config/README.md b/internal/config/README.md new file mode 100644 index 0000000..0b1d102 --- /dev/null +++ b/internal/config/README.md @@ -0,0 +1,4 @@ +# Reserved empty directory + +This path is a monorepo placeholder and is **not implemented**. +Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`. diff --git a/internal/downloader/README.md b/internal/downloader/README.md new file mode 100644 index 0000000..0b1d102 --- /dev/null +++ b/internal/downloader/README.md @@ -0,0 +1,4 @@ +# Reserved empty directory + +This path is a monorepo placeholder and is **not implemented**. +Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`. diff --git a/internal/extractor/README.md b/internal/extractor/README.md new file mode 100644 index 0000000..0b1d102 --- /dev/null +++ b/internal/extractor/README.md @@ -0,0 +1,4 @@ +# Reserved empty directory + +This path is a monorepo placeholder and is **not implemented**. +Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`. diff --git a/internal/manifest/README.md b/internal/manifest/README.md new file mode 100644 index 0000000..0b1d102 --- /dev/null +++ b/internal/manifest/README.md @@ -0,0 +1,4 @@ +# Reserved empty directory + +This path is a monorepo placeholder and is **not implemented**. +Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`. diff --git a/internal/storage/README.md b/internal/storage/README.md new file mode 100644 index 0000000..0b1d102 --- /dev/null +++ b/internal/storage/README.md @@ -0,0 +1,4 @@ +# Reserved empty directory + +This path is a monorepo placeholder and is **not implemented**. +Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`. diff --git a/pkg/README.md b/pkg/README.md new file mode 100644 index 0000000..0f09925 --- /dev/null +++ b/pkg/README.md @@ -0,0 +1,3 @@ +# Reserved empty directory + +Placeholder only. **Not implemented.** See `docs/reports/GO_STATUS.md`. diff --git a/pkg/cas/README.md b/pkg/cas/README.md new file mode 100644 index 0000000..0b1d102 --- /dev/null +++ b/pkg/cas/README.md @@ -0,0 +1,4 @@ +# Reserved empty directory + +This path is a monorepo placeholder and is **not implemented**. +Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`. diff --git a/pkg/translator/README.md b/pkg/translator/README.md new file mode 100644 index 0000000..0b1d102 --- /dev/null +++ b/pkg/translator/README.md @@ -0,0 +1,4 @@ +# Reserved empty directory + +This path is a monorepo placeholder and is **not implemented**. +Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`. diff --git a/pkg/types/README.md b/pkg/types/README.md new file mode 100644 index 0000000..0b1d102 --- /dev/null +++ b/pkg/types/README.md @@ -0,0 +1,4 @@ +# Reserved empty directory + +This path is a monorepo placeholder and is **not implemented**. +Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`. diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..0b1d102 --- /dev/null +++ b/web/README.md @@ -0,0 +1,4 @@ +# Reserved empty directory + +This path is a monorepo placeholder and is **not implemented**. +Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`. diff --git a/web/admin/README.md b/web/admin/README.md new file mode 100644 index 0000000..e048ab3 --- /dev/null +++ b/web/admin/README.md @@ -0,0 +1,10 @@ +# bat-api Admin Panel + +This directory is reserved for the future player-facing `bat-api` management +panel. The current backend exposes a protected placeholder at `GET /admin/` +that returns JSON links for health, readiness, bootstrap, release, resources, +and OpenAPI. + +The production panel must reuse the same HTTP authentication, rate limiting, +access logging, reverse-proxy handling, and no-store dynamic response policy as +the resource API. Static frontend assets are not implemented yet. diff --git a/web/shared/README.md b/web/shared/README.md new file mode 100644 index 0000000..0f09925 --- /dev/null +++ b/web/shared/README.md @@ -0,0 +1,3 @@ +# Reserved empty directory + +Placeholder only. **Not implemented.** See `docs/reports/GO_STATUS.md`.