From 64f6a118e77eeedda47d17bb9ebd8ec22e2ff837 Mon Sep 17 00:00:00 2001 From: Andre Levesque <0sniffs_scaled@icloud.com> Date: Thu, 16 Jul 2026 20:11:39 -0400 Subject: [PATCH 1/2] feat: show band link icons on the public roster page Fans had to open each band's profile page just to reach their links. Each /artists card now carries up to 4 monochrome icon links in hear-first priority (bandcamp, spotify, instagram, website, then youtube/facebook/apple_music/linktree), with real 40px tap targets. The card is restructured (outer div + content Link + sibling icon cluster) so profile navigation and external links never nest anchors. /api/artists now returns `social`, sanitized server-side via safeReflectSocialLinks like the other public band endpoints; the frontend re-sanitizes with safeExternalHref/safeInstagramHref as a second layer. Clicks feed the existing social_link_click metric. Closes #607 Co-Authored-By: Claude Fable 5 --- docs/api-spec.yaml | 11 ++ frontend/src/pages/ArtistsPage.jsx | 106 ++++++++++++---- .../src/pages/__tests__/ArtistsPage.test.jsx | 117 ++++++++++++++++++ functions/api/__tests__/artists.test.js | 79 ++++++++++++ functions/api/artists.js | 8 ++ 5 files changed, 299 insertions(+), 22 deletions(-) diff --git a/docs/api-spec.yaml b/docs/api-spec.yaml index 884d1828..a07fd347 100644 --- a/docs/api-spec.yaml +++ b/docs/api-spec.yaml @@ -113,6 +113,17 @@ paths: nullable: true performance_count: type: integer + social: + allOf: + - $ref: "#/components/schemas/GenericRecord" + nullable: true + description: >- + Sanitized social_links map (safeReflectSocialLinks: + each value a normalized http(s) URL, an Instagram + handle, or null when the stored value was unsafe or + invalid). {} when the stored JSON was malformed; + null when no links are set. Consumers should still + sanitize before rendering (defense in depth). hasMore: type: boolean "503": diff --git a/frontend/src/pages/ArtistsPage.jsx b/frontend/src/pages/ArtistsPage.jsx index 696b2472..de81ea5a 100644 --- a/frontend/src/pages/ArtistsPage.jsx +++ b/frontend/src/pages/ArtistsPage.jsx @@ -1,43 +1,105 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { Helmet } from 'react-helmet-async' -import { ArrowLeft, Search } from 'lucide-react' +import { ArrowLeft, Globe, Search } from 'lucide-react' +import { + AppleMusicIcon, + BandcampIcon, + FacebookIcon, + InstagramIcon, + LinktreeIcon, + SpotifyIcon, + YouTubeIcon, +} from '../components/ui/SocialIcons' import Footer from '../components/Footer' import ThemeToggle from '../components/ThemeToggle.jsx' import { fetchPublicJson } from '../utils/publicApi' -import { trackPageView } from '../utils/metrics' +import { trackPageView, trackSocialClick } from '../utils/metrics' import { buildBandProfileHref } from '../utils/bandProfileLink' +import { safeExternalHref, safeInstagramHref } from '../utils/urlSafety' const PAGE_SIZE = 24 const PAGE_TITLE = 'Artists – SetTimes' +// Priority order for the roster card icon cluster: hear the band first, +// follow second. Only the first MAX_CARD_ICONS valid links show — overflow +// lives on the profile page, which owns the full set with brand colours. +const MAX_CARD_ICONS = 4 +const SOCIAL_LINK_DESCRIPTORS = [ + { key: 'bandcamp', label: 'Bandcamp', Icon: BandcampIcon, getHref: social => safeExternalHref(social.bandcamp) }, + { key: 'spotify', label: 'Spotify', Icon: SpotifyIcon, getHref: social => safeExternalHref(social.spotify) }, + { + key: 'instagram', + label: 'Instagram', + Icon: InstagramIcon, + getHref: social => safeInstagramHref(social.instagram), + }, + { key: 'website', label: 'Website', Icon: Globe, getHref: social => safeExternalHref(social.website) }, + { key: 'youtube', label: 'YouTube', Icon: YouTubeIcon, getHref: social => safeExternalHref(social.youtube) }, + { key: 'facebook', label: 'Facebook', Icon: FacebookIcon, getHref: social => safeExternalHref(social.facebook) }, + { + key: 'apple_music', + label: 'Apple Music', + Icon: AppleMusicIcon, + getHref: social => safeExternalHref(social.apple_music), + }, + { key: 'linktree', label: 'Linktree', Icon: LinktreeIcon, getHref: social => safeExternalHref(social.linktree) }, +] + +function getCardSocialLinks(social) { + if (!social) return [] + return SOCIAL_LINK_DESCRIPTORS.map(descriptor => ({ ...descriptor, href: descriptor.getHref(social) })) + .filter(link => link.href !== '#') + .slice(0, MAX_CARD_ICONS) +} + function ArtistCard({ artist }) { const meta = [artist.genre, artist.origin].filter(Boolean).join(' · ') const shows = `${artist.performance_count} ${artist.performance_count === 1 ? 'show' : 'shows'}` + const socialLinks = getCardSocialLinks(artist.social) return ( - - {artist.photo_url ? ( - - ) : ( -
- {(artist.name || '?').charAt(0).toUpperCase()} +
+ + {artist.photo_url ? ( + + ) : ( +
+ {(artist.name || '?').charAt(0).toUpperCase()} +
+ )} +
+

{artist.name}

+ {meta &&

{meta}

} +

{shows}

+
+ + {socialLinks.length > 0 && ( +
+ {socialLinks.map(({ key, label, Icon, href }) => ( + trackSocialClick(artist.id, key)} + className="flex h-10 w-10 items-center justify-center rounded-full text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-accent-400" + > + + + ))}
)} -
-

{artist.name}

- {meta &&

{meta}

} -

{shows}

-
- +
) } diff --git a/frontend/src/pages/__tests__/ArtistsPage.test.jsx b/frontend/src/pages/__tests__/ArtistsPage.test.jsx index 61df5542..05597602 100644 --- a/frontend/src/pages/__tests__/ArtistsPage.test.jsx +++ b/frontend/src/pages/__tests__/ArtistsPage.test.jsx @@ -64,4 +64,121 @@ describe('ArtistsPage', () => { ) }) }) + + it('renders icon links for an artist with bandcamp and instagram, and keeps the profile Link intact', async () => { + fetchPublicJson.mockResolvedValue({ + artists: [ + { + id: 7, + name: 'Cross Dog', + genre: 'rock', + origin: 'Kitchener, ON', + photo_url: null, + performance_count: 2, + social: { + bandcamp: 'https://crossdog.bandcamp.com', + instagram: 'crossdogband', + }, + }, + ], + hasMore: false, + }) + renderPage() + await screen.findByText('Cross Dog') + + const bandcampLink = screen.getByRole('link', { name: 'Cross Dog on Bandcamp' }) + expect(bandcampLink).toHaveAttribute('href', 'https://crossdog.bandcamp.com/') + expect(bandcampLink).toHaveAttribute('target', '_blank') + expect(bandcampLink).toHaveAttribute('rel', 'noopener noreferrer') + + const instagramLink = screen.getByRole('link', { name: 'Cross Dog on Instagram' }) + expect(instagramLink).toHaveAttribute('href', 'https://instagram.com/crossdogband') + + const profileLink = screen.getByRole('heading', { name: 'Cross Dog' }).closest('a') + expect(profileLink).toHaveAttribute('href', '/band/cross-dog') + }) + + it('caps the icon cluster at 4 links and preserves priority order', async () => { + fetchPublicJson.mockResolvedValue({ + artists: [ + { + id: 8, + name: 'Many Links', + genre: 'pop', + origin: null, + photo_url: null, + performance_count: 1, + social: { + linktree: 'https://linktr.ee/manylinks', + facebook: 'https://facebook.com/manylinks', + youtube: 'https://youtube.com/manylinks', + website: 'https://manylinks.example', + instagram: 'manylinks', + spotify: 'https://open.spotify.com/artist/manylinks', + bandcamp: 'https://manylinks.bandcamp.com', + }, + }, + ], + hasMore: false, + }) + renderPage() + await screen.findByText('Many Links') + + // Priority order: bandcamp, spotify, instagram, website, youtube, facebook, apple_music, linktree. + // Only the first 4 valid links should render. + expect(screen.getByRole('link', { name: 'Many Links on Bandcamp' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Many Links on Spotify' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Many Links on Instagram' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Many Links on Website' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Many Links on YouTube' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Many Links on Facebook' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Many Links on Linktree' })).not.toBeInTheDocument() + }) + + it('renders no icon cluster when social is null', async () => { + fetchPublicJson.mockResolvedValue({ + artists: [ + { + id: 9, + name: 'No Links Band', + genre: 'folk', + origin: null, + photo_url: null, + performance_count: 1, + social: null, + }, + ], + hasMore: false, + }) + renderPage() + await screen.findByText('No Links Band') + + // No social icon anchors for this artist — icon links are the only ones + // whose accessible name follows the " on " pattern + // (distinguishes from the Footer's unrelated target="_blank" links). + expect(screen.queryByRole('link', { name: /No Links Band on/ })).not.toBeInTheDocument() + }) + + it('drops unsafe javascript: URLs so no icon renders for that link', async () => { + fetchPublicJson.mockResolvedValue({ + artists: [ + { + id: 10, + name: 'Unsafe Band', + genre: 'punk', + origin: null, + photo_url: null, + performance_count: 1, + social: { + website: 'javascript:alert(1)', + }, + }, + ], + hasMore: false, + }) + renderPage() + await screen.findByText('Unsafe Band') + + expect(screen.queryByRole('link', { name: 'Unsafe Band on Website' })).not.toBeInTheDocument() + }) }) diff --git a/functions/api/__tests__/artists.test.js b/functions/api/__tests__/artists.test.js index d85ebf4b..f49430fe 100644 --- a/functions/api/__tests__/artists.test.js +++ b/functions/api/__tests__/artists.test.js @@ -121,4 +121,83 @@ describe("Public artists directory - GET /api/artists", () => { // raw-byte order (which would put "The Anti-Queens" under T, after "Beatles"). expect(names).toEqual(["The Anti-Queens", "Beatles", "An Horse", "Zebras"]); }); + + it("round-trips valid social_links JSON into a `social` object (#607)", async () => { + const { env, rawDb } = seedEnv(); + const venue = insertVenue(rawDb, { name: "Main" }); + const ev = insertEvent(rawDb, { name: "Soc", slug: "soc-evt" }); + publish(rawDb, ev.id); + insertBand(rawDb, { + name: "Social Band", + event_id: ev.id, + venue_id: venue.id, + social_links: JSON.stringify({ bandcamp: "https://socialband.bandcamp.com", instagram: "socialband" }), + }); + + const res = await getArtists(env); + const { artists: list } = await res.json(); + const band = list.find((a) => a.name === "Social Band"); + expect(band.social).toEqual({ bandcamp: "https://socialband.bandcamp.com/", instagram: "socialband" }); + expect(band).not.toHaveProperty("social_links"); + }); + + it("yields social: {} for malformed social_links JSON, never throwing (safeReflectSocialLinks convention)", async () => { + const { env, rawDb } = seedEnv(); + const venue = insertVenue(rawDb, { name: "Main" }); + const ev = insertEvent(rawDb, { name: "Bad", slug: "bad-evt" }); + publish(rawDb, ev.id); + insertBand(rawDb, { + name: "Malformed Band", + event_id: ev.id, + venue_id: venue.id, + social_links: "{not valid json", + }); + + const res = await getArtists(env); + expect(res.status).toBe(200); + const { artists: list } = await res.json(); + const band = list.find((a) => a.name === "Malformed Band"); + expect(band.social).toEqual({}); + expect(band).not.toHaveProperty("social_links"); + }); + + it("sanitizes unsafe social URLs server-side (javascript: scheme nulled)", async () => { + const { env, rawDb } = seedEnv(); + const venue = insertVenue(rawDb, { name: "Main" }); + const ev = insertEvent(rawDb, { name: "Unsafe", slug: "unsafe-evt" }); + publish(rawDb, ev.id); + insertBand(rawDb, { + name: "Unsafe Band", + event_id: ev.id, + venue_id: venue.id, + social_links: JSON.stringify({ + bandcamp: "javascript:alert(1)", + website: "https://unsafeband.example.com", + }), + }); + + const res = await getArtists(env); + const { artists: list } = await res.json(); + const band = list.find((a) => a.name === "Unsafe Band"); + expect(band.social.bandcamp).toBeNull(); + expect(band.social.website).toBe("https://unsafeband.example.com/"); + }); + + it("yields social: null when social_links is absent, and never exposes the raw column", async () => { + const { env, rawDb } = seedEnv(); + const venue = insertVenue(rawDb, { name: "Main" }); + const ev = insertEvent(rawDb, { name: "None", slug: "none-evt" }); + publish(rawDb, ev.id); + insertBand(rawDb, { + name: "No Social Band", + event_id: ev.id, + venue_id: venue.id, + }); + + const res = await getArtists(env); + const { artists: list } = await res.json(); + const band = list.find((a) => a.name === "No Social Band"); + expect(band.social).toBeNull(); + expect(band).not.toHaveProperty("social_links"); + }); }); diff --git a/functions/api/artists.js b/functions/api/artists.js index 81b238a9..843d7880 100644 --- a/functions/api/artists.js +++ b/functions/api/artists.js @@ -6,6 +6,7 @@ // PUBLIC_DATA_PUBLISH_ENABLED switch as the rest of the public data. import { getPublicDataGateResponse } from "../utils/publicGate.js"; +import { safeReflectSocialLinks } from "../utils/validation.js"; import { sortableName } from "../utils/sortableName.js"; const DEFAULT_LIMIT = 24; @@ -58,6 +59,7 @@ export async function onRequestGet(context) { bp.origin, bp.origin_city, bp.origin_region, + bp.social_links, COUNT(DISTINCT p.id) AS performance_count FROM band_profiles bp JOIN performances p ON p.band_profile_id = bp.id @@ -86,6 +88,12 @@ export async function onRequestGet(context) { genre: row.genre, origin: formatOrigin(row), performance_count: row.performance_count, + // Server-side sanitized like the other public band endpoints + // (safeReflectSocialLinks: malformed JSON → {}, each value normalized + // to a real http(s) URL or null); the frontend still runs + // safeExternalHref before rendering any href. Absent column → null so + // profiles with no links don't grow an empty object. + social: row.social_links ? safeReflectSocialLinks(row.social_links) : null, })); return new Response(JSON.stringify({ artists, hasMore }), { From a56f287fdf06385e6fc259f8e82c7aba41a3e050 Mon Sep 17 00:00:00 2001 From: Andre Levesque <0sniffs_scaled@icloud.com> Date: Thu, 16 Jul 2026 20:18:14 -0400 Subject: [PATCH 2/2] test: allowlist intentional javascript: fixture for no-script-url The unsafe-scheme sanitization test needs a literal javascript: URL; document the eslint-disable the same way validation.test.js does. Co-Authored-By: Claude Fable 5 --- functions/api/__tests__/artists.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/functions/api/__tests__/artists.test.js b/functions/api/__tests__/artists.test.js index f49430fe..34692883 100644 --- a/functions/api/__tests__/artists.test.js +++ b/functions/api/__tests__/artists.test.js @@ -171,6 +171,7 @@ describe("Public artists directory - GET /api/artists", () => { event_id: ev.id, venue_id: venue.id, social_links: JSON.stringify({ + // eslint-disable-next-line no-script-url -- test fixture: intentional unsafe scheme, exercises the safeReflectSocialLinks read-path guard bandcamp: "javascript:alert(1)", website: "https://unsafeband.example.com", }),