aboutsummaryrefslogtreecommitdiffstats
path: root/packages/api
diff options
context:
space:
mode:
Diffstat (limited to 'packages/api')
-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.ts30
-rw-r--r--packages/api/utils/types.ts28
-rw-r--r--packages/api/utils/upload.ts110
14 files changed, 882 insertions, 0 deletions
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/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(() => ({}));
+ }
+ }
+}