mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-10-23 05:32:05 +00:00
Refactor into multiple modules
This commit is contained in:
1 parent
d6ae282c8c
commit
1ba3dece4f
71 files changed
+2533
-1234
No files matched your search
+2
-1
@@ -8,5 +8,6 @@
|
||||
docker-compose.yml
|
||||
README.md
|
||||
|
||||
/meshtender
|
||||
/cmd/meshtender/meshtender
|
||||
/meshtender
|
||||
/certs
|
||||
@@ -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
@@ -1,4 +1,6 @@
|
||||
/meshtender
|
||||
/cmd/meshtender/meshtender
|
||||
*.exe
|
||||
.env
|
||||
/certs/
|
||||
/cmd/meshtender/meshtender
|
||||
/meshtender
|
||||
|
||||
*.exe
|
||||
+14
-12
@@ -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.")
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
+2
-13
@@ -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):
|
||||
+2
-11
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
+1
-1
@@ -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}}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user