2026-10-03 19:03:05 +02:00
// The Pithos control server: one JSON API + the static UI. Every host (web
// console, desktop window, Theseus tab) runs exactly this and points a view
// at it, so features are written once.
//
// Auth: a session secret. Local hosts pass it in the first URL (?t=...);
// the web console can instead accept a password. Either way it is exchanged
// for an HttpOnly SameSite=Strict cookie, and mutating requests also need the
// x-pithos header so a cross-site form can never drive the API.
import http from 'node:http' ;
import fs from 'node:fs' ;
import path from 'node:path' ;
import crypto from 'node:crypto' ;
import { fileURLToPath } from 'node:url' ;
import { resolveConfigPath , resolveBinary } from './paths.js' ;
import { readConfig , writeConfig , ensureConfig , EDITABLE } from './config.js' ;
import { Daemon } from './daemon.js' ;
import { makeCli , CliError } from './cli.js' ;
import { LoginSession } from './login.js' ;
import { S3Client , S3Error , publicReadPolicy , readBody } from './s3.js' ;
import * as admin from './admin.js' ;
import { installS3d , S3D _VERSION , assetForPlatform } from './binary.js' ;
2026-10-03 22:57:47 +02:00
import * as accounts from './accounts.js' ;
2026-10-04 00:05:50 +02:00
import { readPrefs , writePrefs } from './prefs.js' ;
2026-10-04 03:36:50 +02:00
import { createAutoFlush , clampMinutes , DEFAULT _MINUTES } from './autoflush.js' ;
import { installHosted } from './hosted-routes.js' ;
import { accountInfo , readConnection , fingerprint } from './sia-account.js' ;
2026-10-04 04:06:04 +02:00
import { guessType } from './hosted.js' ;
import { accountSpace } from './space.js' ;
2026-10-04 18:27:05 +02:00
import { createShareUrl } from './sia-share.js' ;
2026-10-03 19:03:05 +02:00
const HERE = path . dirname ( fileURLToPath ( import . meta . url ) ) ;
const UI _DIR = path . join ( HERE , '..' , 'ui' ) ;
const MIME = {
'.html' : 'text/html; charset=utf-8' , '.js' : 'text/javascript; charset=utf-8' , '.css' : 'text/css; charset=utf-8' ,
'.svg' : 'image/svg+xml' , '.png' : 'image/png' , '.ico' : 'image/x-icon' , '.json' : 'application/json' ,
'.woff2' : 'font/woff2' ,
} ;
class HttpError extends Error {
constructor ( status , message ) { super ( message ) ; this . status = status ; }
}
export async function createPithos ( opts = { } ) {
const {
host = '127.0.0.1' ,
port = 0 ,
configFile : configOpt ,
binary : binaryOpt ,
binDir , // where installS3d puts the pinned release
password , // web console: optional login password
allowedHosts = [ ] , // extra Host header values (reverse proxy names)
hostName = 'web' , // 'web' | 'desktop' | 'theseus' — UI tweaks only
openExternal , // (url) => void, for hosts that can open the system browser
2026-10-05 01:47:37 +02:00
openBrowser = openExternal , // (url) => void: the user's default browser (Theseus opens openExternal in its own tab)
// ({ path }) => Promise<boolean>: the host asks the user, in its own UI, whether
// a browser that came to the open-in-Pithos address may be signed in.
confirmBrowserOpen ,
handoffPort = HANDOFF _PORT , // fixed loopback port the pithos.sia website links to; null = off
2026-10-04 00:05:50 +02:00
showPanel , // () => void, for hosts with a side panel to switch back to (Theseus)
2026-10-04 03:36:50 +02:00
secrets , // hosted identity + encryption keys; the Theseus vault, else a local key file
2026-10-03 19:03:05 +02:00
} = opts ;
const configFile = resolveConfigPath ( configOpt ) ;
const daemon = new Daemon ( { configFile , binary : resolveBinary ( binaryOpt , binDir ) } ) ;
const cli = makeCli ( daemon ) ;
2026-10-03 20:42:23 +02:00
const login = new LoginSession ( daemon , {
registration : ( ) => cli . registration ( ) ,
// Hosts without node-pty (the Theseus add-on) fetch it here on first login.
ptyDir : binDir ? path . join ( binDir , '..' , 'pty' ) : null ,
} ) ;
2026-10-03 19:03:05 +02:00
const secret = crypto . randomBytes ( 24 ) . toString ( 'base64url' ) ;
const sessions = new Set ( ) ;
2026-10-05 01:47:37 +02:00
// Sessions of a browser this host opened (or let in), not the host's own
// window: the UI leaves out the controls that only make sense in the host.
const externalSessions = new Set ( ) ;
// One-time sign-in links handed to a browser: id -> expiry. Unlike the
// host's ?t= link they work once, for a minute.
const signins = new Map ( ) ;
function signInUrl ( route = '' ) {
for ( const [ k , exp ] of signins ) if ( exp < Date . now ( ) ) signins . delete ( k ) ;
const id = crypto . randomBytes ( 24 ) . toString ( 'base64url' ) ;
signins . set ( id , Date . now ( ) + 60_000 ) ;
const frag = /^#\/[A-Za-z0-9._~%/=-]{0,300}$/ . test ( route ) ? route : '' ;
return ` ${ baseUrl } ?s= ${ id } ${ frag } ` ;
}
2026-10-03 19:34:55 +02:00
// One-time download links: id -> { path, exp }. Lets a host hand a file
// to its own download manager, which carries no session cookie.
const tickets = new Map ( ) ;
2026-10-03 19:03:05 +02:00
const subscribers = new Set ( ) ;
let versionCache = null ;
2026-10-04 02:38:21 +02:00
// Pithos's own version: addon.json in the Theseus add-on, package.json elsewhere.
const pithosVersion = ( ( ) => {
for ( const f of [ '../addon.json' , '../package.json' ] ) {
try { const v = JSON . parse ( fs . readFileSync ( new URL ( f , import . meta . url ) , 'utf8' ) ) . version ; if ( v ) return v ; } catch { }
}
return null ;
} ) ( ) ;
2026-10-03 19:03:05 +02:00
2026-10-04 03:36:50 +02:00
let hosted = null ; // "On Silent Mode", installed below
2026-10-03 19:03:05 +02:00
const broadcast = ( event , data ) => {
const msg = ` event: ${ event } \n data: ${ JSON . stringify ( data ) } \n \n ` ;
for ( const res of subscribers ) res . write ( msg ) ;
} ;
daemon . on ( 'status' , ( s ) => {
// A registration done outside Pithos (s3d login by hand) shows up on the next start.
if ( s . state === 'starting' && ! regCache ? . registered ) regCache = null ;
2026-10-04 03:36:50 +02:00
// While drives are hosted the UI follows the server's s3d instead.
if ( ! hosted ? . isHosted ( ) ) broadcast ( 'status' , s ) ;
2026-10-03 19:03:05 +02:00
} ) ;
daemon . on ( 'log' , ( l ) => broadcast ( 'log' , l ) ) ;
login . on ( 'state' , ( s ) => {
if ( s . state === 'done' || s . state === 'already' ) regCache = { registered : true , indexerUrl : s . indexerUrl || regCache ? . indexerUrl } ;
broadcast ( 'login' , s ) ;
} ) ;
// Registration costs an s3d process run, so it is cached and only
// recomputed when something that could change it happens.
let regCache = null ;
2026-10-04 00:05:50 +02:00
let lastAutoTry = 0 ;
2026-10-03 19:03:05 +02:00
async function registration ( ) {
2026-10-03 22:57:47 +02:00
// A "not connected" answer is re-checked after 30 s: the connection can
// be made outside Pithos (s3d login by hand, another Pithos).
if ( regCache && ! regCache . registered && Date . now ( ) - ( regCache . at || 0 ) > 30_000 ) regCache = null ;
if ( ! regCache && daemon . binary ) {
const r = await cli . registration ( ) . catch ( ( ) => null ) ;
regCache = r ? { ... r , at : Date . now ( ) } : null ;
}
2026-10-03 19:03:05 +02:00
return regCache ;
}
function cfg ( ) { return readConfig ( configFile ) ; }
async function version ( ) {
if ( ! daemon . binary ) return null ;
if ( versionCache ? . binary === daemon . binary ) return versionCache . info ;
try {
const info = await cli . version ( ) ;
versionCache = { binary : daemon . binary , info } ;
return info ;
} catch ( e ) {
return { version : null , error : e . message } ;
}
}
// An s3d we did not start (a service, or one run by hand) that answers on
// the configured admin address.
async function probeExternal ( c ) {
if ( daemon . child || ! c . adminPassword ) return false ;
try { await admin . uploadStats ( c ) ; return true ; } catch { return false ; }
}
async function s3For ( user ) {
2026-10-04 03:36:50 +02:00
if ( hosted . isHosted ( ) ) return hosted . s3For ( user ) ;
2026-10-03 19:03:05 +02:00
const keys = await cli . listKeys ( user ) ;
if ( ! keys . length ) throw new HttpError ( 409 , ` user " ${ user } " has no access keys yet - create one first ` ) ;
2026-10-04 03:36:50 +02:00
const decrypting = await hosted . s3For ( user , keys ) ;
if ( decrypting ) return decrypting ;
2026-10-03 19:03:05 +02:00
const c = cfg ( ) ;
return new S3Client ( { endpoint : ` http:// ${ c . apiAddress } ` , accessKeyId : keys [ 0 ] . accessKeyId , secretKey : keys [ 0 ] . secretKey } ) ;
}
// ---- routing ---------------------------------------------------------
const routes = [ ] ;
const route = ( method , pattern , handler ) => {
const keys = [ ] ;
const re = new RegExp ( '^' + pattern . replace ( /:(\w+)/g , ( _ , k ) => { keys . push ( k ) ; return '([^/]+)' ; } ) + '$' ) ;
routes . push ( { method , re , keys , handler } ) ;
} ;
route ( 'GET' , '/api/status' , async ( ) => {
const c = cfg ( ) ;
2026-10-04 00:05:50 +02:00
// Backstop for the start-up autostart: opening Pithos also brings s3d
// back if the user wants it running (at most every 30 s).
if ( ! daemon . child && daemon . state !== 'starting' && Date . now ( ) - lastAutoTry > 30_000 && readPrefs ( configFile ) . autostart === true ) {
lastAutoTry = Date . now ( ) ;
autostart ( 'Pithos was opened' ) ;
}
2026-10-03 19:03:05 +02:00
const v = await version ( ) ;
2026-10-04 03:36:50 +02:00
const local = {
2026-10-03 19:03:05 +02:00
host : hostName ,
2026-10-04 02:38:21 +02:00
pithos : pithosVersion ,
2026-10-04 22:02:15 +02:00
// electron-builder's portable exe sets this; updates then fetch the portable build.
portable : hostName === 'desktop' && ! ! process . env . PORTABLE _EXECUTABLE _FILE ,
2026-10-04 22:49:48 +02:00
platform : process . platform ,
2026-10-03 19:03:05 +02:00
daemon : daemon . status ( ) ,
external : await probeExternal ( c ) ,
s3d : { ... v , pinned : S3D _VERSION , installable : ! ! assetForPlatform ( ) , outdated : ! ! ( v ? . version && cmpVersion ( v . version , S3D _VERSION ) < 0 ) } ,
config : { file : c . file , exists : c . exists , apiAddress : c . apiAddress , adminAddress : c . adminAddress , directory : c . directory , https : c . data . apiHTTPSAddress || null } ,
login : login . snapshot ( ) ,
registration : await registration ( ) ,
} ;
2026-10-04 03:36:50 +02:00
return hosted . isHosted ( ) ? hosted . hostedStatus ( local ) : local ;
2026-10-03 19:03:05 +02:00
} ) ;
2026-10-04 00:05:50 +02:00
// Start and Stop also record what the user wants, so s3d comes back by
// itself after Theseus restarts or Pithos is updated.
2026-10-03 19:03:05 +02:00
route ( 'POST' , '/api/daemon/start' , async ( ) => {
ensureConfig ( configFile ) ;
2026-10-04 00:05:50 +02:00
writePrefs ( configFile , { autostart : true } ) ;
2026-10-03 19:03:05 +02:00
return daemon . start ( ) ;
} ) ;
2026-10-04 00:05:50 +02:00
route ( 'POST' , '/api/daemon/stop' , ( ) => { writePrefs ( configFile , { autostart : false } ) ; return daemon . stop ( ) ; } ) ;
2026-10-03 19:03:05 +02:00
route ( 'POST' , '/api/daemon/restart' , async ( ) => {
await daemon . stop ( ) ;
ensureConfig ( configFile ) ;
return daemon . start ( ) ;
} ) ;
route ( 'GET' , '/api/logs' , ( req , url ) => daemon . logsSince ( Number ( url . searchParams . get ( 'since' ) || 0 ) ) ) ;
route ( 'POST' , '/api/binary/install' , async ( ) => {
if ( daemon . child ) throw new HttpError ( 409 , 'stop s3d before replacing its binary' ) ;
if ( ! binDir ) throw new HttpError ( 400 , 'this host has no writable bin folder' ) ;
const target = await installS3d ( binDir , ( p ) => broadcast ( 'install' , p ) ) ;
daemon . binary = target ;
versionCache = null ;
regCache = null ;
return { binary : target , version : await version ( ) } ;
} ) ;
route ( 'GET' , '/api/config' , ( ) => {
const c = cfg ( ) ;
const values = { } ;
for ( const k of EDITABLE ) values [ k ] = getIn ( c . data , k . split ( '.' ) ) ? ? null ;
return { file : c . file , exists : c . exists , values } ;
} ) ;
route ( 'PUT' , '/api/config' , async ( req ) => {
const patch = await jsonBody ( req ) ;
const c = writeConfig ( configFile , patch ) ;
if ( 'directory' in patch ) regCache = null ;
return { file : c . file , restartNeeded : ! ! daemon . child } ;
} ) ;
// Login (indexer registration)
route ( 'GET' , '/api/login' , ( ) => login . snapshot ( ) ) ;
route ( 'POST' , '/api/login' , async ( req ) => {
const { indexerUrl , phrase } = await jsonBody ( req ) ;
if ( phrase && phrase . trim ( ) . split ( /\s+/ ) . length !== 12 ) throw new HttpError ( 400 , 'a recovery phrase is 12 words' ) ;
if ( indexerUrl && ! /^https?:\/\/[^\s]+$/ . test ( indexerUrl ) ) throw new HttpError ( 400 , 'indexer URL must be http(s)' ) ;
ensureConfig ( configFile ) ;
return login . start ( { indexerUrl : indexerUrl ? . trim ( ) , phrase : phrase ? . trim ( ) . toLowerCase ( ) . split ( /\s+/ ) . join ( ' ' ) } ) ;
} ) ;
route ( 'POST' , '/api/login/confirm' , ( ) => { login . confirmGenerated ( ) ; return login . snapshot ( ) ; } ) ;
route ( 'POST' , '/api/login/cancel' , ( ) => { login . cancel ( ) ; return login . snapshot ( ) ; } ) ;
route ( 'POST' , '/api/open-external' , async ( req ) => {
const { url } = await jsonBody ( req ) ;
if ( ! openExternal ) throw new HttpError ( 400 , 'not supported by this host' ) ;
if ( ! /^https:\/\// . test ( url ) ) throw new HttpError ( 400 , 'only https links' ) ;
openExternal ( url ) ;
return { ok : true } ;
} ) ;
// Users & keys
route ( 'GET' , '/api/users' , async ( ) => {
const [ users , keys ] = await Promise . all ( [ cli . listUsers ( ) , cli . listKeys ( ) ] ) ;
return users . map ( ( name ) => ( { name , keys : keys . filter ( ( k ) => k . user === name ) . map ( ( k ) => k . accessKeyId ) } ) ) ;
} ) ;
2026-10-04 00:05:50 +02:00
// Every user gets a bucket of their own (named after them unless the
// caller picks a name), so there is somewhere to put files straight away.
2026-10-03 19:03:05 +02:00
route ( 'POST' , '/api/users' , async ( req ) => {
2026-10-04 00:05:50 +02:00
const { name , withKey = true , bucket } = await jsonBody ( req ) ;
2026-10-03 19:03:05 +02:00
await cli . createUser ( name ) ;
const key = withKey ? await cli . createKey ( name ) : null ;
2026-10-04 00:05:50 +02:00
let created = null ;
let bucketError = null ;
if ( key && bucket !== false && daemon . state === 'running' ) {
const client = new S3Client ( { endpoint : ` http:// ${ cfg ( ) . apiAddress } ` , accessKeyId : key . accessKeyId , secretKey : key . secretKey } ) ;
const base = bucket ? String ( bucket ) : bucketNameFor ( name ) ;
for ( let i = 0 ; i < 5 && ! created ; i ++ ) {
const candidate = i ? ` ${ base . slice ( 0 , 60 ) } - ${ i + 1 } ` : base ;
try { await client . createBucket ( candidate ) ; created = candidate ; }
catch ( e ) {
if ( e . code === 'BucketAlreadyExists' || e . code === 'BucketAlreadyOwnedByYou' ) continue ;
bucketError = e . message ;
break ;
}
}
}
return { name , key , bucket : created , bucketError } ;
2026-10-03 19:03:05 +02:00
} ) ;
route ( 'DELETE' , '/api/users/:name' , async ( req , url , p ) => { await cli . deleteUser ( p . name ) ; return { ok : true } ; } ) ;
route ( 'GET' , '/api/keys' , ( req , url ) => cli . listKeys ( url . searchParams . get ( 'user' ) || undefined ) ) ;
route ( 'POST' , '/api/keys' , async ( req ) => {
const { user , accessKey , secretKey } = await jsonBody ( req ) ;
return cli . createKey ( user , { accessKey , secretKey } ) ;
} ) ;
route ( 'DELETE' , '/api/keys/:id' , async ( req , url , p ) => { await cli . deleteKey ( p . id ) ; return { ok : true } ; } ) ;
// Upload pipeline
2026-10-03 22:57:47 +02:00
// Sia account: which one this s3d uses, a user-set label (Pithos cannot
// see the account's email), switching to another and restoring an old one.
2026-10-04 03:36:50 +02:00
// Which Sia account this is, as the indexer sees it (public key, storage,
// last use), so it can be matched to an app on the sia.storage dashboard.
// Asked at most once a minute; ?fresh=1 skips the cache.
let siaCache = null ;
async function siaAccount ( dir , fresh ) {
if ( ! fresh && siaCache && siaCache . dir === dir && Date . now ( ) - siaCache . at < 60_000 ) return siaCache . info ;
let info ;
try { info = await accountInfo ( dir ) ; } catch ( e ) { info = { error : e . message } ; }
siaCache = { dir , at : Date . now ( ) , info } ;
return info ;
}
// An archive's key is read from its own s3d.db, without asking anyone.
async function archiveKey ( dir ) {
try {
const c = await readConnection ( dir ) ;
return c ? . appKey ? fingerprint ( c . appKey . subarray ( 32 ) . toString ( 'hex' ) ) : null ;
} catch { return null ; }
}
route ( 'GET' , '/api/account' , async ( req , url ) => {
2026-10-03 22:57:47 +02:00
const c = cfg ( ) ;
const reg = await registration ( ) ;
2026-10-04 03:36:50 +02:00
const archives = accounts . listArchives ( c . directory ) ;
for ( const a of archives ) a . fingerprint = await archiveKey ( path . join ( c . directory , a . name ) ) ;
2026-10-03 22:57:47 +02:00
return {
indexerUrl : reg ? . indexerUrl || null ,
registered : ! ! reg ? . registered ,
label : accounts . readLabel ( c . directory ) ,
dataDir : c . directory ,
2026-10-04 03:36:50 +02:00
sia : reg ? . registered ? await siaAccount ( c . directory , url . searchParams . get ( 'fresh' ) === '1' ) : null ,
archives ,
2026-10-03 22:57:47 +02:00
} ;
} ) ;
route ( 'PUT' , '/api/account/label' , async ( req ) => {
const { label } = await jsonBody ( req ) ;
return { label : accounts . writeLabel ( cfg ( ) . directory , label ) } ;
} ) ;
async function accountGuard ( ) {
if ( await probeExternal ( cfg ( ) ) ) throw new HttpError ( 409 , 'another s3d process is serving this config; stop it first' ) ;
if ( login . state !== 'idle' && ! [ 'done' , 'already' , 'failed' ] . includes ( login . state ) ) throw new HttpError ( 409 , 'a Sia login is in progress' ) ;
}
async function currentMeta ( ) {
const reg = await registration ( ) . catch ( ( ) => null ) ;
const users = await cli . listUsers ( ) . catch ( ( ) => [ ] ) ;
return { indexerUrl : reg ? . indexerUrl || null , users } ;
}
route ( 'POST' , '/api/account/switch' , async ( ) => {
await accountGuard ( ) ;
const meta = await currentMeta ( ) ;
await daemon . stop ( ) ;
const r = accounts . archiveCurrent ( cfg ( ) . directory , meta ) ;
regCache = null ;
login . cancel ( ) ;
return { archived : r . name , path : r . path } ;
} ) ;
route ( 'POST' , '/api/account/restore' , async ( req ) => {
const { name } = await jsonBody ( req ) ;
await accountGuard ( ) ;
const meta = await currentMeta ( ) ;
await daemon . stop ( ) ;
const r = accounts . restoreArchive ( cfg ( ) . directory , String ( name || '' ) , meta ) ;
regCache = null ;
login . cancel ( ) ;
return r ;
} ) ;
2026-10-03 19:03:05 +02:00
route ( 'GET' , '/api/stats' , ( ) => admin . uploadStats ( cfg ( ) ) ) ;
route ( 'POST' , '/api/flush' , async ( ) => { await admin . flush ( cfg ( ) ) ; return { ok : true } ; } ) ;
2026-10-04 03:36:50 +02:00
const autoFlush = createAutoFlush ( {
settings : ( ) => {
const p = readPrefs ( configFile ) ;
2026-10-04 14:39:45 +02:00
// On unless the user turned it off: files left in the buffer otherwise
// never reach Sia, and nobody expects to have to push them.
return { enabled : p . autoFlush !== false , minutes : p . autoFlushMinutes ? ? DEFAULT _MINUTES } ;
2026-10-04 03:36:50 +02:00
} ,
// Only an s3d that is up can be asked; a stopped one just means no flush.
stats : ( ) => ( daemon . state === 'starting' || daemon . state === 'stopping'
? Promise . reject ( new Error ( 'busy' ) ) : admin . uploadStats ( cfg ( ) ) ) ,
flush : ( ) => admin . flush ( cfg ( ) ) ,
log : ( line ) => daemon . pushLog ( 'sys' , line ) ,
} ) ;
route ( 'GET' , '/api/autoflush' , ( ) => autoFlush . status ( ) ) ;
route ( 'PUT' , '/api/autoflush' , async ( req ) => {
const body = await jsonBody ( req ) ;
const patch = { } ;
if ( body . enabled !== undefined ) patch . autoFlush = body . enabled === true ;
if ( body . minutes !== undefined ) patch . autoFlushMinutes = clampMinutes ( body . minutes ) ;
writePrefs ( configFile , patch ) ;
return autoFlush . status ( ) ;
} ) ;
2026-10-03 19:03:05 +02:00
// Buckets & objects, acting as one s3d user.
route ( 'GET' , '/api/s3/:user/buckets' , async ( req , url , p ) => ( await s3For ( p . user ) ) . listBuckets ( ) ) ;
route ( 'POST' , '/api/s3/:user/buckets' , async ( req , url , p ) => {
const { name } = await jsonBody ( req ) ;
if ( ! /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/ . test ( name || '' ) ) throw new HttpError ( 400 , 'bucket names are 3-63 chars: lowercase letters, digits, dots, hyphens' ) ;
await ( await s3For ( p . user ) ) . createBucket ( name ) ;
return { name } ;
} ) ;
route ( 'DELETE' , '/api/s3/:user/buckets/:bucket' , async ( req , url , p ) => {
await ( await s3For ( p . user ) ) . deleteBucket ( p . bucket ) ;
return { ok : true } ;
} ) ;
route ( 'GET' , '/api/s3/:user/buckets/:bucket/objects' , async ( req , url , p ) => {
const q = url . searchParams ;
return ( await s3For ( p . user ) ) . listObjects ( p . bucket , { prefix : q . get ( 'prefix' ) || '' , token : q . get ( 'token' ) || undefined } ) ;
} ) ;
route ( 'PUT' , '/api/s3/:user/buckets/:bucket/object' , async ( req , url , p ) => {
const key = requireKey ( url ) ;
const len = req . headers [ 'content-length' ] ;
if ( len == null ) throw new HttpError ( 411 , 'Content-Length required' ) ;
if ( Number ( len ) > 5 * 1024 * * 3 ) throw new HttpError ( 413 , 'single uploads are limited to 5 GiB' ) ;
await ( await s3For ( p . user ) ) . putObject ( p . bucket , key , req , { contentType : req . headers [ 'content-type' ] , contentLength : len } ) ;
return { key } ;
} ) ;
route ( 'GET' , '/api/s3/:user/buckets/:bucket/object' , async ( req , url , p , res ) => {
const key = requireKey ( url ) ;
const up = await ( await s3For ( p . user ) ) . getObject ( p . bucket , key , { range : req . headers . range } ) ;
if ( up . statusCode >= 300 && up . statusCode !== 304 ) {
throw new HttpError ( up . statusCode , ( await readBody ( up ) ) . match ( /<Message>(.*?)<\/Message>/ ) ? . [ 1 ] || ` HTTP ${ up . statusCode } ` ) ;
}
const headers = { } ;
for ( const h of [ 'content-type' , 'content-length' , 'content-range' , 'accept-ranges' , 'etag' , 'last-modified' ] ) if ( up . headers [ h ] ) headers [ h ] = up . headers [ h ] ;
const name = key . split ( '/' ) . pop ( ) || 'download' ;
headers [ 'content-disposition' ] = ` ${ url . searchParams . get ( 'inline' ) ? 'inline' : 'attachment' } ; filename*=UTF-8'' ${ encodeURIComponent ( name ) } ` ;
headers [ 'x-content-type-options' ] = 'nosniff' ;
// Objects are user content: never let them run script on this origin.
headers [ 'content-security-policy' ] = "sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'" ;
res . writeHead ( up . statusCode , headers ) ;
2026-10-04 03:36:50 +02:00
// A decryption failure mid-file must not look like a complete download.
up . on ( 'error' , ( ) => res . destroy ( ) ) ;
2026-10-03 19:03:05 +02:00
up . pipe ( res ) ;
return STREAMED ;
} ) ;
route ( 'DELETE' , '/api/s3/:user/buckets/:bucket/object' , async ( req , url , p ) => {
await ( await s3For ( p . user ) ) . deleteObject ( p . bucket , requireKey ( url ) ) ;
return { ok : true } ;
} ) ;
route ( 'DELETE' , '/api/s3/:user/buckets/:bucket/prefix' , async ( req , url , p ) => {
const prefix = url . searchParams . get ( 'prefix' ) ;
if ( ! prefix ) throw new HttpError ( 400 , 'prefix required' ) ;
return { deleted : await ( await s3For ( p . user ) ) . deletePrefix ( p . bucket , prefix ) } ;
} ) ;
2026-10-04 04:06:04 +02:00
// ---- renames: files, folders, drives -----------------------------------
// Server-side copies, so they are quick and use no extra Sia storage.
// Every client Pithos makes can rename; the check stays for any that
// cannot (encrypted names and contents are tied to their path, so a raw
// server-side copy would break them).
async function renamer ( user ) {
const s3 = await s3For ( user ) ;
if ( typeof s3 . renamePrefix !== 'function' ) throw new HttpError ( 409 , 'Renaming is not available for drives on Silent Mode yet.' ) ;
return s3 ;
}
const badName = ( n ) => ! n || n . includes ( '/' ) || n === '.' || n === '..' ;
async function exists ( s3 , bucket , prefix ) {
const page = await s3 . listObjects ( bucket , { prefix , delimiter : '' , max : 1 } ) ;
return page . objects . some ( ( o ) => o . key === prefix || prefix . endsWith ( '/' ) ) ;
}
// A file: { from: "<key>", name: "<new name>" } in the same folder.
route ( 'POST' , '/api/s3/:user/buckets/:bucket/rename-object' , async ( req , url , p ) => {
const { from , name } = await jsonBody ( req ) ;
if ( ! from || from . endsWith ( '/' ) || badName ( name ) ) throw new HttpError ( 400 , 'a file and a new name (without "/") are required' ) ;
const to = from . slice ( 0 , from . lastIndexOf ( '/' ) + 1 ) + name ;
if ( to === from ) return { key : to } ;
const s3 = await renamer ( p . user ) ;
if ( await exists ( s3 , p . bucket , to ) ) throw new HttpError ( 409 , ` " ${ name } " already exists here ` ) ;
await s3 . renameObject ( p . bucket , from , to ) ;
return { key : to } ;
} ) ;
// A folder: { from: "<prefix>/", name: "<new name>" } beside it.
route ( 'POST' , '/api/s3/:user/buckets/:bucket/rename-prefix' , async ( req , url , p ) => {
const { from , name } = await jsonBody ( req ) ;
if ( ! from || ! from . endsWith ( '/' ) || badName ( name ) ) throw new HttpError ( 400 , 'a folder and a new name (without "/") are required' ) ;
const parent = from . slice ( 0 , from . slice ( 0 , - 1 ) . lastIndexOf ( '/' ) + 1 ) ;
const to = ` ${ parent } ${ name } / ` ;
if ( to === from ) return { prefix : to , moved : 0 } ;
const s3 = await renamer ( p . user ) ;
if ( await exists ( s3 , p . bucket , to ) ) throw new HttpError ( 409 , ` a folder " ${ name } " already exists here ` ) ;
return { prefix : to , moved : await s3 . renamePrefix ( p . bucket , from , to ) } ;
} ) ;
// A drive: a new bucket gets every file and the same access, then the old
// one goes. S3 apps that use the old name need the new one.
route ( 'POST' , '/api/s3/:user/buckets/:bucket/rename' , async ( req , url , p ) => {
const { name } = await jsonBody ( req ) ;
if ( ! /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/ . test ( String ( name || '' ) ) ) throw new HttpError ( 400 , 'drive names are 3-63 lowercase letters, digits, dots and hyphens' ) ;
if ( name === p . bucket ) return { name } ;
const s3 = await renamer ( p . user ) ;
if ( ( await s3 . listBuckets ( ) ) . some ( ( b ) => b . name === name ) ) throw new HttpError ( 409 , ` a drive " ${ name } " already exists ` ) ;
await s3 . createBucket ( name ) ;
// Public access carries over, rebuilt for the new name (a policy names its bucket).
const mode = describePolicy ( await s3 . getPolicy ( p . bucket ) . catch ( ( ) => null ) ) ;
const moved = await s3 . renamePrefix ( p . bucket , '' , '' , name ) ;
if ( mode === 'read' || mode === 'read-list' ) await s3 . putPolicy ( name , publicReadPolicy ( name , { list : mode === 'read-list' } ) ) ;
await s3 . deleteBucket ( p . bucket ) ;
return { name , moved } ;
} ) ;
2026-10-04 18:27:05 +02:00
// ---- public links: navigate.st/sia/<id>/<name> -----------------------------------
// A file in your own Sia account gets a Sia shared-object URL (it reads that
// one file and nothing else), registered at navigate.st, which streams it
// from Sia for anyone. Files still waiting on this computer go up first.
// The delete tokens stay here, beside s3d.yml, so links can be switched off.
const SIA _LINKS _API = process . env . PITHOS _SIA _LINKS _API || 'https://navigate.st/api/sia/links' ;
const siaLinksFile = ( ) => path . join ( path . dirname ( configFile ) , 'pithos-sia-links.json' ) ;
const readSiaLinks = ( ) => { try { return JSON . parse ( fs . readFileSync ( siaLinksFile ( ) , 'utf8' ) ) ; } catch { return [ ] ; } } ;
const writeSiaLinks = ( list ) => fs . writeFileSync ( siaLinksFile ( ) , JSON . stringify ( list , null , 2 ) , { mode : 0o600 } ) ;
async function shareUrlFor ( bucket , key , until ) {
const dir = cfg ( ) . directory ;
try { return await createShareUrl ( dir , bucket , key , until ) ; }
catch ( e ) {
if ( e . code !== 'pending' ) throw new HttpError ( e . status || 502 , e . message ) ;
await admin . flush ( cfg ( ) ) ; // send what is waiting, then try once more
try { return await createShareUrl ( dir , bucket , key , until ) ; }
catch ( e2 ) { throw new HttpError ( e2 . status || 502 , e2 . code === 'pending' ? 'This file is still uploading to Sia. Try again in a minute.' : e2 . message ) ; }
}
}
route ( 'POST' , '/api/s3/:user/buckets/:bucket/sia-link' , async ( req , url , p ) => {
if ( hosted . isHosted ( ) ) throw new HttpError ( 409 , 'Drives on Silent Mode share through Share… (navigate.st/s3).' ) ;
const { key , prefix , days = 365 } = await jsonBody ( req ) ;
const until = Math . floor ( Date . now ( ) / 1000 ) + Math . min ( 3650 , Math . max ( 1 , Number ( days ) || 365 ) ) * 86400 ;
let body ;
2026-10-05 23:52:35 +02:00
// prefix "" (with no key) is the drive's top level
if ( prefix != null && ! key ) {
if ( prefix && ! prefix . endsWith ( '/' ) ) throw new HttpError ( 400 , 'a folder ends with /' ) ;
2026-10-04 18:27:05 +02:00
const s3 = await s3For ( p . user ) ;
const files = [ ] ;
let token ;
do {
const page = await s3 . listObjects ( p . bucket , { prefix , token } ) ;
for ( const o of page . objects ) if ( o . key !== prefix && o . size > 0 ) files . push ( o . key ) ;
token = page . truncated ? page . nextToken : null ;
} while ( token && files . length < 1000 ) ;
if ( ! files . length ) throw new HttpError ( 400 , 'This folder has no files to share (files in sub-folders are not included).' ) ;
const out = [ ] ;
for ( const k of files ) out . push ( { name : k . slice ( prefix . length ) , url : ( await shareUrlFor ( p . bucket , k , until ) ) . url } ) ;
2026-10-05 23:52:35 +02:00
body = { name : prefix ? prefix . slice ( 0 , - 1 ) . split ( '/' ) . pop ( ) : p . bucket , folder : true , files : out } ;
2026-10-04 18:27:05 +02:00
} else {
if ( ! key || key . endsWith ( '/' ) ) throw new HttpError ( 400 , 'a file is required' ) ;
body = { name : key . split ( '/' ) . pop ( ) , url : ( await shareUrlFor ( p . bucket , key , until ) ) . url } ;
}
const res = await fetch ( SIA _LINKS _API , { method : 'POST' , headers : { 'content-type' : 'application/json' } , body : JSON . stringify ( body ) , signal : AbortSignal . timeout ( 180_000 ) } ) ;
const out = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! res . ok ) throw new HttpError ( res . status === 429 ? 429 : 502 , out . error || ` navigate.st answered ${ res . status } ` ) ;
const list = readSiaLinks ( ) ;
list . unshift ( { id : out . id , url : out . url , deleteToken : out . deleteToken , user : p . user , bucket : p . bucket , key : key || null , prefix : prefix || null , until , created : Date . now ( ) , files : body . files ? body . files . length : 1 } ) ;
writeSiaLinks ( list ) ;
return { id : out . id , url : out . url , until } ;
} ) ;
route ( 'GET' , '/api/sia-links' , ( ) => readSiaLinks ( ) . map ( ( { deleteToken , ... l } ) => l ) ) ;
route ( 'DELETE' , '/api/sia-links/:id' , async ( req , url , p ) => {
const list = readSiaLinks ( ) ;
const l = list . find ( ( x ) => x . id === p . id ) ;
if ( ! l ) throw new HttpError ( 404 , 'no such link' ) ;
const res = await fetch ( ` ${ SIA _LINKS _API } / ${ encodeURIComponent ( l . id ) } ` , { method : 'DELETE' , headers : { 'x-delete-token' : l . deleteToken } , signal : AbortSignal . timeout ( 30_000 ) } ) ;
if ( ! res . ok && res . status !== 404 ) throw new HttpError ( 502 , ` navigate.st answered ${ res . status } ` ) ;
writeSiaLinks ( list . filter ( ( x ) => x . id !== p . id ) ) ;
return { ok : true } ;
} ) ;
2026-10-04 04:06:04 +02:00
// ---- free space on the Sia account ----------------------------------------
// From the indexer (signed with s3d's app key). Cached for a minute.
let spaceCache = { at : 0 , value : null } ;
2026-10-04 22:02:15 +02:00
// ---- desktop updates: the newest Pithos desktop build on dl.silentmode.st ----
const RELEASES _URL = process . env . PITHOS _RELEASES _URL || 'https://dl.silentmode.st/releases-manifest.json' ;
let releaseCache = null ;
route ( 'GET' , '/api/desktop-release' , async ( req , url ) => {
if ( releaseCache && Date . now ( ) - releaseCache . at < 10 * 60_000 && url . searchParams . get ( 'fresh' ) !== '1' ) return releaseCache . body ;
const res = await fetch ( ` ${ RELEASES _URL } ?t= ${ Math . floor ( Date . now ( ) / 60000 ) } ` , { cache : 'no-store' , signal : AbortSignal . timeout ( 15_000 ) } ) . catch ( ( e ) => { throw new HttpError ( 502 , e . message ) ; } ) ;
if ( ! res . ok ) throw new HttpError ( 502 , ` the release list answered ${ res . status } ` ) ;
2026-10-04 22:49:48 +02:00
// each build is listed per platform ("win-x64", "linux-x64", "mac-…"); only ours counts
const os = { win32 : 'win' , linux : 'linux' , darwin : 'mac' } [ process . platform ] ;
const list = ( ( await res . json ( ) ) ? . releases || [ ] ) . filter ( ( e ) => e . id === 'pithos-desktop' && /^\d+\.\d+\.\d+$/ . test ( e . version ) && String ( e . platform || '' ) . startsWith ( ` ${ os } - ` ) ) ;
2026-10-04 22:02:15 +02:00
const latest = list . reduce ( ( best , e ) => ( ! best || cmpVersion ( e . version , best . version ) > 0 ? e : best ) , null ) ;
const body = { latest : latest && { version : latest . version , date : latest . date || null , changes : String ( latest . changes || '' ) . slice ( 0 , 600 ) } } ;
releaseCache = { at : Date . now ( ) , body } ;
return body ;
} ) ;
2026-10-05 01:47:37 +02:00
// "Open in browser": the same Pithos in the user's default browser, signed
// in with a one-time link, at the page the user is on.
route ( 'POST' , '/api/open-in-browser' , async ( req ) => {
if ( ! openBrowser ) throw new HttpError ( 400 , 'this Pithos cannot open a browser' ) ;
const { route : r = '' } = await jsonBody ( req ) ;
openBrowser ( signInUrl ( String ( r ) ) ) ;
return { ok : true } ;
} ) ;
2026-10-04 04:06:04 +02:00
route ( 'GET' , '/api/space' , async ( ) => {
// On Silent Mode this call is forwarded: the server's s3d answers for its own folder.
if ( Date . now ( ) - spaceCache . at < 60_000 && spaceCache . value ) return spaceCache . value ;
try {
const s = await accountSpace ( cfg ( ) . directory ) ;
spaceCache = { at : Date . now ( ) , value : s ? { available : true , ... s } : { available : false , reason : 'not connected' } } ;
} catch ( e ) {
spaceCache = { at : Date . now ( ) - 45_000 , value : { available : false , reason : e . message } } ;
}
return spaceCache . value ;
} ) ;
2026-10-04 00:05:50 +02:00
// S3 has no folders, only names with slashes. An empty object named
// "<folder>/" is the usual marker that keeps an empty folder visible.
route ( 'PUT' , '/api/s3/:user/buckets/:bucket/folder' , async ( req , url , p ) => {
let prefix = url . searchParams . get ( 'prefix' ) || '' ;
if ( ! prefix || prefix . startsWith ( '/' ) || prefix . includes ( '//' ) ) throw new HttpError ( 400 , 'folder name required' ) ;
if ( ! prefix . endsWith ( '/' ) ) prefix += '/' ;
await ( await s3For ( p . user ) ) . putObject ( p . bucket , prefix , Buffer . alloc ( 0 ) , { contentLength : 0 } ) ;
return { prefix } ;
} ) ;
// Small text files for the viewer (first 512 KiB).
2026-10-04 04:06:04 +02:00
// 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 ) ;
}
2026-10-04 00:05:50 +02:00
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 ) ;
if ( up . statusCode >= 300 ) throw new HttpError ( up . statusCode , body . match ( /<Message>(.*?)<\/Message>/ ) ? . [ 1 ] || ` HTTP ${ up . statusCode } ` ) ;
const total = Number ( ( up . headers [ 'content-range' ] || '' ) . split ( '/' ) [ 1 ] ) || body . length ;
return { text : body , truncated : total > 524288 , size : total } ;
} ) ;
2026-10-04 00:09:55 +02:00
// Drive usage: files and bytes in a bucket, counted up to 20 pages.
route ( 'GET' , '/api/s3/:user/buckets/:bucket/usage' , async ( req , url , p ) => {
const client = await s3For ( p . user ) ;
let files = 0 , bytes = 0 , token , pages = 0 ;
do {
const page = await client . listObjects ( p . bucket , { delimiter : '' , token } ) ;
for ( const o of page . objects ) { if ( ! o . key . endsWith ( '/' ) ) { files ++ ; bytes += o . size || 0 ; } }
token = page . truncated ? page . nextToken : null ;
} while ( token && ++ pages < 20 ) ;
return { files , bytes , partial : ! ! token } ;
} ) ;
2026-10-04 00:05:50 +02:00
route ( 'POST' , '/api/host/show-panel' , ( ) => {
if ( ! showPanel ) throw new HttpError ( 400 , 'not supported by this host' ) ;
showPanel ( ) ;
return { ok : true } ;
} ) ;
2026-10-03 19:03:05 +02:00
route ( 'GET' , '/api/s3/:user/buckets/:bucket/presign' , async ( req , url , p ) => {
const q = url . searchParams ;
2026-10-04 03:36:50 +02:00
if ( hosted . isHosted ( ) ) return { url : await hosted . shareLink ( p . user , p . bucket , requireKey ( url ) , Number ( q . get ( 'expires' ) || 3600 ) ) } ;
2026-10-03 19:03:05 +02:00
const client = await s3For ( p . user ) ;
return { url : client . presign ( p . bucket , requireKey ( url ) , Number ( q . get ( 'expires' ) || 3600 ) , q . get ( 'endpoint' ) || undefined ) } ;
} ) ;
route ( 'GET' , '/api/s3/:user/buckets/:bucket/policy' , async ( req , url , p ) => {
2026-10-04 03:36:50 +02:00
if ( hosted . isHosted ( ) ) return hosted . drivePolicy ( p . bucket ) ;
2026-10-03 19:03:05 +02:00
const policy = await ( await s3For ( p . user ) ) . getPolicy ( p . bucket ) ;
return { policy , public : describePolicy ( policy ) } ;
} ) ;
route ( 'PUT' , '/api/s3/:user/buckets/:bucket/policy' , async ( req , url , p ) => {
const { mode } = await jsonBody ( req ) ; // 'private' | 'read' | 'read-list'
2026-10-04 03:36:50 +02:00
if ( hosted . isHosted ( ) ) {
if ( ! [ 'private' , 'read' , 'read-list' ] . includes ( mode ) ) throw new HttpError ( 400 , 'mode must be private, read or read-list' ) ;
await hosted . setDrivePolicy ( p . user , p . bucket , mode ) ;
return { ok : true } ;
}
2026-10-03 19:03:05 +02:00
const client = await s3For ( p . user ) ;
if ( mode === 'private' ) { await client . deletePolicy ( p . bucket ) . catch ( ( e ) => { if ( e . status !== 404 ) throw e ; } ) ; }
else if ( mode === 'read' || mode === 'read-list' ) await client . putPolicy ( p . bucket , publicReadPolicy ( p . bucket , { list : mode === 'read-list' } ) ) ;
else throw new HttpError ( 400 , 'mode must be private, read or read-list' ) ;
return { ok : true } ;
} ) ;
2026-10-04 03:36:50 +02:00
hosted = installHosted ( {
route , configFile , cfg , daemon , broadcast , registration , HttpError , secrets ,
resetRegistration : ( ) => { regCache = null ; } ,
hasSubscribers : ( ) => subscribers . size > 0 ,
} ) ;
2026-10-03 19:03:05 +02:00
// ---- server ------------------------------------------------------------
const STREAMED = Symbol ( 'streamed' ) ;
let boundPort = port ;
2026-10-04 03:29:32 +02:00
// Site mode: Theseus relays pithos.sia/<path> here (header x-pithos-site).
// Pithos answers its own files, its pages and /<S3 user>/…; anything else
// is "not mine", so the pithos.sia website answers it instead.
const SITE _PAGES = new Set ( [ 'overview' , 'drives' , 'users' , 'account' , 'start' , 'setup' , 'settings' , 'logs' ] ) ;
const SITE _FILES = new Set ( [ 'app.js' , 'app.css' , 'icon.svg' ] ) ;
let siteUsersCache = { at : 0 , set : new Set ( ) } ;
async function siteUsers ( ) {
if ( Date . now ( ) - siteUsersCache . at > 10_000 ) {
2026-10-04 03:36:50 +02:00
// Hosted drives' users live on the server; the local s3d has none then.
try { siteUsersCache = { at : Date . now ( ) , set : new Set ( hosted ? . isHosted ( ) ? await hosted . userNames ( ) : await cli . listUsers ( ) ) } ; }
2026-10-04 03:29:32 +02:00
catch { siteUsersCache . at = Date . now ( ) ; } // keep the last good list
}
return siteUsersCache . set ;
}
async function serveSite ( req , res , url ) {
const segs = url . pathname . split ( '/' ) . filter ( Boolean ) ;
let first = '' ;
try { first = decodeURIComponent ( segs [ 0 ] || '' ) ; } catch { }
if ( segs . length === 1 && SITE _FILES . has ( first ) ) return serveStatic ( req , res , url ) ;
if ( ! ( SITE _PAGES . has ( first ) || ( await siteUsers ( ) ) . has ( first ) ) ) {
res . writeHead ( 404 , { 'x-pithos-not-mine' : '1' } ) ;
return res . end ( 'not found' ) ;
}
return serveStatic ( req , res , new URL ( '/' , url ) , { base : '/' } ) ;
}
2026-10-03 19:03:05 +02:00
function hostAllowed ( h ) {
if ( ! h ) return false ;
const ok = [ ` 127.0.0.1: ${ boundPort } ` , ` localhost: ${ boundPort } ` , ` [::1]: ${ boundPort } ` , ... allowedHosts ] ;
return ok . includes ( h . toLowerCase ( ) ) ;
}
function cookieSession ( req ) {
const m = /(?:^|;\s*)pithos=([^;]+)/ . exec ( req . headers . cookie || '' ) ;
return m && sessions . has ( m [ 1 ] ) ? m [ 1 ] : null ;
}
2026-10-05 01:47:37 +02:00
function issueSession ( res , { external = false } = { } ) {
2026-10-03 19:03:05 +02:00
const id = crypto . randomBytes ( 24 ) . toString ( 'base64url' ) ;
sessions . add ( id ) ;
2026-10-05 01:47:37 +02:00
if ( external ) externalSessions . add ( id ) ;
2026-10-03 19:03:05 +02:00
res . setHeader ( 'set-cookie' , ` pithos= ${ id } ; HttpOnly; SameSite=Strict; Path=/ ` ) ;
return id ;
}
const server = http . createServer ( async ( req , res ) => {
// DNS-rebinding guard: a page on evil.example resolving to 127.0.0.1 still
// sends its own Host header.
if ( ! hostAllowed ( req . headers . host ) ) { res . writeHead ( 421 ) ; return res . end ( 'misdirected request' ) ; }
2026-10-03 19:34:55 +02:00
let url = new URL ( req . url , ` http:// ${ req . headers . host } ` ) ;
let viaTicket = false ;
2026-10-04 00:05:50 +02:00
const dl = /^\/(dl|v)\/([A-Za-z0-9_-]{20,})$/ . exec ( url . pathname ) ;
if ( dl && ( req . method === 'GET' || req . method === 'HEAD' ) ) {
const t = tickets . get ( dl [ 2 ] ) ;
if ( dl [ 1 ] === 'dl' || ( t && ! t . reuse ) ) tickets . delete ( dl [ 2 ] ) ;
2026-10-03 19:34:55 +02:00
if ( ! t || t . exp < Date . now ( ) ) { res . writeHead ( 410 ) ; return res . end ( 'link expired' ) ; }
url = new URL ( t . path , url ) ;
viaTicket = true ;
}
2026-10-03 19:03:05 +02:00
res . setHeader ( 'referrer-policy' , 'no-referrer' ) ;
2026-10-04 04:06:04 +02:00
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' ) ;
2026-10-03 19:03:05 +02:00
try {
// Session bootstrap: ?t=<secret> on any page swaps the secret for a cookie.
const t = url . searchParams . get ( 't' ) ;
if ( t && safeEqual ( t , secret ) && req . method === 'GET' && ! url . pathname . startsWith ( '/api/' ) ) {
issueSession ( res ) ;
res . writeHead ( 302 , { location : url . pathname } ) ;
return res . end ( ) ;
}
2026-10-05 01:47:37 +02:00
const once = url . searchParams . get ( 's' ) ;
if ( once && req . method === 'GET' && ! url . pathname . startsWith ( '/api/' ) ) {
const exp = signins . get ( once ) ;
signins . delete ( once ) ;
if ( exp && exp > Date . now ( ) ) issueSession ( res , { external : true } ) ;
res . writeHead ( 302 , { location : url . pathname } ) ; // the #route stays with the browser
return res . end ( ) ;
}
2026-10-03 19:03:05 +02:00
if ( url . pathname === '/api/session' ) {
2026-10-05 01:47:37 +02:00
if ( req . method === 'GET' ) { const c = cookieSession ( req ) ; return sendJson ( res , 200 , { authenticated : ! ! c , external : ! ! c && externalSessions . has ( c ) , passwordLogin : ! ! password , host : hostName , canOpenBrowser : ! ! openBrowser } ) ; }
2026-10-03 19:03:05 +02:00
if ( req . method === 'POST' ) {
requireCsrf ( req ) ;
const body = await jsonBody ( req ) ;
if ( ! password || ! safeEqual ( String ( body . password || '' ) , password ) ) {
await new Promise ( ( r ) => setTimeout ( r , 750 ) ) ;
throw new HttpError ( 401 , 'wrong password' ) ;
}
issueSession ( res ) ;
return sendJson ( res , 200 , { authenticated : true } ) ;
}
if ( req . method === 'DELETE' ) {
const s = cookieSession ( req ) ;
if ( s ) sessions . delete ( s ) ;
res . setHeader ( 'set-cookie' , 'pithos=; Max-Age=0; Path=/' ) ;
return sendJson ( res , 200 , { authenticated : false } ) ;
}
}
if ( url . pathname . startsWith ( '/api/' ) ) {
2026-10-03 19:34:55 +02:00
if ( ! viaTicket && ! cookieSession ( req ) ) throw new HttpError ( 401 , 'not signed in' ) ;
2026-10-03 19:03:05 +02:00
if ( req . method !== 'GET' ) requireCsrf ( req ) ;
2026-10-04 03:36:50 +02:00
if ( await hosted . intercept ( req , res , url , ( out ) => sendJson ( res , 200 , out ) ) ) return ;
2026-10-03 19:03:05 +02:00
if ( url . pathname === '/api/events' ) {
res . writeHead ( 200 , { 'content-type' : 'text/event-stream' , 'cache-control' : 'no-store' , connection : 'keep-alive' } ) ;
2026-10-04 03:36:50 +02:00
res . write ( ` event: status \n data: ${ JSON . stringify ( hosted . isHosted ( ) ? hosted . remoteDaemon ( ) : daemon . status ( ) ) } \n \n ` ) ;
2026-10-03 19:03:05 +02:00
res . write ( ` event: login \n data: ${ JSON . stringify ( login . snapshot ( ) ) } \n \n ` ) ;
subscribers . add ( res ) ;
const ping = setInterval ( ( ) => res . write ( ': ping\n\n' ) , 25_000 ) ;
req . on ( 'close' , ( ) => { clearInterval ( ping ) ; subscribers . delete ( res ) ; } ) ;
return ;
}
for ( const r of routes ) {
if ( r . method !== req . method ) continue ;
const m = r . re . exec ( url . pathname ) ;
if ( ! m ) continue ;
const params = Object . fromEntries ( r . keys . map ( ( k , i ) => [ k , decodeURIComponent ( m [ i + 1 ] ) ] ) ) ;
const out = await r . handler ( req , url , params , res ) ;
if ( out === STREAMED ) return ;
return sendJson ( res , 200 , out ? ? { ok : true } ) ;
}
throw new HttpError ( 404 , 'no such endpoint' ) ;
}
2026-10-04 03:29:32 +02:00
if ( req . headers [ 'x-pithos-site' ] === '1' ) return await serveSite ( req , res , url ) ;
2026-10-03 19:03:05 +02:00
return serveStatic ( req , res , url ) ;
} catch ( err ) {
const status = err instanceof HttpError ? err . status
: err instanceof S3Error ? ( err . status >= 400 && err . status < 600 ? err . status : 502 )
: err instanceof CliError ? 400
: 500 ;
if ( status === 500 ) console . error ( '[pithos]' , err ) ;
if ( res . headersSent ) return res . destroy ( ) ;
sendJson ( res , status , { error : err . message , code : err . code } ) ;
}
} ) ;
await new Promise ( ( resolve , reject ) => {
server . once ( 'error' , reject ) ;
server . listen ( port , host , resolve ) ;
} ) ;
boundPort = server . address ( ) . port ;
2026-10-04 00:05:50 +02:00
// Bring s3d back if the user had it running. A crash (not a Stop) is
// retried a few times with a pause, but never a missing Sia connection.
let restarts = [ ] ;
lastAutoTry = Date . now ( ) ;
const wantRunning = ( ) => readPrefs ( configFile ) . autostart === true ;
async function autostart ( reason ) {
if ( daemon . child ) return ;
if ( ! wantRunning ( ) ) return ; // the user stopped it, or never started it
if ( ! daemon . binary ) { daemon . pushLog ( 'sys' , 'not starting s3d: it is not installed' ) ; return ; }
if ( await probeExternal ( cfg ( ) ) ) { daemon . pushLog ( 'sys' , 'not starting s3d: another s3d already serves this config' ) ; return ; }
try { ensureConfig ( configFile ) ; daemon . start ( ) ; daemon . pushLog ( 'sys' , ` started by Pithos ( ${ reason } ) ` ) ; }
catch ( e ) { daemon . pushLog ( 'sys' , ` could not start s3d: ${ e . message } ` ) ; }
}
daemon . on ( 'status' , ( st ) => {
if ( st . state !== 'crashed' || st . needsLogin || ! wantRunning ( ) ) return ;
restarts = restarts . filter ( ( t ) => Date . now ( ) - t < 10 * 60_000 ) ;
if ( restarts . length >= 3 ) { daemon . pushLog ( 'sys' , 's3d keeps stopping; not restarting again for now. See the log above.' ) ; return ; }
restarts . push ( Date . now ( ) ) ;
setTimeout ( ( ) => autostart ( 'it stopped unexpectedly' ) , 5000 ) . unref ? . ( ) ;
} ) ;
setTimeout ( ( ) => autostart ( 'it was running before' ) , 500 ) . unref ? . ( ) ;
2026-10-04 03:36:50 +02:00
const autoFlushTimer = setInterval ( ( ) => { autoFlush . tick ( ) ; } , 30_000 ) ;
autoFlushTimer . unref ? . ( ) ;
2026-10-03 19:03:05 +02:00
const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host ;
const baseUrl = ` http:// ${ displayHost . includes ( ':' ) ? ` [ ${ displayHost } ] ` : displayHost } : ${ boundPort } / ` ;
2026-10-05 01:47:37 +02:00
const handoff = handoffPort && confirmBrowserOpen ? startHandoff ( handoffPort , confirmBrowserOpen , signInUrl , ( m ) => daemon . pushLog ( 'sys' , m ) ) : null ;
2026-10-03 19:03:05 +02:00
return {
server ,
daemon ,
url : baseUrl ,
// Opening this URL signs the view in.
authUrl : ` ${ baseUrl } ?t= ${ secret } ` ,
2026-10-03 19:34:55 +02:00
// For hosts that call the API themselves (the Theseus sidebar bridge):
// a session cookie that never touches a browser.
internalSession ( ) {
const id = crypto . randomBytes ( 24 ) . toString ( 'base64url' ) ;
sessions . add ( id ) ;
return ` pithos= ${ id } ` ;
} ,
// A single-use, 60-second URL for one object download.
2026-10-04 00:05:50 +02:00
// A 10-minute link that streams one object inline, for the viewer in
// hosts whose page has no session (the Theseus panel).
viewUrl ( apiPath ) {
if ( ! /^\/api\/s3\/[^/]+\/buckets\/[^/]+\/object\?/ . test ( apiPath ) ) throw new Error ( 'only objects' ) ;
const id = crypto . randomBytes ( 24 ) . toString ( 'base64url' ) ;
tickets . set ( id , { path : apiPath + '&inline=1' , exp : Date . now ( ) + 10 * 60_000 , reuse : true } ) ;
return ` ${ baseUrl } v/ ${ id } ` ;
} ,
2026-10-03 19:34:55 +02:00
downloadUrl ( apiPath ) {
if ( ! /^\/api\/s3\/[^/]+\/buckets\/[^/]+\/object\?/ . test ( apiPath ) ) throw new Error ( 'only object downloads' ) ;
const id = crypto . randomBytes ( 24 ) . toString ( 'base64url' ) ;
tickets . set ( id , { path : apiPath , exp : Date . now ( ) + 60_000 } ) ;
for ( const [ k , v ] of tickets ) if ( v . exp < Date . now ( ) ) tickets . delete ( k ) ;
return ` ${ baseUrl } dl/ ${ id } ` ;
} ,
2026-10-03 19:03:05 +02:00
async close ( ) {
2026-10-04 03:36:50 +02:00
clearInterval ( autoFlushTimer ) ;
hosted . close ( ) ;
2026-10-03 19:03:05 +02:00
login . cancel ( ) ;
for ( const s of subscribers ) s . end ( ) ;
2026-10-05 01:47:37 +02:00
handoff ? . close ( ) ;
2026-10-03 19:03:05 +02:00
await daemon . stop ( ) ;
2026-10-04 22:02:15 +02:00
// Idle keep-alive sockets would hold close() for seconds; nobody is left to answer.
const closed = new Promise ( ( r ) => server . close ( r ) ) ;
server . closeAllConnections ? . ( ) ;
await closed ;
2026-10-03 19:03:05 +02:00
} ,
} ;
}
2026-10-05 01:47:37 +02:00
// ---- open from the pithos.sia website ---------------------------------------
// The website links to http://127.0.0.1:47621/open?path=/<user>/<drive>. Any
// page or local program could follow that link, so nobody is signed in until
// the user says yes in the host's own window; the page that sent the browser
// here learns nothing either way. One question at a time.
export const HANDOFF _PORT = 47621 ;
export function handoffRoute ( p ) {
const segs = String ( p || '' ) . split ( '/' ) . filter ( Boolean ) . slice ( 0 , 12 ) ;
if ( ! segs . length || segs . some ( ( x ) => ! /^[A-Za-z0-9._~%=-]{1,120}$/ . test ( x ) ) ) return '#/overview' ;
const pages = [ 'overview' , 'users' , 'buckets' , 'account' , 'settings' , 'logs' ] ;
if ( segs [ 0 ] === 'drives' ) return '#/buckets' ;
2026-10-05 23:52:35 +02:00
if ( segs [ 0 ] === 'app' ) return segs . length > 1 ? handoffRoute ( '/' + segs . slice ( 1 ) . join ( '/' ) ) : '#/overview' ;
2026-10-05 01:47:37 +02:00
if ( pages . includes ( segs [ 0 ] ) ) return ` #/ ${ segs . join ( '/' ) } ` ;
return ` #/u/ ${ segs . join ( '/' ) } ` ; // /<user>/<drive>/<folder>
}
function startHandoff ( port , confirm , signInUrl , log ) {
const page = ( res , status , title , text ) => {
res . writeHead ( status , { 'content-type' : 'text/html; charset=utf-8' , 'cache-control' : 'no-store' , 'x-frame-options' : 'DENY' , 'content-security-policy' : "default-src 'none'; style-src 'unsafe-inline'" } ) ;
res . end ( ` <!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title> ${ title } </title><body style="font:16px system-ui;background:#0f1214;color:#e6ebe8;display:grid;place-items:center;min-height:90vh;margin:0"><div style="max-width:460px;padding:24px"><h1 style="font-size:20px"> ${ title } </h1><p style="color:#9aa5a0"> ${ text } </p></div> ` ) ;
} ;
let pending = false ;
const srv = http . createServer ( async ( req , res ) => {
const url = new URL ( req . url , 'http://127.0.0.1' ) ;
// DNS rebinding: only the loopback names, never a hostname that resolves here
if ( ! new RegExp ( ` ^(127 \\ .0 \\ .0 \\ .1|localhost| \\ [::1 \\ ]): ${ port } $ ` ) . test ( req . headers . host || '' ) ) return page ( res , 403 , 'Not allowed' , 'Use http://127.0.0.1 to open Pithos.' ) ;
if ( req . method !== 'GET' || url . pathname !== '/open' ) return page ( res , 404 , 'Pithos' , 'Nothing here. Open Pithos from the pithos.sia website.' ) ;
if ( pending ) return page ( res , 429 , 'Answer Pithos first' , 'Pithos is already asking whether to open in a browser. Answer it there, then try again.' ) ;
pending = true ;
try {
let timer ;
const ok = await Promise . race ( [
Promise . resolve ( confirm ( { path : url . searchParams . get ( 'path' ) || '/' } ) ) . catch ( ( ) => false ) ,
new Promise ( ( r ) => { timer = setTimeout ( ( ) => r ( false ) , 120_000 ) ; } ) ,
] ) . finally ( ( ) => clearTimeout ( timer ) ) ;
if ( ! ok ) return page ( res , 403 , 'Not opened' , 'Pithos was not opened in this browser. If you meant to, go back and press Open my Pithos again, then choose Open in Pithos.' ) ;
res . writeHead ( 302 , { location : signInUrl ( handoffRoute ( url . searchParams . get ( 'path' ) ) ) , 'cache-control' : 'no-store' } ) ;
res . end ( ) ;
} finally { pending = false ; }
} ) ;
srv . on ( 'error' , ( e ) => log ( e . code === 'EADDRINUSE' ? ` another Pithos already answers the website on port ${ port } ` : ` website link: ${ e . message } ` ) ) ;
srv . listen ( port , '127.0.0.1' ) ;
return { close : ( ) => srv . close ( ) } ;
}
2026-10-03 19:03:05 +02:00
// ---- helpers -------------------------------------------------------------
function sendJson ( res , status , body ) {
const data = JSON . stringify ( body ) ;
res . writeHead ( status , { 'content-type' : 'application/json; charset=utf-8' , 'cache-control' : 'no-store' } ) ;
res . end ( data ) ;
}
function requireCsrf ( req ) {
if ( req . headers [ 'x-pithos' ] !== '1' ) throw new HttpError ( 403 , 'missing x-pithos header' ) ;
}
function requireKey ( url ) {
const key = url . searchParams . get ( 'key' ) ;
if ( ! key ) throw new HttpError ( 400 , 'key required' ) ;
return key ;
}
async function jsonBody ( req ) {
const chunks = [ ] ;
let size = 0 ;
for await ( const c of req ) {
size += c . length ;
if ( size > 1 << 20 ) throw new HttpError ( 413 , 'body too large' ) ;
chunks . push ( c ) ;
}
if ( ! chunks . length ) return { } ;
try { return JSON . parse ( Buffer . concat ( chunks ) . toString ( 'utf8' ) ) ; } catch { throw new HttpError ( 400 , 'invalid JSON' ) ; }
}
function safeEqual ( a , b ) {
const x = Buffer . from ( String ( a ) ) ;
const y = Buffer . from ( String ( b ) ) ;
return x . length === y . length && crypto . timingSafeEqual ( x , y ) ;
}
function getIn ( obj , keys ) {
return keys . reduce ( ( o , k ) => ( o == null ? undefined : o [ k ] ) , obj ) ;
}
2026-10-04 00:05:50 +02:00
// A valid S3 bucket name from a user name: lowercase letters, digits and
// hyphens, 3-63 characters.
function bucketNameFor ( name ) {
let b = String ( name || '' ) . toLowerCase ( ) . replace ( /[^a-z0-9-]+/g , '-' ) . replace ( /-+/g , '-' ) . replace ( /^-+|-+$/g , '' ) ;
if ( b . length < 3 ) b = ( b || 'my' ) + '-files' ;
return b . slice ( 0 , 63 ) . replace ( /-+$/ , '' ) ;
}
2026-10-03 19:03:05 +02:00
function cmpVersion ( a , b ) {
const pa = a . replace ( /^v/ , '' ) . split ( /[.-]/ ) . map ( ( n ) => parseInt ( n , 10 ) || 0 ) ;
const pb = b . replace ( /^v/ , '' ) . split ( /[.-]/ ) . map ( ( n ) => parseInt ( n , 10 ) || 0 ) ;
for ( let i = 0 ; i < 3 ; i ++ ) if ( ( pa [ i ] || 0 ) !== ( pb [ i ] || 0 ) ) return ( pa [ i ] || 0 ) - ( pb [ i ] || 0 ) ;
return 0 ;
}
function describePolicy ( policy ) {
if ( ! policy ) return 'private' ;
const actions = new Set ( ) ;
for ( const s of [ ] . concat ( policy . Statement || [ ] ) ) {
if ( s . Effect !== 'Allow' ) continue ;
if ( s . Principal !== '*' && s . Principal ? . AWS !== '*' ) continue ;
for ( const a of [ ] . concat ( s . Action || [ ] ) ) actions . add ( a ) ;
}
if ( actions . has ( 's3:ListBucket' ) ) return 'read-list' ;
if ( actions . has ( 's3:GetObject' ) ) return 'read' ;
return 'custom' ;
}
2026-10-04 03:29:32 +02:00
function serveStatic ( req , res , url , { base } = { } ) {
2026-10-03 19:03:05 +02:00
if ( req . method !== 'GET' && req . method !== 'HEAD' ) { res . writeHead ( 405 ) ; return res . end ( ) ; }
let rel = decodeURIComponent ( url . pathname ) ;
if ( rel === '/' || ! path . extname ( rel ) ) rel = '/index.html' ;
const file = path . join ( UI _DIR , path . normalize ( rel ) ) ;
if ( ! file . startsWith ( UI _DIR + path . sep ) ) { res . writeHead ( 403 ) ; return res . end ( ) ; }
fs . readFile ( file , ( err , data ) => {
if ( err ) { res . writeHead ( 404 ) ; return res . end ( 'not found' ) ; }
res . writeHead ( 200 , {
'content-type' : MIME [ path . extname ( file ) ] || 'application/octet-stream' ,
'cache-control' : 'no-cache' ,
'content-security-policy' : "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'" ,
} ) ;
2026-10-04 03:29:32 +02:00
// Pages under pithos.sia/<user>/<drive>/… load the app's files from the root.
if ( base && file . endsWith ( 'index.html' ) ) data = Buffer . from ( data . toString ( 'utf8' ) . replace ( '<head>' , ` <head><base href=" ${ base } "> ` ) ) ;
2026-10-03 19:03:05 +02:00
res . end ( req . method === 'HEAD' ? undefined : data ) ;
} ) ;
}