package api import ( "errors" "fmt" "io" "net/http" "os" "path/filepath" "time" "github.com/go-chi/chi/v5" "github.com/cdnutter/playlink/internal/backup" "github.com/cdnutter/playlink/internal/store" ) func (s *Server) backupRoutes(r chi.Router) { r.Get("/backups", s.handleListBackups) r.Put("/backups/config", s.handleBackupConfig) r.Post("/backups", s.handleCreateBackup) r.Post("/backups/restore-upload", s.handleRestoreUpload) r.Get("/backups/{name}/download", s.handleDownloadBackup) r.Post("/backups/{name}/restore", s.handleRestoreBackup) r.Delete("/backups/{name}", s.handleDeleteBackup) } func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) { list, err := s.cfg.Backups.List(r.Context()) if err != nil { writeErr(w, 500, err) return } writeJSON(w, 200, map[string]any{"config": s.cfg.Backups.Config(r.Context()), "backups": list}) } func (s *Server) handleBackupConfig(w http.ResponseWriter, r *http.Request) { var c backup.Config if !readJSON(w, r, &c) { return } if err := s.cfg.Backups.SetConfig(r.Context(), c); err != nil { writeErr(w, 400, err) return } writeJSON(w, 200, s.cfg.Backups.Config(r.Context())) } func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) { b, err := s.cfg.Backups.Create(r.Context(), backup.TypeManual, "manual") if err != nil { writeErr(w, 500, err) return } writeJSON(w, 200, b) } func (s *Server) backupPath(w http.ResponseWriter, r *http.Request) (string, bool) { p, err := s.cfg.Backups.Path(r.Context(), chi.URLParam(r, "name")) switch { case errors.Is(err, backup.ErrBadName): writeErr(w, 400, err) case err != nil: writeErr(w, 404, errors.New("backup not found")) default: return p, true } return "", false } func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) { p, ok := s.backupPath(w, r) if !ok { return } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(p))) http.ServeFile(w, r, p) } func (s *Server) handleDeleteBackup(w http.ResponseWriter, r *http.Request) { if _, ok := s.backupPath(w, r); !ok { return } if err := s.cfg.Backups.Delete(r.Context(), chi.URLParam(r, "name")); err != nil { writeErr(w, 500, err) return } writeJSON(w, 200, map[string]any{"ok": true}) } // restoreBusy refuses a restore while any job runs: the database is about to // be replaced. func (s *Server) restoreBusy(w http.ResponseWriter, r *http.Request) bool { pairs, _ := s.st.Pairs(r.Context()) for _, p := range pairs { if j := s.eng.Job(p.ID); j != nil && j.State == "running" { writeErr(w, 409, fmt.Errorf("a job is running for %q; wait for it to finish before restoring", p.Name)) return true } } return false } func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { p, ok := s.backupPath(w, r) if !ok || s.restoreBusy(w, r) { return } s.stageAndRestart(w, r, p) } func (s *Server) handleRestoreUpload(w http.ResponseWriter, r *http.Request) { if s.restoreBusy(w, r) { return } file, _, err := r.FormFile("backup") if err != nil { writeErr(w, 400, errors.New("choose a backup .zip file to upload")) return } defer file.Close() tmp, err := os.CreateTemp(s.cfg.Backups.Config(r.Context()).Folder, ".upload-*.zip") if err != nil { writeErr(w, 500, err) return } defer os.Remove(tmp.Name()) if _, err := io.Copy(tmp, file); err != nil { tmp.Close() writeErr(w, 400, err) return } tmp.Close() s.stageAndRestart(w, r, tmp.Name()) } func (s *Server) stageAndRestart(w http.ResponseWriter, r *http.Request, zipPath string) { if err := s.cfg.Backups.Stage(r.Context(), zipPath, store.SchemaVersion()); err != nil { writeErr(w, 400, err) return } writeJSON(w, 202, map[string]any{"ok": true, "restarting": true}) if s.cfg.Restart != nil { // Let the response reach the browser before shutting down. go func() { time.Sleep(500 * time.Millisecond) s.log.Warn("restarting to apply the restored backup") s.cfg.Restart() }() } }