diff --git a/internal/http/web.go b/internal/http/web.go index 0f720bd..b5edf45 100644 --- a/internal/http/web.go +++ b/internal/http/web.go @@ -263,6 +263,14 @@ func (s *Server) routesWeb(mux *http.ServeMux) { }) })) + mux.HandleFunc("GET /users/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { + s.render(w, r, "public-profile.html", &pageData{ + Title: "Profile", User: u, + Scripts: []string{"/static/js/public-profile.js"}, + Data: map[string]any{"UserID": r.PathValue("id")}, + }) + })) + rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil) rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil) } diff --git a/web/static/js/public-profile.js b/web/static/js/public-profile.js new file mode 100644 index 0000000..5bdbf91 --- /dev/null +++ b/web/static/js/public-profile.js @@ -0,0 +1,57 @@ +// Public profile view (/users/{id}): full bio, contact, links and custom +// details from the user's profile card. +import { api } from '/static/js/api.js'; + +const root = document.getElementById('profile-root'); +const errorBox = document.getElementById('error'); +const uid = root.dataset.userId; + +const HIDDEN_EXTRA = new Set(['ticketingIdentities', 'savedBoardFilters']); + +const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', +}[c])); + +// only allow safe link schemes; bare domains get https://, others are blocked +export function safeUrl(u) { + const s = String(u ?? '').trim(); + if (/^(https?:\/\/|mailto:)/i.test(s)) return s; + if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return '#'; + return s ? 'https://' + s : '#'; +} + +async function load() { + try { + const res = await api('GET', `/api/v1/users/${uid}/card`); + const c = res.card; + const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles && c.roles[r]); + const links = ((c.contact && c.contact.links) || []).filter(Boolean); + const extra = Object.entries(c.extra || {}) + .filter(([k, v]) => !HIDDEN_EXTRA.has(k) && v !== null && typeof v !== 'object'); + const hasContact = (c.contact && (c.contact.phone || c.contact.location)) || links.length; + root.innerHTML = ` +
+
+ ${c.avatarFileId + ? `` + : `${esc((c.name || '?').slice(0, 1).toUpperCase())}`} +
+

${esc(c.name)}

+

${roles.map((r) => `${r}`).join(' ')}

+
+
+ ${c.bio ? `

Bio

${esc(c.bio)}

` : ''} + ${hasContact ? '

Contact

' : ''} + ${c.contact && c.contact.location ? `

📍 ${esc(c.contact.location)}

` : ''} + ${c.contact && c.contact.phone ? `

📞 ${esc(c.contact.phone)}

` : ''} + ${links.length ? `` : ''} + ${extra.length ? `

Details

${extra.map(([k, v]) => + `

${esc(k)}: ${esc(String(v))}

`).join('')}` : ''} +
`; + } catch (e) { + errorBox.textContent = 'Could not load this profile.'; + } +} + +load(); diff --git a/web/static/js/shortcuts.js b/web/static/js/shortcuts.js index 2864b41..d96c673 100644 --- a/web/static/js/shortcuts.js +++ b/web/static/js/shortcuts.js @@ -37,6 +37,14 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[c])); +// allow only safe link schemes (block javascript:/data: etc.) +const safeUrl = (u) => { + const s = String(u ?? '').trim(); + if (/^(https?:\/\/|mailto:)/i.test(s)) return s; + if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return '#'; + return s ? 'https://' + s : '#'; +}; + function hideCard() { if (cardEl) { cardEl.remove(); cardEl = null; } } @@ -49,6 +57,7 @@ async function showCard(target, userId) { cardEl = document.createElement('div'); cardEl.className = 'hovercard card'; const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles[r]); + const links = ((c.contact && c.contact.links) || []).filter(Boolean); cardEl.innerHTML = `
${c.avatarFileId ? `` @@ -58,8 +67,12 @@ async function showCard(target, userId) { ${roles.map((r) => `${r}`).join(' ')}
- ${c.bio ? `

${esc(c.bio.slice(0, 160))}

` : ''} - ${c.contact && c.contact.location ? `

📍 ${esc(c.contact.location)}

` : ''}`; + ${c.bio ? `

${esc(c.bio.slice(0, 220))}${c.bio.length > 220 ? '…' : ''}

` : ''} + ${c.contact && c.contact.location ? `

📍 ${esc(c.contact.location)}

` : ''} + ${c.contact && c.contact.phone ? `

📞 ${esc(c.contact.phone)}

` : ''} + ${links.length ? `

${links.slice(0, 3).map((l) => + `🔗 ${esc(l)}`).join('
')}

` : ''} + See full profile →`; document.body.appendChild(cardEl); const rect = target.getBoundingClientRect(); cardEl.style.left = Math.min(rect.left, window.innerWidth - 320) + 'px'; diff --git a/web/templates/public-profile.html b/web/templates/public-profile.html new file mode 100644 index 0000000..efbeef7 --- /dev/null +++ b/web/templates/public-profile.html @@ -0,0 +1,4 @@ +{{define "content"}} + +
+{{end}}