Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/components/YouTubeLinkCard.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import { useState, useEffect } from 'react'
import { buildYtDlpCommand, fetchYouTubePreview } from '../lib/youtube'

// The thumbnail URL comes from a third-party (noembed) response, so validate it
// before using it as an <img src>: require https and a YouTube-owned host,
// otherwise drop it (the card still shows the title/author).
function safeThumb(url) {
try {
const u = new URL(url)
// Require a subdomain of ytimg.com / ggpht.com (matches the img-src CSP,
// which allows *.ytimg.com / *.ggpht.com, not the bare apex).
if (u.protocol === 'https:' && /\.(ytimg|ggpht)\.com$/i.test(u.hostname)) {
return url
}
} catch {
/* not a valid URL */
}
return null
}

// Shown in Search when a YouTube link is present. Previews the video and offers
// the a-Shell command. The command box itself is the copy control (tap to copy)
// — and pasting via the search bar pre-copies it, so usually it's already done.
Expand Down Expand Up @@ -32,7 +49,7 @@ export default function YouTubeLinkCard({ yt, copied }) {
<div className="ytcard">
{preview ? (
<div className="ytcard__preview">
<img src={preview.thumbnail} alt="" />
{safeThumb(preview.thumbnail) && <img src={safeThumb(preview.thumbnail)} alt="" />}
<div className="ytcard__pmeta">
<p className="ytcard__title">{preview.title}</p>
<p className="dim">{preview.author}</p>
Expand Down
4 changes: 3 additions & 1 deletion src/lib/youtube.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ export function buildYtDlpCommand(url) {
// unlike YouTube's own oEmbed — sends CORS headers so the browser can read it.
export async function fetchYouTubePreview(id) {
try {
// encodeURIComponent(id) is a no-op for a valid 11-char id, but hardens the
// request against query-param injection if an unvalidated id ever reaches here.
const res = await fetch(
`https://noembed.com/embed?url=https://www.youtube.com/watch?v=${id}`,
`https://noembed.com/embed?url=https://www.youtube.com/watch?v=${encodeURIComponent(id)}`,
)
if (!res.ok) return null
const d = await res.json()
Expand Down
34 changes: 34 additions & 0 deletions vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'

// Content-Security-Policy (defense-in-depth). Injected into index.html for
// PRODUCTION BUILDS ONLY — dev/HMR needs inline scripts + eval, which this would
// block. Sources: app assets are same-origin ('self'); thumbnails come from
// YouTube's image CDNs; JSON is fetched from noembed + lrclib; imported audio
// and artwork play from blob: URLs; React sets inline style attributes
// ('unsafe-inline' for style only, never script).
const CSP = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://i.ytimg.com https://*.ytimg.com https://*.ggpht.com",
"media-src 'self' blob:",
"connect-src 'self' https://noembed.com https://lrclib.net",
"font-src 'self'",
"manifest-src 'self'",
"worker-src 'self'",
"object-src 'none'",
"base-uri 'none'",
].join('; ')

const cspPlugin = () => ({
name: 'melody-csp',
transformIndexHtml: {
order: 'post',
// Inject as the FIRST head child so the policy governs every subresource
// (the app bundle, CSS, fonts) — a meta CSP doesn't apply to requests made
// before it's parsed.
handler: (html) =>
html.replace('<head>', `<head>\n <meta http-equiv="Content-Security-Policy" content="${CSP}" />`),
},
})

// NOTE on `base`: production is served from a SUBPATH, so the built asset URLs
// must be prefixed with it or the deployed app loads a blank white screen (the
// JS/CSS 404). The live deploy is Firebase via `npm run deploy:sparky`, hosted
Expand All @@ -15,6 +47,8 @@ import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig(({ command }) => ({
base: command === 'build' ? '/melody/' : '/',
plugins: [
// CSP only in the built HTML — injecting it in dev would break Vite HMR.
command === 'build' && cspPlugin(),
react(),
VitePWA({
// 'autoUpdate' = the new service worker activates and reloads the page as
Expand Down
Loading