const fs = require ( "fs" ) ;
const http = require ( "http" ) ;
const https = require ( "https" ) ;
const path = require ( "path" ) ;
const { execSync } = require ( 'child_process' ) ;
const express = require ( "express" ) ; // could be good to replace with code, no dep
// express does not seem architecture dependant thus copying node_modules sufficed for now
// (worked at least on RPi, Deck, Quest and x64 machines)
// Get port or default to 8082
const port = process . env . PORT || 8082 ; // assumes homogenity across peers
const protocol = process . env . PROTOCOL || 'https'
const subclass = process . env . SUBCLASS || '192.168.0.' // defaulting to IP used at home rather than RPi0. Could also change it there as it's not justified beside helping distinction.
// Object.values(require("os").networkInterfaces()).flat().filter(({ family, internal }) => family === "IPv4" && !internal).map(({ address }) => address)[0].split('.').slice(0,3).join('.')+'.'
const publicKeyPath = path . resolve ( process . env . HOME , '.ssh' , 'id_rsa_offlineoctopus.pub' )
const publicKey = fs . readFileSync ( publicKeyPath ) . toString ( ) . split ( ' ' ) [ 1 ]
const md5fromPub = fs . readFileSync ( path . resolve ( _ _dirname , ".keyfrommd5" ) ) . toString ( ) . replace ( '\n' , '' )
process . title = 'offtopus' // offline-octopus seems too long
const filename = 'editor.html'
const fileSaveFullPath = path . join ( _ _dirname , 'examples' , filename )
const minfilename = 'editor.html.minimal'
const minfileSaveFullPath = path . join ( _ _dirname , 'examples' , minfilename )
const sshconfigpath = path . resolve ( process . env . HOME , '.ssh' , 'config' )
const propath = path . resolve ( process . env . HOME , 'Prototypes' )
const sshfsmounts = "sshfsmounts"
let workspaces = { }
let mountPoints = [ ]
// note that stopping this process removes the mounts
// does not apply in a P2P fashion, must rely on local configuration here config.json
let localServices = [ ]
const configFilePath = path . resolve ( _ _dirname , "config.json" )
if ( fs . existsSync ( configFilePath ) ) {
const configurationFromFile = JSON . parse ( fs . readFileSync ( configFilePath ) . toString ( ) )
localServices = configurationFromFile
}
// note that this is crucial to populate properly as this is what allow recombination
/ * e . g o n q u e s t
spascanodesecure https localhost 7778 / engine /
requires CORS to fetch here
fetch ( 'https://192.168.4.3:8082/available' )
. then ( r => r . json ( ) )
. then ( r => { if ( r ) addNewNote ( 'available' ) } )
gitea http localhost 3000 /
pmwiki http localhost 4000 / pmwiki . php
sshd see quest in ssh config , specific user and port
* /
const kwinmin = `
echo " const clients = workspace . clientList ( ) ;
for ( var i = 0 ; i < clients . length ; i ++ ) {
print ( clients [ i ] . caption ) ;
clients [ i ] . minimized = true ;
} " > / t m p / k w i n s c r i p t d e m o
num = $ ( dbus - send -- print - reply -- dest = org . kde . KWin / Scripting org . kde . kwin . Scripting . loadScript string : "/tmp/kwinscriptdemo" | awk 'END {print $2}' )
dbus - send -- print - reply -- dest = org . kde . KWin / $num org . kde . kwin . Script . run `
const utilsCmd = { // security risk but for now not accepting user input so safer
//'update' : { desc: 'note that will lose the state, e.g foundpeers', cmd: 'killall '+process.title+' && ' },
// should first download the new version and proceed only if new
// e.g git clone deck@localhost:~/Prototypes/offline-octopus/
// should see /sshconfig
// tried git instaweb but unable without lighttpd/apache
// would probably be problematic with https anyway
// ideally all handles within node
'shutdown' : { cmd : 'shutdown -h now' } , // not available everywhere, e.g unrooted Quest
'listprototypes' : { cmd : 'ls' , context : { cwd : propath } ,
format : res => res . toString ( ) . split ( '\n' )
} ,
'mousemove' : { cmd : 'xdotool mousemove ' , optionsPattern : /\d+ \d+/ } ,
'mouseup' : { cmd : 'xdotool mouseup 1' } ,
'mousedown' : { cmd : 'xdotool mousedown 1' } ,
// per device specific (until adjustable per user)
'minimizeall' : { cmd : kwinmin } , //KWin script
//'minimizeall' : { cmd: '/home/fabien/Prototypes/kwin-scripting/launch'}, //KWin script
'highresscreen' : { cmd : 'xrandr --output DP-4 --mode 3840x2160' } ,
'lowresscreen' : { cmd : 'xrandr --output DP-4 --mode 1920x1080' } ,
// 'anyresscreen' : { cmd: 'xrandr --output DP-4 --mode '}, // would require user input which is risky e.g here 'OxO; wget rootkit; bash rootkit;'
'availableRes' : { cmd : 'xrandr -q | grep 0x | grep -v primary | cut -f 4 -d " "' ,
format : res => res . toString ( ) . split ( '\n' ) . filter ( l => l != '' )
} ,
//'npmfind' : { desc: 'package manager finder', cmd: 'find . -wholename "*node_modules/acorn"' },
// security risk if relying on user provided name, e.g replacing acorn by user input
// example that could be generalized to other package managers e.g .deb or opkg
}
// could be interesting to consider also recent containers and ~/.bashrc for services
const instructions = `
< pre >
/ h o m e / d e c k / . s s h /
trusted context , i . e on closed WiFi and over https with bearer authorization
/ h o m e / d e c k / . s s h / c o n f i g
limit to known IP subclass e . g cat . ssh / config | grep 192.168 . 4. - C 3
see / sshconfig
could also re - add new entries rather than extend the format
/ h o m e / d e c k / s e r v e r . l o c a t e d b
seems to be plain text with metadata
/ h o m e / d e c k / d e s k t o p . p l o c a t e . d b
seems to be a specific format , binary or maybe compressed
both should be queriable via http with json output
ssh remarkable2 to get drawing preview
conversion should be done , if necessary , on device
not feasible right now without toltec to get opkg to get node
for typed text
cat 43 * . rm | sed "s/[^a-zA-z0-9 ]//g" | sed "s/[OT,EC]//g"
util functions
modify WiFi parameters , including AP if available
shutdown / reboot
< / p r e >
`
const auth _instructions = ` generate md5 from pub offline-octopus then provide as bearer query param `
// Setup and configure Express http server.
const app = express ( ) ;
app . use ( express . static ( path . resolve ( _ _dirname , "." , "examples" ) ) ) ;
// CORS
app . use ( function ( req , res , next ) {
res . header ( "Access-Control-Allow-Origin" , "*" ) ;
res . header ( "Access-Control-Allow-Headers" , "Origin, X-Requested-With, Content-Type, Accept" ) ;
next ( ) ;
} ) ;
app . get ( '/' , ( req , res ) => {
res . send ( instructions )
// see issue 20
} )
app . get ( '/routes' , ( req , res ) => {
let formattedRoutes = routes . map ( r => ` <li><a href=' ${ r } '> ${ r } </a></li> ` ) . join ( '\n' )
res . send ( formattedRoutes )
} )
app . get ( '/routes/json' , ( req , res ) => {
// minimalist for JXR but could also be OpenAPI, cf https://git.benetou.fr/utopiah/offline-octopus/issues/16
// used in /webxr as
// fetch('/routes/json').then(r=>r.json()).then(r=>r.filter(p=>p.match(/resolution/)).map(p=>addNewNote('jxr fetch("https://192.168.0.129:8082'+p+'")')))
res . send ( routes )
} )
app . get ( '/authtestviaheader' , ( req , res ) => {
if ( req . header ( 'authorization' ) != 'Bearer ' + md5fromPub ) { res . sendStatus ( 401 ) ; return ; }
res . sendStatus ( 200 )
// fetch('/authtestviaheader', {headers: {'authorization': 'Bearer '+ bearer}}).then(r=>r.text()).then(t=>console.log(t))
// prevents from showing in browser history but also makes testing slightly harder
// consider next() for middleware instead of copy/pasting this line
} )
app . get ( '/authtest' , ( req , res ) => {
if ( req . query ? . bearer != md5fromPub ) { res . send ( auth _instructions ) ; return ; }
// relying on this line for each specific route
// this is NOT authentification proper, even less secure
// this is done ONLY to avoid mistakes on a secure LAN
res . json ( { msg : "success" } )
} )
app . get ( '/pwa' , ( req , res ) => {
// for offline use on mobile or VR headset
// should try to sync back when devices connect back
// same when itself connects back to (Internet) own server e.g benetou.fr
// can be cascading or federatede or properly P2P
// responsive programming also, not "just" design
res . redirect ( 'pwa/index.html' )
// see also /editor
} )
let resultFromLastGlobalCmd = { }
app . get ( '/allpeers/exec' , ( req , res ) => {
if ( req . query ? . bearer != md5fromPub ) { res . send ( auth _instructions ) ; return ; }
if ( ! req . query . cmdName ) {
res . json ( utilsCmd )
} else {
foundPeers . map ( i => {
let url = protocol + '://' + subclass + i + ':' + port
+ '/exec?cmdName=' + req . query . cmdName
+ '&bearer=' + req . query . bearer
let opt = { rejectUnauthorized : false }
https . get ( url , opt , res => {
// to simplify with fetch and promises
let data = '' ;
res . on ( 'data' , chunk => {
data += chunk ;
} ) ;
res . on ( 'end' , ( ) => {
console . log ( 'data' , url , data )
data = JSON . parse ( data ) ;
resultFromLastGlobalCmd [ url ] = data
} )
} ) . on ( 'error' , err => {
console . log ( err . message ) ;
} ) . end ( )
} )
res . json ( { msg : 'started' } ) // could redirect('/all/result') instead after a timeout
}
} )
app . get ( '/allpeers/result' , ( req , res ) => {
res . json ( resultFromLastGlobalCmd )
} )
app . get ( '/exec' , ( req , res ) => {
if ( req . query ? . bearer != md5fromPub ) { res . send ( auth _instructions ) ; return ; }
if ( ! req . query . cmdName ) {
res . json ( utilsCmd )
} else {
res . json ( execConfiguredCommand ( req . query . cmdName ) )
}
} )
function execConfiguredCommand ( cmdName , options = '' ) {
let resultFromExecution = execSync ( utilsCmd [ cmdName ] . cmd + options , utilsCmd [ cmdName ] . context )
let formatter = utilsCmd [ cmdName ] . format
if ( formatter ) resultFromExecution = formatter ( resultFromExecution )
return resultFromExecution
}
// app.get('/interface/register', (req, res) => {
// consider uinput to access devices proper, e.g physical keyboard
// could be use for e.g reMarkable2, Quest2, etc with specifically accepted or prefered formats
// app.get('/interface/unregister', (req, res) => {
app . get ( '/services' , ( req , res ) => {
// should be updated via register/unregister
res . json ( localServices )
} )
app . get ( '/services/register' , ( req , res ) => {
// see localServices to load and save in configFilePath
res . json ( { msg : 'not yet implemented' } )
// example {name:'hmdlink', desc:'share URL between local devices', protocol:'http', port:8082, path: '/hmdlink', url:'http://192.168.4.3:8082/hmdlink'},
} )
app . get ( '/services/unregister' , ( req , res ) => {
res . json ( { msg : 'not yet implemented' } )
} )
app . get ( '/updates' , ( req , res ) => {
// see utilsCmd['update']
// could rely on a git reachable by all peers
// this might be feasible from this very https server as read-only
// surely feasible via ssh
// could killall offtopus first then pull then restart detached
res . json ( { msg : 'not yet implemented' } )
} )
app . get ( '/recentfiles' , ( req , res ) => {
// e.g lsof | grep home | grep vim | grep -v firefox
// or history | grep vi
// should be available after (ideally local) conversion if needed, e.g rm -> .svg on reMarkable
res . json ( { msg : 'not yet implemented' } )
} )
let dynURL = '/'
app . get ( '/hmdlink/set' , ( req , res ) => { // could be a PUT instead
// e.g http://192.168.4.3:8082/hmdlink/set?url=http://192.168.4.3:8082/114df5f8-3921-42f0-81e7-48731b563571.thumbnails/f07120ba-0ca1-429d-869f-c704a52b7aa3.png
// should return a warning if req.query.url is undefined
if ( ! req . query . url ) { res . json ( { error : 'use /hmdlink/set?url=YOURURL' } ) ; return }
dynURL = req . query . url
res . redirect ( dynURL )
} )
app . get ( '/hmdlink' , ( req , res ) => {
res . redirect ( dynURL )
} )
app . get ( '/webxr' , ( req , res ) => {
res . redirect ( '/spasca-offline/engine/index.html' )
//res.redirect( '/local-metaverse-tooling/local-aframe-test.html' )
} )
// user for /scan to populate foundPeers
app . get ( '/available' , ( req , res ) => {
res . json ( true )
} )
app . get ( '/foundpeers' , ( req , res ) => {
res . json ( foundPeers )
} )
let foundPeers = [ ]
app . get ( '/scan' , ( req , res ) => {
scanpeers ( )
res . json ( { msg : 'started' } ) // could redirect('/foundpeers') too after a timeout
} )
function scanpeers ( ) {
foundPeers = [ ]
for ( let i = 1 ; i < 254 ; i ++ ) { // async so blasting, gives very quick result for positives
let url = protocol + '://' + subclass + i + ':' + port + '/available'
let opt = { rejectUnauthorized : false }
https . get ( url , opt , res => {
let data = '' ;
res . on ( 'data' , chunk => {
data += chunk ;
} ) ;
res . on ( 'end' , ( ) => {
data = JSON . parse ( data ) ;
foundPeers . push ( i )
// could also register there and then
} )
} ) . on ( 'error' , err => {
//console.log(err.message); usually ECONNREFUSED or EHOSTUNREACH
} ) . end ( )
}
}
app . get ( '/sshconfig' , ( req , res ) => {
res . json ( getSshConfig ( ) )
} )
app . get ( '/sshconfig/live' , ( req , res ) => {
res . json ( getSshConfig ( ) . filter ( i => foundPeers . map ( e => subclass + e ) . filter ( x => i . connectionString . match ( x ) ) . length ) )
} )
app . get ( '/unifiedworkspaces' , ( req , res ) => {
res . json ( workspaces )
} )
function getUnifiedworkspaces ( ) {
mountPoints . map ( mp => {
let dir = path . resolve ( _ _dirname , sshfsmounts , mp )
workspaces [ mp ] = fs . readdirSync ( dir )
console . log ( 'reading' , mp , workspaces . mp )
} )
return workspaces
}
function mountAll ( ) {
// should scanpeers() first
if ( foundPeers . length == 0 ) return
getSshConfig ( ) . filter ( i => foundPeers . map ( e => subclass + e ) . filter ( x => i . connectionString . match ( x ) ) . length )
. map ( l => {
let cs = 'sshfs ' + l . name + ':'
if ( l . custom )
cs += l . custom
else
cs += '/home/' + l . user
let targetPath = path . resolve ( _ _dirname , sshfsmounts , l . name )
if ( ! fs . existsSync ( targetPath ) ) { fs . mkdirSync ( targetPath , { recursive : true } ) ; }
mountPoints . push ( l . name )
return cs + ' ' + targetPath
} )
//.map( l => console.log(l))
. map ( l => { try { execSync ( l ) } catch ( err ) { console . log ( err ) } } )
}
function umountAll ( ) {
mpath = path . resolve ( _ _dirname , sshfsmounts )
fs . readdirSync ( mpath )
// could rely on mountPoints instead
. map ( f => {
try {
execSync ( 'umount ' + path . resolve ( _ _dirname , sshfsmounts , f ) )
// should update mountPoints instead
} catch ( err ) {
console . log ( err )
}
} )
mountPoints = [ ]
}
function getSshConfig ( ) {
let txt = fs . readFileSync ( sshconfigpath ) . toString ( )
return txt . split ( 'Host ' )
. filter ( m => m . match ( subclass ) )
. map ( c => {
let all = c . replaceAll ( ' ' , '' )
. split ( '\n' )
let p = all
. filter ( i => i . match ( /^[a-zA-Z]/ ) ) ;
let custom = all
. filter ( i => i . match ( /^#Dir / ) ) [ 0 ]
? . split ( ' ' ) [ 1 ]
// custom ~/.ssh/config parameter as comment
// user here for home directory in termux
let user = p . filter ( pm => pm . match ( 'User ' ) ) [ 0 ] . replace ( 'User ' , '' )
let hostname = p . filter ( pm => pm . match ( 'HostName' ) ) [ 0 ] . replace ( 'HostName ' , '' )
let connectionString = user + '@' + hostname
let port = p . filter ( pm => pm . match ( 'Port' ) ) [ 0 ] ? . replace ( 'Port ' , '' )
if ( port ) connectionString += ':' + port
return { name : p [ 0 ] , user , connectionString , custom }
} )
}
/ *
e . g AFTER mounting
f . readdirSync ( './sshfsmounts/' ) . map ( d => f . readdirSync ( './sshfsmounts/' + d ) )
location : / h o m e / d e c k / P r o t o t y p e s / o f f l i n e - o c t o p u s / s s h f s m o u n t s
sshfs remarkable2 : / h o m e / r o o t / x o c h i t l - d a t a / r e m a r k a b l e 2 /
works if available
sshfs fabien @ 192.168 . 4.1 : / h o m e / f a b i e n / r p i 0 /
still prompts for password , need manual login
ls rpi0 /
ls remarkable2 /
must be done after passwordless login , i . e after ssh - copy - id
made a dedicated key : / h o m e / d e c k / . s s h / i d _ r s a _ o f f l i n e o c t o p u s
easier to revoke if need be
* /
app . get ( '/mouse/up' , ( req , res ) => { res . json ( execConfiguredCommand ( 'mouseup' ) ) } )
app . get ( '/mouse/down' , ( req , res ) => { res . json ( execConfiguredCommand ( 'mousedown' ) ) } )
app . get ( '/mouse/move' , ( req , res ) => {
if ( ! req . query . options ? . match ( utilsCmd [ 'mousemove' ] ) ) { { res . json ( { error : 'use /mouse/move?options=100 2000' } ) ; return } }
res . json ( execConfiguredCommand ( 'mousemove' , req . query . options ) ) }
)
app . get ( '/minimizeall' , ( req , res ) => { res . json ( execConfiguredCommand ( 'minimizeall' ) ) } )
app . get ( '/resolution/high' , ( req , res ) => { res . json ( execConfiguredCommand ( 'highresscreen' ) ) } )
app . get ( '/resolution/low' , ( req , res ) => { res . json ( execConfiguredCommand ( 'lowresscreen' ) ) } )
app . get ( '/resolution/list' , ( req , res ) => { res . json ( execConfiguredCommand ( 'availableRes' ) ) } )
app . get ( '/localprototypes' , ( req , res ) => {
// examples to disentangle own work for cloned existing repositories :
// find Prototypes/ -iwholename */.git/config | xargs grep git.benetou.fr
// find ~/Prototypes/ -depth -maxdepth 4 -wholename "*/.git/config" | xargs grep -l git.benetou.fr | sed "s|.*Prototypes/\(.*\)/.git/config|\1|"
res . json ( execConfiguredCommand ( 'listprototypes' ) )
} )
app . get ( '/editor/recover' , ( req , res ) => {
// could move the previous file with time stamp
fs . copyFileSync ( minfileSaveFullPath , fileSaveFullPath )
res . json ( { msg : 'copied' } )
} )
/ *
* example of finding past local solution , here shiki for syntax highlighting
*
( deck @ steamdeck serverhome ) $ find . - iname shiki
. / fabien / web / future _of _text _demo / content / shiki
( deck @ steamdeck serverhome ) $ ls . / fabien / web / future _of _text _demo / content / shiki / dist /
onig . wasm
see syntax - highlighting branch in SpaSca git repository
in / h o m e / d e c k / s e r v e r h o m e / f a b i e n / w e b / f u t u r e _ o f _ t e x t _ d e m o / e n g i n e /
* /
app . get ( '/editor/read' , ( req , res ) => {
content = fs . readFileSync ( fileSaveFullPath ) . toString ( )
res . json ( { msg : content } )
} )
app . get ( '/editor/save' , ( req , res ) => {
let content = req . query . content // does not escape, loses newlines
if ( ! content ) {
res . json ( { msg : 'missing content' } )
} else {
console . log ( 'writting' , content )
fs . writeFileSync ( fileSaveFullPath , content )
res . json ( { msg : 'written to ' + fileSaveFullPath } )
}
} )
let routes = app . _router . stack . map ( r => r . route ? . path ) . filter ( r => typeof ( r ) == 'string' )
// it's a form of caching but so far routes are not dynamically generated so sufficient solution
const privateKey = fs . readFileSync ( "naf-key.pem" , "utf8" ) ;
const certificate = fs . readFileSync ( "naf.pem" , "utf8" ) ;
const credentials = { key : privateKey , cert : certificate } ;
const webServer = https . createServer ( credentials , app ) ;
// Start Express http server
// const webServer = http.createServer(app);
// Listen on port
webServer . listen ( port , ( ) => {
console . log ( "listening on " + protocol + "://0.0.0.0:" + port )
getCommand ( )
} ) ;
// REPL testing
const readline = require ( "readline" ) ;
const rl = readline . createInterface ( {
input : process . stdin ,
output : process . stdout ,
completer : completer
} ) ;
// https://nodejs.org/api/readline.html#use-of-the-completer-function
function completer ( line ) {
const completions = help ( ) . split ( '\n' ) ;
const hits = completions . filter ( ( c ) => c . startsWith ( line ) ) ;
// Show all completions if none found
return [ hits . length ? hits : completions , line ] ;
}
let command = ''
function getCommand ( ) {
rl . question ( process . title + " REPL: " , function ( command ) {
if ( command == "close" ) {
rl . close ( ) ;
} else {
try {
console . log ( command , eval ( command ) )
} catch ( err ) {
console . log ( err )
}
getCommand ( )
// somehow switch to node REPL proper after?!
}
} ) ;
}
function help ( ) {
return `
help ( )
execConfiguredCommand ( cmdName )
getSshConfig ( )
mountAll ( )
umountAll ( )
scanpeers ( )
getUnifiedworkspaces ( )
foundPeers
port
protocol
subclass
publicKeyPath
publicKey
md5fromPub
process . title
filename
fileSaveFullPath
minfilename
minfileSaveFullPath
sshconfigpath
propath
localServices
configFilePath
utilsCmd
instructions
auth _instructions
process . title
sshfsmounts
mountPoints
workspaces
`
}
rl . on ( "close" , function ( ) {
console . log ( "\ndone" ) ;
umountAll ( )
process . exit ( 0 ) ;
} ) ;
// Demo Day target :
// show files from ~/Prototypes as cubes from ssh mounted on a virtual workspace
// sshfs might not even be needed, see allpeers/exec instead
// wouldn't easily get content back though, just meta data