386 lines
13 KiB
Go
386 lines
13 KiB
Go
package synclegacy
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
"ymhut-box/server/unified-management/internal/config"
|
|
"ymhut-box/server/unified-management/internal/db"
|
|
"ymhut-box/server/unified-management/internal/notices"
|
|
)
|
|
|
|
type Service struct {
|
|
cfg *config.Config
|
|
store *db.Store
|
|
notices *notices.Service
|
|
}
|
|
|
|
type Result struct {
|
|
Ok bool `json:"ok"`
|
|
DryRun bool `json:"dryRun"`
|
|
Paths map[string]any `json:"paths"`
|
|
Stats map[string]int `json:"stats"`
|
|
Warnings []string `json:"warnings"`
|
|
Errors []string `json:"errors"`
|
|
Started string `json:"startedAt"`
|
|
Finished string `json:"finishedAt"`
|
|
}
|
|
|
|
func New(cfg *config.Config, store *db.Store, noticeService *notices.Service) *Service {
|
|
return &Service{cfg: cfg, store: store, notices: noticeService}
|
|
}
|
|
|
|
func (s *Service) Preview(ctx context.Context) Result {
|
|
return s.run(ctx, true)
|
|
}
|
|
|
|
func (s *Service) Run(ctx context.Context) Result {
|
|
return s.run(ctx, false)
|
|
}
|
|
|
|
func (s *Service) run(ctx context.Context, dryRun bool) Result {
|
|
result := Result{
|
|
Ok: true,
|
|
DryRun: dryRun,
|
|
Paths: map[string]any{
|
|
"legacyUpdateDir": s.cfg.LegacyUpdateDir,
|
|
"legacyFeedbackDir": s.cfg.LegacyFeedbackDir,
|
|
"legacyUpdateNoticeDir": s.cfg.LegacyUpdateNoticeDir,
|
|
"updatePublicDir": s.cfg.UpdatePublicDir,
|
|
"updateNoticeDir": s.cfg.UpdateNoticeDir,
|
|
},
|
|
Stats: map[string]int{},
|
|
Started: db.Now(),
|
|
}
|
|
defer func() {
|
|
result.Finished = db.Now()
|
|
if len(result.Errors) > 0 {
|
|
result.Ok = false
|
|
}
|
|
}()
|
|
s.previewPath(&result, "legacy_update", s.cfg.LegacyUpdateDir)
|
|
s.previewPath(&result, "legacy_feedback", s.cfg.LegacyFeedbackDir)
|
|
s.previewPath(&result, "legacy_update_notice", s.cfg.LegacyUpdateNoticeDir)
|
|
if dryRun {
|
|
return result
|
|
}
|
|
if err := s.backupCurrent(); err != nil {
|
|
result.Errors = append(result.Errors, err.Error())
|
|
return result
|
|
}
|
|
s.syncUpdatePublic(&result)
|
|
s.syncNotices(ctx, &result)
|
|
s.syncFeedbackSQLite(&result)
|
|
_ = s.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "legacy.sync", Target: "legacy-projects", Message: fmt.Sprintf("Legacy sync finished: ok=%v copied=%d imported=%d errors=%d", result.Ok, result.Stats["copiedFiles"], result.Stats["importedRows"], len(result.Errors))})
|
|
return result
|
|
}
|
|
|
|
func (s *Service) previewPath(result *Result, key, path string) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
result.Warnings = append(result.Warnings, key+": "+err.Error())
|
|
result.Stats["missingPaths"]++
|
|
return
|
|
}
|
|
if !info.IsDir() {
|
|
result.Warnings = append(result.Warnings, key+": path is not a directory")
|
|
result.Stats["missingPaths"]++
|
|
}
|
|
}
|
|
|
|
func (s *Service) backupCurrent() error {
|
|
backupRoot := filepath.Join(s.cfg.StorageDir, "backups", "legacy-sync-"+time.Now().UTC().Format("20060102-150405"))
|
|
for _, path := range []string{s.cfg.UpdatePublicDir, s.cfg.UpdateNoticeDir} {
|
|
if _, err := os.Stat(path); err == nil {
|
|
target := filepath.Join(backupRoot, filepath.Base(path))
|
|
if err := copyDir(path, target); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) syncUpdatePublic(result *Result) {
|
|
sourcePublic := filepath.Join(s.cfg.LegacyUpdateDir, "public")
|
|
if _, err := os.Stat(sourcePublic); err != nil {
|
|
result.Warnings = append(result.Warnings, "update public not found: "+err.Error())
|
|
return
|
|
}
|
|
for _, name := range []string{"update-info.json", "media-types.json", "tool-status.json", "modules.json"} {
|
|
source := filepath.Join(sourcePublic, name)
|
|
if _, err := os.Stat(source); err == nil {
|
|
if err := copyFile(source, filepath.Join(s.cfg.UpdatePublicDir, name)); err != nil {
|
|
result.Errors = append(result.Errors, err.Error())
|
|
} else {
|
|
result.Stats["copiedFiles"]++
|
|
}
|
|
}
|
|
}
|
|
sourceDownloads := filepath.Join(sourcePublic, "downloads")
|
|
if _, err := os.Stat(sourceDownloads); err == nil {
|
|
if err := copyDir(sourceDownloads, s.cfg.DownloadsDir); err != nil {
|
|
result.Errors = append(result.Errors, err.Error())
|
|
} else {
|
|
result.Stats["copiedDirectories"]++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) syncNotices(ctx context.Context, result *Result) {
|
|
for _, source := range []string{filepath.Join(s.cfg.LegacyUpdateDir, "update-notice"), s.cfg.LegacyUpdateNoticeDir} {
|
|
if _, err := os.Stat(source); err != nil {
|
|
continue
|
|
}
|
|
if err := copyDir(source, s.cfg.UpdateNoticeDir); err != nil {
|
|
result.Errors = append(result.Errors, err.Error())
|
|
continue
|
|
}
|
|
result.Stats["copiedDirectories"]++
|
|
}
|
|
if s.notices != nil {
|
|
if err := s.notices.Import(ctx); err != nil {
|
|
result.Errors = append(result.Errors, "notice import: "+err.Error())
|
|
} else {
|
|
result.Stats["noticeImports"]++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) syncFeedbackSQLite(result *Result) {
|
|
path := filepath.Join(s.cfg.LegacyFeedbackDir, "storage", "feedback.sqlite")
|
|
if _, err := os.Stat(path); err != nil {
|
|
result.Warnings = append(result.Warnings, "feedback sqlite not found: "+err.Error())
|
|
return
|
|
}
|
|
oldDB, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
result.Errors = append(result.Errors, err.Error())
|
|
return
|
|
}
|
|
defer oldDB.Close()
|
|
s.importOldFeedbacks(oldDB, result)
|
|
s.importOldComments(oldDB, result)
|
|
s.importOldEvents(oldDB, result)
|
|
s.importOldTags(oldDB, result)
|
|
s.importOldMail(oldDB, result)
|
|
s.importOldWebhooks(oldDB, result)
|
|
}
|
|
|
|
func (s *Service) importOldFeedbacks(oldDB *sql.DB, result *Result) {
|
|
rows, err := oldDB.Query(`SELECT code, received_at, title, type, severity, category, priority, contact, body, status, status_detail, note, public_reply, handled_by, assignee, due_at, resolved_at, archived_at, sla_level, source_channel, risk_score, resolution, package_path, encrypted_package_path, package_sha256, plain_package_sha256, remote_addr, summary_text, included_files, mail_sent, updated_at, last_activity_at FROM feedbacks`)
|
|
if err != nil {
|
|
result.Warnings = append(result.Warnings, "feedbacks: "+err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var item db.Feedback
|
|
var mailSent int
|
|
if err := rows.Scan(&item.Code, &item.CreatedAt, &item.Title, &item.Type, &item.Severity, &item.Category, &item.Priority, &item.Contact, &item.Body, &item.Status, &item.StatusDetail, &item.Note, &item.PublicReply, &item.HandledBy, &item.Assignee, &item.DueAt, &item.ResolvedAt, &item.ArchivedAt, &item.SLALevel, &item.SourceChannel, &item.RiskScore, &item.Resolution, &item.PackagePath, &item.EncryptedPackagePath, &item.PackageSha256, &item.PlainPackageSha256, &item.RemoteAddr, &item.SummaryText, &item.IncludedFiles, &mailSent, &item.UpdatedAt, &item.LastActivityAt); err != nil {
|
|
result.Errors = append(result.Errors, "feedback scan: "+err.Error())
|
|
continue
|
|
}
|
|
item.MailSent = mailSent == 1
|
|
item.PackagePath = s.copyLegacyFeedbackFile(item.PackagePath, item.Code, result)
|
|
item.EncryptedPackagePath = s.copyLegacyFeedbackFile(item.EncryptedPackagePath, item.Code, result)
|
|
if err := s.store.InsertFeedback(item); err != nil && !strings.Contains(strings.ToLower(err.Error()), "unique") {
|
|
result.Errors = append(result.Errors, "feedback import "+item.Code+": "+err.Error())
|
|
} else {
|
|
result.Stats["importedRows"]++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) importOldComments(oldDB *sql.DB, result *Result) {
|
|
rows, err := oldDB.Query(`SELECT id, feedback_code, author, body, internal, created_at FROM feedback_comments`)
|
|
if err != nil {
|
|
result.Warnings = append(result.Warnings, "comments: "+err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var comment db.FeedbackComment
|
|
var internal int
|
|
var oldID int64
|
|
if err := rows.Scan(&oldID, &comment.Code, &comment.Author, &comment.Body, &internal, &comment.CreatedAt); err != nil {
|
|
result.Errors = append(result.Errors, "comment scan: "+err.Error())
|
|
continue
|
|
}
|
|
comment.Internal = internal == 1
|
|
if _, err := s.store.InsertFeedbackComment(comment); err == nil {
|
|
result.Stats["importedRows"]++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) importOldEvents(oldDB *sql.DB, result *Result) {
|
|
rows, err := oldDB.Query(`SELECT id, feedback_code, event_type, actor, from_value, to_value, message, created_at FROM feedback_events`)
|
|
if err != nil {
|
|
result.Warnings = append(result.Warnings, "events: "+err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var event db.LegacyFeedbackEvent
|
|
if err := rows.Scan(&event.ID, &event.FeedbackCode, &event.EventType, &event.Actor, &event.FromValue, &event.ToValue, &event.Message, &event.CreatedAt); err != nil {
|
|
result.Errors = append(result.Errors, "event scan: "+err.Error())
|
|
continue
|
|
}
|
|
if err := s.store.UpsertFeedbackEvent(event); err == nil {
|
|
result.Stats["importedRows"]++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) importOldTags(oldDB *sql.DB, result *Result) {
|
|
rows, err := oldDB.Query(`SELECT feedback_code, tag, created_at FROM feedback_tags`)
|
|
if err != nil {
|
|
result.Warnings = append(result.Warnings, "tags: "+err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var code, tag, createdAt string
|
|
if err := rows.Scan(&code, &tag, &createdAt); err != nil {
|
|
continue
|
|
}
|
|
if err := s.store.UpsertFeedbackTag(code, tag, createdAt); err == nil {
|
|
result.Stats["importedRows"]++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) importOldMail(oldDB *sql.DB, result *Result) {
|
|
rows, err := oldDB.Query(`SELECT id, feedback_code, kind, status, to_address, subject, attachment_path, attachment_name, error_message, created_at, sent_at FROM mail_records`)
|
|
if err != nil {
|
|
result.Warnings = append(result.Warnings, "mail_records: "+err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var item db.LegacyMailRecord
|
|
if err := rows.Scan(&item.ID, &item.FeedbackCode, &item.Kind, &item.Status, &item.ToAddress, &item.Subject, &item.AttachmentPath, &item.AttachmentName, &item.ErrorMessage, &item.CreatedAt, &item.SentAt); err != nil {
|
|
result.Errors = append(result.Errors, "mail scan: "+err.Error())
|
|
continue
|
|
}
|
|
item.AttachmentPath = s.copyLegacyFeedbackFile(item.AttachmentPath, item.FeedbackCode, result)
|
|
if err := s.store.UpsertMailRecord(item); err == nil {
|
|
result.Stats["importedRows"]++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) importOldWebhooks(oldDB *sql.DB, result *Result) {
|
|
rows, err := oldDB.Query(`SELECT id, webhook_name, event, status, attempts, response_code, error_message, payload_sha256, created_at, finished_at FROM webhook_deliveries`)
|
|
if err != nil {
|
|
result.Warnings = append(result.Warnings, "webhook_deliveries: "+err.Error())
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id int64
|
|
var name, event, status, message, payload, createdAt, finishedAt string
|
|
var attempts, response int
|
|
if err := rows.Scan(&id, &name, &event, &status, &attempts, &response, &message, &payload, &createdAt, &finishedAt); err != nil {
|
|
continue
|
|
}
|
|
_ = s.store.InsertAudit(db.AuditLog{Actor: "legacy", Type: "webhook." + status, Target: name, Message: event + " " + message, CreatedAt: firstNonEmpty(createdAt, finishedAt, db.Now())})
|
|
result.Stats["importedRows"]++
|
|
}
|
|
}
|
|
|
|
func (s *Service) copyLegacyFeedbackFile(path, code string, result *Result) string {
|
|
if strings.TrimSpace(path) == "" {
|
|
return ""
|
|
}
|
|
source := path
|
|
if !filepath.IsAbs(source) {
|
|
source = filepath.Join(s.cfg.LegacyFeedbackDir, source)
|
|
}
|
|
info, err := os.Stat(source)
|
|
if err != nil || info.IsDir() {
|
|
return path
|
|
}
|
|
target := filepath.Join(s.cfg.StorageDir, "legacy-feedback", safeName(code), filepath.Base(source))
|
|
if err := copyFile(source, target); err != nil {
|
|
result.Warnings = append(result.Warnings, "copy attachment: "+err.Error())
|
|
return path
|
|
}
|
|
result.Stats["copiedFiles"]++
|
|
return target
|
|
}
|
|
|
|
func copyDir(source, target string) error {
|
|
sourceInfo, err := os.Stat(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !sourceInfo.IsDir() {
|
|
return errors.New(source + " is not a directory")
|
|
}
|
|
return filepath.WalkDir(source, func(path string, entry os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(source, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dest := filepath.Join(target, rel)
|
|
if entry.IsDir() {
|
|
return os.MkdirAll(dest, 0o750)
|
|
}
|
|
return copyFile(path, dest)
|
|
})
|
|
}
|
|
|
|
func copyFile(source, target string) error {
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
|
return err
|
|
}
|
|
in, err := os.Open(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
_, err = io.Copy(out, in)
|
|
return err
|
|
}
|
|
|
|
func safeName(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "unknown"
|
|
}
|
|
return strings.Map(func(r rune) rune {
|
|
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
|
|
return r
|
|
}
|
|
return '-'
|
|
}, value)
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|