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 --- apps/web/app/api/assets/[assetId]/route.ts | 85 -------------------- apps/web/app/api/assets/route.ts | 124 ----------------------------- 2 files changed, 209 deletions(-) delete mode 100644 apps/web/app/api/assets/[assetId]/route.ts delete mode 100644 apps/web/app/api/assets/route.ts (limited to 'apps/web/app/api/assets') diff --git a/apps/web/app/api/assets/[assetId]/route.ts b/apps/web/app/api/assets/[assetId]/route.ts deleted file mode 100644 index 8abb9080..00000000 --- a/apps/web/app/api/assets/[assetId]/route.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { createContextFromRequest } from "@/server/api/client"; -import { and, eq } from "drizzle-orm"; - -import { assets } from "@karakeep/db/schema"; -import { - createAssetReadStream, - getAssetSize, - readAssetMetadata, -} from "@karakeep/shared/assetdb"; - -export const dynamic = "force-dynamic"; -export async function GET( - request: Request, - { params }: { params: { assetId: string } }, -) { - const ctx = await createContextFromRequest(request); - if (!ctx.user) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const assetDb = await ctx.db.query.assets.findFirst({ - where: and(eq(assets.id, params.assetId), eq(assets.userId, ctx.user.id)), - }); - - if (!assetDb) { - return Response.json({ error: "Asset not found" }, { status: 404 }); - } - - const [metadata, size] = await Promise.all([ - readAssetMetadata({ - userId: ctx.user.id, - assetId: params.assetId, - }), - - getAssetSize({ - userId: ctx.user.id, - assetId: params.assetId, - }), - ]); - - const range = request.headers.get("Range"); - if (range) { - const parts = range.replace(/bytes=/, "").split("-"); - const start = parseInt(parts[0], 10); - const end = parts[1] ? parseInt(parts[1], 10) : size - 1; - - const stream = createAssetReadStream({ - userId: ctx.user.id, - assetId: params.assetId, - start, - end, - }); - - return new Response( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - stream as any, - { - status: 206, // Partial Content - headers: { - "Content-Range": `bytes ${start}-${end}/${size}`, - "Accept-Ranges": "bytes", - "Content-Length": (end - start + 1).toString(), - "Content-type": metadata.contentType, - }, - }, - ); - } else { - const stream = createAssetReadStream({ - userId: ctx.user.id, - assetId: params.assetId, - }); - - return new Response( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - stream as any, - { - status: 200, - headers: { - "Content-Length": size.toString(), - "Content-type": metadata.contentType, - }, - }, - ); - } -} diff --git a/apps/web/app/api/assets/route.ts b/apps/web/app/api/assets/route.ts deleted file mode 100644 index e2e1e63e..00000000 --- a/apps/web/app/api/assets/route.ts +++ /dev/null @@ -1,124 +0,0 @@ -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 { createContextFromRequest } from "@/server/api/client"; -import { TRPCError } from "@trpc/server"; - -import type { ZUploadResponse } from "@karakeep/shared/types/uploads"; -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; - -export const dynamic = "force-dynamic"; - -// Helper to convert Web Stream to Node Stream (requires Node >= 16.5 / 14.18) -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 async function uploadFromPostData( - user: AuthedContext["user"], - db: AuthedContext["db"], - formData: FormData, -): Promise< - | { error: string; status: number } - | { - assetId: string; - contentType: string; - fileName: string; - size: number; - } -> { - const data = formData.get("file") ?? formData.get("image"); - - if (!(data instanceof File)) { - return { error: "Bad request", status: 400 }; - } - - 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(() => ({})); - } - } -} - -export async function POST(request: Request) { - const ctx = await createContextFromRequest(request); - if (ctx.user === null) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - if (serverConfig.demoMode) { - throw new TRPCError({ - message: "Mutations are not allowed in demo mode", - code: "FORBIDDEN", - }); - } - const formData = await request.formData(); - - const resp = await uploadFromPostData(ctx.user, ctx.db, formData); - if ("error" in resp) { - return Response.json({ error: resp.error }, { status: resp.status }); - } - - return Response.json({ - assetId: resp.assetId, - contentType: resp.contentType, - size: resp.size, - fileName: resp.fileName, - } satisfies ZUploadResponse); -} -- cgit v1.2.3-70-g09d2