package api import ( "encoding/json" "fmt" "log" "net/http" "strconv" "strings" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // Router handles all /api/* requests. type Router struct { cfg *config.Config stackMgr *stacks.Manager logger *log.Logger } func NewRouter(cfg *config.Config, stackMgr *stacks.Manager, logger *log.Logger) *Router { return &Router{cfg: cfg, stackMgr: stackMgr, 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/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")) // GET /api/stacks/{name}/logs case hasSuffix(path, "/logs") && req.Method == http.MethodGet: r.getStackLogs(w, req, extractName(path, "/logs")) // GET /api/system/info case path == "/system/info" && req.Method == http.MethodGet: r.systemInfo(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) { 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) 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) 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) systemInfo(w http.ResponseWriter, _ *http.Request) { info := system.GetInfo(r.cfg.Paths.HDDPath) writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: info}) } // --- 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/") return strings.TrimSuffix(s, suffix) } 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) } }