diff options
Diffstat (limited to 'packages/api')
| -rw-r--r-- | packages/api/index.ts | 46 | ||||
| -rw-r--r-- | packages/api/middlewares/auth.ts | 22 | ||||
| -rw-r--r-- | packages/api/middlewares/trpcAdapter.ts | 41 | ||||
| -rw-r--r-- | packages/api/package.json | 39 | ||||
| -rw-r--r-- | packages/api/routes/assets.ts | 97 | ||||
| -rw-r--r-- | packages/api/routes/bookmarks.ts | 252 | ||||
| -rw-r--r-- | packages/api/routes/highlights.ts | 54 | ||||
| -rw-r--r-- | packages/api/routes/lists.ts | 70 | ||||
| -rw-r--r-- | packages/api/routes/tags.ts | 60 | ||||
| -rw-r--r-- | packages/api/routes/users.ts | 20 | ||||
| -rw-r--r-- | packages/api/tsconfig.json | 13 | ||||
| -rw-r--r-- | packages/api/utils/pagination.ts | 30 | ||||
| -rw-r--r-- | packages/api/utils/types.ts | 28 | ||||
| -rw-r--r-- | packages/api/utils/upload.ts | 110 |
14 files changed, 882 insertions, 0 deletions
diff --git a/packages/api/index.ts b/packages/api/index.ts new file mode 100644 index 00000000..00919f3e --- /dev/null +++ b/packages/api/index.ts @@ -0,0 +1,46 @@ +import { Hono } from "hono"; +import { logger } from "hono/logger"; +import { poweredBy } from "hono/powered-by"; + +import { Context } from "@karakeep/trpc"; + +import trpcAdapter from "./middlewares/trpcAdapter"; +import assets from "./routes/assets"; +import bookmarks from "./routes/bookmarks"; +import highlights from "./routes/highlights"; +import lists from "./routes/lists"; +import tags from "./routes/tags"; +import users from "./routes/users"; + +const v1 = new Hono<{ + Variables: { + ctx: Context; + }; +}>() + .route("/highlights", highlights) + .route("/bookmarks", bookmarks) + .route("/lists", lists) + .route("/tags", tags) + .route("/users", users) + .route("/assets", assets); + +const app = new Hono<{ + Variables: { + // This is going to be coming from the web app + ctx: Context; + }; +}>() + .use(logger()) + .use(poweredBy()) + .use(async (c, next) => { + // Ensure that the ctx is set + if (!c.var.ctx) { + throw new Error("Context is not set"); + } + await next(); + }) + .use(trpcAdapter) + .route("/v1", v1) + .route("/assets", assets); + +export default app; diff --git a/packages/api/middlewares/auth.ts b/packages/api/middlewares/auth.ts new file mode 100644 index 00000000..7f39a6f9 --- /dev/null +++ b/packages/api/middlewares/auth.ts @@ -0,0 +1,22 @@ +import { createMiddleware } from "hono/factory"; +import { HTTPException } from "hono/http-exception"; + +import { AuthedContext, createCallerFactory } from "@karakeep/trpc"; +import { appRouter } from "@karakeep/trpc/routers/_app"; + +const createCaller = createCallerFactory(appRouter); + +export const authMiddleware = createMiddleware<{ + Variables: { + ctx: AuthedContext; + api: ReturnType<typeof createCaller>; + }; +}>(async (c, next) => { + if (!c.var.ctx || !c.var.ctx.user || c.var.ctx.user === null) { + throw new HTTPException(401, { + message: "Unauthorized", + }); + } + c.set("api", createCaller(c.get("ctx"))); + await next(); +}); diff --git a/packages/api/middlewares/trpcAdapter.ts b/packages/api/middlewares/trpcAdapter.ts new file mode 100644 index 00000000..6bb4a790 --- /dev/null +++ b/packages/api/middlewares/trpcAdapter.ts @@ -0,0 +1,41 @@ +import { TRPCError } from "@trpc/server"; +import { createMiddleware } from "hono/factory"; +import { HTTPException } from "hono/http-exception"; + +function trpcCodeToHttpCode(code: TRPCError["code"]) { + switch (code) { + case "BAD_REQUEST": + case "PARSE_ERROR": + return 400; + case "UNAUTHORIZED": + return 401; + case "FORBIDDEN": + return 403; + case "NOT_FOUND": + return 404; + case "METHOD_NOT_SUPPORTED": + return 405; + case "TIMEOUT": + return 408; + case "PAYLOAD_TOO_LARGE": + return 413; + case "INTERNAL_SERVER_ERROR": + return 500; + default: + return 500; + } +} + +const trpcAdapter = createMiddleware(async (c, next) => { + await next(); + const e = c.error; + if (e instanceof TRPCError) { + const code = trpcCodeToHttpCode(e.code); + throw new HTTPException(code, { + message: e.message, + cause: e.cause, + }); + } +}); + +export default trpcAdapter; diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 00000000..f968ed94 --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@karakeep/api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "format": "prettier . --ignore-path ../../.prettierignore", + "format:fix": "prettier . --write --ignore-path ../../.prettierignore", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "vitest" + }, + "dependencies": { + "@hono/zod-validator": "^0.5.0", + "@karakeep/db": "workspace:*", + "@karakeep/shared": "workspace:*", + "@karakeep/trpc": "workspace:*", + "hono": "^4.7.10", + "zod": "^3.24.2" + }, + "devDependencies": { + "@karakeep/eslint-config": "workspace:^0.2.0", + "@karakeep/prettier-config": "workspace:^0.1.0", + "@karakeep/tsconfig": "workspace:^0.1.0", + "@types/bcryptjs": "^2.4.6", + "@types/deep-equal": "^1.0.4", + "vite-tsconfig-paths": "^4.3.1", + "vitest": "^1.6.1" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@karakeep/eslint-config/base" + ] + }, + "prettier": "@karakeep/prettier-config" +} diff --git a/packages/api/routes/assets.ts b/packages/api/routes/assets.ts new file mode 100644 index 00000000..de4e384d --- /dev/null +++ b/packages/api/routes/assets.ts @@ -0,0 +1,97 @@ +import { zValidator } from "@hono/zod-validator"; +import { and, eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { stream } from "hono/streaming"; +import { z } from "zod"; + +import { assets } from "@karakeep/db/schema"; +import { + createAssetReadStream, + getAssetSize, + readAssetMetadata, +} from "@karakeep/shared/assetdb"; + +import { authMiddleware } from "../middlewares/auth"; +import { toWebReadableStream, uploadAsset } from "../utils/upload"; + +const app = new Hono() + .use(authMiddleware) + .post( + "/", + zValidator( + "form", + z + .object({ file: z.instanceof(File) }) + .or(z.object({ image: z.instanceof(File) })), + ), + async (c) => { + const body = c.req.valid("form"); + const up = await uploadAsset(c.var.ctx.user, c.var.ctx.db, body); + if ("error" in up) { + return c.json({ error: up.error }, up.status); + } + return c.json({ + assetId: up.assetId, + contentType: up.contentType, + size: up.size, + fileName: up.fileName, + }); + }, + ) + .get("/:assetId", async (c) => { + const assetId = c.req.param("assetId"); + const assetDb = await c.var.ctx.db.query.assets.findFirst({ + where: and(eq(assets.id, assetId), eq(assets.userId, c.var.ctx.user.id)), + }); + + if (!assetDb) { + return c.json({ error: "Asset not found" }, { status: 404 }); + } + + const [metadata, size] = await Promise.all([ + readAssetMetadata({ + userId: c.var.ctx.user.id, + assetId, + }), + + getAssetSize({ + userId: c.var.ctx.user.id, + assetId, + }), + ]); + + const range = c.req.header("Range"); + if (range) { + const parts = range.replace(/bytes=/, "").split("-"); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : size - 1; + + const fStream = createAssetReadStream({ + userId: c.var.ctx.user.id, + assetId, + start, + end, + }); + c.status(206); // Partial Content + c.header("Content-Range", `bytes ${start}-${end}/${size}`); + c.header("Accept-Ranges", "bytes"); + c.header("Content-Length", (end - start + 1).toString()); + c.header("Content-type", metadata.contentType); + return stream(c, async (stream) => { + await stream.pipe(toWebReadableStream(fStream)); + }); + } else { + const fStream = createAssetReadStream({ + userId: c.var.ctx.user.id, + assetId, + }); + c.status(200); + c.header("Content-Length", size.toString()); + c.header("Content-type", metadata.contentType); + return stream(c, async (stream) => { + await stream.pipe(toWebReadableStream(fStream)); + }); + } + }); + +export default app; diff --git a/packages/api/routes/bookmarks.ts b/packages/api/routes/bookmarks.ts new file mode 100644 index 00000000..fbc46d2f --- /dev/null +++ b/packages/api/routes/bookmarks.ts @@ -0,0 +1,252 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; + +import { + BookmarkTypes, + zAssetSchema, + zManipulatedTagSchema, + zNewBookmarkRequestSchema, + zUpdateBookmarksRequestSchema, +} from "@karakeep/shared/types/bookmarks"; + +import { authMiddleware } from "../middlewares/auth"; +import { adaptPagination, zPagination } from "../utils/pagination"; +import { + zGetBookmarkQueryParamsSchema, + zGetBookmarkSearchParamsSchema, + zIncludeContentSearchParamsSchema, + zStringBool, +} from "../utils/types"; +import { uploadAsset } from "../utils/upload"; + +const app = new Hono() + .use(authMiddleware) + + // GET /bookmarks + .get( + "/", + zValidator( + "query", + z + .object({ + favourited: zStringBool.optional(), + archived: zStringBool.optional(), + }) + .and(zGetBookmarkQueryParamsSchema) + .and(zPagination), + ), + async (c) => { + const searchParams = c.req.valid("query"); + const bookmarks = await c.var.api.bookmarks.getBookmarks(searchParams); + return c.json(adaptPagination(bookmarks), 200); + }, + ) + + // POST /bookmarks + .post("/", zValidator("json", zNewBookmarkRequestSchema), async (c) => { + const body = c.req.valid("json"); + const bookmark = await c.var.api.bookmarks.createBookmark(body); + return c.json(bookmark, 201); + }) + + // GET /bookmarks/search + .get( + "/search", + zValidator( + "query", + z + .object({ + q: z.string(), + limit: z.coerce.number().optional(), + cursor: z + .string() + .optional() + .transform((val) => + val ? { ver: 1 as const, offset: parseInt(val) } : undefined, + ), + }) + .and(zGetBookmarkSearchParamsSchema), + ), + async (c) => { + const searchParams = c.req.valid("query"); + const bookmarks = await c.var.api.bookmarks.searchBookmarks({ + text: searchParams.q, + cursor: searchParams.cursor, + limit: searchParams.limit, + includeContent: searchParams.includeContent, + }); + return c.json( + { + bookmarks: bookmarks.bookmarks, + nextCursor: bookmarks.nextCursor + ? `${bookmarks.nextCursor.offset}` + : null, + }, + 200, + ); + }, + ) + .post( + "/singlefile", + zValidator( + "form", + z.object({ + url: z.string(), + file: z.instanceof(File), + }), + ), + async (c) => { + const form = c.req.valid("form"); + const up = await uploadAsset(c.var.ctx.user, c.var.ctx.db, form); + if ("error" in up) { + return c.json({ error: up.error }, up.status); + } + const bookmark = await c.var.api.bookmarks.createBookmark({ + type: BookmarkTypes.LINK, + url: form.url, + precrawledArchiveId: up.assetId, + }); + return c.json(bookmark, 201); + }, + ) + + // GET /bookmarks/[bookmarkId] + .get( + "/:bookmarkId", + zValidator("query", zIncludeContentSearchParamsSchema), + async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const searchParams = c.req.valid("query"); + const bookmark = await c.var.api.bookmarks.getBookmark({ + bookmarkId, + includeContent: searchParams.includeContent, + }); + return c.json(bookmark, 200); + }, + ) + + // PATCH /bookmarks/[bookmarkId] + .patch( + "/:bookmarkId", + zValidator( + "json", + zUpdateBookmarksRequestSchema.omit({ bookmarkId: true }), + ), + async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const body = c.req.valid("json"); + const bookmark = await c.var.api.bookmarks.updateBookmark({ + bookmarkId, + ...body, + }); + return c.json(bookmark, 200); + }, + ) + + // DELETE /bookmarks/[bookmarkId] + .delete("/:bookmarkId", async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + await c.var.api.bookmarks.deleteBookmark({ bookmarkId }); + return c.body(null, 204); + }) + + // GET /bookmarks/[bookmarkId]/lists + .get("/:bookmarkId/lists", async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const resp = await c.var.api.lists.getListsOfBookmark({ bookmarkId }); + return c.json(resp, 200); + }) + + // GET /bookmarks/[bookmarkId]/assets + .get("/:bookmarkId/assets", async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const resp = await c.var.api.bookmarks.getBookmark({ bookmarkId }); + return c.json({ assets: resp.assets }, 200); + }) + + // POST /bookmarks/[bookmarkId]/assets + .post("/:bookmarkId/assets", zValidator("json", zAssetSchema), async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const body = c.req.valid("json"); + const asset = await c.var.api.assets.attachAsset({ + bookmarkId, + asset: body, + }); + return c.json(asset, 201); + }) + + // PUT /bookmarks/[bookmarkId]/assets/[assetId] + .put( + "/:bookmarkId/assets/:assetId", + zValidator("json", z.object({ assetId: z.string() })), + async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const assetId = c.req.param("assetId"); + const body = c.req.valid("json"); + await c.var.api.assets.replaceAsset({ + bookmarkId, + oldAssetId: assetId, + newAssetId: body.assetId, + }); + return c.body(null, 204); + }, + ) + + // DELETE /bookmarks/[bookmarkId]/assets/[assetId] + .delete("/:bookmarkId/assets/:assetId", async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const assetId = c.req.param("assetId"); + await c.var.api.assets.detachAsset({ bookmarkId, assetId }); + return c.body(null, 204); + }) + + // POST /bookmarks/[bookmarkId]/tags + .post( + "/:bookmarkId/tags", + zValidator("json", z.object({ tags: z.array(zManipulatedTagSchema) })), + async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const body = c.req.valid("json"); + const resp = await c.var.api.bookmarks.updateTags({ + bookmarkId, + attach: body.tags, + detach: [], + }); + return c.json({ attached: resp.attached }, 200); + }, + ) + + // DELETE /bookmarks/[bookmarkId]/tags + .delete( + "/:bookmarkId/tags", + zValidator("json", z.object({ tags: z.array(zManipulatedTagSchema) })), + async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const body = c.req.valid("json"); + const resp = await c.var.api.bookmarks.updateTags({ + bookmarkId, + detach: body.tags, + attach: [], + }); + return c.json({ detached: resp.detached }, 200); + }, + ) + + // POST /bookmarks/[bookmarkId]/summarize + .post("/:bookmarkId/summarize", async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const bookmark = await c.var.api.bookmarks.summarizeBookmark({ + bookmarkId, + }); + return c.json(bookmark, 200); + }) + + // GET /bookmarks/[bookmarkId]/highlights + .get("/:bookmarkId/highlights", async (c) => { + const bookmarkId = c.req.param("bookmarkId"); + const resp = await c.var.api.highlights.getForBookmark({ bookmarkId }); + return c.json(resp, 200); + }); + +export default app; diff --git a/packages/api/routes/highlights.ts b/packages/api/routes/highlights.ts new file mode 100644 index 00000000..d381f7e2 --- /dev/null +++ b/packages/api/routes/highlights.ts @@ -0,0 +1,54 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { + zNewHighlightSchema, + zUpdateHighlightSchema, +} from "@karakeep/shared/types/highlights"; + +import { authMiddleware } from "../middlewares/auth"; +import { adaptPagination, zPagination } from "../utils/pagination"; + +const app = new Hono() + .use(authMiddleware) + .get("/", zValidator("query", zPagination), async (c) => { + const searchParams = c.req.valid("query"); + const resp = await c.var.api.highlights.getAll({ + ...searchParams, + }); + return c.json(adaptPagination(resp)); + }) + .post("/", zValidator("json", zNewHighlightSchema), async (c) => { + const body = c.req.valid("json"); + const resp = await c.var.api.highlights.create(body); + return c.json(resp, 201); + }) + .get("/:highlightId", async (c) => { + const highlightId = c.req.param("highlightId"); + const highlight = await c.var.api.highlights.get({ + highlightId, + }); + return c.json(highlight, 200); + }) + .patch( + "/:highlightId", + zValidator("json", zUpdateHighlightSchema.omit({ highlightId: true })), + async (c) => { + const highlightId = c.req.param("highlightId"); + const body = c.req.valid("json"); + const highlight = await c.var.api.highlights.update({ + highlightId, + ...body, + }); + return c.json(highlight, 200); + }, + ) + .delete("/:highlightId", async (c) => { + const highlightId = c.req.param("highlightId"); + const highlight = await c.var.api.highlights.delete({ + highlightId, + }); + return c.json(highlight, 200); + }); + +export default app; diff --git a/packages/api/routes/lists.ts b/packages/api/routes/lists.ts new file mode 100644 index 00000000..33908629 --- /dev/null +++ b/packages/api/routes/lists.ts @@ -0,0 +1,70 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { + zEditBookmarkListSchema, + zNewBookmarkListSchema, +} from "@karakeep/shared/types/lists"; + +import { authMiddleware } from "../middlewares/auth"; +import { adaptPagination, zPagination } from "../utils/pagination"; +import { zGetBookmarkQueryParamsSchema } from "../utils/types"; + +const app = new Hono() + .use(authMiddleware) + .get("/", async (c) => { + const lists = await c.var.api.lists.list(); + return c.json(lists, 200); + }) + .post("/", zValidator("json", zNewBookmarkListSchema), async (c) => { + const body = c.req.valid("json"); + const list = await c.var.api.lists.create(body); + return c.json(list, 201); + }) + .get("/:listId", async (c) => { + const listId = c.req.param("listId"); + const list = await c.var.api.lists.get({ listId }); + return c.json(list, 200); + }) + .patch( + "/:listId", + zValidator("json", zEditBookmarkListSchema.omit({ listId: true })), + async (c) => { + const listId = c.req.param("listId"); + const body = c.req.valid("json"); + const list = await c.var.api.lists.edit({ ...body, listId }); + return c.json(list, 200); + }, + ) + .delete("/:listId", async (c) => { + const listId = c.req.param("listId"); + await c.var.api.lists.delete({ listId }); + return c.body(null, 204); + }) + .get( + "/:listId/bookmarks", + zValidator("query", zPagination.and(zGetBookmarkQueryParamsSchema)), + async (c) => { + const listId = c.req.param("listId"); + const searchParams = c.req.valid("query"); + const bookmarks = await c.var.api.bookmarks.getBookmarks({ + listId, + ...searchParams, + }); + return c.json(adaptPagination(bookmarks), 200); + }, + ) + .put("/:listId/bookmarks/:bookmarkId", async (c) => { + const listId = c.req.param("listId"); + const bookmarkId = c.req.param("bookmarkId"); + await c.var.api.lists.addToList({ listId, bookmarkId }); + return c.body(null, 204); + }) + .delete("/:listId/bookmarks/:bookmarkId", async (c) => { + const listId = c.req.param("listId"); + const bookmarkId = c.req.param("bookmarkId"); + await c.var.api.lists.removeFromList({ listId, bookmarkId }); + return c.body(null, 204); + }); + +export default app; diff --git a/packages/api/routes/tags.ts b/packages/api/routes/tags.ts new file mode 100644 index 00000000..6d4cf39d --- /dev/null +++ b/packages/api/routes/tags.ts @@ -0,0 +1,60 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { zUpdateTagRequestSchema } from "@karakeep/shared/types/tags"; + +import { authMiddleware } from "../middlewares/auth"; +import { adaptPagination, zPagination } from "../utils/pagination"; +import { zGetBookmarkQueryParamsSchema } from "../utils/types"; + +const app = new Hono() + .use(authMiddleware) + + // GET /tags + .get("/", async (c) => { + const tags = await c.var.api.tags.list(); + return c.json(tags, 200); + }) + + // GET /tags/[tagId] + .get("/:tagId", async (c) => { + const tagId = c.req.param("tagId"); + const tag = await c.var.api.tags.get({ tagId }); + return c.json(tag, 200); + }) + + // PATCH /tags/[tagId] + .patch( + "/:tagId", + zValidator("json", zUpdateTagRequestSchema.omit({ tagId: true })), + async (c) => { + const tagId = c.req.param("tagId"); + const body = c.req.valid("json"); + const tag = await c.var.api.tags.update({ tagId, ...body }); + return c.json(tag, 200); + }, + ) + + // DELETE /tags/[tagId] + .delete("/:tagId", async (c) => { + const tagId = c.req.param("tagId"); + await c.var.api.tags.delete({ tagId }); + return c.body(null, 204); + }) + + // GET /tags/[tagId]/bookmarks + .get( + "/:tagId/bookmarks", + zValidator("query", zPagination.and(zGetBookmarkQueryParamsSchema)), + async (c) => { + const tagId = c.req.param("tagId"); + const searchParams = c.req.valid("query"); + const bookmarks = await c.var.api.bookmarks.getBookmarks({ + tagId, + ...searchParams, + }); + return c.json(adaptPagination(bookmarks), 200); + }, + ); + +export default app; diff --git a/packages/api/routes/users.ts b/packages/api/routes/users.ts new file mode 100644 index 00000000..81177fe3 --- /dev/null +++ b/packages/api/routes/users.ts @@ -0,0 +1,20 @@ +import { Hono } from "hono"; + +import { authMiddleware } from "../middlewares/auth"; + +const app = new Hono() + .use(authMiddleware) + + // GET /users/me + .get("/me", async (c) => { + const user = await c.var.api.users.whoami(); + return c.json(user, 200); + }) + + // GET /users/me/stats + .get("/me/stats", async (c) => { + const stats = await c.var.api.users.stats(); + return c.json(stats, 200); + }); + +export default app; diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 00000000..0036ccfa --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@karakeep/tsconfig/node.json", + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules" + ], + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + } +} diff --git a/packages/api/utils/pagination.ts b/packages/api/utils/pagination.ts new file mode 100644 index 00000000..12a0b950 --- /dev/null +++ b/packages/api/utils/pagination.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@karakeep/shared/types/bookmarks"; +import { zCursorV2 } from "@karakeep/shared/types/pagination"; + +export const zPagination = z.object({ + limit: z.coerce.number().max(MAX_NUM_BOOKMARKS_PER_PAGE).optional(), + cursor: z + .string() + .refine((val) => val.includes("_"), "Must be a valid cursor") + .transform((val) => { + const [id, createdAt] = val.split("_"); + return { id, createdAt }; + }) + .pipe(z.object({ id: z.string(), createdAt: z.coerce.date() })) + .optional(), +}); + +export function adaptPagination< + T extends { nextCursor: z.infer<typeof zCursorV2> | null }, +>(input: T) { + const { nextCursor, ...rest } = input; + if (!nextCursor) { + return input; + } + return { + ...rest, + nextCursor: `${nextCursor.id}_${nextCursor.createdAt.toISOString()}`, + }; +} diff --git a/packages/api/utils/types.ts b/packages/api/utils/types.ts new file mode 100644 index 00000000..bdaf815f --- /dev/null +++ b/packages/api/utils/types.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +import { zSortOrder } from "@karakeep/shared/types/bookmarks"; + +export const zStringBool = z + .string() + .refine((val) => val === "true" || val === "false", "Must be true or false") + .transform((val) => val === "true"); + +export const zIncludeContentSearchParamsSchema = z.object({ + // TODO: Change the default to false in a couple of releases. + includeContent: zStringBool.optional().default("true"), +}); + +export const zGetBookmarkQueryParamsSchema = z + .object({ + sortOrder: zSortOrder + .exclude([zSortOrder.Enum.relevance]) + .optional() + .default(zSortOrder.Enum.desc), + }) + .merge(zIncludeContentSearchParamsSchema); + +export const zGetBookmarkSearchParamsSchema = z + .object({ + sortOrder: zSortOrder.optional().default(zSortOrder.Enum.relevance), + }) + .merge(zIncludeContentSearchParamsSchema); diff --git a/packages/api/utils/upload.ts b/packages/api/utils/upload.ts new file mode 100644 index 00000000..d96a0f60 --- /dev/null +++ b/packages/api/utils/upload.ts @@ -0,0 +1,110 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { Readable } from "stream"; +import { pipeline } from "stream/promises"; + +import { assets, AssetTypes } from "@karakeep/db/schema"; +import { + newAssetId, + saveAssetFromFile, + SUPPORTED_UPLOAD_ASSET_TYPES, +} from "@karakeep/shared/assetdb"; +import serverConfig from "@karakeep/shared/config"; +import { AuthedContext } from "@karakeep/trpc"; + +const MAX_UPLOAD_SIZE_BYTES = serverConfig.maxAssetSizeMb * 1024 * 1024; + +// Helper to convert Web Stream to Node Stream (requires Node >= 16.5 / 14.18) +export function webStreamToNode( + webStream: ReadableStream<Uint8Array>, +): Readable { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + return Readable.fromWeb(webStream as any); // Type assertion might be needed +} + +export function toWebReadableStream( + nodeStream: fs.ReadStream, +): ReadableStream<Uint8Array> { + const reader = nodeStream as unknown as Readable; + + return new ReadableStream({ + start(controller) { + reader.on("data", (chunk) => controller.enqueue(new Uint8Array(chunk))); + reader.on("end", () => controller.close()); + reader.on("error", (err) => controller.error(err)); + }, + }); +} + +export async function uploadAsset( + user: AuthedContext["user"], + db: AuthedContext["db"], + formData: { file: File } | { image: File }, +): Promise< + | { error: string; status: 400 | 413 } + | { + assetId: string; + contentType: string; + fileName: string; + size: number; + } +> { + let data: File; + if ("file" in formData) { + data = formData.file; + } else { + data = formData.image; + } + + const contentType = data.type; + const fileName = data.name; + if (!SUPPORTED_UPLOAD_ASSET_TYPES.has(contentType)) { + return { error: "Unsupported asset type", status: 400 }; + } + if (data.size > MAX_UPLOAD_SIZE_BYTES) { + return { error: "Asset is too big", status: 413 }; + } + + let tempFilePath: string | undefined; + + try { + tempFilePath = path.join(os.tmpdir(), `karakeep-upload-${Date.now()}`); + await pipeline( + webStreamToNode(data.stream()), + fs.createWriteStream(tempFilePath), + ); + const [assetDb] = await db + .insert(assets) + .values({ + id: newAssetId(), + // Initially, uploads are uploaded for unknown purpose + // And without an attached bookmark. + assetType: AssetTypes.UNKNOWN, + bookmarkId: null, + userId: user.id, + contentType, + size: data.size, + fileName, + }) + .returning(); + + await saveAssetFromFile({ + userId: user.id, + assetId: assetDb.id, + assetPath: tempFilePath, + metadata: { contentType, fileName }, + }); + + return { + assetId: assetDb.id, + contentType, + size: data.size, + fileName, + }; + } finally { + if (tempFilePath) { + await fs.promises.unlink(tempFilePath).catch(() => ({})); + } + } +} |
