fix: add /change-email route in index.ts + fix SQL query escaping in keys.ts
All checks were successful
Deploy to Production / Deploy to Server (push) Successful in 1m36s

- Register GET /change-email route in src/index.ts (serves change-email.html)
- Fix updateKeyEmail() SQL query string (dollar signs were stripped by heredoc)
- Fix updateEmailByCustomer() SQL query string
- Rebuild TypeScript dist/
This commit is contained in:
DocFast Bot 2026-02-17 11:34:21 +00:00
parent 8f3b1a9660
commit 1702abdeb8
9 changed files with 172 additions and 95 deletions

4
dist/index.js vendored
View file

@ -196,6 +196,10 @@ app.get("/terms", (_req, res) => {
res.setHeader('Cache-Control', 'public, max-age=86400');
res.sendFile(path.join(__dirname, "../public/terms.html"));
});
app.get("/change-email", (_req, res) => {
res.setHeader('Cache-Control', 'public, max-age=3600');
res.sendFile(path.join(__dirname, "../public/change-email.html"));
});
app.get("/status", (_req, res) => {
res.setHeader("Cache-Control", "public, max-age=60");
res.sendFile(path.join(__dirname, "../public/status.html"));

View file

@ -1,6 +1,6 @@
import { Router } from "express";
import Stripe from "stripe";
import { createProKey, revokeByCustomer } from "../services/keys.js";
import { createProKey, downgradeByCustomer, updateEmailByCustomer } from "../services/keys.js";
import logger from "../services/logger.js";
function escapeHtml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@ -18,6 +18,27 @@ function getStripe() {
const router = Router();
// Track provisioned session IDs to prevent duplicate key creation
const provisionedSessions = new Set();
const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE";
// Returns true if the given Stripe subscription contains a DocFast product.
// Used to filter webhook events — this Stripe account is shared with other projects.
async function isDocFastSubscription(subscriptionId) {
try {
const sub = await getStripe().subscriptions.retrieve(subscriptionId, {
expand: ["items.data.price.product"],
});
return sub.items.data.some((item) => {
const price = item.price;
const productId = typeof price?.product === "string"
? price.product
: price?.product?.id;
return productId === DOCFAST_PRODUCT_ID;
});
}
catch (err) {
logger.error({ err, subscriptionId }, "isDocFastSubscription: failed to retrieve subscription");
return false;
}
}
// Create a Stripe Checkout session for Pro subscription
router.post("/checkout", async (_req, res) => {
try {
@ -114,7 +135,6 @@ router.post("/webhook", async (req, res) => {
const customerId = session.customer;
const email = session.customer_details?.email;
// Filter by product — this Stripe account is shared with other projects
const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE";
try {
const fullSession = await getStripe().checkout.sessions.retrieve(session.id, {
expand: ["line_items"],
@ -143,11 +163,44 @@ router.post("/webhook", async (req, res) => {
logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key");
break;
}
case "customer.subscription.updated": {
const sub = event.data.object;
const customerId = sub.customer;
const shouldDowngrade = sub.status === "canceled" ||
sub.status === "past_due" ||
sub.status === "unpaid" ||
sub.cancel_at_period_end === true;
if (shouldDowngrade) {
if (!(await isDocFastSubscription(sub.id))) {
logger.info({ subscriptionId: sub.id }, "customer.subscription.updated: ignoring event for different product");
break;
}
await downgradeByCustomer(customerId);
logger.info({ customerId, status: sub.status, cancelAtPeriodEnd: sub.cancel_at_period_end }, "customer.subscription.updated: downgraded key to free tier");
}
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object;
const customerId = sub.customer;
await revokeByCustomer(customerId);
logger.info({ customerId }, "Subscription cancelled, key revoked");
if (!(await isDocFastSubscription(sub.id))) {
logger.info({ subscriptionId: sub.id }, "customer.subscription.deleted: ignoring event for different product");
break;
}
await downgradeByCustomer(customerId);
logger.info({ customerId }, "customer.subscription.deleted: downgraded key to free tier");
break;
}
case "customer.updated": {
const customer = event.data.object;
const customerId = customer.id;
const newEmail = customer.email;
if (customerId && newEmail) {
const updated = await updateEmailByCustomer(customerId, newEmail);
if (updated) {
logger.info({ customerId, newEmail }, "Customer email synced from Stripe");
}
}
break;
}
default:

19
dist/services/keys.js vendored
View file

@ -77,12 +77,11 @@ export async function createProKey(email, stripeCustomerId) {
keysCache.push(entry);
return entry;
}
export async function revokeByCustomer(stripeCustomerId) {
const idx = keysCache.findIndex((k) => k.stripeCustomerId === stripeCustomerId);
if (idx >= 0) {
const key = keysCache[idx].key;
keysCache.splice(idx, 1);
await pool.query("DELETE FROM api_keys WHERE key = $1", [key]);
export async function downgradeByCustomer(stripeCustomerId) {
const entry = keysCache.find((k) => k.stripeCustomerId === stripeCustomerId);
if (entry) {
entry.tier = "free";
await pool.query("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]);
return true;
}
return false;
@ -98,3 +97,11 @@ export async function updateKeyEmail(apiKey, newEmail) {
await pool.query("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]);
return true;
}
export async function updateEmailByCustomer(stripeCustomerId, newEmail) {
const entry = keysCache.find(k => k.stripeCustomerId === stripeCustomerId);
if (!entry)
return false;
entry.email = newEmail;
await pool.query("UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", [newEmail, stripeCustomerId]);
return true;
}