box/internal/app/routes_handlers.go
2026-05-14 19:21:15 +02:00

251 lines
7.6 KiB
Go

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})
}