package api import ( "context" "encoding/json" "fmt" "log" "net/http" "strconv" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/metrics" "gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // Router handles all /api/* requests. type Router struct { cfg *config.Config sett *settings.Settings stackMgr *stacks.Manager syncer *catalogsync.Syncer cpuCollector *system.CPUCollector backupMgr *backup.Manager crossDriveRunner *backup.CrossDriveRunner metricsStore *metrics.MetricsStore updater *selfupdate.Updater logger *log.Logger } func NewRouter(cfg *config.Config, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, logger *log.Logger) *Router { return &Router{cfg: cfg, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, updater: updater, logger: logger} } type apiResponse struct { OK bool `json:"ok"` Data interface{} `json:"data,omitempty"` Error string `json:"error,omitempty"` Message string `json:"message,omitempty"` } // ServeHTTP routes /api/* requests. func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { path := strings.TrimPrefix(req.URL.Path, "/api") path = strings.TrimSuffix(path, "/") switch { // GET /api/stacks case path == "/stacks" && req.Method == http.MethodGet: r.listStacks(w, req) // POST /api/stacks/rescan — re-scan stacks directory for new/removed stacks case path == "/stacks/rescan" && req.Method == http.MethodPost: r.rescanStacks(w, req) // GET /api/stacks/{name} case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodGet && !hasSubpath(path, "/stacks/"): r.getStack(w, req, trimSegment(path, "/stacks/")) // GET /api/selfupdate/status — must be before hasSuffix-based stack cases case path == "/selfupdate/status" && req.Method == http.MethodGet: r.selfupdateStatus(w, req) // POST /api/selfupdate/check — must be before hasSuffix-based stack cases case path == "/selfupdate/check" && req.Method == http.MethodPost: r.selfupdateCheck(w, req) // POST /api/selfupdate/update — must be before hasSuffix("/update") stack case case path == "/selfupdate/update" && req.Method == http.MethodPost: r.selfupdateTrigger(w, req) // GET /api/stacks/{name}/deploy-fields case hasSuffix(path, "/deploy-fields") && req.Method == http.MethodGet: r.getDeployFields(w, req, extractName(path, "/deploy-fields")) // POST /api/stacks/{name}/deploy case hasSuffix(path, "/deploy") && req.Method == http.MethodPost: r.deployStack(w, req, extractName(path, "/deploy")) // POST /api/stacks/{name}/start case hasSuffix(path, "/start") && req.Method == http.MethodPost: r.actionStack(w, "start", extractName(path, "/start")) // POST /api/stacks/{name}/stop case hasSuffix(path, "/stop") && req.Method == http.MethodPost: r.actionStack(w, "stop", extractName(path, "/stop")) // POST /api/stacks/{name}/restart case hasSuffix(path, "/restart") && req.Method == http.MethodPost: r.actionStack(w, "restart", extractName(path, "/restart")) // POST /api/stacks/{name}/update case hasSuffix(path, "/update") && req.Method == http.MethodPost: r.actionStack(w, "update", extractName(path, "/update")) // POST /api/stacks/{name}/optional-config case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost: r.updateOptionalConfig(w, req, extractName(path, "/optional-config")) // GET /api/stacks/{name}/logs case hasSuffix(path, "/logs") && req.Method == http.MethodGet: r.getStackLogs(w, req, extractName(path, "/logs")) // GET /api/stacks/{name}/hdd-data case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet: r.getStackHDDData(w, req, extractName(path, "/hdd-data")) // GET /api/stacks/{name}/backup-data case hasSuffix(path, "/backup-data") && req.Method == http.MethodGet: r.getStackBackupData(w, req, extractName(path, "/backup-data")) // POST /api/stacks/{name}/remove — remove a deployed (non-orphaned) stack case hasSuffix(path, "/remove") && req.Method == http.MethodPost: r.removeStack(w, req, extractName(path, "/remove")) // DELETE /api/stacks/{name} case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"): r.deleteStack(w, req, trimSegment(path, "/stacks/")) // POST /api/stacks/{name}/cross-backup — save cross-drive config case hasSuffix(path, "/cross-backup") && req.Method == http.MethodPost && !hasSuffix(path, "/cross-backup/run") && !hasSuffix(path, "/cross-backup/status"): r.saveCrossBackupConfig(w, req, extractName(path, "/cross-backup")) // POST /api/stacks/{name}/cross-backup/run — trigger manual run case hasSuffix(path, "/cross-backup/run") && req.Method == http.MethodPost: r.triggerCrossBackup(w, req, extractName(path, "/cross-backup/run")) // GET /api/stacks/{name}/cross-backup/status — poll status case hasSuffix(path, "/cross-backup/status") && req.Method == http.MethodGet: r.getCrossBackupStatus(w, req, extractName(path, "/cross-backup/status")) // POST /api/backup/cross-drive/run-all — trigger all scheduled cross-drive backups case path == "/backup/cross-drive/run-all" && req.Method == http.MethodPost: r.triggerAllCrossBackups(w, req) // POST /api/sync — trigger immediate catalog sync case path == "/sync" && req.Method == http.MethodPost: r.triggerSync(w, req) // GET /api/system/info case path == "/system/info" && req.Method == http.MethodGet: r.systemInfo(w, req) // GET /api/backup/status case path == "/backup/status" && req.Method == http.MethodGet: r.backupStatus(w, req) // POST /api/backup/run case path == "/backup/run" && req.Method == http.MethodPost: r.triggerBackup(w, req) // GET /api/backup/snapshots case path == "/backup/snapshots" && req.Method == http.MethodGet: r.backupSnapshots(w, req) // GET /api/metrics/system case path == "/metrics/system" && req.Method == http.MethodGet: r.metricsSystem(w, req) // GET /api/metrics/containers/summary case path == "/metrics/containers/summary" && req.Method == http.MethodGet: r.metricsContainerSummary(w, req) // GET /api/metrics/containers/{name} case strings.HasPrefix(path, "/metrics/containers/") && req.Method == http.MethodGet: name := strings.TrimPrefix(path, "/metrics/containers/") r.metricsContainer(w, req, name) // GET /api/metrics/sysinfo case path == "/metrics/sysinfo" && req.Method == http.MethodGet: r.metricsSysInfo(w, req) default: writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"}) } } // HealthHandler responds to /api/health (no auth required). func (r *Router) HealthHandler(w http.ResponseWriter, req *http.Request) { writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "felhom-controller is healthy"}) } // --- Stack handlers --- func (r *Router) listStacks(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.stackMgr.GetStacks()}) } func (r *Router) rescanStacks(w http.ResponseWriter, _ *http.Request) { r.logger.Printf("[API] Manual stack rescan requested") if err := r.stackMgr.ScanStacks(); err != nil { r.logger.Printf("[API] Stack rescan failed: %v", err) writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) return } stackCount := len(r.stackMgr.GetStacks()) r.logger.Printf("[API] Stack rescan completed: %d stacks found", stackCount) writeJSON(w, http.StatusOK, apiResponse{ OK: true, Message: fmt.Sprintf("Rescan completed: %d stacks found", stackCount), }) } func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) { stack, ok := r.stackMgr.GetStack(name) if !ok { writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + name}) return } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack}) } func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name string) { meta, appCfg, err := r.stackMgr.GetDeployFields(name) if err != nil { writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()}) return } data := map[string]interface{}{ "metadata": meta, "app_config": appCfg, "domain": r.cfg.Customer.Domain, "logo_url": r.cfg.AppLogoURL(meta.Slug), } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data}) } func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) { limitBody(w, req) r.logger.Printf("[API] Deploy requested for stack: %s", name) var body struct { Values map[string]string `json:"values"` } if err := json.NewDecoder(req.Body).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"}) return } deployReq := stacks.DeployRequest{ StackName: name, Values: body.Values, } warning, err := r.stackMgr.DeployStack(deployReq) if err != nil { r.logger.Printf("[API] Deploy failed for %s: %v", name, err) status := http.StatusInternalServerError if strings.Contains(err.Error(), "already deployed") { status = http.StatusConflict } if strings.Contains(err.Error(), "required field") || strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "kötelező") || strings.Contains(err.Error(), "memória") { status = http.StatusBadRequest } writeJSON(w, status, apiResponse{OK: false, Error: err.Error()}) return } resp := apiResponse{OK: true, Message: "Stack " + name + " deployed"} if warning != "" { resp.Data = map[string]string{"warning": warning} } writeJSON(w, http.StatusOK, resp) } func (r *Router) actionStack(w http.ResponseWriter, action, name string) { r.logger.Printf("[API] %s requested for stack: %s", action, name) // Protected stacks only allow restart — block all other actions if r.cfg.IsProtectedStack(name) && action != "restart" { writeJSON(w, http.StatusForbidden, apiResponse{OK: false, Error: fmt.Sprintf("cannot %s protected stack %s", action, name)}) return } var err error switch action { case "start": err = r.stackMgr.StartStack(name) case "stop": err = r.stackMgr.StopStack(name) case "restart": err = r.stackMgr.RestartStack(name) case "update": err = r.stackMgr.UpdateStack(name) } if err != nil { status := http.StatusInternalServerError if strings.Contains(err.Error(), "protected") { status = http.StatusForbidden } if strings.Contains(err.Error(), "not found") { status = http.StatusNotFound } writeJSON(w, status, apiResponse{OK: false, Error: err.Error()}) return } writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"}) } func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request, name string) { limitBody(w, req) r.logger.Printf("[API] Optional config update requested for stack: %s", name) var body struct { Values map[string]string `json:"values"` } if err := json.NewDecoder(req.Body).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"}) return } if err := r.stackMgr.UpdateOptionalConfig(name, body.Values); err != nil { r.logger.Printf("[API] Optional config update failed for %s: %v", name, err) writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) return } writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"}) } func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name string) { lines := 100 if v := req.URL.Query().Get("lines"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { lines = n } } output, err := r.stackMgr.GetLogs(name, lines) if err != nil { writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) return } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}}) } func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name string) { resp, err := r.stackMgr.GetStackHDDData(name) if err != nil { writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()}) return } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp}) } func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name string) { if name == "" { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"}) return } // Compute the drive path for this stack (HDD or system data path) var drivePath string if r.crossDriveRunner != nil { drivePath = r.crossDriveRunner.GetAppDrivePath(name) } resp, err := r.stackMgr.GetStackBackupData(name, drivePath) if err != nil { writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()}) return } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp}) } func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) { if name == "" { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"}) return } limitBody(w, req) r.logger.Printf("[API] Remove requested for stack: %s", name) var body struct { RemoveHDDData bool `json:"remove_hdd_data"` RemoveBackups bool `json:"remove_backups"` } if err := json.NewDecoder(req.Body).Decode(&body); err != nil { body.RemoveHDDData = false body.RemoveBackups = false } // Compute backup paths to remove if requested var backupPaths []string if body.RemoveBackups && r.crossDriveRunner != nil { drivePath := r.crossDriveRunner.GetAppDrivePath(name) if drivePath != "" { backupPaths = append(backupPaths, backup.AppDBDumpPath(drivePath, name), backup.AppSecondaryRsyncPath(drivePath, name), ) } } resp, err := r.stackMgr.RemoveStack(name, body.RemoveHDDData, backupPaths) if err != nil { r.logger.Printf("[API] Remove failed for %s: %v", name, err) status := http.StatusInternalServerError if strings.Contains(err.Error(), "protected") { status = http.StatusForbidden } if strings.Contains(err.Error(), "not found") { status = http.StatusNotFound } if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") { status = http.StatusConflict } writeJSON(w, status, apiResponse{OK: false, Error: err.Error()}) return } // Clean up cross-drive backup config for this stack if r.sett != nil { if err := r.sett.SetCrossDriveConfig(name, nil); err != nil { r.logger.Printf("[WARN] Failed to clean cross-drive config for %s: %v", name, err) } } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"}) } func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) { limitBody(w, req) r.logger.Printf("[API] Delete requested for stack: %s", name) var body struct { RemoveHDDData bool `json:"remove_hdd_data"` } if err := json.NewDecoder(req.Body).Decode(&body); err != nil { body.RemoveHDDData = false } resp, err := r.stackMgr.DeleteStack(name, body.RemoveHDDData) if err != nil { r.logger.Printf("[API] Delete failed for %s: %v", name, err) status := http.StatusInternalServerError if strings.Contains(err.Error(), "protected") { status = http.StatusForbidden } if strings.Contains(err.Error(), "not found") { status = http.StatusNotFound } if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") { status = http.StatusConflict } writeJSON(w, status, apiResponse{OK: false, Error: err.Error()}) return } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " deleted"}) } func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) { r.logger.Println("[API] Manual catalog sync requested") result := r.syncer.TriggerSync() if !result.OK { writeJSON(w, http.StatusTooManyRequests, apiResponse{OK: false, Error: result.Message}) return } r.logger.Printf("[API] Catalog sync completed: %s", result.Message) writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: result.Message, Data: result}) } func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) { info := system.GetInfo(r.cfg.Paths.HDDPath, r.cpuCollector) syncStatus := r.syncer.Status() data := map[string]interface{}{ "system": info, "sync_status": syncStatus, } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data}) } // --- Backup handlers --- func (r *Router) backupStatus(w http.ResponseWriter, _ *http.Request) { if r.backupMgr == nil { writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{ "enabled": false, }}) return } dbDump, backupSt := r.backupMgr.GetStatus() data := map[string]interface{}{ "enabled": true, "running": r.backupMgr.IsRunning(), } if dbDump != nil { data["db_dump"] = map[string]interface{}{ "last_run": dbDump.LastRun, "success": dbDump.Success, "duration": dbDump.Duration.String(), "count": len(dbDump.Results), } } if backupSt != nil { backupData := map[string]interface{}{ "last_run": backupSt.LastRun, "success": backupSt.Success, "duration": backupSt.Duration.String(), } if backupSt.Snapshot != nil { backupData["snapshot_id"] = backupSt.Snapshot.SnapshotID backupData["files_new"] = backupSt.Snapshot.FilesNew backupData["data_added"] = backupSt.Snapshot.DataAdded } if backupSt.RepoStats != nil { backupData["repo_size"] = backupSt.RepoStats.TotalSize backupData["snapshot_count"] = backupSt.RepoStats.SnapshotCount } data["backup"] = backupData } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data}) } func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) { if r.backupMgr == nil { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"}) return } if r.backupMgr.IsRunning() { writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"}) return } r.logger.Println("[API] Manual backup triggered") go r.backupMgr.RunFullBackup(context.Background()) writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"}) } func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) { if r.backupMgr == nil { writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}}) return } snapshots, err := r.backupMgr.ListAllSnapshots(50) if err != nil { writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) return } // Enrich snapshots with drive labels from storage paths if r.sett != nil { storagePaths := r.sett.GetStoragePaths() for i := range snapshots { repoPath := snapshots[i].RepoPath for _, sp := range storagePaths { if strings.HasPrefix(repoPath, sp.Path) { snapshots[i].DriveLabel = sp.Label break } } } } if snapshots == nil { snapshots = []backup.SnapshotInfo{} } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: snapshots}) } // --- Metrics handlers --- func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) { if r.metricsStore == nil { writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"labels": []int{}, "cpu": []float64{}, "memory": []float64{}, "temp": []float64{}, "load1": []float64{}}}) return } from, to := parseTimeRange(req) resolution := parseResolution(req, 200) samples, err := r.metricsStore.QuerySystemMetrics(from, to, resolution) if err != nil { writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) return } // Flatten into arrays for Chart.js labels := make([]int64, len(samples)) cpu := make([]float64, len(samples)) memory := make([]float64, len(samples)) temp := make([]float64, len(samples)) load1 := make([]float64, len(samples)) for i, s := range samples { labels[i] = s.Timestamp cpu[i] = s.CPUPercent memory[i] = float64(s.MemUsedMB) / 1024.0 // Convert to GB temp[i] = s.TempCelsius load1[i] = s.LoadAvg1 } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{ "labels": labels, "cpu": cpu, "memory": memory, "temp": temp, "load1": load1, }}) } func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request) { if r.metricsStore == nil { writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}}) return } summary, err := r.metricsStore.QueryContainerSummary() if err != nil { writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) return } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: summary}) } func (r *Router) metricsContainer(w http.ResponseWriter, req *http.Request, name string) { if r.metricsStore == nil { writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"labels": []int{}, "cpu": []float64{}, "memory": []float64{}}}) return } from, to := parseTimeRange(req) resolution := parseResolution(req, 150) samples, err := r.metricsStore.QueryContainerMetrics(name, from, to, resolution) if err != nil { writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) return } labels := make([]int64, len(samples)) cpu := make([]float64, len(samples)) memory := make([]float64, len(samples)) for i, s := range samples { labels[i] = s.Timestamp cpu[i] = s.CPUPercent memory[i] = s.MemUsageMB } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{ "labels": labels, "cpu": cpu, "memory": memory, }}) } func (r *Router) metricsSysInfo(w http.ResponseWriter, _ *http.Request) { info := metrics.GetStaticInfo() writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: info}) } // --- Cross-drive backup handlers --- func (r *Router) saveCrossBackupConfig(w http.ResponseWriter, req *http.Request, name string) { if r.crossDriveRunner == nil { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"}) return } limitBody(w, req) var body struct { Enabled bool `json:"enabled"` DestinationPath string `json:"destination_path"` Schedule string `json:"schedule"` } if err := json.NewDecoder(req.Body).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"}) return } // Validate schedule if body.Schedule != "daily" && body.Schedule != "weekly" && body.Schedule != "manual" { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "schedule must be 'daily', 'weekly', or 'manual'"}) return } // C9: Validate DestinationPath against registered storage paths to prevent path traversal. if body.Enabled && body.DestinationPath != "" { registeredPaths := r.sett.GetStoragePaths() validDest := false for _, sp := range registeredPaths { if body.DestinationPath == sp.Path { validDest = true break } } if !validDest { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "destination_path must be a registered storage path"}) return } } // Preserve existing runtime status existing := r.sett.GetCrossDriveConfig(name) var lastRun, lastStatus, lastError, lastDuration, lastSize string if existing != nil { lastRun, lastStatus, lastError, lastDuration, lastSize = existing.LastRun, existing.LastStatus, existing.LastError, existing.LastDuration, existing.LastSizeHuman } cfg := &settings.CrossDriveBackup{ Enabled: body.Enabled, Method: "rsync", DestinationPath: body.DestinationPath, Schedule: body.Schedule, LastRun: lastRun, LastStatus: lastStatus, LastError: lastError, LastDuration: lastDuration, LastSizeHuman: lastSize, } if err := r.sett.SetCrossDriveConfig(name, cfg); err != nil { r.logger.Printf("[API] Failed to save cross-drive config for %s: %v", name, err) writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) return } r.logger.Printf("[API] Cross-drive backup config saved for %s: dest=%s schedule=%s", name, body.DestinationPath, body.Schedule) writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Cross-drive backup configuration saved"}) } func (r *Router) triggerCrossBackup(w http.ResponseWriter, req *http.Request, name string) { if r.crossDriveRunner == nil { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"}) return } if r.crossDriveRunner.IsRunning(name) { writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"}) return } r.logger.Printf("[API] Cross-drive backup triggered for: %s", name) go func() { if err := r.crossDriveRunner.RunAppBackup(context.Background(), name); err != nil { r.logger.Printf("[API] Cross-drive backup failed for %s: %v", name, err) } }() writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"}) } func (r *Router) getCrossBackupStatus(w http.ResponseWriter, _ *http.Request, name string) { cfg := r.sett.GetCrossDriveConfig(name) if cfg == nil { writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"configured": false}}) return } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{ "configured": true, "enabled": cfg.Enabled, "method": "rsync", "schedule": cfg.Schedule, "running": r.crossDriveRunner != nil && r.crossDriveRunner.IsRunning(name), "last_run": cfg.LastRun, "last_status": cfg.LastStatus, "last_error": cfg.LastError, "last_duration": cfg.LastDuration, "last_size": cfg.LastSizeHuman, }}) } func (r *Router) triggerAllCrossBackups(w http.ResponseWriter, _ *http.Request) { if r.crossDriveRunner == nil { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"}) return } r.logger.Println("[API] All cross-drive backups triggered") go func() { ctx := context.Background() if err := r.crossDriveRunner.RunAllScheduled(ctx, "daily"); err != nil { r.logger.Printf("[API] Cross-drive run-all error: %v", err) } if err := r.crossDriveRunner.RunAllScheduled(ctx, "weekly"); err != nil { r.logger.Printf("[API] Cross-drive run-all weekly error: %v", err) } if err := r.crossDriveRunner.RunAllScheduled(ctx, "manual"); err != nil { r.logger.Printf("[API] Cross-drive run-all manual error: %v", err) } }() writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Összes mentés elindítva"}) } // parseTimeRange reads range or from/to query params. func parseTimeRange(req *http.Request) (from, to time.Time) { to = time.Now() if rangeStr := req.URL.Query().Get("range"); rangeStr != "" { switch rangeStr { case "1h": from = to.Add(-1 * time.Hour) case "6h": from = to.Add(-6 * time.Hour) case "24h": from = to.Add(-24 * time.Hour) case "7d": from = to.Add(-7 * 24 * time.Hour) case "30d": from = to.Add(-30 * 24 * time.Hour) default: from = to.Add(-24 * time.Hour) // default 24h } return } if fromStr := req.URL.Query().Get("from"); fromStr != "" { if t, err := time.Parse(time.RFC3339, fromStr); err == nil { from = t } } if toStr := req.URL.Query().Get("to"); toStr != "" { if t, err := time.Parse(time.RFC3339, toStr); err == nil { to = t } } if from.IsZero() { from = to.Add(-24 * time.Hour) } return } // parseResolution reads the resolution query param. func parseResolution(req *http.Request, defaultVal int) int { if v := req.URL.Query().Get("resolution"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { return n } } return defaultVal } // --- Helpers --- func hasSuffix(path, suffix string) bool { return strings.HasSuffix(path, suffix) } func hasSubpath(path, prefix string) bool { rest := strings.TrimPrefix(path, prefix) return strings.Contains(rest, "/") } func trimSegment(path, prefix string) string { return strings.TrimPrefix(path, prefix) } func extractName(path, suffix string) string { s := strings.TrimPrefix(path, "/stacks/") name := strings.TrimSuffix(s, suffix) // C7: Reject path traversal characters — name is used in file paths and Docker commands. if name == "" || name == "." || name == ".." || strings.ContainsAny(name, "/\\") { return "" } return name } func (r *Router) selfupdateStatus(w http.ResponseWriter, _ *http.Request) { if r.updater == nil { writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"enabled": false}}) return } writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.updater.GetStatus()}) } func (r *Router) selfupdateCheck(w http.ResponseWriter, _ *http.Request) { if r.updater == nil { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"}) return } result := r.updater.CheckForUpdate() writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: result}) } func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) { if r.updater == nil { writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"}) return } if err := r.updater.TriggerUpdate("manual"); err != nil { writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: err.Error()}) return } r.logger.Println("[API] Manual self-update triggered") writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Frissítés elindítva"}) } func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(v); err != nil { log.Printf("[ERROR] Failed to write JSON response: %v", err) } } // limitBody wraps the request body with a size limit (default 1MB). func limitBody(w http.ResponseWriter, req *http.Request) { req.Body = http.MaxBytesReader(w, req.Body, 1<<20) // 1MB }