From 0dae75cacf26c20c4c1a66588ebd2ea1b5abad94 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 23:04:56 +0200 Subject: [PATCH 1/9] test: a raw link to a subscribed private feed keeps its secret out of the live episode --- src/URIHandler.feeds.test.ts | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/URIHandler.feeds.test.ts b/src/URIHandler.feeds.test.ts index b5c71766..d80755dc 100644 --- a/src/URIHandler.feeds.test.ts +++ b/src/URIHandler.feeds.test.ts @@ -51,7 +51,7 @@ afterEach(() => { }); describe("podNotesURIHandler with saved feeds", () => { - const SECRET_URL = "https://patron.example.com/feed.xml?auth=s3cret"; + const SECRET_URL = "https://patron.example.com/PATH_SENTINEL/feed.xml?auth=QUERY_SENTINEL"; const PRIVATE_LINK_NOTICE = "This link points to a private feed that is not available on this device."; const patronEpisode = (title: string): Episode => ({ @@ -117,6 +117,37 @@ describe("podNotesURIHandler with saved feeds", () => { expect(get(viewState)).toBe(ViewState.Player); }); + test("a link to a private feed by its secret URL plays the subscription's episode, which keeps the secret out", async () => { + follow(); + const linkless = { ...second, url: "" }; + load.mockResolvedValue(feedDocument("Patron Show", [first, linkless])); + + await open(SECRET_URL, linkless.title); + + expect(playing()).toMatchObject({ + title: linkless.title, + podcastName: "Patron Show", + url: privateLink, + feedUrl: privateLink, + }); + expect(JSON.stringify(playing())).not.toMatch(/PATH_SENTINEL|QUERY_SENTINEL/); + }); + + test("a link to the playing private episode by its secret URL seeks it without loading the feed", async () => { + const { feeds } = follow(); + load.mockResolvedValue(feedDocument("Patron Show", [first])); + await feeds.ensureAll(); + const [subscribed] = get(feeds).get("Patron Show")?.episodes ?? []; + player.play(subscribed); + load.mockClear(); + + await open(SECRET_URL, first.title); + + expect(load).not.toHaveBeenCalled(); + expect(get(player.state).currentTime).toBe(5); + expect(playing()).toMatchObject({ title: first.title, feedUrl: privateLink }); + }); + test.each([ ["a podcast this device does not follow", privateFeedPlaceholder("Other Show"), true], ["a private feed whose URL is not on this device", privateLink, false], From 370485bfc33a55d595dfff69d944c1832e5737e5 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 23:34:10 +0200 Subject: [PATCH 2/9] fix: keep a subscribed private feed's secret URL out of episodes opened by a raw link A podnotes link whose url is a subscribed private feed's real URL now opens as that feed's placeholder link. The player's episode carries the placeholder, so timestamps captured from it no longer copy the secret URL into notes. --- src/URIHandler.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/URIHandler.ts b/src/URIHandler.ts index 955e1356..b13435d6 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -5,7 +5,7 @@ import { localFiles, plugin, savedFeeds, viewState } from "./store"; import type { Episode } from "./types/Episode"; import type { LocalEpisode } from "./types/LocalEpisode"; import { isFetchableUrl } from "./utility/assertFetchableUrl"; -import { resolveFeedUrl } from "./services/privateFeeds"; +import { isSavedFeed, resolveFeedUrl } from "./services/privateFeeds"; import { isPrivateFeedPlaceholder, parsePrivateFeedPlaceholder } from "./utility/privateFeedUrl"; import { findUniqueTitleMatch, titleTokenSimilarity } from "./utility/episodeTitleMatch"; import { episodeLinkTarget } from "./utility/episodeLinkTarget"; @@ -90,10 +90,10 @@ function parseSeconds(value: string): number { let latestRequest = 0; export default async function podNotesURIHandler( - { url, episodeName, time, endTime, end, to }: PodNotesProtocolData, + { url: linkUrl, episodeName, time, endTime, end, to }: PodNotesProtocolData, revealPlayer?: RevealPodNotesPlayer, ) { - if (!url || !episodeName) { + if (!linkUrl || !episodeName) { new Notice("URL and episode name are required to play an episode"); return; } @@ -127,7 +127,12 @@ export default async function podNotesURIHandler( const player = get(plugin).player; const playOptions = hasExplicitTime ? { time: requestedTime, endTime: requestedEndTime } : {}; const loaded = get(player.state).episode; - const { app } = get(plugin); + const { app, feedUrls } = get(plugin); + // A link carrying a saved private feed's secret URL opens as the feed's placeholder + // link, so the secret stays out of the episode. + const url = + Object.values(get(savedFeeds)).find((saved) => isSavedFeed(saved, linkUrl, feedUrls)) + ?.url ?? linkUrl; const localFile = candidateValues(url) .map((path) => app.vault.getAbstractFileByPath(path)) .find((file) => file instanceof TFile); From aaefcd65f8305af78bd4f3bd0a34d969759d5276 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 00:21:57 +0200 Subject: [PATCH 3/9] test: playing the loaded episode from its private feed keeps the secret out of the live episode --- src/player/PlayerController.test.ts | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/player/PlayerController.test.ts b/src/player/PlayerController.test.ts index 1351fbca..66ca150a 100644 --- a/src/player/PlayerController.test.ts +++ b/src/player/PlayerController.test.ts @@ -7,6 +7,7 @@ import { downloadedEpisodes, playedEpisodes, playlists, queue } from "src/store" import type { Episode } from "src/types/Episode"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import { getEpisodeKey } from "src/utility/episodeKey"; +import { privateFeedPlaceholder } from "src/utility/privateFeedUrl"; import { FakeDocument, FakeParent, type FakeMedia } from "../../tests/mocks/fakeMedia"; import { PlayerController } from "./PlayerController"; @@ -836,6 +837,36 @@ describe("selection", () => { reach(media(), 46, 7000); expect(written.at(-1)).toEqual({ time: 46, finished: false, checkpoint: true }); }); + + const SECRET_FEED = "https://patron.example.com/PATH_SENTINEL/feed.xml?auth=QUERY_SENTINEL"; + const secretA = episode("A", { url: SECRET_FEED, feedUrl: SECRET_FEED }); + const privateA = episode("A", { + url: privateFeedPlaceholder("Show"), + feedUrl: privateFeedPlaceholder("Show"), + }); + + test.each([ + ["takes its private feed's placeholder over the loaded secret URL", secretA, privateA], + ["never takes a secret URL over the loaded private feed's placeholder", privateA, secretA], + ])("play() of the episode already playing %s, and keeps playing", (_, loaded, selected) => { + const { doc, player, media, state } = setup(); + player.play(loaded); + metadata(media()); + reach(media(), 42); + + player.play(selected); + + expect(state().episode).toBe(privateA); + expect(JSON.stringify(state().episode)).not.toMatch(/SENTINEL/); + expect(doc.created).toHaveLength(1); + expect(media()).toMatchObject({ + src: secretA.streamUrl, + readyState: 1, + currentTime: 42, + paused: false, + }); + expect(state()).toMatchObject({ status: "playing", currentTime: 42 }); + }); }); describe("legacy title-only progress (PB-06)", () => { From fe4ab58bc38d8bb115a8574ddb4a6a96f29ba09d Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 00:22:16 +0200 Subject: [PATCH 4/9] fix: show a private feed's placeholder when its episode is already loaded with the secret URL --- src/player/PlayerController.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 5b0ec177..b89031b4 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -9,6 +9,7 @@ import { getEpisodeKey, isSameStoredEpisode } from "src/utility/episodeKey"; import { getPlayedEpisode } from "src/utility/episodeStatus"; import { getEpisodeMediaType } from "src/utility/mediaType"; import { normalizePlaybackRate } from "src/utility/playbackRate"; +import { isPrivateFeedPlaceholder } from "src/utility/privateFeedUrl"; import { selectSource, type PlaybackSource } from "./source"; export type PlayerStatus = @@ -155,6 +156,15 @@ export class PlayerController { session.src === source.src && mediaType === source.mediaType ) { + // The loaded copy may carry a private feed's secret URL, as when it was opened + // before its feed was subscribed. The subscription's copy replaces it; a copy + // with the secret never replaces one without. + if ( + isPrivateFeedPlaceholder(episode.feedUrl ?? "") && + !isPrivateFeedPlaceholder(outgoing?.feedUrl ?? "") + ) { + this.setState({ episode }); + } session.segmentEnd = endTime ?? null; session.preview = endTime !== undefined; if (time === undefined && status === "ended") this.moveTo(0); From e5f9728ab40ac175d3b500a1fac3bbe233a53e65 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 00:23:11 +0200 Subject: [PATCH 5/9] test: a private feed's secret URL with another host case or a default port keeps the secret out --- src/URIHandler.feeds.test.ts | 52 ++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/src/URIHandler.feeds.test.ts b/src/URIHandler.feeds.test.ts index d80755dc..3bfb2a71 100644 --- a/src/URIHandler.feeds.test.ts +++ b/src/URIHandler.feeds.test.ts @@ -65,13 +65,13 @@ describe("podNotesURIHandler with saved feeds", () => { const load = vi.fn<(url: string) => Promise>(); /** Subscribes to "Patron Show", whose URL is on this device only when `secretStored`. */ - function follow(secretStored = true) { + function follow(secretStored = true, secretUrl = SECRET_URL) { stubLegacyStorage(); const feedUrls = new FeedUrlRepository(new obsidian.SecretStorage()); const patron: PodcastFeed = { title: "Patron Show", url: privateFeedPlaceholder("Patron Show"), - urlSecretId: secretStored ? feedUrls.store(SECRET_URL) : "podnotes-feed-url-gone", + urlSecretId: secretStored ? feedUrls.store(secretUrl) : "podnotes-feed-url-gone", artworkUrl: "", }; const feeds = new FeedRepository({ @@ -148,6 +148,54 @@ describe("podNotesURIHandler with saved feeds", () => { expect(playing()).toMatchObject({ title: first.title, feedUrl: privateLink }); }); + const HTTP_SECRET_URL = SECRET_URL.replace("https:", "http:"); + + test.each([ + [ + "an uppercase host", + SECRET_URL, + SECRET_URL.replace("patron.example.com", "PATRON.EXAMPLE.COM"), + ], + ["an uppercase scheme", SECRET_URL, SECRET_URL.replace("https:", "HTTPS:")], + ["https's default port", SECRET_URL, SECRET_URL.replace(".com/", ".com:443/")], + ["http's default port", HTTP_SECRET_URL, HTTP_SECRET_URL.replace(".com/", ".com:80/")], + ])( + "a link to a private feed by its secret URL with %s plays the subscription's episode", + async (_, secretUrl, link) => { + follow(true, secretUrl); + player.play(testEpisode); + const linkless = { ...first, url: "" }; + load.mockResolvedValue(feedDocument("Patron Show", [linkless])); + + await open(link, linkless.title); + + expect(playing()).toMatchObject({ + title: linkless.title, + podcastName: "Patron Show", + url: privateLink, + feedUrl: privateLink, + }); + expect(JSON.stringify(playing())).not.toMatch(/PATH_SENTINEL|QUERY_SENTINEL/); + expect(load.mock.calls).toEqual([[secretUrl]]); + }, + ); + + test.each([ + ["another path case", SECRET_URL.replace("feed.xml", "FEED.xml")], + ["a trailing slash", SECRET_URL.replace("feed.xml", "feed.xml/")], + ["another query", SECRET_URL.replace("QUERY_SENTINEL", "OTHER_SENTINEL")], + ["http for https", HTTP_SECRET_URL], + ])("a link with %s is not the private feed's secret URL", async (_, link) => { + follow(); + player.play(testEpisode); + load.mockResolvedValue(feedDocument("Patron Show", [{ ...first, url: "" }])); + + await open(link, first.title); + + expect(load.mock.calls).toEqual([[link]]); + expect(playing()).toMatchObject({ title: first.title, feedUrl: link }); + }); + test.each([ ["a podcast this device does not follow", privateFeedPlaceholder("Other Show"), true], ["a private feed whose URL is not on this device", privateLink, false], From 5a175688967e2819d6eb21383e2eca5492787e76 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 00:25:22 +0200 Subject: [PATCH 6/9] test: OPML import and Settings know a saved private feed by another host case --- src/opml.test.ts | 20 ++++++++++++++++++++ src/ui/settings/PodcastQueryGrid.test.ts | 16 +++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/opml.test.ts b/src/opml.test.ts index 9b8b629d..a7bf503d 100644 --- a/src/opml.test.ts +++ b/src/opml.test.ts @@ -233,6 +233,26 @@ describe("importOPML (IE-01)", () => { expect(noticeMessages.some((m) => m.includes("Skipped 1 existing podcasts"))).toBe(true); }); + it("skips a saved private feed listed by its real url with an uppercase host", async () => { + feedUrlSecrets.clear(); + feedUrlSecrets.set("podnotes-feed-url", "https://private.test/feed?auth=s3cret"); + const saved = { + title: "Private", + url: "podnotes-private-feed:Private", + urlSecretId: "podnotes-feed-url", + artworkUrl: "", + }; + savedFeeds.set({ Private: saved }); + + await importOPML( + '', + ); + + expect(channelAt).not.toHaveBeenCalled(); + expect(get(savedFeeds)).toEqual({ Private: saved }); + expect(noticeMessages.some((m) => m.includes("Skipped 1 existing podcasts"))).toBe(true); + }); + it("gives a saved private feed whose URL is not on this device the listed URL", async () => { const privateUrl = "https://private.test/feed?auth=s3cret"; feedUrlSecrets.clear(); diff --git a/src/ui/settings/PodcastQueryGrid.test.ts b/src/ui/settings/PodcastQueryGrid.test.ts index 2cd81eb6..c5307a34 100644 --- a/src/ui/settings/PodcastQueryGrid.test.ts +++ b/src/ui/settings/PodcastQueryGrid.test.ts @@ -71,16 +71,16 @@ const settle = () => new Promise((resolve) => setTimeout(resolve)); const PRIVATE_URL = "https://www.patreon.com/rss/show?auth=se-cret"; -// Follows "Private Show" through PRIVATE_URL. Pasting any URL loads its RSS, +// Follows "Private Show" through `url`. Pasting any URL loads its RSS, // whose author tells the pasted card apart from the saved one. -function followPrivateShow() { +function followPrivateShow(url = PRIVATE_URL) { const secrets = new obsidian.SecretStorage(); const feedUrls = new FeedUrlRepository(secrets); usePlugin(feedUrls); const saved = { title: "Private Show", url: privateFeedPlaceholder("Private Show"), - urlSecretId: feedUrls.store(PRIVATE_URL), + urlSecretId: feedUrls.store(url), artworkUrl: "", }; savedFeeds.set({ "Private Show": saved }); @@ -197,6 +197,16 @@ describe("PodcastQueryGrid", () => { expect(queryByRole("button", { name: "Add Private Show podcast" })).toBeNull(); }); + test("shows a private feed followed with an uppercase host as saved when its URL is pasted", async () => { + followPrivateShow(PRIVATE_URL.replace("www.patreon.com", "WWW.PATREON.COM")); + + const { findByText, getByRole, queryByRole } = paste(PRIVATE_URL); + await findByText("Patron Host"); + + expect(getByRole("button", { name: "Remove Private Show podcast" })).toBeInTheDocument(); + expect(queryByRole("button", { name: "Add Private Show podcast" })).toBeNull(); + }); + test("refusing a private namesake stores none of its URL", async () => { const notice = vi.spyOn(obsidian, "Notice"); const { saved, secrets } = followPrivateShow(); From d99fbb76ff7a05ecfa9b4f69d0940e86710f1d84 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 00:25:22 +0200 Subject: [PATCH 7/9] fix: match a saved feed's URL whatever its scheme and host case or default port --- src/services/privateFeeds.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/services/privateFeeds.ts b/src/services/privateFeeds.ts index 47cce388..77c73a99 100644 --- a/src/services/privateFeeds.ts +++ b/src/services/privateFeeds.ts @@ -68,10 +68,24 @@ export function resolveFeedUrl(feed: FeedAddress, feedUrls: FeedUrlRepository): /** * Whether `url` is the saved feed's URL. A private feed matches both its * placeholder and the URL the placeholder stands for, so the same feed reads - * as saved whether it is listed from settings or pasted again. + * as saved whether it is listed from settings or pasted again. URLs compare as + * a request sends them: the scheme and host in any case, and with or without a + * default port, name the same feed; the path, query and trailing slash must match. */ export function isSavedFeed(saved: PodcastFeed, url: string, feedUrls: FeedUrlRepository): boolean { - return saved.url === url || resolveFeedUrl(saved, feedUrls) === url; + const wanted = canonicalUrl(url); + if (canonicalUrl(saved.url) === wanted) return true; + const resolved = resolveFeedUrl(saved, feedUrls); + return resolved !== null && canonicalUrl(resolved) === wanted; +} + +/** `url` as the URL parser serializes it, or as given when it is not a URL. */ +function canonicalUrl(url: string): string { + try { + return new URL(url).href; + } catch { + return url; + } } /** Tells the user that a saved private feed's URL is not on this device. */ From d62dac5218fe7ecd44f6cf4115fdd5eb0396b4d0 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 00:41:01 +0200 Subject: [PATCH 8/9] test: a secret URL link takes the secret out of the episode loaded from it before subscribing --- src/URIHandler.feeds.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/URIHandler.feeds.test.ts b/src/URIHandler.feeds.test.ts index 3bfb2a71..63ddd2c4 100644 --- a/src/URIHandler.feeds.test.ts +++ b/src/URIHandler.feeds.test.ts @@ -148,6 +148,22 @@ describe("podNotesURIHandler with saved feeds", () => { expect(playing()).toMatchObject({ title: first.title, feedUrl: privateLink }); }); + test("a link to a private feed by its secret URL takes the secret out of the episode loaded from it before subscribing", async () => { + player.play({ ...first, url: SECRET_URL, feedUrl: SECRET_URL }); + follow(); + load.mockResolvedValue(feedDocument("Patron Show", [{ ...first, url: "" }])); + + await open(SECRET_URL, first.title); + + expect(playing()).toMatchObject({ + title: first.title, + url: privateLink, + feedUrl: privateLink, + }); + expect(JSON.stringify(playing())).not.toMatch(/PATH_SENTINEL|QUERY_SENTINEL/); + expect(get(player.state).currentTime).toBe(5); + }); + const HTTP_SECRET_URL = SECRET_URL.replace("https:", "http:"); test.each([ From 0e0666690d3dc80f963f8850be93d38a8bddf70d Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 00:41:01 +0200 Subject: [PATCH 9/9] refactor: parse feed URLs for matching with checkStringIsUrl --- src/services/privateFeeds.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/services/privateFeeds.ts b/src/services/privateFeeds.ts index 77c73a99..1fc0bb61 100644 --- a/src/services/privateFeeds.ts +++ b/src/services/privateFeeds.ts @@ -1,6 +1,7 @@ import { Notice } from "obsidian"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import type { PodcastFeed } from "src/types/PodcastFeed"; +import checkStringIsUrl from "src/utility/checkStringIsUrl"; import { ownRecord } from "src/utility/ownRecord"; import { isCredentialBearingUrl, @@ -81,11 +82,7 @@ export function isSavedFeed(saved: PodcastFeed, url: string, feedUrls: FeedUrlRe /** `url` as the URL parser serializes it, or as given when it is not a URL. */ function canonicalUrl(url: string): string { - try { - return new URL(url).href; - } catch { - return url; - } + return checkStringIsUrl(url)?.href ?? url; } /** Tells the user that a saved private feed's URL is not on this device. */