From 5f0bdb885b1a0c964cd6c445555a22deb3bea24a Mon Sep 17 00:00:00 2001 From: creations Date: Fri, 13 Jun 2025 17:45:27 -0400 Subject: [PATCH] refactor: improve code structure and add better logging - Replace custom logger with @atums/echo library - Restructure imports using # path aliases - Add request performance tracking and better request logging - Improve type definitions and error handling - Add custom file serving capability - Update biome configuration with stricter linting rules - Add comprehensive API documentation (DOCS.md) --- DOCS.md | 246 +++++++++++++++++++++++ biome.json | 25 ++- config/{booru.ts => constants.ts} | 11 +- config/environment.ts | 6 - config/index.ts | 30 +++ logger.json | 40 ++++ package.json | 14 +- src/helpers/logger.ts | 175 ---------------- src/index.ts | 11 +- src/{helpers => lib}/char.ts | 87 +++++--- src/routes/[booru]/autocomplete/[tag].ts | 31 ++- src/routes/[booru]/id/[id].ts | 34 ++-- src/routes/[booru]/random.ts | 35 ++-- src/routes/[booru]/search.ts | 26 ++- src/routes/index.ts | 2 + src/server.ts | 169 +++++++++++----- tsconfig.json | 28 ++- types/booruResponses.d.ts | 26 --- types/booruResponses.ts | 28 +++ types/bun.d.ts | 5 - types/bun.ts | 10 + types/{config.d.ts => config.ts} | 2 + types/logger.d.ts | 9 - types/{routes.d.ts => routes.ts} | 8 +- 24 files changed, 666 insertions(+), 392 deletions(-) create mode 100644 DOCS.md rename config/{booru.ts => constants.ts} (88%) delete mode 100644 config/environment.ts create mode 100644 config/index.ts create mode 100644 logger.json delete mode 100644 src/helpers/logger.ts rename src/{helpers => lib}/char.ts (57%) delete mode 100644 types/booruResponses.d.ts create mode 100644 types/booruResponses.ts delete mode 100644 types/bun.d.ts create mode 100644 types/bun.ts rename types/{config.d.ts => config.ts} (86%) delete mode 100644 types/logger.d.ts rename types/{routes.d.ts => routes.ts} (75%) diff --git a/DOCS.md b/DOCS.md new file mode 100644 index 0000000..c18fb83 --- /dev/null +++ b/DOCS.md @@ -0,0 +1,246 @@ +# Booru API Documentation + +A unified API for accessing multiple booru image boards. + +## Base URL +``` +http://localhost:6600 +``` + +## Supported Boorus + +| Booru | Aliases | Status | +|-------|---------|--------| +| rule34.xxx | `rule34`, `r34`, `rule34xxx` | ✅ Enabled | +| safebooru.org | `safebooru`, `sb`, `s34` | ✅ Enabled | +| tbib.org | `tbib`, `tb`, `tbiborg` | ✅ Enabled | +| hypnohub.net | `hypnohub`, `hh`, `hypnohubnet` | ✅ Enabled | +| xbooru.com | `xbooru`, `xb`, `xboorucom` | ✅ Enabled | +| e621.net | `e621`, `e6`, `e621net` | ✅ Enabled | +| gelbooru.com | `gelbooru`, `gb`, `gelboorucom` | ✅ Enabled | +| realbooru.com | `realbooru`, `rb`, `real34`, `realb` | ❌ Disabled | + +## Authentication + +### E621 +Required headers for e621 requests: +```http +e621UserAgent: YourApplication/1.0 (by username on e621) +e621Username: your-username +e621ApiKey: your-apikey +``` + +### Gelbooru +Required headers for Gelbooru requests: +```http +gelbooruApiKey: your-apikey +gelbooruUserId: your-user-id +``` + +## Endpoints + +### 1. Search Posts +Search for posts with specific tags. + +```http +POST /{booru}/search +Content-Type: application/json +``` + +**Request Body:** +```json +{ + "tags": ["tag1", "tag2"], + "excludeTags": ["unwanted_tag"], + "page": 0, + "results": 10, + "tag_format": "formatted" +} +``` + +**Parameters:** +- `tags` (string|array): Tags to search for +- `excludeTags` (string|array): Tags to exclude +- `page` (number): Page number (default: 0) +- `results` (number): Number of results (default: 5) +- `tag_format` (string): Format of tags in response (`"formatted"` or `"unformatted"`) + +**Example:** +```bash +curl -X POST "http://localhost:6600/rule34/search" \ + -H "Content-Type: application/json" \ + -d '{ + "tags": ["cat", "cute"], + "results": 5 + }' +``` + +### 2. Random Posts +Get random posts with optional tag filtering. + +```http +POST /{booru}/random +Content-Type: application/json +``` + +**Request Body:** +```json +{ + "tags": ["tag1", "tag2"], + "excludeTags": ["unwanted_tag"], + "results": 5, + "tag_format": "formatted" +} +``` + +**Example:** +```bash +curl -X POST "http://localhost:6600/safebooru/random" \ + -H "Content-Type: application/json" \ + -d '{ + "tags": ["anime"], + "results": 3 + }' +``` + +### 3. Get Post by ID +Retrieve a specific post by its ID. + +```http +GET /{booru}/id/{id}?tag_format=formatted +``` + +**Parameters:** +- `id` (string): Post ID +- `tag_format` (query): Format of tags in response + +**Example:** +```bash +curl "http://localhost:6600/rule34/id/123456?tag_format=formatted" +``` + +### 4. Tag Autocomplete +Get tag suggestions for autocomplete. + +```http +GET /{booru}/autocomplete/{tag} +``` + +**Parameters:** +- `tag` (string): Partial tag name (minimum 3 characters for e621) + +**Example:** +```bash +curl "http://localhost:6600/safebooru/autocomplete/anim" +``` + +### 5. API Info +Get basic API information. + +```http +GET / +``` + +**Example:** +```bash +curl "http://localhost:6600/" +``` + +## Response Format + +### Success Response +```json +{ + "success": true, + "code": 200, + "posts": [ + { + "id": 123456, + "file_url": "https://example.com/image.jpg", + "post_url": "https://example.com/post/123456", + "tags": "tag1 tag2 tag3", + "directory": 1234, + "hash": "abcdef123456" + } + ] +} +``` + +### Error Response +```json +{ + "success": false, + "code": 400, + "error": "Missing booru parameter" +} +``` + +## Error Codes + +| Code | Description | +|------|-------------| +| 400 | Bad Request - Missing or invalid parameters | +| 401 | Unauthorized - Missing authentication headers | +| 403 | Forbidden - Booru is disabled | +| 404 | Not Found - No results found or booru not found | +| 405 | Method Not Allowed - Wrong HTTP method | +| 406 | Not Acceptable - Wrong content type | +| 500 | Internal Server Error | +| 501 | Not Implemented - Feature not supported | + +## Usage Examples + +### Search for anime posts on Safebooru +```bash +curl -X POST "http://localhost:6600/safebooru/search" \ + -H "Content-Type: application/json" \ + -d '{ + "tags": ["anime", "girl"], + "results": 10, + "page": 0 + }' +``` + +### Get random posts from Rule34 +```bash +curl -X POST "http://localhost:6600/rule34/random" \ + -H "Content-Type: application/json" \ + -d '{ + "tags": ["pokemon"], + "excludeTags": ["gore"], + "results": 5 + }' +``` + +### Search E621 with authentication +```bash +curl -X POST "http://localhost:6600/e621/search" \ + -H "Content-Type: application/json" \ + -H "e621UserAgent: MyApp/1.0 (by myusername on e621)" \ + -H "e621Username: myusername" \ + -H "e621ApiKey: myapikey" \ + -d '{ + "tags": ["canine"], + "results": 5 + }' +``` + +### Get tag suggestions +```bash +curl "http://localhost:6600/gelbooru/autocomplete/anim" \ + -H "gelbooruApiKey: your-api-key" \ + -H "gelbooruUserId: your-user-id" +``` + +## Rate Limiting + +This API respects the rate limits of the underlying booru services. Please be mindful of your request frequency to avoid being blocked by the source APIs. + +## Notes + +- All POST requests require `Content-Type: application/json` header +- Some boorus may have different response formats +- E621 requires authentication for all requests +- Gelbooru requires authentication for better rate limits +- Tag formats may vary between boorus +- The `tag_format` parameter allows you to choose between formatted strings or raw objects diff --git a/biome.json b/biome.json index 921a7a5..032f6fa 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": true, - "ignore": [] + "ignore": ["dist"] }, "formatter": { "enabled": true, @@ -17,11 +17,30 @@ "organizeImports": { "enabled": true }, + "css": { + "formatter": { + "indentStyle": "tab", + "lineEnding": "lf" + } + }, "linter": { "enabled": true, "rules": { - "recommended": true - } + "recommended": true, + "correctness": { + "noUnusedImports": "error", + "noUnusedVariables": "error" + }, + "suspicious": { + "noConsole": "error" + }, + "style": { + "useConst": "error", + "noVar": "error", + "useImportType": "error" + } + }, + "ignore": ["types"] }, "javascript": { "formatter": { diff --git a/config/booru.ts b/config/constants.ts similarity index 88% rename from config/booru.ts rename to config/constants.ts index 11198e4..3d9920d 100644 --- a/config/booru.ts +++ b/config/constants.ts @@ -1,12 +1,19 @@ // cSpell:disable +const reqLoggerIgnores = { + ignoredStartsWith: ["/public"], + ignoredPaths: ["/favicon.ico"], +}; + +import type { IBooruConfigMap, IBooruDefaults } from "#types/config"; + const booruDefaults: IBooruDefaults = { search: "index.php?page=dapi&s=post&q=index&json=1", random: "s", id: "index.php?page=dapi&s=post&q=index&json=1&id=", }; -export const booruConfig: IBooruConfigMap = { +const booruConfig: IBooruConfigMap = { "rule34.xxx": { enabled: true, name: "rule34.xxx", @@ -76,3 +83,5 @@ export const booruConfig: IBooruConfigMap = { functions: booruDefaults, }, }; + +export { reqLoggerIgnores, booruConfig, booruDefaults }; diff --git a/config/environment.ts b/config/environment.ts deleted file mode 100644 index da15e14..0000000 --- a/config/environment.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const environment: Environment = { - port: Number.parseInt(process.env.PORT || "6600", 10), - host: process.env.HOST || "0.0.0.0", - development: - process.env.NODE_ENV === "development" || process.argv.includes("--dev"), -}; diff --git a/config/index.ts b/config/index.ts new file mode 100644 index 0000000..8ea9b2d --- /dev/null +++ b/config/index.ts @@ -0,0 +1,30 @@ +import { echo } from "@atums/echo"; + +import type { Environment } from "#types/config"; + +const environment: Environment = { + port: Number.parseInt(process.env.PORT || "8080", 10), + host: process.env.HOST || "0.0.0.0", + development: + process.env.NODE_ENV === "development" || process.argv.includes("--dev"), +}; + +function verifyRequiredVariables(): void { + const requiredVariables = ["HOST", "PORT"]; + + let hasError = false; + + for (const key of requiredVariables) { + const value = process.env[key]; + if (value === undefined || value.trim() === "") { + echo.error(`Missing or empty environment variable: ${key}`); + hasError = true; + } + } + + if (hasError) { + process.exit(1); + } +} + +export { environment, verifyRequiredVariables }; diff --git a/logger.json b/logger.json new file mode 100644 index 0000000..15b2c23 --- /dev/null +++ b/logger.json @@ -0,0 +1,40 @@ +{ + "directory": "logs", + "level": "debug", + "disableFile": false, + + "rotate": true, + "maxFiles": 3, + "fileNameFormat": "yyyy-MM-dd", + + "console": true, + "consoleColor": true, + + "dateFormat": "yyyy-MM-dd HH:mm:ss.SSS", + "timezone": "local", + + "silent": false, + + "pattern": "{color:gray}{pretty-timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{color:blue}{line}{reset}:{color:blue}{column}{color:gray}){reset} {data}", + "levelColor": { + "debug": "blue", + "info": "green", + "warn": "yellow", + "error": "red", + "fatal": "red" + }, + + "customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}", + "customColors": { + "GET": "green", + "POST": "blue", + "PUT": "yellow", + "DELETE": "red", + "PATCH": "cyan", + "HEAD": "magenta", + "OPTIONS": "white", + "TRACE": "gray" + }, + + "prettyPrint": true +} diff --git a/package.json b/package.json index 3f5f4af..986257d 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,8 @@ "name": "booru-api", "module": "src/index.ts", "devDependencies": { - "@biomejs/biome": "^1.9.4", - "@eslint/js": "^9.24.0", - "@types/bun": "^1.2.9", - "globals": "^16.0.0" - }, - "peerDependencies": { - "typescript": "^5.8.3" + "@biomejs/biome": "latest", + "@types/bun": "latest" }, "scripts": { "start": "bun run src/index.ts", @@ -17,5 +12,8 @@ "lint:fix": "bunx biome check --fix", "cleanup": "rm -rf logs node_modules bun.lock" }, - "type": "module" + "type": "module", + "dependencies": { + "@atums/echo": "latest" + } } diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts deleted file mode 100644 index fa39adf..0000000 --- a/src/helpers/logger.ts +++ /dev/null @@ -1,175 +0,0 @@ -import type { Stats } from "node:fs"; -import { - type WriteStream, - createWriteStream, - existsSync, - mkdirSync, - statSync, -} from "node:fs"; -import { EOL } from "node:os"; -import { basename, join } from "node:path"; -import { environment } from "@config/environment"; - -import { timestampToReadable } from "./char"; - -class Logger { - private static instance: Logger; - private static log: string = join(__dirname, "../../logs"); - - public static getInstance(): Logger { - if (!Logger.instance) { - Logger.instance = new Logger(); - } - - return Logger.instance; - } - - private writeToLog(logMessage: string): void { - if (environment.development) return; - - const date: Date = new Date(); - const logDir: string = Logger.log; - const logFile: string = join( - logDir, - `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.log`, - ); - - if (!existsSync(logDir)) { - mkdirSync(logDir, { recursive: true }); - } - - let addSeparator = false; - - if (existsSync(logFile)) { - const fileStats: Stats = statSync(logFile); - if (fileStats.size > 0) { - const lastModified: Date = new Date(fileStats.mtime); - if ( - lastModified.getFullYear() === date.getFullYear() && - lastModified.getMonth() === date.getMonth() && - lastModified.getDate() === date.getDate() && - lastModified.getHours() !== date.getHours() - ) { - addSeparator = true; - } - } - } - - const stream: WriteStream = createWriteStream(logFile, { flags: "a" }); - - if (addSeparator) { - stream.write(`${EOL}${date.toISOString()}${EOL}`); - } - - stream.write(`${logMessage}${EOL}`); - stream.close(); - } - - private extractFileName(stack: string): string { - const stackLines: string[] = stack.split("\n"); - let callerFile = ""; - - for (let i = 2; i < stackLines.length; i++) { - const line: string = stackLines[i].trim(); - if (line && !line.includes("Logger.") && line.includes("(")) { - callerFile = line.split("(")[1]?.split(")")[0] || ""; - break; - } - } - - return basename(callerFile); - } - - private getCallerInfo(stack: unknown): { - filename: string; - timestamp: string; - } { - const filename: string = - typeof stack === "string" ? this.extractFileName(stack) : "unknown"; - - const readableTimestamp: string = timestampToReadable(); - - return { filename, timestamp: readableTimestamp }; - } - - public info(message: string | string[], breakLine = false): void { - const stack: string = new Error().stack || ""; - const { filename, timestamp } = this.getCallerInfo(stack); - - const joinedMessage: string = Array.isArray(message) - ? message.join(" ") - : message; - - const logMessageParts: ILogMessageParts = { - readableTimestamp: { value: timestamp, color: "90" }, - level: { value: "[INFO]", color: "32" }, - filename: { value: `(${filename})`, color: "36" }, - message: { value: joinedMessage, color: "0" }, - }; - - this.writeToLog(`${timestamp} [INFO] (${filename}) ${joinedMessage}`); - this.writeConsoleMessageColored(logMessageParts, breakLine); - } - - public warn(message: string | string[], breakLine = false): void { - const stack: string = new Error().stack || ""; - const { filename, timestamp } = this.getCallerInfo(stack); - - const joinedMessage: string = Array.isArray(message) - ? message.join(" ") - : message; - - const logMessageParts: ILogMessageParts = { - readableTimestamp: { value: timestamp, color: "90" }, - level: { value: "[WARN]", color: "33" }, - filename: { value: `(${filename})`, color: "36" }, - message: { value: joinedMessage, color: "0" }, - }; - - this.writeToLog(`${timestamp} [WARN] (${filename}) ${joinedMessage}`); - this.writeConsoleMessageColored(logMessageParts, breakLine); - } - - public error( - message: string | string[] | Error | Error[], - breakLine = false, - ): void { - const stack: string = new Error().stack || ""; - const { filename, timestamp } = this.getCallerInfo(stack); - - const messages: (string | Error)[] = Array.isArray(message) - ? message - : [message]; - const joinedMessage: string = messages - .map((msg: string | Error): string => - typeof msg === "string" ? msg : msg.message, - ) - .join(" "); - - const logMessageParts: ILogMessageParts = { - readableTimestamp: { value: timestamp, color: "90" }, - level: { value: "[ERROR]", color: "31" }, - filename: { value: `(${filename})`, color: "36" }, - message: { value: joinedMessage, color: "0" }, - }; - - this.writeToLog(`${timestamp} [ERROR] (${filename}) ${joinedMessage}`); - this.writeConsoleMessageColored(logMessageParts, breakLine); - } - - private writeConsoleMessageColored( - logMessageParts: ILogMessageParts, - breakLine = false, - ): void { - const logMessage: string = Object.keys(logMessageParts) - .map((key: string) => { - const part: ILogMessagePart = logMessageParts[key]; - return `\x1b[${part.color}m${part.value}\x1b[0m`; - }) - .join(" "); - console.log(logMessage + (breakLine ? EOL : "")); - } -} - -const logger: Logger = Logger.getInstance(); -export { logger }; diff --git a/src/index.ts b/src/index.ts index decf139..0b0094b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,16 @@ -import { logger } from "@helpers/logger"; - +import { Echo } from "@atums/echo"; import { serverHandler } from "./server"; +export const noFileLog = new Echo({ + disableFile: true, +}); + async function main(): Promise { serverHandler.initialize(); } main().catch((error: Error) => { - logger.error("Error initializing the server:"); - logger.error(error as Error); + noFileLog.error("Error initializing the server:"); + noFileLog.error(error as Error); process.exit(1); }); diff --git a/src/helpers/char.ts b/src/lib/char.ts similarity index 57% rename from src/helpers/char.ts rename to src/lib/char.ts index 0313a37..1f61ba5 100644 --- a/src/helpers/char.ts +++ b/src/lib/char.ts @@ -1,10 +1,13 @@ -/* eslint-disable prettier/prettier */ +import { booruConfig } from "#environment/constants"; -import { booruConfig } from "@config/booru"; +import type { BooruPost } from "#types/booruResponses"; +import type { IBooruConfig, IBooruConfigMap } from "#types/config"; export function timestampToReadable(timestamp?: number): string { const date: Date = - timestamp && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date(); + timestamp && !Number.isNaN(timestamp) + ? new Date(timestamp * 1000) + : new Date(); if (Number.isNaN(date.getTime())) return "Invalid Date"; return date.toISOString().replace("T", " ").replace("Z", ""); } @@ -18,57 +21,63 @@ export function tagsToExpectedFormat( if (!tags) return ""; - const processTag: (tag: string) => string | null = (tag: string) => { - const trimmed: string | null = tag.trim(); - return trimmed ? trimmed : null; + const processTag = (tag: string): string | null => { + const trimmed = tag.trim(); + return trimmed || null; }; if (typeof tags === "string") { return tags .split(/\s+|,/) .map(processTag) - .filter((tag: string | null): tag is string => Boolean(tag)) + .filter((tag): tag is string => Boolean(tag)) .join(delimiter); } if (Array.isArray(tags)) { return tags .map(processTag) - .filter((tag: string | null): tag is string => Boolean(tag)) + .filter((tag): tag is string => Boolean(tag)) .join(delimiter); } const allTags: string[] = Object.values(tags).flat(); return allTags .map(processTag) - .filter((tag: string | null): tag is string => Boolean(tag)) + .filter((tag): tag is string => Boolean(tag)) .join(delimiter); } -export function shufflePosts(posts: BooruPost[]): BooruPost[] { - for (let i: number = posts.length - 1; i > 0; i--) { - const j: number = Math.floor(Math.random() * (i + 1)); - [posts[i], posts[j]] = [posts[j], posts[i]]; +export function shufflePosts(posts: T[]): T[] { + if (posts.length <= 1) return posts; + + const shuffled = [...posts]; + + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const itemI = shuffled[i]; + const itemJ = shuffled[j]; + + if (itemI !== undefined && itemJ !== undefined) { + shuffled[i] = itemJ; + shuffled[j] = itemI; + } } - return posts; + return shuffled; } -export function minPosts( - posts: BooruPost[], - min: number, -): BooruPost[] { +export function minPosts(posts: T[], min: number): T[] { return posts.slice(0, min); } export function determineBooru( booruName: string, ): IBooruConfigMap[keyof IBooruConfigMap] | null { - const booru: IBooruConfigMap[keyof IBooruConfigMap] | undefined = - Object.values(booruConfig).find( - (booru: IBooruConfigMap[keyof IBooruConfigMap]) => - booru.name === booruName || - booru.aliases.includes(booruName.toLowerCase()), - ); + const booru = Object.values(booruConfig).find( + (booru) => + booru.name === booruName || + booru.aliases.includes(booruName.toLowerCase()), + ); return booru || null; } @@ -85,33 +94,47 @@ export function postExpectedFormat( if (booru.name === "e621.net") { return { - posts: normalizedPosts.map((post: BooruPost) => { + posts: normalizedPosts.map((post) => { + const hasE621Structure = + "file" in post && + post.file && + typeof post.file === "object" && + "url" in post.file; + const fileUrl = hasE621Structure ? post.file.url : null; + return { ...post, - file_url: post.file.url ?? null, + file_url: fileUrl ?? null, post_url: post.post_url ?? `https://${booru.endpoint}/posts/${post.id}`, tags: tag_format === "unformatted" ? post.tags - : Object.values(post.tags || {}) - .flat() - .join(" "), + : typeof post.tags === "object" && post.tags !== null + ? Object.values(post.tags).flat().join(" ") + : String(post.tags || ""), }; }), }; } const fixedDomain: string = booru.endpoint.replace(/^api\./, ""); - const formattedPosts: BooruPost[] = normalizedPosts.map((post: BooruPost) => { + const formattedPosts: BooruPost[] = normalizedPosts.map((post) => { const postUrl: string = post.post_url ?? `https://${fixedDomain}/index.php?page=post&s=view&id=${post.id}`; + + const hasDefaultStructure = + "directory" in post && "hash" in post && "image" in post; + const imageExtension: string = - post.image?.substring(post.image.lastIndexOf(".") + 1) ?? ""; + hasDefaultStructure && post.image + ? post.image.substring(post.image.lastIndexOf(".") + 1) + : ""; + const fileUrl: string | null = post.file_url ?? - (post.directory && post.hash && imageExtension + (hasDefaultStructure && post.directory && post.hash && imageExtension ? `https://${booru.endpoint}/images/${post.directory}/${post.hash}.${imageExtension}` : null); diff --git a/src/routes/[booru]/autocomplete/[tag].ts b/src/routes/[booru]/autocomplete/[tag].ts index e93ce13..2545532 100644 --- a/src/routes/[booru]/autocomplete/[tag].ts +++ b/src/routes/[booru]/autocomplete/[tag].ts @@ -1,7 +1,10 @@ -import { determineBooru, getE621Auth, getGelBooruAuth } from "@helpers/char"; +import { echo } from "@atums/echo"; import { fetch } from "bun"; +import { determineBooru, getE621Auth, getGelBooruAuth } from "#lib/char"; -import { logger } from "@helpers/logger"; +import type { ExtendedRequest } from "#types/bun"; +import type { IBooruConfig } from "#types/config"; +import type { RouteDef } from "#types/routes"; const routeDef: RouteDef = { method: "GET", @@ -9,14 +12,8 @@ const routeDef: RouteDef = { returns: "application/json", }; -async function handler( - request: Request, - _server: BunServer, - _requestBody: unknown, - query: Query, - params: Params, -): Promise { - const { booru, tag } = params as { booru: string; tag: string }; +async function handler(request: ExtendedRequest): Promise { + const { booru, tag } = request.params as { booru: string; tag: string }; if (!booru) { return Response.json( @@ -125,11 +122,11 @@ async function handler( let url = `https://${booruConfig.autocomplete}${editedTag}`; if (isGelbooru && gelbooruAuth) { - url += `?api_key=${gelbooruAuth.api_key}&user_id=${gelbooruAuth.user_id}`; + url += `?api_key=${gelbooruAuth.apiKey}&user_id=${gelbooruAuth.userId}`; } try { - let headers: Record | undefined; + let headers: Record = {}; if (isE621) { const e621Auth: Record | null = getE621Auth( @@ -159,7 +156,7 @@ async function handler( }); if (!response.ok) { - logger.error([ + echo.error([ "Failed to fetch post", `Booru: ${booru}`, `Status: ${response.status}`, @@ -181,11 +178,7 @@ async function handler( const data: unknown = await response.json(); if (!data) { - logger.error([ - "No data returned", - `Booru: ${booru}`, - `Tag: ${editedTag}`, - ]); + echo.error(["No data returned", `Booru: ${booru}`, `Tag: ${editedTag}`]); return Response.json( { success: false, @@ -225,7 +218,7 @@ async function handler( }, ); } catch (error) { - logger.error([ + echo.error([ "Failed to fetch post", `Booru: ${booru}`, `Tag: ${editedTag}`, diff --git a/src/routes/[booru]/id/[id].ts b/src/routes/[booru]/id/[id].ts index 4e9da6e..e9877c3 100644 --- a/src/routes/[booru]/id/[id].ts +++ b/src/routes/[booru]/id/[id].ts @@ -1,12 +1,16 @@ +import { echo } from "@atums/echo"; +import { fetch } from "bun"; import { determineBooru, getE621Auth, getGelBooruAuth, postExpectedFormat, -} from "@helpers/char"; -import { fetch } from "bun"; +} from "#lib/char"; -import { logger } from "@helpers/logger"; +import type { BooruPost, Data } from "#types/booruResponses"; +import type { ExtendedRequest } from "#types/bun"; +import type { IBooruConfig } from "#types/config"; +import type { RouteDef } from "#types/routes"; const routeDef: RouteDef = { method: "GET", @@ -14,17 +18,11 @@ const routeDef: RouteDef = { returns: "application/json", }; -async function handler( - request: Request, - _server: BunServer, - _requestBody: unknown, - query: Query, - params: Params, -): Promise { - const { tag_format } = query as { +async function handler(request: ExtendedRequest): Promise { + const { tag_format } = request.query as { tag_format: string; }; - const { booru, id } = params as { booru: string; id: string }; + const { booru, id } = request.params as { booru: string; id: string }; if (!booru || !id) { return Response.json( @@ -89,7 +87,7 @@ async function handler( let url = `https://${booruConfig.endpoint}/${booruConfig.functions.id}${id}`; if (isGelbooru && gelbooruAuth) { - url += `?api_key=${gelbooruAuth.api_key}&user_id=${gelbooruAuth.user_id}`; + url += `?api_key=${gelbooruAuth.apiKey}&user_id=${gelbooruAuth.userId}`; } if (Array.isArray(funcString)) { @@ -99,7 +97,7 @@ async function handler( } try { - let headers: Record | undefined; + let headers: Record = {}; if (isE621) { const e621Auth: Record | null = getE621Auth( @@ -129,7 +127,7 @@ async function handler( }); if (!response.ok) { - logger.error([ + echo.error([ "Failed to fetch post", `Booru: ${booru}`, `ID: ${id}`, @@ -152,7 +150,7 @@ async function handler( const data: unknown = await response.json(); if (!data) { - logger.error(["No data returned", `Booru: ${booru}`, `ID: ${id}`]); + echo.error(["No data returned", `Booru: ${booru}`, `ID: ${id}`]); return Response.json( { success: false, @@ -177,7 +175,7 @@ async function handler( } if (posts.length === 0) { - logger.error(["No posts found", `Booru: ${booru}`, `ID: ${id}`]); + echo.error(["No posts found", `Booru: ${booru}`, `ID: ${id}`]); return Response.json( { success: false, @@ -197,7 +195,7 @@ async function handler( ); if (!expectedData) { - logger.error([ + echo.error([ "Unexpected data format", `Booru: ${booru}`, `ID: ${id}`, diff --git a/src/routes/[booru]/random.ts b/src/routes/[booru]/random.ts index 3b1ebe8..c7a887a 100644 --- a/src/routes/[booru]/random.ts +++ b/src/routes/[booru]/random.ts @@ -1,3 +1,5 @@ +import { echo } from "@atums/echo"; +import { type Server, fetch } from "bun"; import { determineBooru, getE621Auth, @@ -6,10 +8,12 @@ import { postExpectedFormat, shufflePosts, tagsToExpectedFormat, -} from "@helpers/char"; -import { fetch } from "bun"; +} from "#lib/char"; -import { logger } from "@helpers/logger"; +import type { BooruPost, Data } from "#types/booruResponses"; +import type { ExtendedRequest } from "#types/bun"; +import type { IBooruConfig } from "#types/config"; +import type { RouteDef } from "#types/routes"; const routeDef: RouteDef = { method: "POST", @@ -19,13 +23,11 @@ const routeDef: RouteDef = { }; async function handler( - request: Request, - _server: BunServer, + request: ExtendedRequest, + _server: Server, requestBody: unknown, - query: Query, - params: Params, ): Promise { - const { booru } = params as { booru: string }; + const { booru } = request.params as { booru: string }; const { tags, results = 5, @@ -184,7 +186,12 @@ async function handler( parts.push("&"); } - if (isGelbooru && gelbooruAuth) { + if ( + isGelbooru && + gelbooruAuth && + gelbooruAuth.apiKey && + gelbooruAuth.userId + ) { parts.push("api_key"); parts.push(gelbooruAuth.apiKey); parts.push("&"); @@ -211,7 +218,7 @@ async function handler( const url: string = getUrl(pageString(state.page), resultsString); try { - let headers: Record | undefined; + let headers: Record = {}; if (isE621) { const e621Auth: Record | null = getE621Auth( @@ -272,7 +279,11 @@ async function handler( let posts: BooruPost[] = []; if (booruConfig.name === "realbooru.com" || isGelbooru) { - posts = parsedData.post || []; + if (parsedData.post) { + posts = Array.isArray(parsedData.post) + ? parsedData.post + : [parsedData.post]; + } } else { if (parsedData.post) { posts = [parsedData.post]; @@ -321,7 +332,7 @@ async function handler( } } - logger.error([ + echo.error([ "No posts found", `Booru: ${booru}`, `Tags: ${tagsString()}`, diff --git a/src/routes/[booru]/search.ts b/src/routes/[booru]/search.ts index 1078d52..a54d3e5 100644 --- a/src/routes/[booru]/search.ts +++ b/src/routes/[booru]/search.ts @@ -1,11 +1,16 @@ +import { type Server, fetch } from "bun"; import { determineBooru, getE621Auth, getGelBooruAuth, postExpectedFormat, tagsToExpectedFormat, -} from "@helpers/char"; -import { fetch } from "bun"; +} from "#lib/char"; + +import type { BooruPost, Data } from "#types/booruResponses"; +import type { ExtendedRequest } from "#types/bun"; +import type { IBooruConfig } from "#types/config"; +import type { RouteDef } from "#types/routes"; const routeDef: RouteDef = { method: "POST", @@ -15,13 +20,11 @@ const routeDef: RouteDef = { }; async function handler( - request: Request, - _server: BunServer, + request: ExtendedRequest, + _server: Server, requestBody: unknown, - query: Query, - params: Params, ): Promise { - const { booru } = params as { booru: string }; + const { booru } = request.params as { booru: string }; const { page = 0, tags, @@ -182,7 +185,12 @@ async function handler( parts.push("&"); } - if (isGelbooru && gelbooruAuth) { + if ( + isGelbooru && + gelbooruAuth && + gelbooruAuth.apiKey && + gelbooruAuth.userId + ) { parts.push("api_key"); parts.push(gelbooruAuth.apiKey); parts.push("&"); @@ -200,7 +208,7 @@ async function handler( }; try { - let headers: Record | undefined; + let headers: Record = {}; if (isE621) { const e621Auth: Record | null = getE621Auth( diff --git a/src/routes/index.ts b/src/routes/index.ts index 65213cc..3268a66 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,3 +1,5 @@ +import type { RouteDef } from "#types/routes"; + const routeDef: RouteDef = { method: "GET", accepts: "*/*", diff --git a/src/server.ts b/src/server.ts index f7981ac..ac0f46c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,12 @@ -import { environment } from "@config/environment"; -import { logger } from "@helpers/logger"; -import { FileSystemRouter, type MatchedRoute, type Serve } from "bun"; +import { resolve } from "node:path"; +import { type Echo, echo } from "@atums/echo"; +import { FileSystemRouter, type MatchedRoute, type Server } from "bun"; +import { environment } from "#environment"; +import { reqLoggerIgnores } from "#environment/constants"; +import { noFileLog } from "#index"; + +import type { ExtendedRequest } from "#types/bun"; +import type { RouteModule } from "#types/routes"; class ServerHandler { private router: FileSystemRouter; @@ -11,28 +17,27 @@ class ServerHandler { ) { this.router = new FileSystemRouter({ style: "nextjs", - dir: "./src/routes", + dir: resolve("src", "routes"), + fileExtensions: [".ts"], origin: `http://${this.host}:${this.port}`, }); } public initialize(): void { - const server: Serve = Bun.serve({ + const server: Server = Bun.serve({ port: this.port, hostname: this.host, fetch: this.handleRequest.bind(this), }); - logger.info( + noFileLog.info( `Server running at http://${server.hostname}:${server.port}`, - true, ); - - this.logRoutes(); + this.logRoutes(noFileLog); } - private logRoutes(): void { - logger.info("Available routes:"); + private logRoutes(echo: Echo): void { + echo.info("Available routes:"); const sortedRoutes: [string, string][] = Object.entries( this.router.routes, @@ -41,17 +46,76 @@ class ServerHandler { ); for (const [path, filePath] of sortedRoutes) { - logger.info(`Route: ${path}, File: ${filePath}`); + echo.info(`Route: ${path}, File: ${filePath}`); } } + private logRequest( + request: ExtendedRequest, + response: Response, + ip: string | undefined, + ): void { + const pathname = new URL(request.url).pathname; + + const { ignoredStartsWith, ignoredPaths } = reqLoggerIgnores; + + if ( + ignoredStartsWith.some((prefix) => pathname.startsWith(prefix)) || + ignoredPaths.includes(pathname) + ) { + return; + } + + echo.custom(`${request.method}`, `${response.status}`, [ + pathname, + `${(performance.now() - request.startPerf).toFixed(2)}ms`, + ip || "unknown", + ]); + } + private async handleRequest( request: Request, - server: BunServer, + server: Server, ): Promise { + const extendedRequest: ExtendedRequest = request as ExtendedRequest; + extendedRequest.startPerf = performance.now(); + + const headers = request.headers; + let ip = server.requestIP(request)?.address; + let response: Response; + + if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") { + ip = + headers.get("CF-Connecting-IP")?.trim() || + headers.get("X-Real-IP")?.trim() || + headers.get("X-Forwarded-For")?.split(",")[0]?.trim() || + "unknown"; + } + + const pathname: string = new URL(request.url).pathname; + + const baseDir = resolve("custom"); + const customPath = resolve(baseDir, pathname.slice(1)); + + if (!customPath.startsWith(baseDir)) { + response = new Response("Forbidden", { status: 403 }); + this.logRequest(extendedRequest, response, ip); + return response; + } + + const customFile = Bun.file(customPath); + if (await customFile.exists()) { + const content = await customFile.arrayBuffer(); + const type: string = customFile.type ?? "application/octet-stream"; + response = new Response(content, { + headers: { "Content-Type": type }, + }); + this.logRequest(extendedRequest, response, ip); + return response; + } + const match: MatchedRoute | null = this.router.match(request); let requestBody: unknown = {}; - let response: Response; if (match) { const { filePath, params, query } = match; @@ -60,7 +124,7 @@ class ServerHandler { const routeModule: RouteModule = await import(filePath); const contentType: string | null = request.headers.get("Content-Type"); const actualContentType: string | null = contentType - ? contentType.split(";")[0].trim() + ? (contentType.split(";")[0]?.trim() ?? null) : null; if ( @@ -83,47 +147,76 @@ class ServerHandler { } } - if (routeModule.routeDef.method !== request.method) { + if ( + (Array.isArray(routeModule.routeDef.method) && + !routeModule.routeDef.method.includes(request.method)) || + (!Array.isArray(routeModule.routeDef.method) && + routeModule.routeDef.method !== request.method) + ) { response = Response.json( { success: false, code: 405, - error: `Method ${request.method} Not Allowed`, + error: `Method ${request.method} Not Allowed, expected ${ + Array.isArray(routeModule.routeDef.method) + ? routeModule.routeDef.method.join(", ") + : routeModule.routeDef.method + }`, }, { status: 405 }, ); } else { - const expectedContentType: string | null = + const expectedContentType: string | string[] | null = routeModule.routeDef.accepts; - const matchesAccepts: boolean = - expectedContentType === "*/*" || - actualContentType === expectedContentType; + let matchesAccepts: boolean; + + if (Array.isArray(expectedContentType)) { + matchesAccepts = + expectedContentType.includes("*/*") || + expectedContentType.includes(actualContentType || ""); + } else { + matchesAccepts = + expectedContentType === "*/*" || + actualContentType === expectedContentType; + } if (!matchesAccepts) { response = Response.json( { success: false, code: 406, - error: `Content-Type ${contentType} Not Acceptable`, + error: `Content-Type ${actualContentType} Not Acceptable, expected ${ + Array.isArray(expectedContentType) + ? expectedContentType.join(", ") + : expectedContentType + }`, }, { status: 406 }, ); } else { + extendedRequest.params = params; + extendedRequest.query = query; + response = await routeModule.handler( - request, + extendedRequest, server, requestBody, - query, - params, ); - response.headers.set("Content-Type", routeModule.routeDef.returns); + if (routeModule.routeDef.returns !== "*/*") { + response.headers.set( + "Content-Type", + routeModule.routeDef.returns, + ); + } } } } catch (error: unknown) { - logger.error(`Error handling route ${request.url}:`); - logger.error(error as Error); + echo.error({ + message: `Error handling route ${request.url}`, + error: error, + }); response = Response.json( { @@ -145,27 +238,11 @@ class ServerHandler { ); } - const headers: Headers = response.headers; - let ip: string | null = server.requestIP(request)?.address || null; - - if (!ip) { - ip = - headers.get("CF-Connecting-IP") || - headers.get("X-Real-IP") || - headers.get("X-Forwarded-For") || - null; - } - - logger.info([ - `[${request.method}]`, - request.url, - `${response.status}`, - `(${ip || "unknown"})`, - ]); - + this.logRequest(extendedRequest, response, ip); return response; } } + const serverHandler: ServerHandler = new ServerHandler( environment.port, environment.host, diff --git a/tsconfig.json b/tsconfig.json index 17b2607..31abc0c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,33 +2,29 @@ "compilerOptions": { "baseUrl": "./", "paths": { - "@/*": ["src/*"], - "@config/*": ["config/*"], - "@types/*": ["types/*"], - "@helpers/*": ["src/helpers/*"], - "@database/*": ["src/database/*"] + "#*": ["src/*"], + "#types/*": ["types/*"], + "#environment": ["config/index.ts"], + "#environment/*": ["config/*"] }, - "typeRoots": ["./src/types", "./node_modules/@types"], - // Enable latest features + "typeRoots": ["./node_modules/@types"], "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - // Bundler mode + "allowJs": false, "moduleResolution": "bundler", - "allowImportingTsExtensions": true, + "allowImportingTsExtensions": false, "verbatimModuleSyntax": true, "noEmit": true, - // Best practices "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, "noPropertyAccessFromIndexSignature": false }, - "include": ["src", "types", "config"] + "include": ["src", "environment", "config"] } diff --git a/types/booruResponses.d.ts b/types/booruResponses.d.ts deleted file mode 100644 index 1d7cc28..0000000 --- a/types/booruResponses.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -type Data = { - post?: Post; - posts?: Post[]; - [key: string]: unknown; -}; - -interface DefaultPost { - directory: number; - hash: string; - id: number; - image: string; - tags: string; -} - -type E621Post = { - id: number; - file: { - url: string; - }; - tags: string; -}; - -type BooruPost = { - file_url?: string | null; - post_url?: string; -} & (DefaultPost | e621Post); diff --git a/types/booruResponses.ts b/types/booruResponses.ts new file mode 100644 index 0000000..08924ed --- /dev/null +++ b/types/booruResponses.ts @@ -0,0 +1,28 @@ +type Data = { + post?: BooruPost; + posts?: BooruPost[]; + [key: string]: unknown; +}; + +interface DefaultPost { + directory?: number; + hash?: string; + id: number; + image?: string; + tags: string | Record; +} + +type E621Post = { + id: number; + file: { + url: string; + }; + tags: Record; +}; + +type BooruPost = { + file_url?: string | null; + post_url?: string; +} & (DefaultPost | E621Post); + +export type { Data, DefaultPost, E621Post, BooruPost }; diff --git a/types/bun.d.ts b/types/bun.d.ts deleted file mode 100644 index 741e55c..0000000 --- a/types/bun.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Server } from "bun"; - -declare global { - type BunServer = Server; -} diff --git a/types/bun.ts b/types/bun.ts new file mode 100644 index 0000000..3eb18d0 --- /dev/null +++ b/types/bun.ts @@ -0,0 +1,10 @@ +type Query = Record; +type Params = Record; + +interface ExtendedRequest extends Request { + startPerf: number; + query: Query; + params: Params; +} + +export type { ExtendedRequest, Query, Params }; diff --git a/types/config.d.ts b/types/config.ts similarity index 86% rename from types/config.d.ts rename to types/config.ts index 6aae73e..ea59ba5 100644 --- a/types/config.d.ts +++ b/types/config.ts @@ -29,3 +29,5 @@ type IBooruConfig = { functions: IBooruDefaults; autocomplete?: string; }; + +export type { Environment, IBooruDefaults, IBooruConfigMap, IBooruConfig }; diff --git a/types/logger.d.ts b/types/logger.d.ts deleted file mode 100644 index ff6a601..0000000 --- a/types/logger.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -type ILogMessagePart = { value: string; color: string }; - -type ILogMessageParts = { - level: ILogMessagePart; - filename: ILogMessagePart; - readableTimestamp: ILogMessagePart; - message: ILogMessagePart; - [key: string]: ILogMessagePart; -}; diff --git a/types/routes.d.ts b/types/routes.ts similarity index 75% rename from types/routes.d.ts rename to types/routes.ts index 031ea9c..5de3431 100644 --- a/types/routes.d.ts +++ b/types/routes.ts @@ -1,3 +1,5 @@ +import type { Server } from "bun"; + type RouteDef = { method: string; accepts: string | null; @@ -11,10 +13,10 @@ type Params = Record; type RouteModule = { handler: ( request: Request, - server: BunServer, + server: Server, requestBody: unknown, - query: Query, - params: Params, ) => Promise | Response; routeDef: RouteDef; }; + +export type { RouteDef, Query, Params, RouteModule };