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
This commit is contained in:
parent
890b82e5ec
commit
1b20665b0d
7 changed files with 252 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -357,14 +357,11 @@ html, body {
|
|||
</div>
|
||||
|
||||
<div id="signupResult">
|
||||
<h2>🚀 You're in!</h2>
|
||||
<h2>📧 Check your email!</h2>
|
||||
<p style="color:var(--fg);line-height:1.7;">We've sent a verification link to your email address. Click the link to get your API key.</p>
|
||||
<div class="warning-box">
|
||||
<span class="icon">⚠️</span>
|
||||
<span>Save your API key now — we can't recover it later.</span>
|
||||
</div>
|
||||
<div class="key-box" id="apiKeyDisplay">
|
||||
<span class="key-text" id="apiKeyText"></span>
|
||||
<button class="copy-btn" id="copyBtn">Copy</button>
|
||||
<span class="icon">💡</span>
|
||||
<span>The link expires in 24 hours. Check your spam folder if you don't see it.</span>
|
||||
</div>
|
||||
<p style="margin-top:20px;color:var(--muted);font-size:0.9rem;">100 free PDFs/month • <a href="/docs">Read the docs →</a></p>
|
||||
</div>
|
||||
|
|
|
|||
58
src/index.ts
58
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 `<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${title} — DocFast</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Inter',sans-serif;background:#0b0d11;color:#e4e7ed;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
|
||||
.card{background:#151922;border:1px solid #1e2433;border-radius:16px;padding:48px;max-width:520px;width:100%;text-align:center}
|
||||
h1{font-size:1.8rem;margin-bottom:12px;font-weight:800}
|
||||
p{color:#7a8194;margin-bottom:24px;line-height:1.6}
|
||||
.key-box{background:#0b0d11;border:1px solid #34d399;border-radius:8px;padding:16px;font-family:monospace;font-size:0.82rem;word-break:break-all;margin:16px 0;cursor:pointer;transition:background 0.2s;position:relative}
|
||||
.key-box:hover{background:#12151c}
|
||||
.key-box::after{content:'Click to copy';position:absolute;top:-24px;right:0;font-size:0.7rem;color:#7a8194;font-family:'Inter',sans-serif}
|
||||
.warning{background:rgba(251,191,36,0.06);border:1px solid rgba(251,191,36,0.15);border-radius:8px;padding:12px 16px;font-size:0.85rem;color:#fbbf24;margin-bottom:16px;text-align:left}
|
||||
.links{margin-top:24px;color:#7a8194;font-size:0.9rem}
|
||||
.links a{color:#34d399;text-decoration:none}
|
||||
.links a:hover{color:#5eead4}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<h1>${title}</h1>
|
||||
<p>${message}</p>
|
||||
${apiKey ? `
|
||||
<div class="warning">⚠️ Save your API key now — we can't recover it later.</div>
|
||||
<div class="key-box" onclick="navigator.clipboard.writeText('${apiKey}');this.style.borderColor='#5eead4';setTimeout(()=>this.style.borderColor='#34d399',1500)">${apiKey}</div>
|
||||
<div class="links">100 free PDFs/month · <a href="/docs">Read the docs →</a></div>
|
||||
` : `<div class="links"><a href="/">← Back to DocFast</a></div>`}
|
||||
</div></body></html>`;
|
||||
}
|
||||
|
||||
// Landing page
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
app.use(express.static(path.join(__dirname, "../public")));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
52
src/services/email.ts
Normal file
52
src/services/email.ts
Normal file
|
|
@ -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<boolean> {
|
||||
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 <noreply@docfast.dev>",
|
||||
to: [email],
|
||||
subject: "Verify your DocFast account",
|
||||
html: `
|
||||
<div style="font-family: -apple-system, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||
<h1 style="font-size: 24px; margin-bottom: 16px;">⚡ Welcome to DocFast</h1>
|
||||
<p style="color: #555; line-height: 1.6;">Click the button below to verify your email and get your API key:</p>
|
||||
<a href="${verifyUrl}" style="display: inline-block; background: #34d399; color: #0b0d11; padding: 14px 28px; border-radius: 8px; font-weight: 600; text-decoration: none; margin: 24px 0;">Verify Email →</a>
|
||||
<p style="color: #999; font-size: 13px; margin-top: 24px;">This link expires in 24 hours. If you didn't sign up for DocFast, ignore this email.</p>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
98
src/services/verification.ts
Normal file
98
src/services/verification.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue