Compare commits
17 Commits
13d5467177
...
867cdf01ab
| Author | SHA1 | Date | |
|---|---|---|---|
| 867cdf01ab | |||
| 6475389280 | |||
| 3a2bd0e51d | |||
| 79a3165f90 | |||
| 86491b1367 | |||
| 98b8403e8b | |||
| 076442923c | |||
| 57ea724419 | |||
| 39bcfb7456 | |||
| 4247162d30 | |||
| 4bad74081b | |||
|
|
be6080280e | ||
|
|
b1dc8fcc4e | ||
|
|
72786a9feb | ||
|
|
185daab909 | ||
|
|
ed4ddc5536 | ||
|
|
e212910143 |
3
go.mod
3
go.mod
@ -5,9 +5,11 @@ go 1.25
|
||||
require (
|
||||
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
|
||||
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/cors v1.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/csrf v1.7.3
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
@ -27,7 +29,6 @@ require (
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // 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/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@ -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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
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/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
|
||||
@ -3,7 +3,9 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@ -158,6 +160,60 @@ func (d *Database) connect(ctx context.Context) error {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
28
internal/database/hash_test.go
Normal file
28
internal/database/hash_test.go
Normal 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)
|
||||
}
|
||||
@ -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 '';
|
||||
@ -29,8 +29,8 @@ const (
|
||||
func (h *Handlers) HandleAppNew() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
|
||||
return func(writer http.ResponseWriter, _ *http.Request) {
|
||||
data := h.addGlobals(map[string]any{})
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
data := h.addGlobals(map[string]any{}, request)
|
||||
|
||||
err := tmpl.ExecuteTemplate(writer, "app_new.html", data)
|
||||
if err != nil {
|
||||
@ -57,12 +57,12 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc {
|
||||
branch := request.FormValue("branch")
|
||||
dockerfilePath := request.FormValue("dockerfile_path")
|
||||
|
||||
data := map[string]any{
|
||||
data := h.addGlobals(map[string]any{
|
||||
"Name": name,
|
||||
"RepoURL": repoURL,
|
||||
"Branch": branch,
|
||||
"DockerfilePath": dockerfilePath,
|
||||
}
|
||||
}, request)
|
||||
|
||||
if name == "" || repoURL == "" {
|
||||
data["Error"] = "Name and repository URL are required"
|
||||
@ -150,7 +150,7 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
|
||||
"WebhookURL": webhookURL,
|
||||
"DeployKey": deployKey,
|
||||
"Success": request.URL.Query().Get("success"),
|
||||
})
|
||||
}, request)
|
||||
|
||||
err := tmpl.ExecuteTemplate(writer, "app_detail.html", data)
|
||||
if err != nil {
|
||||
@ -183,7 +183,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
|
||||
|
||||
data := h.addGlobals(map[string]any{
|
||||
"App": application,
|
||||
})
|
||||
}, request)
|
||||
|
||||
err := tmpl.ExecuteTemplate(writer, "app_edit.html", data)
|
||||
if err != nil {
|
||||
@ -241,10 +241,10 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
|
||||
if saveErr != nil {
|
||||
h.log.Error("failed to update app", "error", saveErr)
|
||||
|
||||
data := map[string]any{
|
||||
data := h.addGlobals(map[string]any{
|
||||
"App": application,
|
||||
"Error": "Failed to update app",
|
||||
}
|
||||
}, request)
|
||||
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
|
||||
|
||||
return
|
||||
@ -267,6 +267,29 @@ func (h *Handlers) HandleAppDelete() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Stop and remove the Docker container before deleting the DB record
|
||||
containerInfo, containerErr := h.docker.FindContainerByAppID(request.Context(), appID)
|
||||
if containerErr == nil && containerInfo != nil {
|
||||
if containerInfo.Running {
|
||||
stopErr := h.docker.StopContainer(request.Context(), containerInfo.ID)
|
||||
if stopErr != nil {
|
||||
h.log.Error("failed to stop container during app deletion",
|
||||
"error", stopErr, "app", application.Name,
|
||||
"container", containerInfo.ID)
|
||||
}
|
||||
}
|
||||
|
||||
removeErr := h.docker.RemoveContainer(request.Context(), containerInfo.ID, true)
|
||||
if removeErr != nil {
|
||||
h.log.Error("failed to remove container during app deletion",
|
||||
"error", removeErr, "app", application.Name,
|
||||
"container", containerInfo.ID)
|
||||
} else {
|
||||
h.log.Info("removed container during app deletion",
|
||||
"app", application.Name, "container", containerInfo.ID)
|
||||
}
|
||||
}
|
||||
|
||||
deleteErr := application.Delete(request.Context())
|
||||
if deleteErr != nil {
|
||||
h.log.Error("failed to delete app", "error", deleteErr)
|
||||
@ -337,7 +360,7 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
|
||||
data := h.addGlobals(map[string]any{
|
||||
"App": application,
|
||||
"Deployments": deployments,
|
||||
})
|
||||
}, request)
|
||||
|
||||
err := tmpl.ExecuteTemplate(writer, "deployments.html", data)
|
||||
if err != nil {
|
||||
@ -801,7 +824,7 @@ func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
|
||||
}
|
||||
|
||||
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
|
||||
if findErr != nil || envVar == nil {
|
||||
if findErr != nil || envVar == nil || envVar.AppID != appID {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
@ -848,7 +871,7 @@ func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
|
||||
}
|
||||
|
||||
label, findErr := models.FindLabel(request.Context(), h.db, labelID)
|
||||
if findErr != nil || label == nil {
|
||||
if findErr != nil || label == nil || label.AppID != appID {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
@ -926,7 +949,7 @@ func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
|
||||
}
|
||||
|
||||
volume, findErr := models.FindVolume(request.Context(), h.db, volumeID)
|
||||
if findErr != nil || volume == nil {
|
||||
if findErr != nil || volume == nil || volume.AppID != appID {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
@ -1016,7 +1039,7 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc {
|
||||
}
|
||||
|
||||
port, findErr := models.FindPort(request.Context(), h.db, portID)
|
||||
if findErr != nil || port == nil {
|
||||
if findErr != nil || port == nil || port.AppID != appID {
|
||||
http.NotFound(writer, request)
|
||||
|
||||
return
|
||||
|
||||
@ -10,8 +10,8 @@ import (
|
||||
func (h *Handlers) HandleLoginGET() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
|
||||
return func(writer http.ResponseWriter, _ *http.Request) {
|
||||
data := h.addGlobals(map[string]any{})
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
data := h.addGlobals(map[string]any{}, request)
|
||||
|
||||
err := tmpl.ExecuteTemplate(writer, "login.html", data)
|
||||
if err != nil {
|
||||
@ -38,7 +38,7 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc {
|
||||
|
||||
data := h.addGlobals(map[string]any{
|
||||
"Username": username,
|
||||
})
|
||||
}, request)
|
||||
|
||||
if username == "" || password == "" {
|
||||
data["Error"] = "Username and password are required"
|
||||
|
||||
@ -67,7 +67,7 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
|
||||
|
||||
data := h.addGlobals(map[string]any{
|
||||
"AppStats": appStats,
|
||||
})
|
||||
}, request)
|
||||
|
||||
execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data)
|
||||
if execErr != nil {
|
||||
|
||||
@ -3,9 +3,11 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
@ -64,11 +66,18 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// addGlobals adds version info to template data map.
|
||||
func (h *Handlers) addGlobals(data map[string]any) map[string]any {
|
||||
// addGlobals adds version info and CSRF token to template data map.
|
||||
func (h *Handlers) addGlobals(
|
||||
data map[string]any,
|
||||
request *http.Request,
|
||||
) map[string]any {
|
||||
data["Version"] = h.globals.Version
|
||||
data["Appname"] = h.globals.Appname
|
||||
|
||||
if request != nil {
|
||||
data["CSRFField"] = template.HTML(csrf.TemplateField(request)) //nolint:gosec // csrf.TemplateField produces safe HTML
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@ -14,6 +15,8 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/models"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
"git.eeqj.de/sneak/upaas/internal/docker"
|
||||
@ -426,6 +429,233 @@ func addChiURLParams(
|
||||
)
|
||||
}
|
||||
|
||||
// createTestApp creates an app using the app service and returns it.
|
||||
func createTestApp(
|
||||
t *testing.T,
|
||||
tc *testContext,
|
||||
name string,
|
||||
) *models.App {
|
||||
t.Helper()
|
||||
|
||||
createdApp, err := tc.appSvc.CreateApp(
|
||||
context.Background(),
|
||||
app.CreateAppInput{
|
||||
Name: name,
|
||||
RepoURL: "git@example.com:user/" + name + ".git",
|
||||
Branch: "main",
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return createdApp
|
||||
}
|
||||
|
||||
// TestDeleteEnvVarOwnershipVerification tests that deleting an env var
|
||||
// via another app's URL path returns 404 (IDOR prevention).
|
||||
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)
|
||||
}
|
||||
|
||||
// TestDeleteEnvVarOwnershipVerification tests that deleting an env var
|
||||
// via another app's URL path returns 404 (IDOR prevention).
|
||||
func TestDeleteEnvVarOwnershipVerification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
app1 := createTestApp(t, testCtx, "envvar-owner-app")
|
||||
app2 := createTestApp(t, testCtx, "envvar-other-app")
|
||||
|
||||
// Create env var belonging to app1
|
||||
envVar := models.NewEnvVar(testCtx.database)
|
||||
envVar.AppID = app1.ID
|
||||
envVar.Key = "SECRET"
|
||||
envVar.Value = "hunter2"
|
||||
require.NoError(t, envVar.Save(context.Background()))
|
||||
|
||||
// Try to delete app1's env var using app2's URL path
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/apps/"+app2.ID+"/env/"+strconv.FormatInt(envVar.ID, 10)+"/delete",
|
||||
nil,
|
||||
)
|
||||
request = addChiURLParams(request, map[string]string{
|
||||
"id": app2.ID,
|
||||
"envID": strconv.FormatInt(envVar.ID, 10),
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleEnvVarDelete()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
// Should return 404 because the env var doesn't belong to app2
|
||||
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
|
||||
// Verify the env var was NOT deleted
|
||||
found, err := models.FindEnvVar(context.Background(), testCtx.database, envVar.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, found, "env var should still exist after IDOR attempt")
|
||||
}
|
||||
|
||||
// TestDeleteLabelOwnershipVerification tests that deleting a label
|
||||
// via another app's URL path returns 404 (IDOR prevention).
|
||||
func TestDeleteLabelOwnershipVerification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
app1 := createTestApp(t, testCtx, "label-owner-app")
|
||||
app2 := createTestApp(t, testCtx, "label-other-app")
|
||||
|
||||
// Create label belonging to app1
|
||||
label := models.NewLabel(testCtx.database)
|
||||
label.AppID = app1.ID
|
||||
label.Key = "traefik.enable"
|
||||
label.Value = "true"
|
||||
require.NoError(t, label.Save(context.Background()))
|
||||
|
||||
// Try to delete app1's label using app2's URL path
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/apps/"+app2.ID+"/labels/"+strconv.FormatInt(label.ID, 10)+"/delete",
|
||||
nil,
|
||||
)
|
||||
request = addChiURLParams(request, map[string]string{
|
||||
"id": app2.ID,
|
||||
"labelID": strconv.FormatInt(label.ID, 10),
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleLabelDelete()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
|
||||
// Verify the label was NOT deleted
|
||||
found, err := models.FindLabel(context.Background(), testCtx.database, label.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, found, "label should still exist after IDOR attempt")
|
||||
}
|
||||
|
||||
// TestDeleteVolumeOwnershipVerification tests that deleting a volume
|
||||
// via another app's URL path returns 404 (IDOR prevention).
|
||||
func TestDeleteVolumeOwnershipVerification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
app1 := createTestApp(t, testCtx, "volume-owner-app")
|
||||
app2 := createTestApp(t, testCtx, "volume-other-app")
|
||||
|
||||
// Create volume belonging to app1
|
||||
volume := models.NewVolume(testCtx.database)
|
||||
volume.AppID = app1.ID
|
||||
volume.HostPath = "/data/app1"
|
||||
volume.ContainerPath = "/app/data"
|
||||
volume.ReadOnly = false
|
||||
require.NoError(t, volume.Save(context.Background()))
|
||||
|
||||
// Try to delete app1's volume using app2's URL path
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/apps/"+app2.ID+"/volumes/"+strconv.FormatInt(volume.ID, 10)+"/delete",
|
||||
nil,
|
||||
)
|
||||
request = addChiURLParams(request, map[string]string{
|
||||
"id": app2.ID,
|
||||
"volumeID": strconv.FormatInt(volume.ID, 10),
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandleVolumeDelete()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
|
||||
// Verify the volume was NOT deleted
|
||||
found, err := models.FindVolume(context.Background(), testCtx.database, volume.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, found, "volume should still exist after IDOR attempt")
|
||||
}
|
||||
|
||||
// TestDeletePortOwnershipVerification tests that deleting a port
|
||||
// via another app's URL path returns 404 (IDOR prevention).
|
||||
func TestDeletePortOwnershipVerification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCtx := setupTestHandlers(t)
|
||||
|
||||
app1 := createTestApp(t, testCtx, "port-owner-app")
|
||||
app2 := createTestApp(t, testCtx, "port-other-app")
|
||||
|
||||
// Create port belonging to app1
|
||||
port := models.NewPort(testCtx.database)
|
||||
port.AppID = app1.ID
|
||||
port.HostPort = 8080
|
||||
port.ContainerPort = 80
|
||||
port.Protocol = models.PortProtocolTCP
|
||||
require.NoError(t, port.Save(context.Background()))
|
||||
|
||||
// Try to delete app1's port using app2's URL path
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/apps/"+app2.ID+"/ports/"+strconv.FormatInt(port.ID, 10)+"/delete",
|
||||
nil,
|
||||
)
|
||||
request = addChiURLParams(request, map[string]string{
|
||||
"id": app2.ID,
|
||||
"portID": strconv.FormatInt(port.ID, 10),
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := testCtx.handlers.HandlePortDelete()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
|
||||
// Verify the port was NOT deleted
|
||||
found, err := models.FindPort(context.Background(), testCtx.database, port.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, found, "port should still exist after IDOR attempt")
|
||||
}
|
||||
|
||||
func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@ -15,8 +15,8 @@ const (
|
||||
func (h *Handlers) HandleSetupGET() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
|
||||
return func(writer http.ResponseWriter, _ *http.Request) {
|
||||
data := h.addGlobals(map[string]any{})
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
data := h.addGlobals(map[string]any{}, request)
|
||||
|
||||
err := tmpl.ExecuteTemplate(writer, "setup.html", data)
|
||||
if err != nil {
|
||||
@ -54,13 +54,14 @@ func validateSetupForm(formData setupFormData) string {
|
||||
func (h *Handlers) renderSetupError(
|
||||
tmpl *templates.TemplateExecutor,
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
username string,
|
||||
errorMsg string,
|
||||
) {
|
||||
data := h.addGlobals(map[string]any{
|
||||
"Username": username,
|
||||
"Error": errorMsg,
|
||||
})
|
||||
}, request)
|
||||
_ = tmpl.ExecuteTemplate(writer, "setup.html", data)
|
||||
}
|
||||
|
||||
@ -83,7 +84,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
|
||||
}
|
||||
|
||||
if validationErr := validateSetupForm(formData); validationErr != "" {
|
||||
h.renderSetupError(tmpl, writer, formData.username, validationErr)
|
||||
h.renderSetupError(tmpl, writer, request, formData.username, validationErr)
|
||||
|
||||
return
|
||||
}
|
||||
@ -95,7 +96,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
|
||||
)
|
||||
if createErr != nil {
|
||||
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
|
||||
}
|
||||
@ -106,6 +107,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
|
||||
h.renderSetupError(
|
||||
tmpl,
|
||||
writer,
|
||||
request,
|
||||
formData.username,
|
||||
"Failed to create session",
|
||||
)
|
||||
|
||||
@ -9,6 +9,9 @@ import (
|
||||
"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.
|
||||
func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
@ -38,8 +41,8 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body
|
||||
body, readErr := io.ReadAll(request.Body)
|
||||
// Read request body with size limit to prevent memory exhaustion
|
||||
body, readErr := io.ReadAll(io.LimitReader(request.Body, maxWebhookBodySize))
|
||||
if readErr != nil {
|
||||
h.log.Error("failed to read webhook body", "error", readErr)
|
||||
http.Error(writer, "Bad Request", http.StatusBadRequest)
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"github.com/99designs/basicauth-go"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/gorilla/csrf"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
@ -152,6 +153,15 @@ 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("/"),
|
||||
)
|
||||
}
|
||||
|
||||
// SetupRequired returns middleware that redirects to setup if no user exists.
|
||||
func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
|
||||
@ -10,6 +10,12 @@ import (
|
||||
"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.
|
||||
type AppStatus string
|
||||
|
||||
@ -31,8 +37,9 @@ type App struct {
|
||||
RepoURL string
|
||||
Branch string
|
||||
DockerfilePath string
|
||||
WebhookSecret string
|
||||
SSHPrivateKey string
|
||||
WebhookSecret string
|
||||
WebhookSecretHash string
|
||||
SSHPrivateKey string
|
||||
SSHPublicKey string
|
||||
ImageID sql.NullString
|
||||
Status AppStatus
|
||||
@ -70,11 +77,8 @@ func (a *App) Delete(ctx context.Context) error {
|
||||
|
||||
// Reload refreshes the app from the database.
|
||||
func (a *App) Reload(ctx context.Context) error {
|
||||
row := a.db.QueryRow(ctx, `
|
||||
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
||||
FROM apps WHERE id = ?`,
|
||||
row := a.db.QueryRow(ctx,
|
||||
"SELECT "+appColumns+" FROM apps WHERE id = ?",
|
||||
a.ID,
|
||||
)
|
||||
|
||||
@ -136,13 +140,13 @@ func (a *App) insert(ctx context.Context) error {
|
||||
INSERT INTO apps (
|
||||
id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
_, err := a.db.Exec(ctx, query,
|
||||
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
|
||||
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 {
|
||||
return err
|
||||
@ -177,6 +181,7 @@ func (a *App) scan(row *sql.Row) error {
|
||||
&a.SSHPrivateKey, &a.SSHPublicKey,
|
||||
&a.ImageID, &a.Status,
|
||||
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
|
||||
&a.WebhookSecretHash,
|
||||
&a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
}
|
||||
@ -193,6 +198,7 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
|
||||
&app.SSHPrivateKey, &app.SSHPublicKey,
|
||||
&app.ImageID, &app.Status,
|
||||
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
|
||||
&app.WebhookSecretHash,
|
||||
&app.CreatedAt, &app.UpdatedAt,
|
||||
)
|
||||
if scanErr != nil {
|
||||
@ -221,11 +227,8 @@ func FindApp(
|
||||
app := NewApp(appDB)
|
||||
app.ID = appID
|
||||
|
||||
row := appDB.QueryRow(ctx, `
|
||||
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
||||
FROM apps WHERE id = ?`,
|
||||
row := appDB.QueryRow(ctx,
|
||||
"SELECT "+appColumns+" FROM apps WHERE id = ?",
|
||||
appID,
|
||||
)
|
||||
|
||||
@ -241,7 +244,8 @@ func FindApp(
|
||||
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
|
||||
func FindAppByWebhookSecret(
|
||||
@ -250,13 +254,11 @@ func FindAppByWebhookSecret(
|
||||
secret string,
|
||||
) (*App, error) {
|
||||
app := NewApp(appDB)
|
||||
secretHash := database.HashWebhookSecret(secret)
|
||||
|
||||
row := appDB.QueryRow(ctx, `
|
||||
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
||||
FROM apps WHERE webhook_secret = ?`,
|
||||
secret,
|
||||
row := appDB.QueryRow(ctx,
|
||||
"SELECT "+appColumns+" FROM apps WHERE webhook_secret_hash = ?",
|
||||
secretHash,
|
||||
)
|
||||
|
||||
err := app.scan(row)
|
||||
@ -273,11 +275,8 @@ func FindAppByWebhookSecret(
|
||||
|
||||
// AllApps returns all apps ordered by name.
|
||||
func AllApps(ctx context.Context, appDB *database.Database) ([]*App, error) {
|
||||
rows, err := appDB.Query(ctx, `
|
||||
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
||||
FROM apps ORDER BY name`,
|
||||
rows, err := appDB.Query(ctx,
|
||||
"SELECT "+appColumns+" FROM apps ORDER BY name",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying all apps: %w", err)
|
||||
|
||||
@ -297,6 +297,7 @@ func TestAllApps(t *testing.T) {
|
||||
app.Branch = testBranch
|
||||
app.DockerfilePath = "Dockerfile"
|
||||
app.WebhookSecret = "secret-" + strconv.Itoa(idx)
|
||||
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
|
||||
app.SSHPrivateKey = "private"
|
||||
app.SSHPublicKey = "public"
|
||||
|
||||
@ -791,6 +792,7 @@ func createTestApp(t *testing.T, testDB *database.Database) *models.App {
|
||||
app.Branch = testBranch
|
||||
app.DockerfilePath = "Dockerfile"
|
||||
app.WebhookSecret = "secret-" + t.Name()
|
||||
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
|
||||
app.SSHPrivateKey = "private"
|
||||
app.SSHPublicKey = "public"
|
||||
|
||||
|
||||
@ -37,18 +37,22 @@ func (s *Server) SetupRoutes() {
|
||||
http.FileServer(http.FS(static.Static)),
|
||||
))
|
||||
|
||||
// Public routes
|
||||
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)
|
||||
// Webhook endpoint (uses secret for auth, not session — no CSRF)
|
||||
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) {
|
||||
r.Use(s.mw.SessionAuth())
|
||||
r.Use(s.mw.CSRF())
|
||||
|
||||
// Public routes
|
||||
r.Get("/login", s.handlers.HandleLoginGET())
|
||||
r.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
|
||||
r.Get("/", s.handlers.HandleDashboard())
|
||||
@ -90,6 +94,7 @@ func (s *Server) SetupRoutes() {
|
||||
// Ports
|
||||
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
|
||||
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
|
||||
})
|
||||
})
|
||||
|
||||
// Metrics endpoint (optional, with basic auth)
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
@ -82,6 +83,7 @@ func (svc *Service) CreateApp(
|
||||
}
|
||||
|
||||
app.WebhookSecret = uuid.New().String()
|
||||
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
|
||||
app.SSHPrivateKey = keyPair.PrivateKey
|
||||
app.SSHPublicKey = keyPair.PublicKey
|
||||
app.Status = models.AppStatusPending
|
||||
|
||||
@ -73,6 +73,7 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
|
||||
Path: "/",
|
||||
MaxAge: sessionMaxAgeSeconds,
|
||||
HttpOnly: true,
|
||||
Secure: !params.Config.Debug,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,8 @@ package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@ -68,6 +70,74 @@ func setupTestService(t *testing.T) (*auth.Service, func()) {
|
||||
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) {
|
||||
testingT.Parallel()
|
||||
|
||||
|
||||
@ -91,6 +91,7 @@ func createTestApp(
|
||||
app.Branch = branch
|
||||
app.DockerfilePath = "Dockerfile"
|
||||
app.WebhookSecret = "webhook-secret-123"
|
||||
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
|
||||
app.SSHPrivateKey = "private-key"
|
||||
app.SSHPublicKey = "public-key"
|
||||
app.Status = models.AppStatusPending
|
||||
|
||||
@ -61,15 +61,21 @@ document.addEventListener("alpine:init", () => {
|
||||
*/
|
||||
scrollToBottom(el) {
|
||||
if (el) {
|
||||
// Use double RAF to ensure DOM has fully updated and reflowed
|
||||
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
|
||||
*/
|
||||
@ -176,11 +182,27 @@ document.addEventListener("alpine:init", () => {
|
||||
showBuildLogs: !!config.initialDeploymentId,
|
||||
deploying: false,
|
||||
deployments: [],
|
||||
// Track whether user wants auto-scroll (per log pane)
|
||||
_containerAutoScroll: true,
|
||||
_buildAutoScroll: true,
|
||||
|
||||
init() {
|
||||
this.deploying = Alpine.store("utils").isDeploying(this.appStatus);
|
||||
this.fetchAll();
|
||||
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() {
|
||||
@ -214,11 +236,15 @@ document.addEventListener("alpine:init", () => {
|
||||
try {
|
||||
const res = await fetch(`/apps/${this.appId}/container-logs`);
|
||||
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.$nextTick(() => {
|
||||
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
|
||||
});
|
||||
if (changed && this._containerAutoScroll) {
|
||||
this.$nextTick(() => {
|
||||
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.containerLogs = "Failed to fetch logs";
|
||||
}
|
||||
@ -231,11 +257,15 @@ document.addEventListener("alpine:init", () => {
|
||||
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
|
||||
);
|
||||
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.$nextTick(() => {
|
||||
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
|
||||
});
|
||||
if (changed && this._buildAutoScroll) {
|
||||
this.$nextTick(() => {
|
||||
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.buildLogs = "Failed to fetch logs";
|
||||
}
|
||||
@ -306,12 +336,23 @@ document.addEventListener("alpine:init", () => {
|
||||
logs: "",
|
||||
status: config.status || "",
|
||||
pollInterval: null,
|
||||
_autoScroll: true,
|
||||
|
||||
init() {
|
||||
// Read initial logs from script tag (avoids escaping issues)
|
||||
const initialLogsEl = this.$el.querySelector(".initial-logs");
|
||||
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
|
||||
if (Alpine.store("utils").isDeploying(this.status)) {
|
||||
this.fetchLogs();
|
||||
@ -336,8 +377,8 @@ document.addEventListener("alpine:init", () => {
|
||||
this.logs = newLogs;
|
||||
this.status = data.status;
|
||||
|
||||
// Scroll to bottom only when content changes
|
||||
if (logsChanged) {
|
||||
// Scroll to bottom only when content changes AND user hasn't scrolled up
|
||||
if (logsChanged && this._autoScroll) {
|
||||
this.$nextTick(() => {
|
||||
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper);
|
||||
});
|
||||
|
||||
@ -35,6 +35,7 @@
|
||||
<div class="flex gap-3">
|
||||
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary">Edit</a>
|
||||
<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 }">
|
||||
<span x-text="deploying ? 'Deploying...' : 'Deploy Now'"></span>
|
||||
</button>
|
||||
@ -106,6 +107,7 @@
|
||||
<td class="font-mono text-gray-500">{{.Value}}</td>
|
||||
<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)">
|
||||
{{ .CSRFField }}
|
||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
@ -116,6 +118,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
<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="value" placeholder="value" required class="input flex-1 font-mono text-sm">
|
||||
<button type="submit" class="btn-primary">Add</button>
|
||||
@ -149,6 +152,7 @@
|
||||
<td class="font-mono text-gray-500">{{.Value}}</td>
|
||||
<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)">
|
||||
{{ .CSRFField }}
|
||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
@ -158,6 +162,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<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="value" placeholder="value" required class="input flex-1 font-mono text-sm">
|
||||
<button type="submit" class="btn-primary">Add</button>
|
||||
@ -192,6 +197,7 @@
|
||||
</td>
|
||||
<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)">
|
||||
{{ .CSRFField }}
|
||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
@ -202,6 +208,7 @@
|
||||
</div>
|
||||
{{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">
|
||||
<input type="text" name="host_path" placeholder="/host/path" required class="input font-mono text-sm">
|
||||
</div>
|
||||
@ -244,6 +251,7 @@
|
||||
</td>
|
||||
<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)">
|
||||
{{ .CSRFField }}
|
||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
@ -254,6 +262,7 @@
|
||||
</div>
|
||||
{{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">
|
||||
<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">
|
||||
@ -279,8 +288,17 @@
|
||||
<h2 class="section-title">Container Logs</h2>
|
||||
<span x-bind:class="containerStatusBadgeClass" x-text="containerStatusLabel"></span>
|
||||
</div>
|
||||
<div x-ref="containerLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
||||
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="containerLogs"></pre>
|
||||
<div class="relative">
|
||||
<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>
|
||||
|
||||
@ -329,8 +347,17 @@
|
||||
<h2 class="section-title">Last Deployment Build Logs</h2>
|
||||
<span x-bind:class="buildStatusBadgeClass" x-text="buildStatusLabel"></span>
|
||||
</div>
|
||||
<div x-ref="buildLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
||||
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="buildLogs"></pre>
|
||||
<div class="relative">
|
||||
<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>
|
||||
|
||||
@ -339,6 +366,7 @@
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
{{template "alert-error" .}}
|
||||
|
||||
<form method="POST" action="/apps/{{.App.ID}}" class="space-y-6">
|
||||
{{ .CSRFField }}
|
||||
<div class="form-group">
|
||||
<label for="name" class="label">App Name</label>
|
||||
<input
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
{{template "alert-error" .}}
|
||||
|
||||
<form method="POST" action="/apps" class="space-y-6">
|
||||
{{ .CSRFField }}
|
||||
<div class="form-group">
|
||||
<label for="name" class="label">App Name</label>
|
||||
<input
|
||||
|
||||
@ -32,6 +32,7 @@
|
||||
New App
|
||||
</a>
|
||||
<form method="POST" action="/logout" class="inline">
|
||||
{{ .CSRFField }}
|
||||
<button type="submit" class="btn-text">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -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}}/edit" class="btn-secondary text-sm py-1 px-2">Edit</a>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
<div class="section-header">
|
||||
<h1 class="text-2xl font-medium text-gray-900">Deployment History</h1>
|
||||
<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 }">
|
||||
<span x-text="isDeploying ? 'Deploying...' : 'Deploy Now'"></span>
|
||||
</button>
|
||||
@ -85,8 +86,17 @@
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div x-ref="logsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
|
||||
<pre class="text-gray-100 text-xs font-mono whitespace-pre-wrap" x-text="logs"></pre>
|
||||
<div class="relative">
|
||||
<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>
|
||||
{{if .Logs.Valid}}<script type="text/plain" class="initial-logs">{{.Logs.String}}</script>{{end}}
|
||||
</div>
|
||||
@ -103,6 +113,7 @@
|
||||
<p class="empty-state-description">Deploy your application to see the deployment history here.</p>
|
||||
<div class="mt-6">
|
||||
<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 }">
|
||||
<span x-text="isDeploying ? 'Deploying...' : 'Deploy Now'"></span>
|
||||
</button>
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
{{template "alert-error" .}}
|
||||
|
||||
<form method="POST" action="/login" class="space-y-6">
|
||||
{{ .CSRFField }}
|
||||
<div class="form-group">
|
||||
<label for="username" class="label">Username</label>
|
||||
<input
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
{{template "alert-error" .}}
|
||||
|
||||
<form method="POST" action="/setup" class="space-y-6">
|
||||
{{ .CSRFField }}
|
||||
<div class="form-group">
|
||||
<label for="username" class="label">Username</label>
|
||||
<input
|
||||
|
||||
Loading…
Reference in New Issue
Block a user