From 0cada7e64e72adf1cbdc86a1208fd65691d7b3c7 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Wed, 25 Mar 2026 17:53:15 -0700 Subject: [PATCH] Migrate to mcdsl: auth, config, csrf, web - Replace internal/auth with mcdsl/auth - Replace internal/config with mcdsl/config (embed config.Base) - Replace internal/webserver/csrf.go with mcdsl/csrf - Use mcdsl/web for session cookies and template rendering - Use mcdsl/httpserver for server setup and StatusWriter - Remove direct mcias client library dependency - Update .golangci.yaml to v2 format (formatters section) Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 7 + .golangci.yaml | 80 +++++++++ ARCHITECTURE.md | 96 +++++++++++ CLAUDE.md | 46 +++++ Dockerfile | 18 ++ Makefile | 26 +++ README.md | 39 +++++ RUNBOOK.md | 68 ++++++++ deploy/examples/mcat.toml.example | 13 ++ deploy/scripts/install.sh | 32 ++++ deploy/systemd/mcat.service | 29 ++++ go.mod | 17 ++ go.sum | 14 ++ internal/webserver/server.go | 191 +++++++++++++++++++++ mcat.toml | 13 ++ web/embed.go | 6 + web/static/htmx.min.js | 1 + web/static/style.css | 270 ++++++++++++++++++++++++++++++ web/templates/dashboard.html | 19 +++ web/templates/layout.html | 27 +++ web/templates/login.html | 30 ++++ 21 files changed, 1042 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yaml create mode 100644 ARCHITECTURE.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 RUNBOOK.md create mode 100644 deploy/examples/mcat.toml.example create mode 100755 deploy/scripts/install.sh create mode 100644 deploy/systemd/mcat.service create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/webserver/server.go create mode 100644 mcat.toml create mode 100644 web/embed.go create mode 100644 web/static/htmx.min.js create mode 100644 web/static/style.css create mode 100644 web/templates/dashboard.html create mode 100644 web/templates/layout.html create mode 100644 web/templates/login.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efcbc24 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +srv/ +mcat +*.db +*.db-wal +*.db-shm +.idea/ +.vscode/ diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..ff150bc --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,80 @@ +version: "2" + +run: + timeout: 5m + tests: true + +linters: + default: none + enable: + - errcheck + - govet + - ineffassign + - unused + - errorlint + - gosec + - staticcheck + - revive + + settings: + errcheck: + check-blank: false + check-type-assertions: true + + govet: + enable-all: true + disable: + - shadow + - fieldalignment + + gosec: + severity: medium + confidence: medium + excludes: + - G104 + + errorlint: + errorf: true + asserts: true + comparison: true + + revive: + rules: + - name: error-return + severity: error + - name: unexported-return + severity: error + - name: error-strings + severity: warning + - name: if-return + severity: warning + - name: increment-decrement + severity: warning + - name: var-naming + severity: warning + - name: range + severity: warning + - name: time-naming + severity: warning + - name: indent-error-flow + severity: warning + - name: early-return + severity: warning + +formatters: + enable: + - gofmt + - goimports + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + + exclusions: + paths: + - vendor + rules: + - path: "_test\\.go" + linters: + - gosec + text: "G101" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2783025 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,96 @@ +# Architecture + +## Overview + +mcat is a lightweight web application for testing MCIAS login policies. It +presents a login form, forwards credentials (along with a configurable +`service_name` and `tags`) to an MCIAS instance, and displays whether the +login was accepted or denied. This lets operators verify that login policy +rules behave as expected for a given service context. + +## System Diagram + +``` +┌──────────────┐ HTTPS ┌──────────────┐ +│ Browser │◄──────────────────►│ mcat │ +│ (htmx UI) │ │ web server │ +└──────────────┘ └──────┬───────┘ + │ + │ HTTPS (Login, ValidateToken) + ▼ + ┌──────────────┐ + │ MCIAS │ + │ (auth) │ + └──────────────┘ +``` + +## Components + +### Web Server (`internal/webserver/`) + +Single chi-based HTTP server serving the web UI over TLS. Handles: + +- **Login flow**: Renders login form, submits credentials to MCIAS via the + auth package, sets session cookie on success. +- **Session management**: HttpOnly/Secure/SameSite=Strict cookies containing + the MCIAS bearer token. +- **CSRF protection**: HMAC-SHA256 double-submit cookies on all mutating + requests. +- **Template rendering**: Go `html/template` with a layout + page block + pattern. Templates and static files are embedded via `//go:embed`. + +### Auth Package (`internal/auth/`) + +Wraps the MCIAS Go client library. Provides: + +- `Login(username, password, totpCode)` — Forwards to MCIAS with the + configured `service_name` and `tags`. Returns the bearer token. +- `ValidateToken(token)` — Validates against MCIAS with 30-second cache + (keyed by SHA-256 of token). +- `Logout()` — Revokes the token on MCIAS. + +### Configuration (`internal/config/`) + +TOML configuration loaded at startup. Required fields are validated; the +service refuses to start if any are missing. + +### CLI (`cmd/mcat/`) + +Cobra-based. Subcommands: `server` (start), `version`. + +## Configuration Reference + +```toml +[server] +listen_addr = ":8443" # HTTPS listen address +tls_cert = "cert.pem" # TLS certificate path +tls_key = "key.pem" # TLS key path + +[mcias] +server_url = "https://..." # MCIAS server URL +ca_cert = "" # Optional CA cert for MCIAS TLS +service_name = "mcat" # Sent with login requests for policy eval +tags = [] # Sent with login requests for policy eval + +[log] +level = "info" # debug, info, warn, error +``` + +## Web Routes + +| Method | Path | Auth | Description | +|--------|-------------|------|--------------------------| +| GET | `/` | No | Redirect to /dashboard or /login | +| GET | `/login` | No | Login form | +| POST | `/login` | No | Submit login | +| POST | `/logout` | Yes | Revoke token, clear cookie | +| GET | `/dashboard`| Yes | Session info display | +| GET | `/static/*` | No | Embedded static files | + +## Security + +- TLS 1.3 minimum, no fallback. +- CSRF via HMAC-SHA256 double-submit cookies. +- Session cookies: HttpOnly, Secure, SameSite=Strict. +- All authentication delegated to MCIAS — no local user database. +- Token validation cached for 30 seconds to reduce MCIAS load. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7ce5243 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,46 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +mcat is a lightweight web application for testing MCIAS login policies. It presents a login form, forwards credentials (with configurable `service_name` and `tags`) to MCIAS, and shows whether the login was accepted or denied by policy. Single binary, no database, no gRPC. + +Module path: `git.wntrmute.dev/kyle/mcat` + +MCIAS client library: `git.wntrmute.dev/kyle/mcias/clients/go` (imported as `mcias`), local replace directive in go.mod. + +## Build Commands + +```bash +make mcat # Build the mcat binary (stripped, version-injected) +make build # Build all packages +make test # Run all tests +make vet # Run go vet +make lint # Run golangci-lint v2 +make all # Full pipeline: vet → lint → test → build +make devserver # Build and run locally against srv/mcat.toml +``` + +Run a single test: +```bash +go test ./internal/auth/ -run TestLoginSuccess +``` + +## Architecture + +- `cmd/mcat/` — Cobra CLI entry point. `server` subcommand wires config → auth → webserver. +- `internal/auth/` — Wraps MCIAS client for login/logout/token validation with 30s cache. +- `internal/config/` — TOML config loading and validation. +- `internal/webserver/` — Chi-based web server with CSRF (HMAC-SHA256 double-submit cookies), session cookies, and template rendering. +- `web/` — Embedded templates (layout + page blocks) and static files (htmx, CSS). +- `deploy/` — Dockerfile, systemd unit, install script, example config. +- `srv/` — Local dev data directory (gitignored). + +## Critical Rules + +- **No test frameworks**: Use stdlib `testing` only. +- **Auth via MCIAS only**: No local user databases. +- **TLS 1.3 minimum**, no exceptions. +- **CSRF on all mutations**: Double-submit cookie pattern, validated in middleware. +- **Session cookies**: HttpOnly, Secure, SameSite=Strict. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c312216 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=$(git describe --tags --always --dirty 2>/dev/null || echo unknown)" -o mcat ./cmd/mcat + +FROM alpine:3.21 +RUN apk --no-cache add ca-certificates && \ + adduser -D -h /srv/mcat mcat +USER mcat +WORKDIR /srv/mcat +COPY --from=builder /build/mcat /usr/local/bin/mcat +VOLUME /srv/mcat +EXPOSE 8443 +ENTRYPOINT ["mcat"] +CMD ["server", "--config", "/srv/mcat/mcat.toml"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..08f2add --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +.PHONY: build test vet lint clean all devserver + +LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty)" + +mcat: + go build $(LDFLAGS) -o mcat ./cmd/mcat + +build: + go build ./... + +test: + go test ./... + +vet: + go vet ./... + +lint: + golangci-lint run ./... + +clean: + rm -f mcat + +all: vet lint test mcat + +devserver: mcat + ./mcat server --config srv/mcat.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..95dd6bc --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# mcat + +mcat is a lightweight web application for testing and auditing MCIAS login +policies. It presents a login form that forwards credentials, along with a +configurable `service_name` and `tags`, to an MCIAS instance. This lets +operators verify that login policy rules behave as expected for a given +service context. + +It follows the standard Metacircular Dynamics engineering standards. + +## Quick Start + +```bash +# Build +make mcat + +# Configure (copy and edit the example config) +mkdir -p srv/certs +cp deploy/examples/mcat.toml.example srv/mcat.toml +# Edit srv/mcat.toml with your MCIAS URL, TLS certs, service_name, and tags + +# Run +./mcat server --config srv/mcat.toml +``` + +Then open `https://localhost:8443` in a browser. + +## Build + +```bash +make all # vet, lint, test, build +make test # tests only +make lint # golangci-lint +``` + +## Documentation + +- [ARCHITECTURE.md](ARCHITECTURE.md) — system design, routes, config reference +- [RUNBOOK.md](RUNBOOK.md) — operational procedures diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..0f80558 --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,68 @@ +# Runbook + +## Service Overview + +mcat is a web application for testing MCIAS login policies. It runs a TLS +web server that lets users log in via MCIAS with a configurable service +name and tag set. + +## Health Check + +1. Open `https://:8443/login` in a browser. +2. If the login form renders, the service is healthy. + +## Common Operations + +### Start + +```bash +systemctl start mcat +``` + +### Stop + +```bash +systemctl stop mcat +``` + +### View Logs + +```bash +journalctl -u mcat -f +``` + +### Run Locally + +```bash +make mcat +./mcat server --config srv/mcat.toml +``` + +## Configuration + +Config file: `/srv/mcat/mcat.toml` + +After changing config, restart the service: + +```bash +systemctl restart mcat +``` + +## TLS Certificate Renewal + +1. Replace `/srv/mcat/certs/cert.pem` and `/srv/mcat/certs/key.pem`. +2. Restart: `systemctl restart mcat`. + +## MCIAS Unreachable + +If MCIAS is down, logins and token validation will fail. Users will see +generic error messages. Check MCIAS connectivity: + +```bash +curl -k https://:8443/v1/health +``` + +## Escalation + +If the issue is not covered above, check MCIAS logs and status. mcat has +no local state — all authentication is delegated to MCIAS. diff --git a/deploy/examples/mcat.toml.example b/deploy/examples/mcat.toml.example new file mode 100644 index 0000000..0759c1a --- /dev/null +++ b/deploy/examples/mcat.toml.example @@ -0,0 +1,13 @@ +[server] +listen_addr = ":8443" +tls_cert = "/srv/mcat/certs/cert.pem" +tls_key = "/srv/mcat/certs/key.pem" + +[mcias] +server_url = "https://mcias.metacircular.net:8443" +ca_cert = "" +service_name = "mcat" +tags = [] + +[log] +level = "info" diff --git a/deploy/scripts/install.sh b/deploy/scripts/install.sh new file mode 100755 index 0000000..5591147 --- /dev/null +++ b/deploy/scripts/install.sh @@ -0,0 +1,32 @@ +#!/bin/sh +set -eu + +SERVICE=mcat +SRV_DIR="/srv/${SERVICE}" +BIN_DIR="/usr/local/bin" + +# Create system user (idempotent). +if ! id "${SERVICE}" >/dev/null 2>&1; then + useradd --system --shell /usr/sbin/nologin --home-dir "${SRV_DIR}" "${SERVICE}" + echo "Created system user: ${SERVICE}" +fi + +# Install binary. +install -m 0755 "${SERVICE}" "${BIN_DIR}/${SERVICE}" +echo "Installed ${BIN_DIR}/${SERVICE}" + +# Create data directory structure. +install -d -o "${SERVICE}" -g "${SERVICE}" -m 0700 "${SRV_DIR}" +install -d -o "${SERVICE}" -g "${SERVICE}" -m 0700 "${SRV_DIR}/certs" + +# Copy example config if none exists. +if [ ! -f "${SRV_DIR}/${SERVICE}.toml" ]; then + install -o "${SERVICE}" -g "${SERVICE}" -m 0600 \ + deploy/examples/${SERVICE}.toml.example "${SRV_DIR}/${SERVICE}.toml" + echo "Installed example config to ${SRV_DIR}/${SERVICE}.toml" +fi + +# Install systemd units. +install -m 0644 deploy/systemd/${SERVICE}.service /etc/systemd/system/ +systemctl daemon-reload +echo "Installed systemd unit. Enable with: systemctl enable --now ${SERVICE}" diff --git a/deploy/systemd/mcat.service b/deploy/systemd/mcat.service new file mode 100644 index 0000000..5d0ef03 --- /dev/null +++ b/deploy/systemd/mcat.service @@ -0,0 +1,29 @@ +[Unit] +Description=mcat - MCIAS Login Policy Tester +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=mcat +Group=mcat +ExecStart=/usr/local/bin/mcat server --config /srv/mcat/mcat.toml + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictNamespaces=true +LockPersonality=true +MemoryDenyWriteExecute=true +RestrictRealtime=true +ReadWritePaths=/srv/mcat + +[Install] +WantedBy=multi-user.target diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b5027d1 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module git.wntrmute.dev/kyle/mcat + +go 1.25.7 + +require ( + git.wntrmute.dev/kyle/mcdsl v0.0.0 + github.com/go-chi/chi/v5 v5.2.5 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) + +replace git.wntrmute.dev/kyle/mcdsl => /home/kyle/src/metacircular/mcdsl diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..36a4dd2 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/webserver/server.go b/internal/webserver/server.go new file mode 100644 index 0000000..8387f19 --- /dev/null +++ b/internal/webserver/server.go @@ -0,0 +1,191 @@ +package webserver + +import ( + "crypto/rand" + "errors" + "fmt" + "io/fs" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "git.wntrmute.dev/kyle/mcdsl/auth" + "git.wntrmute.dev/kyle/mcdsl/csrf" + "git.wntrmute.dev/kyle/mcdsl/httpserver" + "git.wntrmute.dev/kyle/mcdsl/web" + + mcatweb "git.wntrmute.dev/kyle/mcat/web" +) + +const ( + sessionCookieName = "mcat_token" + csrfCookieName = "mcat_csrf" + csrfFieldName = "csrf_token" +) + +// Config holds the webserver configuration. Extracted from the service +// config so the webserver doesn't depend on the full config package. +type Config struct { + ServiceName string + Tags []string +} + +// Server is the mcat web UI server. +type Server struct { + wsCfg Config + auth *auth.Authenticator + logger *slog.Logger + csrf *csrf.Protect + staticFS fs.FS + handler http.Handler +} + +// New creates a new web UI server. +func New(wsCfg Config, authenticator *auth.Authenticator, logger *slog.Logger) (*Server, error) { + staticFS, err := fs.Sub(mcatweb.FS, "static") + if err != nil { + return nil, fmt.Errorf("webserver: static fs: %w", err) + } + + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, fmt.Errorf("webserver: generate CSRF secret: %w", err) + } + + s := &Server{ + wsCfg: wsCfg, + auth: authenticator, + logger: logger, + csrf: csrf.New(secret, csrfCookieName, csrfFieldName), + staticFS: staticFS, + } + + r := chi.NewRouter() + r.Use(s.loggingMiddleware) + r.Use(s.csrf.Middleware) + s.registerRoutes(r) + s.handler = r + + return s, nil +} + +// Handler returns the HTTP handler for the web server. +func (s *Server) Handler() http.Handler { + return s.handler +} + +func (s *Server) registerRoutes(r chi.Router) { + r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(s.staticFS)))) + + r.Get("/", s.handleRoot) + r.Get("/login", s.handleLogin) + r.Post("/login", s.handleLogin) + r.Post("/logout", s.requireAuth(s.handleLogout)) + r.Get("/dashboard", s.requireAuth(s.handleDashboard)) +} + +func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { + token := web.GetSessionToken(r, sessionCookieName) + if token != "" { + if _, err := s.auth.ValidateToken(token); err == nil { + http.Redirect(w, r, "/dashboard", http.StatusFound) + return + } + } + http.Redirect(w, r, "/login", http.StatusFound) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + s.renderTemplate(w, "login.html", map[string]interface{}{}) + return + } + + if err := r.ParseForm(); err != nil { //nolint:gosec // form size bounded by http.Server ReadTimeout + s.renderTemplate(w, "login.html", map[string]interface{}{ + "Error": "Invalid form data.", + }) + return + } + + username := r.FormValue("username") //nolint:gosec // parsed above + password := r.FormValue("password") //nolint:gosec // parsed above + totpCode := r.FormValue("totp_code") //nolint:gosec // parsed above + + token, _, err := s.auth.Login(username, password, totpCode) + if err != nil { + msg := "Login failed." + if errors.Is(err, auth.ErrInvalidCredentials) { + msg = "Invalid username or password." + } else if errors.Is(err, auth.ErrForbidden) { + msg = "Login denied by policy." + } + s.renderTemplate(w, "login.html", map[string]interface{}{ + "Error": msg, + }) + return + } + + web.SetSessionCookie(w, sessionCookieName, token) + http.Redirect(w, r, "/dashboard", http.StatusFound) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + token := web.GetSessionToken(r, sessionCookieName) + if token != "" { + if err := s.auth.Logout(token); err != nil { + s.logger.Warn("logout failed", "error", err) + } + } + web.ClearSessionCookie(w, sessionCookieName) + http.Redirect(w, r, "/login", http.StatusFound) +} + +func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { + info := auth.TokenInfoFromContext(r.Context()) + s.renderTemplate(w, "dashboard.html", map[string]interface{}{ + "Username": info.Username, + "Roles": info.Roles, + "IsAdmin": info.IsAdmin, + "ServiceName": s.wsCfg.ServiceName, + "Tags": s.wsCfg.Tags, + }) +} + +// requireAuth wraps a handler that requires a valid MCIAS session. +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token := web.GetSessionToken(r, sessionCookieName) + if token == "" { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + info, err := s.auth.ValidateToken(token) + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + ctx := auth.ContextWithTokenInfo(r.Context(), info) + next(w, r.WithContext(ctx)) + } +} + +func (s *Server) renderTemplate(w http.ResponseWriter, name string, data interface{}) { + web.RenderTemplate(w, mcatweb.FS, name, data, s.csrf.TemplateFunc(w)) +} + +func (s *Server) loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sw := &httpserver.StatusWriter{ResponseWriter: w, Status: http.StatusOK} + next.ServeHTTP(sw, r) + s.logger.Info("http", + "method", r.Method, + "path", r.URL.Path, + "status", sw.Status, + "remote", r.RemoteAddr, + ) + }) +} diff --git a/mcat.toml b/mcat.toml new file mode 100644 index 0000000..d648af1 --- /dev/null +++ b/mcat.toml @@ -0,0 +1,13 @@ +[server] +listen_addr = ":7443" +tls_cert = "/Users/kyle/tmp/ca/mc-localhost/cert.pem" +tls_key = "/Users/kyle/tmp/ca/mc-localhost/key.pem" + +[mcias] +server_url = "https://mcias.metacircular.net:8443" +ca_cert = "/Users/kyle/tmp/ca/ca.pem" +service_name = "mcat" +tags = ["env:restricted"] + +[log] +level = "info" diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..be1d123 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,6 @@ +package web + +import "embed" + +//go:embed templates static +var FS embed.FS diff --git a/web/static/htmx.min.js b/web/static/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/web/static/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..6b54ab8 --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,270 @@ +/* mcat — Nord dark theme */ + +/* =========================== + Colour tokens (Nord palette) + =========================== */ +:root { + --n0: #2E3440; + --n1: #3B4252; + --n2: #434C5E; + --n3: #4C566A; + --s0: #D8DEE9; + --s1: #E5E9F0; + --s2: #ECEFF4; + --f0: #8FBCBB; + --f1: #88C0D0; + --f2: #81A1C1; + --f3: #5E81AC; + --red: #BF616A; + --green: #A3BE8C; +} + +/* =========================== + Reset + =========================== */ +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } +html { font-size: 16px; } + +/* =========================== + Base + =========================== */ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + background: var(--n0); + color: var(--s0); + line-height: 1.6; + min-height: 100vh; +} +a { color: var(--f1); text-decoration: none; } +a:hover { color: var(--f0); text-decoration: underline; } +p { margin-bottom: 0.875rem; } +h2 { font-size: 1.375rem; font-weight: 600; color: var(--s2); margin-bottom: 0.25rem; } +code { + font-family: "SF Mono", "Cascadia Code", "Fira Code", Consolas, monospace; + font-size: 0.8125rem; + color: var(--f0); + background: var(--n2); + padding: 0.125rem 0.375rem; + border-radius: 3px; +} + +/* =========================== + Top navigation + =========================== */ +.topnav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 2rem; + height: 52px; + background: var(--n1); + border-bottom: 1px solid var(--n3); + position: sticky; + top: 0; + z-index: 100; +} +.topnav-brand { + font-size: 1rem; + font-weight: 700; + color: var(--s2); + text-decoration: none; + letter-spacing: 0.04em; +} +.topnav-brand:hover { color: var(--f1); text-decoration: none; } +.topnav-right { + display: flex; + align-items: center; + gap: 0.75rem; +} +.topnav-user { + font-size: 0.875rem; + color: var(--s1); +} + +/* =========================== + Page containers + =========================== */ +.page-container { + max-width: 800px; + margin: 0 auto; + padding: 2rem; +} +.auth-container { + max-width: 420px; + margin: 5rem auto 2rem; + padding: 0 1rem; +} + +/* =========================== + Auth pages + =========================== */ +.auth-header { + text-align: center; + margin-bottom: 1.75rem; +} +.auth-header .brand { + font-size: 1.5rem; + font-weight: 700; + color: var(--s2); + letter-spacing: 0.04em; +} +.auth-header .tagline { + font-size: 0.6875rem; + color: var(--f2); + text-transform: uppercase; + letter-spacing: 0.12em; + margin-top: 0.25rem; +} + +/* =========================== + Cards + =========================== */ +.card { + background: var(--n1); + border: 1px solid var(--n3); + border-radius: 6px; + padding: 1.5rem; + margin-bottom: 1.25rem; +} +.card:last-child { margin-bottom: 0; } +.card-title { + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.09em; + color: var(--f2); + margin-bottom: 1rem; + padding-bottom: 0.625rem; + border-bottom: 1px solid var(--n2); +} +.card p:last-child { margin-bottom: 0; } + +/* =========================== + Alerts + =========================== */ +.error { + background: rgba(191, 97, 106, 0.12); + color: #e07c82; + border: 1px solid rgba(191, 97, 106, 0.3); + padding: 0.75rem 1rem; + border-radius: 4px; + margin-bottom: 1rem; + font-size: 0.875rem; +} +.success { + background: rgba(163, 190, 140, 0.1); + border: 1px solid rgba(163, 190, 140, 0.3); + border-radius: 4px; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + font-size: 0.875rem; + color: var(--green); +} + +/* =========================== + Buttons + =========================== */ +button, .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + padding: 0.5rem 1.25rem; + font-size: 0.875rem; + font-weight: 600; + font-family: inherit; + border: 1px solid var(--f3); + border-radius: 4px; + cursor: pointer; + text-decoration: none; + white-space: nowrap; + transition: background 0.12s, border-color 0.12s, color 0.12s; + line-height: 1.4; + background: var(--f3); + color: var(--s2); +} +button:hover, .btn:hover { + background: var(--f2); + border-color: var(--f2); + text-decoration: none; + color: var(--s2); +} +.btn-ghost { + background: transparent; + color: var(--s0); + border-color: var(--n3); +} +.btn-ghost:hover { + background: var(--n2); + color: var(--s1); + border-color: var(--n3); + text-decoration: none; +} + +/* =========================== + Forms + =========================== */ +.form-group { margin-bottom: 1rem; } +.form-group label { + display: block; + font-size: 0.6875rem; + font-weight: 700; + color: var(--s0); + margin-bottom: 0.375rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.form-group input { + width: 100%; + padding: 0.5rem 0.75rem; + background: var(--n0); + border: 1px solid var(--n3); + border-radius: 4px; + color: var(--s1); + font-size: 0.9375rem; + font-family: inherit; + transition: border-color 0.12s, box-shadow 0.12s; + -webkit-appearance: none; + appearance: none; +} +.form-group input:focus { + outline: none; + border-color: var(--f3); + box-shadow: 0 0 0 3px rgba(94, 129, 172, 0.2); +} +.form-group input::placeholder { color: var(--n3); } +.form-actions { margin-top: 0.25rem; } + +/* =========================== + Session info + =========================== */ +.session-info { + font-size: 0.875rem; + color: var(--s0); +} +.session-info dt { + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--f2); + margin-bottom: 0.25rem; +} +.session-info dd { + margin-bottom: 0.75rem; + margin-left: 0; +} +.session-info dd:last-child { margin-bottom: 0; } +.role-tag { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 3px; + font-size: 0.6875rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + background: rgba(94, 129, 172, 0.2); + color: var(--f1); + border: 1px solid rgba(94, 129, 172, 0.35); + margin-right: 0.25rem; +} diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html new file mode 100644 index 0000000..b92da0b --- /dev/null +++ b/web/templates/dashboard.html @@ -0,0 +1,19 @@ +{{define "title"}} - Dashboard{{end}} +{{define "content"}} +
+
Session
+
Login successful. MCIAS accepted this service context.
+
+
Username
+
{{.Username}}
+
Roles
+
{{range .Roles}}{{.}}{{end}}
+
Service Name
+
{{.ServiceName}}
+ {{if .Tags}} +
Tags
+
{{range .Tags}}{{.}} {{end}}
+ {{end}} +
+
+{{end}} diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000..a588d06 --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,27 @@ +{{define "layout"}} + + + + + mcat{{block "title" .}}{{end}} + + + + + +
+ {{template "content" .}} +
+ +{{end}} diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..ad3c2d7 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,30 @@ +{{define "title"}} - Login{{end}} +{{define "container-class"}}auth-container{{end}} +{{define "content"}} +
+
mcat
+
MCIAS Login Policy Tester
+
+
+
Sign In
+ {{if .Error}}
{{.Error}}
{{end}} +
+ {{csrfField}} +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+{{end}}