Files
MeshTender/internal/core/web.go
T

376 lines
13 KiB
Go

// 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)
r.Use(s.Auth.ValidateSession)
}
// 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("/orgs/{id}/members", s.pageOrgMembers)
r.Get("/orgs/{id}/repeaters", s.pageOrgRepeaters)
r.Get("/repeaters", s.pageRepeaters)
r.Post("/logout", s.handleLogout)
r.Get("/repeaters/add", s.pageAddRepeater)
r.Post("/repeaters", s.handleAddRepeater)
r.Post("/repeaters/setup/commands", s.handleSetupCommands)
r.Post("/repeaters/setup/complete", s.handleSetupComplete)
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}/docs", s.pageRepeaterDocs)
r.Post("/repeaters/{id}/docs", s.handleRepeaterDocs)
r.Get("/repeaters/{id}/maintenance", s.pageRepeaterMaintenance)
r.Post("/repeaters/{id}/maintenance", s.handleAddMaintenance)
r.Post("/repeaters/{id}/maintenance/delete", s.handleDeleteMaintenance)
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.Post("/repeaters/{id}/share/{userID}/steward", s.handleSetShareSteward)
r.Post("/repeaters/{id}/orgs/{orgID}/participation", s.handleSetRepeaterOrg)
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}/links", s.handleSetOrgLinks)
r.Get("/orgs/{id}/join", s.pageJoinOrg)
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}/my-commands", s.pageOrgCommands)
r.Post("/orgs/{id}/my-commands", s.handleSaveOrgCommands)
r.Get("/orgs/{id}/config", s.pageOrgConfig)
r.Get("/orgs/{id}/config/edit", s.pageConfigHub)
r.Get("/orgs/{id}/config/profiles/new", s.pageProfileEdit)
r.Post("/orgs/{id}/config/profiles", s.handleCreateProfile)
r.Get("/orgs/{id}/config/profiles/{pid}/edit", s.pageProfileEdit)
r.Post("/orgs/{id}/config/profiles/{pid}", s.handleUpdateProfile)
r.Post("/orgs/{id}/config/profiles/{pid}/delete", s.handleDeleteProfile)
r.Get("/orgs/{id}/config/regions", s.pageRegionsEdit)
r.Post("/orgs/{id}/config/regions", s.handleSaveRegions)
// Custom org domains are hidden for now — the hosting/TLS infrastructure
// isn't in place yet. Leave the handlers in tree but don't expose them.
// 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(capAny)).Get("/analytics", s.pageAnalytics)
r.With(s.requireCap(capAny)).Get("/proxy-test", s.pageProxyTest)
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)).Get("/users/{id}/history", s.pageUserHistory)
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 as one
// combined list, owned first, each tagged owned/shared in the template.
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
}
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)
combined := make([]*store.Repeater, 0, len(owned)+len(shared))
combined = append(combined, owned...)
combined = append(combined, shared...)
s.Render(w, r, "repeaters.html", map[string]any{
"Repeaters": combined,
"ShareCounts": shareCounts,
"Error": r.URL.Query().Get("error"),
})
}
// onboardingStep is one item in the dashboard getting-started checklist. Steps
// are data-driven so adding a new one (e.g. future profile fields) is a single
// entry in buildOnboarding rather than template surgery.
type onboardingStep struct {
Title string
Desc string
Action string // CTA label, shown only while the step is pending
Href string // where the CTA leads (absolute when it crosses hosts)
Done bool
}
// pageDashboard renders the signed-in overview: summary stats, a getting-started
// checklist, 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)
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
}
user, err := s.Store.GetUserByID(ctx, uid)
if err != nil {
http.Error(w, "could not load account", 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)
}
}
steps := []onboardingStep{
{
Title: "Set up your profile",
Desc: "Add a display name so teammates recognize you.",
Action: "Edit profile",
Href: s.Origin(r, s.Cfg.AuthHost) + "/account",
Done: user.DisplayName != nil && *user.DisplayName != "",
},
{
Title: "Add a repeater",
Desc: "Connect a repeater to MeshTender and test it with a modem.",
Action: "Add repeater",
Href: "/repeaters/add",
Done: len(owned) > 0,
},
{
Title: "Join an organization",
Desc: "Find a local mesh community to share repeaters and coordinate.",
Action: "Browse organizations",
Href: s.Origin(r, s.Cfg.RootHost) + "/orgs",
Done: len(orgs) > 0,
},
}
// People listed publicly (org admins, public repeater owners/stewards) should
// give visitors a way to reach them. Only nudge those users, and only until
// they set a primary contact link.
publicRole, err := s.Store.UserHasPublicRole(ctx, uid)
if err != nil {
http.Error(w, "could not load account", http.StatusInternalServerError)
return
}
if publicRole {
links, err := s.Store.ListUserLinks(ctx, uid)
if err != nil {
http.Error(w, "could not load account", http.StatusInternalServerError)
return
}
steps = append(steps, onboardingStep{
Title: "Add a contact link",
Desc: "You're listed publicly as an admin or steward — add a way for people to reach you.",
Action: "Edit profile",
Href: s.Origin(r, s.Cfg.AuthHost) + "/account",
Done: store.PrimaryUserLink(links) != nil,
})
}
total := len(steps)
doneCount := 0
for _, st := range steps {
if st.Done {
doneCount++
}
}
// Hide the checklist entirely once everything's done.
if doneCount == total {
steps = nil
}
s.Render(w, r, "dashboard.html", map[string]any{
"OwnedCount": len(owned),
"SharedCount": len(shared),
"OrgCount": len(orgs),
"Confirmed": confirmed,
"Unconfirmed": unconfirmed,
"Mapped": mapped,
"Recent": recent,
"Onboarding": steps,
"OnboardingDone": doneCount,
"OnboardingTotal": total,
"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
}
// 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) //nolint:gosec // G710: local path or config-pinned origin
}