mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:17:53 +08:00
361 lines
13 KiB
Go
361 lines
13 KiB
Go
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 {
|
|
Status string `json:"status,omitempty"`
|
|
StatusCode string `json:"status_code,omitempty"`
|
|
DistributionStatus string `json:"distribution_status,omitempty"`
|
|
DistributionStatusCode string `json:"distribution_status_code,omitempty"`
|
|
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"`
|
|
}
|
|
|
|
// DistributionHealth is the release-level authorization used by read paths.
|
|
//
|
|
// In RPC mode these fields are copied from Rust's current official
|
|
// attestation. The local manifest checks only establish that this process has
|
|
// a complete, safe read snapshot; they do not replace Rust's verifier.
|
|
type DistributionHealth struct {
|
|
Available bool `json:"available"`
|
|
Ready bool `json:"ready"`
|
|
Source string `json:"source"`
|
|
Channel string `json:"channel,omitempty"`
|
|
ReleaseID string `json:"release_id,omitempty"`
|
|
ResourceRoot string `json:"resource_root,omitempty"`
|
|
PublicationIdentity string `json:"publication_identity,omitempty"`
|
|
MappingIdentity string `json:"mapping_identity,omitempty"`
|
|
ManifestIdentity string `json:"manifest_identity,omitempty"`
|
|
EntryCount int `json:"entry_count,omitempty"`
|
|
VerificationGeneration uint64 `json:"verification_generation,omitempty"`
|
|
VerifiedAt *uint64 `json:"verified_at,omitempty"`
|
|
MaxAgeSeconds uint64 `json:"max_age_seconds,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
StatusCode string `json:"status_code,omitempty"`
|
|
IntegrityStatus string `json:"integrity_status,omitempty"`
|
|
Diagnostics []string `json:"diagnostics,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"`
|
|
Distribution DistributionHealth `json:"distribution"`
|
|
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"`
|
|
Distribution DistributionHealth `json:"distribution"`
|
|
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++
|
|
}
|
|
}
|
|
distribution := idx.Distribution
|
|
distribution.Diagnostics = append([]string(nil), idx.Distribution.Diagnostics...)
|
|
localComplete := idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries)
|
|
// Hand-built indexes are retained for compatibility with local tests and
|
|
// diagnostics. Any index explicitly sourced from RPC must carry the Rust
|
|
// health fact; an RPC index without it is never considered distributable.
|
|
if distribution.Source == "" {
|
|
distribution.Ready = localComplete && idx.Source != "rpc" && idx.Source != "rpc+local_manifest"
|
|
} else {
|
|
distribution.Ready = distribution.Available &&
|
|
distribution.Ready &&
|
|
localComplete &&
|
|
(idx.Source != "rpc" ||
|
|
(distribution.ManifestIdentity != "" &&
|
|
distribution.EntryCount == len(idx.Entries)))
|
|
}
|
|
return ReleaseSummary{
|
|
ResourceRoot: idx.ResourceRoot,
|
|
Source: idx.Source,
|
|
RPCAvailable: idx.RPCAvailable,
|
|
DoctorHealthy: idx.DoctorHealthy,
|
|
Distribution: distribution,
|
|
Snapshot: idx.Snapshot,
|
|
ManifestVersion: idx.ManifestVersion,
|
|
EntryCount: len(idx.Entries),
|
|
PresentCount: present,
|
|
MissingCount: len(idx.MissingOnDisk),
|
|
// A release is distributable only when Rust authorizes it and every
|
|
// entry in this process's read snapshot is usable. Serving a partial
|
|
// release can leave clients with an unrecoverable download set.
|
|
Ready: distribution.Ready,
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
distribution DistributionHealth,
|
|
) (*ReleaseIndex, error) {
|
|
rootAbs, err := filepath.Abs(resourceRoot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
idx := &ReleaseIndex{
|
|
ResourceRoot: rootAbs,
|
|
Source: source,
|
|
RPCAvailable: rpcAvailable,
|
|
DoctorHealthy: doctorHealthy,
|
|
Distribution: distribution,
|
|
Snapshot: snapshotWithDistributionHealth(snapshot, distribution),
|
|
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,
|
|
DistributionHealth{
|
|
Available: true,
|
|
Ready: true,
|
|
Source: "resource_root_override",
|
|
Status: "ready",
|
|
StatusCode: "resource_root.ready",
|
|
},
|
|
)
|
|
}
|
|
|
|
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)
|
|
}
|