diff --git a/src/URIHandler.feeds.test.ts b/src/URIHandler.feeds.test.ts index b5c71766..63ddd2c4 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 => ({ @@ -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({ @@ -117,6 +117,101 @@ 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("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([ + [ + "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], 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); 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/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)", () => { 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); diff --git a/src/services/privateFeeds.ts b/src/services/privateFeeds.ts index 47cce388..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, @@ -68,10 +69,20 @@ 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 { + return checkStringIsUrl(url)?.href ?? url; } /** Tells the user that a saved private feed's URL is not on this device. */ 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();