Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/api-spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
106 changes: 84 additions & 22 deletions frontend/src/pages/ArtistsPage.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Link
to={buildBandProfileHref(artist.name)}
className="flex items-center gap-4 rounded-xl border border-border bg-gradient-card p-4 transition hover:scale-[1.01] hover:border-accent-400/50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-accent-400"
>
{artist.photo_url ? (
<img
src={artist.photo_url}
alt=""
loading="lazy"
className="h-16 w-16 shrink-0 rounded-full object-cover ring-2 ring-border"
/>
) : (
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-accent-500/20 text-2xl font-bold text-accent-400">
{(artist.name || '?').charAt(0).toUpperCase()}
<div className="relative flex items-center gap-4 rounded-xl border border-border bg-gradient-card p-4 transition hover:border-accent-400/50">
<Link
to={buildBandProfileHref(artist.name)}
className="flex min-w-0 flex-1 items-center gap-4 rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-accent-400"
>
{artist.photo_url ? (
<img
src={artist.photo_url}
alt=""
loading="lazy"
className="h-16 w-16 shrink-0 rounded-full object-cover ring-2 ring-border"
/>
) : (
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-accent-500/20 text-2xl font-bold text-accent-400">
{(artist.name || '?').charAt(0).toUpperCase()}
</div>
)}
<div className="min-w-0">
<h2 className="truncate font-display text-lg font-bold text-text-primary">{artist.name}</h2>
{meta && <p className="truncate text-sm text-text-tertiary">{meta}</p>}
<p className="mt-0.5 text-xs text-text-tertiary">{shows}</p>
</div>
</Link>
{socialLinks.length > 0 && (
<div className="flex shrink-0 items-center gap-1">
{socialLinks.map(({ key, label, Icon, href }) => (
<a
key={key}
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={`${artist.name} on ${label}`}
onClick={() => 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"
>
<Icon size={16} />
</a>
))}
</div>
)}
<div className="min-w-0">
<h2 className="truncate font-display text-lg font-bold text-text-primary">{artist.name}</h2>
{meta && <p className="truncate text-sm text-text-tertiary">{meta}</p>}
<p className="mt-0.5 text-xs text-text-tertiary">{shows}</p>
</div>
</Link>
</div>
)
}

Expand Down
117 changes: 117 additions & 0 deletions frontend/src/pages/__tests__/ArtistsPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<artist> on <platform>" 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()
})
})
80 changes: 80 additions & 0 deletions functions/api/__tests__/artists.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
8 changes: 8 additions & 0 deletions functions/api/artists.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }), {
Expand Down
Loading