diff --git a/docs/commercial/checkout-migration.md b/docs/commercial/checkout-migration.md index ebe3c3c..c064917 100644 --- a/docs/commercial/checkout-migration.md +++ b/docs/commercial/checkout-migration.md @@ -23,6 +23,7 @@ This runbook moves Dictx Pro sales to `https://dictx.splitlabs.io/buy` while kee - Product name: `Dictx Pro` - Offer copy: `Signed binaries, auto-updates, and direct support` - Price: `USD 29 one-time` +- Success URL: `https://dictx.splitlabs.io/buy/success?checkout_id={CHECKOUT_ID}` 3. Enable customer portal for: diff --git a/landing/README.md b/landing/README.md index 25f264d..5c92fd7 100644 --- a/landing/README.md +++ b/landing/README.md @@ -24,3 +24,9 @@ Attach `dictx.splitlabs.io` to this Vercel project. - `POLAR_ACCESS_TOKEN`: Polar API token - `POLAR_DICTX_PRODUCT_IDS`: optional comma-separated product IDs allowed for Dictx Pro activation - `POLAR_API_BASE`: optional override (defaults to `https://api.polar.sh/v1`) + +## Polar Checkout Success URL + +Set the product checkout success URL to: + +- `https://dictx.splitlabs.io/buy/success?checkout_id={CHECKOUT_ID}` diff --git a/landing/api/pro/verify.js b/landing/api/pro/verify.js index 1469f2e..7c0b127 100644 --- a/landing/api/pro/verify.js +++ b/landing/api/pro/verify.js @@ -4,6 +4,17 @@ const ALLOWED_PRODUCT_IDS = (process.env.POLAR_DICTX_PRODUCT_IDS || "") .split(",") .map((value) => value.trim()) .filter(Boolean); +const RATE_LIMIT_WINDOW_MS = Number.parseInt( + process.env.PRO_VERIFY_RATE_LIMIT_WINDOW_MS || "60000", + 10, +); +const RATE_LIMIT_MAX = Number.parseInt( + process.env.PRO_VERIFY_RATE_LIMIT_MAX || "20", + 10, +); +const MAX_LICENSE_KEY_LENGTH = 128; +const LICENSE_KEY_PATTERN = /^polar_cl_[A-Za-z0-9]+$/; +const requestBuckets = new Map(); const readBody = (req) => { if (!req.body) return {}; @@ -28,6 +39,39 @@ const statusLooksPaid = (status, paid) => { ); }; +const getClientId = (req) => { + const forwarded = req.headers["x-forwarded-for"]; + if (Array.isArray(forwarded)) { + return forwarded[0] || "unknown"; + } + if (typeof forwarded === "string" && forwarded.length > 0) { + const [first] = forwarded.split(","); + return first.trim() || "unknown"; + } + return req.socket?.remoteAddress || "unknown"; +}; + +const isRateLimited = (clientId) => { + const now = Date.now(); + if (requestBuckets.size > 5000) { + for (const [key, bucket] of requestBuckets.entries()) { + if (now > bucket.resetAt) { + requestBuckets.delete(key); + } + } + } + + const existing = requestBuckets.get(clientId); + if (!existing || now > existing.resetAt) { + requestBuckets.set(clientId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); + return false; + } + + existing.count += 1; + requestBuckets.set(clientId, existing); + return existing.count > RATE_LIMIT_MAX; +}; + module.exports = async (req, res) => { if (req.method !== "POST") { res.status(405).json({ error: "method_not_allowed" }); @@ -39,6 +83,12 @@ module.exports = async (req, res) => { return; } + const clientId = getClientId(req); + if (isRateLimited(clientId)) { + res.status(429).json({ error: "rate_limited" }); + return; + } + const body = readBody(req); const licenseKey = (body.licenseKey || body.checkoutId || "").trim(); @@ -47,6 +97,18 @@ module.exports = async (req, res) => { return; } + if (licenseKey.length > MAX_LICENSE_KEY_LENGTH) { + console.warn("pro_verify_invalid_key_length", { clientId }); + res.status(400).json({ error: "invalid_license_key" }); + return; + } + + if (!LICENSE_KEY_PATTERN.test(licenseKey)) { + console.warn("pro_verify_invalid_key_format", { clientId }); + res.status(400).json({ error: "invalid_license_key" }); + return; + } + try { const response = await fetch( `${POLAR_API_BASE}/checkouts/${encodeURIComponent(licenseKey)}`, @@ -66,6 +128,10 @@ module.exports = async (req, res) => { if (!response.ok) { const text = await response.text(); + console.warn("pro_verify_polar_api_error", { + status: response.status, + clientId, + }); res.status(502).json({ error: "polar_api_error", detail: text }); return; } @@ -79,6 +145,7 @@ module.exports = async (req, res) => { res.status(200).json({ active: Boolean(productAllowed && paid) }); } catch (error) { + console.warn("pro_verify_internal_error", { clientId }); res.status(500).json({ error: "internal_error", detail: error instanceof Error ? error.message : String(error), diff --git a/landing/buy/success/index.html b/landing/buy/success/index.html new file mode 100644 index 0000000..307d96f --- /dev/null +++ b/landing/buy/success/index.html @@ -0,0 +1,75 @@ + + + + + + Dictx Pro Activated + + + + + +
+ + + +
+
+

Purchase Complete

+

Activate Dictx Pro

+

+ Copy your license key and paste it in Dictx: + Settings -> About -> Activate Dictx Pro. +

+ +
+

Your License Key

+

+ Not found in URL. Add ?checkout_id=polar_cl_.... +

+
+ + Back to Dictx +
+

+
+
+
+ + + + diff --git a/src/components/settings/about/AboutSettings.tsx b/src/components/settings/about/AboutSettings.tsx index 4d11db3..4fe65cb 100644 --- a/src/components/settings/about/AboutSettings.tsx +++ b/src/components/settings/about/AboutSettings.tsx @@ -16,6 +16,7 @@ export const AboutSettings: React.FC = () => { const { t } = useTranslation(); const [version, setVersion] = useState(""); const [licenseKey, setLicenseKey] = useState(""); + const [clipboardError, setClipboardError] = useState(null); const { entitlement, isSubmitting: proSubmitting, @@ -44,6 +45,16 @@ export const AboutSettings: React.FC = () => { } }; + const handlePasteFromClipboard = async () => { + try { + setClipboardError(null); + const text = await navigator.clipboard.readText(); + setLicenseKey(text.trim()); + } catch (_error) { + setClipboardError(t("settings.about.proActivation.clipboardFailed")); + } + }; + return (
@@ -92,6 +103,14 @@ export const AboutSettings: React.FC = () => { placeholder={t("settings.about.proActivation.licenseKeyPlaceholder")} disabled={proSubmitting} /> + + {clipboardError && ( +

{clipboardError}

+ )} {proError &&

{proError}

} {entitlement?.verification_error && (

diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 1ee4dfd..6552a9a 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -546,7 +546,9 @@ "refresh": "Re-check Entitlement", "clear": "Remove Activation", "licenseKey": "License Key", - "licenseKeyPlaceholder": "polar_cl_..." + "licenseKeyPlaceholder": "polar_cl_...", + "pasteFromClipboard": "Paste From Clipboard", + "clipboardFailed": "Clipboard access failed. Paste manually." }, "acknowledgments": { "title": "Acknowledgments",