From 1b20665b0dd59b3f289bf12fd16e91c5d0962f48 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 14 Feb 2026 18:12:25 +0000 Subject: [PATCH] feat: email verification for free tier signup - Signup now requires email verification before API key is revealed - Verification token sent via email (Resend) with console fallback - GET /verify?token=xxx shows API key in styled HTML page - Handles expired (24h), invalid, and already-verified tokens - Frontend modal shows 'check your email' instead of key - Keeps existing rate limiting --- docker-compose.yml | 1 + public/app.js | 16 ++++-- public/index.html | 11 ++-- src/index.ts | 58 +++++++++++++++++++++ src/routes/signup.ts | 45 ++++++++++------- src/services/email.ts | 52 +++++++++++++++++++ src/services/verification.ts | 98 ++++++++++++++++++++++++++++++++++++ 7 files changed, 252 insertions(+), 29 deletions(-) create mode 100644 src/services/email.ts create mode 100644 src/services/verification.ts diff --git a/docker-compose.yml b/docker-compose.yml index 9a0458c..af57b32 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} - BASE_URL=${BASE_URL:-https://docfast.dev} - PRO_KEYS=${PRO_KEYS} + - RESEND_API_KEY=${RESEND_API_KEY:-FILL_IN} volumes: - docfast-data:/app/data mem_limit: 512m diff --git a/public/app.js b/public/app.js index 6e99687..6882a11 100644 --- a/public/app.js +++ b/public/app.js @@ -9,6 +9,7 @@ function openSignup() { document.getElementById('signupModal').classList.add('active'); showState('signupInitial'); document.getElementById('signupError').style.display = 'none'; + document.getElementById('signupEmail').value = ''; } function closeSignup() { @@ -18,6 +19,15 @@ function closeSignup() { async function submitSignup() { var errEl = document.getElementById('signupError'); var btn = document.getElementById('signupBtn'); + var emailInput = document.getElementById('signupEmail'); + var email = emailInput.value.trim(); + + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + errEl.textContent = 'Please enter a valid email address.'; + errEl.style.display = 'block'; + return; + } + errEl.style.display = 'none'; btn.disabled = true; showState('signupLoading'); @@ -26,7 +36,7 @@ async function submitSignup() { var res = await fetch('/v1/signup/free', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}) + body: JSON.stringify({ email: email }) }); var data = await res.json(); @@ -38,7 +48,7 @@ async function submitSignup() { return; } - document.getElementById('apiKeyText').textContent = data.apiKey; + // Show "check your email" message showState('signupResult'); } catch (err) { showState('signupInitial'); @@ -86,11 +96,9 @@ document.addEventListener('DOMContentLoaded', function() { document.getElementById('btn-checkout').addEventListener('click', checkout); document.getElementById('btn-close-signup').addEventListener('click', closeSignup); document.getElementById('signupBtn').addEventListener('click', submitSignup); - document.getElementById('copyBtn').addEventListener('click', copyKey); document.getElementById('signupModal').addEventListener('click', function(e) { if (e.target === this) closeSignup(); }); - // Smooth scroll for nav links document.querySelectorAll('a[href^="#"]').forEach(function(a) { a.addEventListener('click', function(e) { e.preventDefault(); diff --git a/public/index.html b/public/index.html index e229cf5..508c659 100644 --- a/public/index.html +++ b/public/index.html @@ -357,14 +357,11 @@ html, body {
-

๐Ÿš€ You're in!

+

๐Ÿ“ง Check your email!

+

We've sent a verification link to your email address. Click the link to get your API key.

- โš ๏ธ - Save your API key now โ€” we can't recover it later. -
-
- - + ๐Ÿ’ก + The link expires in 24 hours. Check your spam folder if you don't see it.

100 free PDFs/month โ€ข Read the docs โ†’

diff --git a/src/index.ts b/src/index.ts index b7e8e91..d1d45bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { usageMiddleware } from "./middleware/usage.js"; import { getUsageStats } from "./middleware/usage.js"; import { initBrowser, closeBrowser } from "./services/browser.js"; import { loadKeys, getAllKeys } from "./services/keys.js"; +import { verifyToken } from "./services/verification.js"; const app = express(); const PORT = parseInt(process.env.PORT || "3100", 10); @@ -77,6 +78,63 @@ app.get("/v1/usage", authMiddleware, (_req, res) => { res.json(getUsageStats()); }); +// Email verification endpoint +app.get("/verify", (req, res) => { + const token = req.query.token as string; + if (!token) { + res.status(400).send(verifyPage("Invalid Link", "No verification token provided.", null)); + return; + } + + const result = verifyToken(token); + + switch (result.status) { + case "ok": + res.send(verifyPage("Email Verified! ๐Ÿš€", "Your DocFast API key is ready:", result.verification!.apiKey)); + break; + case "already_verified": + res.send(verifyPage("Already Verified", "This email was already verified. Here's your API key:", result.verification!.apiKey)); + break; + case "expired": + res.status(410).send(verifyPage("Link Expired", "This verification link has expired (24h). Please sign up again.", null)); + break; + case "invalid": + res.status(404).send(verifyPage("Invalid Link", "This verification link is not valid.", null)); + break; + } +}); + +function verifyPage(title: string, message: string, apiKey: string | null): string { + return ` + +${title} โ€” DocFast + + + +
+

${title}

+

${message}

+${apiKey ? ` +
โš ๏ธ Save your API key now โ€” we can't recover it later.
+
${apiKey}
+ +` : ``} +
`; +} + // Landing page const __dirname = path.dirname(fileURLToPath(import.meta.url)); app.use(express.static(path.join(__dirname, "../public"))); diff --git a/src/routes/signup.ts b/src/routes/signup.ts index f98c1ab..508a321 100644 --- a/src/routes/signup.ts +++ b/src/routes/signup.ts @@ -1,13 +1,15 @@ import { Router, Request, Response } from "express"; import rateLimit from "express-rate-limit"; import { createFreeKey } from "../services/keys.js"; +import { createVerification, verifyToken, isEmailVerified, getVerifiedApiKey } from "../services/verification.js"; +import { sendVerificationEmail } from "../services/email.js"; const router = Router(); // Rate limiting for signup - 5 signups per IP per hour const signupLimiter = rateLimit({ - windowMs: 60 * 60 * 1000, // 1 hour - max: 5, // 5 signups per IP per hour + windowMs: 60 * 60 * 1000, + max: 5, message: { error: "Too many signup attempts. Please try again in 1 hour.", retryAfter: "1 hour" @@ -18,29 +20,36 @@ const signupLimiter = rateLimit({ skipFailedRequests: false, }); -// Self-service free API key signup -router.post("/free", signupLimiter, (req: Request, res: Response) => { +// Self-service free API key signup โ€” now requires email verification +router.post("/free", signupLimiter, async (req: Request, res: Response) => { const { email } = req.body || {}; - // Email is optional โ€” validate only if provided - let cleanEmail: string | undefined; - if (email && typeof email === "string") { - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - res.status(400).json({ error: "Invalid email address" }); - return; - } - cleanEmail = email.trim().toLowerCase(); + if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + res.status(400).json({ error: "A valid email address is required." }); + return; } + const cleanEmail = email.trim().toLowerCase(); + + // If already verified, tell them + if (isEmailVerified(cleanEmail)) { + res.status(409).json({ error: "This email is already registered. Check your inbox for the original verification email, or contact support." }); + return; + } + + // Create the API key (but don't reveal it yet) const keyInfo = createFreeKey(cleanEmail); + // Create verification record + const verification = createVerification(cleanEmail, keyInfo.key); + + // Send verification email + await sendVerificationEmail(cleanEmail, verification.token); + res.json({ - message: "Welcome to DocFast! ๐Ÿš€", - apiKey: keyInfo.key, - tier: "free", - limit: "100 PDFs/month", - docs: "https://docfast.dev/docs", - note: "Save this API key โ€” it won't be shown again.", + message: "Check your email for a verification link to get your API key.", + email: cleanEmail, + verified: false, }); }); diff --git a/src/services/email.ts b/src/services/email.ts new file mode 100644 index 0000000..d169e3c --- /dev/null +++ b/src/services/email.ts @@ -0,0 +1,52 @@ +const RESEND_API_KEY = process.env.RESEND_API_KEY; +const BASE_URL = process.env.BASE_URL || "https://docfast.dev"; + +export async function sendVerificationEmail(email: string, token: string): Promise { + const verifyUrl = `${BASE_URL}/verify?token=${token}`; + + if (!RESEND_API_KEY || RESEND_API_KEY === "FILL_IN") { + console.log(`\n๐Ÿ“ง VERIFICATION EMAIL (no Resend configured)`); + console.log(` To: ${email}`); + console.log(` URL: ${verifyUrl}\n`); + return true; + } + + try { + const res = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + "Authorization": `Bearer ${RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: "DocFast ", + to: [email], + subject: "Verify your DocFast account", + html: ` +
+

โšก Welcome to DocFast

+

Click the button below to verify your email and get your API key:

+ Verify Email โ†’ +

This link expires in 24 hours. If you didn't sign up for DocFast, ignore this email.

+
+ `, + }), + }); + + if (!res.ok) { + const err = await res.text(); + console.error(`Resend API error: ${res.status} ${err}`); + // Fallback to console + console.log(`\n๐Ÿ“ง VERIFICATION EMAIL (Resend failed, logging)`); + console.log(` To: ${email}`); + console.log(` URL: ${verifyUrl}\n`); + } + return true; + } catch (err) { + console.error("Email send error:", err); + console.log(`\n๐Ÿ“ง VERIFICATION EMAIL (fallback)`); + console.log(` To: ${email}`); + console.log(` URL: ${verifyUrl}\n`); + return true; + } +} diff --git a/src/services/verification.ts b/src/services/verification.ts new file mode 100644 index 0000000..6f1f524 --- /dev/null +++ b/src/services/verification.ts @@ -0,0 +1,98 @@ +import { randomBytes } from "crypto"; +import { existsSync, mkdirSync } from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = path.join(__dirname, "../../data"); +const DB_PATH = path.join(DATA_DIR, "verifications.json"); + +// Simple JSON-based store (no SQLite dependency needed) +import { readFileSync, writeFileSync } from "fs"; + +export interface Verification { + email: string; + token: string; + apiKey: string; + createdAt: string; + verifiedAt: string | null; +} + +let verifications: Verification[] = []; + +function ensureDataDir(): void { + if (!existsSync(DATA_DIR)) { + mkdirSync(DATA_DIR, { recursive: true }); + } +} + +function load(): void { + ensureDataDir(); + if (existsSync(DB_PATH)) { + try { + verifications = JSON.parse(readFileSync(DB_PATH, "utf-8")); + } catch { + verifications = []; + } + } +} + +function save(): void { + ensureDataDir(); + writeFileSync(DB_PATH, JSON.stringify(verifications, null, 2)); +} + +// Initialize on import +load(); + +const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours + +export function createVerification(email: string, apiKey: string): Verification { + // Check for existing pending verification for this email + const existing = verifications.find( + (v) => v.email === email && !v.verifiedAt + ); + if (existing) { + // Check if expired + const age = Date.now() - new Date(existing.createdAt).getTime(); + if (age < TOKEN_EXPIRY_MS) { + return existing; // Return existing pending verification + } + // Expired โ€” remove it + verifications = verifications.filter((v) => v !== existing); + } + + const verification: Verification = { + email, + token: randomBytes(32).toString("hex"), + apiKey, + createdAt: new Date().toISOString(), + verifiedAt: null, + }; + verifications.push(verification); + save(); + return verification; +} + +export function verifyToken(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } { + const v = verifications.find((v) => v.token === token); + if (!v) return { status: "invalid" }; + + if (v.verifiedAt) return { status: "already_verified", verification: v }; + + const age = Date.now() - new Date(v.createdAt).getTime(); + if (age > TOKEN_EXPIRY_MS) return { status: "expired" }; + + v.verifiedAt = new Date().toISOString(); + save(); + return { status: "ok", verification: v }; +} + +export function isEmailVerified(email: string): boolean { + return verifications.some((v) => v.email === email && v.verifiedAt !== null); +} + +export function getVerifiedApiKey(email: string): string | null { + const v = verifications.find((v) => v.email === email && v.verifiedAt !== null); + return v?.apiKey ?? null; +}