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 = `PaidPrivate 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", + ); +});