aboutsummaryrefslogtreecommitdiffstats
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
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
-rw-r--r--apps/web/app/api/[[...route]]/route.ts28
-rw-r--r--apps/web/app/api/assets/[assetId]/route.ts85
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts37
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts36
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/highlights/route.ts18
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts18
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts54
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/summarize/route.ts19
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts45
-rw-r--r--apps/web/app/api/v1/bookmarks/route.ts46
-rw-r--r--apps/web/app/api/v1/bookmarks/search/route.ts44
-rw-r--r--apps/web/app/api/v1/bookmarks/singlefile/route.ts54
-rw-r--r--apps/web/app/api/v1/highlights/[highlightId]/route.ts50
-rw-r--r--apps/web/app/api/v1/highlights/route.ts30
-rw-r--r--apps/web/app/api/v1/lists/[listId]/bookmarks/[bookmarkId]/route.ts35
-rw-r--r--apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts19
-rw-r--r--apps/web/app/api/v1/lists/[listId]/route.ts55
-rw-r--r--apps/web/app/api/v1/lists/route.ts26
-rw-r--r--apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts27
-rw-r--r--apps/web/app/api/v1/tags/[tagId]/route.ts55
-rw-r--r--apps/web/app/api/v1/tags/route.ts14
-rw-r--r--apps/web/app/api/v1/users/me/route.ts14
-rw-r--r--apps/web/app/api/v1/users/me/stats/route.ts14
-rw-r--r--apps/web/app/api/v1/utils/handler.ts170
-rw-r--r--apps/web/app/api/v1/utils/types.ts23
-rw-r--r--apps/web/package.json1
-rw-r--r--packages/api/index.ts46
-rw-r--r--packages/api/middlewares/auth.ts22
-rw-r--r--packages/api/middlewares/trpcAdapter.ts41
-rw-r--r--packages/api/package.json39
-rw-r--r--packages/api/routes/assets.ts97
-rw-r--r--packages/api/routes/bookmarks.ts252
-rw-r--r--packages/api/routes/highlights.ts54
-rw-r--r--packages/api/routes/lists.ts70
-rw-r--r--packages/api/routes/tags.ts60
-rw-r--r--packages/api/routes/users.ts20
-rw-r--r--packages/api/tsconfig.json13
-rw-r--r--packages/api/utils/pagination.ts (renamed from apps/web/app/api/v1/utils/pagination.ts)0
-rw-r--r--packages/api/utils/types.ts28
-rw-r--r--packages/api/utils/upload.ts (renamed from apps/web/app/api/assets/route.ts)64
-rw-r--r--packages/e2e_tests/tests/api/assets.test.ts4
-rw-r--r--packages/e2e_tests/utils/api.ts2
-rw-r--r--pnpm-lock.yaml71
43 files changed, 867 insertions, 1033 deletions
diff --git a/apps/web/app/api/[[...route]]/route.ts b/apps/web/app/api/[[...route]]/route.ts
new file mode 100644
index 00000000..8930671d
--- /dev/null
+++ b/apps/web/app/api/[[...route]]/route.ts
@@ -0,0 +1,28 @@
+import { createContextFromRequest } from "@/server/api/client";
+import { Hono } from "hono";
+import { createMiddleware } from "hono/factory";
+import { handle } from "hono/vercel";
+
+import allApp from "@karakeep/api";
+import { Context } from "@karakeep/trpc";
+
+export const runtime = "nodejs";
+
+export const nextAuth = createMiddleware<{
+ Variables: {
+ ctx: Context;
+ };
+}>(async (c, next) => {
+ const ctx = await createContextFromRequest(c.req.raw);
+ c.set("ctx", ctx);
+ await next();
+});
+
+const app = new Hono().basePath("/api").use(nextAuth).route("/", allApp);
+
+export const GET = handle(app);
+export const POST = handle(app);
+export const PATCH = handle(app);
+export const DELETE = handle(app);
+export const OPTIONS = handle(app);
+export const PUT = handle(app);
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/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts
deleted file mode 100644
index 88e203de..00000000
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-import { z } from "zod";
-
-export const dynamic = "force-dynamic";
-
-export const PUT = (
- req: NextRequest,
- params: { params: { bookmarkId: string; assetId: string } },
-) =>
- buildHandler({
- req,
- bodySchema: z.object({ assetId: z.string() }),
- handler: async ({ api, body }) => {
- await api.assets.replaceAsset({
- bookmarkId: params.params.bookmarkId,
- oldAssetId: params.params.assetId,
- newAssetId: body!.assetId,
- });
- return { status: 204 };
- },
- });
-
-export const DELETE = (
- req: NextRequest,
- params: { params: { bookmarkId: string; assetId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- await api.assets.detachAsset({
- bookmarkId: params.params.bookmarkId,
- assetId: params.params.assetId,
- });
- return { status: 204 };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts
deleted file mode 100644
index 6c7c70d7..00000000
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-import { zAssetSchema } from "@karakeep/shared/types/bookmarks";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (
- req: NextRequest,
- params: { params: { bookmarkId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const resp = await api.bookmarks.getBookmark({
- bookmarkId: params.params.bookmarkId,
- });
- return { status: 200, resp: { assets: resp.assets } };
- },
- });
-
-export const POST = (
- req: NextRequest,
- params: { params: { bookmarkId: string } },
-) =>
- buildHandler({
- req,
- bodySchema: zAssetSchema,
- handler: async ({ api, body }) => {
- const asset = await api.assets.attachAsset({
- bookmarkId: params.params.bookmarkId,
- asset: body!,
- });
- return { status: 201, resp: asset };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/highlights/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/highlights/route.ts
deleted file mode 100644
index 4e1f87a0..00000000
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/highlights/route.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (
- req: NextRequest,
- params: { params: { bookmarkId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const resp = await api.highlights.getForBookmark({
- bookmarkId: params.params.bookmarkId,
- });
- return { status: 200, resp };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts
deleted file mode 100644
index ad3052c9..00000000
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (
- req: NextRequest,
- params: { params: { bookmarkId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const resp = await api.lists.getListsOfBookmark({
- bookmarkId: params.params.bookmarkId,
- });
- return { status: 200, resp };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts
deleted file mode 100644
index 9ad18fd3..00000000
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-import { zUpdateBookmarksRequestSchema } from "@karakeep/shared/types/bookmarks";
-
-import { zGetBookmarkQueryParamsSchema } from "../../utils/types";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (
- req: NextRequest,
- { params }: { params: { bookmarkId: string } },
-) =>
- buildHandler({
- req,
- searchParamsSchema: zGetBookmarkQueryParamsSchema,
- handler: async ({ api, searchParams }) => {
- const bookmark = await api.bookmarks.getBookmark({
- bookmarkId: params.bookmarkId,
- includeContent: searchParams.includeContent,
- });
- return { status: 200, resp: bookmark };
- },
- });
-
-export const PATCH = (
- req: NextRequest,
- { params }: { params: { bookmarkId: string } },
-) =>
- buildHandler({
- req,
- bodySchema: zUpdateBookmarksRequestSchema.omit({ bookmarkId: true }),
- handler: async ({ api, body }) => {
- const bookmark = await api.bookmarks.updateBookmark({
- bookmarkId: params.bookmarkId,
- ...body!,
- });
- return { status: 200, resp: bookmark };
- },
- });
-
-export const DELETE = (
- req: NextRequest,
- { params }: { params: { bookmarkId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- await api.bookmarks.deleteBookmark({
- bookmarkId: params.bookmarkId,
- });
- return { status: 204 };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/summarize/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/summarize/route.ts
deleted file mode 100644
index ea41cad4..00000000
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/summarize/route.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-export const dynamic = "force-dynamic";
-
-export const POST = (
- req: NextRequest,
- params: { params: { bookmarkId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const bookmark = await api.bookmarks.summarizeBookmark({
- bookmarkId: params.params.bookmarkId,
- });
-
- return { status: 200, resp: bookmark };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts
deleted file mode 100644
index 00c28afa..00000000
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-import { z } from "zod";
-
-import { zManipulatedTagSchema } from "@karakeep/shared/types/bookmarks";
-
-export const dynamic = "force-dynamic";
-
-export const POST = (
- req: NextRequest,
- params: { params: { bookmarkId: string } },
-) =>
- buildHandler({
- req,
- bodySchema: z.object({
- tags: z.array(zManipulatedTagSchema),
- }),
- handler: async ({ api, body }) => {
- const resp = await api.bookmarks.updateTags({
- bookmarkId: params.params.bookmarkId,
- attach: body!.tags,
- detach: [],
- });
- return { status: 200, resp: { attached: resp.attached } };
- },
- });
-
-export const DELETE = (
- req: NextRequest,
- params: { params: { bookmarkId: string } },
-) =>
- buildHandler({
- req,
- bodySchema: z.object({
- tags: z.array(zManipulatedTagSchema),
- }),
- handler: async ({ api, body }) => {
- const resp = await api.bookmarks.updateTags({
- bookmarkId: params.params.bookmarkId,
- detach: body!.tags,
- attach: [],
- });
- return { status: 200, resp: { detached: resp.detached } };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/route.ts b/apps/web/app/api/v1/bookmarks/route.ts
deleted file mode 100644
index 4df4f6ad..00000000
--- a/apps/web/app/api/v1/bookmarks/route.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { NextRequest } from "next/server";
-import { z } from "zod";
-
-import {
- zNewBookmarkRequestSchema,
- zSortOrder,
-} from "@karakeep/shared/types/bookmarks";
-
-import { buildHandler } from "../utils/handler";
-import { adaptPagination, zPagination } from "../utils/pagination";
-import { zStringBool } from "../utils/types";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (req: NextRequest) =>
- buildHandler({
- req,
- searchParamsSchema: z
- .object({
- favourited: zStringBool.optional(),
- archived: zStringBool.optional(),
- sortOrder: zSortOrder
- .exclude([zSortOrder.Enum.relevance])
- .optional()
- .default(zSortOrder.Enum.desc),
- // TODO: Change the default to false in a couple of releases.
- includeContent: zStringBool.optional().default("true"),
- })
- .and(zPagination),
- handler: async ({ api, searchParams }) => {
- const bookmarks = await api.bookmarks.getBookmarks({
- ...searchParams,
- });
- return { status: 200, resp: adaptPagination(bookmarks) };
- },
- });
-
-export const POST = (req: NextRequest) =>
- buildHandler({
- req,
- bodySchema: zNewBookmarkRequestSchema,
- handler: async ({ api, body }) => {
- const bookmark = await api.bookmarks.createBookmark(body!);
- return { status: 201, resp: bookmark };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/search/route.ts b/apps/web/app/api/v1/bookmarks/search/route.ts
deleted file mode 100644
index e85c7954..00000000
--- a/apps/web/app/api/v1/bookmarks/search/route.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { NextRequest } from "next/server";
-import { z } from "zod";
-
-import { buildHandler } from "../../utils/handler";
-import { zGetBookmarkSearchParamsSchema } from "../../utils/types";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (req: NextRequest) =>
- buildHandler({
- req,
- searchParamsSchema: z
- .object({
- q: z.string(),
- limit: z.coerce.number().optional(),
- cursor: z
- .string()
- // Search cursor V1 is just a number
- .pipe(z.coerce.number())
- .transform((val) => {
- return { ver: 1 as const, offset: val };
- })
- .optional(),
- })
- .and(zGetBookmarkSearchParamsSchema),
- handler: async ({ api, searchParams }) => {
- const bookmarks = await api.bookmarks.searchBookmarks({
- text: searchParams.q,
- cursor: searchParams.cursor,
- sortOrder: searchParams.sortOrder,
- limit: searchParams.limit,
- includeContent: searchParams.includeContent,
- });
- return {
- status: 200,
- resp: {
- bookmarks: bookmarks.bookmarks,
- nextCursor: bookmarks.nextCursor
- ? `${bookmarks.nextCursor.offset}`
- : null,
- },
- };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/singlefile/route.ts b/apps/web/app/api/v1/bookmarks/singlefile/route.ts
deleted file mode 100644
index 7c1d7201..00000000
--- a/apps/web/app/api/v1/bookmarks/singlefile/route.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { createContextFromRequest } from "@/server/api/client";
-import { TRPCError } from "@trpc/server";
-
-import serverConfig from "@karakeep/shared/config";
-import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
-import { createCallerFactory } from "@karakeep/trpc";
-import { appRouter } from "@karakeep/trpc/routers/_app";
-
-import { uploadFromPostData } from "../../../assets/route";
-
-export const dynamic = "force-dynamic";
-
-export async function POST(req: Request) {
- const ctx = await createContextFromRequest(req);
- if (!ctx.user) {
- 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 req.formData();
- const up = await uploadFromPostData(ctx.user, ctx.db, formData);
-
- if ("error" in up) {
- return Response.json({ error: up.error }, { status: up.status });
- }
-
- const url = formData.get("url");
- if (!url) {
- throw new TRPCError({
- message: "URL is required",
- code: "BAD_REQUEST",
- });
- }
- if (typeof url !== "string") {
- throw new TRPCError({
- message: "URL must be a string",
- code: "BAD_REQUEST",
- });
- }
-
- const createCaller = createCallerFactory(appRouter);
- const api = createCaller(ctx);
-
- const bookmark = await api.bookmarks.createBookmark({
- type: BookmarkTypes.LINK,
- url,
- precrawledArchiveId: up.assetId,
- });
- return Response.json(bookmark, { status: 201 });
-}
diff --git a/apps/web/app/api/v1/highlights/[highlightId]/route.ts b/apps/web/app/api/v1/highlights/[highlightId]/route.ts
deleted file mode 100644
index 50420427..00000000
--- a/apps/web/app/api/v1/highlights/[highlightId]/route.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-import { zUpdateHighlightSchema } from "@karakeep/shared/types/highlights";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (
- req: NextRequest,
- { params }: { params: { highlightId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const highlight = await api.highlights.get({
- highlightId: params.highlightId,
- });
- return { status: 200, resp: highlight };
- },
- });
-
-export const PATCH = (
- req: NextRequest,
- { params }: { params: { highlightId: string } },
-) =>
- buildHandler({
- req,
- bodySchema: zUpdateHighlightSchema.omit({ highlightId: true }),
- handler: async ({ api, body }) => {
- const highlight = await api.highlights.update({
- highlightId: params.highlightId,
- ...body!,
- });
- return { status: 200, resp: highlight };
- },
- });
-
-export const DELETE = (
- req: NextRequest,
- { params }: { params: { highlightId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const highlight = await api.highlights.delete({
- highlightId: params.highlightId,
- });
- return { status: 200, resp: highlight };
- },
- });
diff --git a/apps/web/app/api/v1/highlights/route.ts b/apps/web/app/api/v1/highlights/route.ts
deleted file mode 100644
index e95d84f6..00000000
--- a/apps/web/app/api/v1/highlights/route.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-import { zNewHighlightSchema } from "@karakeep/shared/types/highlights";
-
-import { adaptPagination, zPagination } from "../utils/pagination";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (req: NextRequest) =>
- buildHandler({
- req,
- searchParamsSchema: zPagination,
- handler: async ({ api, searchParams }) => {
- const resp = await api.highlights.getAll({
- ...searchParams,
- });
- return { status: 200, resp: adaptPagination(resp) };
- },
- });
-
-export const POST = (req: NextRequest) =>
- buildHandler({
- req,
- bodySchema: zNewHighlightSchema,
- handler: async ({ body, api }) => {
- const resp = await api.highlights.create(body!);
- return { status: 201, resp };
- },
- });
diff --git a/apps/web/app/api/v1/lists/[listId]/bookmarks/[bookmarkId]/route.ts b/apps/web/app/api/v1/lists/[listId]/bookmarks/[bookmarkId]/route.ts
deleted file mode 100644
index 6efe2055..00000000
--- a/apps/web/app/api/v1/lists/[listId]/bookmarks/[bookmarkId]/route.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-export const dynamic = "force-dynamic";
-
-export const PUT = (
- req: NextRequest,
- { params }: { params: { listId: string; bookmarkId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- // TODO: PUT is supposed to be idempotent, but we currently fail if the bookmark is already in the list.
- await api.lists.addToList({
- listId: params.listId,
- bookmarkId: params.bookmarkId,
- });
- return { status: 204 };
- },
- });
-
-export const DELETE = (
- req: NextRequest,
- { params }: { params: { listId: string; bookmarkId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- await api.lists.removeFromList({
- listId: params.listId,
- bookmarkId: params.bookmarkId,
- });
- return { status: 204 };
- },
- });
diff --git a/apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts b/apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts
deleted file mode 100644
index daf78449..00000000
--- a/apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-import { adaptPagination, zPagination } from "@/app/api/v1/utils/pagination";
-import { zGetBookmarkQueryParamsSchema } from "@/app/api/v1/utils/types";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (req: NextRequest, params: { params: { listId: string } }) =>
- buildHandler({
- req,
- searchParamsSchema: zPagination.and(zGetBookmarkQueryParamsSchema),
- handler: async ({ api, searchParams }) => {
- const bookmarks = await api.bookmarks.getBookmarks({
- listId: params.params.listId,
- ...searchParams,
- });
- return { status: 200, resp: adaptPagination(bookmarks) };
- },
- });
diff --git a/apps/web/app/api/v1/lists/[listId]/route.ts b/apps/web/app/api/v1/lists/[listId]/route.ts
deleted file mode 100644
index 2cddbfdb..00000000
--- a/apps/web/app/api/v1/lists/[listId]/route.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-import { zEditBookmarkListSchema } from "@karakeep/shared/types/lists";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (
- req: NextRequest,
- { params }: { params: { listId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const list = await api.lists.get({
- listId: params.listId,
- });
- return {
- status: 200,
- resp: list,
- };
- },
- });
-
-export const PATCH = (
- req: NextRequest,
- { params }: { params: { listId: string } },
-) =>
- buildHandler({
- req,
- bodySchema: zEditBookmarkListSchema.omit({ listId: true }),
- handler: async ({ api, body }) => {
- const list = await api.lists.edit({
- ...body!,
- listId: params.listId,
- });
- return { status: 200, resp: list };
- },
- });
-
-export const DELETE = (
- req: NextRequest,
- { params }: { params: { listId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- await api.lists.delete({
- listId: params.listId,
- });
- return {
- status: 204,
- };
- },
- });
diff --git a/apps/web/app/api/v1/lists/route.ts b/apps/web/app/api/v1/lists/route.ts
deleted file mode 100644
index 5def2506..00000000
--- a/apps/web/app/api/v1/lists/route.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { NextRequest } from "next/server";
-
-import { zNewBookmarkListSchema } from "@karakeep/shared/types/lists";
-
-import { buildHandler } from "../utils/handler";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (req: NextRequest) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const lists = await api.lists.list();
- return { status: 200, resp: lists };
- },
- });
-
-export const POST = (req: NextRequest) =>
- buildHandler({
- req,
- bodySchema: zNewBookmarkListSchema,
- handler: async ({ api, body }) => {
- const list = await api.lists.create(body!);
- return { status: 201, resp: list };
- },
- });
diff --git a/apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts b/apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts
deleted file mode 100644
index aaa5087b..00000000
--- a/apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-import { adaptPagination, zPagination } from "@/app/api/v1/utils/pagination";
-import { zGetBookmarkQueryParamsSchema } from "@/app/api/v1/utils/types";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (
- req: NextRequest,
- { params }: { params: { tagId: string } },
-) =>
- buildHandler({
- req,
- searchParamsSchema: zPagination.and(zGetBookmarkQueryParamsSchema),
- handler: async ({ api, searchParams }) => {
- const bookmarks = await api.bookmarks.getBookmarks({
- tagId: params.tagId,
- sortOrder: searchParams.sortOrder,
- limit: searchParams.limit,
- cursor: searchParams.cursor,
- });
- return {
- status: 200,
- resp: adaptPagination(bookmarks),
- };
- },
- });
diff --git a/apps/web/app/api/v1/tags/[tagId]/route.ts b/apps/web/app/api/v1/tags/[tagId]/route.ts
deleted file mode 100644
index 234d952d..00000000
--- a/apps/web/app/api/v1/tags/[tagId]/route.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-import { zUpdateTagRequestSchema } from "@karakeep/shared/types/tags";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (
- req: NextRequest,
- { params }: { params: { tagId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const tag = await api.tags.get({
- tagId: params.tagId,
- });
- return {
- status: 200,
- resp: tag,
- };
- },
- });
-
-export const PATCH = (
- req: NextRequest,
- { params }: { params: { tagId: string } },
-) =>
- buildHandler({
- req,
- bodySchema: zUpdateTagRequestSchema.omit({ tagId: true }),
- handler: async ({ api, body }) => {
- const tag = await api.tags.update({
- tagId: params.tagId,
- ...body!,
- });
- return { status: 200, resp: tag };
- },
- });
-
-export const DELETE = (
- req: NextRequest,
- { params }: { params: { tagId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- await api.tags.delete({
- tagId: params.tagId,
- });
- return {
- status: 204,
- };
- },
- });
diff --git a/apps/web/app/api/v1/tags/route.ts b/apps/web/app/api/v1/tags/route.ts
deleted file mode 100644
index 9625820c..00000000
--- a/apps/web/app/api/v1/tags/route.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { NextRequest } from "next/server";
-
-import { buildHandler } from "../utils/handler";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (req: NextRequest) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const tags = await api.tags.list();
- return { status: 200, resp: tags };
- },
- });
diff --git a/apps/web/app/api/v1/users/me/route.ts b/apps/web/app/api/v1/users/me/route.ts
deleted file mode 100644
index bf0a3ba2..00000000
--- a/apps/web/app/api/v1/users/me/route.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { NextRequest } from "next/server";
-
-import { buildHandler } from "../../utils/handler";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (req: NextRequest) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const user = await api.users.whoami();
- return { status: 200, resp: user };
- },
- });
diff --git a/apps/web/app/api/v1/users/me/stats/route.ts b/apps/web/app/api/v1/users/me/stats/route.ts
deleted file mode 100644
index 359c3156..00000000
--- a/apps/web/app/api/v1/users/me/stats/route.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { NextRequest } from "next/server";
-
-import { buildHandler } from "../../../utils/handler";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (req: NextRequest) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const stats = await api.users.stats();
- return { status: 200, resp: stats };
- },
- });
diff --git a/apps/web/app/api/v1/utils/handler.ts b/apps/web/app/api/v1/utils/handler.ts
deleted file mode 100644
index 9154506d..00000000
--- a/apps/web/app/api/v1/utils/handler.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-import { NextRequest } from "next/server";
-import {
- createContextFromRequest,
- createTrcpClientFromCtx,
-} from "@/server/api/client";
-import { TRPCError } from "@trpc/server";
-import { z, ZodError } from "zod";
-
-import { Context } from "@karakeep/trpc";
-
-function trpcCodeToHttpCode(code: TRPCError["code"]) {
- switch (code) {
- case "BAD_REQUEST":
- case "PARSE_ERROR":
- return 400;
- case "UNAUTHORIZED":
- return 401;
- case "FORBIDDEN":
- return 403;
- case "NOT_FOUND":
- return 404;
- case "METHOD_NOT_SUPPORTED":
- return 405;
- case "TIMEOUT":
- return 408;
- case "PAYLOAD_TOO_LARGE":
- return 413;
- case "INTERNAL_SERVER_ERROR":
- return 500;
- default:
- return 500;
- }
-}
-
-interface ErrorMessage {
- path: (string | number)[];
- message: string;
-}
-
-function formatZodError(error: ZodError): string {
- if (!error.issues) {
- return error.message || "An unknown error occurred";
- }
-
- const errors: ErrorMessage[] = error.issues.map((issue) => ({
- path: issue.path,
- message: issue.message,
- }));
-
- const formattedErrors = errors.map((err) => {
- const path = err.path.join(".");
- return path ? `${path}: ${err.message}` : err.message;
- });
-
- return `${formattedErrors.join(", ")}`;
-}
-
-export interface TrpcAPIRequest<SearchParamsT, BodyType> {
- ctx: Context;
- api: ReturnType<typeof createTrcpClientFromCtx>;
- searchParams: SearchParamsT extends z.ZodTypeAny
- ? z.infer<SearchParamsT>
- : undefined;
- body: BodyType extends z.ZodTypeAny
- ? z.infer<BodyType> | undefined
- : undefined;
-}
-
-type SchemaType<T> = T extends z.ZodTypeAny
- ? z.infer<T> | undefined
- : undefined;
-
-export async function buildHandler<
- SearchParamsT extends z.ZodTypeAny | undefined,
- BodyT extends z.ZodTypeAny | undefined,
- InputT extends TrpcAPIRequest<SearchParamsT, BodyT>,
->({
- req,
- handler,
- searchParamsSchema,
- bodySchema,
-}: {
- req: NextRequest;
- handler: (req: InputT) => Promise<{ status: number; resp?: object }>;
- searchParamsSchema?: SearchParamsT | undefined;
- bodySchema?: BodyT | undefined;
-}) {
- try {
- const ctx = await createContextFromRequest(req);
- const api = createTrcpClientFromCtx(ctx);
-
- let searchParams: SchemaType<SearchParamsT> | undefined = undefined;
- if (searchParamsSchema !== undefined) {
- searchParams = searchParamsSchema.parse(
- Object.fromEntries(req.nextUrl.searchParams.entries()),
- ) as SchemaType<SearchParamsT>;
- }
-
- let body: SchemaType<BodyT> | undefined = undefined;
- if (bodySchema) {
- if (req.headers.get("Content-Type") !== "application/json") {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Content-Type must be application/json",
- });
- }
-
- let bodyJson = undefined;
- try {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- bodyJson = await req.json();
- } catch (e) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: `Invalid JSON: ${(e as Error).message}`,
- });
- }
- body = bodySchema.parse(bodyJson) as SchemaType<BodyT>;
- }
-
- const { status, resp } = await handler({
- ctx,
- api,
- searchParams,
- body,
- } as InputT);
-
- return new Response(resp ? JSON.stringify(resp) : null, {
- status,
- headers: {
- "Content-Type": "application/json",
- },
- });
- } catch (e) {
- if (e instanceof ZodError) {
- return new Response(
- JSON.stringify({ code: "ParseError", message: formatZodError(e) }),
- {
- status: 400,
- headers: {
- "Content-Type": "application/json",
- },
- },
- );
- }
- if (e instanceof TRPCError) {
- let message = e.message;
- if (e.cause instanceof ZodError) {
- message = formatZodError(e.cause);
- }
- return new Response(JSON.stringify({ code: e.code, error: message }), {
- status: trpcCodeToHttpCode(e.code),
- headers: {
- "Content-Type": "application/json",
- },
- });
- } else {
- const error = e as Error;
- console.error(
- `Unexpected error in: ${req.method} ${req.nextUrl.pathname}:\n${error.stack}`,
- );
- return new Response(JSON.stringify({ code: "UnknownError" }), {
- status: 500,
- headers: {
- "Content-Type": "application/json",
- },
- });
- }
- }
-}
diff --git a/apps/web/app/api/v1/utils/types.ts b/apps/web/app/api/v1/utils/types.ts
deleted file mode 100644
index bf181ce4..00000000
--- a/apps/web/app/api/v1/utils/types.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-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 zGetBookmarkQueryParamsSchema = z.object({
- sortOrder: zSortOrder
- .exclude([zSortOrder.Enum.relevance])
- .optional()
- .default(zSortOrder.Enum.desc),
- // TODO: Change the default to false in a couple of releases.
- includeContent: zStringBool.optional().default("true"),
-});
-
-export const zGetBookmarkSearchParamsSchema = z.object({
- sortOrder: zSortOrder.optional().default(zSortOrder.Enum.relevance),
- // TODO: Change the default to false in a couple of releases.
- includeContent: zStringBool.optional().default("true"),
-});
diff --git a/apps/web/package.json b/apps/web/package.json
index 34e4752a..062565d8 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -21,6 +21,7 @@
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^3.3.4",
+ "@karakeep/api": "workspace:^0.1.0",
"@karakeep/db": "workspace:^0.1.0",
"@karakeep/shared": "workspace:^0.1.0",
"@karakeep/shared-react": "workspace:^0.1.0",
diff --git a/packages/api/index.ts b/packages/api/index.ts
new file mode 100644
index 00000000..00919f3e
--- /dev/null
+++ b/packages/api/index.ts
@@ -0,0 +1,46 @@
+import { Hono } from "hono";
+import { logger } from "hono/logger";
+import { poweredBy } from "hono/powered-by";
+
+import { Context } from "@karakeep/trpc";
+
+import trpcAdapter from "./middlewares/trpcAdapter";
+import assets from "./routes/assets";
+import bookmarks from "./routes/bookmarks";
+import highlights from "./routes/highlights";
+import lists from "./routes/lists";
+import tags from "./routes/tags";
+import users from "./routes/users";
+
+const v1 = new Hono<{
+ Variables: {
+ ctx: Context;
+ };
+}>()
+ .route("/highlights", highlights)
+ .route("/bookmarks", bookmarks)
+ .route("/lists", lists)
+ .route("/tags", tags)
+ .route("/users", users)
+ .route("/assets", assets);
+
+const app = new Hono<{
+ Variables: {
+ // This is going to be coming from the web app
+ ctx: Context;
+ };
+}>()
+ .use(logger())
+ .use(poweredBy())
+ .use(async (c, next) => {
+ // Ensure that the ctx is set
+ if (!c.var.ctx) {
+ throw new Error("Context is not set");
+ }
+ await next();
+ })
+ .use(trpcAdapter)
+ .route("/v1", v1)
+ .route("/assets", assets);
+
+export default app;
diff --git a/packages/api/middlewares/auth.ts b/packages/api/middlewares/auth.ts
new file mode 100644
index 00000000..7f39a6f9
--- /dev/null
+++ b/packages/api/middlewares/auth.ts
@@ -0,0 +1,22 @@
+import { createMiddleware } from "hono/factory";
+import { HTTPException } from "hono/http-exception";
+
+import { AuthedContext, createCallerFactory } from "@karakeep/trpc";
+import { appRouter } from "@karakeep/trpc/routers/_app";
+
+const createCaller = createCallerFactory(appRouter);
+
+export const authMiddleware = createMiddleware<{
+ Variables: {
+ ctx: AuthedContext;
+ api: ReturnType<typeof createCaller>;
+ };
+}>(async (c, next) => {
+ if (!c.var.ctx || !c.var.ctx.user || c.var.ctx.user === null) {
+ throw new HTTPException(401, {
+ message: "Unauthorized",
+ });
+ }
+ c.set("api", createCaller(c.get("ctx")));
+ await next();
+});
diff --git a/packages/api/middlewares/trpcAdapter.ts b/packages/api/middlewares/trpcAdapter.ts
new file mode 100644
index 00000000..6bb4a790
--- /dev/null
+++ b/packages/api/middlewares/trpcAdapter.ts
@@ -0,0 +1,41 @@
+import { TRPCError } from "@trpc/server";
+import { createMiddleware } from "hono/factory";
+import { HTTPException } from "hono/http-exception";
+
+function trpcCodeToHttpCode(code: TRPCError["code"]) {
+ switch (code) {
+ case "BAD_REQUEST":
+ case "PARSE_ERROR":
+ return 400;
+ case "UNAUTHORIZED":
+ return 401;
+ case "FORBIDDEN":
+ return 403;
+ case "NOT_FOUND":
+ return 404;
+ case "METHOD_NOT_SUPPORTED":
+ return 405;
+ case "TIMEOUT":
+ return 408;
+ case "PAYLOAD_TOO_LARGE":
+ return 413;
+ case "INTERNAL_SERVER_ERROR":
+ return 500;
+ default:
+ return 500;
+ }
+}
+
+const trpcAdapter = createMiddleware(async (c, next) => {
+ await next();
+ const e = c.error;
+ if (e instanceof TRPCError) {
+ const code = trpcCodeToHttpCode(e.code);
+ throw new HTTPException(code, {
+ message: e.message,
+ cause: e.cause,
+ });
+ }
+});
+
+export default trpcAdapter;
diff --git a/packages/api/package.json b/packages/api/package.json
new file mode 100644
index 00000000..f968ed94
--- /dev/null
+++ b/packages/api/package.json
@@ -0,0 +1,39 @@
+{
+ "$schema": "https://json.schemastore.org/package.json",
+ "name": "@karakeep/api",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "format": "prettier . --ignore-path ../../.prettierignore",
+ "format:fix": "prettier . --write --ignore-path ../../.prettierignore",
+ "lint": "eslint .",
+ "lint:fix": "eslint . --fix",
+ "test": "vitest"
+ },
+ "dependencies": {
+ "@hono/zod-validator": "^0.5.0",
+ "@karakeep/db": "workspace:*",
+ "@karakeep/shared": "workspace:*",
+ "@karakeep/trpc": "workspace:*",
+ "hono": "^4.7.10",
+ "zod": "^3.24.2"
+ },
+ "devDependencies": {
+ "@karakeep/eslint-config": "workspace:^0.2.0",
+ "@karakeep/prettier-config": "workspace:^0.1.0",
+ "@karakeep/tsconfig": "workspace:^0.1.0",
+ "@types/bcryptjs": "^2.4.6",
+ "@types/deep-equal": "^1.0.4",
+ "vite-tsconfig-paths": "^4.3.1",
+ "vitest": "^1.6.1"
+ },
+ "eslintConfig": {
+ "root": true,
+ "extends": [
+ "@karakeep/eslint-config/base"
+ ]
+ },
+ "prettier": "@karakeep/prettier-config"
+}
diff --git a/packages/api/routes/assets.ts b/packages/api/routes/assets.ts
new file mode 100644
index 00000000..de4e384d
--- /dev/null
+++ b/packages/api/routes/assets.ts
@@ -0,0 +1,97 @@
+import { zValidator } from "@hono/zod-validator";
+import { and, eq } from "drizzle-orm";
+import { Hono } from "hono";
+import { stream } from "hono/streaming";
+import { z } from "zod";
+
+import { assets } from "@karakeep/db/schema";
+import {
+ createAssetReadStream,
+ getAssetSize,
+ readAssetMetadata,
+} from "@karakeep/shared/assetdb";
+
+import { authMiddleware } from "../middlewares/auth";
+import { toWebReadableStream, uploadAsset } from "../utils/upload";
+
+const app = new Hono()
+ .use(authMiddleware)
+ .post(
+ "/",
+ zValidator(
+ "form",
+ z
+ .object({ file: z.instanceof(File) })
+ .or(z.object({ image: z.instanceof(File) })),
+ ),
+ async (c) => {
+ const body = c.req.valid("form");
+ const up = await uploadAsset(c.var.ctx.user, c.var.ctx.db, body);
+ if ("error" in up) {
+ return c.json({ error: up.error }, up.status);
+ }
+ return c.json({
+ assetId: up.assetId,
+ contentType: up.contentType,
+ size: up.size,
+ fileName: up.fileName,
+ });
+ },
+ )
+ .get("/:assetId", async (c) => {
+ const assetId = c.req.param("assetId");
+ const assetDb = await c.var.ctx.db.query.assets.findFirst({
+ where: and(eq(assets.id, assetId), eq(assets.userId, c.var.ctx.user.id)),
+ });
+
+ if (!assetDb) {
+ return c.json({ error: "Asset not found" }, { status: 404 });
+ }
+
+ const [metadata, size] = await Promise.all([
+ readAssetMetadata({
+ userId: c.var.ctx.user.id,
+ assetId,
+ }),
+
+ getAssetSize({
+ userId: c.var.ctx.user.id,
+ assetId,
+ }),
+ ]);
+
+ const range = c.req.header("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 fStream = createAssetReadStream({
+ userId: c.var.ctx.user.id,
+ assetId,
+ start,
+ end,
+ });
+ c.status(206); // Partial Content
+ c.header("Content-Range", `bytes ${start}-${end}/${size}`);
+ c.header("Accept-Ranges", "bytes");
+ c.header("Content-Length", (end - start + 1).toString());
+ c.header("Content-type", metadata.contentType);
+ return stream(c, async (stream) => {
+ await stream.pipe(toWebReadableStream(fStream));
+ });
+ } else {
+ const fStream = createAssetReadStream({
+ userId: c.var.ctx.user.id,
+ assetId,
+ });
+ c.status(200);
+ c.header("Content-Length", size.toString());
+ c.header("Content-type", metadata.contentType);
+ return stream(c, async (stream) => {
+ await stream.pipe(toWebReadableStream(fStream));
+ });
+ }
+ });
+
+export default app;
diff --git a/packages/api/routes/bookmarks.ts b/packages/api/routes/bookmarks.ts
new file mode 100644
index 00000000..fbc46d2f
--- /dev/null
+++ b/packages/api/routes/bookmarks.ts
@@ -0,0 +1,252 @@
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+import { z } from "zod";
+
+import {
+ BookmarkTypes,
+ zAssetSchema,
+ zManipulatedTagSchema,
+ zNewBookmarkRequestSchema,
+ zUpdateBookmarksRequestSchema,
+} from "@karakeep/shared/types/bookmarks";
+
+import { authMiddleware } from "../middlewares/auth";
+import { adaptPagination, zPagination } from "../utils/pagination";
+import {
+ zGetBookmarkQueryParamsSchema,
+ zGetBookmarkSearchParamsSchema,
+ zIncludeContentSearchParamsSchema,
+ zStringBool,
+} from "../utils/types";
+import { uploadAsset } from "../utils/upload";
+
+const app = new Hono()
+ .use(authMiddleware)
+
+ // GET /bookmarks
+ .get(
+ "/",
+ zValidator(
+ "query",
+ z
+ .object({
+ favourited: zStringBool.optional(),
+ archived: zStringBool.optional(),
+ })
+ .and(zGetBookmarkQueryParamsSchema)
+ .and(zPagination),
+ ),
+ async (c) => {
+ const searchParams = c.req.valid("query");
+ const bookmarks = await c.var.api.bookmarks.getBookmarks(searchParams);
+ return c.json(adaptPagination(bookmarks), 200);
+ },
+ )
+
+ // POST /bookmarks
+ .post("/", zValidator("json", zNewBookmarkRequestSchema), async (c) => {
+ const body = c.req.valid("json");
+ const bookmark = await c.var.api.bookmarks.createBookmark(body);
+ return c.json(bookmark, 201);
+ })
+
+ // GET /bookmarks/search
+ .get(
+ "/search",
+ zValidator(
+ "query",
+ z
+ .object({
+ q: z.string(),
+ limit: z.coerce.number().optional(),
+ cursor: z
+ .string()
+ .optional()
+ .transform((val) =>
+ val ? { ver: 1 as const, offset: parseInt(val) } : undefined,
+ ),
+ })
+ .and(zGetBookmarkSearchParamsSchema),
+ ),
+ async (c) => {
+ const searchParams = c.req.valid("query");
+ const bookmarks = await c.var.api.bookmarks.searchBookmarks({
+ text: searchParams.q,
+ cursor: searchParams.cursor,
+ limit: searchParams.limit,
+ includeContent: searchParams.includeContent,
+ });
+ return c.json(
+ {
+ bookmarks: bookmarks.bookmarks,
+ nextCursor: bookmarks.nextCursor
+ ? `${bookmarks.nextCursor.offset}`
+ : null,
+ },
+ 200,
+ );
+ },
+ )
+ .post(
+ "/singlefile",
+ zValidator(
+ "form",
+ z.object({
+ url: z.string(),
+ file: z.instanceof(File),
+ }),
+ ),
+ async (c) => {
+ const form = c.req.valid("form");
+ const up = await uploadAsset(c.var.ctx.user, c.var.ctx.db, form);
+ if ("error" in up) {
+ return c.json({ error: up.error }, up.status);
+ }
+ const bookmark = await c.var.api.bookmarks.createBookmark({
+ type: BookmarkTypes.LINK,
+ url: form.url,
+ precrawledArchiveId: up.assetId,
+ });
+ return c.json(bookmark, 201);
+ },
+ )
+
+ // GET /bookmarks/[bookmarkId]
+ .get(
+ "/:bookmarkId",
+ zValidator("query", zIncludeContentSearchParamsSchema),
+ async (c) => {
+ const bookmarkId = c.req.param("bookmarkId");
+ const searchParams = c.req.valid("query");
+ const bookmark = await c.var.api.bookmarks.getBookmark({
+ bookmarkId,
+ includeContent: searchParams.includeContent,
+ });
+ return c.json(bookmark, 200);
+ },
+ )
+
+ // PATCH /bookmarks/[bookmarkId]
+ .patch(
+ "/:bookmarkId",
+ zValidator(
+ "json",
+ zUpdateBookmarksRequestSchema.omit({ bookmarkId: true }),
+ ),
+ async (c) => {
+ const bookmarkId = c.req.param("bookmarkId");
+ const body = c.req.valid("json");
+ const bookmark = await c.var.api.bookmarks.updateBookmark({
+ bookmarkId,
+ ...body,
+ });
+ return c.json(bookmark, 200);
+ },
+ )
+
+ // DELETE /bookmarks/[bookmarkId]
+ .delete("/:bookmarkId", async (c) => {
+ const bookmarkId = c.req.param("bookmarkId");
+ await c.var.api.bookmarks.deleteBookmark({ bookmarkId });
+ return c.body(null, 204);
+ })
+
+ // GET /bookmarks/[bookmarkId]/lists
+ .get("/:bookmarkId/lists", async (c) => {
+ const bookmarkId = c.req.param("bookmarkId");
+ const resp = await c.var.api.lists.getListsOfBookmark({ bookmarkId });
+ return c.json(resp, 200);
+ })
+
+ // GET /bookmarks/[bookmarkId]/assets
+ .get("/:bookmarkId/assets", async (c) => {
+ const bookmarkId = c.req.param("bookmarkId");
+ const resp = await c.var.api.bookmarks.getBookmark({ bookmarkId });
+ return c.json({ assets: resp.assets }, 200);
+ })
+
+ // POST /bookmarks/[bookmarkId]/assets
+ .post("/:bookmarkId/assets", zValidator("json", zAssetSchema), async (c) => {
+ const bookmarkId = c.req.param("bookmarkId");
+ const body = c.req.valid("json");
+ const asset = await c.var.api.assets.attachAsset({
+ bookmarkId,
+ asset: body,
+ });
+ return c.json(asset, 201);
+ })
+
+ // PUT /bookmarks/[bookmarkId]/assets/[assetId]
+ .put(
+ "/:bookmarkId/assets/:assetId",
+ zValidator("json", z.object({ assetId: z.string() })),
+ async (c) => {
+ const bookmarkId = c.req.param("bookmarkId");
+ const assetId = c.req.param("assetId");
+ const body = c.req.valid("json");
+ await c.var.api.assets.replaceAsset({
+ bookmarkId,
+ oldAssetId: assetId,
+ newAssetId: body.assetId,
+ });
+ return c.body(null, 204);
+ },
+ )
+
+ // DELETE /bookmarks/[bookmarkId]/assets/[assetId]
+ .delete("/:bookmarkId/assets/:assetId", async (c) => {
+ const bookmarkId = c.req.param("bookmarkId");
+ const assetId = c.req.param("assetId");
+ await c.var.api.assets.detachAsset({ bookmarkId, assetId });
+ return c.body(null, 204);
+ })
+
+ // POST /bookmarks/[bookmarkId]/tags
+ .post(
+ "/:bookmarkId/tags",
+ zValidator("json", z.object({ tags: z.array(zManipulatedTagSchema) })),
+ async (c) => {
+ const bookmarkId = c.req.param("bookmarkId");
+ const body = c.req.valid("json");
+ const resp = await c.var.api.bookmarks.updateTags({
+ bookmarkId,
+ attach: body.tags,
+ detach: [],
+ });
+ return c.json({ attached: resp.attached }, 200);
+ },
+ )
+
+ // DELETE /bookmarks/[bookmarkId]/tags
+ .delete(
+ "/:bookmarkId/tags",
+ zValidator("json", z.object({ tags: z.array(zManipulatedTagSchema) })),
+ async (c) => {
+ const bookmarkId = c.req.param("bookmarkId");
+ const body = c.req.valid("json");
+ const resp = await c.var.api.bookmarks.updateTags({
+ bookmarkId,
+ detach: body.tags,
+ attach: [],
+ });
+ return c.json({ detached: resp.detached }, 200);
+ },
+ )
+
+ // POST /bookmarks/[bookmarkId]/summarize
+ .post("/:bookmarkId/summarize", async (c) => {
+ const bookmarkId = c.req.param("bookmarkId");
+ const bookmark = await c.var.api.bookmarks.summarizeBookmark({
+ bookmarkId,
+ });
+ return c.json(bookmark, 200);
+ })
+
+ // GET /bookmarks/[bookmarkId]/highlights
+ .get("/:bookmarkId/highlights", async (c) => {
+ const bookmarkId = c.req.param("bookmarkId");
+ const resp = await c.var.api.highlights.getForBookmark({ bookmarkId });
+ return c.json(resp, 200);
+ });
+
+export default app;
diff --git a/packages/api/routes/highlights.ts b/packages/api/routes/highlights.ts
new file mode 100644
index 00000000..d381f7e2
--- /dev/null
+++ b/packages/api/routes/highlights.ts
@@ -0,0 +1,54 @@
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+
+import {
+ zNewHighlightSchema,
+ zUpdateHighlightSchema,
+} from "@karakeep/shared/types/highlights";
+
+import { authMiddleware } from "../middlewares/auth";
+import { adaptPagination, zPagination } from "../utils/pagination";
+
+const app = new Hono()
+ .use(authMiddleware)
+ .get("/", zValidator("query", zPagination), async (c) => {
+ const searchParams = c.req.valid("query");
+ const resp = await c.var.api.highlights.getAll({
+ ...searchParams,
+ });
+ return c.json(adaptPagination(resp));
+ })
+ .post("/", zValidator("json", zNewHighlightSchema), async (c) => {
+ const body = c.req.valid("json");
+ const resp = await c.var.api.highlights.create(body);
+ return c.json(resp, 201);
+ })
+ .get("/:highlightId", async (c) => {
+ const highlightId = c.req.param("highlightId");
+ const highlight = await c.var.api.highlights.get({
+ highlightId,
+ });
+ return c.json(highlight, 200);
+ })
+ .patch(
+ "/:highlightId",
+ zValidator("json", zUpdateHighlightSchema.omit({ highlightId: true })),
+ async (c) => {
+ const highlightId = c.req.param("highlightId");
+ const body = c.req.valid("json");
+ const highlight = await c.var.api.highlights.update({
+ highlightId,
+ ...body,
+ });
+ return c.json(highlight, 200);
+ },
+ )
+ .delete("/:highlightId", async (c) => {
+ const highlightId = c.req.param("highlightId");
+ const highlight = await c.var.api.highlights.delete({
+ highlightId,
+ });
+ return c.json(highlight, 200);
+ });
+
+export default app;
diff --git a/packages/api/routes/lists.ts b/packages/api/routes/lists.ts
new file mode 100644
index 00000000..33908629
--- /dev/null
+++ b/packages/api/routes/lists.ts
@@ -0,0 +1,70 @@
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+
+import {
+ zEditBookmarkListSchema,
+ zNewBookmarkListSchema,
+} from "@karakeep/shared/types/lists";
+
+import { authMiddleware } from "../middlewares/auth";
+import { adaptPagination, zPagination } from "../utils/pagination";
+import { zGetBookmarkQueryParamsSchema } from "../utils/types";
+
+const app = new Hono()
+ .use(authMiddleware)
+ .get("/", async (c) => {
+ const lists = await c.var.api.lists.list();
+ return c.json(lists, 200);
+ })
+ .post("/", zValidator("json", zNewBookmarkListSchema), async (c) => {
+ const body = c.req.valid("json");
+ const list = await c.var.api.lists.create(body);
+ return c.json(list, 201);
+ })
+ .get("/:listId", async (c) => {
+ const listId = c.req.param("listId");
+ const list = await c.var.api.lists.get({ listId });
+ return c.json(list, 200);
+ })
+ .patch(
+ "/:listId",
+ zValidator("json", zEditBookmarkListSchema.omit({ listId: true })),
+ async (c) => {
+ const listId = c.req.param("listId");
+ const body = c.req.valid("json");
+ const list = await c.var.api.lists.edit({ ...body, listId });
+ return c.json(list, 200);
+ },
+ )
+ .delete("/:listId", async (c) => {
+ const listId = c.req.param("listId");
+ await c.var.api.lists.delete({ listId });
+ return c.body(null, 204);
+ })
+ .get(
+ "/:listId/bookmarks",
+ zValidator("query", zPagination.and(zGetBookmarkQueryParamsSchema)),
+ async (c) => {
+ const listId = c.req.param("listId");
+ const searchParams = c.req.valid("query");
+ const bookmarks = await c.var.api.bookmarks.getBookmarks({
+ listId,
+ ...searchParams,
+ });
+ return c.json(adaptPagination(bookmarks), 200);
+ },
+ )
+ .put("/:listId/bookmarks/:bookmarkId", async (c) => {
+ const listId = c.req.param("listId");
+ const bookmarkId = c.req.param("bookmarkId");
+ await c.var.api.lists.addToList({ listId, bookmarkId });
+ return c.body(null, 204);
+ })
+ .delete("/:listId/bookmarks/:bookmarkId", async (c) => {
+ const listId = c.req.param("listId");
+ const bookmarkId = c.req.param("bookmarkId");
+ await c.var.api.lists.removeFromList({ listId, bookmarkId });
+ return c.body(null, 204);
+ });
+
+export default app;
diff --git a/packages/api/routes/tags.ts b/packages/api/routes/tags.ts
new file mode 100644
index 00000000..6d4cf39d
--- /dev/null
+++ b/packages/api/routes/tags.ts
@@ -0,0 +1,60 @@
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+
+import { zUpdateTagRequestSchema } from "@karakeep/shared/types/tags";
+
+import { authMiddleware } from "../middlewares/auth";
+import { adaptPagination, zPagination } from "../utils/pagination";
+import { zGetBookmarkQueryParamsSchema } from "../utils/types";
+
+const app = new Hono()
+ .use(authMiddleware)
+
+ // GET /tags
+ .get("/", async (c) => {
+ const tags = await c.var.api.tags.list();
+ return c.json(tags, 200);
+ })
+
+ // GET /tags/[tagId]
+ .get("/:tagId", async (c) => {
+ const tagId = c.req.param("tagId");
+ const tag = await c.var.api.tags.get({ tagId });
+ return c.json(tag, 200);
+ })
+
+ // PATCH /tags/[tagId]
+ .patch(
+ "/:tagId",
+ zValidator("json", zUpdateTagRequestSchema.omit({ tagId: true })),
+ async (c) => {
+ const tagId = c.req.param("tagId");
+ const body = c.req.valid("json");
+ const tag = await c.var.api.tags.update({ tagId, ...body });
+ return c.json(tag, 200);
+ },
+ )
+
+ // DELETE /tags/[tagId]
+ .delete("/:tagId", async (c) => {
+ const tagId = c.req.param("tagId");
+ await c.var.api.tags.delete({ tagId });
+ return c.body(null, 204);
+ })
+
+ // GET /tags/[tagId]/bookmarks
+ .get(
+ "/:tagId/bookmarks",
+ zValidator("query", zPagination.and(zGetBookmarkQueryParamsSchema)),
+ async (c) => {
+ const tagId = c.req.param("tagId");
+ const searchParams = c.req.valid("query");
+ const bookmarks = await c.var.api.bookmarks.getBookmarks({
+ tagId,
+ ...searchParams,
+ });
+ return c.json(adaptPagination(bookmarks), 200);
+ },
+ );
+
+export default app;
diff --git a/packages/api/routes/users.ts b/packages/api/routes/users.ts
new file mode 100644
index 00000000..81177fe3
--- /dev/null
+++ b/packages/api/routes/users.ts
@@ -0,0 +1,20 @@
+import { Hono } from "hono";
+
+import { authMiddleware } from "../middlewares/auth";
+
+const app = new Hono()
+ .use(authMiddleware)
+
+ // GET /users/me
+ .get("/me", async (c) => {
+ const user = await c.var.api.users.whoami();
+ return c.json(user, 200);
+ })
+
+ // GET /users/me/stats
+ .get("/me/stats", async (c) => {
+ const stats = await c.var.api.users.stats();
+ return c.json(stats, 200);
+ });
+
+export default app;
diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json
new file mode 100644
index 00000000..0036ccfa
--- /dev/null
+++ b/packages/api/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "@karakeep/tsconfig/node.json",
+ "include": [
+ "**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ],
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
+ }
+}
diff --git a/apps/web/app/api/v1/utils/pagination.ts b/packages/api/utils/pagination.ts
index 12a0b950..12a0b950 100644
--- a/apps/web/app/api/v1/utils/pagination.ts
+++ b/packages/api/utils/pagination.ts
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/apps/web/app/api/assets/route.ts b/packages/api/utils/upload.ts
index e2e1e63e..d96a0f60 100644
--- a/apps/web/app/api/assets/route.ts
+++ b/packages/api/utils/upload.ts
@@ -3,10 +3,7 @@ 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,
@@ -18,20 +15,34 @@ 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<Uint8Array>): Readable {
+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 async function uploadFromPostData(
+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: FormData,
+ formData: { file: File } | { image: File },
): Promise<
- | { error: string; status: number }
+ | { error: string; status: 400 | 413 }
| {
assetId: string;
contentType: string;
@@ -39,10 +50,11 @@ export async function uploadFromPostData(
size: number;
}
> {
- const data = formData.get("file") ?? formData.get("image");
-
- if (!(data instanceof File)) {
- return { error: "Bad request", status: 400 };
+ let data: File;
+ if ("file" in formData) {
+ data = formData.file;
+ } else {
+ data = formData.image;
}
const contentType = data.type;
@@ -96,29 +108,3 @@ export async function uploadFromPostData(
}
}
}
-
-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);
-}
diff --git a/packages/e2e_tests/tests/api/assets.test.ts b/packages/e2e_tests/tests/api/assets.test.ts
index 5c294929..78a5c7fe 100644
--- a/packages/e2e_tests/tests/api/assets.test.ts
+++ b/packages/e2e_tests/tests/api/assets.test.ts
@@ -39,7 +39,7 @@ describe("Assets API", () => {
// Retrieve the asset
const resp = await fetch(
- `http://localhost:${port}/api/assets/${uploadResponse.assetId}`,
+ `http://localhost:${port}/api/v1/assets/${uploadResponse.assetId}`,
{
headers: {
authorization: `Bearer ${apiKey}`,
@@ -123,7 +123,7 @@ describe("Assets API", () => {
// Verify asset is deleted
const assetResponse = await fetch(
- `http://localhost:${port}/api/assets/${uploadResponse.assetId}`,
+ `http://localhost:${port}/api/v1/assets/${uploadResponse.assetId}`,
{
headers: {
authorization: `Bearer ${apiKey}`,
diff --git a/packages/e2e_tests/utils/api.ts b/packages/e2e_tests/utils/api.ts
index 84a6eb91..9f9052fc 100644
--- a/packages/e2e_tests/utils/api.ts
+++ b/packages/e2e_tests/utils/api.ts
@@ -15,7 +15,7 @@ export async function uploadTestAsset(
const formData = new FormData();
formData.append("file", file);
- const response = await fetch(`http://localhost:${port}/api/assets`, {
+ const response = await fetch(`http://localhost:${port}/api/v1/assets`, {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 36cc420a..f5e92b9c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -515,6 +515,9 @@ importers:
'@hookform/resolvers':
specifier: ^3.3.4
version: 3.3.4(react-hook-form@7.50.1(react@18.3.1))
+ '@karakeep/api':
+ specifier: workspace:^0.1.0
+ version: link:../../packages/api
'@karakeep/db':
specifier: workspace:^0.1.0
version: link:../../packages/db
@@ -960,6 +963,49 @@ importers:
specifier: ^5.7.3
version: 5.7.3
+ packages/api:
+ dependencies:
+ '@hono/zod-validator':
+ specifier: ^0.5.0
+ version: 0.5.0(hono@4.7.10)(zod@3.24.2)
+ '@karakeep/db':
+ specifier: workspace:*
+ version: link:../db
+ '@karakeep/shared':
+ specifier: workspace:*
+ version: link:../shared
+ '@karakeep/trpc':
+ specifier: workspace:*
+ version: link:../trpc
+ hono:
+ specifier: ^4.7.10
+ version: 4.7.10
+ zod:
+ specifier: ^3.24.2
+ version: 3.24.2
+ devDependencies:
+ '@karakeep/eslint-config':
+ specifier: workspace:^0.2.0
+ version: link:../../tooling/eslint
+ '@karakeep/prettier-config':
+ specifier: workspace:^0.1.0
+ version: link:../../tooling/prettier
+ '@karakeep/tsconfig':
+ specifier: workspace:^0.1.0
+ version: link:../../tooling/typescript
+ '@types/bcryptjs':
+ specifier: ^2.4.6
+ version: 2.4.6
+ '@types/deep-equal':
+ specifier: ^1.0.4
+ version: 1.0.4
+ vite-tsconfig-paths:
+ specifier: ^4.3.1
+ version: 4.3.1(typescript@5.7.3)
+ vitest:
+ specifier: ^1.6.1
+ version: 1.6.1(@types/node@22.13.0)
+
packages/db:
dependencies:
'@auth/core':
@@ -3555,6 +3601,12 @@ packages:
'@hapi/topo@5.1.0':
resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==}
+ '@hono/zod-validator@0.5.0':
+ resolution: {integrity: sha512-ds5bW6DCgAnNHP33E3ieSbaZFd5dkV52ZjyaXtGoR06APFrCtzAsKZxTHwOrJNBdXsi0e5wNwo5L4nVEVnJUdg==}
+ peerDependencies:
+ hono: '>=3.9.0'
+ zod: ^3.19.1
+
'@hookform/error-message@2.0.1':
resolution: {integrity: sha512-U410sAr92xgxT1idlu9WWOVjndxLdgPUHEB8Schr27C9eh7/xUnITWpCMF93s+lGiG++D4JnbSnrb5A21AdSNg==}
peerDependencies:
@@ -9366,6 +9418,10 @@ packages:
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
+ hono@4.7.10:
+ resolution: {integrity: sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==}
+ engines: {node: '>=16.9.0'}
+
hosted-git-info@7.0.2:
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
engines: {node: ^16.14.0 || >=18.0.0}
@@ -18692,7 +18748,7 @@ snapshots:
'@eslint/eslintrc@2.1.4':
dependencies:
ajv: 6.12.6
- debug: 4.3.7
+ debug: 4.4.0(supports-color@9.4.0)
espree: 9.6.1
globals: 13.24.0
ignore: 5.3.1
@@ -19085,6 +19141,12 @@ snapshots:
dependencies:
'@hapi/hoek': 9.3.0
+ '@hono/zod-validator@0.5.0(hono@4.7.10)(zod@3.24.2)':
+ dependencies:
+ hono: 4.7.10
+ zod: 3.24.2
+ dev: false
+
'@hookform/error-message@2.0.1(react-dom@18.3.1(react@18.3.1))(react-hook-form@7.50.1(react@18.3.1))(react@18.3.1)':
dependencies:
react: 18.3.1
@@ -19100,7 +19162,7 @@ snapshots:
'@humanwhocodes/config-array@0.11.14':
dependencies:
'@humanwhocodes/object-schema': 2.0.2
- debug: 4.3.7
+ debug: 4.4.0(supports-color@9.4.0)
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -21852,7 +21914,7 @@ snapshots:
'@types/request-ip@0.0.41':
dependencies:
- '@types/node': 20.11.20
+ '@types/node': 22.13.0
dev: true
'@types/resolve@1.17.1':
@@ -26748,6 +26810,9 @@ snapshots:
react-is: 16.13.1
dev: false
+ hono@4.7.10:
+ dev: false
+
hosted-git-info@7.0.2:
dependencies:
lru-cache: 10.4.3