refactor: improve code structure and add better logging
All checks were successful
Code quality checks / biome (push) Successful in 10s

- 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)
This commit is contained in:
creations 2025-06-13 17:45:27 -04:00
parent 82c9d72619
commit 5f0bdb885b
Signed by: creations
GPG key ID: 8F553AA4320FC711
24 changed files with 666 additions and 392 deletions

246
DOCS.md Normal file
View file

@ -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

View file

@ -7,7 +7,7 @@
}, },
"files": { "files": {
"ignoreUnknown": true, "ignoreUnknown": true,
"ignore": [] "ignore": ["dist"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
@ -17,12 +17,31 @@
"organizeImports": { "organizeImports": {
"enabled": true "enabled": true
}, },
"css": {
"formatter": {
"indentStyle": "tab",
"lineEnding": "lf"
}
},
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true "recommended": true,
"correctness": {
"noUnusedImports": "error",
"noUnusedVariables": "error"
},
"suspicious": {
"noConsole": "error"
},
"style": {
"useConst": "error",
"noVar": "error",
"useImportType": "error"
} }
}, },
"ignore": ["types"]
},
"javascript": { "javascript": {
"formatter": { "formatter": {
"quoteStyle": "double", "quoteStyle": "double",

View file

@ -1,12 +1,19 @@
// cSpell:disable // cSpell:disable
const reqLoggerIgnores = {
ignoredStartsWith: ["/public"],
ignoredPaths: ["/favicon.ico"],
};
import type { IBooruConfigMap, IBooruDefaults } from "#types/config";
const booruDefaults: IBooruDefaults = { const booruDefaults: IBooruDefaults = {
search: "index.php?page=dapi&s=post&q=index&json=1", search: "index.php?page=dapi&s=post&q=index&json=1",
random: "s", random: "s",
id: "index.php?page=dapi&s=post&q=index&json=1&id=", id: "index.php?page=dapi&s=post&q=index&json=1&id=",
}; };
export const booruConfig: IBooruConfigMap = { const booruConfig: IBooruConfigMap = {
"rule34.xxx": { "rule34.xxx": {
enabled: true, enabled: true,
name: "rule34.xxx", name: "rule34.xxx",
@ -76,3 +83,5 @@ export const booruConfig: IBooruConfigMap = {
functions: booruDefaults, functions: booruDefaults,
}, },
}; };
export { reqLoggerIgnores, booruConfig, booruDefaults };

View file

@ -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"),
};

30
config/index.ts Normal file
View file

@ -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 };

40
logger.json Normal file
View file

@ -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
}

View file

@ -2,13 +2,8 @@
"name": "booru-api", "name": "booru-api",
"module": "src/index.ts", "module": "src/index.ts",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "latest",
"@eslint/js": "^9.24.0", "@types/bun": "latest"
"@types/bun": "^1.2.9",
"globals": "^16.0.0"
},
"peerDependencies": {
"typescript": "^5.8.3"
}, },
"scripts": { "scripts": {
"start": "bun run src/index.ts", "start": "bun run src/index.ts",
@ -17,5 +12,8 @@
"lint:fix": "bunx biome check --fix", "lint:fix": "bunx biome check --fix",
"cleanup": "rm -rf logs node_modules bun.lock" "cleanup": "rm -rf logs node_modules bun.lock"
}, },
"type": "module" "type": "module",
"dependencies": {
"@atums/echo": "latest"
}
} }

View file

@ -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 };

View file

@ -1,13 +1,16 @@
import { logger } from "@helpers/logger"; import { Echo } from "@atums/echo";
import { serverHandler } from "./server"; import { serverHandler } from "./server";
export const noFileLog = new Echo({
disableFile: true,
});
async function main(): Promise<void> { async function main(): Promise<void> {
serverHandler.initialize(); serverHandler.initialize();
} }
main().catch((error: Error) => { main().catch((error: Error) => {
logger.error("Error initializing the server:"); noFileLog.error("Error initializing the server:");
logger.error(error as Error); noFileLog.error(error as Error);
process.exit(1); process.exit(1);
}); });

View file

@ -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 { export function timestampToReadable(timestamp?: number): string {
const date: Date = 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"; if (Number.isNaN(date.getTime())) return "Invalid Date";
return date.toISOString().replace("T", " ").replace("Z", ""); return date.toISOString().replace("T", " ").replace("Z", "");
} }
@ -18,54 +21,60 @@ export function tagsToExpectedFormat(
if (!tags) return ""; if (!tags) return "";
const processTag: (tag: string) => string | null = (tag: string) => { const processTag = (tag: string): string | null => {
const trimmed: string | null = tag.trim(); const trimmed = tag.trim();
return trimmed ? trimmed : null; return trimmed || null;
}; };
if (typeof tags === "string") { if (typeof tags === "string") {
return tags return tags
.split(/\s+|,/) .split(/\s+|,/)
.map(processTag) .map(processTag)
.filter((tag: string | null): tag is string => Boolean(tag)) .filter((tag): tag is string => Boolean(tag))
.join(delimiter); .join(delimiter);
} }
if (Array.isArray(tags)) { if (Array.isArray(tags)) {
return tags return tags
.map(processTag) .map(processTag)
.filter((tag: string | null): tag is string => Boolean(tag)) .filter((tag): tag is string => Boolean(tag))
.join(delimiter); .join(delimiter);
} }
const allTags: string[] = Object.values(tags).flat(); const allTags: string[] = Object.values(tags).flat();
return allTags return allTags
.map(processTag) .map(processTag)
.filter((tag: string | null): tag is string => Boolean(tag)) .filter((tag): tag is string => Boolean(tag))
.join(delimiter); .join(delimiter);
} }
export function shufflePosts<BooruPost>(posts: BooruPost[]): BooruPost[] { export function shufflePosts<T extends BooruPost>(posts: T[]): T[] {
for (let i: number = posts.length - 1; i > 0; i--) { if (posts.length <= 1) return posts;
const j: number = Math.floor(Math.random() * (i + 1));
[posts[i], posts[j]] = [posts[j], posts[i]]; 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<BooruPost>( export function minPosts<T extends BooruPost>(posts: T[], min: number): T[] {
posts: BooruPost[],
min: number,
): BooruPost[] {
return posts.slice(0, min); return posts.slice(0, min);
} }
export function determineBooru( export function determineBooru(
booruName: string, booruName: string,
): IBooruConfigMap[keyof IBooruConfigMap] | null { ): IBooruConfigMap[keyof IBooruConfigMap] | null {
const booru: IBooruConfigMap[keyof IBooruConfigMap] | undefined = const booru = Object.values(booruConfig).find(
Object.values(booruConfig).find( (booru) =>
(booru: IBooruConfigMap[keyof IBooruConfigMap]) =>
booru.name === booruName || booru.name === booruName ||
booru.aliases.includes(booruName.toLowerCase()), booru.aliases.includes(booruName.toLowerCase()),
); );
@ -85,33 +94,47 @@ export function postExpectedFormat(
if (booru.name === "e621.net") { if (booru.name === "e621.net") {
return { 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 { return {
...post, ...post,
file_url: post.file.url ?? null, file_url: fileUrl ?? null,
post_url: post_url:
post.post_url ?? `https://${booru.endpoint}/posts/${post.id}`, post.post_url ?? `https://${booru.endpoint}/posts/${post.id}`,
tags: tags:
tag_format === "unformatted" tag_format === "unformatted"
? post.tags ? post.tags
: Object.values(post.tags || {}) : typeof post.tags === "object" && post.tags !== null
.flat() ? Object.values(post.tags).flat().join(" ")
.join(" "), : String(post.tags || ""),
}; };
}), }),
}; };
} }
const fixedDomain: string = booru.endpoint.replace(/^api\./, ""); const fixedDomain: string = booru.endpoint.replace(/^api\./, "");
const formattedPosts: BooruPost[] = normalizedPosts.map((post: BooruPost) => { const formattedPosts: BooruPost[] = normalizedPosts.map((post) => {
const postUrl: string = const postUrl: string =
post.post_url ?? post.post_url ??
`https://${fixedDomain}/index.php?page=post&s=view&id=${post.id}`; `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 = const imageExtension: string =
post.image?.substring(post.image.lastIndexOf(".") + 1) ?? ""; hasDefaultStructure && post.image
? post.image.substring(post.image.lastIndexOf(".") + 1)
: "";
const fileUrl: string | null = const fileUrl: string | null =
post.file_url ?? post.file_url ??
(post.directory && post.hash && imageExtension (hasDefaultStructure && post.directory && post.hash && imageExtension
? `https://${booru.endpoint}/images/${post.directory}/${post.hash}.${imageExtension}` ? `https://${booru.endpoint}/images/${post.directory}/${post.hash}.${imageExtension}`
: null); : null);

View file

@ -1,7 +1,10 @@
import { determineBooru, getE621Auth, getGelBooruAuth } from "@helpers/char"; import { echo } from "@atums/echo";
import { fetch } from "bun"; 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 = { const routeDef: RouteDef = {
method: "GET", method: "GET",
@ -9,14 +12,8 @@ const routeDef: RouteDef = {
returns: "application/json", returns: "application/json",
}; };
async function handler( async function handler(request: ExtendedRequest): Promise<Response> {
request: Request, const { booru, tag } = request.params as { booru: string; tag: string };
_server: BunServer,
_requestBody: unknown,
query: Query,
params: Params,
): Promise<Response> {
const { booru, tag } = params as { booru: string; tag: string };
if (!booru) { if (!booru) {
return Response.json( return Response.json(
@ -125,11 +122,11 @@ async function handler(
let url = `https://${booruConfig.autocomplete}${editedTag}`; let url = `https://${booruConfig.autocomplete}${editedTag}`;
if (isGelbooru && gelbooruAuth) { if (isGelbooru && gelbooruAuth) {
url += `?api_key=${gelbooruAuth.api_key}&user_id=${gelbooruAuth.user_id}`; url += `?api_key=${gelbooruAuth.apiKey}&user_id=${gelbooruAuth.userId}`;
} }
try { try {
let headers: Record<string, string> | undefined; let headers: Record<string, string> = {};
if (isE621) { if (isE621) {
const e621Auth: Record<string, string> | null = getE621Auth( const e621Auth: Record<string, string> | null = getE621Auth(
@ -159,7 +156,7 @@ async function handler(
}); });
if (!response.ok) { if (!response.ok) {
logger.error([ echo.error([
"Failed to fetch post", "Failed to fetch post",
`Booru: ${booru}`, `Booru: ${booru}`,
`Status: ${response.status}`, `Status: ${response.status}`,
@ -181,11 +178,7 @@ async function handler(
const data: unknown = await response.json(); const data: unknown = await response.json();
if (!data) { if (!data) {
logger.error([ echo.error(["No data returned", `Booru: ${booru}`, `Tag: ${editedTag}`]);
"No data returned",
`Booru: ${booru}`,
`Tag: ${editedTag}`,
]);
return Response.json( return Response.json(
{ {
success: false, success: false,
@ -225,7 +218,7 @@ async function handler(
}, },
); );
} catch (error) { } catch (error) {
logger.error([ echo.error([
"Failed to fetch post", "Failed to fetch post",
`Booru: ${booru}`, `Booru: ${booru}`,
`Tag: ${editedTag}`, `Tag: ${editedTag}`,

View file

@ -1,12 +1,16 @@
import { echo } from "@atums/echo";
import { fetch } from "bun";
import { import {
determineBooru, determineBooru,
getE621Auth, getE621Auth,
getGelBooruAuth, getGelBooruAuth,
postExpectedFormat, postExpectedFormat,
} from "@helpers/char"; } from "#lib/char";
import { fetch } from "bun";
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 = { const routeDef: RouteDef = {
method: "GET", method: "GET",
@ -14,17 +18,11 @@ const routeDef: RouteDef = {
returns: "application/json", returns: "application/json",
}; };
async function handler( async function handler(request: ExtendedRequest): Promise<Response> {
request: Request, const { tag_format } = request.query as {
_server: BunServer,
_requestBody: unknown,
query: Query,
params: Params,
): Promise<Response> {
const { tag_format } = query as {
tag_format: string; 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) { if (!booru || !id) {
return Response.json( return Response.json(
@ -89,7 +87,7 @@ async function handler(
let url = `https://${booruConfig.endpoint}/${booruConfig.functions.id}${id}`; let url = `https://${booruConfig.endpoint}/${booruConfig.functions.id}${id}`;
if (isGelbooru && gelbooruAuth) { 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)) { if (Array.isArray(funcString)) {
@ -99,7 +97,7 @@ async function handler(
} }
try { try {
let headers: Record<string, string> | undefined; let headers: Record<string, string> = {};
if (isE621) { if (isE621) {
const e621Auth: Record<string, string> | null = getE621Auth( const e621Auth: Record<string, string> | null = getE621Auth(
@ -129,7 +127,7 @@ async function handler(
}); });
if (!response.ok) { if (!response.ok) {
logger.error([ echo.error([
"Failed to fetch post", "Failed to fetch post",
`Booru: ${booru}`, `Booru: ${booru}`,
`ID: ${id}`, `ID: ${id}`,
@ -152,7 +150,7 @@ async function handler(
const data: unknown = await response.json(); const data: unknown = await response.json();
if (!data) { if (!data) {
logger.error(["No data returned", `Booru: ${booru}`, `ID: ${id}`]); echo.error(["No data returned", `Booru: ${booru}`, `ID: ${id}`]);
return Response.json( return Response.json(
{ {
success: false, success: false,
@ -177,7 +175,7 @@ async function handler(
} }
if (posts.length === 0) { 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( return Response.json(
{ {
success: false, success: false,
@ -197,7 +195,7 @@ async function handler(
); );
if (!expectedData) { if (!expectedData) {
logger.error([ echo.error([
"Unexpected data format", "Unexpected data format",
`Booru: ${booru}`, `Booru: ${booru}`,
`ID: ${id}`, `ID: ${id}`,

View file

@ -1,3 +1,5 @@
import { echo } from "@atums/echo";
import { type Server, fetch } from "bun";
import { import {
determineBooru, determineBooru,
getE621Auth, getE621Auth,
@ -6,10 +8,12 @@ import {
postExpectedFormat, postExpectedFormat,
shufflePosts, shufflePosts,
tagsToExpectedFormat, tagsToExpectedFormat,
} from "@helpers/char"; } from "#lib/char";
import { fetch } from "bun";
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 = { const routeDef: RouteDef = {
method: "POST", method: "POST",
@ -19,13 +23,11 @@ const routeDef: RouteDef = {
}; };
async function handler( async function handler(
request: Request, request: ExtendedRequest,
_server: BunServer, _server: Server,
requestBody: unknown, requestBody: unknown,
query: Query,
params: Params,
): Promise<Response> { ): Promise<Response> {
const { booru } = params as { booru: string }; const { booru } = request.params as { booru: string };
const { const {
tags, tags,
results = 5, results = 5,
@ -184,7 +186,12 @@ async function handler(
parts.push("&"); parts.push("&");
} }
if (isGelbooru && gelbooruAuth) { if (
isGelbooru &&
gelbooruAuth &&
gelbooruAuth.apiKey &&
gelbooruAuth.userId
) {
parts.push("api_key"); parts.push("api_key");
parts.push(gelbooruAuth.apiKey); parts.push(gelbooruAuth.apiKey);
parts.push("&"); parts.push("&");
@ -211,7 +218,7 @@ async function handler(
const url: string = getUrl(pageString(state.page), resultsString); const url: string = getUrl(pageString(state.page), resultsString);
try { try {
let headers: Record<string, string> | undefined; let headers: Record<string, string> = {};
if (isE621) { if (isE621) {
const e621Auth: Record<string, string> | null = getE621Auth( const e621Auth: Record<string, string> | null = getE621Auth(
@ -272,7 +279,11 @@ async function handler(
let posts: BooruPost[] = []; let posts: BooruPost[] = [];
if (booruConfig.name === "realbooru.com" || isGelbooru) { if (booruConfig.name === "realbooru.com" || isGelbooru) {
posts = parsedData.post || []; if (parsedData.post) {
posts = Array.isArray(parsedData.post)
? parsedData.post
: [parsedData.post];
}
} else { } else {
if (parsedData.post) { if (parsedData.post) {
posts = [parsedData.post]; posts = [parsedData.post];
@ -321,7 +332,7 @@ async function handler(
} }
} }
logger.error([ echo.error([
"No posts found", "No posts found",
`Booru: ${booru}`, `Booru: ${booru}`,
`Tags: ${tagsString()}`, `Tags: ${tagsString()}`,

View file

@ -1,11 +1,16 @@
import { type Server, fetch } from "bun";
import { import {
determineBooru, determineBooru,
getE621Auth, getE621Auth,
getGelBooruAuth, getGelBooruAuth,
postExpectedFormat, postExpectedFormat,
tagsToExpectedFormat, tagsToExpectedFormat,
} from "@helpers/char"; } from "#lib/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";
const routeDef: RouteDef = { const routeDef: RouteDef = {
method: "POST", method: "POST",
@ -15,13 +20,11 @@ const routeDef: RouteDef = {
}; };
async function handler( async function handler(
request: Request, request: ExtendedRequest,
_server: BunServer, _server: Server,
requestBody: unknown, requestBody: unknown,
query: Query,
params: Params,
): Promise<Response> { ): Promise<Response> {
const { booru } = params as { booru: string }; const { booru } = request.params as { booru: string };
const { const {
page = 0, page = 0,
tags, tags,
@ -182,7 +185,12 @@ async function handler(
parts.push("&"); parts.push("&");
} }
if (isGelbooru && gelbooruAuth) { if (
isGelbooru &&
gelbooruAuth &&
gelbooruAuth.apiKey &&
gelbooruAuth.userId
) {
parts.push("api_key"); parts.push("api_key");
parts.push(gelbooruAuth.apiKey); parts.push(gelbooruAuth.apiKey);
parts.push("&"); parts.push("&");
@ -200,7 +208,7 @@ async function handler(
}; };
try { try {
let headers: Record<string, string> | undefined; let headers: Record<string, string> = {};
if (isE621) { if (isE621) {
const e621Auth: Record<string, string> | null = getE621Auth( const e621Auth: Record<string, string> | null = getE621Auth(

View file

@ -1,3 +1,5 @@
import type { RouteDef } from "#types/routes";
const routeDef: RouteDef = { const routeDef: RouteDef = {
method: "GET", method: "GET",
accepts: "*/*", accepts: "*/*",

View file

@ -1,6 +1,12 @@
import { environment } from "@config/environment"; import { resolve } from "node:path";
import { logger } from "@helpers/logger"; import { type Echo, echo } from "@atums/echo";
import { FileSystemRouter, type MatchedRoute, type Serve } from "bun"; 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 { class ServerHandler {
private router: FileSystemRouter; private router: FileSystemRouter;
@ -11,28 +17,27 @@ class ServerHandler {
) { ) {
this.router = new FileSystemRouter({ this.router = new FileSystemRouter({
style: "nextjs", style: "nextjs",
dir: "./src/routes", dir: resolve("src", "routes"),
fileExtensions: [".ts"],
origin: `http://${this.host}:${this.port}`, origin: `http://${this.host}:${this.port}`,
}); });
} }
public initialize(): void { public initialize(): void {
const server: Serve = Bun.serve({ const server: Server = Bun.serve({
port: this.port, port: this.port,
hostname: this.host, hostname: this.host,
fetch: this.handleRequest.bind(this), fetch: this.handleRequest.bind(this),
}); });
logger.info( noFileLog.info(
`Server running at http://${server.hostname}:${server.port}`, `Server running at http://${server.hostname}:${server.port}`,
true,
); );
this.logRoutes(noFileLog);
this.logRoutes();
} }
private logRoutes(): void { private logRoutes(echo: Echo): void {
logger.info("Available routes:"); echo.info("Available routes:");
const sortedRoutes: [string, string][] = Object.entries( const sortedRoutes: [string, string][] = Object.entries(
this.router.routes, this.router.routes,
@ -41,17 +46,76 @@ class ServerHandler {
); );
for (const [path, filePath] of sortedRoutes) { 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( private async handleRequest(
request: Request, request: Request,
server: BunServer, server: Server,
): Promise<Response> { ): Promise<Response> {
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); const match: MatchedRoute | null = this.router.match(request);
let requestBody: unknown = {}; let requestBody: unknown = {};
let response: Response;
if (match) { if (match) {
const { filePath, params, query } = match; const { filePath, params, query } = match;
@ -60,7 +124,7 @@ class ServerHandler {
const routeModule: RouteModule = await import(filePath); const routeModule: RouteModule = await import(filePath);
const contentType: string | null = request.headers.get("Content-Type"); const contentType: string | null = request.headers.get("Content-Type");
const actualContentType: string | null = contentType const actualContentType: string | null = contentType
? contentType.split(";")[0].trim() ? (contentType.split(";")[0]?.trim() ?? null)
: null; : null;
if ( 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( response = Response.json(
{ {
success: false, success: false,
code: 405, 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 }, { status: 405 },
); );
} else { } else {
const expectedContentType: string | null = const expectedContentType: string | string[] | null =
routeModule.routeDef.accepts; routeModule.routeDef.accepts;
const matchesAccepts: boolean = let matchesAccepts: boolean;
if (Array.isArray(expectedContentType)) {
matchesAccepts =
expectedContentType.includes("*/*") ||
expectedContentType.includes(actualContentType || "");
} else {
matchesAccepts =
expectedContentType === "*/*" || expectedContentType === "*/*" ||
actualContentType === expectedContentType; actualContentType === expectedContentType;
}
if (!matchesAccepts) { if (!matchesAccepts) {
response = Response.json( response = Response.json(
{ {
success: false, success: false,
code: 406, code: 406,
error: `Content-Type ${contentType} Not Acceptable`, error: `Content-Type ${actualContentType} Not Acceptable, expected ${
Array.isArray(expectedContentType)
? expectedContentType.join(", ")
: expectedContentType
}`,
}, },
{ status: 406 }, { status: 406 },
); );
} else { } else {
extendedRequest.params = params;
extendedRequest.query = query;
response = await routeModule.handler( response = await routeModule.handler(
request, extendedRequest,
server, server,
requestBody, 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) { } catch (error: unknown) {
logger.error(`Error handling route ${request.url}:`); echo.error({
logger.error(error as Error); message: `Error handling route ${request.url}`,
error: error,
});
response = Response.json( response = Response.json(
{ {
@ -145,27 +238,11 @@ class ServerHandler {
); );
} }
const headers: Headers = response.headers; this.logRequest(extendedRequest, response, ip);
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; return response;
} }
} }
const serverHandler: ServerHandler = new ServerHandler( const serverHandler: ServerHandler = new ServerHandler(
environment.port, environment.port,
environment.host, environment.host,

View file

@ -2,33 +2,29 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"@/*": ["src/*"], "#*": ["src/*"],
"@config/*": ["config/*"], "#types/*": ["types/*"],
"@types/*": ["types/*"], "#environment": ["config/index.ts"],
"@helpers/*": ["src/helpers/*"], "#environment/*": ["config/*"]
"@database/*": ["src/database/*"]
}, },
"typeRoots": ["./src/types", "./node_modules/@types"], "typeRoots": ["./node_modules/@types"],
// Enable latest features
"lib": ["ESNext", "DOM"], "lib": ["ESNext", "DOM"],
"target": "ESNext", "target": "ESNext",
"module": "ESNext", "module": "ESNext",
"moduleDetection": "force", "moduleDetection": "force",
"jsx": "react-jsx", "allowJs": false,
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": false,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"noEmit": true, "noEmit": true,
// Best practices
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default) "noUnusedLocals": true,
"noUnusedLocals": false, "noUnusedParameters": true,
"noUnusedParameters": false, "exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": false "noPropertyAccessFromIndexSignature": false
}, },
"include": ["src", "types", "config"] "include": ["src", "environment", "config"]
} }

View file

@ -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);

28
types/booruResponses.ts Normal file
View file

@ -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<string, string[]>;
}
type E621Post = {
id: number;
file: {
url: string;
};
tags: Record<string, string[]>;
};
type BooruPost = {
file_url?: string | null;
post_url?: string;
} & (DefaultPost | E621Post);
export type { Data, DefaultPost, E621Post, BooruPost };

5
types/bun.d.ts vendored
View file

@ -1,5 +0,0 @@
import type { Server } from "bun";
declare global {
type BunServer = Server;
}

10
types/bun.ts Normal file
View file

@ -0,0 +1,10 @@
type Query = Record<string, string>;
type Params = Record<string, string>;
interface ExtendedRequest extends Request {
startPerf: number;
query: Query;
params: Params;
}
export type { ExtendedRequest, Query, Params };

View file

@ -29,3 +29,5 @@ type IBooruConfig = {
functions: IBooruDefaults; functions: IBooruDefaults;
autocomplete?: string; autocomplete?: string;
}; };
export type { Environment, IBooruDefaults, IBooruConfigMap, IBooruConfig };

9
types/logger.d.ts vendored
View file

@ -1,9 +0,0 @@
type ILogMessagePart = { value: string; color: string };
type ILogMessageParts = {
level: ILogMessagePart;
filename: ILogMessagePart;
readableTimestamp: ILogMessagePart;
message: ILogMessagePart;
[key: string]: ILogMessagePart;
};

View file

@ -1,3 +1,5 @@
import type { Server } from "bun";
type RouteDef = { type RouteDef = {
method: string; method: string;
accepts: string | null; accepts: string | null;
@ -11,10 +13,10 @@ type Params = Record<string, string>;
type RouteModule = { type RouteModule = {
handler: ( handler: (
request: Request, request: Request,
server: BunServer, server: Server,
requestBody: unknown, requestBody: unknown,
query: Query,
params: Params,
) => Promise<Response> | Response; ) => Promise<Response> | Response;
routeDef: RouteDef; routeDef: RouteDef;
}; };
export type { RouteDef, Query, Params, RouteModule };