@@ -0,0 +1,215 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user