2026-10-04 03:36:50 +02:00
// "On Silent Mode" inside the control server. In hosted mode the UI talks to
// this server exactly as before; this module answers by:
// - forwarding daemon, users, keys, stats and status calls to the gateway,
// - giving the object routes an S3 client that encrypts and decrypts here,
// - turning "Access" and "Share link" into gateway rules (and converting the
// files they cover to readable copies, since visitors have no key),
// - moving the s3d data folder to the server and back.
import fs from 'node:fs' ;
import path from 'node:path' ;
import * as admin from './admin.js' ;
import * as accounts from './accounts.js' ;
import { readPrefs , writePrefs } from './prefs.js' ;
import { ensureConfig } from './config.js' ;
import {
fileSecrets , signIn , gateway , HostedError , EncryptingS3Client , plainCovered ,
dataFiles , uploadData , downloadData ,
} from './hosted.js' ;
export const DEFAULT _HOSTED _URL = 'https://s3.silentmode.st' ;
// Calls forwarded as they are. The gateway allows the same set.
const FORWARD = [
[ 'GET' , /^\/api\/status$/ ] , [ 'GET' , /^\/api\/logs$/ ] , [ 'GET' , /^\/api\/stats$/ ] ,
[ 'POST' , /^\/api\/flush$/ ] , [ 'GET' , /^\/api\/autoflush$/ ] ,
[ 'POST' , /^\/api\/daemon\/(start|restart)$/ ] ,
[ 'GET' , /^\/api\/users$/ ] , [ 'POST' , /^\/api\/users$/ ] , [ 'DELETE' , /^\/api\/users\/[^/]+$/ ] ,
[ 'GET' , /^\/api\/keys$/ ] , [ 'POST' , /^\/api\/keys$/ ] , [ 'DELETE' , /^\/api\/keys\/[^/]+$/ ] ,
[ 'GET' , /^\/api\/account$/ ] , [ 'PUT' , /^\/api\/account\/label$/ ] ,
2026-10-04 04:06:04 +02:00
[ 'GET' , /^\/api\/space$/ ] ,
2026-10-04 03:36:50 +02:00
] ;
// Things that act on this computer's s3d and make no sense while drives live
// on the server.
const LOCAL _ONLY = [
[ /^\/api\/binary\/install$/ , 'There is nothing to install while your drives are on Silent Mode.' ] ,
[ /^\/api\/login/ , 'Your drives are on Silent Mode, already connected to your Sia account.' ] ,
[ /^\/api\/account\/(switch|restore)$/ , 'Move your drives back to this computer before switching Sia accounts.' ] ,
[ /^\/api\/daemon\/stop$/ , 'On Silent Mode, s3d stops by itself when nobody uses it.' ] ,
] ;
export function installHosted ( ctx ) {
const { route , configFile , cfg , daemon , broadcast , registration , resetRegistration , HttpError } = ctx ;
const secrets = ctx . secrets || fileSecrets ( configFile ) ;
const prefs = ( ) => readPrefs ( configFile ) ;
const hp = ( ) => prefs ( ) . hosted || null ;
const isHosted = ( ) => prefs ( ) . mode === 'hosted' && ! ! hp ( ) ? . token ;
const gw = ( ) => {
const h = hp ( ) ;
if ( ! h ? . token ) throw new HttpError ( 401 , 'sign in to Silent Mode first' ) ;
return gateway ( h . url , h . token ) ;
} ;
const wrap = async ( fn ) => {
try { return await fn ( ) ; } catch ( e ) {
if ( e instanceof HostedError ) {
if ( e . status === 401 ) { writePrefs ( configFile , { hosted : { ... hp ( ) , token : null } } ) ; }
throw new HttpError ( e . status , e . message ) ;
}
throw e ;
}
} ;
// ---- rules, cached briefly ----
let rulesCache = null ;
async function rules ( ) {
if ( ! isHosted ( ) ) return [ ] ;
if ( rulesCache && Date . now ( ) - rulesCache . at < 30_000 ) return rulesCache . list ;
const list = await wrap ( ( ) => gw ( ) . call ( 'GET' , '/hosted/v1/rules' ) ) ;
rulesCache = { at : Date . now ( ) , list } ;
return list ;
}
const dropRules = ( ) => { rulesCache = null ; } ;
// ---- the S3 client for object routes ----
const keyCache = new Map ( ) ;
async function hostedKeys ( user ) {
const c = keyCache . get ( user ) ;
if ( c && Date . now ( ) - c . at < 60_000 ) return c . keys ;
const keys = await wrap ( ( ) => gw ( ) . call ( 'GET' , ` /api/keys?user= ${ encodeURIComponent ( user ) } ` ) ) ;
keyCache . set ( user , { at : Date . now ( ) , keys } ) ;
return keys ;
}
async function s3For ( user , localKeys ) {
if ( isHosted ( ) ) {
const keys = await hostedKeys ( user ) ;
if ( ! keys . length ) throw new HttpError ( 409 , ` user " ${ user } " has no access keys yet - create one first ` ) ;
return new EncryptingS3Client ( {
endpoint : hp ( ) . url , accessKeyId : keys [ 0 ] . accessKeyId , secretKey : keys [ 0 ] . secretKey ,
master : ( ) => secrets . encryptionKey ( ) , rules ,
} ) ;
}
// Back on this computer, files encrypted while hosted still decrypt.
if ( prefs ( ) . encryptedData && localKeys ? . length ) {
return new EncryptingS3Client ( {
endpoint : ` http:// ${ cfg ( ) . apiAddress } ` , accessKeyId : localKeys [ 0 ] . accessKeyId , secretKey : localKeys [ 0 ] . secretKey ,
master : ( ) => secrets . encryptionKey ( ) , encryptWrites : false ,
} ) ;
}
return null ;
}
// ---- status, logs and events from the server ----
let remote = null ; // last /api/status from the gateway
async function hostedStatus ( local ) {
const h = hp ( ) ;
try {
remote = await wrap ( ( ) => gw ( ) . call ( 'GET' , '/api/status' ) ) ;
return { ... remote , host : local . host , mode : 'hosted' , login : local . login , hosted : { ... remote . hosted , url : h . url } } ;
} catch ( e ) {
return { ... local , mode : 'hosted' , daemon : { state : 'stopped' , error : e . message } , hosted : { url : h . url , error : e . message } , registration : { registered : true } } ;
}
}
let lastSeq = 0 ;
let lastDaemon = '' ;
async function pollRemote ( ) {
if ( ! isHosted ( ) || ! ctx . hasSubscribers ( ) ) return ;
try {
const st = await gw ( ) . call ( 'GET' , '/api/status' ) ;
remote = st ;
const d = JSON . stringify ( st . daemon ) ;
if ( d !== lastDaemon ) { lastDaemon = d ; broadcast ( 'status' , st . daemon ) ; }
const logs = await gw ( ) . call ( 'GET' , ` /api/logs?since= ${ lastSeq } ` ) ;
for ( const l of logs || [ ] ) { lastSeq = Math . max ( lastSeq , l . seq ) ; broadcast ( 'log' , l ) ; }
} catch { /* the next poll tries again */ }
}
const poller = setInterval ( ( ) => { pollRemote ( ) ; } , 5000 ) ;
poller . unref ? . ( ) ;
// ---- intercept: runs before the local routes in hosted mode ----
async function intercept ( req , res , url , send ) {
if ( ! isHosted ( ) ) return false ;
const p = url . pathname ;
for ( const [ re , msg ] of LOCAL _ONLY ) if ( re . test ( p ) ) throw new HttpError ( 409 , msg ) ;
if ( p === '/api/config' && req . method === 'PUT' ) throw new HttpError ( 409 , 'Server settings are managed by Silent Mode while your drives are there.' ) ;
if ( p === '/api/status' ) return false ; // answered by the local route, which asks us
if ( ! FORWARD . some ( ( [ m , re ] ) => m === req . method && re . test ( p ) ) ) return false ;
const body = req . method === 'GET' ? undefined : await readJson ( req ) ;
const out = await wrap ( ( ) => gw ( ) . call ( req . method , p + url . search , { body } ) ) ;
if ( /^\/api\/(users|keys)/ . test ( p ) && req . method !== 'GET' ) keyCache . clear ( ) ;
send ( out ) ;
return true ;
}
// ---- routes ----
route ( 'GET' , '/api/hosted' , async ( ) => {
const h = hp ( ) ;
const out = { mode : isHosted ( ) ? 'hosted' : 'local' , url : h ? . url || DEFAULT _HOSTED _URL , id : h ? . id || null , signedIn : ! ! h ? . token , secrets : secrets . kind || 'vault' } ;
if ( h ? . token ) {
try { const warn = await settleMoves ( ) ; if ( warn ) out . warning = warn ; } catch { }
out . mode = isHosted ( ) ? 'hosted' : 'local' ;
try { out . me = await gw ( ) . call ( 'GET' , '/hosted/v1/me' ) ; } catch ( e ) { out . error = e . message ; if ( e . status === 401 ) out . signedIn = false ; }
}
if ( ! out . signedIn ) {
try { out . info = await gateway ( out . url ) . call ( 'GET' , '/hosted/v1/info' ) ; } catch ( e ) { out . error = ` Silent Mode is not reachable: ${ e . message } ` ; }
}
return out ;
} ) ;
route ( 'POST' , '/api/hosted/signin' , async ( req ) => {
const { url = DEFAULT _HOSTED _URL , signup } = await readJson ( req ) ;
if ( ! /^https:\/\/[^\s/]+$|^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/ . test ( url . replace ( /\/$/ , '' ) ) ) throw new HttpError ( 400 , 'the server address must be https' ) ;
const r = await wrap ( ( ) => signIn ( url . replace ( /\/$/ , '' ) , secrets , signup ) ) ;
if ( r . needsSignup ) return r ;
writePrefs ( configFile , { hosted : { url : url . replace ( /\/$/ , '' ) , id : r . id , token : r . token } } ) ;
return { ok : true , id : r . id , state : r . state } ;
} ) ;
route ( 'POST' , '/api/hosted/signout' , async ( ) => {
const h = hp ( ) ;
if ( h ? . token ) await gw ( ) . call ( 'DELETE' , '/hosted/v1/session' ) . catch ( ( ) => { } ) ;
writePrefs ( configFile , { hosted : h ? { ... h , token : null } : null , mode : 'local' } ) ;
dropRules ( ) ;
keyCache . clear ( ) ;
return { ok : true } ;
} ) ;
// Use drives already on Silent Mode from this computer (a second device).
route ( 'POST' , '/api/hosted/mode' , async ( req ) => {
const { mode } = await readJson ( req ) ;
if ( mode === 'local' ) { writePrefs ( configFile , { mode : 'local' } ) ; return { mode : 'local' } ; }
if ( mode !== 'hosted' ) throw new HttpError ( 400 , 'mode is local or hosted' ) ;
const me = await wrap ( ( ) => gw ( ) . call ( 'GET' , '/hosted/v1/me' ) ) ;
if ( me . state !== 'active' ) throw new HttpError ( 409 , 'Silent Mode has no drives for you yet. Move them there first.' ) ;
writePrefs ( configFile , { mode : 'hosted' , encryptedData : true } ) ;
return { mode : 'hosted' } ;
} ) ;
// Asks the server who holds the drives, a few times. null if it cannot tell.
async function serverState ( g ) {
for ( let i = 0 ; i < 3 ; i ++ ) {
try { return ( await g . call ( 'GET' , '/hosted/v1/me' ) ) . state ; } catch { await new Promise ( ( r ) => setTimeout ( r , 1000 * ( i + 1 ) ) ) ; }
}
return null ;
}
// The server has the drives: set the local copy aside so it can never run
// next to the server's (that would fork the account).
function adoptHosted ( reg , users ) {
const c = cfg ( ) ;
if ( fs . existsSync ( path . join ( c . directory , 's3d.db' ) ) ) {
accounts . archiveCurrent ( c . directory , { indexerUrl : reg ? . indexerUrl || null , users : users || [ ] , movedTo : hp ( ) . url } ) ;
}
resetRegistration ( ) ;
writePrefs ( configFile , { mode : 'hosted' , encryptedData : true , autostart : false , moveUncertain : null } ) ;
keyCache . clear ( ) ;
dropRules ( ) ;
}
// Finishes a move whose last answer was lost (see move-up and move-down).
async function settleMoves ( ) {
const p = prefs ( ) ;
if ( ! p . moveUncertain && ! p . pendingRelease ) return null ;
const g = gw ( ) ;
const state = await serverState ( g ) ;
if ( state == null ) return 'Silent Mode is not reachable, so an earlier move is not finished yet.' ;
if ( p . moveUncertain ) {
if ( state === 'active' ) adoptHosted ( p . moveUncertain . reg , p . moveUncertain . users ) ;
else writePrefs ( configFile , { moveUncertain : null } ) ; // it never arrived; the local copy is the account
}
if ( p . pendingRelease ) {
if ( state === 'moving-out' ) await g . call ( 'POST' , '/hosted/v1/data/release' ) . catch ( ( ) => { } ) ;
if ( ( await serverState ( g ) ) !== 'moving-out' ) writePrefs ( configFile , { pendingRelease : null } ) ;
}
return null ;
}
// Local -> Silent Mode. Nothing is re-uploaded to Sia: s3d's database and
// any objects still waiting for Sia move, then s3d runs on the server.
route ( 'POST' , '/api/hosted/move-up' , async ( req ) => {
const { force } = await readJson ( req ) ;
const g = gw ( ) ;
const progress = ( p ) => broadcast ( 'move' , { direction : 'up' , ... p } ) ;
if ( prefs ( ) . moveUncertain ) throw new HttpError ( 409 , 'An earlier move is not settled yet; open Settings again in a minute.' ) ;
const me = await wrap ( ( ) => g . call ( 'GET' , '/hosted/v1/me' ) ) ;
if ( me . state !== 'empty' ) throw new HttpError ( 409 , 'Silent Mode already holds drives for you. Use them from here, or move them back first.' ) ;
const c = cfg ( ) ;
const reg = await registration ( ) ;
if ( ! reg ? . registered ) throw new HttpError ( 409 , 'Connect this computer to your Sia account first; then move the connection to Silent Mode.' ) ;
const wasRunning = ! ! daemon . child ;
if ( wasRunning ) {
progress ( { phase : 'flush' } ) ;
try { await admin . flush ( c ) ; } catch ( e ) {
if ( ! force ) throw new HttpError ( 409 , ` Some uploads could not reach Sia yet ( ${ e . message } ). Try again later, or move anyway: they will finish on the server. ` ) ;
}
}
progress ( { phase : 'stop' } ) ;
await daemon . stop ( ) ;
const restartLocal = ( ) => { if ( wasRunning ) { try { ensureConfig ( configFile ) ; daemon . start ( ) ; } catch { } } } ;
let files ;
try {
files = await dataFiles ( c . directory ) ;
if ( ! files . some ( ( f ) => f . path === 's3d.db' ) ) throw new HttpError ( 409 , 'there is no s3d database to move' ) ;
await uploadData ( g , c . directory , files , progress ) ;
} catch ( e ) {
// Nothing was committed: the server discards what arrived, s3d runs here again.
await g . call ( 'POST' , '/hosted/v1/data/abort' ) . catch ( ( ) => { } ) ;
restartLocal ( ) ;
progress ( { phase : 'failed' , error : e . message } ) ;
throw e ;
}
progress ( { phase : 'start' } ) ;
let r = null ;
let commitError = null ;
try { r = await wrap ( ( ) => g . call ( 'POST' , '/hosted/v1/data/commit' , { body : { files } } ) ) ; } catch ( e ) { commitError = e ; }
// From here an answer can be lost in either direction, so the server's
// state decides which copy is the account.
const state = r ? 'active' : await serverState ( g ) ;
if ( state === 'active' ) {
adoptHosted ( reg , r ? . users ) ;
progress ( { phase : 'done' } ) ;
return { ok : true , registered : true , users : r ? . users || [ ] } ;
}
if ( state === 'empty' ) {
restartLocal ( ) ;
progress ( { phase : 'failed' , error : commitError ? . message } ) ;
throw commitError || new HttpError ( 502 , 'the server did not take the drives' ) ;
}
// Unknown: keep s3d stopped here until the server can be asked again.
writePrefs ( configFile , { moveUncertain : { at : Date . now ( ) , reg : { indexerUrl : reg . indexerUrl } , users : [ ] } } ) ;
progress ( { phase : 'failed' , error : 'no answer from Silent Mode' } ) ;
throw new HttpError ( 502 , 'Silent Mode stopped answering during the move. s3d stays stopped here until Pithos can check which side has your drives; open Settings again in a minute.' ) ;
} ) ;
// Silent Mode -> local. The server flushes and stops its s3d, the folder
// comes down, the local s3d starts on it, and only then is the server copy
// released. Once release is sent the local copy is never removed.
route ( 'POST' , '/api/hosted/move-down' , async ( req ) => {
const { force } = await readJson ( req ) ;
const g = gw ( ) ;
const progress = ( p ) => broadcast ( 'move' , { direction : 'down' , ... p } ) ;
const c = cfg ( ) ;
if ( daemon . child ) await daemon . stop ( ) ;
let archived = null ;
if ( fs . existsSync ( path . join ( c . directory , 's3d.db' ) ) ) {
archived = accounts . archiveCurrent ( c . directory , { note : 'set aside to bring drives back from Silent Mode' } ) . name ;
}
progress ( { phase : 'flush' } ) ;
let man ;
try { man = await wrap ( ( ) => g . call ( 'POST' , ` /hosted/v1/data/checkout ${ force ? '?force=1' : '' } ` ) ) ; } catch ( e ) {
if ( archived ) { try { accounts . restoreArchive ( c . directory , archived , { } ) ; } catch { } }
throw e ;
}
const written = [ ] ;
try {
await downloadData ( g , c . directory , man . files , progress , written ) ;
progress ( { phase : 'start' } ) ;
ensureConfig ( configFile ) ;
resetRegistration ( ) ;
daemon . start ( ) ;
const until = Date . now ( ) + 30_000 ;
while ( daemon . state === 'starting' && Date . now ( ) < until ) await new Promise ( ( r ) => setTimeout ( r , 200 ) ) ;
if ( daemon . state !== 'running' ) throw new Error ( 's3d did not start on the moved folder' ) ;
const reg = await registration ( ) ;
if ( ! reg ? . registered ) throw new Error ( 'the moved folder has no Sia connection' ) ;
} catch ( e ) {
// Only what this move wrote is removed; the server keeps its copy.
await daemon . stop ( ) . catch ( ( ) => { } ) ;
for ( const f of written ) fs . rmSync ( f , { force : true } ) ;
if ( archived ) { try { accounts . restoreArchive ( c . directory , archived , { } ) ; } catch { } }
writePrefs ( configFile , { mode : 'hosted' } ) ;
await g . call ( 'POST' , '/hosted/v1/data/cancel-checkout' ) . catch ( ( ) => { } ) ;
progress ( { phase : 'failed' , error : e . message } ) ;
throw new HttpError ( 502 , ` Moving back failed and nothing changed: ${ e . message } ` ) ;
}
// The drives now run here. Whatever happens to the release, this copy stays.
writePrefs ( configFile , { mode : 'local' , autostart : true , encryptedData : true , pendingRelease : true } ) ;
keyCache . clear ( ) ;
dropRules ( ) ;
try {
await g . call ( 'POST' , '/hosted/v1/data/release' ) ;
writePrefs ( configFile , { pendingRelease : null } ) ;
} catch { /* settleMoves() retries; the server copy stays parked meanwhile */ }
progress ( { phase : 'done' } ) ;
return { ok : true } ;
} ) ;
// Rules: public folders, shared keys, links. Files they cover become
// readable copies; removing a rule encrypts them again.
route ( 'GET' , '/api/hosted/rules' , async ( ) => rules ( ) ) ;
route ( 'POST' , '/api/hosted/rules' , async ( req ) => {
const body = await readJson ( req ) ;
const client = await s3For ( body . user ) ;
if ( ! client ) throw new HttpError ( 409 , 'not on Silent Mode' ) ;
const rule = await wrap ( ( ) => gw ( ) . call ( 'POST' , '/hosted/v1/rules' , { body } ) ) ;
dropRules ( ) ;
await client . convert ( rule . bucket , rule . prefix , { toPlain : true , onProgress : ( p ) => broadcast ( 'convert' , { rule : rule . id , ... p } ) } ) ;
return rule ;
} ) ;
route ( 'DELETE' , '/api/hosted/rules' , async ( req , url ) => {
const id = url . searchParams . get ( 'id' ) ;
const user = url . searchParams . get ( 'user' ) ;
const rule = ( await rules ( ) ) . find ( ( r ) => r . id === id ) ;
if ( ! rule ) throw new HttpError ( 404 , 'no such rule' ) ;
await wrap ( ( ) => gw ( ) . call ( 'DELETE' , ` /hosted/v1/rules?id= ${ encodeURIComponent ( id ) } ` ) ) ;
dropRules ( ) ;
const remaining = await rules ( ) ;
const client = await s3For ( user ) ;
if ( client ) {
await client . convert ( rule . bucket , rule . prefix , {
toPlain : false ,
skip : ( key ) => plainCovered ( remaining , rule . bucket , key ) ,
onProgress : ( p ) => broadcast ( 'convert' , { rule : rule . id , ... p } ) ,
} ) ;
}
return { ok : true } ;
} ) ;
// Files from before hosting (or from another S3 client) are stored as they
// came. This encrypts every one that nothing shares.
route ( 'POST' , '/api/hosted/encrypt' , async ( req ) => {
const { user , bucket } = await readJson ( req ) ;
const client = await s3For ( user ) ;
if ( ! isHosted ( ) || ! client ) throw new HttpError ( 409 , 'not on Silent Mode' ) ;
const current = await rules ( ) ;
return client . convert ( bucket , '' , {
toPlain : false ,
skip : ( key ) => plainCovered ( current , bucket , key ) ,
onProgress : ( p ) => broadcast ( 'convert' , p ) ,
} ) ;
} ) ;
// The whole-drive "Access" dialog, answered with a public rule on "".
async function drivePolicy ( bucket ) {
const r = ( await rules ( ) ) . find ( ( x ) => x . type === 'public' && x . bucket === bucket && x . prefix === '' ) ;
return { policy : null , public : r ? r . rights : 'private' , hosted : true } ;
}
async function setDrivePolicy ( user , bucket , mode ) {
const current = ( await rules ( ) ) . filter ( ( x ) => x . type === 'public' && x . bucket === bucket && x . prefix === '' ) ;
const client = await s3For ( user ) ;
for ( const r of current ) await wrap ( ( ) => gw ( ) . call ( 'DELETE' , ` /hosted/v1/rules?id= ${ encodeURIComponent ( r . id ) } ` ) ) ;
dropRules ( ) ;
if ( mode === 'private' ) {
const remaining = await rules ( ) ;
await client . convert ( bucket , '' , { toPlain : false , skip : ( key ) => plainCovered ( remaining , bucket , key ) , onProgress : ( p ) => broadcast ( 'convert' , p ) } ) ;
return ;
}
await wrap ( ( ) => gw ( ) . call ( 'POST' , '/hosted/v1/rules' , { body : { type : 'public' , bucket , prefix : '' , rights : mode } } ) ) ;
dropRules ( ) ;
await client . convert ( bucket , '' , { toPlain : true , onProgress : ( p ) => broadcast ( 'convert' , p ) } ) ;
}
// "Share link" for one file: a link rule, and the file stored readable.
async function shareLink ( user , bucket , key , seconds ) {
const client = await s3For ( user ) ;
const expires = Date . now ( ) + Math . min ( Math . max ( 60 , seconds | 0 ) , 30 * 86400 ) * 1000 ;
const rule = await wrap ( ( ) => gw ( ) . call ( 'POST' , '/hosted/v1/rules' , { body : { type : 'link' , bucket , prefix : key , expires } } ) ) ;
dropRules ( ) ;
await client . convert ( bucket , key , { toPlain : true , skip : ( k ) => k !== key } ) ;
const links = ( prefs ( ) . hostedLinks || [ ] ) . filter ( ( l ) => l . expires > Date . now ( ) ) ;
links . push ( { user , bucket , key , expires } ) ;
writePrefs ( configFile , { hostedLinks : links } ) ;
return ` ${ hp ( ) . url } /l/ ${ rule . token } ` ;
}
// Files shared by a link that has run out go back to being encrypted.
async function sweepLinks ( ) {
if ( ! isHosted ( ) ) return ;
const all = prefs ( ) . hostedLinks || [ ] ;
const expired = all . filter ( ( l ) => l . expires <= Date . now ( ) ) ;
if ( ! expired . length ) return ;
dropRules ( ) ;
const remaining = await rules ( ) ;
for ( const l of expired ) {
if ( plainCovered ( remaining , l . bucket , l . key ) ) continue ;
const client = await s3For ( l . user ) . catch ( ( ) => null ) ;
await client ? . convert ( l . bucket , l . key , { toPlain : false , skip : ( k ) => k !== l . key } ) . catch ( ( ) => { } ) ;
}
writePrefs ( configFile , { hostedLinks : all . filter ( ( l ) => l . expires > Date . now ( ) ) } ) ;
}
const sweeper = setInterval ( ( ) => { sweepLinks ( ) . catch ( ( ) => { } ) ; } , 10 * 60_000 ) ;
sweeper . unref ? . ( ) ;
// Key file export/import for hosts without a vault.
route ( 'GET' , '/api/hosted/secret' , ( ) => {
if ( ! secrets . exportSecret ) throw new HttpError ( 400 , 'your keys come from the Theseus vault; back up the vault instead' ) ;
return secrets . exportSecret ( ) ;
} ) ;
route ( 'POST' , '/api/hosted/secret' , async ( req ) => {
if ( ! secrets . importSecret ) throw new HttpError ( 400 , 'your keys come from the Theseus vault' ) ;
if ( isHosted ( ) || hp ( ) ? . token ) throw new HttpError ( 409 , 'sign out of Silent Mode before replacing your key' ) ;
secrets . importSecret ( await readJson ( req ) ) ;
return { ok : true } ;
} ) ;
return {
isHosted , intercept , s3For , hostedStatus , drivePolicy , setDrivePolicy , shareLink ,
userNames : async ( ) => ( await wrap ( ( ) => gw ( ) . call ( 'GET' , '/api/users' ) ) ) . map ( ( u ) => u . name ) ,
remoteDaemon : ( ) => remote ? . daemon || { state : 'stopped' } ,
close ( ) { clearInterval ( poller ) ; clearInterval ( sweeper ) ; } ,
} ;
}
async function readJson ( req ) {
const parts = [ ] ;
for await ( const c of req ) parts . push ( c ) ;
const text = Buffer . concat ( parts ) . toString ( 'utf8' ) ;
if ( ! text ) return { } ;
try { return JSON . parse ( text ) ; } catch { return { } ; }
}