diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-05-18 16:58:08 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-18 16:58:08 +0100 |
| commit | 3505cb7d6416d101a4fcb1be27fc22e0171bacd2 (patch) | |
| tree | ef9f55504b8a5b20add8c0ebe916972ab4ab0178 | |
| parent | 74e74fa6425f072107de3a9bc9dd8f91c5ac9a7d (diff) | |
| download | karakeep-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
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 |
