aboutsummaryrefslogtreecommitdiffstats
path: root/packages/api/utils
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-05-18 16:58:08 +0100
committerGitHub <noreply@github.com>2025-05-18 16:58:08 +0100
commit3505cb7d6416d101a4fcb1be27fc22e0171bacd2 (patch)
treeef9f55504b8a5b20add8c0ebe916972ab4ab0178 /packages/api/utils
parent74e74fa6425f072107de3a9bc9dd8f91c5ac9a7d (diff)
downloadkarakeep-3505cb7d6416d101a4fcb1be27fc22e0171bacd2.tar.zst
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
Diffstat (limited to 'packages/api/utils')
-rw-r--r--packages/api/utils/pagination.ts30
-rw-r--r--packages/api/utils/types.ts28
-rw-r--r--packages/api/utils/upload.ts110
3 files changed, 168 insertions, 0 deletions
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<typeof zCursorV2> | 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<Uint8Array>,
+): 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<Uint8Array> {
+ 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(() => ({}));
+ }
+ }
+}