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.