aboutsummaryrefslogtreecommitdiffstats
path: root/packages/shared/utils
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--packages/shared/utils/bookmarkUtils.ts10
-rw-r--r--packages/shared/utils/redirectUrl.test.ts89
-rw-r--r--packages/shared/utils/redirectUrl.ts35
-rw-r--r--packages/shared/utils/tag.ts31
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 "";
+}