package handlers import ( "context" "database/sql" "net/http" "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, _ *http.Request) { data := map[string]any{} 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 := map[string]any{ "Name": name, "RepoURL": repoURL, "Branch": branch, "DockerfilePath": dockerfilePath, } 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()) deployments, _ := application.GetDeployments( request.Context(), recentDeploymentsLimit, ) host := request.Host webhookURL := "https://" + host + "/webhook/" + application.WebhookSecret deployKey := formatDeployKey(application.SSHPublicKey, application.CreatedAt, host) data := map[string]any{ "App": application, "EnvVars": envVars, "Labels": labels, "Volumes": volumes, "Deployments": deployments, "WebhookURL": webhookURL, "DeployKey": deployKey, "Success": request.URL.Query().Get("success"), } 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 := map[string]any{ "App": application, } 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 := map[string]any{ "App": application, "Error": "Failed to update app", } _ = 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 } 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 := map[string]any{ "App": application, "Deployments": deployments, } 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)) } } // 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 { 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 { 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 { 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) } } // formatDeployKey formats an SSH public key with a descriptive comment. // Format: ssh-ed25519 AAAA... upaas-2025-01-15-example.com. func formatDeployKey(pubKey string, createdAt time.Time, host string) string { const minKeyParts = 2 parts := strings.Fields(pubKey) if len(parts) < minKeyParts { return pubKey } comment := "upaas-" + createdAt.Format("2006-01-02") + "-" + host return parts[0] + " " + parts[1] + " " + comment }