docs: add missing OpenAPI annotations for signup/verify, billing/success, billing/webhook
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 16m15s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 16m15s
This commit is contained in:
parent
427ec8e894
commit
8b31d11e74
15 changed files with 2167 additions and 128 deletions
81
dist/routes/billing.js
vendored
81
dist/routes/billing.js
vendored
|
|
@ -3,9 +3,7 @@ import rateLimit from "express-rate-limit";
|
|||
import Stripe from "stripe";
|
||||
import { createProKey, downgradeByCustomer, updateEmailByCustomer, findKeyByCustomerId } from "../services/keys.js";
|
||||
import logger from "../services/logger.js";
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
import { escapeHtml } from "../utils/html.js";
|
||||
let _stripe = null;
|
||||
function getStripe() {
|
||||
if (!_stripe) {
|
||||
|
|
@ -103,6 +101,36 @@ router.post("/checkout", checkoutLimiter, async (req, res) => {
|
|||
res.status(500).json({ error: "Failed to create checkout session" });
|
||||
}
|
||||
});
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/billing/success:
|
||||
* get:
|
||||
* tags: [Billing]
|
||||
* summary: Checkout success page
|
||||
* description: |
|
||||
* Provisions a Pro API key after successful Stripe checkout and displays it in an HTML page.
|
||||
* Called by Stripe redirect after payment completion.
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: session_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Stripe Checkout session ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: HTML page displaying the new API key
|
||||
* content:
|
||||
* text/html:
|
||||
* schema:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Missing session_id or no customer found
|
||||
* 409:
|
||||
* description: Checkout session already used
|
||||
* 500:
|
||||
* description: Failed to retrieve session
|
||||
*/
|
||||
// Success page — provision Pro API key after checkout
|
||||
router.get("/success", async (req, res) => {
|
||||
const sessionId = req.query.session_id;
|
||||
|
|
@ -161,17 +189,60 @@ a { color: #4f9; }
|
|||
<div class="card">
|
||||
<h1>🎉 Welcome to Pro!</h1>
|
||||
<p>Your API key:</p>
|
||||
<div class="key" style="position:relative" data-key="${escapeHtml(keyInfo.key)}">${escapeHtml(keyInfo.key)}<button onclick="navigator.clipboard.writeText(this.parentElement.dataset.key);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)" style="position:absolute;top:8px;right:8px;background:#4f9;color:#0a0a0a;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-size:0.8rem;font-family:system-ui">Copy</button></div>
|
||||
<div class="key" style="position:relative">${escapeHtml(keyInfo.key)}<button data-copy="${escapeHtml(keyInfo.key)}" style="position:absolute;top:8px;right:8px;background:#4f9;color:#0a0a0a;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-size:0.8rem;font-family:system-ui">Copy</button></div>
|
||||
<p><strong>Save this key!</strong> It won't be shown again.</p>
|
||||
<p>5,000 PDFs/month • All endpoints • Priority support</p>
|
||||
<p><a href="/docs">View API docs →</a></p>
|
||||
</div></body></html>`);
|
||||
</div>
|
||||
<script src="/copy-helper.js"></script>
|
||||
</body></html>`);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error({ err }, "Success page error");
|
||||
res.status(500).json({ error: "Failed to retrieve session" });
|
||||
}
|
||||
});
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/billing/webhook:
|
||||
* post:
|
||||
* tags: [Billing]
|
||||
* summary: Stripe webhook endpoint
|
||||
* description: |
|
||||
* Receives Stripe webhook events for subscription lifecycle management.
|
||||
* Requires the raw request body and a valid Stripe-Signature header for verification.
|
||||
* Handles checkout.session.completed, customer.subscription.updated,
|
||||
* customer.subscription.deleted, and customer.updated events.
|
||||
* parameters:
|
||||
* - in: header
|
||||
* name: Stripe-Signature
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Stripe webhook signature for payload verification
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* description: Raw Stripe event payload
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Webhook received
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* received:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* 400:
|
||||
* description: Missing Stripe-Signature header or invalid signature
|
||||
* 500:
|
||||
* description: Webhook secret not configured
|
||||
*/
|
||||
// Stripe webhook for subscription lifecycle events
|
||||
router.post("/webhook", async (req, res) => {
|
||||
const sig = req.headers["stripe-signature"];
|
||||
|
|
|
|||
63
dist/routes/convert.js
vendored
63
dist/routes/convert.js
vendored
|
|
@ -3,43 +3,8 @@ import { renderPdf, renderUrlPdf } from "../services/browser.js";
|
|||
import { markdownToHtml, wrapHtml } from "../services/markdown.js";
|
||||
import dns from "node:dns/promises";
|
||||
import logger from "../services/logger.js";
|
||||
import net from "node:net";
|
||||
function isPrivateIP(ip) {
|
||||
// IPv6 loopback/unspecified
|
||||
if (ip === "::1" || ip === "::")
|
||||
return true;
|
||||
// IPv6 link-local (fe80::/10)
|
||||
if (ip.toLowerCase().startsWith("fe8") || ip.toLowerCase().startsWith("fe9") ||
|
||||
ip.toLowerCase().startsWith("fea") || ip.toLowerCase().startsWith("feb"))
|
||||
return true;
|
||||
// IPv6 unique local (fc00::/7)
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower.startsWith("fc") || lower.startsWith("fd"))
|
||||
return true;
|
||||
// IPv4-mapped IPv6
|
||||
if (ip.startsWith("::ffff:"))
|
||||
ip = ip.slice(7);
|
||||
if (!net.isIPv4(ip))
|
||||
return false;
|
||||
const parts = ip.split(".").map(Number);
|
||||
if (parts[0] === 0)
|
||||
return true; // 0.0.0.0/8
|
||||
if (parts[0] === 10)
|
||||
return true; // 10.0.0.0/8
|
||||
if (parts[0] === 127)
|
||||
return true; // 127.0.0.0/8
|
||||
if (parts[0] === 169 && parts[1] === 254)
|
||||
return true; // 169.254.0.0/16
|
||||
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
||||
return true; // 172.16.0.0/12
|
||||
if (parts[0] === 192 && parts[1] === 168)
|
||||
return true; // 192.168.0.0/16
|
||||
return false;
|
||||
}
|
||||
function sanitizeFilename(name) {
|
||||
// Strip characters dangerous in Content-Disposition headers
|
||||
return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf";
|
||||
}
|
||||
import { isPrivateIP } from "../utils/network.js";
|
||||
import { sanitizeFilename } from "../utils/sanitize.js";
|
||||
export const convertRouter = Router();
|
||||
/**
|
||||
* @openapi
|
||||
|
|
@ -118,6 +83,14 @@ convertRouter.post("/html", async (req, res) => {
|
|||
landscape: body.landscape,
|
||||
margin: body.margin,
|
||||
printBackground: body.printBackground,
|
||||
headerTemplate: body.headerTemplate,
|
||||
footerTemplate: body.footerTemplate,
|
||||
displayHeaderFooter: body.displayHeaderFooter,
|
||||
scale: body.scale,
|
||||
pageRanges: body.pageRanges,
|
||||
preferCSSPageSize: body.preferCSSPageSize,
|
||||
width: body.width,
|
||||
height: body.height,
|
||||
});
|
||||
const filename = sanitizeFilename(body.filename || "document.pdf");
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
|
|
@ -211,6 +184,14 @@ convertRouter.post("/markdown", async (req, res) => {
|
|||
landscape: body.landscape,
|
||||
margin: body.margin,
|
||||
printBackground: body.printBackground,
|
||||
headerTemplate: body.headerTemplate,
|
||||
footerTemplate: body.footerTemplate,
|
||||
displayHeaderFooter: body.displayHeaderFooter,
|
||||
scale: body.scale,
|
||||
pageRanges: body.pageRanges,
|
||||
preferCSSPageSize: body.preferCSSPageSize,
|
||||
width: body.width,
|
||||
height: body.height,
|
||||
});
|
||||
const filename = sanitizeFilename(body.filename || "document.pdf");
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
|
|
@ -335,6 +316,14 @@ convertRouter.post("/url", async (req, res) => {
|
|||
landscape: body.landscape,
|
||||
margin: body.margin,
|
||||
printBackground: body.printBackground,
|
||||
headerTemplate: body.headerTemplate,
|
||||
footerTemplate: body.footerTemplate,
|
||||
displayHeaderFooter: body.displayHeaderFooter,
|
||||
scale: body.scale,
|
||||
pageRanges: body.pageRanges,
|
||||
preferCSSPageSize: body.preferCSSPageSize,
|
||||
width: body.width,
|
||||
height: body.height,
|
||||
waitUntil: body.waitUntil,
|
||||
hostResolverRules: `MAP ${parsed.hostname} ${resolvedAddress}`,
|
||||
});
|
||||
|
|
|
|||
54
dist/routes/signup.js
vendored
54
dist/routes/signup.js
vendored
|
|
@ -51,6 +51,60 @@ router.post("/free", rejectDuplicateEmail, signupLimiter, async (req, res) => {
|
|||
message: "Check your email for the verification code.",
|
||||
});
|
||||
});
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/signup/verify:
|
||||
* post:
|
||||
* tags: [Account]
|
||||
* summary: Verify email and get API key
|
||||
* description: |
|
||||
* Verifies the 6-digit code sent to the user's email and provisions a free API key.
|
||||
* Rate limited to 15 attempts per 15 minutes.
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [email, code]
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: Email address used during signup
|
||||
* example: user@example.com
|
||||
* code:
|
||||
* type: string
|
||||
* description: 6-digit verification code from email
|
||||
* example: "123456"
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Email verified, API key issued
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: verified
|
||||
* message:
|
||||
* type: string
|
||||
* apiKey:
|
||||
* type: string
|
||||
* description: The provisioned API key
|
||||
* tier:
|
||||
* type: string
|
||||
* example: free
|
||||
* 400:
|
||||
* description: Missing fields or invalid verification code
|
||||
* 409:
|
||||
* description: Email already verified
|
||||
* 410:
|
||||
* description: Verification code expired
|
||||
* 429:
|
||||
* description: Too many failed attempts
|
||||
*/
|
||||
// Step 2: Verify code — creates API key
|
||||
router.post("/verify", verifyLimiter, async (req, res) => {
|
||||
const { email, code } = req.body || {};
|
||||
|
|
|
|||
4
dist/routes/templates.js
vendored
4
dist/routes/templates.js
vendored
|
|
@ -2,9 +2,7 @@ import { Router } from "express";
|
|||
import { renderPdf } from "../services/browser.js";
|
||||
import logger from "../services/logger.js";
|
||||
import { templates, renderTemplate } from "../services/templates.js";
|
||||
function sanitizeFilename(name) {
|
||||
return name.replace(/["\r\n\x00-\x1f]/g, "_").substring(0, 200);
|
||||
}
|
||||
import { sanitizeFilename } from "../utils/sanitize.js";
|
||||
export const templatesRouter = Router();
|
||||
/**
|
||||
* @openapi
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue