veza/veza-backend-api/internal/handlers/embed_handler.go
senke 806bd77d09
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
feat(embed): /embed/track/:id widget + /oembed envelope + per-track OG tags (W3 Day 15)
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>
2026-04-28 15:49:54 +02:00

308 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}