fix: self-service signup, unified key store, persistent data volume
- Added /v1/signup/free endpoint for instant API key provisioning - Built unified key store (services/keys.ts) with file-based persistence - Refactored auth middleware to use key store (no more hardcoded env keys) - Refactored usage middleware to check key tier from store - Updated billing to use key store for Pro key provisioning - Landing page: replaced mailto: link with signup modal - Landing page: Pro checkout button now properly calls /v1/billing/checkout - Added Docker volume for persistent key storage - Success page now renders HTML instead of raw JSON - Tested: signup → key → PDF generation works end-to-end
This commit is contained in:
parent
c12c1176b0
commit
467a97ae1c
9 changed files with 361 additions and 126 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@ node_modules/
|
|||
dist/
|
||||
.env
|
||||
*.log
|
||||
data/
|
||||
|
|
|
|||
|
|
@ -13,5 +13,10 @@ services:
|
|||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||
- BASE_URL=${BASE_URL:-https://docfast.dev}
|
||||
- PRO_KEYS=${PRO_KEYS}
|
||||
volumes:
|
||||
- docfast-data:/app/data
|
||||
mem_limit: 512m
|
||||
cpus: 1.0
|
||||
|
||||
volumes:
|
||||
docfast-data:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.6; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 0 24px; }
|
||||
|
||||
/* Hero */
|
||||
|
|
@ -20,10 +19,10 @@ a:hover { text-decoration: underline; }
|
|||
.hero h1 span { color: var(--accent); }
|
||||
.hero p { font-size: 1.25rem; color: var(--muted); max-width: 600px; margin: 0 auto 40px; }
|
||||
.hero-actions { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
|
||||
.btn { display: inline-block; padding: 14px 32px; border-radius: 8px; font-size: 1rem; font-weight: 600; transition: all 0.2s; }
|
||||
.btn { display: inline-block; padding: 14px 32px; border-radius: 8px; font-size: 1rem; font-weight: 600; transition: all 0.2s; border: none; cursor: pointer; }
|
||||
.btn-primary { background: var(--accent); color: #000; }
|
||||
.btn-primary:hover { background: #6fb; text-decoration: none; }
|
||||
.btn-secondary { border: 1px solid var(--border); color: var(--fg); }
|
||||
.btn-secondary { border: 1px solid var(--border); color: var(--fg); background: transparent; }
|
||||
.btn-secondary:hover { border-color: var(--muted); text-decoration: none; }
|
||||
|
||||
/* Code block */
|
||||
|
|
@ -65,6 +64,24 @@ a:hover { text-decoration: underline; }
|
|||
|
||||
/* Footer */
|
||||
footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.85rem; border-top: 1px solid var(--border); }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; }
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 40px; max-width: 440px; width: 90%; }
|
||||
.modal h2 { margin-bottom: 8px; font-size: 1.5rem; }
|
||||
.modal p { color: var(--muted); margin-bottom: 24px; font-size: 0.95rem; }
|
||||
.modal input { width: 100%; padding: 14px 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 1rem; margin-bottom: 16px; outline: none; }
|
||||
.modal input:focus { border-color: var(--accent); }
|
||||
.modal .btn { width: 100%; text-align: center; }
|
||||
.modal .error { color: #f66; font-size: 0.85rem; margin-bottom: 12px; display: none; }
|
||||
.modal .close { position: absolute; top: 16px; right: 20px; color: var(--muted); font-size: 1.5rem; cursor: pointer; background: none; border: none; }
|
||||
|
||||
/* Key result */
|
||||
.key-result { display: none; }
|
||||
.key-result .key-box { background: var(--bg); border: 1px solid var(--accent); border-radius: 8px; padding: 16px; font-family: monospace; font-size: 0.85rem; word-break: break-all; margin: 16px 0; cursor: pointer; transition: background 0.2s; }
|
||||
.key-result .key-box:hover { background: #111; }
|
||||
.key-result .copy-hint { color: var(--muted); font-size: 0.8rem; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -74,12 +91,12 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.
|
|||
<h1>HTML & Markdown to <span>PDF</span></h1>
|
||||
<p>One API call. Beautiful PDFs. Built-in invoice templates. No headless browser setup, no dependencies, no hassle.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="#pricing" class="btn btn-primary">Get API Key</a>
|
||||
<button class="btn btn-primary" onclick="openSignup()">Get Free API Key</button>
|
||||
<a href="#endpoints" class="btn btn-secondary">View Docs</a>
|
||||
</div>
|
||||
<div class="code-hero">
|
||||
<span class="comment">// Convert markdown to PDF in one call</span><br>
|
||||
<span class="key">curl</span> -X POST https://api.docfast.dev/v1/convert/markdown \<br>
|
||||
<span class="key">curl</span> -X POST https://docfast.dev/v1/convert/markdown \<br>
|
||||
-H <span class="string">"Authorization: Bearer YOUR_KEY"</span> \<br>
|
||||
-H <span class="string">"Content-Type: application/json"</span> \<br>
|
||||
-d <span class="string">'{"markdown": "# Invoice\\n\\nAmount: $500"}'</span> \<br>
|
||||
|
|
@ -139,6 +156,11 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.
|
|||
<span class="endpoint-path">/v1/convert/markdown</span>
|
||||
<div class="endpoint-desc">Convert Markdown to styled PDF with syntax highlighting.</div>
|
||||
</div>
|
||||
<div class="endpoint">
|
||||
<span class="endpoint-method">POST</span>
|
||||
<span class="endpoint-path">/v1/convert/url</span>
|
||||
<div class="endpoint-desc">Navigate to a URL and convert the page to PDF.</div>
|
||||
</div>
|
||||
<div class="endpoint">
|
||||
<span class="endpoint-method">GET</span>
|
||||
<span class="endpoint-path">/v1/templates</span>
|
||||
|
|
@ -171,7 +193,7 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.
|
|||
<li>All templates</li>
|
||||
<li>Community support</li>
|
||||
</ul>
|
||||
<a href="mailto:hello@docfast.dev?subject=Free API Key" class="btn btn-secondary" style="width:100%">Get Free Key</a>
|
||||
<button class="btn btn-secondary" style="width:100%" onclick="openSignup()">Get Free Key</button>
|
||||
</div>
|
||||
<div class="price-card featured">
|
||||
<h3>Pro</h3>
|
||||
|
|
@ -183,7 +205,7 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.
|
|||
<li>Priority support</li>
|
||||
<li>Custom templates</li>
|
||||
</ul>
|
||||
<a href="#" class="btn btn-primary" style="width:100%" onclick="checkout(event)">Get Started</a>
|
||||
<button class="btn btn-primary" style="width:100%" onclick="checkout()">Get Started</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -195,9 +217,102 @@ footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.
|
|||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Signup Modal -->
|
||||
<div class="modal-overlay" id="signupModal">
|
||||
<div class="modal" style="position:relative">
|
||||
<button class="close" onclick="closeSignup()">×</button>
|
||||
<div id="signupForm">
|
||||
<h2>Get Your Free API Key</h2>
|
||||
<p>Enter your email and get an API key instantly. No credit card required.</p>
|
||||
<div class="error" id="signupError"></div>
|
||||
<input type="email" id="signupEmail" placeholder="you@example.com" autofocus>
|
||||
<button class="btn btn-primary" style="width:100%" onclick="submitSignup()" id="signupBtn">Get API Key</button>
|
||||
</div>
|
||||
<div class="key-result" id="keyResult">
|
||||
<h2>🚀 You're in!</h2>
|
||||
<p>Here's your API key. <strong>Save it now</strong> — it won't be shown again.</p>
|
||||
<div class="key-box" id="apiKeyDisplay" onclick="copyKey()" title="Click to copy"></div>
|
||||
<div class="copy-hint">Click to copy</div>
|
||||
<p style="margin-top:24px;color:var(--muted);font-size:0.9rem;">100 free PDFs/month • All endpoints • <a href="#endpoints">View docs →</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function checkout(e) {
|
||||
e.preventDefault();
|
||||
function openSignup() {
|
||||
document.getElementById('signupModal').classList.add('active');
|
||||
document.getElementById('signupForm').style.display = 'block';
|
||||
document.getElementById('keyResult').style.display = 'none';
|
||||
document.getElementById('signupEmail').value = '';
|
||||
document.getElementById('signupError').style.display = 'none';
|
||||
setTimeout(() => document.getElementById('signupEmail').focus(), 100);
|
||||
}
|
||||
|
||||
function closeSignup() {
|
||||
document.getElementById('signupModal').classList.remove('active');
|
||||
}
|
||||
|
||||
// Close on overlay click
|
||||
document.getElementById('signupModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeSignup();
|
||||
});
|
||||
|
||||
// Submit on Enter
|
||||
document.getElementById('signupEmail').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') submitSignup();
|
||||
});
|
||||
|
||||
async function submitSignup() {
|
||||
const email = document.getElementById('signupEmail').value.trim();
|
||||
const errEl = document.getElementById('signupError');
|
||||
const btn = document.getElementById('signupBtn');
|
||||
|
||||
if (!email) {
|
||||
errEl.textContent = 'Please enter your email.';
|
||||
errEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.textContent = 'Creating...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/v1/signup/free', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
errEl.textContent = data.error || 'Something went wrong.';
|
||||
errEl.style.display = 'block';
|
||||
btn.textContent = 'Get API Key';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show key
|
||||
document.getElementById('signupForm').style.display = 'none';
|
||||
document.getElementById('keyResult').style.display = 'block';
|
||||
document.getElementById('apiKeyDisplay').textContent = data.apiKey;
|
||||
} catch (err) {
|
||||
errEl.textContent = 'Network error. Please try again.';
|
||||
errEl.style.display = 'block';
|
||||
btn.textContent = 'Get API Key';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function copyKey() {
|
||||
const key = document.getElementById('apiKeyDisplay').textContent;
|
||||
navigator.clipboard.writeText(key).then(() => {
|
||||
document.querySelector('.copy-hint').textContent = '✓ Copied!';
|
||||
setTimeout(() => document.querySelector('.copy-hint').textContent = 'Click to copy', 2000);
|
||||
});
|
||||
}
|
||||
|
||||
async function checkout() {
|
||||
try {
|
||||
const res = await fetch('/v1/billing/checkout', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
|
|
|||
28
src/index.ts
28
src/index.ts
|
|
@ -6,22 +6,27 @@ import rateLimit from "express-rate-limit";
|
|||
import { convertRouter } from "./routes/convert.js";
|
||||
import { templatesRouter } from "./routes/templates.js";
|
||||
import { healthRouter } from "./routes/health.js";
|
||||
import { signupRouter } from "./routes/signup.js";
|
||||
import { billingRouter } from "./routes/billing.js";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
import { usageMiddleware } from "./middleware/usage.js";
|
||||
import { getUsageStats } from "./middleware/usage.js";
|
||||
import { initBrowser, closeBrowser } from "./services/browser.js";
|
||||
import { billingRouter } from "./routes/billing.js";
|
||||
import { loadKeys, getAllKeys } from "./services/keys.js";
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || "3100", 10);
|
||||
|
||||
// Load API keys from persistent store
|
||||
loadKeys();
|
||||
|
||||
app.use(helmet());
|
||||
// Raw body for Stripe webhook signature verification
|
||||
app.use("/v1/billing/webhook", express.raw({ type: "application/json" }));
|
||||
app.use(express.json({ limit: "2mb" }));
|
||||
app.use(express.text({ limit: "2mb", type: "text/*" }));
|
||||
|
||||
// Rate limiting: 100 req/min for free tier
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: 60_000,
|
||||
max: 100,
|
||||
|
|
@ -30,17 +35,16 @@ const limiter = rateLimit({
|
|||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Public
|
||||
// Public routes
|
||||
app.use("/health", healthRouter);
|
||||
app.use("/v1/signup", signupRouter);
|
||||
app.use("/v1/billing", billingRouter);
|
||||
|
||||
// Authenticated
|
||||
// Authenticated routes
|
||||
app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter);
|
||||
app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter);
|
||||
|
||||
// Billing (public — Stripe handles auth)
|
||||
app.use("/v1/billing", billingRouter);
|
||||
|
||||
// Admin: usage stats (protected by auth)
|
||||
// Admin: usage stats
|
||||
app.get("/v1/usage", authMiddleware, (_req, res) => {
|
||||
res.json(getUsageStats());
|
||||
});
|
||||
|
|
@ -49,24 +53,26 @@ app.get("/v1/usage", authMiddleware, (_req, res) => {
|
|||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
app.use(express.static(path.join(__dirname, "../public")));
|
||||
|
||||
// API root (for programmatic discovery)
|
||||
// API root
|
||||
app.get("/api", (_req, res) => {
|
||||
res.json({
|
||||
name: "DocFast API",
|
||||
version: "0.1.0",
|
||||
docs: "/health",
|
||||
version: "0.2.0",
|
||||
endpoints: [
|
||||
"POST /v1/signup/free — Get a free API key",
|
||||
"POST /v1/convert/html",
|
||||
"POST /v1/convert/markdown",
|
||||
"POST /v1/convert/url",
|
||||
"POST /v1/templates/:id/render",
|
||||
"GET /v1/templates",
|
||||
"POST /v1/billing/checkout — Start Pro subscription",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
async function start() {
|
||||
await initBrowser();
|
||||
console.log(`Loaded ${getAllKeys().length} API keys`);
|
||||
app.listen(PORT, () => console.log(`DocFast API running on :${PORT}`));
|
||||
|
||||
const shutdown = async () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
|
||||
const API_KEYS = new Set(
|
||||
(process.env.API_KEYS || "test-key-123").split(",").map((k) => k.trim())
|
||||
);
|
||||
import { isValidKey, getKeyInfo } from "../services/keys.js";
|
||||
|
||||
export function authMiddleware(
|
||||
req: Request,
|
||||
|
|
@ -15,9 +12,11 @@ export function authMiddleware(
|
|||
return;
|
||||
}
|
||||
const key = header.slice(7);
|
||||
if (!API_KEYS.has(key)) {
|
||||
if (!isValidKey(key)) {
|
||||
res.status(403).json({ error: "Invalid API key" });
|
||||
return;
|
||||
}
|
||||
// Attach key info to request for downstream use
|
||||
(req as any).apiKeyInfo = getKeyInfo(key);
|
||||
next();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,13 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { isProKey } from "../services/keys.js";
|
||||
|
||||
interface UsageRecord {
|
||||
count: number;
|
||||
monthKey: string;
|
||||
}
|
||||
|
||||
// In-memory usage tracking (replace with Redis/DB for production)
|
||||
const usage = new Map<string, UsageRecord>();
|
||||
|
||||
const FREE_TIER_LIMIT = 100; // 100 PDFs/month for free tier
|
||||
import { isProKey as isRuntimeProKey } from "../routes/billing.js";
|
||||
|
||||
const PRO_KEYS = new Set(
|
||||
(process.env.PRO_KEYS || "").split(",").map((k) => k.trim()).filter(Boolean)
|
||||
);
|
||||
const FREE_TIER_LIMIT = 100;
|
||||
|
||||
function getMonthKey(): string {
|
||||
const d = new Date();
|
||||
|
|
@ -28,8 +22,8 @@ export function usageMiddleware(
|
|||
const key = req.headers.authorization?.slice(7) || "unknown";
|
||||
const monthKey = getMonthKey();
|
||||
|
||||
// Pro keys have no limit (env-configured or runtime-provisioned via Stripe)
|
||||
if (PRO_KEYS.has(key) || isRuntimeProKey(key)) {
|
||||
// Pro keys have no limit
|
||||
if (isProKey(key)) {
|
||||
trackUsage(key, monthKey);
|
||||
next();
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import Stripe from "stripe";
|
||||
import { nanoid } from "nanoid";
|
||||
import { createProKey, revokeByCustomer } from "../services/keys.js";
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
|
||||
apiVersion: "2025-01-27.acacia" as any,
|
||||
|
|
@ -8,22 +8,17 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
|
|||
|
||||
const router = Router();
|
||||
|
||||
// In-memory store of customer → API key mappings
|
||||
// In production, this would be a database
|
||||
const customerKeys = new Map<string, string>();
|
||||
|
||||
// Create a Stripe Checkout session for Pro subscription
|
||||
router.post("/checkout", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
// Find or create the Pro plan product+price
|
||||
const priceId = await getOrCreateProPrice();
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${process.env.BASE_URL || "https://docfast.dev"}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.BASE_URL || "https://docfast.dev"}/pricing`,
|
||||
success_url: `${process.env.BASE_URL || "https://docfast.dev"}/v1/billing/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.BASE_URL || "https://docfast.dev"}/#pricing`,
|
||||
});
|
||||
|
||||
res.json({ url: session.url });
|
||||
|
|
@ -33,7 +28,7 @@ router.post("/checkout", async (_req: Request, res: Response) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Success page — retrieve API key after checkout
|
||||
// Success page — provision Pro API key after checkout
|
||||
router.get("/success", async (req: Request, res: Response) => {
|
||||
const sessionId = req.query.session_id as string;
|
||||
if (!sessionId) {
|
||||
|
|
@ -44,27 +39,35 @@ router.get("/success", async (req: Request, res: Response) => {
|
|||
try {
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
const customerId = session.customer as string;
|
||||
const email = session.customer_details?.email || "unknown@docfast.dev";
|
||||
|
||||
if (!customerId) {
|
||||
res.status(400).json({ error: "No customer found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate or retrieve API key for this customer
|
||||
let apiKey = customerKeys.get(customerId);
|
||||
if (!apiKey) {
|
||||
apiKey = `df_pro_${nanoid(32)}`;
|
||||
customerKeys.set(customerId, apiKey);
|
||||
// Add to PRO_KEYS runtime set
|
||||
addProKey(apiKey);
|
||||
}
|
||||
const keyInfo = createProKey(email, customerId);
|
||||
|
||||
res.json({
|
||||
message: "Welcome to DocFast Pro! 🎉",
|
||||
apiKey,
|
||||
docs: "/api",
|
||||
note: "Save this API key — it won't be shown again.",
|
||||
});
|
||||
// Return a nice HTML page instead of raw JSON
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html><head><title>Welcome to DocFast Pro!</title>
|
||||
<style>
|
||||
body { font-family: system-ui; background: #0a0a0a; color: #e8e8e8; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||
.card { background: #141414; border: 1px solid #222; border-radius: 16px; padding: 48px; max-width: 500px; text-align: center; }
|
||||
h1 { color: #4f9; margin-bottom: 8px; }
|
||||
.key { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 16px; margin: 24px 0; font-family: monospace; font-size: 0.9rem; word-break: break-all; cursor: pointer; }
|
||||
.key:hover { border-color: #4f9; }
|
||||
p { color: #888; line-height: 1.6; }
|
||||
a { color: #4f9; }
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<h1>🎉 Welcome to Pro!</h1>
|
||||
<p>Your API key:</p>
|
||||
<div class="key" onclick="navigator.clipboard.writeText('${keyInfo.key}')" title="Click to copy">${keyInfo.key}</div>
|
||||
<p><strong>Save this key!</strong> It won't be shown again.</p>
|
||||
<p>10,000 PDFs/month • All endpoints • Priority support</p>
|
||||
<p><a href="/#endpoints">View API docs →</a></p>
|
||||
</div></body></html>`);
|
||||
} catch (err: any) {
|
||||
console.error("Success page error:", err.message);
|
||||
res.status(500).json({ error: "Failed to retrieve session" });
|
||||
|
|
@ -72,68 +75,38 @@ router.get("/success", async (req: Request, res: Response) => {
|
|||
});
|
||||
|
||||
// Stripe webhook for subscription lifecycle events
|
||||
router.post(
|
||||
"/webhook",
|
||||
// Raw body needed for signature verification
|
||||
async (req: Request, res: Response) => {
|
||||
const sig = req.headers["stripe-signature"] as string;
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
router.post("/webhook", async (req: Request, res: Response) => {
|
||||
const sig = req.headers["stripe-signature"] as string;
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
let event: Stripe.Event;
|
||||
let event: Stripe.Event;
|
||||
|
||||
if (webhookSecret && sig) {
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
req.body,
|
||||
sig,
|
||||
webhookSecret
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error("Webhook signature verification failed:", err.message);
|
||||
res.status(400).json({ error: "Invalid signature" });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No webhook secret configured — accept all events (dev mode)
|
||||
event = req.body as Stripe.Event;
|
||||
if (webhookSecret && sig) {
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
|
||||
} catch (err: any) {
|
||||
console.error("Webhook signature verification failed:", err.message);
|
||||
res.status(400).json({ error: "Invalid signature" });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "customer.subscription.deleted": {
|
||||
const sub = event.data.object as Stripe.Subscription;
|
||||
const customerId = sub.customer as string;
|
||||
const key = customerKeys.get(customerId);
|
||||
if (key) {
|
||||
removeProKey(key);
|
||||
customerKeys.delete(customerId);
|
||||
console.log(`Subscription cancelled for ${customerId}, key revoked`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Ignore other events
|
||||
break;
|
||||
}
|
||||
|
||||
res.json({ received: true });
|
||||
} else {
|
||||
event = req.body as Stripe.Event;
|
||||
}
|
||||
);
|
||||
|
||||
// --- Pro key management ---
|
||||
// These integrate with the usage middleware's PRO_KEYS set
|
||||
const runtimeProKeys = new Set<string>();
|
||||
switch (event.type) {
|
||||
case "customer.subscription.deleted": {
|
||||
const sub = event.data.object as Stripe.Subscription;
|
||||
const customerId = sub.customer as string;
|
||||
revokeByCustomer(customerId);
|
||||
console.log(`Subscription cancelled for ${customerId}, key revoked`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
export function addProKey(key: string): void {
|
||||
runtimeProKeys.add(key);
|
||||
}
|
||||
|
||||
export function removeProKey(key: string): void {
|
||||
runtimeProKeys.delete(key);
|
||||
}
|
||||
|
||||
export function isProKey(key: string): boolean {
|
||||
return runtimeProKeys.has(key);
|
||||
}
|
||||
res.json({ received: true });
|
||||
});
|
||||
|
||||
// --- Price management ---
|
||||
let cachedPriceId: string | null = null;
|
||||
|
|
@ -141,21 +114,12 @@ let cachedPriceId: string | null = null;
|
|||
async function getOrCreateProPrice(): Promise<string> {
|
||||
if (cachedPriceId) return cachedPriceId;
|
||||
|
||||
// Search for existing product
|
||||
const products = await stripe.products.search({
|
||||
query: "name:'DocFast Pro'",
|
||||
});
|
||||
|
||||
const products = await stripe.products.search({ query: "name:'DocFast Pro'" });
|
||||
let productId: string;
|
||||
|
||||
if (products.data.length > 0) {
|
||||
productId = products.data[0].id;
|
||||
// Find active price
|
||||
const prices = await stripe.prices.list({
|
||||
product: productId,
|
||||
active: true,
|
||||
limit: 1,
|
||||
});
|
||||
const prices = await stripe.prices.list({ product: productId, active: true, limit: 1 });
|
||||
if (prices.data.length > 0) {
|
||||
cachedPriceId = prices.data[0].id;
|
||||
return cachedPriceId;
|
||||
|
|
@ -170,7 +134,7 @@ async function getOrCreateProPrice(): Promise<string> {
|
|||
|
||||
const price = await stripe.prices.create({
|
||||
product: productId,
|
||||
unit_amount: 900, // $9.00
|
||||
unit_amount: 900,
|
||||
currency: "usd",
|
||||
recurring: { interval: "month" },
|
||||
});
|
||||
|
|
|
|||
33
src/routes/signup.ts
Normal file
33
src/routes/signup.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import { createFreeKey } from "../services/keys.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Self-service free API key signup
|
||||
router.post("/free", (req: Request, res: Response) => {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email || typeof email !== "string") {
|
||||
res.status(400).json({ error: "Email is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
res.status(400).json({ error: "Invalid email address" });
|
||||
return;
|
||||
}
|
||||
|
||||
const keyInfo = createFreeKey(email.trim().toLowerCase());
|
||||
|
||||
res.json({
|
||||
message: "Welcome to DocFast! 🚀",
|
||||
apiKey: keyInfo.key,
|
||||
tier: "free",
|
||||
limit: "100 PDFs/month",
|
||||
docs: "https://docfast.dev/#endpoints",
|
||||
note: "Save this API key — it won't be shown again.",
|
||||
});
|
||||
});
|
||||
|
||||
export { router as signupRouter };
|
||||
118
src/services/keys.ts
Normal file
118
src/services/keys.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { randomBytes } from "crypto";
|
||||
import { readFileSync, writeFileSync, 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 KEYS_FILE = path.join(DATA_DIR, "keys.json");
|
||||
|
||||
export interface ApiKey {
|
||||
key: string;
|
||||
tier: "free" | "pro";
|
||||
email: string;
|
||||
createdAt: string;
|
||||
stripeCustomerId?: string;
|
||||
}
|
||||
|
||||
interface KeyStore {
|
||||
keys: ApiKey[];
|
||||
}
|
||||
|
||||
let store: KeyStore = { keys: [] };
|
||||
|
||||
function ensureDataDir(): void {
|
||||
if (!existsSync(DATA_DIR)) {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function loadKeys(): void {
|
||||
ensureDataDir();
|
||||
if (existsSync(KEYS_FILE)) {
|
||||
try {
|
||||
store = JSON.parse(readFileSync(KEYS_FILE, "utf-8"));
|
||||
} catch {
|
||||
store = { keys: [] };
|
||||
}
|
||||
}
|
||||
// Also load seed keys from env
|
||||
const envKeys = process.env.API_KEYS?.split(",").map((k) => k.trim()).filter(Boolean) || [];
|
||||
for (const k of envKeys) {
|
||||
if (!store.keys.find((e) => e.key === k)) {
|
||||
store.keys.push({ key: k, tier: "pro", email: "seed@docfast.dev", createdAt: new Date().toISOString() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
ensureDataDir();
|
||||
writeFileSync(KEYS_FILE, JSON.stringify(store, null, 2));
|
||||
}
|
||||
|
||||
export function isValidKey(key: string): boolean {
|
||||
return store.keys.some((k) => k.key === key);
|
||||
}
|
||||
|
||||
export function getKeyInfo(key: string): ApiKey | undefined {
|
||||
return store.keys.find((k) => k.key === key);
|
||||
}
|
||||
|
||||
export function isProKey(key: string): boolean {
|
||||
const info = getKeyInfo(key);
|
||||
return info?.tier === "pro";
|
||||
}
|
||||
|
||||
function generateKey(prefix: string): string {
|
||||
return `${prefix}_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function createFreeKey(email: string): ApiKey {
|
||||
// Check if email already has a free key
|
||||
const existing = store.keys.find((k) => k.email === email && k.tier === "free");
|
||||
if (existing) return existing;
|
||||
|
||||
const entry: ApiKey = {
|
||||
key: generateKey("df_free"),
|
||||
tier: "free",
|
||||
email,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
store.keys.push(entry);
|
||||
save();
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function createProKey(email: string, stripeCustomerId: string): ApiKey {
|
||||
const existing = store.keys.find((k) => k.stripeCustomerId === stripeCustomerId);
|
||||
if (existing) {
|
||||
existing.tier = "pro";
|
||||
save();
|
||||
return existing;
|
||||
}
|
||||
|
||||
const entry: ApiKey = {
|
||||
key: generateKey("df_pro"),
|
||||
tier: "pro",
|
||||
email,
|
||||
createdAt: new Date().toISOString(),
|
||||
stripeCustomerId,
|
||||
};
|
||||
store.keys.push(entry);
|
||||
save();
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function revokeByCustomer(stripeCustomerId: string): boolean {
|
||||
const idx = store.keys.findIndex((k) => k.stripeCustomerId === stripeCustomerId);
|
||||
if (idx >= 0) {
|
||||
store.keys.splice(idx, 1);
|
||||
save();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getAllKeys(): ApiKey[] {
|
||||
return [...store.keys];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue