Merge pull request #69 from xarmian/phase-14/production-ops

Phase 14: Production Ops — Metrics, SSE Limits, Audit Trail, Deploy & Backup
This commit is contained in:
xarmian
2026-04-06 21:30:43 -04:00
committed by GitHub
42 changed files with 2492 additions and 45 deletions
+14
View File
@@ -49,6 +49,7 @@ func workspaceCmd() *cobra.Command {
joinCmd(),
exportCmd(),
importCmd(),
auditLogCmd(),
)
return cmd
}
@@ -143,6 +144,19 @@ func agentCmd() *cobra.Command {
return cmd
}
func dbCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "db",
Short: "Database backup, restore, and migration tools",
}
cmd.AddCommand(
dbBackupCmd(),
dbRestoreCmd(),
dbMigrateToPgCmd(),
)
return cmd
}
func agentUpdateCmd() *cobra.Command {
return &cobra.Command{
Use: "update",
+350
View File
@@ -33,6 +33,7 @@ import (
"github.com/redis/go-redis/v9"
"github.com/xarmian/pad/internal/events"
"github.com/xarmian/pad/internal/logging"
"github.com/xarmian/pad/internal/metrics"
"github.com/xarmian/pad/internal/models"
"github.com/xarmian/pad/internal/server"
"github.com/xarmian/pad/internal/store"
@@ -86,6 +87,7 @@ func main() {
githubCmd(),
roleCmd(),
webhooksCmd(),
dbCmd(),
completionCmd(),
)
@@ -223,6 +225,13 @@ func serveCmd() *cobra.Command {
srv.SetBaseURL(cfg.BaseURL())
srv.SetCORSOrigins(cfg.CORSOrigins)
srv.SetSecureCookies(cfg.SecureCookies)
srv.SetSSELimits(cfg.SSEMaxConnections, cfg.SSEMaxPerWorkspace)
// Initialize Prometheus metrics
m := metrics.New()
m.RegisterDBCollector(s.DB())
srv.SetMetrics(m)
slog.Info("Prometheus metrics enabled at /metrics")
// Attach event bus for real-time SSE
var eventBus events.EventBus
@@ -241,6 +250,8 @@ func serveCmd() *cobra.Command {
eventBus = events.New()
slog.Info("Event bus using in-memory (single instance)")
}
// Wrap event bus with Prometheus instrumentation
eventBus = metrics.NewInstrumentedBus(eventBus, m)
srv.SetEventBus(eventBus)
// Attach webhook dispatcher for outgoing notifications
@@ -5190,3 +5201,342 @@ func relativeTimeStr(t time.Time) string {
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
// --- database tools ---
// pgDbnameFromURL extracts just the database name from a PostgreSQL URL for display purposes.
func pgDbnameFromURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return "unknown"
}
return strings.TrimPrefix(u.Path, "/")
}
func dbBackupCmd() *cobra.Command {
var output string
var cronMode bool
cmd := &cobra.Command{
Use: "backup",
Short: "Back up the PostgreSQL database using pg_dump",
Long: `Creates a SQL dump of the Pad PostgreSQL database.
Requires pg_dump to be installed and PAD_DATABASE_URL or PAD_DB_DRIVER=postgres to be configured.
For SQLite, simply copy the database file (default: ~/.pad/pad.db).`,
RunE: func(cmd *cobra.Command, args []string) error {
dbURL := os.Getenv("PAD_DATABASE_URL")
if dbURL == "" {
return fmt.Errorf("PAD_DATABASE_URL is not set. This command requires PostgreSQL.\nFor SQLite, copy the database file directly: cp ~/.pad/pad.db backup.db")
}
if output == "" {
output = fmt.Sprintf("pad-backup-%s.sql", time.Now().Format("20060102-150405"))
}
// Pass the connection URL via environment variable instead of
// command-line args, so credentials don't leak in ps/proc output.
// --clean emits DROP statements so the dump can be restored into an
// existing database, and --if-exists avoids errors on a fresh DB.
pgArgs := []string{
"--format", "plain",
"--clean",
"--if-exists",
"--file", output,
}
pgCmd := exec.Command("pg_dump", pgArgs...)
pgCmd.Env = append(os.Environ(), "PGDATABASE="+dbURL)
pgCmd.Stdout = os.Stdout
pgCmd.Stderr = os.Stderr
dbname := pgDbnameFromURL(dbURL)
if !cronMode {
fmt.Fprintf(os.Stderr, "Backing up database %s to %s...\n", dbname, output)
}
if err := pgCmd.Run(); err != nil {
if cronMode {
slog.Error("backup failed", "error", err, "output", output)
}
return fmt.Errorf("pg_dump failed: %w", err)
}
// Get file size
if info, err := os.Stat(output); err == nil {
sizeMB := float64(info.Size()) / 1024 / 1024
if cronMode {
slog.Info("backup completed", "output", output, "size_mb", fmt.Sprintf("%.1f", sizeMB))
} else {
fmt.Fprintf(os.Stderr, "Backup complete: %s (%.1f MB)\n", output, sizeMB)
}
}
return nil
},
}
cmd.Flags().StringVarP(&output, "output", "o", "", "output file path (default: pad-backup-YYYYMMDD-HHMMSS.sql)")
cmd.Flags().BoolVar(&cronMode, "cron", false, "cron mode: structured log output, no interactive messages")
return cmd
}
func dbRestoreCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "restore <file.sql>",
Short: "Restore a PostgreSQL database from a backup",
Long: `Restores a Pad PostgreSQL database from a SQL dump created by 'pad db backup'.
Requires psql to be installed and PAD_DATABASE_URL to be configured.
WARNING: This will overwrite the current database contents.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inputFile := args[0]
if _, err := os.Stat(inputFile); os.IsNotExist(err) {
return fmt.Errorf("backup file not found: %s", inputFile)
}
dbURL := os.Getenv("PAD_DATABASE_URL")
if dbURL == "" {
return fmt.Errorf("PAD_DATABASE_URL is not set. This command requires PostgreSQL.")
}
dbname := pgDbnameFromURL(dbURL)
if !force {
fmt.Fprintf(os.Stderr, "WARNING: This will overwrite the database '%s' with data from %s.\n", dbname, inputFile)
fmt.Fprintf(os.Stderr, "Run with --force to skip this confirmation, or press Ctrl+C to abort.\n")
fmt.Fprintf(os.Stderr, "Continue? [y/N] ")
var confirm string
fmt.Scanln(&confirm)
if confirm != "y" && confirm != "Y" {
fmt.Fprintln(os.Stderr, "Aborted.")
return nil
}
}
// Pass the connection URL via environment variable instead of
// command-line args, so credentials don't leak in ps/proc output.
psqlArgs := []string{
"--file", inputFile,
"--single-transaction",
}
psqlCmd := exec.Command("psql", psqlArgs...)
psqlCmd.Env = append(os.Environ(), "PGDATABASE="+dbURL)
psqlCmd.Stdout = os.Stdout
psqlCmd.Stderr = os.Stderr
fmt.Fprintf(os.Stderr, "Restoring database %s from %s...\n", dbname, inputFile)
if err := psqlCmd.Run(); err != nil {
return fmt.Errorf("psql restore failed: %w", err)
}
fmt.Fprintln(os.Stderr, "Restore complete.")
return nil
},
}
cmd.Flags().BoolVar(&force, "force", false, "skip confirmation prompt")
return cmd
}
func dbMigrateToPgCmd() *cobra.Command {
var fromPath string
var toURL string
cmd := &cobra.Command{
Use: "migrate-to-pg",
Short: "Migrate data from SQLite to PostgreSQL",
Long: `One-time migration from a SQLite database to PostgreSQL.
Uses application-level export/import to transfer all workspace data.
This reads each workspace from the SQLite database and imports it into
the PostgreSQL database. Users, platform settings, and auth data are
NOT migrated — only workspace content (collections, items, comments,
links, versions).
Steps:
1. Set up a fresh PostgreSQL database
2. Run 'pad serve' with PAD_DB_DRIVER=postgres once to create the schema
3. Stop the server
4. Run this command to migrate workspace data`,
RunE: func(cmd *cobra.Command, args []string) error {
if fromPath == "" {
fromPath = filepath.Join(os.Getenv("HOME"), ".pad", "pad.db")
}
if _, err := os.Stat(fromPath); os.IsNotExist(err) {
return fmt.Errorf("SQLite database not found: %s", fromPath)
}
if toURL == "" {
toURL = os.Getenv("PAD_DATABASE_URL")
}
if toURL == "" {
return fmt.Errorf("target PostgreSQL URL required: use --to or set PAD_DATABASE_URL")
}
// Open source SQLite
fmt.Fprintf(os.Stderr, "Opening SQLite database: %s\n", fromPath)
srcStore, err := store.New(fromPath)
if err != nil {
return fmt.Errorf("open SQLite: %w", err)
}
defer srcStore.Close()
// Open target PostgreSQL
fmt.Fprintf(os.Stderr, "Connecting to PostgreSQL: %s\n", maskPassword(toURL))
dstStore, err := store.NewPostgres(toURL)
if err != nil {
return fmt.Errorf("open PostgreSQL: %w", err)
}
defer dstStore.Close()
// List workspaces from source
workspaces, err := srcStore.ListWorkspaces()
if err != nil {
return fmt.Errorf("list workspaces: %w", err)
}
if len(workspaces) == 0 {
fmt.Fprintln(os.Stderr, "No workspaces found in SQLite database.")
return nil
}
fmt.Fprintf(os.Stderr, "Found %d workspace(s) to migrate:\n", len(workspaces))
for _, ws := range workspaces {
fmt.Fprintf(os.Stderr, " - %s (%s)\n", ws.Name, ws.Slug)
}
fmt.Fprintln(os.Stderr)
migrated := 0
for _, ws := range workspaces {
fmt.Fprintf(os.Stderr, "Migrating workspace: %s...\n", ws.Name)
data, err := srcStore.ExportWorkspace(ws.Slug)
if err != nil {
fmt.Fprintf(os.Stderr, " ERROR exporting %s: %v (skipping)\n", ws.Slug, err)
continue
}
stats := fmt.Sprintf("%d collections, %d items, %d comments",
len(data.Collections), len(data.Items), len(data.Comments))
if _, err := dstStore.ImportWorkspace(data, ""); err != nil {
fmt.Fprintf(os.Stderr, " ERROR importing %s: %v (skipping)\n", ws.Slug, err)
continue
}
fmt.Fprintf(os.Stderr, " OK: %s\n", stats)
migrated++
}
fmt.Fprintf(os.Stderr, "\nMigration complete: %d/%d workspace(s) migrated.\n", migrated, len(workspaces))
if migrated < len(workspaces) {
fmt.Fprintln(os.Stderr, "Some workspaces failed — check the errors above.")
return fmt.Errorf("%d workspace(s) failed to migrate", len(workspaces)-migrated)
}
fmt.Fprintln(os.Stderr, "\nNext steps:")
fmt.Fprintln(os.Stderr, " 1. Set PAD_DB_DRIVER=postgres and PAD_DATABASE_URL in your environment")
fmt.Fprintln(os.Stderr, " 2. Start the server: pad serve")
fmt.Fprintln(os.Stderr, " 3. Run 'pad auth setup' to create an admin account on the new database")
fmt.Fprintln(os.Stderr, " 4. Verify your data in the web UI")
return nil
},
}
cmd.Flags().StringVar(&fromPath, "from", "", "SQLite database path (default: ~/.pad/pad.db)")
cmd.Flags().StringVar(&toURL, "to", "", "PostgreSQL connection URL (default: PAD_DATABASE_URL)")
return cmd
}
// maskPassword replaces the password in a PostgreSQL URL for safe display.
func maskPassword(pgURL string) string {
u, err := url.Parse(pgURL)
if err != nil {
return "***"
}
if _, hasPW := u.User.Password(); hasPW {
u.User = url.UserPassword(u.User.Username(), "***")
}
return u.String()
}
// --- audit-log ---
func auditLogCmd() *cobra.Command {
var days int
var actor string
var action string
var limit int
cmd := &cobra.Command{
Use: "audit-log",
Short: "View the compliance audit log (admin-only)",
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
params := models.AuditLogParams{
Days: days,
Actor: actor,
Action: action,
Limit: limit,
}
activities, err := client.GetAuditLog(params)
if err != nil {
return err
}
if formatFlag == "json" {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(activities)
}
if len(activities) == 0 {
fmt.Println("No audit log entries found.")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "TIME\tACTION\tACTOR\tIP\tDETAILS")
for _, a := range activities {
ts := a.CreatedAt.Format("2006-01-02 15:04")
actorName := a.ActorName
if actorName == "" {
actorName = a.UserID
}
ip := a.IPAddress
if ip == "" {
ip = "-"
}
detail := a.Metadata
if detail == "" {
detail = "-"
}
// Truncate long metadata
if len(detail) > 60 {
detail = detail[:57] + "..."
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", ts, a.Action, actorName, ip, detail)
}
w.Flush()
return nil
},
}
cmd.Flags().IntVar(&days, "days", 30, "number of days to look back")
cmd.Flags().StringVar(&actor, "actor", "", "filter by actor (user ID)")
cmd.Flags().StringVar(&action, "action", "", "filter by action type")
cmd.Flags().IntVar(&limit, "limit", 50, "maximum number of entries")
return cmd
}
+31
View File
@@ -0,0 +1,31 @@
# Pad — Caddy reverse proxy
# Caddy automatically provisions and renews TLS certificates.
#
# Usage:
# 1. Replace "pad.example.com" with your domain
# 2. Run: caddy run --config Caddyfile
#
# With Docker Compose, add a Caddy service on the pad-net network.
pad.example.com {
reverse_proxy pad:7777 {
# SSE: disable buffering for real-time events
flush_interval -1
}
# Optional: rate limiting
# rate_limit {
# zone api {
# key {remote_host}
# events 100
# window 1m
# }
# }
encode gzip
log {
output file /var/log/caddy/pad-access.log
format json
}
}
+18
View File
@@ -0,0 +1,18 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: pad-config
namespace: pad
data:
PAD_HOST: "0.0.0.0"
PAD_PORT: "7777"
PAD_DB_DRIVER: "postgres"
PAD_DATA_DIR: "/data"
PAD_LOG_LEVEL: "info"
PAD_SECURE_COOKIES: "true"
PAD_SSE_MAX_CONNECTIONS: "1000"
PAD_SSE_MAX_PER_WORKSPACE: "100"
# Set your public URL:
# PAD_URL: "https://pad.example.com"
# CORS origins:
# PAD_CORS_ORIGINS: "https://pad.example.com"
+60
View File
@@ -0,0 +1,60 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: pad
namespace: pad
labels:
app: pad
spec:
replicas: 2
selector:
matchLabels:
app: pad
template:
metadata:
labels:
app: pad
spec:
containers:
- name: pad
image: ghcr.io/xarmian/pad:latest
ports:
- containerPort: 7777
name: http
envFrom:
- configMapRef:
name: pad-config
- secretRef:
name: pad-secret
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
readinessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
emptyDir: {}
# For SQLite mode, use a PersistentVolumeClaim instead:
# persistentVolumeClaim:
# claimName: pad-data
+25
View File
@@ -0,0 +1,25 @@
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: pad
namespace: pad
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: pad
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
+29
View File
@@ -0,0 +1,29 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: pad
namespace: pad
annotations:
# For nginx ingress controller:
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
# For cert-manager:
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
ingressClassName: nginx
tls:
- hosts:
- pad.example.com
secretName: pad-tls
rules:
- host: pad.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: pad
port:
name: http
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: pad
+16
View File
@@ -0,0 +1,16 @@
# IMPORTANT: Replace placeholder values before applying.
# Consider using sealed-secrets, external-secrets, or a vault integration
# instead of committing real credentials.
apiVersion: v1
kind: Secret
metadata:
name: pad-secret
namespace: pad
type: Opaque
stringData:
PAD_DATABASE_URL: "postgres://pad:CHANGE_ME@postgres-host:5432/pad?sslmode=require"
PAD_REDIS_URL: "redis://:CHANGE_ME@redis-host:6379"
# Optional email:
# PAD_MAILEROO_API_KEY: "your-sending-key"
# PAD_EMAIL_FROM: "noreply@example.com"
# PAD_EMAIL_FROM_NAME: "Pad"
+16
View File
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: pad
namespace: pad
labels:
app: pad
spec:
type: ClusterIP
ports:
- port: 80
targetPort: http
protocol: TCP
name: http
selector:
app: pad
+83
View File
@@ -0,0 +1,83 @@
# Pad — nginx reverse proxy
#
# Usage:
# 1. Replace "pad.example.com" with your domain
# 2. Update ssl_certificate paths to your TLS certs
# 3. Include this file in your nginx config or copy to /etc/nginx/conf.d/
#
# Key settings for SSE support:
# - proxy_buffering off
# - proxy_read_timeout 86400s (24h for long-lived SSE connections)
# - proxy_http_version 1.1 with Connection ""
upstream pad_backend {
server 127.0.0.1:7777;
# For Docker: server pad:7777;
keepalive 32;
}
server {
listen 80;
server_name pad.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name pad.example.com;
ssl_certificate /etc/ssl/certs/pad.example.com.pem;
ssl_certificate_key /etc/ssl/private/pad.example.com-key.pem;
# Modern TLS settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip
gzip on;
gzip_types text/plain application/json text/css application/javascript;
# SSE endpoint — requires special proxy settings
location /api/v1/events {
proxy_pass http://pad_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Critical for SSE:
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
chunked_transfer_encoding on;
}
# All other routes
location / {
proxy_pass http://pad_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 60s;
# File uploads
client_max_body_size 10M;
}
access_log /var/log/nginx/pad-access.log;
error_log /var/log/nginx/pad-error.log;
}
+78
View File
@@ -0,0 +1,78 @@
# Pad — production Docker Compose override
# Usage: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
#
# Extends the base docker-compose.yml with:
# - Resource limits
# - Secure cookie settings
# - CORS configuration
# - Email (Maileroo) support
# - Named network for reverse proxy integration
services:
pad:
environment:
PAD_SECURE_COOKIES: "true"
# Override Redis URL to include password when REDIS_PASSWORD is set.
# Without this, the pad container inherits the passwordless URL from
# docker-compose.yml and fails to connect when Redis AUTH is enabled.
PAD_REDIS_URL: "redis://:${REDIS_PASSWORD:-}@redis:6379"
# Set your public-facing URL for correct invitation links:
# PAD_URL: "https://pad.example.com"
# CORS origins (comma-separated):
# PAD_CORS_ORIGINS: "https://pad.example.com"
# Email (Maileroo):
# PAD_MAILEROO_API_KEY: "your-sending-key"
# PAD_EMAIL_FROM: "noreply@example.com"
# PAD_EMAIL_FROM_NAME: "Pad"
# SSE limits:
# PAD_SSE_MAX_CONNECTIONS: "1000"
# PAD_SSE_MAX_PER_WORKSPACE: "100"
deploy:
resources:
limits:
cpus: "2.0"
memory: 512M
reservations:
cpus: "0.5"
memory: 128M
networks:
- pad-net
- default
postgres:
environment:
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-change-me-in-production}"
deploy:
resources:
limits:
cpus: "2.0"
memory: 1G
reservations:
cpus: "0.25"
memory: 256M
# In production, consider using a managed PostgreSQL service instead.
redis:
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru --requirepass "${REDIS_PASSWORD:-}"
healthcheck:
# Override the base healthcheck to authenticate when REDIS_PASSWORD is set.
# redis-cli reads REDISCLI_AUTH automatically for authentication.
test: ["CMD-SHELL", "REDISCLI_AUTH=$${REDIS_PASSWORD:-} redis-cli ping | grep -q PONG"]
interval: 5s
timeout: 3s
retries: 5
environment:
REDIS_PASSWORD: "${REDIS_PASSWORD:-}"
deploy:
resources:
limits:
cpus: "1.0"
memory: 256M
reservations:
cpus: "0.1"
memory: 64M
networks:
pad-net:
name: pad-net
# Attach your reverse proxy (Caddy, nginx) to this network.
+68
View File
@@ -0,0 +1,68 @@
# Pad — local production setup with PostgreSQL + Redis
# Usage: docker compose up -d
#
# Starts Pad with PostgreSQL for storage and Redis for real-time events.
# Access the web UI at http://localhost:7777
# First-time setup: visit the UI or run `pad auth setup` from a local CLI.
services:
pad:
build:
context: .
dockerfile: Dockerfile
ports:
- "7777:7777"
environment:
PAD_HOST: "0.0.0.0"
PAD_PORT: "7777"
PAD_DB_DRIVER: "postgres"
PAD_DATABASE_URL: "postgres://pad:pad@postgres:5432/pad?sslmode=disable"
PAD_REDIS_URL: "redis://redis:6379"
PAD_DATA_DIR: "/data"
PAD_LOG_LEVEL: "info"
volumes:
- pad-data:/data
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:7777/api/v1/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: pad
POSTGRES_PASSWORD: pad
POSTGRES_DB: pad
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pad"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
command: redis-server --maxmemory 64mb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
volumes:
pad-data:
pg-data:
redis-data:
+151
View File
@@ -0,0 +1,151 @@
# Backup & Restore Guide
## Overview
Pad provides built-in tooling for database backup, restore, and migration between SQLite and PostgreSQL.
| Command | Description |
|---------|-------------|
| `pad db backup` | PostgreSQL backup via `pg_dump` |
| `pad db restore <file>` | PostgreSQL restore via `psql` |
| `pad db migrate-to-pg` | One-time SQLite → PostgreSQL migration |
| `pad workspace export` | Application-level JSON export (portable) |
| `pad workspace import` | Application-level JSON import |
## SQLite Backups
SQLite stores everything in a single file (default: `~/.pad/pad.db`). Back it up by copying the file:
```bash
# Simple file copy (stop the server first for consistency)
cp ~/.pad/pad.db ~/backups/pad-$(date +%Y%m%d).db
# Or use SQLite's backup command (safe while server is running)
sqlite3 ~/.pad/pad.db ".backup ~/backups/pad-$(date +%Y%m%d).db"
```
## PostgreSQL Backups
### Manual Backup
```bash
# Create a SQL dump
pad db backup
# Specify output file
pad db backup --output /backups/pad-backup.sql
```
Requires:
- `pg_dump` installed
- `PAD_DATABASE_URL` environment variable set
### Automated Backups (Cron)
```bash
# Add to crontab: daily backup at 2 AM
0 2 * * * PAD_DATABASE_URL="postgres://pad:secret@localhost:5432/pad" /usr/local/bin/pad db backup --cron --output /backups/pad-$(date +\%Y\%m\%d).sql
```
The `--cron` flag uses structured log output suitable for log aggregation systems.
### Restore
```bash
# Restore from backup (will prompt for confirmation)
pad db restore /backups/pad-backup.sql
# Skip confirmation (for automated restore)
pad db restore --force /backups/pad-backup.sql
```
### Cloud Database Snapshots
For managed PostgreSQL (AWS RDS, Google Cloud SQL, Azure Database):
- **AWS RDS**: Use automated backups + manual snapshots via the AWS Console or CLI
- **Google Cloud SQL**: Enable automated backups in instance settings
- **Azure**: Configure automated backups via the portal
These are generally preferred over `pg_dump` for large databases as they use filesystem-level snapshots.
## Migrating SQLite → PostgreSQL
When graduating from a local SQLite setup to production PostgreSQL:
```bash
# 1. Set up PostgreSQL and create the database
createdb pad
# 2. Run Pad once against PostgreSQL to create the schema
PAD_DB_DRIVER=postgres PAD_DATABASE_URL="postgres://pad:secret@localhost:5432/pad" pad serve &
# Wait a few seconds for migrations to run, then stop it
kill %1
# 3. Migrate workspace data
pad db migrate-to-pg \
--from ~/.pad/pad.db \
--to "postgres://pad:secret@localhost:5432/pad"
# 4. Create an admin account on the new database
PAD_DB_DRIVER=postgres PAD_DATABASE_URL="postgres://pad:secret@localhost:5432/pad" pad auth setup
# 5. Start the server with PostgreSQL
PAD_DB_DRIVER=postgres PAD_DATABASE_URL="postgres://pad:secret@localhost:5432/pad" pad serve
```
**What gets migrated:**
- Workspaces, collections, items, comments
- Item links (dependencies)
- Item versions (history)
**What does NOT get migrated:**
- User accounts and sessions (re-create with `pad auth setup`)
- Platform settings (reconfigure in admin panel)
- Activity/audit log (starts fresh)
## Application-Level Export/Import
For portable workspace backups that work across SQLite and PostgreSQL:
```bash
# Export a workspace to JSON
pad workspace export > my-workspace.json
# Import into any Pad instance (SQLite or PostgreSQL)
pad workspace import < my-workspace.json
# Import with a new name
pad workspace import --name "imported-workspace" < my-workspace.json
```
This format is database-agnostic and can be used to:
- Transfer workspaces between Pad instances
- Create workspace templates
- Back up individual workspaces
## Backup Strategy Recommendations
### Small Teams (SQLite)
```
Daily: Copy pad.db to a backup location
Weekly: Rotate old backups (keep 4 weeks)
```
### Production (PostgreSQL)
```
Continuous: WAL archiving (point-in-time recovery)
Daily: pg_dump via 'pad db backup --cron'
Weekly: Full filesystem snapshot (if using managed DB)
Monthly: Test restore procedure
```
### Disaster Recovery Checklist
- [ ] Backups are being created on schedule
- [ ] Backups are stored off-site (different region/provider)
- [ ] Restore procedure has been tested recently
- [ ] Recovery time objective (RTO) is documented
- [ ] Recovery point objective (RPO) is documented
+246
View File
@@ -0,0 +1,246 @@
# Deployment Guide
Pad is a single Go binary with an embedded web UI. It supports SQLite (default) for single-node deployments and PostgreSQL + Redis for production multi-node setups.
## Architecture
```
┌─────────────────┐
│ Reverse Proxy │
│ (Caddy/nginx) │
└────────┬────────┘
│ :443
┌────────▼────────┐
│ Pad │
│ Go binary │
│ (web UI + API) │
└──┬──────────┬───┘
│ │
┌────────▼──┐ ┌───▼────────┐
│ PostgreSQL │ │ Redis │
│ (storage) │ │ (pub/sub) │
└────────────┘ └────────────┘
```
- **Pad** serves the REST API and embedded SvelteKit web UI on a single port (default: 7777)
- **PostgreSQL** stores all data (workspaces, items, users, activity). SQLite works for single-node.
- **Redis** enables real-time SSE events across multiple Pad instances. Optional for single-node.
## Quick Start with Docker Compose
```bash
# Clone the repo
git clone https://github.com/xarmian/pad.git
cd pad
# Start everything (Pad + PostgreSQL + Redis)
docker compose up -d
# Check status
docker compose ps
# View logs
docker compose logs -f pad
```
Access the web UI at **http://localhost:7777**. On first visit, you'll be prompted to create an admin account.
### Production Docker Compose
```bash
# Use the production overlay for resource limits and secure settings
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
Edit `docker-compose.prod.yml` to set your domain, email credentials, and database password.
## Environment Variables
All configuration is via environment variables or a config file (`~/.pad/config.toml` / `/data/config.toml`).
### Core
| Variable | Default | Description |
|----------|---------|-------------|
| `PAD_HOST` | `127.0.0.1` | Listen address (`0.0.0.0` for Docker/production) |
| `PAD_PORT` | `7777` | Listen port |
| `PAD_URL` | — | Public-facing base URL (e.g., `https://pad.example.com`). Used for invitation links. |
| `PAD_DATA_DIR` | `~/.pad` | Data directory for SQLite DB, logs, and config |
| `PAD_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
| `PAD_MODE` | `local` | Mode: `local`, `remote`, `docker`, `cloud` |
### Database
| Variable | Default | Description |
|----------|---------|-------------|
| `PAD_DB_DRIVER` | `sqlite` | Database driver: `sqlite` or `postgres` |
| `PAD_DB_PATH` | `~/.pad/pad.db` | SQLite database path (ignored when using PostgreSQL) |
| `PAD_DATABASE_URL` | — | PostgreSQL connection string (required when `PAD_DB_DRIVER=postgres`) |
### Real-time Events
| Variable | Default | Description |
|----------|---------|-------------|
| `PAD_REDIS_URL` | — | Redis URL for cross-instance pub/sub. Without Redis, SSE events are in-process only. |
| `PAD_SSE_MAX_CONNECTIONS` | `1000` | Global maximum SSE connections |
| `PAD_SSE_MAX_PER_WORKSPACE` | `100` | Per-workspace maximum SSE connections |
### Security
| Variable | Default | Description |
|----------|---------|-------------|
| `PAD_SECURE_COOKIES` | `false` | Set `Secure` flag on session cookies (requires TLS) |
| `PAD_CORS_ORIGINS` | — | Comma-separated allowed CORS origins |
### Email (Optional)
Email enables sending workspace invitation links. Without it, users can still join via CLI invite codes.
| Variable | Default | Description |
|----------|---------|-------------|
| `PAD_MAILEROO_API_KEY` | — | Maileroo sending API key |
| `PAD_EMAIL_FROM` | `noreply@getpad.dev` | Sender email address |
| `PAD_EMAIL_FROM_NAME` | `Pad` | Sender display name |
## Deployment Options
### Single Binary (SQLite)
The simplest deployment — one binary, one file for the database.
```bash
# Download or build
make build
# Run directly
PAD_HOST=0.0.0.0 ./pad serve
# Or install as a systemd service (see below)
```
Best for: single-user, small teams, evaluations.
### Docker Compose (PostgreSQL + Redis)
See [Quick Start](#quick-start-with-docker-compose) above. This is the recommended setup for teams.
### Kubernetes
Manifests are in `deploy/k8s/`. Apply them in order:
```bash
# Create namespace
kubectl apply -f deploy/k8s/namespace.yaml
# Configure secrets (edit first!)
kubectl apply -f deploy/k8s/secret.yaml
# Deploy
kubectl apply -f deploy/k8s/configmap.yaml
kubectl apply -f deploy/k8s/deployment.yaml
kubectl apply -f deploy/k8s/service.yaml
kubectl apply -f deploy/k8s/ingress.yaml
kubectl apply -f deploy/k8s/hpa.yaml
```
**Prerequisites:**
- External PostgreSQL (e.g., AWS RDS, Cloud SQL, managed PG)
- External Redis (e.g., ElastiCache, Memorystore)
- Ingress controller (nginx-ingress or similar)
- TLS certificates (cert-manager recommended)
### Systemd Service
```ini
# /etc/systemd/system/pad.service
[Unit]
Description=Pad
After=network.target postgresql.service redis.service
[Service]
Type=simple
User=pad
Group=pad
ExecStart=/usr/local/bin/pad serve
Environment=PAD_HOST=0.0.0.0
Environment=PAD_DATA_DIR=/var/lib/pad
Environment=PAD_DB_DRIVER=postgres
Environment=PAD_DATABASE_URL=postgres://pad:secret@localhost:5432/pad
Environment=PAD_REDIS_URL=redis://localhost:6379
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now pad
```
## Reverse Proxy
Pad needs a reverse proxy for TLS termination. SSE connections require specific proxy settings to avoid buffering.
### Caddy (Recommended)
Caddy handles TLS automatically. See `deploy/Caddyfile`:
```
pad.example.com {
reverse_proxy pad:7777 {
flush_interval -1
}
}
```
### nginx
See `deploy/nginx.conf`. Critical settings for SSE:
```nginx
location /api/v1/events {
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
```
## Monitoring
Pad exposes Prometheus metrics at `/metrics` (unauthenticated). Key metrics:
| Metric | Type | Description |
|--------|------|-------------|
| `pad_http_requests_total` | counter | Total HTTP requests by method, path, status |
| `pad_http_request_duration_seconds` | histogram | Request latency |
| `pad_http_response_size_bytes` | histogram | Response body sizes |
| `pad_sse_connections_active` | gauge | Current SSE connections |
| `pad_eventbus_publish_total` | counter | Events published |
| `pad_eventbus_subscribers` | gauge | Active event subscribers |
| `pad_db_open_connections` | gauge | Database connection pool stats |
### Health Check
```bash
curl http://localhost:7777/api/v1/health
# {"status":"ok"}
```
## Production Checklist
- [ ] **Database:** PostgreSQL configured with `PAD_DB_DRIVER=postgres`
- [ ] **Redis:** Connected for multi-instance SSE (`PAD_REDIS_URL`)
- [ ] **TLS:** Reverse proxy with valid certificates
- [ ] **Secure cookies:** `PAD_SECURE_COOKIES=true` (requires TLS)
- [ ] **Public URL:** `PAD_URL` set to your public-facing domain
- [ ] **CORS:** `PAD_CORS_ORIGINS` set if serving from a different domain
- [ ] **Backups:** PostgreSQL backup strategy in place (see `docs/backup.md`)
- [ ] **Monitoring:** Prometheus scraping `/metrics`
- [ ] **Admin account:** Created via `pad auth setup` or web UI on first visit
- [ ] **Email (optional):** Maileroo configured for invitation emails
- [ ] **Resource limits:** Set in Docker Compose or K8s manifests
- [ ] **Log level:** `PAD_LOG_LEVEL=info` (use `debug` only for troubleshooting)
+8
View File
@@ -19,6 +19,7 @@ require (
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -28,13 +29,20 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+16
View File
@@ -1,5 +1,7 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -45,10 +47,20 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -70,6 +82,8 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
@@ -88,6 +102,8 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+31
View File
@@ -461,6 +461,37 @@ func (c *Client) CheckSession() (*SessionResponse, error) {
return &result, c.get("/auth/session", &result)
}
// --- Audit Log ---
// GetAuditLog fetches the global audit log (admin-only).
func (c *Client) GetAuditLog(params models.AuditLogParams) ([]models.Activity, error) {
q := url.Values{}
if params.Action != "" {
q.Set("action", params.Action)
}
if params.Actor != "" {
q.Set("actor", params.Actor)
}
if params.WorkspaceID != "" {
q.Set("workspace", params.WorkspaceID)
}
if params.Days > 0 {
q.Set("days", fmt.Sprintf("%d", params.Days))
}
if params.Limit > 0 {
q.Set("limit", fmt.Sprintf("%d", params.Limit))
}
if params.Offset > 0 {
q.Set("offset", fmt.Sprintf("%d", params.Offset))
}
path := "/audit-log"
if qs := q.Encode(); qs != "" {
path += "?" + qs
}
var result []models.Activity
return result, c.get(path, &result)
}
// --- HTTP helpers ---
type APIError struct {
+23 -7
View File
@@ -40,19 +40,25 @@ type Config struct {
// Security
CORSOrigins string `toml:"cors_origins"` // Comma-separated allowed origins (e.g. "https://app.pad.dev,https://admin.pad.dev")
SecureCookies bool `toml:"secure_cookies"` // Set Secure flag on cookies (requires TLS)
// SSE limits
SSEMaxConnections int `toml:"sse_max_connections"` // Global max SSE connections (0 = unlimited)
SSEMaxPerWorkspace int `toml:"sse_max_per_workspace"` // Per-workspace max SSE connections (0 = unlimited)
}
func DefaultConfig() *Config {
homeDir, _ := os.UserHomeDir()
dataDir := filepath.Join(homeDir, ".pad")
return &Config{
Host: "127.0.0.1",
Port: 7777,
Editor: "",
LogLevel: "info",
DBPath: filepath.Join(dataDir, "pad.db"),
DataDir: dataDir,
ConfigPath: filepath.Join(dataDir, "config.toml"),
Host: "127.0.0.1",
Port: 7777,
Editor: "",
LogLevel: "info",
DBPath: filepath.Join(dataDir, "pad.db"),
DataDir: dataDir,
ConfigPath: filepath.Join(dataDir, "config.toml"),
SSEMaxConnections: 1000,
SSEMaxPerWorkspace: 100,
}
}
@@ -128,6 +134,16 @@ func Load() (*Config, error) {
if v := os.Getenv("PAD_SECURE_COOKIES"); v == "true" || v == "1" {
cfg.SecureCookies = true
}
if v := os.Getenv("PAD_SSE_MAX_CONNECTIONS"); v != "" {
if max, err := strconv.Atoi(v); err == nil {
cfg.SSEMaxConnections = max
}
}
if v := os.Getenv("PAD_SSE_MAX_PER_WORKSPACE"); v != "" {
if max, err := strconv.Atoi(v); err == nil {
cfg.SSEMaxPerWorkspace = max
}
}
return cfg, nil
}
+51
View File
@@ -54,6 +54,12 @@ type EventBus interface {
// Returns a buffered channel that will receive events for that workspace.
Subscribe(workspaceID string) chan Event
// SubscribeIfAllowed atomically checks the global and per-workspace
// subscriber limits and, only if both are satisfied, subscribes in the
// same critical section. Returns (ch, true) on success or (nil, false)
// when a limit would be exceeded. Pass 0 for either limit to disable it.
SubscribeIfAllowed(workspaceID string, maxGlobal, maxPerWorkspace int) (chan Event, bool)
// Unsubscribe removes a subscriber and closes its channel.
Unsubscribe(ch chan Event)
@@ -65,6 +71,10 @@ type EventBus interface {
// SubscriberCount returns the number of active local subscribers.
SubscriberCount() int
// WorkspaceSubscriberCount returns the number of active subscribers
// for a specific workspace.
WorkspaceSubscriberCount(workspaceID string) int
}
// subscriber wraps a channel with its workspace filter.
@@ -101,6 +111,34 @@ func (b *MemoryBus) Subscribe(workspaceID string) chan Event {
return ch
}
// SubscribeIfAllowed atomically checks limits and subscribes.
func (b *MemoryBus) SubscribeIfAllowed(workspaceID string, maxGlobal, maxPerWorkspace int) (chan Event, bool) {
b.mu.Lock()
defer b.mu.Unlock()
if maxGlobal > 0 && len(b.subscribers) >= maxGlobal {
return nil, false
}
if maxPerWorkspace > 0 {
count := 0
for _, sub := range b.subscribers {
if sub.workspaceID == workspaceID {
count++
}
}
if count >= maxPerWorkspace {
return nil, false
}
}
ch := make(chan Event, 64)
b.subscribers[ch] = &subscriber{
ch: ch,
workspaceID: workspaceID,
}
return ch, true
}
// Unsubscribe removes a subscriber and closes its channel.
func (b *MemoryBus) Unsubscribe(ch chan Event) {
b.mu.Lock()
@@ -153,3 +191,16 @@ func (b *MemoryBus) SubscriberCount() int {
defer b.mu.RUnlock()
return len(b.subscribers)
}
// WorkspaceSubscriberCount returns the number of active subscribers for a workspace.
func (b *MemoryBus) WorkspaceSubscriberCount(workspaceID string) int {
b.mu.RLock()
defer b.mu.RUnlock()
count := 0
for _, sub := range b.subscribers {
if sub.workspaceID == workspaceID {
count++
}
}
return count
}
+37
View File
@@ -230,6 +230,43 @@ func TestConcurrentAccess(t *testing.T) {
}
}
func TestWorkspaceSubscriberCount(t *testing.T) {
bus := New()
// No subscribers initially
if got := bus.WorkspaceSubscriberCount("ws-1"); got != 0 {
t.Fatalf("expected 0, got %d", got)
}
// Subscribe to ws-1
ch1 := bus.Subscribe("ws-1")
ch2 := bus.Subscribe("ws-1")
ch3 := bus.Subscribe("ws-2")
if got := bus.WorkspaceSubscriberCount("ws-1"); got != 2 {
t.Fatalf("expected 2 for ws-1, got %d", got)
}
if got := bus.WorkspaceSubscriberCount("ws-2"); got != 1 {
t.Fatalf("expected 1 for ws-2, got %d", got)
}
if got := bus.WorkspaceSubscriberCount("ws-3"); got != 0 {
t.Fatalf("expected 0 for ws-3, got %d", got)
}
// Unsubscribe one from ws-1
bus.Unsubscribe(ch1)
if got := bus.WorkspaceSubscriberCount("ws-1"); got != 1 {
t.Fatalf("expected 1 for ws-1 after unsubscribe, got %d", got)
}
// Unsubscribe remaining
bus.Unsubscribe(ch2)
bus.Unsubscribe(ch3)
if got := bus.WorkspaceSubscriberCount("ws-1"); got != 0 {
t.Fatalf("expected 0 for ws-1 after all unsubscribed, got %d", got)
}
}
func TestPublishNoSubscribers(t *testing.T) {
bus := New()
// Should not panic
+36
View File
@@ -77,6 +77,35 @@ func (b *RedisBus) Subscribe(workspaceID string) chan Event {
return ch
}
// SubscribeIfAllowed atomically checks limits and subscribes.
// NOTE: Limits are enforced against local (per-pod) subscriber counts only.
// In multi-replica deployments the effective cap is multiplied by the number
// of replicas. For truly global caps, use a Redis-backed counter.
func (b *RedisBus) SubscribeIfAllowed(workspaceID string, maxGlobal, maxPerWorkspace int) (chan Event, bool) {
b.mu.Lock()
defer b.mu.Unlock()
if maxGlobal > 0 && len(b.subscribers) >= maxGlobal {
return nil, false
}
if maxPerWorkspace > 0 && b.wsCounts[workspaceID] >= maxPerWorkspace {
return nil, false
}
ch := make(chan Event, 64)
b.subscribers[ch] = &subscriber{
ch: ch,
workspaceID: workspaceID,
}
b.wsCounts[workspaceID]++
if b.wsCounts[workspaceID] == 1 {
b.startRedisSubscription(workspaceID)
}
return ch, true
}
// Unsubscribe removes a local subscriber and closes its channel.
// Cancels the Redis subscription if this was the last local subscriber for the workspace.
func (b *RedisBus) Unsubscribe(ch chan Event) {
@@ -143,6 +172,13 @@ func (b *RedisBus) SubscriberCount() int {
return len(b.subscribers)
}
// WorkspaceSubscriberCount returns the number of active local subscribers for a workspace.
func (b *RedisBus) WorkspaceSubscriberCount(workspaceID string) int {
b.mu.RLock()
defer b.mu.RUnlock()
return b.wsCounts[workspaceID]
}
// startRedisSubscription begins listening on a Redis channel for a workspace.
// Must be called with b.mu held.
func (b *RedisBus) startRedisSubscription(workspaceID string) {
+96
View File
@@ -0,0 +1,96 @@
package metrics
import (
"sync"
"github.com/xarmian/pad/internal/events"
)
// InstrumentedBus wraps an events.EventBus to record Prometheus metrics
// for SSE connections (per workspace) and event publish counts.
// It implements the events.EventBus interface so it can be used as a
// drop-in replacement without changing the interface or its implementations.
type InstrumentedBus struct {
inner events.EventBus
metrics *Metrics
mu sync.Mutex
workspaces map[chan events.Event]string // channel → workspaceID for gauge decrement
}
// NewInstrumentedBus wraps an EventBus with Prometheus instrumentation.
func NewInstrumentedBus(inner events.EventBus, m *Metrics) *InstrumentedBus {
return &InstrumentedBus{
inner: inner,
metrics: m,
workspaces: make(map[chan events.Event]string),
}
}
// Subscribe delegates to the inner bus and increments the SSE connection gauge.
func (b *InstrumentedBus) Subscribe(workspaceID string) chan events.Event {
ch := b.inner.Subscribe(workspaceID)
b.mu.Lock()
b.workspaces[ch] = workspaceID
b.mu.Unlock()
(*b.metrics.SSEConnectionsActive).Inc()
(*b.metrics.EventBusSubscribers).Set(float64(b.inner.SubscriberCount()))
return ch
}
// SubscribeIfAllowed delegates the atomic check-and-subscribe to the inner bus
// and updates Prometheus gauges on success.
func (b *InstrumentedBus) SubscribeIfAllowed(workspaceID string, maxGlobal, maxPerWorkspace int) (chan events.Event, bool) {
ch, ok := b.inner.SubscribeIfAllowed(workspaceID, maxGlobal, maxPerWorkspace)
if !ok {
return nil, false
}
b.mu.Lock()
b.workspaces[ch] = workspaceID
b.mu.Unlock()
(*b.metrics.SSEConnectionsActive).Inc()
(*b.metrics.EventBusSubscribers).Set(float64(b.inner.SubscriberCount()))
return ch, true
}
// Unsubscribe delegates to the inner bus and decrements the SSE connection gauge.
func (b *InstrumentedBus) Unsubscribe(ch chan events.Event) {
b.mu.Lock()
_, ok := b.workspaces[ch]
if ok {
delete(b.workspaces, ch)
}
b.mu.Unlock()
b.inner.Unsubscribe(ch)
if ok {
(*b.metrics.SSEConnectionsActive).Dec()
}
(*b.metrics.EventBusSubscribers).Set(float64(b.inner.SubscriberCount()))
}
// Publish delegates to the inner bus and increments the publish counter.
func (b *InstrumentedBus) Publish(event events.Event) {
b.inner.Publish(event)
(*b.metrics.EventBusPublishTotal).Inc()
}
// Close delegates to the inner bus.
func (b *InstrumentedBus) Close() {
b.inner.Close()
}
// SubscriberCount delegates to the inner bus.
func (b *InstrumentedBus) SubscriberCount() int {
return b.inner.SubscriberCount()
}
// WorkspaceSubscriberCount delegates to the inner bus.
func (b *InstrumentedBus) WorkspaceSubscriberCount(workspaceID string) int {
return b.inner.WorkspaceSubscriberCount(workspaceID)
}
+147
View File
@@ -0,0 +1,147 @@
// Package metrics provides Prometheus instrumentation for the Pad server.
// It uses a custom registry (not the global default) for test isolation
// and explicit control over exposed metrics.
package metrics
import (
"database/sql"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
)
// Metrics holds all Prometheus collectors and the custom registry.
type Metrics struct {
Registry *prometheus.Registry
// HTTP request metrics
HTTPRequestsTotal *prometheus.CounterVec
HTTPRequestDuration *prometheus.HistogramVec
HTTPResponseSize *prometheus.HistogramVec
// SSE connection metrics (single gauge to avoid unbounded label cardinality)
SSEConnectionsActive *prometheus.Gauge
// EventBus metrics
EventBusPublishTotal *prometheus.Counter
EventBusSubscribers *prometheus.Gauge
}
// New creates a new Metrics instance with a custom registry and registers
// all application metrics plus Go runtime and process collectors.
func New() *Metrics {
reg := prometheus.NewRegistry()
// Go runtime + process collectors (goroutines, memory, GC, file descriptors)
reg.MustRegister(collectors.NewGoCollector())
reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
httpRequestsTotal := prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "pad_http_requests_total",
Help: "Total number of HTTP requests by method, route, and status code.",
}, []string{"method", "route", "status"})
httpRequestDuration := prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "pad_http_request_duration_seconds",
Help: "HTTP request duration in seconds by method, route, and status code.",
Buckets: prometheus.DefBuckets,
}, []string{"method", "route", "status"})
httpResponseSize := prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "pad_http_response_size_bytes",
Help: "HTTP response size in bytes by method, route, and status code.",
Buckets: prometheus.ExponentialBuckets(100, 10, 7), // 100B to 100MB
}, []string{"method", "route", "status"})
sseConnectionsActive := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "pad_sse_connections_active",
Help: "Total number of active SSE connections.",
})
eventBusPublishTotal := prometheus.NewCounter(prometheus.CounterOpts{
Name: "pad_eventbus_publish_total",
Help: "Total number of events published to the event bus.",
})
eventBusSubscribers := prometheus.NewGauge(prometheus.GaugeOpts{
Name: "pad_eventbus_subscribers",
Help: "Current number of event bus subscribers.",
})
reg.MustRegister(
httpRequestsTotal,
httpRequestDuration,
httpResponseSize,
sseConnectionsActive,
eventBusPublishTotal,
eventBusSubscribers,
)
return &Metrics{
Registry: reg,
HTTPRequestsTotal: httpRequestsTotal,
HTTPRequestDuration: httpRequestDuration,
HTTPResponseSize: httpResponseSize,
SSEConnectionsActive: &sseConnectionsActive,
EventBusPublishTotal: &eventBusPublishTotal,
EventBusSubscribers: &eventBusSubscribers,
}
}
// RegisterDBCollector registers a callback-based collector that exposes
// database connection pool statistics on each Prometheus scrape.
// This is preferred over a periodic goroutine: zero overhead between
// scrapes and always fresh data.
func (m *Metrics) RegisterDBCollector(db *sql.DB) {
m.Registry.MustRegister(&dbStatsCollector{db: db})
}
// dbStatsCollector implements prometheus.Collector using db.Stats() callbacks.
type dbStatsCollector struct {
db *sql.DB
}
var (
dbOpenDesc = prometheus.NewDesc(
"pad_db_open_connections",
"Number of open database connections.",
nil, nil,
)
dbIdleDesc = prometheus.NewDesc(
"pad_db_idle_connections",
"Number of idle database connections.",
nil, nil,
)
dbInUseDesc = prometheus.NewDesc(
"pad_db_in_use_connections",
"Number of in-use database connections.",
nil, nil,
)
dbWaitCountDesc = prometheus.NewDesc(
"pad_db_wait_count_total",
"Total number of connections waited for.",
nil, nil,
)
dbWaitDurationDesc = prometheus.NewDesc(
"pad_db_wait_duration_seconds_total",
"Total time blocked waiting for a new connection.",
nil, nil,
)
)
func (c *dbStatsCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- dbOpenDesc
ch <- dbIdleDesc
ch <- dbInUseDesc
ch <- dbWaitCountDesc
ch <- dbWaitDurationDesc
}
func (c *dbStatsCollector) Collect(ch chan<- prometheus.Metric) {
stats := c.db.Stats()
ch <- prometheus.MustNewConstMetric(dbOpenDesc, prometheus.GaugeValue, float64(stats.OpenConnections))
ch <- prometheus.MustNewConstMetric(dbIdleDesc, prometheus.GaugeValue, float64(stats.Idle))
ch <- prometheus.MustNewConstMetric(dbInUseDesc, prometheus.GaugeValue, float64(stats.InUse))
ch <- prometheus.MustNewConstMetric(dbWaitCountDesc, prometheus.CounterValue, float64(stats.WaitCount))
ch <- prometheus.MustNewConstMetric(dbWaitDurationDesc, prometheus.CounterValue, stats.WaitDuration.Seconds())
}
+191
View File
@@ -0,0 +1,191 @@
package metrics
import (
"database/sql"
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus"
io_prometheus_client "github.com/prometheus/client_model/go"
"github.com/xarmian/pad/internal/events"
_ "modernc.org/sqlite"
)
func TestNew(t *testing.T) {
m := New()
if m.Registry == nil {
t.Fatal("Registry should not be nil")
}
if m.HTTPRequestsTotal == nil {
t.Fatal("HTTPRequestsTotal should not be nil")
}
if m.HTTPRequestDuration == nil {
t.Fatal("HTTPRequestDuration should not be nil")
}
if m.HTTPResponseSize == nil {
t.Fatal("HTTPResponseSize should not be nil")
}
if m.SSEConnectionsActive == nil {
t.Fatal("SSEConnectionsActive should not be nil")
}
if m.EventBusPublishTotal == nil {
t.Fatal("EventBusPublishTotal should not be nil")
}
if m.EventBusSubscribers == nil {
t.Fatal("EventBusSubscribers should not be nil")
}
// Verify all metrics can be gathered without error
families, err := m.Registry.Gather()
if err != nil {
t.Fatalf("Gather failed: %v", err)
}
// Should have Go runtime + process metrics
found := false
for _, f := range families {
if strings.HasPrefix(f.GetName(), "go_") {
found = true
break
}
}
if !found {
t.Fatal("Expected Go runtime metrics to be registered")
}
}
func TestRegisterDBCollector(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("Failed to open in-memory SQLite: %v", err)
}
defer db.Close()
m := New()
m.RegisterDBCollector(db)
families, err := m.Registry.Gather()
if err != nil {
t.Fatalf("Gather failed: %v", err)
}
expected := map[string]bool{
"pad_db_open_connections": false,
"pad_db_idle_connections": false,
"pad_db_in_use_connections": false,
"pad_db_wait_count_total": false,
"pad_db_wait_duration_seconds_total": false,
}
for _, f := range families {
if _, ok := expected[f.GetName()]; ok {
expected[f.GetName()] = true
}
}
for name, found := range expected {
if !found {
t.Errorf("Expected metric %q not found in gathered metrics", name)
}
}
}
func TestInstrumentedBus_SubscribeUnsubscribe(t *testing.T) {
inner := events.New()
m := New()
bus := NewInstrumentedBus(inner, m)
// Subscribe to a workspace
ch := bus.Subscribe("ws-1")
if ch == nil {
t.Fatal("Subscribe should return a channel")
}
// Check SSE gauge incremented (single total gauge, not per-workspace)
gauge := getGaugeValue(t, *m.SSEConnectionsActive)
if gauge != 1 {
t.Errorf("Expected SSE active connections = 1, got %v", gauge)
}
// Subscribe a second connection to same workspace
ch2 := bus.Subscribe("ws-1")
gauge = getGaugeValue(t, *m.SSEConnectionsActive)
if gauge != 2 {
t.Errorf("Expected SSE active connections = 2, got %v", gauge)
}
// Subscribe to a different workspace
ch3 := bus.Subscribe("ws-2")
gauge = getGaugeValue(t, *m.SSEConnectionsActive)
if gauge != 3 {
t.Errorf("Expected SSE active connections = 3, got %v", gauge)
}
// Unsubscribe one from ws-1
bus.Unsubscribe(ch)
gauge = getGaugeValue(t, *m.SSEConnectionsActive)
if gauge != 2 {
t.Errorf("Expected SSE active connections = 2 after unsubscribe, got %v", gauge)
}
// Unsubscribe remaining
bus.Unsubscribe(ch2)
bus.Unsubscribe(ch3)
gauge = getGaugeValue(t, *m.SSEConnectionsActive)
if gauge != 0 {
t.Errorf("Expected SSE active connections = 0 after all unsubscribed, got %v", gauge)
}
}
func TestInstrumentedBus_Publish(t *testing.T) {
inner := events.New()
m := New()
bus := NewInstrumentedBus(inner, m)
// Subscribe so we can publish
ch := bus.Subscribe("ws-1")
defer bus.Unsubscribe(ch)
bus.Publish(events.Event{Type: "test", WorkspaceID: "ws-1"})
bus.Publish(events.Event{Type: "test", WorkspaceID: "ws-1"})
var metric io_prometheus_client.Metric
if err := (*m.EventBusPublishTotal).Write(&metric); err != nil {
t.Fatalf("Failed to write publish metric: %v", err)
}
if got := metric.GetCounter().GetValue(); got != 2 {
t.Errorf("Expected publish count = 2, got %v", got)
}
}
func TestInstrumentedBus_SubscriberCount(t *testing.T) {
inner := events.New()
m := New()
bus := NewInstrumentedBus(inner, m)
if bus.SubscriberCount() != 0 {
t.Errorf("Expected 0 subscribers initially")
}
ch := bus.Subscribe("ws-1")
if bus.SubscriberCount() != 1 {
t.Errorf("Expected 1 subscriber after subscribe")
}
bus.Unsubscribe(ch)
if bus.SubscriberCount() != 0 {
t.Errorf("Expected 0 subscribers after unsubscribe")
}
}
// getGaugeValue extracts the current value from a Prometheus Gauge.
func getGaugeValue(t *testing.T, g prometheus.Gauge) float64 {
t.Helper()
var metric io_prometheus_client.Metric
if err := g.Write(&metric); err != nil {
t.Fatalf("Failed to write gauge metric: %v", err)
}
return metric.GetGauge().GetValue()
}
+31 -2
View File
@@ -2,20 +2,39 @@ package models
import "time"
// Valid actions
// Item-level actions (existing)
var ValidActions = []string{
"created", "updated", "archived", "restored", "moved", "read", "searched",
}
// Audit action constants for auth/admin events
const (
ActionLogin = "login"
ActionLoginFailed = "login_failed"
ActionLogout = "logout"
ActionBootstrap = "bootstrap"
ActionRegister = "register"
ActionPasswordChanged = "password_changed"
ActionPasswordReset = "password_reset"
ActionTokenCreated = "token_created"
ActionTokenRevoked = "token_revoked"
ActionMemberInvited = "member_invited"
ActionMemberRemoved = "member_removed"
ActionRoleChanged = "role_changed"
ActionSettingsChanged = "settings_changed"
)
type Activity struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
WorkspaceID string `json:"workspace_id,omitempty"`
DocumentID string `json:"document_id,omitempty"`
Action string `json:"action"`
Actor string `json:"actor"`
Source string `json:"source"`
Metadata string `json:"metadata,omitempty"` // JSON
UserID string `json:"user_id,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
CreatedAt time.Time `json:"created_at"`
// Enrichment fields — populated by handlers, not stored in DB
@@ -33,6 +52,16 @@ type ActivityListParams struct {
Offset int
}
// AuditLogParams are query parameters for the audit log endpoint.
type AuditLogParams struct {
Action string
Actor string
WorkspaceID string
Days int
Limit int
Offset int
}
// TimelineEntry represents a single entry in the unified item timeline.
// It wraps one of: a comment, an activity, or a version.
type TimelineEntry struct {
+14
View File
@@ -1,7 +1,11 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"github.com/xarmian/pad/internal/models"
)
// Known platform setting keys. Values are stored in the platform_settings table.
@@ -75,6 +79,16 @@ func (s *Server) handleUpdatePlatformSettings(w http.ResponseWriter, r *http.Req
// Reconfigure email sender if email settings changed
s.reconfigureEmail()
// Log which settings were changed (keys only, not values for security)
var keys []string
for key := range input {
if allowed[key] {
keys = append(keys, key)
}
}
keysJSON, _ := json.Marshal(keys)
s.logAuditEvent(models.ActionSettingsChanged, r, fmt.Sprintf(`{"keys":%s}`, keysJSON))
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}
+55
View File
@@ -0,0 +1,55 @@
package server
import (
"net/http"
"strconv"
"github.com/xarmian/pad/internal/models"
)
// handleAuditLog returns a filtered audit log. Admin-only.
// Supports filtering by action, user (user ID), workspace, days, and pagination.
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user == nil || user.Role != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "Admin access required")
return
}
// Accept both "user" and "actor" query params for filtering by user ID.
actorFilter := r.URL.Query().Get("user")
if actorFilter == "" {
actorFilter = r.URL.Query().Get("actor")
}
params := models.AuditLogParams{
Action: r.URL.Query().Get("action"),
Actor: actorFilter,
WorkspaceID: r.URL.Query().Get("workspace"),
Days: 30, // default
}
if d := r.URL.Query().Get("days"); d != "" {
if days, err := strconv.Atoi(d); err == nil && days > 0 {
params.Days = days
}
}
if l := r.URL.Query().Get("limit"); l != "" {
if limit, err := strconv.Atoi(l); err == nil && limit > 0 {
params.Limit = limit
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if offset, err := strconv.Atoi(o); err == nil && offset >= 0 {
params.Offset = offset
}
}
activities, err := s.store.ListAuditLog(params)
if err != nil {
writeInternalError(w, err)
return
}
writeJSON(w, http.StatusOK, activities)
}
+15
View File
@@ -162,6 +162,8 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
return
}
s.logAuditEventForUser(models.ActionBootstrap, r, user.ID, auditMeta(map[string]string{"email": user.Email}))
writeJSON(w, http.StatusCreated, map[string]interface{}{
"user": sessionUserPayload(user),
"token": token,
@@ -274,6 +276,8 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
s.logAuditEventForUser(models.ActionRegister, r, user.ID, auditMeta(map[string]string{"email": user.Email}))
writeJSON(w, http.StatusCreated, map[string]interface{}{
"user": sessionUserPayload(user),
"token": token,
@@ -310,6 +314,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if user == nil {
// Slow down brute force attempts
time.Sleep(500 * time.Millisecond)
s.logAuditEvent(models.ActionLoginFailed, r, auditMeta(map[string]string{"email": input.Email}))
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid email or password")
return
}
@@ -319,6 +324,8 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
s.logAuditEventForUser(models.ActionLogin, r, user.ID, auditMeta(map[string]string{"email": user.Email}))
writeJSON(w, http.StatusOK, map[string]interface{}{
"user": sessionUserPayload(user),
"token": token,
@@ -388,6 +395,8 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
// Clear CSRF cookie on logout
clearCSRFCookie(w)
s.logAuditEvent(models.ActionLogout, r, "")
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
})
@@ -492,6 +501,10 @@ func (s *Server) handleUpdateCurrentUser(w http.ResponseWriter, r *http.Request)
return
}
if input.NewPassword != "" {
s.logAuditEvent(models.ActionPasswordChanged, r, "")
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"id": updated.ID,
"email": updated.Email,
@@ -621,6 +634,8 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
// Set CSRF cookie alongside the new session
setCSRFCookie(w, int(webSessionTTL.Seconds()), s.secureCookies)
s.logAuditEventForUser(models.ActionPasswordReset, r, user.ID, auditMeta(map[string]string{"email": user.Email}))
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"user": map[string]interface{}{
+60 -3
View File
@@ -414,11 +414,12 @@ func agentMeta(r *http.Request, existingMeta string) string {
return existingMeta
}
if existingMeta == "" || existingMeta == "{}" {
return fmt.Sprintf(`{"agent":"%s"}`, agentName)
return auditMeta(map[string]string{"agent": agentName})
}
// Merge: insert agent field into existing JSON
// Merge: insert agent field into existing JSON object
if strings.HasPrefix(existingMeta, "{") {
return fmt.Sprintf(`{"agent":"%s",%s`, agentName, existingMeta[1:])
agentJSON, _ := json.Marshal(agentName)
return fmt.Sprintf(`{"agent":%s,%s`, agentJSON, existingMeta[1:])
}
return existingMeta
}
@@ -448,6 +449,62 @@ func (s *Server) logActivityWithMetaReturningID(workspaceID, documentID, action
Source: source,
Metadata: metadata,
UserID: currentUserID(r),
IPAddress: clientIP(r),
UserAgent: r.Header.Get("User-Agent"),
})
}
// logAuditEvent logs a non-workspace audit event (e.g. login, logout).
// Best-effort: errors are silently ignored.
func (s *Server) logAuditEvent(action string, r *http.Request, metadata string) {
s.logAuditEventForUser(action, r, currentUserID(r), metadata)
}
// logAuditEventForUser logs an audit event with an explicit user ID.
// Use this when the user isn't (yet) in the request context, e.g. after
// a successful login/register/bootstrap where the session was just created.
func (s *Server) logAuditEventForUser(action string, r *http.Request, userID string, metadata string) {
actor, source := actorFromRequest(r)
if metadata == "" {
metadata = "{}"
}
_, _ = s.store.CreateActivity(models.Activity{
Action: action,
Actor: actor,
Source: source,
Metadata: metadata,
UserID: userID,
IPAddress: clientIP(r),
UserAgent: r.Header.Get("User-Agent"),
})
}
// auditMeta safely marshals a map to a JSON string for audit log metadata.
// Falls back to "{}" on marshal error so audit calls never break.
func auditMeta(kv map[string]string) string {
data, err := json.Marshal(kv)
if err != nil {
return "{}"
}
return string(data)
}
// logWorkspaceAuditEvent logs a workspace-scoped audit event (e.g. member invited).
// Best-effort: errors are silently ignored.
func (s *Server) logWorkspaceAuditEvent(workspaceID, action string, r *http.Request, metadata string) {
actor, source := actorFromRequest(r)
if metadata == "" {
metadata = "{}"
}
_, _ = s.store.CreateActivity(models.Activity{
WorkspaceID: workspaceID,
Action: action,
Actor: actor,
Source: source,
Metadata: metadata,
UserID: currentUserID(r),
IPAddress: clientIP(r),
UserAgent: r.Header.Get("User-Agent"),
})
}
+19 -2
View File
@@ -47,10 +47,27 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
// Subscribe to events for this workspace
ch := s.events.Subscribe(ws.ID)
// Atomically check SSE connection limits and subscribe in one step.
// This prevents TOCTOU races where two concurrent requests both pass the
// limit check before either subscribes.
ch, ok := s.events.SubscribeIfAllowed(ws.ID, s.sseMaxConnections, s.sseMaxPerWorkspace)
if !ok {
slog.Warn("SSE connection limit reached", "workspace", ws.Slug,
"global_current", s.events.SubscriberCount(), "global_max", s.sseMaxConnections,
"ws_current", s.events.WorkspaceSubscriberCount(ws.ID), "ws_max", s.sseMaxPerWorkspace)
writeError(w, http.StatusTooManyRequests, "sse_limit_exceeded", "SSE connection limit reached")
return
}
defer s.events.Unsubscribe(ch)
// Log warning at 80% global capacity
if s.sseMaxConnections > 0 {
total := s.events.SubscriberCount()
if total >= s.sseMaxConnections*80/100 {
slog.Warn("SSE connections approaching global limit", "current", total, "max", s.sseMaxConnections)
}
}
// Send initial connected event
writeSSEEvent(w, "connected", map[string]string{
"workspace_id": ws.ID,
+102
View File
@@ -360,3 +360,105 @@ func TestSSENoEventBus(t *testing.T) {
t.Errorf("expected 503, got %d", rr.Code)
}
}
func TestSSEGlobalConnectionLimit(t *testing.T) {
srv := testServerWithEvents(t)
srv.SetSSELimits(1, 0) // global limit of 1, no per-workspace limit
ts := httptest.NewServer(srv)
defer ts.Close()
slug := createTestWorkspace(t, ts.URL, "Test")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// First connection should succeed
ch := connectSSE(ctx, t, ts.URL, slug)
waitForEvent(t, ch, 3*time.Second) // connected
// Second connection should be rejected with 429
req, err := http.NewRequest("GET", ts.URL+"/api/v1/events?workspace="+slug, nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
t.Errorf("expected 429, got %d", resp.StatusCode)
}
}
func TestSSEPerWorkspaceLimit(t *testing.T) {
srv := testServerWithEvents(t)
srv.SetSSELimits(0, 1) // no global limit, per-workspace limit of 1
ts := httptest.NewServer(srv)
defer ts.Close()
slug1 := createTestWorkspace(t, ts.URL, "WS One")
slug2 := createTestWorkspace(t, ts.URL, "WS Two")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// First connection to ws1 should succeed
ch1 := connectSSE(ctx, t, ts.URL, slug1)
waitForEvent(t, ch1, 3*time.Second) // connected
// Second connection to ws1 should be rejected
req, err := http.NewRequest("GET", ts.URL+"/api/v1/events?workspace="+slug1, nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
t.Errorf("expected 429 for second ws1 connection, got %d", resp.StatusCode)
}
// Connection to ws2 should still succeed (different workspace)
ch2 := connectSSE(ctx, t, ts.URL, slug2)
event := waitForEvent(t, ch2, 3*time.Second)
if event.Type != "connected" {
t.Errorf("expected 'connected' for ws2, got %q", event.Type)
}
}
func TestSSELimitsExistingConnectionsUnaffected(t *testing.T) {
srv := testServerWithEvents(t)
srv.SetSSELimits(1, 0) // global limit of 1
ts := httptest.NewServer(srv)
defer ts.Close()
slug := createTestWorkspace(t, ts.URL, "Test")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Establish connection
ch := connectSSE(ctx, t, ts.URL, slug)
waitForEvent(t, ch, 3*time.Second) // connected
// Try (and fail) to get a second connection
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/events?workspace="+slug, nil)
resp, _ := http.DefaultClient.Do(req)
resp.Body.Close()
// The existing connection should still work — publish an event
srv.events.Publish(events.Event{
Type: "item.created",
WorkspaceID: slug, // need the real workspace ID
})
// We can't easily test the existing connection receives events here
// because the workspace ID in the event must match the internal UUID,
// but we can verify the subscriber count is still 1
if got := srv.events.SubscriberCount(); got != 1 {
t.Errorf("expected 1 subscriber still active, got %d", got)
}
}
+3 -2
View File
@@ -338,7 +338,8 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
}
if input.Title != nil && *input.Title != item.Title {
if meta == "" {
meta = fmt.Sprintf(`{"changes":"title: %s → %s"}`, item.Title, *input.Title)
titleChange := fmt.Sprintf("title: %s → %s", item.Title, *input.Title)
meta = fmt.Sprintf(`{"changes":%q}`, titleChange)
}
}
// Track role and assignment changes
@@ -566,7 +567,7 @@ func (s *Server) handleMoveItem(w http.ResponseWriter, r *http.Request) {
// Log activity with metadata about the move
actor, source := actorFromRequest(r)
moveMeta := fmt.Sprintf(`{"from_collection":"%s","to_collection":"%s"}`, sourceColl.Slug, targetColl.Slug)
moveMeta := auditMeta(map[string]string{"from_collection": sourceColl.Slug, "to_collection": targetColl.Slug})
s.logActivityWithMeta(workspaceID, moved.ID, "moved", r, moveMeta)
// Publish events for both old and new collections
+9
View File
@@ -6,6 +6,8 @@ import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/xarmian/pad/internal/models"
)
// handleListMembers returns all members of a workspace.
@@ -106,6 +108,7 @@ func (s *Server) handleInviteMember(w http.ResponseWriter, r *http.Request) {
writeInternalError(w, err)
return
}
s.logWorkspaceAuditEvent(workspaceID, models.ActionMemberInvited, r, auditMeta(map[string]string{"email": existingUser.Email, "role": input.Role, "added_directly": "true"}))
writeJSON(w, http.StatusCreated, map[string]interface{}{
"added": true,
"user_id": existingUser.ID,
@@ -135,6 +138,8 @@ func (s *Server) handleInviteMember(w http.ResponseWriter, r *http.Request) {
resp["join_url"] = joinURL
}
s.logWorkspaceAuditEvent(workspaceID, models.ActionMemberInvited, r, auditMeta(map[string]string{"email": input.Email, "role": input.Role}))
writeJSON(w, http.StatusCreated, resp)
// Send invitation email asynchronously (fire-and-forget)
@@ -180,6 +185,8 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
return
}
s.logWorkspaceAuditEvent(workspaceID, models.ActionMemberRemoved, r, auditMeta(map[string]string{"user_id": userID}))
w.WriteHeader(http.StatusNoContent)
}
@@ -215,6 +222,8 @@ func (s *Server) handleUpdateMemberRole(w http.ResponseWriter, r *http.Request)
return
}
s.logWorkspaceAuditEvent(workspaceID, models.ActionRoleChanged, r, auditMeta(map[string]string{"user_id": userID, "role": input.Role}))
writeJSON(w, http.StatusOK, map[string]interface{}{
"user_id": userID,
"role": input.Role,
+8
View File
@@ -37,6 +37,8 @@ func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
return
}
s.logAuditEvent(models.ActionTokenCreated, r, auditMeta(map[string]string{"name": input.Name, "workspace_id": input.WorkspaceID}))
writeJSON(w, http.StatusCreated, token)
}
@@ -76,6 +78,8 @@ func (s *Server) handleDeleteToken(w http.ResponseWriter, r *http.Request) {
return
}
s.logAuditEvent(models.ActionTokenRevoked, r, auditMeta(map[string]string{"token_id": tokenID}))
w.WriteHeader(http.StatusNoContent)
}
@@ -126,6 +130,8 @@ func (s *Server) handleCreateUserToken(w http.ResponseWriter, r *http.Request) {
return
}
s.logAuditEvent(models.ActionTokenCreated, r, auditMeta(map[string]string{"name": input.Name}))
writeJSON(w, http.StatusCreated, token)
}
@@ -147,5 +153,7 @@ func (s *Server) handleDeleteUserToken(w http.ResponseWriter, r *http.Request) {
return
}
s.logAuditEvent(models.ActionTokenRevoked, r, auditMeta(map[string]string{"token_id": tokenID}))
w.WriteHeader(http.StatusNoContent)
}
+40
View File
@@ -0,0 +1,40 @@
package server
import (
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/xarmian/pad/internal/metrics"
)
// MetricsMiddleware returns a chi middleware that records Prometheus metrics
// for every HTTP request: count, duration, and response size.
//
// Path labels use chi's RoutePattern (e.g. "/api/v1/workspaces/{slug}/items/{itemSlug}")
// instead of actual URL paths, keeping label cardinality bounded.
func MetricsMiddleware(m *metrics.Metrics) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := chimiddleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
duration := time.Since(start).Seconds()
status := strconv.Itoa(ww.Status())
route := chi.RouteContext(r.Context()).RoutePattern()
if route == "" {
route = "unmatched"
}
method := r.Method
m.HTTPRequestsTotal.WithLabelValues(method, route, status).Inc()
m.HTTPRequestDuration.WithLabelValues(method, route, status).Observe(duration)
m.HTTPResponseSize.WithLabelValues(method, route, status).Observe(float64(ww.BytesWritten()))
})
}
}
+134
View File
@@ -0,0 +1,134 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
io_prometheus_client "github.com/prometheus/client_model/go"
"github.com/xarmian/pad/internal/metrics"
)
func TestMetricsMiddleware_RecordsRequestMetrics(t *testing.T) {
m := metrics.New()
r := chi.NewRouter()
r.Use(MetricsMiddleware(m))
r.Get("/api/v1/items/{slug}", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
})
req := httptest.NewRequest("GET", "/api/v1/items/my-item", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("Expected 200, got %d", rec.Code)
}
// Check request count
counter, err := m.HTTPRequestsTotal.GetMetricWithLabelValues("GET", "/api/v1/items/{slug}", "200")
if err != nil {
t.Fatalf("Failed to get counter: %v", err)
}
var metric io_prometheus_client.Metric
if err := counter.Write(&metric); err != nil {
t.Fatalf("Failed to write counter: %v", err)
}
if got := metric.GetCounter().GetValue(); got != 1 {
t.Errorf("Expected request count = 1, got %v", got)
}
// Check duration and response size via the registry gather
families, gatherErr := m.Registry.Gather()
if gatherErr != nil {
t.Fatalf("Failed to gather metrics: %v", gatherErr)
}
var durationCount uint64
var responseSizeSum float64
for _, f := range families {
switch f.GetName() {
case "pad_http_request_duration_seconds":
for _, fm := range f.GetMetric() {
durationCount += fm.GetHistogram().GetSampleCount()
}
case "pad_http_response_size_bytes":
for _, fm := range f.GetMetric() {
responseSizeSum += fm.GetHistogram().GetSampleSum()
}
}
}
if durationCount != 1 {
t.Errorf("Expected 1 observation in duration histogram, got %d", durationCount)
}
if responseSizeSum <= 0 {
t.Errorf("Expected response size > 0, got %v", responseSizeSum)
}
}
func TestMetricsMiddleware_UsesRoutePattern(t *testing.T) {
m := metrics.New()
r := chi.NewRouter()
r.Use(MetricsMiddleware(m))
r.Get("/api/v1/workspaces/{ws}/items/{slug}", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Hit with different slugs — should all map to the same route pattern
for _, path := range []string{
"/api/v1/workspaces/my-ws/items/item-1",
"/api/v1/workspaces/other-ws/items/item-2",
"/api/v1/workspaces/third/items/item-3",
} {
req := httptest.NewRequest("GET", path, nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
}
// All 3 requests should be under the same route pattern label
counter, err := m.HTTPRequestsTotal.GetMetricWithLabelValues("GET", "/api/v1/workspaces/{ws}/items/{slug}", "200")
if err != nil {
t.Fatalf("Failed to get counter: %v", err)
}
var metric io_prometheus_client.Metric
if err := counter.Write(&metric); err != nil {
t.Fatalf("Failed to write counter: %v", err)
}
if got := metric.GetCounter().GetValue(); got != 3 {
t.Errorf("Expected 3 requests under route pattern, got %v", got)
}
}
func TestMetricsMiddleware_UnmatchedRoute(t *testing.T) {
m := metrics.New()
r := chi.NewRouter()
r.Use(MetricsMiddleware(m))
r.Get("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Hit a route that doesn't exist
req := httptest.NewRequest("GET", "/nonexistent", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
// chi returns 404 for unmatched routes; the middleware should label it "unmatched"
counter, err := m.HTTPRequestsTotal.GetMetricWithLabelValues("GET", "unmatched", "404")
if err != nil {
t.Fatalf("Failed to get counter: %v", err)
}
var metric io_prometheus_client.Metric
if err := counter.Write(&metric); err != nil {
t.Fatalf("Failed to write counter: %v", err)
}
if got := metric.GetCounter().GetValue(); got != 1 {
t.Errorf("Expected 1 unmatched request, got %v", got)
}
}
+57 -22
View File
@@ -14,9 +14,11 @@ import (
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/xarmian/pad/internal/email"
"github.com/xarmian/pad/internal/events"
"github.com/xarmian/pad/internal/metrics"
"github.com/xarmian/pad/internal/models"
"github.com/xarmian/pad/internal/store"
"github.com/xarmian/pad/internal/webhooks"
@@ -35,7 +37,10 @@ type Server struct {
baseURL string // public base URL for generating links (e.g. invite URLs)
corsOrigins string // comma-separated CORS origins (empty = localhost defaults)
secureCookies bool // set Secure flag on cookies (for TLS deployments)
version string // release version (e.g. "dev", "1.2.3")
metrics *metrics.Metrics // Prometheus metrics (optional)
sseMaxConnections int // global SSE connection limit (0 = unlimited)
sseMaxPerWorkspace int // per-workspace SSE connection limit (0 = unlimited)
version string // release version (e.g. "dev", "1.2.3")
commit string // git commit hash
buildTime string // build timestamp
}
@@ -84,6 +89,19 @@ func (s *Server) SetSecureCookies(secure bool) {
s.secureCookies = secure
}
// SetMetrics attaches Prometheus metrics to the server.
// Must be called before the first request is served.
func (s *Server) SetMetrics(m *metrics.Metrics) {
s.metrics = m
}
// SetSSELimits configures global and per-workspace SSE connection limits.
// A value of 0 means unlimited.
func (s *Server) SetSSELimits(global, perWorkspace int) {
s.sseMaxConnections = global
s.sseMaxPerWorkspace = perWorkspace
}
// reconfigureEmail reads email settings from the platform_settings table
// and updates (or creates) the email sender. Called after admin settings change.
func (s *Server) reconfigureEmail() {
@@ -113,34 +131,47 @@ func (s *Server) InitEmailFromSettings() {
func (s *Server) setupRouter() {
r := chi.NewRouter()
// Middleware
// Infrastructure middleware (applies to all routes including /metrics)
r.Use(chimiddleware.RealIP)
r.Use(chimiddleware.RequestID)
r.Use(StructuredLogger)
r.Use(chimiddleware.Recoverer)
r.Use(SecurityHeaders)
if s.secureCookies {
r.Use(StrictTransportSecurity)
if s.metrics != nil {
r.Use(MetricsMiddleware(s.metrics))
}
r.Use(cors.Handler(cors.Options{
AllowedOrigins: parseCORSOrigins(s.corsOrigins),
AllowedMethods: []string{"GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Use(s.TokenAuth)
r.Use(s.SessionAuth)
r.Use(s.RateLimit)
r.Use(s.CSRFProtect)
r.Use(s.RequireAuth)
r.Use(jsonContentType)
// SSE endpoint (outside jsonContentType middleware)
r.Get("/api/v1/events", s.handleSSE)
// Prometheus scrape endpoint — no auth/CSRF/security headers
if s.metrics != nil {
r.Group(func(r chi.Router) {
r.Handle("/metrics", promhttp.HandlerFor(s.metrics.Registry, promhttp.HandlerOpts{}))
})
}
// API routes
r.Route("/api/v1", func(r chi.Router) {
// All other routes — full middleware stack
r.Group(func(r chi.Router) {
r.Use(SecurityHeaders)
if s.secureCookies {
r.Use(StrictTransportSecurity)
}
r.Use(cors.Handler(cors.Options{
AllowedOrigins: parseCORSOrigins(s.corsOrigins),
AllowedMethods: []string{"GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Use(s.TokenAuth)
r.Use(s.SessionAuth)
r.Use(s.RateLimit)
r.Use(s.CSRFProtect)
r.Use(s.RequireAuth)
r.Use(jsonContentType)
// SSE endpoint (outside jsonContentType middleware — but inherits auth)
r.Get("/api/v1/events", s.handleSSE)
// API routes
r.Route("/api/v1", func(r chi.Router) {
r.Get("/health", s.handleHealth)
r.Get("/health/live", s.handleHealthLive)
r.Get("/health/ready", s.handleHealthReady)
@@ -172,6 +203,9 @@ func (s *Server) setupRouter() {
r.Post("/test-email", s.handleTestEmail)
})
// Audit log (admin-only)
r.Get("/audit-log", s.handleAuditLog)
// Templates
r.Get("/templates", s.handleListTemplates)
@@ -325,6 +359,7 @@ func (s *Server) setupRouter() {
// Search
r.Get("/search", s.handleSearch)
})
}) // end r.Group (full middleware stack)
s.router = r
}
+61 -7
View File
@@ -21,9 +21,9 @@ func (s *Store) CreateActivity(a models.Activity) (string, error) {
ts := now()
_, err := s.db.Exec(s.q(`
INSERT INTO activities (id, workspace_id, document_id, action, actor, source, metadata, user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`), a.ID, a.WorkspaceID, nilIfEmpty(a.DocumentID), a.Action, a.Actor, a.Source, a.Metadata, nilIfEmpty(a.UserID), ts)
INSERT INTO activities (id, workspace_id, document_id, action, actor, source, metadata, user_id, ip_address, user_agent, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), a.ID, nilIfEmpty(a.WorkspaceID), nilIfEmpty(a.DocumentID), a.Action, a.Actor, a.Source, a.Metadata, nilIfEmpty(a.UserID), nilIfEmpty(a.IPAddress), nilIfEmpty(a.UserAgent), ts)
return a.ID, err
}
@@ -118,7 +118,7 @@ func mergeActivityMeta(existing, incoming string) string {
func (s *Store) ListWorkspaceActivity(workspaceID string, params models.ActivityListParams) ([]models.Activity, error) {
query := `
SELECT a.id, a.workspace_id, COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, '')
SELECT a.id, COALESCE(a.workspace_id, ''), COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, ''), COALESCE(a.ip_address, ''), COALESCE(a.user_agent, '')
FROM activities a
LEFT JOIN users u ON a.user_id = u.id
WHERE a.workspace_id = ?
@@ -160,7 +160,7 @@ func (s *Store) ListWorkspaceActivity(workspaceID string, params models.Activity
func (s *Store) ListDocumentActivity(documentID string, params models.ActivityListParams) ([]models.Activity, error) {
query := `
SELECT a.id, a.workspace_id, COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, '')
SELECT a.id, COALESCE(a.workspace_id, ''), COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, ''), COALESCE(a.ip_address, ''), COALESCE(a.user_agent, '')
FROM activities a
LEFT JOIN users u ON a.user_id = u.id
WHERE a.document_id = ?
@@ -201,7 +201,7 @@ func (s *Store) ListDocumentActivity(documentID string, params models.ActivityLi
func (s *Store) ListDocumentActivityBeforeTime(documentID string, before time.Time, beforeID string, limit int) ([]models.Activity, error) {
ts := before.Format(time.RFC3339)
rows, err := s.db.Query(s.q(`
SELECT a.id, a.workspace_id, COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, '')
SELECT a.id, COALESCE(a.workspace_id, ''), COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, ''), COALESCE(a.ip_address, ''), COALESCE(a.user_agent, '')
FROM activities a
LEFT JOIN users u ON a.user_id = u.id
WHERE a.document_id = ? AND (a.created_at < ? OR (a.created_at = ? AND a.id < ?))
@@ -225,7 +225,7 @@ func scanActivitiesWithUser(rows interface {
for rows.Next() {
var a models.Activity
var createdAt string
if err := rows.Scan(&a.ID, &a.WorkspaceID, &a.DocumentID, &a.Action, &a.Actor, &a.Source, &a.Metadata, &a.UserID, &createdAt, &a.ActorName); err != nil {
if err := rows.Scan(&a.ID, &a.WorkspaceID, &a.DocumentID, &a.Action, &a.Actor, &a.Source, &a.Metadata, &a.UserID, &createdAt, &a.ActorName, &a.IPAddress, &a.UserAgent); err != nil {
return nil, err
}
a.CreatedAt = parseTime(createdAt)
@@ -240,3 +240,57 @@ func nilIfEmpty(s string) interface{} {
}
return s
}
// ListAuditLog returns activities matching the given audit log filters.
// Supports filtering by action, actor, workspace, and date range.
func (s *Store) ListAuditLog(params models.AuditLogParams) ([]models.Activity, error) {
// Build the full query with ? placeholders first, then rebind once at
// the end so PostgreSQL $1/$2/... numbering is correct across all filters.
query := `
SELECT a.id, COALESCE(a.workspace_id, ''), COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, ''), COALESCE(a.ip_address, ''), COALESCE(a.user_agent, '')
FROM activities a
LEFT JOIN users u ON a.user_id = u.id
WHERE 1=1
`
args := []interface{}{}
if params.WorkspaceID != "" {
query += ` AND a.workspace_id = ?`
args = append(args, params.WorkspaceID)
}
if params.Action != "" {
query += ` AND a.action = ?`
args = append(args, params.Action)
}
if params.Actor != "" {
query += ` AND a.user_id = ?`
args = append(args, params.Actor)
}
if params.Days > 0 {
cutoff := time.Now().UTC().AddDate(0, 0, -params.Days).Format(time.RFC3339)
query += ` AND a.created_at >= ?`
args = append(args, cutoff)
}
query += ` ORDER BY a.created_at DESC`
limit := params.Limit
if limit <= 0 {
limit = 50
}
query += fmt.Sprintf(` LIMIT %d`, limit)
if params.Offset > 0 {
query += fmt.Sprintf(` OFFSET %d`, params.Offset)
}
// Rebind all ? placeholders in one pass so $1, $2, ... are sequential.
query = s.q(query)
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanActivitiesWithUser(rows)
}
@@ -0,0 +1,40 @@
-- Audit trail: extend activities with IP/UA, nullable workspace, expanded action types.
-- SQLite requires table recreation to alter CHECK constraints.
PRAGMA foreign_keys = OFF;
DROP TABLE IF EXISTS activities_new;
CREATE TABLE activities_new (
id TEXT PRIMARY KEY,
workspace_id TEXT REFERENCES workspaces(id),
document_id TEXT,
action TEXT NOT NULL,
actor TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'web',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
user_id TEXT REFERENCES users(id),
ip_address TEXT,
user_agent TEXT
);
-- Copy existing data (ip_address and user_agent will be NULL for historical records)
INSERT INTO activities_new (id, workspace_id, document_id, action, actor, source, metadata, created_at, user_id)
SELECT id, workspace_id, document_id, action, actor, source, metadata, created_at, user_id
FROM activities;
-- Update foreign keys pointing to activities
-- comments.activity_id references activities(id) — handled automatically by SQLite
-- since we're using the same primary key values
DROP TABLE activities;
ALTER TABLE activities_new RENAME TO activities;
-- Recreate indexes
CREATE INDEX IF NOT EXISTS idx_activities_workspace ON activities(workspace_id, created_at);
CREATE INDEX IF NOT EXISTS idx_activities_document ON activities(document_id, created_at);
CREATE INDEX IF NOT EXISTS idx_activities_action ON activities(action, created_at);
CREATE INDEX IF NOT EXISTS idx_activities_user ON activities(user_id, created_at);
PRAGMA foreign_keys = ON;
@@ -0,0 +1,17 @@
-- Audit trail: extend activities with IP/UA, nullable workspace, expanded action types.
-- Add new columns
ALTER TABLE activities ADD COLUMN IF NOT EXISTS ip_address TEXT;
ALTER TABLE activities ADD COLUMN IF NOT EXISTS user_agent TEXT;
-- Make workspace_id nullable (auth events have no workspace)
ALTER TABLE activities ALTER COLUMN workspace_id DROP NOT NULL;
-- Drop restrictive CHECK constraints and replace with open ones
ALTER TABLE activities DROP CONSTRAINT IF EXISTS activities_action_check;
ALTER TABLE activities DROP CONSTRAINT IF EXISTS activities_actor_check;
ALTER TABLE activities DROP CONSTRAINT IF EXISTS activities_source_check;
-- Add indexes for audit log queries
CREATE INDEX IF NOT EXISTS idx_activities_action ON activities(action, created_at);
CREATE INDEX IF NOT EXISTS idx_activities_user ON activities(user_id, created_at);
+2
View File
@@ -138,6 +138,7 @@ func (s *Store) migrate() error {
"019_agent_roles_tools.sql",
"020_role_sort_order.sql",
"021_phase_to_links.sql",
"022_audit_trail.sql",
}
for _, name := range migrations {
@@ -183,6 +184,7 @@ func (s *Store) migratePostgres() error {
migrations := []string{
"001_initial.sql",
"002_audit_trail.sql",
}
for _, name := range migrations {