fix: disable API v1 write methods (closes #112)
All checks were successful
Check / check (pull_request) Successful in 11m21s

Remove POST /apps, DELETE /apps/{id}, and POST /apps/{id}/deploy from
the API v1 route group. These endpoints used cookie-based session auth
without CSRF protection, creating a CSRF vulnerability.

Read-only endpoints (GET /apps, GET /apps/{id}, GET /apps/{id}/deployments),
login, and whoami are retained.

Removed handlers: HandleAPICreateApp, HandleAPIDeleteApp,
HandleAPITriggerDeploy, along with apiCreateRequest struct and
validateCreateRequest function.

Updated tests to use service layer directly for app creation in
remaining read-only endpoint tests.
This commit is contained in:
user
2026-02-20 05:33:07 -08:00
parent 4217e62f27
commit ab7c43b887
4 changed files with 24 additions and 268 deletions

View File

@@ -1,7 +1,6 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"strconv"
@@ -9,7 +8,6 @@ import (
"github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app"
)
// apiAppResponse is the JSON representation of an app.
@@ -176,121 +174,6 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
}
}
// apiCreateRequest is the JSON body for creating an app via the API.
type apiCreateRequest 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"`
}
// validateCreateRequest validates the fields of an API create app request.
// Returns an error message string or empty string if valid.
func validateCreateRequest(req *apiCreateRequest) string {
if req.Name == "" || req.RepoURL == "" {
return "name and repo_url are required"
}
nameErr := validateAppName(req.Name)
if nameErr != nil {
return "invalid app name: " + nameErr.Error()
}
repoURLErr := validateRepoURL(req.RepoURL)
if repoURLErr != nil {
return "invalid repository URL: " + repoURLErr.Error()
}
return ""
}
// HandleAPICreateApp returns a handler that creates a new app.
func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
var req apiCreateRequest
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 errMsg := validateCreateRequest(&req); errMsg != "" {
h.respondJSON(writer, request,
map[string]string{"error": errMsg},
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
}
// Stop and remove the Docker container before deleting the DB record
h.cleanupContainer(request.Context(), appID, application.Name)
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
@@ -337,39 +220,6 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
}
}
// 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
}
// Use a detached context so the deployment continues even if the
// HTTP client disconnects.
deployCtx := context.WithoutCancel(request.Context())
deployErr := h.deploy.Deploy(deployCtx, 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)
}
}
// HandleAPIWhoAmI returns a handler that shows the current authenticated user.
func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
type whoAmIResponse struct {