185 lines
6.9 KiB
TypeScript
185 lines
6.9 KiB
TypeScript
import express from "express";
|
|
import helmet from "helmet";
|
|
import path from "path";
|
|
import { fileURLToPath } from "url";
|
|
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 { recoverRouter } from "./routes/recover.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 { loadKeys, getAllKeys } from "./services/keys.js";
|
|
import { verifyToken } from "./services/verification.js";
|
|
|
|
const app = express();
|
|
const PORT = parseInt(process.env.PORT || "3100", 10);
|
|
|
|
// Load API keys from persistent store
|
|
loadKeys();
|
|
|
|
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
|
|
|
|
// Differentiated CORS middleware
|
|
app.use((req, res, next) => {
|
|
const isAuthBillingRoute = req.path.startsWith('/v1/signup') ||
|
|
req.path.startsWith('/v1/billing');
|
|
|
|
if (isAuthBillingRoute) {
|
|
// Auth/billing routes: restrict to docfast.dev
|
|
res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev");
|
|
} else {
|
|
// Conversion API routes: allow all origins
|
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
}
|
|
|
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key");
|
|
res.setHeader("Access-Control-Max-Age", "86400");
|
|
|
|
if (req.method === "OPTIONS") {
|
|
res.status(204).end();
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
|
|
// 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/*" }));
|
|
|
|
// Trust nginx proxy
|
|
app.set("trust proxy", 1);
|
|
|
|
// Rate limiting
|
|
const limiter = rateLimit({
|
|
windowMs: 60_000,
|
|
max: 100,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
});
|
|
app.use(limiter);
|
|
|
|
// Public routes
|
|
app.use("/health", healthRouter);
|
|
app.use("/v1/signup", signupRouter);
|
|
app.use("/v1/recover", recoverRouter);
|
|
app.use("/v1/billing", billingRouter);
|
|
|
|
// Authenticated routes
|
|
app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter);
|
|
app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter);
|
|
|
|
// Admin: usage stats
|
|
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 securely. You can recover it via email if needed..</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")));
|
|
|
|
// Docs page (clean URL)
|
|
app.get("/docs", (_req, res) => {
|
|
res.sendFile(path.join(__dirname, "../public/docs.html"));
|
|
});
|
|
|
|
// API root
|
|
app.get("/api", (_req, res) => {
|
|
res.json({
|
|
name: "DocFast API",
|
|
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 () => {
|
|
console.log("Shutting down...");
|
|
await closeBrowser();
|
|
process.exit(0);
|
|
};
|
|
process.on("SIGTERM", shutdown);
|
|
process.on("SIGINT", shutdown);
|
|
}
|
|
|
|
start().catch((err) => {
|
|
console.error("Failed to start:", err);
|
|
process.exit(1);
|
|
});
|
|
|
|
export { app };
|