The Directory comes from the directory service (#205)

ipx asks ipx-directory on its people's behalf, with the token an admin set
in the new Admin, Directory tab, so the page never talks to it and never
sees the token or a feed's address. /api/directory, /api/popular and a
feed's Directory page answer from it; subscribing takes the address from
the directory and goes through Add feed's steps. With no directory set
up, the Directory says so. The local directory code is gone; its privacy
checks are the snapshot builder's now, and their tests moved with them.

With sharing on, ipx sends its snapshot after every scan, whenever the
config changes (set_cfg) and every half hour: each public feed and how
many here subscribe, never an OPML or Patreon creator itself or anything
private. A feed can be marked private in its settings, for a paid feed
whose address does not give it away; anyone subscribed may mark it, only
an admin unmark it. The admin's category for a feed that names none goes
with it, which the directory uses only while the feed names none, so the
category an admin gives a blog still reaches the Directory.

The directory lists only feeds it has read as a feed: the first snapshot
after a start went before the first scan, when an OPML was not yet known
to be one, and the directory listed the OPML. It now fetches what a
snapshot brings in at once instead of within the minute.

The browser suite runs its own ipx-directory on SQLite; the Directory's
tests match feeds by title, since the directory names them by address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 04:31:32 +00:00
parent ed82166745
commit c47aa57909
19 changed files with 710 additions and 246 deletions

View File

@@ -7,7 +7,7 @@
let me: {name: string} | null = null;
api('/api/me').then(u => { me = u; }).catch(() => {});
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, log: drawLogView};
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, directory: drawDirectorySettings, log: drawLogView};
function showSection(){
const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server';
@@ -70,6 +70,67 @@ async function drawServer(){
};
}
/* ---------------- directory ---------------- */
// The directory service the Directory shows (#205): where it is, this server's token for it, and
// whether to share. The token is sent, never shown: the server says only whether one is set.
const ACCESS: Record<string, string> = {ro: 'browse', rw: 'browse and share', rwa: 'browse, share and administer the directory'};
async function drawDirectorySettings(){
const box = $('#directory');
const g = await api('/api/settings');
box.innerHTML = `<h2>Directory</h2>
<p class="hint">The Directory shows podcasts from a directory service that iPX servers share.
Without one, people add feeds by their address.</p>
<div class="field"><label for="durl">Directory address</label>
<input type="text" inputmode="url" id="durl" value="${esc(g.directory_url)}" placeholder="https://ipx-dir.example.net" spellcheck="false"></div>
<div class="field"><label for="dtoken">Access token</label>
<div class="inline"><input type="password" id="dtoken" autocomplete="off" spellcheck="false"
placeholder="${g.directory_token_set ? 'Set. Paste a new one to replace it' : 'Paste the token the directory gave this server'}">
${g.directory_token_set ? `<button type="button" class="btn" id="dclear">Clear</button>` : ''}</div>
<span class="hint">Whoever runs the directory makes one for each server, and can revoke it.</span></div>
<div class="field"><label class="check"><input type="checkbox" id="dshare"${g.directory_share ? ' checked' : ''}> Share this server's public feeds</label>
<span class="hint">Sends the directory the feeds people here subscribe to, and how many of them do, every
half hour. Never a private feed: one marked private, one with a login, or one whose address carries a key.</span></div>
<div class="field"><label>Status</label><p class="hint" id="dstatus">Asking the directory…</p></div>
<div class="cardacts"><button class="btn primary" id="dsave">${ICON.check} Save</button></div>`;
const save = async (patch: Record<string, unknown>, said: string) => {
try{
await api('/api/settings', {method: 'PATCH', body: JSON.stringify(patch)});
toast(said);
drawDirectorySettings();
}catch(e){ toast(e.message, true); }
};
$('#dsave').onclick = () => {
const patch: Record<string, unknown> = {directory_url: $('#durl').value.trim(), directory_share: $('#dshare').checked};
// Blank leaves the token as it is; Clear is the way to remove it.
if($('#dtoken').value.trim()) patch.directory_token = $('#dtoken').value.trim();
save(patch, 'Directory settings saved');
};
const clear = $('#dclear');
if(clear) clear.onclick = () => save({directory_token: ''}, 'Token cleared');
drawDirectoryStatus();
}
async function drawDirectoryStatus(){
const el = $('#dstatus');
let s;
try{ s = await api('/api/directory-status'); }catch(e){ el.textContent = e.message; return; }
if(!el.isConnected) return;
const lines: string[] = [];
if(!s.configured) lines.push('Not set up: it needs an address and a token.');
else if(s.error) lines.push(esc(s.error));
else lines.push(`Connected as <b>${esc(s.name)}</b>, which may ${esc(ACCESS[s.access] || s.access)}.`);
const sh = s.shared;
if(sh){
const when = new Date(sh.at * 1000).toLocaleString();
lines.push(sh.error ? `Sharing failed at ${esc(when)}: ${esc(sh.error)}`
: `Shared ${plural(sh.accepted, 'feed')} at ${esc(when)}.` +
(sh.refused_private ? ` The directory refused ${plural(sh.refused_private, 'feed')} as private.` : '') +
(sh.refused_blocked ? ` ${plural(sh.refused_blocked, 'feed')} the directory has removed.` : ''));
}
el.innerHTML = lines.join('<br>');
}
/* ---------------- accounts ---------------- */
async function drawAccounts(){
const box = $('#accounts');

View File

@@ -34,8 +34,11 @@ $('#addFeed').onclick=()=>{
};
};
// The rows carry an id, never a URL, so a key in someone's feed address never reaches this page.
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
// The rows come from the directory through iPX (#205), and carry its id, never a URL, so a key in
// someone's feed address never reaches this page.
const NONE_LISTED='<p class="hint">Nothing in the directory yet. Feeds people subscribe to show up here once their servers share them.</p>';
// Why the directory could not be shown: none set up, or it did not answer. In iPX's words.
let dirError='';
/// One listed feed: a tile in the Directory's grid or, given its place, a row in its chart. The
/// parts are the same either way; the class lays them out. Yours opens its feed; any other, its
@@ -57,7 +60,8 @@ function listedFeed(p,cls,rank?: number){
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
if(p.subscribed){ el.onclick=()=>selectFeed(p.id); return el; }
// Yours has an id here as well as the directory's, and opens by it.
if(p.subscribed){ el.onclick=()=>selectFeed(p.feed||p.id); return el; }
el.onclick=()=>{ dirShow=p.id; $('#content').scrollTop=0; drawDirectory(undefined,'.dback'); };
// Its own click, not the tile's as well.
$('[data-a="sub"]',el).onclick=ev=>{ ev.stopPropagation(); subscribeListed(p); };
@@ -66,9 +70,9 @@ function listedFeed(p,cls,rank?: number){
async function subscribeListed(p){
try{
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
const r=await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
toast(`Subscribed to ${NAME(p)}`);
await loadFeeds(true); selectFeed(p.id);
await loadFeeds(true); selectFeed(r.id);
}catch(e){ toast(e.message,true); }
}
@@ -99,7 +103,8 @@ const KIND_WORD={All:'feed',Podcasts:'podcast',Blogs:'blog'};
async function renderDirectory(url,box){
// Each visit starts at the front, as going back to a tab does.
dirCat=null; dirSub=null; dirShow=null;
try{ dirRows=await api(url)||[]; }catch{ dirRows=[]; }
dirError='';
try{ dirRows=await api(url)||[]; }catch(e){ dirRows=[]; dirError=e.message; }
drawDirectory(box);
return dirRows.length;
}
@@ -107,7 +112,7 @@ async function renderDirectory(url,box){
/// `focus` is the control to put the keyboard back on, since the whole page is redrawn.
function drawDirectory(box=$('#directory'), focus?: string){
if(!box) return;
if(!dirRows.length){ box.innerHTML=NONE_LISTED; return; }
if(!dirRows.length){ box.innerHTML=dirError?`<p class="hint">${esc(dirError)}</p>`:NONE_LISTED; return; }
// Only a choice when the server has both kinds.
const both=dirRows.some(KINDS.Podcasts)&&dirRows.some(KINDS.Blogs);
if(!both) dirKind='All';
@@ -137,7 +142,7 @@ function drawDirectory(box=$('#directory'), focus?: string){
<div class="show">${artHTML(show.image,NAME(show))}<div class="meta"><h2>${esc(NAME(show))}</h2>${
// Its category's page is one click away from here.
show.category?`<button type="button" class="dlink" data-go="${esc(show.category)}">${esc(show.subcategory||show.category)}</button>`:''}
${n?`<p class="sub">${n} ${n===1?'person here subscribes':'people here subscribe'}</p>`:''}
${n?`<p class="sub">${n} ${n===1?'person subscribes':'people subscribe'}</p>`:''}
<p class="sub dfail" id="dfail" hidden></p>
<div class="acts"><button class="btn ico primary" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button></div></div></div>
<div class="about" id="dabout" hidden><p></p><button type="button" class="dlink" aria-expanded="false" hidden>More</button></div>`
@@ -153,7 +158,7 @@ function drawDirectory(box=$('#directory'), focus?: string){
for(const p of rows){ const c=p.category||OTHER; if(!cats.has(c)) cats.set(c,[]); cats.get(c).push(p); }
// Other, the feeds with no category, goes last.
const order=[...cats.keys()].sort((a,b)=>Number(a===OTHER)-Number(b===OTHER)||a.localeCompare(b));
html=head('Directory',`${count(rows.length)} on this server. Private feeds are never listed.`)
html=head('Directory',`${count(rows.length)} shared by iPX servers. Private feeds are never listed.`)
+(chart.length?sec('Most subscribed',board):'')
+sec('Categories',`<div class="cats">${order.map(c=>catTile(c,catName(c),count(cats.get(c).length),cats.get(c))).join('')}</div>`,
`<button type="button" class="dall" data-go="${EVERY}">See all ${rows.length.toLocaleString()}</button>`);
@@ -227,8 +232,7 @@ async function fillShow(box,p){
return `<article class="lep"><b>${e.link?`<a href="${esc(e.link)}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>`
+(text?`<p>${esc(text)}</p>`:'')
+(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>';
}).join(''):`<p class="hint">Nothing read from it yet.${p.subscribers||got.failing?''
:' A feed nobody here subscribes to is checked once a day.'}</p>`;
}).join(''):'<p class="hint">Nothing read from it yet. The directory checks each feed every few hours.</p>';
}
/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
@@ -539,6 +543,13 @@ function settingsModal(f, newUrl?: string){
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
with what Settings hides everywhere.</p></div>`}
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sprivate"${f.private||f.private_by_address?' checked':''}${
f.private_by_address||(f.private&&!admin)?' disabled':''}>`,'Private',' lead')}
<p class="cap">${f.private_by_address
?'Its address or login shows it is someone’s own, so it is never shared with the directory.'
:f.private&&!admin
?'Never shared with the directory. Only an admin can share it again.'
:`Never shared with the directory, for ${isGroup?'shows':'a show'} you pay for. iPX knows most paid feeds by their address; this is for the ones it does not.`}</p></div>
</div>
<div class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset');
@@ -625,6 +636,9 @@ function settingsModal(f, newUrl?: string){
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked,
deadair_pause:Number($('#sdpause').value)};
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
// Sent only when it changed: anyone may mark a feed private, but only an admin unmark it.
const priv=$('#sprivate');
if(!priv.disabled&&priv.checked!==!!f.private) patch.private=priv.checked;
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
// API refuses it from anyone else. The folder and category are not this dialog's at all.
if(admin) patch.url=$('#surl').value.trim();