UNPKG

@softvisio/core

Version:
575 lines (447 loc) • 16.7 kB
import Component from "#lib/app/api/component"; import Context from "#lib/app/api/frontend/context"; import WebSocketConnection from "#lib/app/api/frontend/websocket-connection"; import Token from "#lib/app/token"; import env from "#lib/env"; import Events from "#lib/events"; import File from "#lib/file"; import JsonContainer from "#lib/json-container"; import Counter from "#lib/threads/counter"; import { resolve } from "#lib/utils"; import Cache from "./frontend/cache.js"; export default class extends Component { #incomingEvents = new Events(); #outgoingEvents = new Events( { "maxListeners": Infinity } ); #activeApiCallsCounter = new Counter(); #cache; #stats = {}; // public on ( name, listener ) { this.#incomingEvents.on( name, listener ); return this; } once ( name, listener ) { this.#incomingEvents.once( name, listener ); return this; } off ( name, listener ) { this.#incomingEvents.off( name, listener ); return this; } publish ( name, ...args ) { var users, publisherId; if ( typeof name === "object" ) { ( { name, users, "data": args, publisherId } = name ); } const cache = {}; if ( name.endsWith( "/" ) ) { users ??= args.shift(); if ( !Array.isArray( users ) ) users = [ users ]; for ( const user of users ) { this.#outgoingEvents.emit( name + user, args, cache, publisherId ); } } else { this.#outgoingEvents.emit( name, args, cache, publisherId ); } return this; } updateTokenLastActivity ( token ) { return this.#cache.updateTokenLastActivity( token ); } startApiCall ( { activityCounter } ) { if ( activityCounter ) this.#activeApiCallsCounter.value++; return { activityCounter, }; } async checkApiCallLimits ( callDescriptor, ctx, method, isPrivateCall ) { // do not check limits for if ( isPrivateCall ) return true; const maxParallelCallsPerClient = method.maxParallelCallsPerClient || this.api.config.frontend.maxParallelCallsPerClient; if ( !maxParallelCallsPerClient ) return true; const stats = this.#stats, statsId = ( ctx.token ? ctx.token.type + "/" + ctx.token.id : ctx.remoteAddress ) + "/" + method.id; // too many calls if ( stats[ statsId ] >= maxParallelCallsPerClient ) { return false; } stats[ statsId ] ??= 0; stats[ statsId ]++; callDescriptor.statsId = statsId; return true; } endApiCall ( callDescriptor ) { // decrement call limi counter if ( callDescriptor.statsId ) { this.#stats[ callDescriptor.statsId ]--; if ( !this.#stats[ callDescriptor.statsId ] ) delete this.#stats[ callDescriptor.statsId ]; } // finish active call if ( callDescriptor.activityCounter ) this.#activeApiCallsCounter.value--; } // protected async _init ( getSchema ) { // link to the cluster if ( this.app.cluster ) { const prefix = this.api.isApi ? "to-api/" : "to-rpc/"; this.#outgoingEvents.link( this.app.cluster, { "on": name => prefix + name, "forwarder": ( name, args ) => this.#outgoingEvents.emit( name, args, {} ), } ); } if ( this.api.isApi ) { this.#cache = new Cache( this ); } // configure http server this.#configureHttpServer(); return result( 200 ); } async _afterInit ( getSchema ) { var res; // load schema api objects res = await this.api.schema.loadApi( this.api ); if ( !res.ok ) return res; return result( 200 ); } async _start () { var res; if ( this.#cache ) { res = await this.#cache.start(); if ( !res.ok ) return res; } return result( 200 ); } async _destroy () { await this.#activeApiCallsCounter.wait(); await this.#cache?.destroy(); } // private #configureHttpServer () { const httpServer = this.api.httpServer, publishRemoteIncomingEvent = this.#publishRemoteIncomingEvent.bind( this ), websocketsConfig = { "maxBackpressure": 0, "onUpgrade": this.#onWebSocketUpgrade.bind( this ), "createConnection": this.#createWebSocketConnection.bind( this, publishRemoteIncomingEvent ), "maxPayloadLength": this.api.maxApiRequestBodySize, "idleTimeout": this.api.idleTimeout, "compress": this.api.config.frontend.compress, "sendPingsAutomatically": this.api.config.frontend.sendPingsAutomatically, }, httpCallback = this.#onHttpRequest.bind( this ), oauthHtmlPath = resolve( "#resources/oauth.html", import.meta.url ), location = this.api.config.frontend.location === "/" ? "" : this.api.config.frontend.location; // websocket if ( location === "" ) { httpServer.ws( "/", websocketsConfig ); } else { httpServer.ws( location, websocketsConfig ); httpServer.ws( `${ location }/`, websocketsConfig ); } // options if ( env.isDevelopment ) { httpServer.options( `${ location }/*`, req => { req.end( { "status": 204, "headers": { "access-control-allow-origin": "*", "access-control-allow-methods": "*", "access-control-allow-headers": "*, Authorization", "access-control-expose-headers": "*, Authorization", "access-control-max-age": 86_400, // cache for 24 hours }, } ); } ); } // http post httpServer.post( `${ location }/*`, req => { // get method id const methodId = req.path.slice( location.length ); httpCallback( req, methodId ); } ); // http get httpServer.get( `${ location }/*`, req => { // get method id const methodId = req.path.slice( location.length ); httpCallback( req, methodId ); } ); // oauth.html httpServer.get( `${ location }/oauth.html`, req => { req.end( { "headers": { "cache-control": "public, max-age=1", }, "body": new File( { "path": oauthHtmlPath } ), } ); } ); } async #onWebSocketUpgrade ( req ) { // close new connections if frontend is destroying if ( this.isDestroying ) { req.close( -32_816 ); return; } const secWebSocketProtocol = req.headers.get( "sec-websocket-protocol" ); const [ protocol, token ] = secWebSocketProtocol?.split( /\s*,\s*/ ) || []; const ctx = await this.#authenticate( token, { "hostname": req.headers.get( "host" ), "userAgent": req.headers.get( "user-agent" ), "remoteAddress": req.remoteAddress, } ); if ( req.isAborted ) return; // backend is down if ( !ctx ) return req.end( -32_814 ); // defined connection locale var locale = req.url.searchParams.get( "locale" ); if ( !this.app.locales.has( locale ) ) locale = null; req.upgrade( { "data": { ctx, locale, }, protocol, } ); } #createWebSocketConnection ( publishRemoteIncomingEvent, server, ws, options ) { const connection = new WebSocketConnection( { server, ws, options, "incomingEvents": this.#incomingEvents, "outgoingEvents": this.#outgoingEvents, "publishRemoteIncomingEvent": publishRemoteIncomingEvent, } ); return connection; } async #onHttpRequest ( req, methodId ) { var res, token, voidCall, id, args; try { voidCall = req.headers.get( "x-api-void-call" ) === "true"; // get token { token = req.headers.get( "authorization" ); // prepare token if ( token ) token = token.trim().replace( /^bearer\s+/i, "" ); } // get if ( req.method === "get" ) { args = []; } // post else if ( req.method === "post" ) { // multipart/form-data if ( req.headers.contentType?.type === "multipart/form-data" ) { // read form data const fields = await req.formData.getFields( { "maxBufferLength": this.api.maxApiRequestBodySize, "maxFileSize": this.api.schema.maxUploadFileSize, } ); try { const params = fields.params.value; delete fields.params; args = JSON.parse( params, ( key, value ) => { if ( fields[ value ] ) { return fields[ value ].value; } else { return value; } } ); } catch { // unable to decode RPC message body throw result( -32_807 ); } } // application/json else { const body = await req.buffer( { "maxLength": this.api.maxApiRequestBodySize } ).catch( e => { // http request aborted throw result( -32_807 ); } ); args = this.#decodeArguments( body, req.headers.contentType ); // jsonrpc call if ( typeof args === "object" && args.jsonrpc === "2.0" ) { methodId = args.method; id = args.id; args = args.params; if ( !id ) voidCall = true; } } // prepare args if ( !Array.isArray( args ) ) { args = args === undefined ? [] : [ args ]; } } // invalid HTTP method else { throw result( -32_804 ); } // authenticate const ctx = await this.#authenticate( token, { "signal": req.abortSignal, "hostname": req.headers.get( "host" ), "userAgent": req.headers.get( "user-agent" ), "remoteAddress": req.remoteAddress, } ); // backend is down if ( !ctx ) throw result( -32_814 ); // update last activity timestamp ctx.updateLastActivity(); // publish if ( methodId === "/publish" ) { this.#publishRemoteIncomingEvent( ctx, args ); res = result( 200 ); } // get schema else if ( methodId === "/schema" ) { res = result( 200, this.api.schema ); } // rpc else { // void api call if ( voidCall ) { ctx.voidCall( methodId, ...args ); res = result( 200 ); } else { res = await ctx.call( methodId, ...args ); } } } catch ( e ) { res = result.catch( e ); } if ( req.isAborted ) return; var locale = req.url.searchParams.get( "locale" ); if ( !this.app.locales.has( locale ) ) locale = null; var body; if ( locale ) { body = JSON.stringify( new JsonContainer( res.toJsonRpc( id ), { "translation": { "localeDomain": locale, }, } ) ); } else { body = JSON.stringify( res.toJsonRpc( id ) ); } // write response req.end( { "status": res.status, "headers": { "content-type": "application/json", ...( env.isDevelopment ? { "access-control-allow-origin": "*" } : {} ), }, body, "compress": this.api.config.frontend.compress, } ); } #decodeArguments ( buffer, contentType ) { // invalid content type if ( contentType && contentType.type !== "application/json" ) throw result( -32_803 ); if ( !buffer.length ) return []; try { return JSON.parse( buffer ); } catch { // unable to decode RPC message body throw result( -32_807 ); } } #publishRemoteIncomingEvent ( ctx, args ) { // context is deleted or disabled if ( ctx.isDeleted || !ctx.isEnabled ) return; if ( !args[ 0 ] ) return; const name = args.shift(); if ( WebSocketConnection.localEvents.has( name ) ) return; this.#incomingEvents.emit( name, ctx, ...args ); } async #authenticate ( token, { signal, hostname, userAgent, remoteAddress } = {} ) { var ctx; // rpc or no token if ( this.api.isRpc || !token ) { return new Context( this.api, { signal, hostname, userAgent, remoteAddress, } ); } // telegram web app token else if ( token.startsWith( "telegram%3A" ) ) { try { token = JSON.parse( decodeURIComponent( token ).slice( 9 ) ); } catch { return; } const bot = this.app.telegram?.bots.getBotById( token.telegram_bot_id ); // telegram bot not found if ( !bot ) return; const telegramBotUser = await bot.authenticateWebApp( token.telegram_webapp_init_data ); // auth error if ( !telegramBotUser ) return; ctx = new Context( this.api, { telegramBotUser, signal, hostname, userAgent, remoteAddress, } ); } // api / session token else { token = Token.new( this.app, token ); var internalToken; // api token if ( token.isApiToken ) { internalToken = this.api.tokens.cache.getCachedTokenById( token.id ) ?? ( await this.api.tokens.cache.getTokenById( token.id ) ); } // session token else if ( token.isSessionToken ) { internalToken = this.api.sessions.cache.getCachedSessionById( token.id ) ?? ( await this.api.sessions.cache.getSessionById( token.id ) ); } // backend error if ( internalToken === false ) { return; } // token not found or invalid else if ( !internalToken || !( await internalToken.verify( token ) ) ) { ctx = new Context( this.api, { "isDeleted": true, signal, hostname, userAgent, remoteAddress, } ); } // token is ok else { ctx = new Context( this.api, { "token": internalToken, signal, hostname, userAgent, remoteAddress, } ); } } if ( !ctx ) return; // unable to update ctx if ( !( await ctx.update() ) ) return; return ctx; } }