From 608f4d5e8dcbc9eb1416d50a9a30870fdc92ff8e Mon Sep 17 00:00:00 2001 From: creations Date: Sun, 5 Jan 2025 20:03:30 -0500 Subject: [PATCH] add random, add autocomplete, more smaller changes --- config/booru.ts | 9 +- src/database/redis.ts | 2 +- src/helpers/char.ts | 64 ++-- src/routes/nsfw/[booru]/autocomplete/[tag].ts | 259 +++++++++++++ src/routes/{ => nsfw}/[booru]/id/[id].ts | 82 ++++- src/routes/nsfw/[booru]/random.ts | 346 ++++++++++++++++++ src/routes/nsfw/[booru]/search.ts | 301 +++++++++++++++ src/server.ts | 7 + types/booruResponses.d.ts | 33 +- types/config.d.ts | 2 + 10 files changed, 1039 insertions(+), 66 deletions(-) create mode 100644 src/routes/nsfw/[booru]/autocomplete/[tag].ts rename src/routes/{ => nsfw}/[booru]/id/[id].ts (63%) create mode 100644 src/routes/nsfw/[booru]/random.ts create mode 100644 src/routes/nsfw/[booru]/search.ts diff --git a/config/booru.ts b/config/booru.ts index ae9e919..2bb9edb 100644 --- a/config/booru.ts +++ b/config/booru.ts @@ -15,13 +15,15 @@ export const booruConfig: IBooruConfigMap = { aliases: ["rule34", "r34", "rule34xxx"], endpoint: "api.rule34.xxx", functions: booruDefaults, + autocomplete: "ac.rule34.xxx/autocomplete.php?q=", }, "realbooru.com": { - enabled: false, + enabled: true, name: "realbooru.com", aliases: ["realbooru", "rb", "real34"], endpoint: "realbooru.com", functions: booruDefaults, + autocomplete: "realbooru.com/index.php?page=autocomplete&term=", }, "safebooru.org": { enabled: true, @@ -29,6 +31,7 @@ export const booruConfig: IBooruConfigMap = { aliases: ["safebooru", "sb", "s34"], endpoint: "safebooru.org", functions: booruDefaults, + autocomplete: "safebooru.org/autocomplete.php?q=", }, "tbib.org": { enabled: true, @@ -36,6 +39,7 @@ export const booruConfig: IBooruConfigMap = { aliases: ["tbib", "tb", "tbiborg"], endpoint: "tbib.org", functions: booruDefaults, + autocomplete: "tbib.org/autocomplete.php?q=", }, "hypnohub.net": { enabled: true, @@ -43,6 +47,7 @@ export const booruConfig: IBooruConfigMap = { aliases: ["hypnohub", "hh", "hypnohubnet"], endpoint: "hypnohub.net", functions: booruDefaults, + autocomplete: "hypnohub.net/autocomplete.php?q=", }, "xbooru.com": { enabled: true, @@ -50,12 +55,14 @@ export const booruConfig: IBooruConfigMap = { aliases: ["xbooru", "xb", "xboorucom"], endpoint: "xbooru.com", functions: booruDefaults, + autocomplete: "xbooru.com/autocomplete.php?q=", }, "e621.net": { enabled: true, name: "e621.net", aliases: ["e621", "e6", "e621net"], endpoint: "e621.net", + autocomplete: "e621.net/tags/autocomplete.json?search[name_matches]=", functions: { search: "posts.json", random: "defaultRandom", diff --git a/src/database/redis.ts b/src/database/redis.ts index 3923ca0..1eec95d 100644 --- a/src/database/redis.ts +++ b/src/database/redis.ts @@ -103,8 +103,8 @@ class RedisJson { type: "JSON" | "STRING", key: string, value: unknown, - path?: string, expiresInSeconds?: number, + path?: string, ): Promise { if (!this.client) { logger.error("Redis client is not initialized."); diff --git a/src/helpers/char.ts b/src/helpers/char.ts index 5ff8639..a14fc29 100644 --- a/src/helpers/char.ts +++ b/src/helpers/char.ts @@ -43,7 +43,7 @@ export function tagsToExpectedFormat( .join(delimiter); } -export function shufflePosts(posts: T[]): T[] { +export function shufflePosts(posts: BooruPost[]): BooruPost[] { for (let i: number = posts.length - 1; i > 0; i--) { const j: number = Math.floor(Math.random() * (i + 1)); [posts[i], posts[j]] = [posts[j], posts[i]]; @@ -51,6 +51,13 @@ export function shufflePosts(posts: T[]): T[] { return posts; } +export function minPosts( + posts: BooruPost[], + min: number, +): BooruPost[] { + return posts.slice(0, min); +} + export function determineBooru( booruName: string, ): IBooruConfigMap[keyof IBooruConfigMap] | null { @@ -66,40 +73,49 @@ export function determineBooru( export function postExpectedFormat( booru: IBooruConfig, - posts: BooruPost[], + posts: BooruPost[] | BooruPost, ): { posts: BooruPost[] } | null { if (!posts) return null; - posts = Array.isArray(posts) ? posts : [posts]; - if (posts.length === 0) return null; + const normalizedPosts: BooruPost[] = Array.isArray(posts) ? posts : [posts]; + if (normalizedPosts.length === 0) return null; - if (booru.name === "e621.net") + if (booru.name === "e621.net") { return { - posts: posts.map( - (post: BooruPost): BooruPost => ({ + posts: normalizedPosts.map((post: BooruPost) => { + return { ...post, - file_url: post.file_url, - post_url: `https://${booru.endpoint}/posts/${post.id}`, - tags: Object.keys(post.tags) - .flatMap( - (key: string) => - post.tags[key as keyof typeof post.tags], - ) + file_url: post.file.url ?? null, + post_url: + post.post_url ?? + `https://${booru.endpoint}/posts/${post.id}`, + tags: Object.values(post.tags || {}) + .flat() .join(" "), - }), - ), + }; + }), }; + } const fixedDomain: string = booru.endpoint.replace(/^api\./, ""); - const formattedPosts: BooruPost[] = posts.map( - (post: BooruPost): BooruPost => { - const postUrl: string = `https://${fixedDomain}/index.php?page=post&s=view&id=${post.id}`; - const imageExtension: string = post.image.substring( - post.image.lastIndexOf(".") + 1, - ); - const fileUrl: string = `https://${booru.endpoint}/images/${post.directory}/${post.hash}.${imageExtension}`; + const formattedPosts: BooruPost[] = normalizedPosts.map( + (post: BooruPost) => { + const postUrl: string = + post.post_url ?? + `https://${fixedDomain}/index.php?page=post&s=view&id=${post.id}`; + const imageExtension: string = + post.image?.substring(post.image.lastIndexOf(".") + 1) ?? ""; + const fileUrl: string | null = + post.file_url ?? + (post.directory && post.hash && imageExtension + ? `https://${booru.endpoint}/images/${post.directory}/${post.hash}.${imageExtension}` + : null); - return { ...post, file_url: fileUrl, post_url: postUrl }; + return { + ...post, + file_url: fileUrl, + post_url: postUrl, + }; }, ); diff --git a/src/routes/nsfw/[booru]/autocomplete/[tag].ts b/src/routes/nsfw/[booru]/autocomplete/[tag].ts new file mode 100644 index 0000000..ffe4047 --- /dev/null +++ b/src/routes/nsfw/[booru]/autocomplete/[tag].ts @@ -0,0 +1,259 @@ +import { determineBooru } from "@helpers/char"; +import { fetch } from "bun"; + +import { redis } from "@/database/redis"; +import { logger } from "@/helpers/logger"; + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "application/json", +}; + +async function handler( + _request: Request, + _server: BunServer, + _requestBody: unknown, + query: Query, + params: Params, +): Promise { + const { force } = query as { force: string }; + const { booru, tag } = params as { booru: string; tag: string }; + + if (!booru) { + return Response.json( + { + success: false, + code: 400, + error: "Missing booru", + }, + { + status: 400, + }, + ); + } + + if (!tag) { + return Response.json( + { + success: false, + code: 400, + error: "Missing tag", + }, + { + 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 isE621: boolean = booruConfig.name === "e621.net"; + + if (isE621 && tag.length < 3) { + return Response.json( + { + success: false, + code: 400, + error: "Tag must be at least 3 characters long for e621", + }, + { + status: 400, + }, + ); + } + + let editedTag: string = tag; + if (editedTag.startsWith("-")) { + editedTag = editedTag.slice(1); + } + editedTag = editedTag.replace(/\s/g, "_"); + + const cacheKey: string = `nsfw:${booru}:autocomplete:${editedTag}`; + + if (!force) { + const cacheData: unknown = await redis + .getInstance() + .get("JSON", cacheKey); + + if (cacheData) { + const dataAsType: { count: number; data: unknown } = cacheData as { + count: number; + data: unknown; + }; + + if (dataAsType.count === 0) { + return Response.json( + { + success: false, + code: 404, + error: "No results found", + }, + { + status: 404, + }, + ); + } + + return Response.json( + { + success: true, + code: 200, + cache: true, + count: dataAsType.count, + data: dataAsType.data, + }, + { + status: 200, + }, + ); + } + } + + if (!booruConfig.autocomplete) { + return Response.json( + { + success: false, + code: 501, + error: `${booruConfig.name} does not support autocomplete (yet)`, + }, + { + status: 501, + }, + ); + } + + const url: string = `https://${booruConfig.autocomplete}${editedTag}`; + + try { + const headers: IBooruConfig["auth"] | undefined = booruConfig.auth + ? booruConfig.auth + : undefined; + + const response: Response = await fetch(url, { + headers, + }); + + if (!response.ok) { + logger.error([ + "Failed to fetch post", + `Booru: ${booru}`, + `Status: ${response.status}`, + `Status Text: ${response.statusText}`, + ]); + + 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) { + logger.error([ + "No data returned", + `Booru: ${booru}`, + `Tag: ${editedTag}`, + ]); + return Response.json( + { + success: false, + code: 404, + error: "No data was returned", + }, + { + status: 404, + }, + ); + } + + const resultCount: number = (data as unknown[]).length; + + if (resultCount === 0) { + await redis + .getInstance() + .set("JSON", cacheKey, { count: 0, data }, 60 * 60 * 2); // 2 hours + + return Response.json( + { + success: false, + code: 404, + error: "No results found", + }, + { + status: 404, + }, + ); + } + + await redis + .getInstance() + .set("JSON", cacheKey, { count: resultCount, data }, 60 * 60 * 24); // 24 hours + + return Response.json( + { + success: true, + code: 200, + cache: false, + count: resultCount, + data, + }, + { + status: 200, + }, + ); + } catch (error) { + logger.error([ + "Failed to fetch post", + `Booru: ${booru}`, + `Tag: ${editedTag}`, + `Error: ${error}`, + ]); + + return Response.json( + { + success: false, + code: 500, + error: "Could not reach booru", + }, + { + status: 500, + }, + ); + } +} + +export { handler, routeDef }; diff --git a/src/routes/[booru]/id/[id].ts b/src/routes/nsfw/[booru]/id/[id].ts similarity index 63% rename from src/routes/[booru]/id/[id].ts rename to src/routes/nsfw/[booru]/id/[id].ts index b86cc58..bcce398 100644 --- a/src/routes/[booru]/id/[id].ts +++ b/src/routes/nsfw/[booru]/id/[id].ts @@ -2,6 +2,7 @@ import { determineBooru, postExpectedFormat } from "@helpers/char"; import { fetch } from "bun"; import { redis } from "@/database/redis"; +import { logger } from "@/helpers/logger"; const routeDef: RouteDef = { method: "GET", @@ -10,12 +11,13 @@ const routeDef: RouteDef = { }; async function handler( - request: Request, - server: BunServer, - requestBody: unknown, + _request: Request, + _server: BunServer, + _requestBody: unknown, query: Query, params: Params, ): Promise { + const { force } = query as { force: string }; const { booru, id } = params as { booru: string; id: string }; if (!booru || !id) { @@ -68,27 +70,46 @@ async function handler( url = `https://${booruConfig.endpoint}/${start}${id}${end}`; } - const cacheKey: string = `${booru}:${id}`; - const cacheData: unknown = await redis.getInstance().get("JSON", cacheKey); + const cacheKey: string = `nsfw:${booru}:${id}`; + if (!force) { + const cacheData: unknown = await redis + .getInstance() + .get("JSON", cacheKey); - if (cacheData) { - return Response.json( - { - success: true, - code: 200, - cache: true, - data: cacheData, - }, - { - status: 200, - }, - ); + if (cacheData) { + return Response.json( + { + success: true, + code: 200, + cache: true, + post: + (cacheData as { posts: BooruPost[] }).posts[0] || null, + }, + { + status: 200, + }, + ); + } } try { - const response: Response = await fetch(url); + const headers: IBooruConfig["auth"] | undefined = booruConfig.auth + ? booruConfig.auth + : undefined; + + const response: Response = await fetch(url, { + headers, + }); if (!response.ok) { + logger.error([ + "Failed to fetch post", + `Booru: ${booru}`, + `ID: ${id}`, + `Status: ${response.status}`, + `Status Text: ${response.statusText}`, + ]); + return Response.json( { success: false, @@ -104,11 +125,12 @@ async function handler( const data: unknown = await response.json(); if (!data) { + logger.error(["No data returned", `Booru: ${booru}`, `ID: ${id}`]); return Response.json( { success: false, code: 404, - error: "Post not found", + error: "Post not found, or no json data was returned", }, { status: 404, @@ -128,6 +150,7 @@ async function handler( } if (posts.length === 0) { + logger.error(["No posts found", `Booru: ${booru}`, `ID: ${id}`]); return Response.json( { success: false, @@ -145,6 +168,27 @@ async function handler( posts, ); + if (!expectedData) { + logger.error([ + "Unexpected data format", + `Booru: ${booru}`, + `ID: ${id}`, + `Data: ${JSON.stringify(data)}`, + ]); + return Response.json( + { + success: false, + code: 500, + error: "Unexpected data format", + }, + { + status: 500, + }, + ); + } + + await redis.getInstance().set("JSON", cacheKey, expectedData, 60 * 30); // 30 minutes + return Response.json( { success: true, diff --git a/src/routes/nsfw/[booru]/random.ts b/src/routes/nsfw/[booru]/random.ts new file mode 100644 index 0000000..a688488 --- /dev/null +++ b/src/routes/nsfw/[booru]/random.ts @@ -0,0 +1,346 @@ +import { + determineBooru, + minPosts, + postExpectedFormat, + shufflePosts, + tagsToExpectedFormat, +} from "@helpers/char"; +import { fetch } from "bun"; + +import { redis } from "@/database/redis"; +import { logger } from "@/helpers/logger"; + +const routeDef: RouteDef = { + method: "POST", + accepts: "*/*", + returns: "application/json", + needsBody: "json", +}; + +async function handler( + _request: Request, + _server: BunServer, + requestBody: unknown, + query: Query, + params: Params, +): Promise { + const { force } = query as { force: string }; + const { booru } = params as { booru: string }; + const { + tags, + results = 5, + excludeTags, + } = requestBody as { + tags: string[]; + results: number; + excludeTags: string[]; + }; + + if (!booru) { + return Response.json( + { + success: false, + code: 400, + error: "Missing booru", + }, + { + status: 400, + }, + ); + } + + if (tags && !(typeof tags === "string" || Array.isArray(tags))) { + return Response.json( + { + success: false, + code: 400, + error: "Invalid tags, must be a string or array of strings", + }, + { + status: 400, + }, + ); + } + + if ( + excludeTags && + !(typeof excludeTags === "string" || Array.isArray(excludeTags)) + ) { + return Response.json( + { + success: false, + code: 400, + error: "Invalid excludeTags, must be a string or array of strings", + }, + { + status: 400, + }, + ); + } + + if (results && (typeof results !== "number" || results <= 0)) { + return Response.json( + { + success: false, + code: 400, + error: "Invalid results, must be a number greater than 0", + }, + { + 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 isE621: boolean = booruConfig.name === "e621.net"; + + const formattedTags: string = tags ? tagsToExpectedFormat(tags) : ""; + const formattedExcludeTags: string = excludeTags + ? tagsToExpectedFormat(excludeTags, true, isE621) + : ""; + + const tagsString: () => string = (): string => { + if (formattedTags && formattedExcludeTags) { + return `tags=${formattedTags}+-${formattedExcludeTags}`; + } else if (formattedTags) { + return `tags=${formattedTags}`; + } else if (formattedExcludeTags) { + return `tags=-${formattedExcludeTags}`; + } + + return ""; + }; + + const pageString: (page: string | number) => string = ( + page: string | number, + ): string => { + if (isE621) { + return `page=${page}`; + } + return `pid=${page}`; + }; + + const resultsString: string = `limit=${isE621 ? 320 : 1000}`; + + const getUrl: (pageString: string, resultsString: string) => string = ( + page: string, + resultsString: string, + ): string => { + const parts: string[] = [ + `https://${booruConfig.endpoint}/${booruConfig.functions.search}`, + ]; + + if (isE621) { + parts.push("?"); + } else { + parts.push("&"); + } + + const queryParams: string = [tagsString(), page, resultsString] + .filter(Boolean) + .join("&"); + parts.push(queryParams); + + return parts.join(""); + }; + + const noResultsCacheKey: string = `nsfw:${booru}:random:noResults:${tagsString()}`; + + if (!force) { + const cacheData: unknown = await redis + .getInstance() + .get("JSON", noResultsCacheKey); + + if (cacheData) { + return Response.json( + { + success: false, + code: 404, + cache: true, + error: "No posts found with the given tags", + }, + { + status: 404, + }, + ); + } + } + + const config: { maxPage: number; maxTries: number } = { + maxPage: 12, + maxTries: 6, + }; + let state: { tries: number; page: number } = { tries: 0, page: 16 }; + + while (state.tries < config.maxTries) { + if (state.tries === config.maxTries) { + state.page = 0; + } else { + const oldPage: number = state.page; + + do { + state.page = Math.floor(Math.random() * config.maxPage); + } while (state.page === oldPage); + } + + const cacheKey: string = `nsfw:${booru}:random:${tagsString()}:${results}:${state.page}`; + if (!force) { + const cacheData: unknown = await redis + .getInstance() + .get("JSON", cacheKey); + + if (cacheData) { + const minimizedPosts: BooruPost[] = minPosts( + (cacheData as { posts: BooruPost[] }).posts, + results, + ); + + const shuffledPosts: BooruPost[] = shufflePosts(minimizedPosts); + + return Response.json( + { + success: true, + code: 200, + cache: true, + posts: shuffledPosts, + }, + { + status: 200, + }, + ); + } + } + + const url: string = getUrl(pageString(state.page), resultsString); + + try { + const headers: IBooruConfig["auth"] | undefined = booruConfig.auth + ? booruConfig.auth + : undefined; + const response: Response = await fetch(url, { + headers, + }); + + if (!response.ok) { + return Response.json( + { + success: false, + code: response.status || 500, + error: + response.statusText || + `Could not reach ${booruConfig.name}`, + }, + { + status: response.status || 500, + }, + ); + } + + const data: unknown = await response.json(); + + if (!data) { + return Response.json( + { + success: false, + code: 500, + error: `No data returned from ${booruConfig.name}`, + }, + { + status: 500, + }, + ); + } + + const parsedData: Data = data as Data; + + let posts: BooruPost[] = []; + if (parsedData.post) { + posts = [parsedData.post]; + } else if (parsedData.posts) { + posts = parsedData.posts; + } else { + posts = Array.isArray(data) ? (data as BooruPost[]) : []; + } + + if (posts.length === 0) continue; + + let expectedData: { posts: BooruPost[] } | null = + postExpectedFormat(booruConfig, posts); + + if (!expectedData) continue; + + expectedData.posts = minPosts(expectedData.posts, results); + expectedData.posts = shufflePosts(expectedData.posts); + + await redis + .getInstance() + .set("JSON", cacheKey, expectedData, 60 * 60 * 1); // 1 hours + + return Response.json( + { + success: true, + code: 200, + cache: false, + posts: expectedData.posts, + }, + { + status: 200, + }, + ); + } catch { + continue; + } finally { + state.tries++; + } + } + + await redis.getInstance().set("JSON", noResultsCacheKey, true, 60 * 30); // 30 minutes + + logger.error([ + "No posts found", + `Booru: ${booru}`, + `Tags: ${tagsString()}`, + `Exclude tags: ${formattedExcludeTags}`, + `Tries: ${state.tries}`, + ]); + + return Response.json( + { + success: false, + code: 404, + error: "No posts found with the given tags", + }, + { + status: 404, + }, + ); +} + +export { handler, routeDef }; diff --git a/src/routes/nsfw/[booru]/search.ts b/src/routes/nsfw/[booru]/search.ts new file mode 100644 index 0000000..7b89c58 --- /dev/null +++ b/src/routes/nsfw/[booru]/search.ts @@ -0,0 +1,301 @@ +import { + determineBooru, + postExpectedFormat, + tagsToExpectedFormat, +} from "@helpers/char"; +import { fetch } from "bun"; + +import { redis } from "@/database/redis"; + +const routeDef: RouteDef = { + method: "POST", + accepts: "*/*", + returns: "application/json", + needsBody: "json", +}; + +async function handler( + _request: Request, + _server: BunServer, + requestBody: unknown, + query: Query, + params: Params, +): Promise { + const { force } = query as { force: string }; + const { booru } = params as { booru: string }; + const { + page = 0, + tags, + results = 5, + excludeTags, + } = requestBody as { + page: 0; + tags: string[] | string; + results: number; + excludeTags: string[] | string; + }; + + if (!booru) { + return Response.json( + { + success: false, + code: 400, + error: "Missing booru", + }, + { + status: 400, + }, + ); + } + + if (tags && !(typeof tags === "string" || Array.isArray(tags))) { + return Response.json( + { + success: false, + code: 400, + error: "Invalid tags, must be a string or array of strings", + }, + { + status: 400, + }, + ); + } + + if ( + excludeTags && + !(typeof excludeTags === "string" || Array.isArray(excludeTags)) + ) { + return Response.json( + { + success: false, + code: 400, + error: "Invalid excludeTags, must be a string or array of strings", + }, + { + status: 400, + }, + ); + } + + if (results && (typeof results !== "number" || results < 1)) { + return Response.json( + { + success: false, + code: 400, + error: "Invalid results, must be a number greater than 0", + }, + { + 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 isE621: boolean = booruConfig.name === "e621.net"; + + const formattedTags: string = tags ? tagsToExpectedFormat(tags) : ""; + const formattedExcludeTags: string = excludeTags + ? tagsToExpectedFormat(excludeTags, true, isE621) + : ""; + + const safePage: string | number = Number.isSafeInteger(page) ? page : 0; + const safeResults: string | number = Number.isSafeInteger(results) + ? results + : 5; + + const tagsString: () => string = (): string => { + if (formattedTags && formattedExcludeTags) { + return `tags=${formattedTags}+-${formattedExcludeTags}`; + } else if (formattedTags) { + return `tags=${formattedTags}`; + } else if (formattedExcludeTags) { + return `tags=-${formattedExcludeTags}`; + } + + return ""; + }; + + const pageString: () => string = (): string => { + if (isE621) { + return `page=${safePage}`; + } + return `pid=${safePage}`; + }; + + const resultsString: string = `limit=${safeResults}`; + + const url: () => string = (): string => { + const parts: string[] = [ + `https://${booruConfig.endpoint}/${booruConfig.functions.search}`, + ]; + + if (isE621) { + parts.push("?"); + } else { + parts.push("&"); + } + + const queryParams: string = [tagsString(), pageString(), resultsString] + .filter(Boolean) + .join("&"); + parts.push(queryParams); + + return parts.join(""); + }; + + const cacheKey: string = `nsfw:${booru}:${formattedTags}:${formattedExcludeTags}:${safePage}:${safeResults}`; + if (!force) { + const cacheData: unknown = await redis + .getInstance() + .get("JSON", cacheKey); + + if (cacheData) { + return Response.json( + { + success: true, + code: 200, + cache: true, + posts: (cacheData as { posts: BooruPost[] }).posts, + }, + { + status: 200, + }, + ); + } + } + + try { + const headers: IBooruConfig["auth"] | undefined = booruConfig.auth + ? booruConfig.auth + : undefined; + const response: Response = await fetch(url(), { + headers, + }); + + if (!response.ok) { + return Response.json( + { + success: false, + code: response.status || 500, + error: + response.statusText || + `Could not reach ${booruConfig.name}`, + }, + { + status: response.status || 500, + }, + ); + } + + const data: unknown = await response.json(); + + if (!data) { + return Response.json( + { + success: false, + code: 500, + error: `No data returned from ${booruConfig.name}`, + }, + { + status: 500, + }, + ); + } + + const parsedData: Data = data as Data; + + let posts: BooruPost[] = []; + if (parsedData.post) { + posts = [parsedData.post]; + } else if (parsedData.posts) { + posts = parsedData.posts; + } else { + posts = Array.isArray(data) ? (data as BooruPost[]) : []; + } + + if (posts.length === 0) { + return Response.json( + { + success: false, + code: 404, + error: "No posts found", + }, + { + status: 404, + }, + ); + } + + const expectedData: { posts: BooruPost[] } | null = postExpectedFormat( + booruConfig, + posts, + ); + + if (!expectedData) { + return Response.json( + { + success: false, + code: 500, + error: "Unexpected data format", + }, + { + status: 500, + }, + ); + } + + await redis.getInstance().set("JSON", cacheKey, expectedData, 60 * 30); // 30 minutes + + return Response.json( + { + success: true, + code: 200, + cache: false, + posts: expectedData.posts, + }, + { + status: 200, + }, + ); + } catch (error) { + return Response.json( + { + success: false, + code: 500, + error: (error as Error).message || "Unknown error", + }, + { + status: 500, + }, + ); + } +} + +export { handler, routeDef }; diff --git a/src/server.ts b/src/server.ts index 9b6833d..5d9ba31 100644 --- a/src/server.ts +++ b/src/server.ts @@ -149,6 +149,13 @@ class ServerHandler { ); } + logger.info([ + `[${request.method}]`, + request.url, + `${response.status}`, + server.requestIP(request)?.address || "unknown", + ]); + return response; } } diff --git a/types/booruResponses.d.ts b/types/booruResponses.d.ts index a384e2a..1d7cc28 100644 --- a/types/booruResponses.d.ts +++ b/types/booruResponses.d.ts @@ -4,32 +4,23 @@ type Data = { [key: string]: unknown; }; -interface Rule34Post { - preview_url: string; - sample_url: string; - file_url: string; +interface DefaultPost { directory: number; hash: string; - width: number; - height: number; id: number; image: string; - change: number; - owner: string; - parent_id: number; - rating: string; - sample: boolean; - sample_height: number; - sample_width: number; - score: number; tags: string; - source: string; - status: string; - has_notes: boolean; - comment_count: number; } -type BooruPost = Rule34Post & { - post_url: string; - file_url: string; +type E621Post = { + id: number; + file: { + url: string; + }; + tags: string; }; + +type BooruPost = { + file_url?: string | null; + post_url?: string; +} & (DefaultPost | e621Post); diff --git a/types/config.d.ts b/types/config.d.ts index 639bb5c..1a4ca02 100644 --- a/types/config.d.ts +++ b/types/config.d.ts @@ -24,6 +24,7 @@ type IBooruConfigMap = { aliases: string[]; endpoint: string; functions: IBooruDefaults; + autocomplete?: string; auth?: Record; }; }; @@ -34,5 +35,6 @@ type IBooruConfig = { aliases: string[]; endpoint: string; functions: IBooruDefaults; + autocomplete?: string; auth?: Record; };