2023-07-06 14:47:50 +00:00
import { Application , Context , Next , oakCors , Router } from "./deps.ts" ;
2022-10-10 12:50:21 +00:00
import { SpacePrimitives } from "../common/spaces/space_primitives.ts" ;
2022-10-12 09:47:13 +00:00
import { AssetBundle } from "../plugos/asset_bundle/bundle.ts" ;
2023-05-23 18:53:53 +00:00
import { ensureSettingsAndIndex } from "../common/util.ts" ;
import { performLocalFetch } from "../common/proxy_fetch.ts" ;
2023-05-29 07:53:49 +00:00
import { BuiltinSettings } from "../web/types.ts" ;
import { gitIgnoreCompiler } from "./deps.ts" ;
import { FilteredSpacePrimitives } from "../common/spaces/filtered_space_primitives.ts" ;
2023-06-13 18:47:05 +00:00
import { Authenticator } from "./auth.ts" ;
2023-07-06 14:47:50 +00:00
import { FileMeta } from "../common/types.ts" ;
2022-10-10 12:50:21 +00:00
export type ServerOptions = {
2022-12-04 05:24:06 +00:00
hostname : string ;
2022-10-10 12:50:21 +00:00
port : number ;
pagesPath : string ;
2023-05-23 18:53:53 +00:00
clientAssetBundle : AssetBundle ;
2023-06-13 18:47:05 +00:00
authenticator : Authenticator ;
2022-12-05 11:14:21 +00:00
pass? : string ;
2023-05-23 18:53:53 +00:00
certFile? : string ;
keyFile? : string ;
maxFileSizeMB? : number ;
2022-10-10 12:50:21 +00:00
} ;
export class HttpServer {
app : Application ;
2022-12-04 05:24:06 +00:00
private hostname : string ;
2022-10-10 12:50:21 +00:00
private port : number ;
abortController? : AbortController ;
2023-05-23 18:53:53 +00:00
clientAssetBundle : AssetBundle ;
2023-05-29 07:53:49 +00:00
settings? : BuiltinSettings ;
spacePrimitives : SpacePrimitives ;
2023-06-13 18:47:05 +00:00
authenticator : Authenticator ;
2022-10-10 12:50:21 +00:00
2023-05-23 18:53:53 +00:00
constructor (
2023-05-29 07:53:49 +00:00
spacePrimitives : SpacePrimitives ,
2023-05-23 18:53:53 +00:00
private options : ServerOptions ,
) {
2022-12-04 05:24:06 +00:00
this . hostname = options . hostname ;
2022-10-10 12:50:21 +00:00
this . port = options . port ;
2023-05-23 18:53:53 +00:00
this . app = new Application ( ) ;
2023-06-13 18:47:05 +00:00
this . authenticator = options . authenticator ;
2023-05-23 18:53:53 +00:00
this . clientAssetBundle = options . clientAssetBundle ;
2023-05-29 07:53:49 +00:00
let fileFilterFn : ( s : string ) = > boolean = ( ) = > true ;
this . spacePrimitives = new FilteredSpacePrimitives (
spacePrimitives ,
( meta ) = > {
// Don't list file exceeding the maximum file size
if (
options . maxFileSizeMB &&
meta . size / ( 1024 * 1024 ) > options . maxFileSizeMB
) {
return false ;
}
return fileFilterFn ( meta . name ) ;
} ,
async ( ) = > {
await this . reloadSettings ( ) ;
if ( typeof this . settings ? . spaceIgnore === "string" ) {
fileFilterFn = gitIgnoreCompiler ( this . settings . spaceIgnore ) . accepts ;
} else {
fileFilterFn = ( ) = > true ;
}
} ,
) ;
2023-05-23 18:53:53 +00:00
}
2022-10-10 12:50:21 +00:00
2023-05-23 18:53:53 +00:00
// Replaces some template variables in index.html in a rather ad-hoc manner, but YOLO
renderIndexHtml() {
return this . clientAssetBundle . readTextFileSync ( ".client/index.html" )
. replaceAll (
"{{SPACE_PATH}}" ,
this . options . pagesPath . replaceAll ( "\\" , "\\\\" ) ,
) ;
2022-10-10 12:50:21 +00:00
}
async start() {
2023-05-29 07:53:49 +00:00
await this . reloadSettings ( ) ;
2022-10-12 09:47:13 +00:00
2023-07-06 14:47:50 +00:00
// Serve static files (javascript, css, html)
this . app . use ( this . serveStatic . bind ( this ) ) ;
2023-05-23 18:53:53 +00:00
2023-06-13 18:47:05 +00:00
await this . addPasswordAuth ( this . app ) ;
2023-07-06 14:47:50 +00:00
const fsRouter = this . addFsRoutes ( this . spacePrimitives ) ;
2022-10-10 12:50:21 +00:00
this . app . use ( fsRouter . routes ( ) ) ;
this . app . use ( fsRouter . allowedMethods ( ) ) ;
2023-07-06 14:47:50 +00:00
// Fallback, serve the UI index.html
this . app . use ( ( { response } ) = > {
response . headers . set ( "Content-type" , "text/html" ) ;
response . body = this . renderIndexHtml ( ) ;
} ) ;
2023-06-13 18:47:05 +00:00
2022-10-10 12:50:21 +00:00
this . abortController = new AbortController ( ) ;
2023-05-23 18:53:53 +00:00
const listenOptions : any = {
2022-12-14 19:32:26 +00:00
hostname : this.hostname ,
port : this.port ,
signal : this.abortController.signal ,
2023-05-23 18:53:53 +00:00
} ;
if ( this . options . keyFile ) {
listenOptions . key = Deno . readTextFileSync ( this . options . keyFile ) ;
}
if ( this . options . certFile ) {
listenOptions . cert = Deno . readTextFileSync ( this . options . certFile ) ;
}
this . app . listen ( listenOptions )
2022-11-25 12:08:59 +00:00
. catch ( ( e : any ) = > {
console . log ( "Server listen error:" , e . message ) ;
Deno . exit ( 1 ) ;
} ) ;
2022-12-14 19:32:26 +00:00
const visibleHostname = this . hostname === "0.0.0.0"
? "localhost"
: this . hostname ;
2022-10-10 12:50:21 +00:00
console . log (
2023-01-16 15:45:55 +00:00
` SilverBullet is now running: http:// ${ visibleHostname } : ${ this . port } ` ,
2022-10-10 12:50:21 +00:00
) ;
2022-11-26 13:15:38 +00:00
}
2023-07-06 14:47:50 +00:00
serveStatic (
{ request , response } : Context < Record < string , any > , Record < string , any > > ,
next : Next ,
) {
if (
request . url . pathname === "/"
) {
// Serve the UI (index.html)
// Note: we're explicitly not setting Last-Modified and If-Modified-Since header here because this page is dynamic
response . headers . set ( "Content-type" , "text/html" ) ;
response . body = this . renderIndexHtml ( ) ;
return ;
}
try {
const assetName = request . url . pathname . slice ( 1 ) ;
if (
this . clientAssetBundle . has ( assetName ) &&
request . headers . get ( "If-Modified-Since" ) ===
utcDateString ( this . clientAssetBundle . getMtime ( assetName ) )
) {
response . status = 304 ;
return ;
}
response . status = 200 ;
response . headers . set (
"Content-type" ,
this . clientAssetBundle . getMimeType ( assetName ) ,
) ;
const data = this . clientAssetBundle . readFileSync (
assetName ,
) ;
response . headers . set ( "Cache-Control" , "no-cache" ) ;
response . headers . set ( "Content-length" , "" + data . length ) ;
response . headers . set (
"Last-Modified" ,
utcDateString ( this . clientAssetBundle . getMtime ( assetName ) ) ,
) ;
if ( request . method === "GET" ) {
response . body = data ;
}
} catch {
return next ( ) ;
}
}
2023-05-29 07:53:49 +00:00
async reloadSettings() {
// TODO: Throttle this?
this . settings = await ensureSettingsAndIndex ( this . spacePrimitives ) ;
}
2023-06-13 18:47:05 +00:00
private async addPasswordAuth ( app : Application ) {
2022-12-22 10:21:12 +00:00
const excludedPaths = [
"/manifest.json" ,
"/favicon.png" ,
"/logo.png" ,
"/.auth" ,
] ;
2023-07-02 09:25:32 +00:00
2023-07-06 14:47:50 +00:00
// Middleware handling the /.auth page and flow
2023-07-02 09:25:32 +00:00
app . use ( async ( { request , response , cookies } , next ) = > {
if ( request . url . pathname === "/.auth" ) {
if ( request . url . search === "?logout" ) {
await cookies . delete ( "auth" ) ;
// Implicit fallthrough to login page
}
if ( request . method === "GET" ) {
response . headers . set ( "Content-type" , "text/html" ) ;
response . body = this . clientAssetBundle . readTextFileSync (
".client/auth.html" ,
) ;
return ;
} else if ( request . method === "POST" ) {
const values = await request . body ( { type : "form" } ) . value ;
const username = values . get ( "username" ) ! ,
password = values . get ( "password" ) ! ,
refer = values . get ( "refer" ) ;
const hashedPassword = await this . authenticator . authenticate (
username ,
password ,
) ;
if ( hashedPassword ) {
await cookies . set ( "auth" , ` ${ username } : ${ hashedPassword } ` , {
expires : new Date ( Date . now ( ) + 1000 * 60 * 60 * 24 * 7 ) , // in a week
sameSite : "strict" ,
} ) ;
response . redirect ( refer || "/" ) ;
// console.log("All headers", request.headers);
} else {
response . redirect ( "/.auth?error=1" ) ;
}
return ;
} else {
2023-07-04 14:53:39 +00:00
response . redirect ( "/.auth" ) ;
2023-07-02 09:25:32 +00:00
return ;
}
} else {
await next ( ) ;
}
} ) ;
2023-06-13 18:47:05 +00:00
if ( ( await this . authenticator . getAllUsers ( ) ) . length > 0 ) {
2023-07-06 14:47:50 +00:00
// Users defined, so enabling auth
2022-12-22 10:21:12 +00:00
app . use ( async ( { request , response , cookies } , next ) = > {
2022-12-15 11:59:31 +00:00
if ( ! excludedPaths . includes ( request . url . pathname ) ) {
2022-12-22 10:21:12 +00:00
const authCookie = await cookies . get ( "auth" ) ;
2023-06-13 18:47:05 +00:00
if ( ! authCookie ) {
2023-07-04 14:53:39 +00:00
response . redirect ( "/.auth" ) ;
2022-12-22 10:21:12 +00:00
return ;
}
2023-06-13 18:47:05 +00:00
const [ username , hashedPassword ] = authCookie . split ( ":" ) ;
if (
! await this . authenticator . authenticateHashed (
username ,
hashedPassword ,
)
) {
2023-07-04 14:53:39 +00:00
response . redirect ( "/.auth" ) ;
2023-06-13 18:47:05 +00:00
return ;
}
2022-12-22 10:21:12 +00:00
}
2023-07-02 09:25:32 +00:00
await next ( ) ;
2022-10-17 18:35:38 +00:00
} ) ;
}
}
2023-07-06 14:47:50 +00:00
private addFsRoutes ( spacePrimitives : SpacePrimitives ) : Router {
2022-10-17 18:35:38 +00:00
const fsRouter = new Router ( ) ;
2023-07-02 09:25:32 +00:00
const corsMiddleware = oakCors ( {
allowedHeaders : "*" ,
exposedHeaders : "*" ,
2023-07-06 14:47:50 +00:00
methods : [ "GET" , "POST" , "PUT" , "DELETE" , "HEAD" , "OPTIONS" ] ,
2023-07-02 09:25:32 +00:00
} ) ;
2023-07-06 14:47:50 +00:00
fsRouter . use ( corsMiddleware ) ;
2022-10-17 18:35:38 +00:00
// File list
2023-07-06 14:47:50 +00:00
fsRouter . get (
"/index.json" ,
// corsMiddleware,
async ( { request , response } ) = > {
2023-08-08 13:00:18 +00:00
if ( request . headers . has ( "X-Sync-Mode" ) ) {
2023-07-06 14:47:50 +00:00
// Only handle direct requests for a JSON representation of the file list
response . headers . set ( "Content-type" , "application/json" ) ;
response . headers . set ( "X-Space-Path" , this . options . pagesPath ) ;
const files = await spacePrimitives . fetchFileList ( ) ;
response . body = JSON . stringify ( files ) ;
} else {
// Otherwise, redirect to the UI
// The reason to do this is to handle authentication systems like Authelia nicely
response . redirect ( "/" ) ;
}
} ,
) ;
2022-10-17 18:35:38 +00:00
2023-05-23 18:53:53 +00:00
// RPC
2023-07-06 14:47:50 +00:00
fsRouter . post ( "/.rpc" , async ( { request , response } ) = > {
2023-05-23 18:53:53 +00:00
const body = await request . body ( { type : "json" } ) . value ;
try {
switch ( body . operation ) {
case "fetch" : {
const result = await performLocalFetch ( body . url , body . options ) ;
2023-05-24 04:47:39 +00:00
console . log ( "Proxying fetch request to" , body . url ) ;
2023-05-23 18:53:53 +00:00
response . headers . set ( "Content-Type" , "application/json" ) ;
response . body = JSON . stringify ( result ) ;
return ;
}
case "shell" : {
// TODO: Have a nicer way to do this
if ( this . options . pagesPath . startsWith ( "s3://" ) ) {
response . status = 500 ;
response . body = JSON . stringify ( {
stdout : "" ,
stderr : "Cannot run shell commands with S3 backend" ,
code : 500 ,
} ) ;
return ;
}
2023-07-06 14:47:50 +00:00
console . log ( "Running shell command:" , body . cmd , body . args ) ;
2023-05-23 18:53:53 +00:00
const p = new Deno . Command ( body . cmd , {
args : body.args ,
cwd : this.options.pagesPath ,
stdout : "piped" ,
stderr : "piped" ,
} ) ;
const output = await p . output ( ) ;
const stdout = new TextDecoder ( ) . decode ( output . stdout ) ;
const stderr = new TextDecoder ( ) . decode ( output . stderr ) ;
response . headers . set ( "Content-Type" , "application/json" ) ;
response . body = JSON . stringify ( {
stdout ,
stderr ,
code : output.code ,
} ) ;
2023-07-06 14:47:50 +00:00
if ( output . code !== 0 ) {
console . error ( "Error running shell command" , stdout , stderr ) ;
}
2023-05-23 18:53:53 +00:00
return ;
}
default :
response . headers . set ( "Content-Type" , "text/plain" ) ;
response . status = 400 ;
response . body = "Unknown operation" ;
}
} catch ( e : any ) {
console . log ( "Error" , e ) ;
response . status = 500 ;
response . body = e . message ;
return ;
}
} ) ;
2023-07-06 14:47:50 +00:00
const filePathRegex = "\/(.+\\.[a-zA-Z]+)" ;
2022-10-17 18:35:38 +00:00
fsRouter
2023-07-06 14:47:50 +00:00
. get (
filePathRegex ,
async ( { params , response , request } ) = > {
const name = params [ 0 ] ;
console . log ( "Requested file" , name ) ;
2023-08-08 13:00:18 +00:00
if ( ! request . headers . has ( "X-Sync-Mode" ) && name . endsWith ( ".md" ) ) {
2023-08-08 14:35:46 +00:00
// It can happen that during a sync, authentication expires, this may result in a redirect to the login page and then back to this particular file. This particular file may be an .md file, which isn't great to show so we're redirecting to the associated SB UI page.
2023-08-08 13:00:18 +00:00
console . log ( "Request was without X-Sync-Mode, redirecting to page" ) ;
response . redirect ( ` / ${ name . slice ( 0 , - 3 ) } ` ) ;
return ;
}
2023-07-06 14:47:50 +00:00
if ( name . startsWith ( "." ) ) {
// Don't expose hidden files
response . status = 404 ;
response . body = "Not exposed" ;
2022-10-21 14:56:46 +00:00
return ;
}
2023-07-29 15:06:32 +00:00
// Handle federated links through a simple redirect, only used for attachments loads with service workers disabled
if ( name . startsWith ( "!" ) ) {
let url = name . slice ( 1 ) ;
if ( url . startsWith ( "localhost" ) ) {
url = ` http:// ${ url } ` ;
} else {
url = ` https:// ${ url } ` ;
}
2023-07-29 21:41:37 +00:00
try {
const req = await fetch ( url ) ;
response . status = req . status ;
2023-07-30 09:30:01 +00:00
// Override X-Permssion header to always be "ro"
const newHeaders = new Headers ( ) ;
for ( const [ key , value ] of req . headers . entries ( ) ) {
newHeaders . set ( key , value ) ;
}
newHeaders . set ( "X-Permission" , "ro" ) ;
response . headers = newHeaders ;
2023-07-29 21:41:37 +00:00
response . body = req . body ;
} catch ( e : any ) {
console . error ( "Error fetching federated link" , e ) ;
response . status = 500 ;
response . body = e . message ;
}
// response.redirect(url);
2023-07-29 15:06:32 +00:00
return ;
}
2023-07-06 14:47:50 +00:00
try {
2023-08-08 13:00:18 +00:00
if ( request . headers . has ( "X-Get-Meta" ) ) {
// Getting meta via GET request
const fileData = await spacePrimitives . getFileMeta ( name ) ;
response . status = 200 ;
this . fileMetaToHeaders ( response . headers , fileData ) ;
response . body = "" ;
return ;
}
const fileData = await spacePrimitives . readFile ( name ) ;
2023-07-06 14:47:50 +00:00
const lastModifiedHeader = new Date ( fileData . meta . lastModified )
. toUTCString ( ) ;
if (
request . headers . get ( "If-Modified-Since" ) === lastModifiedHeader
) {
response . status = 304 ;
return ;
}
response . status = 200 ;
this . fileMetaToHeaders ( response . headers , fileData . meta ) ;
response . headers . set ( "Last-Modified" , lastModifiedHeader ) ;
2022-10-17 18:35:38 +00:00
2023-07-06 14:47:50 +00:00
response . body = fileData . data ;
2023-08-08 13:00:18 +00:00
} catch ( e : any ) {
2023-08-08 14:35:46 +00:00
console . error ( "Error GETting file" , name , e ) ;
2023-07-06 14:47:50 +00:00
response . status = 404 ;
response . body = "Not found" ;
}
} ,
)
. put (
filePathRegex ,
async ( { request , response , params } ) = > {
const name = params [ 0 ] ;
console . log ( "Saving file" , name ) ;
if ( name . startsWith ( "." ) ) {
// Don't expose hidden files
response . status = 403 ;
return ;
}
2023-01-13 14:41:29 +00:00
2023-07-06 14:47:50 +00:00
const body = await request . body ( { type : "bytes" } ) . value ;
2023-07-02 09:25:32 +00:00
2023-07-06 14:47:50 +00:00
try {
const meta = await spacePrimitives . writeFile (
name ,
body ,
) ;
response . status = 200 ;
this . fileMetaToHeaders ( response . headers , meta ) ;
response . body = "OK" ;
} catch ( err ) {
console . error ( "Write failed" , err ) ;
response . status = 500 ;
response . body = "Write failed" ;
2023-07-02 09:25:32 +00:00
}
2023-07-06 14:47:50 +00:00
} ,
)
. delete ( filePathRegex , async ( { response , params } ) = > {
2022-10-17 18:35:38 +00:00
const name = params [ 0 ] ;
2023-05-25 15:21:51 +00:00
console . log ( "Deleting file" , name ) ;
2023-07-06 14:47:50 +00:00
if ( name . startsWith ( "." ) ) {
// Don't expose hidden files
response . status = 403 ;
return ;
}
2022-10-17 18:35:38 +00:00
try {
await spacePrimitives . deleteFile ( name ) ;
response . status = 200 ;
response . body = "OK" ;
} catch ( e : any ) {
console . error ( "Error deleting attachment" , e ) ;
2023-07-06 14:47:50 +00:00
response . status = 500 ;
2022-10-17 18:35:38 +00:00
response . body = e . message ;
}
2023-07-06 14:47:50 +00:00
} )
. options ( filePathRegex , corsMiddleware ) ;
return fsRouter ;
}
private fileMetaToHeaders ( headers : Headers , fileMeta : FileMeta ) {
headers . set ( "Content-Type" , fileMeta . contentType ) ;
headers . set (
"X-Last-Modified" ,
"" + fileMeta . lastModified ,
) ;
headers . set ( "Cache-Control" , "no-cache" ) ;
headers . set ( "X-Permission" , fileMeta . perm ) ;
2023-08-08 13:00:18 +00:00
headers . set ( "X-Content-Length" , "" + fileMeta . size ) ;
2022-10-10 12:50:21 +00:00
}
2023-05-23 18:53:53 +00:00
stop() {
2022-10-10 12:50:21 +00:00
if ( this . abortController ) {
this . abortController . abort ( ) ;
console . log ( "stopped server" ) ;
}
}
}
2023-05-23 18:53:53 +00:00
function utcDateString ( mtime : number ) : string {
return new Date ( mtime ) . toUTCString ( ) ;
}