From a04d3bf2d0dfca56905842463d403b5264d65f22 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Tue, 30 Jun 2026 21:01:36 -0400 Subject: [PATCH] User details page --- internal/auth/account.go | 151 ++++++++++++++ internal/auth/templates/account.html | 115 +++++++++++ internal/auth/web.go | 2 + internal/core/orgs.go | 15 +- internal/core/repeaters.go | 29 +-- internal/core/web.go | 22 +++ internal/marketing/marketing.go | 1 + internal/marketing/orgs.go | 2 +- .../marketing/templates/repeater_public.html | 4 +- internal/marketing/templates/user_public.html | 58 ++++++ internal/marketing/users.go | 68 +++++++ .../store/migrations/0031_user_profiles.sql | 28 +++ internal/store/org_links.go | 12 ++ internal/store/orgs.go | 21 +- internal/store/user_links.go | 146 ++++++++++++++ internal/store/user_links_test.go | 184 ++++++++++++++++++ internal/store/users.go | 38 +++- internal/web/qr.go | 41 ++++ internal/web/templates/icons.html | 8 +- internal/web/templates/org_public.html | 2 +- 20 files changed, 892 insertions(+), 55 deletions(-) create mode 100644 internal/marketing/templates/user_public.html create mode 100644 internal/marketing/users.go create mode 100644 internal/store/migrations/0031_user_profiles.sql create mode 100644 internal/store/user_links.go create mode 100644 internal/store/user_links_test.go create mode 100644 internal/web/qr.go diff --git a/internal/auth/account.go b/internal/auth/account.go index 622e2d3..1d6fede 100644 --- a/internal/auth/account.go +++ b/internal/auth/account.go @@ -4,9 +4,13 @@ import ( "encoding/hex" "errors" "net/http" + "net/mail" "strconv" + "strings" "time" + meshcore "github.com/meshcore-go/meshcore-go" + "github.com/jleight/meshtender/internal/store" "github.com/jleight/meshtender/internal/web" ) @@ -60,9 +64,19 @@ func (s *Handlers) pageAccount(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not load account", http.StatusInternalServerError) return } + links, err := s.Store.ListUserLinks(r.Context(), uid) + if err != nil { + http.Error(w, "could not load profile links", http.StatusInternalServerError) + return + } s.Render(w, r, "account.html", map[string]any{ "User": u, "DisplayName": u.DisplayName, // nil when unset + "Bio": u.Bio, + "Location": u.Location, + "Callsign": u.Callsign, + "Links": links, + "Platforms": store.UserLinkPlatforms(), "HasPassword": u.PasswordHash != nil, "Passkeys": views, "NextRename": nextRename, // nil when a rename is allowed now @@ -110,6 +124,143 @@ func (s *Handlers) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { accountRedirect(w, r, "ok", "Profile updated.") } +// handleSetProfileFields saves the user's public profile fields (bio, location, +// callsign) shown on their public page. Each field is trimmed and bounded; a +// blank field simply won't render. +func (s *Handlers) handleSetProfileFields(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + bio := boundedText(r.FormValue("bio"), 500) + location := boundedText(r.FormValue("location"), 120) + callsign := boundedText(r.FormValue("callsign"), 32) + if err := s.Store.SetProfile(r.Context(), uid, bio, location, callsign); err != nil { + accountRedirect(w, r, "error", "Could not save your profile.") + return + } + accountRedirect(w, r, "ok", "Profile updated.") +} + +// handleSetUserLinks replaces the current user's whole set of public profile +// links from the repeatable rows posted by the editor. Rows with a blank value +// are dropped. Most rows carry an http(s) URL; a "meshcore" row carries a +// MeshCore public key (validated as hex) and renders as a QR code. The optional +// primary-contact radio flags one non-MeshCore link as the preferred way to +// reach the user. +func (s *Handlers) handleSetUserLinks(w http.ResponseWriter, r *http.Request) { + uid := s.Auth.CurrentUserID(r.Context()) + if err := r.ParseForm(); err != nil { + accountRedirect(w, r, "error", "Could not save links.") + return + } + // Index-aligned parallel arrays, one entry per row, in row order. + platforms := r.Form["link_platform"] + labels := r.Form["link_label"] + urls := r.Form["link_url"] + // The primary radio's value is the row index (renumbered to DOM order on + // submit), or absent when no primary is chosen. + primaryIdx := -1 + if v := r.FormValue("link_primary"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + primaryIdx = n + } + } + var links []store.UserLink + for i, raw := range urls { + val := strings.TrimSpace(raw) + if val == "" { + continue // empty row — skip it + } + platform := "" + if i < len(platforms) { + platform = platforms[i] + } + if !store.ValidUserLinkPlatform(platform) { + accountRedirect(w, r, "error", "Choose a type for each link.") + return + } + switch platform { + case store.MeshCorePlatform: + val = strings.ToLower(val) + if !validMeshCoreKey(val) { + accountRedirect(w, r, "error", "Enter a valid MeshCore public key (64-character hex).") + return + } + case store.EmailPlatform: + addr, err := mail.ParseAddress(val) + if err != nil || addr.Name != "" { + accountRedirect(w, r, "error", "Enter a valid email address.") + return + } + val = addr.Address + case store.SignalPlatform: + val = strings.TrimPrefix(val, "@") + if !validSignalUsername(val) { + accountRedirect(w, r, "error", "Enter a valid Signal username (3–32 characters: letters, digits, . and _).") + return + } + default: + if !store.ValidLinkURL(val) { + accountRedirect(w, r, "error", "Each link must be a valid http:// or https:// URL.") + return + } + } + label := "" + if i < len(labels) { + label = strings.TrimSpace(labels[i]) + } + if len(val) > 300 { + val = val[:300] + } + if len(label) > 60 { + label = label[:60] + } + // A MeshCore key is an identity, not a way to reach someone, so it can't be + // the primary contact (mirrors excluding callsign/node info). + primary := i == primaryIdx && platform != store.MeshCorePlatform + links = append(links, store.UserLink{Platform: platform, Label: label, URL: val, IsPrimary: primary}) + if len(links) >= store.MaxUserLinks { + break + } + } + if err := s.Store.ReplaceUserLinks(r.Context(), uid, links); err != nil { + accountRedirect(w, r, "error", "Could not save links.") + return + } + accountRedirect(w, r, "ok", "Links updated.") +} + +// boundedText trims s and caps it at max bytes (empty means "unset"). +func boundedText(s string, max int) string { + s = strings.TrimSpace(s) + if len(s) > max { + s = s[:max] + } + return s +} + +// validMeshCoreKey reports whether s is a valid MeshCore public key (a 32-byte +// Ed25519 key, hex-encoded), using the MeshCore library's own parser. +func validMeshCoreKey(s string) bool { + _, err := meshcore.NewIdentityFromHex(s) + return err == nil +} + +// validSignalUsername reports whether s is a plausible Signal username: 3–32 +// characters of letters, digits, dot, or underscore (Signal usernames carry a +// dotted numeric discriminator, e.g. alice.42). +func validSignalUsername(s string) bool { + if len(s) < 3 || len(s) > 32 { + return false + } + for _, c := range s { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '.', c == '_': + default: + return false + } + } + return true +} + // handleChangePassword sets, changes, or removes the user's password. func (s *Handlers) handleChangePassword(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/internal/auth/templates/account.html b/internal/auth/templates/account.html index 66f520c..e0b2806 100644 --- a/internal/auth/templates/account.html +++ b/internal/auth/templates/account.html @@ -53,6 +53,121 @@ +
+

Public profile

+
+

These appear on your public page{{if .RootURL}} at {{.RootURL}}/u/{{.User.Username}}{{end}}, which anyone can view. Leave a field blank to keep it off your page.

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

Links (public)

+
+

Ways to reach or find you — social media, a website, or your MeshCore public key (shown as a QR code). Mark one non-MeshCore link as your primary contact so people know the best way to reach you.

+ + + +
+
+

Passkeys

diff --git a/internal/auth/web.go b/internal/auth/web.go index d7c5883..b463783 100644 --- a/internal/auth/web.go +++ b/internal/auth/web.go @@ -53,6 +53,8 @@ func (s *Handlers) Routes() chi.Router { r.Get("/account", s.pageAccount) r.Post("/account/username", s.handleChangeUsername) r.Post("/account/profile", s.handleUpdateProfile) + r.Post("/account/profile-fields", s.handleSetProfileFields) + r.Post("/account/links", s.handleSetUserLinks) r.Post("/account/password", s.handleChangePassword) r.Post("/account/passkeys/rename", s.handleRenamePasskey) r.Post("/account/passkeys/delete", s.handleDeletePasskey) diff --git a/internal/core/orgs.go b/internal/core/orgs.go index afa0294..136cc80 100644 --- a/internal/core/orgs.go +++ b/internal/core/orgs.go @@ -3,7 +3,6 @@ package core import ( "errors" "net/http" - "net/url" "strconv" "strings" @@ -148,7 +147,7 @@ func (s *Handlers) pageOrg(w http.ResponseWriter, r *http.Request) { // ?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.ListOrgAdminNames(r.Context(), org.ID) + admins, err := s.Store.ListOrgAdmins(r.Context(), org.ID) if err != nil { http.Error(w, "could not load org", http.StatusInternalServerError) return @@ -321,7 +320,7 @@ func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) { orgErr(w, r, "Choose a type for each link.") return } - if !validLinkURL(u) { + if !store.ValidLinkURL(u) { orgErr(w, r, "Each link must be a valid http:// or https:// URL.") return } @@ -347,16 +346,6 @@ func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } -// validLinkURL reports whether s is an absolute http(s) URL with a host. Limiting -// the scheme keeps javascript:/data: URLs out of rendered hrefs. -func validLinkURL(s string) bool { - u, err := url.Parse(s) - if err != nil { - return false - } - return (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" -} - // 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()) diff --git a/internal/core/repeaters.go b/internal/core/repeaters.go index b4c6e5f..d466d07 100644 --- a/internal/core/repeaters.go +++ b/internal/core/repeaters.go @@ -1,20 +1,16 @@ package core import ( - "encoding/base64" "encoding/json" "errors" "fmt" "html/template" - "image/color" "math" "net/http" - "net/url" "strconv" "strings" meshcore "github.com/meshcore-go/meshcore-go" - qrcode "github.com/skip2/go-qrcode" "github.com/jleight/meshtender/internal/config" "github.com/jleight/meshtender/internal/store" @@ -120,24 +116,15 @@ func (s *Handlers) pageRepeater(w http.ResponseWriter, r *http.Request) { if isOwner && rep.ExposePublicPage { publicURL := s.Origin(r, s.rootHost()) + "/r/" + rep.PublicID data["PublicPageURL"] = publicURL - if qr, err := qrcode.New(publicURL, qrcode.Medium); err == nil { - qr.BackgroundColor = color.Transparent - qr.ForegroundColor = color.RGBA{R: 0x8a, G: 0x97, B: 0xa8, A: 0xff} - if png, err := qr.PNG(256); err == nil { - data["PublicPageQR"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)) //nolint:gosec // G203: fixed data: URI over base64 PNG, no user input - } + if qr, ok := web.QRDataURI(publicURL); ok { + data["PublicPageQR"] = qr } } // QR code that adds the repeater as a MeshCore contact. Embedded as a data // URI so it needs no extra route or asset; if encoding fails the page just - // renders without it. Light modules on a transparent quiet zone so it sits on - // the dark card instead of a stark white block (scanners decode inverted QR). - if qr, err := qrcode.New(contactURI, qrcode.Medium); err == nil { - qr.BackgroundColor = color.Transparent - qr.ForegroundColor = color.RGBA{R: 0x8a, G: 0x97, B: 0xa8, A: 0xff} - if png, err := qr.PNG(256); err == nil { - data["ContactQR"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)) //nolint:gosec // G203: fixed data: URI over base64 PNG, no user input - } + // renders without it. + if qr, ok := web.QRDataURI(contactURI); ok { + data["ContactQR"] = qr } if isOwner { orgs, err := s.Store.ListRepeaterOrgs(r.Context(), id) @@ -153,11 +140,7 @@ func (s *Handlers) pageRepeater(w http.ResponseWriter, r *http.Request) { // repeaterContactURI builds the meshcore:// deep link that adds the repeater as // a contact in the MeshCore app. type=2 is MeshCore's repeater contact type. func repeaterContactURI(rep *store.Repeater) string { - q := url.Values{} - q.Set("name", rep.Name) - q.Set("public_key", rep.PublicKeyHex) - q.Set("type", "2") - return "meshcore://contact/add?" + q.Encode() + return web.MeshCoreContactURI(rep.Name, rep.PublicKeyHex, int(meshcore.AdvertTypeRepeater)) } func addErr(w http.ResponseWriter, r *http.Request, msg string) { diff --git a/internal/core/web.go b/internal/core/web.go index 5f5529a..ecffebf 100644 --- a/internal/core/web.go +++ b/internal/core/web.go @@ -299,6 +299,28 @@ func (s *Handlers) pageDashboard(w http.ResponseWriter, r *http.Request) { 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 { diff --git a/internal/marketing/marketing.go b/internal/marketing/marketing.go index 4ffed45..f7af479 100644 --- a/internal/marketing/marketing.go +++ b/internal/marketing/marketing.go @@ -53,6 +53,7 @@ func (s *Handlers) Routes() chi.Router { r.Get("/orgs/{id}/repeaters", s.pageOrgRepeaters) // public repeater list + map r.Get("/orgs/{id}/config", s.pageOrgConfig) // public recommended config r.Get("/r/{id}", s.pageRepeaterPublic) // public repeater page (NFC/QR target) + r.Get("/u/{username}", s.pageUserPublic) // public user profile return r } diff --git a/internal/marketing/orgs.go b/internal/marketing/orgs.go index afebb32..2cfb8a6 100644 --- a/internal/marketing/orgs.go +++ b/internal/marketing/orgs.go @@ -65,7 +65,7 @@ func (s *Handlers) pageOrgs(w http.ResponseWriter, r *http.Request) { // 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, isAdmin bool) { - admins, err := s.Store.ListOrgAdminNames(r.Context(), org.ID) + admins, err := s.Store.ListOrgAdmins(r.Context(), org.ID) if err != nil { http.Error(w, "could not load org", http.StatusInternalServerError) return diff --git a/internal/marketing/templates/repeater_public.html b/internal/marketing/templates/repeater_public.html index fbc7509..b10bb2d 100644 --- a/internal/marketing/templates/repeater_public.html +++ b/internal/marketing/templates/repeater_public.html @@ -81,13 +81,13 @@
{{template "icon-user" ""}} -
{{.Repeater.OwnerName}}
+ Primary
{{range .Stewards}}
{{template "icon-user" ""}} -
{{.Name}}
+ Backup
{{end}} diff --git a/internal/marketing/templates/user_public.html b/internal/marketing/templates/user_public.html new file mode 100644 index 0000000..dc0b5ab --- /dev/null +++ b/internal/marketing/templates/user_public.html @@ -0,0 +1,58 @@ +{{define "title"}}{{.ProfileUser.Name}} · MeshTender{{end}} +{{define "header"}} +
+
+
Profile
+

{{.ProfileUser.Name}}

+
@{{.ProfileUser.Username}}
+
+
+{{end}} +{{define "content"}} +
+
+
+

About

+
+ {{if .Location}}
{{template "icon-map-pin" "me-1"}}{{.Location}}
{{end}} + {{if .Callsign}}
Callsign: {{.Callsign}}
{{end}} + {{if .Bio}}

{{.Bio}}

{{end}} + {{if not .HasDetails}}

This person hasn't added any profile details yet.

{{end}} + + {{if .Links}} +
Links
+
+ {{range .Links}} + {{if .Href}} + {{template "link-icon" .Platform}}{{.Display}}{{if .IsPrimary}} Primary{{end}} + {{else}} + {{template "link-icon" .Platform}}{{.URL}}{{if .IsPrimary}} Primary{{end}} + {{end}} + {{end}} +
+ {{end}} +
+
+
+ + {{if .MeshKeys}} +
+
+

MeshCore

+
+ {{range .MeshKeys}} +
+ {{if .QR}}
MeshCore contact QR for {{.Label}}
{{end}} +
{{.Label}}
+ {{.Key}} +
+ {{end}} +

Scan to add this contact in the MeshCore app.

+
+
+
+ {{end}} +
+ +{{template "icon-arrow-left" "me-1"}}All organizations +{{end}} diff --git a/internal/marketing/users.go b/internal/marketing/users.go new file mode 100644 index 0000000..854d36f --- /dev/null +++ b/internal/marketing/users.go @@ -0,0 +1,68 @@ +package marketing + +import ( + "errors" + "html/template" + "net/http" + + "github.com/go-chi/chi/v5" + meshcore "github.com/meshcore-go/meshcore-go" + + "github.com/jleight/meshtender/internal/auth" + "github.com/jleight/meshtender/internal/store" + "github.com/jleight/meshtender/internal/web" +) + +// meshKeyView is a MeshCore public key rendered for the public profile: the +// label, the key text, and a QR code that adds the person as a MeshCore contact. +type meshKeyView struct { + Label string + Key string + QR template.URL +} + +// pageUserPublic renders a user's public profile (/u/{username}) for anyone. All +// profile fields are optional; an unfilled profile just shows the display name. +// MeshCore-key links render as scannable QR codes; other links render as buttons. +func (s *Handlers) pageUserPublic(w http.ResponseWriter, r *http.Request) { + username := auth.NormalizeUsername(chi.URLParam(r, "username")) + u, err := s.Store.GetUserByUsername(r.Context(), username) + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + if err != nil { + http.Error(w, "could not load profile", http.StatusInternalServerError) + return + } + links, err := s.Store.ListUserLinks(r.Context(), u.ID) + if err != nil { + http.Error(w, "could not load profile", http.StatusInternalServerError) + return + } + // Ordinary links render as buttons; MeshCore keys render as QR codes. + var webLinks []store.UserLink + var meshKeys []meshKeyView + for _, l := range links { + if l.IsMeshCore() { + mk := meshKeyView{Label: l.Display(), Key: l.URL} + if qr, ok := web.QRDataURI(web.MeshCoreContactURI(u.Name(), l.URL, int(meshcore.AdvertTypeChat))); ok { + mk.QR = qr + } + meshKeys = append(meshKeys, mk) + continue + } + webLinks = append(webLinks, l) + } + // Whether there's anything beyond the name to show — drives an empty-state hint. + hasDetails := u.Bio != "" || u.Location != "" || u.Callsign != "" || len(webLinks) > 0 || len(meshKeys) > 0 + s.Render(w, r, "user_public.html", map[string]any{ + "ProfileUser": u, + "Bio": u.Bio, + "Location": u.Location, + "Callsign": u.Callsign, + "Links": webLinks, + "MeshKeys": meshKeys, + "HasDetails": hasDetails, + }) +} diff --git a/internal/store/migrations/0031_user_profiles.sql b/internal/store/migrations/0031_user_profiles.sql new file mode 100644 index 0000000..5d54d1e --- /dev/null +++ b/internal/store/migrations/0031_user_profiles.sql @@ -0,0 +1,28 @@ +-- +goose Up +-- Public profile fields for a user's page (/u/{username}). All optional — a blank +-- field simply doesn't render, which is how users keep information private. +ALTER TABLE users ADD COLUMN bio TEXT NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN location TEXT NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN callsign TEXT NOT NULL DEFAULT ''; + +-- Contact/social links shown on a user's public page, mirroring org_links. One +-- link may be flagged is_primary as the preferred way to reach the user (drives +-- the "add a contact link" nudge for people listed publicly). The meshcore +-- platform's url column holds a MeshCore public key rather than an http URL and +-- renders as a QR code. +CREATE TABLE user_links ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + platform TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL, + position INT NOT NULL DEFAULT 0, + is_primary BOOLEAN NOT NULL DEFAULT FALSE +); +CREATE INDEX user_links_user_idx ON user_links(user_id, position); + +-- +goose Down +DROP TABLE user_links; +ALTER TABLE users DROP COLUMN callsign; +ALTER TABLE users DROP COLUMN location; +ALTER TABLE users DROP COLUMN bio; diff --git a/internal/store/org_links.go b/internal/store/org_links.go index 9b84cb0..8b3c5ce 100644 --- a/internal/store/org_links.go +++ b/internal/store/org_links.go @@ -3,6 +3,7 @@ package store import ( "context" "fmt" + "net/url" "github.com/jackc/pgx/v5" ) @@ -53,6 +54,17 @@ func ValidLinkPlatform(key string) bool { return ok } +// ValidLinkURL reports whether s is an absolute http(s) URL with a host. Limiting +// the scheme keeps javascript:/data: URLs out of rendered hrefs. Shared by the +// org- and user-link editors. +func ValidLinkURL(s string) bool { + u, err := url.Parse(s) + if err != nil { + return false + } + return (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" +} + // linkPlatformName returns the display name for a platform key, or the key itself // if it is unknown (defensive — stored rows should always be valid). func linkPlatformName(key string) string { diff --git a/internal/store/orgs.go b/internal/store/orgs.go index ca9916d..1fec4bb 100644 --- a/internal/store/orgs.go +++ b/internal/store/orgs.go @@ -395,22 +395,23 @@ func (s *Store) ListOrgMembers(ctx context.Context, orgID int64) ([]OrgMemberInf }) } -// ListOrgAdminNames returns just the display names of an org's admins, ordered -// for display. The public org page only needs admin names, so this avoids -// loading every member row via ListOrgMembers. -func (s *Store) ListOrgAdminNames(ctx context.Context, orgID int64) ([]string, error) { +// ListOrgAdmins returns an org's admins (id, username, display name) ordered for +// display. The public org page links each admin to their public profile, so it +// needs the username alongside the display name. It avoids loading every member +// row via ListOrgMembers. +func (s *Store) ListOrgAdmins(ctx context.Context, orgID int64) ([]OrgMemberInfo, error) { rows, err := s.pool.Query(ctx, ` - SELECT COALESCE(NULLIF(u.display_name, ''), u.username) AS name + SELECT u.id, u.username, u.display_name, m.role FROM org_members m JOIN users u ON u.id = m.user_id WHERE m.org_id = $1 AND m.role = 'admin' - ORDER BY name`, orgID) + ORDER BY COALESCE(NULLIF(u.display_name, ''), u.username)`, orgID) if err != nil { return nil, fmt.Errorf("list org admins: %w", err) } - return collectRows(rows, func(r pgx.Row) (string, error) { - var name string - err := r.Scan(&name) - return name, err + return collectRows(rows, func(r pgx.Row) (OrgMemberInfo, error) { + var m OrgMemberInfo + err := r.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.Role) + return m, err }) } diff --git a/internal/store/user_links.go b/internal/store/user_links.go new file mode 100644 index 0000000..ef23e0f --- /dev/null +++ b/internal/store/user_links.go @@ -0,0 +1,146 @@ +package store + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// MaxUserLinks caps how many contact/social links a user may list on their +// public profile. Same generous bound as orgs. +const MaxUserLinks = 20 + +// Platform keys whose values aren't plain http(s) URLs and so need their own +// validation and rendering: a MeshCore public key (hex, shown as a QR), an email +// address (a mailto: link), and a Signal username (shown as text — Signal has no +// stable link-from-username). +const ( + MeshCorePlatform = "meshcore" + EmailPlatform = "email" + SignalPlatform = "signal" +) + +// userLinkPlatforms is the curated set a user can choose from: direct contact +// channels first (email, Signal, MeshCore), then the shared social platforms. +var userLinkPlatforms = append([]LinkPlatform{ + {EmailPlatform, "Email"}, + {SignalPlatform, "Signal"}, + {MeshCorePlatform, "MeshCore"}, +}, linkPlatforms...) + +var userLinkPlatformByKey = func() map[string]LinkPlatform { + m := make(map[string]LinkPlatform, len(userLinkPlatforms)) + for _, p := range userLinkPlatforms { + m[p.Key] = p + } + return m +}() + +// UserLinkPlatforms returns the platforms a user may pick for a profile link. +func UserLinkPlatforms() []LinkPlatform { return userLinkPlatforms } + +// ValidUserLinkPlatform reports whether key is a platform a user link may use. +func ValidUserLinkPlatform(key string) bool { + _, ok := userLinkPlatformByKey[key] + return ok +} + +func userLinkPlatformName(key string) string { + if p, ok := userLinkPlatformByKey[key]; ok { + return p.Name + } + return key +} + +// UserLink is a single contact/social link on a user's public profile. +type UserLink struct { + ID int64 + UserID int64 + Platform string + Label string + // URL holds an http(s) URL for most platforms, or a MeshCore public key (hex) + // when Platform == MeshCorePlatform. + URL string + Position int + IsPrimary bool +} + +// Display is the text to show for the link: the custom label if set, otherwise +// the platform's name (e.g. "Discord"). +func (l UserLink) Display() string { + if l.Label != "" { + return l.Label + } + return userLinkPlatformName(l.Platform) +} + +// IsMeshCore reports whether this link carries a MeshCore public key (rendered as +// a QR code) rather than an ordinary URL. +func (l UserLink) IsMeshCore() bool { return l.Platform == MeshCorePlatform } + +// Href is the hyperlink target for this link, or "" when it isn't directly +// linkable (a MeshCore key renders as a QR; a Signal username as plain text). An +// email becomes a mailto: link; everything else uses the stored URL as-is. +func (l UserLink) Href() string { + switch l.Platform { + case MeshCorePlatform, SignalPlatform: + return "" + case EmailPlatform: + return "mailto:" + l.URL + default: + return l.URL + } +} + +// PrimaryUserLink returns the link flagged as the primary contact, or nil if +// none is. Used to decide whether a publicly-listed user has a way to be reached. +func PrimaryUserLink(links []UserLink) *UserLink { + for i := range links { + if links[i].IsPrimary { + return &links[i] + } + } + return nil +} + +// ListUserLinks returns a user's profile links in display order. +func (s *Store) ListUserLinks(ctx context.Context, userID int64) ([]UserLink, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, user_id, platform, label, url, position, is_primary + FROM user_links WHERE user_id = $1 ORDER BY position, id`, userID) + if err != nil { + return nil, fmt.Errorf("list user links: %w", err) + } + return collectRows(rows, func(r pgx.Row) (UserLink, error) { + var l UserLink + err := r.Scan(&l.ID, &l.UserID, &l.Platform, &l.Label, &l.URL, &l.Position, &l.IsPrimary) + return l, err + }) +} + +// ReplaceUserLinks atomically replaces a user's entire link set with links, in +// the given order. An empty slice clears all links. At most one link is stored +// as primary (the first flagged wins). Platform/URL validation is the caller's +// responsibility. +func (s *Store) ReplaceUserLinks(ctx context.Context, userID int64, links []UserLink) error { + primarySeen := false + return s.inTx(ctx, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `DELETE FROM user_links WHERE user_id = $1`, userID); err != nil { + return fmt.Errorf("clear user links: %w", err) + } + for i, l := range links { + primary := l.IsPrimary && !primarySeen + if primary { + primarySeen = true + } + if _, err := tx.Exec(ctx, + `INSERT INTO user_links (user_id, platform, label, url, position, is_primary) + VALUES ($1, $2, $3, $4, $5, $6)`, + userID, l.Platform, l.Label, l.URL, i, primary); err != nil { + return fmt.Errorf("insert user link: %w", err) + } + } + return nil + }) +} diff --git a/internal/store/user_links_test.go b/internal/store/user_links_test.go new file mode 100644 index 0000000..c378653 --- /dev/null +++ b/internal/store/user_links_test.go @@ -0,0 +1,184 @@ +package store + +import ( + "strings" + "testing" +) + +func TestUserLinksReplaceAndList(t *testing.T) { + t.Parallel() + st, ctx := orgTestStore(t) + + u, err := st.CreateUser(ctx, "linkuser", "") + if err != nil { + t.Fatalf("create user: %v", err) + } + + // A fresh user has no links. + got, err := st.ListUserLinks(ctx, u.ID) + if err != nil { + t.Fatalf("list (empty): %v", err) + } + if len(got) != 0 { + t.Fatalf("new user links = %d, want 0", len(got)) + } + + // Replace with three links; order is preserved and the second is primary. + links := []UserLink{ + {Platform: "discord", URL: "https://discord.gg/abc"}, + {Platform: "website", Label: "Home", URL: "https://example.org", IsPrimary: true}, + {Platform: MeshCorePlatform, URL: strings.Repeat("a", 64)}, + } + if err := st.ReplaceUserLinks(ctx, u.ID, links); err != nil { + t.Fatalf("replace: %v", err) + } + got, err = st.ListUserLinks(ctx, u.ID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(got) != 3 { + t.Fatalf("links = %d, want 3", len(got)) + } + if got[0].Position != 0 || got[1].Position != 1 || got[2].Position != 2 { + t.Errorf("positions = %d,%d,%d, want 0,1,2", got[0].Position, got[1].Position, got[2].Position) + } + if p := PrimaryUserLink(got); p == nil || p.URL != "https://example.org" { + t.Errorf("primary = %+v, want the website link", p) + } + if !got[2].IsMeshCore() { + t.Errorf("link[2] should be a MeshCore link") + } + if d := got[0].Display(); d != "Discord" { + t.Errorf("link[0].Display() = %q, want %q", d, "Discord") + } +} + +func TestUserLinkHref(t *testing.T) { + t.Parallel() + cases := []struct { + link UserLink + want string + }{ + {UserLink{Platform: EmailPlatform, URL: "a@b.com"}, "mailto:a@b.com"}, + {UserLink{Platform: SignalPlatform, URL: "alice.42"}, ""}, + {UserLink{Platform: MeshCorePlatform, URL: "abcd"}, ""}, + {UserLink{Platform: "website", URL: "https://example.org"}, "https://example.org"}, + } + for _, c := range cases { + if got := c.link.Href(); got != c.want { + t.Errorf("%s.Href() = %q, want %q", c.link.Platform, got, c.want) + } + } +} + +func TestReplaceUserLinksSinglePrimary(t *testing.T) { + t.Parallel() + st, ctx := orgTestStore(t) + u, err := st.CreateUser(ctx, "multiprimary", "") + if err != nil { + t.Fatalf("create user: %v", err) + } + + // Two links flagged primary — only the first flagged should win. + links := []UserLink{ + {Platform: "website", URL: "https://one.example", IsPrimary: true}, + {Platform: "website", URL: "https://two.example", IsPrimary: true}, + } + if err := st.ReplaceUserLinks(ctx, u.ID, links); err != nil { + t.Fatalf("replace: %v", err) + } + got, err := st.ListUserLinks(ctx, u.ID) + if err != nil { + t.Fatalf("list: %v", err) + } + primaries := 0 + for _, l := range got { + if l.IsPrimary { + primaries++ + } + } + if primaries != 1 { + t.Fatalf("primary links = %d, want exactly 1", primaries) + } + if p := PrimaryUserLink(got); p == nil || p.URL != "https://one.example" { + t.Errorf("primary = %+v, want the first flagged link", p) + } + + // Replacing with an empty set clears every link. + if err := st.ReplaceUserLinks(ctx, u.ID, nil); err != nil { + t.Fatalf("replace (clear): %v", err) + } + got, err = st.ListUserLinks(ctx, u.ID) + if err != nil { + t.Fatalf("list (after clear): %v", err) + } + if len(got) != 0 { + t.Errorf("links after clear = %d, want 0", len(got)) + } +} + +func TestUserHasPublicRole(t *testing.T) { + t.Parallel() + st, ctx := orgTestStore(t) + + // A user with no public role. + nobody, err := st.CreateUser(ctx, "nobody", "") + if err != nil { + t.Fatalf("create user: %v", err) + } + if yes, err := st.UserHasPublicRole(ctx, nobody.ID); err != nil || yes { + t.Fatalf("nobody has public role = %v (err %v), want false", yes, err) + } + + // An org admin is public. + admin, err := st.CreateUser(ctx, "orgadmin", "") + if err != nil { + t.Fatalf("create user: %v", err) + } + if _, err := st.CreateOrg(ctx, "Public Org", admin.ID); err != nil { + t.Fatalf("create org: %v", err) + } + if yes, err := st.UserHasPublicRole(ctx, admin.ID); err != nil || !yes { + t.Fatalf("org admin has public role = %v (err %v), want true", yes, err) + } + + // A repeater owner is public only once the public page is exposed. + owner, err := st.CreateUser(ctx, "repowner", "") + if err != nil { + t.Fatalf("create user: %v", err) + } + rep, err := st.CreateRepeater(ctx, &Repeater{ + OwnerID: owner.ID, Name: "R", PublicKeyHex: strings.Repeat("b", 64), + RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5, + }) + if err != nil { + t.Fatalf("create repeater: %v", err) + } + if yes, err := st.UserHasPublicRole(ctx, owner.ID); err != nil || yes { + t.Fatalf("owner of private repeater has public role = %v, want false", yes) + } + if err := st.UpdateRepeater(ctx, owner.ID, rep.ID, "R", 1, 1, 11, 5, false, true); err != nil { + t.Fatalf("expose public page: %v", err) + } + if yes, err := st.UserHasPublicRole(ctx, owner.ID); err != nil || !yes { + t.Fatalf("owner of public repeater has public role = %v (err %v), want true", yes, err) + } + + // A steward of that public repeater is also public. + steward, err := st.CreateUser(ctx, "repsteward", "") + if err != nil { + t.Fatalf("create user: %v", err) + } + if _, err := st.AddShare(ctx, rep.ID, steward.ID); err != nil { + t.Fatalf("add share: %v", err) + } + if yes, err := st.UserHasPublicRole(ctx, steward.ID); err != nil || yes { + t.Fatalf("non-steward share has public role = %v, want false", yes) + } + if err := st.SetShareSteward(ctx, rep.ID, steward.ID, true); err != nil { + t.Fatalf("set steward: %v", err) + } + if yes, err := st.UserHasPublicRole(ctx, steward.ID); err != nil || !yes { + t.Fatalf("steward of public repeater has public role = %v (err %v), want true", yes, err) + } +} diff --git a/internal/store/users.go b/internal/store/users.go index f2e7550..2f5f27f 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -24,6 +24,11 @@ type User struct { Username string DisplayName *string PasswordHash *string + // Public profile fields, shown on the user's public page (/u/{username}). + // Empty when unset; a blank field simply doesn't render. + Bio string + Location string + Callsign string // Instance-level capability flags. CapManageUsers bool CapManageCatalog bool @@ -37,12 +42,12 @@ func (u *User) Name() string { return u.Username } -const userCols = `id, username, display_name, password_hash, cap_manage_users, cap_manage_catalog` +const userCols = `id, username, display_name, password_hash, bio, location, callsign, cap_manage_users, cap_manage_catalog` func scanUser(row pgx.Row) (*User, error) { var u User if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, - &u.CapManageUsers, &u.CapManageCatalog); err != nil { + &u.Bio, &u.Location, &u.Callsign, &u.CapManageUsers, &u.CapManageCatalog); err != nil { return nil, err } return &u, nil @@ -173,6 +178,35 @@ func (s *Store) SetDisplayName(ctx context.Context, userID int64, displayName st return nil } +// SetProfile updates a user's public profile fields (bio, location, callsign). +// Empty strings clear a field. Length bounding is the caller's responsibility. +func (s *Store) SetProfile(ctx context.Context, userID int64, bio, location, callsign string) error { + _, err := s.pool.Exec(ctx, + `UPDATE users SET bio = $2, location = $3, callsign = $4 WHERE id = $1`, + userID, bio, location, callsign) + if err != nil { + return fmt.Errorf("set profile: %w", err) + } + return nil +} + +// UserHasPublicRole reports whether the user is listed on any public page: as an +// org admin, or as the owner or steward of a repeater with a published public +// page. Such users are nudged to add a way to be reached. +func (s *Store) UserHasPublicRole(ctx context.Context, userID int64) (bool, error) { + var yes bool + err := s.pool.QueryRow(ctx, `SELECT + EXISTS(SELECT 1 FROM org_members WHERE user_id = $1 AND role = 'admin') + OR EXISTS(SELECT 1 FROM repeaters WHERE owner_id = $1 AND expose_public_page) + OR EXISTS(SELECT 1 FROM repeater_shares rs JOIN repeaters r ON r.id = rs.repeater_id + WHERE rs.user_id = $1 AND rs.steward AND r.expose_public_page)`, + userID).Scan(&yes) + if err != nil { + return false, fmt.Errorf("user has public role: %w", err) + } + return yes, nil +} + // SetPassword sets a user's bcrypt password hash. func (s *Store) SetPassword(ctx context.Context, userID int64, hash string) error { _, err := s.pool.Exec(ctx, `UPDATE users SET password_hash = $1 WHERE id = $2`, hash, userID) diff --git a/internal/web/qr.go b/internal/web/qr.go new file mode 100644 index 0000000..f4dc4ac --- /dev/null +++ b/internal/web/qr.go @@ -0,0 +1,41 @@ +package web + +import ( + "encoding/base64" + "html/template" + "image/color" + "net/url" + "strconv" + + qrcode "github.com/skip2/go-qrcode" +) + +// QRDataURI encodes content as a QR code and returns it as a base64 PNG data URI +// ready to drop into an . ok is false if encoding fails (the caller +// should then just render without the QR). Light modules on a transparent quiet +// zone so the code sits on a dark card rather than a stark white block (scanners +// decode inverted QR fine). +func QRDataURI(content string) (template.URL, bool) { + qr, err := qrcode.New(content, qrcode.Medium) + if err != nil { + return "", false + } + qr.BackgroundColor = color.Transparent + qr.ForegroundColor = color.RGBA{R: 0x8a, G: 0x97, B: 0xa8, A: 0xff} + png, err := qr.PNG(256) + if err != nil { + return "", false + } + return template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)), true //nolint:gosec // G203: fixed data: URI over base64 PNG, no user input +} + +// MeshCoreContactURI builds the meshcore:// deep link that adds a contact in the +// MeshCore app. advertType is the MeshCore advert/contact type (1 = chat/person, +// 2 = repeater). +func MeshCoreContactURI(name, publicKeyHex string, advertType int) string { + q := url.Values{} + q.Set("name", name) + q.Set("public_key", publicKeyHex) + q.Set("type", strconv.Itoa(advertType)) + return "meshcore://contact/add?" + q.Encode() +} diff --git a/internal/web/templates/icons.html b/internal/web/templates/icons.html index 97165af..943f1aa 100644 --- a/internal/web/templates/icons.html +++ b/internal/web/templates/icons.html @@ -40,9 +40,11 @@ {{define "icon-brand-telegram"}}{{end}} {{define "icon-brand-reddit"}}{{end}} {{define "icon-brand-linkedin"}}{{end}} -{{/* link-icon renders the brand/site icon for an org link's platform key (.). - Falls back to a generic link glyph for "website" and anything unknown. */}} -{{define "link-icon"}}{{if eq . "discord"}}{{template "icon-brand-discord" ""}}{{else if eq . "facebook"}}{{template "icon-brand-facebook" ""}}{{else if eq . "instagram"}}{{template "icon-brand-instagram" ""}}{{else if eq . "x"}}{{template "icon-brand-x" ""}}{{else if eq . "youtube"}}{{template "icon-brand-youtube" ""}}{{else if eq . "github"}}{{template "icon-brand-github" ""}}{{else if eq . "telegram"}}{{template "icon-brand-telegram" ""}}{{else if eq . "reddit"}}{{template "icon-brand-reddit" ""}}{{else if eq . "linkedin"}}{{template "icon-brand-linkedin" ""}}{{else}}{{template "icon-link" ""}}{{end}}{{end}} +{{define "icon-mail"}}{{end}} +{{define "icon-brand-signal"}}{{end}} +{{/* link-icon renders the brand/site icon for a link's platform key (.). Falls + back to a generic link glyph for "website" and anything unknown. */}} +{{define "link-icon"}}{{if eq . "email"}}{{template "icon-mail" ""}}{{else if eq . "signal"}}{{template "icon-brand-signal" ""}}{{else if eq . "discord"}}{{template "icon-brand-discord" ""}}{{else if eq . "facebook"}}{{template "icon-brand-facebook" ""}}{{else if eq . "instagram"}}{{template "icon-brand-instagram" ""}}{{else if eq . "x"}}{{template "icon-brand-x" ""}}{{else if eq . "youtube"}}{{template "icon-brand-youtube" ""}}{{else if eq . "github"}}{{template "icon-brand-github" ""}}{{else if eq . "telegram"}}{{template "icon-brand-telegram" ""}}{{else if eq . "reddit"}}{{template "icon-brand-reddit" ""}}{{else if eq . "linkedin"}}{{template "icon-brand-linkedin" ""}}{{else}}{{template "icon-link" ""}}{{end}}{{end}} {{/* link-list renders a wrapping row of org link buttons. . is a slice of links, each exposing .Platform, .URL, and .Display. */}} {{define "link-list"}}{{end}} diff --git a/internal/web/templates/org_public.html b/internal/web/templates/org_public.html index 6c2a10d..7f63354 100644 --- a/internal/web/templates/org_public.html +++ b/internal/web/templates/org_public.html @@ -76,7 +76,7 @@
Admins
{{if .Admins}}
- {{range .Admins}}{{.}}{{end}} + {{range .Admins}}{{.Name}}{{end}}
{{else}}

No admins listed.

{{end}} {{if .Links}}