diff --git a/hosted/package-lock.json b/hosted/package-lock.json new file mode 100644 index 0000000..45dda7d --- /dev/null +++ b/hosted/package-lock.json @@ -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" + } + } + } +} diff --git a/hosted/server.js b/hosted/server.js index fd87d2c..4f92c70 100644 --- a/hosted/server.js +++ b/hosted/server.js @@ -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///. 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = [`${PAGE_ESC(bucket)}`, ...parts.map((p, i) => i === parts.length - 1 ? `${PAGE_ESC(p)}` : `${PAGE_ESC(p)}`)].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) => `
  • 📁 ${PAGE_ESC(f.replace(/\/$/, ''))}
  • `), + ...listing.files.map((f) => { + const k = kind(f.name); + const media = k === 'img' ? `` : k ? `` : ''; + return `
  • ${media}${PAGE_ESC(f.name)}${fmtSize(f.size)}
  • `; + }), + ].join(''); + const more = listing.next ? `

    More…

    ` : ''; + return ` +${PAGE_ESC(parts.at(-1) || bucket)} · ${PAGE_ESC(owner)} + +
    ${PAGE_ESC(owner)} · public folder

    ${crumbs}

    +${rows ? `
      ${rows}
    ` : '

    This folder is empty.

    '}${more} +
    Shared with Pithos on Silent Mode. JSON
    +
    + +`; +} + function json(res, status, body) { const data = JSON.stringify(body); res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); diff --git a/hosted/supervisor.js b/hosted/supervisor.js index e89cebe..310d960 100644 --- a/hosted/supervisor.js +++ b/hosted/supervisor.js @@ -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//… 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() { diff --git a/hosted/test/public-page.test.js b/hosted/test/public-page.test.js new file mode 100644 index 0000000..ed3d799 --- /dev/null +++ b/hosted/test/public-page.test.js @@ -0,0 +1,28 @@ +// Public folder pages are served at /p//// 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 song & co.mp3', size: 9_437_184 }, { name: 'cover.jpg', size: 2048 }, { name: 'notes.txt', size: 12 }], next: null }, + }); + assert.match(html, //); + assert.match(html, /href="A%20%3Ci%3Esong%20%26%20co\.mp3"/); + assert.match(html, /A <i>song & co\.mp3/); + assert.doesNotMatch(html, /song/); + assert.match(html, /data-kind="audio"/); + assert.match(html, /music-house<\/a> \/ Deep<\/a> \/ 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, /d<\/a>/); +}); diff --git a/ui/app.js b/ui/app.js index f6b8508..125d860 100644 --- a/ui/app.js +++ b/ui/app.js @@ -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.'),