@@ -0,0 +1,497 @@
|
||||
package notices
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"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 {
|
||||
Notice db.ReleaseNotice `json:"notice"`
|
||||
Raw string `json:"raw"`
|
||||
Parsed map[string]any `json:"parsed"`
|
||||
Path string `json:"path"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Revisions []db.ReleaseNoticeRevision `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) Import(ctx context.Context) error {
|
||||
if strings.TrimSpace(s.cfg.UpdateNoticeDir) == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(s.cfg.UpdateNoticeDir); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err := s.importTotalIndex(); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(s.cfg.UpdateNoticeDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".json") || strings.EqualFold(entry.Name(), "total.json") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(s.cfg.UpdateNoticeDir, entry.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item, _, _, err := parseNotice(data, versionFromFile(entry.Name()), entry.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.store.UpsertReleaseNotice(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) List(limit int) ([]db.ReleaseNotice, error) {
|
||||
return s.store.ListReleaseNotices(limit)
|
||||
}
|
||||
|
||||
func (s *Service) Get(version string) (Document, error) {
|
||||
item, err := s.store.GetReleaseNotice(version)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
raw := item.RawJSON
|
||||
parsed, formatted, err := parseAndFormat([]byte(raw), version, item.NoticeFile)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
revisions, _ := s.store.ListReleaseNoticeRevisions(version, 20)
|
||||
updatedAt := item.UpdatedAt
|
||||
path := filepath.Join(s.cfg.UpdateNoticeDir, firstNonEmpty(item.NoticeFile, version+".json"))
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
updatedAt = info.ModTime().UTC().Format(time.RFC3339)
|
||||
}
|
||||
return Document{Notice: item, Raw: formatted, Parsed: parsed, Path: path, UpdatedAt: updatedAt, Revisions: revisions}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Validate(ctx context.Context, version string, req SaveRequest) (Document, error) {
|
||||
raw, err := requestRaw(req)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
item, parsed, formatted, err := parseNotice([]byte(raw), version, version+".json")
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
return Document{Notice: item, Raw: formatted, Parsed: parsed}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Save(ctx context.Context, version string, req SaveRequest, actor string) (Document, error) {
|
||||
raw, err := requestRaw(req)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
item, parsed, formatted, err := parseNotice([]byte(raw), version, version+".json")
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
item.RawJSON = formatted
|
||||
if current, err := s.store.GetReleaseNotice(item.Version); err == nil && current.RawJSON != "" {
|
||||
_, _ = s.store.SaveReleaseNoticeRevision(item.Version, current.RawJSON, "auto backup before save", actor)
|
||||
}
|
||||
saved, err := s.store.UpsertReleaseNotice(item)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
_, _ = s.store.SaveReleaseNoticeRevision(saved.Version, formatted, req.Note, actor)
|
||||
if err := s.writeNoticeFile(saved, formatted); err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
if err := s.writeTotalIndex(saved, parsed); err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
if err := s.syncLegacyUpdateInfo(saved, parsed); err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: actor, Type: "release_notice.saved", Target: saved.Version, Message: "Release notice saved"})
|
||||
return s.Get(saved.Version)
|
||||
}
|
||||
|
||||
func (s *Service) Restore(ctx context.Context, version string, revisionID int64, actor string) (Document, error) {
|
||||
revision, err := s.store.GetReleaseNoticeRevision(version, revisionID)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
return s.Save(ctx, version, SaveRequest{Raw: revision.RawJSON, Note: "restored revision"}, actor)
|
||||
}
|
||||
|
||||
func (s *Service) importTotalIndex() error {
|
||||
path := filepath.Join(s.cfg.UpdateNoticeDir, "total.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var root map[string]any
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
return err
|
||||
}
|
||||
if latest, ok := root["latest"].(map[string]any); ok {
|
||||
raw, _ := json.MarshalIndent(latest, "", " ")
|
||||
item, _, _, err := parseNotice(raw, stringValue(root, "latest_version"), stringValue(root, "latest_notice_file"))
|
||||
if err == nil {
|
||||
_, _ = s.store.UpsertReleaseNotice(item)
|
||||
}
|
||||
}
|
||||
for _, entry := range arrayValue(root, "versions") {
|
||||
version := stringValue(entry, "version")
|
||||
if version == "" {
|
||||
continue
|
||||
}
|
||||
raw, _ := json.MarshalIndent(entry, "", " ")
|
||||
item, _, _, err := parseNotice(raw, version, stringValue(entry, "notice_file"))
|
||||
if err == nil {
|
||||
_, _ = s.store.UpsertReleaseNotice(item)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) writeNoticeFile(item db.ReleaseNotice, raw string) error {
|
||||
if err := os.MkdirAll(s.cfg.UpdateNoticeDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicWrite(filepath.Join(s.cfg.UpdateNoticeDir, firstNonEmpty(item.NoticeFile, item.Version+".json")), []byte(raw))
|
||||
}
|
||||
|
||||
func (s *Service) writeTotalIndex(item db.ReleaseNotice, parsed map[string]any) error {
|
||||
path := filepath.Join(s.cfg.UpdateNoticeDir, "total.json")
|
||||
root := map[string]any{"schema_version": 1, "product": "YMhut Box", "versions": []any{}}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal(data, &root)
|
||||
}
|
||||
root["latest_version"] = newestVersion(stringValue(root, "latest_version"), item.Version)
|
||||
if root["latest_version"] == item.Version {
|
||||
root["latest_notice_file"] = item.NoticeFile
|
||||
root["latest"] = latestMap(item, parsed)
|
||||
}
|
||||
root["last_updated"] = db.Now()
|
||||
versions := arrayValue(root, "versions")
|
||||
next := make([]any, 0, len(versions)+1)
|
||||
found := false
|
||||
for _, entry := range versions {
|
||||
if stringValue(entry, "version") == item.Version {
|
||||
next = append(next, summaryMap(item, parsed))
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
next = append(next, entry)
|
||||
}
|
||||
if !found {
|
||||
next = append(next, summaryMap(item, parsed))
|
||||
}
|
||||
sort.SliceStable(next, func(i, j int) bool {
|
||||
return compareVersion(stringValue(next[i], "version"), stringValue(next[j], "version")) > 0
|
||||
})
|
||||
root["versions"] = next
|
||||
data, err := json.MarshalIndent(root, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicWrite(path, append(data, '\n'))
|
||||
}
|
||||
|
||||
func (s *Service) syncLegacyUpdateInfo(item db.ReleaseNotice, parsed map[string]any) error {
|
||||
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
|
||||
payload := map[string]any{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal(data, &payload)
|
||||
}
|
||||
payload["app_version"] = item.Version
|
||||
setNonEmpty(payload, "build", item.Build)
|
||||
setNonEmpty(payload, "channel", item.Channel)
|
||||
setNonEmpty(payload, "title", item.Title)
|
||||
setNonEmpty(payload, "message", item.Message)
|
||||
setNonEmpty(payload, "message_md", item.MessageMD)
|
||||
setNonEmpty(payload, "release_notes", item.ReleaseNotes)
|
||||
setNonEmpty(payload, "release_notes_md", item.ReleaseNotesMD)
|
||||
setNonEmpty(payload, "download_url", item.DownloadURL)
|
||||
payload["last_updated"] = firstNonEmpty(item.PublishedAt, db.Now())
|
||||
for _, key := range []string{"update_notes", "last_update_notes", "download_mirrors", "detected_packages", "detected_product", "category_list", "home_notes", "tool_metadata", "api_keys"} {
|
||||
if value, ok := parsed[key]; ok {
|
||||
payload[key] = value
|
||||
}
|
||||
}
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicWrite(path, append(data, '\n'))
|
||||
}
|
||||
|
||||
func parseAndFormat(data []byte, fallbackVersion, noticeFile string) (map[string]any, string, error) {
|
||||
_, parsed, formatted, err := parseNotice(data, fallbackVersion, noticeFile)
|
||||
return parsed, formatted, err
|
||||
}
|
||||
|
||||
func parseNotice(data []byte, fallbackVersion, noticeFile string) (db.ReleaseNotice, 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 db.ReleaseNotice{}, nil, "", err
|
||||
}
|
||||
version := firstNonEmpty(stringValue(parsed, "app_version"), stringValue(parsed, "version"), fallbackVersion)
|
||||
if version == "" {
|
||||
return db.ReleaseNotice{}, nil, "", errors.New("version or app_version is required")
|
||||
}
|
||||
if noticeFile == "" {
|
||||
noticeFile = version + ".json"
|
||||
}
|
||||
formattedBytes, err := json.MarshalIndent(parsed, "", " ")
|
||||
if err != nil {
|
||||
return db.ReleaseNotice{}, nil, "", err
|
||||
}
|
||||
formatted := string(formattedBytes) + "\n"
|
||||
item := db.ReleaseNotice{
|
||||
Version: version,
|
||||
Build: stringValue(parsed, "build"),
|
||||
Channel: firstNonEmpty(stringValue(parsed, "channel"), "stable"),
|
||||
Title: firstNonEmpty(stringValue(parsed, "title"), "YMhut Box "+version),
|
||||
Message: firstNonEmpty(stringValue(parsed, "message"), stringValue(parsed, "summary"), stringValue(parsed, "home_notes")),
|
||||
ReleaseNotes: firstNonEmpty(stringValue(parsed, "release_notes"), stringValue(parsed, "summary")),
|
||||
MessageMD: stringValue(parsed, "message_md"),
|
||||
ReleaseNotesMD: stringValue(parsed, "release_notes_md"),
|
||||
DownloadURL: stringValue(parsed, "download_url"),
|
||||
NoticeFile: noticeFile,
|
||||
RawJSON: formatted,
|
||||
PublishedAt: normalizeTime(firstNonEmpty(stringValue(parsed, "published_at"), stringValue(parsed, "release_date"), stringValue(parsed, "last_updated"))),
|
||||
}
|
||||
return item, parsed, formatted, nil
|
||||
}
|
||||
|
||||
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)
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
func latestMap(item db.ReleaseNotice, parsed map[string]any) map[string]any {
|
||||
out := summaryMap(item, parsed)
|
||||
out["title"] = item.Title
|
||||
out["message"] = item.Message
|
||||
out["download_url"] = item.DownloadURL
|
||||
out["release_notes"] = item.ReleaseNotes
|
||||
out["message_md"] = item.MessageMD
|
||||
out["release_notes_md"] = item.ReleaseNotesMD
|
||||
return out
|
||||
}
|
||||
|
||||
func summaryMap(item db.ReleaseNotice, parsed map[string]any) map[string]any {
|
||||
out := map[string]any{
|
||||
"version": item.Version,
|
||||
"build": item.Build,
|
||||
"channel": item.Channel,
|
||||
"release_date": dateOnly(item.PublishedAt),
|
||||
"notice_file": item.NoticeFile,
|
||||
"summary": firstNonEmpty(item.Message, item.ReleaseNotes),
|
||||
}
|
||||
if value, ok := parsed["highlights"]; ok {
|
||||
out["highlights"] = value
|
||||
}
|
||||
if value, ok := parsed["categories"]; ok {
|
||||
out["categories"] = value
|
||||
} else if value, ok := parsed["update_notes"]; ok {
|
||||
out["categories"] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func PublicNotice(item db.ReleaseNotice) map[string]any {
|
||||
return map[string]any{
|
||||
"version": item.Version,
|
||||
"build": item.Build,
|
||||
"channel": item.Channel,
|
||||
"title": item.Title,
|
||||
"message": item.Message,
|
||||
"release_notes": item.ReleaseNotes,
|
||||
"message_md": item.MessageMD,
|
||||
"release_notes_md": item.ReleaseNotesMD,
|
||||
"download_url": item.DownloadURL,
|
||||
"notice_file": item.NoticeFile,
|
||||
"published_at": item.PublishedAt,
|
||||
"updated_at": item.UpdatedAt,
|
||||
"releaseNotes": item.ReleaseNotes,
|
||||
"messageMarkdown": item.MessageMD,
|
||||
"releaseNotesMarkdown": item.ReleaseNotesMD,
|
||||
}
|
||||
}
|
||||
|
||||
func PublicList(items []db.ReleaseNotice) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, PublicNotice(item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func arrayValue(root map[string]any, key string) []any {
|
||||
if values, ok := root[key].([]any); ok {
|
||||
return values
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
func stringValue(root any, key string) string {
|
||||
obj, ok := root.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch value := obj[key].(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(value)
|
||||
case json.Number:
|
||||
return value.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func setNonEmpty(target map[string]any, key, value string) {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTime(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", value); err == nil {
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func dateOnly(value string) string {
|
||||
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return t.UTC().Format("2006-01-02")
|
||||
}
|
||||
if len(value) >= 10 {
|
||||
return value[:10]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func newestVersion(current, candidate string) string {
|
||||
if current == "" || compareVersion(candidate, current) >= 0 {
|
||||
return candidate
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func compareVersion(a, b string) int {
|
||||
as := strings.Split(a, ".")
|
||||
bs := strings.Split(b, ".")
|
||||
for len(as) < 4 {
|
||||
as = append(as, "0")
|
||||
}
|
||||
for len(bs) < 4 {
|
||||
bs = append(bs, "0")
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
ai := parseInt(as[i])
|
||||
bi := parseInt(bs[i])
|
||||
if ai > bi {
|
||||
return 1
|
||||
}
|
||||
if ai < bi {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseInt(value string) int {
|
||||
n := 0
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
break
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func versionFromFile(name string) string {
|
||||
return strings.TrimSuffix(filepath.Base(name), filepath.Ext(name))
|
||||
}
|
||||
|
||||
func atomicWrite(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user