aboutsummaryrefslogtreecommitdiffstats
path: root/packages/shared
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/shared
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/shared')
-rw-r--r--packages/shared/config.ts7
-rw-r--r--packages/shared/signedTokens.ts71
-rw-r--r--packages/shared/types/assets.ts6
-rw-r--r--packages/shared/types/bookmarks.ts2
-rw-r--r--packages/shared/types/lists.ts2
-rw-r--r--packages/shared/utils/bookmarkUtils.ts22
6 files changed, 106 insertions, 4 deletions
diff --git a/packages/shared/config.ts b/packages/shared/config.ts
index 218b46b0..b899dbeb 100644
--- a/packages/shared/config.ts
+++ b/packages/shared/config.ts
@@ -18,6 +18,7 @@ const optionalStringBool = () =>
const allEnv = z.object({
API_URL: z.string().url().default("http://localhost:3000"),
NEXTAUTH_URL: z.string().url().default("http://localhost:3000"),
+ NEXTAUTH_SECRET: z.string().optional(),
DISABLE_SIGNUPS: stringBool("false"),
DISABLE_PASSWORD_AUTH: stringBool("false"),
OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING: stringBool("false"),
@@ -94,6 +95,12 @@ const serverConfigSchema = allEnv.transform((val) => {
apiUrl: val.API_URL,
publicUrl: val.NEXTAUTH_URL,
publicApiUrl: `${val.NEXTAUTH_URL}/api`,
+ signingSecret: () => {
+ if (!val.NEXTAUTH_SECRET) {
+ throw new Error("NEXTAUTH_SECRET is not set");
+ }
+ return val.NEXTAUTH_SECRET;
+ },
auth: {
disableSignups: val.DISABLE_SIGNUPS,
disablePasswordAuth: val.DISABLE_PASSWORD_AUTH,
diff --git a/packages/shared/signedTokens.ts b/packages/shared/signedTokens.ts
new file mode 100644
index 00000000..b5e27f3e
--- /dev/null
+++ b/packages/shared/signedTokens.ts
@@ -0,0 +1,71 @@
+import crypto from "node:crypto";
+import { z } from "zod";
+
+import serverConfig from "./config";
+
+const zTokenPayload = z.object({
+ payload: z.unknown(),
+ expiresAt: z.number(),
+});
+
+const zSignedTokenPayload = z.object({
+ payload: zTokenPayload,
+ signature: z.string(),
+});
+
+export type SignedTokenPayload = z.infer<typeof zSignedTokenPayload>;
+
+export function createSignedToken(
+ payload: unknown,
+ expiryEpoch?: number,
+): string {
+ const expiresAt = expiryEpoch ?? Date.now() + 5 * 60 * 1000; // 5 minutes from now
+
+ const toBeSigned: z.infer<typeof zTokenPayload> = {
+ payload,
+ expiresAt,
+ };
+
+ const payloadString = JSON.stringify(toBeSigned);
+ const signature = crypto
+ .createHmac("sha256", serverConfig.signingSecret())
+ .update(payloadString)
+ .digest("hex");
+
+ const tokenData: z.infer<typeof zSignedTokenPayload> = {
+ payload: toBeSigned,
+ signature,
+ };
+
+ return Buffer.from(JSON.stringify(tokenData)).toString("base64");
+}
+
+export function verifySignedToken<T>(
+ token: string,
+ schema: z.ZodSchema<T>,
+): T | null {
+ try {
+ const tokenData = zSignedTokenPayload.parse(
+ JSON.parse(Buffer.from(token, "base64").toString()),
+ );
+ const { payload, signature } = tokenData;
+
+ // Verify signature
+ const expectedSignature = crypto
+ .createHmac("sha256", serverConfig.signingSecret())
+ .update(JSON.stringify(payload))
+ .digest("hex");
+
+ if (signature !== expectedSignature) {
+ return null;
+ }
+ // Check expiry
+ if (Date.now() > payload.expiresAt) {
+ return null;
+ }
+
+ return schema.parse(payload.payload);
+ } catch {
+ return null;
+ }
+}
diff --git a/packages/shared/types/assets.ts b/packages/shared/types/assets.ts
new file mode 100644
index 00000000..fe0adcfd
--- /dev/null
+++ b/packages/shared/types/assets.ts
@@ -0,0 +1,6 @@
+import { z } from "zod";
+
+export const zAssetSignedTokenSchema = z.object({
+ assetId: z.string(),
+ userId: z.string(),
+});
diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts
index 3522fad3..ea1ab717 100644
--- a/packages/shared/types/bookmarks.ts
+++ b/packages/shared/types/bookmarks.ts
@@ -250,6 +250,7 @@ export const zPublicBookmarkSchema = z.object({
title: z.string().nullish(),
tags: z.array(z.string()),
description: z.string().nullish(),
+ bannerImageUrl: z.string().nullable(),
content: z.discriminatedUnion("type", [
z.object({
type: z.literal(BookmarkTypes.LINK),
@@ -264,6 +265,7 @@ export const zPublicBookmarkSchema = z.object({
type: z.literal(BookmarkTypes.ASSET),
assetType: z.enum(["image", "pdf"]),
assetId: z.string(),
+ assetUrl: z.string(),
fileName: z.string().nullish(),
sourceUrl: z.string().nullish(),
}),
diff --git a/packages/shared/types/lists.ts b/packages/shared/types/lists.ts
index 7ef5687c..51fb458c 100644
--- a/packages/shared/types/lists.ts
+++ b/packages/shared/types/lists.ts
@@ -47,6 +47,7 @@ export const zBookmarkListSchema = z.object({
parentId: z.string().nullable(),
type: z.enum(["manual", "smart"]).default("manual"),
query: z.string().nullish(),
+ public: z.boolean(),
});
export type ZBookmarkList = z.infer<typeof zBookmarkListSchema>;
@@ -66,6 +67,7 @@ export const zEditBookmarkListSchema = z.object({
icon: z.string().optional(),
parentId: z.string().nullish(),
query: z.string().min(1).optional(),
+ public: z.boolean().optional(),
});
export const zEditBookmarkListSchemaWithValidation = zEditBookmarkListSchema
diff --git a/packages/shared/utils/bookmarkUtils.ts b/packages/shared/utils/bookmarkUtils.ts
index 31d7b698..97ef08fc 100644
--- a/packages/shared/utils/bookmarkUtils.ts
+++ b/packages/shared/utils/bookmarkUtils.ts
@@ -3,18 +3,32 @@ import { getAssetUrl } from "./assetUtils";
const MAX_LOADING_MSEC = 30 * 1000;
-export function getBookmarkLinkImageUrl(bookmark: ZBookmarkedLink) {
+export function getBookmarkLinkAssetIdOrUrl(bookmark: ZBookmarkedLink) {
if (bookmark.imageAssetId) {
- return { url: getAssetUrl(bookmark.imageAssetId), localAsset: true };
+ return { assetId: bookmark.imageAssetId, localAsset: true as const };
}
if (bookmark.screenshotAssetId) {
- return { url: getAssetUrl(bookmark.screenshotAssetId), localAsset: true };
+ return { assetId: bookmark.screenshotAssetId, localAsset: true as const };
}
return bookmark.imageUrl
- ? { url: bookmark.imageUrl, localAsset: false }
+ ? { url: bookmark.imageUrl, localAsset: false as const }
: null;
}
+export function getBookmarkLinkImageUrl(bookmark: ZBookmarkedLink) {
+ const assetOrUrl = getBookmarkLinkAssetIdOrUrl(bookmark);
+ if (!assetOrUrl) {
+ return null;
+ }
+ if (!assetOrUrl.localAsset) {
+ return assetOrUrl;
+ }
+ return {
+ url: getAssetUrl(assetOrUrl.assetId),
+ localAsset: true,
+ };
+}
+
export function isBookmarkStillCrawling(bookmark: ZBookmark) {
return (
bookmark.content.type == BookmarkTypes.LINK &&