Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
59c6865
chore: ignore .worktrees/ for local worktree workspaces
schliz May 3, 2026
178bd78
refactor: introduce handler.Base and export shared helpers
schliz May 3, 2026
1bea608
refactor: load templates recursively from sub-directories
schliz May 3, 2026
71b65ce
refactor: move templates into per-domain sub-folders
schliz May 3, 2026
e783aa1
refactor: extract shared.HeaderStats into handler/shared sub-package
schliz May 3, 2026
a2f64e8
refactor: move admin handlers into handler/admin sub-package
schliz May 3, 2026
334fbba
refactor: move kiosk handlers into handler/kiosk sub-package
schliz May 3, 2026
3831261
refactor: move member handlers into handler/member sub-package
schliz May 3, 2026
90e9467
refactor: extract route registration into internal/router package
schliz May 3, 2026
e92fa18
refactor: split kiosk handlers by responsibility
schliz May 3, 2026
4b3051c
refactor: split admin menu handlers into categories and items
schliz May 3, 2026
60fd58f
refactor: split admin users handlers from deposits
schliz May 3, 2026
a480131
refactor: split member history view from custom-transaction handlers
schliz May 3, 2026
f61d914
feat: enforce spending limits on custom transactions and exclude kios…
schliz Mar 16, 2026
112291b
test: add e2e tests for spending limits, kiosk exclusion, and kiosk auth
schliz Mar 16, 2026
207e3a7
feat: add manual e-mail notifications
schliz Mar 16, 2026
d9ce683
fix: correctly display custom transaction modal errors
schliz Mar 16, 2026
d547b2a
test: e2e case for invalid custom transaction
schliz Mar 16, 2026
1322f43
Merge remote-tracking branch 'origin/master' into refactor/deepen-dirs
schliz May 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ static/css/styles.*.css
# Build output
/tmp/

# Git worktrees
.worktrees/

# Secrets
.env

Expand Down
172 changes: 20 additions & 152 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,20 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"

"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"github.com/schliz/deckel/internal/auth"
"github.com/schliz/deckel/internal/config"
"github.com/schliz/deckel/internal/handler"
"github.com/schliz/deckel/internal/middleware"
"github.com/schliz/deckel/internal/handler/admin"
"github.com/schliz/deckel/internal/handler/kiosk"
"github.com/schliz/deckel/internal/handler/member"
"github.com/schliz/deckel/internal/handler/shared"
"github.com/schliz/deckel/internal/render"
"github.com/schliz/deckel/internal/router"
"github.com/schliz/deckel/internal/store"
"github.com/schliz/deckel/migrations"
)
Expand Down Expand Up @@ -62,137 +63,34 @@ func main() {
if err != nil {
log.Fatalf("Failed to initialize renderer: %v", err)
}
h := &handler.Handler{
h := &handler.Base{
Store: s,
Renderer: rndr,
Config: cfg,
}
sharedH := &shared.Handler{Base: h}
adminH := &admin.Handler{Base: h}
kioskH := &kiosk.Handler{Base: h}
memberH := &member.Handler{Base: h}

// Generate CSRF secret (random 32 bytes).
csrfSecret := make([]byte, 32)
if _, err := rand.Read(csrfSecret); err != nil {
log.Fatalf("Failed to generate CSRF secret: %v", err)
}

// Middleware chains.
base := middleware.Chain(
middleware.Logging(),
middleware.Recovery(),
auth.Middleware(s, cfg.AdminGroup, cfg.KioskGroup, cfg.Organization, cfg.AppName),
)
withCSRF := middleware.Chain(
base,
middleware.CSRF(csrfSecret),
)
mux := http.NewServeMux()

// Health check - outside middleware (no auth needed).
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := dbpool.Ping(r.Context()); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("database unreachable"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})

// Static file server - outside auth middleware.
staticFS := http.FileServer(http.Dir(cfg.StaticDir))
mux.Handle("/static/", http.StripPrefix("/static/", staticCacheHandler(staticFS, cfg.DevMode)))

// PWA assets served from root - must bypass auth (see OAUTH2_PROXY_SKIP_AUTH_REGEX).
mux.HandleFunc("GET /sw.js", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, filepath.Join(cfg.StaticDir, "js", "sw.js"))
})
mux.HandleFunc("GET /manifest.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, filepath.Join(cfg.StaticDir, "manifest.json"))
})
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, filepath.Join(cfg.StaticDir, "icons", "favicon.ico"))
mux := router.New(router.Deps{
Base: h,
Admin: adminH,
Kiosk: kioskH,
Member: memberH,
Shared: sharedH,
Store: s,
DBPool: dbpool,
Config: cfg,
CSRFSecret: csrfSecret,
})

// Menu page (GET / and GET /menu).
mux.Handle("GET /{$}", withCSRF(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if user := auth.UserFromContext(r.Context()); user != nil && user.IsKiosk {
http.Redirect(w, r, "/kiosk", http.StatusFound)
return
}
h.Wrap(h.MenuPage)(w, r)
})))
mux.Handle("GET /menu", withCSRF(h.Wrap(h.MenuPage)))

// Order modal (GET /menu/items/{id}/order).
mux.Handle("GET /menu/items/{id}/order", withCSRF(h.Wrap(h.OrderModal)))

// Place order (POST /menu/order).
mux.Handle("POST /menu/order", withCSRF(h.Wrap(h.PlaceOrder)))

// Header stats (lazy-loaded on page init).
mux.Handle("GET /header-stats", base(h.Wrap(h.HeaderStats)))

// Placeholder routes with base middleware (auth, no CSRF for GET).
mux.Handle("GET /profile", withCSRF(h.Wrap(h.ProfilePage)))
mux.Handle("POST /profile/export", withCSRF(h.Wrap(h.ExportData)))
mux.Handle("GET /transactions", withCSRF(h.Wrap(h.TransactionHistory)))
mux.Handle("GET /transactions/custom", withCSRF(h.Wrap(h.CustomTransactionModal)))
mux.Handle("POST /transactions/custom", withCSRF(h.Wrap(h.CreateCustomTransaction)))
mux.Handle("GET /transactions/{id}/cancel", withCSRF(h.Wrap(h.CancelModal)))
mux.Handle("POST /transactions/{id}/cancel", withCSRF(h.Wrap(h.CancelTransaction)))

// Kiosk routes with CSRF + RequireKiosk.
kioskOnly := func(h http.Handler) http.Handler {
return withCSRF(auth.RequireKiosk(h))
}
mux.Handle("GET /kiosk", kioskOnly(h.Wrap(h.KioskMenuPage)))
mux.Handle("GET /kiosk/items/{id}/users", kioskOnly(h.Wrap(h.KioskUserSelect)))
mux.Handle("GET /kiosk/items/{id}/confirm/{uid}", kioskOnly(h.Wrap(h.KioskConfirm)))
mux.Handle("POST /kiosk/order", kioskOnly(h.Wrap(h.KioskPlaceOrder)))
mux.Handle("GET /kiosk/history", kioskOnly(h.Wrap(h.KioskHistory)))
mux.Handle("GET /kiosk/transactions/{id}/cancel", kioskOnly(h.Wrap(h.KioskCancelModal)))
mux.Handle("POST /kiosk/transactions/{id}/cancel", kioskOnly(h.Wrap(h.KioskCancelTransaction)))

// Admin routes with CSRF + RequireAdmin.
adminOnly := func(h http.Handler) http.Handler {
return withCSRF(auth.RequireAdmin(h))
}
mux.Handle("GET /admin/menu", adminOnly(h.Wrap(h.AdminMenuPage)))
mux.Handle("GET /admin/menu/batch", adminOnly(h.Wrap(h.MenuBatchPage)))
mux.Handle("GET /admin/menu/batch/export", adminOnly(h.Wrap(h.MenuBatchExport)))
mux.Handle("POST /admin/menu/batch/upload", adminOnly(h.Wrap(h.MenuBatchUpload)))
mux.Handle("POST /admin/menu/batch/apply", adminOnly(h.Wrap(h.MenuBatchApply)))
mux.Handle("POST /admin/categories", adminOnly(h.Wrap(h.CreateCategory)))
mux.Handle("GET /admin/categories/{id}/edit", adminOnly(h.Wrap(h.EditCategoryForm)))
mux.Handle("POST /admin/categories/{id}/update", adminOnly(h.Wrap(h.UpdateCategory)))
mux.Handle("POST /admin/categories/{id}/reorder", adminOnly(h.Wrap(h.ReorderCategory)))
mux.Handle("DELETE /admin/categories/{id}", adminOnly(h.Wrap(h.DeleteCategory)))
mux.Handle("POST /admin/categories/{id}/items", adminOnly(h.Wrap(h.CreateItem)))
mux.Handle("GET /admin/items/{id}/edit", adminOnly(h.Wrap(h.EditItemForm)))
mux.Handle("POST /admin/items/{id}/update", adminOnly(h.Wrap(h.UpdateItem)))
mux.Handle("POST /admin/items/{id}/reorder", adminOnly(h.Wrap(h.ReorderItem)))
mux.Handle("POST /admin/items/{id}/delete", adminOnly(h.Wrap(h.SoftDeleteItem)))
mux.Handle("GET /admin/users", adminOnly(h.Wrap(h.AdminUserList)))
mux.Handle("GET /admin/users/{id}/confirm-toggle", adminOnly(h.Wrap(h.ConfirmToggleModal)))
mux.Handle("POST /admin/users/{id}/toggle-barteamer", adminOnly(h.Wrap(h.ToggleBarteamer)))
mux.Handle("POST /admin/users/{id}/toggle-active", adminOnly(h.Wrap(h.ToggleActive)))
mux.Handle("POST /admin/users/{id}/toggle-spending-limit", adminOnly(h.Wrap(h.ToggleSpendingLimit)))
mux.Handle("GET /admin/users/{id}/deposit", adminOnly(h.Wrap(h.DepositModal)))
mux.Handle("POST /admin/users/{id}/deposit", adminOnly(h.Wrap(h.RegisterDeposit)))
mux.Handle("GET /admin/transactions", adminOnly(h.Wrap(h.AdminTransactionList)))
mux.Handle("GET /admin/transactions/{id}/cancel", adminOnly(h.Wrap(h.AdminCancelModal)))
mux.Handle("POST /admin/transactions/{id}/cancel", adminOnly(h.Wrap(h.AdminCancelTransaction)))
mux.Handle("GET /admin/stats", adminOnly(h.Wrap(h.AdminStatsPage)))
mux.Handle("GET /admin/settings", adminOnly(h.Wrap(h.AdminSettingsPage)))
mux.Handle("POST /admin/settings", adminOnly(h.Wrap(h.SaveSettings)))
mux.Handle("GET /admin/settings/send-reminders", adminOnly(h.Wrap(h.SendRemindersModal)))
mux.Handle("POST /admin/settings/send-reminders", adminOnly(h.Wrap(h.SendReminders)))

// Catch-all: styled 404 for unmatched routes.
mux.Handle("/", base(h.NotFoundHandler()))

srv := &http.Server{
Addr: cfg.ListenAddr,
Handler: mux,
Expand Down Expand Up @@ -223,36 +121,6 @@ func main() {
log.Println("Server stopped gracefully")
}

// staticCacheHandler wraps a file server handler to add cache headers based on file type.
// In dev mode, cache headers are set to no-cache.
func staticCacheHandler(next http.Handler, devMode bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if devMode {
w.Header().Set("Cache-Control", "no-cache")
} else {
path := r.URL.Path
switch {
case strings.HasSuffix(path, ".css"):
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
case strings.HasSuffix(path, ".js"):
w.Header().Set("Cache-Control", "public, max-age=86400")
case strings.HasSuffix(path, ".png"), strings.HasSuffix(path, ".json"):
w.Header().Set("Cache-Control", "public, max-age=86400")
}
}
next.ServeHTTP(w, r)
})
}

// placeholderHandler returns a simple handler that responds with the route name.
func placeholderHandler(name string) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte("placeholder: " + name))
}
}

func runMigrations(databaseURL string) error {
db, err := sql.Open("pgx", databaseURL)
if err != nil {
Expand Down
Loading
Loading