diff options
Diffstat (limited to '')
| -rw-r--r-- | packages/shared/utils/bookmarkUtils.ts | 10 | ||||
| -rw-r--r-- | packages/shared/utils/redirectUrl.test.ts | 89 | ||||
| -rw-r--r-- | packages/shared/utils/redirectUrl.ts | 35 | ||||
| -rw-r--r-- | packages/shared/utils/tag.ts | 31 |
4 files changed, 162 insertions, 3 deletions
diff --git a/packages/shared/utils/bookmarkUtils.ts b/packages/shared/utils/bookmarkUtils.ts index 9d4659b1..c9587c6c 100644 --- a/packages/shared/utils/bookmarkUtils.ts +++ b/packages/shared/utils/bookmarkUtils.ts @@ -28,9 +28,13 @@ export function getBookmarkLinkImageUrl(bookmark: ZBookmarkedLink) { } export function isBookmarkStillCrawling(bookmark: ZBookmark) { - return ( - bookmark.content.type == BookmarkTypes.LINK && !bookmark.content.crawledAt - ); + if (bookmark.content.type != BookmarkTypes.LINK) { + return false; + } + if (bookmark.content.crawlStatus) { + return bookmark.content.crawlStatus === "pending"; + } + return !bookmark.content.crawledAt; } export function isBookmarkStillTagging(bookmark: ZBookmark) { diff --git a/packages/shared/utils/redirectUrl.test.ts b/packages/shared/utils/redirectUrl.test.ts new file mode 100644 index 00000000..97d52cf2 --- /dev/null +++ b/packages/shared/utils/redirectUrl.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { isMobileAppRedirect, validateRedirectUrl } from "./redirectUrl"; + +describe("validateRedirectUrl", () => { + it("should return undefined for null input", () => { + expect(validateRedirectUrl(null)).toBe(undefined); + }); + + it("should return undefined for undefined input", () => { + expect(validateRedirectUrl(undefined)).toBe(undefined); + }); + + it("should return undefined for empty string", () => { + expect(validateRedirectUrl("")).toBe(undefined); + }); + + it("should allow relative paths starting with '/'", () => { + expect(validateRedirectUrl("/")).toBe("/"); + expect(validateRedirectUrl("/dashboard")).toBe("/dashboard"); + expect(validateRedirectUrl("/settings/profile")).toBe("/settings/profile"); + expect(validateRedirectUrl("/path?query=value")).toBe("/path?query=value"); + expect(validateRedirectUrl("/path#hash")).toBe("/path#hash"); + }); + + it("should reject protocol-relative URLs (//)", () => { + expect(validateRedirectUrl("//evil.com")).toBe(undefined); + expect(validateRedirectUrl("//evil.com/path")).toBe(undefined); + }); + + it("should allow karakeep:// scheme for mobile app", () => { + expect(validateRedirectUrl("karakeep://")).toBe("karakeep://"); + expect(validateRedirectUrl("karakeep://callback")).toBe( + "karakeep://callback", + ); + expect(validateRedirectUrl("karakeep://callback/path")).toBe( + "karakeep://callback/path", + ); + expect(validateRedirectUrl("karakeep://callback?param=value")).toBe( + "karakeep://callback?param=value", + ); + }); + + it("should reject http:// scheme", () => { + expect(validateRedirectUrl("http://example.com")).toBe(undefined); + expect(validateRedirectUrl("http://localhost:3000")).toBe(undefined); + }); + + it("should reject https:// scheme", () => { + expect(validateRedirectUrl("https://example.com")).toBe(undefined); + expect(validateRedirectUrl("https://evil.com/phishing")).toBe(undefined); + }); + + it("should reject javascript: scheme", () => { + expect(validateRedirectUrl("javascript:alert(1)")).toBe(undefined); + }); + + it("should reject data: scheme", () => { + expect( + validateRedirectUrl("data:text/html,<script>alert(1)</script>"), + ).toBe(undefined); + }); + + it("should reject other custom schemes", () => { + expect(validateRedirectUrl("file:///etc/passwd")).toBe(undefined); + expect(validateRedirectUrl("ftp://example.com")).toBe(undefined); + expect(validateRedirectUrl("mailto:test@example.com")).toBe(undefined); + }); + + it("should reject paths not starting with /", () => { + expect(validateRedirectUrl("dashboard")).toBe(undefined); + expect(validateRedirectUrl("path/to/page")).toBe(undefined); + }); +}); + +describe("isMobileAppRedirect", () => { + it("should return true for karakeep:// URLs", () => { + expect(isMobileAppRedirect("karakeep://")).toBe(true); + expect(isMobileAppRedirect("karakeep://callback")).toBe(true); + expect(isMobileAppRedirect("karakeep://callback/path")).toBe(true); + }); + + it("should return false for other URLs", () => { + expect(isMobileAppRedirect("/")).toBe(false); + expect(isMobileAppRedirect("/dashboard")).toBe(false); + expect(isMobileAppRedirect("https://example.com")).toBe(false); + expect(isMobileAppRedirect("http://localhost")).toBe(false); + }); +}); diff --git a/packages/shared/utils/redirectUrl.ts b/packages/shared/utils/redirectUrl.ts new file mode 100644 index 00000000..c2adffc0 --- /dev/null +++ b/packages/shared/utils/redirectUrl.ts @@ -0,0 +1,35 @@ +/** + * Validates a redirect URL to prevent open redirect attacks. + * Only allows: + * - Relative paths starting with "/" (but not "//" to prevent protocol-relative URLs) + * - The karakeep:// scheme for the mobile app + * + * @returns The validated URL if valid, otherwise undefined. + */ +export function validateRedirectUrl( + url: string | null | undefined, +): string | undefined { + if (!url) { + return undefined; + } + + // Allow relative paths starting with "/" but not "//" (protocol-relative URLs) + if (url.startsWith("/") && !url.startsWith("//")) { + return url; + } + + // Allow karakeep:// scheme for mobile app deep links + if (url.startsWith("karakeep://")) { + return url; + } + + // Reject all other schemes (http, https, javascript, data, etc.) + return undefined; +} + +/** + * Checks if the redirect URL is a mobile app deep link. + */ +export function isMobileAppRedirect(url: string): boolean { + return url.startsWith("karakeep://"); +} diff --git a/packages/shared/utils/tag.ts b/packages/shared/utils/tag.ts index 8e1bd105..b69b817e 100644 --- a/packages/shared/utils/tag.ts +++ b/packages/shared/utils/tag.ts @@ -1,6 +1,37 @@ +import type { ZTagStyle } from "../types/users"; + /** * Ensures exactly ONE leading # */ export function normalizeTagName(raw: string): string { return raw.trim().replace(/^#+/, ""); // strip every leading # } + +export type TagStyle = ZTagStyle; + +export function getTagStylePrompt(style: TagStyle): string { + switch (style) { + case "lowercase-hyphens": + return "- Use lowercase letters with hyphens between words (e.g., 'machine-learning', 'web-development')"; + case "lowercase-spaces": + return "- Use lowercase letters with spaces between words (e.g., 'machine learning', 'web development')"; + case "lowercase-underscores": + return "- Use lowercase letters with underscores between words (e.g., 'machine_learning', 'web_development')"; + case "titlecase-spaces": + return "- Use title case with spaces between words (e.g., 'Machine Learning', 'Web Development')"; + case "titlecase-hyphens": + return "- Use title case with hyphens between words (e.g., 'Machine-Learning', 'Web-Development')"; + case "camelCase": + return "- Use camelCase format (e.g., 'machineLearning', 'webDevelopment')"; + case "as-generated": + default: + return ""; + } +} + +export function getCuratedTagsPrompt(curatedTags?: string[]): string { + if (curatedTags && curatedTags.length > 0) { + return `- ONLY use tags from this predefined list: [${curatedTags.join(", ")}]. Do not create any new tags outside this list. If no tags fit, don't emit any.`; + } + return ""; +} |
