mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +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:
@@ -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`.
|
||||
Reference in New Issue
Block a user