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": {
"ignoreUnknown": true,
"ignore": []
"ignore": ["dist"]
},
"formatter": {
"enabled": true,
@ -17,12 +17,31 @@
"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": {
"quoteStyle": "double",

View file

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

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",
"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"
}
}

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";
export const noFileLog = new Echo({
disableFile: true,
});
async function main(): Promise<void> {
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);
});

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 {
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,54 +21,60 @@ 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<BooruPost>(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<T extends BooruPost>(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<BooruPost>(
posts: BooruPost[],
min: number,
): BooruPost[] {
export function minPosts<T extends BooruPost>(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]) =>
const booru = Object.values(booruConfig).find(
(booru) =>
booru.name === booruName ||
booru.aliases.includes(booruName.toLowerCase()),
);
@ -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);

View file

@ -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<Response> {
const { booru, tag } = params as { booru: string; tag: string };
async function handler(request: ExtendedRequest): Promise<Response> {
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<string, string> | undefined;
let headers: Record<string, string> = {};
if (isE621) {
const e621Auth: Record<string, string> | 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}`,

View file

@ -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<Response> {
const { tag_format } = query as {
async function handler(request: ExtendedRequest): Promise<Response> {
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<string, string> | undefined;
let headers: Record<string, string> = {};
if (isE621) {
const e621Auth: Record<string, string> | 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}`,

View file

@ -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<Response> {
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<string, string> | undefined;
let headers: Record<string, string> = {};
if (isE621) {
const e621Auth: Record<string, string> | 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()}`,

View file

@ -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<Response> {
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<string, string> | undefined;
let headers: Record<string, string> = {};
if (isE621) {
const e621Auth: Record<string, string> | null = getE621Auth(

View file

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

View file

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

View file

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

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;
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 = {
method: string;
accepts: string | null;
@ -11,10 +13,10 @@ type Params = Record<string, string>;
type RouteModule = {
handler: (
request: Request,
server: BunServer,
server: Server,
requestBody: unknown,
query: Query,
params: Params,
) => Promise<Response> | Response;
routeDef: RouteDef;
};
export type { RouteDef, Query, Params, RouteModule };