Dokploy deploy: Dockerfile, DB CA cert, blog images, CSP
CI / build-and-test (push) Has been cancelled
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:
co-authored by
Claude Fable 5
parent
d9b807662a
commit
d1d96e4dd2
@@ -14,6 +14,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.111.0",
|
||||
"@aws-sdk/client-s3": "^3.1088.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1088.0",
|
||||
"@fastify/cookie": "^11.0.1",
|
||||
|
||||
@@ -30,6 +30,8 @@ const envSchema = z.object({
|
||||
// Legacy local path — read only by the one-time migration script, not the running app.
|
||||
STORAGE_PATH: z.string().optional().default('./storage'),
|
||||
SMTP2GO_API_KEY: z.string().optional().default(''),
|
||||
ANTHROPIC_API_KEY: z.string().optional().default(''),
|
||||
TURNSTILE_SECRET_KEY: z.string().optional().default(''),
|
||||
EMAIL_FROM: z.string().optional().default('eLegal Software <noreply@elegalsoftware.com>'),
|
||||
STRIPE_SECRET_KEY: z.string().optional().default(''),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional().default(''),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// AI layer — Anthropic Claude. All platform AI features go through this module so the
|
||||
// model choice, error mapping, and enablement check live in one place.
|
||||
//
|
||||
// Model: claude-haiku-4-5 — the cheapest current Claude model ($1/M input, $5/M output),
|
||||
// chosen deliberately for cost; swap AI_MODEL to a bigger model if quality needs grow.
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { env } from '../env';
|
||||
|
||||
export const AI_MODEL = 'claude-haiku-4-5';
|
||||
|
||||
let _client: Anthropic | null = null;
|
||||
|
||||
function getClient(): Anthropic | null {
|
||||
if (!env.ANTHROPIC_API_KEY) return null;
|
||||
if (!_client) _client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
|
||||
return _client;
|
||||
}
|
||||
|
||||
export function isAiEnabled(): boolean {
|
||||
return Boolean(env.ANTHROPIC_API_KEY);
|
||||
}
|
||||
|
||||
export class AiDisabledError extends Error {
|
||||
constructor() {
|
||||
super('ai_not_configured');
|
||||
this.name = 'AiDisabledError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AiUnavailableError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public retryable: boolean,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AiUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface AiUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
export interface AiResult {
|
||||
text: string;
|
||||
usage: AiUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-turn completion. `content` is either plain text or prebuilt content blocks
|
||||
* (e.g. a document block for PDF summarization).
|
||||
*/
|
||||
export async function aiComplete(opts: {
|
||||
system: string;
|
||||
content: string | Anthropic.ContentBlockParam[];
|
||||
maxTokens?: number;
|
||||
}): Promise<AiResult> {
|
||||
const client = getClient();
|
||||
if (!client) throw new AiDisabledError();
|
||||
|
||||
let response: Anthropic.Message;
|
||||
try {
|
||||
response = await client.messages.create({
|
||||
model: AI_MODEL,
|
||||
max_tokens: opts.maxTokens ?? 1500,
|
||||
system: opts.system,
|
||||
messages: [{ role: 'user', content: opts.content }],
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Anthropic.RateLimitError) {
|
||||
throw new AiUnavailableError('ai_rate_limited', true);
|
||||
}
|
||||
if (err instanceof Anthropic.APIConnectionError) {
|
||||
throw new AiUnavailableError('ai_connection_failed', true);
|
||||
}
|
||||
if (err instanceof Anthropic.APIError) {
|
||||
// 4xx: our request is malformed (e.g. unsupported document) — not retryable.
|
||||
// 5xx/overloaded: transient.
|
||||
const retryable = typeof err.status === 'number' ? err.status >= 500 : true;
|
||||
throw new AiUnavailableError(`ai_error_${err.status ?? 'unknown'}`, retryable);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (response.stop_reason === 'refusal') {
|
||||
throw new AiUnavailableError('ai_refused', false);
|
||||
}
|
||||
|
||||
const text = response.content
|
||||
.filter((b): b is Anthropic.TextBlock => b.type === 'text')
|
||||
.map((b) => b.text)
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
return {
|
||||
text,
|
||||
usage: {
|
||||
inputTokens: response.usage.input_tokens,
|
||||
outputTokens: response.usage.output_tokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Cloudflare Turnstile server-side verification. Protects the public, abuse-prone
|
||||
// endpoints (signup, login, password reset, contact form) from bots.
|
||||
// With TURNSTILE_SECRET_KEY unset (local dev), verification is skipped entirely.
|
||||
import { env } from '../env';
|
||||
|
||||
const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
export function isTurnstileEnabled(): boolean {
|
||||
return Boolean(env.TURNSTILE_SECRET_KEY);
|
||||
}
|
||||
|
||||
interface SiteverifyResponse {
|
||||
success: boolean;
|
||||
'error-codes'?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a Turnstile token. Returns true when the check passes or Turnstile is
|
||||
* disabled. Tokens are single-use — the widget must be reset after a failed submit.
|
||||
*/
|
||||
export async function verifyTurnstile(
|
||||
token: string | undefined | null,
|
||||
ip?: string | null,
|
||||
): Promise<boolean> {
|
||||
if (!isTurnstileEnabled()) return true;
|
||||
if (!token) return false;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
secret: env.TURNSTILE_SECRET_KEY,
|
||||
response: token,
|
||||
});
|
||||
if (ip) body.set('remoteip', ip);
|
||||
|
||||
try {
|
||||
const res = await fetch(VERIFY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
const json = (await res.json()) as SiteverifyResponse;
|
||||
return json.success === true;
|
||||
} catch {
|
||||
// Cloudflare unreachable — fail closed. The client shows "try again" and the
|
||||
// widget issues a fresh token on retry.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { getDb, cases, clients, timeEntries, documents, invoices } from '@lawdesk/db';
|
||||
import { aiComplete, isAiEnabled, AiDisabledError, AiUnavailableError, AI_MODEL } from '../lib/ai';
|
||||
import { getObjectStream, FileNotFoundError } from '../lib/storage';
|
||||
|
||||
// The one non-negotiable framing for a legal-tech product: the model assists with
|
||||
// organization and drafting; it must never present itself as giving legal advice.
|
||||
const BASE_SYSTEM = `You are an assistant inside eLegal Software, a practice-management tool used by attorneys.
|
||||
You help organize and summarize the firm's own records. You do not give legal advice, cite law, or predict case outcomes.
|
||||
Be factual and concise. Only state what is supported by the provided material; if something is unclear or missing, say so plainly.`;
|
||||
|
||||
const AI_DISCLAIMER =
|
||||
'AI-generated from your case records — review for accuracy. Not legal advice.';
|
||||
|
||||
// Document types we can hand to the model directly.
|
||||
const AI_MIME = {
|
||||
pdf: 'application/pdf',
|
||||
text: 'text/plain',
|
||||
images: new Set(['image/jpeg', 'image/png', 'image/webp']),
|
||||
} as const;
|
||||
const MAX_AI_DOC_BYTES = 15 * 1024 * 1024; // base64 expansion must stay under the 32MB request cap
|
||||
|
||||
function sendAiError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||
if (err instanceof AiDisabledError) {
|
||||
return reply.code(503).send({ error: 'ai_not_configured' });
|
||||
}
|
||||
if (err instanceof AiUnavailableError) {
|
||||
return reply.code(err.retryable ? 503 : 422).send({ error: err.message });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream as AsyncIterable<Buffer | string>) {
|
||||
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export async function aiRoutes(app: FastifyInstance) {
|
||||
app.addHook('preHandler', app.requireFirm);
|
||||
|
||||
// Lets the UI decide whether to render AI affordances at all.
|
||||
app.get('/api/ai/status', async () => ({ enabled: isAiEnabled(), model: AI_MODEL }));
|
||||
|
||||
// ── Case summary ─────────────────────────────────────────────────────────
|
||||
// Builds a brief from everything the firm has recorded on a case.
|
||||
app.post(
|
||||
'/api/cases/:id/ai/summary',
|
||||
{ config: { rateLimit: { max: 30, timeWindow: '1 hour' } } },
|
||||
async (req, reply) => {
|
||||
const firmId = req.user!.firmId!;
|
||||
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
|
||||
const db = getDb();
|
||||
|
||||
const [c] = await db
|
||||
.select()
|
||||
.from(cases)
|
||||
.where(and(eq(cases.id, id), eq(cases.firmId, firmId)))
|
||||
.limit(1);
|
||||
if (!c) return reply.code(404).send({ error: 'not_found' });
|
||||
|
||||
const [client] = await db
|
||||
.select({ name: clients.name })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, c.clientId))
|
||||
.limit(1);
|
||||
|
||||
const entries = await db
|
||||
.select({
|
||||
description: timeEntries.description,
|
||||
minutes: timeEntries.minutes,
|
||||
billable: timeEntries.billable,
|
||||
startedAt: timeEntries.startedAt,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.where(and(eq(timeEntries.caseId, id), eq(timeEntries.firmId, firmId)))
|
||||
.orderBy(desc(timeEntries.startedAt))
|
||||
.limit(50);
|
||||
|
||||
const docs = await db
|
||||
.select({ name: documents.name, createdAt: documents.createdAt })
|
||||
.from(documents)
|
||||
.where(and(eq(documents.caseId, id), eq(documents.firmId, firmId)))
|
||||
.orderBy(desc(documents.createdAt))
|
||||
.limit(50);
|
||||
|
||||
const caseInvoices = await db
|
||||
.select({ number: invoices.number, status: invoices.status, total: invoices.total })
|
||||
.from(invoices)
|
||||
.where(and(eq(invoices.caseId, id), eq(invoices.firmId, firmId)));
|
||||
|
||||
const fmtDate = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : 'n/a');
|
||||
const context = [
|
||||
`CASE: ${c.title}${c.caseNumber ? ` (No. ${c.caseNumber})` : ''}`,
|
||||
`Client: ${client?.name ?? 'unknown'} · Status: ${c.status} · Practice area: ${c.practiceArea ?? 'n/a'}`,
|
||||
`Opened: ${fmtDate(c.openedAt)}${c.closedAt ? ` · Closed: ${fmtDate(c.closedAt)}` : ''}`,
|
||||
c.description ? `Description: ${c.description}` : '',
|
||||
'',
|
||||
`TIME ENTRIES (most recent ${entries.length}):`,
|
||||
...entries.map(
|
||||
(e) =>
|
||||
`- ${fmtDate(e.startedAt)} · ${e.minutes} min · ${e.billable ? 'billable' : 'non-billable'} · ${e.description}`,
|
||||
),
|
||||
'',
|
||||
`DOCUMENTS (${docs.length}):`,
|
||||
...docs.map((d) => `- ${d.name} (uploaded ${fmtDate(d.createdAt)})`),
|
||||
'',
|
||||
`INVOICES (${caseInvoices.length}):`,
|
||||
...caseInvoices.map((i) => `- ${i.number} · ${i.status} · $${i.total}`),
|
||||
]
|
||||
.filter((line) => line !== '')
|
||||
.join('\n');
|
||||
|
||||
try {
|
||||
const result = await aiComplete({
|
||||
system: `${BASE_SYSTEM}
|
||||
Write a case brief for the attorney working this case, as plain text (no markdown syntax) with these section headings on their own lines:
|
||||
STATUS SNAPSHOT — 2-3 sentences on where the case stands based on the records.
|
||||
RECENT ACTIVITY — the notable recent work, grouped, not a raw list.
|
||||
BILLING PICTURE — hours logged, what's invoiced/outstanding.
|
||||
GAPS & FOLLOW-UPS — anything the records suggest needs attention (stale activity, unbilled time, missing documents).
|
||||
Keep it under 300 words.`,
|
||||
content: context,
|
||||
});
|
||||
return { summary: result.text, disclaimer: AI_DISCLAIMER, usage: result.usage };
|
||||
} catch (err) {
|
||||
return sendAiError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Document summary ─────────────────────────────────────────────────────
|
||||
app.post(
|
||||
'/api/documents/:docId/ai/summary',
|
||||
{ config: { rateLimit: { max: 30, timeWindow: '1 hour' } } },
|
||||
async (req, reply) => {
|
||||
const firmId = req.user!.firmId!;
|
||||
const { docId } = z.object({ docId: z.string().uuid() }).parse(req.params);
|
||||
const db = getDb();
|
||||
|
||||
const [doc] = await db
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(and(eq(documents.id, docId), eq(documents.firmId, firmId)))
|
||||
.limit(1);
|
||||
if (!doc) return reply.code(404).send({ error: 'not_found' });
|
||||
|
||||
const supported =
|
||||
doc.mimeType === AI_MIME.pdf ||
|
||||
doc.mimeType === AI_MIME.text ||
|
||||
AI_MIME.images.has(doc.mimeType);
|
||||
if (!supported) {
|
||||
return reply.code(400).send({
|
||||
error: 'unsupported_for_ai',
|
||||
hint: 'AI summaries support PDF, plain text, and image documents.',
|
||||
});
|
||||
}
|
||||
if (doc.sizeBytes > MAX_AI_DOC_BYTES) {
|
||||
return reply.code(400).send({ error: 'document_too_large_for_ai' });
|
||||
}
|
||||
|
||||
let buf: Buffer;
|
||||
try {
|
||||
buf = await streamToBuffer(await getObjectStream(doc.storageKey));
|
||||
} catch (err) {
|
||||
if (err instanceof FileNotFoundError) return reply.code(404).send({ error: 'file_missing' });
|
||||
throw err;
|
||||
}
|
||||
|
||||
const instruction = `Summarize the attached document ("${doc.name}") for the attorney's case file, as plain text (no markdown syntax) with these section headings on their own lines:
|
||||
WHAT IT IS — document type and apparent purpose.
|
||||
KEY POINTS — parties, dates, amounts, deadlines, obligations that appear in it.
|
||||
ANYTHING UNUSUAL — inconsistencies, missing signatures/pages, ambiguities worth a closer look.
|
||||
Keep it under 300 words.`;
|
||||
|
||||
const content =
|
||||
doc.mimeType === AI_MIME.text
|
||||
? `${instruction}\n\n--- DOCUMENT CONTENT ---\n${buf.toString('utf8')}`
|
||||
: ([
|
||||
doc.mimeType === AI_MIME.pdf
|
||||
? {
|
||||
type: 'document' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: 'application/pdf' as const,
|
||||
data: buf.toString('base64'),
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: doc.mimeType as 'image/jpeg' | 'image/png' | 'image/webp',
|
||||
data: buf.toString('base64'),
|
||||
},
|
||||
},
|
||||
{ type: 'text' as const, text: instruction },
|
||||
]);
|
||||
|
||||
try {
|
||||
const result = await aiComplete({ system: BASE_SYSTEM, content });
|
||||
return { summary: result.text, disclaimer: AI_DISCLAIMER, usage: result.usage };
|
||||
} catch (err) {
|
||||
return sendAiError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Text polish ──────────────────────────────────────────────────────────
|
||||
// Generic "make this professional" helper for notes, descriptions, and messages.
|
||||
app.post(
|
||||
'/api/ai/polish',
|
||||
{ config: { rateLimit: { max: 60, timeWindow: '1 hour' } } },
|
||||
async (req, reply) => {
|
||||
const body = z
|
||||
.object({
|
||||
text: z.string().min(1).max(10_000),
|
||||
kind: z.enum(['time_entry', 'invoice_note', 'case_description', 'client_message']),
|
||||
})
|
||||
.parse(req.body);
|
||||
|
||||
const KIND_GUIDANCE: Record<typeof body.kind, string> = {
|
||||
time_entry:
|
||||
'Rewrite as a professional billing narrative: past tense, specific, defensible to a client reviewing the invoice. One or two sentences.',
|
||||
invoice_note:
|
||||
'Rewrite as a courteous, professional note to appear on a client invoice. Brief.',
|
||||
case_description:
|
||||
'Rewrite as a clear internal case description: what the matter is, who is involved, current posture.',
|
||||
client_message:
|
||||
'Rewrite as a professional, warm message from a law firm to its client. Plain language, no legalese.',
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await aiComplete({
|
||||
system: `${BASE_SYSTEM}
|
||||
${KIND_GUIDANCE[body.kind]}
|
||||
Return ONLY the rewritten text — no preamble, no quotes, no commentary. Preserve all facts; never invent names, dates, or amounts.`,
|
||||
content: body.text,
|
||||
maxTokens: 800,
|
||||
});
|
||||
return { text: result.text, usage: result.usage };
|
||||
} catch (err) {
|
||||
return sendAiError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -3,11 +3,13 @@ import { z } from 'zod';
|
||||
import { getDb, contactMessages } from '@lawdesk/db';
|
||||
import { sendEmail, contactAckEmail, contactNotifyEmail } from '../lib/email';
|
||||
import { env } from '../env';
|
||||
import { verifyTurnstile } from '../lib/turnstile';
|
||||
|
||||
const contactBody = z.object({
|
||||
fullName: z.string().min(1).max(120).trim(),
|
||||
email: z.string().email().max(254).toLowerCase().trim(),
|
||||
message: z.string().min(1).max(5000).trim(),
|
||||
turnstileToken: z.string().max(3000).optional(),
|
||||
});
|
||||
|
||||
export async function contactRoutes(app: FastifyInstance) {
|
||||
@@ -18,6 +20,11 @@ export async function contactRoutes(app: FastifyInstance) {
|
||||
const parsed = contactBody.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
|
||||
const body = parsed.data;
|
||||
|
||||
if (!(await verifyTurnstile(body.turnstileToken, req.ip))) {
|
||||
return reply.code(400).send({ error: 'captcha_failed' });
|
||||
}
|
||||
|
||||
await getDb().insert(contactMessages).values({
|
||||
fullName: body.fullName,
|
||||
email: body.email,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { accountRoutes } from './routes/account';
|
||||
import { toolUsageRoutes } from './routes/tool-usage';
|
||||
import { billingRoutes } from './routes/billing';
|
||||
import { documentsRoutes } from './routes/documents';
|
||||
import { aiRoutes } from './routes/ai';
|
||||
import { stripeWebhookRoute } from './routes/webhooks-stripe';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -70,11 +71,12 @@ export async function buildServer() {
|
||||
? {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", 'https://challenges.cloudflare.com'],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
||||
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
imgSrc: ["'self'", 'data:', 'blob:', 'https://*.digitaloceanspaces.com', 'https://*.cdn.digitaloceanspaces.com'],
|
||||
connectSrc: ["'self'"],
|
||||
frameSrc: ['https://challenges.cloudflare.com'],
|
||||
frameAncestors: ["'none'"],
|
||||
formAction: ["'self'"],
|
||||
baseUri: ["'self'"],
|
||||
@@ -119,6 +121,7 @@ export async function buildServer() {
|
||||
await app.register(toolUsageRoutes);
|
||||
await app.register(billingRoutes);
|
||||
await app.register(documentsRoutes);
|
||||
await app.register(aiRoutes);
|
||||
|
||||
// Serve the built SPA in production. In dev, the Vite dev server runs separately.
|
||||
const webDist = env.WEB_DIST_PATH ?? path.resolve(__dirname, '../../web/dist');
|
||||
|
||||
Reference in New Issue
Block a user