import Setting from './models/Setting'; import Logger from '@joplin/utils/Logger'; import Api, { RequestFile } from './services/rest/Api'; import ApiResponse from './services/rest/ApiResponse'; import { ltrimSlashes } from './path-utils'; import * as urlParser from 'url'; const { randomClipperPort, startPort } = require('./randomClipperPort'); const enableServerDestroy = require('server-destroy'); const multiparty = require('multiparty'); export enum StartState { Idle = 'idle', Starting = 'starting', Started = 'started', } type ClipperDispatch = (action: { type: string; [key: string]: unknown })=> void; // The pairing endpoints hand out the permanent API token without requiring one, // so they must not be callable from a website. The extension calls them from an // extension origin; a web page carries an http(s) origin. No-Origin requests // (native clients) aren't a browser CSRF vector, so they're allowed. // // Stored without leading slashes and compared via ltrimSlashes, matching how the // router normalises paths - otherwise //auth would skip this check but still // reach the auth handler. const pairingPaths = ['auth', 'auth/check']; export const isPairingPath = (pathname: string) => { return pairingPaths.includes(ltrimSlashes(pathname || '')); }; export const isAllowedPairingOrigin = (origin: string) => { if (!origin) return true; try { const protocol = new URL(origin).protocol; return protocol !== 'http:' && protocol !== 'https:'; } catch (error) { return false; } }; export default class ClipperServer { private logger_: Logger; private startState_: StartState = StartState.Idle; // .destroy is added by the `server-destroy` package private server_: (import('http').Server & { destroy?: ()=> void }) | null = null; private port_: number = null; private api_: Api = null; private dispatch_: ClipperDispatch; private enabled_ = true; private static instance_: ClipperServer = null; public constructor() { this.logger_ = new Logger(); } public static instance() { if (this.instance_) return this.instance_; this.instance_ = new ClipperServer(); return this.instance_; } public get api(): Api { return this.api_; } public enabled() { return this.enabled_; } public setEnabled(v: boolean) { this.enabled_ = v; if (!this.enabled_ && this.isRunning()) { void this.stop(); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- actionApi is the optional handler bag for unknown API routes (e.g. from desktop's command service); its shape varies per consumer public initialize(actionApi: any = null) { this.api_ = new Api(() => { return Setting.value('api.token'); }, (action: { type: string; [key: string]: unknown }) => { this.dispatch(action); }, actionApi); } public setLogger(l: Logger) { this.logger_ = l; } public logger() { return this.logger_; } public setDispatch(d: ClipperDispatch) { this.dispatch_ = d; } public dispatch(action: { type: string; [key: string]: unknown }) { if (!this.dispatch_) throw new Error('dispatch not set!'); this.dispatch_(action); } public setStartState(v: StartState) { if (this.startState_ === v) return; this.startState_ = v; this.dispatch({ type: 'CLIPPER_SERVER_SET', startState: v, }); } public setPort(v: number) { if (this.port_ === v) return; this.port_ = v; this.dispatch({ type: 'CLIPPER_SERVER_SET', port: v, }); } public async findAvailablePort(): Promise { const tcpPortUsed = require('tcp-port-used'); let state = null; for (let i = 0; i < 10000; i++) { state = randomClipperPort(state, Setting.value('env')); const inUse = await tcpPortUsed.check(state.port); if (!inUse) return state.port; } throw new Error('All potential ports are in use or not available.'); } public async isRunning() { const tcpPortUsed = require('tcp-port-used'); const port = Setting.value('api.port') ? Setting.value('api.port') : startPort(Setting.value('env')); const inUse = await tcpPortUsed.check(port); return inUse ? port : 0; } public async start() { if (!this.enabled()) throw new Error('Cannot start clipper server because it is disabled'); this.setPort(null); this.setStartState(StartState.Starting); const settingPort = Setting.value('api.port'); try { const p = settingPort ? settingPort : await this.findAvailablePort(); this.setPort(p); } catch (error) { this.setStartState(StartState.Idle); this.logger().error(error); return null; } this.server_ = require('http').createServer(); this.server_.on('request', async (request: import('http').IncomingMessage, response: import('http').ServerResponse) => { const writeCorsHeaders = (code: number, contentType = 'application/json', additionalHeaders: Record = null) => { const headers = { 'Content-Type': contentType, 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, PUT, PATCH, DELETE', 'Access-Control-Allow-Headers': 'X-Requested-With,content-type', ...(additionalHeaders ? additionalHeaders : {}), }; response.writeHead(code, headers); }; const writeResponseJson = (code: number, object: unknown) => { writeCorsHeaders(code, 'application/json'); response.write(JSON.stringify(object)); response.end(); }; const writeResponseText = (code: number, text: string) => { writeCorsHeaders(code, 'text/plain'); response.write(text); response.end(); }; const writeResponseInstance = (code: number, instance: ApiResponse) => { if (instance.type === 'attachment') { const filename = instance.attachmentFilename ? instance.attachmentFilename : 'file'; const body = instance.body as { length: number }; writeCorsHeaders(code, instance.contentType ? instance.contentType : 'application/octet-stream', { 'Content-disposition': `attachment; filename=${filename}`, 'Content-Length': body.length, }); response.end(instance.body); } else if (typeof instance.body === 'string' || instance.body === null || instance.body === undefined) { writeCorsHeaders(code, instance.contentType ? instance.contentType : 'text/plain'); response.end(instance.body ? instance.body : ''); } else { writeCorsHeaders(code, instance.contentType ? instance.contentType : 'application/json'); response.end(JSON.stringify(instance.body)); } }; const writeResponse = (code: number, response: unknown) => { if (response instanceof ApiResponse) { writeResponseInstance(response.status ? response.status : code, response); } else if (typeof response === 'string') { writeResponseText(code, response); } else if (response === null || response === undefined) { writeResponseText(code, ''); } else { writeResponseJson(code, response); } }; this.logger().info(`Request: ${request.method} ${request.url}`); const url = urlParser.parse(request.url, true); if (request.method !== 'OPTIONS' && isPairingPath(url.pathname) && !isAllowedPairingOrigin(request.headers.origin)) { this.logger().warn(`Rejected pairing request from disallowed origin: ${request.headers.origin}`); writeResponse(403, { error: 'This endpoint cannot be called from a web page' }); return; } const execRequest = async (request: import('http').IncomingMessage, body = '', files: RequestFile[] = []) => { try { const response = await this.api_.route(request.method as Parameters[0], url.pathname, url.query, body, files); writeResponse(200, response); } catch (error) { this.logger().error(error); const httpCode = error.httpCode ? error.httpCode : 500; const msg = []; if (httpCode >= 500) msg.push('Internal Server Error'); if (error.message) msg.push(error.message); if (error.stack) msg.push(`\n\n${error.stack}`); writeResponse(httpCode, { error: msg.join(': ') }); } }; const contentType = request.headers['content-type'] ? request.headers['content-type'] : ''; if (request.method === 'OPTIONS') { writeCorsHeaders(200); response.end(); } else { if (contentType.indexOf('multipart/form-data') === 0) { const form = new multiparty.Form(); form.parse(request, (error: (Error & { httpCode?: number }) | null, fields: Record, files: Record) => { if (error) { writeResponse(error.httpCode ? error.httpCode : 500, error.message); return; } else { void execRequest(request, fields && fields.props && fields.props.length ? fields.props[0] : '', files && files.data ? files.data : []); } }); } else { if (request.method === 'POST' || request.method === 'PUT') { let body = ''; request.on('data', (data: Buffer | string) => { body += data; }); request.on('end', async () => { void execRequest(request, body); }); } else { void execRequest(request); } } } }); enableServerDestroy(this.server_); this.logger().info(`Starting Clipper server on port ${this.port_}`); this.server_.listen(this.port_, '127.0.0.1'); this.setStartState(StartState.Started); // We return an empty promise that never resolves so that it's possible to `await` the server indefinitely. // This is used only in command-server.js return new Promise(() => {}); } public async stop() { if (this.server_) { this.server_.destroy(); this.server_ = null; } this.setStartState(StartState.Idle); this.setPort(null); } }