mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-19 13:36:42 +08:00
392 lines
9.0 KiB
Go
392 lines
9.0 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"time"
|
|
|
|
"github.com/pelletier/go-toml/v2"
|
|
)
|
|
|
|
// ConfigTOMLTemplate is the only template generated by bat-api. Rust bat
|
|
// accepts the same file and ignores the [api] section.
|
|
const ConfigTOMLTemplate = `# BlueArchive Toolkit shared application configuration.
|
|
# Priority: CLI flags > process environment > config.toml > built-in defaults.
|
|
# This file is the application configuration. The application never reads .env.
|
|
#
|
|
# Rust bat consumes [runtime], [resource], [localized], [repository], [network]
|
|
# and [translation.worker]. Go bat-api consumes [api]. Each binary ignores the
|
|
# other application's section.
|
|
|
|
[runtime]
|
|
state_dir = '/tmp/bat-pid'
|
|
interval_seconds = 3600
|
|
error_retry_seconds = 60
|
|
quiet_up_to_date = false
|
|
output_format = 'human'
|
|
banner = true
|
|
progress = true
|
|
tail_lines = 200
|
|
|
|
[resource]
|
|
output_root = './bat-resources'
|
|
auto_discover = true
|
|
app_version = ''
|
|
connection_group = ''
|
|
launcher_version = '1.7.2'
|
|
platforms = ['windows', 'android']
|
|
snapshot_path = ''
|
|
dry_run = false
|
|
plan = false
|
|
force = false
|
|
audit_local = true
|
|
repair = true
|
|
|
|
[resource.server_info]
|
|
kind = 'none'
|
|
value = ''
|
|
|
|
[localized]
|
|
output_root = './bat-localized'
|
|
|
|
[repository]
|
|
import_repository = false
|
|
import_cas_root = ''
|
|
import_resource_repository_path = ''
|
|
|
|
[network]
|
|
curl_command = 'curl'
|
|
proxy = 'auto'
|
|
unzip_command = 'unzip'
|
|
zip_command = 'zip'
|
|
download_concurrency = 8
|
|
|
|
[translation.worker]
|
|
provider = 'mock'
|
|
fixture = ''
|
|
translation_memory_path = ''
|
|
glossary_path = ''
|
|
concurrency = 8
|
|
max_attempts = 3
|
|
lease_seconds = 300
|
|
retry_backoff_seconds = 5
|
|
max_tasks = ''
|
|
worker_id = ''
|
|
|
|
[api]
|
|
listen = ':18080'
|
|
public_base_url = 'http://127.0.0.1:18080'
|
|
state_dir = '/tmp/bat-pid'
|
|
socket_path = ''
|
|
resource_root = ''
|
|
server_info_file = ''
|
|
require_indexed = true
|
|
verify_size = true
|
|
rpc_timeout = '30s'
|
|
refresh_interval = '1m'
|
|
auth_token = ''
|
|
auth_query_param = 'bat_token'
|
|
auth_exempt_paths = []
|
|
trust_proxy_headers = false
|
|
access_log = false
|
|
rate_limit_rps = 0
|
|
rate_limit_burst = 0
|
|
max_resource_page_limit = 1000
|
|
|
|
# Reserved for a future API persistence layer.
|
|
database_url = ''
|
|
database_password = ''
|
|
redis_url = ''
|
|
redis_password = ''
|
|
`
|
|
|
|
// LoadConfigFromCurrentExe loads config.toml next to the running binary.
|
|
func LoadConfigFromCurrentExe(cfg *Config) error {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return fmt.Errorf("locate current executable: %w", err)
|
|
}
|
|
return LoadConfigFromBinaryDir(filepath.Dir(exe), cfg)
|
|
}
|
|
|
|
// LoadConfigFromBinaryDir loads only config.toml from binaryDir. Missing
|
|
// config.toml causes config.toml.example to be created when possible; the
|
|
// example is never parsed as the active configuration.
|
|
func LoadConfigFromBinaryDir(binaryDir string, cfg *Config) error {
|
|
if cfg == nil {
|
|
return fmt.Errorf("config must not be nil")
|
|
}
|
|
configPath := filepath.Join(binaryDir, ConfigFileName)
|
|
examplePath := filepath.Join(binaryDir, ConfigExampleName)
|
|
info, err := os.Lstat(configPath)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
return fmt.Errorf("stat %s: %w", configPath, err)
|
|
}
|
|
ensureConfigExample(examplePath)
|
|
return nil
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
return fmt.Errorf("%s must be a regular file", configPath)
|
|
}
|
|
if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
|
|
return fmt.Errorf("%s permissions must be 0600 or stricter (current %03o)", configPath, info.Mode().Perm())
|
|
}
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read %s: %w", configPath, err)
|
|
}
|
|
if err := applyAPITOML(data, cfg); err != nil {
|
|
return fmt.Errorf("parse %s: %w", configPath, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureConfigExample(path string) {
|
|
if _, err := os.Lstat(path); err == nil || !os.IsNotExist(err) {
|
|
return
|
|
}
|
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if _, err := file.WriteString(ConfigTOMLTemplate); err != nil {
|
|
_ = file.Close()
|
|
return
|
|
}
|
|
_ = file.Close()
|
|
}
|
|
|
|
func applyAPITOML(data []byte, cfg *Config) error {
|
|
var document map[string]any
|
|
if err := toml.Unmarshal(data, &document); err != nil {
|
|
return err
|
|
}
|
|
rawAPI, exists := document["api"]
|
|
if !exists {
|
|
return nil
|
|
}
|
|
apiValues, ok := rawAPI.(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("[api] must be a table")
|
|
}
|
|
for key, value := range apiValues {
|
|
if err := applyAPIValue(cfg, key, value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyAPIValue(cfg *Config, key string, raw any) error {
|
|
field := fmt.Sprintf("[api].%s", key)
|
|
switch key {
|
|
case "listen":
|
|
value, err := tomlString(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.Listen = value
|
|
case "public_base_url":
|
|
value, err := tomlString(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.PublicBaseURL = value
|
|
case "state_dir":
|
|
value, err := tomlString(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.StateDir = value
|
|
case "socket_path":
|
|
value, err := tomlString(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.SocketPath = value
|
|
case "resource_root":
|
|
value, err := tomlString(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.ResourceRoot = value
|
|
case "server_info_file":
|
|
value, err := tomlString(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.ServerInfoFile = value
|
|
case "require_indexed":
|
|
value, err := tomlBool(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.RequireIndexed = value
|
|
case "verify_size":
|
|
value, err := tomlBool(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.VerifySize = value
|
|
case "rpc_timeout":
|
|
value, err := tomlDuration(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.RPCTimeout = value
|
|
case "refresh_interval":
|
|
value, err := tomlDuration(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.RefreshInterval = value
|
|
case "auth_token":
|
|
value, err := tomlString(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.AuthToken = value
|
|
case "auth_query_param":
|
|
value, err := tomlString(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.AuthQueryParam = value
|
|
case "auth_exempt_paths":
|
|
value, err := tomlStringArray(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.AuthExemptPaths = value
|
|
case "trust_proxy_headers":
|
|
value, err := tomlBool(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.TrustProxyHeaders = value
|
|
case "access_log":
|
|
value, err := tomlBool(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.AccessLog = value
|
|
case "rate_limit_rps":
|
|
value, err := tomlNonNegativeNumber(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.RateLimitRPS = value
|
|
case "rate_limit_burst":
|
|
value, err := tomlNonNegativeInt(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.RateLimitBurst = value
|
|
case "max_resource_page_limit":
|
|
value, err := tomlNonNegativeInt(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.MaxResourcePageLimit = value
|
|
case "database_url":
|
|
value, err := tomlString(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.DatabaseURL = value
|
|
case "database_password":
|
|
value, err := tomlString(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.DatabasePassword = value
|
|
case "redis_url":
|
|
value, err := tomlString(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.RedisURL = value
|
|
case "redis_password":
|
|
value, err := tomlString(raw, field)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.RedisPassword = value
|
|
default:
|
|
return fmt.Errorf("unsupported [api] key %q", key)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func tomlString(raw any, field string) (string, error) {
|
|
value, ok := raw.(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("%s must be a string (got %T)", field, raw)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func tomlBool(raw any, field string) (bool, error) {
|
|
value, ok := raw.(bool)
|
|
if !ok {
|
|
return false, fmt.Errorf("%s must be a boolean (got %T)", field, raw)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func tomlDuration(raw any, field string) (time.Duration, error) {
|
|
value, err := tomlString(raw, field)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
duration, err := time.ParseDuration(value)
|
|
if err != nil || duration < 0 {
|
|
return 0, fmt.Errorf("%s must be a non-negative duration", field)
|
|
}
|
|
return duration, nil
|
|
}
|
|
|
|
func tomlNonNegativeNumber(raw any, field string) (float64, error) {
|
|
var value float64
|
|
switch typed := raw.(type) {
|
|
case int64:
|
|
value = float64(typed)
|
|
case float64:
|
|
value = typed
|
|
default:
|
|
return 0, fmt.Errorf("%s must be a non-negative number (got %T)", field, raw)
|
|
}
|
|
if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
|
return 0, fmt.Errorf("%s must be a non-negative number", field)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func tomlNonNegativeInt(raw any, field string) (int, error) {
|
|
value, ok := raw.(int64)
|
|
if !ok || value < 0 || int64(int(value)) != value {
|
|
return 0, fmt.Errorf("%s must be a non-negative integer (got %T)", field, raw)
|
|
}
|
|
return int(value), nil
|
|
}
|
|
|
|
func tomlStringArray(raw any, field string) ([]string, error) {
|
|
values, ok := raw.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s must be an array of strings (got %T)", field, raw)
|
|
}
|
|
items := make([]string, len(values))
|
|
for index, rawItem := range values {
|
|
item, ok := rawItem.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s[%d] must be a string (got %T)", field, index, rawItem)
|
|
}
|
|
items[index] = item
|
|
}
|
|
return items, nil
|
|
}
|