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:
parent
fa1ecc954a
commit
7fa7f71be4
5 changed files with 132 additions and 9 deletions
28
hosted/package-lock.json
generated
Normal file
28
hosted/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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' });
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
28
hosted/test/public-page.test.js
Normal file
28
hosted/test/public-page.test.js
Normal 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 <i>song & 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>/);
|
||||
});
|
||||
|
|
@ -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.'),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue