aboutsummaryrefslogtreecommitdiffstats
path: root/packages/api
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-06-01 20:46:41 +0100
committerGitHub <noreply@github.com>2025-06-01 20:46:41 +0100
commitea1d0023bfee55358ebb1a96f3d06e783a219c0d (patch)
tree5bddd451728cb7dd377574a9ea1ea591bca069c4 /packages/api
parent3afe1e21df6dcc0483e74e0db02d9d82af32ecea (diff)
downloadkarakeep-ea1d0023bfee55358ebb1a96f3d06e783a219c0d.tar.zst
feat: Add support for public lists (#1511)
* WIP: public lists * Drop viewing modes * Add the public endpoint for assets * regen the openapi spec * proper handling for different asset types * Add num bookmarks and a no bookmark banner * Correctly set page title * Add a not-found page * merge the RSS and public list endpoints * Add e2e tests for the public endpoints * Redesign the share list modal * Make NEXTAUTH_SECRET not required * propery render text bookmarks * rebase migration * fix public token tests * Add more tests
Diffstat (limited to 'packages/api')
-rw-r--r--packages/api/index.ts4
-rw-r--r--packages/api/routes/assets.ts55
-rw-r--r--packages/api/routes/public.ts47
-rw-r--r--packages/api/routes/rss.ts4
-rw-r--r--packages/api/utils/assets.ts57
5 files changed, 113 insertions, 54 deletions
diff --git a/packages/api/index.ts b/packages/api/index.ts
index a3ba8d42..5147ea37 100644
--- a/packages/api/index.ts
+++ b/packages/api/index.ts
@@ -9,6 +9,7 @@ import assets from "./routes/assets";
import bookmarks from "./routes/bookmarks";
import highlights from "./routes/highlights";
import lists from "./routes/lists";
+import publicRoute from "./routes/public";
import rss from "./routes/rss";
import tags from "./routes/tags";
import users from "./routes/users";
@@ -43,6 +44,7 @@ const app = new Hono<{
})
.use(trpcAdapter)
.route("/v1", v1)
- .route("/assets", assets);
+ .route("/assets", assets)
+ .route("/public", publicRoute);
export default app;
diff --git a/packages/api/routes/assets.ts b/packages/api/routes/assets.ts
index de4e384d..9d9a60b3 100644
--- a/packages/api/routes/assets.ts
+++ b/packages/api/routes/assets.ts
@@ -1,18 +1,13 @@
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";
+import { serveAsset } from "../utils/assets";
+import { uploadAsset } from "../utils/upload";
const app = new Hono()
.use(authMiddleware)
@@ -47,51 +42,7 @@ const app = new Hono()
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));
- });
- }
+ return await serveAsset(c, assetId, c.var.ctx.user.id);
});
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
index 81c9756c..88b943ad 100644
--- a/packages/api/routes/rss.ts
+++ b/packages/api/routes/rss.ts
@@ -28,8 +28,10 @@ const app = new Hono().get(
const searchParams = c.req.valid("query");
const token = searchParams.token;
- const res = await List.getForRss(c.var.ctx, listId, token, {
+ const res = await List.getPublicListContents(c.var.ctx, listId, token, {
limit: searchParams.limit ?? 20,
+ order: "desc",
+ cursor: null,
});
const list = res.list;
diff --git a/packages/api/utils/assets.ts b/packages/api/utils/assets.ts
new file mode 100644
index 00000000..d8a726a6
--- /dev/null
+++ b/packages/api/utils/assets.ts
@@ -0,0 +1,57 @@
+import { Context } from "hono";
+import { stream } from "hono/streaming";
+
+import {
+ createAssetReadStream,
+ getAssetSize,
+ readAssetMetadata,
+} from "@karakeep/shared/assetdb";
+
+import { toWebReadableStream } from "./upload";
+
+export async function serveAsset(c: Context, assetId: string, userId: string) {
+ const [metadata, size] = await Promise.all([
+ readAssetMetadata({
+ userId,
+ assetId,
+ }),
+
+ getAssetSize({
+ userId,
+ 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,
+ 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,
+ 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));
+ });
+ }
+}