From 3505cb7d6416d101a4fcb1be27fc22e0171bacd2 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 18 May 2025 16:58:08 +0100 Subject: refactor: Migrate from NextJs's API routes to Hono based routes for the API (#1432) * Setup Hono and migrate the highlights API there * Implement the tags and lists endpoint * Implement the bookmarks and users endpoints * Add the trpc error code adapter * Remove the old nextjs handlers * fix api key not found handling * Fix trpc error handling * Fix 204 handling * Fix search ordering * Implement the singlefile endpoint * Implement the asset serving endpoints * Implement webauth * Add hono as a catch all route under api * fix tests --- packages/api/utils/pagination.ts | 30 +++++++++++ packages/api/utils/types.ts | 28 ++++++++++ packages/api/utils/upload.ts | 110 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 packages/api/utils/pagination.ts create mode 100644 packages/api/utils/types.ts create mode 100644 packages/api/utils/upload.ts (limited to 'packages/api/utils') diff --git a/packages/api/utils/pagination.ts b/packages/api/utils/pagination.ts new file mode 100644 index 00000000..12a0b950 --- /dev/null +++ b/packages/api/utils/pagination.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@karakeep/shared/types/bookmarks"; +import { zCursorV2 } from "@karakeep/shared/types/pagination"; + +export const zPagination = z.object({ + limit: z.coerce.number().max(MAX_NUM_BOOKMARKS_PER_PAGE).optional(), + cursor: z + .string() + .refine((val) => val.includes("_"), "Must be a valid cursor") + .transform((val) => { + const [id, createdAt] = val.split("_"); + return { id, createdAt }; + }) + .pipe(z.object({ id: z.string(), createdAt: z.coerce.date() })) + .optional(), +}); + +export function adaptPagination< + T extends { nextCursor: z.infer | null }, +>(input: T) { + const { nextCursor, ...rest } = input; + if (!nextCursor) { + return input; + } + return { + ...rest, + nextCursor: `${nextCursor.id}_${nextCursor.createdAt.toISOString()}`, + }; +} diff --git a/packages/api/utils/types.ts b/packages/api/utils/types.ts new file mode 100644 index 00000000..bdaf815f --- /dev/null +++ b/packages/api/utils/types.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +import { zSortOrder } from "@karakeep/shared/types/bookmarks"; + +export const zStringBool = z + .string() + .refine((val) => val === "true" || val === "false", "Must be true or false") + .transform((val) => val === "true"); + +export const zIncludeContentSearchParamsSchema = z.object({ + // TODO: Change the default to false in a couple of releases. + includeContent: zStringBool.optional().default("true"), +}); + +export const zGetBookmarkQueryParamsSchema = z + .object({ + sortOrder: zSortOrder + .exclude([zSortOrder.Enum.relevance]) + .optional() + .default(zSortOrder.Enum.desc), + }) + .merge(zIncludeContentSearchParamsSchema); + +export const zGetBookmarkSearchParamsSchema = z + .object({ + sortOrder: zSortOrder.optional().default(zSortOrder.Enum.relevance), + }) + .merge(zIncludeContentSearchParamsSchema); diff --git a/packages/api/utils/upload.ts b/packages/api/utils/upload.ts new file mode 100644 index 00000000..d96a0f60 --- /dev/null +++ b/packages/api/utils/upload.ts @@ -0,0 +1,110 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { Readable } from "stream"; +import { pipeline } from "stream/promises"; + +import { assets, AssetTypes } from "@karakeep/db/schema"; +import { + newAssetId, + saveAssetFromFile, + SUPPORTED_UPLOAD_ASSET_TYPES, +} from "@karakeep/shared/assetdb"; +import serverConfig from "@karakeep/shared/config"; +import { AuthedContext } from "@karakeep/trpc"; + +const MAX_UPLOAD_SIZE_BYTES = serverConfig.maxAssetSizeMb * 1024 * 1024; + +// Helper to convert Web Stream to Node Stream (requires Node >= 16.5 / 14.18) +export function webStreamToNode( + webStream: ReadableStream, +): Readable { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + return Readable.fromWeb(webStream as any); // Type assertion might be needed +} + +export function toWebReadableStream( + nodeStream: fs.ReadStream, +): ReadableStream { + const reader = nodeStream as unknown as Readable; + + return new ReadableStream({ + start(controller) { + reader.on("data", (chunk) => controller.enqueue(new Uint8Array(chunk))); + reader.on("end", () => controller.close()); + reader.on("error", (err) => controller.error(err)); + }, + }); +} + +export async function uploadAsset( + user: AuthedContext["user"], + db: AuthedContext["db"], + formData: { file: File } | { image: File }, +): Promise< + | { error: string; status: 400 | 413 } + | { + assetId: string; + contentType: string; + fileName: string; + size: number; + } +> { + let data: File; + if ("file" in formData) { + data = formData.file; + } else { + data = formData.image; + } + + const contentType = data.type; + const fileName = data.name; + if (!SUPPORTED_UPLOAD_ASSET_TYPES.has(contentType)) { + return { error: "Unsupported asset type", status: 400 }; + } + if (data.size > MAX_UPLOAD_SIZE_BYTES) { + return { error: "Asset is too big", status: 413 }; + } + + let tempFilePath: string | undefined; + + try { + tempFilePath = path.join(os.tmpdir(), `karakeep-upload-${Date.now()}`); + await pipeline( + webStreamToNode(data.stream()), + fs.createWriteStream(tempFilePath), + ); + const [assetDb] = await db + .insert(assets) + .values({ + id: newAssetId(), + // Initially, uploads are uploaded for unknown purpose + // And without an attached bookmark. + assetType: AssetTypes.UNKNOWN, + bookmarkId: null, + userId: user.id, + contentType, + size: data.size, + fileName, + }) + .returning(); + + await saveAssetFromFile({ + userId: user.id, + assetId: assetDb.id, + assetPath: tempFilePath, + metadata: { contentType, fileName }, + }); + + return { + assetId: assetDb.id, + contentType, + size: data.size, + fileName, + }; + } finally { + if (tempFilePath) { + await fs.promises.unlink(tempFilePath).catch(() => ({})); + } + } +} -- cgit v1.2.3-70-g09d2