Refactor into multiple modules

This commit is contained in:
Jonathon Leight committed 2026-06-21 19:21:17 -04:00
1 parent d6ae282c8c
commit 1ba3dece4f
71 files changed
+2533 -1234

No files matched your search

+2 -1
View File
@@ -8,5 +8,6 @@
docker-compose.yml
README.md
/meshtender
/cmd/meshtender/meshtender
/meshtender
/certs
+34
View File
@@ -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
+5 -3
View File
@@ -1,4 +1,6 @@
/meshtender
/cmd/meshtender/meshtender
*.exe
.env
/certs/
/cmd/meshtender/meshtender
/meshtender
*.exe
+14 -12
View File
@@ -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
}
}()
@@ -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.")
}
+23 -4
View File
@@ -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 ---
+216
View File
@@ -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
}
+2 -2
View File
@@ -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) {
+37 -3
View File
@@ -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.
+123
View File
@@ -0,0 +1,123 @@
{{define "title"}}Account settings · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Your account</div>
<h2 class="page-title">Account settings</h2>
</div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{if .OK}}<div class="alert alert-success">{{.OK}}</div>{{end}}
<div class="card">
<div class="row g-0">
<!-- Section nav -->
<div class="col-12 col-md-3 border-end">
<div class="card-body">
<h4 class="subheader">Settings</h4>
<div class="list-group list-group-transparent">
<a href="#profile" class="list-group-item list-group-item-action">Profile</a>
<a href="#passkeys" class="list-group-item list-group-item-action">Passkeys</a>
<a href="#password" class="list-group-item list-group-item-action">Password</a>
</div>
</div>
</div>
<!-- Section content -->
<div class="col-12 col-md-9 d-flex flex-column">
<div class="card-body">
<!-- Profile -->
<h3 class="card-title" id="profile">Profile</h3>
<p class="card-subtitle">Your username can't be changed. Your display name is shown to people you share with.</p>
<form method="post" action="/account/profile">
<div class="row g-3">
<div class="col-md">
<label class="form-label" for="acct_username">Username</label>
<input type="text" class="form-control" id="acct_username" value="{{.User.Username}}" disabled>
</div>
<div class="col-md">
<label class="form-label" for="acct_display_name">Display name <span class="form-label-description">optional</span></label>
<input type="text" class="form-control" id="acct_display_name" name="display_name" maxlength="64" value="{{if .DisplayName}}{{.DisplayName}}{{end}}" placeholder="Shown to people you share with">
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">Save profile</button>
</div>
</form>
<!-- Passkeys -->
<h3 class="card-title mt-4" id="passkeys">Passkeys</h3>
<p class="card-subtitle">Passkeys let you sign in with your device's fingerprint, face, or PIN — no password to remember or leak.</p>
{{if .PKErr}}<div class="alert alert-danger">{{.PKErr}}</div>{{end}}
{{if .PKMsg}}<div class="alert alert-success">{{.PKMsg}}</div>{{end}}
{{if .Passkeys}}
<div class="list-group mb-3">
{{range .Passkeys}}
<div class="list-group-item d-flex align-items-center gap-2">
<span class="text-secondary">{{template "icon-key" ""}}</span>
<div class="flex-fill">
<div class="font-monospace">{{.ShortID}}…</div>
<div class="text-secondary small">Added {{.Added.Format "Jan 2, 2006"}}</div>
</div>
<form method="post" action="/account/passkeys/delete" class="m-0" onsubmit="return confirm('Remove this passkey? You won\'t be able to sign in with it anymore.')">
<input type="hidden" name="credential_id" value="{{.ID}}">
<button type="submit" class="btn btn-sm btn-ghost-danger">Remove</button>
</form>
</div>
{{end}}
</div>
{{else}}
<p class="text-secondary">No passkeys yet. Add one for faster, more secure sign-in.</p>
{{end}}
<div>
<button type="button" class="btn btn-primary" onclick="addPasskey()">{{template "icon-key" "me-1"}}Add a passkey</button>
</div>
<p id="passkey-status" class="text-secondary small mt-2 mb-0" style="min-height:1.2em"></p>
<!-- Password -->
<h3 class="card-title mt-4" id="password">Password</h3>
{{if .HasPassword}}
<p class="card-subtitle">Change the password you use as a fallback to passkeys.</p>
{{else}}
<p class="card-subtitle">You don't have a password set — you sign in with a passkey. You can add one as a fallback.</p>
{{end}}
<form method="post" action="/account/password">
<div class="row g-3">
{{if .HasPassword}}
<div class="col-md">
<label class="form-label" for="cur_pw">Current password</label>
<div class="input-group input-group-flat">
<input type="password" class="form-control" id="cur_pw" name="current_password" autocomplete="current-password" required>
<span class="input-group-text"><a href="#" class="link-secondary" role="button" aria-pressed="false" title="Show password" data-pwtoggle="cur_pw" aria-label="Show password">{{template "icon-eye" ""}}</a></span>
</div>
</div>
{{end}}
<div class="col-md">
<label class="form-label" for="new_pw">{{if .HasPassword}}New password{{else}}Password{{end}} <span class="form-label-description">min 8 characters</span></label>
<div class="input-group input-group-flat">
<input type="password" class="form-control" id="new_pw" name="new_password" autocomplete="new-password" minlength="8" required>
<span class="input-group-text"><a href="#" class="link-secondary" role="button" aria-pressed="false" title="Show password" data-pwtoggle="new_pw" aria-label="Show password">{{template "icon-eye" ""}}</a></span>
</div>
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">{{if .HasPassword}}Change password{{else}}Set password{{end}}</button>
</div>
</form>
{{if .HasPassword}}
<form method="post" action="/account/password" class="mt-3" onsubmit="return confirm('Remove your password? You will only be able to sign in with a passkey.')">
<input type="hidden" name="remove" value="1">
<button type="submit" class="btn btn-ghost-danger">Remove password</button>
<small class="form-hint mt-1">Only available if you have at least one passkey.</small>
</form>
{{end}}
</div>
</div>
</div>
</div>
<script src="/static/webauthn.js"></script>
{{end}}
File renamed without changes.
File renamed without changes.
+128
View File
@@ -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)
}
}
+67 -6
View File
@@ -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 {
@@ -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
}
@@ -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.
@@ -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))
@@ -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)
@@ -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{
@@ -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{
@@ -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):
@@ -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 {
@@ -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
}
+412
View File
@@ -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")
}
})
}
@@ -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
}
@@ -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)
}
}
}
@@ -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
}
+257
View File
@@ -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)
}
@@ -1,4 +1,4 @@
package web
package core
import "github.com/jleight/meshtender/internal/config"
@@ -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"} {
@@ -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)
}
@@ -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"
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+39
View File
@@ -0,0 +1,39 @@
{{define "title"}}Your organizations · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">Your organizations</h2></div>
<div class="col-auto ms-auto d-print-none">
<div class="btn-list">
<a class="btn" href="{{.RootURL}}/orgs">{{template "icon-world" "me-1"}}Discover organizations</a>
<a class="btn btn-primary" href="/orgs/new">{{template "icon-plus" "me-1"}}Create organization</a>
</div>
</div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="card">
{{if .Orgs}}
<div class="list-group list-group-flush">
{{range .Orgs}}
<a class="list-group-item list-group-item-action" href="/orgs/{{.Org.Slug}}">
<div class="d-flex align-items-center gap-2">
<span class="fw-bold">{{.Org.Name}}</span>
{{if .Org.Region}}<span class="badge bg-secondary-lt">{{.Org.Region}}</span>{{end}}
{{if eq .Role "admin"}}<span class="badge bg-success-lt">admin</span>{{else}}<span class="badge bg-azure-lt">member</span>{{end}}
</div>
{{if .Org.Description}}<p class="text-secondary mb-0 mt-1">{{.Org.Description}}</p>{{end}}
</a>
{{end}}
</div>
{{else}}
<div class="card-body text-center py-5">
<p class="text-secondary mb-3">You're not a member of any organizations yet.</p>
<div class="btn-list justify-content-center">
<a class="btn" href="{{.RootURL}}/orgs">{{template "icon-world" "me-1"}}Discover organizations</a>
<a class="btn btn-primary" href="/orgs/new">{{template "icon-plus" "me-1"}}Create one</a>
</div>
</div>
{{end}}
</div>
{{end}}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+301
View File
@@ -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)
}
+99
View File
@@ -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
}
+155
View File
@@ -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
}
@@ -1,4 +1,4 @@
package web
package marketing
import "testing"
@@ -11,7 +11,7 @@
in sync without needing to share admin passwords.
</p>
<div class="btn-list">
<a href="/signup" class="btn btn-primary btn-lg">{{template "icon-key" "me-2"}}Get started</a>
<a href="{{.AppURL}}/signup" class="btn btn-primary btn-lg">{{template "icon-key" "me-2"}}Get started</a>
<a href="/orgs" class="btn btn-lg">Browse organizations</a>
</div>
</div>
@@ -102,8 +102,8 @@
<h2 class="mb-3">Ready to tend your mesh?</h2>
<p class="text-secondary mb-4">Register a repeater in minutes and bring your team along.</p>
<div class="btn-list justify-content-center">
<a href="/signup" class="btn btn-primary btn-lg">Create your account</a>
<a href="/login" class="btn btn-lg">Sign in</a>
<a href="{{.AppURL}}/signup" class="btn btn-primary btn-lg">Create your account</a>
<a href="{{.AppURL}}/login" class="btn btn-lg">Sign in</a>
</div>
</div>
</div>
@@ -13,7 +13,7 @@
<button type="submit" class="btn btn-primary">{{template "icon-users" "me-1"}}Join organization</button>
</form>
{{else}}
<a class="btn btn-primary" href="/login?next=/orgs/{{.Org.Slug}}">Sign in to join</a>
<a class="btn btn-primary" href="{{.AppURL}}/orgs/{{.Org.Slug}}">Sign in to join</a>
{{end}}
</div>
</div>
@@ -61,7 +61,7 @@
{{if not .LoggedIn}}
<div class="card mt-3">
<div class="card-body">
<p class="text-secondary mb-0">Want to start or join one? <a href="/login">Sign in or create an account →</a></p>
<p class="text-secondary mb-0">Want to start or join one? <a href="{{.AppURL}}/login">Sign in or create an account →</a></p>
</div>
</div>
{{end}}
+51
View File
@@ -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
}
+50
View File
@@ -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)
}
}
@@ -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;
+239
View File
@@ -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)
}
})
}
-388
View File
@@ -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)
}
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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")
+1 -1
View File
@@ -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);
}
-115
View File
@@ -1,115 +0,0 @@
{{define "title"}}Account settings · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Your account</div>
<h2 class="page-title">Account settings</h2>
</div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
{{if .OK}}<div class="alert alert-success">{{.OK}}</div>{{end}}
<div class="row row-cards">
<!-- Profile -->
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header"><h3 class="card-title">Profile</h3></div>
<div class="card-body">
<form method="post" action="/account/profile">
<div class="mb-3">
<label class="form-label" for="acct_username">Username</label>
<input type="text" class="form-control" id="acct_username" value="{{.User.Username}}" disabled>
<small class="form-hint">Your username can't be changed.</small>
</div>
<div class="mb-3">
<label class="form-label" for="acct_display_name">Display name <span class="form-label-description">optional</span></label>
<input type="text" class="form-control" id="acct_display_name" name="display_name" maxlength="64" value="{{if .DisplayName}}{{.DisplayName}}{{end}}" placeholder="Shown to people you share with">
</div>
<button type="submit" class="btn btn-primary">Save profile</button>
</form>
</div>
</div>
</div>
<!-- Passkeys -->
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">Passkeys</h3>
<div class="card-actions">
<button type="button" class="btn btn-primary btn-sm" onclick="addPasskey()">{{template "icon-key" "me-1"}}Add a passkey</button>
</div>
</div>
<div class="card-body">
<p class="text-secondary">Passkeys let you sign in with your device's fingerprint, face, or PIN — no password to remember or leak.</p>
<p id="passkey-status" class="text-secondary small mb-0" style="min-height:1.2em"></p>
</div>
{{if .Passkeys}}
<div class="list-group list-group-flush">
{{range .Passkeys}}
<div class="list-group-item d-flex align-items-center gap-2">
<span class="text-secondary">{{template "icon-key" ""}}</span>
<div class="flex-fill">
<div class="font-monospace">{{.ShortID}}…</div>
<div class="text-secondary small">Added {{.Added.Format "Jan 2, 2006"}}</div>
</div>
<form method="post" action="/account/passkeys/delete" class="m-0" onsubmit="return confirm('Remove this passkey? You won\'t be able to sign in with it anymore.')">
<input type="hidden" name="credential_id" value="{{.ID}}">
<button type="submit" class="btn btn-sm btn-ghost-danger">Remove</button>
</form>
</div>
{{end}}
</div>
{{else}}
<div class="card-body pt-0"><p class="text-secondary mb-0">No passkeys yet. Add one for faster, more secure sign-in.</p></div>
{{end}}
</div>
</div>
<!-- Password -->
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header"><h3 class="card-title">Password</h3></div>
<div class="card-body">
{{if .HasPassword}}
<p class="text-secondary">Change the password you use as a fallback to passkeys.</p>
{{else}}
<p class="text-secondary">You don't have a password set — you sign in with a passkey. You can add a password as a fallback.</p>
{{end}}
<form method="post" action="/account/password">
{{if .HasPassword}}
<div class="mb-3">
<label class="form-label" for="cur_pw">Current password</label>
<div class="input-group input-group-flat">
<input type="password" class="form-control" id="cur_pw" name="current_password" autocomplete="current-password" required>
<span class="input-group-text"><a href="#" class="link-secondary" role="button" aria-pressed="false" title="Show password" data-pwtoggle="cur_pw" aria-label="Show password">{{template "icon-eye" ""}}</a></span>
</div>
</div>
{{end}}
<div class="mb-3">
<label class="form-label" for="new_pw">{{if .HasPassword}}New password{{else}}Password{{end}} <span class="form-label-description">min 8 characters</span></label>
<div class="input-group input-group-flat">
<input type="password" class="form-control" id="new_pw" name="new_password" autocomplete="new-password" minlength="8" required>
<span class="input-group-text"><a href="#" class="link-secondary" role="button" aria-pressed="false" title="Show password" data-pwtoggle="new_pw" aria-label="Show password">{{template "icon-eye" ""}}</a></span>
</div>
</div>
<button type="submit" class="btn btn-primary">{{if .HasPassword}}Change password{{else}}Set password{{end}}</button>
</form>
{{if .HasPassword}}
<hr>
<form method="post" action="/account/password" onsubmit="return confirm('Remove your password? You will only be able to sign in with a passkey.')">
<input type="hidden" name="remove" value="1">
<button type="submit" class="btn btn-ghost-danger">Remove password</button>
<small class="form-hint mt-1">Only available if you have at least one passkey.</small>
</form>
{{end}}
</div>
</div>
</div>
</div>
<script src="/static/webauthn.js"></script>
{{end}}
+10 -10
View File
@@ -17,7 +17,7 @@
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<a href="/" class="navbar-brand navbar-brand-autodark d-flex align-items-center text-reset text-decoration-none pe-0 pe-md-3">
<a href="{{if .UserName}}{{.AppURL}}{{else}}{{.RootURL}}{{end}}/" class="navbar-brand navbar-brand-autodark d-flex align-items-center text-reset text-decoration-none pe-0 pe-md-3">
{{template "icon-tower" "me-1"}}<span class="fw-bold fs-3">MeshTender</span>
</a>
<div class="navbar-nav flex-row order-md-last align-items-center gap-2">
@@ -28,42 +28,42 @@
<div class="d-none d-xl-block ps-2"><div>{{.UserName}}</div></div>
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<a class="dropdown-item" href="/account">{{template "icon-settings" "dropdown-item-icon"}}Account settings</a>
<a class="dropdown-item" href="{{.AuthURL}}/account">{{template "icon-settings" "dropdown-item-icon"}}Account settings</a>
<div class="dropdown-divider"></div>
<form method="post" action="/logout" class="m-0">
<form method="post" action="{{.AppURL}}/logout" class="m-0">
<button type="submit" class="dropdown-item text-danger">{{template "icon-logout" "dropdown-item-icon"}}Sign out</button>
</form>
</div>
</div>
{{else}}
<a href="/login" class="btn btn-primary">{{template "icon-login" "me-1"}}Sign in</a>
<a href="{{.AppURL}}/login" class="btn btn-primary">{{template "icon-login" "me-1"}}Sign in</a>
{{end}}
</div>
<div class="collapse navbar-collapse" id="navbar-menu">
<ul class="navbar-nav">
{{if .UserName}}
<li class="nav-item">
<a class="nav-link" href="/">
<a class="nav-link" href="{{.AppURL}}/">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-home" ""}}</span>
<span class="nav-link-title">Dashboard</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/repeaters">
<a class="nav-link" href="{{.AppURL}}/repeaters">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-antenna" ""}}</span>
<span class="nav-link-title">Repeaters</span>
</a>
</li>
{{end}}
<li class="nav-item">
<a class="nav-link" href="/orgs">
<a class="nav-link" href="{{if .UserName}}{{.AppURL}}{{else}}{{.RootURL}}{{end}}/orgs">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-world" ""}}</span>
<span class="nav-link-title">Organizations</span>
</a>
</li>
{{if .CanAdmin}}
<li class="nav-item">
<a class="nav-link" href="/admin">
<a class="nav-link" href="{{.AppURL}}/admin">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-settings" ""}}</span>
<span class="nav-link-title">Admin</span>
</a>
@@ -151,8 +151,8 @@
{{template "icon-tower" "me-1"}}<span class="fw-bold fs-3">MeshTender</span>
</a>
<div class="navbar-nav flex-row order-md-last align-items-center gap-2">
<a href="/login" class="btn btn-ghost-secondary">Sign in</a>
<a href="/signup" class="btn btn-primary">Create account</a>
<a href="{{.AppURL}}/login" class="btn btn-ghost-secondary">Sign in</a>
<a href="{{.AppURL}}/signup" class="btn btn-primary">Create account</a>
</div>
</div>
</header>
-430
View File
@@ -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)
}