Dokploy deploy: Dockerfile, DB CA cert, blog images, CSP
CI / build-and-test (push) Has been cancelled

- Add Dockerfile (multi-stage Node 20), .dockerignore, docker-compose.yml, and
  DEPLOY-DOKPLOY.md for container deployment on Dokploy.
- Commit the DigitalOcean managed-Postgres Project CA cert (certs/ca-certificate.crt)
  so production TLS verification (fail-closed) works in-container. Public CA, safe to commit.
- Blog cover images served from DO Spaces; allow *.digitaloceanspaces.com in the prod CSP img-src.
- Includes the AI (case summaries) and Cloudflare Turnstile bot-protection features.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-17 13:04:18 -04:00
co-authored by Claude Fable 5
parent d9b807662a
commit d1d96e4dd2
29 changed files with 1224 additions and 28 deletions
+26 -2
View File
@@ -23,17 +23,20 @@ import {
verifyEmailEmail,
} from '../lib/email';
import { env } from '../env';
import { verifyTurnstile } from '../lib/turnstile';
const signupBody = z.object({
email: z.string().email().max(254).toLowerCase().trim(),
password: z.string().min(10).max(200),
fullName: z.string().min(1).max(120).trim(),
firmName: z.string().min(1).max(160).trim(),
turnstileToken: z.string().max(3000).optional(),
});
const loginBody = z.object({
email: z.string().email().max(254).toLowerCase().trim(),
password: z.string().min(1).max(200),
turnstileToken: z.string().max(3000).optional(),
});
const MAX_FAILS_PER_15_MIN = 5;
@@ -74,6 +77,11 @@ export async function authRoutes(app: FastifyInstance) {
{ config: { rateLimit: { max: 5, timeWindow: '1 hour' } } },
async (req, reply) => {
const body = signupBody.parse(req.body);
if (!(await verifyTurnstile(body.turnstileToken, req.ip))) {
return reply.code(400).send({ error: 'captcha_failed' });
}
const db = getDb();
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, body.email)).limit(1);
@@ -144,6 +152,11 @@ export async function authRoutes(app: FastifyInstance) {
{ config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } },
async (req, reply) => {
const body = loginBody.parse(req.body);
if (!(await verifyTurnstile(body.turnstileToken, req.ip))) {
return reply.code(400).send({ error: 'captcha_failed' });
}
const db = getDb();
const ip = req.ip ?? null;
@@ -216,10 +229,21 @@ export async function authRoutes(app: FastifyInstance) {
app.post(
'/api/auth/request-password-reset',
{ config: { rateLimit: { max: 5, timeWindow: '15 minutes' } } },
async (req) => {
const parsed = z.object({ email: z.string().email().max(254).toLowerCase().trim() }).safeParse(req.body);
async (req, reply) => {
const parsed = z
.object({
email: z.string().email().max(254).toLowerCase().trim(),
turnstileToken: z.string().max(3000).optional(),
})
.safeParse(req.body);
if (!parsed.success) return { ok: true };
// Bot check is orthogonal to email enumeration — a captcha failure is reported
// honestly; only account existence is concealed by the ok-always contract.
if (!(await verifyTurnstile(parsed.data.turnstileToken, req.ip))) {
return reply.code(400).send({ error: 'captcha_failed' });
}
const db = getDb();
const [user] = await db.select().from(users).where(eq(users.email, parsed.data.email)).limit(1);
if (!user || user.isSuspended) return { ok: true };