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:
2026-07-31 00:41:48 +08:00
parent 2079c6a307
commit 3f78f8f880
43 changed files with 4427 additions and 23 deletions
+29
View File
@@ -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)
}
+962
View File
@@ -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)
}
}
+87
View File
@@ -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"
}
+372
View File
@@ -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
`
+208
View File
@@ -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)
}
+259
View File
@@ -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,
},
})
}
+161
View File
@@ -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))
}
+93
View File
@@ -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
}
+292
View File
@@ -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)
}
+174
View File
@@ -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)
}
+269
View File
@@ -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
}
+483
View File
@@ -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
}
}
+125
View File
@@ -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": []
}
@@ -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
View File
@@ -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/"
}
]
}
+4
View File
@@ -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`.
+4
View File
@@ -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`.
+4
View File
@@ -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`.
+4
View File
@@ -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`.
+4
View File
@@ -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`.