package legacy import ( "bytes" "context" "encoding/json" "errors" "os" "path/filepath" "strings" "time" "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/db" ) type Service struct { cfg *config.Config store *db.Store } type Document struct { Name string `json:"name"` Raw string `json:"raw"` Parsed map[string]any `json:"parsed"` Path string `json:"path"` UpdatedAt string `json:"updatedAt"` Revisions []db.LegacyJsonRevision `json:"revisions"` } type SaveRequest struct { Raw string `json:"raw"` Parsed map[string]any `json:"parsed"` Note string `json:"note"` } func NewService(cfg *config.Config, store *db.Store) *Service { return &Service{cfg: cfg, store: store} } func (s *Service) Get(ctx context.Context, name string) (Document, error) { fileName, err := fileNameFor(name) if err != nil { return Document{}, err } path := filepath.Join(s.cfg.UpdatePublicDir, fileName) data, err := os.ReadFile(path) if err != nil { return Document{}, err } parsed, formatted, err := parseAndFormat(name, data) if err != nil { return Document{}, err } revisions, _ := s.store.ListLegacyRevisions(name, 20) updatedAt := "" if info, err := os.Stat(path); err == nil { updatedAt = info.ModTime().UTC().Format(time.RFC3339) } return Document{Name: name, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: updatedAt, Revisions: revisions}, nil } func (s *Service) Validate(ctx context.Context, name string, req SaveRequest) (Document, error) { raw, err := requestRaw(req) if err != nil { return Document{}, err } parsed, formatted, err := parseAndFormat(name, []byte(raw)) if err != nil { return Document{}, err } return Document{Name: name, Raw: formatted, Parsed: parsed}, nil } func (s *Service) Save(ctx context.Context, name string, req SaveRequest, actor string) (Document, error) { fileName, err := fileNameFor(name) if err != nil { return Document{}, err } raw, err := requestRaw(req) if err != nil { return Document{}, err } parsed, formatted, err := parseAndFormat(name, []byte(raw)) if err != nil { return Document{}, err } path := filepath.Join(s.cfg.UpdatePublicDir, fileName) current, _ := os.ReadFile(path) if len(current) > 0 { _, _ = s.store.SaveLegacyRevision(name, string(current), "auto backup before save", actor) } if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return Document{}, err } if err := atomicWrite(path, []byte(formatted)); err != nil { return Document{}, err } _, _ = s.store.SaveLegacyRevision(name, formatted, req.Note, actor) _ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "legacy_json.saved", Target: name, Message: "Legacy JSON saved"}) revisions, _ := s.store.ListLegacyRevisions(name, 20) return Document{Name: name, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: db.Now(), Revisions: revisions}, nil } func (s *Service) Restore(ctx context.Context, name string, revisionID int64, actor string) (Document, error) { fileName, err := fileNameFor(name) if err != nil { return Document{}, err } revision, err := s.store.GetLegacyRevision(name, revisionID) if err != nil { return Document{}, err } parsed, formatted, err := parseAndFormat(name, []byte(revision.Raw)) if err != nil { return Document{}, err } path := filepath.Join(s.cfg.UpdatePublicDir, fileName) current, _ := os.ReadFile(path) if len(current) > 0 { _, _ = s.store.SaveLegacyRevision(name, string(current), "auto backup before restore", actor) } if err := atomicWrite(path, []byte(formatted)); err != nil { return Document{}, err } _, _ = s.store.SaveLegacyRevision(name, formatted, "restored revision", actor) _ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "legacy_json.restored", Target: name, Message: "Legacy JSON restored"}) revisions, _ := s.store.ListLegacyRevisions(name, 20) return Document{Name: name, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: db.Now(), Revisions: revisions}, nil } func fileNameFor(name string) (string, error) { switch strings.TrimSpace(name) { case "update-info": return "update-info.json", nil case "media-types": return "media-types.json", nil default: return "", errors.New("unsupported legacy document") } } func requestRaw(req SaveRequest) (string, error) { if strings.TrimSpace(req.Raw) != "" { return req.Raw, nil } if req.Parsed == nil { return "", errors.New("raw or parsed JSON is required") } data, err := json.Marshal(req.Parsed) if err != nil { return "", err } return string(data), nil } func parseAndFormat(name string, data []byte) (map[string]any, string, error) { decoder := json.NewDecoder(bytes.NewReader(data)) decoder.UseNumber() var parsed map[string]any if err := decoder.Decode(&parsed); err != nil { return nil, "", err } if err := validate(name, parsed); err != nil { return nil, "", err } out, err := json.MarshalIndent(parsed, "", " ") if err != nil { return nil, "", err } return parsed, string(out) + "\n", nil } func validate(name string, parsed map[string]any) error { switch name { case "update-info": if _, ok := parsed["app_version"]; !ok { if _, ok := parsed["title"]; !ok { return errors.New("update-info requires app_version or title") } } case "media-types": if _, ok := parsed["categories"].([]any); !ok { return errors.New("media-types requires categories array") } if _, ok := parsed["layout_version"]; !ok { parsed["layout_version"] = "1.0.0" } if _, ok := parsed["last_updated"]; !ok { parsed["last_updated"] = time.Now().UTC().Format(time.RFC3339) } } return nil } func atomicWrite(path string, data []byte) error { dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") if err != nil { return err } tmpName := tmp.Name() defer os.Remove(tmpName) if _, err := tmp.Write(data); err != nil { _ = tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if err := os.Chmod(tmpName, 0o640); err != nil { return err } return os.Rename(tmpName, path) }