first commit
This commit is contained in:
commit
d69d3b7033
21 changed files with 1142 additions and 0 deletions
12
.editorconfig
Normal file
12
.editorconfig
Normal file
|
@ -0,0 +1,12 @@
|
|||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
* text=auto eol=lf
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
/node_modules
|
||||
bun.lockb
|
||||
/config/secrets.ts
|
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"cSpell.words": [
|
||||
"Booru"
|
||||
]
|
||||
}
|
1
README.md
Normal file
1
README.md
Normal file
|
@ -0,0 +1 @@
|
|||
# booru-api
|
68
config/booru.ts
Normal file
68
config/booru.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
// cSpell:disable
|
||||
|
||||
import { e621Auth } from "./secrets";
|
||||
|
||||
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 = {
|
||||
"rule34.xxx": {
|
||||
enabled: true,
|
||||
name: "rule34.xxx",
|
||||
aliases: ["rule34", "r34", "rule34xxx"],
|
||||
endpoint: "api.rule34.xxx",
|
||||
functions: booruDefaults,
|
||||
},
|
||||
"realbooru.com": {
|
||||
enabled: true,
|
||||
name: "realbooru.com",
|
||||
aliases: ["realbooru", "rb", "real34"],
|
||||
endpoint: "realbooru.com",
|
||||
functions: booruDefaults,
|
||||
},
|
||||
"safebooru.org": {
|
||||
enabled: true,
|
||||
name: "safebooru.org",
|
||||
aliases: ["safebooru", "sb", "s34"],
|
||||
endpoint: "safebooru.org",
|
||||
functions: booruDefaults,
|
||||
},
|
||||
"tbib.org": {
|
||||
enabled: true,
|
||||
name: "tbib.org",
|
||||
aliases: ["tbib", "tb", "tbiborg"],
|
||||
endpoint: "tbib.org",
|
||||
functions: booruDefaults,
|
||||
},
|
||||
"hypnohub.net": {
|
||||
enabled: true,
|
||||
name: "hypnohub.net",
|
||||
aliases: ["hypnohub", "hh", "hypnohubnet"],
|
||||
endpoint: "hypnohub.net",
|
||||
functions: booruDefaults,
|
||||
},
|
||||
"xbooru.com": {
|
||||
enabled: true,
|
||||
name: "xbooru.com",
|
||||
aliases: ["xbooru", "xb", "xboorucom"],
|
||||
endpoint: "xbooru.com",
|
||||
functions: booruDefaults,
|
||||
},
|
||||
"e621.net": {
|
||||
enabled: true,
|
||||
name: "e621.net",
|
||||
aliases: ["e621", "e6", "e621net"],
|
||||
endpoint: "e621.net",
|
||||
functions: {
|
||||
search: "posts.json",
|
||||
random: "defaultRandom",
|
||||
id: ["posts/", ".json"],
|
||||
},
|
||||
auth: {
|
||||
...e621Auth,
|
||||
},
|
||||
},
|
||||
};
|
7
config/environment.ts
Normal file
7
config/environment.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export const environment: Environment = {
|
||||
port: 6600,
|
||||
host: "127.0.0.1",
|
||||
development:
|
||||
process.argv.includes("--dev") ||
|
||||
process.argv.includes("--development"),
|
||||
};
|
132
eslint.config.js
Normal file
132
eslint.config.js
Normal file
|
@ -0,0 +1,132 @@
|
|||
import pluginJs from "@eslint/js";
|
||||
import tseslintPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import prettier from "eslint-plugin-prettier";
|
||||
import promisePlugin from "eslint-plugin-promise";
|
||||
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
||||
import unicorn from "eslint-plugin-unicorn";
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
import globals from "globals";
|
||||
|
||||
/** @type {import('eslint').Linter.FlatConfig[]} */
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs}"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
...pluginJs.configs.recommended,
|
||||
plugins: {
|
||||
"simple-import-sort": simpleImportSort,
|
||||
"unused-imports": unusedImports,
|
||||
promise: promisePlugin,
|
||||
prettier: prettier,
|
||||
unicorn: unicorn,
|
||||
},
|
||||
rules: {
|
||||
"eol-last": ["error", "always"],
|
||||
"no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }],
|
||||
"no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
vars: "all",
|
||||
varsIgnorePattern: "^_",
|
||||
args: "after-used",
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"promise/always-return": "error",
|
||||
"promise/no-return-wrap": "error",
|
||||
"promise/param-names": "error",
|
||||
"promise/catch-or-return": "error",
|
||||
"promise/no-nesting": "warn",
|
||||
"promise/no-promise-in-callback": "warn",
|
||||
"promise/no-callback-in-promise": "warn",
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
useTabs: true,
|
||||
tabWidth: 4,
|
||||
},
|
||||
],
|
||||
indent: ["error", "tab", { SwitchCase: 1 }],
|
||||
"unicorn/filename-case": [
|
||||
"error",
|
||||
{
|
||||
case: "camelCase",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
globals: globals.node,
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslintPlugin,
|
||||
"simple-import-sort": simpleImportSort,
|
||||
"unused-imports": unusedImports,
|
||||
promise: promisePlugin,
|
||||
prettier: prettier,
|
||||
unicorn: unicorn,
|
||||
},
|
||||
rules: {
|
||||
...tseslintPlugin.configs.recommended.rules,
|
||||
quotes: ["error", "double"],
|
||||
"eol-last": ["error", "always"],
|
||||
"no-multiple-empty-lines": ["error", { max: 1, maxEOF: 1 }],
|
||||
"no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
vars: "all",
|
||||
varsIgnorePattern: "^_",
|
||||
args: "after-used",
|
||||
argsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"promise/always-return": "error",
|
||||
"promise/no-return-wrap": "error",
|
||||
"promise/param-names": "error",
|
||||
"promise/catch-or-return": "error",
|
||||
"promise/no-nesting": "warn",
|
||||
"promise/no-promise-in-callback": "warn",
|
||||
"promise/no-callback-in-promise": "warn",
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
useTabs: true,
|
||||
tabWidth: 4,
|
||||
},
|
||||
],
|
||||
indent: ["error", "tab", { SwitchCase: 1 }],
|
||||
"unicorn/filename-case": [
|
||||
"error",
|
||||
{
|
||||
case: "camelCase",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/explicit-function-return-type": ["error"],
|
||||
"@typescript-eslint/explicit-module-boundary-types": ["error"],
|
||||
"@typescript-eslint/typedef": [
|
||||
"error",
|
||||
{
|
||||
arrowParameter: true,
|
||||
variableDeclaration: true,
|
||||
propertyDeclaration: true,
|
||||
memberVariableDeclaration: true,
|
||||
parameter: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
32
package.json
Normal file
32
package.json
Normal file
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "booru-api",
|
||||
"module": "src/index.ts",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/bun": "^1.1.14",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-promise": "^7.2.1",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unicorn": "^56.0.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"globals": "^15.14.0",
|
||||
"prettier": "^3.4.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"dev": "bun run --watch src/index.ts --dev",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "bun lint --fix",
|
||||
"cleanup": "rm -rf logs node_modules bun.lockdb"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"redis": "^4.7.0"
|
||||
}
|
||||
}
|
181
src/database/redis.ts
Normal file
181
src/database/redis.ts
Normal file
|
@ -0,0 +1,181 @@
|
|||
import { redisConfig } from "@config/secrets";
|
||||
import { logger } from "@helpers/logger";
|
||||
import { createClient, type RedisClientType } from "redis";
|
||||
|
||||
class RedisJson {
|
||||
private static instance: RedisJson | null = null;
|
||||
private client: RedisClientType | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static async initialize(): Promise<RedisJson> {
|
||||
if (!RedisJson.instance) {
|
||||
RedisJson.instance = new RedisJson();
|
||||
RedisJson.instance.client = createClient({
|
||||
socket: {
|
||||
host: redisConfig.host,
|
||||
port: redisConfig.port,
|
||||
},
|
||||
username: redisConfig.username || undefined,
|
||||
password: redisConfig.password || undefined,
|
||||
});
|
||||
|
||||
RedisJson.instance.client.on("error", (err: Error) => {
|
||||
logger.error("Redis connection error:");
|
||||
logger.error((err as Error) || "Unknown error");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
RedisJson.instance.client.on("connect", () => {
|
||||
logger.info([
|
||||
"Connected to Redis on",
|
||||
`${redisConfig.host}:${redisConfig.port}`,
|
||||
]);
|
||||
});
|
||||
|
||||
await RedisJson.instance.client.connect();
|
||||
}
|
||||
|
||||
return RedisJson.instance;
|
||||
}
|
||||
|
||||
public static getInstance(): RedisJson {
|
||||
if (!RedisJson.instance || !RedisJson.instance.client) {
|
||||
throw new Error(
|
||||
"Redis instance not initialized. Call initialize() first.",
|
||||
);
|
||||
}
|
||||
return RedisJson.instance;
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
if (!this.client) {
|
||||
logger.error("Redis client is not initialized.");
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
await this.client.disconnect();
|
||||
this.client = null;
|
||||
logger.info("Redis disconnected successfully.");
|
||||
} catch (error) {
|
||||
logger.error("Error disconnecting Redis client:");
|
||||
logger.error(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async get(
|
||||
type: "JSON" | "STRING",
|
||||
key: string,
|
||||
path?: string,
|
||||
): Promise<
|
||||
string | number | boolean | Record<string, unknown> | null | unknown
|
||||
> {
|
||||
if (!this.client) {
|
||||
logger.error("Redis client is not initialized.");
|
||||
throw new Error("Redis client is not initialized.");
|
||||
}
|
||||
try {
|
||||
if (type === "JSON") {
|
||||
const value: unknown = await this.client.json.get(key, {
|
||||
path,
|
||||
});
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
return value;
|
||||
} else if (type === "STRING") {
|
||||
const value: string | null = await this.client.get(key);
|
||||
return value;
|
||||
} else {
|
||||
throw new Error(`Invalid type: ${type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error getting value from Redis for key: ${key}`);
|
||||
logger.error(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async set(
|
||||
type: "JSON" | "STRING",
|
||||
key: string,
|
||||
value: unknown,
|
||||
path?: string,
|
||||
expiresInSeconds?: number,
|
||||
): Promise<void> {
|
||||
if (!this.client) {
|
||||
logger.error("Redis client is not initialized.");
|
||||
throw new Error("Redis client is not initialized.");
|
||||
}
|
||||
try {
|
||||
if (type === "JSON") {
|
||||
await this.client.json.set(key, path || "$", value as string);
|
||||
|
||||
if (expiresInSeconds) {
|
||||
await this.client.expire(key, expiresInSeconds);
|
||||
}
|
||||
} else if (type === "STRING") {
|
||||
if (expiresInSeconds) {
|
||||
await this.client.set(key, value as string, {
|
||||
EX: expiresInSeconds,
|
||||
});
|
||||
} else {
|
||||
await this.client.set(key, value as string);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Invalid type: ${type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error setting value in Redis for key: ${key}`);
|
||||
logger.error(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(type: "JSON" | "STRING", key: string): Promise<void> {
|
||||
if (!this.client) {
|
||||
logger.error("Redis client is not initialized.");
|
||||
throw new Error("Redis client is not initialized.");
|
||||
}
|
||||
try {
|
||||
if (type === "JSON") {
|
||||
await this.client.json.del(key);
|
||||
} else if (type === "STRING") {
|
||||
await this.client.del(key);
|
||||
} else {
|
||||
throw new Error(`Invalid type: ${type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error deleting value from Redis for key: ${key}`);
|
||||
logger.error(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async expire(key: string, seconds: number): Promise<void> {
|
||||
if (!this.client) {
|
||||
logger.error("Redis client is not initialized.");
|
||||
throw new Error("Redis client is not initialized.");
|
||||
}
|
||||
try {
|
||||
await this.client.expire(key, seconds);
|
||||
} catch (error) {
|
||||
logger.error(`Error expiring key in Redis: ${key}`);
|
||||
logger.error(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const redis: {
|
||||
initialize: () => Promise<RedisJson>;
|
||||
getInstance: () => RedisJson;
|
||||
} = {
|
||||
initialize: RedisJson.initialize,
|
||||
getInstance: RedisJson.getInstance,
|
||||
};
|
||||
|
||||
export { RedisJson };
|
59
src/helpers/char.ts
Normal file
59
src/helpers/char.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { booruConfig } from "@config/booru";
|
||||
|
||||
function timestampToReadable(timestamp?: number): string {
|
||||
const date: Date =
|
||||
timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date();
|
||||
if (isNaN(date.getTime())) return "Invalid Date";
|
||||
return date.toISOString().replace("T", " ").replace("Z", "");
|
||||
}
|
||||
|
||||
function tagsToExpectedFormat(
|
||||
tags: string[] | string | Record<string, string[]>,
|
||||
minus: boolean = false,
|
||||
onlyMinus: boolean = false,
|
||||
): string {
|
||||
const delimiter: string = minus ? (onlyMinus ? "-" : "+-") : "+";
|
||||
|
||||
if (!tags) return "";
|
||||
|
||||
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: string | null): tag is string => Boolean(tag))
|
||||
.join(delimiter);
|
||||
}
|
||||
|
||||
if (Array.isArray(tags)) {
|
||||
return tags
|
||||
.map(processTag)
|
||||
.filter((tag: string | null): 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))
|
||||
.join(delimiter);
|
||||
}
|
||||
|
||||
function determineBooru(
|
||||
booruName: string,
|
||||
): IBooruConfigMap[keyof IBooruConfigMap] | null {
|
||||
const booru: IBooruConfigMap[keyof IBooruConfigMap] | undefined =
|
||||
Object.values(booruConfig).find(
|
||||
(booru: IBooruConfigMap[keyof IBooruConfigMap]) =>
|
||||
booru.name === booruName ||
|
||||
booru.aliases.includes(booruName.toLowerCase()),
|
||||
);
|
||||
|
||||
return booru || null;
|
||||
}
|
||||
|
||||
export { determineBooru, tagsToExpectedFormat, timestampToReadable };
|
175
src/helpers/logger.ts
Normal file
175
src/helpers/logger.ts
Normal file
|
@ -0,0 +1,175 @@
|
|||
import { environment } from "@config/environment";
|
||||
import type { Stats } from "fs";
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
statSync,
|
||||
WriteStream,
|
||||
} from "fs";
|
||||
import { EOL } from "os";
|
||||
import { basename, join } from "path";
|
||||
|
||||
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: boolean = 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: string = "";
|
||||
|
||||
for (let i: number = 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: boolean = 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: boolean = 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: boolean = 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: boolean = 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 };
|
19
src/index.ts
Normal file
19
src/index.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { redis } from "@database/redis";
|
||||
import { logger } from "@helpers/logger";
|
||||
|
||||
import { serverHandler } from "./server";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
await redis.initialize();
|
||||
serverHandler.initialize();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: Error) => {
|
||||
logger.error("Error initializing the server:");
|
||||
logger.error(error as Error);
|
||||
process.exit(1);
|
||||
});
|
146
src/routes/[booru]/[id].ts
Normal file
146
src/routes/[booru]/[id].ts
Normal file
|
@ -0,0 +1,146 @@
|
|||
import { determineBooru } from "@helpers/char";
|
||||
import { fetch } from "bun";
|
||||
|
||||
import { redis } from "@/database/redis";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
accepts: "*/*",
|
||||
returns: "application/json",
|
||||
};
|
||||
|
||||
async function handler(
|
||||
request: Request,
|
||||
server: BunServer,
|
||||
requestBody: unknown,
|
||||
query: Query,
|
||||
params: Params,
|
||||
): Promise<Response> {
|
||||
const { booru, id } = params as { booru: string; id: string };
|
||||
|
||||
if (!booru || !id) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 400,
|
||||
error: "Missing booru or id",
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const booruConfig: IBooruConfig | null = determineBooru(booru);
|
||||
|
||||
if (!booruConfig) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 404,
|
||||
error: "Booru not found",
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!booruConfig.enabled) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 403,
|
||||
error: "Booru is disabled",
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const funcString: string | [string, string] = booruConfig.functions.id;
|
||||
let url: string = `https://${booruConfig.endpoint}/${booruConfig.functions.id}${id}`;
|
||||
|
||||
if (Array.isArray(funcString)) {
|
||||
const [start, end] = funcString;
|
||||
|
||||
url = `https://${booruConfig.endpoint}/${start}${id}${end}`;
|
||||
}
|
||||
|
||||
const cacheKey: string = `${booru}:${id}`;
|
||||
const cacheData: unknown = await redis.getInstance().get("JSON", cacheKey);
|
||||
|
||||
if (cacheData) {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
code: 200,
|
||||
cache: true,
|
||||
data: cacheData,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response: Response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: response.status || 500,
|
||||
error: response.statusText || "Could not reach booru",
|
||||
},
|
||||
{
|
||||
status: response.status || 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
|
||||
if (!data) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 404,
|
||||
error: "Post not found",
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// let keyString = Array.isArray(data) ? "posts" : "post";
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
code: 200,
|
||||
cache: false,
|
||||
data,
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 500,
|
||||
error: "Internal Server Error",
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
15
src/routes/index.ts
Normal file
15
src/routes/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
accepts: "*/*",
|
||||
returns: "text/html",
|
||||
};
|
||||
|
||||
async function handler(): Promise<Response> {
|
||||
return new Response("Hello, World!", {
|
||||
headers: {
|
||||
"content-type": "text/html",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
160
src/server.ts
Normal file
160
src/server.ts
Normal file
|
@ -0,0 +1,160 @@
|
|||
import { environment } from "@config/environment";
|
||||
import { logger } from "@helpers/logger";
|
||||
import { FileSystemRouter, type MatchedRoute, type Serve } from "bun";
|
||||
|
||||
class ServerHandler {
|
||||
private router: FileSystemRouter;
|
||||
|
||||
constructor(
|
||||
private port: number,
|
||||
private host: string,
|
||||
) {
|
||||
this.router = new FileSystemRouter({
|
||||
style: "nextjs",
|
||||
dir: "./src/routes",
|
||||
origin: `http://${this.host}:${this.port}`,
|
||||
});
|
||||
}
|
||||
|
||||
public initialize(): void {
|
||||
const server: Serve = Bun.serve({
|
||||
port: this.port,
|
||||
hostname: this.host,
|
||||
fetch: this.handleRequest.bind(this),
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Server running at http://${server.hostname}:${server.port}`,
|
||||
true,
|
||||
);
|
||||
|
||||
this.logRoutes();
|
||||
}
|
||||
|
||||
private logRoutes(): void {
|
||||
logger.info("Available routes:");
|
||||
|
||||
const sortedRoutes: [string, string][] = Object.entries(
|
||||
this.router.routes,
|
||||
).sort(([pathA]: [string, string], [pathB]: [string, string]) =>
|
||||
pathA.localeCompare(pathB),
|
||||
);
|
||||
|
||||
for (const [path, filePath] of sortedRoutes) {
|
||||
logger.info(`Route: ${path}, File: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRequest(
|
||||
request: Request,
|
||||
server: BunServer,
|
||||
): Promise<Response> {
|
||||
const match: MatchedRoute | null = this.router.match(request);
|
||||
let requestBody: unknown = {};
|
||||
let response: Response;
|
||||
|
||||
if (match) {
|
||||
const { filePath, params, query } = match;
|
||||
|
||||
try {
|
||||
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;
|
||||
|
||||
if (
|
||||
routeModule.routeDef.needsBody === "json" &&
|
||||
actualContentType === "application/json"
|
||||
) {
|
||||
try {
|
||||
requestBody = await request.json();
|
||||
} catch {
|
||||
requestBody = {};
|
||||
}
|
||||
} else if (
|
||||
routeModule.routeDef.needsBody === "multipart" &&
|
||||
actualContentType === "multipart/form-data"
|
||||
) {
|
||||
try {
|
||||
requestBody = await request.formData();
|
||||
} catch {
|
||||
requestBody = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (routeModule.routeDef.method !== request.method) {
|
||||
response = Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 405,
|
||||
error: `Method ${request.method} Not Allowed`,
|
||||
},
|
||||
{ status: 405 },
|
||||
);
|
||||
} else {
|
||||
const expectedContentType: string | null =
|
||||
routeModule.routeDef.accepts;
|
||||
|
||||
const matchesAccepts: boolean =
|
||||
expectedContentType === "*/*" ||
|
||||
actualContentType === expectedContentType;
|
||||
|
||||
if (!matchesAccepts) {
|
||||
response = Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 406,
|
||||
error: `Content-Type ${contentType} Not Acceptable`,
|
||||
},
|
||||
{ status: 406 },
|
||||
);
|
||||
} else {
|
||||
response = await routeModule.handler(
|
||||
request,
|
||||
server,
|
||||
requestBody,
|
||||
query,
|
||||
params,
|
||||
);
|
||||
|
||||
response.headers.set(
|
||||
"Content-Type",
|
||||
routeModule.routeDef.returns,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.error(`Error handling route ${request.url}:`);
|
||||
logger.error(error as Error);
|
||||
|
||||
response = Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 500,
|
||||
error: "Internal Server Error",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
response = Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 404,
|
||||
error: "Not Found",
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
const serverHandler: ServerHandler = new ServerHandler(
|
||||
environment.port,
|
||||
environment.host,
|
||||
);
|
||||
|
||||
export { serverHandler };
|
54
tsconfig.json
Normal file
54
tsconfig.json
Normal file
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
],
|
||||
"@config/*": [
|
||||
"config/*"
|
||||
],
|
||||
"@types/*": [
|
||||
"types/*"
|
||||
],
|
||||
"@helpers/*": [
|
||||
"src/helpers/*"
|
||||
],
|
||||
"@database/*": [
|
||||
"src/database/*"
|
||||
],
|
||||
},
|
||||
"typeRoots": [
|
||||
"./src/types",
|
||||
"./node_modules/@types"
|
||||
],
|
||||
// Enable latest features
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"types",
|
||||
"config"
|
||||
],
|
||||
}
|
5
types/bun.d.ts
vendored
Normal file
5
types/bun.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
import type { Server } from "bun";
|
||||
|
||||
declare global {
|
||||
type BunServer = Server;
|
||||
}
|
38
types/config.d.ts
vendored
Normal file
38
types/config.d.ts
vendored
Normal file
|
@ -0,0 +1,38 @@
|
|||
type Environment = {
|
||||
port: number;
|
||||
host: string;
|
||||
development: boolean;
|
||||
};
|
||||
|
||||
type RedisConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
type IBooruDefaults = {
|
||||
search: string;
|
||||
random: string;
|
||||
id: string | [string, string];
|
||||
};
|
||||
|
||||
type IBooruConfigMap = {
|
||||
[key: string]: {
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
aliases: string[];
|
||||
endpoint: string;
|
||||
functions: IBooruDefaults;
|
||||
auth?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
type IBooruConfig = {
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
aliases: string[];
|
||||
endpoint: string;
|
||||
functions: IBooruDefaults;
|
||||
auth?: Record<string, string>;
|
||||
};
|
9
types/logger.d.ts
vendored
Normal file
9
types/logger.d.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
type ILogMessagePart = { value: string; color: string };
|
||||
|
||||
type ILogMessageParts = {
|
||||
level: ILogMessagePart;
|
||||
filename: ILogMessagePart;
|
||||
readableTimestamp: ILogMessagePart;
|
||||
message: ILogMessagePart;
|
||||
[key: string]: ILogMessagePart;
|
||||
};
|
20
types/routes.d.ts
vendored
Normal file
20
types/routes.d.ts
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
type RouteDef = {
|
||||
method: string;
|
||||
accepts: string | null;
|
||||
returns: string;
|
||||
needsBody?: "multipart" | "json";
|
||||
};
|
||||
|
||||
type Query = Record<string, string>;
|
||||
type Params = Record<string, string>;
|
||||
|
||||
type RouteModule = {
|
||||
handler: (
|
||||
request: Request,
|
||||
server: BunServer,
|
||||
requestBody: unknown,
|
||||
query: Query,
|
||||
params: Params,
|
||||
) => Promise<Response> | Response;
|
||||
routeDef: RouteDef;
|
||||
};
|
Loading…
Add table
Reference in a new issue