From 1f68a4b0478e763ba7dd9e04a4e05f0d0649ef44 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Fri, 3 Jul 2026 20:05:59 -0400 Subject: [PATCH] Make sure discord invite links are clickable --- internal/auth/account.go | 15 ++++++++-- internal/core/orgs.go | 20 +++++++++---- internal/store/link_platform_test.go | 22 ++++++++++++++ internal/store/org_links.go | 45 ++++++++++++++++++++++++---- 4 files changed, 89 insertions(+), 13 deletions(-) diff --git a/internal/auth/account.go b/internal/auth/account.go index 5a94a95..8a1ce43 100644 --- a/internal/auth/account.go +++ b/internal/auth/account.go @@ -328,15 +328,26 @@ func validMeshCoreKey(s string) bool { // Signal enforces its username grammar; other text platforms (Discord) just need // a non-empty, space-free handle. func normalizeTextHandle(platform, val string) (string, string) { - v := strings.TrimPrefix(strings.TrimSpace(val), "@") + val = strings.TrimSpace(val) if platform == store.SignalPlatform { + v := strings.TrimPrefix(val, "@") if !validSignalUsername(v) { return "", "Enter a valid Signal username (3–32 characters: letters, digits, . and _)." } return v, "" } + // A text handle (Discord) also accepts an invite/profile URL, stored and + // rendered as a clickable link; a bare username stays plain text. + if store.LooksLikeURL(val) { + u := store.NormalizeLinkURL(val) + if !store.ValidLinkURL(u) { + return "", "Enter a valid username or invite link." + } + return u, "" + } + v := strings.TrimPrefix(val, "@") if v == "" || len(v) > 64 || strings.ContainsAny(v, " \t\n\r") { - return "", "Enter a valid username (no spaces)." + return "", "Enter a valid username (no spaces) or an invite link." } return v, "" } diff --git a/internal/core/orgs.go b/internal/core/orgs.go index 34f32e2..d08d471 100644 --- a/internal/core/orgs.go +++ b/internal/core/orgs.go @@ -327,13 +327,21 @@ func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) { } // Validate/canonicalise per kind, leaving `u` as the value to persist. switch p.Kind { - case store.KindText: // Discord — a handle shown as text. - v := strings.TrimPrefix(u, "@") - if v == "" || len(v) > 64 || strings.ContainsAny(v, " \t\n\r") { - orgErr(w, r, "Enter a valid username (no spaces).") - return + case store.KindText: // Discord — a username shown as text, or an invite link. + if store.LooksLikeURL(u) { + u = store.NormalizeLinkURL(u) + if !store.ValidLinkURL(u) { + orgErr(w, r, "Enter a valid username or invite link.") + return + } + } else { + v := strings.TrimPrefix(u, "@") + if v == "" || len(v) > 64 || strings.ContainsAny(v, " \t\n\r") { + orgErr(w, r, "Enter a valid username (no spaces) or an invite link.") + return + } + u = v } - u = v case store.KindHandle: canon, ok := p.CanonicalHandleURL(u) if !ok { diff --git a/internal/store/link_platform_test.go b/internal/store/link_platform_test.go index 2826f8c..9be9e2e 100644 --- a/internal/store/link_platform_test.go +++ b/internal/store/link_platform_test.go @@ -108,6 +108,10 @@ func TestHandleFromURLAndDisplay(t *testing.T) { {UserLink{Platform: "github", Label: "My code", URL: "https://github.com/octocat"}, "My code", "https://github.com/octocat"}, // Text platform: display the handle text, no link. {UserLink{Platform: "discord", URL: "cooluser"}, "cooluser", ""}, + // A new-style Discord username may contain dots but is still just text. + {UserLink{Platform: "discord", URL: "cool.user"}, "cool.user", ""}, + // A Discord invite URL is clickable and reads as the platform name. + {UserLink{Platform: "discord", URL: "https://discord.gg/abc123"}, "Discord", "https://discord.gg/abc123"}, {UserLink{Platform: SignalPlatform, URL: "alice.42"}, "alice.42", ""}, // Email: mailto:, and the address itself as the display (not "Email"). {UserLink{Platform: EmailPlatform, URL: "a@b.com"}, "a@b.com", "mailto:a@b.com"}, @@ -133,3 +137,21 @@ func TestHandleFromURLAndDisplay(t *testing.T) { t.Errorf("org discord Href() = %q, want empty", h) } } + +func TestLooksLikeURL(t *testing.T) { + cases := map[string]bool{ + "cooluser": false, // bare handle + "cool.user": false, // dotted handle (new Discord usernames) + "@handle": false, + "discord.gg/abc": true, // bare host + path + "discord.com/invite/abc": true, + "https://discord.gg/abc": true, + "http://example.com": true, + "example.com": false, // dotted host but no path — treated as a handle + } + for in, want := range cases { + if got := LooksLikeURL(in); got != want { + t.Errorf("LooksLikeURL(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/internal/store/org_links.go b/internal/store/org_links.go index ce6a4bc..f0f5db4 100644 --- a/internal/store/org_links.go +++ b/internal/store/org_links.go @@ -76,7 +76,7 @@ var linkPlatforms = []LinkPlatform{ {Key: "telegram", Name: "Telegram", Kind: KindHandle, Icon: "icon-brand-telegram", URLFmt: "https://t.me/%s", Hosts: []string{"t.me", "telegram.me"}, Placeholder: "@username or profile URL"}, {Key: mastodonKey, Name: "Mastodon", Kind: KindHandle, Icon: "icon-brand-mastodon", Placeholder: "@user@instance or profile URL"}, {Key: "bluesky", Name: "Bluesky", Kind: KindHandle, Icon: "icon-brand-bluesky", URLFmt: "https://bsky.app/profile/%s", Hosts: []string{"bsky.app"}, Placeholder: "@handle or profile URL"}, - {Key: "discord", Name: "Discord", Kind: KindText, Icon: "icon-brand-discord", Placeholder: "username"}, + {Key: "discord", Name: "Discord", Kind: KindText, Icon: "icon-brand-discord", Placeholder: "username or invite link"}, } var linkPlatformByKey = func() map[string]LinkPlatform { @@ -322,8 +322,15 @@ func linkDisplay(p LinkPlatform, key, value string) string { return h } return p.Name - case KindText, KindEmail: - // Show the handle / email address itself rather than the platform name. + case KindText: + // A text handle (e.g. a Discord username) shows itself; but the field also + // accepts an invite/profile URL, which reads better as the platform name. + if isHTTPURL(value) { + return p.Name + } + return value + case KindEmail: + // Show the email address itself rather than the platform name. return value case "": return key // unknown platform — show the raw key defensively @@ -333,10 +340,17 @@ func linkDisplay(p LinkPlatform, key, value string) string { } // linkHref computes the hyperlink target for a link value given its platform -// descriptor. Text/key platforms aren't linkable; email becomes mailto:. +// descriptor. Key platforms aren't linkable; email becomes mailto:. A text handle +// isn't linkable either — except the field accepts a URL (e.g. a Discord invite), +// which we do link. func linkHref(p LinkPlatform, value string) string { switch p.Kind { - case KindText, KindKey: + case KindKey: + return "" + case KindText: + if isHTTPURL(value) { + return value + } return "" case KindEmail: return "mailto:" + value @@ -345,6 +359,27 @@ func linkHref(p LinkPlatform, value string) string { } } +// isHTTPURL reports whether value is already a stored http(s) URL. Stored values +// are normalized on save, so a scheme prefix is a reliable signal. +func isHTTPURL(value string) bool { + return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") +} + +// LooksLikeURL reports whether raw was entered as a web URL (a scheme, or a +// dotted host followed by a path like "discord.gg/abc") rather than a bare handle. +// A handle may contain dots (new Discord usernames do) but never a slash, so the +// path is the reliable discriminator. +func LooksLikeURL(raw string) bool { + raw = strings.TrimSpace(raw) + if strings.Contains(raw, "://") { + return true + } + if i := strings.IndexByte(raw, '/'); i > 0 { + return strings.Contains(raw[:i], ".") + } + return false +} + // ListOrgLinks returns an org's links in display order. func (s *Store) ListOrgLinks(ctx context.Context, orgID int64) ([]OrgLink, error) { rows, err := s.pool.Query(ctx,