| 1 | // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt |
| 2 | |
| 3 | import Koa from 'koa' |
| 4 | import createSSE from './sse' |
| 5 | import { Readable } from 'stream' |
| 6 | import { asyncGeneratorToReadable, CFG, Promisable } from './misc' |
| 7 | import { HTTP_BAD_REQUEST, HTTP_FOOL, PLUGIN_CUSTOM_REST_PREFIX } from './const' |
| 8 | import { defineConfig } from './config' |
| 9 | import { firstPlugin } from './plugins' |
| 10 | |
| 11 | export class ApiError extends Error { |
| 12 | constructor(public status:number, message?:string | Error | object) { |
| 13 | super(typeof message === 'string' ? message : message && message instanceof Error ? message.message : JSON.stringify(message)) |
| 14 | } |
| 15 | } |
| 16 | type ApiHandlerResult = Record<string,any> | ApiError | Readable | AsyncGenerator<any> | null |
| 17 | // allow defining extra parameters that can be used when an api to invoke another (like copy_files) |
| 18 | export type ApiHandler = (params:any, ctx:Koa.Context, ...ignore: unknown[]) => Promisable<ApiHandlerResult> |
| 19 | export type ApiHandlers = Record<string, ApiHandler> |
| 20 | |
| 21 | const logApi = defineConfig(CFG.log_api, true) |
| 22 | |
| 23 | export function apiMiddleware(apis: ApiHandlers) : Koa.Middleware { |
| 24 | return async (ctx) => { |
| 25 | if (!logApi.get()) |
| 26 | ctx.state.dontLog = true |
| 27 | const isPost = ctx.state.params |
| 28 | const params = isPost ? ctx.state.params || {} : ctx.query |
| 29 | const apiName = ctx.path |
| 30 | console.debug('API', ctx.method, apiName, { ...params }) |
| 31 | const csrfSafe = ctx.get('x-hfs-anti-csrf') // automatic browser actions won't carry this header |
| 32 | || apiName.startsWith('get_') // "get_" apis are safe because they make no change |
| 33 | || /^(curl|wget|python|go-|java|axios|postman|httpie|insomnia|bruno)/i.test(ctx.get('user-agent') || '') // only browser are subject to CSRF |
| 34 | if (!csrfSafe) |
| 35 | return send(HTTP_FOOL, "missing header x-hfs-anti-csrf:1") |
| 36 | const customApiRest = apiName.startsWith(PLUGIN_CUSTOM_REST_PREFIX) && apiName.slice(PLUGIN_CUSTOM_REST_PREFIX.length) |
| 37 | const apiFun = customApiRest && firstPlugin(pl => pl.getData().customRest?.[customApiRest]) |
| 38 | || apis.hasOwnProperty(apiName) && apis[apiName]! |
| 39 | if (!apiFun) |
| 40 | return send(HTTP_BAD_REQUEST, 'invalid api') |
| 41 | // we don't rely on SameSite cookie option because it's https-only |
| 42 | let res |
| 43 | try { |
| 44 | res = await apiFun(params, ctx) |
| 45 | if (res === null) return |
| 46 | } |
| 47 | catch(e) { |
| 48 | if (typeof e === 'string') // message meant to be transmitted |
| 49 | return send(HTTP_BAD_REQUEST, e) |
| 50 | if (typeof e === 'number') |
| 51 | e = new ApiError(e) |
| 52 | res = e |
| 53 | } |
| 54 | if (isAsyncGenerator(res)) |
| 55 | res = asyncGeneratorToReadable(res) |
| 56 | if (res instanceof Readable) { // Readable, we'll go SSE-mode |
| 57 | res.pipe(createSSE(ctx)) |
| 58 | const resAsReadable = res // satisfy ts |
| 59 | ctx.req.on('close', () => // by closing the generated stream, creator of the stream will know the request is over without having to access anything else |
| 60 | resAsReadable.destroy()) |
| 61 | return |
| 62 | } |
| 63 | if (res instanceof ApiError) |
| 64 | return send(res.status, res.message) |
| 65 | if (res instanceof Error) // generic error/exception |
| 66 | return send(HTTP_BAD_REQUEST, res.stack || res.message || String(res)) |
| 67 | ctx.body = res |
| 68 | |
| 69 | function send(status: number, body?: string) { |
| 70 | ctx.body = body |
| 71 | ctx.status = status |
| 72 | } |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | function isAsyncGenerator(x: any): x is AsyncGenerator { |
| 77 | return typeof (x as AsyncGenerator)?.next === 'function' |
| 78 | } |