Compare commits

...

17 Commits

Author SHA1 Message Date
user
a1b06219e7 fix: add eviction for stale IP rate limiter entries and Retry-After header
- Store lastSeen timestamp per IP limiter entry
- Lazy sweep removes entries older than 10 minutes on each request
- Add Retry-After header to 429 responses
- Add test for stale entry eviction

Fixes memory leak under sustained attack from many IPs.
2026-02-15 21:01:11 -08:00
clawbot
66661d1b1d Add rate limiting to login endpoint to prevent brute force
Apply per-IP rate limiting (5 attempts/minute) to POST /login using
golang.org/x/time/rate. Returns 429 Too Many Requests when exceeded.

Closes #12
2026-02-15 21:01:11 -08:00
3a2bd0e51d Merge pull request 'Set Secure flag on session cookie in production mode (closes #5)' (#10) from clawbot/upaas:fix/issue-5 into main
Reviewed-on: #10
2026-02-16 05:58:22 +01:00
79a3165f90 Merge pull request 'Clean up Docker container when deleting an app (closes #2)' (#7) from clawbot/upaas:fix/issue-2 into main
Reviewed-on: #7
2026-02-16 05:56:56 +01:00
86491b1367 Merge pull request 'Limit webhook request body size to 1MB to prevent DoS (closes #1)' (#6) from clawbot/upaas:fix/issue-1 into main
Reviewed-on: #6
2026-02-16 05:56:14 +01:00
98b8403e8b Merge branch 'main' into fix/issue-1 2026-02-16 05:56:06 +01:00
076442923c Merge pull request 'Use hashed webhook secrets for constant-time comparison (closes #13)' (#15) from clawbot/upaas:fix/issue-13 into main
Reviewed-on: #15
2026-02-16 05:55:46 +01:00
57ea724419 Merge branch 'main' into fix/issue-13 2026-02-16 05:55:17 +01:00
39bcfb7456 Merge pull request 'Add CSRF protection to state-changing POST endpoints (closes #11)' (#16) from clawbot/upaas:fix/issue-11 into main
Reviewed-on: #16
2026-02-16 05:53:37 +01:00
4247162d30 Merge branch 'main' into fix/issue-11 2026-02-16 05:51:25 +01:00
4bad74081b Merge pull request 'rewrite log viewer panes (closes #17)' (#27) from clawbot/upaas:fix/log-viewer-rewrite into main
Reviewed-on: #27
2026-02-16 05:51:12 +01:00
clawbot
be6080280e rewrite log viewer panes: smart auto-scroll with follow button
- Track scroll position per log pane (container logs, build logs, deployment cards)
- Auto-scroll to bottom only when user is already at bottom (tail-follow)
- When user scrolls up to review earlier output, pause auto-scroll
- Show a '↓ Follow' button when auto-scroll is paused; clicking resumes
- Only scroll on actual content changes (skip no-op updates)
- Use overflow-y: auto for proper scrollable containers
- Add break-words to prevent horizontal overflow on long lines

Closes #17
2026-02-15 20:48:43 -08:00
clawbot
b1dc8fcc4e Add CSRF protection to state-changing POST endpoints
Add gorilla/csrf middleware to protect all HTML-serving routes against
cross-site request forgery attacks. The webhook endpoint is excluded
since it uses secret-based authentication.

Changes:
- Add gorilla/csrf v1.7.3 dependency
- Add CSRF() middleware method using session secret as key
- Apply CSRF middleware to all HTML route groups in routes.go
- Pass CSRF token to all templates via addGlobals helper
- Add {{ .CSRFField }} / {{ $.CSRFField }} hidden inputs to all forms

Closes #11
2026-02-15 14:17:55 -08:00
clawbot
72786a9feb fix: use hashed webhook secrets for constant-time comparison
Store a SHA-256 hash of the webhook secret in a new webhook_secret_hash
column. FindAppByWebhookSecret now hashes the incoming secret and queries
by hash, eliminating the SQL string comparison timing side-channel.

- Add migration 005_add_webhook_secret_hash.sql
- Add database.HashWebhookSecret() helper
- Backfill existing secrets on startup
- Update App model to include WebhookSecretHash in all queries
- Update app creation to compute hash at insert time
- Add TestHashWebhookSecret unit test
- Update all test fixtures to set WebhookSecretHash

Closes #13
2026-02-15 14:06:53 -08:00
clawbot
185daab909 fix: set Secure flag on session cookie in production mode (closes #5) 2026-02-08 12:05:09 -08:00
clawbot
ed4ddc5536 fix: clean up Docker container when deleting an app (closes #2) 2026-02-08 12:02:56 -08:00
clawbot
e212910143 fix: limit webhook request body size to 1MB to prevent DoS (closes #1) 2026-02-08 12:02:06 -08:00
30 changed files with 668 additions and 79 deletions

3
go.mod
View File

@ -5,9 +5,11 @@ go 1.25
require ( require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/docker/docker v27.3.1+incompatible github.com/docker/docker v27.3.1+incompatible
github.com/docker/go-connections v0.6.0
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/csrf v1.7.3
github.com/gorilla/sessions v1.4.0 github.com/gorilla/sessions v1.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.32 github.com/mattn/go-sqlite3 v1.14.32
@ -27,7 +29,6 @@ require (
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect

2
go.sum
View File

@ -50,6 +50,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=

View File

@ -3,7 +3,9 @@ package database
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
@ -158,6 +160,60 @@ func (d *Database) connect(ctx context.Context) error {
return fmt.Errorf("failed to run migrations: %w", err) return fmt.Errorf("failed to run migrations: %w", err)
} }
// Backfill webhook_secret_hash for any rows that have a secret but no hash
err = d.backfillWebhookSecretHashes(ctx)
if err != nil {
return fmt.Errorf("failed to backfill webhook secret hashes: %w", err)
}
return nil
}
// HashWebhookSecret returns the hex-encoded SHA-256 hash of a webhook secret.
func HashWebhookSecret(secret string) string {
sum := sha256.Sum256([]byte(secret))
return hex.EncodeToString(sum[:])
}
func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error {
rows, err := d.database.QueryContext(ctx,
"SELECT id, webhook_secret FROM apps WHERE webhook_secret_hash = '' AND webhook_secret != ''")
if err != nil {
return fmt.Errorf("querying apps for backfill: %w", err)
}
defer func() { _ = rows.Close() }()
type row struct {
id, secret string
}
var toUpdate []row
for rows.Next() {
var r row
if scanErr := rows.Scan(&r.id, &r.secret); scanErr != nil {
return fmt.Errorf("scanning app for backfill: %w", scanErr)
}
toUpdate = append(toUpdate, r)
}
if rowsErr := rows.Err(); rowsErr != nil {
return fmt.Errorf("iterating apps for backfill: %w", rowsErr)
}
for _, r := range toUpdate {
hash := HashWebhookSecret(r.secret)
_, updateErr := d.database.ExecContext(ctx,
"UPDATE apps SET webhook_secret_hash = ? WHERE id = ?", hash, r.id)
if updateErr != nil {
return fmt.Errorf("updating webhook_secret_hash for app %s: %w", r.id, updateErr)
}
d.log.Info("backfilled webhook_secret_hash", "app_id", r.id)
}
return nil return nil
} }

View File

@ -0,0 +1,28 @@
package database_test
import (
"testing"
"github.com/stretchr/testify/assert"
"git.eeqj.de/sneak/upaas/internal/database"
)
func TestHashWebhookSecret(t *testing.T) {
t.Parallel()
// Known SHA-256 of "test-secret"
hash := database.HashWebhookSecret("test-secret")
assert.Equal(t,
"9caf06bb4436cdbfa20af9121a626bc1093c4f54b31c0fa937957856135345b6",
hash,
)
// Different secrets produce different hashes
hash2 := database.HashWebhookSecret("other-secret")
assert.NotEqual(t, hash, hash2)
// Same secret always produces same hash (deterministic)
hash3 := database.HashWebhookSecret("test-secret")
assert.Equal(t, hash, hash3)
}

View File

@ -0,0 +1,2 @@
-- Add webhook_secret_hash column for constant-time secret lookup
ALTER TABLE apps ADD COLUMN webhook_secret_hash TEXT NOT NULL DEFAULT '';

View File

@ -29,8 +29,8 @@ const (
func (h *Handlers) HandleAppNew() http.HandlerFunc { func (h *Handlers) HandleAppNew() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, _ *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
data := h.addGlobals(map[string]any{}) data := h.addGlobals(map[string]any{}, request)
err := tmpl.ExecuteTemplate(writer, "app_new.html", data) err := tmpl.ExecuteTemplate(writer, "app_new.html", data)
if err != nil { if err != nil {
@ -57,12 +57,12 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc {
branch := request.FormValue("branch") branch := request.FormValue("branch")
dockerfilePath := request.FormValue("dockerfile_path") dockerfilePath := request.FormValue("dockerfile_path")
data := map[string]any{ data := h.addGlobals(map[string]any{
"Name": name, "Name": name,
"RepoURL": repoURL, "RepoURL": repoURL,
"Branch": branch, "Branch": branch,
"DockerfilePath": dockerfilePath, "DockerfilePath": dockerfilePath,
} }, request)
if name == "" || repoURL == "" { if name == "" || repoURL == "" {
data["Error"] = "Name and repository URL are required" data["Error"] = "Name and repository URL are required"
@ -150,7 +150,7 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
"WebhookURL": webhookURL, "WebhookURL": webhookURL,
"DeployKey": deployKey, "DeployKey": deployKey,
"Success": request.URL.Query().Get("success"), "Success": request.URL.Query().Get("success"),
}) }, request)
err := tmpl.ExecuteTemplate(writer, "app_detail.html", data) err := tmpl.ExecuteTemplate(writer, "app_detail.html", data)
if err != nil { if err != nil {
@ -183,7 +183,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"App": application, "App": application,
}) }, request)
err := tmpl.ExecuteTemplate(writer, "app_edit.html", data) err := tmpl.ExecuteTemplate(writer, "app_edit.html", data)
if err != nil { if err != nil {
@ -241,10 +241,10 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
if saveErr != nil { if saveErr != nil {
h.log.Error("failed to update app", "error", saveErr) h.log.Error("failed to update app", "error", saveErr)
data := map[string]any{ data := h.addGlobals(map[string]any{
"App": application, "App": application,
"Error": "Failed to update app", "Error": "Failed to update app",
} }, request)
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data) _ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
return return
@ -267,6 +267,29 @@ func (h *Handlers) HandleAppDelete() http.HandlerFunc {
return 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()) deleteErr := application.Delete(request.Context())
if deleteErr != nil { if deleteErr != nil {
h.log.Error("failed to delete app", "error", deleteErr) h.log.Error("failed to delete app", "error", deleteErr)
@ -337,7 +360,7 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"App": application, "App": application,
"Deployments": deployments, "Deployments": deployments,
}) }, request)
err := tmpl.ExecuteTemplate(writer, "deployments.html", data) err := tmpl.ExecuteTemplate(writer, "deployments.html", data)
if err != nil { if err != nil {

View File

@ -10,8 +10,8 @@ import (
func (h *Handlers) HandleLoginGET() http.HandlerFunc { func (h *Handlers) HandleLoginGET() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, _ *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
data := h.addGlobals(map[string]any{}) data := h.addGlobals(map[string]any{}, request)
err := tmpl.ExecuteTemplate(writer, "login.html", data) err := tmpl.ExecuteTemplate(writer, "login.html", data)
if err != nil { if err != nil {
@ -38,7 +38,7 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc {
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"Username": username, "Username": username,
}) }, request)
if username == "" || password == "" { if username == "" || password == "" {
data["Error"] = "Username and password are required" data["Error"] = "Username and password are required"

View File

@ -67,7 +67,7 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"AppStats": appStats, "AppStats": appStats,
}) }, request)
execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data) execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data)
if execErr != nil { if execErr != nil {

View File

@ -3,9 +3,11 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/gorilla/csrf"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
@ -64,11 +66,18 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
}, nil }, nil
} }
// addGlobals adds version info to template data map. // addGlobals adds version info and CSRF token to template data map.
func (h *Handlers) addGlobals(data map[string]any) map[string]any { func (h *Handlers) addGlobals(
data map[string]any,
request *http.Request,
) map[string]any {
data["Version"] = h.globals.Version data["Version"] = h.globals.Version
data["Appname"] = h.globals.Appname data["Appname"] = h.globals.Appname
if request != nil {
data["CSRFField"] = template.HTML(csrf.TemplateField(request)) //nolint:gosec // csrf.TemplateField produces safe HTML
}
return data return data
} }

View File

@ -426,6 +426,47 @@ func addChiURLParams(
) )
} }
func TestHandleWebhookRejectsOversizedBody(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// Create an app first
createdApp, createErr := testCtx.appSvc.CreateApp(
context.Background(),
app.CreateAppInput{
Name: "oversize-test-app",
RepoURL: "git@example.com:user/repo.git",
Branch: "main",
},
)
require.NoError(t, createErr)
// Create a body larger than 1MB - it should be silently truncated
// and the webhook should still process (or fail gracefully on parse)
largePayload := strings.Repeat("x", 2*1024*1024) // 2MB
request := httptest.NewRequest(
http.MethodPost,
"/webhook/"+createdApp.WebhookSecret,
strings.NewReader(largePayload),
)
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)
// Should still return OK (payload is truncated and fails JSON parse,
// but webhook service handles invalid JSON gracefully)
assert.Equal(t, http.StatusOK, recorder.Code)
}
func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) { func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
t.Parallel() t.Parallel()

View File

@ -15,8 +15,8 @@ const (
func (h *Handlers) HandleSetupGET() http.HandlerFunc { func (h *Handlers) HandleSetupGET() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, _ *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
data := h.addGlobals(map[string]any{}) data := h.addGlobals(map[string]any{}, request)
err := tmpl.ExecuteTemplate(writer, "setup.html", data) err := tmpl.ExecuteTemplate(writer, "setup.html", data)
if err != nil { if err != nil {
@ -54,13 +54,14 @@ func validateSetupForm(formData setupFormData) string {
func (h *Handlers) renderSetupError( func (h *Handlers) renderSetupError(
tmpl *templates.TemplateExecutor, tmpl *templates.TemplateExecutor,
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request,
username string, username string,
errorMsg string, errorMsg string,
) { ) {
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"Username": username, "Username": username,
"Error": errorMsg, "Error": errorMsg,
}) }, request)
_ = tmpl.ExecuteTemplate(writer, "setup.html", data) _ = tmpl.ExecuteTemplate(writer, "setup.html", data)
} }
@ -83,7 +84,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
} }
if validationErr := validateSetupForm(formData); validationErr != "" { if validationErr := validateSetupForm(formData); validationErr != "" {
h.renderSetupError(tmpl, writer, formData.username, validationErr) h.renderSetupError(tmpl, writer, request, formData.username, validationErr)
return return
} }
@ -95,7 +96,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
) )
if createErr != nil { if createErr != nil {
h.log.Error("failed to create user", "error", createErr) h.log.Error("failed to create user", "error", createErr)
h.renderSetupError(tmpl, writer, formData.username, "Failed to create user") h.renderSetupError(tmpl, writer, request, formData.username, "Failed to create user")
return return
} }
@ -106,6 +107,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
h.renderSetupError( h.renderSetupError(
tmpl, tmpl,
writer, writer,
request,
formData.username, formData.username,
"Failed to create session", "Failed to create session",
) )

View File

@ -9,6 +9,9 @@ import (
"git.eeqj.de/sneak/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
) )
// maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB).
const maxWebhookBodySize = 1 << 20
// HandleWebhook handles incoming Gitea webhooks. // HandleWebhook handles incoming Gitea webhooks.
func (h *Handlers) HandleWebhook() http.HandlerFunc { func (h *Handlers) HandleWebhook() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@ -38,8 +41,8 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
return return
} }
// Read request body // Read request body with size limit to prevent memory exhaustion
body, readErr := io.ReadAll(request.Body) body, readErr := io.ReadAll(io.LimitReader(request.Body, maxWebhookBodySize))
if readErr != nil { if readErr != nil {
h.log.Error("failed to read webhook body", "error", readErr) h.log.Error("failed to read webhook body", "error", readErr)
http.Error(writer, "Bad Request", http.StatusBadRequest) http.Error(writer, "Bad Request", http.StatusBadRequest)

View File

@ -2,15 +2,20 @@
package middleware package middleware
import ( import (
"fmt"
"log/slog" "log/slog"
"math"
"net" "net"
"net/http" "net/http"
"sync"
"time" "time"
"github.com/99designs/basicauth-go" "github.com/99designs/basicauth-go"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
"github.com/gorilla/csrf"
"go.uber.org/fx" "go.uber.org/fx"
"golang.org/x/time/rate"
"git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
@ -152,6 +157,122 @@ func (m *Middleware) SessionAuth() func(http.Handler) http.Handler {
} }
} }
// CSRF returns CSRF protection middleware using gorilla/csrf.
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
return csrf.Protect(
[]byte(m.params.Config.SessionSecret),
csrf.Secure(false), // Allow HTTP for development; reverse proxy handles TLS
csrf.Path("/"),
)
}
// loginRateLimit configures the login rate limiter.
const (
loginRateLimit = rate.Limit(5.0 / 60.0) // 5 requests per 60 seconds
loginBurst = 5 // allow burst of 5
limiterExpiry = 10 * time.Minute // evict entries not seen in 10 minutes
limiterCleanupEvery = 1 * time.Minute // sweep interval
)
// ipLimiterEntry stores a rate limiter with its last-seen timestamp.
type ipLimiterEntry struct {
limiter *rate.Limiter
lastSeen time.Time
}
// ipLimiter tracks per-IP rate limiters for login attempts with automatic
// eviction of stale entries to prevent unbounded memory growth.
type ipLimiter struct {
mu sync.Mutex
limiters map[string]*ipLimiterEntry
lastSweep time.Time
}
func newIPLimiter() *ipLimiter {
return &ipLimiter{
limiters: make(map[string]*ipLimiterEntry),
lastSweep: time.Now(),
}
}
// sweep removes entries not seen within limiterExpiry. Must be called with mu held.
func (i *ipLimiter) sweep(now time.Time) {
for ip, entry := range i.limiters {
if now.Sub(entry.lastSeen) > limiterExpiry {
delete(i.limiters, ip)
}
}
i.lastSweep = now
}
func (i *ipLimiter) getLimiter(ip string) *rate.Limiter {
i.mu.Lock()
defer i.mu.Unlock()
now := time.Now()
// Lazy sweep: clean up stale entries periodically.
if now.Sub(i.lastSweep) >= limiterCleanupEvery {
i.sweep(now)
}
entry, exists := i.limiters[ip]
if !exists {
entry = &ipLimiterEntry{
limiter: rate.NewLimiter(loginRateLimit, loginBurst),
}
i.limiters[ip] = entry
}
entry.lastSeen = now
return entry.limiter
}
// loginLimiter is the singleton IP rate limiter for login attempts.
//
//nolint:gochecknoglobals // intentional singleton for rate limiting state
var loginLimiter = newIPLimiter()
// LoginRateLimit returns middleware that rate-limits login attempts per IP.
// It allows 5 attempts per minute and returns 429 Too Many Requests when exceeded.
func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
ip := ipFromHostPort(request.RemoteAddr)
limiter := loginLimiter.getLimiter(ip)
if !limiter.Allow() {
m.log.WarnContext(request.Context(), "login rate limit exceeded",
"remoteIP", ip,
)
// Compute seconds until the next token is available.
reservation := limiter.Reserve()
delay := reservation.Delay()
reservation.Cancel()
retryAfter := int(math.Ceil(delay.Seconds()))
if retryAfter < 1 {
retryAfter = 1
}
writer.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
http.Error(
writer,
"Too Many Requests",
http.StatusTooManyRequests,
)
return
}
next.ServeHTTP(writer, request)
})
}
}
// SetupRequired returns middleware that redirects to setup if no user exists. // SetupRequired returns middleware that redirects to setup if no user exists.
func (m *Middleware) SetupRequired() func(http.Handler) http.Handler { func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {

View File

@ -0,0 +1,135 @@
package middleware
import (
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"git.eeqj.de/sneak/upaas/internal/config"
)
func newTestMiddleware(t *testing.T) *Middleware {
t.Helper()
return &Middleware{
log: slog.Default(),
params: &Params{
Config: &config.Config{},
},
}
}
func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
// Reset the global limiter to get clean state
loginLimiter = newIPLimiter()
mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// First 5 requests should succeed (burst)
for i := range 5 {
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.168.1.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code, "request %d should succeed", i+1)
}
// 6th request should be rate limited
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "192.168.1.1:12345"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "6th request should be rate limited")
}
func TestLoginRateLimitIsolatesIPs(t *testing.T) {
loginLimiter = newIPLimiter()
mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Exhaust IP1's budget
for range 5 {
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "10.0.0.1:1234"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
}
// IP1 should be blocked
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "10.0.0.1:1234"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
// IP2 should still work
req2 := httptest.NewRequest(http.MethodPost, "/login", nil)
req2.RemoteAddr = "10.0.0.2:1234"
rec2 := httptest.NewRecorder()
handler.ServeHTTP(rec2, req2)
assert.Equal(t, http.StatusOK, rec2.Code, "different IP should not be rate limited")
}
func TestLoginRateLimitReturns429Body(t *testing.T) {
loginLimiter = newIPLimiter()
mw := newTestMiddleware(t)
handler := mw.LoginRateLimit()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Exhaust burst
for range 5 {
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "172.16.0.1:5555"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
}
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "172.16.0.1:5555"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
assert.Contains(t, rec.Body.String(), "Too Many Requests")
assert.NotEmpty(t, rec.Header().Get("Retry-After"), "should include Retry-After header")
}
func TestIPLimiterEvictsStaleEntries(t *testing.T) {
il := newIPLimiter()
// Add an entry and backdate its lastSeen
il.mu.Lock()
il.limiters["1.2.3.4"] = &ipLimiterEntry{
limiter: nil,
lastSeen: time.Now().Add(-15 * time.Minute),
}
il.limiters["5.6.7.8"] = &ipLimiterEntry{
limiter: nil,
lastSeen: time.Now(),
}
il.mu.Unlock()
// Trigger sweep
il.mu.Lock()
il.sweep(time.Now())
il.mu.Unlock()
il.mu.Lock()
defer il.mu.Unlock()
assert.NotContains(t, il.limiters, "1.2.3.4", "stale entry should be evicted")
assert.Contains(t, il.limiters, "5.6.7.8", "fresh entry should remain")
}

View File

@ -10,6 +10,12 @@ import (
"git.eeqj.de/sneak/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// appColumns is the standard column list for app queries.
const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
created_at, updated_at`
// AppStatus represents the status of an app. // AppStatus represents the status of an app.
type AppStatus string type AppStatus string
@ -31,8 +37,9 @@ type App struct {
RepoURL string RepoURL string
Branch string Branch string
DockerfilePath string DockerfilePath string
WebhookSecret string WebhookSecret string
SSHPrivateKey string WebhookSecretHash string
SSHPrivateKey string
SSHPublicKey string SSHPublicKey string
ImageID sql.NullString ImageID sql.NullString
Status AppStatus Status AppStatus
@ -70,11 +77,8 @@ func (a *App) Delete(ctx context.Context) error {
// Reload refreshes the app from the database. // Reload refreshes the app from the database.
func (a *App) Reload(ctx context.Context) error { func (a *App) Reload(ctx context.Context) error {
row := a.db.QueryRow(ctx, ` row := a.db.QueryRow(ctx,
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret, "SELECT "+appColumns+" FROM apps WHERE id = ?",
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
FROM apps WHERE id = ?`,
a.ID, a.ID,
) )
@ -136,13 +140,13 @@ func (a *App) insert(ctx context.Context) error {
INSERT INTO apps ( INSERT INTO apps (
id, name, repo_url, branch, dockerfile_path, webhook_secret, id, name, repo_url, branch, dockerfile_path, webhook_secret,
ssh_private_key, ssh_public_key, image_id, status, ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook docker_network, ntfy_topic, slack_webhook, webhook_secret_hash
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := a.db.Exec(ctx, query, _, err := a.db.Exec(ctx, query,
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret, a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status, a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash,
) )
if err != nil { if err != nil {
return err return err
@ -177,6 +181,7 @@ func (a *App) scan(row *sql.Row) error {
&a.SSHPrivateKey, &a.SSHPublicKey, &a.SSHPrivateKey, &a.SSHPublicKey,
&a.ImageID, &a.Status, &a.ImageID, &a.Status,
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook, &a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
&a.WebhookSecretHash,
&a.CreatedAt, &a.UpdatedAt, &a.CreatedAt, &a.UpdatedAt,
) )
} }
@ -193,6 +198,7 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
&app.SSHPrivateKey, &app.SSHPublicKey, &app.SSHPrivateKey, &app.SSHPublicKey,
&app.ImageID, &app.Status, &app.ImageID, &app.Status,
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook, &app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
&app.WebhookSecretHash,
&app.CreatedAt, &app.UpdatedAt, &app.CreatedAt, &app.UpdatedAt,
) )
if scanErr != nil { if scanErr != nil {
@ -221,11 +227,8 @@ func FindApp(
app := NewApp(appDB) app := NewApp(appDB)
app.ID = appID app.ID = appID
row := appDB.QueryRow(ctx, ` row := appDB.QueryRow(ctx,
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret, "SELECT "+appColumns+" FROM apps WHERE id = ?",
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
FROM apps WHERE id = ?`,
appID, appID,
) )
@ -241,7 +244,8 @@ func FindApp(
return app, nil return app, nil
} }
// FindAppByWebhookSecret finds an app by webhook secret. // FindAppByWebhookSecret finds an app by webhook secret using a SHA-256 hash
// lookup. This avoids SQL string comparison timing side-channels.
// //
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record //nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
func FindAppByWebhookSecret( func FindAppByWebhookSecret(
@ -250,13 +254,11 @@ func FindAppByWebhookSecret(
secret string, secret string,
) (*App, error) { ) (*App, error) {
app := NewApp(appDB) app := NewApp(appDB)
secretHash := database.HashWebhookSecret(secret)
row := appDB.QueryRow(ctx, ` row := appDB.QueryRow(ctx,
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret, "SELECT "+appColumns+" FROM apps WHERE webhook_secret_hash = ?",
ssh_private_key, ssh_public_key, image_id, status, secretHash,
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
FROM apps WHERE webhook_secret = ?`,
secret,
) )
err := app.scan(row) err := app.scan(row)
@ -273,11 +275,8 @@ func FindAppByWebhookSecret(
// AllApps returns all apps ordered by name. // AllApps returns all apps ordered by name.
func AllApps(ctx context.Context, appDB *database.Database) ([]*App, error) { func AllApps(ctx context.Context, appDB *database.Database) ([]*App, error) {
rows, err := appDB.Query(ctx, ` rows, err := appDB.Query(ctx,
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret, "SELECT "+appColumns+" FROM apps ORDER BY name",
ssh_private_key, ssh_public_key, image_id, status,
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
FROM apps ORDER BY name`,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("querying all apps: %w", err) return nil, fmt.Errorf("querying all apps: %w", err)

View File

@ -297,6 +297,7 @@ func TestAllApps(t *testing.T) {
app.Branch = testBranch app.Branch = testBranch
app.DockerfilePath = "Dockerfile" app.DockerfilePath = "Dockerfile"
app.WebhookSecret = "secret-" + strconv.Itoa(idx) app.WebhookSecret = "secret-" + strconv.Itoa(idx)
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
app.SSHPrivateKey = "private" app.SSHPrivateKey = "private"
app.SSHPublicKey = "public" app.SSHPublicKey = "public"
@ -791,6 +792,7 @@ func createTestApp(t *testing.T, testDB *database.Database) *models.App {
app.Branch = testBranch app.Branch = testBranch
app.DockerfilePath = "Dockerfile" app.DockerfilePath = "Dockerfile"
app.WebhookSecret = "secret-" + t.Name() app.WebhookSecret = "secret-" + t.Name()
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
app.SSHPrivateKey = "private" app.SSHPrivateKey = "private"
app.SSHPublicKey = "public" app.SSHPublicKey = "public"

View File

@ -37,18 +37,22 @@ func (s *Server) SetupRoutes() {
http.FileServer(http.FS(static.Static)), http.FileServer(http.FS(static.Static)),
)) ))
// Public routes // Webhook endpoint (uses secret for auth, not session — no CSRF)
s.router.Get("/login", s.handlers.HandleLoginGET())
s.router.Post("/login", s.handlers.HandleLoginPOST())
s.router.Get("/setup", s.handlers.HandleSetupGET())
s.router.Post("/setup", s.handlers.HandleSetupPOST())
// Webhook endpoint (uses secret for auth, not session)
s.router.Post("/webhook/{secret}", s.handlers.HandleWebhook()) s.router.Post("/webhook/{secret}", s.handlers.HandleWebhook())
// Protected routes (require session auth) // All HTML-serving routes get CSRF protection
s.router.Group(func(r chi.Router) { s.router.Group(func(r chi.Router) {
r.Use(s.mw.SessionAuth()) r.Use(s.mw.CSRF())
// Public routes
r.Get("/login", s.handlers.HandleLoginGET())
r.With(s.mw.LoginRateLimit()).Post("/login", s.handlers.HandleLoginPOST())
r.Get("/setup", s.handlers.HandleSetupGET())
r.Post("/setup", s.handlers.HandleSetupPOST())
// Protected routes (require session auth)
r.Group(func(r chi.Router) {
r.Use(s.mw.SessionAuth())
// Dashboard // Dashboard
r.Get("/", s.handlers.HandleDashboard()) r.Get("/", s.handlers.HandleDashboard())
@ -90,6 +94,7 @@ func (s *Server) SetupRoutes() {
// Ports // Ports
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd()) r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete()) r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
})
}) })
// Metrics endpoint (optional, with basic auth) // Metrics endpoint (optional, with basic auth)

View File

@ -11,6 +11,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/oklog/ulid/v2" "github.com/oklog/ulid/v2"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
@ -82,6 +83,7 @@ func (svc *Service) CreateApp(
} }
app.WebhookSecret = uuid.New().String() app.WebhookSecret = uuid.New().String()
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
app.SSHPrivateKey = keyPair.PrivateKey app.SSHPrivateKey = keyPair.PrivateKey
app.SSHPublicKey = keyPair.PublicKey app.SSHPublicKey = keyPair.PublicKey
app.Status = models.AppStatusPending app.Status = models.AppStatusPending

View File

@ -73,6 +73,7 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
Path: "/", Path: "/",
MaxAge: sessionMaxAgeSeconds, MaxAge: sessionMaxAgeSeconds,
HttpOnly: true, HttpOnly: true,
Secure: !params.Config.Debug,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
} }

View File

@ -2,6 +2,8 @@ package auth_test
import ( import (
"context" "context"
"net/http"
"net/http/httptest"
"path/filepath" "path/filepath"
"testing" "testing"
@ -68,6 +70,74 @@ func setupTestService(t *testing.T) (*auth.Service, func()) {
return svc, cleanup return svc, cleanup
} }
func TestSessionCookieSecureFlag(testingT *testing.T) {
testingT.Parallel()
testingT.Run("secure flag is true when debug is false", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalsInst, err := globals.New(fx.Lifecycle(nil))
require.NoError(t, err)
loggerInst, err := logger.New(
fx.Lifecycle(nil),
logger.Params{Globals: globalsInst},
)
require.NoError(t, err)
cfg := &config.Config{
Port: 8080,
DataDir: tmpDir,
SessionSecret: "test-secret-key-at-least-32-chars",
Debug: false,
}
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{
Logger: loggerInst,
Config: cfg,
})
require.NoError(t, err)
svc, err := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
Logger: loggerInst,
Config: cfg,
Database: dbInst,
})
require.NoError(t, err)
// Create user and session, check cookie has Secure flag
_, err = svc.CreateUser(context.Background(), "admin", "password123")
require.NoError(t, err)
user, err := svc.Authenticate(context.Background(), "admin", "password123")
require.NoError(t, err)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/", nil)
err = svc.CreateSession(recorder, request, user)
require.NoError(t, err)
cookies := recorder.Result().Cookies()
require.NotEmpty(t, cookies)
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "upaas_session" {
sessionCookie = c
break
}
}
require.NotNil(t, sessionCookie, "session cookie should exist")
assert.True(t, sessionCookie.Secure, "session cookie should have Secure flag in production mode")
})
}
func TestHashPassword(testingT *testing.T) { func TestHashPassword(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()

View File

@ -91,6 +91,7 @@ func createTestApp(
app.Branch = branch app.Branch = branch
app.DockerfilePath = "Dockerfile" app.DockerfilePath = "Dockerfile"
app.WebhookSecret = "webhook-secret-123" app.WebhookSecret = "webhook-secret-123"
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
app.SSHPrivateKey = "private-key" app.SSHPrivateKey = "private-key"
app.SSHPublicKey = "public-key" app.SSHPublicKey = "public-key"
app.Status = models.AppStatusPending app.Status = models.AppStatusPending

View File

@ -61,15 +61,21 @@ document.addEventListener("alpine:init", () => {
*/ */
scrollToBottom(el) { scrollToBottom(el) {
if (el) { if (el) {
// Use double RAF to ensure DOM has fully updated and reflowed
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { el.scrollTop = el.scrollHeight;
el.scrollTop = el.scrollHeight;
});
}); });
} }
}, },
/**
* Check if a scrollable element is at (or near) the bottom.
* Tolerance of 30px accounts for rounding and partial lines.
*/
isScrolledToBottom(el, tolerance = 30) {
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance;
},
/** /**
* Copy text to clipboard * Copy text to clipboard
*/ */
@ -176,11 +182,27 @@ document.addEventListener("alpine:init", () => {
showBuildLogs: !!config.initialDeploymentId, showBuildLogs: !!config.initialDeploymentId,
deploying: false, deploying: false,
deployments: [], deployments: [],
// Track whether user wants auto-scroll (per log pane)
_containerAutoScroll: true,
_buildAutoScroll: true,
init() { init() {
this.deploying = Alpine.store("utils").isDeploying(this.appStatus); this.deploying = Alpine.store("utils").isDeploying(this.appStatus);
this.fetchAll(); this.fetchAll();
setInterval(() => this.fetchAll(), 1000); setInterval(() => this.fetchAll(), 1000);
// Set up scroll listeners after DOM is ready
this.$nextTick(() => {
this._initScrollTracking(this.$refs.containerLogsWrapper, '_containerAutoScroll');
this._initScrollTracking(this.$refs.buildLogsWrapper, '_buildAutoScroll');
});
},
_initScrollTracking(el, flag) {
if (!el) return;
el.addEventListener('scroll', () => {
this[flag] = Alpine.store("utils").isScrolledToBottom(el);
}, { passive: true });
}, },
fetchAll() { fetchAll() {
@ -214,11 +236,15 @@ document.addEventListener("alpine:init", () => {
try { try {
const res = await fetch(`/apps/${this.appId}/container-logs`); const res = await fetch(`/apps/${this.appId}/container-logs`);
const data = await res.json(); const data = await res.json();
this.containerLogs = data.logs || "No logs available"; const newLogs = data.logs || "No logs available";
const changed = newLogs !== this.containerLogs;
this.containerLogs = newLogs;
this.containerStatus = data.status; this.containerStatus = data.status;
this.$nextTick(() => { if (changed && this._containerAutoScroll) {
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper); this.$nextTick(() => {
}); Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
});
}
} catch (err) { } catch (err) {
this.containerLogs = "Failed to fetch logs"; this.containerLogs = "Failed to fetch logs";
} }
@ -231,11 +257,15 @@ document.addEventListener("alpine:init", () => {
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`, `/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
); );
const data = await res.json(); const data = await res.json();
this.buildLogs = data.logs || "No build logs available"; const newLogs = data.logs || "No build logs available";
const changed = newLogs !== this.buildLogs;
this.buildLogs = newLogs;
this.buildStatus = data.status; this.buildStatus = data.status;
this.$nextTick(() => { if (changed && this._buildAutoScroll) {
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper); this.$nextTick(() => {
}); Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
});
}
} catch (err) { } catch (err) {
this.buildLogs = "Failed to fetch logs"; this.buildLogs = "Failed to fetch logs";
} }
@ -306,12 +336,23 @@ document.addEventListener("alpine:init", () => {
logs: "", logs: "",
status: config.status || "", status: config.status || "",
pollInterval: null, pollInterval: null,
_autoScroll: true,
init() { init() {
// Read initial logs from script tag (avoids escaping issues) // Read initial logs from script tag (avoids escaping issues)
const initialLogsEl = this.$el.querySelector(".initial-logs"); const initialLogsEl = this.$el.querySelector(".initial-logs");
this.logs = initialLogsEl?.textContent || "Loading..."; this.logs = initialLogsEl?.textContent || "Loading...";
// Set up scroll tracking
this.$nextTick(() => {
const wrapper = this.$refs.logsWrapper;
if (wrapper) {
wrapper.addEventListener('scroll', () => {
this._autoScroll = Alpine.store("utils").isScrolledToBottom(wrapper);
}, { passive: true });
}
});
// Only poll if deployment is in progress // Only poll if deployment is in progress
if (Alpine.store("utils").isDeploying(this.status)) { if (Alpine.store("utils").isDeploying(this.status)) {
this.fetchLogs(); this.fetchLogs();
@ -336,8 +377,8 @@ document.addEventListener("alpine:init", () => {
this.logs = newLogs; this.logs = newLogs;
this.status = data.status; this.status = data.status;
// Scroll to bottom only when content changes // Scroll to bottom only when content changes AND user hasn't scrolled up
if (logsChanged) { if (logsChanged && this._autoScroll) {
this.$nextTick(() => { this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper); Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper);
}); });

View File

@ -35,6 +35,7 @@
<div class="flex gap-3"> <div class="flex gap-3">
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary">Edit</a> <a href="/apps/{{.App.ID}}/edit" class="btn-secondary">Edit</a>
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline" @submit="submitDeploy()"> <form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline" @submit="submitDeploy()">
{{ .CSRFField }}
<button type="submit" class="btn-success" x-bind:disabled="deploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': deploying }"> <button type="submit" class="btn-success" x-bind:disabled="deploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': deploying }">
<span x-text="deploying ? 'Deploying...' : 'Deploy Now'"></span> <span x-text="deploying ? 'Deploying...' : 'Deploy Now'"></span>
</button> </button>
@ -106,6 +107,7 @@
<td class="font-mono text-gray-500">{{.Value}}</td> <td class="font-mono text-gray-500">{{.Value}}</td>
<td class="text-right"> <td class="text-right">
<form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)"> <form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
{{ .CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button> <button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form> </form>
</td> </td>
@ -116,6 +118,7 @@
</div> </div>
{{end}} {{end}}
<form method="POST" action="/apps/{{.App.ID}}/env" class="flex flex-col sm:flex-row gap-2"> <form method="POST" action="/apps/{{.App.ID}}/env" class="flex flex-col sm:flex-row gap-2">
{{ .CSRFField }}
<input type="text" name="key" placeholder="KEY" required class="input flex-1 font-mono text-sm"> <input type="text" name="key" placeholder="KEY" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm"> <input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary">Add</button> <button type="submit" class="btn-primary">Add</button>
@ -149,6 +152,7 @@
<td class="font-mono text-gray-500">{{.Value}}</td> <td class="font-mono text-gray-500">{{.Value}}</td>
<td class="text-right"> <td class="text-right">
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)"> <form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)">
{{ .CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button> <button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form> </form>
</td> </td>
@ -158,6 +162,7 @@
</table> </table>
</div> </div>
<form method="POST" action="/apps/{{.App.ID}}/labels" class="flex flex-col sm:flex-row gap-2"> <form method="POST" action="/apps/{{.App.ID}}/labels" class="flex flex-col sm:flex-row gap-2">
{{ .CSRFField }}
<input type="text" name="key" placeholder="label.key" required class="input flex-1 font-mono text-sm"> <input type="text" name="key" placeholder="label.key" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm"> <input type="text" name="value" placeholder="value" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary">Add</button> <button type="submit" class="btn-primary">Add</button>
@ -192,6 +197,7 @@
</td> </td>
<td class="text-right"> <td class="text-right">
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)"> <form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)">
{{ .CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button> <button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form> </form>
</td> </td>
@ -202,6 +208,7 @@
</div> </div>
{{end}} {{end}}
<form method="POST" action="/apps/{{.App.ID}}/volumes" class="flex flex-col sm:flex-row gap-2 items-end"> <form method="POST" action="/apps/{{.App.ID}}/volumes" class="flex flex-col sm:flex-row gap-2 items-end">
{{ .CSRFField }}
<div class="flex-1 w-full"> <div class="flex-1 w-full">
<input type="text" name="host_path" placeholder="/host/path" required class="input font-mono text-sm"> <input type="text" name="host_path" placeholder="/host/path" required class="input font-mono text-sm">
</div> </div>
@ -244,6 +251,7 @@
</td> </td>
<td class="text-right"> <td class="text-right">
<form method="POST" action="/apps/{{$.App.ID}}/ports/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this port mapping?')" @submit="confirm($event)"> <form method="POST" action="/apps/{{$.App.ID}}/ports/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this port mapping?')" @submit="confirm($event)">
{{ .CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button> <button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form> </form>
</td> </td>
@ -254,6 +262,7 @@
</div> </div>
{{end}} {{end}}
<form method="POST" action="/apps/{{.App.ID}}/ports" class="flex flex-col sm:flex-row gap-2 items-end"> <form method="POST" action="/apps/{{.App.ID}}/ports" class="flex flex-col sm:flex-row gap-2 items-end">
{{ .CSRFField }}
<div class="flex-1 w-full"> <div class="flex-1 w-full">
<label class="block text-xs text-gray-500 mb-1">Host (external)</label> <label class="block text-xs text-gray-500 mb-1">Host (external)</label>
<input type="text" name="host_port" placeholder="8080" required pattern="[0-9]+" class="input font-mono text-sm"> <input type="text" name="host_port" placeholder="8080" required pattern="[0-9]+" class="input font-mono text-sm">
@ -279,8 +288,17 @@
<h2 class="section-title">Container Logs</h2> <h2 class="section-title">Container Logs</h2>
<span x-bind:class="containerStatusBadgeClass" x-text="containerStatusLabel"></span> <span x-bind:class="containerStatusBadgeClass" x-text="containerStatusLabel"></span>
</div> </div>
<div x-ref="containerLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;"> <div class="relative">
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="containerLogs"></pre> <div x-ref="containerLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 400px;">
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="containerLogs"></pre>
</div>
<button
x-show="!_containerAutoScroll"
x-transition
@click="_containerAutoScroll = true; Alpine.store('utils').scrollToBottom($refs.containerLogsWrapper)"
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
title="Scroll to bottom"
>↓ Follow</button>
</div> </div>
</div> </div>
@ -329,8 +347,17 @@
<h2 class="section-title">Last Deployment Build Logs</h2> <h2 class="section-title">Last Deployment Build Logs</h2>
<span x-bind:class="buildStatusBadgeClass" x-text="buildStatusLabel"></span> <span x-bind:class="buildStatusBadgeClass" x-text="buildStatusLabel"></span>
</div> </div>
<div x-ref="buildLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;"> <div class="relative">
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="buildLogs"></pre> <div x-ref="buildLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 400px;">
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="buildLogs"></pre>
</div>
<button
x-show="!_buildAutoScroll"
x-transition
@click="_buildAutoScroll = true; Alpine.store('utils').scrollToBottom($refs.buildLogsWrapper)"
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
title="Scroll to bottom"
>↓ Follow</button>
</div> </div>
</div> </div>
@ -339,6 +366,7 @@
<h2 class="text-lg font-medium text-error-700 mb-4">Danger Zone</h2> <h2 class="text-lg font-medium text-error-700 mb-4">Danger Zone</h2>
<p class="text-error-600 text-sm mb-4">Deleting this app will remove all configuration and deployment history. This action cannot be undone.</p> <p class="text-error-600 text-sm mb-4">Deleting this app will remove all configuration and deployment history. This action cannot be undone.</p>
<form method="POST" action="/apps/{{.App.ID}}/delete" x-data="confirmAction('Are you sure you want to delete this app? This action cannot be undone.')" @submit="confirm($event)"> <form method="POST" action="/apps/{{.App.ID}}/delete" x-data="confirmAction('Are you sure you want to delete this app? This action cannot be undone.')" @submit="confirm($event)">
{{ .CSRFField }}
<button type="submit" class="btn-danger">Delete App</button> <button type="submit" class="btn-danger">Delete App</button>
</form> </form>
</div> </div>

View File

@ -21,6 +21,7 @@
{{template "alert-error" .}} {{template "alert-error" .}}
<form method="POST" action="/apps/{{.App.ID}}" class="space-y-6"> <form method="POST" action="/apps/{{.App.ID}}" class="space-y-6">
{{ .CSRFField }}
<div class="form-group"> <div class="form-group">
<label for="name" class="label">App Name</label> <label for="name" class="label">App Name</label>
<input <input

View File

@ -21,6 +21,7 @@
{{template "alert-error" .}} {{template "alert-error" .}}
<form method="POST" action="/apps" class="space-y-6"> <form method="POST" action="/apps" class="space-y-6">
{{ .CSRFField }}
<div class="form-group"> <div class="form-group">
<label for="name" class="label">App Name</label> <label for="name" class="label">App Name</label>
<input <input

View File

@ -32,6 +32,7 @@
New App New App
</a> </a>
<form method="POST" action="/logout" class="inline"> <form method="POST" action="/logout" class="inline">
{{ .CSRFField }}
<button type="submit" class="btn-text">Logout</button> <button type="submit" class="btn-text">Logout</button>
</form> </form>
</div> </div>

View File

@ -69,6 +69,7 @@
<a href="/apps/{{.App.ID}}" class="btn-text text-sm py-1 px-2">View</a> <a href="/apps/{{.App.ID}}" class="btn-text text-sm py-1 px-2">View</a>
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary text-sm py-1 px-2">Edit</a> <a href="/apps/{{.App.ID}}/edit" class="btn-secondary text-sm py-1 px-2">Edit</a>
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline"> <form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline">
{{ .CSRFField }}
<button type="submit" class="btn-success text-sm py-1 px-2">Deploy</button> <button type="submit" class="btn-success text-sm py-1 px-2">Deploy</button>
</form> </form>
</div> </div>

View File

@ -18,6 +18,7 @@
<div class="section-header"> <div class="section-header">
<h1 class="text-2xl font-medium text-gray-900">Deployment History</h1> <h1 class="text-2xl font-medium text-gray-900">Deployment History</h1>
<form method="POST" action="/apps/{{.App.ID}}/deploy" @submit="submitDeploy()"> <form method="POST" action="/apps/{{.App.ID}}/deploy" @submit="submitDeploy()">
{{ .CSRFField }}
<button type="submit" class="btn-success" x-bind:disabled="isDeploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': isDeploying }"> <button type="submit" class="btn-success" x-bind:disabled="isDeploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': isDeploying }">
<span x-text="isDeploying ? 'Deploying...' : 'Deploy Now'"></span> <span x-text="isDeploying ? 'Deploying...' : 'Deploy Now'"></span>
</button> </button>
@ -85,8 +86,17 @@
</a> </a>
{{end}} {{end}}
</div> </div>
<div x-ref="logsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;"> <div class="relative">
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="logs"></pre> <div x-ref="logsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-y-auto" style="max-height: 400px;">
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap break-words m-0" x-text="logs"></pre>
</div>
<button
x-show="!_autoScroll"
x-transition
@click="_autoScroll = true; Alpine.store('utils').scrollToBottom($refs.logsWrapper)"
class="absolute bottom-2 right-4 bg-primary-600 hover:bg-primary-700 text-white text-xs px-3 py-1 rounded-full shadow-lg opacity-90 hover:opacity-100 transition"
title="Scroll to bottom"
>↓ Follow</button>
</div> </div>
{{if .Logs.Valid}}<script type="text/plain" class="initial-logs">{{.Logs.String}}</script>{{end}} {{if .Logs.Valid}}<script type="text/plain" class="initial-logs">{{.Logs.String}}</script>{{end}}
</div> </div>
@ -103,6 +113,7 @@
<p class="empty-state-description">Deploy your application to see the deployment history here.</p> <p class="empty-state-description">Deploy your application to see the deployment history here.</p>
<div class="mt-6"> <div class="mt-6">
<form method="POST" action="/apps/{{.App.ID}}/deploy" @submit="submitDeploy()"> <form method="POST" action="/apps/{{.App.ID}}/deploy" @submit="submitDeploy()">
{{ .CSRFField }}
<button type="submit" class="btn-success" x-bind:disabled="isDeploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': isDeploying }"> <button type="submit" class="btn-success" x-bind:disabled="isDeploying" x-bind:class="{ 'opacity-50 cursor-not-allowed': isDeploying }">
<span x-text="isDeploying ? 'Deploying...' : 'Deploy Now'"></span> <span x-text="isDeploying ? 'Deploying...' : 'Deploy Now'"></span>
</button> </button>

View File

@ -14,6 +14,7 @@
{{template "alert-error" .}} {{template "alert-error" .}}
<form method="POST" action="/login" class="space-y-6"> <form method="POST" action="/login" class="space-y-6">
{{ .CSRFField }}
<div class="form-group"> <div class="form-group">
<label for="username" class="label">Username</label> <label for="username" class="label">Username</label>
<input <input

View File

@ -14,6 +14,7 @@
{{template "alert-error" .}} {{template "alert-error" .}}
<form method="POST" action="/setup" class="space-y-6"> <form method="POST" action="/setup" class="space-y-6">
{{ .CSRFField }}
<div class="form-group"> <div class="form-group">
<label for="username" class="label">Username</label> <label for="username" class="label">Username</label>
<input <input