main
ts 78 lines 3.63 KB
Raw
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 }