server.ts
Rama main · réplica de lectura del repositorio original.
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import helmet from '@fastify/helmet';
import fastifyMultipart from '@fastify/multipart';
import rateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static';
import Fastify, { LogController } from 'fastify';
import { getConfig } from './lib/config.js';
import { parseCSV } from './lib/converter.js';
import { setLogger } from './lib/logger.js';
import { envSchema, uploadQuerySchema } from './lib/schemas.js';
import { listAccounts, listBudgets, uploadTransactions } from './lib/uploader.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Validate environment variables
const env = envSchema.parse(process.env);
const fastify = Fastify({
logger:
env.NODE_ENV === 'development'
? true
: {
level: 'error',
redact: ['req.headers.authorization', 'req.headers.cookie'],
},
requestIdHeader: 'x-request-id',
logController: new LogController({ requestIdLogLabel: 'requestId' }),
genReqId: () => crypto.randomUUID(),
});
// Wire Fastify's Pino logger into the shared lib logger
setLogger({
info: (msg) => fastify.log.info(msg),
error: (msg) => fastify.log.error(msg),
});
// Rate limit configuration
const RATE_LIMITS = {
health: {
max: 60,
timeWindow: '1 minute',
},
config: {
max: 10,
timeWindow: '1 minute',
},
budgets: {
max: 30,
timeWindow: '1 minute',
},
accounts: {
max: 30,
timeWindow: '1 minute',
},
upload: {
max: 10,
timeWindow: '1 minute',
},
} as const;
// Security headers
await fastify.register(helmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for now
scriptSrc: ["'self'"],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"], // Only allow connections to own server
frameSrc: ["'none'"],
objectSrc: ["'none'"],
// Don't upgrade to HTTPS - let reverse proxy handle that
upgradeInsecureRequests: null,
},
},
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: 'same-site' },
});
// Rate limiting
await fastify.register(rateLimit, {
max: 100,
timeWindow: '15 minutes',
cache: 10000,
allowList: ['127.0.0.1'],
skipOnError: true,
});
// Register plugins
// When running from dist/server.js, __dirname is already 'dist', so we just need 'public'
// When running from server.ts (dev mode), __dirname is root, so we need 'dist/public'
const publicDir = __dirname.endsWith('dist')
? path.join(__dirname, 'public')
: path.join(__dirname, 'dist', 'public');
await fastify.register(fastifyStatic, {
root: publicDir,
prefix: '/',
});
await fastify.register(fastifyMultipart, {
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
files: 1,
},
});
// CORS - Allow same-origin requests (no CORS headers needed)
// Parsed once at startup (entries trimmed), not per request
const allowedOrigins = (
env.ALLOWED_ORIGINS.length > 0 ? env.ALLOWED_ORIGINS : ['http://localhost:3000']
)
.map((o) => o.trim())
.filter((o) => o !== 'null' && o.length > 0);
fastify.addHook('onRequest', async (request, reply) => {
const origin = request.headers.origin;
// Only add CORS headers if the origin exactly matches a whitelisted one
if (origin && origin !== 'null' && allowedOrigins.includes(origin)) {
reply.header('Access-Control-Allow-Origin', origin);
reply.header('Access-Control-Allow-Credentials', 'true');
reply.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
reply.header('Access-Control-Allow-Headers', 'Content-Type');
}
});
// File validation utility
function validateCSVFile(buffer: Buffer, filename: string): boolean {
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_LINES = 50000;
const MAX_LINE_LENGTH = 10000;
if (buffer.length > MAX_SIZE) {
throw new Error('File too large (max 10MB)');
}
if (!filename.endsWith('.csv')) {
throw new Error('File must have .csv extension');
}
const content = buffer.toString('utf-8');
const lines = content.split('\n');
if (lines.length > MAX_LINES) {
throw new Error(`Too many lines (max ${MAX_LINES})`);
}
// Check line length (first 100 lines)
for (let i = 0; i < Math.min(lines.length, 100); i++) {
const line = lines[i];
if (line && line.length > MAX_LINE_LENGTH) {
throw new Error(`Line ${i + 1} is too long`);
}
}
// Check for CSV structure
if (lines.length > 0) {
const firstLine = lines[0];
if (firstLine && !firstLine.includes(',') && !firstLine.includes(';')) {
throw new Error('File does not appear to be a valid CSV');
}
}
return true;
}
// Health check
fastify.get(
'/api/health',
{
config: {
rateLimit: RATE_LIMITS.health,
},
},
async () => {
return { status: 'ok' };
}
);
fastify.get(
'/api/config',
{
config: {
rateLimit: RATE_LIMITS.config,
},
},
async () => {
try {
const config = getConfig();
return {
configured: true,
hasBudget: !!config.budgetId,
hasAccount: !!config.accountId,
budgetId: config.budgetId || null,
accountId: config.accountId || null,
};
} catch (_error) {
return {
configured: false,
hasBudget: false,
hasAccount: false,
budgetId: null,
accountId: null,
};
}
}
);
fastify.get(
'/api/budgets',
{
config: {
rateLimit: RATE_LIMITS.budgets,
},
},
async () => {
try {
const config = getConfig();
const budgets = await listBudgets(config.accessToken);
const budgetsWithCurrency = budgets.map((b) => ({
id: b.id,
name: b.name,
currency_format: b.currency_format,
}));
return { budgets: budgetsWithCurrency };
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
}
);
fastify.get<{
Querystring: { budgetId?: string };
}>(
'/api/accounts',
{
config: {
rateLimit: RATE_LIMITS.accounts,
},
},
async (request, reply) => {
try {
const config = getConfig();
// Get budget ID from query param or config
const budgetId = request.query.budgetId || config.budgetId;
if (!budgetId) {
// If no budget ID, return budgets list
const budgets = await listBudgets(config.accessToken);
if (budgets.length === 1) {
const firstBudget = budgets[0];
if (!firstBudget) {
return { accounts: [], needBudgetSelection: true };
}
// Auto-select if only one budget
const accounts = await listAccounts(config.accessToken, firstBudget.id);
return { accounts, budgetId: firstBudget.id };
}
// Return empty if multiple budgets
return { accounts: [], needBudgetSelection: true };
}
const accounts = await listAccounts(config.accessToken, budgetId);
return {
accounts,
budgetId,
};
} catch (error) {
reply.code(500);
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
}
);
// Upload and import CSV with stricter rate limit
fastify.post<{
Querystring: { dryRun?: string; budgetId?: string; accountId?: string };
}>(
'/api/upload',
{
config: {
rateLimit: RATE_LIMITS.upload,
},
},
async (request, reply) => {
let tmpFile: string | null = null;
try {
// Validate query parameters
const queryResult = uploadQuerySchema.safeParse(request.query);
if (!queryResult.success) {
reply.code(400);
return { error: `Invalid query parameters: ${queryResult.error.message}` };
}
const query = queryResult.data;
const config = getConfig();
// Get the uploaded file
const data = await request.file();
if (!data) {
reply.code(400);
return { error: 'No file uploaded' };
}
const buffer = await data.toBuffer();
// Validate file
try {
validateCSVFile(buffer, data.filename);
} catch (validationError) {
reply.code(400);
return {
error: validationError instanceof Error ? validationError.message : 'Validation failed',
};
}
// Save file temporarily with a secure random name; only the extension
// is taken from the user filename (the original name is passed to
// parseCSV separately for bank detection)
const randomName = crypto.randomBytes(16).toString('hex');
const originalFilename = data.filename;
const extension = path.extname(originalFilename).toLowerCase();
tmpFile = path.join(os.tmpdir(), `ynab-${randomName}${extension}`);
fs.writeFileSync(tmpFile, buffer);
try {
// Parse CSV (pass original filename for bank detection)
const transactions = parseCSV(tmpFile, originalFilename);
// Check if dry run
if (query.dryRun) {
// Return preview
return {
success: true,
dryRun: true,
count: transactions.length,
preview: transactions.slice(0, 10).map((tx) => ({
date: tx.date,
payee: tx.payee_name,
amount: tx.amount,
memo: tx.memo,
})),
};
}
// Get budget and account IDs from validated query params
const budgetId = query.budgetId;
const accountId = query.accountId;
if (!accountId && !config.accountId) {
reply.code(400);
return { error: 'Account ID is required. Please select an account.' };
}
// Upload to YNAB
const result = await uploadTransactions(
transactions,
config,
accountId || null,
budgetId || null
);
return {
success: true,
imported: result.imported,
duplicates: result.duplicates,
count: transactions.length,
};
} finally {
// Cleanup
if (tmpFile && fs.existsSync(tmpFile)) {
fs.unlinkSync(tmpFile);
}
}
} catch (error) {
reply.code(500);
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
}
);
// Start server
const start = async () => {
try {
await fastify.listen({ port: env.PORT, host: '0.0.0.0' });
fastify.log.info(`YNAB Web App running at http://localhost:${env.PORT}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();