diff --git a/Caddyfile b/Caddyfile index 911b3f9..f6a6c66 100644 --- a/Caddyfile +++ b/Caddyfile @@ -23,8 +23,8 @@ # Prevent MIME type sniffing X-Content-Type-Options "nosniff" - # Clickjacking protection - X-Frame-Options "SAMEORIGIN" + # X-Frame-Options removed - frame-ancestors in CSP (set by app) provides better control + # See src/Configuration/StartupExtensions.cs for CSP configuration # XSS protection (legacy browsers) X-XSS-Protection "1; mode=block" @@ -32,8 +32,8 @@ # Referrer policy Referrer-Policy "strict-origin-when-cross-origin" - # Content Security Policy - Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self';" + # Content Security Policy is handled by the application middleware + # CSP headers are set in src/Configuration/StartupExtensions.cs with nonce support # Permissions policy - Deny access to sensitive browser features Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" diff --git a/src/Configuration/StartupExtensions.cs b/src/Configuration/StartupExtensions.cs index 4229c62..9b818a4 100644 --- a/src/Configuration/StartupExtensions.cs +++ b/src/Configuration/StartupExtensions.cs @@ -195,22 +195,25 @@ this WebApplicationBuilder builder _ = app.Use( async ( ctx, next ) => { string path = ctx.Request.Path.Value ?? string.Empty; bool isEmbed = path.EndsWith( "/embed", StringComparison.OrdinalIgnoreCase ) && (path.StartsWith( "/card/", StringComparison.OrdinalIgnoreCase ) || path.StartsWith( "/playlist/", StringComparison.OrdinalIgnoreCase )); + bool isAppleMusic = path.StartsWith( "/applemusic", StringComparison.OrdinalIgnoreCase ); // Generate a unique nonce for this request to allow inline scripts and styles string nonce = Convert.ToBase64String( System.Security.Cryptography.RandomNumberGenerator.GetBytes( 16 ) ); ctx.Items["CSPNonce"] = nonce; // Build CSP policy - // Allow MusicKit JS CDN, nonce-based inline styles, and API calls to music providers - string frameAncestors = isEmbed ? "*" : "'self'"; // Allow any site to embed cards & playlists + // Allow MusicKit JS CDN, Cloudflare analytics, nonce-based inline styles, and API calls to music providers + // Allow framing for embed endpoints, Apple Music integration, and playlist pages + // Note: frame-ancestors uses https: scheme with 'self' to support HTTPS framing including same-origin + string frameAncestors = (isEmbed || isAppleMusic) ? "https: 'self'" : "'self'"; string csp = string.Join( "; ", new[] { "default-src 'self'", - $"script-src 'self' 'nonce-{nonce}' https://js-cdn.music.apple.com", + $"script-src 'self' 'nonce-{nonce}' https://js-cdn.music.apple.com https://static.cloudflareinsights.com", $"style-src 'self' 'nonce-{nonce}'", "img-src 'self' data: https:", "font-src 'self' data:", - "connect-src 'self' https://api.music.apple.com https://accounts.spotify.com https://api.spotify.com https://openapi.tidal.com", + "connect-src 'self' https://api.music.apple.com https://accounts.spotify.com https://api.spotify.com https://openapi.tidal.com https://cloudflareinsights.com", "media-src 'self' https:", "frame-ancestors " + frameAncestors, "object-src 'none'", diff --git a/src/Web/Views/Shared/_Layout.cshtml b/src/Web/Views/Shared/_Layout.cshtml index 8ab1333..2fea14c 100644 --- a/src/Web/Views/Shared/_Layout.cshtml +++ b/src/Web/Views/Shared/_Layout.cshtml @@ -12,6 +12,7 @@ @title - TuneBridge + diff --git a/src/Web/wwwroot/public/playlist-manager.css b/src/Web/wwwroot/public/playlist-manager.css new file mode 100644 index 0000000..85a19fe --- /dev/null +++ b/src/Web/wwwroot/public/playlist-manager.css @@ -0,0 +1,141 @@ +/* Playlist Management Styles - v1.0 */ + +.playlist-toolbar { + position: sticky; + top: 0; + z-index: 1000; + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + padding: 1rem; + margin-bottom: 1rem; + border-radius: 0.375rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.playlist-toolbar-content { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.playlist-selection-info { + font-size: 1rem; + font-weight: 600; + color: var(--bs-body-color); + min-width: 120px; +} + +.playlist-inputs { + flex: 1; + min-width: 200px; +} + +.playlist-inputs input, +.playlist-inputs textarea { + width: 100%; +} + +.playlist-actions { + display: flex; + gap: 0.5rem; +} + +.card-selection-checkbox { + position: absolute; + top: 0.5rem; + left: 0.5rem; + z-index: 10; +} + +.card-selection-checkbox input[type="checkbox"] { + appearance: none; + width: 24px; + height: 24px; + border: 2px solid var(--bs-primary); + border-radius: 4px; + background: var(--bs-body-bg); + cursor: pointer; + position: relative; +} + +.card-selection-checkbox input[type="checkbox"]:checked { + background: var(--bs-primary); +} + +.card-selection-checkbox input[type="checkbox"]:checked::after { + content: '✓'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: white; + font-size: 16px; + font-weight: bold; +} + +.card-selection-checkbox label { + position: absolute; + top: 0; + left: 0; + width: 24px; + height: 24px; + cursor: pointer; +} + +.playlist-selection-mode .embed-card { + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + position: relative; +} + +.playlist-selection-mode .embed-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(var(--bs-primary-rgb), 0.3); +} + +.playlist-selection-mode .embed-card.selected { + border: 2px solid var(--bs-primary); + box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), 0.2); +} + +/* Adjust share button position when in selection mode */ +.playlist-selection-mode .embed-header-top { + padding-left: 40px; +} + +#startPlaylistBtn { + margin-top: 0.5rem; +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } +} + +.shake-animation { + animation: shake 0.3s ease-in-out; +} + +@media (max-width: 768px) { + .playlist-toolbar-content { + flex-direction: column; + align-items: stretch; + } + + .playlist-inputs { + order: 1; + min-width: 100%; + } + + .playlist-selection-info { + order: 2; + } + + .playlist-actions { + order: 3; + justify-content: space-between; + } +} diff --git a/src/Web/wwwroot/public/playlist-manager.js b/src/Web/wwwroot/public/playlist-manager.js index f7bb87e..ae348db 100644 --- a/src/Web/wwwroot/public/playlist-manager.js +++ b/src/Web/wwwroot/public/playlist-manager.js @@ -407,154 +407,6 @@ // Initialize on page load function init() { - // Add styles - if (!document.getElementById('playlistStyles')) { - const style = document.createElement('style'); - style.id = 'playlistStyles'; - style.textContent = ` - .playlist-toolbar { - position: sticky; - top: 0; - z-index: 1000; - background: var(--bs-body-bg); - border: 1px solid var(--bs-border-color); - padding: 1rem; - margin-bottom: 1rem; - border-radius: 0.375rem; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - } - - .playlist-toolbar-content { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - flex-wrap: wrap; - } - - .playlist-selection-info { - font-size: 1rem; - font-weight: 600; - color: var(--bs-body-color); - min-width: 120px; - } - - .playlist-inputs { - flex: 1; - min-width: 200px; - } - - .playlist-inputs input, - .playlist-inputs textarea { - width: 100%; - } - - .playlist-actions { - display: flex; - gap: 0.5rem; - } - - .card-selection-checkbox { - position: absolute; - top: 0.5rem; - left: 0.5rem; - z-index: 10; - } - - .card-selection-checkbox input[type="checkbox"] { - appearance: none; - width: 24px; - height: 24px; - border: 2px solid var(--bs-primary); - border-radius: 4px; - background: var(--bs-body-bg); - cursor: pointer; - position: relative; - } - - .card-selection-checkbox input[type="checkbox"]:checked { - background: var(--bs-primary); - } - - .card-selection-checkbox input[type="checkbox"]:checked::after { - content: '✓'; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - color: white; - font-size: 16px; - font-weight: bold; - } - - .card-selection-checkbox label { - position: absolute; - top: 0; - left: 0; - width: 24px; - height: 24px; - cursor: pointer; - } - - .playlist-selection-mode .embed-card { - cursor: pointer; - transition: transform 0.2s, box-shadow 0.2s; - position: relative; - } - - .playlist-selection-mode .embed-card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(var(--bs-primary-rgb), 0.3); - } - - .playlist-selection-mode .embed-card.selected { - border: 2px solid var(--bs-primary); - box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), 0.2); - } - - /* Adjust share button position when in selection mode */ - .playlist-selection-mode .embed-header-top { - padding-left: 40px; - } - - #startPlaylistBtn { - margin-top: 0.5rem; - } - - @keyframes shake { - 0%, 100% { transform: translateX(0); } - 25% { transform: translateX(-5px); } - 75% { transform: translateX(5px); } - } - - .shake-animation { - animation: shake 0.3s ease-in-out; - } - - @media (max-width: 768px) { - .playlist-toolbar-content { - flex-direction: column; - align-items: stretch; - } - - .playlist-inputs { - order: 1; - min-width: 100%; - } - - .playlist-selection-info { - order: 2; - } - - .playlist-actions { - order: 3; - justify-content: space-between; - } - } - `; - document.head.appendChild(style); - } - // Add playlist button when results are loaded // Use MutationObserver to detect when results are added const observer = new MutationObserver((mutations) => {