diff --git a/bun.lockb b/bun.lockb index c8b41b1..cfde67b 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/projects/backend/package.json b/projects/backend/package.json index 08a9216..bd83df5 100644 --- a/projects/backend/package.json +++ b/projects/backend/package.json @@ -7,12 +7,16 @@ "start": "bun run src/index.ts" }, "dependencies": { + "@bogeychan/elysia-etag": "^0.0.6", "@elysiajs/cors": "^1.1.1", + "@elysiajs/swagger": "^1.1.3", "@ssr/common": "workspace:common", "@tqman/nice-logger": "^1.0.1", "elysia": "latest", "elysia-autoroutes": "^0.5.0", - "elysia-decorators": "^1.0.2" + "elysia-decorators": "^1.0.2", + "elysia-helmet": "^2.0.0", + "elysia-rate-limit": "^4.1.0" }, "devDependencies": { "bun-types": "latest" diff --git a/projects/backend/src/common/http-codes.ts b/projects/backend/src/common/http-codes.ts new file mode 100644 index 0000000..640280e --- /dev/null +++ b/projects/backend/src/common/http-codes.ts @@ -0,0 +1,75 @@ +export const HttpCode = { + // 1xx Informational + CONTINUE: { code: 100, message: "Continue" }, + SWITCHING_PROTOCOLS: { code: 101, message: "Switching Protocols" }, + PROCESSING: { code: 102, message: "Processing" }, + EARLY_HINTS: { code: 103, message: "Early Hints" }, + + // 2xx Success + OK: { code: 200, message: "OK" }, + CREATED: { code: 201, message: "Created" }, + ACCEPTED: { code: 202, message: "Accepted" }, + NON_AUTHORITATIVE_INFORMATION: { code: 203, message: "Non-Authoritative Information" }, + NO_CONTENT: { code: 204, message: "No Content" }, + RESET_CONTENT: { code: 205, message: "Reset Content" }, + PARTIAL_CONTENT: { code: 206, message: "Partial Content" }, + MULTI_STATUS: { code: 207, message: "Multi-Status" }, + ALREADY_REPORTED: { code: 208, message: "Already Reported" }, + IM_USED: { code: 226, message: "IM Used" }, + + // 3xx Redirection + MULTIPLE_CHOICES: { code: 300, message: "Multiple Choices" }, + MOVED_PERMANENTLY: { code: 301, message: "Moved Permanently" }, + FOUND: { code: 302, message: "Found" }, + SEE_OTHER: { code: 303, message: "See Other" }, + NOT_MODIFIED: { code: 304, message: "Not Modified" }, + USE_PROXY: { code: 305, message: "Use Proxy" }, + TEMPORARY_REDIRECT: { code: 307, message: "Temporary Redirect" }, + PERMANENT_REDIRECT: { code: 308, message: "Permanent Redirect" }, + + // 4xx Client Errors + BAD_REQUEST: { code: 400, message: "Bad Request" }, + UNAUTHORIZED: { code: 401, message: "Unauthorized" }, + PAYMENT_REQUIRED: { code: 402, message: "Payment Required" }, + FORBIDDEN: { code: 403, message: "Forbidden" }, + NOT_FOUND: { code: 404, message: "Not Found" }, + METHOD_NOT_ALLOWED: { code: 405, message: "Method Not Allowed" }, + NOT_ACCEPTABLE: { code: 406, message: "Not Acceptable" }, + PROXY_AUTHENTICATION_REQUIRED: { code: 407, message: "Proxy Authentication Required" }, + REQUEST_TIMEOUT: { code: 408, message: "Request Timeout" }, + CONFLICT: { code: 409, message: "Conflict" }, + GONE: { code: 410, message: "Gone" }, + LENGTH_REQUIRED: { code: 411, message: "Length Required" }, + PRECONDITION_FAILED: { code: 412, message: "Precondition Failed" }, + PAYLOAD_TOO_LARGE: { code: 413, message: "Payload Too Large" }, + URI_TOO_LONG: { code: 414, message: "URI Too Long" }, + UNSUPPORTED_MEDIA_TYPE: { code: 415, message: "Unsupported Media Type" }, + RANGE_NOT_SATISFIABLE: { code: 416, message: "Range Not Satisfiable" }, + EXPECTATION_FAILED: { code: 417, message: "Expectation Failed" }, + IM_A_TEAPOT: { code: 418, message: "I'm a teapot" }, + MISDIRECTED_REQUEST: { code: 421, message: "Misdirected Request" }, + UNPROCESSABLE_ENTITY: { code: 422, message: "Unprocessable Entity" }, + LOCKED: { code: 423, message: "Locked" }, + FAILED_DEPENDENCY: { code: 424, message: "Failed Dependency" }, + TOO_EARLY: { code: 425, message: "Too Early" }, + UPGRADE_REQUIRED: { code: 426, message: "Upgrade Required" }, + PRECONDITION_REQUIRED: { code: 428, message: "Precondition Required" }, + TOO_MANY_REQUESTS: { code: 429, message: "Too Many Requests" }, + REQUEST_HEADER_FIELDS_TOO_LARGE: { code: 431, message: "Request Header Fields Too Large" }, + UNAVAILABLE_FOR_LEGAL_REASONS: { code: 451, message: "Unavailable For Legal Reasons" }, + + // 5xx Server Errors + INTERNAL_SERVER_ERROR: { code: 500, message: "Internal Server Error" }, + NOT_IMPLEMENTED: { code: 501, message: "Not Implemented" }, + BAD_GATEWAY: { code: 502, message: "Bad Gateway" }, + SERVICE_UNAVAILABLE: { code: 503, message: "Service Unavailable" }, + GATEWAY_TIMEOUT: { code: 504, message: "Gateway Timeout" }, + HTTP_VERSION_NOT_SUPPORTED: { code: 505, message: "HTTP Version Not Supported" }, + VARIANT_ALSO_NEGOTIATES: { code: 506, message: "Variant Also Negotiates" }, + INSUFFICIENT_STORAGE: { code: 507, message: "Insufficient Storage" }, + LOOP_DETECTED: { code: 508, message: "Loop Detected" }, + NOT_EXTENDED: { code: 510, message: "Not Extended" }, + NETWORK_AUTHENTICATION_REQUIRED: { code: 511, message: "Network Authentication Required" }, +} as const; + +export type HttpCode = typeof HttpCode[keyof typeof HttpCode]; diff --git a/projects/backend/src/controller/app.ts b/projects/backend/src/controller/app.controller.ts similarity index 90% rename from projects/backend/src/controller/app.ts rename to projects/backend/src/controller/app.controller.ts index 429f0fd..b646126 100644 --- a/projects/backend/src/controller/app.ts +++ b/projects/backend/src/controller/app.controller.ts @@ -1,9 +1,9 @@ import { Controller, Get } from "elysia-decorators"; import { getAppVersion } from "../common/app-utils"; -@Controller("/") +@Controller() export default class AppController { - @Get() + @Get("/") public index() { return { app: "backend", diff --git a/projects/backend/src/error/rate-limit-error.ts b/projects/backend/src/error/rate-limit-error.ts new file mode 100644 index 0000000..50c944c --- /dev/null +++ b/projects/backend/src/error/rate-limit-error.ts @@ -0,0 +1,11 @@ +import { HttpCode } from "../common/http-codes"; + +export class RateLimitError extends Error { + constructor( + public message: string = 'rate-limited', + public detail: string = '', + public status: number = HttpCode.TOO_MANY_REQUESTS.code + ) { + super(message) + } +} \ No newline at end of file diff --git a/projects/backend/src/index.ts b/projects/backend/src/index.ts index abdcba8..ee7362f 100644 --- a/projects/backend/src/index.ts +++ b/projects/backend/src/index.ts @@ -2,7 +2,12 @@ import { Elysia } from "elysia"; import cors from "@elysiajs/cors"; import { decorators } from "elysia-decorators"; import { logger } from "@tqman/nice-logger"; -import AppController from "./controller/app"; +import { swagger } from '@elysiajs/swagger' +import { rateLimit } from 'elysia-rate-limit' +import { RateLimitError } from "./error/rate-limit-error"; +import { helmet } from 'elysia-helmet'; +import { etag } from '@bogeychan/elysia-etag' +import AppController from "./controller/app.controller"; const app = new Elysia(); @@ -23,6 +28,11 @@ app.onError({ as: "global" }, ({ code, error }) => { }; }); +/** + * Enable E-Tags + */ +app.use(etag()); + /** * Enable CORS */ @@ -37,6 +47,30 @@ app.use( }) ); +/** + * Rate limit (100 requests per minute) + */ +app.use(rateLimit({ + scoping: "global", + duration: 60 * 1000, + max: 100, + skip: (request) => { + let [ _, path ] = request.url.split("/"); // Get the url parts + path === "" || path === undefined && (path = "/"); // If we're on /, the path is undefined, so we set it to / + return path === "/"; // ignore all requests to / + }, + errorResponse: new RateLimitError("Too many requests, please try again later"), +})) + +/** + * Security settings + */ +app.use(helmet({ + hsts: false, // Disable HSTS + contentSecurityPolicy: false, // Disable CSP + dnsPrefetchControl: true, // Enable DNS prefetch +})) + /** * Controllers */ @@ -46,6 +80,11 @@ app.use( }) ); +/** + * Swagger Documentation + */ +app.use(swagger()); + app.onStart(() => { console.log("Listening on port http://localhost:8080"); });