From 1ba3dece4fe8e60d1807907fbf8f449b4990f976 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Sun, 21 Jun 2026 19:21:17 -0400 Subject: [PATCH] Refactor into multiple modules --- .dockerignore | 3 +- .env.example | 34 ++ .gitignore | 8 +- cmd/meshtender/main.go | 26 +- internal/{web => auth}/account.go | 71 +-- internal/auth/handlers.go | 27 +- internal/auth/handoff.go | 216 +++++++++ internal/auth/password_handlers.go | 4 +- internal/auth/service.go | 40 +- internal/auth/templates/account.html | 123 +++++ internal/{web => auth}/templates/login.html | 0 internal/{web => auth}/templates/signup.html | 0 internal/auth/web.go | 128 ++++++ internal/config/config.go | 73 ++- internal/{web => core}/admin.go | 41 +- internal/{web => core}/audit.go | 8 +- internal/{web => core}/confirm.go | 18 +- .../{web => core}/confirm_integration_test.go | 15 +- .../{web => core}/confirm_location_test.go | 7 +- internal/{web => core}/confirm_retry_test.go | 7 +- internal/{web => core}/console.go | 34 +- .../{web => core}/console_integration_test.go | 13 +- internal/{web => core}/contribute.go | 44 +- internal/core/handoff_test.go | 412 +++++++++++++++++ internal/{web => core}/org_domains.go | 18 +- internal/{web => core}/org_domains_test.go | 12 +- internal/{web => core}/org_permissions.go | 20 +- internal/core/orgs.go | 257 +++++++++++ internal/{web => core}/presets.go | 2 +- internal/{web => core}/render_test.go | 12 +- internal/{web => core}/repeaters.go | 71 +-- internal/{web => core}/shares.go | 99 ++-- .../{web => core}/templates/add_repeater.html | 0 internal/{web => core}/templates/admin.html | 0 .../templates/admin_catalog.html | 0 .../{web => core}/templates/admin_users.html | 0 .../{web => core}/templates/command_log.html | 0 internal/{web => core}/templates/confirm.html | 0 internal/{web => core}/templates/console.html | 0 .../{web => core}/templates/contribute.html | 0 .../{web => core}/templates/dashboard.html | 0 .../templates/delete_repeater.html | 0 .../templates/edit_repeater.html | 0 internal/{web => core}/templates/invite.html | 0 internal/core/templates/my_orgs.html | 39 ++ internal/{web => core}/templates/new_org.html | 0 internal/{web => core}/templates/org.html | 0 .../templates/org_permissions.html | 0 .../{web => core}/templates/repeater.html | 0 .../templates/repeater_added.html | 0 .../{web => core}/templates/repeaters.html | 0 internal/{web => core}/templates/share.html | 0 .../templates/share_commands.html | 0 internal/core/web.go | 301 ++++++++++++ internal/marketing/marketing.go | 99 ++++ internal/marketing/orgs.go | 155 +++++++ .../{web => marketing}/orgs_cursor_test.go | 2 +- .../{web => marketing}/templates/landing.html | 6 +- .../templates/org_public.html | 2 +- .../{web => marketing}/templates/orgs.html | 2 +- internal/store/auth_codes.go | 51 +++ internal/store/auth_codes_test.go | 50 ++ internal/store/migrations/0015_auth_codes.sql | 15 + internal/web/env.go | 239 ++++++++++ internal/web/orgs.go | 388 ---------------- internal/web/ratelimit.go | 4 +- internal/web/ratelimit_test.go | 4 +- internal/web/static/webauthn.js | 2 +- internal/web/templates/account.html | 115 ----- internal/web/templates/base.html | 20 +- internal/web/web.go | 430 ------------------ 71 files changed, 2533 insertions(+), 1234 deletions(-) rename internal/{web => auth}/account.go (58%) create mode 100644 internal/auth/handoff.go create mode 100644 internal/auth/templates/account.html rename internal/{web => auth}/templates/login.html (100%) rename internal/{web => auth}/templates/signup.html (100%) create mode 100644 internal/auth/web.go rename internal/{web => core}/admin.go (74%) rename internal/{web => core}/audit.go (88%) rename internal/{web => core}/confirm.go (93%) rename internal/{web => core}/confirm_integration_test.go (95%) rename internal/{web => core}/confirm_location_test.go (96%) rename internal/{web => core}/confirm_retry_test.go (95%) rename internal/{web => core}/console.go (87%) rename internal/{web => core}/console_integration_test.go (95%) rename internal/{web => core}/contribute.go (73%) create mode 100644 internal/core/handoff_test.go rename internal/{web => core}/org_domains.go (83%) rename internal/{web => core}/org_domains_test.go (87%) rename internal/{web => core}/org_permissions.go (77%) create mode 100644 internal/core/orgs.go rename internal/{web => core}/presets.go (99%) rename internal/{web => core}/render_test.go (84%) rename internal/{web => core}/repeaters.go (73%) rename internal/{web => core}/shares.go (73%) rename internal/{web => core}/templates/add_repeater.html (100%) rename internal/{web => core}/templates/admin.html (100%) rename internal/{web => core}/templates/admin_catalog.html (100%) rename internal/{web => core}/templates/admin_users.html (100%) rename internal/{web => core}/templates/command_log.html (100%) rename internal/{web => core}/templates/confirm.html (100%) rename internal/{web => core}/templates/console.html (100%) rename internal/{web => core}/templates/contribute.html (100%) rename internal/{web => core}/templates/dashboard.html (100%) rename internal/{web => core}/templates/delete_repeater.html (100%) rename internal/{web => core}/templates/edit_repeater.html (100%) rename internal/{web => core}/templates/invite.html (100%) create mode 100644 internal/core/templates/my_orgs.html rename internal/{web => core}/templates/new_org.html (100%) rename internal/{web => core}/templates/org.html (100%) rename internal/{web => core}/templates/org_permissions.html (100%) rename internal/{web => core}/templates/repeater.html (100%) rename internal/{web => core}/templates/repeater_added.html (100%) rename internal/{web => core}/templates/repeaters.html (100%) rename internal/{web => core}/templates/share.html (100%) rename internal/{web => core}/templates/share_commands.html (100%) create mode 100644 internal/core/web.go create mode 100644 internal/marketing/marketing.go create mode 100644 internal/marketing/orgs.go rename internal/{web => marketing}/orgs_cursor_test.go (98%) rename internal/{web => marketing}/templates/landing.html (94%) rename internal/{web => marketing}/templates/org_public.html (98%) rename internal/{web => marketing}/templates/orgs.html (97%) create mode 100644 internal/store/auth_codes.go create mode 100644 internal/store/auth_codes_test.go create mode 100644 internal/store/migrations/0015_auth_codes.sql create mode 100644 internal/web/env.go delete mode 100644 internal/web/orgs.go delete mode 100644 internal/web/templates/account.html delete mode 100644 internal/web/web.go diff --git a/.dockerignore b/.dockerignore index f2c081b..583dd9f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,5 +8,6 @@ docker-compose.yml README.md -/meshtender /cmd/meshtender/meshtender +/meshtender +/certs diff --git a/.env.example b/.env.example index 1f151f5..0cd8009 100644 --- a/.env.example +++ b/.env.example @@ -8,10 +8,44 @@ MESHTENDER_DATABASE_URL=postgres://meshtender:meshtender@localhost:5432/meshtend MESHTENDER_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000 # WebAuthn relying-party settings. RP_ID is the bare domain (no scheme/port). +# Set it to the ROOT registrable domain (e.g. meshtender.com) so passkeys stay +# valid across every subdomain — this is permanent, so don't pin it to a +# subdomain. RP_ORIGIN is a comma-separated list of every origin a ceremony may +# run from (the auth and app hosts). MESHTENDER_RP_ID=localhost MESHTENDER_RP_NAME=MeshTender MESHTENDER_RP_ORIGIN=http://localhost:8080 +# Optional split-host topology, all served by one binary on one port (browsers +# route *.localhost to loopback). Roles: +# ROOT_HOST — public marketing + organization discovery (no session). +# WWW_HOST — redirects to ROOT_HOST (defaults to "www." + ROOT_HOST). +# AUTH_HOST — login/signup + WebAuthn ceremonies; hands off to the app host. +# PRIMARY_HOST — the product/app host (dashboard at /, authenticated area). +# Leave AUTH_HOST empty for single-host mode (everything on PRIMARY_HOST). +# +# NOTE: don't use *.localhost for the split — "localhost" is a public suffix, so +# browsers reject RP ID "localhost" from a subdomain and passkeys won't work. Use +# a real registrable dev domain with its subdomains pointed at 127.0.0.1, so RP +# ID can be the registrable parent. Example (leighthaus.dev): +# MESHTENDER_RP_ID=leighthaus.dev +# MESHTENDER_RP_ORIGIN=https://auth.leighthaus.dev:8080,https://app.leighthaus.dev:8080 +# MESHTENDER_ROOT_HOST=leighthaus.dev +# MESHTENDER_AUTH_HOST=auth.leighthaus.dev +# MESHTENDER_PRIMARY_HOST=app.leighthaus.dev +# MESHTENDER_ROOT_HOST= +# MESHTENDER_WWW_HOST= +# MESHTENDER_PRIMARY_HOST= +# MESHTENDER_AUTH_HOST= +# +# HSTS-preloaded TLDs (.dev, .app, …) force HTTPS, so plain-HTTP dev won't load. +# Serve TLS in-process with a locally-trusted mkcert cert (origins above are +# https for this reason): +# brew install mkcert && mkcert -install +# mkcert -cert-file ./certs/dev.pem -key-file ./certs/dev-key.pem "*.leighthaus.dev" leighthaus.dev +# MESHTENDER_TLS_CERT=./certs/dev.pem +# MESHTENDER_TLS_KEY=./certs/dev-key.pem + # Default LoRa radio params suggested when adding a repeater (EU 869.525 / 250k / SF11 / CR5). MESHTENDER_RADIO_FREQ_HZ=869525000 MESHTENDER_RADIO_BW_HZ=250000 diff --git a/.gitignore b/.gitignore index 35f50ae..3eced6a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ -/meshtender -/cmd/meshtender/meshtender -*.exe .env +/certs/ +/cmd/meshtender/meshtender +/meshtender + +*.exe diff --git a/cmd/meshtender/main.go b/cmd/meshtender/main.go index 22aec98..18e75ff 100644 --- a/cmd/meshtender/main.go +++ b/cmd/meshtender/main.go @@ -8,15 +8,14 @@ import ( "net/http" "os" "os/signal" - "strings" "syscall" "time" "github.com/jleight/meshtender/internal/auth" "github.com/jleight/meshtender/internal/config" + "github.com/jleight/meshtender/internal/core" "github.com/jleight/meshtender/internal/identity" "github.com/jleight/meshtender/internal/store" - "github.com/jleight/meshtender/internal/web" ) func main() { @@ -53,23 +52,19 @@ func run(logger *slog.Logger) error { } logger.Info("server identity ready", "pubkey", idSvc.PublicKeyHex()) - secure := false - for _, o := range cfg.RPOrigins { - if strings.HasPrefix(o, "https://") { - secure = true - } - } authSvc, err := auth.New(st, st.Pool(), auth.Config{ RPID: cfg.RPID, RPDisplayName: cfg.RPDisplayName, RPOrigins: cfg.RPOrigins, - Secure: secure, + AppHost: cfg.PrimaryHost, + AuthHost: cfg.AuthHost, + Secure: cfg.Secure, }) if err != nil { return err } - srv, err := web.NewServer(st, authSvc, idSvc, cfg) + srv, err := core.NewServer(st, authSvc, idSvc, cfg) if err != nil { return err } @@ -80,10 +75,17 @@ func run(logger *slog.Logger) error { ReadHeaderTimeout: 10 * time.Second, } + tls := cfg.TLSCert != "" && cfg.TLSKey != "" errCh := make(chan error, 1) go func() { - logger.Info("listening", "addr", cfg.Addr) - if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Info("listening", "addr", cfg.Addr, "tls", tls) + var err error + if tls { + err = httpSrv.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey) + } else { + err = httpSrv.ListenAndServe() + } + if err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err } }() diff --git a/internal/web/account.go b/internal/auth/account.go similarity index 58% rename from internal/web/account.go rename to internal/auth/account.go index 402b8c3..893b200 100644 --- a/internal/web/account.go +++ b/internal/auth/account.go @@ -1,4 +1,4 @@ -package web +package auth import ( "encoding/hex" @@ -6,7 +6,7 @@ import ( "strconv" "time" - "github.com/jleight/meshtender/internal/auth" + "github.com/jleight/meshtender/internal/web" ) // passkeyView is display metadata for one registered passkey. @@ -17,20 +17,27 @@ type passkeyView struct { } // accountRedirect bounces back to the account page with a flash message under -// the given key ("ok" or "error"). +// the given key ("ok" or "error"), shown in the page-level banner. func accountRedirect(w http.ResponseWriter, r *http.Request, key, msg string) { - redirectFlash(w, r, "/account", key, msg) + web.RedirectFlash(w, r, "/account", key, msg) +} + +// passkeyRedirect bounces back to the account page with a flash shown inside the +// Passkeys card ("pk" for success, "pkerr" for failure), so passkey messages +// stay next to the passkey controls rather than in the top banner. +func passkeyRedirect(w http.ResponseWriter, r *http.Request, key, msg string) { + web.RedirectFlash(w, r, "/account", key, msg) } // pageAccount renders the current user's account-settings page. -func (s *Server) pageAccount(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) - u, err := s.store.GetUserByID(r.Context(), uid) +func (s *Handlers) pageAccount(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + u, err := s.Store.GetUserByID(r.Context(), uid) if err != nil { http.Error(w, "could not load account", http.StatusInternalServerError) return } - creds, err := s.store.ListCredentials(r.Context(), uid) + creds, err := s.Store.ListCredentials(r.Context(), uid) if err != nil { http.Error(w, "could not load passkeys", http.StatusInternalServerError) return @@ -43,21 +50,23 @@ func (s *Server) pageAccount(w http.ResponseWriter, r *http.Request) { } views = append(views, passkeyView{ID: c.ID, ShortID: short, Added: c.CreatedAt}) } - s.render(w, r, "account.html", map[string]any{ + s.Render(w, r, "account.html", map[string]any{ "User": u, "DisplayName": u.DisplayName, // nil when unset "HasPassword": u.PasswordHash != nil, "Passkeys": views, "Error": r.URL.Query().Get("error"), "OK": r.URL.Query().Get("ok"), + "PKMsg": r.URL.Query().Get("pk"), + "PKErr": r.URL.Query().Get("pkerr"), }) } // handleUpdateProfile saves the user's display name. -func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) - displayName := auth.NormalizeDisplayName(r.FormValue("display_name")) - if err := s.store.SetDisplayName(r.Context(), uid, displayName); err != nil { +func (s *Handlers) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + displayName := NormalizeDisplayName(r.FormValue("display_name")) + if err := s.Store.SetDisplayName(r.Context(), uid, displayName); err != nil { accountRedirect(w, r, "error", "Could not save your profile.") return } @@ -65,10 +74,10 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { } // handleChangePassword sets, changes, or removes the user's password. -func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleChangePassword(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - uid := s.auth.CurrentUserID(ctx) - u, err := s.store.GetUserByID(ctx, uid) + uid := s.Auth.CurrentUserID(ctx) + u, err := s.Store.GetUserByID(ctx, uid) if err != nil { accountRedirect(w, r, "error", "Could not load account.") return @@ -79,7 +88,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { accountRedirect(w, r, "error", "No password to remove.") return } - n, err := s.store.CountCredentials(ctx, uid) + n, err := s.Store.CountCredentials(ctx, uid) if err != nil { accountRedirect(w, r, "error", "Could not load passkeys.") return @@ -88,7 +97,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { accountRedirect(w, r, "error", "Add a passkey before removing your password — it's your only way to sign in.") return } - if err := s.store.ClearPassword(ctx, uid); err != nil { + if err := s.Store.ClearPassword(ctx, uid); err != nil { accountRedirect(w, r, "error", "Could not remove password.") return } @@ -102,11 +111,11 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { return } // When a password already exists, require the current one to change it. - if u.PasswordHash != nil && !s.auth.PasswordMatches(u, r.FormValue("current_password")) { + if u.PasswordHash != nil && !s.Auth.PasswordMatches(u, r.FormValue("current_password")) { accountRedirect(w, r, "error", "Current password is incorrect.") return } - if err := s.auth.SetPassword(ctx, uid, newPassword); err != nil { + if err := s.Auth.SetPassword(ctx, uid, newPassword); err != nil { accountRedirect(w, r, "error", "Could not update password.") return } @@ -115,32 +124,32 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { // handleDeletePasskey removes one of the user's passkeys, refusing to remove // the last sign-in method. -func (s *Server) handleDeletePasskey(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleDeletePasskey(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - uid := s.auth.CurrentUserID(ctx) + uid := s.Auth.CurrentUserID(ctx) credID, err := strconv.ParseInt(r.FormValue("credential_id"), 10, 64) if err != nil { - accountRedirect(w, r, "error", "Invalid passkey.") + passkeyRedirect(w, r, "pkerr", "Invalid passkey.") return } - u, err := s.store.GetUserByID(ctx, uid) + u, err := s.Store.GetUserByID(ctx, uid) if err != nil { - accountRedirect(w, r, "error", "Could not load account.") + passkeyRedirect(w, r, "pkerr", "Could not load account.") return } - n, err := s.store.CountCredentials(ctx, uid) + n, err := s.Store.CountCredentials(ctx, uid) if err != nil { - accountRedirect(w, r, "error", "Could not load passkeys.") + passkeyRedirect(w, r, "pkerr", "Could not load passkeys.") return } // After removal the user must retain at least one way to sign in. if n-1 < 1 && u.PasswordHash == nil { - accountRedirect(w, r, "error", "You can't remove your only sign-in method. Add another passkey or set a password first.") + passkeyRedirect(w, r, "pkerr", "You can't remove your only sign-in method. Add another passkey or set a password first.") return } - if err := s.store.DeleteCredential(ctx, uid, credID); err != nil { - accountRedirect(w, r, "error", "Could not remove that passkey.") + if err := s.Store.DeleteCredential(ctx, uid, credID); err != nil { + passkeyRedirect(w, r, "pkerr", "Could not remove that passkey.") return } - accountRedirect(w, r, "ok", "Passkey removed.") + passkeyRedirect(w, r, "pk", "Passkey removed.") } diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index b90d02a..e656b1a 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -14,10 +14,29 @@ import ( "github.com/jleight/meshtender/internal/store" ) -// RequireUser is middleware that redirects unauthenticated requests to /login. +// RequireUser is middleware that sends unauthenticated requests to the sign-in +// page — local in single-host mode, or the auth host (with a handoff back) when +// auth lives on a dedicated host. func (s *Service) RequireUser(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.CurrentUserID(r.Context()) == 0 { + s.StartLogin(w, r, r.URL.RequestURI()) + return + } + next.ServeHTTP(w, r) + }) +} + +// RequireSSO guards auth-host-local pages (e.g. account settings) against the +// auth host's own SSO session. Unlike RequireUser it does NOT hand off to the +// app afterward: it flags the login as auth-local so PostAuthRedirect returns to +// the requested path on the auth host once the SSO session exists. +func (s *Service) RequireSSO(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if s.CurrentUserID(ctx) == 0 { + s.Sessions.Put(ctx, sessKeyAuthLocal, true) + s.SetNext(ctx, r.URL.RequestURI()) http.Redirect(w, r, "/login", http.StatusSeeOther) return } @@ -118,7 +137,7 @@ func (s *Service) RegisterFinish(w http.ResponseWriter, r *http.Request) { httpError(w, http.StatusInternalServerError, "login") return } - writeJSON(w, map[string]any{"ok": true, "redirect": s.PopNext(ctx)}) + writeJSON(w, map[string]any{"ok": true, "redirect": s.PostAuthRedirect(r, u.ID)}) } // --- Passkey login --- @@ -194,7 +213,7 @@ func (s *Service) LoginFinish(w http.ResponseWriter, r *http.Request) { httpError(w, http.StatusInternalServerError, "login") return } - writeJSON(w, map[string]any{"ok": true, "redirect": s.PopNext(ctx)}) + writeJSON(w, map[string]any{"ok": true, "redirect": s.PostAuthRedirect(r, u.ID)}) } // --- usernameless (discoverable) passkey login --- @@ -266,7 +285,7 @@ func (s *Service) LoginDiscoverableFinish(w http.ResponseWriter, r *http.Request httpError(w, http.StatusInternalServerError, "login") return } - writeJSON(w, map[string]any{"ok": true, "redirect": s.PopNext(ctx)}) + writeJSON(w, map[string]any{"ok": true, "redirect": s.PostAuthRedirect(r, resolved.ID)}) } // --- ceremony state helpers --- diff --git a/internal/auth/handoff.go b/internal/auth/handoff.go new file mode 100644 index 0000000..f7983df --- /dev/null +++ b/internal/auth/handoff.go @@ -0,0 +1,216 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "net" + "net/http" + "net/url" + "time" +) + +// The cross-host sign-in handoff. When AuthHost is configured, all credential +// ceremonies run on the auth host; on success it mints a single-use code (see +// store.CreateAuthCode) and redirects the browser to the app host's +// SessionCallback, which redeems the code and establishes the app host's own +// host-scoped session. A CSRF "state" nonce, set as a host-only cookie on the +// app host before the bounce and echoed back through the auth host, ties the +// returning callback to the request that started it (blocks login-CSRF). + +const ( + sessKeyState = "auth_state" // string: CSRF nonce echoed through the auth host + sessKeyAuthLocal = "auth_local" // bool: this login should return to an auth-host page, not hand off + stateCookie = "mt_state" // base name; __Host- prefixed over HTTPS + stateMaxAge = 10 * time.Minute // window to complete a sign-in + maxStateLen = 256 // bound stored/echoed state length +) + +// SplitHost returns true when this Service runs the auth front door on a +// separate host from the app (cross-host handoff mode). +func (s *Service) SplitHost() bool { return s.authHost != "" } + +// scheme is the URL scheme matching the cookie Secure setting. +func (s *Service) scheme() string { + if s.secure { + return "https" + } + return "http" +} + +// portSuffix returns the ":port" from the request host, or "" for default +// ports. The auth and app hosts share one listener, so the app callback reuses +// whatever port the auth request arrived on (covers :8090 dev, bare 443 prod). +func portSuffix(r *http.Request) string { + if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" { + return ":" + port + } + return "" +} + +func (s *Service) appOrigin(r *http.Request) string { + return s.scheme() + "://" + s.appHost + portSuffix(r) +} + +func (s *Service) authOrigin(r *http.Request) string { + return s.scheme() + "://" + s.authHost + portSuffix(r) +} + +// SetAuthState stashes the CSRF nonce carried into the auth host so a finisher +// can echo it back to the app callback. Bounded and dropped if empty. +func (s *Service) SetAuthState(ctx context.Context, state string) { + if state == "" || len(state) > maxStateLen { + return + } + s.Sessions.Put(ctx, sessKeyState, state) + // A state nonce means this is a cross-host handoff login, not an auth-local + // one — clear any stale auth-local flag so the handoff isn't suppressed. + s.Sessions.Remove(ctx, sessKeyAuthLocal) +} + +func (s *Service) popAuthState(ctx context.Context) string { + state, _ := s.Sessions.Get(ctx, sessKeyState).(string) + s.Sessions.Remove(ctx, sessKeyState) + return state +} + +// PostAuthRedirect is the destination after a successful sign-in. In single-host +// mode it's the stored post-auth path. In split-host mode (on the auth host) it +// mints a handoff code and points at the app host's callback — UNLESS the login +// was initiated for an auth-host-local page (e.g. account settings), in which +// case it returns that local path with no handoff. The caller must already have +// run login() for the auth host's own (SSO) session. +func (s *Service) PostAuthRedirect(r *http.Request, userID int64) string { + ctx := r.Context() + next := s.PopNext(ctx) + // Auth-host-local return (account settings et al.): SSO session is enough, + // stay on the auth host rather than handing off to the app. + if s.Sessions.PopBool(ctx, sessKeyAuthLocal) { + return next + } + if !s.SplitHost() || !s.onAuthHost(r) { + return next + } + code, err := s.store.CreateAuthCode(ctx, userID, next) + if err != nil { + // Couldn't mint a code; fall back to the app root rather than stranding + // the user. They're authenticated on the auth host either way. + return s.appOrigin(r) + "/" + } + q := url.Values{"code": {code}} + if state := s.popAuthState(ctx); state != "" { + q.Set("state", state) + } + return s.appOrigin(r) + "/session/callback?" + q.Encode() +} + +func (s *Service) onAuthHost(r *http.Request) bool { + return hostOnly(r.Host) == s.authHost +} + +// hostOnly strips a trailing :port from a request Host. +func hostOnly(host string) string { + if h, _, err := net.SplitHostPort(host); err == nil { + return h + } + return host +} + +// StartLogin sends an unauthenticated visitor to the sign-in page (see +// startAuth). next is preserved so they land back where they wanted. +func (s *Service) StartLogin(w http.ResponseWriter, r *http.Request, next string) { + s.startAuth(w, r, next, "/login") +} + +// StartSignup is StartLogin's sibling for account creation: it routes to the +// auth host's sign-up page. Used by the app host's /signup entry and marketing +// "create account" CTAs so signup still flows through the state-protected +// handoff. +func (s *Service) StartSignup(w http.ResponseWriter, r *http.Request, next string) { + s.startAuth(w, r, next, "/signup") +} + +// startAuth begins a sign-in/sign-up. In split-host mode it redirects to the +// auth host's page, first dropping a host-only state cookie on the app host that +// the returning callback must match — which is why auth entry must always go +// through the app host, never a direct link to the auth host. In single-host +// mode it just redirects to the local page. +func (s *Service) startAuth(w http.ResponseWriter, r *http.Request, next, page string) { + if !SafeLocalPath(next) { + next = "/" + } + if !s.SplitHost() { + http.Redirect(w, r, page+"?next="+url.QueryEscape(next), http.StatusSeeOther) + return + } + state, err := randomState() + if err != nil { + http.Error(w, "could not start sign-in", http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: cookieName(stateCookie, s.secure), + Value: state, + Path: "/", + MaxAge: int(stateMaxAge.Seconds()), + HttpOnly: true, + Secure: s.secure, + SameSite: http.SameSiteLaxMode, + }) + q := url.Values{"next": {next}, "state": {state}} + http.Redirect(w, r, s.authOrigin(r)+page+"?"+q.Encode(), http.StatusSeeOther) +} + +// SessionCallback runs on the app host. It verifies the state nonce against the +// host-only cookie, redeems the single-use handoff code, establishes the app +// host's session, and redirects to the stored post-auth path. +func (s *Service) SessionCallback(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + clearStateCookie(w, s.secure) + + state := r.URL.Query().Get("state") + c, err := r.Cookie(cookieName(stateCookie, s.secure)) + if err != nil || state == "" || subtle.ConstantTimeCompare([]byte(state), []byte(c.Value)) != 1 { + s.StartLogin(w, r, "/") + return + } + + userID, next, ok, err := s.store.ConsumeAuthCode(ctx, r.URL.Query().Get("code")) + if err != nil { + http.Error(w, "sign-in failed", http.StatusInternalServerError) + return + } + if !ok { + s.StartLogin(w, r, "/") + return + } + if err := s.login(ctx, userID); err != nil { + http.Error(w, "could not start session", http.StatusInternalServerError) + return + } + if !SafeLocalPath(next) { + next = "/" + } + http.Redirect(w, r, next, http.StatusSeeOther) +} + +func clearStateCookie(w http.ResponseWriter, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName(stateCookie, secure), + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func randomState() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/auth/password_handlers.go b/internal/auth/password_handlers.go index 5cdd726..21f288f 100644 --- a/internal/auth/password_handlers.go +++ b/internal/auth/password_handlers.go @@ -36,7 +36,7 @@ func (s *Service) SignupPassword(w http.ResponseWriter, r *http.Request) { redirectErr(w, r, "/signup", "Could not start session.") return } - http.Redirect(w, r, s.PopNext(ctx), http.StatusSeeOther) + http.Redirect(w, r, s.PostAuthRedirect(r, u.ID), http.StatusSeeOther) } // LoginPassword handles a form-based password sign-in. @@ -53,7 +53,7 @@ func (s *Service) LoginPassword(w http.ResponseWriter, r *http.Request) { redirectErr(w, r, "/login", "Could not start session.") return } - http.Redirect(w, r, s.PopNext(r.Context()), http.StatusSeeOther) + http.Redirect(w, r, s.PostAuthRedirect(r, u.ID), http.StatusSeeOther) } func redirectErr(w http.ResponseWriter, r *http.Request, path, msg string) { diff --git a/internal/auth/service.go b/internal/auth/service.go index efb95b6..6862959 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -27,6 +27,13 @@ type Service struct { wa *webauthn.WebAuthn store *store.Store Sessions *scs.SessionManager + + // Host split: when authHost is set, sign-in happens on a dedicated host and + // hands off to appHost via a single-use code. Empty authHost ⇒ single-host + // mode (auth served from appHost, no cross-host handoff). + appHost string + authHost string + secure bool } // Config configures the auth service. @@ -34,7 +41,12 @@ type Config struct { RPID string RPDisplayName string RPOrigins []string - // Secure marks the session cookie Secure (set false for plain-HTTP localhost dev). + // AppHost serves the product; AuthHost (optional) serves the login UI and + // runs ceremonies, handing off to AppHost. Both are bare hostnames (no + // scheme/port). Empty AuthHost selects single-host mode. + AppHost string + AuthHost string + // Secure marks cookies Secure (set false for plain-HTTP localhost dev). Secure bool } @@ -53,12 +65,34 @@ func New(st *store.Store, pool *pgxpool.Pool, cfg Config) (*Service, error) { sm := scs.New() sm.Store = pgxstore.New(pool) sm.Lifetime = 7 * 24 * time.Hour - sm.Cookie.Name = "meshtender_session" + // Host-only cookie: no Domain attribute, so the auth and app hosts each get + // their own independent session — the browser never sends one host's cookie + // to the other. Over HTTPS we use the __Host- prefix, which the browser + // enforces as host-only + Secure + Path=/ (and blocks subdomain injection). + // The prefix is illegal over plain HTTP, so dev falls back to a bare name. + sm.Cookie.Name = cookieName("meshtender_session", cfg.Secure) sm.Cookie.HttpOnly = true sm.Cookie.SameSite = http.SameSiteLaxMode sm.Cookie.Secure = cfg.Secure + sm.Cookie.Path = "/" - return &Service{wa: wa, store: st, Sessions: sm}, nil + return &Service{ + wa: wa, + store: st, + Sessions: sm, + appHost: cfg.AppHost, + authHost: cfg.AuthHost, + secure: cfg.Secure, + }, nil +} + +// cookieName applies the __Host- prefix over HTTPS (where browsers enforce it) +// and a bare name over plain-HTTP dev (where the prefix is rejected). +func cookieName(base string, secure bool) string { + if secure { + return "__Host-" + base + } + return base } // CurrentUserID returns the authenticated user id from the session, or 0. diff --git a/internal/auth/templates/account.html b/internal/auth/templates/account.html new file mode 100644 index 0000000..d2c6510 --- /dev/null +++ b/internal/auth/templates/account.html @@ -0,0 +1,123 @@ +{{define "title"}}Account settings · MeshTender{{end}} +{{define "header"}} +
+
+
Your account
+

Account settings

+
+
+{{end}} +{{define "content"}} +{{if .Error}}
{{.Error}}
{{end}} +{{if .OK}}
{{.OK}}
{{end}} + +
+
+ +
+
+

Settings

+ +
+
+ + +
+
+ +

Profile

+

Your username can't be changed. Your display name is shown to people you share with.

+
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + +

Passkeys

+

Passkeys let you sign in with your device's fingerprint, face, or PIN — no password to remember or leak.

+ {{if .PKErr}}
{{.PKErr}}
{{end}} + {{if .PKMsg}}
{{.PKMsg}}
{{end}} + {{if .Passkeys}} +
+ {{range .Passkeys}} +
+ {{template "icon-key" ""}} +
+
{{.ShortID}}…
+
Added {{.Added.Format "Jan 2, 2006"}}
+
+
+ + +
+
+ {{end}} +
+ {{else}} +

No passkeys yet. Add one for faster, more secure sign-in.

+ {{end}} +
+ +
+

+ + +

Password

+ {{if .HasPassword}} +

Change the password you use as a fallback to passkeys.

+ {{else}} +

You don't have a password set — you sign in with a passkey. You can add one as a fallback.

+ {{end}} +
+
+ {{if .HasPassword}} +
+ + +
+ {{end}} +
+ + +
+
+
+ +
+
+ + {{if .HasPassword}} +
+ + + Only available if you have at least one passkey. +
+ {{end}} +
+
+
+
+ + +{{end}} diff --git a/internal/web/templates/login.html b/internal/auth/templates/login.html similarity index 100% rename from internal/web/templates/login.html rename to internal/auth/templates/login.html diff --git a/internal/web/templates/signup.html b/internal/auth/templates/signup.html similarity index 100% rename from internal/web/templates/signup.html rename to internal/auth/templates/signup.html diff --git a/internal/auth/web.go b/internal/auth/web.go new file mode 100644 index 0000000..dd04109 --- /dev/null +++ b/internal/auth/web.go @@ -0,0 +1,128 @@ +package auth + +import ( + "embed" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/jleight/meshtender/internal/web" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +// Handlers serve the auth host's surface: sign-in/sign-up, the WebAuthn/password +// ceremonies, single logout, and account/credential management. Keeping them on +// the auth host means passkeys and passwords are created on the same origin used +// to sign in. +type Handlers struct { + *web.Env + Auth *Service +} + +// NewWeb builds the auth surface handlers with their own renderer. +func NewWeb(deps web.Deps, svc *Service) (*Handlers, error) { + env, err := web.NewEnv(deps, templatesFS) + if err != nil { + return nil, err + } + return &Handlers{Env: env, Auth: svc}, nil +} + +func (s *Handlers) baseMW(r chi.Router) { + s.CommonMiddleware(r) + r.Use(s.Auth.Sessions.LoadAndSave) +} + +// Routes is the auth host's router. +func (s *Handlers) Routes() chi.Router { + r := chi.NewRouter() + s.baseMW(r) + s.SharedRoutes(r) + s.credentialRoutes(r) + // Single logout: clears the SSO session (chained to from the app host). + r.Get("/logout", s.handleAuthLogout) + + // Account/credential management lives here so credentials are created on the + // same origin used to sign in. Guarded by the SSO session. + r.Group(func(r chi.Router) { + r.Use(s.Auth.RequireSSO) + r.Get("/account", s.pageAccount) + r.Post("/account/profile", s.handleUpdateProfile) + r.Post("/account/password", s.handleChangePassword) + r.Post("/account/passkeys/delete", s.handleDeletePasskey) + }) + + // Bare visits to the auth host go to the sign-in page. + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/login", http.StatusSeeOther) + }) + return r +} + +// credentialRoutes mounts the sign-in/sign-up UI and WebAuthn/password ceremony +// endpoints. +func (s *Handlers) credentialRoutes(r chi.Router) { + r.Get("/login", s.pageLogin) + r.Get("/signup", s.pageSignup) + // Throttle credential submission per client IP to blunt password guessing + // and signup spam. Allows a burst (e.g. fat-fingered retries), then ~1 try + // every 6s; bcrypt's cost is the second line of defense. + authLimit := web.NewRateLimiter(10, 6*time.Second) + r.With(authLimit.Middleware).Post("/login/password", s.Auth.LoginPassword) + r.With(authLimit.Middleware).Post("/signup/password", s.Auth.SignupPassword) + r.Post("/api/register/begin", s.Auth.RegisterBegin) + r.Post("/api/register/finish", s.Auth.RegisterFinish) + r.Post("/api/login/begin", s.Auth.LoginBegin) + r.Post("/api/login/finish", s.Auth.LoginFinish) + r.Post("/api/login/discoverable/begin", s.Auth.LoginDiscoverableBegin) + r.Post("/api/login/discoverable/finish", s.Auth.LoginDiscoverableFinish) +} + +func (s *Handlers) pageLogin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + s.Auth.SetNext(ctx, r.URL.Query().Get("next")) + s.Auth.SetAuthState(ctx, r.URL.Query().Get("state")) + // Already signed in (e.g. an existing auth-host SSO session): skip the + // ceremony and hand straight off, rather than re-prompting. + if uid := s.Auth.CurrentUserID(ctx); uid != 0 { + http.Redirect(w, r, s.Auth.PostAuthRedirect(r, uid), http.StatusSeeOther) + return + } + s.Render(w, r, "login.html", map[string]any{ + "Layout": "authbase", + "Error": r.URL.Query().Get("error"), + "Next": r.URL.Query().Get("next"), + }) +} + +func (s *Handlers) pageSignup(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + s.Auth.SetNext(ctx, r.URL.Query().Get("next")) + s.Auth.SetAuthState(ctx, r.URL.Query().Get("state")) + if uid := s.Auth.CurrentUserID(ctx); uid != 0 { + http.Redirect(w, r, s.Auth.PostAuthRedirect(r, uid), http.StatusSeeOther) + return + } + s.Render(w, r, "signup.html", map[string]any{ + "Layout": "authbase", + "Error": r.URL.Query().Get("error"), + "Next": r.URL.Query().Get("next"), + }) +} + +// handleAuthLogout clears the auth host's SSO session and lands the visitor on +// the public root (or the app in narrower configs). +func (s *Handlers) handleAuthLogout(w http.ResponseWriter, r *http.Request) { + _ = s.Auth.Logout(r.Context()) + switch { + case s.Cfg.RootHost != "": + http.Redirect(w, r, s.Origin(r, s.Cfg.RootHost)+"/", http.StatusSeeOther) + case s.Cfg.PrimaryHost != "": + http.Redirect(w, r, s.Origin(r, s.Cfg.PrimaryHost)+"/", http.StatusSeeOther) + default: + http.Redirect(w, r, "/login", http.StatusSeeOther) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 465b85f..aabd552 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strconv" + "strings" ) // Config holds all runtime configuration for the server. @@ -20,17 +21,48 @@ type Config struct { MasterKey [32]byte // WebAuthn relying-party settings. RPID is the effective domain (no - // scheme/port), RPOrigins are the full origins browsers will send. + // scheme/port) — set it to the root registrable domain (e.g. + // "meshtender.com") so credentials are valid across every subdomain. + // RPOrigins are the full origins browsers will send a ceremony from; + // supply a comma-separated list to allow both the auth and app hosts + // (e.g. "https://auth.meshtender.com,https://app.meshtender.com"). RPID string RPDisplayName string RPOrigins []string - // PrimaryHost is the canonical app hostname (no scheme/port). Requests - // arriving on a different, verified org custom domain serve that org's - // public page; all other paths there redirect back to PrimaryHost (where - // the WebAuthn RP is valid). Defaults to RPID. + // PrimaryHost is the canonical app hostname (no scheme/port) — the host + // that serves the product. Requests arriving on a different, verified org + // custom domain serve that org's public page; all other paths there + // redirect back to PrimaryHost. Defaults to RPID. PrimaryHost string + // AuthHost is the dedicated hostname that serves the login/signup UI and + // runs WebAuthn ceremonies (e.g. "auth.meshtender.com"). A successful + // sign-in there hands off to PrimaryHost via a single-use code. When + // empty, auth is served from PrimaryHost (single-host mode). + AuthHost string + + // RootHost is the public marketing + organization-discovery hostname (the + // bare apex, e.g. "meshtender.com" / dev "localhost"). It carries no + // session (cookies are host-only), so it serves only public content. When + // empty, that content stays on PrimaryHost. + RootHost string + + // WWWHost redirects to RootHost (e.g. "www.meshtender.com"). Defaults to + // "www." + RootHost when RootHost is set. + WWWHost string + + // Secure reports whether the deployment is HTTPS (derived from RPOrigins). + // Drives cookie Secure/__Host- prefixing and the scheme of cross-host URLs. + Secure bool + + // TLSCert and TLSKey, when both set, make the server terminate TLS itself + // (https). Used for local HTTPS dev (e.g. an mkcert cert for *.example.dev), + // where HSTS-preloaded TLDs like .dev force the browser onto https. In + // production TLS usually terminates at a proxy and these stay empty. + TLSCert string + TLSKey string + // DefaultRadio holds fallback LoRa parameters offered when adding a // repeater. Per-repeater values in the DB take precedence. DefaultRadio RadioDefaults @@ -52,8 +84,13 @@ func Load() (*Config, error) { DatabaseURL: os.Getenv("MESHTENDER_DATABASE_URL"), RPID: envOr("MESHTENDER_RP_ID", "localhost"), RPDisplayName: envOr("MESHTENDER_RP_NAME", "MeshTender"), - RPOrigins: []string{envOr("MESHTENDER_RP_ORIGIN", "http://localhost:8080")}, + RPOrigins: splitOrigins(envOr("MESHTENDER_RP_ORIGIN", "http://localhost:8080")), PrimaryHost: envOr("MESHTENDER_PRIMARY_HOST", envOr("MESHTENDER_RP_ID", "localhost")), + AuthHost: os.Getenv("MESHTENDER_AUTH_HOST"), + RootHost: os.Getenv("MESHTENDER_ROOT_HOST"), + WWWHost: os.Getenv("MESHTENDER_WWW_HOST"), + TLSCert: os.Getenv("MESHTENDER_TLS_CERT"), + TLSKey: os.Getenv("MESHTENDER_TLS_KEY"), DefaultRadio: RadioDefaults{ FreqHz: uint32(envUintOr("MESHTENDER_RADIO_FREQ_HZ", 869525000)), BwHz: uint32(envUintOr("MESHTENDER_RADIO_BW_HZ", 250000)), @@ -62,6 +99,17 @@ func Load() (*Config, error) { }, } + if c.RootHost != "" && c.WWWHost == "" { + c.WWWHost = "www." + c.RootHost + } + // HTTPS deployments advertise https:// origins; this drives Secure cookies + // and the scheme used when building absolute cross-host URLs. + for _, o := range c.RPOrigins { + if strings.HasPrefix(o, "https://") { + c.Secure = true + } + } + if c.DatabaseURL == "" { return nil, fmt.Errorf("MESHTENDER_DATABASE_URL is required") } @@ -89,6 +137,19 @@ func envOr(key, def string) string { return def } +// splitOrigins parses a comma-separated list of WebAuthn origins, trimming +// surrounding whitespace and dropping empty entries. +func splitOrigins(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + func envUintOr(key string, def uint64) uint64 { if v := os.Getenv(key); v != "" { if n, err := strconv.ParseUint(v, 10, 64); err == nil { diff --git a/internal/web/admin.go b/internal/core/admin.go similarity index 74% rename from internal/web/admin.go rename to internal/core/admin.go index f2b3534..9521c17 100644 --- a/internal/web/admin.go +++ b/internal/core/admin.go @@ -1,4 +1,4 @@ -package web +package core import ( "net/http" @@ -7,14 +7,15 @@ import ( "github.com/go-chi/chi/v5" "github.com/jleight/meshtender/internal/store" + "github.com/jleight/meshtender/internal/web" ) // requireCap is middleware that allows the request only if the current user // satisfies pick; otherwise it 404s (without revealing the admin area exists). -func (s *Server) requireCap(pick func(*store.User) bool) func(http.Handler) http.Handler { +func (s *Handlers) requireCap(pick func(*store.User) bool) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - u, err := s.store.GetUserByID(r.Context(), s.auth.CurrentUserID(r.Context())) + u, err := s.Store.GetUserByID(r.Context(), s.Auth.CurrentUserID(r.Context())) if err != nil || !pick(u) { http.NotFound(w, r) return @@ -61,27 +62,27 @@ func groupCatalog(catalog []*store.Command) []catalogGroup { return groupByCategory(catalog, func(c *store.Command) *store.Command { return c }) } -func (s *Server) pageAdmin(w http.ResponseWriter, r *http.Request) { - u, _ := s.store.GetUserByID(r.Context(), s.auth.CurrentUserID(r.Context())) - s.render(w, r, "admin.html", map[string]any{ +func (s *Handlers) pageAdmin(w http.ResponseWriter, r *http.Request) { + u, _ := s.Store.GetUserByID(r.Context(), s.Auth.CurrentUserID(r.Context())) + s.Render(w, r, "admin.html", map[string]any{ "CanCatalog": u.CapManageCatalog, "CanUsers": u.CapManageUsers, }) } -func (s *Server) pageCatalog(w http.ResponseWriter, r *http.Request) { - catalog, err := s.store.ListCommands(r.Context()) +func (s *Handlers) pageCatalog(w http.ResponseWriter, r *http.Request) { + catalog, err := s.Store.ListCommands(r.Context()) if err != nil { http.Error(w, "could not load catalog", http.StatusInternalServerError) return } - s.render(w, r, "admin_catalog.html", map[string]any{ + s.Render(w, r, "admin_catalog.html", map[string]any{ "Groups": groupCatalog(catalog), "Saved": r.URL.Query().Get("saved") != "", }) } -func (s *Server) handleUpdateCommand(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleUpdateCommand(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { http.NotFound(w, r) @@ -92,7 +93,7 @@ func (s *Server) handleUpdateCommand(w http.ResponseWriter, r *http.Request) { return } on := func(name string) bool { return r.FormValue(name) != "" } - if err := s.store.UpdateCommandFlags(r.Context(), id, + if err := s.Store.UpdateCommandFlags(r.Context(), id, on("risky"), on("share"), on("org_member"), on("org_admin")); err != nil { http.Error(w, "could not save", http.StatusInternalServerError) return @@ -100,25 +101,25 @@ func (s *Server) handleUpdateCommand(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/catalog?saved=1", http.StatusSeeOther) } -func (s *Server) pageUsers(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) pageUsers(w http.ResponseWriter, r *http.Request) { after := r.URL.Query().Get("after") - users, hasMore, err := s.store.ListUsersPage(r.Context(), after) + users, hasMore, err := s.Store.ListUsersPage(r.Context(), after) if err != nil { http.Error(w, "could not load users", http.StatusInternalServerError) return } data := map[string]any{ "Users": users, - "Self": s.auth.CurrentUserID(r.Context()), + "Self": s.Auth.CurrentUserID(r.Context()), "Error": r.URL.Query().Get("error"), } if hasMore && len(users) > 0 { data["NextAfter"] = users[len(users)-1].Username } - s.render(w, r, "admin_users.html", data) + s.Render(w, r, "admin_users.html", data) } -func (s *Server) handleSetUserCaps(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleSetUserCaps(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { http.NotFound(w, r) @@ -129,24 +130,24 @@ func (s *Server) handleSetUserCaps(w http.ResponseWriter, r *http.Request) { // Guard against removing the last user-manager. if !manageUsers { - target, err := s.store.GetUserByID(r.Context(), id) + target, err := s.Store.GetUserByID(r.Context(), id) if err != nil { http.NotFound(w, r) return } if target.CapManageUsers { - n, err := s.store.CountManageUsers(r.Context()) + n, err := s.Store.CountManageUsers(r.Context()) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return } if n <= 1 { - redirectErr(w, r, "/admin/users", "Can't remove the last user manager.") + web.RedirectErr(w, r, "/admin/users", "Can't remove the last user manager.") return } } } - if err := s.store.SetCapabilities(r.Context(), id, manageUsers, manageCatalog); err != nil { + if err := s.Store.SetCapabilities(r.Context(), id, manageUsers, manageCatalog); err != nil { http.Error(w, "could not save", http.StatusInternalServerError) return } diff --git a/internal/web/audit.go b/internal/core/audit.go similarity index 88% rename from internal/web/audit.go rename to internal/core/audit.go index 40d6c28..dac7454 100644 --- a/internal/web/audit.go +++ b/internal/core/audit.go @@ -1,4 +1,4 @@ -package web +package core import ( "encoding/base64" @@ -11,12 +11,12 @@ import ( // pageCommandLog shows the command audit log for a repeater (owner only), // keyset-paginated by console session via an opaque ?before token. -func (s *Server) pageCommandLog(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) pageCommandLog(w http.ResponseWriter, r *http.Request) { rep, id, ok := s.requireRepeaterOwned(w, r) if !ok { return } - sessions, hasMore, err := s.store.ListCommandLogSessionsPage(r.Context(), id, decodeLogCursor(r.URL.Query().Get("before"))) + sessions, hasMore, err := s.Store.ListCommandLogSessionsPage(r.Context(), id, decodeLogCursor(r.URL.Query().Get("before"))) if err != nil { http.Error(w, "could not load log", http.StatusInternalServerError) return @@ -29,7 +29,7 @@ func (s *Server) pageCommandLog(w http.ResponseWriter, r *http.Request) { last := sessions[len(sessions)-1] data["NextBefore"] = encodeLogCursor(last.StartedAt, last.ID) } - s.render(w, r, "command_log.html", data) + s.Render(w, r, "command_log.html", data) } // logCursor is the wire form of a command-log keyset position. diff --git a/internal/web/confirm.go b/internal/core/confirm.go similarity index 93% rename from internal/web/confirm.go rename to internal/core/confirm.go index 63f5081..6d0779b 100644 --- a/internal/web/confirm.go +++ b/internal/core/confirm.go @@ -1,4 +1,4 @@ -package web +package core import ( "context" @@ -31,12 +31,12 @@ var perTryReply = 10 * time.Second const maxSendTries = 4 // pageConfirm renders the WebSerial confirm page for a repeater the user can access. -func (s *Server) pageConfirm(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) pageConfirm(w http.ResponseWriter, r *http.Request) { rep, _, ok := s.requireRepeaterAccess(w, r) if !ok { return } - s.render(w, r, "confirm.html", map[string]any{ + s.Render(w, r, "confirm.html", map[string]any{ "Repeater": rep, "Debug": r.URL.Query().Get("debug") == "1", }) @@ -44,14 +44,14 @@ func (s *Server) pageConfirm(w http.ResponseWriter, r *http.Request) { // wsConfirm runs the live login round-trip over a WebSocket bridged to the // browser's WebSerial-attached KISS modem. -func (s *Server) wsConfirm(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) +func (s *Handlers) wsConfirm(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) id, ok := s.repeaterID(r) if !ok { http.NotFound(w, r) return } - rep, err := s.store.GetRepeaterForUser(r.Context(), uid, id) + rep, err := s.Store.GetRepeaterForUser(r.Context(), uid, id) if err != nil { http.Error(w, "no access", http.StatusForbidden) return @@ -80,7 +80,7 @@ func (s *Server) wsConfirm(w http.ResponseWriter, r *http.Request) { modem := hardware.NewKissModem(bridge, hardware.WithTxFlowControl(0)) defer modem.Close() - server := s.identity.Local() + server := s.Identity.Local() debug := r.URL.Query().Get("debug") == "1" // All sends (login + location queries) go through one exchanger: rate-limited, @@ -164,7 +164,7 @@ func (s *Server) wsConfirm(w http.ResponseWriter, r *http.Request) { return // context cancelled or a build/transmit error already reported } - if err := s.store.SetRepeaterConfirmed(ctx, id, uid, lr.IsAdmin, int16(lr.Permissions)); err != nil { + if err := s.Store.SetRepeaterConfirmed(ctx, id, uid, lr.IsAdmin, int16(lr.Permissions)); err != nil { _ = bridge.Status("error", "could not save confirmation: "+err.Error()) return } @@ -198,7 +198,7 @@ func (s *Server) wsConfirm(w http.ResponseWriter, r *http.Request) { lat, okLat := fetchCoord("latitude", "get lat") lon, okLon := fetchCoord("longitude", "get lon") if okLat && okLon { - if err := s.store.SetRepeaterLocation(ctx, id, lat, lon); err != nil { + if err := s.Store.SetRepeaterLocation(ctx, id, lat, lon); err != nil { _ = bridge.Status("error", "could not store location: "+err.Error()) } else { _ = bridge.Status("info", fmt.Sprintf("Stored location: %.5f, %.5f", lat, lon)) diff --git a/internal/web/confirm_integration_test.go b/internal/core/confirm_integration_test.go similarity index 95% rename from internal/web/confirm_integration_test.go rename to internal/core/confirm_integration_test.go index 1ca7182..2c703d1 100644 --- a/internal/web/confirm_integration_test.go +++ b/internal/core/confirm_integration_test.go @@ -1,4 +1,4 @@ -package web +package core import ( "context" @@ -77,18 +77,7 @@ func TestConfirmRoundTrip(t *testing.T) { // --- sign up a user (password) and capture the session cookie --- jar, _ := cookiejar.New(nil) - client := &http.Client{Jar: jar} - form := url.Values{"username": {"tester"}, "password": {"supersecret"}} - resp, err := client.PostForm(ts.URL+"/signup/password", form) - if err != nil { - t.Fatalf("signup: %v", err) - } - resp.Body.Close() - - user, err := st.GetUserByUsername(ctx, "tester") - if err != nil { - t.Fatalf("get user: %v", err) - } + user := seedSession(t, ts, st, ctx, jar, "tester") // --- register a repeater whose private key we (the test) hold --- repeater, err := meshcore.GenerateLocalIdentity(rand.Reader) diff --git a/internal/web/confirm_location_test.go b/internal/core/confirm_location_test.go similarity index 96% rename from internal/web/confirm_location_test.go rename to internal/core/confirm_location_test.go index 35eefbe..a95eb08 100644 --- a/internal/web/confirm_location_test.go +++ b/internal/core/confirm_location_test.go @@ -1,4 +1,4 @@ -package web +package core import ( "context" @@ -75,10 +75,7 @@ func TestConfirmFetchesLocation(t *testing.T) { defer ts.Close() jar, _ := cookiejar.New(nil) - client := &http.Client{Jar: jar} - resp, _ := client.PostForm(ts.URL+"/signup/password", url.Values{"username": {"alice"}, "password": {"supersecret"}}) - resp.Body.Close() - user, _ := st.GetUserByUsername(ctx, "alice") + user := seedSession(t, ts, st, ctx, jar, "alice") repeater, _ := meshcore.GenerateLocalIdentity(rand.Reader) rep, err := st.CreateRepeater(ctx, &store.Repeater{ diff --git a/internal/web/confirm_retry_test.go b/internal/core/confirm_retry_test.go similarity index 95% rename from internal/web/confirm_retry_test.go rename to internal/core/confirm_retry_test.go index 4570a3e..1b29fd5 100644 --- a/internal/web/confirm_retry_test.go +++ b/internal/core/confirm_retry_test.go @@ -1,4 +1,4 @@ -package web +package core import ( "context" @@ -62,10 +62,7 @@ func TestConfirmLoginRetry(t *testing.T) { defer ts.Close() jar, _ := cookiejar.New(nil) - client := &http.Client{Jar: jar} - resp, _ := client.PostForm(ts.URL+"/signup/password", url.Values{"username": {"alice"}, "password": {"supersecret"}}) - resp.Body.Close() - user, _ := st.GetUserByUsername(ctx, "alice") + user := seedSession(t, ts, st, ctx, jar, "alice") repeater, _ := meshcore.GenerateLocalIdentity(rand.Reader) rep, err := st.CreateRepeater(ctx, &store.Repeater{ diff --git a/internal/web/console.go b/internal/core/console.go similarity index 87% rename from internal/web/console.go rename to internal/core/console.go index abd9d6a..7380c2c 100644 --- a/internal/web/console.go +++ b/internal/core/console.go @@ -1,4 +1,4 @@ -package web +package core import ( "context" @@ -57,11 +57,11 @@ func resolveCommand(typed string, catalog []*store.Command) *store.Command { // allowedCommands returns the catalog commands the user may run on the repeater: // all of them for the owner, else the share-granted subset. -func (s *Server) allowedCommands(ctx context.Context, rep *store.Repeater, userID int64, catalog []*store.Command) []*store.Command { +func (s *Handlers) allowedCommands(ctx context.Context, rep *store.Repeater, userID int64, catalog []*store.Command) []*store.Command { if rep.OwnerID == userID { return catalog } - ids, err := s.store.ListShareCommandIDs(ctx, rep.ID, userID) + ids, err := s.Store.ListShareCommandIDs(ctx, rep.ID, userID) if err != nil { return nil } @@ -78,34 +78,34 @@ func (s *Server) allowedCommands(ctx context.Context, rep *store.Repeater, userI return out } -func (s *Server) pageConsole(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) +func (s *Handlers) pageConsole(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) rep, _, ok := s.requireRepeaterAccess(w, r) if !ok { return } - catalog, err := s.store.ListCommands(r.Context()) + catalog, err := s.Store.ListCommands(r.Context()) if err != nil { http.Error(w, "could not load commands", http.StatusInternalServerError) return } allowed := s.allowedCommands(r.Context(), rep, uid, catalog) sort.Slice(allowed, func(i, j int) bool { return allowed[i].Template < allowed[j].Template }) - s.render(w, r, "console.html", map[string]any{ + s.Render(w, r, "console.html", map[string]any{ "Repeater": rep, "Commands": allowed, }) } // wsConsole runs an interactive command session over the modem bridge. -func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) +func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) id, ok := s.repeaterID(r) if !ok { http.NotFound(w, r) return } - rep, err := s.store.GetRepeaterForUser(r.Context(), uid, id) + rep, err := s.Store.GetRepeaterForUser(r.Context(), uid, id) if err != nil { http.Error(w, "no access", http.StatusForbidden) return @@ -115,7 +115,7 @@ func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) { http.Error(w, "stored repeater key invalid", http.StatusInternalServerError) return } - catalog, err := s.store.ListCommands(r.Context()) + catalog, err := s.Store.ListCommands(r.Context()) if err != nil { http.Error(w, "could not load commands", http.StatusInternalServerError) return @@ -133,7 +133,7 @@ func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) { bridge := wsbridge.New(ctx, ws) modem := hardware.NewKissModem(bridge, hardware.WithTxFlowControl(0)) defer modem.Close() - server := s.identity.Local() + server := s.Identity.Local() // All commands go through one exchanger: rate-limited, monotonic timestamps, // automatic retry of lost packets. @@ -143,7 +143,7 @@ func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) { }) // Group this connection's commands into a session (required for logging). - sessionID, err := s.store.StartConsoleSession(ctx, id, uid) + sessionID, err := s.Store.StartConsoleSession(ctx, id, uid) if err != nil { _ = bridge.Status("error", "could not start session") return @@ -151,7 +151,7 @@ func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) { defer func() { endCtx, c := context.WithTimeout(context.Background(), 5*time.Second) defer c() - _ = s.store.EndConsoleSession(endCtx, sessionID) + _ = s.Store.EndConsoleSession(endCtx, sessionID) }() if err := modem.Connect(ctx); err != nil { @@ -238,7 +238,7 @@ func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) { _ = bridge.Status("denied", "Unknown command: "+text) return } - allowed, err := s.store.CanSendCommand(ctx, uid, id, cmd.ID) + allowed, err := s.Store.CanSendCommand(ctx, uid, id, cmd.ID) if err != nil { _ = bridge.Status("error", "permission check failed") return @@ -248,7 +248,7 @@ func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) { return } - logID, _ := s.store.LogCommand(ctx, id, uid, sessionID, cmd.ID, text) + logID, _ := s.Store.LogCommand(ctx, id, uid, sessionID, cmd.ID, text) reply, err := ex.Command(ctx, text, func(attempt, max int) { if attempt == 1 { @@ -260,7 +260,7 @@ func (s *Server) wsConsole(w http.ResponseWriter, r *http.Request) { switch { case err == nil: if logID != 0 { - _ = s.store.MarkCommandReply(ctx, logID, reply) + _ = s.Store.MarkCommandReply(ctx, logID, reply) } _ = bridge.Status("reply", reply) case errors.Is(err, mesh.ErrNoReply): diff --git a/internal/web/console_integration_test.go b/internal/core/console_integration_test.go similarity index 95% rename from internal/web/console_integration_test.go rename to internal/core/console_integration_test.go index 64969af..e1f8986 100644 --- a/internal/web/console_integration_test.go +++ b/internal/core/console_integration_test.go @@ -1,4 +1,4 @@ -package web +package core import ( "context" @@ -71,16 +71,7 @@ func TestConsoleRoundTrip(t *testing.T) { defer ts.Close() jar, _ := cookiejar.New(nil) - client := &http.Client{Jar: jar} - resp, err := client.PostForm(ts.URL+"/signup/password", url.Values{"username": {"alice"}, "password": {"supersecret"}}) - if err != nil { - t.Fatalf("signup: %v", err) - } - resp.Body.Close() - user, err := st.GetUserByUsername(ctx, "alice") - if err != nil { - t.Fatalf("get user: %v", err) - } + user := seedSession(t, ts, st, ctx, jar, "alice") repeater, err := meshcore.GenerateLocalIdentity(rand.Reader) if err != nil { diff --git a/internal/web/contribute.go b/internal/core/contribute.go similarity index 73% rename from internal/web/contribute.go rename to internal/core/contribute.go index 969e87a..bc3af34 100644 --- a/internal/web/contribute.go +++ b/internal/core/contribute.go @@ -1,4 +1,4 @@ -package web +package core import ( "net/http" @@ -9,20 +9,20 @@ import ( ) // orgContext resolves the {id} repeater (owned) and {orgID} the user belongs to. -func (s *Server) orgContext(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) { - owner := s.auth.CurrentUserID(r.Context()) +func (s *Handlers) orgContext(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) { + owner := s.Auth.CurrentUserID(r.Context()) id, ok := s.repeaterID(r) - orgID, oerr := s.store.OrgIDBySlug(r.Context(), chi.URLParam(r, "orgID")) + orgID, oerr := s.Store.OrgIDBySlug(r.Context(), chi.URLParam(r, "orgID")) if !ok || oerr != nil { http.NotFound(w, r) return nil, 0, false } - rep, err := s.store.GetRepeaterOwned(r.Context(), owner, id) + rep, err := s.Store.GetRepeaterOwned(r.Context(), owner, id) if err != nil { http.NotFound(w, r) return nil, 0, false } - if _, isMember, err := s.store.OrgRole(r.Context(), orgID, owner); err != nil || !isMember { + if _, isMember, err := s.Store.OrgRole(r.Context(), orgID, owner); err != nil || !isMember { http.NotFound(w, r) // can only contribute to orgs you belong to return nil, 0, false } @@ -31,27 +31,27 @@ func (s *Server) orgContext(w http.ResponseWriter, r *http.Request) (*store.Repe // pageContribute shows the org's current permission envelope for the owner to // review before consenting (also used for re-consent). -func (s *Server) pageContribute(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) pageContribute(w http.ResponseWriter, r *http.Request) { rep, orgID, ok := s.orgContext(w, r) if !ok { return } - org, err := s.store.GetOrg(r.Context(), orgID) + org, err := s.Store.GetOrg(r.Context(), orgID) if err != nil { http.NotFound(w, r) return } - versionID, version, err := s.store.CurrentVersion(r.Context(), orgID) + versionID, version, err := s.Store.CurrentVersion(r.Context(), orgID) if err != nil { http.Error(w, "could not load policy", http.StatusInternalServerError) return } - adminIDs, memberIDs, err := s.store.VersionCommandIDs(r.Context(), versionID) + adminIDs, memberIDs, err := s.Store.VersionCommandIDs(r.Context(), versionID) if err != nil { http.Error(w, "could not load policy", http.StatusInternalServerError) return } - catalog, err := s.store.ListCommands(r.Context()) + catalog, err := s.Store.ListCommands(r.Context()) if err != nil { http.Error(w, "could not load commands", http.StatusInternalServerError) return @@ -80,10 +80,10 @@ func (s *Server) pageContribute(w http.ResponseWriter, r *http.Request) { // If already contributed and behind the current version, show what changed // since the owner last consented. - cvID, contributed, _ := s.store.ConsentedVersionID(r.Context(), orgID, rep.ID) + cvID, contributed, _ := s.Store.ConsentedVersionID(r.Context(), orgID, rep.ID) if contributed && cvID != versionID { - cAdmin, cMember, err1 := s.store.VersionCommandIDs(r.Context(), cvID) - consentedNum, err2 := s.store.VersionNumber(r.Context(), cvID) + cAdmin, cMember, err1 := s.Store.VersionCommandIDs(r.Context(), cvID) + consentedNum, err2 := s.Store.VersionNumber(r.Context(), cvID) if err1 == nil && err2 == nil { tmpl := map[int64]string{} for _, c := range catalog { @@ -95,13 +95,13 @@ func (s *Server) pageContribute(w http.ResponseWriter, r *http.Request) { data["ConsentedVersion"] = consentedNum data["Added"] = templatesFor(current, consented, tmpl) // newly granted data["Removed"] = templatesFor(consented, current, tmpl) // no longer granted - if notes, err := s.store.VersionNotesSince(r.Context(), orgID, consentedNum); err == nil { + if notes, err := s.Store.VersionNotesSince(r.Context(), orgID, consentedNum); err == nil { data["Notes"] = notes } } } - s.render(w, r, "contribute.html", data) + s.Render(w, r, "contribute.html", data) } // union returns the set union of two id sets. @@ -130,18 +130,18 @@ func templatesFor(in, notIn map[int64]bool, tmpl map[int64]string) []string { } // handleContribute pins the repeater to the org's current permission version. -func (s *Server) handleContribute(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleContribute(w http.ResponseWriter, r *http.Request) { rep, orgID, ok := s.orgContext(w, r) if !ok { return } - versionID, _, err := s.store.CurrentVersion(r.Context(), orgID) + versionID, _, err := s.Store.CurrentVersion(r.Context(), orgID) if err != nil { http.Error(w, "could not load policy", http.StatusInternalServerError) return } - owner := s.auth.CurrentUserID(r.Context()) - if err := s.store.ContributeRepeater(r.Context(), orgID, rep.ID, versionID, owner); err != nil { + owner := s.Auth.CurrentUserID(r.Context()) + if err := s.Store.ContributeRepeater(r.Context(), orgID, rep.ID, versionID, owner); err != nil { http.Error(w, "could not contribute", http.StatusInternalServerError) return } @@ -149,12 +149,12 @@ func (s *Server) handleContribute(w http.ResponseWriter, r *http.Request) { } // handleWithdraw removes the repeater from the org. -func (s *Server) handleWithdraw(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleWithdraw(w http.ResponseWriter, r *http.Request) { rep, orgID, ok := s.orgContext(w, r) if !ok { return } - if err := s.store.WithdrawRepeater(r.Context(), orgID, rep.ID); err != nil { + if err := s.Store.WithdrawRepeater(r.Context(), orgID, rep.ID); err != nil { http.Error(w, "could not withdraw", http.StatusInternalServerError) return } diff --git a/internal/core/handoff_test.go b/internal/core/handoff_test.go new file mode 100644 index 0000000..77bc5ac --- /dev/null +++ b/internal/core/handoff_test.go @@ -0,0 +1,412 @@ +package core + +import ( + "context" + "crypto/rand" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + "github.com/jleight/meshtender/internal/auth" + "github.com/jleight/meshtender/internal/config" + "github.com/jleight/meshtender/internal/identity" + "github.com/jleight/meshtender/internal/store" +) + +const ( + testAuthHost = "auth.localhost" + testAppHost = "app.localhost" + testRootHost = "localhost" + testWWWHost = "www.localhost" +) + +// hostEnv holds the host:port for each surface of a split-host test server. +type hostEnv struct{ auth, app, root, www string } + +// splitServer stands up a server in full split-host mode (root/www/auth/app) +// against the test database, returning the store and the per-surface host:port. +func splitServer(t *testing.T) (*store.Store, context.Context, *httptest.Server, hostEnv) { + t.Helper() + dsn := os.Getenv("MESHTENDER_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set MESHTENDER_TEST_DATABASE_URL to run this integration test") + } + if u, err := url.Parse(dsn); err != nil || !strings.HasSuffix(strings.TrimPrefix(u.Path, "/"), "_test") { + t.Fatalf("refusing to run: test DB name must end in _test (got %q)", dsn) + } + ctx := context.Background() + st, err := store.New(ctx, dsn) + if err != nil { + t.Fatalf("store: %v", err) + } + t.Cleanup(st.Close) + if err := st.Migrate(ctx); err != nil { + t.Fatalf("migrate: %v", err) + } + if _, err := st.Pool().Exec(ctx, + `TRUNCATE users, repeaters, server_identity, sessions, auth_codes RESTART IDENTITY CASCADE`); err != nil { + t.Fatalf("truncate: %v", err) + } + + var masterKey [32]byte + _, _ = rand.Read(masterKey[:]) + idSvc, _ := identity.LoadOrCreate(ctx, st, masterKey) + authSvc, err := auth.New(st, st.Pool(), auth.Config{ + RPID: "localhost", RPDisplayName: "test", + RPOrigins: []string{"http://auth.localhost", "http://app.localhost"}, + AppHost: testAppHost, AuthHost: testAuthHost, + }) + if err != nil { + t.Fatalf("auth: %v", err) + } + srv, err := NewServer(st, authSvc, idSvc, &config.Config{ + PrimaryHost: testAppHost, AuthHost: testAuthHost, + RootHost: testRootHost, WWWHost: testWWWHost, + }) + if err != nil { + t.Fatalf("server: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + port := mustURL(t, ts.URL).Port() + hp := func(h string) string { return h + ":" + port } + return st, ctx, ts, hostEnv{auth: hp(testAuthHost), app: hp(testAppHost), root: hp(testRootHost), www: hp(testWWWHost)} +} + +// seedSession creates a user and establishes an authenticated app-host session +// by redeeming a handoff code at /session/callback, storing the session cookie +// in jar. Integration tests use this instead of the auth-host sign-in UI. +func seedSession(t *testing.T, ts *httptest.Server, st *store.Store, ctx context.Context, jar http.CookieJar, username string) *store.User { + t.Helper() + u, err := st.CreateUser(ctx, username, "") + if err != nil { + t.Fatalf("create user: %v", err) + } + code, err := st.CreateAuthCode(ctx, u.ID, "/") + if err != nil { + t.Fatalf("create auth code: %v", err) + } + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/session/callback?code="+code+"&state=s1", nil) + req.AddCookie(&http.Cookie{Name: "mt_state", Value: "s1"}) + client := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("session callback: %v", err) + } + resp.Body.Close() + return u +} + +// noRedirect is a client that surfaces redirects instead of following them. +func noRedirect() *http.Client { + return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} +} + +// do issues a request to ts with an explicit Host header (so the dispatcher +// routes by hostname) and optional cookies. +func do(t *testing.T, ts *httptest.Server, host, path string, cookies ...*http.Cookie) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil) + if err != nil { + t.Fatalf("request: %v", err) + } + req.Host = host + for _, c := range cookies { + req.AddCookie(c) + } + resp, err := noRedirect().Do(req) + if err != nil { + t.Fatalf("do %s%s: %v", host, path, err) + } + return resp +} + +// post issues a form POST with an explicit Host header and optional cookies. +func post(t *testing.T, ts *httptest.Server, host, path string, form url.Values, cookies ...*http.Cookie) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, ts.URL+path, strings.NewReader(form.Encode())) + if err != nil { + t.Fatalf("request: %v", err) + } + req.Host = host + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range cookies { + req.AddCookie(c) + } + resp, err := noRedirect().Do(req) + if err != nil { + t.Fatalf("post %s%s: %v", host, path, err) + } + return resp +} + +func cookieByName(resp *http.Response, name string) *http.Cookie { + for _, c := range resp.Cookies() { + if c.Name == name { + return c + } + } + return nil +} + +// TestRequireUserBouncesToAuthHost: an unauthenticated app-host request to a +// protected page redirects to the auth host's /login, carrying next and a state +// nonce that matches the host-only state cookie just set. +func TestRequireUserBouncesToAuthHost(t *testing.T) { + _, _, ts, h := splitServer(t) + + resp := do(t, ts, h.app, "/repeaters") + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", resp.StatusCode) + } + loc, _ := url.Parse(resp.Header.Get("Location")) + if loc.Host != h.auth || loc.Path != "/login" { + t.Fatalf("Location = %q, want auth host /login", resp.Header.Get("Location")) + } + if got := loc.Query().Get("next"); got != "/repeaters" { + t.Fatalf("next = %q, want /repeaters", got) + } + state := loc.Query().Get("state") + c := cookieByName(resp, "mt_state") + if state == "" || c == nil || c.Value != state { + t.Fatalf("state cookie %v must match state param %q", c, state) + } +} + +// TestAppHostLoginRedirectsToAuth: the credential UI does not live on the app +// host; /login there bounces to the auth host. +func TestAppHostLoginRedirectsToAuth(t *testing.T) { + _, _, ts, h := splitServer(t) + resp := do(t, ts, h.app, "/login") + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", resp.StatusCode) + } + if loc, _ := url.Parse(resp.Header.Get("Location")); loc.Host != h.auth { + t.Fatalf("Location host = %q, want %q", loc.Host, h.auth) + } +} + +// TestAuthHostServesLogin: the auth host renders the sign-in page in place. +func TestAuthHostServesLogin(t *testing.T) { + _, _, ts, h := splitServer(t) + resp := do(t, ts, h.auth, "/login") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +// TestHostRouting checks each surface is served on its own host: the root host +// serves public marketing + org discovery, www redirects to root, and the app +// host's / and /orgs require auth (bounce to the auth host). +func TestHostRouting(t *testing.T) { + _, _, ts, h := splitServer(t) + + t.Run("root serves landing", func(t *testing.T) { + resp := do(t, ts, h.root, "/") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("root / = %d, want 200", resp.StatusCode) + } + }) + t.Run("root serves public org directory", func(t *testing.T) { + resp := do(t, ts, h.root, "/orgs") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("root /orgs = %d, want 200", resp.StatusCode) + } + }) + t.Run("www redirects to root", func(t *testing.T) { + resp := do(t, ts, h.www, "/orgs") + defer resp.Body.Close() + if resp.StatusCode != http.StatusMovedPermanently { + t.Fatalf("www /orgs = %d, want 301", resp.StatusCode) + } + if loc, _ := url.Parse(resp.Header.Get("Location")); loc.Host != h.root || loc.Path != "/orgs" { + t.Fatalf("www redirect = %q, want root /orgs", resp.Header.Get("Location")) + } + }) + t.Run("app root bounces anonymous to auth", func(t *testing.T) { + resp := do(t, ts, h.app, "/") + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("app / = %d, want 303", resp.StatusCode) + } + if loc, _ := url.Parse(resp.Header.Get("Location")); loc.Host != h.auth { + t.Fatalf("app / redirect host = %q, want auth", loc.Host) + } + }) + t.Run("app /orgs requires auth", func(t *testing.T) { + resp := do(t, ts, h.app, "/orgs") + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("app /orgs anon = %d, want 303 to auth", resp.StatusCode) + } + }) +} + +// TestSingleLogout verifies sign-out clears BOTH sessions: the app host's +// /logout chains to the auth host, and the auth host's /logout destroys the SSO +// session so it can't silently re-authenticate. +func TestSingleLogout(t *testing.T) { + st, ctx, ts, h := splitServer(t) + + // An app session (via the handoff) logs out by bouncing to the auth host. + u, _ := st.CreateUser(ctx, "logoutuser", "") + code, _ := st.CreateAuthCode(ctx, u.ID, "/") + cb := do(t, ts, h.app, "/session/callback?code="+code+"&state=s1", &http.Cookie{Name: "mt_state", Value: "s1"}) + cb.Body.Close() + appSess := cookieByName(cb, "meshtender_session") + if appSess == nil { + t.Fatal("expected an app session from the callback") + } + out := post(t, ts, h.app, "/logout", url.Values{}, appSess) + out.Body.Close() + if loc, _ := url.Parse(out.Header.Get("Location")); out.StatusCode != http.StatusSeeOther || loc.Host != h.auth || loc.Path != "/logout" { + t.Fatalf("app logout = %d %q, want 303 to auth /logout", out.StatusCode, out.Header.Get("Location")) + } + + // Sign up on the auth host to hold an SSO session, then confirm it is live + // (auth /login hands off) until /logout, after which it no longer does. + su := post(t, ts, h.auth, "/signup/password", url.Values{"username": {"ssouser"}, "password": {"supersecret"}}) + su.Body.Close() + sso := cookieByName(su, "meshtender_session") + if sso == nil { + t.Fatal("expected an SSO session from signup") + } + live := do(t, ts, h.auth, "/login?next=%2F&state=x", sso) + live.Body.Close() + if live.StatusCode != http.StatusSeeOther { + t.Fatalf("authed /login = %d, want 303 handoff (SSO live)", live.StatusCode) + } + lo := do(t, ts, h.auth, "/logout", sso) + lo.Body.Close() + if lo.StatusCode != http.StatusSeeOther { + t.Fatalf("auth /logout = %d, want 303", lo.StatusCode) + } + after := do(t, ts, h.auth, "/login", sso) + defer after.Body.Close() + if after.StatusCode != http.StatusOK { + t.Fatalf("post-logout /login = %d, want 200 (SSO cleared, no re-handoff)", after.StatusCode) + } +} + +// TestAccountOnAuthHost verifies account/credential management lives on the auth +// host: the app host no longer serves /account, the auth host guards it with the +// SSO session, and an SSO-less visit returns LOCALLY to /account after login +// (rather than handing off to the app). +func TestAccountOnAuthHost(t *testing.T) { + _, _, ts, h := splitServer(t) + + t.Run("app host no longer serves /account", func(t *testing.T) { + resp := do(t, ts, h.app, "/account") + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("app /account = %d, want 404", resp.StatusCode) + } + }) + + t.Run("auth /account anon bounces to local login", func(t *testing.T) { + resp := do(t, ts, h.auth, "/account") + defer resp.Body.Close() + loc, _ := url.Parse(resp.Header.Get("Location")) + if resp.StatusCode != http.StatusSeeOther || loc.Path != "/login" || loc.Host != "" { + t.Fatalf("auth /account anon = %d %q, want 303 to local /login", resp.StatusCode, resp.Header.Get("Location")) + } + // Completing login carries the auth-local flag, so it returns to /account + // on the auth host — NOT a handoff to the app callback. + sess := cookieByName(resp, "meshtender_session") + if sess == nil { + t.Fatal("expected a session cookie carrying the auth-local flag") + } + fin := post(t, ts, h.auth, "/signup/password", + url.Values{"username": {"acctlocal"}, "password": {"supersecret"}}, sess) + fin.Body.Close() + if got := fin.Header.Get("Location"); got != "/account" { + t.Fatalf("post-login redirect = %q, want local /account (no handoff)", got) + } + }) + + t.Run("auth /account with SSO session renders", func(t *testing.T) { + su := post(t, ts, h.auth, "/signup/password", + url.Values{"username": {"acctsso"}, "password": {"supersecret"}}) + su.Body.Close() + sso := cookieByName(su, "meshtender_session") + if sso == nil { + t.Fatal("expected an SSO session from signup") + } + resp := do(t, ts, h.auth, "/account", sso) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("auth /account with SSO = %d, want 200", resp.StatusCode) + } + }) +} + +// TestSessionCallback exercises the handoff redemption: a valid code + matching +// state establishes an app-host session; tampered state or an unknown code is +// rejected back to sign-in without a session. +func TestSessionCallback(t *testing.T) { + st, ctx, ts, h := splitServer(t) + appHost := h.app + u, err := st.CreateUser(ctx, "callbackuser", "") + if err != nil { + t.Fatalf("create user: %v", err) + } + + t.Run("happy path establishes a session", func(t *testing.T) { + code, _ := st.CreateAuthCode(ctx, u.ID, "/repeaters") + resp := do(t, ts, appHost, "/session/callback?code="+code+"&state=s1", + &http.Cookie{Name: "mt_state", Value: "s1"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", resp.StatusCode) + } + if loc := resp.Header.Get("Location"); loc != "/repeaters" { + t.Fatalf("Location = %q, want /repeaters", loc) + } + sess := cookieByName(resp, "meshtender_session") + if sess == nil || sess.Value == "" { + t.Fatalf("expected a session cookie to be set") + } + // The session must actually authenticate: the protected page now loads. + follow := do(t, ts, appHost, "/repeaters", sess) + defer follow.Body.Close() + if follow.StatusCode != http.StatusOK { + t.Fatalf("authenticated /repeaters = %d, want 200", follow.StatusCode) + } + }) + + t.Run("state mismatch is rejected", func(t *testing.T) { + code, _ := st.CreateAuthCode(ctx, u.ID, "/repeaters") + resp := do(t, ts, appHost, "/session/callback?code="+code+"&state=s1", + &http.Cookie{Name: "mt_state", Value: "different"}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") == "/repeaters" { + t.Fatalf("status=%d loc=%q, want bounce to login", resp.StatusCode, resp.Header.Get("Location")) + } + if c := cookieByName(resp, "meshtender_session"); c != nil && c.Value != "" { + t.Fatalf("no session should be set on state mismatch") + } + // The code must NOT have been consumed, since state failed first. + if _, _, ok, _ := st.ConsumeAuthCode(ctx, code); !ok { + t.Fatalf("code should remain valid after a state-mismatch rejection") + } + }) + + t.Run("unknown code is rejected", func(t *testing.T) { + resp := do(t, ts, appHost, "/session/callback?code=bogus&state=s1", + &http.Cookie{Name: "mt_state", Value: "s1"}) + defer resp.Body.Close() + if c := cookieByName(resp, "meshtender_session"); c != nil && c.Value != "" { + t.Fatalf("no session should be set for an unknown code") + } + }) +} diff --git a/internal/web/org_domains.go b/internal/core/org_domains.go similarity index 83% rename from internal/web/org_domains.go rename to internal/core/org_domains.go index 1856613..e292dfe 100644 --- a/internal/web/org_domains.go +++ b/internal/core/org_domains.go @@ -1,4 +1,4 @@ -package web +package core import ( "errors" @@ -35,7 +35,7 @@ func normalizeHostname(raw string) string { } // handleAddOrgDomain registers a new custom domain for an org (admin only). -func (s *Server) handleAddOrgDomain(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleAddOrgDomain(w http.ResponseWriter, r *http.Request) { id, ok := s.requireOrgAdmin(w, r) if !ok { return @@ -45,7 +45,7 @@ func (s *Server) handleAddOrgDomain(w http.ResponseWriter, r *http.Request) { orgErr(w, r, "Enter a valid domain (e.g. mesh.example.org).") return } - if _, err := s.store.CreateOrgDomain(r.Context(), id, host); errors.Is(err, store.ErrDuplicate) { + if _, err := s.Store.CreateOrgDomain(r.Context(), id, host); errors.Is(err, store.ErrDuplicate) { orgErr(w, r, "That domain is already claimed.") return } else if err != nil { @@ -57,7 +57,7 @@ func (s *Server) handleAddOrgDomain(w http.ResponseWriter, r *http.Request) { // handleVerifyOrgDomain checks the org's DNS TXT record carries the domain's // verification token, then marks it verified (admin only). -func (s *Server) handleVerifyOrgDomain(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleVerifyOrgDomain(w http.ResponseWriter, r *http.Request) { id, ok := s.requireOrgAdmin(w, r) if !ok { return @@ -67,12 +67,12 @@ func (s *Server) handleVerifyOrgDomain(w http.ResponseWriter, r *http.Request) { orgErr(w, r, "Invalid domain.") return } - d, err := s.store.GetOrgDomain(r.Context(), id, domainID) + d, err := s.Store.GetOrgDomain(r.Context(), id, domainID) if err != nil { orgErr(w, r, "Unknown domain.") return } - records, err := s.lookupTXT(txtRecordPrefix + d.Hostname) + records, err := s.LookupTXT(txtRecordPrefix + d.Hostname) if err != nil { orgErr(w, r, "Could not read DNS TXT records yet — they may still be propagating.") return @@ -81,7 +81,7 @@ func (s *Server) handleVerifyOrgDomain(w http.ResponseWriter, r *http.Request) { orgErr(w, r, "TXT record not found yet. Double-check the value, then retry.") return } - if err := s.store.MarkOrgDomainVerified(r.Context(), id, domainID); err != nil { + if err := s.Store.MarkOrgDomainVerified(r.Context(), id, domainID); err != nil { orgErr(w, r, "Could not save verification.") return } @@ -100,7 +100,7 @@ func txtRecordsHaveToken(records []string, token string) bool { } // handleDeleteOrgDomain removes a custom domain (admin only). -func (s *Server) handleDeleteOrgDomain(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleDeleteOrgDomain(w http.ResponseWriter, r *http.Request) { id, ok := s.requireOrgAdmin(w, r) if !ok { return @@ -110,7 +110,7 @@ func (s *Server) handleDeleteOrgDomain(w http.ResponseWriter, r *http.Request) { orgErr(w, r, "Invalid domain.") return } - if err := s.store.DeleteOrgDomain(r.Context(), id, domainID); err != nil { + if err := s.Store.DeleteOrgDomain(r.Context(), id, domainID); err != nil { orgErr(w, r, "Could not remove domain.") return } diff --git a/internal/web/org_domains_test.go b/internal/core/org_domains_test.go similarity index 87% rename from internal/web/org_domains_test.go rename to internal/core/org_domains_test.go index 153909c..02f3f85 100644 --- a/internal/web/org_domains_test.go +++ b/internal/core/org_domains_test.go @@ -1,6 +1,10 @@ -package web +package core -import "testing" +import ( + "testing" + + "github.com/jleight/meshtender/internal/web" +) func TestNormalizeHostname(t *testing.T) { cases := map[string]string{ @@ -43,8 +47,8 @@ func TestHostWithoutPort(t *testing.T) { "localhost:8090": "localhost", } for in, want := range cases { - if got := hostWithoutPort(in); got != want { - t.Errorf("hostWithoutPort(%q) = %q, want %q", in, got, want) + if got := web.HostWithoutPort(in); got != want { + t.Errorf("web.HostWithoutPort(%q) = %q, want %q", in, got, want) } } } diff --git a/internal/web/org_permissions.go b/internal/core/org_permissions.go similarity index 77% rename from internal/web/org_permissions.go rename to internal/core/org_permissions.go index 9280ed2..e10887b 100644 --- a/internal/web/org_permissions.go +++ b/internal/core/org_permissions.go @@ -1,4 +1,4 @@ -package web +package core import ( "net/http" @@ -37,39 +37,39 @@ func idSet(ids []int64) map[int64]bool { return m } -func (s *Server) pageOrgPermissions(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) pageOrgPermissions(w http.ResponseWriter, r *http.Request) { id, ok := s.requireOrgAdmin(w, r) if !ok { return } - org, err := s.store.GetOrg(r.Context(), id) + org, err := s.Store.GetOrg(r.Context(), id) if err != nil { http.NotFound(w, r) return } - versionID, version, err := s.store.CurrentVersion(r.Context(), id) + versionID, version, err := s.Store.CurrentVersion(r.Context(), id) if err != nil { http.Error(w, "could not load policy", http.StatusInternalServerError) return } - adminIDs, memberIDs, err := s.store.VersionCommandIDs(r.Context(), versionID) + adminIDs, memberIDs, err := s.Store.VersionCommandIDs(r.Context(), versionID) if err != nil { http.Error(w, "could not load policy", http.StatusInternalServerError) return } - catalog, err := s.store.ListCommands(r.Context()) + catalog, err := s.Store.ListCommands(r.Context()) if err != nil { http.Error(w, "could not load commands", http.StatusInternalServerError) return } - s.render(w, r, "org_permissions.html", map[string]any{ + s.Render(w, r, "org_permissions.html", map[string]any{ "Org": org, "Version": version, "Groups": groupPermissions(catalog, idSet(adminIDs), idSet(memberIDs)), }) } -func (s *Server) handleSaveOrgPermissions(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleSaveOrgPermissions(w http.ResponseWriter, r *http.Request) { id, ok := s.requireOrgAdmin(w, r) if !ok { return @@ -87,9 +87,9 @@ func (s *Server) handleSaveOrgPermissions(w http.ResponseWriter, r *http.Request } return ids } - uid := s.auth.CurrentUserID(r.Context()) + uid := s.Auth.CurrentUserID(r.Context()) note := r.FormValue("note") - if _, err := s.store.PublishVersion(r.Context(), id, note, uid, parse("admin"), parse("member")); err != nil { + if _, err := s.Store.PublishVersion(r.Context(), id, note, uid, parse("admin"), parse("member")); err != nil { http.Error(w, "could not publish", http.StatusInternalServerError) return } diff --git a/internal/core/orgs.go b/internal/core/orgs.go new file mode 100644 index 0000000..2c2704f --- /dev/null +++ b/internal/core/orgs.go @@ -0,0 +1,257 @@ +package core + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/jleight/meshtender/internal/store" + "github.com/jleight/meshtender/internal/web" +) + +// orgID resolves the {id} URL param (a slug) to the internal int64 primary key. +func (s *Handlers) orgID(r *http.Request) (int64, bool) { + id, err := s.Store.OrgIDBySlug(r.Context(), chi.URLParam(r, "id")) + return id, err == nil +} + +// orgParam returns the raw slug from the {id} URL param, for building redirects. +func orgParam(r *http.Request) string { return chi.URLParam(r, "id") } + +func orgErr(w http.ResponseWriter, r *http.Request, msg string) { + web.RedirectErr(w, r, "/orgs/"+orgParam(r), msg) +} + +// pageMyOrgs lists the organizations the signed-in user belongs to (the app +// host's /orgs). Public discovery lives on the root host; a "Discover" button +// links there. +func (s *Handlers) pageMyOrgs(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + mine, err := s.Store.ListOrgsForUser(r.Context(), uid) + if err != nil { + http.Error(w, "could not load orgs", http.StatusInternalServerError) + return + } + s.Render(w, r, "my_orgs.html", map[string]any{ + "Orgs": mine, + "Error": r.URL.Query().Get("error"), + }) +} + +// pageNewOrg renders the standalone "create an organization" form. +func (s *Handlers) pageNewOrg(w http.ResponseWriter, r *http.Request) { + s.Render(w, r, "new_org.html", map[string]any{ + "Error": r.URL.Query().Get("error"), + }) +} + +// handleCreateOrg creates an org with the current user as its first admin. +func (s *Handlers) handleCreateOrg(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + name := strings.TrimSpace(r.FormValue("name")) + if name == "" || len(name) > 80 { + web.RedirectErr(w, r, "/orgs/new", "Enter an organization name.") + return + } + org, err := s.Store.CreateOrg(r.Context(), name, uid) + if err != nil { + web.RedirectErr(w, r, "/orgs/new", "Could not create organization.") + return + } + http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther) +} + +// pageOrg shows an org's home. Members get the full management view; everyone +// else (anonymous or non-member) gets the public view. Members can preview the +// public view with ?view=public. +func (s *Handlers) pageOrg(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + id, ok := s.orgID(r) + if !ok { + http.NotFound(w, r) + return + } + org, err := s.Store.GetOrg(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + role, isMember, err := s.Store.OrgRole(r.Context(), id, uid) + if err != nil { + http.Error(w, "could not load org", http.StatusInternalServerError) + return + } + if !isMember || r.URL.Query().Get("view") == "public" { + // Public org content lives on the root host (marketing surface). + http.Redirect(w, r, s.Origin(r, s.Cfg.RootHost)+"/orgs/"+org.Slug, http.StatusSeeOther) + return + } + members, err := s.Store.ListOrgMembers(r.Context(), id) + if err != nil { + http.Error(w, "could not load members", http.StatusInternalServerError) + return + } + repeaters, err := s.Store.ListOrgRepeaters(r.Context(), id) + if err != nil { + http.Error(w, "could not load repeaters", http.StatusInternalServerError) + return + } + mapped := 0 + for _, rp := range repeaters { + if rp.HasLocation { + mapped++ + } + } + adminCount := 0 + for _, m := range members { + if m.Role == "admin" { + adminCount++ + } + } + isAdmin := role == "admin" + data := map[string]any{ + "Org": org, + "Role": role, + "IsAdmin": isAdmin, + "Members": members, + "Repeaters": repeaters, + "HasMap": mapped > 0, + "MemberCount": len(members), + "RepeaterCount": len(repeaters), + "AdminCount": adminCount, + "Self": uid, + "Error": r.URL.Query().Get("error"), + } + if isAdmin { + domains, err := s.Store.ListOrgDomains(r.Context(), id) + if err != nil { + http.Error(w, "could not load domains", http.StatusInternalServerError) + return + } + data["Domains"] = domains + data["TXTName"] = txtRecordPrefix // org appends its hostname for the full record name + } + s.Render(w, r, "org.html", data) +} + +// handleEditOrg updates an org's slug, name, description, and region (admin only). +func (s *Handlers) handleEditOrg(w http.ResponseWriter, r *http.Request) { + id, ok := s.requireOrgAdmin(w, r) + if !ok { + return + } + name := strings.TrimSpace(r.FormValue("name")) + slug := strings.ToLower(strings.TrimSpace(r.FormValue("slug"))) + desc := strings.TrimSpace(r.FormValue("description")) + region := strings.TrimSpace(r.FormValue("region")) + if name == "" || len(name) > 80 { + orgErr(w, r, "Enter an organization name.") + return + } + if !store.ValidOrgSlug(slug) { + orgErr(w, r, "Slug must be 3–40 lowercase letters, numbers, and hyphens (and not reserved).") + return + } + if len(desc) > 2000 { + desc = desc[:2000] + } + if len(region) > 120 { + region = region[:120] + } + if err := s.Store.UpdateOrg(r.Context(), id, slug, name, desc, region); errors.Is(err, store.ErrDuplicate) { + orgErr(w, r, "That URL slug is already taken.") + return + } else if err != nil { + orgErr(w, r, "Could not save changes.") + return + } + // The slug may have changed; redirect to the new canonical URL. + http.Redirect(w, r, "/orgs/"+slug, http.StatusSeeOther) +} + +// requireOrgAdmin resolves {id} and verifies the current user is an org admin. +func (s *Handlers) requireOrgAdmin(w http.ResponseWriter, r *http.Request) (int64, bool) { + uid := s.Auth.CurrentUserID(r.Context()) + id, ok := s.orgID(r) + if !ok { + http.NotFound(w, r) + return 0, false + } + admin, err := s.Store.IsOrgAdmin(r.Context(), id, uid) + if err != nil || !admin { + http.NotFound(w, r) + return 0, false + } + return id, true +} + +func (s *Handlers) handleLeaveOrg(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + id, ok := s.orgID(r) + if !ok { + http.NotFound(w, r) + return + } + err := s.Store.RemoveOrgMember(r.Context(), id, uid) + if errors.Is(err, store.ErrLastAdmin) { + orgErr(w, r, "You're the last admin — promote someone else first.") + return + } + if err != nil { + orgErr(w, r, "Could not leave.") + return + } + http.Redirect(w, r, "/orgs", http.StatusSeeOther) +} + +// handleJoinOrg adds the current user to an org as a member. Orgs are publicly +// listed and anyone signed in may join directly from the org page (idempotent). +func (s *Handlers) handleJoinOrg(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + id, ok := s.orgID(r) + if !ok { + http.NotFound(w, r) + return + } + if err := s.Store.AddOrgMember(r.Context(), id, uid, "member"); err != nil { + orgErr(w, r, "Could not join.") + return + } + http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) +} + +// handleSetOrgMember promotes/demotes/removes a member (admin only). +func (s *Handlers) handleSetOrgMember(w http.ResponseWriter, r *http.Request) { + id, ok := s.requireOrgAdmin(w, r) + if !ok { + return + } + targetID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + switch r.FormValue("action") { + case "promote": + err = s.Store.SetOrgMemberRole(r.Context(), id, targetID, "admin") + case "demote": + err = s.Store.SetOrgMemberRole(r.Context(), id, targetID, "member") + case "remove": + err = s.Store.RemoveOrgMember(r.Context(), id, targetID) + default: + orgErr(w, r, "Unknown action.") + return + } + if errors.Is(err, store.ErrLastAdmin) { + orgErr(w, r, "That would leave the org with no admin.") + return + } + if err != nil { + orgErr(w, r, "Could not update member.") + return + } + http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) +} diff --git a/internal/web/presets.go b/internal/core/presets.go similarity index 99% rename from internal/web/presets.go rename to internal/core/presets.go index e5d172f..3b2623f 100644 --- a/internal/web/presets.go +++ b/internal/core/presets.go @@ -1,4 +1,4 @@ -package web +package core import "github.com/jleight/meshtender/internal/config" diff --git a/internal/web/render_test.go b/internal/core/render_test.go similarity index 84% rename from internal/web/render_test.go rename to internal/core/render_test.go index 973b19e..4206a75 100644 --- a/internal/web/render_test.go +++ b/internal/core/render_test.go @@ -1,9 +1,12 @@ -package web +package core import ( "io" "strings" "testing" + + "github.com/jleight/meshtender/internal/config" + "github.com/jleight/meshtender/internal/web" ) // TestBuildPagesComposeAndExecute verifies every content page is pre-built with @@ -11,12 +14,13 @@ import ( // or template-resolution error. This guards the startup-time template // composition (each page redefines content/title/header onto the base set). func TestBuildPagesComposeAndExecute(t *testing.T) { - pages, err := buildPages() + rn, err := web.NewRenderer(&config.Config{}, templatesFS) if err != nil { - t.Fatalf("buildPages: %v", err) + t.Fatalf("NewRenderer: %v", err) } + pages := rn.Pages() if len(pages) == 0 { - t.Fatal("buildPages returned no pages") + t.Fatal("renderer built no pages") } // The shared partials are not pages and must not be rendered directly. for _, name := range []string{"base.html", "icons.html"} { diff --git a/internal/web/repeaters.go b/internal/core/repeaters.go similarity index 73% rename from internal/web/repeaters.go rename to internal/core/repeaters.go index e545e42..c1263a8 100644 --- a/internal/web/repeaters.go +++ b/internal/core/repeaters.go @@ -1,4 +1,4 @@ -package web +package core import ( "errors" @@ -11,25 +11,26 @@ import ( "github.com/jleight/meshtender/internal/config" "github.com/jleight/meshtender/internal/store" + "github.com/jleight/meshtender/internal/web" ) // pageAddRepeater drives the add-repeater wizard. Step 1 ("grant") is a // mandatory acknowledgment that the owner has granted MeshTender admin on the // repeater; step 2 ("details") collects the repeater's name/key/radio. The two // post-creation steps (confirm, contribute) live on pageRepeaterAdded. -func (s *Server) pageAddRepeater(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) pageAddRepeater(w http.ResponseWriter, r *http.Request) { step := r.URL.Query().Get("step") if step != "details" { step = "grant" } - s.render(w, r, "add_repeater.html", map[string]any{ + s.Render(w, r, "add_repeater.html", map[string]any{ "Step": step, - "ServerPubKey": s.identity.PublicKeyHex(), - "SetPermCommand": s.identity.SetPermCommand(), - "RevokeCommand": s.identity.RevokePermCommand(), - "Defaults": s.cfg.DefaultRadio, + "ServerPubKey": s.Identity.PublicKeyHex(), + "SetPermCommand": s.Identity.SetPermCommand(), + "RevokeCommand": s.Identity.RevokePermCommand(), + "Defaults": s.Cfg.DefaultRadio, "Presets": radioPresets, - "DefaultPresetID": defaultPresetID(s.cfg.DefaultRadio), + "DefaultPresetID": defaultPresetID(s.Cfg.DefaultRadio), "Error": r.URL.Query().Get("error"), }) } @@ -37,7 +38,7 @@ func (s *Server) pageAddRepeater(w http.ResponseWriter, r *http.Request) { // pageRepeater shows a repeater's details: status, radio config, location, // recent activity, and the actions available to the viewer. Owners get full // management; users with shared/org access see a read-only view plus console. -func (s *Server) pageRepeater(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) pageRepeater(w http.ResponseWriter, r *http.Request) { rep, id, ok := s.requireRepeaterAccess(w, r) if !ok { return @@ -53,12 +54,12 @@ func (s *Server) pageRepeater(w http.ResponseWriter, r *http.Request) { "Error": r.URL.Query().Get("error"), } if isOwner { - orgs, err := s.store.ListRepeaterOrgs(r.Context(), id) + orgs, err := s.Store.ListRepeaterOrgs(r.Context(), id) if err != nil { http.Error(w, "could not load organizations", http.StatusInternalServerError) return } - recent, err := s.store.ListCommandLog(r.Context(), id, 8) + recent, err := s.Store.ListCommandLog(r.Context(), id, 8) if err != nil { http.Error(w, "could not load activity", http.StatusInternalServerError) return @@ -66,37 +67,37 @@ func (s *Server) pageRepeater(w http.ResponseWriter, r *http.Request) { data["Orgs"] = orgs data["Recent"] = recent } - s.render(w, r, "repeater.html", data) + s.Render(w, r, "repeater.html", data) } func addErr(w http.ResponseWriter, r *http.Request, msg string) { - redirectErr(w, r, "/repeaters/add?step=details", msg) + web.RedirectErr(w, r, "/repeaters/add?step=details", msg) } // pageRepeaterAdded is the wizard's final two steps for a freshly-added // repeater: optionally confirm it with a modem now, and optionally contribute // it to an organization the owner belongs to. -func (s *Server) pageRepeaterAdded(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) +func (s *Handlers) pageRepeaterAdded(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) rep, _, ok := s.requireRepeaterOwned(w, r) if !ok { return } - orgs, err := s.store.ListOrgsForUser(r.Context(), uid) + orgs, err := s.Store.ListOrgsForUser(r.Context(), uid) if err != nil { http.Error(w, "could not load orgs", http.StatusInternalServerError) return } - s.render(w, r, "repeater_added.html", map[string]any{ + s.Render(w, r, "repeater_added.html", map[string]any{ "Repeater": rep, "Orgs": orgs, - "RevokeCommand": s.identity.RevokePermCommand(), + "RevokeCommand": s.Identity.RevokePermCommand(), }) } // handleAddRepeater registers a new repeater (unconfirmed) for the current user. -func (s *Server) handleAddRepeater(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) +func (s *Handlers) handleAddRepeater(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) name := strings.TrimSpace(r.FormValue("name")) pubHex := strings.ToLower(strings.TrimSpace(r.FormValue("public_key"))) @@ -117,7 +118,7 @@ func (s *Server) handleAddRepeater(w http.ResponseWriter, r *http.Request) { } storeLocation := r.FormValue("store_location") != "" - rep, err := s.store.CreateRepeater(r.Context(), &store.Repeater{ + rep, err := s.Store.CreateRepeater(r.Context(), &store.Repeater{ OwnerID: uid, Name: name, PublicKeyHex: pubHex, @@ -153,30 +154,30 @@ func parseRadioForm(r *http.Request) (freq, bw int64, sf, cr int16, ok bool) { } // pageEditRepeater shows the edit form for an owned repeater. -func (s *Server) pageEditRepeater(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) pageEditRepeater(w http.ResponseWriter, r *http.Request) { rep, _, ok := s.requireRepeaterOwned(w, r) if !ok { return } - s.render(w, r, "edit_repeater.html", map[string]any{ + s.Render(w, r, "edit_repeater.html", map[string]any{ "Repeater": rep, "Presets": radioPresets, "SelectedPreset": defaultPresetID(config.RadioDefaults{FreqHz: uint32(rep.RadioFreqHz), BwHz: uint32(rep.RadioBwHz), SF: uint8(rep.RadioSF), CR: uint8(rep.RadioCR)}), - "RevokeCommand": s.identity.RevokePermCommand(), + "RevokeCommand": s.Identity.RevokePermCommand(), "Error": r.URL.Query().Get("error"), }) } // handleEditRepeater saves changes to an owned repeater's settings. -func (s *Server) handleEditRepeater(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) +func (s *Handlers) handleEditRepeater(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) id, ok := s.repeaterID(r) if !ok { http.NotFound(w, r) return } editErr := func(msg string) { - redirectErr(w, r, "/repeaters/"+repeaterParam(r)+"/edit", msg) + web.RedirectErr(w, r, "/repeaters/"+repeaterParam(r)+"/edit", msg) } name := strings.TrimSpace(r.FormValue("name")) if name == "" { @@ -190,7 +191,7 @@ func (s *Server) handleEditRepeater(w http.ResponseWriter, r *http.Request) { } storeLocation := r.FormValue("store_location") != "" publicMap := r.FormValue("public_map") != "" - if err := s.store.UpdateRepeater(r.Context(), uid, id, name, freq, bw, sf, cr, storeLocation, publicMap); err != nil { + if err := s.Store.UpdateRepeater(r.Context(), uid, id, name, freq, bw, sf, cr, storeLocation, publicMap); err != nil { editErr("Could not save changes.") return } @@ -200,26 +201,26 @@ func (s *Server) handleEditRepeater(w http.ResponseWriter, r *http.Request) { // pageDeleteRepeater shows a confirmation page before deleting, reminding the // owner that removing the repeater here does not revoke MeshTender's access on // the device. -func (s *Server) pageDeleteRepeater(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) pageDeleteRepeater(w http.ResponseWriter, r *http.Request) { rep, _, ok := s.requireRepeaterOwned(w, r) if !ok { return } - s.render(w, r, "delete_repeater.html", map[string]any{ + s.Render(w, r, "delete_repeater.html", map[string]any{ "Repeater": rep, - "RevokeCommand": s.identity.RevokePermCommand(), + "RevokeCommand": s.Identity.RevokePermCommand(), }) } // handleDeleteRepeater removes a repeater the current user owns. -func (s *Server) handleDeleteRepeater(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) +func (s *Handlers) handleDeleteRepeater(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) id, ok := s.repeaterID(r) if !ok { dashErr(w, r, "Invalid repeater.") return } - if err := s.store.DeleteRepeaterOwned(r.Context(), uid, id); err != nil { + if err := s.Store.DeleteRepeaterOwned(r.Context(), uid, id); err != nil { dashErr(w, r, "Could not delete repeater (only the owner can).") return } @@ -227,5 +228,5 @@ func (s *Server) handleDeleteRepeater(w http.ResponseWriter, r *http.Request) { } func dashErr(w http.ResponseWriter, r *http.Request, msg string) { - redirectErr(w, r, "/", msg) + web.RedirectErr(w, r, "/", msg) } diff --git a/internal/web/shares.go b/internal/core/shares.go similarity index 73% rename from internal/web/shares.go rename to internal/core/shares.go index fca33da..1a8fe15 100644 --- a/internal/web/shares.go +++ b/internal/core/shares.go @@ -1,4 +1,4 @@ -package web +package core import ( "context" @@ -10,34 +10,35 @@ import ( "github.com/go-chi/chi/v5" "github.com/jleight/meshtender/internal/store" + "github.com/jleight/meshtender/internal/web" ) // pageShare renders the sharing page for a repeater the user owns: the current // share link (if any) and the list of people who have accepted. -func (s *Server) pageShare(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) +func (s *Handlers) pageShare(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) rep, id, ok := s.requireRepeaterOwned(w, r) if !ok { return } - shares, err := s.store.ListShares(r.Context(), id) + shares, err := s.Store.ListShares(r.Context(), id) if err != nil { http.Error(w, "could not load shares", http.StatusInternalServerError) return } - invites, err := s.store.ListInvites(r.Context(), id) + invites, err := s.Store.ListInvites(r.Context(), id) if err != nil { http.Error(w, "could not load links", http.StatusInternalServerError) return } // Organizations section: orgs this repeater is contributed to, plus orgs the // owner belongs to but hasn't contributed it to yet. - contributed, err := s.store.ListRepeaterOrgs(r.Context(), id) + contributed, err := s.Store.ListRepeaterOrgs(r.Context(), id) if err != nil { http.Error(w, "could not load orgs", http.StatusInternalServerError) return } - memberships, err := s.store.ListOrgsForUser(r.Context(), uid) + memberships, err := s.Store.ListOrgsForUser(r.Context(), uid) if err != nil { http.Error(w, "could not load memberships", http.StatusInternalServerError) return @@ -52,7 +53,7 @@ func (s *Server) pageShare(w http.ResponseWriter, r *http.Request) { available = append(available, m.Org) } } - s.render(w, r, "share.html", map[string]any{ + s.Render(w, r, "share.html", map[string]any{ "Repeater": rep, "Shares": shares, "Invites": invites, @@ -64,7 +65,7 @@ func (s *Server) pageShare(w http.ResponseWriter, r *http.Request) { } // handleCreateLink mints a new single-use share link with a description (owner only). -func (s *Server) handleCreateLink(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleCreateLink(w http.ResponseWriter, r *http.Request) { id, ok := s.requireOwned(w, r) if !ok { return @@ -73,7 +74,7 @@ func (s *Server) handleCreateLink(w http.ResponseWriter, r *http.Request) { if len(description) > 100 { description = description[:100] } - if _, err := s.store.CreateInvite(r.Context(), id, description); err != nil { + if _, err := s.Store.CreateInvite(r.Context(), id, description); err != nil { shareErr(w, r, "Could not create share link.") return } @@ -81,7 +82,7 @@ func (s *Server) handleCreateLink(w http.ResponseWriter, r *http.Request) { } // handleDeleteInvite revokes (or clears) a single share link by id (owner only). -func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleDeleteInvite(w http.ResponseWriter, r *http.Request) { id, ok := s.requireOwned(w, r) if !ok { return @@ -91,7 +92,7 @@ func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) { shareErr(w, r, "Invalid link.") return } - if err := s.store.DeleteInvite(r.Context(), id, inviteID); err != nil { + if err := s.Store.DeleteInvite(r.Context(), id, inviteID); err != nil { shareErr(w, r, "Could not remove link.") return } @@ -99,7 +100,7 @@ func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) { } // handleUnshare revokes a user's access (owner only). -func (s *Server) handleUnshare(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleUnshare(w http.ResponseWriter, r *http.Request) { id, ok := s.requireOwned(w, r) if !ok { return @@ -109,7 +110,7 @@ func (s *Server) handleUnshare(w http.ResponseWriter, r *http.Request) { shareErr(w, r, "Invalid user.") return } - if err := s.store.RemoveShare(r.Context(), id, targetID); err != nil { + if err := s.Store.RemoveShare(r.Context(), id, targetID); err != nil { shareErr(w, r, "Could not revoke access.") return } @@ -121,14 +122,14 @@ func (s *Server) handleUnshare(w http.ResponseWriter, r *http.Request) { // pageInvite shows the accept page for a share link. It handles logged-out // users (offer login/register), the owner, already-shared users, and the // confirm-and-accept case. -func (s *Server) pageInvite(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) pageInvite(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") - uid := s.auth.CurrentUserID(r.Context()) + uid := s.Auth.CurrentUserID(r.Context()) // queryUserID drives the Shared flag; 0 when logged out is fine. - rep, err := s.store.RepeaterByInviteToken(r.Context(), uid, token) + rep, err := s.Store.RepeaterByInviteToken(r.Context(), uid, token) if errors.Is(err, store.ErrNotFound) { - s.render(w, r, "invite.html", map[string]any{"State": "invalid"}) + s.Render(w, r, "invite.html", map[string]any{"State": "invalid"}) return } if err != nil { @@ -144,7 +145,7 @@ func (s *Server) pageInvite(w http.ResponseWriter, r *http.Request) { case rep.OwnerID == uid: data["State"] = "owner" default: - shared, err := s.store.IsShared(r.Context(), rep.ID, uid) + shared, err := s.Store.IsShared(r.Context(), rep.ID, uid) if err != nil { http.Error(w, "could not check access", http.StatusInternalServerError) return @@ -155,18 +156,18 @@ func (s *Server) pageInvite(w http.ResponseWriter, r *http.Request) { data["State"] = "confirm" } } - s.render(w, r, "invite.html", data) + s.Render(w, r, "invite.html", data) } // handleAcceptInvite grants the logged-in user access via a single-use share // link, consuming the link atomically. -func (s *Server) handleAcceptInvite(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleAcceptInvite(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") - uid := s.auth.CurrentUserID(r.Context()) + uid := s.Auth.CurrentUserID(r.Context()) - rep, err := s.store.RepeaterByInviteToken(r.Context(), uid, token) + rep, err := s.Store.RepeaterByInviteToken(r.Context(), uid, token) if errors.Is(err, store.ErrNotFound) { - s.render(w, r, "invite.html", map[string]any{"State": "invalid"}) + s.Render(w, r, "invite.html", map[string]any{"State": "invalid"}) return } if err != nil { @@ -179,27 +180,27 @@ func (s *Server) handleAcceptInvite(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) return } - if shared, err := s.store.IsShared(r.Context(), rep.ID, uid); err == nil && shared { + if shared, err := s.Store.IsShared(r.Context(), rep.ID, uid); err == nil && shared { http.Redirect(w, r, "/", http.StatusSeeOther) return } // Atomically consume the link (single-use guard against concurrent accepts). - if _, err := s.store.ConsumeInvite(r.Context(), token, uid); errors.Is(err, store.ErrNotFound) { - s.render(w, r, "invite.html", map[string]any{"State": "invalid"}) + if _, err := s.Store.ConsumeInvite(r.Context(), token, uid); errors.Is(err, store.ErrNotFound) { + s.Render(w, r, "invite.html", map[string]any{"State": "invalid"}) return } else if err != nil { http.Error(w, "could not accept invite", http.StatusInternalServerError) return } - added, err := s.store.AddShare(r.Context(), rep.ID, uid) + added, err := s.Store.AddShare(r.Context(), rep.ID, uid) if err != nil { http.Error(w, "could not accept invite", http.StatusInternalServerError) return } if added { // Seed the new share with the default command set; owner can adjust. - _ = s.store.SeedShareCommands(r.Context(), rep.ID, uid) + _ = s.Store.SeedShareCommands(r.Context(), rep.ID, uid) } http.Redirect(w, r, "/", http.StatusSeeOther) } @@ -226,7 +227,7 @@ func groupCommands(catalog []*store.Command, checked map[int64]bool) []commandGr } // pageShareCommands lets a repeater owner choose which commands a shared user may run. -func (s *Server) pageShareCommands(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) pageShareCommands(w http.ResponseWriter, r *http.Request) { rep, id, ok := s.requireRepeaterOwned(w, r) if !ok { return @@ -236,26 +237,26 @@ func (s *Server) pageShareCommands(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - if shared, err := s.store.IsShared(r.Context(), id, targetID); err != nil || !shared { + if shared, err := s.Store.IsShared(r.Context(), id, targetID); err != nil || !shared { http.NotFound(w, r) return } - target, err := s.store.GetUserByID(r.Context(), targetID) + target, err := s.Store.GetUserByID(r.Context(), targetID) if err != nil { http.NotFound(w, r) return } - catalog, err := s.store.ListCommands(r.Context()) + catalog, err := s.Store.ListCommands(r.Context()) if err != nil { http.Error(w, "could not load commands", http.StatusInternalServerError) return } - ids, _ := s.store.ListShareCommandIDs(r.Context(), id, targetID) + ids, _ := s.Store.ListShareCommandIDs(r.Context(), id, targetID) checked := make(map[int64]bool, len(ids)) for _, cid := range ids { checked[cid] = true } - s.render(w, r, "share_commands.html", map[string]any{ + s.Render(w, r, "share_commands.html", map[string]any{ "Repeater": rep, "Target": target, "Groups": groupCommands(catalog, checked), @@ -263,7 +264,7 @@ func (s *Server) pageShareCommands(w http.ResponseWriter, r *http.Request) { } // handleSetShareCommands saves the chosen command set for a shared user. -func (s *Server) handleSetShareCommands(w http.ResponseWriter, r *http.Request) { +func (s *Handlers) handleSetShareCommands(w http.ResponseWriter, r *http.Request) { id, ok := s.requireOwned(w, r) if !ok { return @@ -273,7 +274,7 @@ func (s *Server) handleSetShareCommands(w http.ResponseWriter, r *http.Request) http.NotFound(w, r) return } - if shared, err := s.store.IsShared(r.Context(), id, targetID); err != nil || !shared { + if shared, err := s.Store.IsShared(r.Context(), id, targetID); err != nil || !shared { http.NotFound(w, r) return } @@ -287,7 +288,7 @@ func (s *Server) handleSetShareCommands(w http.ResponseWriter, r *http.Request) cmdIDs = append(cmdIDs, cid) } } - if err := s.store.SetShareCommands(r.Context(), id, targetID, cmdIDs); err != nil { + if err := s.Store.SetShareCommands(r.Context(), id, targetID, cmdIDs); err != nil { http.Error(w, "could not save commands", http.StatusInternalServerError) return } @@ -300,21 +301,21 @@ func (s *Server) handleSetShareCommands(w http.ResponseWriter, r *http.Request) // current user owns it or has shared/org access, writing the response (404 for an // unknown id or no access, 500 on an unexpected error) and returning ok=false // otherwise. It is the control-action gate for pages any authorized user can see. -func (s *Server) requireRepeaterAccess(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) { - return s.loadRepeater(w, r, s.store.GetRepeaterForUser) +func (s *Handlers) requireRepeaterAccess(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) { + return s.loadRepeater(w, r, s.Store.GetRepeaterForUser) } // requireRepeaterOwned is the owner-only variant of requireRepeaterAccess. -func (s *Server) requireRepeaterOwned(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) { - return s.loadRepeater(w, r, s.store.GetRepeaterOwned) +func (s *Handlers) requireRepeaterOwned(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) { + return s.loadRepeater(w, r, s.Store.GetRepeaterOwned) } // loadRepeater resolves {id} and loads the repeater via get (an access-scoped // lookup), mapping ErrNotFound to 404 and other failures to 500. -func (s *Server) loadRepeater(w http.ResponseWriter, r *http.Request, +func (s *Handlers) loadRepeater(w http.ResponseWriter, r *http.Request, get func(ctx context.Context, userID, repeaterID int64) (*store.Repeater, error), ) (*store.Repeater, int64, bool) { - uid := s.auth.CurrentUserID(r.Context()) + uid := s.Auth.CurrentUserID(r.Context()) id, ok := s.repeaterID(r) if !ok { http.NotFound(w, r) @@ -333,15 +334,15 @@ func (s *Server) loadRepeater(w http.ResponseWriter, r *http.Request, } // requireOwned is requireRepeaterOwned for callers that only need the id. -func (s *Server) requireOwned(w http.ResponseWriter, r *http.Request) (int64, bool) { +func (s *Handlers) requireOwned(w http.ResponseWriter, r *http.Request) (int64, bool) { _, id, ok := s.requireRepeaterOwned(w, r) return id, ok } // repeaterID resolves the opaque {id} URL param (a public_id) to the internal // int64 primary key used by the store. Returns ok=false for unknown ids. -func (s *Server) repeaterID(r *http.Request) (int64, bool) { - id, err := s.store.RepeaterIDByPublicID(r.Context(), chi.URLParam(r, "id")) +func (s *Handlers) repeaterID(r *http.Request) (int64, bool) { + id, err := s.Store.RepeaterIDByPublicID(r.Context(), chi.URLParam(r, "id")) return id, err == nil } @@ -352,11 +353,11 @@ func repeaterParam(r *http.Request) string { return chi.URLParam(r, "id") } func sharePath(publicID string) string { return "/repeaters/" + publicID + "/share" } func shareErr(w http.ResponseWriter, r *http.Request, msg string) { - redirectErr(w, r, sharePath(repeaterParam(r)), msg) + web.RedirectErr(w, r, sharePath(repeaterParam(r)), msg) } // absoluteURL builds an absolute URL for a path using the request's scheme/host. -func (s *Server) absoluteURL(r *http.Request, path string) string { +func (s *Handlers) absoluteURL(r *http.Request, path string) string { scheme := "http" if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { scheme = "https" diff --git a/internal/web/templates/add_repeater.html b/internal/core/templates/add_repeater.html similarity index 100% rename from internal/web/templates/add_repeater.html rename to internal/core/templates/add_repeater.html diff --git a/internal/web/templates/admin.html b/internal/core/templates/admin.html similarity index 100% rename from internal/web/templates/admin.html rename to internal/core/templates/admin.html diff --git a/internal/web/templates/admin_catalog.html b/internal/core/templates/admin_catalog.html similarity index 100% rename from internal/web/templates/admin_catalog.html rename to internal/core/templates/admin_catalog.html diff --git a/internal/web/templates/admin_users.html b/internal/core/templates/admin_users.html similarity index 100% rename from internal/web/templates/admin_users.html rename to internal/core/templates/admin_users.html diff --git a/internal/web/templates/command_log.html b/internal/core/templates/command_log.html similarity index 100% rename from internal/web/templates/command_log.html rename to internal/core/templates/command_log.html diff --git a/internal/web/templates/confirm.html b/internal/core/templates/confirm.html similarity index 100% rename from internal/web/templates/confirm.html rename to internal/core/templates/confirm.html diff --git a/internal/web/templates/console.html b/internal/core/templates/console.html similarity index 100% rename from internal/web/templates/console.html rename to internal/core/templates/console.html diff --git a/internal/web/templates/contribute.html b/internal/core/templates/contribute.html similarity index 100% rename from internal/web/templates/contribute.html rename to internal/core/templates/contribute.html diff --git a/internal/web/templates/dashboard.html b/internal/core/templates/dashboard.html similarity index 100% rename from internal/web/templates/dashboard.html rename to internal/core/templates/dashboard.html diff --git a/internal/web/templates/delete_repeater.html b/internal/core/templates/delete_repeater.html similarity index 100% rename from internal/web/templates/delete_repeater.html rename to internal/core/templates/delete_repeater.html diff --git a/internal/web/templates/edit_repeater.html b/internal/core/templates/edit_repeater.html similarity index 100% rename from internal/web/templates/edit_repeater.html rename to internal/core/templates/edit_repeater.html diff --git a/internal/web/templates/invite.html b/internal/core/templates/invite.html similarity index 100% rename from internal/web/templates/invite.html rename to internal/core/templates/invite.html diff --git a/internal/core/templates/my_orgs.html b/internal/core/templates/my_orgs.html new file mode 100644 index 0000000..630c4ab --- /dev/null +++ b/internal/core/templates/my_orgs.html @@ -0,0 +1,39 @@ +{{define "title"}}Your organizations · MeshTender{{end}} +{{define "header"}} +
+

Your organizations

+ +
+{{end}} +{{define "content"}} +{{if .Error}}
{{.Error}}
{{end}} +
+ {{if .Orgs}} + + {{else}} + + {{end}} +
+{{end}} diff --git a/internal/web/templates/new_org.html b/internal/core/templates/new_org.html similarity index 100% rename from internal/web/templates/new_org.html rename to internal/core/templates/new_org.html diff --git a/internal/web/templates/org.html b/internal/core/templates/org.html similarity index 100% rename from internal/web/templates/org.html rename to internal/core/templates/org.html diff --git a/internal/web/templates/org_permissions.html b/internal/core/templates/org_permissions.html similarity index 100% rename from internal/web/templates/org_permissions.html rename to internal/core/templates/org_permissions.html diff --git a/internal/web/templates/repeater.html b/internal/core/templates/repeater.html similarity index 100% rename from internal/web/templates/repeater.html rename to internal/core/templates/repeater.html diff --git a/internal/web/templates/repeater_added.html b/internal/core/templates/repeater_added.html similarity index 100% rename from internal/web/templates/repeater_added.html rename to internal/core/templates/repeater_added.html diff --git a/internal/web/templates/repeaters.html b/internal/core/templates/repeaters.html similarity index 100% rename from internal/web/templates/repeaters.html rename to internal/core/templates/repeaters.html diff --git a/internal/web/templates/share.html b/internal/core/templates/share.html similarity index 100% rename from internal/web/templates/share.html rename to internal/core/templates/share.html diff --git a/internal/web/templates/share_commands.html b/internal/core/templates/share_commands.html similarity index 100% rename from internal/web/templates/share_commands.html rename to internal/core/templates/share_commands.html diff --git a/internal/core/web.go b/internal/core/web.go new file mode 100644 index 0000000..05db28b --- /dev/null +++ b/internal/core/web.go @@ -0,0 +1,301 @@ +// Package core is the application (app host) surface — the authenticated product +// — and the composition root that assembles the auth, marketing, and core +// surfaces behind the host dispatcher. +package core + +import ( + "context" + "embed" + "net" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/jleight/meshtender/internal/auth" + "github.com/jleight/meshtender/internal/config" + "github.com/jleight/meshtender/internal/identity" + "github.com/jleight/meshtender/internal/marketing" + "github.com/jleight/meshtender/internal/store" + "github.com/jleight/meshtender/internal/web" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +// Handlers carries the shared environment plus the auth service for the app +// surface's HTTP handlers. +type Handlers struct { + *web.Env + Auth *auth.Service +} + +// Server is the built HTTP entry point. +type Server struct{ handler http.Handler } + +// Handler returns the root HTTP handler. +func (s *Server) Handler() http.Handler { return s.handler } + +// NewServer assembles every surface and the host dispatcher. The deployment is +// always split-host (auth / root / app); the auth host serves sign-in + account, +// the root host serves marketing + public org discovery, and the app host (plus +// custom org domains and any unrecognized host) serves the product. +func NewServer(st *store.Store, authSvc *auth.Service, idSvc *identity.Service, cfg *config.Config) (*Server, error) { + deps := web.Deps{ + Store: st, + Identity: idSvc, + Cfg: cfg, + UserInfo: func(ctx context.Context) (string, bool, bool) { + uid := authSvc.CurrentUserID(ctx) + if uid == 0 { + return "", false, false + } + u, err := st.GetUserByID(ctx, uid) + if err != nil { + return "", false, false + } + return u.Name(), u.CapManageUsers || u.CapManageCatalog, true + }, + LookupTXT: net.LookupTXT, + } + + coreEnv, err := web.NewEnv(deps, templatesFS) + if err != nil { + return nil, err + } + app := &Handlers{Env: coreEnv, Auth: authSvc} + + authH, err := auth.NewWeb(deps, authSvc) + if err != nil { + return nil, err + } + mkH, err := marketing.New(deps, authSvc) + if err != nil { + return nil, err + } + + // Custom org domains and unrecognized hosts fall through to the app router, + // wrapped by marketing's custom-domain interceptor (it serves verified hosts' + // public org pages). + appHandler := mkH.CustomDomain(app.appRouter()) + handler := web.Dispatcher(cfg, authH.Routes(), mkH.Routes(), appHandler) + return &Server{handler: handler}, nil +} + +// baseMW applies the shared middleware plus session loading. +func (s *Handlers) baseMW(r chi.Router) { + s.CommonMiddleware(r) + r.Use(s.Auth.Sessions.LoadAndSave) +} + +// redirectToAuthLogin / redirectToAuthSignup are the app-host entry points that +// initiate the auth handoff (setting the state cookie) and bounce to the auth +// host. Cross-host "sign in"/"create account" CTAs target these, never the auth +// host directly, so the state cookie is always established first. +func (s *Handlers) redirectToAuthLogin(w http.ResponseWriter, r *http.Request) { + s.Auth.StartLogin(w, r, r.URL.Query().Get("next")) +} + +func (s *Handlers) redirectToAuthSignup(w http.ResponseWriter, r *http.Request) { + s.Auth.StartSignup(w, r, r.URL.Query().Get("next")) +} + +// appRouter is the product surface served on the app host. +func (s *Handlers) appRouter() chi.Router { + r := chi.NewRouter() + s.baseMW(r) + s.SharedRoutes(r) + + // The handoff endpoint that turns an auth-host sign-in into an app session. + r.Get("/session/callback", s.Auth.SessionCallback) + // Credential UI lives on the auth host; these initiate the handoff there. + r.Get("/login", s.redirectToAuthLogin) + r.Get("/signup", s.redirectToAuthSignup) + + r.Get("/", s.pageHome) + r.Get("/invite/{token}", s.pageInvite) // public: handles logged-out state + + // Authenticated area. + r.Group(func(r chi.Router) { + r.Use(s.Auth.RequireUser) + r.Get("/orgs", s.pageMyOrgs) // your organizations; discovery is on root + r.Get("/orgs/{id}", s.pageOrg) // member view (non-members redirect to root) + r.Get("/repeaters", s.pageRepeaters) + r.Post("/logout", s.handleLogout) + r.Get("/repeaters/add", s.pageAddRepeater) + r.Post("/repeaters", s.handleAddRepeater) + r.Get("/repeaters/{id}", s.pageRepeater) + r.Get("/repeaters/{id}/added", s.pageRepeaterAdded) + r.Get("/repeaters/{id}/edit", s.pageEditRepeater) + r.Post("/repeaters/{id}/edit", s.handleEditRepeater) + r.Get("/repeaters/{id}/delete", s.pageDeleteRepeater) + r.Post("/repeaters/{id}/delete", s.handleDeleteRepeater) + r.Get("/repeaters/{id}/confirm", s.pageConfirm) + r.Get("/repeaters/{id}/ws", s.wsConfirm) + r.Get("/repeaters/{id}/console", s.pageConsole) + r.Get("/repeaters/{id}/console/ws", s.wsConsole) + r.Get("/repeaters/{id}/log", s.pageCommandLog) + r.Get("/repeaters/{id}/share", s.pageShare) + r.Post("/repeaters/{id}/share/link", s.handleCreateLink) + r.Post("/repeaters/{id}/share/link/delete", s.handleDeleteInvite) + r.Post("/repeaters/{id}/unshare", s.handleUnshare) + r.Get("/repeaters/{id}/share/{userID}/commands", s.pageShareCommands) + r.Post("/repeaters/{id}/share/{userID}/commands", s.handleSetShareCommands) + r.Get("/repeaters/{id}/orgs/{orgID}/contribute", s.pageContribute) + r.Post("/repeaters/{id}/orgs/{orgID}/contribute", s.handleContribute) + r.Post("/repeaters/{id}/orgs/{orgID}/withdraw", s.handleWithdraw) + r.Post("/invite/{token}/accept", s.handleAcceptInvite) + + r.Get("/orgs/new", s.pageNewOrg) + r.Post("/orgs", s.handleCreateOrg) + r.Post("/orgs/{id}/edit", s.handleEditOrg) + r.Post("/orgs/{id}/join", s.handleJoinOrg) + r.Post("/orgs/{id}/leave", s.handleLeaveOrg) + r.Post("/orgs/{id}/members/{userID}", s.handleSetOrgMember) + r.Get("/orgs/{id}/permissions", s.pageOrgPermissions) + r.Post("/orgs/{id}/permissions", s.handleSaveOrgPermissions) + r.Post("/orgs/{id}/domains", s.handleAddOrgDomain) + r.Post("/orgs/{id}/domains/verify", s.handleVerifyOrgDomain) + r.Post("/orgs/{id}/domains/delete", s.handleDeleteOrgDomain) + + r.Route("/admin", func(r chi.Router) { + r.With(s.requireCap(capAny)).Get("/", s.pageAdmin) + r.With(s.requireCap(capCatalog)).Get("/catalog", s.pageCatalog) + r.With(s.requireCap(capCatalog)).Post("/catalog/{id}", s.handleUpdateCommand) + r.With(s.requireCap(capUsers)).Get("/users", s.pageUsers) + r.With(s.requireCap(capUsers)).Post("/users/{id}", s.handleSetUserCaps) + }) + }) + + return r +} + +// pageHome serves the signed-in dashboard; anonymous visitors are sent to sign +// in on the auth host. +func (s *Handlers) pageHome(w http.ResponseWriter, r *http.Request) { + if s.Auth.CurrentUserID(r.Context()) == 0 { + s.Auth.StartLogin(w, r, "/") + return + } + s.pageDashboard(w, r) +} + +// pageRepeaters lists every repeater the user owns or has been shared, split +// into owned and shared sections. +func (s *Handlers) pageRepeaters(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + repeaters, err := s.Store.ListRepeatersForUser(r.Context(), uid) + if err != nil { + http.Error(w, "could not load repeaters", http.StatusInternalServerError) + return + } + reconsent, err := s.Store.OwnedRepeatersNeedingReconsent(r.Context(), uid) + if err != nil { + http.Error(w, "could not load org state", http.StatusInternalServerError) + return + } + shareCounts, err := s.Store.RepeaterSharingCounts(r.Context(), uid) + if err != nil { + http.Error(w, "could not load sharing", http.StatusInternalServerError) + return + } + owned, shared := splitOwnedShared(repeaters) + s.Render(w, r, "repeaters.html", map[string]any{ + "Owned": owned, + "Shared": shared, + "Reconsent": reconsent, + "ShareCounts": shareCounts, + "Error": r.URL.Query().Get("error"), + }) +} + +// pageDashboard renders the signed-in overview: summary stats, short lists of +// repeaters and organizations, a map of owned repeaters, and recent activity. +func (s *Handlers) pageDashboard(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid := s.Auth.CurrentUserID(ctx) + + repeaters, err := s.Store.ListRepeatersForUser(ctx, uid) + if err != nil { + http.Error(w, "could not load repeaters", http.StatusInternalServerError) + return + } + owned, shared := splitOwnedShared(repeaters) + + reconsent, err := s.Store.OwnedRepeatersNeedingReconsent(ctx, uid) + if err != nil { + http.Error(w, "could not load org state", http.StatusInternalServerError) + return + } + orgs, err := s.Store.ListOrgsForUser(ctx, uid) + if err != nil { + http.Error(w, "could not load organizations", http.StatusInternalServerError) + return + } + recent, err := s.Store.ListRecentCommandsForOwner(ctx, uid, 8) + if err != nil { + http.Error(w, "could not load activity", http.StatusInternalServerError) + return + } + shareCounts, err := s.Store.RepeaterSharingCounts(ctx, uid) + if err != nil { + http.Error(w, "could not load sharing", http.StatusInternalServerError) + return + } + + confirmed, unconfirmed := 0, 0 + var mapped []*store.Repeater + for _, rp := range owned { + if rp.Confirmed { + confirmed++ + } else { + unconfirmed++ + } + if rp.Latitude != nil && rp.Longitude != nil { + mapped = append(mapped, rp) + } + } + + s.Render(w, r, "dashboard.html", map[string]any{ + "OwnedCount": len(owned), + "SharedCount": len(shared), + "OrgCount": len(orgs), + "Confirmed": confirmed, + "Unconfirmed": unconfirmed, + "Owned": first(owned, 5), + "Shared": first(shared, 5), + "Orgs": first(orgs, 5), + "Mapped": mapped, + "Recent": recent, + "Reconsent": reconsent, + "ShareCounts": shareCounts, + "Error": r.URL.Query().Get("error"), + }) +} + +// splitOwnedShared partitions a repeater list into owned and directly-shared. +func splitOwnedShared(repeaters []*store.Repeater) (owned, shared []*store.Repeater) { + for _, rp := range repeaters { + if rp.Shared { + shared = append(shared, rp) + } else { + owned = append(owned, rp) + } + } + return owned, shared +} + +// first returns the first n elements of s, or all of them if there are fewer. +func first[T any](s []T, n int) []T { + if len(s) > n { + return s[:n] + } + return s +} + +// handleLogout signs the user out of the app host, then chains to the auth host's +// /logout to clear the SSO session too — otherwise the surviving SSO session +// would silently re-authenticate on the next request. +func (s *Handlers) handleLogout(w http.ResponseWriter, r *http.Request) { + _ = s.Auth.Logout(r.Context()) + http.Redirect(w, r, s.Origin(r, s.Cfg.AuthHost)+"/logout", http.StatusSeeOther) +} diff --git a/internal/marketing/marketing.go b/internal/marketing/marketing.go new file mode 100644 index 0000000..4bcdc25 --- /dev/null +++ b/internal/marketing/marketing.go @@ -0,0 +1,99 @@ +// Package marketing is the public surface served on the root (apex) host: the +// marketing landing page and organization discovery. It carries no session +// (cookies are host-only), so every page renders the anonymous/public view. It +// also serves verified custom org domains. +package marketing + +import ( + "embed" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/jleight/meshtender/internal/auth" + "github.com/jleight/meshtender/internal/web" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +// Handlers serve the public marketing/discovery pages. +type Handlers struct { + *web.Env + Auth *auth.Service +} + +// New builds the marketing handlers with their own renderer (marketing pages +// composed onto the shared base layout). +func New(deps web.Deps, svc *auth.Service) (*Handlers, error) { + env, err := web.NewEnv(deps, templatesFS) + if err != nil { + return nil, err + } + return &Handlers{Env: env, Auth: svc}, nil +} + +// Routes is the root host's router. +func (s *Handlers) Routes() chi.Router { + r := chi.NewRouter() + s.CommonMiddleware(r) + r.Use(s.Auth.Sessions.LoadAndSave) + s.SharedRoutes(r) + r.Get("/", s.pageLanding) + r.Get("/orgs", s.pageOrgs) // public organization directory + r.Get("/orgs/{id}", s.pageOrgPublic) // public org page + return r +} + +// pageLanding renders the public marketing page. +func (s *Handlers) pageLanding(w http.ResponseWriter, r *http.Request) { + s.Render(w, r, "landing.html", map[string]any{"Layout": "landingbase"}) +} + +// pageOrgPublic renders an organization's public page (resolved by slug). +func (s *Handlers) pageOrgPublic(w http.ResponseWriter, r *http.Request) { + id, ok := s.orgID(r) + if !ok { + http.NotFound(w, r) + return + } + org, err := s.Store.GetOrg(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + uid := s.Auth.CurrentUserID(r.Context()) + _, isMember, _ := s.Store.OrgRole(r.Context(), id, uid) + s.renderOrgPublic(w, r, org, isMember) +} + +// CustomDomain serves an org's public page when the request arrives on one of +// the org's verified custom domains. Any non-root path is redirected to the app +// on the canonical host. Used as the dispatcher's unknown-host fallback (it +// wraps the app router). +func (s *Handlers) CustomDomain(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := web.HostWithoutPort(r.Host) + if host == "" || host == "localhost" || strings.EqualFold(host, s.Cfg.PrimaryHost) { + next.ServeHTTP(w, r) + return + } + org, ok, err := s.Store.OrgByVerifiedDomain(r.Context(), strings.ToLower(host)) + if err != nil || !ok { + next.ServeHTTP(w, r) // unknown host: serve the app normally + return + } + if r.URL.Path == "/" { + s.renderOrgPublic(w, r, org, false) + return + } + http.Redirect(w, r, s.Origin(r, s.Cfg.PrimaryHost)+r.URL.RequestURI(), http.StatusFound) + }) +} + +// orgID resolves the {id} URL param (a slug) to the internal int64 primary key. +func (s *Handlers) orgID(r *http.Request) (int64, bool) { + id, err := s.Store.OrgIDBySlug(r.Context(), chi.URLParam(r, "id")) + return id, err == nil +} diff --git a/internal/marketing/orgs.go b/internal/marketing/orgs.go new file mode 100644 index 0000000..5c9ccea --- /dev/null +++ b/internal/marketing/orgs.go @@ -0,0 +1,155 @@ +package marketing + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/jleight/meshtender/internal/store" +) + +// pageOrgs is the public organization directory. Everyone sees the list; signed- +// in users also get a marker on orgs they belong to. The directory is keyset- +// paginated via an opaque ?cursor token. +func (s *Handlers) pageOrgs(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + q := r.URL.Query() + + sortKey := store.NormalizeOrgSort(q.Get("sort")) + query := strings.TrimSpace(q.Get("q")) + if len(query) > 100 { + query = query[:100] + } + + var p store.OrgListParams + // A cursor carries the authoritative sort, search, and keyset position for + // the page it points at, so paging stays consistent without re-sending the + // form; the sort/q params only matter on the first (cursorless) page. + if c, ok := decodeOrgCursor(q.Get("cursor")); ok { + sortKey = store.NormalizeOrgSort(c.Sort) + query = c.Query + p.HasCursor = true + p.AfterName = c.Name + p.AfterCount = c.Count + p.AfterID = c.ID + if c.Time != "" { + p.AfterTime, _ = time.Parse(time.RFC3339Nano, c.Time) + } + } + p.Sort = sortKey + p.Query = query + + all, hasMore, err := s.Store.ListPublicOrgsPage(r.Context(), p) + if err != nil { + http.Error(w, "could not load orgs", http.StatusInternalServerError) + return + } + data := map[string]any{ + "All": all, + "LoggedIn": uid != 0, + "Error": q.Get("error"), + "Sort": string(sortKey), + "Query": query, + } + if hasMore && len(all) > 0 { + data["NextCursor"] = encodeOrgCursor(nextOrgCursor(sortKey, query, all[len(all)-1])) + } + if uid != 0 { + mine, err := s.Store.ListOrgsForUser(r.Context(), uid) + if err != nil { + http.Error(w, "could not load orgs", http.StatusInternalServerError) + return + } + memberOf := map[int64]string{} + for _, m := range mine { + memberOf[m.Org.ID] = m.Role + } + data["MemberOf"] = memberOf + } + s.Render(w, r, "orgs.html", data) +} + +// renderOrgPublic renders the public-facing org page (name, description, admins, +// counts, and a map of repeaters opted into public display). +func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *store.Org, isMember bool) { + admins, err := s.Store.ListOrgAdminNames(r.Context(), org.ID) + if err != nil { + http.Error(w, "could not load org", http.StatusInternalServerError) + return + } + memberCount, repeaterCount, err := s.Store.OrgCounts(r.Context(), org.ID) + if err != nil { + http.Error(w, "could not load org", http.StatusInternalServerError) + return + } + pubReps, err := s.Store.ListPublicMapRepeaters(r.Context(), org.ID) + if err != nil { + http.Error(w, "could not load org", http.StatusInternalServerError) + return + } + uid := s.Auth.CurrentUserID(r.Context()) + s.Render(w, r, "org_public.html", map[string]any{ + "Org": org, + "Admins": admins, + "MemberCount": memberCount, + "RepeaterCount": repeaterCount, + "Repeaters": pubReps, + "HasMap": len(pubReps) > 0, + "IsMember": isMember, + "LoggedIn": uid != 0, + "CanJoin": uid != 0 && !isMember, + }) +} + +// orgCursor is an opaque directory position: the sort and search it belongs to, +// plus the sort key and id of the last org on the current page. +type orgCursor struct { + Sort string `json:"s,omitempty"` + Query string `json:"q,omitempty"` + Name string `json:"n,omitempty"` + Count int `json:"c,omitempty"` + Time string `json:"t,omitempty"` // RFC3339Nano, for the "newest" sort + ID int64 `json:"i"` +} + +// nextOrgCursor builds the cursor pointing just past last, carrying the current +// sort and search so the next page reproduces them. +func nextOrgCursor(sort store.OrgSort, query string, last store.OrgSummary) orgCursor { + c := orgCursor{Sort: string(sort), Query: query, ID: last.ID} + switch sort { + case store.OrgSortName: + c.Name = last.Name + case store.OrgSortRepeaters: + c.Count = last.RepeaterCount + case store.OrgSortNewest: + c.Time = last.CreatedAt.Format(time.RFC3339Nano) + default: // OrgSortMembers + c.Count = last.MemberCount + } + return c +} + +// encodeOrgCursor packs a directory position into an opaque, URL-safe token. +func encodeOrgCursor(c orgCursor) string { + b, _ := json.Marshal(c) + return base64.RawURLEncoding.EncodeToString(b) +} + +// decodeOrgCursor reverses encodeOrgCursor. A missing or malformed cursor decodes +// to ok=false, i.e. the first page. +func decodeOrgCursor(tok string) (orgCursor, bool) { + if tok == "" { + return orgCursor{}, false + } + b, err := base64.RawURLEncoding.DecodeString(tok) + if err != nil { + return orgCursor{}, false + } + var c orgCursor + if json.Unmarshal(b, &c) != nil { + return orgCursor{}, false + } + return c, true +} diff --git a/internal/web/orgs_cursor_test.go b/internal/marketing/orgs_cursor_test.go similarity index 98% rename from internal/web/orgs_cursor_test.go rename to internal/marketing/orgs_cursor_test.go index c95cb63..2749032 100644 --- a/internal/web/orgs_cursor_test.go +++ b/internal/marketing/orgs_cursor_test.go @@ -1,4 +1,4 @@ -package web +package marketing import "testing" diff --git a/internal/web/templates/landing.html b/internal/marketing/templates/landing.html similarity index 94% rename from internal/web/templates/landing.html rename to internal/marketing/templates/landing.html index 6ef0565..cd92c84 100644 --- a/internal/web/templates/landing.html +++ b/internal/marketing/templates/landing.html @@ -11,7 +11,7 @@ in sync without needing to share admin passwords.

- {{template "icon-key" "me-2"}}Get started + {{template "icon-key" "me-2"}}Get started Browse organizations
@@ -102,8 +102,8 @@

Ready to tend your mesh?

Register a repeater in minutes and bring your team along.

- Create your account - Sign in + Create your account + Sign in
diff --git a/internal/web/templates/org_public.html b/internal/marketing/templates/org_public.html similarity index 98% rename from internal/web/templates/org_public.html rename to internal/marketing/templates/org_public.html index 1c44c0e..c1d0d04 100644 --- a/internal/web/templates/org_public.html +++ b/internal/marketing/templates/org_public.html @@ -13,7 +13,7 @@ {{else}} - Sign in to join + Sign in to join {{end}} diff --git a/internal/web/templates/orgs.html b/internal/marketing/templates/orgs.html similarity index 97% rename from internal/web/templates/orgs.html rename to internal/marketing/templates/orgs.html index f105437..acfdb1d 100644 --- a/internal/web/templates/orgs.html +++ b/internal/marketing/templates/orgs.html @@ -61,7 +61,7 @@ {{if not .LoggedIn}}
-

Want to start or join one? Sign in or create an account →

+

Want to start or join one? Sign in or create an account →

{{end}} diff --git a/internal/store/auth_codes.go b/internal/store/auth_codes.go new file mode 100644 index 0000000..2c372ab --- /dev/null +++ b/internal/store/auth_codes.go @@ -0,0 +1,51 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// authCodeTTL bounds how long a handoff code is valid between the auth host +// minting it and the app host consuming it. Kept short: the browser redirects +// immediately, so this only needs to cover one network round-trip. +const authCodeTTL = 60 * time.Second + +// CreateAuthCode mints a single-use handoff code for userID, to be redeemed at +// the app host's callback. next is the validated app-local path to land on. +func (s *Store) CreateAuthCode(ctx context.Context, userID int64, next string) (string, error) { + code, err := randomToken() + if err != nil { + return "", err + } + if next == "" { + next = "/" + } + _, err = s.pool.Exec(ctx, + `INSERT INTO auth_codes (code, user_id, next, expiry) VALUES ($1, $2, $3, now() + $4)`, + code, userID, next, authCodeTTL) + if err != nil { + return "", fmt.Errorf("create auth code: %w", err) + } + return code, nil +} + +// ConsumeAuthCode atomically redeems a handoff code, returning its user id and +// post-auth path. The code is deleted on success (single-use) and only matches +// while unexpired. ok is false when the code is unknown, expired, or already +// used — the caller should treat that as an auth failure, not an error. +func (s *Store) ConsumeAuthCode(ctx context.Context, code string) (userID int64, next string, ok bool, err error) { + err = s.pool.QueryRow(ctx, + `DELETE FROM auth_codes WHERE code = $1 AND expiry > now() RETURNING user_id, next`, + code).Scan(&userID, &next) + if errors.Is(err, pgx.ErrNoRows) { + return 0, "", false, nil + } + if err != nil { + return 0, "", false, fmt.Errorf("consume auth code: %w", err) + } + return userID, next, true, nil +} diff --git a/internal/store/auth_codes_test.go b/internal/store/auth_codes_test.go new file mode 100644 index 0000000..2429458 --- /dev/null +++ b/internal/store/auth_codes_test.go @@ -0,0 +1,50 @@ +package store + +import "testing" + +func TestAuthCodes(t *testing.T) { + st, ctx := orgTestStore(t) + u, err := st.CreateUser(ctx, "codeuser", "") + if err != nil { + t.Fatalf("create user: %v", err) + } + + // Round-trip: a freshly minted code redeems once to its user and next path. + code, err := st.CreateAuthCode(ctx, u.ID, "/repeaters") + if err != nil { + t.Fatalf("create auth code: %v", err) + } + gotID, gotNext, ok, err := st.ConsumeAuthCode(ctx, code) + if err != nil { + t.Fatalf("consume: %v", err) + } + if !ok || gotID != u.ID || gotNext != "/repeaters" { + t.Fatalf("consume = (%d, %q, %v), want (%d, /repeaters, true)", gotID, gotNext, ok, u.ID) + } + + // Single-use: the same code can't be redeemed twice. + if _, _, ok, err := st.ConsumeAuthCode(ctx, code); err != nil || ok { + t.Fatalf("second consume = (ok=%v, err=%v), want (false, nil)", ok, err) + } + + // Unknown codes are a soft failure, not an error. + if _, _, ok, err := st.ConsumeAuthCode(ctx, "nope"); err != nil || ok { + t.Fatalf("unknown consume = (ok=%v, err=%v), want (false, nil)", ok, err) + } + + // Empty next defaults to "/". + code2, _ := st.CreateAuthCode(ctx, u.ID, "") + if _, gotNext, _, _ := st.ConsumeAuthCode(ctx, code2); gotNext != "/" { + t.Fatalf("empty next = %q, want /", gotNext) + } + + // Expired codes don't redeem. + if _, err := st.pool.Exec(ctx, + `INSERT INTO auth_codes (code, user_id, next, expiry) VALUES ('stale', $1, '/', now() - interval '1 minute')`, + u.ID); err != nil { + t.Fatalf("insert expired: %v", err) + } + if _, _, ok, err := st.ConsumeAuthCode(ctx, "stale"); err != nil || ok { + t.Fatalf("expired consume = (ok=%v, err=%v), want (false, nil)", ok, err) + } +} diff --git a/internal/store/migrations/0015_auth_codes.sql b/internal/store/migrations/0015_auth_codes.sql new file mode 100644 index 0000000..e5337c4 --- /dev/null +++ b/internal/store/migrations/0015_auth_codes.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- Single-use codes that hand off an authenticated session from the auth host to +-- the app host. The auth host mints a row after a successful sign-in and +-- redirects the browser to the app host's callback, which consumes it (deleting +-- the row) to establish its own host-scoped session. Short-lived and one-shot. +CREATE TABLE auth_codes ( + code TEXT PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + next TEXT NOT NULL DEFAULT '/', -- validated app-local post-auth path + expiry TIMESTAMPTZ NOT NULL +); +CREATE INDEX auth_codes_expiry_idx ON auth_codes (expiry); + +-- +goose Down +DROP TABLE auth_codes; diff --git a/internal/web/env.go b/internal/web/env.go new file mode 100644 index 0000000..4a52f4d --- /dev/null +++ b/internal/web/env.go @@ -0,0 +1,239 @@ +// Package web is the shared HTTP foundation: template rendering, common +// middleware/helpers, and the host dispatcher that the marketing/auth/core +// surface packages build on. It deliberately does NOT import the surface +// packages (or internal/auth), so those can import web without a cycle. +package web + +import ( + "context" + "embed" + "html/template" + "io/fs" + "net" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "github.com/jleight/meshtender/internal/config" + "github.com/jleight/meshtender/internal/identity" + "github.com/jleight/meshtender/internal/store" +) + +//go:embed templates/base.html templates/icons.html +var sharedTemplatesFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +// UserInfoFunc reports the signed-in user's display name and admin flag for the +// page chrome. ok is false when no user is signed in. Injected by the assembler +// (which wires it from the auth service + store) so web stays auth-free. +type UserInfoFunc func(ctx context.Context) (name string, canAdmin bool, ok bool) + +// Deps are the shared dependencies every surface needs. +type Deps struct { + Store *store.Store + Identity *identity.Service + Cfg *config.Config + UserInfo UserInfoFunc + LookupTXT func(name string) ([]string, error) +} + +// Env is the shared environment a surface's Handlers embeds. It carries the +// store/identity/config, the surface's renderer, and the DNS lookup. +type Env struct { + Store *store.Store + Identity *identity.Service + Cfg *config.Config + Renderer *Renderer + // LookupTXT resolves DNS TXT records; injectable so domain verification is + // testable. Defaults to net.LookupTXT. + LookupTXT func(name string) ([]string, error) +} + +// NewEnv builds a surface environment from shared Deps plus that surface's own +// page templates (composed onto the shared base layout). +func NewEnv(d Deps, surfaceTemplates fs.FS) (*Env, error) { + r, err := NewRenderer(d.Cfg, surfaceTemplates) + if err != nil { + return nil, err + } + r.userInfo = d.UserInfo + lookup := d.LookupTXT + if lookup == nil { + lookup = net.LookupTXT + } + return &Env{Store: d.Store, Identity: d.Identity, Cfg: d.Cfg, Renderer: r, LookupTXT: lookup}, nil +} + +// Render delegates to the shared renderer (convenience for handlers via Env). +func (e *Env) Render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) { + e.Renderer.Render(w, r, page, data) +} + +// Origin builds an absolute scheme://host[:port] for a sibling surface, reusing +// the port the request arrived on (one binary serves all hosts on one port). +func (e *Env) Origin(r *http.Request, host string) string { + return originFor(e.Cfg, r, host) +} + +func originFor(cfg *config.Config, r *http.Request, host string) string { + scheme := "http" + if cfg.Secure { + scheme = "https" + } + port := "" + if _, p, err := net.SplitHostPort(r.Host); err == nil && p != "" { + port = ":" + p + } + return scheme + "://" + host + port +} + +// Renderer composes content pages onto the shared base layout and executes them, +// injecting the cross-host URLs and current-user info every page's chrome needs. +type Renderer struct { + cfg *config.Config + pages map[string]*template.Template + userInfo UserInfoFunc +} + +// NewRenderer parses the shared base layout (base.html + icons.html) and composes +// each of the surface's own *.html pages onto it. Each page redefines the +// content/title/header blocks, so every page gets its own cloned template set. +func NewRenderer(cfg *config.Config, surfaceTemplates fs.FS) (*Renderer, error) { + base, err := template.New("").ParseFS(sharedTemplatesFS, "templates/base.html", "templates/icons.html") + if err != nil { + return nil, err + } + all, err := fs.Glob(surfaceTemplates, "templates/*.html") + if err != nil { + return nil, err + } + // The shared layout/partials are composed in above; skip them if they happen + // to live in the surface's template dir. + shared := map[string]bool{"templates/base.html": true, "templates/icons.html": true} + pages := map[string]*template.Template{} + for _, p := range all { + if shared[p] { + continue + } + clone, err := base.Clone() + if err != nil { + return nil, err + } + if _, err := clone.ParseFS(surfaceTemplates, p); err != nil { + return nil, err + } + pages[strings.TrimPrefix(p, "templates/")] = clone + } + return &Renderer{cfg: cfg, pages: pages}, nil +} + +// Pages returns the composed page templates keyed by file name. Intended for +// startup/composition tests. +func (rn *Renderer) Pages() map[string]*template.Template { return rn.pages } + +// Render executes a content page within its layout (default "base"; pages can +// opt into another via the "Layout" data key). +func (rn *Renderer) Render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) { + if data == nil { + data = map[string]any{} + } + // Absolute origins for cross-host links (sessions are host-scoped, so a link + // from root/app to a sibling surface must be absolute). Empty in single-host + // mode, where templates' relative paths already resolve correctly. + if rn.cfg.AuthHost != "" { + data["AppURL"] = originFor(rn.cfg, r, rn.cfg.PrimaryHost) + data["AuthURL"] = originFor(rn.cfg, r, rn.cfg.AuthHost) + if rn.cfg.RootHost != "" { + data["RootURL"] = originFor(rn.cfg, r, rn.cfg.RootHost) + } + } + if rn.userInfo != nil { + if name, canAdmin, ok := rn.userInfo(r.Context()); ok { + data["UserName"] = name + data["CanAdmin"] = canAdmin + } + } + t, ok := rn.pages[page] + if !ok { + http.Error(w, "unknown page: "+page, http.StatusInternalServerError) + return + } + layout, _ := data["Layout"].(string) + if layout == "" { + layout = "base" + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := t.ExecuteTemplate(w, layout, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// --- shared HTTP helpers (used across surfaces) --- + +// CommonMiddleware applies the generic middleware every surface needs. Session +// loading is added by each surface (it owns the auth service), so web stays +// auth-free. chi requires all Use() calls before any route is registered. +func (e *Env) CommonMiddleware(r chi.Router) { + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Recoverer) +} + +// SharedRoutes registers endpoints every surface needs (health, static assets). +func (e *Env) SharedRoutes(r chi.Router) { + r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + staticSub, _ := fs.Sub(staticFS, "static") + r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) +} + +// HostWithoutPort strips a trailing :port from a request Host, if present. +func HostWithoutPort(host string) string { + if h, _, err := net.SplitHostPort(host); err == nil { + return h + } + return host +} + +// RedirectFlash 303-redirects to path with a single flash query param (key=msg, +// escaped), picking ? or & as the separator. +func RedirectFlash(w http.ResponseWriter, r *http.Request, path, key, msg string) { + sep := "?" + if strings.Contains(path, "?") { + sep = "&" + } + http.Redirect(w, r, path+sep+key+"="+url.QueryEscape(msg), http.StatusSeeOther) +} + +// RedirectErr is RedirectFlash with the conventional "error" key. +func RedirectErr(w http.ResponseWriter, r *http.Request, path, msg string) { + RedirectFlash(w, r, path, "error", msg) +} + +// Dispatcher routes by hostname across the surfaces. authH/rootH/appH are the +// per-surface handlers; rootH may be nil (single-host). When AuthHost is empty, +// appH serves everything (single-host mode). +func Dispatcher(cfg *config.Config, authH, rootH, appH http.Handler) http.Handler { + if cfg.AuthHost == "" { + return appH + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := HostWithoutPort(r.Host) + switch { + case strings.EqualFold(host, cfg.AuthHost): + authH.ServeHTTP(w, r) + case rootH != nil && strings.EqualFold(host, cfg.RootHost): + rootH.ServeHTTP(w, r) + case cfg.WWWHost != "" && strings.EqualFold(host, cfg.WWWHost): + http.Redirect(w, r, originFor(cfg, r, cfg.RootHost)+r.URL.RequestURI(), http.StatusMovedPermanently) + default: + appH.ServeHTTP(w, r) + } + }) +} diff --git a/internal/web/orgs.go b/internal/web/orgs.go deleted file mode 100644 index e4c0273..0000000 --- a/internal/web/orgs.go +++ /dev/null @@ -1,388 +0,0 @@ -package web - -import ( - "encoding/base64" - "encoding/json" - "errors" - "net/http" - "strconv" - "strings" - "time" - - "github.com/go-chi/chi/v5" - - "github.com/jleight/meshtender/internal/store" -) - -// orgID resolves the {id} URL param (a slug) to the internal int64 primary key. -func (s *Server) orgID(r *http.Request) (int64, bool) { - id, err := s.store.OrgIDBySlug(r.Context(), chi.URLParam(r, "id")) - return id, err == nil -} - -// orgParam returns the raw slug from the {id} URL param, for building redirects. -func orgParam(r *http.Request) string { return chi.URLParam(r, "id") } - -func orgErr(w http.ResponseWriter, r *http.Request, msg string) { - redirectErr(w, r, "/orgs/"+orgParam(r), msg) -} - -// pageOrgs is the public organization directory. Everyone sees the list; signed- -// in users also get the create form and a marker on orgs they belong to. The -// directory is keyset-paginated via an opaque ?cursor token. -func (s *Server) pageOrgs(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) - q := r.URL.Query() - - sortKey := store.NormalizeOrgSort(q.Get("sort")) - query := strings.TrimSpace(q.Get("q")) - if len(query) > 100 { - query = query[:100] - } - - var p store.OrgListParams - // A cursor carries the authoritative sort, search, and keyset position for - // the page it points at, so paging stays consistent without re-sending the - // form; the sort/q params only matter on the first (cursorless) page. - if c, ok := decodeOrgCursor(q.Get("cursor")); ok { - sortKey = store.NormalizeOrgSort(c.Sort) - query = c.Query - p.HasCursor = true - p.AfterName = c.Name - p.AfterCount = c.Count - p.AfterID = c.ID - if c.Time != "" { - p.AfterTime, _ = time.Parse(time.RFC3339Nano, c.Time) - } - } - p.Sort = sortKey - p.Query = query - - all, hasMore, err := s.store.ListPublicOrgsPage(r.Context(), p) - if err != nil { - http.Error(w, "could not load orgs", http.StatusInternalServerError) - return - } - data := map[string]any{ - "All": all, - "LoggedIn": uid != 0, - "Error": q.Get("error"), - "Sort": string(sortKey), - "Query": query, - } - if hasMore && len(all) > 0 { - data["NextCursor"] = encodeOrgCursor(nextOrgCursor(sortKey, query, all[len(all)-1])) - } - if uid != 0 { - mine, err := s.store.ListOrgsForUser(r.Context(), uid) - if err != nil { - http.Error(w, "could not load orgs", http.StatusInternalServerError) - return - } - memberOf := map[int64]string{} - for _, m := range mine { - memberOf[m.Org.ID] = m.Role - } - data["MemberOf"] = memberOf - } - s.render(w, r, "orgs.html", data) -} - -// orgCursor is an opaque directory position: the sort and search it belongs to, -// plus the sort key and id of the last org on the current page. The next page -// seeks strictly past it. Only the key field relevant to Sort is populated. -type orgCursor struct { - Sort string `json:"s,omitempty"` - Query string `json:"q,omitempty"` - Name string `json:"n,omitempty"` - Count int `json:"c,omitempty"` - Time string `json:"t,omitempty"` // RFC3339Nano, for the "newest" sort - ID int64 `json:"i"` -} - -// nextOrgCursor builds the cursor pointing just past last, carrying the current -// sort and search so the next page reproduces them. -func nextOrgCursor(sort store.OrgSort, query string, last store.OrgSummary) orgCursor { - c := orgCursor{Sort: string(sort), Query: query, ID: last.ID} - switch sort { - case store.OrgSortName: - c.Name = last.Name - case store.OrgSortRepeaters: - c.Count = last.RepeaterCount - case store.OrgSortNewest: - c.Time = last.CreatedAt.Format(time.RFC3339Nano) - default: // OrgSortMembers - c.Count = last.MemberCount - } - return c -} - -// encodeOrgCursor packs a directory position into an opaque, URL-safe token. -func encodeOrgCursor(c orgCursor) string { - b, _ := json.Marshal(c) - return base64.RawURLEncoding.EncodeToString(b) -} - -// decodeOrgCursor reverses encodeOrgCursor. A missing or malformed cursor -// decodes to ok=false, i.e. the first page — so a tampered URL just resets -// paging rather than erroring. -func decodeOrgCursor(tok string) (orgCursor, bool) { - if tok == "" { - return orgCursor{}, false - } - b, err := base64.RawURLEncoding.DecodeString(tok) - if err != nil { - return orgCursor{}, false - } - var c orgCursor - if json.Unmarshal(b, &c) != nil { - return orgCursor{}, false - } - return c, true -} - -// pageNewOrg renders the standalone "create an organization" form. -func (s *Server) pageNewOrg(w http.ResponseWriter, r *http.Request) { - s.render(w, r, "new_org.html", map[string]any{ - "Error": r.URL.Query().Get("error"), - }) -} - -// handleCreateOrg creates an org with the current user as its first admin. -func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) - name := strings.TrimSpace(r.FormValue("name")) - if name == "" || len(name) > 80 { - redirectErr(w, r, "/orgs/new", "Enter an organization name.") - return - } - org, err := s.store.CreateOrg(r.Context(), name, uid) - if err != nil { - redirectErr(w, r, "/orgs/new", "Could not create organization.") - return - } - http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther) -} - -// pageOrg shows an org's home. Members get the full management view; everyone -// else (anonymous or non-member) gets the public view. Members can preview the -// public view with ?view=public. -func (s *Server) pageOrg(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) - id, ok := s.orgID(r) - if !ok { - http.NotFound(w, r) - return - } - org, err := s.store.GetOrg(r.Context(), id) - if err != nil { - http.NotFound(w, r) - return - } - role, isMember, err := s.store.OrgRole(r.Context(), id, uid) - if err != nil { - http.Error(w, "could not load org", http.StatusInternalServerError) - return - } - if !isMember || r.URL.Query().Get("view") == "public" { - s.renderOrgPublic(w, r, org, isMember) - return - } - members, err := s.store.ListOrgMembers(r.Context(), id) - if err != nil { - http.Error(w, "could not load members", http.StatusInternalServerError) - return - } - repeaters, err := s.store.ListOrgRepeaters(r.Context(), id) - if err != nil { - http.Error(w, "could not load repeaters", http.StatusInternalServerError) - return - } - mapped := 0 - for _, rp := range repeaters { - if rp.HasLocation { - mapped++ - } - } - adminCount := 0 - for _, m := range members { - if m.Role == "admin" { - adminCount++ - } - } - isAdmin := role == "admin" - data := map[string]any{ - "Org": org, - "Role": role, - "IsAdmin": isAdmin, - "Members": members, - "Repeaters": repeaters, - "HasMap": mapped > 0, - "MemberCount": len(members), - "RepeaterCount": len(repeaters), - "AdminCount": adminCount, - "Self": uid, - "Error": r.URL.Query().Get("error"), - } - if isAdmin { - domains, err := s.store.ListOrgDomains(r.Context(), id) - if err != nil { - http.Error(w, "could not load domains", http.StatusInternalServerError) - return - } - data["Domains"] = domains - data["TXTName"] = txtRecordPrefix // org appends its hostname for the full record name - } - s.render(w, r, "org.html", data) -} - -// renderOrgPublic renders the public-facing org page (name, description, admins, -// counts, and a map of repeaters opted into public display). -func (s *Server) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *store.Org, isMember bool) { - admins, err := s.store.ListOrgAdminNames(r.Context(), org.ID) - if err != nil { - http.Error(w, "could not load org", http.StatusInternalServerError) - return - } - memberCount, repeaterCount, err := s.store.OrgCounts(r.Context(), org.ID) - if err != nil { - http.Error(w, "could not load org", http.StatusInternalServerError) - return - } - pubReps, err := s.store.ListPublicMapRepeaters(r.Context(), org.ID) - if err != nil { - http.Error(w, "could not load org", http.StatusInternalServerError) - return - } - uid := s.auth.CurrentUserID(r.Context()) - s.render(w, r, "org_public.html", map[string]any{ - "Org": org, - "Admins": admins, - "MemberCount": memberCount, - "RepeaterCount": repeaterCount, - "Repeaters": pubReps, - "HasMap": len(pubReps) > 0, - "IsMember": isMember, - "LoggedIn": uid != 0, - "CanJoin": uid != 0 && !isMember, - }) -} - -// handleEditOrg updates an org's slug, name, description, and region (admin only). -func (s *Server) handleEditOrg(w http.ResponseWriter, r *http.Request) { - id, ok := s.requireOrgAdmin(w, r) - if !ok { - return - } - name := strings.TrimSpace(r.FormValue("name")) - slug := strings.ToLower(strings.TrimSpace(r.FormValue("slug"))) - desc := strings.TrimSpace(r.FormValue("description")) - region := strings.TrimSpace(r.FormValue("region")) - if name == "" || len(name) > 80 { - orgErr(w, r, "Enter an organization name.") - return - } - if !store.ValidOrgSlug(slug) { - orgErr(w, r, "Slug must be 3–40 lowercase letters, numbers, and hyphens (and not reserved).") - return - } - if len(desc) > 2000 { - desc = desc[:2000] - } - if len(region) > 120 { - region = region[:120] - } - if err := s.store.UpdateOrg(r.Context(), id, slug, name, desc, region); errors.Is(err, store.ErrDuplicate) { - orgErr(w, r, "That URL slug is already taken.") - return - } else if err != nil { - orgErr(w, r, "Could not save changes.") - return - } - // The slug may have changed; redirect to the new canonical URL. - http.Redirect(w, r, "/orgs/"+slug, http.StatusSeeOther) -} - -// requireOrgAdmin resolves {id} and verifies the current user is an org admin. -func (s *Server) requireOrgAdmin(w http.ResponseWriter, r *http.Request) (int64, bool) { - uid := s.auth.CurrentUserID(r.Context()) - id, ok := s.orgID(r) - if !ok { - http.NotFound(w, r) - return 0, false - } - admin, err := s.store.IsOrgAdmin(r.Context(), id, uid) - if err != nil || !admin { - http.NotFound(w, r) - return 0, false - } - return id, true -} - -func (s *Server) handleLeaveOrg(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) - id, ok := s.orgID(r) - if !ok { - http.NotFound(w, r) - return - } - err := s.store.RemoveOrgMember(r.Context(), id, uid) - if errors.Is(err, store.ErrLastAdmin) { - orgErr(w, r, "You're the last admin — promote someone else first.") - return - } - if err != nil { - orgErr(w, r, "Could not leave.") - return - } - http.Redirect(w, r, "/orgs", http.StatusSeeOther) -} - -// handleJoinOrg adds the current user to an org as a member. Orgs are publicly -// listed and anyone signed in may join directly from the org page (idempotent). -func (s *Server) handleJoinOrg(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) - id, ok := s.orgID(r) - if !ok { - http.NotFound(w, r) - return - } - if err := s.store.AddOrgMember(r.Context(), id, uid, "member"); err != nil { - orgErr(w, r, "Could not join.") - return - } - http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) -} - -// handleSetOrgMember promotes/demotes/removes a member (admin only). -func (s *Server) handleSetOrgMember(w http.ResponseWriter, r *http.Request) { - id, ok := s.requireOrgAdmin(w, r) - if !ok { - return - } - targetID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64) - if err != nil { - http.NotFound(w, r) - return - } - switch r.FormValue("action") { - case "promote": - err = s.store.SetOrgMemberRole(r.Context(), id, targetID, "admin") - case "demote": - err = s.store.SetOrgMemberRole(r.Context(), id, targetID, "member") - case "remove": - err = s.store.RemoveOrgMember(r.Context(), id, targetID) - default: - orgErr(w, r, "Unknown action.") - return - } - if errors.Is(err, store.ErrLastAdmin) { - orgErr(w, r, "That would leave the org with no admin.") - return - } - if err != nil { - orgErr(w, r, "Could not update member.") - return - } - http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) -} diff --git a/internal/web/ratelimit.go b/internal/web/ratelimit.go index d4f1af5..7f4a843 100644 --- a/internal/web/ratelimit.go +++ b/internal/web/ratelimit.go @@ -27,7 +27,7 @@ type tokenBucket struct { // newRateLimiter builds a limiter allowing bursts up to burst requests, then // one further request every refill interval. -func newRateLimiter(burst float64, refill time.Duration) *rateLimiter { +func NewRateLimiter(burst float64, refill time.Duration) *rateLimiter { return &rateLimiter{ buckets: map[string]*tokenBucket{}, ratePerSec: 1 / refill.Seconds(), @@ -76,7 +76,7 @@ func (l *rateLimiter) sweep(now time.Time) { // middleware rejects requests from a client that has exceeded its rate, keyed by // client IP. -func (l *rateLimiter) middleware(next http.Handler) http.Handler { +func (l *rateLimiter) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !l.allow(clientIP(r)) { http.Error(w, "Too many attempts. Please wait a moment and try again.", http.StatusTooManyRequests) diff --git a/internal/web/ratelimit_test.go b/internal/web/ratelimit_test.go index a26f2ef..45710d3 100644 --- a/internal/web/ratelimit_test.go +++ b/internal/web/ratelimit_test.go @@ -7,7 +7,7 @@ import ( func TestRateLimiterBurstThenThrottle(t *testing.T) { now := time.Unix(0, 0) - l := newRateLimiter(3, time.Second) + l := NewRateLimiter(3, time.Second) l.now = func() time.Time { return now } // Burst of 3 is allowed from a cold start. @@ -36,7 +36,7 @@ func TestRateLimiterBurstThenThrottle(t *testing.T) { func TestRateLimiterSweepReclaims(t *testing.T) { now := time.Unix(0, 0) - l := newRateLimiter(2, time.Second) + l := NewRateLimiter(2, time.Second) l.now = func() time.Time { return now } l.allow("1.2.3.4") diff --git a/internal/web/static/webauthn.js b/internal/web/static/webauthn.js index c905bd1..982d765 100644 --- a/internal/web/static/webauthn.js +++ b/internal/web/static/webauthn.js @@ -129,7 +129,7 @@ async function addPasskey() { }); const data = await result.json().catch(() => ({})); if (!result.ok) throw new Error(data.error || "could not add passkey"); - window.location = "/account?ok=" + encodeURIComponent("Passkey added."); + window.location = "/account?pk=" + encodeURIComponent("Passkey added."); } catch (e) { setStatus("Error: " + e.message); } diff --git a/internal/web/templates/account.html b/internal/web/templates/account.html deleted file mode 100644 index f2e33f2..0000000 --- a/internal/web/templates/account.html +++ /dev/null @@ -1,115 +0,0 @@ -{{define "title"}}Account settings · MeshTender{{end}} -{{define "header"}} -
-
-
Your account
-

Account settings

-
-
-{{end}} -{{define "content"}} -{{if .Error}}
{{.Error}}
{{end}} -{{if .OK}}
{{.OK}}
{{end}} - -
- -
-
-

Profile

-
-
-
- - - Your username can't be changed. -
-
- - -
- -
-
-
-
- - -
-
-
-

Passkeys

-
- -
-
-
-

Passkeys let you sign in with your device's fingerprint, face, or PIN — no password to remember or leak.

-

-
- {{if .Passkeys}} -
- {{range .Passkeys}} -
- {{template "icon-key" ""}} -
-
{{.ShortID}}…
-
Added {{.Added.Format "Jan 2, 2006"}}
-
-
- - -
-
- {{end}} -
- {{else}} -

No passkeys yet. Add one for faster, more secure sign-in.

- {{end}} -
-
- - -
-
-

Password

-
- {{if .HasPassword}} -

Change the password you use as a fallback to passkeys.

- {{else}} -

You don't have a password set — you sign in with a passkey. You can add a password as a fallback.

- {{end}} -
- {{if .HasPassword}} -
- - -
- {{end}} -
- - -
- -
- - {{if .HasPassword}} -
-
- - - Only available if you have at least one passkey. -
- {{end}} -
-
-
-
- - -{{end}} diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index 97b7e95..b54cc55 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -17,7 +17,7 @@ - + {{template "icon-tower" "me-1"}}MeshTender {{else}} - {{template "icon-login" "me-1"}}Sign in + {{template "icon-login" "me-1"}}Sign in {{end}} diff --git a/internal/web/web.go b/internal/web/web.go deleted file mode 100644 index d694213..0000000 --- a/internal/web/web.go +++ /dev/null @@ -1,430 +0,0 @@ -// Package web assembles the HTTP server: routing, templates, and static -// assets, wiring together auth, identity, and the data store. -package web - -import ( - "embed" - "html/template" - "io/fs" - "net" - "net/http" - "net/url" - "strings" - "time" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - - "github.com/jleight/meshtender/internal/auth" - "github.com/jleight/meshtender/internal/config" - "github.com/jleight/meshtender/internal/identity" - "github.com/jleight/meshtender/internal/store" -) - -//go:embed templates/*.html -var templatesFS embed.FS - -//go:embed static/* -var staticFS embed.FS - -// Server holds the dependencies shared by HTTP handlers. -type Server struct { - store *store.Store - auth *auth.Service - identity *identity.Service - cfg *config.Config - // pages holds each content page pre-composed with the shared layouts and - // partials, keyed by file name (e.g. "dashboard.html"). Built once at - // startup since the templates are embedded and never change at runtime. - pages map[string]*template.Template - router chi.Router - // lookupTXT resolves DNS TXT records; injectable so domain verification is - // testable. Defaults to net.LookupTXT. - lookupTXT func(name string) ([]string, error) -} - -// NewServer constructs the HTTP server and its routes. -func NewServer(st *store.Store, authSvc *auth.Service, idSvc *identity.Service, cfg *config.Config) (*Server, error) { - pages, err := buildPages() - if err != nil { - return nil, err - } - s := &Server{store: st, auth: authSvc, identity: idSvc, cfg: cfg, pages: pages, lookupTXT: net.LookupTXT} - s.routes() - return s, nil -} - -// sharedTemplates are the layouts and partials shared by every page (the root -// layouts live in base.html; reusable snippets like the icon set in icons.html). -// They define no "content"/"title" blocks of their own, so they can be the -// common base each page is composed onto. -var sharedTemplates = []string{"templates/base.html", "templates/icons.html"} - -// buildPages composes each content page with the shared layouts/partials once, -// returning a map keyed by the page's file name. Each page redefines the -// "content"/"title"/"header" blocks, so every page needs its own template set -// rather than one shared set (where the blocks would collide). -func buildPages() (map[string]*template.Template, error) { - base, err := template.New("").ParseFS(templatesFS, sharedTemplates...) - if err != nil { - return nil, err - } - all, err := fs.Glob(templatesFS, "templates/*.html") - if err != nil { - return nil, err - } - shared := map[string]bool{} - for _, p := range sharedTemplates { - shared[p] = true - } - pages := map[string]*template.Template{} - for _, p := range all { - if shared[p] { - continue - } - clone, err := base.Clone() - if err != nil { - return nil, err - } - if _, err := clone.ParseFS(templatesFS, p); err != nil { - return nil, err - } - pages[strings.TrimPrefix(p, "templates/")] = clone - } - return pages, nil -} - -// Handler returns the root HTTP handler. -func (s *Server) Handler() http.Handler { return s.router } - -func (s *Server) routes() { - r := chi.NewRouter() - r.Use(middleware.RequestID) - r.Use(middleware.RealIP) - r.Use(middleware.Recoverer) - // scs must wrap everything that touches the session. - r.Use(s.auth.Sessions.LoadAndSave) - // Custom org domains: a verified CNAMEd host serves that org's public page. - r.Use(s.customDomain) - - r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("ok")) - }) - - staticSub, _ := fs.Sub(staticFS, "static") - r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) - - // Root: marketing landing for visitors, overview dashboard for members. - r.Get("/", s.pageHome) - - // Public auth pages + JSON ceremony endpoints. - r.Get("/login", s.pageLogin) - r.Get("/signup", s.pageSignup) - // Throttle credential submission per client IP to blunt password guessing - // and signup spam. Allows a burst (e.g. fat-fingered retries), then ~1 try - // every 6s; bcrypt's cost is the second line of defense. - authLimit := newRateLimiter(10, 6*time.Second) - r.With(authLimit.middleware).Post("/login/password", s.auth.LoginPassword) - r.With(authLimit.middleware).Post("/signup/password", s.auth.SignupPassword) - r.Post("/api/register/begin", s.auth.RegisterBegin) - r.Post("/api/register/finish", s.auth.RegisterFinish) - r.Post("/api/login/begin", s.auth.LoginBegin) - r.Post("/api/login/finish", s.auth.LoginFinish) - r.Post("/api/login/discoverable/begin", s.auth.LoginDiscoverableBegin) - r.Post("/api/login/discoverable/finish", s.auth.LoginDiscoverableFinish) - r.Get("/invite/{token}", s.pageInvite) // public: handles logged-out state - r.Get("/orgs", s.pageOrgs) // public: organization directory - r.Get("/orgs/{id}", s.pageOrg) // public: org page (public view for non-members) - - // Authenticated area. - r.Group(func(r chi.Router) { - r.Use(s.auth.RequireUser) - r.Get("/repeaters", s.pageRepeaters) - r.Post("/logout", s.handleLogout) - r.Get("/account", s.pageAccount) - r.Post("/account/profile", s.handleUpdateProfile) - r.Post("/account/password", s.handleChangePassword) - r.Post("/account/passkeys/delete", s.handleDeletePasskey) - r.Get("/repeaters/add", s.pageAddRepeater) - r.Post("/repeaters", s.handleAddRepeater) - r.Get("/repeaters/{id}", s.pageRepeater) - r.Get("/repeaters/{id}/added", s.pageRepeaterAdded) - r.Get("/repeaters/{id}/edit", s.pageEditRepeater) - r.Post("/repeaters/{id}/edit", s.handleEditRepeater) - r.Get("/repeaters/{id}/delete", s.pageDeleteRepeater) - r.Post("/repeaters/{id}/delete", s.handleDeleteRepeater) - r.Get("/repeaters/{id}/confirm", s.pageConfirm) - r.Get("/repeaters/{id}/ws", s.wsConfirm) - r.Get("/repeaters/{id}/console", s.pageConsole) - r.Get("/repeaters/{id}/console/ws", s.wsConsole) - r.Get("/repeaters/{id}/log", s.pageCommandLog) - r.Get("/repeaters/{id}/share", s.pageShare) - r.Post("/repeaters/{id}/share/link", s.handleCreateLink) - r.Post("/repeaters/{id}/share/link/delete", s.handleDeleteInvite) - r.Post("/repeaters/{id}/unshare", s.handleUnshare) - r.Get("/repeaters/{id}/share/{userID}/commands", s.pageShareCommands) - r.Post("/repeaters/{id}/share/{userID}/commands", s.handleSetShareCommands) - r.Get("/repeaters/{id}/orgs/{orgID}/contribute", s.pageContribute) - r.Post("/repeaters/{id}/orgs/{orgID}/contribute", s.handleContribute) - r.Post("/repeaters/{id}/orgs/{orgID}/withdraw", s.handleWithdraw) - r.Post("/invite/{token}/accept", s.handleAcceptInvite) - - r.Get("/orgs/new", s.pageNewOrg) - r.Post("/orgs", s.handleCreateOrg) - r.Post("/orgs/{id}/edit", s.handleEditOrg) - r.Post("/orgs/{id}/join", s.handleJoinOrg) - r.Post("/orgs/{id}/leave", s.handleLeaveOrg) - r.Post("/orgs/{id}/members/{userID}", s.handleSetOrgMember) - r.Get("/orgs/{id}/permissions", s.pageOrgPermissions) - r.Post("/orgs/{id}/permissions", s.handleSaveOrgPermissions) - r.Post("/orgs/{id}/domains", s.handleAddOrgDomain) - r.Post("/orgs/{id}/domains/verify", s.handleVerifyOrgDomain) - r.Post("/orgs/{id}/domains/delete", s.handleDeleteOrgDomain) - - r.Route("/admin", func(r chi.Router) { - r.With(s.requireCap(capAny)).Get("/", s.pageAdmin) - r.With(s.requireCap(capCatalog)).Get("/catalog", s.pageCatalog) - r.With(s.requireCap(capCatalog)).Post("/catalog/{id}", s.handleUpdateCommand) - r.With(s.requireCap(capUsers)).Get("/users", s.pageUsers) - r.With(s.requireCap(capUsers)).Post("/users/{id}", s.handleSetUserCaps) - }) - }) - - s.router = r -} - -// customDomain serves an org's public page when the request arrives on one of -// the org's verified custom domains. Any non-root path is redirected to the -// canonical host, where auth and management (and the WebAuthn RP) live. -func (s *Server) customDomain(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - host := hostWithoutPort(r.Host) - if host == "" || host == "localhost" || strings.EqualFold(host, s.cfg.PrimaryHost) { - next.ServeHTTP(w, r) - return - } - org, ok, err := s.store.OrgByVerifiedDomain(r.Context(), strings.ToLower(host)) - if err != nil || !ok { - next.ServeHTTP(w, r) // unknown host: serve the app normally - return - } - if r.URL.Path == "/" { - s.renderOrgPublic(w, r, org, false) - return - } - http.Redirect(w, r, "https://"+s.cfg.PrimaryHost+r.URL.RequestURI(), http.StatusFound) - }) -} - -// hostWithoutPort strips a trailing :port from a request Host, if present. -func hostWithoutPort(host string) string { - if h, _, err := net.SplitHostPort(host); err == nil { - return h - } - return host -} - -// redirectFlash 303-redirects to path with a single flash query param (key=msg, -// the value escaped), picking ? or & as the separator. The per-page error/flash -// helpers (orgErr, shareErr, accountRedirect, …) all build on it. -func redirectFlash(w http.ResponseWriter, r *http.Request, path, key, msg string) { - sep := "?" - if strings.Contains(path, "?") { - sep = "&" - } - http.Redirect(w, r, path+sep+key+"="+url.QueryEscape(msg), http.StatusSeeOther) -} - -// redirectErr is redirectFlash with the conventional "error" key. -func redirectErr(w http.ResponseWriter, r *http.Request, path, msg string) { - redirectFlash(w, r, path, "error", msg) -} - -// render executes a content template within the base layout. -func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) { - if data == nil { - data = map[string]any{} - } - // Attach the current user's display name for the layout, when authenticated. - if uid := s.auth.CurrentUserID(r.Context()); uid != 0 { - if u, err := s.store.GetUserByID(r.Context(), uid); err == nil { - data["UserName"] = u.Name() - data["CanAdmin"] = u.CapManageUsers || u.CapManageCatalog - } - } - t, ok := s.pages[page] - if !ok { - http.Error(w, "unknown page: "+page, http.StatusInternalServerError) - return - } - // Pages may opt into an alternate root layout (e.g. the centered "authbase" - // for sign-in/up) via the "Layout" data key; everything else uses "base". - layout, _ := data["Layout"].(string) - if layout == "" { - layout = "base" - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := t.ExecuteTemplate(w, layout, data); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - -func (s *Server) pageLogin(w http.ResponseWriter, r *http.Request) { - if s.auth.CurrentUserID(r.Context()) != 0 { - http.Redirect(w, r, "/", http.StatusSeeOther) - return - } - s.auth.SetNext(r.Context(), r.URL.Query().Get("next")) - s.render(w, r, "login.html", map[string]any{ - "Layout": "authbase", - "Error": r.URL.Query().Get("error"), - "Next": r.URL.Query().Get("next"), - }) -} - -func (s *Server) pageSignup(w http.ResponseWriter, r *http.Request) { - if s.auth.CurrentUserID(r.Context()) != 0 { - http.Redirect(w, r, "/", http.StatusSeeOther) - return - } - s.auth.SetNext(r.Context(), r.URL.Query().Get("next")) - s.render(w, r, "signup.html", map[string]any{ - "Layout": "authbase", - "Error": r.URL.Query().Get("error"), - "Next": r.URL.Query().Get("next"), - }) -} - -// pageHome serves the marketing landing page to visitors and the overview -// dashboard to signed-in members. -func (s *Server) pageHome(w http.ResponseWriter, r *http.Request) { - if s.auth.CurrentUserID(r.Context()) == 0 { - s.render(w, r, "landing.html", map[string]any{"Layout": "landingbase"}) - return - } - s.pageDashboard(w, r) -} - -// pageRepeaters lists every repeater the user owns or has been shared, split -// into owned and shared sections. -func (s *Server) pageRepeaters(w http.ResponseWriter, r *http.Request) { - uid := s.auth.CurrentUserID(r.Context()) - repeaters, err := s.store.ListRepeatersForUser(r.Context(), uid) - if err != nil { - http.Error(w, "could not load repeaters", http.StatusInternalServerError) - return - } - reconsent, err := s.store.OwnedRepeatersNeedingReconsent(r.Context(), uid) - if err != nil { - http.Error(w, "could not load org state", http.StatusInternalServerError) - return - } - shareCounts, err := s.store.RepeaterSharingCounts(r.Context(), uid) - if err != nil { - http.Error(w, "could not load sharing", http.StatusInternalServerError) - return - } - owned, shared := splitOwnedShared(repeaters) - s.render(w, r, "repeaters.html", map[string]any{ - "Owned": owned, - "Shared": shared, - "Reconsent": reconsent, - "ShareCounts": shareCounts, - "Error": r.URL.Query().Get("error"), - }) -} - -// pageDashboard renders the signed-in overview: summary stats, short lists of -// repeaters and organizations, a map of owned repeaters, and recent activity. -func (s *Server) pageDashboard(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - uid := s.auth.CurrentUserID(ctx) - - repeaters, err := s.store.ListRepeatersForUser(ctx, uid) - if err != nil { - http.Error(w, "could not load repeaters", http.StatusInternalServerError) - return - } - owned, shared := splitOwnedShared(repeaters) - - reconsent, err := s.store.OwnedRepeatersNeedingReconsent(ctx, uid) - if err != nil { - http.Error(w, "could not load org state", http.StatusInternalServerError) - return - } - orgs, err := s.store.ListOrgsForUser(ctx, uid) - if err != nil { - http.Error(w, "could not load organizations", http.StatusInternalServerError) - return - } - recent, err := s.store.ListRecentCommandsForOwner(ctx, uid, 8) - if err != nil { - http.Error(w, "could not load activity", http.StatusInternalServerError) - return - } - shareCounts, err := s.store.RepeaterSharingCounts(ctx, uid) - if err != nil { - http.Error(w, "could not load sharing", http.StatusInternalServerError) - return - } - - // Summary counts and the owned repeaters that have a stored location. - confirmed, unconfirmed := 0, 0 - var mapped []*store.Repeater - for _, rp := range owned { - if rp.Confirmed { - confirmed++ - } else { - unconfirmed++ - } - if rp.Latitude != nil && rp.Longitude != nil { - mapped = append(mapped, rp) - } - } - - s.render(w, r, "dashboard.html", map[string]any{ - "OwnedCount": len(owned), - "SharedCount": len(shared), - "OrgCount": len(orgs), - "Confirmed": confirmed, - "Unconfirmed": unconfirmed, - "Owned": first(owned, 5), - "Shared": first(shared, 5), - "Orgs": first(orgs, 5), - "Mapped": mapped, - "Recent": recent, - "Reconsent": reconsent, - "ShareCounts": shareCounts, - "Error": r.URL.Query().Get("error"), - }) -} - -// splitOwnedShared partitions a repeater list into owned and directly-shared. -func splitOwnedShared(repeaters []*store.Repeater) (owned, shared []*store.Repeater) { - for _, rp := range repeaters { - if rp.Shared { - shared = append(shared, rp) - } else { - owned = append(owned, rp) - } - } - return owned, shared -} - -// first returns the first n elements of s, or all of them if there are fewer. -func first[T any](s []T, n int) []T { - if len(s) > n { - return s[:n] - } - return s -} - -func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { - next := r.FormValue("next") - _ = s.auth.Logout(r.Context()) - dest := "/login" - if auth.SafeLocalPath(next) { - dest = "/login?next=" + url.QueryEscape(next) - } - http.Redirect(w, r, dest, http.StatusSeeOther) -}