Ctrl K
ComunidadGuías…

server.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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();