package handlers import ( "encoding/json" "net/http" "strconv" "github.com/go-chi/chi/v5" "git.eeqj.de/sneak/upaas/internal/middleware" "git.eeqj.de/sneak/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/service/app" ) // apiAppResponse is the JSON representation of an app. type apiAppResponse struct { ID string `json:"id"` Name string `json:"name"` RepoURL string `json:"repoUrl"` Branch string `json:"branch"` DockerfilePath string `json:"dockerfilePath"` Status string `json:"status"` WebhookSecret string `json:"webhookSecret"` SSHPublicKey string `json:"sshPublicKey"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } // apiDeploymentResponse is the JSON representation of a deployment. type apiDeploymentResponse struct { ID int64 `json:"id"` AppID string `json:"appId"` CommitSHA string `json:"commitSha,omitempty"` Status string `json:"status"` Duration string `json:"duration,omitempty"` StartedAt string `json:"startedAt"` FinishedAt string `json:"finishedAt,omitempty"` } func appToAPI(a *models.App) apiAppResponse { return apiAppResponse{ ID: a.ID, Name: a.Name, RepoURL: a.RepoURL, Branch: a.Branch, DockerfilePath: a.DockerfilePath, Status: string(a.Status), WebhookSecret: a.WebhookSecret, SSHPublicKey: a.SSHPublicKey, CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"), } } func deploymentToAPI(d *models.Deployment) apiDeploymentResponse { resp := apiDeploymentResponse{ ID: d.ID, AppID: d.AppID, Status: string(d.Status), Duration: d.Duration(), StartedAt: d.StartedAt.Format("2006-01-02T15:04:05Z"), } if d.CommitSHA.Valid { resp.CommitSHA = d.CommitSHA.String } if d.FinishedAt.Valid { resp.FinishedAt = d.FinishedAt.Time.Format("2006-01-02T15:04:05Z") } return resp } // HandleAPIListApps returns a handler that lists all apps as JSON. func (h *Handlers) HandleAPIListApps() http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { apps, err := h.appService.ListApps(request.Context()) if err != nil { h.respondJSON(writer, request, map[string]string{"error": "failed to list apps"}, http.StatusInternalServerError) return } result := make([]apiAppResponse, 0, len(apps)) for _, a := range apps { result = append(result, appToAPI(a)) } h.respondJSON(writer, request, result, http.StatusOK) } } // HandleAPIGetApp returns a handler that gets a single app by ID. func (h *Handlers) HandleAPIGetApp() http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { appID := chi.URLParam(request, "id") application, err := h.appService.GetApp(request.Context(), appID) if err != nil { h.respondJSON(writer, request, map[string]string{"error": "internal server error"}, http.StatusInternalServerError) return } if application == nil { h.respondJSON(writer, request, map[string]string{"error": "app not found"}, http.StatusNotFound) return } h.respondJSON(writer, request, appToAPI(application), http.StatusOK) } } // HandleAPICreateApp returns a handler that creates a new app. func (h *Handlers) HandleAPICreateApp() http.HandlerFunc { type createRequest struct { Name string `json:"name"` RepoURL string `json:"repoUrl"` Branch string `json:"branch"` DockerfilePath string `json:"dockerfilePath"` DockerNetwork string `json:"dockerNetwork"` NtfyTopic string `json:"ntfyTopic"` SlackWebhook string `json:"slackWebhook"` } return func(writer http.ResponseWriter, request *http.Request) { var req createRequest decodeErr := json.NewDecoder(request.Body).Decode(&req) if decodeErr != nil { h.respondJSON(writer, request, map[string]string{"error": "invalid JSON body"}, http.StatusBadRequest) return } if req.Name == "" || req.RepoURL == "" { h.respondJSON(writer, request, map[string]string{"error": "name and repo_url are required"}, http.StatusBadRequest) return } nameErr := validateAppName(req.Name) if nameErr != nil { h.respondJSON(writer, request, map[string]string{"error": "invalid app name: " + nameErr.Error()}, http.StatusBadRequest) return } createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{ Name: req.Name, RepoURL: req.RepoURL, Branch: req.Branch, DockerfilePath: req.DockerfilePath, DockerNetwork: req.DockerNetwork, NtfyTopic: req.NtfyTopic, SlackWebhook: req.SlackWebhook, }) if createErr != nil { h.log.Error("api: failed to create app", "error", createErr) h.respondJSON(writer, request, map[string]string{"error": "failed to create app"}, http.StatusInternalServerError) return } h.respondJSON(writer, request, appToAPI(createdApp), http.StatusCreated) } } // HandleAPIDeleteApp returns a handler that deletes an app. func (h *Handlers) HandleAPIDeleteApp() http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { appID := chi.URLParam(request, "id") application, err := h.appService.GetApp(request.Context(), appID) if err != nil { h.respondJSON(writer, request, map[string]string{"error": "internal server error"}, http.StatusInternalServerError) return } if application == nil { h.respondJSON(writer, request, map[string]string{"error": "app not found"}, http.StatusNotFound) return } deleteErr := h.appService.DeleteApp(request.Context(), application) if deleteErr != nil { h.log.Error("api: failed to delete app", "error", deleteErr) h.respondJSON(writer, request, map[string]string{"error": "failed to delete app"}, http.StatusInternalServerError) return } h.respondJSON(writer, request, map[string]string{"status": "deleted"}, http.StatusOK) } } // deploymentsPageLimit is the default number of deployments per page. const deploymentsPageLimit = 20 // HandleAPIListDeployments returns a handler that lists deployments for an app. func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { appID := chi.URLParam(request, "id") application, err := h.appService.GetApp(request.Context(), appID) if err != nil || application == nil { h.respondJSON(writer, request, map[string]string{"error": "app not found"}, http.StatusNotFound) return } limit := deploymentsPageLimit if l := request.URL.Query().Get("limit"); l != "" { parsed, parseErr := strconv.Atoi(l) if parseErr == nil && parsed > 0 { limit = parsed } } deployments, deployErr := application.GetDeployments( request.Context(), limit, ) if deployErr != nil { h.respondJSON(writer, request, map[string]string{"error": "failed to list deployments"}, http.StatusInternalServerError) return } result := make([]apiDeploymentResponse, 0, len(deployments)) for _, d := range deployments { result = append(result, deploymentToAPI(d)) } h.respondJSON(writer, request, result, http.StatusOK) } } // HandleAPITriggerDeploy returns a handler that triggers a deployment for an app. func (h *Handlers) HandleAPITriggerDeploy() http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { appID := chi.URLParam(request, "id") application, err := h.appService.GetApp(request.Context(), appID) if err != nil || application == nil { h.respondJSON(writer, request, map[string]string{"error": "app not found"}, http.StatusNotFound) return } deployErr := h.deploy.Deploy(request.Context(), application, nil, true) if deployErr != nil { h.log.Error("api: failed to trigger deploy", "error", deployErr) h.respondJSON(writer, request, map[string]string{"error": deployErr.Error()}, http.StatusConflict) return } h.respondJSON(writer, request, map[string]string{"status": "deploying"}, http.StatusAccepted) } } // HandleAPICreateToken returns a handler that creates an API token. func (h *Handlers) HandleAPICreateToken() http.HandlerFunc { type createTokenRequest struct { Name string `json:"name"` } type createTokenResponse struct { Token string `json:"token"` Name string `json:"name"` ID int64 `json:"id"` } return func(writer http.ResponseWriter, request *http.Request) { user := middleware.APIUserFromContext(request.Context()) if user == nil { h.respondJSON(writer, request, map[string]string{"error": "unauthorized"}, http.StatusUnauthorized) return } var req createTokenRequest decodeErr := json.NewDecoder(request.Body).Decode(&req) if decodeErr != nil { req.Name = "default" } if req.Name == "" { req.Name = "default" } rawToken, token, err := models.GenerateAPIToken( request.Context(), h.db, user.ID, req.Name, ) if err != nil { h.log.Error("api: failed to create token", "error", err) h.respondJSON(writer, request, map[string]string{"error": "failed to create token"}, http.StatusInternalServerError) return } h.respondJSON(writer, request, createTokenResponse{ Token: rawToken, Name: token.Name, ID: token.ID, }, http.StatusCreated) } } // HandleAPIWhoAmI returns a handler that shows the current authenticated user. func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc { type whoAmIResponse struct { UserID int64 `json:"userId"` Username string `json:"username"` } return func(writer http.ResponseWriter, request *http.Request) { user := middleware.APIUserFromContext(request.Context()) if user == nil { h.respondJSON(writer, request, map[string]string{"error": "unauthorized"}, http.StatusUnauthorized) return } h.respondJSON(writer, request, whoAmIResponse{ UserID: user.ID, Username: user.Username, }, http.StatusOK) } }