package api import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net" "net/http" "os" "path/filepath" "strings" "time" "github.com/backupper-unraid/backupper/internal/model" "github.com/backupper-unraid/backupper/internal/service" ) type Server struct { service *service.Service log *slog.Logger http *http.Server socket string } var Version = "dev" func New(socket string, svc *service.Service, log *slog.Logger) *Server { s := &Server{service: svc, log: log, socket: socket} mux := http.NewServeMux() mux.HandleFunc("GET /v1/health", s.health) mux.HandleFunc("GET /v1/config", s.getConfig) mux.HandleFunc("PUT /v1/config", s.putConfig) mux.HandleFunc("PUT /v1/secrets/{id}", s.putSecret) mux.HandleFunc("DELETE /v1/secrets/{id}", s.deleteSecret) mux.HandleFunc("GET /v1/runs", s.runs) mux.HandleFunc("DELETE /v1/runs", s.clearRuns) mux.HandleFunc("GET /v1/filesystem/directories", s.directories) mux.HandleFunc("GET /v1/workloads/{kind}", s.workloads) mux.HandleFunc("POST /v1/jobs/{id}/run", s.runJob) mux.HandleFunc("POST /v1/runs/{id}/cancel", s.cancelRun) mux.HandleFunc("POST /v1/runs/{id}/pause", s.pauseRun) mux.HandleFunc("POST /v1/runs/{id}/resume", s.resumeRun) mux.HandleFunc("POST /v1/repositories/{id}/test", s.testRepository) mux.HandleFunc("POST /v1/repositories/{id}/init", s.initRepository) mux.HandleFunc("POST /v1/repositories/{id}/{action}", s.maintenance) mux.HandleFunc("GET /v1/repositories/{id}/snapshots", s.snapshots) mux.HandleFunc("GET /v1/repositories/{id}/snapshots/{snapshot}/files", s.snapshotFiles) mux.HandleFunc("POST /v1/restores", s.restore) s.http = &http.Server{Handler: requestLog(log, mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 5 * time.Minute, IdleTimeout: 30 * time.Second} return s } func (s *Server) ListenAndServe() error { if err := os.MkdirAll(filepath.Dir(s.socket), 0750); err != nil { return err } _ = os.Remove(s.socket) listener, err := net.Listen("unix", s.socket) if err != nil { return err } if err := os.Chmod(s.socket, 0660); err != nil { listener.Close() return err } err = s.http.Serve(listener) if errors.Is(err, http.ErrServerClosed) { return nil } return err } func (s *Server) Shutdown(ctx context.Context) error { defer os.Remove(s.socket) return s.http.Shutdown(ctx) } func (s *Server) health(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]any{"status": "ok", "version": Version, "schemaVersion": model.SchemaVersion}) } func (s *Server) getConfig(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.Config()) } func (s *Server) putConfig(w http.ResponseWriter, r *http.Request) { var config model.Config if err := decode(r, &config); err != nil { writeError(w, err) return } if err := s.service.SaveConfig(config); err != nil { writeError(w, err) return } writeJSON(w, 200, config) } func (s *Server) putSecret(w http.ResponseWriter, r *http.Request) { var body struct { Type string `json:"type"` Value string `json:"value"` } if err := decode(r, &body); err != nil { writeError(w, err) return } if err := s.service.PutSecret(r.PathValue("id"), body.Type, body.Value); err != nil { writeError(w, err) return } writeJSON(w, 204, nil) } func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) { if err := s.service.DeleteSecret(r.PathValue("id")); err != nil { writeError(w, err) return } w.WriteHeader(204) } func (s *Server) runs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.Runs()) } func (s *Server) clearRuns(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]int{"cleared": s.service.ClearRunHistory()}) } func (s *Server) directories(w http.ResponseWriter, r *http.Request) { items, err := s.service.BrowseDirectories(r.URL.Query().Get("path")) if err != nil { writeError(w, err) return } writeJSON(w, 200, items) } func (s *Server) workloads(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() items, err := s.service.DiscoverWorkloads(ctx, model.JobType(r.PathValue("kind"))) if err != nil { writeError(w, err) return } writeJSON(w, 200, items) } func (s *Server) runJob(w http.ResponseWriter, r *http.Request) { run, err := s.service.EnqueueJob(r.PathValue("id"), 10) if err != nil { writeError(w, err) return } writeJSON(w, 202, run) } func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) { if !s.service.Cancel(r.PathValue("id")) { writeJSON(w, 404, map[string]string{"error": "run not found"}) return } w.WriteHeader(204) } func (s *Server) pauseRun(w http.ResponseWriter, r *http.Request) { if err := s.service.Pause(r.PathValue("id")); err != nil { writeError(w, err) return } w.WriteHeader(204) } func (s *Server) resumeRun(w http.ResponseWriter, r *http.Request) { if err := s.service.Resume(r.PathValue("id")); err != nil { writeError(w, err) return } w.WriteHeader(204) } func (s *Server) testRepository(w http.ResponseWriter, r *http.Request) { s.repositoryAction(w, r, false) } func (s *Server) initRepository(w http.ResponseWriter, r *http.Request) { s.repositoryAction(w, r, true) } func (s *Server) repositoryAction(w http.ResponseWriter, r *http.Request, initialize bool) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) defer cancel() if err := s.service.TestRepository(ctx, r.PathValue("id"), initialize); err != nil { writeError(w, err) return } writeJSON(w, 200, map[string]bool{"ok": true}) } func (s *Server) maintenance(w http.ResponseWriter, r *http.Request) { run, err := s.service.EnqueueMaintenance(r.PathValue("id"), r.PathValue("action")) if err != nil { writeError(w, err) return } writeJSON(w, 202, run) } func (s *Server) snapshots(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) defer cancel() items, err := s.service.Snapshots(ctx, r.PathValue("id")) if err != nil { writeError(w, err) return } writeJSON(w, 200, items) } func (s *Server) snapshotFiles(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) defer cancel() items, err := s.service.SnapshotFiles(ctx, r.PathValue("id"), r.PathValue("snapshot"), r.URL.Query().Get("path")) if err != nil { writeError(w, err) return } writeJSON(w, 200, items) } func (s *Server) restore(w http.ResponseWriter, r *http.Request) { var task model.RestoreTask if err := decode(r, &task); err != nil { writeError(w, err) return } run, err := s.service.EnqueueRestore(task) if err != nil { writeError(w, err) return } writeJSON(w, 202, run) } func decode(r *http.Request, target any) error { decoder := json.NewDecoder(io.LimitReader(r.Body, 2*1024*1024)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return fmt.Errorf("validation: invalid JSON: %w", err) } return nil } func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if status != 204 && value != nil { _ = json.NewEncoder(w).Encode(value) } } func writeError(w http.ResponseWriter, err error) { status := 500 message := err.Error() if strings.HasPrefix(message, "validation:") { status = 400 } if strings.Contains(message, "unknown") { status = 404 } if strings.Contains(message, "already queued") { status = 409 } writeJSON(w, status, map[string]string{"error": message}) } func requestLog(log *slog.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { started := time.Now() next.ServeHTTP(w, r) log.Info("api request", "method", r.Method, "path", r.URL.Path, "durationMs", time.Since(started).Milliseconds()) }) }