diff --git a/src/API/API.test.ts b/src/API/API.test.ts
index 8c6ea9aa..6ae2254b 100644
--- a/src/API/API.test.ts
+++ b/src/API/API.test.ts
@@ -110,6 +110,19 @@ describe("API.getPodcastSegmentFormatted", () => {
);
});
+ test("captures from a private feed this device has not saved are plain text", () => {
+ const { api, player } = setup();
+ player.restore({
+ ...feedEpisode,
+ feedUrl: "https://pod.example.com/feed.xml?auth=CAPTURE_SENTINEL",
+ });
+
+ expect(api.getPodcastTimeFormatted("HH:mm:ss", true)).toBe("00:00:00");
+ expect(api.getPodcastSegmentFormatted("HH:mm:ss", 115, 125, true)).toBe(
+ "00:01:55-00:02:05",
+ );
+ });
+
test("does not link invalid segment ranges", () => {
const { api, player } = setup();
player.restore(feedEpisode);
diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts
index ed10df81..8d498592 100644
--- a/src/TemplateEngine.ts
+++ b/src/TemplateEngine.ts
@@ -17,6 +17,7 @@ import type { PodcastSegmentTimes } from "./utility/podcastSegment";
import type { Chapter } from "./types/Chapter";
import { normalizeChapters } from "./utility/normalizeChapters";
import { isFetchableUrl } from "./utility/assertFetchableUrl";
+import { vaultSafeLinkTarget, withoutPrivateFeedAddresses } from "./services/privateFeeds";
// Each tag is either a literal string or a function taking at most one argument
// (the raw text after the leading colon, e.g. the format in {{date:YYYY}}). The
@@ -32,7 +33,7 @@ type ReplacerFn = (template: string) => string;
// The optional argument group uses `.*?` (zero-or-more), so a bare trailing
// colon ({{date:}}) is captured AS the argument group (params === ":") rather
-// than being absorbed into the tag id — which previously yielded tagId "date:",
+// than being absorbed into the tag id - which previously yielded tagId "date:",
// an unknown tag, and a spurious "invalid tag" Notice (NT-05/CH-09).
const TEMPLATE_TAG_REGEX = /\{\{(.*?)(:\s*.*?)?\}\}/g;
@@ -46,6 +47,10 @@ export function templateHasTag(template: string, tag: Lowercase): boolea
);
}
+function withoutFeedAddresses(feed: PodcastFeed): PodcastFeed {
+ return withoutPrivateFeedAddresses([feed, { feedUrl: feed.url }] as const)[0];
+}
+
function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> {
const tags: Tags = {};
@@ -54,7 +59,7 @@ function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> {
}
function replacer(template: string): string {
- return template.replace(
+ return withoutPrivateFeedAddresses(template).replace(
TEMPLATE_TAG_REGEX,
(match: string, tagId: string, params: string) => {
const tagValue = tags[tagId.toLowerCase()];
@@ -74,7 +79,7 @@ function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> {
if (typeof tagValue === "function") {
// Everything after the leading colon is a single argument. A bare
// trailing colon ({{date:}}) means "use the tag default", same as a
- // bare {{date}} — not an empty format string (NT-05/CH-09). Only an
+ // bare {{date}} - not an empty format string (NT-05/CH-09). Only an
// EMPTY argument is collapsed; a whitespace argument ({{description: }})
// is preserved so the obscure space-prepend usage still works.
const arg = params ? params.slice(1) : "";
@@ -128,7 +133,7 @@ function legalizedNameTag(
* template engine: {{title}} and {{podcast}} (illegal-character-stripped, with an
* optional whitespace-replacement arg), {{date}} (episode publish date),
* {{currentdate}}, and {{episodenumber}}. NoteTemplateEngine intentionally does
- * NOT use this — there {{title}} is the raw episode title, not a file name.
+ * NOT use this - there {{title}} is the raw episode title, not a file name.
*/
function addEpisodeFileNameTags(
addTag: AddTagFn,
@@ -233,7 +238,7 @@ function neutralizeMarkdownEmbeds(markdown: string): string {
* rewritten here to avoid mangling legitimate inline code in programming show
* notes.
*/
-function feedHtmlToMarkdown(html: string): string {
+function feedHtmlToMarkdown(html: string, source: object): string {
const document = new DOMParser().parseFromString(html, "text/html");
for (const element of document.querySelectorAll(
"img, audio, video, source, iframe, object, embed, link",
@@ -241,6 +246,13 @@ function feedHtmlToMarkdown(html: string): string {
const alt = element.instanceOf(HTMLImageElement) ? element.alt.trim() : "";
element.replaceWith(document.createTextNode(alt));
}
+ // Entities can spell out a private feed address, so redact the decoded text.
+ const nodes = [document.body, ...document.body.querySelectorAll("*")].flatMap((element) => [
+ ...element.attributes,
+ ...element.childNodes,
+ ]);
+ const [, values] = withoutPrivateFeedAddresses([source, nodes.map((node) => node.nodeValue)]);
+ nodes.forEach((node, index) => (node.nodeValue = values[index]));
return neutralizeMarkdownEmbeds(
neutralizeExecutableCodeBlocks(htmlToMarkdown(document.body.innerHTML)),
);
@@ -279,17 +291,23 @@ function resolveEpisodeUrl(episode: Episode): string {
export function NoteTemplateEngine(
template: string,
- episode: Episode,
+ liveEpisode: Episode,
context: NoteTemplateContext = {},
) {
const [replacer, addTag] = useTemplateEngine();
+ const parentFeed = get(plugin)?.settings?.savedFeeds?.[liveEpisode.podcastName];
+ const [episode, chapters, feedArtwork] = withoutPrivateFeedAddresses([
+ liveEpisode,
+ context.chapters,
+ parentFeed?.artworkUrl,
+ ] as const);
const episodeUrl = resolveEpisodeUrl(episode);
addTag("title", escapeMarkdownBodyText(episode.title));
addTag("description", (prependToLines?: string) => {
// reduce multiple new lines
- const sanitizeDescription = feedHtmlToMarkdown(episode.description).replace(
+ const sanitizeDescription = feedHtmlToMarkdown(episode.description, liveEpisode).replace(
/\n{3,}/g,
"\n\n",
);
@@ -304,13 +322,13 @@ export function NoteTemplateEngine(
});
addTag("content", (prependToLines?: string) => {
if (prependToLines) {
- return feedHtmlToMarkdown(episode.content)
+ return feedHtmlToMarkdown(episode.content, liveEpisode)
.split("\n")
.map((str) => `${prependToLines}${str}`)
.join("\n");
}
- return feedHtmlToMarkdown(episode.content);
+ return feedHtmlToMarkdown(episode.content, liveEpisode);
});
addTag("safetitle", replaceIllegalFileNameCharactersInString(episode.title));
addTag("stream", sanitizePortableResourceUrl(episode.streamUrl));
@@ -332,7 +350,7 @@ export function NoteTemplateEngine(
// Podcasting 2.0 chapters, fetched before note creation when the template
// asks for them. Empty when the feed has no chapters URL or fetching fails.
addTag("chapters", (prependToLines?: string) =>
- formatTemplateChapters(context.chapters, prependToLines),
+ formatTemplateChapters(chapters, prependToLines),
);
addTag("podcast", replaceIllegalFileNameCharactersInString(episode.podcastName));
addTag("artwork", sanitizePortableResourceUrl(episode.artworkUrl ?? ""));
@@ -342,12 +360,11 @@ export function NoteTemplateEngine(
// (which always describe the episode itself). See issue #163.
addTag("episodeurl", episodeUrl);
addTag("episodeartwork", sanitizePortableResourceUrl(episode.artworkUrl ?? ""));
- addTag("feedurl", sanitizeUrlForTemplate(episode.feedUrl ?? ""));
- const parentFeed = get(plugin)?.settings?.savedFeeds?.[episode.podcastName];
addTag(
- "feedartwork",
- sanitizePortableResourceUrl(parentFeed?.artworkUrl ?? episode.artworkUrl ?? ""),
+ "feedurl",
+ sanitizeUrlForTemplate(vaultSafeLinkTarget(liveEpisode, liveEpisode.feedUrl) ?? ""),
);
+ addTag("feedartwork", sanitizePortableResourceUrl(feedArtwork ?? episode.artworkUrl ?? ""));
// A ready-made wikilink to the parent feed's note, pointing at the same file
// createFeedNote writes (derived from the feed-note path setting).
addTag("podcastlink", getFeedNoteWikilink(episode.podcastName));
@@ -356,7 +373,7 @@ export function NoteTemplateEngine(
// never been played). The resume point is resolved at click time, not baked
// in here, so a single templated link always jumps to where you left off.
// Empty when the episode has no feed/file URL to address it by. See issue #35.
- addTag("episodelink", buildEpisodeResumeLink(episode));
+ addTag("episodelink", buildEpisodeResumeLink(liveEpisode));
return replacer(template);
}
@@ -410,7 +427,7 @@ export function FilePathTemplateEngine(
) {
const [replacer, addTag] = useTemplateEngine();
- addEpisodeFileNameTags(addTag, episode, sanitizeFileName);
+ addEpisodeFileNameTags(addTag, withoutPrivateFeedAddresses(episode), sanitizeFileName);
return replacer(template);
}
@@ -431,17 +448,21 @@ export function DownloadPathTemplateEngine(template: string, episode: Episode) {
const [replacer, addTag] = useTemplateEngine();
- addEpisodeFileNameTags(addTag, episode);
+ addEpisodeFileNameTags(addTag, withoutPrivateFeedAddresses(episode));
return replacer(templateWithoutExtension);
}
export function TranscriptTemplateEngine(
template: string,
- episode: Episode,
- transcription: string,
+ liveEpisode: Episode,
+ liveTranscription: string,
) {
const [replacer, addTag] = useTemplateEngine();
+ const [episode, transcription] = withoutPrivateFeedAddresses([
+ liveEpisode,
+ liveTranscription,
+ ] as const);
addEpisodeFileNameTags(addTag, episode);
addTag("duration", (format?: string) =>
@@ -450,13 +471,13 @@ export function TranscriptTemplateEngine(
addTag("transcript", transcription);
addTag("description", (prependToLines?: string) => {
if (prependToLines) {
- return feedHtmlToMarkdown(episode.description)
+ return feedHtmlToMarkdown(episode.description, liveEpisode)
.split("\n")
.map((str) => `${prependToLines}${str}`)
.join("\n");
}
- return feedHtmlToMarkdown(episode.description);
+ return feedHtmlToMarkdown(episode.description, liveEpisode);
});
addTag("url", resolveEpisodeUrl(episode));
addTag("artwork", sanitizePortableResourceUrl(episode.artworkUrl ?? ""));
@@ -464,8 +485,9 @@ export function TranscriptTemplateEngine(
return replacer(template);
}
-export function FeedNoteTemplateEngine(template: string, feed: PodcastFeed) {
+export function FeedNoteTemplateEngine(template: string, liveFeed: PodcastFeed) {
const [replacer, addTag] = useTemplateEngine();
+ const feed = withoutFeedAddresses(liveFeed);
const safeTitle = replaceIllegalFileNameCharactersInString(feed.title);
@@ -480,15 +502,14 @@ export function FeedNoteTemplateEngine(template: string, feed: PodcastFeed) {
// inside quoted YAML frontmatter scalars (the default feed template uses both).
// A well-formed URL never contains the characters this neutralizes.
addTag("url", sanitizeUrlForTemplate(feed.link ?? ""));
- addTag("feedurl", sanitizeUrlForTemplate(feed.url));
+ addTag("feedurl", sanitizeUrlForTemplate(vaultSafeLinkTarget(liveFeed, liveFeed.url) ?? ""));
addTag("artwork", sanitizePortableResourceUrl(feed.artworkUrl ?? ""));
addTag("feedartwork", sanitizePortableResourceUrl(feed.artworkUrl ?? ""));
addTag("author", escapeMarkdownBodyText(feed.author ?? ""));
addTag("description", (prependToLines?: string) => {
- const sanitizeDescription = feedHtmlToMarkdown(feed.description ?? "").replace(
- /\n{3,}/g,
- "\n\n",
- );
+ const sanitizeDescription = feedHtmlToMarkdown(feed.description ?? "", {
+ feedUrl: liveFeed.url,
+ }).replace(/\n{3,}/g, "\n\n");
if (prependToLines) {
return sanitizeDescription
.split("\n")
@@ -506,7 +527,7 @@ export function FeedNoteTemplateEngine(template: string, feed: PodcastFeed) {
export function FeedFilePathTemplateEngine(template: string, feed: PodcastFeed) {
const [replacer, addTag] = useTemplateEngine();
- const nameTag = legalizedNameTag(feed.title);
+ const nameTag = legalizedNameTag(withoutFeedAddresses(feed).title);
addTag("title", nameTag);
addTag("podcast", nameTag);
addTag("date", (format?: string) => formatDate(new Date(), format ?? "YYYY-MM-DD"));
diff --git a/src/createPodcastNote.privacy.test.ts b/src/createPodcastNote.privacy.test.ts
new file mode 100644
index 00000000..d49a4370
--- /dev/null
+++ b/src/createPodcastNote.privacy.test.ts
@@ -0,0 +1,190 @@
+// @vitest-environment jsdom
+import { SecretStorage, TFile } from "obsidian";
+import { afterEach, expect, test } from "vitest";
+import { DEFAULT_SETTINGS } from "./constants";
+import { createPodcastNoteFileIfNotExists } from "./createPodcastNote";
+import { FeedUrlRepository } from "./services/FeedUrlRepository";
+import FeedDocumentParser from "./parser/feedDocumentParser";
+import { plugin } from "./store";
+import {
+ FeedFilePathTemplateEngine,
+ FeedNoteTemplateEngine,
+ NoteTemplateEngine,
+ TranscriptTemplateEngine,
+} from "./TemplateEngine";
+import type { Episode } from "./types/Episode";
+import buildEpisodeResumeLink from "./utility/buildEpisodeResumeLink";
+import { formatEpisodeTimestamp } from "./utility/formatEpisodeTimestamp";
+
+const address = "https://private.example.com/rss?auth=NOTE_SENTINEL";
+const placeholder = "podnotes-private-feed:Paid";
+const episode: Episode = {
+ title: "Private episode",
+ podcastName: "Paid",
+ feedUrl: address,
+ guid: "episode-1",
+ url: "https://private.example.com/episode?token=EPISODE_TOKEN",
+ streamUrl: "https://private.example.com/audio.mp3?token=MEDIA_SIGNATURE",
+ description: `Your feed: ${address}.`,
+ content: "",
+};
+
+function noteVault(subscribed: boolean, artworkUrl = "") {
+ const written: Array<{ path: string; content: string }> = [];
+ const feedUrls = new FeedUrlRepository(new SecretStorage());
+ const urlSecretId = feedUrls.store(address);
+ plugin.set({
+ feedUrls,
+ settings: {
+ ...structuredClone(DEFAULT_SETTINGS),
+ savedFeeds: subscribed
+ ? { Paid: { title: "Paid", url: placeholder, urlSecretId, artworkUrl } }
+ : {},
+ },
+ app: {
+ vault: {
+ getAbstractFileByPath: () => null,
+ getMarkdownFiles: () => [],
+ getRoot: () => null,
+ createFolder: async () => {},
+ create: async (path: string, content: string) => {
+ written.push({ path, content });
+ return Object.assign(new TFile(), { path });
+ },
+ },
+ metadataCache: { getFileCache: () => null },
+ },
+ } as never);
+ return written;
+}
+
+afterEach(() => plugin.set(undefined as never));
+
+test.each([
+ address,
+ "https://private.example.com./rss?auth=NOTE_SENTINEL",
+ "https:\\\\private.example.com/rss?auth=NOTE_SENTINEL",
+])("a newly written note redacts a subscribed feed quoted as %s", async (quoted) => {
+ const written = noteVault(true);
+ await createPodcastNoteFileIfNotExists({
+ ...episode,
+ feedUrl: placeholder,
+ description: `Your feed: ${quoted}.`,
+ });
+ expect(written).toHaveLength(1);
+ expect(written[0].content).toContain("Your feed: podnotes-private-feed:Paid.");
+ expect(written[0].content).toContain(episode.url);
+ expect(written[0].content).toContain('feedUrl: "podnotes-private-feed:Paid"');
+ expect(written[0].content).toContain(
+ "[Resume in PodNotes](obsidian://podnotes?episodeName=Private%20episode&url=podnotes-private-feed%3APaid&guid=episode-1)",
+ );
+ expect(written[0].content).not.toContain("NOTE_SENTINEL");
+});
+
+test("an unsubscribed private episode writes no feed address and keeps its live URL", async () => {
+ const written = noteVault(false);
+ await createPodcastNoteFileIfNotExists(episode);
+ expect(written).toHaveLength(1);
+ expect(written[0].content).toContain("# Private episode");
+ expect(written[0].content).toContain(episode.url);
+ expect(written[0].content).not.toContain("NOTE_SENTINEL");
+ expect(written[0].content).not.toMatch(/^feedUrl:/m);
+ expect(episode.feedUrl).toBe(address);
+});
+
+test.each([
+ address,
+ "https://user:API_SENTINEL@private.example.com/rss",
+ "https://priv3.supercast.com/feeds/API_SENTINEL/feed.xml",
+ "https://subscribers.transistor.fm/API_SENTINEL",
+])("the public resume-link builder refuses an unsubscribed private feed: %s", (feedUrl) => {
+ noteVault(false);
+ expect(buildEpisodeResumeLink({ ...episode, feedUrl })).toBe("");
+});
+
+test.each([
+ ["title", { title: `Feed ${address}` }, "PodNotes/Paid/Feed podnotes-private-feedPaid.md"],
+ ["GUID", { guid: `${address}#1` }, "PodNotes/Paid/Private episode.md"],
+])(
+ "an episode whose %s quotes a subscribed feed gets no link or identity",
+ async (_, quoting, path) => {
+ const written = noteVault(true);
+ const quoted: Episode = { ...episode, feedUrl: placeholder, ...quoting };
+ expect(formatEpisodeTimestamp(quoted, 65, "HH:mm:ss", true)).toBe("00:01:05");
+ expect(formatEpisodeTimestamp(quoted, 65, "HH:mm:ss", true, 70)).toBe("00:01:05-00:01:10");
+ expect(buildEpisodeResumeLink(quoted)).toBe("");
+ await createPodcastNoteFileIfNotExists(quoted);
+ expect(written).toHaveLength(1);
+ expect(written[0].path).toBe(path);
+ expect(written[0].content).toContain("[Resume in PodNotes]()");
+ expect(written[0].content).not.toMatch(/^(guid|feedUrl):/m);
+ expect(written[0].content).not.toContain("NOTE_SENTINEL");
+ },
+);
+
+test.each([`Your feed: ${address.replace("_", "_")}`, `Your feed: <${address}>`])(
+ "a description that spells a subscribed feed with entities is redacted: %s",
+ async (html) => {
+ const written = noteVault(true);
+ const rss = `Paid- Private episode${html}
]]>`;
+ const [item] = new FeedDocumentParser().parseEpisodes(rss).episodes;
+ await createPodcastNoteFileIfNotExists({
+ ...item,
+ url: episode.url,
+ podcastName: "Paid",
+ feedUrl: placeholder,
+ });
+ expect(written).toHaveLength(1);
+ expect(written[0].content).toContain("Your feed: ");
+ expect(written[0].content).toContain("podnotes-private-feed:Paid");
+ expect(written[0].content).not.toContain("NOTE_SENTINEL");
+ },
+);
+
+test.each([
+ [address, ""],
+ [
+ "https://cdn.example.com/art.jpg?token=ART_SIGNATURE",
+ "https://cdn.example.com/art.jpg?token=ART_SIGNATURE",
+ ],
+])("{{feedartwork}} for saved artwork %s renders %j", (artworkUrl, expected) => {
+ noteVault(true, artworkUrl);
+ expect(NoteTemplateEngine("{{feedartwork}}", { ...episode, feedUrl: placeholder })).toBe(
+ expected,
+ );
+});
+
+test("a template that quotes a subscribed feed renders its placeholder", () => {
+ noteVault(true);
+ expect(
+ NoteTemplateEngine(`Feed: ${address}\n{{title}}`, { ...episode, feedUrl: placeholder }),
+ ).toBe("Feed: podnotes-private-feed:Paid\nPrivate episode");
+});
+
+test("chapters and transcripts that quote a subscribed feed render its placeholder", () => {
+ noteVault(true);
+ const quoted = `Your feed: ${address}.`;
+ const chapters = [{ startTime: 0, title: quoted }];
+ expect(NoteTemplateEngine("{{chapters}}", episode, { chapters })).toBe(
+ "- 0:00 Your feed: podnotes\\-private\\-feed:Paid\\.",
+ );
+ expect(TranscriptTemplateEngine("{{transcript}}", episode, quoted)).toBe(
+ "Your feed: podnotes-private-feed:Paid.",
+ );
+});
+
+test("a feed note for an unsubscribed private feed writes no feed address", () => {
+ noteVault(false);
+ const feed = {
+ title: "Paid",
+ url: address,
+ artworkUrl: "",
+ description: `Your feed: ${address}.`,
+ };
+ expect(FeedNoteTemplateEngine("{{feedurl}}|{{description}}", feed)).toBe(
+ "|Your feed: podnotes-private-feed:.",
+ );
+ expect(FeedFilePathTemplateEngine("{{title}}", { ...feed, title: `Paid ${address}` })).toBe(
+ "Paid podnotes-private-feed",
+ );
+});
diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts
index feb08a33..d2041d58 100644
--- a/src/createPodcastNote.ts
+++ b/src/createPodcastNote.ts
@@ -16,6 +16,7 @@ import { fetchChapters } from "./utility/fetchChapters";
import { findUniqueTitleMatch } from "./utility/episodeTitleMatch";
import { findByGuid } from "./utility/findByGuid";
import { addFrontmatterFields } from "./utility/noteFrontmatter";
+import { vaultSafeLinkTarget } from "./services/privateFeeds";
/**
* Resolve the on-disk path of an episode's note from the configured template.
@@ -100,11 +101,12 @@ export async function createPodcastNoteFileIfNotExists(
if (arrivedFile) return { file: arrivedFile, created: false };
const content = NoteTemplateEngine(template, episode, { chapters });
+ const feedUrl = vaultSafeLinkTarget(episode, episode.feedUrl);
const identified =
- episode.guid && episode.feedUrl
+ episode.guid && feedUrl
? addFrontmatterFields(content, [
["guid", episode.guid],
- ["feedUrl", episode.feedUrl],
+ ["feedUrl", feedUrl],
])
: content;
diff --git a/src/services/TranscriptionService.published.test.ts b/src/services/TranscriptionService.published.test.ts
index fa324bea..a7d878af 100644
--- a/src/services/TranscriptionService.published.test.ts
+++ b/src/services/TranscriptionService.published.test.ts
@@ -309,4 +309,40 @@ describe("published transcripts", () => {
);
}
});
+
+ test.each([
+ [
+ "timed",
+ { url: "1.vtt", type: "text/vtt" },
+ (feed: string) =>
+ `WEBVTT\n\n00:00:00.480 --> 00:00:04.410\nYour feed: ${feed}\n`,
+ "# Test Episode\n\n00:00:00 **Host podnotes-private-feed:Test\\%20Podcast:** Your feed: podnotes-private-feed:Test\\%20Podcast",
+ ],
+ [
+ "untimed",
+ { url: "1.txt", type: "text/plain" },
+ (feed: string) => `Your feed: ${feed}`,
+ "# Test Episode\n\nYour feed: podnotes-private-feed:Test\\%20Podcast",
+ ],
+ ])(
+ "a %s transcript quoting the episode's private feed keeps no address",
+ async (_, link, body, note) => {
+ const secretFeed = "https://private.example.com/rss?auth=REVIEW%2FSENTINEL";
+ fetchTextMock.mockResolvedValue(body(secretFeed));
+ const plugin = createMockPlugin({
+ openAIKey: "",
+ podcast: { ...mockEpisode, feedUrl: secretFeed },
+ transcripts: [link],
+ });
+ const service = new TranscriptionService(plugin);
+
+ await service.transcribeCurrentEpisode();
+ await finished(service, "Saved the transcript the feed publishes.");
+
+ expect(plugin.app.vault.create).toHaveBeenCalledExactlyOnceWith(
+ "Transcripts/Test Podcast/Test Episode.md",
+ note,
+ );
+ },
+ );
});
diff --git a/src/services/privateFeeds.ts b/src/services/privateFeeds.ts
index 83b0fa14..1eadd5c4 100644
--- a/src/services/privateFeeds.ts
+++ b/src/services/privateFeeds.ts
@@ -1,4 +1,7 @@
import { Notice } from "obsidian";
+import { get } from "svelte/store";
+import { plugin } from "src/store";
+import type { Episode } from "src/types/Episode";
import type { IPodNotesSettings } from "src/types/IPodNotesSettings";
import type { PodcastFeed } from "src/types/PodcastFeed";
import { ownRecord } from "src/utility/ownRecord";
@@ -167,3 +170,26 @@ export function privateFeedAddresses(
}
return addresses;
}
+
+export function withoutPrivateFeedAddresses(value: T): T {
+ const instance = get(plugin);
+ return redactPrivateFeedAddresses(
+ value,
+ instance?.feedUrls && privateFeedAddresses(instance.settings.savedFeeds, instance.feedUrls),
+ );
+}
+
+/**
+ * `target` when a note may address `episode` by it, so a PodNotes link or note
+ * identity can carry it with the episode's title and GUID. Undefined when the
+ * target is a private feed's own address, or any of them quotes one, because a
+ * redacted title or GUID would no longer find the episode.
+ */
+export function vaultSafeLinkTarget(
+ episode: Pick,
+ target: string | undefined,
+): string | undefined {
+ if (!target || isCredentialBearingUrl(target)) return undefined;
+ const fields = [target, episode.title, episode.guid];
+ return withoutPrivateFeedAddresses(fields) === fields ? target : undefined;
+}
diff --git a/src/services/publishedTranscript/render.test.ts b/src/services/publishedTranscript/render.test.ts
index 77c669ae..18dbfde7 100644
--- a/src/services/publishedTranscript/render.test.ts
+++ b/src/services/publishedTranscript/render.test.ts
@@ -43,6 +43,22 @@ describe("renderTranscriptBody", () => {
);
});
+ test("plain times for a private feed this device has not saved", () => {
+ expect(
+ renderTranscriptBody(
+ { kind: "timed", cues },
+ {
+ episode: {
+ ...episode,
+ feedUrl: "https://pod.example.com/feed.xml?auth=TRANSCRIPT_SENTINEL",
+ },
+ speakerTemplate: "**{{speaker}}:** ",
+ timestamp: timestampStyle("- {{linktime}} "),
+ },
+ ),
+ ).toBe("00:00:00 **Justin:** Hello there. Still Justin.\n\n00:00:10 **Michael:** Hi!");
+ });
+
test("plain times when the timestamp template asks for {{time}}", () => {
expect(render("{{time}} ")).toBe(
"00:00:00 **Justin:** Hello there. Still Justin.\n\n00:00:10 **Michael:** Hi!",
diff --git a/src/services/publishedTranscript/render.ts b/src/services/publishedTranscript/render.ts
index ba65318a..27f30d60 100644
--- a/src/services/publishedTranscript/render.ts
+++ b/src/services/publishedTranscript/render.ts
@@ -1,4 +1,5 @@
import type { Episode } from "src/types/Episode";
+import { withoutPrivateFeedAddresses } from "src/services/privateFeeds";
import { DEFAULT_SPEAKER_TEMPLATE, formatSpeakerLabel } from "src/services/diarization/segments";
import { formatEpisodeTimestamp } from "src/utility/formatEpisodeTimestamp";
import { collapseWhitespace, type ParsedTranscript, type TranscriptCue } from "./parse";
@@ -97,9 +98,11 @@ function splitText(text: string, max: number): string[] {
}
export function renderTranscriptBody(
- transcript: ParsedTranscript,
+ parsed: ParsedTranscript,
options: { episode: Episode; speakerTemplate: string; timestamp: TimestampStyle },
): string {
+ // Escaping puts backslashes inside an address, so redact before it.
+ const [, transcript] = withoutPrivateFeedAddresses([options.episode, parsed] as const);
if (transcript.kind === "untimed") {
return transcript.paragraphs
.flatMap((text) => splitText(escapeTranscriptText(text), MAX_PARAGRAPH_LENGTH))
diff --git a/src/utility/buildEpisodeResumeLink.test.ts b/src/utility/buildEpisodeResumeLink.test.ts
index 58f22898..bdc92233 100644
--- a/src/utility/buildEpisodeResumeLink.test.ts
+++ b/src/utility/buildEpisodeResumeLink.test.ts
@@ -97,6 +97,17 @@ describe("buildEpisodeResumeLink (#35)", () => {
expect(parsed.searchParams.has("time")).toBe(false);
});
+ test("omits an unsaved private feed link even when a download exists", () => {
+ const privateEpisode: Episode = {
+ ...remoteEpisode,
+ feedUrl: "https://pod.example.com/feed.xml?auth=LINK_SENTINEL",
+ };
+
+ expect(buildEpisodeResumeLink(privateEpisode)).toBe("");
+ downloadedEpisodes.addEpisode(privateEpisode, "Downloads/ep1.mp3", 1);
+ expect(buildEpisodeResumeLink(privateEpisode)).toBe("");
+ });
+
test("returns an empty string when there is no feed URL or file path to address", () => {
const orphan: Episode = { ...remoteEpisode, feedUrl: undefined };
diff --git a/src/utility/buildEpisodeResumeLink.ts b/src/utility/buildEpisodeResumeLink.ts
index ea24c524..8102e72c 100644
--- a/src/utility/buildEpisodeResumeLink.ts
+++ b/src/utility/buildEpisodeResumeLink.ts
@@ -2,6 +2,7 @@ import type { Episode } from "src/types/Episode";
import { downloadedEpisodes } from "src/store";
import encodePodnotesURI from "./encodePodnotesURI";
import { episodeLinkTarget } from "./episodeLinkTarget";
+import { vaultSafeLinkTarget } from "src/services/privateFeeds";
/**
* Build an `obsidian://podnotes` deep link that reopens `episode` in the PodNotes
@@ -14,11 +15,14 @@ import { episodeLinkTarget } from "./episodeLinkTarget";
* (the player refetches the feed and matches by title); a local file is
* addressed by its on-disk vault path. As a last resort either kind falls back
* to a downloaded copy's path, so an episode whose snapshot predates `feedUrl`
- * still gets a working link instead of none. Returns "" only when nothing can
- * address it, so a template degrades to an empty value rather than a broken link.
+ * still gets a working link instead of none. Returns "" when nothing can address the
+ * episode, or the link would carry a private feed address (see vaultSafeLinkTarget).
*/
export default function buildEpisodeResumeLink(episode: Episode): string {
- const target = episodeLinkTarget(episode) ?? downloadedEpisodes.getEpisode(episode)?.filePath;
+ const target = vaultSafeLinkTarget(
+ episode,
+ episodeLinkTarget(episode) ?? downloadedEpisodes.getEpisode(episode)?.filePath,
+ );
// URIHandler rejects an empty episodeName, so without a title there is no
// working link to emit: degrade to "" rather than a dead link (UR-03).
diff --git a/src/utility/formatEpisodeTimestamp.test.ts b/src/utility/formatEpisodeTimestamp.test.ts
index 9f5f7c13..192f10d9 100644
--- a/src/utility/formatEpisodeTimestamp.test.ts
+++ b/src/utility/formatEpisodeTimestamp.test.ts
@@ -23,6 +23,29 @@ describe("formatEpisodeTimestamp", () => {
expect(formatEpisodeTimestamp(episode, 65.9, "mm:ss", false)).toBe("01:05");
});
+ test("renders the plain time for a private feed this device has not saved", () => {
+ const feedUrl = "https://pod.example.com/feed.xml?auth=TIMESTAMP_SENTINEL";
+ expect(formatEpisodeTimestamp({ ...episode, feedUrl }, 65, "HH:mm:ss", true)).toBe(
+ "00:01:05",
+ );
+ expect(formatEpisodeTimestamp({ ...episode, feedUrl }, 65, "HH:mm:ss", true, 70)).toBe(
+ "00:01:05-00:01:10",
+ );
+ });
+
+ test("links a saved private feed by its placeholder", () => {
+ expect(
+ formatEpisodeTimestamp(
+ { ...episode, feedUrl: "podnotes-private-feed:Feed%20Podcast" },
+ 65,
+ "HH:mm:ss",
+ true,
+ ),
+ ).toBe(
+ "[00:01:05](obsidian://podnotes?episodeName=Pilot%20%2B%20Q%26A&url=podnotes-private-feed%3AFeed%2520Podcast&time=65)",
+ );
+ });
+
test("renders the plain time for an episode PodNotes cannot link to", () => {
expect(
formatEpisodeTimestamp({ ...episode, feedUrl: undefined }, 65, "HH:mm:ss", true),
diff --git a/src/utility/formatEpisodeTimestamp.ts b/src/utility/formatEpisodeTimestamp.ts
index d481ae47..e2785214 100644
--- a/src/utility/formatEpisodeTimestamp.ts
+++ b/src/utility/formatEpisodeTimestamp.ts
@@ -3,6 +3,7 @@ import encodePodnotesURI from "./encodePodnotesURI";
import { episodeLinkTarget } from "./episodeLinkTarget";
import { formatSeconds } from "./formatSeconds";
import { formatPodcastSegment } from "./podcastSegment";
+import { vaultSafeLinkTarget } from "src/services/privateFeeds";
export function formatEpisodeTimestamp(
episode: Episode,
@@ -15,7 +16,7 @@ export function formatEpisodeTimestamp(
endSeconds === undefined
? formatSeconds(seconds, format)
: formatPodcastSegment(seconds, endSeconds, format);
- const target = linkify ? episodeLinkTarget(episode) : undefined;
+ const target = linkify ? vaultSafeLinkTarget(episode, episodeLinkTarget(episode)) : undefined;
return target
? `[${time}](${encodePodnotesURI(episode, target, seconds, endSeconds).href})`
: time;
diff --git a/src/utility/privateFeedUrl.test.ts b/src/utility/privateFeedUrl.test.ts
index 91b5b328..2a20a0bc 100644
--- a/src/utility/privateFeedUrl.test.ts
+++ b/src/utility/privateFeedUrl.test.ts
@@ -125,6 +125,44 @@ describe("isCredentialBearingUrl for publishers that put the secret in the addre
});
describe("redactPrivateFeedAddresses", () => {
+ it.each([
+ "https://private.example.com./rss?auth=ALIAS_SENTINEL",
+ "https:\\\\private.example.com/rss?auth=ALIAS_SENTINEL",
+ "https:/\\private.example.com/rss?auth=ALIAS_SENTINEL",
+ "https:///%70rivate.example.com/rss?auth=ALIAS_SENTINEL",
+ "https:/private.example.com/rss?auth=ALIAS_SENTINEL",
+ "https:\\private.example.com/rss?auth=ALIAS_SENTINEL",
+ "HTTPS:private.example.com/rss?auth=ALIAS_SENTINEL",
+ ])("redacts standalone and quoted browser aliases: %s", (alias) => {
+ const known = new Map([
+ ["https://private.example.com/rss?auth=ALIAS_SENTINEL", "podnotes-private-feed:Paid"],
+ ]);
+ const document = {
+ standalone: alias,
+ quoted: `Your feed: ${alias}.`,
+ episode: "https://private.example.com/episode?auth=EPISODE_TOKEN",
+ media: "https://private.example.com/audio.mp3?token=MEDIA_SIGNATURE",
+ };
+ expect(redactPrivateFeedAddresses(document, known)).toEqual({
+ ...document,
+ standalone: "podnotes-private-feed:Paid",
+ quoted: "Your feed: podnotes-private-feed:Paid.",
+ });
+ });
+
+ it("redacts an address an HTML attribute quotes with &", () => {
+ const known = new Map([
+ [
+ "https://private.example.com/rss?auth=HTML_SENTINEL&show=1",
+ "podnotes-private-feed:Paid",
+ ],
+ ]);
+ const html = '';
+ expect(redactPrivateFeedAddresses({ description: html }, known)).toEqual({
+ description: '',
+ });
+ });
+
it.each([
["https://private.example.com", "https://%70rivate.example.com"],
["https://xn--bcher-kva.example.com", "https://bücher.example.com"],
diff --git a/src/utility/privateFeedUrl.ts b/src/utility/privateFeedUrl.ts
index 2b06311b..ca407498 100644
--- a/src/utility/privateFeedUrl.ts
+++ b/src/utility/privateFeedUrl.ts
@@ -91,12 +91,11 @@ export function parsePrivateFeedPlaceholder(value: string): string | null {
}
}
-/**
- * `url` as a request sends it, so the scheme and host in any case, and with or
- * without a default port, name the same feed. As given when it is not a URL.
- */
export function canonicalUrl(url: string): string {
- return checkStringIsUrl(url)?.href ?? url;
+ const parsed = checkStringIsUrl(url);
+ if (!parsed) return url;
+ parsed.hostname = parsed.hostname.replace(/\.$/, "");
+ return parsed.href;
}
/**
@@ -140,20 +139,22 @@ export function redactPrivateFeedAddresses(
// plugin, so keep this untagged to let Obsidian reuse it across reloads.
const candidate = new RegExp(
hosts.map((host) => host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|") +
- "|https?:\\/\\/[^\\s/?#]*[%\\\\\\[\\u0080-\\uFFFF]|https?:\\/\\/(?:[^\\s/?#@]*@)?[\\d/\\\\]",
+ "|https?:[/\\\\]*[^\\s/?#]*[%\\\\\\[\\u0080-\\uFFFF]|https?:[/\\\\]*(?:[^\\s/?#@]*@)?\\d",
"i",
);
const matches = new Map();
const lookup = (address: string) => {
if (matches.has(address)) return matches.get(address);
- const replacement = placeholders.get(canonicalUrl(address).split("#")[0]);
+ const replacement = placeholders.get(
+ canonicalUrl(address.replace(/&/gi, "&")).split("#")[0],
+ );
matches.set(address, replacement);
return replacement;
};
return replaceStrings(document, (value) => {
- if (!value.includes("://") || !candidate.test(value)) return value;
- if (/^https?:\/\/[^\s<>"'`]+$/i.test(value)) return lookup(value) ?? value;
- return value.replace(/https?:\/\/[^\s<>"'`]+/gi, (address) => {
+ if (!/https?:/i.test(value) || !candidate.test(value)) return value;
+ if (/^https?:[^\s<>"'`]+$/i.test(value)) return lookup(value) ?? value;
+ return value.replace(/https?:[^\s<>"'`]+/gi, (address) => {
const whole = lookup(address);
if (whole) return whole;
const bare = address.replace(/[.,;:!?)\]}]+$/, "");
diff --git a/tests/e2e/private-notes.test.ts b/tests/e2e/private-notes.test.ts
new file mode 100644
index 00000000..26e52490
--- /dev/null
+++ b/tests/e2e/private-notes.test.ts
@@ -0,0 +1,195 @@
+import { expect, onTestFinished, test } from "vitest";
+import { SUPERCAST_FIXTURE_HOST } from "../../scripts/e2e-instance.mjs";
+import { createPodNotesE2EHarness } from "./harness";
+import {
+ click,
+ closeSettings,
+ openInReadingView,
+ openPodNotesSettings,
+ runCommand,
+ showAddPodcast,
+ showLibrary,
+ showPodNotes,
+ typeInto,
+ ui,
+} from "./journey/actions";
+import { type FixtureFeed, startFixtureServer } from "./journey/fixtures";
+import {
+ feedCacheText,
+ readVaultText,
+ seedLibrary,
+ waitForLibrary,
+ waitForPlayback,
+ writeVaultFile,
+} from "./journey/library";
+import { jsLiteral, RELOAD_OPTIONS } from "./runtime";
+
+const getContext = createPodNotesE2EHarness("private-notes");
+
+test("private descriptions and aliases stay out of cold and warm-cache episode notes", async () => {
+ const context = getContext();
+ const { obsidian, plugin, sandbox } = context;
+ const episodes = ["Cold episode", "Warm episode"].map((title) => ({
+ title,
+ seconds: 60,
+ description: "",
+ guid: title,
+ }));
+ const show: FixtureFeed = {
+ title: "Quoted Private",
+ slug: "quoted-private",
+ host: SUPERCAST_FIXTURE_HOST,
+ path: "/feeds/QUOTED_NOTE_SENTINEL/feed.xml",
+ artwork: false,
+ episodes,
+ };
+ const server = await startFixtureServer([show]);
+ onTestFinished(() => server.stop());
+ const address = server.feedUrl(show);
+ const dotted = address.replace(".com:", ".com.:");
+ const backslashes = address.replace("http://", "http:\\\\");
+ for (const episode of episodes)
+ episode.description = `Your feed: ${address}. Aliases: ${dotted}. ${backslashes}.`;
+ server.retitle(show, episodes[0], episodes[0].title);
+ await seedLibrary(context, server);
+ await showPodNotes(obsidian);
+ await showAddPodcast(obsidian);
+ await typeInto(obsidian, ui.podcastSearch, address);
+ await click(obsidian, ui.addPodcast(show.title));
+ await waitForLibrary(plugin, (library) => library.subscriptions.length === 1, "subscribed");
+ const secretId = await obsidian.dev.evalJson(
+ 'app.plugins.plugins.podnotes.settings.savedFeeds["Quoted Private"].urlSecretId',
+ );
+ onTestFinished(() =>
+ obsidian.dev.evalJson(`app.secretStorage.setSecret(${jsLiteral(secretId)}, ""), true`),
+ );
+
+ let coldFetches = 0;
+ for (const [index, episode] of episodes.entries()) {
+ if (index) await plugin.reload(RELOAD_OPTIONS);
+ await showPodNotes(obsidian);
+ await showLibrary(obsidian);
+ await click(obsidian, ui.podcastCard(show.title));
+ await click(obsidian, ui.episode(episode.title));
+ await waitForPlayback(obsidian, (state) => state.playing && state.seconds > 0, "playing");
+ await click(obsidian, ui.playbackToggle);
+ await runCommand(obsidian, "podnotes:create-podcast-note");
+ const notePath = `PodNotes/${show.title}/${episode.title}.md`;
+ await sandbox.waitForExists(notePath, { timeoutMs: 10_000 });
+ const note = await obsidian.waitFor(
+ async () => {
+ const text = await readVaultText(obsidian, sandbox.path(notePath));
+ return text.includes("# " + episode.title) && text;
+ },
+ { timeoutMs: 10_000, intervalMs: 100, message: "note content written" },
+ );
+ expect(note).toContain("Your feed: podnotes-private-feed:Quoted%20Private.");
+ expect(note).toContain('feedUrl: "podnotes-private-feed:Quoted%20Private"');
+ expect(note).not.toContain("QUOTED_NOTE_SENTINEL");
+ const fetches = server.requests().filter((path) => path === show.path).length;
+ if (index) expect(fetches).toBe(coldFetches);
+ else coldFetches = fetches;
+ }
+ expect(Object.keys(JSON.parse(await feedCacheText(obsidian)))).toEqual([
+ "podnotes-private-feed:Quoted%20Private",
+ ]);
+});
+
+test("hostname and slash aliases are redacted from persisted templates and the settings export", async () => {
+ const context = getContext();
+ const { obsidian, plugin, sandbox } = context;
+ await seedLibrary(context, null);
+ await plugin.updateDataAndReload>((data) => {
+ data.savedFeeds = {
+ Private: {
+ title: "Private",
+ url: "https://private.example.com/rss?auth=EXPORT_ALIAS_SENTINEL",
+ artworkUrl: "",
+ },
+ };
+ data.note = {
+ path: sandbox.path("{{title}}.md"),
+ template: [
+ "https://private.example.com./rss?auth=EXPORT_ALIAS_SENTINEL",
+ "Quoted https:\\\\private.example.com/rss?auth=EXPORT_ALIAS_SENTINEL.",
+ "Quoted https:///%70rivate.example.com/rss?auth=EXPORT_ALIAS_SENTINEL.",
+ "https://private.example.com/audio.mp3?token=MEDIA_SIGNATURE",
+ ].join("\n"),
+ };
+ }, RELOAD_OPTIONS);
+ const secretId = await obsidian.dev.evalJson(
+ "app.plugins.plugins.podnotes.settings.savedFeeds.Private.urlSecretId",
+ );
+ onTestFinished(() =>
+ obsidian.dev.evalJson(`app.secretStorage.setSecret(${jsLiteral(secretId)}, ""), true`),
+ );
+ const persisted = JSON.stringify(await plugin.data().read());
+ expect(persisted).not.toContain("EXPORT_ALIAS_SENTINEL");
+ expect(persisted).toContain("MEDIA_SIGNATURE");
+ await openPodNotesSettings(obsidian);
+ const name = "private-settings.json";
+ await typeInto(obsidian, ui.settingControl("Export settings", "input"), sandbox.path(name), {
+ replace: true,
+ });
+ await click(obsidian, ui.settingControl("Export settings", "button"));
+ await sandbox.waitForExists(name, { timeoutMs: 10_000 });
+ await closeSettings(obsidian);
+ const exported = await readVaultText(obsidian, sandbox.path(name));
+ expect(exported).toContain("podnotes-private-feed:Private");
+ expect(exported).toContain("MEDIA_SIGNATURE");
+ expect(exported).not.toContain("EXPORT_ALIAS_SENTINEL");
+});
+
+test("an unsubscribed private link plays, creates a secret-free note and captures plain timestamps", async () => {
+ const context = getContext();
+ const { obsidian, sandbox } = context;
+ const episode = { title: "Unsubscribed episode", seconds: 60, guid: "unsubscribed-1" };
+ const show: FixtureFeed = {
+ title: "Unsubscribed Private",
+ slug: "unsubscribed-private",
+ host: SUPERCAST_FIXTURE_HOST,
+ path: "/feeds/UNSUBSCRIBED_NOTE_SENTINEL/feed.xml",
+ artwork: false,
+ episodes: [episode],
+ };
+ const server = await startFixtureServer([show]);
+ onTestFinished(() => server.stop());
+ await seedLibrary(context, server);
+ const address = server.feedUrl(show);
+ const uri = `obsidian://podnotes?episodeName=${encodeURIComponent(episode.title)}&url=${encodeURIComponent(address)}&time=12`;
+ const legacyPath = sandbox.path("legacy-link.md");
+ await writeVaultFile(obsidian, legacyPath, `[Play private episode](${uri})\n`);
+ await openInReadingView(obsidian, legacyPath);
+ await click(obsidian, ui.readingLink("Play private episode"));
+ await waitForPlayback(obsidian, (state) => state.playing && state.seconds >= 12, "URI playing");
+ await runCommand(obsidian, "podnotes:create-podcast-note");
+ const notePath = `PodNotes/${show.title}/${episode.title}.md`;
+ await sandbox.waitForExists(notePath, { timeoutMs: 10_000 });
+ await obsidian.dev.evalJsonAsync(
+ '(async () => { await app.workspace.detachLeavesOfType("markdown"); return true; })()',
+ );
+ await runCommand(obsidian, "podnotes:capture-timestamp");
+ await obsidian.waitFor(
+ async () => /- 00:00:\d\d/.test(await readVaultText(obsidian, sandbox.path(notePath))),
+ { timeoutMs: 10_000, intervalMs: 100, message: "timestamp written" },
+ );
+ await runCommand(obsidian, "podnotes:capture-segment-10s");
+ await obsidian.waitFor(
+ async () =>
+ /- 00:00:\d\d-00:00:\d\d/.test(await readVaultText(obsidian, sandbox.path(notePath))),
+ { timeoutMs: 10_000, intervalMs: 100, message: "plain timestamp and segment saved" },
+ );
+ const note = await readVaultText(obsidian, sandbox.path(notePath));
+ expect(note).toContain("# Unsubscribed episode");
+ expect(note).not.toContain("UNSUBSCRIBED_NOTE_SENTINEL");
+ expect(note).not.toContain("obsidian://podnotes");
+ expect(note).not.toMatch(/^feedUrl:/m);
+ expect(
+ await obsidian.dev.evalJson("app.plugins.plugins.podnotes.api.podcast.feedUrl"),
+ ).toBe(address);
+ await waitForPlayback(
+ obsidian,
+ (state) => state.playing && state.seconds > 12,
+ "still playing",
+ );
+});