2026-10-03 19:03:05 +02:00
// Pithos UI. Plain DOM, no build step: every host serves these files as-is.
// All user content (object keys, user names, log lines) goes in via
// textContent through h(); nothing here assigns innerHTML.
// ---------------------------------------------------------------- helpers
function h ( tag , attrs = { } , ... children ) {
const el = document . createElement ( tag ) ;
for ( const [ k , v ] of Object . entries ( attrs || { } ) ) {
if ( v == null || v === false ) continue ;
if ( k === 'class' ) el . className = v ;
else if ( k . startsWith ( 'on' ) ) el . addEventListener ( k . slice ( 2 ) , v ) ;
else if ( k === 'dataset' ) Object . assign ( el . dataset , v ) ;
else if ( v === true ) el . setAttribute ( k , '' ) ;
else el . setAttribute ( k , v ) ;
}
for ( const c of children . flat ( Infinity ) ) {
if ( c == null || c === false ) continue ;
el . append ( c instanceof Node ? c : String ( c ) ) ;
}
return el ;
}
const $ = ( sel , root = document ) => root . querySelector ( sel ) ;
// replaceChildren, but skipping the null/false that conditional children produce.
function put ( el , ... children ) {
el . replaceChildren ( ... children . flat ( Infinity ) . filter ( ( c ) => c != null && c !== false ) ) ;
}
2026-10-03 19:34:55 +02:00
// Inside the Theseus sidebar the page is the add-on's own file, with no
// server origin to fetch from: every call goes over the add-on IPC bridge
// and the add-on forwards it to the control server.
const bridge = window . silentmode && typeof window . silentmode . invoke === 'function' ? window . silentmode : null ;
2026-10-03 19:03:05 +02:00
async function api ( method , path , body , { raw } = { } ) {
2026-10-03 19:34:55 +02:00
if ( bridge ) {
const r = await bridge . invoke ( 'api' , { method , path , body } ) ;
const data = r . data || { } ;
if ( r . status >= 400 ) throw new Error ( data . error || ` HTTP ${ r . status } ` ) ;
return data ;
}
2026-10-03 19:03:05 +02:00
const opts = { method , headers : { 'x-pithos' : '1' } , credentials : 'same-origin' } ;
if ( body !== undefined ) {
opts . headers [ 'content-type' ] = 'application/json' ;
opts . body = JSON . stringify ( body ) ;
}
const res = await fetch ( path , opts ) ;
if ( res . status === 401 && path !== '/api/session' ) { showSignin ( ) ; throw new Error ( 'signed out' ) ; }
if ( raw ) return res ;
const data = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! res . ok ) throw new Error ( data . error || ` HTTP ${ res . status } ` ) ;
return data ;
}
function toast ( msg , kind = '' ) {
const el = h ( 'div' , { class : ` toast ${ kind } ` } , msg ) ;
$ ( '#toasts' ) . append ( el ) ;
setTimeout ( ( ) => el . remove ( ) , kind === 'error' ? 7000 : 3500 ) ;
}
const fail = ( e ) => { if ( e . message !== 'signed out' ) toast ( e . message , 'error' ) ; } ;
function fmtBytes ( n ) {
if ( n == null || isNaN ( n ) ) return '– ' ;
const u = [ 'B' , 'KiB' , 'MiB' , 'GiB' , 'TiB' , 'PiB' ] ;
let i = 0 ;
while ( n >= 1024 && i < u . length - 1 ) { n /= 1024 ; i ++ ; }
return ` ${ i ? n . toFixed ( n < 10 ? 2 : 1 ) : n } ${ u [ i ] } ` ;
}
function fmtDate ( s ) { if ( ! s ) return '– ' ; const d = new Date ( s ) ; return isNaN ( d ) ? s : d . toLocaleString ( ) ; }
function fmtDuration ( ms ) {
const s = Math . floor ( ms / 1000 ) ;
if ( s < 60 ) return ` ${ s } s ` ;
if ( s < 3600 ) return ` ${ Math . floor ( s / 60 ) } m ${ s % 60 } s ` ;
if ( s < 86400 ) return ` ${ Math . floor ( s / 3600 ) } h ${ Math . floor ( ( s % 3600 ) / 60 ) } m ` ;
return ` ${ Math . floor ( s / 86400 ) } d ${ Math . floor ( ( s % 86400 ) / 3600 ) } h ` ;
}
async function copy ( text , what = 'Copied' ) {
try { await navigator . clipboard . writeText ( text ) ; toast ( what ) ; }
catch { prompt ( 'Copy:' , text ) ; }
}
function modal ( title , body , buttons = [ ] ) {
return new Promise ( ( resolve ) => {
const close = ( v ) => { root . remove ( ) ; document . removeEventListener ( 'keydown' , onKey ) ; resolve ( v ) ; } ;
const onKey = ( e ) => { if ( e . key === 'Escape' ) close ( undefined ) ; } ;
const foot = h ( 'div' , { class : 'foot' } , buttons . map ( ( b ) => h ( 'button' , {
class : ` btn ${ b . kind || '' } ` , type : b . submit ? 'submit' : 'button' ,
onclick : b . submit ? null : async ( ) => { const v = b . value ? await b . value ( ) : b . result ; if ( v !== false ) close ( v ) ; } ,
} , b . label ) ) ) ;
const form = h ( 'form' , { class : 'modal' , onsubmit : async ( e ) => {
e . preventDefault ( ) ;
const b = buttons . find ( ( x ) => x . submit ) ;
const v = b . value ? await b . value ( ) : b . result ;
if ( v !== false ) close ( v ) ;
} } , h ( 'h2' , { } , title ) , body , foot ) ;
const root = h ( 'div' , { class : 'modal-backdrop' , onmousedown : ( e ) => { if ( e . target === root ) close ( undefined ) ; } } , form ) ;
$ ( '#modal-root' ) . append ( root ) ;
document . addEventListener ( 'keydown' , onKey ) ;
setTimeout ( ( ) => ( form . querySelector ( 'input, textarea, select' ) || form . querySelector ( '.btn.primary' ) ) ? . focus ( ) , 0 ) ;
} ) ;
}
function confirmModal ( title , text , label = 'Delete' , kind = 'danger' ) {
return modal ( title , h ( 'p' , { } , text ) , [
{ label : 'Cancel' , result : false } ,
{ label , kind : ` primary ${ kind === 'danger' ? '' : '' } ` , submit : true , result : true } ,
] ) . then ( ( v ) => v === true ) ;
}
function secretRow ( label , value ) {
return h ( 'div' , { class : 'stack' } ,
h ( 'span' , { class : 'small muted' } , label ) ,
h ( 'div' , { class : 'secret' } , h ( 'code' , { } , value ) , h ( 'button' , { class : 'btn small' , type : 'button' , onclick : ( ) => copy ( value ) } , 'Copy' ) ) ) ;
}
// ---------------------------------------------------------------- state
const state = {
status : null ,
login : { state : 'idle' } ,
logs : [ ] ,
logSeq : 0 ,
selectedUser : localStorage . getItem ( 'pithos.user' ) || '' ,
} ;
const listeners = new Set ( ) ;
const onState = ( fn ) => { listeners . add ( fn ) ; return ( ) => listeners . delete ( fn ) ; } ;
const emit = ( ) => listeners . forEach ( ( fn ) => fn ( ) ) ;
async function refreshStatus ( ) {
try {
state . status = await api ( 'GET' , '/api/status' ) ;
state . login = state . status . login ;
renderPill ( ) ;
emit ( ) ;
} catch ( e ) { fail ( e ) ; }
}
2026-10-03 19:34:55 +02:00
const eventHandlers = {
status ( d ) {
2026-10-03 19:03:05 +02:00
if ( ! state . status ) return ;
2026-10-03 19:34:55 +02:00
state . status . daemon = d ;
2026-10-03 19:03:05 +02:00
renderPill ( ) ;
emit ( ) ;
2026-10-03 19:34:55 +02:00
} ,
log ( l ) {
2026-10-03 19:03:05 +02:00
if ( l . seq <= state . logSeq ) return ;
state . logSeq = l . seq ;
state . logs . push ( l ) ;
if ( state . logs . length > 2000 ) state . logs . shift ( ) ;
logSink ? . ( l ) ;
2026-10-03 19:34:55 +02:00
} ,
login ( d ) { state . login = d ; emit ( ) ; } ,
install ( p ) { if ( p . phase === 'extract' ) toast ( 'Verified checksum, installing…' ) ; } ,
} ;
function connectEvents ( ) {
if ( bridge ) {
bridge . on ( 'pithos-event' , ( { event , data } ) => eventHandlers [ event ] ? . ( data ) ) ;
bridge . invoke ( 'events-start' ) . catch ( fail ) ;
return ;
}
const es = new EventSource ( '/api/events' ) ;
for ( const [ name , fn ] of Object . entries ( eventHandlers ) ) es . addEventListener ( name , ( e ) => fn ( JSON . parse ( e . data ) ) ) ;
2026-10-03 19:03:05 +02:00
es . onerror = ( ) => { /* EventSource reconnects on its own */ } ;
}
function daemonState ( ) {
const s = state . status ;
if ( ! s ) return 'stopped' ;
if ( s . daemon . state === 'stopped' && s . external ) return 'external' ;
if ( s . daemon . state === 'crashed' && s . daemon . needsLogin ) return 'not connected' ;
return s . daemon . state ;
}
function renderPill ( ) {
const st = daemonState ( ) ;
const label = { running : 's3d running' , starting : 'Starting…' , stopping : 'Stopping…' , crashed : 's3d stopped (error)' , 'not connected' : 'Not connected to Sia' , stopped : 's3d stopped' , external : 's3d running (external)' } [ st ] ;
const pill = $ ( '#daemon-pill' ) ;
pill . querySelector ( '.dot' ) . className = ` dot ${ st === 'external' ? 'running' : st === 'not connected' ? 'starting' : st } ` ;
pill . querySelector ( '.label' ) . textContent = label ;
}
// ---------------------------------------------------------------- router
let cleanup = [ ] ;
let logSink = null ;
2026-10-03 20:42:23 +02:00
const views = { start : getStarted , overview , buckets , users , setup , settings , logs } ;
2026-10-03 19:03:05 +02:00
function route ( ) {
cleanup . forEach ( ( fn ) => fn ( ) ) ;
cleanup = [ ] ;
logSink = null ;
2026-10-03 20:42:23 +02:00
// First run lands on the guided setup until it has been finished once.
const fallback = ! setupDone ( ) && ! state . status ? . registration ? . registered ? 'start' : 'overview' ;
const [ , name = fallback , ... rest ] = location . hash . replace ( /^#/ , '' ) . split ( '/' ) ;
const view = views [ name ] ? name : fallback ;
2026-10-03 19:03:05 +02:00
for ( const a of document . querySelectorAll ( '#nav a' ) ) a . classList . toggle ( 'active' , a . dataset . view === view ) ;
const main = $ ( '#main' ) ;
put ( main ) ;
views [ view ] ( main , rest . map ( decodeURIComponent ) ) ;
}
// ---------------------------------------------------------------- overview
function overview ( main ) {
const daemonCard = h ( 'div' , { class : 'card' } ) ;
const statsCard = h ( 'div' , { class : 'card' } ) ;
const banners = h ( 'div' ) ;
main . append (
h ( 'div' , { class : 'page-head' } , h ( 'div' , { } , h ( 'h1' , { } , 'Overview' ) , h ( 'p' , { class : 'muted' } , 'Your s3d gateway and its upload pipeline to Sia.' ) ) ) ,
banners ,
h ( 'div' , { class : 'grid two' } , daemonCard , statsCard ) ,
) ;
function draw ( ) {
const s = state . status ;
if ( ! s ) return ;
put ( banners , ... bannersFor ( s ) ) ;
const st = daemonState ( ) ;
const busy = st === 'starting' || st === 'stopping' ;
const running = st === 'running' || st === 'starting' ;
put ( daemonCard ,
h ( 'div' , { class : 'row spread' } , h ( 'h2' , { } , 'Gateway' ) , h ( 'span' , { class : ` badge ${ st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : '' } ` } , st ) ) ,
h ( 'dl' , { class : 'kv' } ,
h ( 'dt' , { } , 'S3 endpoint' ) , h ( 'dd' , { } , h ( 'code' , { } , ` http:// ${ s . config . apiAddress } ` ) ) ,
s . config . https && [ h ( 'dt' , { } , 'S3 HTTPS' ) , h ( 'dd' , { } , h ( 'code' , { } , ` https:// ${ s . config . https } ` ) ) ] ,
h ( 'dt' , { } , 's3d' ) , h ( 'dd' , { } , s . s3d ? . version ? ` v ${ s . s3d . version } ` : ( s . daemon . binary ? 'unknown version' : 'not installed' ) ) ,
h ( 'dt' , { } , 'Uptime' ) , h ( 'dd' , { } , st === 'running' && s . daemon . startedAt ? fmtDuration ( Date . now ( ) - s . daemon . startedAt ) : '– ' ) ,
h ( 'dt' , { } , 'Config' ) , h ( 'dd' , { } , h ( 'code' , { } , s . config . file ) ) ,
h ( 'dt' , { } , 'Data' ) , h ( 'dd' , { } , h ( 'code' , { } , s . config . directory ) ) ,
) ,
h ( 'div' , { class : 'row' , style : 'margin-top:16px' } ,
st === 'external'
? h ( 'span' , { class : 'muted small' } , 'Another s3d process is serving this config. Stop it to manage it from here.' )
: [
h ( 'button' , { class : 'btn primary' , disabled : busy || running || ! s . daemon . binary , onclick : ( ) => daemonAction ( 'start' ) } , 'Start' ) ,
h ( 'button' , { class : 'btn' , disabled : busy || ! running , onclick : ( ) => daemonAction ( 'restart' ) } , 'Restart' ) ,
h ( 'button' , { class : 'btn danger' , disabled : busy || ! running , onclick : ( ) => daemonAction ( 'stop' ) } , 'Stop' ) ,
] ,
) ,
) ;
}
async function drawStats ( ) {
const st = daemonState ( ) ;
if ( st !== 'running' && st !== 'external' ) {
put ( statsCard , h ( 'h2' , { } , 'Upload pipeline' ) , h ( 'p' , { class : 'muted' } , 'Start s3d to see live upload stats.' ) ) ;
return ;
}
try {
const x = await api ( 'GET' , '/api/stats' ) ;
const stat = ( v , l , bad ) => h ( 'div' , { class : ` stat ${ bad ? 'bad' : '' } ` } , h ( 'div' , { class : 'v' } , v ) , h ( 'div' , { class : 'l' } , l ) ) ;
put ( statsCard ,
h ( 'div' , { class : 'row spread' } , h ( 'h2' , { } , 'Upload pipeline' ) ,
h ( 'button' , { class : 'btn small' , disabled : ! x . pendingObjects , title : 'Upload everything waiting in the local buffer now, even if slabs are not full' , onclick : flushNow } , 'Flush now' ) ) ,
h ( 'div' , { class : 'stats' } ,
stat ( x . pendingObjects , ` pending · ${ fmtBytes ( x . pendingSize ) } ` ) ,
stat ( x . uploadedObjects , ` on Sia · ${ fmtBytes ( x . uploadedSize ) } ` ) ,
stat ( x . unpinnedObjects , 'awaiting pin' ) ,
stat ( x . multipartUploads , 'open multipart' ) ,
stat ( x . failedUploads , 'failed uploads' , x . failedUploads > 0 ) ,
stat ( x . orphanedObjects , 'orphaned' , x . orphanedObjects > 0 ) ,
) ,
h ( 'p' , { class : 'small muted' , style : 'margin:12px 0 0' } , 'New objects wait in a local buffer and are packed into full slabs before upload, which keeps wasted space down.' ) ,
) ;
} catch ( e ) {
put ( statsCard , h ( 'h2' , { } , 'Upload pipeline' ) , h ( 'p' , { class : 'error' } , e . message ) ) ;
}
}
async function flushNow ( ev ) {
const btn = ev . currentTarget ;
btn . disabled = true ;
put ( btn , h ( 'span' , { class : 'spinner' } ) , ' Flushing…' ) ;
try { await api ( 'POST' , '/api/flush' ) ; toast ( 'All pending objects are on Sia' ) ; }
catch ( e ) { fail ( e ) ; }
drawStats ( ) ;
}
draw ( ) ;
drawStats ( ) ;
cleanup . push ( onState ( draw ) ) ;
let lastState = daemonState ( ) ;
cleanup . push ( onState ( ( ) => { if ( daemonState ( ) !== lastState ) { lastState = daemonState ( ) ; drawStats ( ) ; } } ) ) ;
const t = setInterval ( ( ) => { draw ( ) ; drawStats ( ) ; } , 4000 ) ;
cleanup . push ( ( ) => clearInterval ( t ) ) ;
}
function bannersFor ( s ) {
const out = [ ] ;
if ( ! s . daemon . binary ) {
out . push ( h ( 'div' , { class : 'banner warn' } ,
h ( 'span' , { } , ` s3d is not installed. Pithos can download the official v ${ s . s3d . pinned } release and check its SHA-256. ` ) ,
s . s3d . installable && h ( 'button' , { class : 'btn primary' , onclick : installBinary } , ` Install s3d v ${ s . s3d . pinned } ` ) ) ) ;
} else if ( s . s3d ? . outdated ) {
out . push ( h ( 'div' , { class : 'banner warn' } ,
h ( 'span' , { } , ` s3d v ${ s . s3d . version } is older than v ${ s . s3d . pinned } . User and key listing needs v0.2.0 or newer. ` ) ,
s . s3d . installable && h ( 'button' , { class : 'btn' , onclick : installBinary } , ` Install v ${ s . s3d . pinned } ` ) ) ) ;
}
if ( s . registration ? . registered === false || s . daemon . needsLogin ) {
out . push ( h ( 'div' , { class : 'banner info' } ,
h ( 'span' , { } , 'This s3d is not connected to a Sia indexer yet.' ) ,
2026-10-03 20:42:23 +02:00
h ( 'a' , { class : 'btn primary' , href : '#/start' } , 'Get started' ) ) ) ;
2026-10-03 19:03:05 +02:00
}
return out ;
}
async function installBinary ( ev ) {
const btn = ev . currentTarget ;
btn . disabled = true ;
put ( btn , h ( 'span' , { class : 'spinner' } ) , ' Downloading…' ) ;
try {
const r = await api ( 'POST' , '/api/binary/install' ) ;
toast ( ` Installed s3d v ${ r . version ? . version || '' } ` ) ;
} catch ( e ) { fail ( e ) ; }
refreshStatus ( ) ;
}
async function daemonAction ( action ) {
try {
await api ( 'POST' , ` /api/daemon/ ${ action } ` ) ;
await refreshStatus ( ) ;
} catch ( e ) { fail ( e ) ; }
}
2026-10-03 20:42:23 +02:00
// ---------------------------------------------------------------- get started (guided setup)
// One step per screen, Back / Next, from "no account" to a working S3 key.
// The connect step drives the same /api/login flow as the Sia connection
// page, but opens the indexer's approval page by itself and moves on as
// soon as the approval lands.
const SETUP _DONE _KEY = 'pithos.setupDone' ;
const wiz = { step : 0 , indexerUrl : 'https://sia.storage' , mode : 'new' , phrase : '' , ownIndexer : false , openedUrl : null , key : null } ;
function setupDone ( ) {
try { return localStorage . getItem ( SETUP _DONE _KEY ) === '1' ; } catch { return false ; }
}
function getStarted ( main ) {
const STEPS = [ 'Welcome' , 'Sia Storage account' , 'Install s3d' , 'Recovery phrase' , 'Connect' , 'Start gateway' , 'Access key' , 'Done' ] ;
const body = h ( 'div' , { class : 'card wizard' } ) ;
main . append (
h ( 'div' , { class : 'page-head' } , h ( 'div' , { } , h ( 'h1' , { } , 'Get started' ) ,
h ( 'p' , { class : 'muted' } , 'Set up your own S3 storage on Sia, one step at a time.' ) ) ) ,
body ,
) ;
const go = ( n ) => { wiz . step = Math . max ( 0 , Math . min ( STEPS . length - 1 , n ) ) ; draw ( ) ; } ;
const nav = ( opts = { } ) => h ( 'div' , { class : 'row wizard-nav' } ,
wiz . step > 0 && wiz . step < STEPS . length - 1 && h ( 'button' , { class : 'btn' , disabled : opts . backDisabled , onclick : ( ) => go ( wiz . step - 1 ) } , 'Back' ) ,
h ( 'span' , { style : 'flex:1' } ) ,
opts . extra ,
opts . next !== false && h ( 'button' , { class : 'btn primary' , disabled : ! ! opts . nextDisabled , onclick : opts . onNext || ( ( ) => go ( wiz . step + 1 ) ) } , opts . nextLabel || 'Next' ) ) ;
function progress ( ) {
return h ( 'div' , { class : 'wizard-progress' } ,
h ( 'div' , { class : 'small muted' } , ` Step ${ wiz . step + 1 } of ${ STEPS . length } · ${ STEPS [ wiz . step ] } ` ) ,
h ( 'div' , { class : 'bar' } , h ( 'i' , { style : ` width: ${ Math . round ( ( wiz . step / ( STEPS . length - 1 ) ) * 100 ) } % ` } ) ) ) ;
}
function draw ( ) {
const s = state . status ;
const registered = ! ! s ? . registration ? . registered || [ 'done' , 'already' ] . includes ( state . login . state ) ;
let content ;
switch ( wiz . step ) {
case 0 :
content = [
h ( 'h2' , { } , 'Your own S3 storage, on Sia' ) ,
h ( 'p' , { } , 'Pithos runs s3d, an S3-compatible gateway, on this computer. Your files are encrypted here and spread across independent Sia hosts.' ) ,
h ( 'p' , { } , 'This takes about five minutes:' ) ,
h ( 'ol' , { class : 'wizard-list' } ,
h ( 'li' , { } , 'Create a free Sia Storage account (50 GB free)' ) ,
h ( 'li' , { } , 'Install s3d' ) ,
h ( 'li' , { } , 'Choose your recovery phrase' ) ,
h ( 'li' , { } , 'Approve Pithos on Sia Storage' ) ,
h ( 'li' , { } , 'Start the gateway and create an access key' ) ) ,
registered && h ( 'div' , { class : 'banner info' } , h ( 'span' , { } , 'This s3d is already connected to Sia. You can skip ahead.' ) ,
h ( 'button' , { class : 'btn' , onclick : ( ) => go ( 5 ) } , 'Skip to the gateway' ) ) ,
nav ( { nextLabel : 'Get started' } ) ,
] ;
break ;
case 1 : {
const url = h ( 'input' , { type : 'url' , value : wiz . indexerUrl , oninput : ( e ) => { wiz . indexerUrl = e . target . value . trim ( ) ; } } ) ;
content = [
h ( 'h2' , { } , 'Create your Sia Storage account' ) ,
h ( 'p' , { } , 'Sia Storage runs the indexer that rents space from Sia hosts for you. The free plan gives you 50 GB; you sign up with Sia Storage directly, and Pithos never sees your account password.' ) ,
h ( 'div' , { class : 'row' } ,
h ( 'button' , { class : 'btn primary' , onclick : ( ) => openLink ( 'https://sia.storage' ) } , 'Open sia.storage ↗' ) ,
h ( 'span' , { class : 'muted small' } , 'Sign up there, then come back and press Next.' ) ) ,
h ( 'label' , { class : 'check' , style : 'margin-top:18px' } ,
h ( 'input' , { type : 'checkbox' , checked : wiz . ownIndexer , onchange : ( e ) => { wiz . ownIndexer = e . target . checked ; if ( ! wiz . ownIndexer ) wiz . indexerUrl = 'https://sia.storage' ; draw ( ) ; } } ) ,
'I run my own indexer instead' ) ,
wiz . ownIndexer && h ( 'label' , { class : 'field' , style : 'margin-top:8px' } , h ( 'span' , { } , 'Indexer URL' ) , url ) ,
nav ( { nextLabel : 'I have an account — Next' , onNext : ( ) => {
if ( ! /^https?:\/\/\S+$/ . test ( wiz . indexerUrl ) ) return toast ( 'Enter the indexer URL' , 'error' ) ;
go ( 2 ) ;
} } ) ,
] ;
break ;
}
case 2 : {
const ok = ! ! s ? . daemon . binary && ! s ? . s3d ? . outdated && ! ! s ? . s3d ? . version ;
content = [
h ( 'h2' , { } , 'Install s3d' ) ,
ok
? h ( 'p' , { } , '✓ s3d ' , h ( 'code' , { } , ` v ${ s . s3d . version } ` ) , ' is installed.' )
: [
h ( 'p' , { } , s ? . daemon . binary ? ` Your s3d (v ${ s . s3d . version || '?' } ) is too old for Pithos. ` : 's3d is not installed yet.' ) ,
h ( 'p' , { class : 'muted small' } , ` Pithos downloads the official v ${ s ? . s3d ? . pinned } release from the Sia Foundation and checks its SHA-256 before installing it. ` ) ,
h ( 'button' , { class : 'btn primary' , disabled : ! s ? . s3d ? . installable , onclick : async ( e ) => { await installBinary ( e ) ; draw ( ) ; } } , ` Install s3d v ${ s ? . s3d ? . pinned } ` ) ,
] ,
nav ( { nextDisabled : ! ok } ) ,
] ;
break ;
}
case 3 : {
const phrase = h ( 'textarea' , { rows : 3 , autocomplete : 'off' , spellcheck : 'false' , placeholder : 'twelve words separated by spaces' , oninput : ( e ) => { wiz . phrase = e . target . value ; } } ) ;
phrase . value = wiz . phrase ;
const vaultOk = ! ! ( bridge && vaultInfo && vaultInfo . setup ) ;
content = [
h ( 'h2' , { } , 'Your recovery phrase' ) ,
h ( 'p' , { } , 'Twelve words that let you reconnect to the same storage later, on this computer or a new one. Your files cannot be reached without them.' ) ,
h ( 'div' , { class : 'stack' } ,
vaultOk && h ( 'label' , { class : 'check' , style : 'align-items:flex-start' } , h ( 'input' , { type : 'radio' , name : 'wm' , checked : wiz . mode === 'vault' , onchange : ( ) => { wiz . mode = 'vault' ; wiz . modeChosen = true ; draw ( ) ; } } ) ,
h ( 'span' , { } , h ( 'strong' , { } , 'Use my Theseus vault' ) , ' (recommended)' , h ( 'br' ) , h ( 'span' , { class : 'small muted' } , 'Nothing to write down. The phrase comes from your password vault, so backing up the vault backs up your storage too.' ) ) ) ,
h ( 'label' , { class : 'check' } , h ( 'input' , { type : 'radio' , name : 'wm' , checked : wiz . mode === 'new' , onchange : ( ) => { wiz . mode = 'new' ; wiz . modeChosen = true ; draw ( ) ; } } ) , 'Create a new phrase for me to write down' ) ,
h ( 'label' , { class : 'check' } , h ( 'input' , { type : 'radio' , name : 'wm' , checked : wiz . mode === 'existing' , onchange : ( ) => { wiz . mode = 'existing' ; wiz . modeChosen = true ; draw ( ) ; } } ) , 'I already have a phrase (reconnecting)' ) ) ,
wiz . mode === 'existing' && h ( 'label' , { class : 'field' , style : 'margin-top:12px' } , h ( 'span' , { } , 'Recovery phrase' ) , phrase ,
h ( 'small' , { } , 'Sent only to the s3d process on this computer.' ) ) ,
wiz . mode === 'new' && h ( 'p' , { class : 'muted small' , style : 'margin-top:12px' } , 'You will see the new phrase on the next screen and confirm you saved it before anything is registered.' ) ,
wiz . mode === 'vault' && h ( 'p' , { class : 'muted small' , style : 'margin-top:12px' } , 'Theseus may ask for your PIN or master password to open the vault.' ) ,
nav ( { nextLabel : 'Connect' , onNext : ( ) => {
if ( wiz . mode === 'existing' && wiz . phrase . trim ( ) . split ( /\s+/ ) . length !== 12 ) return toast ( 'A recovery phrase is 12 words' , 'error' ) ;
wiz . openedUrl = null ;
go ( 4 ) ;
startConnect ( ) ;
} } ) ,
] ;
break ;
}
case 4 :
content = connectStep ( registered ) ;
break ;
case 5 : {
const st = daemonState ( ) ;
const running = st === 'running' || st === 'external' ;
content = [
h ( 'h2' , { } , 'Start your gateway' ) ,
running
? h ( 'p' , { } , '✓ s3d is running. Your S3 endpoint is ' , h ( 'code' , { } , ` http:// ${ s . config . apiAddress } ` ) , '.' )
: st === 'starting'
? h ( 'p' , { class : 'muted row' } , h ( 'span' , { class : 'spinner' } ) , ' Starting s3d…' )
: [
st === 'crashed' && h ( 'div' , { class : 'banner warn' } , h ( 'span' , { } , 's3d stopped. The Logs page shows why.' ) , h ( 'a' , { class : 'btn' , href : '#/logs' } , 'Logs' ) ) ,
h ( 'p' , { } , 'Pithos starts s3d for you. It keeps running while Pithos is open.' ) ,
h ( 'button' , { class : 'btn primary' , onclick : ( ) => daemonAction ( st === 'crashed' ? 'restart' : 'start' ) } , 'Start s3d' ) ,
] ,
nav ( { nextDisabled : ! running } ) ,
] ;
break ;
}
case 6 : {
if ( wiz . key ) {
const k = wiz . key ;
const endpoint = ` http:// ${ s ? . config . apiAddress } ` ;
content = [
h ( 'h2' , { } , 'Your first access key' ) ,
h ( 'p' , { } , 'S3 apps sign in with this key pair. You can see it again under Users & keys.' ) ,
secretRow ( 'Access key ID' , k . accessKeyId ) ,
secretRow ( 'Secret key' , k . secretKey ) ,
h ( 'p' , { class : 'small muted' , style : 'margin:14px 0 6px' } , 'Try it with the aws CLI:' ) ,
h ( 'pre' , { class : 'snippet' } , ` aws configure set aws_access_key_id ${ k . accessKeyId } --profile ${ k . user } \n aws configure set aws_secret_access_key ${ k . secretKey } --profile ${ k . user } \n aws configure set region us-east-1 --profile ${ k . user } \n aws --profile ${ k . user } --endpoint-url ${ endpoint } s3 mb s3://my-first-bucket ` ) ,
nav ( { backDisabled : true } ) ,
] ;
} else {
const name = h ( 'input' , { type : 'text' , value : 'me' , pattern : '[A-Za-z0-9][A-Za-z0-9._@-]{0,63}' } ) ;
content = [
h ( 'h2' , { } , 'Create an access key' ) ,
h ( 'p' , { } , 'Each S3 user owns its own buckets. Start with one for yourself; you can add more for apps or people later.' ) ,
h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'User name' ) , name ) ,
nav ( { nextLabel : 'Create user and key' , onNext : async ( e ) => {
e . currentTarget . disabled = true ;
try {
const r = await api ( 'POST' , '/api/users' , { name : name . value . trim ( ) , withKey : true } ) ;
wiz . key = r . key ;
state . selectedUser = r . name ;
try { localStorage . setItem ( 'pithos.user' , r . name ) ; } catch { }
} catch ( err ) { fail ( err ) ; }
draw ( ) ;
} } ) ,
] ;
}
break ;
}
default :
try { localStorage . setItem ( SETUP _DONE _KEY , '1' ) ; } catch { }
content = [
h ( 'h2' , { } , '🎉 You are all set' ) ,
h ( 'p' , { } , 'Your S3 gateway is running and stores everything on Sia. Point any S3 app at it with the key you just created, or manage files right here.' ) ,
h ( 'div' , { class : 'row' } ,
h ( 'a' , { class : 'btn primary' , href : '#/buckets' } , 'Open buckets' ) ,
h ( 'a' , { class : 'btn' , href : '#/overview' } , 'Overview' ) ) ,
] ;
}
put ( body , progress ( ) , content ) ;
}
async function startConnect ( ) {
if ( state . status ? . registration ? . registered ) return ;
if ( wiz . mode === 'vault' && bridge ) {
try {
const r = await bridge . invoke ( 'connect-with-vault' , { indexerUrl : wiz . indexerUrl } ) ;
if ( r . status >= 400 ) throw new Error ( r . data ? . error || ` HTTP ${ r . status } ` ) ;
} catch ( e ) { fail ( e ) ; }
return ;
}
try {
await api ( 'POST' , '/api/login' , { indexerUrl : wiz . indexerUrl , phrase : wiz . mode === 'existing' ? wiz . phrase . trim ( ) : '' } ) ;
wiz . phrase = '' ;
} catch ( e ) { fail ( e ) ; }
}
function connectStep ( registered ) {
const l = state . login ;
const retry = h ( 'button' , { class : 'btn' , onclick : ( ) => go ( 3 ) } , 'Back to the phrase' ) ;
if ( registered ) {
// Approval landed: move on by itself.
setTimeout ( ( ) => { if ( wiz . step === 4 ) { refreshStatus ( ) ; go ( 5 ) ; } } , 1200 ) ;
return [ h ( 'h2' , { } , 'Connected' ) , h ( 'p' , { } , '✓ Pithos is approved on ' , h ( 'code' , { } , l . indexerUrl || wiz . indexerUrl ) , '. Continuing…' ) , nav ( { next : false } ) ] ;
}
switch ( l . state ) {
case 'confirm' : {
const ack = h ( 'input' , { type : 'checkbox' } ) ;
const go2 = h ( 'button' , { class : 'btn primary' , disabled : true , onclick : ( ) => api ( 'POST' , '/api/login/confirm' ) . catch ( fail ) } , 'I saved it — continue' ) ;
ack . addEventListener ( 'change' , ( ) => { go2 . disabled = ! ack . checked ; } ) ;
return [
h ( 'h2' , { } , 'Write down your recovery phrase' ) ,
h ( 'p' , { } , 'This is the only way to reconnect to your storage. Pithos does not keep it. Write it on paper or store it in a password manager.' ) ,
h ( 'div' , { class : 'phrase' } , ( l . generatedPhrase || '' ) . split ( /\s+/ ) . map ( ( w ) => h ( 'span' , { } , w ) ) ) ,
h ( 'div' , { class : 'row' , style : 'margin-top:12px' } , h ( 'button' , { class : 'btn small' , onclick : ( ) => copy ( l . generatedPhrase , 'Phrase copied: clear your clipboard after storing it' ) } , 'Copy' ) ) ,
h ( 'label' , { class : 'check' , style : 'margin:16px 0' } , ack , 'I have stored these 12 words somewhere safe' ) ,
nav ( { next : false , extra : go2 } ) ,
] ;
}
case 'approve' : {
// Open the approval page once, by itself; the button re-opens it.
if ( l . approvalUrl && wiz . openedUrl !== l . approvalUrl ) {
wiz . openedUrl = l . approvalUrl ;
wiz . autoOpened = null ;
openLink ( l . approvalUrl ) . then ( ( ok ) => { wiz . autoOpened = ok ; if ( wiz . step === 4 ) draw ( ) ; } ) ;
}
return [
h ( 'h2' , { } , 'Approve Pithos on Sia Storage' ) ,
h ( 'p' , { } , wiz . autoOpened === false
? 'Open the approval page, sign in to Sia Storage and approve the "S3d" app. Pithos continues on its own once you do.'
: 'The approval page has opened. Sign in to Sia Storage there and approve the "S3d" app. Pithos continues on its own once you do.' ) ,
h ( 'div' , { class : 'row' } ,
h ( 'button' , { class : wiz . autoOpened === false ? 'btn primary' : 'btn' , onclick : ( ) => openLink ( l . approvalUrl ) } , wiz . autoOpened === false ? 'Open the approval page ↗' : 'Open the approval page again ↗' ) ,
h ( 'button' , { class : 'btn small' , onclick : ( ) => copy ( l . approvalUrl ) } , 'Copy link' ) ) ,
h ( 'p' , { class : 'muted row' , style : 'margin-top:16px' } , h ( 'span' , { class : 'spinner' } ) , ' Waiting for your approval…' ) ,
nav ( { next : false , extra : h ( 'button' , { class : 'btn danger' , onclick : ( ) => api ( 'POST' , '/api/login/cancel' ) . then ( ( ) => go ( 3 ) ) . catch ( fail ) } , 'Cancel' ) } ) ,
] ;
}
case 'terminal' :
return [
h ( 'h2' , { } , 'Finish in the console window' ) ,
h ( 'p' , { } , 'Terminal support could not be set up here, so a console window running "s3d login" has opened. Enter the indexer URL and your recovery phrase there, then open the link it prints. This page continues when the window closes.' ) ,
h ( 'p' , { class : 'muted row' } , h ( 'span' , { class : 'spinner' } ) , ' Waiting for the login window…' ) ,
nav ( { next : false } ) ,
] ;
case 'failed' :
return [
h ( 'h2' , { } , 'Connection failed' ) ,
h ( 'div' , { class : 'banner warn' } , h ( 'span' , { } , l . error || 'The login did not finish.' ) ) ,
// The phrase is dropped from memory once sent, so a retry with an
// existing phrase has to ask for it again; a blank one would make
// s3d generate a new phrase instead.
nav ( { next : false , extra : [ retry , h ( 'button' , { class : 'btn primary' , onclick : ( ) => {
wiz . openedUrl = null ;
if ( wiz . mode === 'existing' && ! wiz . phrase . trim ( ) ) return go ( 3 ) ;
startConnect ( ) ;
} } , 'Try again' ) ] } ) ,
] ;
case 'idle' :
return [ h ( 'h2' , { } , 'Connect to Sia' ) , h ( 'p' , { } , 'Ready to connect.' ) ,
nav ( { nextLabel : 'Connect' , onNext : ( ) => startConnect ( ) } ) ] ;
default : {
const what = l . state === 'preparing' ? 'Setting up terminal support (first time only)…' : 'Contacting the indexer…' ;
return [ h ( 'h2' , { } , 'Connecting' ) , h ( 'p' , { class : 'muted row' } , h ( 'span' , { class : 'spinner' } ) , ` ${ what } ` ) , nav ( { next : false } ) ] ;
}
}
}
if ( bridge && vaultInfo ? . setup && ! wiz . modeChosen ) wiz . mode = 'vault' ;
// Resume where things stand if the user comes back mid-setup.
if ( wiz . step === 0 && state . login . state === 'approve' ) wiz . step = 4 ;
draw ( ) ;
cleanup . push ( onState ( draw ) ) ;
}
2026-10-03 19:03:05 +02:00
// ---------------------------------------------------------------- setup (indexer login)
function setup ( main ) {
const body = h ( 'div' , { class : 'card' } ) ;
main . append (
h ( 'div' , { class : 'page-head' } , h ( 'div' , { } , h ( 'h1' , { } , 'Sia connection' ) ,
h ( 'p' , { class : 'muted' } , 'Register this s3d with a Sia indexer. You do this once; the indexer then handles hosts and contracts for it.' ) ) ) ,
body ,
) ;
let draft = { indexerUrl : 'https://sia.storage' , phrase : '' , mode : 'existing' } ;
function draw ( ) {
let l = state . login ;
// Registered already (e.g. on an earlier run): show that instead of the form.
if ( l . state === 'idle' && state . status ? . registration ? . registered ) l = { state : 'already' , indexerUrl : state . status . registration . indexerUrl } ;
const step = [ 'idle' , 'failed' ] . includes ( l . state ) ? 0 : [ 'done' , 'already' ] . includes ( l . state ) ? 2 : 1 ;
const steps = h ( 'div' , { class : 'steps' } , [ 'Recovery phrase' , 'Approve on the indexer' , 'Connected' ] . map ( ( t , i ) => h ( 'span' , { class : i === step ? 'on' : '' } , ` ${ i + 1 } . ${ t } ` ) ) ) ;
if ( l . state === 'done' || l . state === 'already' ) {
put ( body , steps ,
h ( 'h2' , { } , l . state === 'done' ? 'Connected' : 'Already connected' ) ,
h ( 'p' , { } , l . indexerUrl ? [ 'This s3d is registered with ' , h ( 'code' , { } , l . indexerUrl ) , '.' ] : 'This s3d is registered with an indexer.' ) ,
h ( 'div' , { class : 'row' } ,
daemonState ( ) !== 'running' && h ( 'button' , { class : 'btn primary' , onclick : async ( ) => { await daemonAction ( daemonState ( ) === 'crashed' ? 'restart' : 'start' ) ; location . hash = '#/overview' ; } } , 'Start s3d' ) ,
h ( 'a' , { class : 'btn' , href : '#/users' } , 'Create a user' ) ,
h ( 'button' , { class : 'btn' , onclick : ( ) => api ( 'POST' , '/api/login/cancel' ) . catch ( fail ) } , 'Done' ) ) ) ;
return ;
}
if ( l . state === 'confirm' ) {
const words = ( l . generatedPhrase || '' ) . split ( /\s+/ ) ;
const ack = h ( 'input' , { type : 'checkbox' , id : 'ack' } ) ;
const go = h ( 'button' , { class : 'btn primary' , disabled : true , onclick : ( ) => api ( 'POST' , '/api/login/confirm' ) . catch ( fail ) } , 'Continue' ) ;
ack . addEventListener ( 'change' , ( ) => { go . disabled = ! ack . checked ; } ) ;
put ( body , steps ,
h ( 'h2' , { } , 'Write down your new recovery phrase' ) ,
h ( 'p' , { } , 'This phrase is the only way to register this storage again on another machine or after a reinstall. Pithos does not keep it. Store it offline.' ) ,
h ( 'div' , { class : 'phrase' } , words . map ( ( w ) => h ( 'span' , { } , w ) ) ) ,
h ( 'div' , { class : 'row' , style : 'margin-top:14px' } , h ( 'button' , { class : 'btn small' , onclick : ( ) => copy ( l . generatedPhrase , 'Phrase copied - clear your clipboard after storing it' ) } , 'Copy' ) ) ,
h ( 'label' , { class : 'check' , style : 'margin:16px 0' } , ack , 'I have stored the phrase somewhere safe' ) ,
h ( 'div' , { class : 'row' } , go , h ( 'button' , { class : 'btn' , onclick : cancel } , 'Cancel' ) ) ) ;
return ;
}
if ( l . state === 'terminal' ) {
put ( body , steps ,
h ( 'h2' , { } , 'Finish in the console window' ) ,
h ( 'p' , { } , 'A console window running "s3d login" has opened. Enter the indexer URL and your recovery phrase there, then open the approval link it prints. This page updates when the window closes.' ) ,
h ( 'p' , { class : 'muted row' } , h ( 'span' , { class : 'spinner' } ) , ' Waiting for the login window…' ) ,
h ( 'div' , { class : 'row' } , h ( 'button' , { class : 'btn danger' , onclick : cancel } , 'Cancel' ) ) ) ;
return ;
}
if ( step === 1 ) {
const waiting = l . state === 'approve' ? null : h ( 'p' , { class : 'muted row' } , h ( 'span' , { class : 'spinner' } ) , ' Talking to the indexer…' ) ;
put ( body , steps ,
h ( 'h2' , { } , 'Approve the connection' ) ,
waiting ,
l . approvalUrl && [
h ( 'p' , { } , 'Open this link, sign in to your indexer account and approve the "S3d" app. This page continues by itself once you approve.' ) ,
h ( 'div' , { class : 'secret' } , h ( 'code' , { } , l . approvalUrl ) ) ,
h ( 'div' , { class : 'row' , style : 'margin-top:12px' } ,
h ( 'button' , { class : 'btn primary' , onclick : ( ) => openLink ( l . approvalUrl ) } , 'Open approval page' ) ,
h ( 'button' , { class : 'btn' , onclick : ( ) => copy ( l . approvalUrl ) } , 'Copy link' ) ) ,
h ( 'p' , { class : 'muted row' , style : 'margin-top:16px' } , h ( 'span' , { class : 'spinner' } ) , ' Waiting for approval…' ) ,
] ,
h ( 'div' , { class : 'row' , style : 'margin-top:12px' } , h ( 'button' , { class : 'btn danger' , onclick : cancel } , 'Cancel' ) ) ) ;
return ;
}
// step 0: form
const url = h ( 'input' , { type : 'url' , value : draft . indexerUrl , required : true , oninput : ( e ) => { draft . indexerUrl = e . target . value ; } } ) ;
const phrase = h ( 'textarea' , { rows : 3 , autocomplete : 'off' , spellcheck : 'false' , placeholder : 'twelve words separated by spaces' , oninput : ( e ) => { draft . phrase = e . target . value ; } } ) ;
phrase . value = draft . phrase ;
const radios = h ( 'div' , { class : 'stack' } ,
h ( 'label' , { class : 'check' } , h ( 'input' , { type : 'radio' , name : 'mode' , checked : draft . mode === 'existing' , onchange : ( ) => { draft . mode = 'existing' ; draw ( ) ; } } ) , 'I have a recovery phrase' ) ,
h ( 'label' , { class : 'check' } , h ( 'input' , { type : 'radio' , name : 'mode' , checked : draft . mode === 'new' , onchange : ( ) => { draft . mode = 'new' ; draw ( ) ; } } ) , 'Generate a new one' ) ) ;
put ( body , steps ,
l . state === 'failed' && h ( 'div' , { class : 'banner warn' } , h ( 'span' , { } , l . error || 'Login failed' ) ) ,
h ( 'form' , { class : 'stack' , onsubmit : start } ,
h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Indexer' ) , url , h ( 'small' , { } , 'https://sia.storage is the hosted indexer run by the Sia Foundation. Use your own indexd URL if you run one.' ) ) ,
radios ,
draft . mode === 'existing' && h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Recovery phrase' ) , phrase ,
h ( 'small' , { } , 'Sent only to the local s3d process. It derives this app\'s key and is not stored by Pithos.' ) ) ,
h ( 'div' , { class : 'row' } , h ( 'button' , { class : 'btn primary' , type : 'submit' } , 'Connect' ) ) ) ) ;
}
async function start ( e ) {
e . preventDefault ( ) ;
const phrase = draft . mode === 'existing' ? draft . phrase . trim ( ) : '' ;
if ( draft . mode === 'existing' && phrase . split ( /\s+/ ) . length !== 12 ) return toast ( 'A recovery phrase is 12 words' , 'error' ) ;
try {
await api ( 'POST' , '/api/login' , { indexerUrl : draft . indexerUrl , phrase } ) ;
draft . phrase = '' ;
} catch ( err ) { fail ( err ) ; }
}
async function cancel ( ) { try { await api ( 'POST' , '/api/login/cancel' ) ; } catch ( e ) { fail ( e ) ; } }
draw ( ) ;
cleanup . push ( onState ( draw ) ) ;
}
async function openLink ( url ) {
2026-10-03 20:42:23 +02:00
if ( bridge ) { try { await bridge . invoke ( 'open-external' , { url } ) ; return true ; } catch ( e ) { fail ( e ) ; return false ; } }
2026-10-03 19:03:05 +02:00
const host = state . status ? . host ;
if ( host === 'desktop' || host === 'theseus' ) {
2026-10-03 20:42:23 +02:00
try { await api ( 'POST' , '/api/open-external' , { url } ) ; return true ; } catch { /* fall through */ }
2026-10-03 19:03:05 +02:00
}
2026-10-03 20:42:23 +02:00
// Returns false when a pop-up blocker stopped it (no click behind the call).
return ! ! window . open ( url , '_blank' , 'noopener' ) ;
2026-10-03 19:03:05 +02:00
}
// ---------------------------------------------------------------- users & keys
function users ( main ) {
const list = h ( 'div' , { class : 'card' } ) ;
main . append (
h ( 'div' , { class : 'page-head' } ,
h ( 'div' , { } , h ( 'h1' , { } , 'Users & keys' ) , h ( 'p' , { class : 'muted' } , 'Each S3 user owns its own buckets. Access keys let S3 clients (aws, rclone, apps) act as that user.' ) ) ,
h ( 'button' , { class : 'btn primary' , onclick : createUser } , 'New user' ) ) ,
list ,
) ;
let expanded = new Set ( ) ;
async function load ( ) {
let rows ;
try { rows = await api ( 'GET' , '/api/users' ) ; }
catch ( e ) { put ( list , h ( 'p' , { class : 'error' } , e . message ) ) ; return ; }
if ( ! rows . length ) {
put ( list , h ( 'div' , { class : 'empty' } , h ( 'p' , { } , 'No users yet.' ) , h ( 'button' , { class : 'btn primary' , onclick : createUser } , 'Create the first user' ) ) ) ;
return ;
}
const keys = await api ( 'GET' , '/api/keys' ) . catch ( ( ) => [ ] ) ;
put ( list , h ( 'table' , { } ,
h ( 'thead' , { } , h ( 'tr' , { } , h ( 'th' , { } , 'User' ) , h ( 'th' , { } , 'Access keys' ) , h ( 'th' , { } ) ) ) ,
h ( 'tbody' , { } , rows . flatMap ( ( u ) => {
const open = expanded . has ( u . name ) ;
const userKeys = keys . filter ( ( k ) => k . user === u . name ) ;
const main = h ( 'tr' , { class : 'clickable' , onclick : ( ) => { open ? expanded . delete ( u . name ) : expanded . add ( u . name ) ; load ( ) ; } } ,
h ( 'td' , { } , h ( 'strong' , { } , u . name ) ) ,
h ( 'td' , { } , ` ${ u . keys . length } ` ) ,
h ( 'td' , { class : 'actions' } ,
h ( 'button' , { class : 'btn small' , onclick : ( e ) => { e . stopPropagation ( ) ; state . selectedUser = u . name ; localStorage . setItem ( 'pithos.user' , u . name ) ; location . hash = '#/buckets' ; } } , 'Buckets' ) ,
' ' ,
h ( 'button' , { class : 'btn small' , onclick : ( e ) => { e . stopPropagation ( ) ; newKey ( u . name ) ; } } , 'New key' ) ,
' ' ,
h ( 'button' , { class : 'btn small danger' , onclick : ( e ) => { e . stopPropagation ( ) ; deleteUser ( u . name ) ; } } , 'Delete' ) ) ) ;
if ( ! open ) return [ main ] ;
const detail = h ( 'tr' , { } , h ( 'td' , { colspan : 3 , style : 'background:var(--surface-2)' } ,
userKeys . length ? h ( 'table' , { } , h ( 'tbody' , { } , userKeys . map ( ( k ) => h ( 'tr' , { } ,
h ( 'td' , { } , h ( 'code' , { } , k . accessKeyId ) ) ,
h ( 'td' , { class : 'actions' } ,
h ( 'button' , { class : 'btn small' , onclick : ( ) => showKey ( k ) } , 'Show secret & config' ) ,
' ' ,
h ( 'button' , { class : 'btn small danger' , onclick : ( ) => deleteKey ( k . accessKeyId ) } , 'Revoke' ) ) ) ) ) )
: h ( 'p' , { class : 'muted' , style : 'margin:0' } , 'No keys. Create one to use S3 clients or browse buckets.' ) ) ) ;
return [ main , detail ] ;
} ) ) ) ) ;
}
async function createUser ( ) {
const name = h ( 'input' , { type : 'text' , required : true , pattern : '[A-Za-z0-9][A-Za-z0-9._@-]{0,63}' , placeholder : 'e.g. backups' } ) ;
const withKey = h ( 'input' , { type : 'checkbox' , checked : true } ) ;
const v = await modal ( 'New user' , h ( 'div' , { class : 'stack' } ,
h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Name' ) , name , h ( 'small' , { } , 'Letters, digits, . _ @ -' ) ) ,
h ( 'label' , { class : 'check' } , withKey , 'Create an access key now' ) ) ,
[ { label : 'Cancel' , result : null } , { label : 'Create' , kind : 'primary' , submit : true , value : ( ) => ( { name : name . value . trim ( ) , withKey : withKey . checked } ) } ] ) ;
if ( ! v ) return ;
try {
const r = await api ( 'POST' , '/api/users' , v ) ;
toast ( ` Created ${ r . name } ` ) ;
expanded . add ( r . name ) ;
if ( ! state . selectedUser ) { state . selectedUser = r . name ; localStorage . setItem ( 'pithos.user' , r . name ) ; }
await load ( ) ;
if ( r . key ) showKey ( r . key , true ) ;
} catch ( e ) { fail ( e ) ; }
}
async function newKey ( user ) {
try {
const k = await api ( 'POST' , '/api/keys' , { user } ) ;
expanded . add ( user ) ;
await load ( ) ;
showKey ( k , true ) ;
} catch ( e ) { fail ( e ) ; }
}
async function deleteUser ( name ) {
if ( ! ( await confirmModal ( ` Delete ${ name } ? ` , 'Its access keys stop working immediately. s3d refuses if the user still owns buckets.' ) ) ) return ;
try { await api ( 'DELETE' , ` /api/users/ ${ encodeURIComponent ( name ) } ` ) ; toast ( ` Deleted ${ name } ` ) ; load ( ) ; } catch ( e ) { fail ( e ) ; }
}
async function deleteKey ( id ) {
if ( ! ( await confirmModal ( 'Revoke this key?' , ` Clients using ${ id } will be refused from now on. ` , 'Revoke' ) ) ) return ;
try { await api ( 'DELETE' , ` /api/keys/ ${ encodeURIComponent ( id ) } ` ) ; toast ( 'Key revoked' ) ; load ( ) ; } catch ( e ) { fail ( e ) ; }
}
load ( ) ;
}
function showKey ( k , fresh ) {
const s = state . status ;
const endpoint = ` http:// ${ s ? . config . apiAddress || '127.0.0.1:8000' } ` ;
const aws = ` aws configure set aws_access_key_id ${ k . accessKeyId } --profile ${ k . user }
aws configure set aws _secret _access _key $ { k . secretKey } -- profile $ { k . user }
aws configure set region us - east - 1 -- profile $ { k . user }
aws -- profile $ { k . user } -- endpoint - url $ { endpoint } s3 ls ` ;
const rclone = ` [sia- ${ k . user } ]
type = s3
provider = Other
access _key _id = $ { k . accessKeyId }
secret _access _key = $ { k . secretKey }
endpoint = $ { endpoint }
region = us - east - 1
force _path _style = true ` ;
const env = ` AWS_ACCESS_KEY_ID= ${ k . accessKeyId }
AWS _SECRET _ACCESS _KEY = $ { k . secretKey }
AWS _REGION = us - east - 1
AWS _ENDPOINT _URL = $ { endpoint } ` ;
const tabs = { 'aws cli' : aws , rclone , env } ;
const pre = h ( 'pre' , { class : 'snippet' } , aws ) ;
const tabBtns = h ( 'div' , { class : 'row' } , Object . keys ( tabs ) . map ( ( t , i ) => h ( 'button' , {
type : 'button' , class : ` btn small ${ i === 0 ? 'primary' : '' } ` ,
onclick : ( e ) => { for ( const b of tabBtns . children ) b . classList . remove ( 'primary' ) ; e . currentTarget . classList . add ( 'primary' ) ; pre . textContent = tabs [ t ] ; } ,
} , t ) ) ) ;
modal ( fresh ? ` Access key for ${ k . user } ` : ` Key ${ k . accessKeyId } ` , h ( 'div' , { class : 'stack' } ,
fresh && h ( 'p' , { class : 'small muted' , style : 'margin:0' } , 'Treat the secret like a password. You can view it again here later.' ) ,
secretRow ( 'Access key ID' , k . accessKeyId ) ,
secretRow ( 'Secret key' , k . secretKey ) ,
h ( 'h3' , { style : 'margin-top:8px' } , 'Client config' ) ,
tabBtns , pre ,
h ( 'div' , { } , h ( 'button' , { class : 'btn small' , type : 'button' , onclick : ( ) => copy ( pre . textContent ) } , 'Copy snippet' ) ) ) ,
[ { label : 'Done' , kind : 'primary' , submit : true , result : true } ] ) ;
}
// ---------------------------------------------------------------- buckets & objects
function buckets ( main , [ bucket , ... prefixParts ] ) {
const head = h ( 'div' , { class : 'page-head' } ) ;
const body = h ( 'div' ) ;
main . append ( head , body ) ;
let users = [ ] ;
const userSel = h ( 'select' , { onchange : ( e ) => { state . selectedUser = e . target . value ; localStorage . setItem ( 'pithos.user' , e . target . value ) ; location . hash = '#/buckets' ; route ( ) ; } } ) ;
( async ( ) => {
try { users = await api ( 'GET' , '/api/users' ) ; }
catch ( e ) { put ( body , h ( 'div' , { class : 'card' } , h ( 'p' , { class : 'error' } , e . message ) ) ) ; return ; }
const usable = users . filter ( ( u ) => u . keys . length ) ;
if ( ! usable . length ) {
put ( head , h ( 'div' , { } , h ( 'h1' , { } , 'Buckets' ) ) ) ;
put ( body , h ( 'div' , { class : 'card empty' } , h ( 'p' , { } , users . length ? 'None of your users has an access key yet.' : 'Create a user first; buckets belong to users.' ) , h ( 'a' , { class : 'btn primary' , href : '#/users' } , 'Users & keys' ) ) ) ;
return ;
}
if ( ! usable . some ( ( u ) => u . name === state . selectedUser ) ) state . selectedUser = usable [ 0 ] . name ;
put ( userSel , ... usable . map ( ( u ) => h ( 'option' , { value : u . name , selected : u . name === state . selectedUser } , u . name ) ) ) ;
const st = daemonState ( ) ;
if ( st !== 'running' && st !== 'external' ) {
put ( head , h ( 'div' , { } , h ( 'h1' , { } , 'Buckets' ) ) ) ;
put ( body , h ( 'div' , { class : 'card empty' } , h ( 'p' , { } , 's3d is not running.' ) , h ( 'button' , { class : 'btn primary' , onclick : async ( ) => { await daemonAction ( 'start' ) ; setTimeout ( route , 1500 ) ; } } , 'Start s3d' ) ) ) ;
return ;
}
if ( bucket ) objectBrowser ( head , body , state . selectedUser , bucket , prefixParts . join ( '/' ) , userSel ) ;
else bucketList ( head , body , state . selectedUser , userSel ) ;
} ) ( ) ;
}
function userPicker ( userSel ) {
return h ( 'label' , { class : 'row small' } , h ( 'span' , { class : 'muted' } , 'Acting as' ) , h ( 'span' , { style : 'width:180px' } , userSel ) ) ;
}
async function bucketList ( head , body , user , userSel ) {
put ( head ,
h ( 'div' , { } , h ( 'h1' , { } , 'Buckets' ) , h ( 'p' , { class : 'muted' } , 'Buckets owned by the selected user.' ) ) ,
h ( 'div' , { class : 'row' } , userPicker ( userSel ) , h ( 'button' , { class : 'btn primary' , onclick : create } , 'New bucket' ) ) ) ;
const card = h ( 'div' , { class : 'card' } , h ( 'p' , { class : 'muted' } , 'Loading…' ) ) ;
put ( body , card ) ;
const base = ` /api/s3/ ${ encodeURIComponent ( user ) } /buckets ` ;
async function load ( ) {
try {
const list = await api ( 'GET' , base ) ;
if ( ! list . length ) { put ( card , h ( 'div' , { class : 'empty' } , h ( 'p' , { } , 'No buckets yet.' ) , h ( 'button' , { class : 'btn primary' , onclick : create } , 'Create a bucket' ) ) ) ; return ; }
put ( card , h ( 'table' , { } ,
h ( 'thead' , { } , h ( 'tr' , { } , h ( 'th' , { } , 'Name' ) , h ( 'th' , { class : 'hide-sm' } , 'Created' ) , h ( 'th' , { } , 'Access' ) , h ( 'th' , { } ) ) ) ,
h ( 'tbody' , { } , list . map ( ( b ) => {
const access = h ( 'td' , { } , h ( 'span' , { class : 'badge' } , '…' ) ) ;
api ( 'GET' , ` ${ base } / ${ encodeURIComponent ( b . name ) } /policy ` ) . then ( ( p ) => {
put ( access , h ( 'span' , { class : ` badge ${ p . public === 'private' ? '' : 'warn' } ` } , { private : 'private' , read : 'public read' , 'read-list' : 'public read + list' , custom : 'custom policy' } [ p . public ] ) ) ;
} ) . catch ( ( ) => put ( access , h ( 'span' , { class : 'badge' } , '?' ) ) ) ;
return h ( 'tr' , { class : 'clickable' , onclick : ( ) => { location . hash = ` #/buckets/ ${ encodeURIComponent ( b . name ) } ` ; } } ,
h ( 'td' , { } , h ( 'strong' , { } , b . name ) ) ,
h ( 'td' , { class : 'hide-sm muted' } , fmtDate ( b . created ) ) ,
access ,
h ( 'td' , { class : 'actions' } ,
h ( 'button' , { class : 'btn small' , onclick : ( e ) => { e . stopPropagation ( ) ; accessDialog ( user , b . name ) . then ( load ) ; } } , 'Access' ) ,
' ' ,
h ( 'button' , { class : 'btn small danger' , onclick : ( e ) => { e . stopPropagation ( ) ; remove ( b . name ) ; } } , 'Delete' ) ) ) ;
} ) ) ) ) ;
} catch ( e ) { put ( card , h ( 'p' , { class : 'error' } , e . message ) ) ; }
}
async function create ( ) {
const name = h ( 'input' , { type : 'text' , required : true , pattern : '[a-z0-9][a-z0-9.\\-]{1,61}[a-z0-9]' , placeholder : 'my-bucket' } ) ;
const v = await modal ( 'New bucket' , h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Name' ) , name , h ( 'small' , { } , '3-63 characters: lowercase letters, digits, dots and hyphens. Names are unique across this s3d.' ) ) ,
[ { label : 'Cancel' , result : null } , { label : 'Create' , kind : 'primary' , submit : true , value : ( ) => name . value . trim ( ) } ] ) ;
if ( ! v ) return ;
try { await api ( 'POST' , base , { name : v } ) ; toast ( ` Created ${ v } ` ) ; load ( ) ; } catch ( e ) { fail ( e ) ; }
}
async function remove ( name ) {
if ( ! ( await confirmModal ( ` Delete bucket ${ name } ? ` , 'The bucket must be empty. This cannot be undone.' ) ) ) return ;
try { await api ( 'DELETE' , ` ${ base } / ${ encodeURIComponent ( name ) } ` ) ; toast ( ` Deleted ${ name } ` ) ; load ( ) ; } catch ( e ) { fail ( e ) ; }
}
load ( ) ;
}
async function accessDialog ( user , bucket ) {
const base = ` /api/s3/ ${ encodeURIComponent ( user ) } /buckets/ ${ encodeURIComponent ( bucket ) } /policy ` ;
let current = 'private' ;
try { current = ( await api ( 'GET' , base ) ) . public ; } catch ( e ) { return fail ( e ) ; }
const opts = [
[ 'private' , 'Private' , 'Only this user\'s access keys can read.' ] ,
[ 'read' , 'Public read' , 'Anyone who knows an object\'s URL can download it.' ] ,
[ 'read-list' , 'Public read + list' , 'Anyone can also list the bucket\'s contents.' ] ,
] ;
let choice = current === 'custom' ? 'private' : current ;
const endpoint = ` http:// ${ state . status ? . config . apiAddress } ` ;
const v = await modal ( ` Access for ${ bucket } ` , h ( 'div' , { class : 'stack' } ,
current === 'custom' && h ( 'div' , { class : 'banner warn' } , 'This bucket has a custom policy. Saving replaces it.' ) ,
opts . map ( ( [ val , label , desc ] ) => h ( 'label' , { class : 'check' , style : 'align-items:flex-start' } ,
h ( 'input' , { type : 'radio' , name : 'acc' , checked : choice === val , onchange : ( ) => { choice = val ; } } ) ,
h ( 'span' , { } , h ( 'strong' , { } , label ) , h ( 'br' ) , h ( 'span' , { class : 'small muted' } , desc ) ) ) ) ,
h ( 'p' , { class : 'small muted' , style : 'margin:0' } , 'Public objects are served at ' , h ( 'code' , { } , ` ${ endpoint } / ${ bucket } /<key> ` ) , '. Writes and deletes always need a key.' ) ) ,
[ { label : 'Cancel' , result : null } , { label : 'Save' , kind : 'primary' , submit : true , value : ( ) => choice } ] ) ;
if ( ! v || v === current ) return ;
try { await api ( 'PUT' , base , { mode : v } ) ; toast ( 'Access updated' ) ; } catch ( e ) { fail ( e ) ; }
}
function objectBrowser ( head , body , user , bucket , prefix , userSel ) {
if ( prefix && ! prefix . endsWith ( '/' ) ) prefix += '/' ;
const base = ` /api/s3/ ${ encodeURIComponent ( user ) } /buckets/ ${ encodeURIComponent ( bucket ) } ` ;
const go = ( p ) => { location . hash = ` #/buckets/ ${ encodeURIComponent ( bucket ) } ${ p ? '/' + p . split ( '/' ) . filter ( Boolean ) . map ( encodeURIComponent ) . join ( '/' ) : '' } ` ; } ;
const crumbs = h ( 'div' , { class : 'crumbs' } ,
h ( 'button' , { onclick : ( ) => { location . hash = '#/buckets' ; } } , 'Buckets' ) , h ( 'span' , { class : 'sep' } , '/' ) ,
h ( 'button' , { onclick : ( ) => go ( '' ) } , bucket ) ,
prefix . split ( '/' ) . filter ( Boolean ) . map ( ( part , i , all ) => [ h ( 'span' , { class : 'sep' } , '/' ) , h ( 'button' , { onclick : ( ) => go ( all . slice ( 0 , i + 1 ) . join ( '/' ) ) } , part ) ] ) ) ;
const fileInput = h ( 'input' , { type : 'file' , multiple : true , hidden : true , onchange : ( e ) => { uploadFiles ( [ ... e . target . files ] ) ; e . target . value = '' ; } } ) ;
put ( head ,
h ( 'div' , { class : 'stack' } , crumbs ) ,
h ( 'div' , { class : 'row' } , userPicker ( userSel ) ,
h ( 'button' , { class : 'btn' , onclick : newFolder } , 'New folder' ) ,
h ( 'button' , { class : 'btn' , onclick : ( ) => accessDialog ( user , bucket ) } , 'Access' ) ,
h ( 'button' , { class : 'btn primary' , onclick : ( ) => fileInput . click ( ) } , 'Upload' ) , fileInput ) ) ;
const uploads = h ( 'div' , { class : 'uploads' } ) ;
const card = h ( 'div' , { class : 'card dropzone' } , h ( 'p' , { class : 'muted' } , 'Loading…' ) ) ;
put ( body , uploads , card ) ;
let depth = 0 ;
card . addEventListener ( 'dragenter' , ( e ) => { e . preventDefault ( ) ; depth ++ ; card . classList . add ( 'over' ) ; } ) ;
card . addEventListener ( 'dragleave' , ( ) => { if ( -- depth <= 0 ) { depth = 0 ; card . classList . remove ( 'over' ) ; } } ) ;
card . addEventListener ( 'dragover' , ( e ) => e . preventDefault ( ) ) ;
card . addEventListener ( 'drop' , ( e ) => {
e . preventDefault ( ) ; depth = 0 ; card . classList . remove ( 'over' ) ;
uploadFiles ( [ ... e . dataTransfer . files ] ) ;
} ) ;
let pages = [ ] ;
async function load ( token ) {
try {
const q = new URLSearchParams ( { prefix } ) ;
if ( token ) q . set ( 'token' , token ) ;
const page = await api ( 'GET' , ` ${ base } /objects? ${ q } ` ) ;
pages = token ? [ ... pages , page ] : [ page ] ;
draw ( ) ;
} catch ( e ) { put ( card , h ( 'p' , { class : 'error' } , e . message ) ) ; }
}
function draw ( ) {
const folders = pages . flatMap ( ( p ) => p . folders ) ;
const objects = pages . flatMap ( ( p ) => p . objects ) . filter ( ( o ) => o . key !== prefix ) ;
const last = pages [ pages . length - 1 ] ;
if ( ! folders . length && ! objects . length ) {
put ( card , h ( 'div' , { class : 'empty' } , h ( 'p' , { } , prefix ? 'This folder is empty.' : 'This bucket is empty.' ) , h ( 'p' , { class : 'small' } , 'Drag files here or use Upload.' ) ) ) ;
return ;
}
put ( card , h ( 'table' , { } ,
h ( 'thead' , { } , h ( 'tr' , { } , h ( 'th' , { } , 'Name' ) , h ( 'th' , { class : 'num' } , 'Size' ) , h ( 'th' , { class : 'hide-sm' } , 'Modified' ) , h ( 'th' , { } ) ) ) ,
h ( 'tbody' , { } ,
folders . map ( ( f ) => h ( 'tr' , { class : 'clickable' , onclick : ( ) => go ( f ) } ,
h ( 'td' , { } , '📁 ' , f . slice ( prefix . length ) ) ,
h ( 'td' , { class : 'num muted' } , '– ' ) , h ( 'td' , { class : 'hide-sm' } ) ,
h ( 'td' , { class : 'actions' } , h ( 'button' , { class : 'btn small danger' , onclick : ( e ) => { e . stopPropagation ( ) ; deleteFolder ( f ) ; } } , 'Delete' ) ) ) ) ,
objects . map ( ( o ) => h ( 'tr' , { } ,
2026-10-03 19:34:55 +02:00
h ( 'td' , { } , h ( 'a' , {
href : ` ${ base } /object?key= ${ encodeURIComponent ( o . key ) } ` , download : '' ,
// The sidebar page has no server origin; Theseus's download
// manager fetches the file through a one-time link instead.
onclick : bridge ? ( e ) => { e . preventDefault ( ) ; bridge . invoke ( 'download' , { path : ` ${ base } /object?key= ${ encodeURIComponent ( o . key ) } ` } ) . then ( ( ) => toast ( 'Download started' ) ) . catch ( fail ) ; } : null ,
} , o . key . slice ( prefix . length ) ) ) ,
2026-10-03 19:03:05 +02:00
h ( 'td' , { class : 'num' } , fmtBytes ( o . size ) ) ,
h ( 'td' , { class : 'hide-sm muted' } , fmtDate ( o . modified ) ) ,
h ( 'td' , { class : 'actions' } ,
h ( 'button' , { class : 'btn small' , onclick : ( ) => share ( o . key ) } , 'Share' ) ,
' ' ,
h ( 'button' , { class : 'btn small danger' , onclick : ( ) => deleteObject ( o . key ) } , 'Delete' ) ) ) ) ) ) ,
last ? . truncated && h ( 'div' , { class : 'row' , style : 'justify-content:center;margin-top:12px' } , h ( 'button' , { class : 'btn' , onclick : ( ) => load ( last . nextToken ) } , 'Load more' ) ) ) ;
}
function uploadFiles ( files ) {
for ( const file of files ) {
if ( file . size > 5 * 1024 * * 3 ) { toast ( ` ${ file . name } : single uploads are limited to 5 GiB ` , 'error' ) ; continue ; }
const key = prefix + file . name ;
const bar = h ( 'i' ) ;
const pct = h ( 'span' , { class : 'muted' } , '0%' ) ;
const row = h ( 'div' , { class : 'upload' } , h ( 'span' , { } , key ) , h ( 'div' , { class : 'bar' } , bar ) , pct ) ;
uploads . append ( row ) ;
2026-10-03 19:34:55 +02:00
if ( bridge ) {
// The whole file crosses the IPC bridge as one buffer, so keep it to
// sizes a renderer can hold comfortably; S3 clients have no limit.
if ( file . size > 1024 * * 3 ) { row . remove ( ) ; toast ( ` ${ file . name } : uploads from the sidebar are limited to 1 GiB; use an S3 client for larger files ` , 'error' ) ; continue ; }
pct . textContent = 'uploading…' ;
bar . style . width = '100%' ;
bar . style . opacity = '.4' ;
file . arrayBuffer ( )
. then ( ( bytes ) => bridge . invoke ( 'upload' , { path : ` ${ base } /object?key= ${ encodeURIComponent ( key ) } ` , bytes , type : file . type } ) )
. then ( ( r ) => {
if ( r . status >= 200 && r . status < 300 ) { pct . textContent = 'done' ; bar . style . opacity = '1' ; setTimeout ( ( ) => row . remove ( ) , 1500 ) ; load ( ) ; }
else { pct . textContent = 'failed' ; pct . className = 'error' ; toast ( ` ${ file . name } : ${ r . data ? . error || ` HTTP ${ r . status } ` } ` , 'error' ) ; }
} )
. catch ( ( e ) => { pct . textContent = 'failed' ; pct . className = 'error' ; toast ( ` ${ file . name } : ${ e . message } ` , 'error' ) ; } ) ;
continue ;
}
2026-10-03 19:03:05 +02:00
const xhr = new XMLHttpRequest ( ) ;
xhr . open ( 'PUT' , ` ${ base } /object?key= ${ encodeURIComponent ( key ) } ` ) ;
xhr . setRequestHeader ( 'x-pithos' , '1' ) ;
if ( file . type ) xhr . setRequestHeader ( 'content-type' , file . type ) ;
xhr . upload . onprogress = ( e ) => { if ( e . lengthComputable ) { const p = Math . round ( ( e . loaded / e . total ) * 100 ) ; bar . style . width = p + '%' ; pct . textContent = p + '%' ; } } ;
xhr . onload = ( ) => {
if ( xhr . status >= 200 && xhr . status < 300 ) { pct . textContent = 'done' ; setTimeout ( ( ) => row . remove ( ) , 1500 ) ; load ( ) ; }
else { pct . textContent = 'failed' ; pct . className = 'error' ; let m = xhr . statusText ; try { m = JSON . parse ( xhr . responseText ) . error ; } catch { } toast ( ` ${ file . name } : ${ m } ` , 'error' ) ; }
} ;
xhr . onerror = ( ) => { pct . textContent = 'failed' ; pct . className = 'error' ; toast ( ` ${ file . name } : network error ` , 'error' ) ; } ;
xhr . send ( file ) ;
}
}
async function newFolder ( ) {
const name = h ( 'input' , { type : 'text' , required : true , pattern : '[^/]+' , placeholder : 'photos' } ) ;
const v = await modal ( 'New folder' , h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Name' ) , name , h ( 'small' , { } , 'S3 has no real folders. This opens an empty prefix; it appears once you upload into it.' ) ) ,
[ { label : 'Cancel' , result : null } , { label : 'Open' , kind : 'primary' , submit : true , value : ( ) => name . value . trim ( ) } ] ) ;
if ( v ) go ( prefix + v ) ;
}
async function deleteObject ( key ) {
if ( ! ( await confirmModal ( 'Delete object?' , key ) ) ) return ;
try { await api ( 'DELETE' , ` ${ base } /object?key= ${ encodeURIComponent ( key ) } ` ) ; toast ( 'Deleted' ) ; load ( ) ; } catch ( e ) { fail ( e ) ; }
}
async function deleteFolder ( p ) {
if ( ! ( await confirmModal ( 'Delete folder?' , ` Every object under ${ p } will be deleted. ` ) ) ) return ;
try { const r = await api ( 'DELETE' , ` ${ base } /prefix?prefix= ${ encodeURIComponent ( p ) } ` ) ; toast ( ` Deleted ${ r . deleted } objects ` ) ; load ( ) ; } catch ( e ) { fail ( e ) ; }
}
async function share ( key ) {
const expires = h ( 'select' , { } , [ [ '3600' , '1 hour' ] , [ '86400' , '1 day' ] , [ '604800' , '7 days (max)' ] ] . map ( ( [ v , l ] ) => h ( 'option' , { value : v } , l ) ) ) ;
const savedEndpoint = localStorage . getItem ( 'pithos.publicEndpoint' ) || '' ;
const endpoint = h ( 'input' , { type : 'url' , value : savedEndpoint , placeholder : ` http:// ${ state . status ? . config . apiAddress } ` } ) ;
const out = h ( 'div' ) ;
const gen = async ( ) => {
const q = new URLSearchParams ( { key , expires : expires . value } ) ;
if ( endpoint . value . trim ( ) ) { q . set ( 'endpoint' , endpoint . value . trim ( ) ) ; localStorage . setItem ( 'pithos.publicEndpoint' , endpoint . value . trim ( ) ) ; }
try {
const { url } = await api ( 'GET' , ` ${ base } /presign? ${ q } ` ) ;
put ( out , secretRow ( 'Link' , url ) ) ;
} catch ( e ) { fail ( e ) ; }
return false ;
} ;
await modal ( 'Share link' , h ( 'div' , { class : 'stack' } ,
h ( 'p' , { class : 'small muted' , style : 'margin:0' } , 'A signed link that works without a key until it expires.' ) ,
h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Valid for' ) , expires ) ,
h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Public address of s3d' ) , endpoint , h ( 'small' , { } , 'The address recipients will use to reach s3d (e.g. behind a reverse proxy). Leave blank for the local endpoint.' ) ) ,
out ) ,
[ { label : 'Close' , result : true } , { label : 'Create link' , kind : 'primary' , submit : true , value : gen } ] ) ;
}
load ( ) ;
}
// ---------------------------------------------------------------- settings
function settings ( main ) {
const card = h ( 'div' , { class : 'card' } , h ( 'p' , { class : 'muted' } , 'Loading…' ) ) ;
main . append ( h ( 'div' , { class : 'page-head' } , h ( 'div' , { } , h ( 'h1' , { } , 'Settings' ) , h ( 'p' , { class : 'muted' } , 'Edits s3d.yml. s3d reads it at start, so a restart applies the changes.' ) ) ) , card ) ;
const fields = [
[ 'Network' , [
[ 'apiAddress' , 'S3 API address' , 'text' , 'host:port the S3 API listens on. Use 0.0.0.0:8000 to serve other machines.' ] ,
[ 'apiHTTPSAddress' , 'S3 HTTPS address' , 'text' , 'Optional. s3d serves a self-signed certificate here.' ] ,
[ 'adminAddress' , 'Admin API address' , 'text' , 'Pithos reads stats from here. Keep it on loopback.' ] ,
[ 'adminPassword' , 'Admin API password' , 'password' , 'Generated on first start.' ] ,
[ 's3.hostBases' , 'Virtual-host bases' , 'list' , 'Comma-separated domains for bucket.domain style addressing.' ] ,
] ] ,
[ 'Storage' , [
[ 'directory' , 'Data directory' , 'text' , 'Database and upload buffer. Changing it points s3d at a different store.' ] ,
[ 'sia.diskUsageLimit' , 'Buffer disk limit (bytes)' , 'number' , 'Upper bound for objects waiting to upload.' ] ,
[ 'sia.uploadThreads' , 'Upload threads' , 'number' , '' ] ,
[ 'sia.dataShards' , 'Data shards' , 'number' , 'Erasure coding: data shards (default 10). Applies to new uploads only.' ] ,
[ 'sia.parityShards' , 'Parity shards' , 'number' , 'Erasure coding: parity shards (default 20).' ] ,
] ] ,
[ 'Logging' , [
[ 'log.stdout.level' , 'Console log level' , 'select:debug,info,warn,error' , '' ] ,
[ 'log.file.enabled' , 'Write a log file' , 'bool' , '' ] ,
[ 'log.file.level' , 'File log level' , 'select:debug,info,warn,error' , '' ] ,
] ] ,
] ;
( async ( ) => {
let cfg ;
try { cfg = await api ( 'GET' , '/api/config' ) ; } catch ( e ) { put ( card , h ( 'p' , { class : 'error' } , e . message ) ) ; return ; }
const inputs = { } ;
const sections = fields . map ( ( [ title , list ] ) => h ( 'div' , { class : 'stack' , style : 'margin-bottom:22px' } , h ( 'h2' , { } , title ) , h ( 'div' , { class : 'form-grid' } , list . map ( ( [ key , label , type , help ] ) => {
const v = cfg . values [ key ] ;
let input ;
if ( type === 'bool' ) input = h ( 'input' , { type : 'checkbox' , checked : ! ! v } ) ;
else if ( type . startsWith ( 'select:' ) ) input = h ( 'select' , { } , h ( 'option' , { value : '' } , '(default)' ) , type . slice ( 7 ) . split ( ',' ) . map ( ( o ) => h ( 'option' , { value : o , selected : v === o } , o ) ) ) ;
else input = h ( 'input' , { type : type === 'list' ? 'text' : type , value : Array . isArray ( v ) ? v . join ( ', ' ) : ( v ? ? '' ) , placeholder : '(default)' , autocomplete : 'off' } ) ;
inputs [ key ] = { input , type , orig : v } ;
return type === 'bool'
? h ( 'label' , { class : 'check' } , input , label )
: h ( 'label' , { class : 'field' } , h ( 'span' , { } , label ) , input , help && h ( 'small' , { } , help ) ) ;
} ) ) ) ) ;
put ( card ,
h ( 'p' , { class : 'small muted' } , 'File: ' , h ( 'code' , { } , cfg . file ) ) ,
... sections ,
h ( 'div' , { class : 'row' } , h ( 'button' , { class : 'btn primary' , onclick : save } , 'Save' ) ) ) ;
async function save ( ) {
const patch = { } ;
for ( const [ key , { input , type , orig } ] of Object . entries ( inputs ) ) {
let v ;
if ( type === 'bool' ) v = input . checked ;
else if ( type === 'number' ) v = input . value === '' ? null : Number ( input . value ) ;
else if ( type === 'list' ) v = input . value . split ( ',' ) . map ( ( s ) => s . trim ( ) ) . filter ( Boolean ) ;
else v = input . value . trim ( ) || null ;
const same = JSON . stringify ( v ? ? null ) === JSON . stringify ( orig ? ? null ) || ( type === 'list' && ! v . length && orig == null ) || ( type === 'bool' && ! v && orig == null ) ;
if ( ! same ) patch [ key ] = v ;
}
if ( ! Object . keys ( patch ) . length ) return toast ( 'Nothing changed' ) ;
try {
const r = await api ( 'PUT' , '/api/config' , patch ) ;
for ( const k of Object . keys ( patch ) ) inputs [ k ] . orig = patch [ k ] ;
if ( r . restartNeeded ) {
if ( await confirmModal ( 'Restart s3d?' , 'The new settings apply after a restart.' , 'Restart now' , 'primary' ) ) await daemonAction ( 'restart' ) ;
} else toast ( 'Saved' ) ;
refreshStatus ( ) ;
} catch ( e ) { fail ( e ) ; }
}
} ) ( ) ;
}
// ---------------------------------------------------------------- logs
function logs ( main ) {
const box = h ( 'div' , { class : 'logs' } ) ;
let follow = true ;
box . addEventListener ( 'scroll' , ( ) => { follow = box . scrollTop + box . clientHeight >= box . scrollHeight - 20 ; } ) ;
main . append (
h ( 'div' , { class : 'page-head' } , h ( 'div' , { } , h ( 'h1' , { } , 'Logs' ) , h ( 'p' , { class : 'muted' } , 'Output from the s3d process Pithos started.' ) ) ,
h ( 'button' , { class : 'btn' , onclick : ( ) => copy ( state . logs . map ( ( l ) => l . line ) . join ( '\n' ) , 'Logs copied' ) } , 'Copy all' ) ) ,
box ) ;
const line = ( l ) => h ( 'div' , { class : l . stream } , l . line ) ;
box . append ( ... state . logs . map ( line ) ) ;
if ( ! state . logs . length ) box . append ( h ( 'div' , { class : 'sys' } , 'No output yet.' ) ) ;
box . scrollTop = box . scrollHeight ;
logSink = ( l ) => {
box . append ( line ( l ) ) ;
while ( box . childElementCount > 2000 ) box . firstChild . remove ( ) ;
if ( follow ) box . scrollTop = box . scrollHeight ;
} ;
}
// ---------------------------------------------------------------- boot
2026-10-03 19:34:55 +02:00
// Theseus sidebar: the panel starts narrow; bucket tables want room, so offer
// the host's widen/restore, plus a regular tab for anyone who prefers one.
function addSidebarControls ( ) {
const row = h ( 'div' , { class : 'host-controls' } ) ;
if ( bridge . sidebar ? . toggleMax ) {
const widen = h ( 'button' , { class : 'btn small' , title : 'Widen the sidebar' , onclick : ( ) => bridge . sidebar . toggleMax ( ) } , '⤢ Widen' ) ;
const label = ( max ) => { widen . textContent = max ? '⤡ Narrow' : '⤢ Widen' ; widen . title = max ? 'Back to the normal sidebar width' : 'Widen the sidebar' ; } ;
bridge . sidebar . isMax ? . ( ) . then ( label ) . catch ( ( ) => { } ) ;
bridge . sidebar . onMaxChange ? . ( label ) ;
row . append ( widen ) ;
}
row . append ( h ( 'button' , { class : 'btn small' , title : 'Open Pithos in a browser tab' , onclick : ( ) => bridge . invoke ( 'open-in-tab' ) . catch ( fail ) } , 'Open in tab' ) ) ;
$ ( '.sidebar' ) . insertBefore ( row , $ ( '#daemon-pill' ) ) ;
}
2026-10-03 20:42:23 +02:00
// Theseus: Pithos opens only with the vault unlocked. The add-on asks Theseus
// for the unlock, and Theseus shows its own PIN / master-password prompt.
let vaultInfo = null ;
async function vaultGate ( ) {
let r ;
try { r = await bridge . invoke ( 'gate' ) ; } catch ( e ) { fail ( e ) ; r = { ok : false } ; }
vaultInfo = r ;
if ( r . ok ) { $ ( '#locked' ) . hidden = true ; return true ; }
$ ( '#app' ) . hidden = true ;
$ ( '#locked' ) . hidden = false ;
return false ;
}
// Re-lock when the vault locks (Settings › Lock now), so secrets are not left
// on screen. The add-on watches from the main process and pushes the change.
function watchVaultLock ( ) {
bridge . on ( 'pithos-vault' , ( st ) => {
vaultInfo = { ... vaultInfo , ... st } ;
if ( st . setup && st . prompt && ! st . unlocked && $ ( '#locked' ) . hidden ) {
$ ( '#app' ) . hidden = true ;
$ ( '#locked' ) . hidden = false ;
}
} ) ;
}
$ ( '#unlock-btn' ) . addEventListener ( 'click' , async ( ) => {
if ( await vaultGate ( ) ) {
const first = $ ( '#app' ) . dataset . booted !== '1' ;
$ ( '#app' ) . hidden = false ;
if ( first ) location . reload ( ) ;
}
} ) ;
2026-10-03 19:03:05 +02:00
function showSignin ( ) {
$ ( '#app' ) . hidden = true ;
$ ( '#signin' ) . hidden = false ;
}
async function boot ( ) {
const s = await api ( 'GET' , '/api/session' ) ;
if ( ! s . authenticated ) {
showSignin ( ) ;
if ( ! s . passwordLogin ) {
$ ( '#signin-hint' ) . textContent = 'Open Pithos with the sign-in link printed when it started.' ;
$ ( '#signin-form' ) . querySelector ( 'input' ) . hidden = true ;
$ ( '#signin-form' ) . querySelector ( 'button' ) . hidden = true ;
}
return ;
}
$ ( '#signin' ) . hidden = true ;
2026-10-03 20:42:23 +02:00
if ( bridge && ! ( await vaultGate ( ) ) ) return ;
2026-10-03 19:03:05 +02:00
$ ( '#app' ) . hidden = false ;
2026-10-03 20:42:23 +02:00
$ ( '#app' ) . dataset . booted = '1' ;
if ( bridge ) { addSidebarControls ( ) ; watchVaultLock ( ) ; }
2026-10-03 19:03:05 +02:00
// Backfill logs the server already holds, then stream.
try {
state . logs = await api ( 'GET' , '/api/logs' ) ;
state . logSeq = state . logs . at ( - 1 ) ? . seq || 0 ;
} catch { }
await refreshStatus ( ) ;
connectEvents ( ) ;
window . addEventListener ( 'hashchange' , route ) ;
route ( ) ;
setInterval ( refreshStatus , 15000 ) ;
}
$ ( '#signin-form' ) . addEventListener ( 'submit' , async ( e ) => {
e . preventDefault ( ) ;
const err = $ ( '#signin-error' ) ;
err . hidden = true ;
try {
await api ( 'POST' , '/api/session' , { password : $ ( '#signin-password' ) . value } ) ;
location . reload ( ) ;
} catch ( ex ) { err . textContent = ex . message ; err . hidden = false ; }
} ) ;
boot ( ) . catch ( fail ) ;