diff --git a/DOCS.md b/DOCS.md deleted file mode 100644 index c18fb83..0000000 --- a/DOCS.md +++ /dev/null @@ -1,246 +0,0 @@ -# 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 032f6fa..921a7a5 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": true, - "ignore": ["dist"] + "ignore": [] }, "formatter": { "enabled": true, @@ -17,30 +17,11 @@ "organizeImports": { "enabled": true }, - "css": { - "formatter": { - "indentStyle": "tab", - "lineEnding": "lf" - } - }, "linter": { "enabled": true, "rules": { - "recommended": true, - "correctness": { - "noUnusedImports": "error", - "noUnusedVariables": "error" - }, - "suspicious": { - "noConsole": "error" - }, - "style": { - "useConst": "error", - "noVar": "error", - "useImportType": "error" - } - }, - "ignore": ["types"] + "recommended": true + } }, "javascript": { "formatter": { diff --git a/config/constants.ts b/config/booru.ts similarity index 88% rename from config/constants.ts rename to config/booru.ts index 3d9920d..11198e4 100644 --- a/config/constants.ts +++ b/config/booru.ts @@ -1,19 +1,12 @@ // 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=", }; -const booruConfig: IBooruConfigMap = { +export const booruConfig: IBooruConfigMap = { "rule34.xxx": { enabled: true, name: "rule34.xxx", @@ -83,5 +76,3 @@ const booruConfig: IBooruConfigMap = { functions: booruDefaults, }, }; - -export { reqLoggerIgnores, booruConfig, booruDefaults }; diff --git a/config/environment.ts b/config/environment.ts new file mode 100644 index 0000000..da15e14 --- /dev/null +++ b/config/environment.ts @@ -0,0 +1,6 @@ +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 deleted file mode 100644 index 8ea9b2d..0000000 --- a/config/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 15b2c23..0000000 --- a/logger.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "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 986257d..3f5f4af 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,13 @@ "name": "booru-api", "module": "src/index.ts", "devDependencies": { - "@biomejs/biome": "latest", - "@types/bun": "latest" + "@biomejs/biome": "^1.9.4", + "@eslint/js": "^9.24.0", + "@types/bun": "^1.2.9", + "globals": "^16.0.0" + }, + "peerDependencies": { + "typescript": "^5.8.3" }, "scripts": { "start": "bun run src/index.ts", @@ -12,8 +17,5 @@ "lint:fix": "bunx biome check --fix", "cleanup": "rm -rf logs node_modules bun.lock" }, - "type": "module", - "dependencies": { - "@atums/echo": "latest" - } + "type": "module" } diff --git a/src/lib/char.ts b/src/helpers/char.ts similarity index 57% rename from src/lib/char.ts rename to src/helpers/char.ts index 1f61ba5..0313a37 100644 --- a/src/lib/char.ts +++ b/src/helpers/char.ts @@ -1,13 +1,10 @@ -import { booruConfig } from "#environment/constants"; +/* eslint-disable prettier/prettier */ -import type { BooruPost } from "#types/booruResponses"; -import type { IBooruConfig, IBooruConfigMap } from "#types/config"; +import { booruConfig } from "@config/booru"; export function timestampToReadable(timestamp?: number): string { const date: Date = - timestamp && !Number.isNaN(timestamp) - ? new Date(timestamp * 1000) - : new Date(); + timestamp && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date(); if (Number.isNaN(date.getTime())) return "Invalid Date"; return date.toISOString().replace("T", " ").replace("Z", ""); } @@ -21,63 +18,57 @@ export function tagsToExpectedFormat( if (!tags) return ""; - const processTag = (tag: string): string | null => { - const trimmed = tag.trim(); - return trimmed || null; + const processTag: (tag: string) => string | null = (tag: string) => { + const trimmed: string | null = tag.trim(); + return trimmed ? trimmed : null; }; if (typeof tags === "string") { return tags .split(/\s+|,/) .map(processTag) - .filter((tag): tag is string => Boolean(tag)) + .filter((tag: string | null): tag is string => Boolean(tag)) .join(delimiter); } if (Array.isArray(tags)) { return tags .map(processTag) - .filter((tag): tag is string => Boolean(tag)) + .filter((tag: string | null): tag is string => Boolean(tag)) .join(delimiter); } const allTags: string[] = Object.values(tags).flat(); return allTags .map(processTag) - .filter((tag): tag is string => Boolean(tag)) + .filter((tag: string | null): tag is string => Boolean(tag)) .join(delimiter); } -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; - } +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]]; } - return shuffled; + return posts; } -export function minPosts(posts: T[], min: number): T[] { +export function minPosts( + posts: BooruPost[], + min: number, +): BooruPost[] { return posts.slice(0, min); } export function determineBooru( booruName: string, ): IBooruConfigMap[keyof IBooruConfigMap] | null { - const booru = Object.values(booruConfig).find( - (booru) => - booru.name === booruName || - booru.aliases.includes(booruName.toLowerCase()), - ); + const booru: IBooruConfigMap[keyof IBooruConfigMap] | undefined = + Object.values(booruConfig).find( + (booru: IBooruConfigMap[keyof IBooruConfigMap]) => + booru.name === booruName || + booru.aliases.includes(booruName.toLowerCase()), + ); return booru || null; } @@ -94,47 +85,33 @@ export function postExpectedFormat( if (booru.name === "e621.net") { return { - 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; - + posts: normalizedPosts.map((post: BooruPost) => { return { ...post, - file_url: fileUrl ?? null, + file_url: post.file.url ?? null, post_url: post.post_url ?? `https://${booru.endpoint}/posts/${post.id}`, tags: tag_format === "unformatted" ? post.tags - : typeof post.tags === "object" && post.tags !== null - ? Object.values(post.tags).flat().join(" ") - : String(post.tags || ""), + : Object.values(post.tags || {}) + .flat() + .join(" "), }; }), }; } const fixedDomain: string = booru.endpoint.replace(/^api\./, ""); - const formattedPosts: BooruPost[] = normalizedPosts.map((post) => { + const formattedPosts: BooruPost[] = normalizedPosts.map((post: BooruPost) => { 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 = - hasDefaultStructure && post.image - ? post.image.substring(post.image.lastIndexOf(".") + 1) - : ""; - + post.image?.substring(post.image.lastIndexOf(".") + 1) ?? ""; const fileUrl: string | null = post.file_url ?? - (hasDefaultStructure && post.directory && post.hash && imageExtension + (post.directory && post.hash && imageExtension ? `https://${booru.endpoint}/images/${post.directory}/${post.hash}.${imageExtension}` : null); diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts new file mode 100644 index 0000000..fa39adf --- /dev/null +++ b/src/helpers/logger.ts @@ -0,0 +1,175 @@ +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 0b0094b..decf139 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,13 @@ -import { Echo } from "@atums/echo"; -import { serverHandler } from "./server"; +import { logger } from "@helpers/logger"; -export const noFileLog = new Echo({ - disableFile: true, -}); +import { serverHandler } from "./server"; async function main(): Promise { serverHandler.initialize(); } main().catch((error: Error) => { - noFileLog.error("Error initializing the server:"); - noFileLog.error(error as Error); + logger.error("Error initializing the server:"); + logger.error(error as Error); process.exit(1); }); diff --git a/src/routes/[booru]/autocomplete/[tag].ts b/src/routes/[booru]/autocomplete/[tag].ts index 2545532..e93ce13 100644 --- a/src/routes/[booru]/autocomplete/[tag].ts +++ b/src/routes/[booru]/autocomplete/[tag].ts @@ -1,10 +1,7 @@ -import { echo } from "@atums/echo"; +import { determineBooru, getE621Auth, getGelBooruAuth } from "@helpers/char"; import { fetch } from "bun"; -import { determineBooru, getE621Auth, getGelBooruAuth } from "#lib/char"; -import type { ExtendedRequest } from "#types/bun"; -import type { IBooruConfig } from "#types/config"; -import type { RouteDef } from "#types/routes"; +import { logger } from "@helpers/logger"; const routeDef: RouteDef = { method: "GET", @@ -12,8 +9,14 @@ const routeDef: RouteDef = { returns: "application/json", }; -async function handler(request: ExtendedRequest): Promise { - const { booru, tag } = request.params as { booru: string; tag: string }; +async function handler( + request: Request, + _server: BunServer, + _requestBody: unknown, + query: Query, + params: Params, +): Promise { + const { booru, tag } = params as { booru: string; tag: string }; if (!booru) { return Response.json( @@ -122,11 +125,11 @@ async function handler(request: ExtendedRequest): Promise { let url = `https://${booruConfig.autocomplete}${editedTag}`; if (isGelbooru && gelbooruAuth) { - url += `?api_key=${gelbooruAuth.apiKey}&user_id=${gelbooruAuth.userId}`; + url += `?api_key=${gelbooruAuth.api_key}&user_id=${gelbooruAuth.user_id}`; } try { - let headers: Record = {}; + let headers: Record | undefined; if (isE621) { const e621Auth: Record | null = getE621Auth( @@ -156,7 +159,7 @@ async function handler(request: ExtendedRequest): Promise { }); if (!response.ok) { - echo.error([ + logger.error([ "Failed to fetch post", `Booru: ${booru}`, `Status: ${response.status}`, @@ -178,7 +181,11 @@ async function handler(request: ExtendedRequest): Promise { const data: unknown = await response.json(); if (!data) { - echo.error(["No data returned", `Booru: ${booru}`, `Tag: ${editedTag}`]); + logger.error([ + "No data returned", + `Booru: ${booru}`, + `Tag: ${editedTag}`, + ]); return Response.json( { success: false, @@ -218,7 +225,7 @@ async function handler(request: ExtendedRequest): Promise { }, ); } catch (error) { - echo.error([ + logger.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 e9877c3..4e9da6e 100644 --- a/src/routes/[booru]/id/[id].ts +++ b/src/routes/[booru]/id/[id].ts @@ -1,16 +1,12 @@ -import { echo } from "@atums/echo"; -import { fetch } from "bun"; import { determineBooru, getE621Auth, getGelBooruAuth, postExpectedFormat, -} from "#lib/char"; +} from "@helpers/char"; +import { fetch } from "bun"; -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"; +import { logger } from "@helpers/logger"; const routeDef: RouteDef = { method: "GET", @@ -18,11 +14,17 @@ const routeDef: RouteDef = { returns: "application/json", }; -async function handler(request: ExtendedRequest): Promise { - const { tag_format } = request.query as { +async function handler( + request: Request, + _server: BunServer, + _requestBody: unknown, + query: Query, + params: Params, +): Promise { + const { tag_format } = query as { tag_format: string; }; - const { booru, id } = request.params as { booru: string; id: string }; + const { booru, id } = params as { booru: string; id: string }; if (!booru || !id) { return Response.json( @@ -87,7 +89,7 @@ async function handler(request: ExtendedRequest): Promise { let url = `https://${booruConfig.endpoint}/${booruConfig.functions.id}${id}`; if (isGelbooru && gelbooruAuth) { - url += `?api_key=${gelbooruAuth.apiKey}&user_id=${gelbooruAuth.userId}`; + url += `?api_key=${gelbooruAuth.api_key}&user_id=${gelbooruAuth.user_id}`; } if (Array.isArray(funcString)) { @@ -97,7 +99,7 @@ async function handler(request: ExtendedRequest): Promise { } try { - let headers: Record = {}; + let headers: Record | undefined; if (isE621) { const e621Auth: Record | null = getE621Auth( @@ -127,7 +129,7 @@ async function handler(request: ExtendedRequest): Promise { }); if (!response.ok) { - echo.error([ + logger.error([ "Failed to fetch post", `Booru: ${booru}`, `ID: ${id}`, @@ -150,7 +152,7 @@ async function handler(request: ExtendedRequest): Promise { const data: unknown = await response.json(); if (!data) { - echo.error(["No data returned", `Booru: ${booru}`, `ID: ${id}`]); + logger.error(["No data returned", `Booru: ${booru}`, `ID: ${id}`]); return Response.json( { success: false, @@ -175,7 +177,7 @@ async function handler(request: ExtendedRequest): Promise { } if (posts.length === 0) { - echo.error(["No posts found", `Booru: ${booru}`, `ID: ${id}`]); + logger.error(["No posts found", `Booru: ${booru}`, `ID: ${id}`]); return Response.json( { success: false, @@ -195,7 +197,7 @@ async function handler(request: ExtendedRequest): Promise { ); if (!expectedData) { - echo.error([ + logger.error([ "Unexpected data format", `Booru: ${booru}`, `ID: ${id}`, diff --git a/src/routes/[booru]/random.ts b/src/routes/[booru]/random.ts index c7a887a..3b1ebe8 100644 --- a/src/routes/[booru]/random.ts +++ b/src/routes/[booru]/random.ts @@ -1,5 +1,3 @@ -import { echo } from "@atums/echo"; -import { type Server, fetch } from "bun"; import { determineBooru, getE621Auth, @@ -8,12 +6,10 @@ import { postExpectedFormat, shufflePosts, tagsToExpectedFormat, -} from "#lib/char"; +} from "@helpers/char"; +import { fetch } from "bun"; -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"; +import { logger } from "@helpers/logger"; const routeDef: RouteDef = { method: "POST", @@ -23,11 +19,13 @@ const routeDef: RouteDef = { }; async function handler( - request: ExtendedRequest, - _server: Server, + request: Request, + _server: BunServer, requestBody: unknown, + query: Query, + params: Params, ): Promise { - const { booru } = request.params as { booru: string }; + const { booru } = params as { booru: string }; const { tags, results = 5, @@ -186,12 +184,7 @@ async function handler( parts.push("&"); } - if ( - isGelbooru && - gelbooruAuth && - gelbooruAuth.apiKey && - gelbooruAuth.userId - ) { + if (isGelbooru && gelbooruAuth) { parts.push("api_key"); parts.push(gelbooruAuth.apiKey); parts.push("&"); @@ -218,7 +211,7 @@ async function handler( const url: string = getUrl(pageString(state.page), resultsString); try { - let headers: Record = {}; + let headers: Record | undefined; if (isE621) { const e621Auth: Record | null = getE621Auth( @@ -279,11 +272,7 @@ async function handler( let posts: BooruPost[] = []; if (booruConfig.name === "realbooru.com" || isGelbooru) { - if (parsedData.post) { - posts = Array.isArray(parsedData.post) - ? parsedData.post - : [parsedData.post]; - } + posts = parsedData.post || []; } else { if (parsedData.post) { posts = [parsedData.post]; @@ -332,7 +321,7 @@ async function handler( } } - echo.error([ + logger.error([ "No posts found", `Booru: ${booru}`, `Tags: ${tagsString()}`, diff --git a/src/routes/[booru]/search.ts b/src/routes/[booru]/search.ts index a54d3e5..1078d52 100644 --- a/src/routes/[booru]/search.ts +++ b/src/routes/[booru]/search.ts @@ -1,16 +1,11 @@ -import { type Server, fetch } from "bun"; import { determineBooru, getE621Auth, getGelBooruAuth, postExpectedFormat, tagsToExpectedFormat, -} 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"; +} from "@helpers/char"; +import { fetch } from "bun"; const routeDef: RouteDef = { method: "POST", @@ -20,11 +15,13 @@ const routeDef: RouteDef = { }; async function handler( - request: ExtendedRequest, - _server: Server, + request: Request, + _server: BunServer, requestBody: unknown, + query: Query, + params: Params, ): Promise { - const { booru } = request.params as { booru: string }; + const { booru } = params as { booru: string }; const { page = 0, tags, @@ -185,12 +182,7 @@ async function handler( parts.push("&"); } - if ( - isGelbooru && - gelbooruAuth && - gelbooruAuth.apiKey && - gelbooruAuth.userId - ) { + if (isGelbooru && gelbooruAuth) { parts.push("api_key"); parts.push(gelbooruAuth.apiKey); parts.push("&"); @@ -208,7 +200,7 @@ async function handler( }; try { - let headers: Record = {}; + let headers: Record | undefined; if (isE621) { const e621Auth: Record | null = getE621Auth( diff --git a/src/routes/index.ts b/src/routes/index.ts index 3268a66..65213cc 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,5 +1,3 @@ -import type { RouteDef } from "#types/routes"; - const routeDef: RouteDef = { method: "GET", accepts: "*/*", diff --git a/src/server.ts b/src/server.ts index ac0f46c..f7981ac 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,12 +1,6 @@ -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"; +import { environment } from "@config/environment"; +import { logger } from "@helpers/logger"; +import { FileSystemRouter, type MatchedRoute, type Serve } from "bun"; class ServerHandler { private router: FileSystemRouter; @@ -17,27 +11,28 @@ class ServerHandler { ) { this.router = new FileSystemRouter({ style: "nextjs", - dir: resolve("src", "routes"), - fileExtensions: [".ts"], + dir: "./src/routes", origin: `http://${this.host}:${this.port}`, }); } public initialize(): void { - const server: Server = Bun.serve({ + const server: Serve = Bun.serve({ port: this.port, hostname: this.host, fetch: this.handleRequest.bind(this), }); - noFileLog.info( + logger.info( `Server running at http://${server.hostname}:${server.port}`, + true, ); - this.logRoutes(noFileLog); + + this.logRoutes(); } - private logRoutes(echo: Echo): void { - echo.info("Available routes:"); + private logRoutes(): void { + logger.info("Available routes:"); const sortedRoutes: [string, string][] = Object.entries( this.router.routes, @@ -46,76 +41,17 @@ class ServerHandler { ); for (const [path, filePath] of sortedRoutes) { - echo.info(`Route: ${path}, File: ${filePath}`); + logger.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: Server, + server: BunServer, ): 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; @@ -124,7 +60,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() ?? null) + ? contentType.split(";")[0].trim() : null; if ( @@ -147,76 +83,47 @@ class ServerHandler { } } - if ( - (Array.isArray(routeModule.routeDef.method) && - !routeModule.routeDef.method.includes(request.method)) || - (!Array.isArray(routeModule.routeDef.method) && - routeModule.routeDef.method !== request.method) - ) { + if (routeModule.routeDef.method !== request.method) { response = Response.json( { success: false, code: 405, - error: `Method ${request.method} Not Allowed, expected ${ - Array.isArray(routeModule.routeDef.method) - ? routeModule.routeDef.method.join(", ") - : routeModule.routeDef.method - }`, + error: `Method ${request.method} Not Allowed`, }, { status: 405 }, ); } else { - const expectedContentType: string | string[] | null = + const expectedContentType: string | null = routeModule.routeDef.accepts; - let matchesAccepts: boolean; - - if (Array.isArray(expectedContentType)) { - matchesAccepts = - expectedContentType.includes("*/*") || - expectedContentType.includes(actualContentType || ""); - } else { - matchesAccepts = - expectedContentType === "*/*" || - actualContentType === expectedContentType; - } + const matchesAccepts: boolean = + expectedContentType === "*/*" || + actualContentType === expectedContentType; if (!matchesAccepts) { response = Response.json( { success: false, code: 406, - error: `Content-Type ${actualContentType} Not Acceptable, expected ${ - Array.isArray(expectedContentType) - ? expectedContentType.join(", ") - : expectedContentType - }`, + error: `Content-Type ${contentType} Not Acceptable`, }, { status: 406 }, ); } else { - extendedRequest.params = params; - extendedRequest.query = query; - response = await routeModule.handler( - extendedRequest, + request, server, requestBody, + query, + params, ); - if (routeModule.routeDef.returns !== "*/*") { - response.headers.set( - "Content-Type", - routeModule.routeDef.returns, - ); - } + response.headers.set("Content-Type", routeModule.routeDef.returns); } } } catch (error: unknown) { - echo.error({ - message: `Error handling route ${request.url}`, - error: error, - }); + logger.error(`Error handling route ${request.url}:`); + logger.error(error as Error); response = Response.json( { @@ -238,11 +145,27 @@ class ServerHandler { ); } - this.logRequest(extendedRequest, response, ip); + 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"})`, + ]); + return response; } } - const serverHandler: ServerHandler = new ServerHandler( environment.port, environment.host, diff --git a/tsconfig.json b/tsconfig.json index 31abc0c..17b2607 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,29 +2,33 @@ "compilerOptions": { "baseUrl": "./", "paths": { - "#*": ["src/*"], - "#types/*": ["types/*"], - "#environment": ["config/index.ts"], - "#environment/*": ["config/*"] + "@/*": ["src/*"], + "@config/*": ["config/*"], + "@types/*": ["types/*"], + "@helpers/*": ["src/helpers/*"], + "@database/*": ["src/database/*"] }, - "typeRoots": ["./node_modules/@types"], + "typeRoots": ["./src/types", "./node_modules/@types"], + // Enable latest features "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", - "allowJs": false, + "jsx": "react-jsx", + "allowJs": true, + // Bundler mode "moduleResolution": "bundler", - "allowImportingTsExtensions": false, + "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true, + // Best practices "strict": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "exactOptionalPropertyTypes": true, - "noUncheckedIndexedAccess": true, + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false }, - "include": ["src", "environment", "config"] + "include": ["src", "types", "config"] } diff --git a/types/booruResponses.d.ts b/types/booruResponses.d.ts new file mode 100644 index 0000000..1d7cc28 --- /dev/null +++ b/types/booruResponses.d.ts @@ -0,0 +1,26 @@ +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 deleted file mode 100644 index 08924ed..0000000 --- a/types/booruResponses.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 new file mode 100644 index 0000000..741e55c --- /dev/null +++ b/types/bun.d.ts @@ -0,0 +1,5 @@ +import type { Server } from "bun"; + +declare global { + type BunServer = Server; +} diff --git a/types/bun.ts b/types/bun.ts deleted file mode 100644 index 3eb18d0..0000000 --- a/types/bun.ts +++ /dev/null @@ -1,10 +0,0 @@ -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.ts b/types/config.d.ts similarity index 86% rename from types/config.ts rename to types/config.d.ts index ea59ba5..6aae73e 100644 --- a/types/config.ts +++ b/types/config.d.ts @@ -29,5 +29,3 @@ type IBooruConfig = { functions: IBooruDefaults; autocomplete?: string; }; - -export type { Environment, IBooruDefaults, IBooruConfigMap, IBooruConfig }; diff --git a/types/logger.d.ts b/types/logger.d.ts new file mode 100644 index 0000000..ff6a601 --- /dev/null +++ b/types/logger.d.ts @@ -0,0 +1,9 @@ +type ILogMessagePart = { value: string; color: string }; + +type ILogMessageParts = { + level: ILogMessagePart; + filename: ILogMessagePart; + readableTimestamp: ILogMessagePart; + message: ILogMessagePart; + [key: string]: ILogMessagePart; +}; diff --git a/types/routes.ts b/types/routes.d.ts similarity index 75% rename from types/routes.ts rename to types/routes.d.ts index 5de3431..031ea9c 100644 --- a/types/routes.ts +++ b/types/routes.d.ts @@ -1,5 +1,3 @@ -import type { Server } from "bun"; - type RouteDef = { method: string; accepts: string | null; @@ -13,10 +11,10 @@ type Params = Record; type RouteModule = { handler: ( request: Request, - server: Server, + server: BunServer, requestBody: unknown, + query: Query, + params: Params, ) => Promise | Response; routeDef: RouteDef; }; - -export type { RouteDef, Query, Params, RouteModule };