aboutsummaryrefslogtreecommitdiffstats
path: root/packages/api/routes
diff options
context:
space:
mode:
Diffstat (limited to 'packages/api/routes')
-rw-r--r--packages/api/routes/assets.ts48
-rw-r--r--packages/api/routes/bookmarks.ts319
-rw-r--r--packages/api/routes/highlights.ts54
-rw-r--r--packages/api/routes/lists.ts70
-rw-r--r--packages/api/routes/public.ts47
-rw-r--r--packages/api/routes/rss.ts53
-rw-r--r--packages/api/routes/tags.ts70
-rw-r--r--packages/api/routes/users.ts20
8 files changed, 681 insertions, 0 deletions
diff --git a/packages/api/routes/assets.ts b/packages/api/routes/assets.ts
new file mode 100644
index 00000000..9d9a60b3
--- /dev/null
+++ b/packages/api/routes/assets.ts
@@ -0,0 +1,48 @@
+import { zValidator } from "@hono/zod-validator";
+import { and, eq } from "drizzle-orm";
+import { Hono } from "hono";
+import { z } from "zod";
+
+import { assets } from "@karakeep/db/schema";
+
+import { authMiddleware } from "../middlewares/auth";
+import { serveAsset } from "../utils/assets";
+import { 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 });
+ }
+ return await serveAsset(c, assetId, c.var.ctx.user.id);
+ });
+
+export default app;
diff --git a/packages/api/routes/bookmarks.ts b/packages/api/routes/bookmarks.ts
new file mode 100644
index 00000000..abf0daae
--- /dev/null
+++ b/packages/api/routes/bookmarks.ts
@@ -0,0 +1,319 @@
+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(
+ "query",
+ z.object({
+ ifexists: z
+ .enum([
+ "skip",
+ "overwrite",
+ "overwrite-recrawl",
+ "append",
+ "append-recrawl",
+ ])
+ .optional()
+ .default("skip"),
+ }),
+ ),
+ 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,
+ });
+ if (bookmark.alreadyExists) {
+ const ifexists = c.req.valid("query").ifexists;
+ switch (ifexists) {
+ case "skip":
+ break;
+ case "overwrite-recrawl":
+ case "overwrite": {
+ const existingPrecrawledArchiveId = bookmark.assets
+ .filter((a) => a.assetType == "precrawledArchive")
+ .at(-1)?.id;
+ if (existingPrecrawledArchiveId) {
+ await c.var.api.assets.replaceAsset({
+ bookmarkId: bookmark.id,
+ oldAssetId: existingPrecrawledArchiveId,
+ newAssetId: up.assetId,
+ });
+ } else {
+ await c.var.api.assets.attachAsset({
+ bookmarkId: bookmark.id,
+ asset: {
+ id: up.assetId,
+ assetType: "precrawledArchive",
+ },
+ });
+ }
+ if (ifexists == "overwrite-recrawl") {
+ await c.var.api.bookmarks.recrawlBookmark({
+ bookmarkId: bookmark.id,
+ });
+ }
+ break;
+ }
+ case "append-recrawl":
+ case "append": {
+ await c.var.api.assets.attachAsset({
+ bookmarkId: bookmark.id,
+ asset: {
+ id: up.assetId,
+ assetType: "precrawledArchive",
+ },
+ });
+ if (ifexists == "append-recrawl") {
+ await c.var.api.bookmarks.recrawlBookmark({
+ bookmarkId: bookmark.id,
+ });
+ }
+ break;
+ }
+ }
+ return c.json(bookmark, 200);
+ } else {
+ 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/public.ts b/packages/api/routes/public.ts
new file mode 100644
index 00000000..d17049c4
--- /dev/null
+++ b/packages/api/routes/public.ts
@@ -0,0 +1,47 @@
+import { zValidator } from "@hono/zod-validator";
+import { and, eq } from "drizzle-orm";
+import { Hono } from "hono";
+import { z } from "zod";
+
+import { assets } from "@karakeep/db/schema";
+import { verifySignedToken } from "@karakeep/shared/signedTokens";
+import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets";
+
+import { unauthedMiddleware } from "../middlewares/auth";
+import { serveAsset } from "../utils/assets";
+
+const app = new Hono().get(
+ "/assets/:assetId",
+ unauthedMiddleware,
+ zValidator(
+ "query",
+ z.object({
+ token: z.string(),
+ }),
+ ),
+ async (c) => {
+ const assetId = c.req.param("assetId");
+ const tokenPayload = verifySignedToken(
+ c.req.valid("query").token,
+ zAssetSignedTokenSchema,
+ );
+ if (!tokenPayload) {
+ return c.json({ error: "Invalid or expired token" }, { status: 403 });
+ }
+ if (tokenPayload.assetId !== assetId) {
+ return c.json({ error: "Invalid or expired token" }, { status: 403 });
+ }
+ const userId = tokenPayload.userId;
+
+ const assetDb = await c.var.ctx.db.query.assets.findFirst({
+ where: and(eq(assets.id, assetId), eq(assets.userId, userId)),
+ });
+
+ if (!assetDb) {
+ return c.json({ error: "Asset not found" }, { status: 404 });
+ }
+ return await serveAsset(c, assetId, userId);
+ },
+);
+
+export default app;
diff --git a/packages/api/routes/rss.ts b/packages/api/routes/rss.ts
new file mode 100644
index 00000000..88b943ad
--- /dev/null
+++ b/packages/api/routes/rss.ts
@@ -0,0 +1,53 @@
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+import { z } from "zod";
+
+import serverConfig from "@karakeep/shared/config";
+import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@karakeep/shared/types/bookmarks";
+import { List } from "@karakeep/trpc/models/lists";
+
+import { unauthedMiddleware } from "../middlewares/auth";
+import { toRSS } from "../utils/rss";
+
+const app = new Hono().get(
+ "/lists/:listId",
+ zValidator(
+ "query",
+ z.object({
+ token: z.string().min(1),
+ limit: z.coerce
+ .number()
+ .min(1)
+ .max(MAX_NUM_BOOKMARKS_PER_PAGE)
+ .optional(),
+ }),
+ ),
+ unauthedMiddleware,
+ async (c) => {
+ const listId = c.req.param("listId");
+ const searchParams = c.req.valid("query");
+ const token = searchParams.token;
+
+ const res = await List.getPublicListContents(c.var.ctx, listId, token, {
+ limit: searchParams.limit ?? 20,
+ order: "desc",
+ cursor: null,
+ });
+ const list = res.list;
+
+ const rssFeed = toRSS(
+ {
+ title: `Bookmarks from ${list.icon} ${list.name}`,
+ feedUrl: `${serverConfig.publicApiUrl}/v1/rss/lists/${listId}`,
+ siteUrl: `${serverConfig.publicUrl}/dashboard/lists/${listId}`,
+ description: list.description ?? undefined,
+ },
+ res.bookmarks,
+ );
+
+ c.header("Content-Type", "application/rss+xml");
+ return c.body(rssFeed);
+ },
+);
+
+export default app;
diff --git a/packages/api/routes/tags.ts b/packages/api/routes/tags.ts
new file mode 100644
index 00000000..816e58b4
--- /dev/null
+++ b/packages/api/routes/tags.ts
@@ -0,0 +1,70 @@
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+
+import {
+ zCreateTagRequestSchema,
+ 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);
+ })
+
+ // POST /tags
+ .post("/", zValidator("json", zCreateTagRequestSchema), async (c) => {
+ const body = c.req.valid("json");
+ const tags = await c.var.api.tags.create(body);
+ return c.json(tags, 201);
+ })
+
+ // 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;