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
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:
parent
49335322b5
commit
806bd77d09
7 changed files with 732 additions and 0 deletions
103
apps/web/src/features/tracks/hooks/useTrackOpenGraph.ts
Normal file
103
apps/web/src/features/tracks/hooks/useTrackOpenGraph.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
158
tests/e2e/30-embed-and-share.spec.ts
Normal file
158
tests/e2e/30-embed-and-share.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
35
veza-backend-api/internal/api/routes_embed.go
Normal file
35
veza-backend-api/internal/api/routes_embed.go
Normal 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)
|
||||
}
|
||||
308
veza-backend-api/internal/handlers/embed_handler.go
Normal file
308
veza-backend-api/internal/handlers/embed_handler.go
Normal 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
|
||||
}
|
||||
108
veza-backend-api/internal/handlers/embed_handler_test.go
Normal file
108
veza-backend-api/internal/handlers/embed_handler_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue