From 5589e15b253f5498056ce2f61a6480db28c34b8f Mon Sep 17 00:00:00 2001
From: Christian Bager Bach Houmann
Date: Tue, 13 Oct 2026 14:47:20 +0200
Subject: [PATCH 1/6] test(privacy): reproduce private feed leaks in generated
notes and aliases
---
src/createPodcastNote.privacy.test.ts | 92 +++++++++++++++++++++++++++
src/utility/privateFeedUrl.test.ts | 22 +++++++
2 files changed, 114 insertions(+)
create mode 100644 src/createPodcastNote.privacy.test.ts
diff --git a/src/createPodcastNote.privacy.test.ts b/src/createPodcastNote.privacy.test.ts
new file mode 100644
index 00000000..16b5a2f8
--- /dev/null
+++ b/src/createPodcastNote.privacy.test.ts
@@ -0,0 +1,92 @@
+// @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 { plugin } from "./store";
+import type { Episode } from "./types/Episode";
+import buildEpisodeResumeLink from "./utility/buildEpisodeResumeLink";
+
+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) {
+ 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).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("");
+});
diff --git a/src/utility/privateFeedUrl.test.ts b/src/utility/privateFeedUrl.test.ts
index 91b5b328..121a5dc5 100644
--- a/src/utility/privateFeedUrl.test.ts
+++ b/src/utility/privateFeedUrl.test.ts
@@ -125,6 +125,28 @@ 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",
+ ])("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.each([
["https://private.example.com", "https://%70rivate.example.com"],
["https://xn--bcher-kva.example.com", "https://bücher.example.com"],
From 2d5202cba49428ecb4d64d73a9cd447152ec6a90 Mon Sep 17 00:00:00 2001
From: Christian Bager Bach Houmann
Date: Tue, 13 Oct 2026 15:10:19 +0200
Subject: [PATCH 2/6] test(privacy): cover capture and transcript timestamp
leaks
---
src/API/API.test.ts | 13 +++++
src/createPodcastNote.privacy.test.ts | 48 +++++++++++++++++++
.../publishedTranscript/render.test.ts | 16 +++++++
src/utility/buildEpisodeResumeLink.test.ts | 13 +++++
src/utility/formatEpisodeTimestamp.test.ts | 23 +++++++++
src/utility/privateFeedUrl.test.ts | 13 +++++
6 files changed, 126 insertions(+)
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/createPodcastNote.privacy.test.ts b/src/createPodcastNote.privacy.test.ts
index 16b5a2f8..fd62adfc 100644
--- a/src/createPodcastNote.privacy.test.ts
+++ b/src/createPodcastNote.privacy.test.ts
@@ -5,6 +5,12 @@ import { DEFAULT_SETTINGS } from "./constants";
import { createPodcastNoteFileIfNotExists } from "./createPodcastNote";
import { FeedUrlRepository } from "./services/FeedUrlRepository";
import { plugin } from "./store";
+import {
+ FeedFilePathTemplateEngine,
+ FeedNoteTemplateEngine,
+ NoteTemplateEngine,
+ TranscriptTemplateEngine,
+} from "./TemplateEngine";
import type { Episode } from "./types/Episode";
import buildEpisodeResumeLink from "./utility/buildEpisodeResumeLink";
@@ -90,3 +96,45 @@ test.each([
noteVault(false);
expect(buildEpisodeResumeLink({ ...episode, feedUrl })).toBe("");
});
+
+test("a note's path, title and identity carry no subscribed feed address they quote", async () => {
+ const written = noteVault(true);
+ await createPodcastNoteFileIfNotExists({
+ ...episode,
+ feedUrl: placeholder,
+ title: `Feed ${address}`,
+ guid: `${address}#1`,
+ });
+ expect(written).toHaveLength(1);
+ expect(written[0].path).toBe("PodNotes/Paid/Feed podnotes-private-feedPaid.md");
+ expect(written[0].content).toContain('guid: "podnotes-private-feed:Paid"');
+ expect(written[0].content).not.toContain("NOTE_SENTINEL");
+});
+
+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/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/utility/buildEpisodeResumeLink.test.ts b/src/utility/buildEpisodeResumeLink.test.ts
index 58f22898..a3af816a 100644
--- a/src/utility/buildEpisodeResumeLink.test.ts
+++ b/src/utility/buildEpisodeResumeLink.test.ts
@@ -97,6 +97,19 @@ describe("buildEpisodeResumeLink (#35)", () => {
expect(parsed.searchParams.has("time")).toBe(false);
});
+ test("addresses an unsaved private feed's episode by its download, never its feed", () => {
+ 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(new URL(buildEpisodeResumeLink(privateEpisode)).searchParams.get("url")).toBe(
+ "Downloads/ep1.mp3",
+ );
+ });
+
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/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/privateFeedUrl.test.ts b/src/utility/privateFeedUrl.test.ts
index 121a5dc5..f56b3411 100644
--- a/src/utility/privateFeedUrl.test.ts
+++ b/src/utility/privateFeedUrl.test.ts
@@ -147,6 +147,19 @@ describe("redactPrivateFeedAddresses", () => {
});
});
+ 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"],
From 2b0e67c098c3f215aae85ad37de5725ffc5fa4e5 Mon Sep 17 00:00:00 2001
From: Christian Bager Bach Houmann
Date: Tue, 13 Oct 2026 15:10:19 +0200
Subject: [PATCH 3/6] fix(privacy): redact vault-bound metadata and private
feed links
---
src/TemplateEngine.ts | 59 +++++++++++++++++++++------
src/createPodcastNote.ts | 16 +++++---
src/utility/buildEpisodeResumeLink.ts | 5 ++-
src/utility/formatEpisodeTimestamp.ts | 3 +-
src/utility/privateFeedUrl.ts | 31 ++++++++++----
5 files changed, 87 insertions(+), 27 deletions(-)
diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts
index ed10df81..fdfec4a2 100644
--- a/src/TemplateEngine.ts
+++ b/src/TemplateEngine.ts
@@ -17,6 +17,8 @@ import type { PodcastSegmentTimes } from "./utility/podcastSegment";
import type { Chapter } from "./types/Chapter";
import { normalizeChapters } from "./utility/normalizeChapters";
import { isFetchableUrl } from "./utility/assertFetchableUrl";
+import { privateFeedAddresses } from "./services/privateFeeds";
+import { redactPrivateFeedAddresses, vaultSafeFeedUrl } from "./utility/privateFeedUrl";
// 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 +34,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 +48,28 @@ export function templateHasTag(template: string, tag: Lowercase): boolea
);
}
+/**
+ * `value` with each private feed address this device knows, and an episode's own
+ * credential-bearing feed, replaced by its placeholder. Templates read this copy,
+ * because escaping, Markdown conversion and file-name stripping would disguise
+ * an address from the matcher.
+ */
+export function withoutPrivateFeedAddresses(value: T): T {
+ const instance = get(plugin);
+ return redactPrivateFeedAddresses(
+ value,
+ instance?.feedUrls && privateFeedAddresses(instance.settings.savedFeeds, instance.feedUrls),
+ );
+}
+
+/**
+ * `feed` as notes may show it, with its own credential-bearing URL counted as an
+ * address. Its placeholder names no feed, since the title may quote the URL.
+ */
+function withoutFeedAddresses(feed: PodcastFeed): PodcastFeed {
+ return withoutPrivateFeedAddresses({ ...feed, feedUrl: feed.url });
+}
+
function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> {
const tags: Tags = {};
@@ -74,7 +98,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 +152,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,
@@ -279,10 +303,16 @@ function resolveEpisodeUrl(episode: Episode): string {
export function NoteTemplateEngine(
template: string,
- episode: Episode,
+ liveEpisode: Episode,
context: NoteTemplateContext = {},
) {
const [replacer, addTag] = useTemplateEngine();
+ const [redacted, chapters] = withoutPrivateFeedAddresses([
+ liveEpisode,
+ context.chapters,
+ ] as const);
+ // A placeholder made up for an unsaved feed names nothing, so such a feed gets no address.
+ const episode = { ...redacted, feedUrl: vaultSafeFeedUrl(liveEpisode.feedUrl) };
const episodeUrl = resolveEpisodeUrl(episode);
@@ -332,7 +362,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 ?? ""));
@@ -410,7 +440,7 @@ export function FilePathTemplateEngine(
) {
const [replacer, addTag] = useTemplateEngine();
- addEpisodeFileNameTags(addTag, episode, sanitizeFileName);
+ addEpisodeFileNameTags(addTag, withoutPrivateFeedAddresses(episode), sanitizeFileName);
return replacer(template);
}
@@ -431,17 +461,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) =>
@@ -464,8 +498,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,7 +515,7 @@ 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(vaultSafeFeedUrl(liveFeed.url) ?? ""));
addTag("artwork", sanitizePortableResourceUrl(feed.artworkUrl ?? ""));
addTag("feedartwork", sanitizePortableResourceUrl(feed.artworkUrl ?? ""));
addTag("author", escapeMarkdownBodyText(feed.author ?? ""));
@@ -506,7 +541,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.ts b/src/createPodcastNote.ts
index 8689ec17..c6ccbf02 100644
--- a/src/createPodcastNote.ts
+++ b/src/createPodcastNote.ts
@@ -4,6 +4,7 @@ import {
legacyReplaceIllegalFileNameCharactersInString,
NoteTemplateEngine,
templateHasTag,
+ withoutPrivateFeedAddresses,
} from "./TemplateEngine";
import type { Episode } from "./types/Episode";
import type { Chapter } from "./types/Chapter";
@@ -16,6 +17,7 @@ import { fetchChapters } from "./utility/fetchChapters";
import { findUniqueTitleMatch } from "./utility/episodeTitleMatch";
import { findByGuid } from "./utility/findByGuid";
import { addFrontmatterFields } from "./utility/noteFrontmatter";
+import { vaultSafeFeedUrl } from "./utility/privateFeedUrl";
/**
* Resolve the on-disk path of an episode's note from the configured template.
@@ -100,12 +102,16 @@ export async function createPodcastNoteFileIfNotExists(
if (arrivedFile) return { file: arrivedFile, created: false };
const content = NoteTemplateEngine(template, episode, { chapters });
+ const feedUrl = vaultSafeFeedUrl(episode.feedUrl);
const identified =
- episode.guid && episode.feedUrl
- ? addFrontmatterFields(content, [
- ["guid", episode.guid],
- ["feedUrl", episode.feedUrl],
- ])
+ episode.guid && feedUrl
+ ? addFrontmatterFields(
+ content,
+ withoutPrivateFeedAddresses([
+ ["guid", episode.guid],
+ ["feedUrl", feedUrl],
+ ]),
+ )
: content;
return createFileIfNotExists(filePathDotMd, identified);
diff --git a/src/utility/buildEpisodeResumeLink.ts b/src/utility/buildEpisodeResumeLink.ts
index ea24c524..a022df46 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 { vaultSafeFeedUrl } from "./privateFeedUrl";
/**
* Build an `obsidian://podnotes` deep link that reopens `episode` in the PodNotes
@@ -18,7 +19,9 @@ import { episodeLinkTarget } from "./episodeLinkTarget";
* address it, so a template degrades to an empty value rather than a broken link.
*/
export default function buildEpisodeResumeLink(episode: Episode): string {
- const target = episodeLinkTarget(episode) ?? downloadedEpisodes.getEpisode(episode)?.filePath;
+ const target =
+ vaultSafeFeedUrl(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.ts b/src/utility/formatEpisodeTimestamp.ts
index d481ae47..aea157d1 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 { vaultSafeFeedUrl } from "./privateFeedUrl";
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 ? vaultSafeFeedUrl(episodeLinkTarget(episode)) : undefined;
return target
? `[${time}](${encodePodnotesURI(episode, target, seconds, endSeconds).href})`
: time;
diff --git a/src/utility/privateFeedUrl.ts b/src/utility/privateFeedUrl.ts
index 2b06311b..cee6ecf6 100644
--- a/src/utility/privateFeedUrl.ts
+++ b/src/utility/privateFeedUrl.ts
@@ -92,11 +92,24 @@ 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.
+ * `url` as a request sends it, so the scheme and host in any case, with or
+ * without a default port or a terminal dot, 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;
+}
+
+/**
+ * `address` unless it is a credential-bearing feed URL. A saved private feed's
+ * episodes carry its placeholder, so such a URL belongs to a feed with no
+ * placeholder on this device, and notes and links get no address for it.
+ */
+export function vaultSafeFeedUrl(address: string | undefined): string | undefined {
+ return address && isCredentialBearingUrl(address) ? undefined : address;
}
/**
@@ -140,20 +153,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?:[/\\\\]{2}[^\\s/?#]*[%\\\\\\[\\u0080-\\uFFFF]|https?:[/\\\\]{2}(?:[^\\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 (!/:[/\\]{2}/.test(value) || !candidate.test(value)) return value;
+ if (/^https?:[/\\]{2}[^\s<>"'`]+$/i.test(value)) return lookup(value) ?? value;
+ return value.replace(/https?:[/\\]{2}[^\s<>"'`]+/gi, (address) => {
const whole = lookup(address);
if (whole) return whole;
const bare = address.replace(/[.,;:!?)\]}]+$/, "");
From 7f0e62616ce9d23ea15b4486df9b7ea675233b05 Mon Sep 17 00:00:00 2001
From: Christian Bager Bach Houmann
Date: Tue, 13 Oct 2026 15:20:23 +0200
Subject: [PATCH 4/6] fix(privacy): keep unsafe resume output empty and verify
note writes
---
src/TemplateEngine.ts | 17 +-
src/utility/buildEpisodeResumeLink.test.ts | 6 +-
src/utility/buildEpisodeResumeLink.ts | 10 +-
src/utility/privateFeedUrl.ts | 10 --
tests/e2e/private-notes.test.ts | 195 +++++++++++++++++++++
5 files changed, 205 insertions(+), 33 deletions(-)
create mode 100644 tests/e2e/private-notes.test.ts
diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts
index fdfec4a2..ad04a921 100644
--- a/src/TemplateEngine.ts
+++ b/src/TemplateEngine.ts
@@ -48,12 +48,6 @@ export function templateHasTag(template: string, tag: Lowercase): boolea
);
}
-/**
- * `value` with each private feed address this device knows, and an episode's own
- * credential-bearing feed, replaced by its placeholder. Templates read this copy,
- * because escaping, Markdown conversion and file-name stripping would disguise
- * an address from the matcher.
- */
export function withoutPrivateFeedAddresses(value: T): T {
const instance = get(plugin);
return redactPrivateFeedAddresses(
@@ -62,12 +56,8 @@ export function withoutPrivateFeedAddresses(value: T): T {
);
}
-/**
- * `feed` as notes may show it, with its own credential-bearing URL counted as an
- * address. Its placeholder names no feed, since the title may quote the URL.
- */
function withoutFeedAddresses(feed: PodcastFeed): PodcastFeed {
- return withoutPrivateFeedAddresses({ ...feed, feedUrl: feed.url });
+ return withoutPrivateFeedAddresses([feed, { feedUrl: feed.url }] as const)[0];
}
function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> {
@@ -311,8 +301,7 @@ export function NoteTemplateEngine(
liveEpisode,
context.chapters,
] as const);
- // A placeholder made up for an unsaved feed names nothing, so such a feed gets no address.
- const episode = { ...redacted, feedUrl: vaultSafeFeedUrl(liveEpisode.feedUrl) };
+ const episode = { ...redacted, feedUrl: liveEpisode.feedUrl };
const episodeUrl = resolveEpisodeUrl(episode);
@@ -372,7 +361,7 @@ 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 ?? ""));
+ addTag("feedurl", sanitizeUrlForTemplate(vaultSafeFeedUrl(episode.feedUrl) ?? ""));
const parentFeed = get(plugin)?.settings?.savedFeeds?.[episode.podcastName];
addTag(
"feedartwork",
diff --git a/src/utility/buildEpisodeResumeLink.test.ts b/src/utility/buildEpisodeResumeLink.test.ts
index a3af816a..bdc92233 100644
--- a/src/utility/buildEpisodeResumeLink.test.ts
+++ b/src/utility/buildEpisodeResumeLink.test.ts
@@ -97,7 +97,7 @@ describe("buildEpisodeResumeLink (#35)", () => {
expect(parsed.searchParams.has("time")).toBe(false);
});
- test("addresses an unsaved private feed's episode by its download, never its feed", () => {
+ 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",
@@ -105,9 +105,7 @@ describe("buildEpisodeResumeLink (#35)", () => {
expect(buildEpisodeResumeLink(privateEpisode)).toBe("");
downloadedEpisodes.addEpisode(privateEpisode, "Downloads/ep1.mp3", 1);
- expect(new URL(buildEpisodeResumeLink(privateEpisode)).searchParams.get("url")).toBe(
- "Downloads/ep1.mp3",
- );
+ expect(buildEpisodeResumeLink(privateEpisode)).toBe("");
});
test("returns an empty string when there is no feed URL or file path to address", () => {
diff --git a/src/utility/buildEpisodeResumeLink.ts b/src/utility/buildEpisodeResumeLink.ts
index a022df46..b51699d3 100644
--- a/src/utility/buildEpisodeResumeLink.ts
+++ b/src/utility/buildEpisodeResumeLink.ts
@@ -15,13 +15,13 @@ import { vaultSafeFeedUrl } from "./privateFeedUrl";
* (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 the target is a private feed
+ * without a subscription placeholder, or nothing can address the episode.
*/
export default function buildEpisodeResumeLink(episode: Episode): string {
- const target =
- vaultSafeFeedUrl(episodeLinkTarget(episode)) ??
- downloadedEpisodes.getEpisode(episode)?.filePath;
+ const target = vaultSafeFeedUrl(
+ 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/privateFeedUrl.ts b/src/utility/privateFeedUrl.ts
index cee6ecf6..7e5a6c63 100644
--- a/src/utility/privateFeedUrl.ts
+++ b/src/utility/privateFeedUrl.ts
@@ -91,11 +91,6 @@ export function parsePrivateFeedPlaceholder(value: string): string | null {
}
}
-/**
- * `url` as a request sends it, so the scheme and host in any case, with or
- * without a default port or a terminal dot, name the same feed. As given when
- * it is not a URL.
- */
export function canonicalUrl(url: string): string {
const parsed = checkStringIsUrl(url);
if (!parsed) return url;
@@ -103,11 +98,6 @@ export function canonicalUrl(url: string): string {
return parsed.href;
}
-/**
- * `address` unless it is a credential-bearing feed URL. A saved private feed's
- * episodes carry its placeholder, so such a URL belongs to a feed with no
- * placeholder on this device, and notes and links get no address for it.
- */
export function vaultSafeFeedUrl(address: string | undefined): string | undefined {
return address && isCredentialBearingUrl(address) ? undefined : address;
}
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",
+ );
+});
From 4b2824f64f8f74ca2a2eed3d0d979ca5149ee7c1 Mon Sep 17 00:00:00 2001
From: Christian Bager Bach Houmann
Date: Tue, 13 Oct 2026 15:37:24 +0200
Subject: [PATCH 5/6] test(privacy): reproduce transformed metadata and URI
identity leaks
---
src/createPodcastNote.privacy.test.ts | 78 +++++++++++++++----
.../TranscriptionService.published.test.ts | 36 +++++++++
src/utility/privateFeedUrl.test.ts | 3 +
3 files changed, 103 insertions(+), 14 deletions(-)
diff --git a/src/createPodcastNote.privacy.test.ts b/src/createPodcastNote.privacy.test.ts
index fd62adfc..d49a4370 100644
--- a/src/createPodcastNote.privacy.test.ts
+++ b/src/createPodcastNote.privacy.test.ts
@@ -4,6 +4,7 @@ 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,
@@ -13,6 +14,7 @@ import {
} 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";
@@ -27,7 +29,7 @@ const episode: Episode = {
content: "",
};
-function noteVault(subscribed: boolean) {
+function noteVault(subscribed: boolean, artworkUrl = "") {
const written: Array<{ path: string; content: string }> = [];
const feedUrls = new FeedUrlRepository(new SecretStorage());
const urlSecretId = feedUrls.store(address);
@@ -36,7 +38,7 @@ function noteVault(subscribed: boolean) {
settings: {
...structuredClone(DEFAULT_SETTINGS),
savedFeeds: subscribed
- ? { Paid: { title: "Paid", url: placeholder, urlSecretId, artworkUrl: "" } }
+ ? { Paid: { title: "Paid", url: placeholder, urlSecretId, artworkUrl } }
: {},
},
app: {
@@ -73,6 +75,9 @@ test.each([
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");
});
@@ -97,18 +102,63 @@ test.each([
expect(buildEpisodeResumeLink({ ...episode, feedUrl })).toBe("");
});
-test("a note's path, title and identity carry no subscribed feed address they quote", async () => {
- const written = noteVault(true);
- await createPodcastNoteFileIfNotExists({
- ...episode,
- feedUrl: placeholder,
- title: `Feed ${address}`,
- guid: `${address}#1`,
- });
- expect(written).toHaveLength(1);
- expect(written[0].path).toBe("PodNotes/Paid/Feed podnotes-private-feedPaid.md");
- expect(written[0].content).toContain('guid: "podnotes-private-feed:Paid"');
- expect(written[0].content).not.toContain("NOTE_SENTINEL");
+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", () => {
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/utility/privateFeedUrl.test.ts b/src/utility/privateFeedUrl.test.ts
index f56b3411..2a20a0bc 100644
--- a/src/utility/privateFeedUrl.test.ts
+++ b/src/utility/privateFeedUrl.test.ts
@@ -130,6 +130,9 @@ describe("redactPrivateFeedAddresses", () => {
"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"],
From 5230a97af544a584b452392c3daf63bc85313102 Mon Sep 17 00:00:00 2001
From: Christian Bager Bach Houmann
Date: Tue, 13 Oct 2026 15:37:24 +0200
Subject: [PATCH 6/6] fix(privacy): protect decoded metadata and URI identity
at vault outputs
---
src/TemplateEngine.ts | 55 ++++++++++------------
src/createPodcastNote.ts | 16 +++----
src/services/privateFeeds.ts | 26 ++++++++++
src/services/publishedTranscript/render.ts | 5 +-
src/utility/buildEpisodeResumeLink.ts | 9 ++--
src/utility/formatEpisodeTimestamp.ts | 4 +-
src/utility/privateFeedUrl.ts | 12 ++---
7 files changed, 73 insertions(+), 54 deletions(-)
diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts
index ad04a921..8d498592 100644
--- a/src/TemplateEngine.ts
+++ b/src/TemplateEngine.ts
@@ -17,8 +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 { privateFeedAddresses } from "./services/privateFeeds";
-import { redactPrivateFeedAddresses, vaultSafeFeedUrl } from "./utility/privateFeedUrl";
+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
@@ -48,14 +47,6 @@ export function templateHasTag(template: string, tag: Lowercase): boolea
);
}
-export function withoutPrivateFeedAddresses(value: T): T {
- const instance = get(plugin);
- return redactPrivateFeedAddresses(
- value,
- instance?.feedUrls && privateFeedAddresses(instance.settings.savedFeeds, instance.feedUrls),
- );
-}
-
function withoutFeedAddresses(feed: PodcastFeed): PodcastFeed {
return withoutPrivateFeedAddresses([feed, { feedUrl: feed.url }] as const)[0];
}
@@ -68,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()];
@@ -247,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",
@@ -255,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)),
);
@@ -297,18 +295,19 @@ export function NoteTemplateEngine(
context: NoteTemplateContext = {},
) {
const [replacer, addTag] = useTemplateEngine();
- const [redacted, chapters] = withoutPrivateFeedAddresses([
+ const parentFeed = get(plugin)?.settings?.savedFeeds?.[liveEpisode.podcastName];
+ const [episode, chapters, feedArtwork] = withoutPrivateFeedAddresses([
liveEpisode,
context.chapters,
+ parentFeed?.artworkUrl,
] as const);
- const episode = { ...redacted, feedUrl: liveEpisode.feedUrl };
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",
);
@@ -323,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));
@@ -361,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(vaultSafeFeedUrl(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));
@@ -375,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);
}
@@ -473,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 ?? ""));
@@ -504,15 +502,14 @@ export function FeedNoteTemplateEngine(template: string, liveFeed: 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(vaultSafeFeedUrl(liveFeed.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")
diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts
index 3b398e5f..d2041d58 100644
--- a/src/createPodcastNote.ts
+++ b/src/createPodcastNote.ts
@@ -4,7 +4,6 @@ import {
legacyReplaceIllegalFileNameCharactersInString,
NoteTemplateEngine,
templateHasTag,
- withoutPrivateFeedAddresses,
} from "./TemplateEngine";
import type { Episode } from "./types/Episode";
import type { Chapter } from "./types/Chapter";
@@ -17,7 +16,7 @@ import { fetchChapters } from "./utility/fetchChapters";
import { findUniqueTitleMatch } from "./utility/episodeTitleMatch";
import { findByGuid } from "./utility/findByGuid";
import { addFrontmatterFields } from "./utility/noteFrontmatter";
-import { vaultSafeFeedUrl } from "./utility/privateFeedUrl";
+import { vaultSafeLinkTarget } from "./services/privateFeeds";
/**
* Resolve the on-disk path of an episode's note from the configured template.
@@ -102,16 +101,13 @@ export async function createPodcastNoteFileIfNotExists(
if (arrivedFile) return { file: arrivedFile, created: false };
const content = NoteTemplateEngine(template, episode, { chapters });
- const feedUrl = vaultSafeFeedUrl(episode.feedUrl);
+ const feedUrl = vaultSafeLinkTarget(episode, episode.feedUrl);
const identified =
episode.guid && feedUrl
- ? addFrontmatterFields(
- content,
- withoutPrivateFeedAddresses([
- ["guid", episode.guid],
- ["feedUrl", feedUrl],
- ]),
- )
+ ? addFrontmatterFields(content, [
+ ["guid", episode.guid],
+ ["feedUrl", feedUrl],
+ ])
: content;
return createFileIfNotExists(filePathDotMd, identified);
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.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.ts b/src/utility/buildEpisodeResumeLink.ts
index b51699d3..8102e72c 100644
--- a/src/utility/buildEpisodeResumeLink.ts
+++ b/src/utility/buildEpisodeResumeLink.ts
@@ -2,7 +2,7 @@ import type { Episode } from "src/types/Episode";
import { downloadedEpisodes } from "src/store";
import encodePodnotesURI from "./encodePodnotesURI";
import { episodeLinkTarget } from "./episodeLinkTarget";
-import { vaultSafeFeedUrl } from "./privateFeedUrl";
+import { vaultSafeLinkTarget } from "src/services/privateFeeds";
/**
* Build an `obsidian://podnotes` deep link that reopens `episode` in the PodNotes
@@ -15,11 +15,12 @@ import { vaultSafeFeedUrl } from "./privateFeedUrl";
* (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 "" when the target is a private feed
- * without a subscription placeholder, or nothing can address the episode.
+ * 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 = vaultSafeFeedUrl(
+ const target = vaultSafeLinkTarget(
+ episode,
episodeLinkTarget(episode) ?? downloadedEpisodes.getEpisode(episode)?.filePath,
);
diff --git a/src/utility/formatEpisodeTimestamp.ts b/src/utility/formatEpisodeTimestamp.ts
index aea157d1..e2785214 100644
--- a/src/utility/formatEpisodeTimestamp.ts
+++ b/src/utility/formatEpisodeTimestamp.ts
@@ -3,7 +3,7 @@ import encodePodnotesURI from "./encodePodnotesURI";
import { episodeLinkTarget } from "./episodeLinkTarget";
import { formatSeconds } from "./formatSeconds";
import { formatPodcastSegment } from "./podcastSegment";
-import { vaultSafeFeedUrl } from "./privateFeedUrl";
+import { vaultSafeLinkTarget } from "src/services/privateFeeds";
export function formatEpisodeTimestamp(
episode: Episode,
@@ -16,7 +16,7 @@ export function formatEpisodeTimestamp(
endSeconds === undefined
? formatSeconds(seconds, format)
: formatPodcastSegment(seconds, endSeconds, format);
- const target = linkify ? vaultSafeFeedUrl(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.ts b/src/utility/privateFeedUrl.ts
index 7e5a6c63..ca407498 100644
--- a/src/utility/privateFeedUrl.ts
+++ b/src/utility/privateFeedUrl.ts
@@ -98,10 +98,6 @@ export function canonicalUrl(url: string): string {
return parsed.href;
}
-export function vaultSafeFeedUrl(address: string | undefined): string | undefined {
- return address && isCredentialBearingUrl(address) ? undefined : address;
-}
-
/**
* `document` with no private feed address left in it. A snapshot's
* credential-bearing `feedUrl` is a private feed address, as is each key of
@@ -143,7 +139,7 @@ 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?:[/\\\\]{2}[^\\s/?#]*[%\\\\\\[\\u0080-\\uFFFF]|https?:[/\\\\]{2}(?:[^\\s/?#@]*@)?[\\d/\\\\]",
+ "|https?:[/\\\\]*[^\\s/?#]*[%\\\\\\[\\u0080-\\uFFFF]|https?:[/\\\\]*(?:[^\\s/?#@]*@)?\\d",
"i",
);
const matches = new Map();
@@ -156,9 +152,9 @@ export function redactPrivateFeedAddresses(
return replacement;
};
return replaceStrings(document, (value) => {
- if (!/:[/\\]{2}/.test(value) || !candidate.test(value)) return value;
- if (/^https?:[/\\]{2}[^\s<>"'`]+$/i.test(value)) return lookup(value) ?? value;
- return value.replace(/https?:[/\\]{2}[^\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(/[.,;:!?)\]}]+$/, "");