From b1c265bbef955e54f67e376c4d2f1ff64db7be0b Mon Sep 17 00:00:00 2001 From: Jamie Banks Date: Wed, 1 Jul 2026 09:17:30 +0100 Subject: [PATCH] feat: add basic authenticated endpoint to download the latest database backup --- web/server.go | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/web/server.go b/web/server.go index 92d3545..09fcd18 100644 --- a/web/server.go +++ b/web/server.go @@ -70,6 +70,8 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/run/", s.requireAuthAPI(s.handleRunInstance)) s.mux.HandleFunc("/api/download", s.requireAuthAPI(s.handleDownload)) s.mux.HandleFunc("/api/delete", s.requireAuthAPI(s.handleDelete)) + + s.mux.HandleFunc("/download/", s.requireBasicAuth(s.handleLatestDownload)) } var sessionToken string @@ -110,6 +112,24 @@ func (s *Server) requireAuthAPI(next http.HandlerFunc) http.HandlerFunc { } } +func (s *Server) requireBasicAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !s.cfg.Auth.Enabled { + next(w, r) + return + } + + user, pass, ok := r.BasicAuth() + if !ok || user != s.cfg.Auth.Username || pass != s.cfg.Auth.Password { + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + next(w, r) + } +} + func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if !s.cfg.Auth.Enabled { http.Redirect(w, r, "/", http.StatusFound) @@ -190,6 +210,70 @@ func (s *Server) resolveFilePath(instName, dbName, fileName string) (string, err return targetPath, nil } +func (s *Server) handleLatestDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Path should be /download// or /download/// + path := strings.TrimPrefix(r.URL.Path, "/download/") + path = strings.TrimSuffix(path, "/") + parts := strings.Split(path, "/") + + if len(parts) != 2 { + http.Error(w, "Invalid path format. Expected /download///", http.StatusBadRequest) + return + } + + instName := parts[0] + dbName := parts[1] + + inst := s.manager.GetInstance(instName) + if inst == nil { + http.Error(w, "Instance not found", http.StatusNotFound) + return + } + + baseDir := filepath.Clean(inst.Config.BackupDir) + dbPath := filepath.Join(baseDir, dbName) + + files, err := os.ReadDir(dbPath) + if err != nil { + http.Error(w, "Database backups not found", http.StatusNotFound) + return + } + + var latestFile os.FileInfo + for _, entry := range files { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + if latestFile == nil || info.ModTime().After(latestFile.ModTime()) { + latestFile = info + } + } + + if latestFile == nil { + http.Error(w, "No backups found for this database", http.StatusNotFound) + return + } + + filePath, err := s.resolveFilePath(instName, dbName, latestFile.Name()) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", latestFile.Name())) + w.Header().Set("Content-Type", "application/x-gzip") + http.ServeFile(w, r, filePath) +} + func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)