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 && (
+
)}
-
-
{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..34692883 100644
--- a/functions/api/__tests__/artists.test.js
+++ b/functions/api/__tests__/artists.test.js
@@ -121,4 +121,84 @@ 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({
+ // 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",
+ }),
+ });
+
+ 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 }), {