Share never hands over a private feed's file (#455)

Share on a paid episode's file gave the file's own address, which for
Patreon is patreon.com/api/rss/u/<your id>/e/<n>.mp3?sig=..., signed for
the subscriber: whoever got it could download the episode as them. A
private feed's episode is now shared by its page alone, from the file
too, and with no page there is nothing to share. The access warning
matched any patreon.com address, so it warned on a post's page, which
asks for a sign-in and carries nothing of yours; it now matches
Patreon's feed and file addresses only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVA8TdTxe556JB3sR1n
This commit is contained in:
2026-11-13 17:34:17 +00:00
parent 45ef1e83f2
commit 757ce5fbb3
5 changed files with 35 additions and 7 deletions

View File

@@ -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);
});

View File

@@ -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 }) => {

View File

@@ -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).

View File

@@ -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=>`<p class="hint">${esc(n)}</p>`).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;

View File

@@ -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.';