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
61 changes: 52 additions & 9 deletions assets/js/podcast-player.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
*/
export function detectSourceType(url) {
if (!url) return "local";
if (/ivoox\.com/i.test(url)) return "ivoox";
if (/azuracast/i.test(url) || /\.stream\./i.test(url)) return "azuracast";
if (/^(https?:)?\/\/[^\/]*ivoox\.com/i.test(url)) return "ivoox";
if (/azuracast\./i.test(url) || /\.stream\./i.test(url)) return "azuracast";
return "local";
}

Expand Down Expand Up @@ -71,6 +71,9 @@ export class AzuracastAdapter {
if (src) return src;
const apiUrl = this.element.getAttribute("azuracast-api-url");
if (!apiUrl) throw new Error("AzuracastAdapter: missing azuracast-api-url attribute");
// Validate scheme before fetching to prevent client-side SSRF
const parsed = new URL(apiUrl);
if (parsed.protocol !== "https:") throw new Error("AzuracastAdapter: azuracast-api-url must use HTTPS");
const resp = await fetch(apiUrl);
if (!resp.ok) throw new Error(`AzuracastAdapter: API error ${resp.status}`);
const data = await resp.json();
Expand All @@ -95,6 +98,14 @@ export class IvooxAdapter {
async resolve() {
const src = this.element.getAttribute("src");
if (!/ivoox\.com/i.test(src)) return src;
// Validate scheme and domain to prevent client-side SSRF
try {
const parsed = new URL(src);
if (parsed.protocol !== "https:" || !parsed.hostname.endsWith("ivoox.com")) {
console.warn("IvooxAdapter: refusing to fetch non-ivoox.com or non-HTTPS URL", src);
return src;
}
} catch { return src; }
try {
const resp = await fetch(src);
if (!resp.ok) return src;
Expand All @@ -113,6 +124,24 @@ export class IvooxAdapter {
}

/* ---- Open-source SVG icons (Feather/Lucide, MIT licensed) ---- */

/**
* Compare two URLs by pathname + search, ignoring protocol/domain differences.
* Falls back to suffix matching for non-URL strings.
* @param {string} a
* @param {string} b
* @returns {boolean}
*/
function urlsMatch(a, b) {
try {
const uA = new URL(a, document.baseURI);
const uB = new URL(b, document.baseURI);
if (uA.pathname === uB.pathname && uA.search === uB.search) return true;
// Also match if paths differ only by trailing slash
return uA.pathname.replace(/\/$/, "") === uB.pathname.replace(/\/$/, "")
&& uA.search === uB.search;
} catch { return a.endsWith(b) || b.endsWith(a); }
}
const ICON_SKIP_BACK =
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
const ICON_SKIP_FWD =
Expand Down Expand Up @@ -1007,7 +1036,7 @@ class PodcastPlayer extends HTMLElement {
const src = e.detail && e.detail.src;
const mySrc = this._audio.src || this.getAttribute("src") || "";
// Only react if the event matches our source (or if unknown — backward compat)
if (src && src !== mySrc && !src.endsWith(mySrc) && !mySrc.endsWith(src)) return;
if (src && src !== mySrc && !urlsMatch(src, mySrc) && !urlsMatch(mySrc, src)) return;

if (this._audio.src && !this._audio.paused) {
this._audio.pause();
Expand Down Expand Up @@ -1042,7 +1071,7 @@ class PodcastPlayer extends HTMLElement {
const mySrc = this._audio.src || this.getAttribute("src") || "";
if (!mySrc || !src) return;

if (mySrc === src || src.endsWith(mySrc) || mySrc.endsWith(src)) {
if (mySrc === src || urlsMatch(src, mySrc) || urlsMatch(mySrc, src)) {
// Same source: someone else started our track — sync button to show pause
this._els.playBtn.innerHTML = '<svg viewBox="0 0 512 512" width="24" height="24"><path fill="currentColor" d="M144 96h96v320h-96zM272 96h96v320h-96z"/></svg>';
this._els.playBtn.setAttribute("aria-label", this._t("player_pause"));
Expand All @@ -1062,7 +1091,7 @@ class PodcastPlayer extends HTMLElement {
// Only seek if the event's source matches ours — prevents a paused
// player's progress bar interaction from affecting the active player.
const mySrc = this._audio.src || this.getAttribute("src") || "";
if (src && mySrc && src !== mySrc && !src.endsWith(mySrc) && !mySrc.endsWith(src)) return;
if (src && mySrc && src !== mySrc && !urlsMatch(src, mySrc) && !urlsMatch(mySrc, src)) return;
if (!this._audio.duration) return;
// Silently seek without dispatching our own seek event
this._audio.currentTime = Math.min(time, this._audio.duration);
Expand Down Expand Up @@ -1218,6 +1247,11 @@ class PodcastPlayer extends HTMLElement {
if (currentSrc && state.src && !this._urlsMatch(state.src, currentSrc)) {
return;
}
// Validate restored src scheme to prevent sessionStorage manipulation
try {
const u = new URL(state.src, document.baseURI);
if (u.protocol !== "http:" && u.protocol !== "https:") return;
} catch (_) { return; }

// Defer position + autoplay to loadedmetadata (where duration is known)
this._pendingRestoreState = state;
Expand Down Expand Up @@ -1363,7 +1397,7 @@ class PodcastPlayer extends HTMLElement {
const d = e.detail || {};
// Only react if src matches
const mySrc = this._audio.src || this.getAttribute("src") || "";
if (d.src && mySrc && d.src !== mySrc && !d.src.endsWith(mySrc) && !mySrc.endsWith(d.src)) return;
if (d.src && mySrc && d.src !== mySrc && !urlsMatch(d.src, mySrc) && !urlsMatch(mySrc, d.src)) return;

// Guard against echo — suppress our own dispatch while updating UI
this._suppressSync = true;
Expand Down Expand Up @@ -1786,7 +1820,7 @@ class PodcastFooter extends HTMLElement {
const mySrc = this._audio.src || "";
// Ignore pauses from a different source (avoids incorrectly stopping when
// one player replaces another)
if (src && mySrc && src !== mySrc && !src.endsWith(mySrc) && !mySrc.endsWith(src)) return;
if (src && mySrc && src !== mySrc && !urlsMatch(src, mySrc) && !urlsMatch(mySrc, src)) return;

if (this._audio.src && !this._audio.paused) {
this._audio.pause();
Expand Down Expand Up @@ -1830,7 +1864,7 @@ class PodcastFooter extends HTMLElement {
if (time == null || !isFinite(time)) return;
// Only seek if the source matches what the footer is playing
const mySrc = this._audio.src || "";
if (src && mySrc && src !== mySrc && !src.endsWith(mySrc) && !mySrc.endsWith(src)) return;
if (src && mySrc && src !== mySrc && !urlsMatch(src, mySrc) && !urlsMatch(mySrc, src)) return;
if (!this._audio.duration) return;
this._audio.currentTime = Math.min(time, this._audio.duration);
}
Expand Down Expand Up @@ -1919,7 +1953,7 @@ class PodcastFooter extends HTMLElement {
const d = e.detail || {};
// Only react if src matches
const mySrc = this._audio.src || "";
if (d.src && mySrc && d.src !== mySrc && !d.src.endsWith(mySrc) && !mySrc.endsWith(d.src)) return;
if (d.src && mySrc && d.src !== mySrc && !urlsMatch(d.src, mySrc) && !urlsMatch(mySrc, d.src)) return;

this._suppressSync = true;
try {
Expand Down Expand Up @@ -2028,6 +2062,15 @@ class PodcastFooter extends HTMLElement {

if (!state.src) return;

// Validate restored src scheme to prevent sessionStorage manipulation
try {
const u = new URL(state.src, document.baseURI);
if (u.protocol !== "http:" && u.protocol !== "https:") {
console.warn("PodcastFooter: ignoring restored src with unsafe scheme", u.protocol);
return;
}
} catch (_) { return; }

// Restore volume/mute/rate immediately
if (state.volume != null) this._audio.volume = state.volume;
if (state.muted != null) this._audio.muted = state.muted;
Expand Down
3 changes: 3 additions & 0 deletions exampleSite/hugo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ defaultContentLanguageInSubdir = false
persistent = true
preload = "metadata"

# WARNING: unsafe=true allows raw HTML in Markdown content (including <script> tags).
# Only enable this if all content authors are trusted. For sites with untrusted
# contributors, set unsafe=false and use shortcodes for HTML needs.
# Allow raw HTML in Markdown
[markup]
[markup.goldmark]
Expand Down
18 changes: 11 additions & 7 deletions exampleSite/layouts/_default/baseof.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ block "title" . }}{{ .Site.Title }}{{ end }}</title>
<meta name="description" content="{{ site.Params.description }}">
{{- /* Content Security Policy */ -}}
{{- $raw := printf "%d%s" now.Unix .Site.Title | sha256 -}}
{{- $cspNonce := substr $raw 0 20 -}}
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'nonce-{{ $cspNonce }}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' https:; connect-src 'self' https:; frame-ancestors 'none';">
{{- if .IsTranslated }}
{{- range .Translations }}
<link rel="alternate" hreflang="{{ .Language.Lang }}" href="{{ .Permalink }}">
Expand All @@ -22,9 +26,9 @@
<link rel="manifest" href="{{ "site.webmanifest" | relURL }}">
{{ $turbolinks := resources.Get "js/vendor/turbolinks.js" | fingerprint }}
<script src="{{ $turbolinks.RelPermalink }}" integrity="{{ $turbolinks.Data.Integrity }}"></script>
{{- $codeCopy := resources.Get "js/code-copy.js" -}}
{{- $codeCopy := resources.Get "js/code-copy.js" | fingerprint -}}
{{- if $codeCopy }}
<script src="{{ $codeCopy.RelPermalink }}" defer></script>
<script src="{{ $codeCopy.RelPermalink }}" integrity="{{ $codeCopy.Data.Integrity }}" defer></script>
{{- end }}
{{- /* Always load podcast-player CSS so the persistent footer has theme support on every page */ -}}
{{- $playerCSS := resources.Get "css/podcast-player.css" -}}
Expand Down Expand Up @@ -101,7 +105,7 @@

<podcast-footer id="podcast-footer" data-turbolinks-permanent data-turbo-permanent hx-preserve></podcast-footer>

<script>
<script nonce="{{ $cspNonce }}">
(function() {
'use strict';
var STORAGE_KEY = 'podcast-demo-theme';
Expand Down Expand Up @@ -179,7 +183,7 @@
"player_error" "player_no_source" "player_unknown_episode"
"code_copy_title" "code_copy_label" "code_copied" "code_copied_label"
-}}
<script>
<script nonce="{{ $cspNonce }}">
window.wavecast = window.wavecast || {};
window.wavecast.i18n = {
{{- range $i18nKeys }}
Expand All @@ -189,12 +193,12 @@
</script>
{{- /* Browser language auto-detection — redirect to preferred language on first visit */}}
{{- if hugo.IsMultilingual }}
<script>
<script nonce="{{ $cspNonce }}">
(function() {
var KEY = 'lang-pref';
try {
if (sessionStorage.getItem(KEY)) return;
sessionStorage.setItem(KEY, '1');
if (localStorage.getItem(KEY)) return;
localStorage.setItem(KEY, '1');
} catch(e) { return; }

var current = '{{ .Site.Language.Lang }}';
Expand Down
8 changes: 8 additions & 0 deletions exampleSite/layouts/_default/schedule.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ <h3 style="font-size:1rem">{{ .name }}</h3>
{{- $found = true }}
<span class="badge" style="background:var(--pp-surface);color:var(--pp-text-muted);text-transform:none;font-size:0.7rem;margin-bottom:0.25rem">{{ .time }}</span>
{{- $u := .url }}
{{- /* Validate URL scheme — reject dangerous protocols */ -}}
{{- if and $u (not (or (hasPrefix $u "http://") (hasPrefix $u "https://") (hasPrefix $u "/") (hasPrefix $u "#") (hasPrefix $u "."))) -}}
{{- errorf "schedule: URL must be HTTP(S), relative, fragment, or dot-relative path, got %q" $u -}}
{{- end -}}
{{- if not (hasPrefix $u "http") }}
{{- $u = printf "%s%s" (strings.TrimSuffix "/" $.Site.BaseURL) $u }}
{{- end }}
Expand All @@ -56,6 +60,10 @@ <h3>{{ .title }} <span style="font-weight:400;color:var(--text-muted);font-size:
<p class="episode-meta">{{ .day }} &middot; {{ .time }}</p>
<p>{{ .description }}</p>
{{- $eu := .url }}
{{- /* Validate URL scheme — reject dangerous protocols */ -}}
{{- if and $eu (not (or (hasPrefix $eu "http://") (hasPrefix $eu "https://") (hasPrefix $eu "/") (hasPrefix $eu "#") (hasPrefix $eu "."))) -}}
{{- errorf "schedule: URL must be HTTP(S), relative, fragment, or dot-relative path, got %q" $eu -}}
{{- end -}}
{{- if not (hasPrefix $eu "http") }}
{{- $eu = printf "%s%s" (strings.TrimSuffix "/" $.Site.BaseURL) $eu }}
{{- end }}
Expand Down
8 changes: 4 additions & 4 deletions exampleSite/layouts/episodes/single.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
{{- /* Load assets on first episode render per page */ -}}
{{- if not (.Page.Store.Get "podcastPlayerAssetsLoaded") -}}
{{- .Page.Store.Set "podcastPlayerAssetsLoaded" true }}
{{- $js := resources.Get "js/podcast-player.js" -}}
{{- $js := resources.Get "js/podcast-player.js" | fingerprint -}}
{{- if $js }}
<script src="{{ $js.RelPermalink }}" type="module" defer></script>
<script src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}" type="module" defer></script>
{{- end }}
{{- $css := resources.Get "css/podcast-player.css" -}}
{{- $css := resources.Get "css/podcast-player.css" | fingerprint -}}
{{- if $css }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}">
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}">
{{- end }}
{{- end }}

Expand Down
8 changes: 4 additions & 4 deletions exampleSite/layouts/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
{{- /* Load podcast-player assets manually since the home page uses no shortcode */ -}}
{{- if not (.Page.Store.Get "podcastPlayerAssetsLoaded") -}}
{{- .Page.Store.Set "podcastPlayerAssetsLoaded" true }}
{{- $js := resources.Get "js/podcast-player.js" -}}
{{- $js := resources.Get "js/podcast-player.js" | fingerprint -}}
{{- if $js }}
<script src="{{ $js.RelPermalink }}" type="module" defer></script>
<script src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}" type="module" defer></script>
{{- end }}
{{- $css := resources.Get "css/podcast-player.css" -}}
{{- $css := resources.Get "css/podcast-player.css" | fingerprint -}}
{{- if $css }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}">
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}">
{{- end }}
{{- end }}

Expand Down
5 changes: 5 additions & 0 deletions exampleSite/layouts/partials/hero.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ <h1>{{ .title }}</h1>
{{- with .buttons }}
<div class="nav-buttons">
{{- range . }}
{{- /* Validate URL scheme — reject dangerous protocols */ -}}
{{- if and .url (not (or (hasPrefix .url "http://") (hasPrefix .url "https://") (hasPrefix .url "/") (hasPrefix .url "#") (hasPrefix .url "."))) -}}
{{- errorf "hero button: URL must be HTTP(S), relative, fragment, or dot-relative path, got %q" .url -}}
{{- end -}}
{{- /* WARNING: .text is rendered as safeHTML to allow SVG icons. Only use trusted SVG markup from theme authors. */ -}}
<a href="{{ .url }}" class="nav-button {{ .class }}">{{ .text | safeHTML }}</a>
{{- end }}
</div>
Expand Down
3 changes: 3 additions & 0 deletions exampleSite/layouts/partials/program-card.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{{- /* program-card.html - renders a single program card with icon, title, description, episodes */ -}}
{{- $icon := .icon -}}
{{- if not (findRE "<svg\\b" $icon) }}
{{- errorf "program card: icon must be a valid SVG element, got: %.20s" $icon }}
{{- end -}}
{{- $url := .url -}}
{{- $title := .title -}}
{{- $desc := .description -}}
Expand Down
16 changes: 8 additions & 8 deletions layouts/_default/rss.xml
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,22 @@
{{- with $podcast.description }}<description>{{ . }}</description>{{ else }}<description>{{ .Site.Params.description | default .Site.Title }}</description>{{ end }}
<language>{{ $podcast.language | default .Site.LanguageCode }}</language>
{{- with $podcast.copyright }}<copyright>{{ . }}</copyright>{{ end }}
{{- with $podcast.author }}<itunes:author>{{ . }}</itunes:author>{{ end }}
{{- with $podcast.author }}<itunes:author>{{ . | transform.XMLEscape }}</itunes:author>{{ end }}
{{- with $podcast.summary }}<itunes:summary>{{ . | transform.XMLEscape | safeHTML }}</itunes:summary>{{ end }}
{{- with $podcast.subtitle }}<itunes:subtitle>{{ . }}</itunes:subtitle>{{ end }}
{{- with $podcast.image }}<itunes:image href="{{ . | absURL }}" />{{ end }}
{{- with $podcast.subtitle }}<itunes:subtitle>{{ . | transform.XMLEscape }}</itunes:subtitle>{{ end }}
{{- with $podcast.image }}<itunes:image href="{{ . | absURL | transform.XMLEscape }}" />{{ end }}
{{- range $podcast.categories }}
<itunes:category text="{{ .category }}">
{{- with .subcategory }}<itunes:category text="{{ . }}" />{{ end }}
<itunes:category text="{{ .category | transform.XMLEscape }}">
{{- with .subcategory }}<itunes:category text="{{ . | transform.XMLEscape }}" />{{ end }}
</itunes:category>
{{- end }}
<itunes:explicit>{{ $podcast.explicit | default "false" }}</itunes:explicit>
{{- with $podcast.owner_name }}<itunes:owner><itunes:name>{{ . }}</itunes:name>{{ with $podcast.owner_email }}<itunes:email>{{ . }}</itunes:email>{{ end }}</itunes:owner>{{ end }}
{{- with $podcast.owner_name }}<itunes:owner><itunes:name>{{ . | transform.XMLEscape }}</itunes:name>{{ with $podcast.owner_email }}<itunes:email>{{ . | transform.XMLEscape }}</itunes:email>{{ end }}</itunes:owner>{{ end }}
<itunes:type>{{ $podcast.type | default "episodic" }}</itunes:type>
{{- with $podcast.block }}<itunes:block>{{ . }}</itunes:block>{{ end }}
<generator>Hugo — wavecast theme</generator>
{{ with .OutputFormats.Get "rss" }}
{{ printf "<atom:link href=%q rel=\"self\" type=%q />" .Permalink .MediaType | safeHTML }}
{{ printf `<atom:link href="%s" rel="self" type="%s" />` (.Permalink | transform.XMLEscape) (.MediaType | transform.XMLEscape) | safeHTML }}
{{ end }}
{{- if not (eq (len $pages) 0) }}
<lastBuildDate>{{ (index ($pages | first 1) 0).Lastmod.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate>
Expand Down Expand Up @@ -93,7 +93,7 @@
{{- $enclosureLen = len $res.Content }}
{{- end }}
{{- end }}
<enclosure url="{{ $enclosureURL }}" length="{{ $enclosureLen }}" type="{{ $enclosureType }}" />
<enclosure url="{{ $enclosureURL | transform.XMLEscape }}" length="{{ $enclosureLen }}" type="{{ $enclosureType | transform.XMLEscape }}" />
{{- with $pPod.duration }}<itunes:duration>{{ . }}</itunes:duration>{{ end }}
{{- with $pPod.author }}<itunes:author>{{ . }}</itunes:author>{{ else }}{{ with $podcast.author }}<itunes:author>{{ . }}</itunes:author>{{ end }}{{ end }}
{{- with $pPod.subtitle }}<itunes:subtitle>{{ . | transform.XMLEscape | safeHTML }}</itunes:subtitle>{{ end }}
Expand Down
4 changes: 4 additions & 0 deletions layouts/_shortcodes/button.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
variants: primary | secondary | outline
*/ -}}
{{- $url := .Get "url" -}}
{{- /* Validate URL scheme — reject dangerous protocols */ -}}
{{- if and $url (not (or (hasPrefix $url "http://") (hasPrefix $url "https://") (hasPrefix $url "/") (hasPrefix $url "#") (hasPrefix $url "."))) -}}
{{- errorf "shortcode: URL must be HTTP(S), relative, fragment, or dot-relative path, got %q" $url -}}
{{- end -}}
{{- $variant := .Get "variant" | default "primary" -}}
{{- $icon := .Get "icon" -}}
{{- $isExternal := or (hasPrefix $url "http://") (hasPrefix $url "https://") -}}
Expand Down
4 changes: 4 additions & 0 deletions layouts/_shortcodes/figure.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
{{- $width := .Get "width" -}}
{{- $href := .Get "href" -}}
{{- $class := .Get "class" | default "" -}}
{{- /* Validate URL scheme — reject dangerous protocols */ -}}
{{- if and $href (not (or (hasPrefix $href "http://") (hasPrefix $href "https://") (hasPrefix $href "/") (hasPrefix $href "#") (hasPrefix $href "."))) -}}
{{- errorf "shortcode: URL must be HTTP(S), relative, fragment, or dot-relative path, got %q" $href -}}
{{- end -}}

{{- $isExternal := or (hasPrefix $href "http://") (hasPrefix $href "https://") (hasPrefix $href "//") -}}
<figure class="wvc-figure{{ with $class }} {{ . }}{{ end }}">
Expand Down
Loading