pithos-hosted: public folders by name, as pages, at navigate.st/s3

Public files were addressed by a 32-character account id, and a public
folder answered with JSON, so a link was unusable to send to anyone.
Accounts that signed up with a BNS name can now be addressed by it
(/p/alice.x/<drive>/<folder>/), a public folder opened in a browser is a
page with its files, small pictures and a player (tools still get JSON),
and every link and redirect on it is relative so navigate.st/s3/ serves
the same pages. Pithos shows the navigate.st/s3 address when a folder is
made public.
This commit is contained in:
Local Dev 2026-10-04 16:10:11 +02:00
parent fa1ecc954a
commit 7fa7f71be4
5 changed files with 132 additions and 9 deletions

28
hosted/package-lock.json generated Normal file
View file

@ -0,0 +1,28 @@
{
"name": "pithos-hosted",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pithos-hosted",
"version": "0.1.0",
"license": "MPL-2.0",
"dependencies": {
"@bitauth/libauth": "3.1.0-next.8"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@bitauth/libauth": {
"version": "3.1.0-next.8",
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz",
"integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
}
}
}

View file

@ -139,7 +139,7 @@ export async function createHosted(cfg, log = console.log) {
...st,
host: 'hosted',
config: st.config ? { apiAddress: null, adminAddress: null, file: null, directory: null, https: null, exists: true } : undefined,
hosted: { id: a.id, state: a.state, publicUrl: cfg.publicUrl, usage: usageOf(a.id), downloadCapBytes: cfg.downloadCapBytes || null },
hosted: { id: a.id, name: a.gate?.type === 'bns' ? a.gate.name : null, state: a.state, publicUrl: cfg.publicUrl, usage: usageOf(a.id), downloadCapBytes: cfg.downloadCapBytes || null },
};
}
@ -388,10 +388,18 @@ export async function createHosted(cfg, log = console.log) {
} finally { release(); }
}
// /p/<account id or BNS name>/<drive>/<path>. Also reached as
// navigate.st/s3/…, so every link and redirect here is relative.
async function servePublic(req, res, url) {
const m = /^\/p\/([0-9a-f]{32})\/([^/]+)\/?(.*)$/.exec(url.pathname);
const m = /^\/p\/([^/]+)\/([^/]+)(\/?)(.*)$/.exec(url.pathname);
if (!m) throw httpError(404, 'not found');
const [, id, bucket] = m;
const [, who, bucket, slash] = m;
let whoName = who;
try { whoName = decodeURIComponent(who); } catch {}
const id = /^[0-9a-f]{32}$/.test(who) ? who : sup.accountByName(whoName);
if (!id) throw httpError(404, 'not found');
// The drive itself without its slash: add it, so relative links resolve.
if (!slash && !m[4]) { res.writeHead(301, { location: `${encodeURIComponent(bucket)}/${url.search}` }); return res.end(); }
let key;
try { key = decodeURIComponent(m[3]); } catch { throw httpError(404, 'not found'); }
if (key.split('/').some((x) => x === '.' || x === '..')) throw httpError(404, 'not found');
@ -403,11 +411,17 @@ export async function createHosted(cfg, log = console.log) {
const client = await sup.bucketClient(inst, bucket);
if (!client) throw httpError(404, 'not found');
const page = await client.listObjects(bucket, { prefix: key, token: url.searchParams.get('token') || undefined });
return json(res, 200, {
const listing = {
folders: page.folders.map((f) => f.slice(key.length)),
files: page.objects.filter((o) => o.key !== key).map((o) => ({ name: o.key.slice(key.length), size: o.size, modified: o.modified })),
next: page.truncated ? page.nextToken : null,
});
};
// Browsers get a page with links and a player; tools get the JSON.
if (/text\/html/.test(req.headers.accept || '') && !url.searchParams.has('json')) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': "default-src 'self'; style-src 'unsafe-inline'; img-src 'self'; media-src 'self'; script-src 'unsafe-inline'; frame-ancestors 'none'", 'x-content-type-options': 'nosniff' });
return res.end(folderPage({ owner: whoName, bucket, key, listing, token: url.searchParams.get('token') }));
}
return json(res, 200, listing);
}
return serveObject(req, res, id, bucket, key);
}
@ -546,6 +560,42 @@ export async function createHosted(cfg, log = console.log) {
};
}
// A public folder as a page: breadcrumbs, folders, files (links are relative
// to the folder, so the page works under /p/ and under navigate.st/s3/),
// pictures shown small, music and video playable in place.
const PAGE_ESC = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const fmtSize = (n) => { const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; let i = 0; n = Number(n) || 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${i ? n.toFixed(1) : n} ${u[i]}`; };
export function folderPage({ owner, bucket, key, listing, token }) {
const parts = key.split('/').filter(Boolean);
const up = (n) => '../'.repeat(n) || './';
const crumbs = [`<a href="${up(parts.length)}">${PAGE_ESC(bucket)}</a>`, ...parts.map((p, i) => i === parts.length - 1 ? `<span>${PAGE_ESC(p)}</span>` : `<a href="${up(parts.length - 1 - i)}">${PAGE_ESC(p)}</a>`)].join(' / ');
const enc = (name) => name.split('/').map(encodeURIComponent).join('/');
const kind = (n) => (/\.(png|jpe?g|gif|webp|avif|svg)$/i.test(n) ? 'img' : /\.(mp3|m4a|aac|ogg|oga|opus|flac|wav)$/i.test(n) ? 'audio' : /\.(mp4|webm|mov|m4v)$/i.test(n) ? 'video' : '');
const rows = [
...listing.folders.map((f) => `<li class="dir"><a href="${enc(f)}">📁 ${PAGE_ESC(f.replace(/\/$/, ''))}</a></li>`),
...listing.files.map((f) => {
const k = kind(f.name);
const media = k === 'img' ? `<img loading="lazy" src="${enc(f.name)}" alt="">` : k ? `<button type="button" class="play" data-src="${enc(f.name)}" data-kind="${k}">▶</button>` : '';
return `<li>${media}<a href="${enc(f.name)}">${PAGE_ESC(f.name)}</a><span class="sz">${fmtSize(f.size)}</span></li>`;
}),
].join('');
const more = listing.next ? `<p><a href="?token=${encodeURIComponent(listing.next)}">More…</a></p>` : '';
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>${PAGE_ESC(parts.at(-1) || bucket)} · ${PAGE_ESC(owner)}</title>
<style>body{margin:0;background:#0b100e;color:#e6ece9;font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}main{max-width:860px;margin:0 auto;padding:28px 18px 120px}
a{color:#3ddc97;text-decoration:none}a:hover{text-decoration:underline}.owner{color:#8e9c96;font-size:13px}h1{font-size:20px;margin:4px 0 18px;font-weight:600}h1 span{color:#e6ece9}
ul{list-style:none;padding:0;margin:0;border:1px solid rgba(255,255,255,.08);border-radius:10px;overflow:hidden}li{display:flex;align-items:center;gap:10px;padding:9px 14px;border-top:1px solid rgba(255,255,255,.06);min-width:0}li:first-child{border-top:none}
li a{flex:1;min-width:0;overflow-wrap:anywhere}li img{width:44px;height:44px;object-fit:cover;border-radius:6px;flex:none}.sz{color:#8e9c96;font-size:13px;white-space:nowrap}
.play{flex:none;width:32px;height:32px;border-radius:50%;border:0;background:#3ddc97;color:#06231a;cursor:pointer;font-size:13px}
#player{position:fixed;left:0;right:0;bottom:0;background:#121a17;border-top:1px solid rgba(255,255,255,.08);padding:10px 16px;display:none}#player.on{display:block}#player audio,#player video{width:100%;max-height:50vh}.empty{color:#8e9c96;padding:18px}footer{color:#5f6b66;font-size:12.5px;margin-top:18px}</style></head>
<body><main><div class="owner">${PAGE_ESC(owner)} · public folder</div><h1>${crumbs}</h1>
${rows ? `<ul>${rows}</ul>` : '<p class="empty">This folder is empty.</p>'}${more}
<footer>Shared with Pithos on Silent Mode. <a href="?json">JSON</a></footer></main>
<div id="player"></div>
<script>document.addEventListener('click',function(e){var b=e.target.closest('.play');if(!b)return;var p=document.getElementById('player');var m=document.createElement(b.dataset.kind);m.src=b.dataset.src;m.controls=true;m.autoplay=true;p.innerHTML='';p.appendChild(m);p.className='on';});</script>
</body></html>`;
}
function json(res, status, body) {
const data = JSON.stringify(body);
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });

View file

@ -43,7 +43,23 @@ export function createSupervisor(cfg, log = console.log, { reservedKey = () => f
return readJson(accountFile(id), null);
}
function saveAccount(a) { writeJson(accountFile(a.id), a); }
function saveAccount(a) { writeJson(accountFile(a.id), a); nameIndex = null; }
// Accounts by the BNS name they signed up with (one slot per name), for
// public addresses like /p/alice.x/<drive>/… instead of the 32-hex id.
let nameIndex = null;
function accountByName(name) {
if (!nameIndex) {
nameIndex = new Map();
let ids = [];
try { ids = fs.readdirSync(usersDir); } catch {}
for (const id of ids) {
const a = account(id);
if (a?.gate?.type === 'bns' && a.gate.name) nameIndex.set(String(a.gate.name).toLowerCase(), id);
}
}
return nameIndex.get(String(name).toLowerCase()) || null;
}
function createAccount({ id, pubkey, gate }) {
const slots = readJson(slotsFile, { next: 0 });
@ -216,7 +232,7 @@ export function createSupervisor(cfg, log = console.log, { reservedKey = () => f
}
return {
dir, dataDir, configFile, account, createAccount, saveAccount, setState,
dir, dataDir, configFile, account, accountByName, createAccount, saveAccount, setState,
load, ensure, close, flushQuietly, bucketClient, mapBuckets, indexKeys, hold, keyTaken,
ownerOfKey: (akid) => (Object.hasOwn(keyIndex, akid) ? keyIndex[akid] : null),
async closeAll() {

View file

@ -0,0 +1,28 @@
// Public folder pages are served at /p/<who>/<drive>/<path>/ and also as
// navigate.st/s3/…, so every link in them must be relative to the folder.
import test from 'node:test';
import assert from 'node:assert/strict';
import { folderPage } from '../server.js';
test('folder page links are relative, names escaped, media playable', () => {
const html = folderPage({
owner: 'alice.x', bucket: 'music-house', key: 'Deep/Night/',
listing: { folders: ['Remixes/'], files: [{ name: 'A <i>song & co.mp3', size: 9_437_184 }, { name: 'cover.jpg', size: 2048 }, { name: 'notes.txt', size: 12 }], next: null },
});
assert.match(html, /<a href="Remixes\/">/);
assert.match(html, /href="A%20%3Ci%3Esong%20%26%20co\.mp3"/);
assert.match(html, /A &lt;i&gt;song &amp; co\.mp3/);
assert.doesNotMatch(html, /<i>song/);
assert.match(html, /data-kind="audio"/);
assert.match(html, /<img loading="lazy" src="cover\.jpg"/);
// Breadcrumbs climb with ../ only: drive root is two levels up.
assert.match(html, /<a href="\.\.\/\.\.\/">music-house<\/a> \/ <a href="\.\.\/">Deep<\/a> \/ <span>Night<\/span>/);
assert.doesNotMatch(html, /href="\/p\//);
});
test('drive root page has no stray parents', () => {
const html = folderPage({ owner: '0123', bucket: 'd', key: '', listing: { folders: [], files: [], next: 'tok/en' } });
assert.match(html, /This folder is empty/);
assert.match(html, /href="\?token=tok%2Fen"/);
assert.match(html, /<a href="\.\/">d<\/a>/);
});

View file

@ -2073,8 +2073,9 @@ async function folderShareDialog(user, bucket, folder) {
});
put(progress, '');
if (r.type === 'public') {
const at = `${hostedUrl()}/p/${state.status.hosted.id}/${bucket}/${folder}`;
put(out, secretRow(r.rights === 'read-list' ? 'Public address (lists the folder as JSON)' : 'Public address: add a file name', at),
const who = state.status.hosted.name || state.status.hosted.id;
const at = `https://navigate.st/s3/${encodeURIComponent(who)}/${encodeURIComponent(bucket)}/${folder.split('/').map(encodeURIComponent).join('/')}`;
put(out, secretRow(r.rights === 'read-list' ? 'Public address (a page anyone can open)' : 'Public address: add a file name', at),
r.rights !== 'read-list' && h('p', { class: 'small muted', style: 'margin:0' }, 'For example ', h('code', {}, `${at}index.html`), '.'));
}
else put(out, h('p', { class: 'small muted', style: 'margin:0' }, 'Give these to the person. The secret is shown here, and in this list, only to you.'),