@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -67,16 +68,28 @@ func Load() (*Config, error) {
|
||||
}
|
||||
cfg := defaults(root)
|
||||
path := firstNonEmpty(os.Getenv("YMHUT_UNIFIED_CONFIG"), filepath.Join(root, "config.json"))
|
||||
var rawConfig []byte
|
||||
loaded := false
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Initialized = true
|
||||
rawConfig = data
|
||||
loaded = true
|
||||
}
|
||||
cfg.BaseDir = root
|
||||
cfg.ConfigPath = path
|
||||
if loaded {
|
||||
sanitizeNonPortablePaths(cfg)
|
||||
}
|
||||
applyEnv(cfg)
|
||||
normalize(root, cfg)
|
||||
if loaded && shouldRewriteRelativeConfig(rawConfig) {
|
||||
if err := Save(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -333,13 +346,110 @@ func Save(cfg *Config) error {
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.ConfigPath), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
persisted := cfg.relativeCopy()
|
||||
data, err := json.MarshalIndent(persisted, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(cfg.ConfigPath, data, 0o600)
|
||||
}
|
||||
|
||||
func (cfg *Config) relativeCopy() Config {
|
||||
next := *cfg
|
||||
base := cfg.BaseDir
|
||||
next.BaseDir = "."
|
||||
next.StorageDir = relativePath(base, cfg.StorageDir)
|
||||
next.DataDir = relativePath(base, cfg.DataDir)
|
||||
next.UpdatePublicDir = relativePath(base, cfg.UpdatePublicDir)
|
||||
next.UpdateNoticeDir = relativePath(base, cfg.UpdateNoticeDir)
|
||||
next.DownloadsDir = relativePath(base, cfg.DownloadsDir)
|
||||
next.AdminWebDir = relativePath(base, cfg.AdminWebDir)
|
||||
next.PortalWebDir = relativePath(base, cfg.PortalWebDir)
|
||||
next.SetupWebDir = relativePath(base, cfg.SetupWebDir)
|
||||
next.LegacyUpdateDir = relativePath(base, cfg.LegacyUpdateDir)
|
||||
next.LegacyFeedbackDir = relativePath(base, cfg.LegacyFeedbackDir)
|
||||
next.LegacyUpdateNoticeDir = relativePath(base, cfg.LegacyUpdateNoticeDir)
|
||||
next.Database.SQLitePath = relativePath(base, cfg.Database.SQLitePath)
|
||||
return next
|
||||
}
|
||||
|
||||
func relativePath(base, value string) string {
|
||||
if strings.TrimSpace(value) == "" || strings.HasPrefix(strings.ToLower(value), "file:") {
|
||||
return value
|
||||
}
|
||||
rel, err := filepath.Rel(base, value)
|
||||
if err != nil || rel == "" {
|
||||
return value
|
||||
}
|
||||
if strings.HasPrefix(rel, "..") {
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
return filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
func sanitizeNonPortablePaths(cfg *Config) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return
|
||||
}
|
||||
for _, target := range []*string{
|
||||
&cfg.StorageDir,
|
||||
&cfg.DataDir,
|
||||
&cfg.UpdatePublicDir,
|
||||
&cfg.UpdateNoticeDir,
|
||||
&cfg.DownloadsDir,
|
||||
&cfg.AdminWebDir,
|
||||
&cfg.PortalWebDir,
|
||||
&cfg.SetupWebDir,
|
||||
&cfg.LegacyUpdateDir,
|
||||
&cfg.LegacyFeedbackDir,
|
||||
&cfg.LegacyUpdateNoticeDir,
|
||||
&cfg.Database.SQLitePath,
|
||||
} {
|
||||
if isWindowsAbsolutePath(*target) {
|
||||
*target = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRewriteRelativeConfig(data []byte) bool {
|
||||
var payload any
|
||||
if len(data) == 0 || json.Unmarshal(data, &payload) != nil {
|
||||
return false
|
||||
}
|
||||
return containsAbsolutePath(payload)
|
||||
}
|
||||
|
||||
func containsAbsolutePath(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, item := range typed {
|
||||
if containsAbsolutePath(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if containsAbsolutePath(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case string:
|
||||
return filepath.IsAbs(typed) || isWindowsAbsolutePath(typed)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isWindowsAbsolutePath(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 3 {
|
||||
drive := value[0]
|
||||
if ((drive >= 'A' && drive <= 'Z') || (drive >= 'a' && drive <= 'z')) && value[1] == ':' && (value[2] == '\\' || value[2] == '/') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.HasPrefix(value, `\\`)
|
||||
}
|
||||
|
||||
func absPath(base, value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return value
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSavePersistsRelativePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := defaults(root)
|
||||
cfg.Initialized = true
|
||||
cfg.ConfigPath = filepath.Join(root, "config.json")
|
||||
|
||||
if err := Save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved Config
|
||||
if err := json.Unmarshal(data, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.BaseDir != "." {
|
||||
t.Fatalf("BaseDir = %q, want relative dot", saved.BaseDir)
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"storage_dir": saved.StorageDir,
|
||||
"data_dir": saved.DataDir,
|
||||
"update_public_dir": saved.UpdatePublicDir,
|
||||
"update_notice_dir": saved.UpdateNoticeDir,
|
||||
"downloads_dir": saved.DownloadsDir,
|
||||
"sqlite_path": saved.Database.SQLitePath,
|
||||
} {
|
||||
if filepath.IsAbs(value) || strings.Contains(value, root) {
|
||||
t.Fatalf("%s saved as absolute path %q", name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightCreatesRuntimeDirectoriesAndNoticeIndex(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := defaults(root)
|
||||
checks := Preflight(cfg)
|
||||
for _, path := range []string{
|
||||
cfg.UpdatePublicDir,
|
||||
cfg.UpdateNoticeDir,
|
||||
cfg.DownloadsDir,
|
||||
filepath.Join(cfg.UpdatePublicDir, "update-info.json"),
|
||||
filepath.Join(cfg.UpdatePublicDir, "media-types.json"),
|
||||
filepath.Join(cfg.UpdateNoticeDir, "total.json"),
|
||||
} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected preflight to create %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
for _, line := range FormatPreflight(cfg, checks) {
|
||||
if strings.Contains(line, root) {
|
||||
t.Fatalf("preflight line leaked absolute base path: %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRewritesAbsoluteConfigPaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("YMHUT_BASE_DIR", root)
|
||||
configPath := filepath.Join(root, "config.json")
|
||||
payload := map[string]any{
|
||||
"initialized": true,
|
||||
"listen": ":33550",
|
||||
"storage_dir": filepath.Join(root, "storage"),
|
||||
"data_dir": filepath.Join(root, "data"),
|
||||
"update_public_dir": filepath.Join(root, "data", "update", "public"),
|
||||
"update_notice_dir": filepath.Join(root, "data", "update-notice"),
|
||||
"downloads_dir": filepath.Join(root, "data", "update", "public", "downloads"),
|
||||
"database": map[string]any{
|
||||
"provider": "sqlite",
|
||||
"sqlite_path": filepath.Join(root, "storage", "unified.sqlite"),
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewritten, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(rewritten), root) {
|
||||
t.Fatalf("config still contains absolute base path: %s", string(rewritten))
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,25 @@ import (
|
||||
webassets "ymhut-box/server/unified-management/web"
|
||||
)
|
||||
|
||||
const defaultUpdateInfoJSON = `{
|
||||
"app_version": "0.0.0",
|
||||
"download_url": "",
|
||||
"update_notes": {},
|
||||
"last_update_notes": {},
|
||||
"release_notes": "",
|
||||
"release_notes_md": "",
|
||||
"last_updated": ""
|
||||
}
|
||||
`
|
||||
|
||||
const defaultMediaTypesJSON = `{
|
||||
"layout_version": "1.0.0",
|
||||
"last_updated": "",
|
||||
"ui_config": {},
|
||||
"categories": []
|
||||
}
|
||||
`
|
||||
|
||||
type Check struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
@@ -19,12 +38,12 @@ func Preflight(cfg *Config) []Check {
|
||||
checks := []Check{
|
||||
checkDir("storage", cfg.StorageDir, true),
|
||||
checkParent("sqlite", cfg.Database.SQLitePath),
|
||||
checkDir("update public", cfg.UpdatePublicDir, false),
|
||||
checkDir("update notice", cfg.UpdateNoticeDir, false),
|
||||
checkDir("downloads", cfg.DownloadsDir, false),
|
||||
checkFile("legacy update-info", filepath.Join(cfg.UpdatePublicDir, "update-info.json"), false),
|
||||
checkFile("legacy media-types", filepath.Join(cfg.UpdatePublicDir, "media-types.json"), false),
|
||||
checkFile("version notice index", filepath.Join(cfg.UpdateNoticeDir, "total.json"), false),
|
||||
checkDir("update public", cfg.UpdatePublicDir, true),
|
||||
checkDir("update notice", cfg.UpdateNoticeDir, true),
|
||||
checkDir("downloads", cfg.DownloadsDir, true),
|
||||
checkSeedFile("legacy update-info", filepath.Join(cfg.UpdatePublicDir, "update-info.json"), []byte(defaultUpdateInfoJSON)),
|
||||
checkSeedFile("legacy media-types", filepath.Join(cfg.UpdatePublicDir, "media-types.json"), []byte(defaultMediaTypesJSON)),
|
||||
checkNoticeIndex("version notice index", filepath.Join(cfg.UpdateNoticeDir, "total.json")),
|
||||
checkWebBuild("admin web dist", cfg.AdminWebDir, "admin/dist"),
|
||||
checkWebBuild("portal web dist", cfg.PortalWebDir, "portal/dist"),
|
||||
checkWebBuild("setup web dist", cfg.SetupWebDir, "setup/dist"),
|
||||
@@ -48,6 +67,37 @@ func checkDir(name, path string, create bool) Check {
|
||||
return Check{Name: name, Status: "ok", Path: path}
|
||||
}
|
||||
|
||||
func checkNoticeIndex(name, path string) Check {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return Check{Name: name, Status: "ok", Path: path}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
data := []byte("{\n \"schema_version\": 1,\n \"product\": \"YMhut Box\",\n \"versions\": []\n}\n")
|
||||
if err := os.WriteFile(path, data, 0o640); err != nil {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
return Check{Name: name, Status: "ok", Path: path, Message: "created empty notice index"}
|
||||
}
|
||||
|
||||
func checkSeedFile(name, path string, data []byte) Check {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return Check{Name: name, Status: "ok", Path: path}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o640); err != nil {
|
||||
return Check{Name: name, Status: "error", Path: path, Message: err.Error()}
|
||||
}
|
||||
return Check{Name: name, Status: "ok", Path: path, Message: "created default compatibility JSON"}
|
||||
}
|
||||
|
||||
func checkParent(name, path string) Check {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
@@ -116,12 +166,12 @@ func embeddedWebBuildOK(embedRoot string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func FormatPreflight(checks []Check) []string {
|
||||
func FormatPreflight(cfg *Config, checks []Check) []string {
|
||||
lines := make([]string, 0, len(checks))
|
||||
for _, check := range checks {
|
||||
line := fmt.Sprintf("[%s] %s", check.Status, check.Name)
|
||||
if check.Path != "" {
|
||||
line += " -> " + check.Path
|
||||
line += " -> " + relativePath(cfg.BaseDir, check.Path)
|
||||
}
|
||||
if check.Message != "" {
|
||||
line += " (" + check.Message + ")"
|
||||
|
||||
Reference in New Issue
Block a user