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