Purchase Complete
+Activate Dictx Pro
++ Copy your license key and paste it in Dictx: + Settings -> About -> Activate Dictx Pro. +
+ + +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
+
+
+ Purchase Complete
++ Copy your license key and paste it in Dictx: + Settings -> About -> Activate Dictx Pro. +
+ + +{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",