feat(embed): /embed/track/:id widget + /oembed envelope + per-track OG tags (W3 Day 15)
Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 5m26s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 56s
Veza CI / Backend (Go) (push) Failing after 8m39s
Veza CI / Frontend (Web) (push) Failing after 16m22s
Veza CI / Notify on failure (push) Successful in 11s
E2E Playwright / e2e (full) (push) Successful in 20m30s

End-to-end embed pipeline. Standalone HTML widget for iframes, oEmbed
JSON for unfurlers (Twitter/Discord/Slack), runtime per-track OG +
Twitter player card on the SPA. Share-token storage + handlers were
already in place from earlier — Day 15 only adds the embed surface.

Backend (root router, no /api/v1 prefix — matches what scrapers expect)
- internal/handlers/embed_handler.go : EmbedTrack renders inline HTML
  with OG tags + <audio controls>. DMCA-blocked tracks 451, private
  tracks 404 (don't leak existence). X-Frame-Options=ALLOWALL +
  CSP frame-ancestors=* so the page can be iframed by third parties.
  OEmbed handler accepts ?url=&format=json, validates the URL points
  at /tracks/:id, returns a type=rich envelope with an iframe HTML
  string. ?maxwidth clamped to [240, 1280].
- internal/api/routes_embed.go : registers the two endpoints.
- internal/handlers/embed_handler_test.go : pure-function coverage
  for extractTrackIDFromURL (8 cases incl. trailing slash, query
  string, hash fragment, subpath) + parseSafeInt (overflow + non-digit
  rejection).

Frontend
- apps/web/src/features/tracks/hooks/useTrackOpenGraph.ts : runtime
  injection of og:* + twitter:player + <link rel=alternate>
  (oEmbed discovery) into document.head. Limitation noted inline —
  pure HTML scrapers don't see these ; the embed widget itself
  carries server-rendered OG tags so unfurlers always work.
- TrackDetailPage : wires useTrackOpenGraph(track) on render.

E2E (tests/e2e/30-embed-and-share.spec.ts)
- 30. /embed/track/:id renders HTML with OG tags + audio src.
- 31. /oembed returns valid JSON envelope (rich type, iframe HTML).
- 32. /oembed rejects non-track URLs (400).
- 33. share-token roundtrip — creator mints, anonymous resolves via
  /api/v1/tracks/shared/:token (re-uses existing share handler ;
  Day 15 didn't add new share infra, just covers it under the embed
  acceptance gate).

Acceptance (Day 15) : embed widget Twitter card preview ✓ (OG tags
present), oEmbed JSON valid ✓, share token roundtrip ✓.

W3 verification gate : Redis Sentinel ✓ · MinIO distribué ✓ ·
CDN signed URLs ✓ · DMCA E2E ✓ · embed + share token ✓ · all 5
W3 days shipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-28 15:49:54 +02:00
parent 49335322b5
commit 806bd77d09
7 changed files with 732 additions and 0 deletions

View file

@ -0,0 +1,103 @@
import { useEffect } from 'react';
// Runtime injection of per-track <meta> + <link rel="alternate"> tags
// on /tracks/:id. v1.0.9 W3 Day 15.
//
// Limitations : pure HTML scrapers (Twitterbot, Facebookexternalhit,
// Slackbot in some modes) don't execute JS, so they read the static
// OG tags from index.html instead of these runtime ones. Modern
// scrapers that DO render JS (LinkedIn, recent Slack unfurls, browsing
// users hitting the share dialog) see the per-track tags.
//
// Long-term : v1.1+ adds an SSR / bot-detector route on the backend
// that serves OG-only HTML to known scraper user-agents. For v1.0
// the embed widget itself (/embed/track/:id) carries server-rendered
// OG tags ; this hook is the SPA-side complement.
export interface TrackOpenGraphInput {
id: string;
title: string;
artist?: string | null;
coverArtURL?: string | null;
}
const META_TAGS = [
{ selector: 'meta[property="og:title"]', attr: 'content' as const },
{ selector: 'meta[property="og:type"]', attr: 'content' as const },
{ selector: 'meta[property="og:url"]', attr: 'content' as const },
{ selector: 'meta[property="og:image"]', attr: 'content' as const },
{ selector: 'meta[property="og:audio"]', attr: 'content' as const, optional: true },
{ selector: 'meta[name="twitter:card"]', attr: 'content' as const },
{ selector: 'meta[name="twitter:title"]', attr: 'content' as const },
{ selector: 'meta[name="twitter:player"]', attr: 'content' as const, optional: true },
{ selector: 'meta[name="twitter:player:width"]', attr: 'content' as const, optional: true },
{ selector: 'meta[name="twitter:player:height"]', attr: 'content' as const, optional: true },
];
function ensureMeta(selector: string, key: 'name' | 'property', value: string): HTMLMetaElement {
let el = document.head.querySelector<HTMLMetaElement>(selector);
if (!el) {
el = document.createElement('meta');
el.setAttribute(key, value);
document.head.appendChild(el);
}
return el;
}
function ensureLink(rel: string, type: string, href: string, title?: string): HTMLLinkElement {
const selector = `link[rel="${rel}"][type="${type}"]`;
let el = document.head.querySelector<HTMLLinkElement>(selector);
if (!el) {
el = document.createElement('link');
el.rel = rel;
el.type = type;
document.head.appendChild(el);
}
el.href = href;
if (title) el.title = title;
return el;
}
export function useTrackOpenGraph(track: TrackOpenGraphInput | null | undefined): void {
useEffect(() => {
if (!track) return;
const origin = window.location.origin;
const trackURL = `${origin}/tracks/${track.id}`;
const embedURL = `${origin}/embed/track/${track.id}`;
const oembedURL = `${origin}/oembed?url=${encodeURIComponent(trackURL)}&format=json`;
const titleAttr = track.artist ? `${track.title}${track.artist}` : track.title;
const coverImage = track.coverArtURL || `${origin}/icons/icon-512x512.png`;
// Open Graph + Twitter player card.
const setOg = (property: string, value: string) => {
const el = ensureMeta(`meta[property="${property}"]`, 'property', property);
el.setAttribute('content', value);
};
const setTwitter = (name: string, value: string) => {
const el = ensureMeta(`meta[name="${name}"]`, 'name', name);
el.setAttribute('content', value);
};
setOg('og:type', 'music.song');
setOg('og:title', titleAttr);
setOg('og:url', trackURL);
setOg('og:image', coverImage);
setTwitter('twitter:card', 'player');
setTwitter('twitter:title', titleAttr);
setTwitter('twitter:player', embedURL);
setTwitter('twitter:player:width', '480');
setTwitter('twitter:player:height', '166');
setTwitter('twitter:image', coverImage);
// oEmbed discovery — Twitter, Slack, Discord scan for this link.
ensureLink('alternate', 'application/json+oembed', oembedURL, 'Veza oEmbed');
return () => {
// We DON'T tear the meta tags down on unmount — leaving generic
// Veza OG tags from index.html in place would fight with the
// mount of the next page that might want its own values. The
// next route's effect overrides them.
void META_TAGS;
};
}, [track]);
}

View file

@ -11,6 +11,7 @@ import { TrackDetailPageNotFound } from './TrackDetailPageNotFound';
import { TrackDetailPageSkeleton } from './TrackDetailPageSkeleton';
import { useTrackDetailPage } from './useTrackDetailPage';
import type { TrackDetailPageProps } from './types';
import { useTrackOpenGraph } from '@/features/tracks/hooks/useTrackOpenGraph';
export function TrackDetailPage(props?: TrackDetailPageProps) {
const trackIdOverride = props?.trackId;
@ -37,6 +38,21 @@ export function TrackDetailPage(props?: TrackDetailPageProps) {
}
}, [track?.title]);
// v1.0.9 W3 Day 15: per-track OG + Twitter card + oEmbed discovery.
useTrackOpenGraph(
track
? {
id: track.id,
title: track.title,
artist: track.artist,
coverArtURL:
(track as { cover_art_url?: string | null; coverArtPath?: string | null }).cover_art_url ??
(track as { coverArtPath?: string | null }).coverArtPath ??
null,
}
: null,
);
if (isLoading) {
return <TrackDetailPageSkeleton />;
}

View file

@ -0,0 +1,158 @@
import { test, expect } from '@chromatic-com/playwright';
import { CONFIG } from './helpers';
/**
* v1.0.9 W3 Day 15 embed widget + oEmbed + share token roundtrip.
*
* Tests :
* 30. GET /embed/track/:id renders standalone HTML with OG tags + a
* playable <audio> source pointing at /api/v1/tracks/:id/stream.
* 31. GET /oembed?url=... returns a valid oEmbed JSON envelope
* (type=rich, html iframe).
* 32. Share-token roundtrip creator generates a token for a private
* track, anonymous user fetches it via /api/v1/tracks/shared/:token.
*
* The share-token test relies on the existing /api/v1/tracks/:id/share
* endpoint (already wired pre-Day 15). Day 15's contribution is the
* embed widget + oEmbed pair on the public side.
*/
interface ApiEnvelope<T> {
success?: boolean;
data: T;
error?: { code?: string; message?: string };
}
async function loginAsCreator(
request: import('@playwright/test').APIRequestContext,
): Promise<{ accessToken: string }> {
const resp = await request.post(`${CONFIG.apiURL}/api/v1/auth/login`, {
data: {
email: CONFIG.users.creator.email,
password: CONFIG.users.creator.password,
remember_me: false,
},
});
expect(resp.status()).toBe(200);
const body = (await resp.json()) as ApiEnvelope<{
token: { access_token: string };
}>;
const data = body.data ?? (body as unknown as { token: { access_token: string } });
return { accessToken: data.token.access_token };
}
async function firstCreatorTrackID(
request: import('@playwright/test').APIRequestContext,
token: string,
): Promise<string> {
const resp = await request.get(`${CONFIG.apiURL}/api/v1/tracks`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(resp.status()).toBe(200);
const body = (await resp.json()) as ApiEnvelope<{ tracks: { id: string }[] }>;
const tracks = body.data?.tracks ?? [];
expect(tracks.length, 'seed creator account must have at least one track').toBeGreaterThan(0);
return tracks[0].id;
}
test.describe('EMBED — widget + oEmbed + share token (v1.0.9 W3 Day 15)', () => {
test('30. embed/track/:id renders standalone HTML with OG tags', async ({ request }) => {
test.setTimeout(20_000);
const { accessToken } = await loginAsCreator(request);
const trackID = await firstCreatorTrackID(request, accessToken);
const resp = await request.get(`${CONFIG.apiURL}/embed/track/${trackID}`);
expect(resp.status(), 'public embed must be reachable without auth').toBe(200);
const ct = resp.headers()['content-type'] || '';
expect(ct.includes('text/html')).toBeTruthy();
expect(resp.headers()['x-frame-options']).toBe('ALLOWALL');
const html = await resp.text();
// Open Graph + Twitter card.
expect(html).toContain('property="og:type"');
expect(html).toContain('property="og:title"');
expect(html).toContain('name="twitter:card"');
// Audio element pointing back at the stream API.
expect(html).toContain(`/api/v1/tracks/${trackID}/stream`);
// <audio> element present.
expect(html).toMatch(/<audio[^>]*controls/);
});
test('31. /oembed returns valid oEmbed JSON envelope', async ({ request }) => {
test.setTimeout(20_000);
const { accessToken } = await loginAsCreator(request);
const trackID = await firstCreatorTrackID(request, accessToken);
const trackPageURL = `${CONFIG.baseURL}/tracks/${trackID}`;
const resp = await request.get(
`${CONFIG.apiURL}/oembed?url=${encodeURIComponent(trackPageURL)}&format=json`,
);
expect(resp.status()).toBe(200);
const body = (await resp.json()) as {
version: string;
type: string;
provider_name: string;
html: string;
width: number;
height: number;
title: string;
};
expect(body.version).toBe('1.0');
expect(body.type).toBe('rich');
expect(body.provider_name).toBe('Veza');
expect(body.html).toMatch(/<iframe[^>]+src="[^"]+"/);
expect(body.html).toContain(`/embed/track/${trackID}`);
expect(body.width).toBeGreaterThanOrEqual(240);
expect(body.height).toBeGreaterThan(0);
expect(body.title.length).toBeGreaterThan(0);
});
test('32. /oembed rejects non-track URLs', async ({ request }) => {
const resp = await request.get(
`${CONFIG.apiURL}/oembed?url=${encodeURIComponent('https://veza.fr/playlists/abc')}&format=json`,
);
expect(resp.status()).toBe(400);
});
test('33. share token roundtrip — create + resolve via /tracks/shared/:token', async ({
request,
}) => {
test.setTimeout(30_000);
const { accessToken } = await loginAsCreator(request);
const trackID = await firstCreatorTrackID(request, accessToken);
// Creator issues a share token.
const createResp = await request.post(
`${CONFIG.apiURL}/api/v1/tracks/${trackID}/share`,
{
headers: { Authorization: `Bearer ${accessToken}` },
data: { permissions: 'read' },
},
);
expect(createResp.status(), 'creator must be able to mint a share token').toBe(200);
const createBody = (await createResp.json()) as ApiEnvelope<{
share: { share_token: string };
}>;
const shareToken =
createBody.data?.share?.share_token ??
(createBody as unknown as { share: { share_token: string } }).share.share_token;
expect(shareToken.length).toBeGreaterThan(0);
// Anonymous resolve via the public endpoint.
const resolveResp = await request.get(
`${CONFIG.apiURL}/api/v1/tracks/shared/${encodeURIComponent(shareToken)}`,
);
expect(resolveResp.status(), 'share token must resolve without auth').toBe(200);
const resolveBody = (await resolveResp.json()) as ApiEnvelope<{
track: { id: string };
share: { share_token: string };
}>;
const resolved = resolveBody.data ?? (resolveBody as unknown as { track: { id: string } });
expect(resolved.track.id).toBe(trackID);
});
});

View file

@ -293,6 +293,10 @@ func (r *APIRouter) Setup(router *gin.Engine) error {
// These need to be on the root router, not under /api/v1
r.setupInternalRoutes(router)
// v1.0.9 W3 Day 15 — embed widget + oEmbed. Lives on root because
// the URLs are /embed/track/:id and /oembed (no /api/v1 prefix).
r.setupEmbedRoutes(router)
// Groupe API v1 (nouveau frontend React)
v1 := router.Group("/api/v1")

View file

@ -0,0 +1,35 @@
package api
import (
"github.com/gin-gonic/gin"
"veza-backend-api/internal/handlers"
)
// setupEmbedRoutes registers the public embed widget + oEmbed
// endpoints. Both live on the root router (no /api/v1 prefix) so
// the URLs match what blogs / Twitter / Discord scrapers expect :
//
// GET /embed/track/:id — standalone HTML widget
// GET /oembed?url=&format=json — oEmbed JSON envelope
//
// v1.0.9 W3 Day 15.
func (r *APIRouter) setupEmbedRoutes(router *gin.Engine) {
frontend := r.config.FrontendURL
if frontend == "" {
frontend = "https://veza.fr"
}
apiBase := frontend // best default — assume frontend reverse-proxies API
if r.config.StreamServerURL != "" {
// Not actually the API base, but if we ever introduce an
// explicit API_BASE_URL config it should land here. For now
// the embed page uses relative-looking URLs which the
// browser resolves against the embed page's own origin.
_ = r.config.StreamServerURL
}
embedHandler := handlers.NewEmbedHandler(r.db.GormDB, frontend, apiBase, r.logger)
router.GET("/embed/track/:id", embedHandler.EmbedTrack)
router.GET("/oembed", embedHandler.OEmbed)
}

View file

@ -0,0 +1,308 @@
package handlers
import (
"context"
"errors"
"fmt"
"html"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"veza-backend-api/internal/models"
)
// EmbedHandler renders the standalone /embed/track/:id HTML page +
// the /oembed JSON envelope. v1.0.9 W3 Day 15.
//
// The HTML page is intentionally not the SPA — it's a static-ish
// document with a <audio> element + minimal styling so it loads fast
// and renders inside iframe sandboxes (Twitter cards, Facebook,
// embeds in blogs, etc).
type EmbedHandler struct {
db *gorm.DB
frontendURL string
apiBaseURL string
logger *zap.Logger
}
// NewEmbedHandler wires the handler. apiBaseURL is the URL prefix
// the backend serves on (e.g. https://api.veza.fr) — used to
// construct the audio source URL inside the embed page. frontendURL
// is used for the "Open in Veza" link + as the canonical URL for OG
// tags.
func NewEmbedHandler(db *gorm.DB, frontendURL, apiBaseURL string, logger *zap.Logger) *EmbedHandler {
if logger == nil {
logger = zap.NewNop()
}
return &EmbedHandler{db: db, frontendURL: frontendURL, apiBaseURL: apiBaseURL, logger: logger}
}
// EmbedTrack renders GET /embed/track/:id as a standalone HTML page.
// Public (no auth) — only public tracks are returned. DMCA-blocked
// tracks return 451 ; private tracks return 404 (don't leak existence).
//
// The page sets `X-Frame-Options: ALLOWALL` so it can be iframed.
// We DON'T include the global CSP frame-ancestors directive on this
// route (it's set on SPA routes only by the security middleware).
//
// @Summary Embed widget for a track
// @Description Standalone HTML widget (audio player + minimal styling) for embedding via iframe. Public, no auth. Twitter/Facebook scrapers ingest the OG tags inside.
// @Tags Embed
// @Produce html
// @Param id path string true "Track UUID"
// @Success 200 {string} string "HTML embed page"
// @Failure 404 {string} string "Track not found or not public"
// @Failure 451 {string} string "DMCA takedown in force"
// @Router /embed/track/{id} [get]
func (h *EmbedHandler) EmbedTrack(c *gin.Context) {
trackID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.String(http.StatusBadRequest, "invalid track id")
return
}
track, err := h.fetchPublicTrack(c.Request.Context(), trackID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.String(http.StatusNotFound, "track not found")
return
}
if errors.Is(err, errDmcaBlocked) {
c.String(http.StatusUnavailableForLegalReasons, "track unavailable: subject to a DMCA takedown notice")
return
}
h.logger.Error("embed: failed to fetch track", zap.Error(err))
c.String(http.StatusInternalServerError, "failed to load track")
return
}
// Allow iframing. The global helmet CSP doesn't apply here.
c.Header("X-Frame-Options", "ALLOWALL")
c.Header("Content-Security-Policy", "frame-ancestors *")
c.Header("Cache-Control", "public, max-age=300")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.renderEmbed(track)))
}
// OEmbed renders GET /oembed?url=<frontend_track_url>&format=json.
// oEmbed spec: https://oembed.com/. We support type=rich with an
// HTML iframe pointing back at /embed/track/:id.
//
// @Summary oEmbed endpoint
// @Description Returns oEmbed JSON for a Veza track URL. Used by Twitter, Slack, Discord etc. for in-line previews. format=xml is not supported.
// @Tags Embed
// @Produce json
// @Param url query string true "Veza track URL (must point at /tracks/:id)"
// @Param format query string false "Must be 'json' (default)"
// @Param maxwidth query int false "Optional max iframe width"
// @Success 200 {object} map[string]interface{} "oEmbed envelope"
// @Failure 400 {string} string "Bad URL or unsupported format"
// @Failure 404 {string} string "Track not found"
// @Router /oembed [get]
func (h *EmbedHandler) OEmbed(c *gin.Context) {
rawURL := c.Query("url")
format := c.DefaultQuery("format", "json")
if format != "" && format != "json" {
c.String(http.StatusNotImplemented, "only format=json is supported")
return
}
// Extract the track UUID from the URL. We accept anything that
// looks like /tracks/<uuid> ; the host check is best-effort
// (we don't strictly require it to match frontendURL — that would
// reject staging URLs, custom domains, etc).
trackID, ok := extractTrackIDFromURL(rawURL)
if !ok {
c.String(http.StatusBadRequest, "url must point to a track page (/tracks/<uuid>)")
return
}
track, err := h.fetchPublicTrack(c.Request.Context(), trackID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.String(http.StatusNotFound, "track not found")
return
}
if errors.Is(err, errDmcaBlocked) {
c.String(http.StatusUnavailableForLegalReasons, "track unavailable")
return
}
h.logger.Error("oembed: failed to fetch track", zap.Error(err))
c.String(http.StatusInternalServerError, "failed to load track")
return
}
// Width / height : a Twitter card is 480×270 ; SoundCloud uses
// 100% width × 166 px tall for compact players. We pick a width
// that respects ?maxwidth, default 480.
width := 480
if w := c.Query("maxwidth"); w != "" {
if iw, err := parseSafeInt(w); err == nil && iw >= 240 && iw <= 1280 {
width = iw
}
}
height := 166
embedURL := strings.TrimSuffix(h.apiBaseURL, "/") + "/embed/track/" + track.ID.String()
htmlIframe := fmt.Sprintf(
`<iframe src="%s" width="%d" height="%d" frameborder="0" allow="autoplay" allowtransparency="true" loading="lazy"></iframe>`,
html.EscapeString(embedURL), width, height,
)
c.Header("Cache-Control", "public, max-age=3600")
c.JSON(http.StatusOK, gin.H{
"version": "1.0",
"type": "rich",
"provider_name": "Veza",
"provider_url": h.frontendURL,
"title": track.Title,
"author_name": track.Artist,
"width": width,
"height": height,
"html": htmlIframe,
})
}
// errDmcaBlocked is the sentinel returned from fetchPublicTrack
// when the track row exists but DmcaBlocked=true.
var errDmcaBlocked = errors.New("track is dmca-blocked")
// fetchPublicTrack returns the track if and only if it is public AND
// not DMCA-blocked AND not soft-deleted. Any other state (private,
// blocked, missing) is reported as gorm.ErrRecordNotFound except for
// the dmca case, which gets its own sentinel so the caller can return
// 451.
func (h *EmbedHandler) fetchPublicTrack(ctx context.Context, id uuid.UUID) (*models.Track, error) {
// We use a raw GORM query rather than going through TrackService
// to keep the embed handler dependency-light + cacheable.
var track models.Track
if err := h.db.WithContext(ctx).Where("id = ?", id).First(&track).Error; err != nil {
return nil, err
}
if track.DmcaBlocked {
return nil, errDmcaBlocked
}
if !track.IsPublic {
return nil, gorm.ErrRecordNotFound
}
return &track, nil
}
// renderEmbed builds the standalone HTML page. Kept inline (no
// template file) so the asset is self-contained — no risk of a
// missing template path in production.
func (h *EmbedHandler) renderEmbed(track *models.Track) string {
canonical := strings.TrimSuffix(h.frontendURL, "/") + "/tracks/" + track.ID.String()
streamURL := strings.TrimSuffix(h.apiBaseURL, "/") + "/api/v1/tracks/" + track.ID.String() + "/stream"
title := html.EscapeString(track.Title)
artist := html.EscapeString(track.Artist)
if artist == "" {
artist = "Veza"
}
canonicalEsc := html.EscapeString(canonical)
streamURLEsc := html.EscapeString(streamURL)
return fmt.Sprintf(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s %s</title>
<link rel="canonical" href="%s">
<!-- Open Graph (Facebook, LinkedIn, Discord) -->
<meta property="og:type" content="music.song">
<meta property="og:title" content="%s">
<meta property="og:site_name" content="Veza">
<meta property="og:url" content="%s">
<meta property="og:audio" content="%s">
<meta property="og:audio:type" content="audio/mpeg">
<meta property="music:musician" content="%s">
<!-- Twitter Cards -->
<meta name="twitter:card" content="player">
<meta name="twitter:title" content="%s">
<meta name="twitter:player" content="%s">
<meta name="twitter:player:width" content="480">
<meta name="twitter:player:height" content="166">
<style>
*{box-sizing:border-box}
body{margin:0;font:14px/1.4 system-ui,-apple-system,sans-serif;background:#111;color:#eee}
.player{display:flex;flex-direction:column;height:100vh;padding:14px;gap:8px}
.meta{display:flex;flex-direction:column;gap:2px}
.title{font-weight:600;font-size:15px;color:#fff}
.artist{font-size:13px;color:#aaa}
.controls audio{width:100%%;outline:none}
.footer{margin-top:auto;font-size:11px;color:#888;display:flex;justify-content:space-between}
.footer a{color:#aaa;text-decoration:none}
.footer a:hover{text-decoration:underline}
</style>
</head>
<body>
<div class="player">
<div class="meta">
<div class="title">%s</div>
<div class="artist">%s</div>
</div>
<div class="controls">
<audio controls preload="metadata" src="%s">
Your browser does not support the audio element.
</audio>
</div>
<div class="footer">
<span>Veza</span>
<a href="%s" target="_blank" rel="noopener">Open in Veza </a>
</div>
</div>
</body>
</html>`,
title, artist, canonicalEsc,
title, canonicalEsc, streamURLEsc, artist,
title, html.EscapeString(strings.TrimSuffix(h.apiBaseURL, "/")+"/embed/track/"+track.ID.String()),
title, artist, streamURLEsc, canonicalEsc,
)
}
// extractTrackIDFromURL parses /tracks/<uuid> out of a URL string.
// We don't validate scheme / host — keeps the handler permissive
// across staging, custom domains, localhost dev.
func extractTrackIDFromURL(raw string) (uuid.UUID, bool) {
if raw == "" {
return uuid.Nil, false
}
idx := strings.Index(raw, "/tracks/")
if idx < 0 {
return uuid.Nil, false
}
tail := raw[idx+len("/tracks/"):]
// strip any trailing ?query / #frag / / segment
for _, sep := range []string{"?", "#", "/"} {
if i := strings.Index(tail, sep); i >= 0 {
tail = tail[:i]
}
}
id, err := uuid.Parse(tail)
if err != nil {
return uuid.Nil, false
}
return id, true
}
func parseSafeInt(s string) (int, error) {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0, errors.New("not an integer")
}
n = n*10 + int(c-'0')
if n > 100_000 {
return 0, errors.New("too large")
}
}
return n, nil
}

View file

@ -0,0 +1,108 @@
package handlers
import (
"testing"
"github.com/google/uuid"
)
// Pure-function tests for the embed handler helpers — no DB / HTTP
// fixtures required.
func TestExtractTrackIDFromURL(t *testing.T) {
id := uuid.New()
cases := []struct {
name string
url string
want uuid.UUID
ok bool
}{
{
name: "happy path — frontend URL",
url: "https://veza.fr/tracks/" + id.String(),
want: id,
ok: true,
},
{
name: "with trailing slash",
url: "https://veza.fr/tracks/" + id.String() + "/",
want: id,
ok: true,
},
{
name: "with query string",
url: "https://veza.fr/tracks/" + id.String() + "?utm_source=twitter",
want: id,
ok: true,
},
{
name: "with hash fragment",
url: "https://veza.fr/tracks/" + id.String() + "#segment-2",
want: id,
ok: true,
},
{
name: "subpath under /tracks",
url: "https://veza.fr/tracks/" + id.String() + "/comments",
want: id,
ok: true,
},
{
name: "empty",
url: "",
ok: false,
},
{
name: "no /tracks/ segment",
url: "https://veza.fr/playlists/abc",
ok: false,
},
{
name: "non-uuid",
url: "https://veza.fr/tracks/not-a-uuid",
ok: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := extractTrackIDFromURL(tc.url)
if ok != tc.ok {
t.Fatalf("ok = %v, want %v", ok, tc.ok)
}
if ok && got != tc.want {
t.Fatalf("id = %s, want %s", got, tc.want)
}
})
}
}
func TestParseSafeInt(t *testing.T) {
cases := []struct {
in string
want int
wantOK bool
}{
{in: "0", want: 0, wantOK: true},
{in: "480", want: 480, wantOK: true},
{in: "1280", want: 1280, wantOK: true},
{in: "100000", want: 100000, wantOK: true},
// reject overflow attempts
{in: "999999", wantOK: false},
// reject non-digit
{in: "480px", wantOK: false},
{in: "-1", wantOK: false},
{in: "", wantOK: true}, // empty parses to 0 (loop never runs)
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
n, err := parseSafeInt(tc.in)
if (err == nil) != tc.wantOK {
t.Fatalf("parseSafeInt(%q) ok=%v, want %v (err=%v)", tc.in, err == nil, tc.wantOK, err)
}
if tc.wantOK && n != tc.want {
t.Fatalf("parseSafeInt(%q) = %d, want %d", tc.in, n, tc.want)
}
})
}
}