package api import ( "bytes" "errors" "fmt" "io" "net/http" "os" "strconv" "strings" "time" "debtik/internal/auth" "debtik/internal/backup" ) func (s *Server) requireAdminRole(w http.ResponseWriter, r *http.Request) bool { sess := getSessionFromContext(r.Context()) if sess != nil { if sess.Role != auth.RoleFull { writeError(w, http.StatusForbidden, "Administrator privileges required") return false } return true } if s.authMgr != nil { writeError(w, http.StatusUnauthorized, "Authentication required") return false } return true } func (s *Server) handleBackupExport(w http.ResponseWriter, r *http.Request) { if !s.requireAdminRole(w, r) { return } if s.backupMgr == nil { s.backupMgr = backup.NewManager("", "", "", nil) } comment := r.URL.Query().Get("comment") zipBytes, manifest, err := s.backupMgr.CreateBackup(comment) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to create backup: "+err.Error()) return } filename := fmt.Sprintf("debtik-backup-%s-%s.zip", manifest.Hostname, manifest.CreatedAt.Format("20060102-150405")) w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) w.Header().Set("Content-Length", strconv.Itoa(len(zipBytes))) w.WriteHeader(http.StatusOK) _, _ = w.Write(zipBytes) } func (s *Server) handleBackupInspect(w http.ResponseWriter, r *http.Request) { if !s.requireAdminRole(w, r) { return } if s.backupMgr == nil { s.backupMgr = backup.NewManager("", "", "", nil) } buf, err := readUploadedBackup(w, r) if err != nil { writeBackupUploadError(w, err) return } manifest, err := s.backupMgr.InspectBackup(bytes.NewReader(buf), int64(len(buf))) if err != nil { writeError(w, http.StatusBadRequest, "Invalid backup archive: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{ "success": true, "manifest": manifest, }) } func (s *Server) handleBackupRestore(w http.ResponseWriter, r *http.Request) { if !s.requireAdminRole(w, r) { return } if s.backupMgr == nil { s.backupMgr = backup.NewManager("", "", "", nil) } buf, err := readUploadedBackup(w, r) if err != nil { writeBackupUploadError(w, err) return } res, err := s.backupMgr.RestoreBackup(bytes.NewReader(buf), int64(len(buf))) if err != nil { writeError(w, http.StatusBadRequest, "Restore failed: "+err.Error()) return } // Trigger asynchronous restart so DebTik reloads cleanly s.backupMgr.TriggerRestart(1 * time.Second) writeJSON(w, http.StatusOK, res) } func (s *Server) handleBackupList(w http.ResponseWriter, r *http.Request) { if !s.requireAdminRole(w, r) { return } if s.backupMgr == nil { s.backupMgr = backup.NewManager("", "", "", nil) } list, err := s.backupMgr.ListLocalBackups() if err != nil { writeError(w, http.StatusInternalServerError, "Failed to list backups: "+err.Error()) return } writeJSON(w, http.StatusOK, list) } func (s *Server) handleBackupDownloadLocal(w http.ResponseWriter, r *http.Request) { if !s.requireAdminRole(w, r) { return } if s.backupMgr == nil { s.backupMgr = backup.NewManager("", "", "", nil) } filename := r.PathValue("filename") fullPath, err := s.backupMgr.GetLocalBackup(filename) if err != nil { writeError(w, http.StatusNotFound, err.Error()) return } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) http.ServeFile(w, r, fullPath) } func (s *Server) handleBackupRestoreLocal(w http.ResponseWriter, r *http.Request) { if !s.requireAdminRole(w, r) { return } if s.backupMgr == nil { s.backupMgr = backup.NewManager("", "", "", nil) } filename := r.PathValue("filename") fullPath, err := s.backupMgr.GetLocalBackup(filename) if err != nil { writeError(w, http.StatusNotFound, err.Error()) return } f, err := os.Open(fullPath) if err != nil { writeError(w, http.StatusInternalServerError, "Cannot open backup file: "+err.Error()) return } defer f.Close() fi, err := f.Stat() if err != nil { writeError(w, http.StatusInternalServerError, "Cannot stat backup file: "+err.Error()) return } res, err := s.backupMgr.RestoreBackup(f, fi.Size()) if err != nil { writeError(w, http.StatusBadRequest, "Restore failed: "+err.Error()) return } s.backupMgr.TriggerRestart(1 * time.Second) writeJSON(w, http.StatusOK, res) } const maxBackupUploadBytes int64 = backup.MaxArchiveBytes + (1 << 20) func writeBackupUploadError(w http.ResponseWriter, err error) { var maxErr *http.MaxBytesError if errors.As(err, &maxErr) { writeError(w, http.StatusRequestEntityTooLarge, "Backup upload exceeds the size limit") return } writeError(w, http.StatusBadRequest, err.Error()) } func readUploadedBackup(w http.ResponseWriter, r *http.Request) ([]byte, error) { return readUploadedBackupLimit(w, r, maxBackupUploadBytes) } func readUploadedBackupLimit(w http.ResponseWriter, r *http.Request, limit int64) ([]byte, error) { if r.Body == nil { return nil, fmt.Errorf("empty request body") } if r.ContentLength > limit { return nil, &http.MaxBytesError{Limit: limit} } r.Body = http.MaxBytesReader(w, r.Body, limit) defer r.Body.Close() contentType := r.Header.Get("Content-Type") if strings.Contains(contentType, "multipart/form-data") { if err := r.ParseMultipartForm(32 << 20); err != nil { return nil, fmt.Errorf("failed parsing upload: %w", err) } if r.MultipartForm != nil { defer r.MultipartForm.RemoveAll() } f, _, err := r.FormFile("backup") if err != nil { f, _, err = r.FormFile("file") } if err != nil { return nil, fmt.Errorf("no 'backup' or 'file' uploaded") } defer f.Close() return io.ReadAll(f) } return io.ReadAll(r.Body) }