diff options
Diffstat (limited to 'packages/api/routes')
| -rw-r--r-- | packages/api/routes/assets.ts | 48 | ||||
| -rw-r--r-- | packages/api/routes/bookmarks.ts | 319 | ||||
| -rw-r--r-- | packages/api/routes/highlights.ts | 54 | ||||
| -rw-r--r-- | packages/api/routes/lists.ts | 70 | ||||
| -rw-r--r-- | packages/api/routes/public.ts | 47 | ||||
| -rw-r--r-- | packages/api/routes/rss.ts | 53 | ||||
| -rw-r--r-- | packages/api/routes/tags.ts | 70 | ||||
| -rw-r--r-- | packages/api/routes/users.ts | 20 |
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; |
