package app import ( "encoding/json" "net/http" osuser "os/user" "strconv" "strings" ) func (a *App) routes() http.Handler { mux := http.NewServeMux() mux.Handle("/", http.FileServer(http.Dir("./web"))) mux.HandleFunc("/api/me", a.withAuth(a.handleMe)) mux.HandleFunc("/api/workspaces", a.withAuth(a.handleWorkspaces)) mux.HandleFunc("/api/projects", a.withAuth(a.handleProjects)) mux.HandleFunc("/api/projects/", a.withAuth(a.handleProjectSubroutes)) mux.HandleFunc("/api/webhooks/deploy/", a.handleDeployWebhook) return loggingMiddleware(mux) } func (a *App) handleMe(w http.ResponseWriter, _ *http.Request, user User) { writeJSON(w, http.StatusOK, user) } func (a *App) handleWorkspaces(w http.ResponseWriter, r *http.Request, user User) { switch r.Method { case http.MethodGet: items, err := a.listWorkspaces(r.Context(), user.Username) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, items) case http.MethodPost: var req struct { Name string `json:"name"` UnixUser string `json:"unix_user"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } req.Name = strings.TrimSpace(req.Name) req.UnixUser = strings.TrimSpace(req.UnixUser) if req.Name == "" || req.UnixUser == "" { http.Error(w, "name and unix_user are required", http.StatusBadRequest) return } if _, err := osuser.Lookup(req.UnixUser); err != nil { http.Error(w, "unix_user does not exist on host", http.StatusBadRequest) return } item, err := a.createWorkspace(r.Context(), user.Username, req.Name, req.UnixUser) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusCreated, item) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (a *App) handleProjects(w http.ResponseWriter, r *http.Request, user User) { switch r.Method { case http.MethodGet: projects, err := a.listProjects(r.Context(), user.Username) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, projects) case http.MethodPost: var req struct { Name string `json:"name"` Description string `json:"description"` RepoPrivate bool `json:"repo_private"` RepoURL string `json:"repo_url"` TargetPort int `json:"target_port"` WorkspaceID int64 `json:"workspace_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } if strings.TrimSpace(req.Name) == "" { http.Error(w, "name is required", http.StatusBadRequest) return } if req.WorkspaceID <= 0 { http.Error(w, "workspace_id is required", http.StatusBadRequest) return } req.RepoURL = strings.TrimSpace(req.RepoURL) if req.RepoURL != "" && !isValidRepoURL(req.RepoURL) { http.Error(w, "repo_url must be a valid repo URL (https://..., ssh://..., or git@...)", http.StatusBadRequest) return } if req.TargetPort == 0 { req.TargetPort = 8000 } if req.TargetPort < 1 || req.TargetPort > 65535 { http.Error(w, "target_port must be 1-65535", http.StatusBadRequest) return } project, err := a.createProject(r.Context(), user, req.WorkspaceID, req.Name, req.Description, req.RepoPrivate, req.RepoURL, req.TargetPort) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusCreated, project) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (a *App) handleProjectSubroutes(w http.ResponseWriter, r *http.Request, user User) { path := strings.TrimPrefix(r.URL.Path, "/api/projects/") parts := strings.Split(path, "/") if len(parts) < 1 || parts[0] == "" { http.Error(w, "bad path", http.StatusBadRequest) return } projectID, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { http.Error(w, "invalid project id", http.StatusBadRequest) return } if len(parts) == 1 { http.Error(w, "unsupported endpoint", http.StatusNotFound) return } switch parts[1] { case "env": a.handleProjectEnv(w, r, user, projectID) case "service": a.handleServiceRegenerate(w, r, user, projectID) case "status": a.handleProjectStatus(w, r, user, projectID) case "logs": a.handleProjectLogs(w, r, user, projectID) default: http.Error(w, "unsupported endpoint", http.StatusNotFound) } } func (a *App) handleProjectEnv(w http.ResponseWriter, r *http.Request, user User, projectID int64) { switch r.Method { case http.MethodGet: envVars, err := a.listEnvVars(r.Context(), user.Username, projectID) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusOK, envVars) case http.MethodPost: var req struct { Key string `json:"key"` Value string `json:"value"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } if err := validateEnvKey(req.Key); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := a.upsertEnvVar(r.Context(), user.Username, projectID, req.Key, req.Value); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) case http.MethodDelete: key := r.URL.Query().Get("key") if err := validateEnvKey(key); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := a.deleteEnvVar(r.Context(), user.Username, projectID, key); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (a *App) handleServiceRegenerate(w http.ResponseWriter, r *http.Request, user User, projectID int64) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } p, err := a.getProject(r.Context(), user.Username, projectID) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := a.renderSystemdForProject(r.Context(), p); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]string{"status": "service regenerated"}) } func (a *App) handleProjectStatus(w http.ResponseWriter, r *http.Request, user User, projectID int64) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } p, err := a.getProject(r.Context(), user.Username, projectID) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } status, err := a.getServiceStatus(p) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } writeJSON(w, http.StatusOK, status) } func (a *App) handleProjectLogs(w http.ResponseWriter, r *http.Request, user User, projectID int64) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } p, err := a.getProject(r.Context(), user.Username, projectID) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } lines := 200 if raw := r.URL.Query().Get("lines"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n < 1 || n > 1000 { http.Error(w, "lines must be 1-1000", http.StatusBadRequest) return } lines = n } logs, err := a.getServiceLogs(p, lines) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } writeJSON(w, http.StatusOK, map[string]any{"lines": lines, "logs": logs}) }