-
Notifications
You must be signed in to change notification settings - Fork 1
feat(valuation): shareable result URLs — /valuation/[artistId] #48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { siteConfig } from "@/lib/config"; | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
|
|
||
| /** | ||
| * GET /api/spotify/artist/[id] | ||
| * | ||
| * Fetches a single Spotify artist by ID via the Recoup API proxy. | ||
| * Returns { id, name, image, genre, followers } or 404. | ||
| */ | ||
| export async function GET( | ||
| _req: NextRequest, | ||
| { params }: { params: Promise<{ id: string }> }, | ||
| ) { | ||
| const { id } = await params; | ||
| if (!id) { | ||
| return NextResponse.json({ error: "missing id" }, { status: 400 }); | ||
| } | ||
|
|
||
| try { | ||
| const res = await fetch(`${siteConfig.apiUrl}/spotify/artist/${id}`); | ||
|
Comment on lines
+19
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win No timeout on upstream fetch. If the upstream API hangs, this request blocks until the serverless function times out. Add an ⏱️ Proposed fix: add AbortController timeout+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 8000);
try {
- const res = await fetch(`${siteConfig.apiUrl}/spotify/artist/${safeId}`);
+ const res = await fetch(`${siteConfig.apiUrl}/spotify/artist/${safeId}`, {
+ signal: controller.signal,
+ });
+ clearTimeout(timeout);
if (!res.ok) {🤖 Prompt for AI AgentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: The Prompt for AI agentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The upstream Prompt for AI agents |
||
| if (!res.ok) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The non-ok upstream response is always mapped to 404 with a generic "not found" error. This loses signal from upstream 429 (rate-limit), 500, or 502 responses. The existing Prompt for AI agents |
||
| return NextResponse.json({ error: "not found" }, { status: 404 }); | ||
| } | ||
|
|
||
| const a = await res.json(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Custom agent: Enforce Clear Code Style and Maintainability Practices The variable Prompt for AI agents |
||
| return NextResponse.json({ | ||
| id: a.id ?? id, | ||
| name: a.name ?? "Unknown Artist", | ||
| image: a.images?.[0]?.url ?? null, | ||
| genre: a.genres?.[0] ?? null, | ||
| followers: a.followers?.total ?? 0, | ||
| }); | ||
| } catch { | ||
| return NextResponse.json({ error: "fetch failed" }, { status: 502 }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import type { Metadata } from "next"; | ||
| import { fetchArtistServer } from "@/lib/spotify/fetchArtistServer"; | ||
| import { CatalogValuation } from "@/components/valuation/CatalogValuation"; | ||
|
|
||
| type Props = { | ||
| params: Promise<{ artistId: string }>; | ||
| }; | ||
|
|
||
| export async function generateMetadata({ params }: Props): Promise<Metadata> { | ||
| const { artistId } = await params; | ||
| const artist = await fetchArtistServer(artistId); | ||
| const name = artist?.name ?? "this artist"; | ||
|
|
||
| return { | ||
| title: `What's ${name}'s Catalog Worth? | Recoup`, | ||
| description: `Get a directional valuation of ${name}'s recorded catalog from live Spotify play counts — measured, not guessed.`, | ||
| openGraph: { | ||
| title: `What's ${name}'s Catalog Worth?`, | ||
| description: `Run a live catalog valuation for ${name} — one click, no uploads, no statements.`, | ||
| ...(artist?.image ? { images: [{ url: artist.image, width: 640, height: 640 }] } : {}), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: When Prompt for AI agents |
||
| }, | ||
| twitter: { | ||
| card: "summary_large_image", | ||
| title: `What's ${name}'s Catalog Worth?`, | ||
| description: `Run a live catalog valuation for ${name} — one click, no uploads, no statements.`, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| export default async function ArtistValuationPage({ params }: Props) { | ||
| const { artistId } = await params; | ||
|
|
||
| return ( | ||
| <main className="relative pt-36 sm:pt-44 pb-24 min-h-screen"> | ||
| <div className="mx-auto max-w-[800px] px-6 flex flex-col items-center text-center"> | ||
| <span | ||
| className="inline-flex items-center gap-2.5 mb-5 px-4 py-2 rounded-full text-[12px] uppercase tracking-[0.16em] font-pixel text-(--foreground)/50" | ||
| style={{ | ||
| boxShadow: | ||
| "0 0 0 1px color-mix(in srgb, var(--foreground) 15%, transparent)", | ||
| }} | ||
| > | ||
| <span className="w-2 h-2 rounded-full bg-green-500/70 animate-pulse" /> | ||
| Live catalog valuation | ||
| </span> | ||
| <h1 className="font-pixel text-[clamp(2.5rem,7vw,4.5rem)] text-(--foreground) leading-[0.95] tracking-[-0.01em] mb-5"> | ||
| What's your catalog worth? | ||
| </h1> | ||
| <p className="text-[clamp(1.0625rem,1.6vw,1.25rem)] text-(--foreground)/60 max-w-[560px] leading-relaxed"> | ||
| Pick your artist. We measure live play counts across your entire | ||
| catalog and turn them into a valuation band — one click, no uploads, | ||
| no statements. | ||
| </p> | ||
| <CatalogValuation initialArtistId={artistId} /> | ||
| </div> | ||
| </main> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| "use client"; | ||
|
|
||
| import { useState } from "react"; | ||
| import { formatUsd } from "@/lib/valuation/formatUsd"; | ||
|
|
||
| type ShareValuationProps = { | ||
| artistName: string | null; | ||
| artistId: string | null; | ||
| centralValue: number; | ||
| }; | ||
|
|
||
| function CopyIcon() { | ||
| return ( | ||
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | ||
| <rect x="9" y="9" width="13" height="13" rx="2" ry="2" /> | ||
| <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /> | ||
| </svg> | ||
| ); | ||
| } | ||
|
|
||
| function CheckIcon() { | ||
| return ( | ||
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | ||
| <polyline points="20 6 9 17 4 12" /> | ||
| </svg> | ||
| ); | ||
| } | ||
|
|
||
| function XIcon() { | ||
| return ( | ||
| <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> | ||
| <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /> | ||
| </svg> | ||
| ); | ||
| } | ||
|
|
||
| function LinkedInIcon() { | ||
| return ( | ||
| <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> | ||
| <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" /> | ||
| </svg> | ||
| ); | ||
| } | ||
|
|
||
| export function ShareValuation({ artistName, artistId, centralValue }: ShareValuationProps) { | ||
| const [copied, setCopied] = useState(false); | ||
|
|
||
| const url = artistId | ||
| ? `https://recoupable.dev/valuation/${artistId}` | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The domain Prompt for AI agents |
||
| : "https://recoupable.dev/valuation"; | ||
|
Comment on lines
+48
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Hardcoded domain violates coding guidelines. The domain As per coding guidelines: "Never hardcode brand values; import from ♻️ Proposed fix- const url = artistId
- ? `https://recoupable.dev/valuation/${artistId}`
- : "https://recoupable.dev/valuation";
+ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://recoupable.dev";
+ const url = artistId
+ ? `${baseUrl}/valuation/${artistId}`
+ : `${baseUrl}/valuation`;Or, if +import { siteUrl } from "`@/lib/config`";
+
// ...
- const url = artistId
- ? `https://recoupable.dev/valuation/${artistId}`
- : "https://recoupable.dev/valuation";
+ const url = artistId
+ ? `${siteUrl}/valuation/${artistId}`
+ : `${siteUrl}/valuation`;🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| const value = formatUsd(centralValue); | ||
| const name = artistName ?? "My artist"; | ||
|
|
||
| const tweetText = `${name} catalog just got valued at ${value}. What's yours worth?\n\n${url}\n\n#recoup`; | ||
| const tweetUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}`; | ||
| const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`; | ||
|
|
||
| async function copyLink() { | ||
| try { | ||
| await navigator.clipboard.writeText(url); | ||
| setCopied(true); | ||
| setTimeout(() => setCopied(false), 2000); | ||
| } catch { | ||
| // Fallback — noop | ||
| } | ||
| } | ||
|
|
||
| const btnClass = | ||
| "inline-flex items-center gap-2 px-4 py-2.5 rounded-lg text-[12px] font-pixel uppercase tracking-[0.12em] text-(--foreground)/45 transition-all duration-200 hover:text-(--foreground)/70 hover:bg-(--foreground)/[0.04]"; | ||
|
|
||
| return ( | ||
| <div className="mt-6 flex items-center justify-center gap-1"> | ||
| <button type="button" onClick={copyLink} className={btnClass}> | ||
| {copied ? <CheckIcon /> : <CopyIcon />} | ||
| {copied ? "Copied" : "Copy link"} | ||
| </button> | ||
| <a href={tweetUrl} target="_blank" rel="noopener noreferrer" className={btnClass}> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Both share links have the same accessible name ("Share"). Add Prompt for AI agents |
||
| <XIcon /> | ||
| Share | ||
| </a> | ||
| <a href={linkedInUrl} target="_blank" rel="noopener noreferrer" className={btnClass}> | ||
| <LinkedInIcon /> | ||
| Share | ||
| </a> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,6 +9,8 @@ import type { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from "@/components/valuation/types"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { runValuationFlow } from "@/lib/valuation/runValuationFlow"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { captureRunLead } from "@/lib/valuation/captureRunLead"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { fetchSpotifyArtist } from "@/lib/spotify/fetchArtist"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { toArtist } from "@/lib/valuation/toArtist"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type Phase = "idle" | "running" | "done" | "error"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -28,10 +30,16 @@ export type CatalogValuationState = { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Drives the catalog valuation behind the Privy sign-in gate (chat#1798). The | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * run trigger opens Privy when signed out and, on login, auto-fires the run for | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * the **originally** selected artist. Render inside `PrivyProvider`. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * When `initialArtistId` is provided (shareable URL), auto-fetches the artist | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * and queues the valuation run on mount. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function useCatalogValuation(): CatalogValuationState { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function useCatalogValuation( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| initialArtistId?: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): CatalogValuationState { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { authenticated, login, getAccessToken, user } = usePrivy(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [picked, setPicked] = useState<Artist | null>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const initialFetched = useRef(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [catalogAlbums, setCatalogAlbums] = useState<StartedAlbum[]>([]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [phase, setPhase] = useState<Phase>("idle"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -83,6 +91,26 @@ export function useCatalogValuation(): CatalogValuationState { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [authenticated]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Shareable URL: fetch artist by Spotify ID and auto-trigger the run. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!initialArtistId || initialFetched.current) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: The Additionally, when Prompt for AI agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| initialFetched.current = true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| void (async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const spotify = await fetchSpotifyArtist(initialArtistId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!spotify) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const artist = toArtist(spotify); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setPicked(artist); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // If already authenticated, run immediately; otherwise queue for login. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (authenticated) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Already-signed-in recipients can land on Prompt for AI agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| void doRun(artist); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pendingRun.current = artist; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| login(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| })(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [initialArtistId]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+94
to
+112
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Two issues in this effect:
🐛 Proposed fix: track fetched ID and surface fetch failures- const initialFetched = useRef(false);
+ const fetchedArtistId = useRef<string | undefined>(undefined); useEffect(() => {
- if (!initialArtistId || initialFetched.current) return;
- initialFetched.current = true;
+ if (!initialArtistId || fetchedArtistId.current === initialArtistId) return;
+ fetchedArtistId.current = initialArtistId;
void (async () => {
const spotify = await fetchSpotifyArtist(initialArtistId);
- if (!spotify) return;
+ if (!spotify) {
+ setError("Could not load this artist. Try searching manually.");
+ return;
+ }
const artist = toArtist(spotify);
setPicked(artist);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| picked, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| catalogAlbums, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import type { SpotifyArtist } from "@/lib/spotify/types"; | ||
|
|
||
| /** | ||
| * Fetch a single Spotify artist by ID via the internal API proxy. | ||
| * Returns null if not found or on error. | ||
| */ | ||
| export async function fetchSpotifyArtist( | ||
| id: string, | ||
| ): Promise<SpotifyArtist | null> { | ||
| try { | ||
| const res = await fetch(`/api/spotify/artist/${id}`); | ||
| if (!res.ok) return null; | ||
| return (await res.json()) as SpotifyArtist; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,25 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { siteConfig } from "@/lib/config"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type ArtistInfo = { name: string; image: string | null }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Server-side fetch of a Spotify artist for metadata generation. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Calls the Recoup API directly (no internal proxy needed at build time). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function fetchArtistServer( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| id: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<ArtistInfo | null> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const res = await fetch(`${siteConfig.apiUrl}/spotify/artist/${id}`, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Malformed shared URLs can make metadata fetch the wrong Spotify artist because the raw Prompt for AI agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| next: { revalidate: 86400 }, // cache 24h | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!res.ok) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const data = await res.json(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: data.name ?? "Unknown Artist", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| image: data.images?.[0]?.url ?? null, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+9
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win Same SSRF vulnerability as the API route — validate
🔒 Proposed fix+import { z } from "zod";
+
export async function fetchArtistServer(
id: string,
): Promise<ArtistInfo | null> {
+ const parsed = z.string().regex(/^[a-zA-Z0-9]+$/).safeParse(id);
+ if (!parsed.success) return null;
+ const safeId = parsed.data;
try {
- const res = await fetch(`${siteConfig.apiUrl}/spotify/artist/${id}`, {
+ const res = await fetch(`${siteConfig.apiUrl}/spotify/artist/${safeId}`, {
next: { revalidate: 86400 },
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win
SSRF via unvalidated
idpath parameter.idis URL-decoded by Next.js before reaching the handler, so a request like/api/spotify/artist/..%2F..%2Fadminyieldsid = "../../admin". When interpolated into the fetch URL,fetch()resolves the../segments, potentially reaching arbitrary endpoints on the upstream API host (e.g.,${siteConfig.apiUrl}/admin). Validateidbefore use — Spotify IDs are base-62, so a simple alphanumeric check suffices. As per coding guidelines, use Zod for all validation in**/*.{ts,tsx,js,jsx}files.🔒 Proposed fix: validate `id` with Zod
Then use
safeIdin the fetch URL:📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Coding guidelines