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>
308 lines
10 KiB
Go
308 lines
10 KiB
Go
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
|
||
}
|