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
101 changes: 98 additions & 3 deletions src/URIHandler.feeds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand All @@ -65,13 +65,13 @@ describe("podNotesURIHandler with saved feeds", () => {
const load = vi.fn<(url: string) => Promise<ParsedFeedEpisodes>>();

/** 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({
Expand Down Expand Up @@ -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],
Expand Down
13 changes: 9 additions & 4 deletions src/URIHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions src/opml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<opml><body><outline text="Private" xmlUrl="https://PRIVATE.TEST/feed?auth=s3cret" /></body></opml>',
);

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();
Expand Down
31 changes: 31 additions & 0 deletions src/player/PlayerController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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)", () => {
Expand Down
10 changes: 10 additions & 0 deletions src/player/PlayerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 13 additions & 2 deletions src/services/privateFeeds.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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. */
Expand Down
16 changes: 13 additions & 3 deletions src/ui/settings/PodcastQueryGrid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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();
Expand Down
Loading