diff --git a/.env.example b/.env.example index 248692f18..7cb817a09 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ POSTGRES_PRISMA_URL= POSTGRES_PRISMA_URL_NON_POOLING= # This variable is from Vercel Storage Blob BLOB_READ_WRITE_TOKEN= +VERCEL_BLOB_HOST=vercel-storage.com # Google client id and secret for authentication GOOGLE_CLIENT_ID= diff --git a/components/documents/add-document-modal.tsx b/components/documents/add-document-modal.tsx index 088fc67cf..e7a8ad854 100644 --- a/components/documents/add-document-modal.tsx +++ b/components/documents/add-document-modal.tsx @@ -405,19 +405,20 @@ export function AddDocumentModal({ } // Try to validate the Notion page URL - let validateNotionPageURL = parsePageId(notionLink); + let validateNotionPageId = parsePageId(notionLink); // If parsePageId fails, try to get page ID from slug - if (validateNotionPageURL === null) { + if (validateNotionPageId === null) { try { - validateNotionPageURL = await getNotionPageIdFromSlug(notionLink); + const pageId = await getNotionPageIdFromSlug(notionLink); + validateNotionPageId = pageId || undefined; } catch (slugError) { toast.error("Please enter a valid Notion link to proceed."); return; } } - if (!validateNotionPageURL) { + if (!validateNotionPageId) { toast.error("Please enter a valid Notion link to proceed."); return; } diff --git a/lib/api/documents/process-document.ts b/lib/api/documents/process-document.ts index 12ad63c6d..ba64f6a58 100644 --- a/lib/api/documents/process-document.ts +++ b/lib/api/documents/process-document.ts @@ -55,7 +55,8 @@ export const processDocument = async ({ // If parsePageId fails, try to get page ID from slug if (!pageId) { try { - pageId = await getNotionPageIdFromSlug(key); + const pageIdFromSlug = await getNotionPageIdFromSlug(key); + pageId = pageIdFromSlug || undefined; } catch (slugError) { throw new Error("Unable to extract page ID from Notion URL"); } diff --git a/lib/dub.ts b/lib/dub.ts index 59acf19b9..c0e172a06 100644 --- a/lib/dub.ts +++ b/lib/dub.ts @@ -3,3 +3,22 @@ import { Dub } from "dub"; export const dub = new Dub({ token: process.env.DUB_API_KEY, }); + +export async function getDubDiscountForExternalUserId(externalId: string) { + try { + const customers = await dub.customers.list({ + externalId, + includeExpandedFields: true, + }); + const first = customers[0]; + const couponId = + process.env.NODE_ENV !== "production" && first?.discount?.couponTestId + ? first.discount.couponTestId + : first?.discount?.couponId; + + return couponId ? { discounts: [{ coupon: couponId }] } : null; + } catch (err) { + console.warn("Skipping Dub discount due to API error", err); + return null; // degrade gracefully; don't block checkout + } +} diff --git a/lib/notion/utils.ts b/lib/notion/utils.ts index e59d4e362..53f45c4af 100644 --- a/lib/notion/utils.ts +++ b/lib/notion/utils.ts @@ -1,5 +1,5 @@ import { NotionAPI } from "notion-client"; -import { getPageContentBlockIds } from "notion-utils"; +import { getPageContentBlockIds, parsePageId } from "notion-utils"; import notion from "./index"; @@ -75,15 +75,86 @@ export const addSignedUrls: NotionAPI["addSignedUrls"] = async ({ } }; -export async function getNotionPageIdFromSlug(url: string) { +/** + * Extracts page ID from custom Notion domain URLs + * For custom domains, the page ID is typically embedded in the URL slug + */ +export function extractPageIdFromCustomNotionUrl(url: string): string | null { + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + + // Try robust parser first (handles hyphenated and plain IDs) + const parsed = parsePageId(url) || parsePageId(pathname); + if (parsed) return parsed; + + // Fallback: match either plain 32-hex or hyphenated UUID-like Notion ID + const pageIdMatch = pathname.match( + /\b(?:[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\b/i, + ); + if (pageIdMatch) return parsePageId(pageIdMatch[0]) ?? pageIdMatch[0]; + + return null; + } catch { + return null; + } +} + +/** + * Check if a URL is potentially a custom Notion domain by attempting to extract a page ID + * and verifying the page exists + */ +export async function isCustomNotionDomain(url: string): Promise { + try { + const pageId = extractPageIdFromCustomNotionUrl(url); + if (!pageId) { + return false; + } + + // Try to fetch the page to verify it exists and is accessible + await notion.getPage(pageId); + return true; + } catch { + return false; + } +} + +export async function getNotionPageIdFromSlug( + url: string, +): Promise { // Parse the URL to extract domain and slug const urlObj = new URL(url); const hostname = urlObj.hostname; + const isNotionSo = hostname === "www.notion.so" || hostname === "notion.so"; + const isNotionSite = hostname.endsWith(".notion.site"); + + // notion.so: extract ID from path directly + if (isNotionSo) { + const pageId = parsePageId(url) ?? parsePageId(urlObj.pathname); + if (pageId) return pageId; + throw new Error(`Unable to extract page ID from Notion URL: ${url}`); + } + + // Custom domains (non notion.site, non notion.so) + if (!isNotionSite) { + const pageId = extractPageIdFromCustomNotionUrl(url); + if (pageId) { + // Verify the page exists before returning the ID + try { + await notion.getPage(pageId); + return pageId; + } catch { + throw new Error(`Custom Notion domain page not accessible: ${url}`); + } + } + throw new Error(`Unable to extract page ID from custom domain URL: ${url}`); + } + // Extract domain from hostname (e.g., "domain" from "domain.notion.site") const domainMatch = hostname.match(/^([^.]+)\.notion\.site$/); if (!domainMatch) { - throw new Error("Invalid Notion site URL format: ${url}"); + throw new Error(`Invalid Notion site URL format: ${url}`); } const spaceDomain = domainMatch[1]; @@ -93,8 +164,7 @@ export async function getNotionPageIdFromSlug(url: string) { let slug = urlObj.pathname.substring(1) || ""; // Make request to Notion's internal API - const apiUrl = - "https://${spaceDomain}.notion.site/api/v3/getPublicPageDataForDomain"; + const apiUrl = `https://${spaceDomain}.notion.site/api/v3/getPublicPageDataForDomain`; const payload = { type: "block-space", name: "page", @@ -114,7 +184,7 @@ export async function getNotionPageIdFromSlug(url: string) { if (!response.ok) { throw new Error( - "Notion API request failed: ${response.status} ${response.statusText}", + `Notion API request failed: ${response.status} ${response.statusText}`, ); } diff --git a/lib/zod/url-validation.ts b/lib/zod/url-validation.ts index e9c4d754d..998ec220c 100644 --- a/lib/zod/url-validation.ts +++ b/lib/zod/url-validation.ts @@ -1,9 +1,14 @@ +import { parsePageId } from "notion-utils"; import { z } from "zod"; import { SUPPORTED_DOCUMENT_MIME_TYPES, SUPPORTED_DOCUMENT_SIMPLE_TYPES, } from "@/lib/constants"; +import { + getNotionPageIdFromSlug, + isCustomNotionDomain, +} from "@/lib/notion/utils"; import { getSupportedContentType } from "@/lib/utils/get-content-type"; /** @@ -76,13 +81,43 @@ export const validateUrlSecurity = (url: string): boolean => { return validatePathSecurity(url) && validateUrlSSRFProtection(url); }; +// Helper function to validate URL hostnames for different storage types +const validateUrlHostname = (hostname: string): boolean => { + // Valid notion domains + const validNotionDomains = ["www.notion.so", "notion.so"]; + + // Check for notion.site subdomains (e.g., example-something.notion.site) + const isNotionSite = hostname.endsWith(".notion.site"); + const isValidNotionDomain = validNotionDomains.includes(hostname); + + // Check for vercel blob storage + let isVercelBlob = false; + + const normalizedHostname = hostname.toLowerCase().trim(); + + if (process.env.VERCEL_BLOB_HOST) { + + const normalizedBlobHost = process.env.VERCEL_BLOB_HOST.toLowerCase().trim(); + // Use exact match or suffix-with-dot to prevent bypasses + isVercelBlob = normalizedHostname === normalizedBlobHost || + normalizedHostname.endsWith("." + normalizedBlobHost); + } else { + // Fallback: check for common Vercel Blob patterns if env var is not set + // Use exact match or suffix-with-dot to prevent bypasses + isVercelBlob = normalizedHostname === "vercel-storage.com" || + normalizedHostname.endsWith(".vercel-storage.com"); + } + + return isNotionSite || isValidNotionDomain || isVercelBlob; +}; + // Custom validator for file paths - either Notion URLs or S3 storage paths const createFilePathValidator = () => { return z .string() .min(1, "File path is required") .refine( - (path) => { + async (path) => { // Case 1: Notion URLs - must start with notion domains if (path.startsWith("https://")) { try { @@ -99,7 +134,23 @@ const createFilePathValidator = () => { // Check for vercel blob storage let isVercelBlob = false; if (process.env.VERCEL_BLOB_HOST) { - isVercelBlob = hostname.startsWith(process.env.VERCEL_BLOB_HOST); + isVercelBlob = hostname.endsWith(process.env.VERCEL_BLOB_HOST); + } + + // If it's not a standard Notion domain or Vercel blob, check if it's a custom Notion domain + if (!isNotionSite && !isValidNotionDomain && !isVercelBlob) { + try { + let pageId = parsePageId(path); + if (!pageId) { + const pageIdFromSlug = await getNotionPageIdFromSlug(path); + if (pageIdFromSlug) { + pageId = pageIdFromSlug; + } + } + return !!pageId; + } catch { + return false; + } } return isNotionSite || isValidNotionDomain || isVercelBlob; @@ -132,6 +183,68 @@ const createFilePathValidator = () => { // File path validation schema export const filePathSchema = createFilePathValidator(); +// Dedicated Notion URL validation schema for URL updates +export const notionUrlUpdateSchema = z + .string() + .url("Invalid URL format") + .refine((url) => url.startsWith("https://"), { + message: "Notion URL must use HTTPS", + }) + .refine( + async (url) => { + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname; + + // Valid notion domains + const validNotionDomains = ["www.notion.so", "notion.so"]; + const isNotionSite = hostname.endsWith(".notion.site"); + const isValidNotionDomain = validNotionDomains.includes(hostname); + + // If it's a standard Notion domain, try to extract page ID + if (isNotionSite || isValidNotionDomain) { + let pageId = parsePageId(url); + if (!pageId) { + try { + const pageIdFromSlug = await getNotionPageIdFromSlug(url); + pageId = pageIdFromSlug || undefined; + } catch { + return false; + } + } + return !!pageId; + } + + // For custom domains, try to extract and validate page ID + try { + let pageId = parsePageId(url); + if (!pageId) { + const pageIdFromSlug = await getNotionPageIdFromSlug(url); + pageId = pageIdFromSlug || undefined; + } + return !!pageId; + } catch { + return false; + } + } catch { + return false; + } + }, + { + message: + "Must be a valid Notion URL (supports notion.so, notion.site, and custom domains)", + }, + ) + .refine( + (url) => { + // Additional security checks + return validatePathSecurity(url) && validateUrlSSRFProtection(url); + }, + { + message: "URL contains invalid characters or targets internal resources", + }, + ); + // Document upload validation schema with comprehensive type and content validation export const documentUploadSchema = z .object({ @@ -196,7 +309,7 @@ export const documentUploadSchema = z }, ) .refine( - (data) => { + async (data) => { // Skip storage type validation if not provided (e.g., for Notion files) if (!data.storageType) { // For Notion URLs, storage type is not required @@ -204,11 +317,21 @@ export const documentUploadSchema = z try { const urlObj = new URL(data.url); const hostname = urlObj.hostname; - return ( + const isStandardNotion = hostname === "www.notion.so" || hostname === "notion.so" || - hostname.endsWith(".notion.site") - ); + hostname.endsWith(".notion.site"); + + if (isStandardNotion) { + return true; + } + + // Check if it's a custom Notion domain + try { + return await isCustomNotionDomain(data.url); + } catch { + return false; + } } catch { return false; } @@ -228,11 +351,30 @@ export const documentUploadSchema = z try { const urlObj = new URL(data.url); const hostname = urlObj.hostname; - return ( + const isStandardNotion = hostname === "www.notion.so" || hostname === "notion.so" || - hostname.endsWith(".notion.site") - ); + hostname.endsWith(".notion.site"); + + if (isStandardNotion) { + return true; + } + + // Check for vercel blob storage + let isVercelBlob = false; + if (process.env.VERCEL_BLOB_HOST) { + isVercelBlob = hostname.endsWith(process.env.VERCEL_BLOB_HOST); + if (isVercelBlob) { + return true; + } + } + + // Check if it's a custom Notion domain + try { + return await isCustomNotionDomain(data.url); + } catch { + return false; + } } catch { return false; } diff --git a/pages/api/teams/[teamId]/billing/upgrade.ts b/pages/api/teams/[teamId]/billing/upgrade.ts index 079bbd108..b18cd6907 100644 --- a/pages/api/teams/[teamId]/billing/upgrade.ts +++ b/pages/api/teams/[teamId]/billing/upgrade.ts @@ -6,6 +6,7 @@ import { waitUntil } from "@vercel/functions"; import { getServerSession } from "next-auth/next"; import { identifyUser, trackAnalytics } from "@/lib/analytics"; +import { dub, getDubDiscountForExternalUserId } from "@/lib/dub"; import prisma from "@/lib/prisma"; import { CustomUser } from "@/lib/types"; @@ -76,6 +77,8 @@ export default async function handle( }), }; + const dubDiscount = await getDubDiscountForExternalUserId(userId); + const stripe = stripeInstance(oldAccount); if (team.stripeId) { // if the team already has a stripeId (i.e. is a customer) let's use as a customer @@ -113,19 +116,22 @@ export default async function handle( mode: "subscription", allow_promotion_codes: true, client_reference_id: teamId, + ...(dubDiscount ?? {}), metadata: { dubCustomerId: userId, }, }); } - waitUntil(identifyUser(userEmail ?? userId)); waitUntil( - trackAnalytics({ - event: "Stripe Checkout Clicked", - teamId, - priceId: priceId, - }), + Promise.all([ + identifyUser(userEmail ?? userId), + trackAnalytics({ + event: "Stripe Checkout Clicked", + teamId, + priceId: priceId, + }), + ]), ); return res.status(200).json(stripeSession); diff --git a/pages/api/teams/[teamId]/documents/[id]/update-notion-url.ts b/pages/api/teams/[teamId]/documents/[id]/update-notion-url.ts index f73d3b723..a79dbd31d 100644 --- a/pages/api/teams/[teamId]/documents/[id]/update-notion-url.ts +++ b/pages/api/teams/[teamId]/documents/[id]/update-notion-url.ts @@ -5,6 +5,7 @@ import { getServerSession } from "next-auth/next"; import prisma from "@/lib/prisma"; import { CustomUser } from "@/lib/types"; +import { notionUrlUpdateSchema } from "@/lib/zod/url-validation"; export default async function handle( req: NextApiRequest, @@ -24,22 +25,14 @@ export default async function handle( teamId: string; id: string; }; - const { notionUrl } = req.body as { notionUrl: string }; + const { notionUrl: url } = req.body as { notionUrl: string }; - // Basic URL validation - if (!notionUrl || typeof notionUrl !== "string") { - return res.status(400).json({ message: "Valid Notion URL is required" }); + const validationResult = await notionUrlUpdateSchema.safeParseAsync(url); + if (!validationResult.success) { + return res.status(400).json({ message: validationResult.error.message }); } - // Validate that it's a Notion URL - try { - const url = new URL(notionUrl); - if (!url.hostname.includes("notion.")) { - return res.status(400).json({ message: "Invalid Notion URL" }); - } - } catch (error) { - return res.status(400).json({ message: "Invalid URL format" }); - } + const notionUrl = validationResult.data; try { // Check if user has access to the team @@ -83,7 +76,7 @@ export default async function handle( // Preserve any existing query parameters from the old URL (like dark mode) const oldUrl = new URL(documentVersion.file); const newUrl = new URL(notionUrl); - + // Copy over the mode parameter if it exists const mode = oldUrl.searchParams.get("mode"); if (mode) { @@ -101,7 +94,7 @@ export default async function handle( }, }); - return res.status(200).json({ + return res.status(200).json({ message: "Notion URL updated successfully", newUrl: newUrl.toString(), }); @@ -109,4 +102,4 @@ export default async function handle( console.error("Error updating Notion URL:", error); return res.status(500).json({ message: "Internal server error" }); } -} \ No newline at end of file +} diff --git a/pages/api/teams/[teamId]/documents/[id]/versions/index.ts b/pages/api/teams/[teamId]/documents/[id]/versions/index.ts index 164992f5e..4eba34aad 100644 --- a/pages/api/teams/[teamId]/documents/[id]/versions/index.ts +++ b/pages/api/teams/[teamId]/documents/[id]/versions/index.ts @@ -32,7 +32,7 @@ export default async function handle( id: string; }; // Validate request body using Zod schema for security - const validationResult = documentUploadSchema.safeParse({ + const validationResult = await documentUploadSchema.safeParseAsync({ ...req.body, name: `Version ${new Date().toISOString()}`, // Dummy name for validation }); diff --git a/pages/api/teams/[teamId]/documents/agreement.ts b/pages/api/teams/[teamId]/documents/agreement.ts index 1790b964a..cd914bf0a 100644 --- a/pages/api/teams/[teamId]/documents/agreement.ts +++ b/pages/api/teams/[teamId]/documents/agreement.ts @@ -32,7 +32,7 @@ export default async function handle( const userId = (session.user as CustomUser).id; // Validate request body using Zod schema for security - const validationResult = documentUploadSchema.safeParse({ + const validationResult = await documentUploadSchema.safeParseAsync({ ...req.body, // Ensure type field is provided for validation type: req.body.type || getExtension(req.body.name), diff --git a/pages/api/teams/[teamId]/documents/index.ts b/pages/api/teams/[teamId]/documents/index.ts index c6d57eca3..682349262 100644 --- a/pages/api/teams/[teamId]/documents/index.ts +++ b/pages/api/teams/[teamId]/documents/index.ts @@ -242,7 +242,9 @@ export default async function handle( } // Validate request body using Zod schema for security - const validationResult = documentUploadSchema.safeParse(req.body); + const validationResult = await documentUploadSchema.safeParseAsync( + req.body, + ); if (!validationResult.success) { log({