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 @@