upaas/internal/handlers/app.go
clawbot 867cdf01ab fix: add ownership verification on env var, label, volume, and port deletion
Verify that the resource's AppID matches the URL path app ID before
allowing deletion. Without this check, any authenticated user could
delete resources belonging to any app by providing the target resource's
ID in the URL regardless of the app ID in the path (IDOR vulnerability).

Closes #19
2026-02-15 21:02:46 -08:00

1071 lines
28 KiB
Go

package handlers
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/templates"
)
const (
// recentDeploymentsLimit is the number of recent deployments to show.
recentDeploymentsLimit = 5
// deploymentsHistoryLimit is the number of deployments to show in history.
deploymentsHistoryLimit = 50
)
// HandleAppNew returns the new app form handler.
func (h *Handlers) HandleAppNew() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
data := h.addGlobals(map[string]any{}, request)
err := tmpl.ExecuteTemplate(writer, "app_new.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// HandleAppCreate handles app creation.
func (h *Handlers) HandleAppCreate() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
parseErr := request.ParseForm()
if parseErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
name := request.FormValue("name")
repoURL := request.FormValue("repo_url")
branch := request.FormValue("branch")
dockerfilePath := request.FormValue("dockerfile_path")
data := h.addGlobals(map[string]any{
"Name": name,
"RepoURL": repoURL,
"Branch": branch,
"DockerfilePath": dockerfilePath,
}, request)
if name == "" || repoURL == "" {
data["Error"] = "Name and repository URL are required"
_ = tmpl.ExecuteTemplate(writer, "app_new.html", data)
return
}
if branch == "" {
branch = "main"
}
if dockerfilePath == "" {
dockerfilePath = "Dockerfile"
}
createdApp, createErr := h.appService.CreateApp(
request.Context(),
app.CreateAppInput{
Name: name,
RepoURL: repoURL,
Branch: branch,
DockerfilePath: dockerfilePath,
},
)
if createErr != nil {
h.log.Error("failed to create app", "error", createErr)
data["Error"] = "Failed to create app: " + createErr.Error()
_ = tmpl.ExecuteTemplate(writer, "app_new.html", data)
return
}
http.Redirect(writer, request, "/apps/"+createdApp.ID, http.StatusSeeOther)
}
}
// HandleAppDetail returns the app detail handler.
func (h *Handlers) HandleAppDetail() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil {
h.log.Error("failed to find app", "error", findErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
if application == nil {
http.NotFound(writer, request)
return
}
envVars, _ := application.GetEnvVars(request.Context())
labels, _ := application.GetLabels(request.Context())
volumes, _ := application.GetVolumes(request.Context())
ports, _ := application.GetPorts(request.Context())
deployments, _ := application.GetDeployments(
request.Context(),
recentDeploymentsLimit,
)
// Get latest deployment for build logs pane
var latestDeployment *models.Deployment
if len(deployments) > 0 {
latestDeployment = deployments[0]
}
webhookURL := "https://" + request.Host + "/webhook/" + application.WebhookSecret
deployKey := formatDeployKey(application.SSHPublicKey, application.CreatedAt, application.Name)
data := h.addGlobals(map[string]any{
"App": application,
"EnvVars": envVars,
"Labels": labels,
"Volumes": volumes,
"Ports": ports,
"Deployments": deployments,
"LatestDeployment": latestDeployment,
"WebhookURL": webhookURL,
"DeployKey": deployKey,
"Success": request.URL.Query().Get("success"),
}, request)
err := tmpl.ExecuteTemplate(writer, "app_detail.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// HandleAppEdit returns the app edit form handler.
func (h *Handlers) HandleAppEdit() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil {
h.log.Error("failed to find app", "error", findErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
if application == nil {
http.NotFound(writer, request)
return
}
data := h.addGlobals(map[string]any{
"App": application,
}, request)
err := tmpl.ExecuteTemplate(writer, "app_edit.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// HandleAppUpdate handles app updates.
func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
parseErr := request.ParseForm()
if parseErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
application.Name = request.FormValue("name")
application.RepoURL = request.FormValue("repo_url")
application.Branch = request.FormValue("branch")
application.DockerfilePath = request.FormValue("dockerfile_path")
if network := request.FormValue("docker_network"); network != "" {
application.DockerNetwork = sql.NullString{String: network, Valid: true}
} else {
application.DockerNetwork = sql.NullString{}
}
if ntfy := request.FormValue("ntfy_topic"); ntfy != "" {
application.NtfyTopic = sql.NullString{String: ntfy, Valid: true}
} else {
application.NtfyTopic = sql.NullString{}
}
if slack := request.FormValue("slack_webhook"); slack != "" {
application.SlackWebhook = sql.NullString{String: slack, Valid: true}
} else {
application.SlackWebhook = sql.NullString{}
}
saveErr := application.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update app", "error", saveErr)
data := h.addGlobals(map[string]any{
"App": application,
"Error": "Failed to update app",
}, request)
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
return
}
redirectURL := "/apps/" + application.ID + "?success=updated"
http.Redirect(writer, request, redirectURL, http.StatusSeeOther)
}
}
// HandleAppDelete handles app deletion.
func (h *Handlers) HandleAppDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
// Stop and remove the Docker container before deleting the DB record
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
if containerErr == nil && containerInfo != nil {
if containerInfo.Running {
stopErr := h.docker.StopContainer(request.Context(), containerInfo.ID)
if stopErr != nil {
h.log.Error("failed to stop container during app deletion",
"error", stopErr, "app", application.Name,
"container", containerInfo.ID)
}
}
removeErr := h.docker.RemoveContainer(request.Context(), containerInfo.ID, true)
if removeErr != nil {
h.log.Error("failed to remove container during app deletion",
"error", removeErr, "app", application.Name,
"container", containerInfo.ID)
} else {
h.log.Info("removed container during app deletion",
"app", application.Name, "container", containerInfo.ID)
}
}
deleteErr := application.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete app", "error", deleteErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
http.Redirect(writer, request, "/", http.StatusSeeOther)
}
}
// HandleAppDeploy triggers a manual deployment.
func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
// Trigger deployment in background with a detached context
// so the deployment continues even if the HTTP request is cancelled
deployCtx := context.WithoutCancel(request.Context())
go func(ctx context.Context, appToDeploy *models.App) {
deployErr := h.deploy.Deploy(ctx, appToDeploy, nil)
if deployErr != nil {
h.log.Error(
"deployment failed",
"error", deployErr,
"app", appToDeploy.Name,
)
}
}(deployCtx, application)
http.Redirect(
writer,
request,
"/apps/"+application.ID+"/deployments",
http.StatusSeeOther,
)
}
}
// HandleAppDeployments returns the deployments history handler.
func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
deployments, _ := application.GetDeployments(
request.Context(),
deploymentsHistoryLimit,
)
data := h.addGlobals(map[string]any{
"App": application,
"Deployments": deployments,
}, request)
err := tmpl.ExecuteTemplate(writer, "deployments.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
}
}
// defaultLogTail is the default number of log lines to fetch.
const defaultLogTail = "500"
// HandleAppLogs returns the container logs handler.
func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
if containerErr != nil || containerInfo == nil {
_, _ = writer.Write([]byte("No container running\n"))
return
}
tail := request.URL.Query().Get("tail")
if tail == "" {
tail = defaultLogTail
}
logs, logsErr := h.docker.ContainerLogs(
request.Context(),
containerInfo.ID,
tail,
)
if logsErr != nil {
h.log.Error("failed to get container logs",
"error", logsErr,
"app", application.Name,
"container", containerInfo.ID,
)
_, _ = writer.Write([]byte("Failed to fetch container logs\n"))
return
}
_, _ = writer.Write([]byte(logs))
}
}
// HandleDeploymentLogsAPI returns JSON with deployment logs.
func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
deploymentIDStr := chi.URLParam(request, "deploymentID")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
deploymentID, parseErr := strconv.ParseInt(deploymentIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
deployment, deployErr := models.FindDeployment(request.Context(), h.db, deploymentID)
if deployErr != nil || deployment == nil || deployment.AppID != appID {
http.NotFound(writer, request)
return
}
writer.Header().Set("Content-Type", "application/json")
logs := ""
if deployment.Logs.Valid {
logs = deployment.Logs.String
}
response := map[string]any{
"logs": logs,
"status": deployment.Status,
}
_ = json.NewEncoder(writer).Encode(response)
}
}
// HandleDeploymentLogDownload serves the log file for download.
func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
deploymentIDStr := chi.URLParam(request, "deploymentID")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
deploymentID, parseErr := strconv.ParseInt(deploymentIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
deployment, deployErr := models.FindDeployment(request.Context(), h.db, deploymentID)
if deployErr != nil || deployment == nil || deployment.AppID != appID {
http.NotFound(writer, request)
return
}
// Get the log file path from deploy service
logPath := h.deploy.GetLogFilePath(application, deployment)
if logPath == "" {
http.NotFound(writer, request)
return
}
// Check if file exists
_, err := os.Stat(logPath)
if os.IsNotExist(err) {
http.NotFound(writer, request)
return
}
if err != nil {
h.log.Error("failed to stat log file", "error", err, "path", logPath)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
// Extract filename for Content-Disposition header
filename := filepath.Base(logPath)
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
http.ServeFile(writer, request, logPath)
}
}
// containerLogsAPITail is the default number of log lines for the container logs API.
const containerLogsAPITail = "100"
// HandleContainerLogsAPI returns JSON with container logs.
func (h *Handlers) HandleContainerLogsAPI() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
writer.Header().Set("Content-Type", "application/json")
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
if containerErr != nil || containerInfo == nil {
response := map[string]any{
"logs": "No container running\n",
"status": "stopped",
}
_ = json.NewEncoder(writer).Encode(response)
return
}
logs, logsErr := h.docker.ContainerLogs(
request.Context(),
containerInfo.ID,
containerLogsAPITail,
)
if logsErr != nil {
h.log.Error("failed to get container logs",
"error", logsErr,
"app", application.Name,
"container", containerInfo.ID,
)
response := map[string]any{
"logs": "Failed to fetch container logs\n",
"status": "error",
}
_ = json.NewEncoder(writer).Encode(response)
return
}
status := "stopped"
if containerInfo.Running {
status = "running"
}
response := map[string]any{
"logs": logs,
"status": status,
}
_ = json.NewEncoder(writer).Encode(response)
}
}
// HandleAppStatusAPI returns JSON with app status and latest deployment info.
func (h *Handlers) HandleAppStatusAPI() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
writer.Header().Set("Content-Type", "application/json")
// Get latest deployment
deployments, _ := application.GetDeployments(request.Context(), 1)
var latestDeploymentID int64
var latestDeploymentStatus string
if len(deployments) > 0 {
latestDeploymentID = deployments[0].ID
latestDeploymentStatus = string(deployments[0].Status)
}
response := map[string]any{
"status": string(application.Status),
"latestDeploymentID": latestDeploymentID,
"latestDeploymentStatus": latestDeploymentStatus,
}
_ = json.NewEncoder(writer).Encode(response)
}
}
// HandleRecentDeploymentsAPI returns JSON with recent deployments.
func (h *Handlers) HandleRecentDeploymentsAPI() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
deployments, deployErr := application.GetDeployments(
request.Context(),
recentDeploymentsLimit,
)
if deployErr != nil {
h.log.Error("failed to get deployments", "error", deployErr, "app", appID)
deployments = []*models.Deployment{}
}
// Build response with formatted data
deploymentsData := make([]map[string]any, 0, len(deployments))
for _, d := range deployments {
deploymentsData = append(deploymentsData, map[string]any{
"id": d.ID,
"status": string(d.Status),
"duration": d.Duration(),
"shortCommit": d.ShortCommit(),
"finishedAtISO": d.FinishedAtISO(),
"finishedAtLabel": d.FinishedAtFormatted(),
})
}
writer.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(writer).Encode(map[string]any{
"deployments": deploymentsData,
})
}
}
// containerAction represents a container operation type.
type containerAction string
const (
actionRestart containerAction = "restart"
actionStop containerAction = "stop"
actionStart containerAction = "start"
)
// handleContainerAction is a helper for container control operations.
func (h *Handlers) handleContainerAction(
writer http.ResponseWriter,
request *http.Request,
action containerAction,
) {
appID := chi.URLParam(request, "id")
ctx := request.Context()
application, findErr := models.FindApp(ctx, h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID)
if containerErr != nil || containerInfo == nil {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
containerID := containerInfo.ID
var actionErr error
switch action {
case actionRestart:
stopErr := h.docker.StopContainer(ctx, containerID)
if stopErr != nil {
h.log.Error("failed to stop container for restart",
"error", stopErr, "app", application.Name, "container", containerID)
}
actionErr = h.docker.StartContainer(ctx, containerID)
case actionStop:
actionErr = h.docker.StopContainer(ctx, containerID)
case actionStart:
actionErr = h.docker.StartContainer(ctx, containerID)
}
if actionErr != nil {
h.log.Error("container action failed",
"action", action, "error", actionErr,
"app", application.Name, "container", containerID)
} else {
h.log.Info("container action completed",
"action", action, "app", application.Name, "container", containerID)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
// HandleAppRestart handles restarting an app's container.
func (h *Handlers) HandleAppRestart() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
h.handleContainerAction(writer, request, actionRestart)
}
}
// HandleAppStop handles stopping an app's container.
func (h *Handlers) HandleAppStop() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
h.handleContainerAction(writer, request, actionStop)
}
}
// HandleAppStart handles starting an app's container.
func (h *Handlers) HandleAppStart() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
h.handleContainerAction(writer, request, actionStart)
}
}
// addKeyValueToApp is a helper for adding key-value pairs (env vars or labels).
func (h *Handlers) addKeyValueToApp(
writer http.ResponseWriter,
request *http.Request,
createAndSave func(
ctx context.Context,
application *models.App,
key, value string,
) error,
) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
parseErr := request.ParseForm()
if parseErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
key := request.FormValue("key")
value := request.FormValue("value")
if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
saveErr := createAndSave(request.Context(), application, key, value)
if saveErr != nil {
h.log.Error("failed to add key-value pair", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
}
// HandleEnvVarAdd handles adding an environment variable.
func (h *Handlers) HandleEnvVarAdd() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
h.addKeyValueToApp(
writer,
request,
func(ctx context.Context, application *models.App, key, value string) error {
envVar := models.NewEnvVar(h.db)
envVar.AppID = application.ID
envVar.Key = key
envVar.Value = value
return envVar.Save(ctx)
},
)
}
}
// HandleEnvVarDelete handles deleting an environment variable.
func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
envVarIDStr := chi.URLParam(request, "envID")
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
if findErr != nil || envVar == nil || envVar.AppID != appID {
http.NotFound(writer, request)
return
}
deleteErr := envVar.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete env var", "error", deleteErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// HandleLabelAdd handles adding a label.
func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
h.addKeyValueToApp(
writer,
request,
func(ctx context.Context, application *models.App, key, value string) error {
label := models.NewLabel(h.db)
label.AppID = application.ID
label.Key = key
label.Value = value
return label.Save(ctx)
},
)
}
}
// HandleLabelDelete handles deleting a label.
func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
labelIDStr := chi.URLParam(request, "labelID")
labelID, parseErr := strconv.ParseInt(labelIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
label, findErr := models.FindLabel(request.Context(), h.db, labelID)
if findErr != nil || label == nil || label.AppID != appID {
http.NotFound(writer, request)
return
}
deleteErr := label.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete label", "error", deleteErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// HandleVolumeAdd handles adding a volume mount.
func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
parseErr := request.ParseForm()
if parseErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
hostPath := request.FormValue("host_path")
containerPath := request.FormValue("container_path")
readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" {
http.Redirect(
writer,
request,
"/apps/"+application.ID,
http.StatusSeeOther,
)
return
}
volume := models.NewVolume(h.db)
volume.AppID = application.ID
volume.HostPath = hostPath
volume.ContainerPath = containerPath
volume.ReadOnly = readOnly
saveErr := volume.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to add volume", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
}
}
// HandleVolumeDelete handles deleting a volume mount.
func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
volumeIDStr := chi.URLParam(request, "volumeID")
volumeID, parseErr := strconv.ParseInt(volumeIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
volume, findErr := models.FindVolume(request.Context(), h.db, volumeID)
if findErr != nil || volume == nil || volume.AppID != appID {
http.NotFound(writer, request)
return
}
deleteErr := volume.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete volume", "error", deleteErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// HandlePortAdd handles adding a port mapping.
func (h *Handlers) HandlePortAdd() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
parseErr := request.ParseForm()
if parseErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
hostPort, containerPort, valid := parsePortValues(
request.FormValue("host_port"),
request.FormValue("container_port"),
)
if !valid {
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
protocol := request.FormValue("protocol")
if protocol != "tcp" && protocol != "udp" {
protocol = "tcp"
}
port := models.NewPort(h.db)
port.AppID = application.ID
port.HostPort = hostPort
port.ContainerPort = containerPort
port.Protocol = models.PortProtocol(protocol)
saveErr := port.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to save port", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
}
}
// parsePortValues parses and validates host and container port strings.
func parsePortValues(hostPortStr, containerPortStr string) (int, int, bool) {
hostPort, hostErr := strconv.Atoi(hostPortStr)
containerPort, containerErr := strconv.Atoi(containerPortStr)
if hostErr != nil || containerErr != nil || hostPort <= 0 || containerPort <= 0 {
return 0, 0, false
}
return hostPort, containerPort, true
}
// HandlePortDelete handles deleting a port mapping.
func (h *Handlers) HandlePortDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
portIDStr := chi.URLParam(request, "portID")
portID, parseErr := strconv.ParseInt(portIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
port, findErr := models.FindPort(request.Context(), h.db, portID)
if findErr != nil || port == nil || port.AppID != appID {
http.NotFound(writer, request)
return
}
deleteErr := port.Delete(request.Context())
if deleteErr != nil {
h.log.Error("failed to delete port", "error", deleteErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// formatDeployKey formats an SSH public key with a descriptive comment.
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {
const minKeyParts = 2
parts := strings.Fields(pubKey)
if len(parts) < minKeyParts {
return pubKey
}
comment := "upaas_" + createdAt.Format("2006-01-02") + "_" + appName
return parts[0] + " " + parts[1] + " " + comment
}