Files
MeshTender/internal/core/orgs.go
T

494 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 organizations", 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) //nolint:gosec // G710: local path or config-pinned origin
}
// 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 organization", http.StatusInternalServerError)
return
}
if !isMember || r.URL.Query().Get("view") == "public" {
// Render the public org view here on the app host (not the root host),
// where the user's session lives: a non-member gets a working "Join"
// button (its POST hits the app host), and a member previewing with
// ?view=public gets the "Back to member view" link. The root host serves
// the same page to anonymous/external visitors.
s.renderOrgPublic(w, r, org, isMember, role == "admin")
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
}
links, err := s.Store.ListOrgLinks(r.Context(), id)
if err != nil {
http.Error(w, "could not load links", 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,
"Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "home", true, isAdmin),
"Role": role,
"IsAdmin": isAdmin,
"Members": members,
"Repeaters": repeaters,
"Links": links,
"Platforms": store.LinkPlatforms(),
"PlatformsJS": web.LinkPlatformsJS(store.LinkPlatforms()),
"HasMap": mapped > 0,
"MemberCount": len(members),
"RepeaterCount": len(repeaters),
"AdminCount": adminCount,
"Self": uid,
"Error": r.URL.Query().Get("error"),
}
// Custom domains are hidden for now (infrastructure not in place); the
// org.html card and the /domains routes are disabled, so don't load them.
s.Render(w, r, "org.html", data)
}
// renderOrgPublic renders the shared public org page on the app host for a
// signed-in user: a non-member sees a Join button, and a member (previewing via
// ?view=public) sees a "Back to member view" link. The data shape matches the
// marketing surface's anonymous rendering of the same template.
func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *store.Org, isMember, isAdmin bool) {
admins, err := s.Store.ListOrgAdmins(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
memberCount, repeaterCount, err := s.Store.OrgCounts(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
pubReps, err := s.Store.ListPublicRepeaters(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
links, err := s.Store.ListOrgLinks(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
uid := s.Auth.CurrentUserID(r.Context())
s.Render(w, r, "org_public.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "home", isMember, isAdmin),
"Admins": admins,
"MemberCount": memberCount,
"RepeaterCount": repeaterCount,
"Repeaters": pubReps,
"Links": links,
"HasMap": len(pubReps) > 0,
"IsMember": isMember,
"LoggedIn": uid != 0,
"CanJoin": uid != 0 && !isMember,
})
}
// pageOrgMembers lists an org's members (with role management for admins). It's
// members-only: the list carries personal info (names/usernames), so non-members
// get a 404 — there is no public version of this page.
func (s *Handlers) pageOrgMembers(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 organization", http.StatusInternalServerError)
return
}
if !isMember {
http.NotFound(w, r)
return
}
members, err := s.Store.ListOrgMembers(r.Context(), id)
if err != nil {
http.Error(w, "could not load members", http.StatusInternalServerError)
return
}
s.Render(w, r, "org_members.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "members", true, role == "admin"),
"IsAdmin": role == "admin",
"Members": members,
"Self": uid,
"Error": r.URL.Query().Get("error"),
})
}
// pageOrgRepeaters lists an org's repeaters with a map. Members see every
// contributed repeater (with links); any other viewer sees only those opted into
// the public map. The root host serves the same page anonymously.
func (s *Handlers) pageOrgRepeaters(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 organization", http.StatusInternalServerError)
return
}
rv, err := web.BuildRepeatersView(r.Context(), s.Store, id, isMember)
if err != nil {
http.Error(w, "could not load repeaters", http.StatusInternalServerError)
return
}
s.Render(w, r, "org_repeaters.html", map[string]any{
"Org": org,
"Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "repeaters", isMember, role == "admin"),
"Reps": rv,
})
}
// 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 340 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) //nolint:gosec // G710: local path or config-pinned origin
}
// handleSetOrgLinks replaces an org's whole set of social/site links from the
// repeatable rows posted by the profile editor (admin only). Rows with a blank
// URL are dropped, so removing a link is just clearing its row and saving.
func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
orgErr(w, r, "Could not save links.")
return
}
// The three fields are submitted as index-aligned parallel arrays: one entry
// each per row, in row order.
platforms := r.Form["link_platform"]
labels := r.Form["link_label"]
urls := r.Form["link_url"]
var links []store.OrgLink
for i, raw := range urls {
u := strings.TrimSpace(raw)
if u == "" {
continue // empty row — skip it
}
platform := ""
if i < len(platforms) {
platform = platforms[i]
}
p, ok := store.OrgLinkPlatform(platform)
if !ok {
orgErr(w, r, "Choose a type for each link.")
return
}
// Validate/canonicalise per kind, leaving `u` as the value to persist.
switch p.Kind {
case store.KindText: // Discord — a handle shown as text.
v := strings.TrimPrefix(u, "@")
if v == "" || len(v) > 64 || strings.ContainsAny(v, " \t\n\r") {
orgErr(w, r, "Enter a valid username (no spaces).")
return
}
u = v
case store.KindHandle:
canon, ok := p.CanonicalHandleURL(u)
if !ok {
orgErr(w, r, "Enter a valid "+p.Name+" username or profile URL.")
return
}
u = canon
default: // KindURL — accept a bare domain by assuming https:// before validating.
u = store.NormalizeLinkURL(u)
if !store.ValidLinkURL(u) {
orgErr(w, r, "Each link must be a valid http:// or https:// URL.")
return
}
}
label := ""
if i < len(labels) {
label = strings.TrimSpace(labels[i])
}
if len(u) > 300 {
u = u[:300]
}
if len(label) > 60 {
label = label[:60]
}
links = append(links, store.OrgLink{Platform: platform, Label: label, URL: u})
if len(links) >= store.MaxOrgLinks {
break
}
}
if err := s.Store.ReplaceOrgLinks(r.Context(), id, links); err != nil {
orgErr(w, r, "Could not save links.")
return
}
http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
}
// 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)
}
// pageJoinOrg shows the join confirmation. Because repeaters are shared with an
// org by default (opt-out), joining is an explicit choice: share all your current
// repeaters, or none of them.
func (s *Handlers) pageJoinOrg(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
}
_, isMember, err := s.Store.OrgRole(r.Context(), id, uid)
if err != nil {
http.Error(w, "could not load organization", http.StatusInternalServerError)
return
}
if isMember {
http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
return
}
hasRepeaters, err := s.Store.OwnsAnyRepeater(r.Context(), uid)
if err != nil {
http.Error(w, "could not load repeaters", http.StatusInternalServerError)
return
}
s.Render(w, r, "join_org.html", map[string]any{"Org": org, "HasRepeaters": hasRepeaters})
}
// handleJoinOrg adds the current user to an org as a member. mode "none" opts all
// of the user's current repeaters out of the org; otherwise they're shared (the
// default). Idempotent on membership.
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
}
if r.FormValue("mode") == "none" {
if err := s.Store.ExcludeOwnerRepeatersFromOrg(r.Context(), id, uid); err != nil {
orgErr(w, r, "Joined, but could not opt your repeaters out — adjust them on the sharing page.")
return
}
}
http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
}
// 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
}
membersURL := "/orgs/" + orgParam(r) + "/members"
memberErr := func(msg string) { web.RedirectErr(w, r, membersURL, msg) }
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:
memberErr("Unknown action.")
return
}
if errors.Is(err, store.ErrLastAdmin) {
memberErr("That would leave the organization with no admin.")
return
}
if err != nil {
memberErr("Could not update member.")
return
}
http.Redirect(w, r, membersURL, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin
}