mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
User details page
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -53,6 +53,121 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">Public profile</h3></div>
|
||||
<div class="card-body">
|
||||
<p class="text-secondary">These appear on your public page{{if .RootURL}} at <a href="{{.RootURL}}/u/{{.User.Username}}" target="_blank" rel="noopener">{{.RootURL}}/u/{{.User.Username}}</a>{{end}}, which anyone can view. Leave a field blank to keep it off your page.</p>
|
||||
<form method="post" action="/account/profile-fields">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="acct_bio">Bio <span class="form-label-description">optional</span></label>
|
||||
<textarea class="form-control" id="acct_bio" name="bio" rows="3" maxlength="500" placeholder="A short blurb about you">{{.Bio}}</textarea>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md">
|
||||
<label class="form-label" for="acct_location">Location <span class="form-label-description">optional</span></label>
|
||||
<input type="text" class="form-control" id="acct_location" name="location" maxlength="120" value="{{.Location}}" placeholder="General area you operate in">
|
||||
</div>
|
||||
<div class="col-md">
|
||||
<label class="form-label" for="acct_callsign">Callsign <span class="form-label-description">optional</span></label>
|
||||
<input type="text" class="form-control" id="acct_callsign" name="callsign" maxlength="32" value="{{.Callsign}}" placeholder="e.g. KD2ABC">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">Save profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">Links <span class="text-secondary">(public)</span></h3></div>
|
||||
<div class="card-body">
|
||||
<p class="text-secondary">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 <strong>primary contact</strong> so people know the best way to reach you.</p>
|
||||
<form method="post" action="/account/links" id="user-links-form">
|
||||
<div id="user-links-rows">
|
||||
{{range $i, $l := .Links}}
|
||||
<div class="row g-2 mb-2 align-items-center link-row">
|
||||
<div class="col-12 col-sm-3">
|
||||
<select class="form-select" name="link_platform" aria-label="Link type">
|
||||
{{$sel := $l.Platform}}
|
||||
{{range $.Platforms}}<option value="{{.Key}}"{{if eq .Key $sel}} selected{{end}}>{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-sm-3">
|
||||
<input class="form-control" name="link_label" value="{{$l.Label}}" maxlength="60" placeholder="Label (optional)" aria-label="Link label">
|
||||
</div>
|
||||
<div class="col">
|
||||
<input class="form-control" type="text" name="link_url" value="{{$l.URL}}" maxlength="300" placeholder="URL, email, Signal username, or MeshCore key" aria-label="Link value">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-check form-check-inline m-0" title="Primary contact">
|
||||
<input class="form-check-input" type="radio" name="link_primary" value="{{$i}}"{{if $l.IsPrimary}} checked{{end}}>
|
||||
<span class="form-check-label">Primary</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="button" class="btn btn-ghost-danger btn-icon remove-link" aria-label="Remove link">{{template "icon-trash" ""}}</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="btn-list mt-2">
|
||||
<button type="button" class="btn" id="add-user-link">{{template "icon-plus" "me-1"}}Add link</button>
|
||||
<button type="submit" class="btn btn-primary">Save links</button>
|
||||
</div>
|
||||
</form>
|
||||
<template id="user-link-row-tpl">
|
||||
<div class="row g-2 mb-2 align-items-center link-row">
|
||||
<div class="col-12 col-sm-3">
|
||||
<select class="form-select" name="link_platform" aria-label="Link type">
|
||||
{{range .Platforms}}<option value="{{.Key}}">{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-sm-3">
|
||||
<input class="form-control" name="link_label" maxlength="60" placeholder="Label (optional)" aria-label="Link label">
|
||||
</div>
|
||||
<div class="col">
|
||||
<input class="form-control" type="text" name="link_url" maxlength="300" placeholder="URL, email, Signal username, or MeshCore key" aria-label="Link value">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-check form-check-inline m-0" title="Primary contact">
|
||||
<input class="form-check-input" type="radio" name="link_primary" value="">
|
||||
<span class="form-check-label">Primary</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="button" class="btn btn-ghost-danger btn-icon remove-link" aria-label="Remove link">{{template "icon-trash" ""}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
(function(){
|
||||
var form = document.getElementById('user-links-form');
|
||||
var wrap = document.getElementById('user-links-rows');
|
||||
var tpl = document.getElementById('user-link-row-tpl');
|
||||
var add = document.getElementById('add-user-link');
|
||||
if(!form || !wrap || !tpl || !add) return;
|
||||
add.addEventListener('click', function(){
|
||||
wrap.appendChild(tpl.content.cloneNode(true));
|
||||
});
|
||||
wrap.addEventListener('click', function(e){
|
||||
var btn = e.target.closest('.remove-link');
|
||||
if(btn){ btn.closest('.link-row').remove(); }
|
||||
});
|
||||
// Renumber the primary-contact radios to DOM order so the selected one's
|
||||
// value matches its row's index in the submitted arrays (rows may have been
|
||||
// added or removed).
|
||||
form.addEventListener('submit', function(){
|
||||
wrap.querySelectorAll('.link-row').forEach(function(row, i){
|
||||
var radio = row.querySelector('input[name="link_primary"]');
|
||||
if(radio) radio.value = i;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header"><h3 class="card-title">Passkeys</h3></div>
|
||||
<div class="card-body">
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
-13
@@ -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())
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -81,13 +81,13 @@
|
||||
<div class="list-group list-group-flush">
|
||||
<div class="list-group-item d-flex align-items-center gap-2">
|
||||
<span class="text-secondary">{{template "icon-user" ""}}</span>
|
||||
<div class="flex-fill"><span class="fw-bold">{{.Repeater.OwnerName}}</span></div>
|
||||
<div class="flex-fill"><a class="fw-bold" href="{{$.RootURL}}/u/{{.Repeater.OwnerUsername}}">{{.Repeater.OwnerName}}</a></div>
|
||||
<span class="badge bg-success-lt">Primary</span>
|
||||
</div>
|
||||
{{range .Stewards}}
|
||||
<div class="list-group-item d-flex align-items-center gap-2">
|
||||
<span class="text-secondary">{{template "icon-user" ""}}</span>
|
||||
<div class="flex-fill"><span class="fw-bold">{{.Name}}</span></div>
|
||||
<div class="flex-fill"><a class="fw-bold" href="{{$.RootURL}}/u/{{.Username}}">{{.Name}}</a></div>
|
||||
<span class="badge bg-azure-lt">Backup</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{{define "title"}}{{.ProfileUser.Name}} · MeshTender{{end}}
|
||||
{{define "header"}}
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col">
|
||||
<div class="page-pretitle">Profile</div>
|
||||
<h2 class="page-title">{{.ProfileUser.Name}}</h2>
|
||||
<div class="text-secondary">@{{.ProfileUser.Username}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
<div class="row row-cards">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">About</h3></div>
|
||||
<div class="card-body">
|
||||
{{if .Location}}<div class="d-flex align-items-center text-secondary mb-2">{{template "icon-map-pin" "me-1"}}{{.Location}}</div>{{end}}
|
||||
{{if .Callsign}}<div class="mb-2"><span class="text-secondary">Callsign:</span> <span class="font-monospace">{{.Callsign}}</span></div>{{end}}
|
||||
{{if .Bio}}<p class="mb-0">{{.Bio}}</p>{{end}}
|
||||
{{if not .HasDetails}}<p class="text-secondary mb-0">This person hasn't added any profile details yet.</p>{{end}}
|
||||
|
||||
{{if .Links}}
|
||||
<div class="hr-text">Links</div>
|
||||
<div class="btn-list">
|
||||
{{range .Links}}
|
||||
{{if .Href}}
|
||||
<a class="btn" href="{{.Href}}" target="_blank" rel="noopener noreferrer nofollow">{{template "link-icon" .Platform}}<span class="ms-1">{{.Display}}</span>{{if .IsPrimary}} <span class="badge bg-primary-lt ms-1">Primary</span>{{end}}</a>
|
||||
{{else}}
|
||||
<span class="btn disabled">{{template "link-icon" .Platform}}<span class="ms-1">{{.URL}}</span>{{if .IsPrimary}} <span class="badge bg-primary-lt ms-1">Primary</span>{{end}}</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .MeshKeys}}
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3 class="card-title">MeshCore</h3></div>
|
||||
<div class="card-body">
|
||||
{{range .MeshKeys}}
|
||||
<div class="mb-3">
|
||||
{{if .QR}}<div class="text-center mb-2"><img src="{{.QR}}" width="200" height="200" alt="MeshCore contact QR for {{.Label}}"></div>{{end}}
|
||||
<div class="form-label">{{.Label}}</div>
|
||||
<code class="pk d-block">{{.Key}}</code>
|
||||
</div>
|
||||
{{end}}
|
||||
<p class="text-secondary small mb-0">Scan to add this contact in the MeshCore app.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<a class="back-link mt-3" href="{{.RootURL}}/orgs">{{template "icon-arrow-left" "me-1"}}All organizations</a>
|
||||
{{end}}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
+11
-10
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+36
-2
@@ -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)
|
||||
|
||||
@@ -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 <img src>. 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()
|
||||
}
|
||||
@@ -40,9 +40,11 @@
|
||||
{{define "icon-brand-telegram"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M15 10l-4 4l6 6l4 -16l-18 7l4 2l2 6l3 -4"/></svg>{{end}}
|
||||
{{define "icon-brand-reddit"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M12 8c2.648 0 5.028 .826 6.675 2.14a2.5 2.5 0 0 1 2.326 4.36c0 3.59 -4.03 6.5 -9 6.5c-4.97 0 -9 -2.91 -9 -6.5a2.5 2.5 0 0 1 2.326 -4.36c1.646 -1.313 4.026 -2.14 6.674 -2.14z"/><path d="M12 8l1 -5l6 1"/><path d="M19 4m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/><path d="M9 13l0 .01"/><path d="M15 13l0 .01"/><path d="M10 17a3.5 3.5 0 0 0 4 0"/></svg>{{end}}
|
||||
{{define "icon-brand-linkedin"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M8 11l0 5"/><path d="M8 8l0 .01"/><path d="M12 16l0 -5"/><path d="M16 16v-3a2 2 0 0 0 -4 0"/><path d="M3 7a4 4 0 0 1 4 -4h10a4 4 0 0 1 4 4v10a4 4 0 0 1 -4 4h-10a4 4 0 0 1 -4 -4z"/></svg>{{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"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10z"/><path d="M3 7l9 6l9 -6"/></svg>{{end}}
|
||||
{{define "icon-brand-signal"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M3 20l1.3 -3.9a9 8 0 1 1 3.4 2.9l-4.7 1"/><path d="M12 12l0 .01"/><path d="M8 12l0 .01"/><path d="M16 12l0 .01"/></svg>{{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"}}<div class="btn-list">{{range .}}<a class="btn" href="{{.URL}}" target="_blank" rel="noopener noreferrer nofollow">{{template "link-icon" .Platform}}<span class="ms-1">{{.Display}}</span></a>{{end}}</div>{{end}}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<div class="hr-text">Admins</div>
|
||||
{{if .Admins}}
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{{range .Admins}}<span class="badge bg-success-lt">{{.}}</span>{{end}}
|
||||
{{range .Admins}}<a class="badge bg-success-lt" href="{{$.RootURL}}/u/{{.Username}}">{{.Name}}</a>{{end}}
|
||||
</div>
|
||||
{{else}}<p class="text-secondary mb-0">No admins listed.</p>{{end}}
|
||||
{{if .Links}}
|
||||
|
||||
Reference in New Issue
Block a user