1 |
/** |
2 |
* This file is part of SudoBot. |
3 |
* |
4 |
* Copyright (C) 2021-2023 OSN Developers. |
5 |
* |
6 |
* SudoBot is free software; you can redistribute it and/or modify it |
7 |
* under the terms of the GNU Affero General Public License as published by |
8 |
* the Free Software Foundation, either version 3 of the License, or |
9 |
* (at your option) any later version. |
10 |
* |
11 |
* SudoBot is distributed in the hope that it will be useful, but |
12 |
* WITHOUT ANY WARRANTY; without even the implied warranty of |
13 |
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
14 |
* GNU Affero General Public License for more details. |
15 |
* |
16 |
* You should have received a copy of the GNU Affero General Public License |
17 |
* along with SudoBot. If not, see <https://www.gnu.org/licenses/>. |
18 |
*/ |
19 |
|
20 |
import cors from "cors"; |
21 |
import express, { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from "express"; |
22 |
import rateLimit from "express-rate-limit"; |
23 |
import fs from "fs/promises"; |
24 |
import { Server as HttpServer } from "http"; |
25 |
import { join, resolve } from "path"; |
26 |
import Client from "../core/Client"; |
27 |
import { RouteMetadata } from "../types/RouteMetadata"; |
28 |
import { log, logError, logInfo, logWarn } from "../utils/logger"; |
29 |
import Controller from "./Controller"; |
30 |
import Response from "./Response"; |
31 |
|
32 |
export default class Server { |
33 |
protected expressApp = express(); |
34 |
public readonly port = process.env.PORT ?? 4000; |
35 |
protected controllersDirectory = resolve(__dirname, "controllers"); |
36 |
expressServer?: HttpServer; |
37 |
|
38 |
constructor(protected client: Client) {} |
39 |
|
40 |
async onReady() { |
41 |
if (this.client.configManager.systemConfig.api.enabled) { |
42 |
await this.boot(); |
43 |
} |
44 |
} |
45 |
|
46 |
async boot() { |
47 |
const router = express.Router(); |
48 |
await this.loadControllers(undefined, router); |
49 |
|
50 |
this.expressApp.use((err: unknown, req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { |
51 |
if (err instanceof SyntaxError && "status" in err && err.status === 400 && "body" in err) { |
52 |
res.status(400).json({ |
53 |
error: "Invalid JSON payload" |
54 |
}); |
55 |
|
56 |
return; |
57 |
} |
58 |
}); |
59 |
|
60 |
this.expressApp.use(cors()); |
61 |
|
62 |
const limiter = rateLimit({ |
63 |
windowMs: 30 * 1000, |
64 |
max: 28, |
65 |
standardHeaders: true, |
66 |
legacyHeaders: false |
67 |
}); |
68 |
|
69 |
const configLimiter = rateLimit({ |
70 |
windowMs: 10 * 1000, |
71 |
max: 7, |
72 |
standardHeaders: true, |
73 |
legacyHeaders: false |
74 |
}); |
75 |
|
76 |
if (this.client.configManager.systemConfig.trust_proxies !== undefined) { |
77 |
logInfo("Set express trust proxy option value to ", this.client.configManager.systemConfig.trust_proxies); |
78 |
this.expressApp.set("trust proxy", this.client.configManager.systemConfig.trust_proxies); |
79 |
} |
80 |
|
81 |
this.expressApp.use(limiter); |
82 |
this.expressApp.use("/config", configLimiter); |
83 |
this.expressApp.use(express.json()); |
84 |
this.expressApp.use("/", router); |
85 |
} |
86 |
|
87 |
async loadControllers(directory = this.controllersDirectory, router: express.Router) { |
88 |
const files = await fs.readdir(directory); |
89 |
|
90 |
for (const file of files) { |
91 |
const filePath = join(directory, file); |
92 |
const isDirectory = (await fs.lstat(filePath)).isDirectory(); |
93 |
|
94 |
if (isDirectory) { |
95 |
await this.loadControllers(filePath, router); |
96 |
continue; |
97 |
} |
98 |
|
99 |
if ((!file.endsWith(".ts") && !file.endsWith(".js")) || file.endsWith(".d.ts")) { |
100 |
continue; |
101 |
} |
102 |
|
103 |
const { default: ControllerClass } = await import(filePath); |
104 |
const controller: Controller = new ControllerClass(this.client); |
105 |
|
106 |
const metadata: Record<string, RouteMetadata> | undefined = Reflect.getMetadata( |
107 |
"action_methods", |
108 |
ControllerClass.prototype |
109 |
); |
110 |
|
111 |
const authMiddleware = Reflect.getMetadata("auth_middleware", ControllerClass.prototype) ?? {}; |
112 |
const gacMiddleware = Reflect.getMetadata("gac_middleware", ControllerClass.prototype) ?? {}; |
113 |
const aacMiddleware = Reflect.getMetadata("aac_middleware", ControllerClass.prototype) ?? {}; |
114 |
const validatonMiddleware = Reflect.getMetadata("validation_middleware", ControllerClass.prototype) ?? {}; |
115 |
|
116 |
if (metadata) { |
117 |
for (const methodName in metadata) { |
118 |
if (!metadata[methodName]) { |
119 |
continue; |
120 |
} |
121 |
|
122 |
for (const method in metadata[methodName]!) { |
123 |
const data = metadata[methodName][method as keyof RouteMetadata]!; |
124 |
|
125 |
if (!data) { |
126 |
continue; |
127 |
} |
128 |
|
129 |
const { middleware, handler, path } = data; |
130 |
|
131 |
if (!handler) { |
132 |
continue; |
133 |
} |
134 |
|
135 |
if (!path) { |
136 |
logError(`[Server] No path specified at function ${handler.name} in controller ${file}. Skipping.`); |
137 |
continue; |
138 |
} |
139 |
|
140 |
if (method && !["GET", "POST", "HEAD", "PUT", "PATCH", "DELETE"].includes(method)) { |
141 |
logError( |
142 |
`[Server] Invalid method '${method}' specified at function ${handler.name} in controller ${file}. Skipping.` |
143 |
); |
144 |
continue; |
145 |
} |
146 |
|
147 |
log(`Added handler for ${method?.toUpperCase() ?? "GET"} ${path}`); |
148 |
|
149 |
const finalMiddlewareArray = [ |
150 |
...(authMiddleware[methodName] ? [authMiddleware[methodName]] : []), |
151 |
...(gacMiddleware[methodName] ? [gacMiddleware[methodName]] : []), |
152 |
...(aacMiddleware[methodName] ? [aacMiddleware[methodName]] : []), |
153 |
...(validatonMiddleware[methodName] ? [validatonMiddleware[methodName]] : []), |
154 |
...(middleware ?? []) |
155 |
]; |
156 |
|
157 |
(router[(method?.toLowerCase() ?? "get") as keyof typeof router] as Function)( |
158 |
path, |
159 |
...(finalMiddlewareArray?.map( |
160 |
fn => (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => |
161 |
fn(this.client, req, res, next) |
162 |
) ?? []), |
163 |
async (req: ExpressRequest, res: ExpressResponse) => { |
164 |
const userResponse = await handler.bind(controller)(req, res); |
165 |
|
166 |
if (!res.headersSent) { |
167 |
if (userResponse instanceof Response) { |
168 |
userResponse.send(res); |
169 |
} else if (userResponse && typeof userResponse === "object") { |
170 |
res.json(userResponse); |
171 |
} else if (typeof userResponse === "string") { |
172 |
res.send(userResponse); |
173 |
} else if (typeof userResponse === "number") { |
174 |
res.send(userResponse.toString()); |
175 |
} else { |
176 |
logWarn("Invalid value was returned from the controller. Not sending a response."); |
177 |
} |
178 |
} |
179 |
} |
180 |
); |
181 |
} |
182 |
} |
183 |
} |
184 |
} |
185 |
} |
186 |
|
187 |
async start() { |
188 |
this.expressServer = this.expressApp.listen(this.port, () => logInfo(`API server is listening at port ${this.port}`)); |
189 |
} |
190 |
} |