Migrate from JSON to PostgreSQL, update SLA to 99.5%
- Replace JSON file storage with PostgreSQL (pg package) - Add db.ts service for connection pool and schema init - Rewrite keys.ts, verification.ts, usage.ts for async PostgreSQL - Update all routes for async function signatures - Add migration script (scripts/migrate-to-postgres.mjs) - Update docker-compose.yml with DATABASE_* env vars - Change SLA from 99.9% to 99.5% in landing page
This commit is contained in:
parent
bb1881af61
commit
e9d16bf2a3
13 changed files with 395 additions and 198 deletions
65
src/services/db.ts
Normal file
65
src/services/db.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DATABASE_HOST || "172.17.0.1",
|
||||
port: parseInt(process.env.DATABASE_PORT || "5432", 10),
|
||||
database: process.env.DATABASE_NAME || "docfast",
|
||||
user: process.env.DATABASE_USER || "docfast",
|
||||
password: process.env.DATABASE_PASSWORD || "docfast",
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
});
|
||||
|
||||
pool.on("error", (err) => {
|
||||
console.error("Unexpected PostgreSQL pool error:", err);
|
||||
});
|
||||
|
||||
export async function initDatabase(): Promise<void> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
key TEXT PRIMARY KEY,
|
||||
tier TEXT NOT NULL DEFAULT 'free',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
stripe_customer_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_email ON api_keys(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_stripe ON api_keys(stripe_customer_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS verifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
api_key TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
verified_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_verifications_email ON verifications(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_verifications_token ON verifications(token);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_verifications (
|
||||
email TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
attempts INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage (
|
||||
key TEXT PRIMARY KEY,
|
||||
count INT NOT NULL DEFAULT 0,
|
||||
month_key TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
console.log("PostgreSQL tables initialized");
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export { pool };
|
||||
export default pool;
|
||||
|
|
@ -1,11 +1,5 @@
|
|||
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");
|
||||
import pool from "./db.js";
|
||||
|
||||
export interface ApiKey {
|
||||
key: string;
|
||||
|
|
@ -15,47 +9,48 @@ export interface ApiKey {
|
|||
stripeCustomerId?: string;
|
||||
}
|
||||
|
||||
interface KeyStore {
|
||||
keys: ApiKey[];
|
||||
}
|
||||
// In-memory cache for fast lookups, synced with PostgreSQL
|
||||
let keysCache: ApiKey[] = [];
|
||||
|
||||
let store: KeyStore = { keys: [] };
|
||||
|
||||
function ensureDataDir(): void {
|
||||
if (!existsSync(DATA_DIR)) {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
export async function loadKeys(): Promise<void> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys"
|
||||
);
|
||||
keysCache = result.rows.map((r) => ({
|
||||
key: r.key,
|
||||
tier: r.tier as "free" | "pro",
|
||||
email: r.email,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
stripeCustomerId: r.stripe_customer_id || undefined,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("Failed to load keys from PostgreSQL:", err);
|
||||
keysCache = [];
|
||||
}
|
||||
}
|
||||
|
||||
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() });
|
||||
if (!keysCache.find((e) => e.key === k)) {
|
||||
const entry: ApiKey = { key: k, tier: "pro", email: "seed@docfast.dev", createdAt: new Date().toISOString() };
|
||||
keysCache.push(entry);
|
||||
// Upsert into DB
|
||||
await pool.query(
|
||||
`INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (key) DO NOTHING`,
|
||||
[k, "pro", "seed@docfast.dev", new Date().toISOString()]
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
return keysCache.some((k) => k.key === key);
|
||||
}
|
||||
|
||||
export function getKeyInfo(key: string): ApiKey | undefined {
|
||||
return store.keys.find((k) => k.key === key);
|
||||
return keysCache.find((k) => k.key === key);
|
||||
}
|
||||
|
||||
export function isProKey(key: string): boolean {
|
||||
|
|
@ -67,10 +62,9 @@ function generateKey(prefix: string): string {
|
|||
return `${prefix}_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function createFreeKey(email?: string): ApiKey {
|
||||
// If email provided, check if it already has a free key
|
||||
export async function createFreeKey(email?: string): Promise<ApiKey> {
|
||||
if (email) {
|
||||
const existing = store.keys.find((k) => k.email === email && k.tier === "free");
|
||||
const existing = keysCache.find((k) => k.email === email && k.tier === "free");
|
||||
if (existing) return existing;
|
||||
}
|
||||
|
||||
|
|
@ -80,16 +74,20 @@ export function createFreeKey(email?: string): ApiKey {
|
|||
email: email || "",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
store.keys.push(entry);
|
||||
save();
|
||||
|
||||
await pool.query(
|
||||
"INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4)",
|
||||
[entry.key, entry.tier, entry.email, entry.createdAt]
|
||||
);
|
||||
keysCache.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function createProKey(email: string, stripeCustomerId: string): ApiKey {
|
||||
const existing = store.keys.find((k) => k.stripeCustomerId === stripeCustomerId);
|
||||
export async function createProKey(email: string, stripeCustomerId: string): Promise<ApiKey> {
|
||||
const existing = keysCache.find((k) => k.stripeCustomerId === stripeCustomerId);
|
||||
if (existing) {
|
||||
existing.tier = "pro";
|
||||
save();
|
||||
await pool.query("UPDATE api_keys SET tier = 'pro' WHERE key = $1", [existing.key]);
|
||||
return existing;
|
||||
}
|
||||
|
||||
|
|
@ -100,29 +98,34 @@ export function createProKey(email: string, stripeCustomerId: string): ApiKey {
|
|||
createdAt: new Date().toISOString(),
|
||||
stripeCustomerId,
|
||||
};
|
||||
store.keys.push(entry);
|
||||
save();
|
||||
|
||||
await pool.query(
|
||||
"INSERT INTO api_keys (key, tier, email, created_at, stripe_customer_id) VALUES ($1, $2, $3, $4, $5)",
|
||||
[entry.key, entry.tier, entry.email, entry.createdAt, entry.stripeCustomerId]
|
||||
);
|
||||
keysCache.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function revokeByCustomer(stripeCustomerId: string): boolean {
|
||||
const idx = store.keys.findIndex((k) => k.stripeCustomerId === stripeCustomerId);
|
||||
export async function revokeByCustomer(stripeCustomerId: string): Promise<boolean> {
|
||||
const idx = keysCache.findIndex((k) => k.stripeCustomerId === stripeCustomerId);
|
||||
if (idx >= 0) {
|
||||
store.keys.splice(idx, 1);
|
||||
save();
|
||||
const key = keysCache[idx].key;
|
||||
keysCache.splice(idx, 1);
|
||||
await pool.query("DELETE FROM api_keys WHERE key = $1", [key]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getAllKeys(): ApiKey[] {
|
||||
return [...store.keys];
|
||||
return [...keysCache];
|
||||
}
|
||||
|
||||
export function updateKeyEmail(apiKey: string, newEmail: string): boolean {
|
||||
const entry = store.keys.find(k => k.key === apiKey);
|
||||
export async function updateKeyEmail(apiKey: string, newEmail: string): Promise<boolean> {
|
||||
const entry = keysCache.find((k) => k.key === apiKey);
|
||||
if (!entry) return false;
|
||||
entry.email = newEmail;
|
||||
save();
|
||||
await pool.query("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import { randomBytes, randomInt } from "crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } 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");
|
||||
import pool from "./db.js";
|
||||
|
||||
export interface Verification {
|
||||
email: string;
|
||||
|
|
@ -23,79 +17,68 @@ export interface PendingVerification {
|
|||
attempts: number;
|
||||
}
|
||||
|
||||
let verifications: Verification[] = [];
|
||||
let pendingVerifications: PendingVerification[] = [];
|
||||
|
||||
function ensureDataDir(): void {
|
||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function load(): void {
|
||||
ensureDataDir();
|
||||
if (existsSync(DB_PATH)) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(DB_PATH, "utf-8"));
|
||||
// Support both old format (array) and new format (object)
|
||||
if (Array.isArray(data)) {
|
||||
verifications = data;
|
||||
pendingVerifications = [];
|
||||
} else {
|
||||
verifications = data.verifications || [];
|
||||
pendingVerifications = data.pendingVerifications || [];
|
||||
}
|
||||
} catch {
|
||||
verifications = [];
|
||||
pendingVerifications = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
ensureDataDir();
|
||||
writeFileSync(DB_PATH, JSON.stringify({ verifications, pendingVerifications }, null, 2));
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||
const CODE_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes
|
||||
const CODE_EXPIRY_MS = 15 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
// Legacy token-based verification (keep for existing links)
|
||||
export function createVerification(email: string, apiKey: string): Verification {
|
||||
const existing = verifications.find(v => v.email === email && !v.verifiedAt);
|
||||
if (existing) {
|
||||
const age = Date.now() - new Date(existing.createdAt).getTime();
|
||||
if (age < TOKEN_EXPIRY_MS) return existing;
|
||||
verifications = verifications.filter(v => v !== existing);
|
||||
export async function createVerification(email: string, apiKey: string): Promise<Verification> {
|
||||
// Check for existing unexpired, unverified
|
||||
const existing = await pool.query(
|
||||
"SELECT * FROM verifications WHERE email = $1 AND verified_at IS NULL AND created_at > NOW() - INTERVAL '24 hours' LIMIT 1",
|
||||
[email]
|
||||
);
|
||||
if (existing.rows.length > 0) {
|
||||
const r = existing.rows[0];
|
||||
return { email: r.email, token: r.token, apiKey: r.api_key, createdAt: r.created_at.toISOString(), verifiedAt: null };
|
||||
}
|
||||
const verification: Verification = {
|
||||
email,
|
||||
token: randomBytes(32).toString("hex"),
|
||||
apiKey,
|
||||
createdAt: new Date().toISOString(),
|
||||
verifiedAt: null,
|
||||
};
|
||||
verifications.push(verification);
|
||||
save();
|
||||
return verification;
|
||||
|
||||
// Remove old unverified
|
||||
await pool.query("DELETE FROM verifications WHERE email = $1 AND verified_at IS NULL", [email]);
|
||||
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const now = new Date().toISOString();
|
||||
await pool.query(
|
||||
"INSERT INTO verifications (email, token, api_key, created_at) VALUES ($1, $2, $3, $4)",
|
||||
[email, token, apiKey, now]
|
||||
);
|
||||
return { email, token, apiKey, createdAt: now, verifiedAt: null };
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } {
|
||||
const v = verifications.find(v => v.token === token);
|
||||
// Synchronous wrapper — we'll make it async-compatible
|
||||
// Actually need to keep sync for the GET /verify route. Use sync query workaround or refactor.
|
||||
// For simplicity, we'll cache verifications in memory too.
|
||||
return verifyTokenSync(token);
|
||||
}
|
||||
|
||||
// In-memory cache for verifications (loaded on startup, updated on changes)
|
||||
let verificationsCache: Verification[] = [];
|
||||
|
||||
export async function loadVerifications(): Promise<void> {
|
||||
const result = await pool.query("SELECT * FROM verifications");
|
||||
verificationsCache = result.rows.map((r) => ({
|
||||
email: r.email,
|
||||
token: r.token,
|
||||
apiKey: r.api_key,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
verifiedAt: r.verified_at ? (r.verified_at instanceof Date ? r.verified_at.toISOString() : r.verified_at) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
function verifyTokenSync(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } {
|
||||
const v = verificationsCache.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();
|
||||
// Update DB async
|
||||
pool.query("UPDATE verifications SET verified_at = $1 WHERE token = $2", [v.verifiedAt, token]).catch(console.error);
|
||||
return { status: "ok", verification: v };
|
||||
}
|
||||
|
||||
// New 6-digit code verification
|
||||
export function createPendingVerification(email: string): PendingVerification {
|
||||
// Remove any existing pending for this email
|
||||
pendingVerifications = pendingVerifications.filter(p => p.email !== email);
|
||||
export async function createPendingVerification(email: string): Promise<PendingVerification> {
|
||||
await pool.query("DELETE FROM pending_verifications WHERE email = $1", [email]);
|
||||
|
||||
const now = new Date();
|
||||
const pending: PendingVerification = {
|
||||
|
|
@ -105,47 +88,53 @@ export function createPendingVerification(email: string): PendingVerification {
|
|||
expiresAt: new Date(now.getTime() + CODE_EXPIRY_MS).toISOString(),
|
||||
attempts: 0,
|
||||
};
|
||||
pendingVerifications.push(pending);
|
||||
save();
|
||||
|
||||
await pool.query(
|
||||
"INSERT INTO pending_verifications (email, code, created_at, expires_at, attempts) VALUES ($1, $2, $3, $4, $5)",
|
||||
[pending.email, pending.code, pending.createdAt, pending.expiresAt, pending.attempts]
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function verifyCode(email: string, code: string): { status: "ok" | "invalid" | "expired" | "max_attempts" } {
|
||||
export async function verifyCode(email: string, code: string): Promise<{ status: "ok" | "invalid" | "expired" | "max_attempts" }> {
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const pending = pendingVerifications.find(p => p.email === cleanEmail);
|
||||
const result = await pool.query("SELECT * FROM pending_verifications WHERE email = $1", [cleanEmail]);
|
||||
const pending = result.rows[0];
|
||||
|
||||
if (!pending) return { status: "invalid" };
|
||||
|
||||
if (new Date() > new Date(pending.expiresAt)) {
|
||||
pendingVerifications = pendingVerifications.filter(p => p !== pending);
|
||||
save();
|
||||
if (new Date() > new Date(pending.expires_at)) {
|
||||
await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
|
||||
return { status: "expired" };
|
||||
}
|
||||
|
||||
if (pending.attempts >= MAX_ATTEMPTS) {
|
||||
pendingVerifications = pendingVerifications.filter(p => p !== pending);
|
||||
save();
|
||||
await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
|
||||
return { status: "max_attempts" };
|
||||
}
|
||||
|
||||
pending.attempts++;
|
||||
await pool.query("UPDATE pending_verifications SET attempts = attempts + 1 WHERE email = $1", [cleanEmail]);
|
||||
|
||||
if (pending.code !== code) {
|
||||
save();
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
// Success - remove pending
|
||||
pendingVerifications = pendingVerifications.filter(p => p !== pending);
|
||||
save();
|
||||
await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
|
||||
return { status: "ok" };
|
||||
}
|
||||
|
||||
export function isEmailVerified(email: string): boolean {
|
||||
return verifications.some(v => v.email === email && v.verifiedAt !== null);
|
||||
export async function isEmailVerified(email: string): Promise<boolean> {
|
||||
const result = await pool.query(
|
||||
"SELECT 1 FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1",
|
||||
[email]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
export function getVerifiedApiKey(email: string): string | null {
|
||||
const v = verifications.find(v => v.email === email && v.verifiedAt !== null);
|
||||
return v?.apiKey ?? null;
|
||||
export async function getVerifiedApiKey(email: string): Promise<string | null> {
|
||||
const result = await pool.query(
|
||||
"SELECT api_key FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1",
|
||||
[email]
|
||||
);
|
||||
return result.rows[0]?.api_key ?? null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue