aboutsummaryrefslogtreecommitdiffstats
path: root/packages/api/routes
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/routes
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/routes')
-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
6 files changed, 553 insertions, 0 deletions
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;