diff --git a/tests/page-unit.ts b/tests/page-unit.ts index 8418098..fc71176 100644 --- a/tests/page-unit.ts +++ b/tests/page-unit.ts @@ -343,3 +343,12 @@ test('Share says why an episode gets no page anyone can play, and warns of a key // The request failed outright: no reason known, but the key is still warned of. assert.match(note(null, paid), /own access/); }); + +test('a Patreon feed or file carries your access, a post page does not (#455)', () => { + const carries = (url: string) => run(`carriesAccess(${JSON.stringify(url)})`) as boolean; + assert.equal(carries('https://www.patreon.com/rss/glasscannon?auth=abc&show=1'), true); + assert.equal(carries('https://www.patreon.com/api/rss/u/H0Pm/e/174604106.mp3?sig=abc'), true); + assert.equal(carries('https://c10.patreonusercdn.com/ep.mp3?token-hash=abc'), true); + assert.equal(carries('https://www.patreon.com/glasscannon/posts/music-from-room-174604106'), false); + assert.equal(carries('https://example.com/ep.mp3'), false); +}); diff --git a/tests/ui/app.spec.ts b/tests/ui/app.spec.ts index a09e68f..0d79e28 100644 --- a/tests/ui/app.spec.ts +++ b/tests/ui/app.spec.ts @@ -2271,6 +2271,17 @@ test('Share hands the feed, and an episode as a link to its page on ipx-play (#4 await expect(page.locator('#shat')).toHaveCount(0); await page.locator('#shlink').click(); await expect.poll(() => page.evaluate(() => window.shared.at(-1)?.url)).toBe('https://www.patreon.com/posts/1?auth=secret123'); + // From its file too, the page alone: a paid file's address is signed for you (#455). A post's + // page carries nothing of yours, so no warning. + await page.evaluate(async () => { + const e = (await api('/api/feeds/paid-show/entries') as ListPage).entries[0]; + const file = { id: 99, url: 'https://www.patreon.com/api/rss/u/H0Pm/e/1.mp3?sig=abc' } as Enclosure; + await shareEpisode({ ...e, link: 'https://www.patreon.com/glasscannon/posts/1', enclosures: [file] }, 99); + }); + await expect(page.locator('#modalCard')).toContainText('its file is never shared'); + await expect(page.locator('#modalCard')).not.toContainText('own access'); + await page.locator('#shlink').click(); + await expect.poll(() => page.evaluate(() => window.shared.at(-1)?.url)).toBe('https://www.patreon.com/glasscannon/posts/1'); }); test('a refresh button turns from the press to the end of the scan it asked for', async ({ page }) => { diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 6a68ec2..6e57355 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Share on an episode offers Share link, Share from where you are, and Copy link, with a link to the episode's page on ipx-play that anyone can play; an episode of a private feed offers its own address in the same menu, which says why there is no page for it and warns when the address carries your own access to the feed. An admin sets ipx-play up in Admin, Directory (#447, #452, #454). +- Share on an episode offers Share link, Share from where you are, and Copy link, with a link to the episode's page on ipx-play that anyone can play; an episode of a private feed offers its own page in the same menu, never its file, which a paid feed signs for you, and says why there is no page anyone can play. An admin sets ipx-play up in Admin, Directory (#447, #452, #454, #455). - In the iOS app, Settings has a This device section for the app's server and player token, with a button that makes a token and hands it to the app (#445). - In the iOS app, the page registers the phone with ipx for its icon's count, and signing out removes it (#433). - In the iOS app, the player bar shows what CarPlay plays, so it can be paused on the phone after leaving the car, including when the app was opened by the car (#420, #442). diff --git a/web/src/items.ts b/web/src/items.ts index 3a24c06..7007c19 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -527,15 +527,15 @@ const shareAt=(url: string, secs: number)=>secs>=5 ? `${url}?t=${Math.floor(secs /// ipx-play, where the item's own address is all Share has ever given. function noLinkNote(why: string|null, url: string): string{ const notes: string[]=[]; - if(why==='private') notes.push('This podcast\'s feed is private to you, so it gets no page anyone can play: Share gives the episode\'s own address instead.'); + if(why==='private') notes.push('This podcast\'s feed is private to you, so it gets no page anyone can play, and its file is never shared: this is the episode\'s own page.'); else if(why==='failed') notes.push('The page anyone can play could not be made just now, so this is the episode\'s own address. Try again later for a link to it.'); if(carriesAccess(url)) notes.push(ACCESS_WARNING); return notes.map(n=>`

${esc(n)}

`).join(''); } /// An episode shared as Overcast shares one (#447): a link to its page on ipx-play, where anyone -/// can play it, from where you are in it if you like. A private feed, a post, or a server with no -/// ipx-play shares the item's own address, through the same menu. +/// can play it, from where you are in it if you like. A post, or a server with no ipx-play, shares +/// the item's own address through the same menu; a private feed's episode, its page alone. async function shareEpisode(e: Entry, encId: number|null){ const name=entryName(e).text; const own=encId!=null ? e.enclosures.find(x=>x.id===encId)?.url : e.link; @@ -544,8 +544,14 @@ async function shareEpisode(e: Entry, encId: number|null){ link=await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/share`, {method:'POST', body:JSON.stringify(encId!=null ? {enc:encId} : {})}); }catch{} - const url=link.url ?? own; - if(!url){ toast('There is nothing to share for this item.', true); return; } + // A private feed's file can be signed for you (Patreon's are), so only the episode's page goes, + // even from Share on the file (#455). + const url=link.url ?? (link.why==='private' ? e.link : own); + if(!url){ + toast(link.why==='private' ? 'This podcast is private to you, and this episode has no page of its own to share.' + : 'There is nothing to share for this item.', true); + return; + } // Where you are: the player's clock when it has this one, else where you left off. Only // ipx-play's page reads a time. const at=!link.url ? 0 : player.guid===e.guid && player.feed===e.feed_id ? audio.currentTime : e.position; diff --git a/web/src/util.ts b/web/src/util.ts index 3d73771..54434c2 100644 --- a/web/src/util.ts +++ b/web/src/util.ts @@ -216,7 +216,9 @@ async function share(title: string, url: string, btn?: HTMLElement){ await sendLink(title, url, btn); } -const carriesAccess=(url: string)=>/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url); +// Patreon's feed and its files, not every patreon.com address: a post's page asks whoever opens it +// to sign in, and carries nothing of yours (#455). +const carriesAccess=(url: string)=>/patreon\.com\/(api\/)?rss\b|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url); const ACCESS_WARNING='This address looks like it includes your own access to the feed, and anyone you ' +'send it to could use it as you.';