Initial commit with server startup infrastructure
Core infrastructure: - Uber fx dependency injection - Chi router with middleware stack - SQLite database with embedded migrations - Embedded templates and static assets - Structured logging with slog Features implemented: - Authentication (login, logout, session management, argon2id hashing) - App management (create, edit, delete, list) - Deployment pipeline (clone, build, deploy, health check) - Webhook processing for Gitea - Notifications (ntfy, Slack) - Environment variables, labels, volumes per app - SSH key generation for deploy keys Server startup: - Server.Run() starts HTTP server on configured port - Server.Shutdown() for graceful shutdown - SetupRoutes() wires all handlers with chi router
This commit is contained in:
572
internal/handlers/app.go
Normal file
572
internal/handlers/app.go
Normal file
@@ -0,0 +1,572 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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,
|
||||
)
|
||||
|
||||
webhookURL := "/webhook/" + application.WebhookSecret
|
||||
|
||||
data := map[string]any{
|
||||
"App": application,
|
||||
"EnvVars": envVars,
|
||||
"Labels": labels,
|
||||
"Volumes": volumes,
|
||||
"Deployments": deployments,
|
||||
"WebhookURL": webhookURL,
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Container logs fetching not yet implemented
|
||||
writer.Header().Set("Content-Type", "text/plain")
|
||||
|
||||
if !application.ContainerID.Valid {
|
||||
_, _ = writer.Write([]byte("No container running"))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = writer.Write([]byte("Container logs not implemented yet"))
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
82
internal/handlers/auth.go
Normal file
82
internal/handlers/auth.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/templates"
|
||||
)
|
||||
|
||||
// HandleLoginGET returns the login page handler.
|
||||
func (h *Handlers) HandleLoginGET() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
|
||||
return func(writer http.ResponseWriter, _ *http.Request) {
|
||||
data := map[string]any{}
|
||||
|
||||
err := tmpl.ExecuteTemplate(writer, "login.html", data)
|
||||
if err != nil {
|
||||
h.log.Error("template execution failed", "error", err)
|
||||
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLoginPOST handles the login form submission.
|
||||
func (h *Handlers) HandleLoginPOST() 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
|
||||
}
|
||||
|
||||
username := request.FormValue("username")
|
||||
password := request.FormValue("password")
|
||||
|
||||
data := map[string]any{
|
||||
"Username": username,
|
||||
}
|
||||
|
||||
if username == "" || password == "" {
|
||||
data["Error"] = "Username and password are required"
|
||||
_ = tmpl.ExecuteTemplate(writer, "login.html", data)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
user, authErr := h.auth.Authenticate(request.Context(), username, password)
|
||||
if authErr != nil {
|
||||
data["Error"] = "Invalid username or password"
|
||||
_ = tmpl.ExecuteTemplate(writer, "login.html", data)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sessionErr := h.auth.CreateSession(writer, request, user)
|
||||
if sessionErr != nil {
|
||||
h.log.Error("failed to create session", "error", sessionErr)
|
||||
|
||||
data["Error"] = "Failed to create session"
|
||||
_ = tmpl.ExecuteTemplate(writer, "login.html", data)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLogout handles logout requests.
|
||||
func (h *Handlers) HandleLogout() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
destroyErr := h.auth.DestroySession(writer, request)
|
||||
if destroyErr != nil {
|
||||
h.log.Error("failed to destroy session", "error", destroyErr)
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/login", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
33
internal/handlers/dashboard.go
Normal file
33
internal/handlers/dashboard.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
"git.eeqj.de/sneak/upaas/templates"
|
||||
)
|
||||
|
||||
// HandleDashboard returns the dashboard handler.
|
||||
func (h *Handlers) HandleDashboard() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
apps, fetchErr := models.AllApps(request.Context(), h.db)
|
||||
if fetchErr != nil {
|
||||
h.log.Error("failed to fetch apps", "error", fetchErr)
|
||||
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"Apps": apps,
|
||||
}
|
||||
|
||||
execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data)
|
||||
if execErr != nil {
|
||||
h.log.Error("template execution failed", "error", execErr)
|
||||
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
76
internal/handlers/handlers.go
Normal file
76
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Package handlers provides HTTP request handlers.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/webhook"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Handlers.
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Logger *logger.Logger
|
||||
Globals *globals.Globals
|
||||
Database *database.Database
|
||||
Healthcheck *healthcheck.Healthcheck
|
||||
Auth *auth.Service
|
||||
App *app.Service
|
||||
Deploy *deploy.Service
|
||||
Webhook *webhook.Service
|
||||
}
|
||||
|
||||
// Handlers provides HTTP request handlers.
|
||||
type Handlers struct {
|
||||
log *slog.Logger
|
||||
params *Params
|
||||
db *database.Database
|
||||
hc *healthcheck.Healthcheck
|
||||
auth *auth.Service
|
||||
appService *app.Service
|
||||
deploy *deploy.Service
|
||||
webhook *webhook.Service
|
||||
}
|
||||
|
||||
// New creates a new Handlers instance.
|
||||
func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
|
||||
return &Handlers{
|
||||
log: params.Logger.Get(),
|
||||
params: ¶ms,
|
||||
db: params.Database,
|
||||
hc: params.Healthcheck,
|
||||
auth: params.Auth,
|
||||
appService: params.App,
|
||||
deploy: params.Deploy,
|
||||
webhook: params.Webhook,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handlers) respondJSON(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
data any,
|
||||
status int,
|
||||
) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(status)
|
||||
|
||||
if data != nil {
|
||||
err := json.NewEncoder(writer).Encode(data)
|
||||
if err != nil {
|
||||
h.log.Error("json encode error", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
486
internal/handlers/handlers_test.go
Normal file
486
internal/handlers/handlers_test.go
Normal file
@@ -0,0 +1,486 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/docker"
|
||||
"git.eeqj.de/sneak/upaas/internal/globals"
|
||||
"git.eeqj.de/sneak/upaas/internal/handlers"
|
||||
"git.eeqj.de/sneak/upaas/internal/healthcheck"
|
||||
"git.eeqj.de/sneak/upaas/internal/logger"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/app"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/auth"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/deploy"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/notify"
|
||||
"git.eeqj.de/sneak/upaas/internal/service/webhook"
|
||||
)
|
||||
|
||||
type testContext struct {
|
||||
handlers *handlers.Handlers
|
||||
database *database.Database
|
||||
authSvc *auth.Service
|
||||
appSvc *app.Service
|
||||
}
|
||||
|
||||
func createTestConfig(t *testing.T) *config.Config {
|
||||
t.Helper()
|
||||
|
||||
return &config.Config{
|
||||
Port: 8080,
|
||||
DataDir: t.TempDir(),
|
||||
SessionSecret: "test-secret-key-at-least-32-characters-long",
|
||||
}
|
||||
}
|
||||
|
||||
func createCoreServices(
|
||||
t *testing.T,
|
||||
cfg *config.Config,
|
||||
) (*globals.Globals, *logger.Logger, *database.Database, *healthcheck.Healthcheck) {
|
||||
t.Helper()
|
||||
|
||||
globals.SetAppname("upaas-test")
|
||||
globals.SetVersion("test")
|
||||
|
||||
globalInstance, globErr := globals.New(fx.Lifecycle(nil))
|
||||
require.NoError(t, globErr)
|
||||
|
||||
logInstance, logErr := logger.New(
|
||||
fx.Lifecycle(nil),
|
||||
logger.Params{Globals: globalInstance},
|
||||
)
|
||||
require.NoError(t, logErr)
|
||||
|
||||
dbInstance, dbErr := database.New(fx.Lifecycle(nil), database.Params{
|
||||
Logger: logInstance,
|
||||
Config: cfg,
|
||||
})
|
||||
require.NoError(t, dbErr)
|
||||
|
||||
hcInstance, hcErr := healthcheck.New(
|
||||
fx.Lifecycle(nil),
|
||||
healthcheck.Params{
|
||||
Logger: logInstance,
|
||||
Globals: globalInstance,
|
||||
Config: cfg,
|
||||
},
|
||||
)
|
||||
require.NoError(t, hcErr)
|
||||
|
||||
return globalInstance, logInstance, dbInstance, hcInstance
|
||||
}
|
||||
|
||||
func createAppServices(
|
||||
t *testing.T,
|
||||
logInstance *logger.Logger,
|
||||
dbInstance *database.Database,
|
||||
cfg *config.Config,
|
||||
) (*auth.Service, *app.Service, *deploy.Service, *webhook.Service) {
|
||||
t.Helper()
|
||||
|
||||
authSvc, authErr := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
|
||||
Logger: logInstance,
|
||||
Config: cfg,
|
||||
Database: dbInstance,
|
||||
})
|
||||
require.NoError(t, authErr)
|
||||
|
||||
appSvc, appErr := app.New(fx.Lifecycle(nil), app.ServiceParams{
|
||||
Logger: logInstance,
|
||||
Database: dbInstance,
|
||||
})
|
||||
require.NoError(t, appErr)
|
||||
|
||||
dockerClient, dockerErr := docker.New(fx.Lifecycle(nil), docker.Params{
|
||||
Logger: logInstance,
|
||||
Config: cfg,
|
||||
})
|
||||
require.NoError(t, dockerErr)
|
||||
|
||||
notifySvc, notifyErr := notify.New(fx.Lifecycle(nil), notify.ServiceParams{
|
||||
Logger: logInstance,
|
||||
})
|
||||
require.NoError(t, notifyErr)
|
||||
|
||||
deploySvc, deployErr := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{
|
||||
Logger: logInstance,
|
||||
Database: dbInstance,
|
||||
Docker: dockerClient,
|
||||
Notify: notifySvc,
|
||||
})
|
||||
require.NoError(t, deployErr)
|
||||
|
||||
webhookSvc, webhookErr := webhook.New(fx.Lifecycle(nil), webhook.ServiceParams{
|
||||
Logger: logInstance,
|
||||
Database: dbInstance,
|
||||
Deploy: deploySvc,
|
||||
})
|
||||
require.NoError(t, webhookErr)
|
||||
|
||||
return authSvc, appSvc, deploySvc, webhookSvc
|
||||
}
|
||||
|
||||
func setupTestHandlers(t *testing.T) *testContext {
|
||||
t.Helper()
|
||||
|
||||
cfg := createTestConfig(t)
|
||||
|
||||
globalInstance, logInstance, dbInstance, hcInstance := createCoreServices(t, cfg)
|
||||
|
||||
authSvc, appSvc, deploySvc, webhookSvc := createAppServices(
|
||||
t,
|
||||
logInstance,
|
||||
dbInstance,
|
||||
cfg,
|
||||
)
|
||||
|
||||
handlersInstance, handlerErr := handlers.New(
|
||||
fx.Lifecycle(nil),
|
||||
handlers.Params{
|
||||
Logger: logInstance,
|
||||
Globals: globalInstance,
|
||||
Database: dbInstance,
|
||||
Healthcheck: hcInstance,
|
||||
Auth: authSvc,
|
||||
App: appSvc,
|
||||
Deploy: deploySvc,
|
||||
Webhook: webhookSvc,
|
||||
},
|
||||
)
|
||||
require.NoError(t, handlerErr)
|
||||
|
||||
return &testContext{
|
||||
handlers: handlersInstance,
|
||||
database: dbInstance,
|
||||
authSvc: authSvc,
|
||||
appSvc: appSvc,
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHealthCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("returns health check response", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/.well-known/healthcheck.json",
|
||||
nil,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleHealthCheck()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Header().Get("Content-Type"), "application/json")
|
||||
assert.Contains(t, recorder.Body.String(), "status")
|
||||
assert.Contains(t, recorder.Body.String(), "ok")
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleSetupGET(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("renders setup page", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/setup", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleSetupGET()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "setup")
|
||||
})
|
||||
}
|
||||
|
||||
func createSetupFormRequest(
|
||||
username, password, confirm string,
|
||||
) *http.Request {
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("password", password)
|
||||
form.Set("password_confirm", confirm)
|
||||
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/setup",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
func TestHandleSetupPOSTCreatesUserAndRedirects(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := createSetupFormRequest("admin", "password123", "password123")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleSetupPOST()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, recorder.Code)
|
||||
assert.Equal(t, "/", recorder.Header().Get("Location"))
|
||||
}
|
||||
|
||||
func TestHandleSetupPOSTRejectsEmptyUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := createSetupFormRequest("", "password123", "password123")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleSetupPOST()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "required")
|
||||
}
|
||||
|
||||
func TestHandleSetupPOSTRejectsShortPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := createSetupFormRequest("admin", "short", "short")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleSetupPOST()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "8 characters")
|
||||
}
|
||||
|
||||
func TestHandleSetupPOSTRejectsMismatchedPasswords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := createSetupFormRequest("admin", "password123", "different123")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleSetupPOST()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "do not match")
|
||||
}
|
||||
|
||||
func TestHandleLoginGET(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("renders login page", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleLoginGET()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "login")
|
||||
})
|
||||
}
|
||||
|
||||
func createLoginFormRequest(username, password string) *http.Request {
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("password", password)
|
||||
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/login",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
func TestHandleLoginPOSTAuthenticatesValidCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
// Create user first
|
||||
_, createErr := testCtx.authSvc.CreateUser(
|
||||
context.Background(),
|
||||
"testuser",
|
||||
"testpass123",
|
||||
)
|
||||
require.NoError(t, createErr)
|
||||
|
||||
request := createLoginFormRequest("testuser", "testpass123")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleLoginPOST()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, recorder.Code)
|
||||
assert.Equal(t, "/", recorder.Header().Get("Location"))
|
||||
}
|
||||
|
||||
func TestHandleLoginPOSTRejectsInvalidCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
// Create user first
|
||||
_, createErr := testCtx.authSvc.CreateUser(
|
||||
context.Background(),
|
||||
"testuser",
|
||||
"testpass123",
|
||||
)
|
||||
require.NoError(t, createErr)
|
||||
|
||||
request := createLoginFormRequest("testuser", "wrongpassword")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleLoginPOST()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "Invalid")
|
||||
}
|
||||
|
||||
func TestHandleDashboard(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("renders dashboard with app list", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleDashboard()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
assert.Contains(t, recorder.Body.String(), "Applications")
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleAppNew(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("renders new app form", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/apps/new", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleAppNew()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// addChiURLParams adds chi URL parameters to a request for testing.
|
||||
func addChiURLParams(
|
||||
request *http.Request,
|
||||
params map[string]string,
|
||||
) *http.Request {
|
||||
routeContext := chi.NewRouteContext()
|
||||
|
||||
for key, value := range params {
|
||||
routeContext.URLParams.Add(key, value)
|
||||
}
|
||||
|
||||
return request.WithContext(
|
||||
context.WithValue(request.Context(), chi.RouteCtxKey, routeContext),
|
||||
)
|
||||
}
|
||||
|
||||
func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
webhookURL := "/webhook/unknown-secret"
|
||||
payload := `{"ref": "refs/heads/main"}`
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
webhookURL,
|
||||
strings.NewReader(payload),
|
||||
)
|
||||
request = addChiURLParams(request, map[string]string{"secret": "unknown-secret"})
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Gitea-Event", "push")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleWebhook()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHandleWebhookProcessesValidWebhook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
// Create an app first
|
||||
createdApp, createErr := testCtx.appSvc.CreateApp(
|
||||
context.Background(),
|
||||
app.CreateAppInput{
|
||||
Name: "webhook-test-app",
|
||||
RepoURL: "git@example.com:user/repo.git",
|
||||
Branch: "main",
|
||||
},
|
||||
)
|
||||
require.NoError(t, createErr)
|
||||
|
||||
payload := `{"ref": "refs/heads/main", "after": "abc123"}`
|
||||
webhookURL := "/webhook/" + createdApp.WebhookSecret
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
webhookURL,
|
||||
strings.NewReader(payload),
|
||||
)
|
||||
request = addChiURLParams(
|
||||
request,
|
||||
map[string]string{"secret": createdApp.WebhookSecret},
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Gitea-Event", "push")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleWebhook()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
12
internal/handlers/healthcheck.go
Normal file
12
internal/handlers/healthcheck.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HandleHealthCheck returns the health check handler.
|
||||
func (h *Handlers) HandleHealthCheck() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
h.respondJSON(writer, request, h.hc.Check(), http.StatusOK)
|
||||
}
|
||||
}
|
||||
118
internal/handlers/setup.go
Normal file
118
internal/handlers/setup.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/templates"
|
||||
)
|
||||
|
||||
const (
|
||||
// minPasswordLength is the minimum required password length.
|
||||
minPasswordLength = 8
|
||||
)
|
||||
|
||||
// HandleSetupGET returns the setup page handler.
|
||||
func (h *Handlers) HandleSetupGET() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
|
||||
return func(writer http.ResponseWriter, _ *http.Request) {
|
||||
data := map[string]any{}
|
||||
|
||||
err := tmpl.ExecuteTemplate(writer, "setup.html", data)
|
||||
if err != nil {
|
||||
h.log.Error("template execution failed", "error", err)
|
||||
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setupFormData holds form data for the setup page.
|
||||
type setupFormData struct {
|
||||
username string
|
||||
password string
|
||||
passwordConfirm string
|
||||
}
|
||||
|
||||
// validateSetupForm validates the setup form and returns an error message if invalid.
|
||||
func validateSetupForm(formData setupFormData) string {
|
||||
if formData.username == "" || formData.password == "" {
|
||||
return "Username and password are required"
|
||||
}
|
||||
|
||||
if len(formData.password) < minPasswordLength {
|
||||
return "Password must be at least 8 characters"
|
||||
}
|
||||
|
||||
if formData.password != formData.passwordConfirm {
|
||||
return "Passwords do not match"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// renderSetupError renders the setup page with an error message.
|
||||
func renderSetupError(
|
||||
tmpl *templates.TemplateExecutor,
|
||||
writer http.ResponseWriter,
|
||||
username string,
|
||||
errorMsg string,
|
||||
) {
|
||||
data := map[string]any{
|
||||
"Username": username,
|
||||
"Error": errorMsg,
|
||||
}
|
||||
_ = tmpl.ExecuteTemplate(writer, "setup.html", data)
|
||||
}
|
||||
|
||||
// HandleSetupPOST handles the setup form submission.
|
||||
func (h *Handlers) HandleSetupPOST() 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
|
||||
}
|
||||
|
||||
formData := setupFormData{
|
||||
username: request.FormValue("username"),
|
||||
password: request.FormValue("password"),
|
||||
passwordConfirm: request.FormValue("password_confirm"),
|
||||
}
|
||||
|
||||
if validationErr := validateSetupForm(formData); validationErr != "" {
|
||||
renderSetupError(tmpl, writer, formData.username, validationErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
user, createErr := h.auth.CreateUser(
|
||||
request.Context(),
|
||||
formData.username,
|
||||
formData.password,
|
||||
)
|
||||
if createErr != nil {
|
||||
h.log.Error("failed to create user", "error", createErr)
|
||||
renderSetupError(tmpl, writer, formData.username, "Failed to create user")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sessionErr := h.auth.CreateSession(writer, request, user)
|
||||
if sessionErr != nil {
|
||||
h.log.Error("failed to create session", "error", sessionErr)
|
||||
renderSetupError(
|
||||
tmpl,
|
||||
writer,
|
||||
formData.username,
|
||||
"Failed to create session",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(writer, request, "/", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
72
internal/handlers/webhook.go
Normal file
72
internal/handlers/webhook.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
)
|
||||
|
||||
// HandleWebhook handles incoming Gitea webhooks.
|
||||
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
secret := chi.URLParam(request, "secret")
|
||||
if secret == "" {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Find app by webhook secret
|
||||
application, findErr := models.FindAppByWebhookSecret(
|
||||
request.Context(),
|
||||
h.db,
|
||||
secret,
|
||||
)
|
||||
if findErr != nil {
|
||||
h.log.Error("failed to find app by webhook secret", "error", findErr)
|
||||
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if application == nil {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body
|
||||
body, readErr := io.ReadAll(request.Body)
|
||||
if readErr != nil {
|
||||
h.log.Error("failed to read webhook body", "error", readErr)
|
||||
http.Error(writer, "Bad Request", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Get event type from header
|
||||
eventType := request.Header.Get("X-Gitea-Event")
|
||||
if eventType == "" {
|
||||
eventType = "push"
|
||||
}
|
||||
|
||||
// Process webhook
|
||||
webhookErr := h.webhook.HandleWebhook(
|
||||
request.Context(),
|
||||
application,
|
||||
eventType,
|
||||
body,
|
||||
)
|
||||
if webhookErr != nil {
|
||||
h.log.Error("failed to process webhook", "error", webhookErr)
|
||||
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user