safesight-control/internal/service/config_preview.go
2026-04-19 11:14:29 +08:00

240 lines
6.2 KiB
Go

package service
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"3588AdminBackend/internal/config"
)
var safeConfigName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
type ConfigPreviewService struct {
cfg *config.Config
}
type ConfigSource struct {
Name string `json:"name"`
Path string `json:"path"`
}
type ConfigPreviewSources struct {
Root string `json:"root"`
Templates []ConfigSource `json:"templates"`
Profiles []ConfigSource `json:"profiles"`
Overlays []ConfigSource `json:"overlays"`
}
type ConfigPreviewRequest struct {
Template string
Profile string
Overlays []string
ConfigID string
ConfigVersion string
}
type ConfigPreviewResult struct {
Request ConfigPreviewRequest `json:"request"`
Root string `json:"root"`
Sha256 string `json:"sha256"`
Size int `json:"size"`
Metadata map[string]any `json:"metadata"`
JSON string `json:"json"`
}
func NewConfigPreviewService(cfg *config.Config) *ConfigPreviewService {
return &ConfigPreviewService{cfg: cfg}
}
func (s *ConfigPreviewService) ListSources() (ConfigPreviewSources, error) {
root := s.mediaRepoRoot()
if root == "" {
return defaultConfigPreviewSources(""), nil
}
out := ConfigPreviewSources{Root: root}
var err error
out.Templates, err = listConfigSources(filepath.Join(root, "configs", "templates"))
if err != nil {
if s.hasExplicitRoot() {
return out, err
}
return defaultConfigPreviewSources(root), nil
}
out.Profiles, err = listConfigSources(filepath.Join(root, "configs", "profiles"))
if err != nil {
if s.hasExplicitRoot() {
return out, err
}
return defaultConfigPreviewSources(root), nil
}
out.Overlays, err = listConfigSources(filepath.Join(root, "configs", "overlays"))
if err != nil {
if s.hasExplicitRoot() {
return out, err
}
return defaultConfigPreviewSources(root), nil
}
return out, nil
}
func (s *ConfigPreviewService) Render(req ConfigPreviewRequest) (*ConfigPreviewResult, error) {
root := s.mediaRepoRoot()
if root == "" {
return nil, fmt.Errorf("media repo path is not configured")
}
if err := validateConfigName(req.Template); err != nil {
return nil, fmt.Errorf("invalid template: %w", err)
}
if err := validateConfigName(req.Profile); err != nil {
return nil, fmt.Errorf("invalid profile: %w", err)
}
for _, overlay := range req.Overlays {
if err := validateConfigName(overlay); err != nil {
return nil, fmt.Errorf("invalid overlay %q: %w", overlay, err)
}
}
if req.ConfigID == "" {
req.ConfigID = "preview_" + time.Now().Format("20060102.150405")
}
if req.ConfigVersion == "" {
req.ConfigVersion = time.Now().Format("20060102.150405")
}
out, err := os.CreateTemp("", "rk3588-config-preview-*.json")
if err != nil {
return nil, err
}
outPath := out.Name()
_ = out.Close()
defer os.Remove(outPath)
args := []string{
filepath.Join(root, "tools", "render_config.py"),
"--template", filepath.Join(root, "configs", "templates", req.Template+".json"),
"--profile", filepath.Join(root, "configs", "profiles", req.Profile+".json"),
"--out", outPath,
"--config-id", req.ConfigID,
"--config-version", req.ConfigVersion,
"--rendered-at", time.Now().Format(time.RFC3339),
}
for _, overlay := range req.Overlays {
args = append(args, "--overlay", filepath.Join(root, "configs", "overlays", overlay+".json"))
}
cmd := exec.Command("python", args...)
cmd.Dir = root
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return nil, fmt.Errorf("render config preview: %s", msg)
}
body, err := os.ReadFile(outPath)
if err != nil {
return nil, err
}
var doc map[string]any
if err := json.Unmarshal(body, &doc); err != nil {
return nil, err
}
metadata, _ := doc["metadata"].(map[string]any)
sum := sha256.Sum256(body)
return &ConfigPreviewResult{
Request: req,
Root: root,
Sha256: hex.EncodeToString(sum[:]),
Size: len(body),
Metadata: metadata,
JSON: string(body),
}, nil
}
func (s *ConfigPreviewService) mediaRepoRoot() string {
if s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != "" {
return filepath.Clean(strings.TrimSpace(s.cfg.MediaRepoPath))
}
if env := strings.TrimSpace(os.Getenv("ORANGEPI_MEDIA_REPO")); env != "" {
return filepath.Clean(env)
}
wd, err := os.Getwd()
if err != nil {
return ""
}
candidates := []string{
filepath.Join(wd, "..", "OrangePi3588Media"),
filepath.Join(wd, "..", "..", "OrangePi3588Media"),
filepath.Join(filepath.Dir(wd), "OrangePi3588Media"),
}
for _, candidate := range candidates {
if _, err := os.Stat(filepath.Join(candidate, "tools", "render_config.py")); err == nil {
return filepath.Clean(candidate)
}
}
return ""
}
func (s *ConfigPreviewService) hasExplicitRoot() bool {
return s.cfg != nil && strings.TrimSpace(s.cfg.MediaRepoPath) != ""
}
func listConfigSources(dir string) ([]ConfigSource, error) {
files, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
out := make([]ConfigSource, 0)
for _, file := range files {
if file.IsDir() || strings.ToLower(filepath.Ext(file.Name())) != ".json" {
continue
}
name := strings.TrimSuffix(file.Name(), filepath.Ext(file.Name()))
out = append(out, ConfigSource{Name: name, Path: filepath.Join(dir, file.Name())})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func validateConfigName(name string) error {
if !safeConfigName.MatchString(strings.TrimSpace(name)) {
return fmt.Errorf("must contain only letters, numbers, dot, underscore, or dash")
}
if strings.Contains(name, "..") {
return fmt.Errorf("must not contain '..'")
}
return nil
}
func defaultConfigPreviewSources(root string) ConfigPreviewSources {
return ConfigPreviewSources{
Root: root,
Templates: []ConfigSource{
{Name: "workshop_face_shoe_alarm"},
},
Profiles: []ConfigSource{
{Name: "local_3588_test"},
},
Overlays: []ConfigSource{
{Name: "face_debug"},
{Name: "face_test_sensitive"},
{Name: "production_quiet"},
{Name: "shoe_debug"},
{Name: "shoe_test_sensitive"},
},
}
}