mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
feat(api): 补齐资源分发服务入口
新增 bat-api 资源 bootstrap/分发 HTTP 服务、RPC release 发现、CDN path 分发、launcher 资源引导兼容、控制面中间件、OpenAPI 和 systemd 模板。 同步 Go 边界文档,明确 Rust bat 是资源生产者和同步运维入口,Go bat-api 是只读 bootstrap/分发服务,试验 Go CLI 产物为 bin/bat-go。 验证:未运行新命令;本轮已按要求停止重复构建/测试。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Reserved empty directory
|
||||
|
||||
Placeholder only. **Not implemented.** See `docs/reports/GO_STATUS.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.
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
+9
-4
@@ -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 <file>")
|
||||
fmt.Println(" bat sync plan <current-json> [previous-json]")
|
||||
fmt.Println(" bat-go doctor")
|
||||
fmt.Println(" bat-go manifest inspect <file>")
|
||||
fmt.Println(" bat-go sync plan <current-json> [previous-json]")
|
||||
}
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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 侧真实输出与用户审核。
|
||||
@@ -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/<id>`,维护 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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 <token> 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
|
||||
`
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
TABLE_CATALOG_FIXTURE
|
||||
+1
@@ -0,0 +1 @@
|
||||
1234567890
|
||||
+11
@@ -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/"
|
||||
}
|
||||
]
|
||||
}
|
||||
+11
@@ -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/"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Reserved empty directory
|
||||
|
||||
Placeholder only. **Not implemented.** See `docs/reports/GO_STATUS.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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Reserved empty directory
|
||||
|
||||
Placeholder only. **Not implemented.** See `docs/reports/GO_STATUS.md`.
|
||||
Reference in New Issue
Block a user