From 2d9b0a7a5e64b06d3009443a2120eb0ba5b65a6a Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 03:49:23 +0200 Subject: [PATCH] Pithos: open HTML files as a page, or read them as text An .html file in a drive could only be read as source. It can now also be opened as a web page in a new tab (Theseus tab, browser tab, or the system browser from the desktop app), with the CSS, images, scripts and pages beside it loading as they would from a website. The page is served under a sandbox CSP without allow-same-origin, so its scripts run in an opaque origin: they cannot read the Pithos session or reach its API, only the files of the folder the page link covers. Links are random, last an hour from last use (12 hours at most), and refuse paths that leave the folder. Pages decrypt on the fly for drives hosted on Silent Mode. --- core/hosted.js | 3 +- core/server.js | 75 +++++++++++++++++++++++++++++++++++++++++++++++- desktop/main.cjs | 6 +++- theseus/index.js | 9 ++++++ ui/app.js | 18 ++++++++++-- 5 files changed, 106 insertions(+), 5 deletions(-) diff --git a/core/hosted.js b/core/hosted.js index cb0f12a..628b692 100644 --- a/core/hosted.js +++ b/core/hosted.js @@ -124,7 +124,8 @@ export function plainCovered(rules, bucket, key) { } const MIME = { - html: 'text/html', htm: 'text/html', txt: 'text/plain', md: 'text/markdown', css: 'text/css', js: 'text/javascript', + html: 'text/html', htm: 'text/html', txt: 'text/plain', md: 'text/markdown', css: 'text/css', js: 'text/javascript', mjs: 'text/javascript', + wasm: 'application/wasm', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf', webmanifest: 'application/manifest+json', json: 'application/json', xml: 'application/xml', csv: 'text/csv', pdf: 'application/pdf', zip: 'application/zip', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', ico: 'image/x-icon', mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac', m4a: 'audio/mp4', opus: 'audio/opus', diff --git a/core/server.js b/core/server.js index 7fb7709..c38494d 100644 --- a/core/server.js +++ b/core/server.js @@ -26,6 +26,7 @@ import { readPrefs, writePrefs } from './prefs.js'; import { createAutoFlush, clampMinutes, DEFAULT_MINUTES } from './autoflush.js'; import { installHosted } from './hosted-routes.js'; import { accountInfo, readConnection, fingerprint } from './sia-account.js'; +import { guessType } from './hosted.js'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const UI_DIR = path.join(HERE, '..', 'ui'); @@ -418,6 +419,68 @@ export async function createPithos(opts = {}) { return { prefix }; }); // Small text files for the viewer (first 512 KiB). + // HTML opened as a page. A ticket covers the folder the page sits in, so + // its relative CSS, images, scripts and links resolve; it lasts an hour from + // the last use (12 hours at most). See servePage for the sandbox. + const pages = new Map(); // id -> { user, bucket, prefix, exp, until } + route('POST', '/api/s3/:user/buckets/:bucket/page', async (req, url, p) => { + const { key } = await jsonBody(req); + if (typeof key !== 'string' || !/\.html?$/i.test(key)) throw new HttpError(400, 'only .html files open as pages'); + const now = Date.now(); + for (const [k, v] of pages) if (v.exp < now) pages.delete(k); + if (pages.size >= 200) throw new HttpError(429, 'too many pages open; close some and try again'); + const cut = key.lastIndexOf('/') + 1; + const id = crypto.randomBytes(24).toString('base64url'); + pages.set(id, { user: p.user, bucket: p.bucket, prefix: key.slice(0, cut), exp: now + 60 * 60_000, until: now + 12 * 3600_000 }); + return { path: `/page/${id}/${key.slice(cut).split('/').map(encodeURIComponent).join('/')}` }; + }); + + // The page and its neighbours. The sandbox CSP (no allow-same-origin) gives + // the page an opaque origin: its scripts run, but requests they make to this + // server are cross-site, so they carry no session cookie and cannot pass + // the x-pithos check. They can only read files the ticket covers. + // The page loads whatever it links to (it is the user's own site); the + // sandbox is the boundary, not a source list. + const PAGE_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads; frame-ancestors 'self'"; + async function servePage(req, res, id, rest) { + const t = pages.get(id); + const now = Date.now(); + if (!t || t.exp < now) { res.writeHead(410, { 'content-type': 'text/plain; charset=utf-8' }); return res.end('This page link has expired. Open the file from Pithos again.'); } + t.exp = Math.min(now + 60 * 60_000, t.until); + let segs; + try { segs = rest.split('/').map((x) => decodeURIComponent(x)); } catch { res.writeHead(400); return res.end('bad path'); } + if (!segs.length || segs[segs.length - 1] === '') segs[segs.length ? segs.length - 1 : 0] = 'index.html'; + // An encoded slash would make one "segment" span folders (..%2F..). + if (segs.some((x) => !x || x === '.' || x === '..' || /[/\\]/.test(x))) { res.writeHead(400); return res.end('bad path'); } + const key = t.prefix + segs.join('/'); + const up = await (await s3For(t.user)).getObject(t.bucket, key, { range: req.headers.range }); + if (up.statusCode >= 300 && up.statusCode !== 304) { + up.resume?.(); + res.writeHead(up.statusCode === 416 ? 416 : 404, { 'content-type': 'text/plain; charset=utf-8', 'content-security-policy': PAGE_CSP }); + return res.end(up.statusCode === 416 ? 'range not satisfiable' : 'not found'); + } + // The extension decides for web files: S3 clients often store HTML, CSS or + // scripts as octet-stream (or anything), and a page must load them as such. + const guessed = guessType(key); + const stored = up.headers['content-type']; + let type = guessed !== 'application/octet-stream' ? guessed : (stored || guessed); + if (/^text\/|javascript|json|xml|svg/i.test(type) && !/charset=/i.test(type)) type += '; charset=utf-8'; + const headers = { + 'content-type': type, + 'content-security-policy': PAGE_CSP, + 'x-content-type-options': 'nosniff', + 'cache-control': 'no-store', + // The sandboxed page is cross-origin even to itself; its scripts may read + // the files its ticket covers (the ticket in the URL is the secret). + 'access-control-allow-origin': '*', + }; + for (const h of ['content-length', 'content-range', 'accept-ranges', 'last-modified', 'etag']) if (up.headers[h]) headers[h] = up.headers[h]; + res.writeHead(up.statusCode, headers); + if (req.method === 'HEAD') { up.resume?.(); return res.end(); } + up.on('error', () => res.destroy()); + up.pipe(res); + } + route('GET', '/api/s3/:user/buckets/:bucket/text', async (req, url, p) => { const up = await (await s3For(p.user)).getObject(p.bucket, requireKey(url), { range: 'bytes=0-524287' }); const body = await readBody(up); @@ -536,8 +599,18 @@ export async function createPithos(opts = {}) { url = new URL(t.path, url); viaTicket = true; } - res.setHeader('x-frame-options', 'DENY'); res.setHeader('referrer-policy', 'no-referrer'); + const page = /^\/page\/([A-Za-z0-9_-]{20,})\/(.*)$/.exec(url.pathname); + if (page && (req.method === 'GET' || req.method === 'HEAD')) { + // A page may frame its own folder's files; nothing else frames Pithos. + res.setHeader('x-frame-options', 'SAMEORIGIN'); + try { return await servePage(req, res, page[1], page[2]); } catch (e) { + if (res.headersSent) return res.destroy(); + res.writeHead(e instanceof S3Error && e.status < 500 ? e.status : 502, { 'content-type': 'text/plain; charset=utf-8' }); + return res.end(e.message); + } + } + res.setHeader('x-frame-options', 'DENY'); try { // Session bootstrap: ?t= on any page swaps the secret for a cookie. diff --git a/desktop/main.cjs b/desktop/main.cjs index 9e43657..d52ba43 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -57,7 +57,11 @@ async function start() { } }); win.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith('https://')) shell.openExternal(url); + // HTML files opened as pages go to the system browser: the link carries + // its own ticket, and the page is sandboxed by the server. + const u = new URL(url); + if (u.origin === origin && u.pathname.startsWith('/page/')) shell.openExternal(url); + else if (url.startsWith('https://')) shell.openExternal(url); return { action: 'deny' }; }); diff --git a/theseus/index.js b/theseus/index.js index 7d15085..16af122 100644 --- a/theseus/index.js +++ b/theseus/index.js @@ -182,6 +182,15 @@ module.exports = { } }); } + // An HTML file from a drive, opened as a page in a new Theseus tab. The + // control server sandboxes it (no access to Pithos or the session). + api.onMessage("open-page", async ({ path: pagePath } = {}) => { + if (!/^\/page\/[A-Za-z0-9_-]{20,}\/[^\s]*$/.test(String(pagePath || ""))) throw new Error("not a page link"); + const p = await ensureServer(); + api.openTab(new URL(pagePath, p.url).href); + return { ok: true }; + }); + // Full page: Pithos at pithos.sia///… in an ordinary tab. // An older Theseus without site routes gets the loopback address. api.onMessage("open-in-tab", async ({ path: appPath } = {}) => { diff --git a/ui/app.js b/ui/app.js index 0bc01ad..664f4fc 100644 --- a/ui/app.js +++ b/ui/app.js @@ -1194,6 +1194,7 @@ function fileKind(key) { if (['txt', 'md', 'json', 'csv', 'log', 'xml', 'yml', 'yaml', 'toml', 'ini', 'html', 'htm', 'css', 'js', 'mjs', 'ts', 'py', 'go', 'rs', 'sh', 'sql'].includes(e)) return 'text'; return null; } +const isHtml = (key) => ['html', 'htm'].includes(EXT(key)); function suggestBucket(name) { let b = String(name || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); if (b && b.length < 3) b += '-files'; @@ -1453,7 +1454,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { const folderName = (f) => f.slice(prefix.length).replace(/\/$/, ''); const fileName = (o) => o.key.slice(prefix.length); const folderMenu = (f) => itemMenu([['Open', () => go(f)], isHosted() && ['Share…', () => folderShareDialog(user, bucket, f)], ['Delete', () => deleteFolder(f), 'danger']]); - const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : 'Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]); + const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : isHtml(o.key) ? 'Show as text' : 'Open', () => openViewer(o)], isHtml(o.key) && ['Open as page ↗', () => openPage(o.key)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]); const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more')); const stop = (e) => e.stopPropagation(); // Narrow screens: Download and Share as icons beside the ⋯ menu. @@ -1613,7 +1614,20 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { } else content = h('div', { class: 'empty' }, h('p', {}, 'Pithos cannot show this kind of file.'), h('p', { class: 'small' }, 'Download it to open it on your computer.')); } catch (e) { return fail(e); } await modal(name, h('div', { class: 'viewer' }, content, h('p', { class: 'small muted', style: 'margin:10px 0 0' }, `${fmtBytes(o.size)} · ${fmtDate(o.modified)}`)), - [{ label: 'Share', value: () => { share(o.key); return false; } }, { label: 'Download', value: () => { downloadObject(o.key); return false; } }, { label: 'Close', kind: 'primary', submit: true, result: true }]); + [isHtml(o.key) && { label: 'Open as page ↗', value: () => { openPage(o.key); return false; } }, + { label: 'Share', value: () => { share(o.key); return false; } }, { label: 'Download', value: () => { downloadObject(o.key); return false; } }, { label: 'Close', kind: 'primary', submit: true, result: true }].filter(Boolean)); + } + + // An HTML file as a web page, in a new tab. The server sandboxes it, so its + // scripts run without any access to Pithos; files beside it (CSS, images, + // other pages) load as they would from a website. + async function openPage(key) { + try { + const { path } = await api('POST', `${base}/page`, { key }); + if (bridge) { await bridge.invoke('open-page', { path }); return; } + const w = window.open(path, '_blank', 'noopener'); + if (!w && !/^(desktop)$/.test(state.status?.host)) toast('Allow pop-ups for Pithos to open pages in a new tab', 'error'); + } catch (e) { fail(e); } } // Music plays in the docked player, on through this folder's other tracks.