import config from '../../config.js'; import log4js from 'log4js'; import { getModelByName } from './model.js'; import { listObjectsOData } from '../../database/odata.js'; import { getFilter } from '../../utils.js'; import { generateExcelTable, createExcelTempToken, getExcelTempParams, incrementExcelTempRequestCount, deleteExcelTempToken, } from '../../database/excel.js'; import { getModelFilterFields, parseOrderBy, rowToFlat } from './export.js'; const logger = log4js.getLogger('Excel'); logger.level = config.server.logLevel; /** * Generate Excel file for the given object type. * @param {Object} options * @param {string} options.objectType - Model type (e.g. 'appPassword', 'user') * @param {Object} [options.filter] - Filter object * @param {string} [options.sort] - Sort field * @param {string} [options.order] - 'ascend' | 'descend' * @param {number} [options.limit=10000] - Max rows to export * @returns {Promise<{ buffer: Buffer, error?: Object }>} */ export async function exportToExcel({ objectType, filter = {}, sort, order, limit = 10000 }) { logger.info('[Excel Export] Starting', { objectType, filter, sort, order }); const entry = getModelByName(objectType); if (!entry?.model) { logger.warn('[Excel Export] Unknown object type:', objectType); return { error: { message: `Unknown object type: ${objectType}` }, code: 404 }; } const orderbyStr = sort ? `${sort} ${order === 'descend' ? 'desc' : 'asc'}` : undefined; const { sort: sortField, order: orderDir } = parseOrderBy(orderbyStr); const result = await listObjectsOData({ model: entry.model, populate: [], page: 1, limit, filter, sort: sortField, order: orderDir, pagination: true, project: undefined, count: false, }); if (result?.error) { logger.error('[Excel Export] listObjectsOData error:', result.error); return { error: result.error, code: result.code || 500 }; } const rows = result?.value || []; logger.info('[Excel Export] Rows fetched', { rowCount: rows.length }); const flatRows = rows.map(rowToFlat); const allKeys = new Set(); flatRows.forEach((r) => Object.keys(r).forEach((k) => allKeys.add(k))); const columnOrder = Array.from(allKeys).sort(); let buffer; try { buffer = await generateExcelTable(flatRows, { sheetName: entry.model.modelName || objectType, columnOrder, }); logger.info('[Excel Export] Buffer generated', { bufferLength: buffer?.length }); } catch (err) { logger.error('[Excel Export] generateExcelTable threw:', err?.message, err?.stack); return { error: { message: err.message || 'Failed to generate Excel' }, code: 500 }; } return { buffer }; } /** * Route handler for GET /odata/:objectType/excel */ export const excelExportRouteHandler = async (req, res) => { const objectType = req.params.objectType; const allowedFilters = getModelFilterFields(objectType); const filter = getFilter(req.query, allowedFilters); const { sort, order } = parseOrderBy(req.query.$orderby); const result = await exportToExcel({ objectType, filter, sort, order, }); if (result.error) { return res.status(result.code || 500).json(result.error); } const filename = `${objectType}-export-${new Date().toISOString().slice(0, 10)}.xlsx`; res.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.set('Content-Disposition', `attachment; filename="${filename}"`); res.send(result.buffer); }; /** * Route handler for POST /excel/:objectType/open - creates a temporary URL for opening in Excel. * Stores export params in Redis; spreadsheet is generated when Excel fetches the temp URL. * Returns { url } - an https URL Excel can fetch (no auth required, one-time use, 5 min expiry). */ export const excelOpenRouteHandler = async (req, res) => { try { const objectType = req.params.objectType; logger.info('[Excel Open] POST received', { objectType, query: req.query }); const allowedFilters = getModelFilterFields(objectType); const filter = getFilter(req.query, allowedFilters); const { sort, order } = parseOrderBy(req.query.$orderby); const entry = getModelByName(objectType); if (!entry?.model) { logger.warn('[Excel Open] Unknown object type:', objectType); return res.status(404).json({ message: `Unknown object type: ${objectType}` }); } const params = { objectType, filter, sort, order }; logger.info('[Excel Open] Creating temp token with params:', JSON.stringify(params)); const { token, url } = await createExcelTempToken(params); if (!url) { logger.error('[Excel Open] createExcelTempToken returned no url'); return res.status(500).json({ message: 'Failed to create temporary URL' }); } logger.info('[Excel Open] Temp URL created', { objectType, token, url }); res.json({ url }); } catch (err) { logger.error('[Excel Open] Route error:', err?.message, err?.stack); res.status(500).json({ message: err.message || 'Failed to create Excel URL' }); } }; /** * Route handler for GET /excel/temp/:token - generates and serves temporary Excel file (no auth). * Retrieves export params from Redis, generates spreadsheet on demand. * Token allows 2 requests before deletion; expires after 5 minutes. */ export const excelTempRouteHandler = async (req, res) => { const tokenParam = req.params.token || ''; const token = tokenParam.endsWith('.xlsx') ? tokenParam.slice(0, -5) : tokenParam; logger.info('[Excel Temp] GET received', { tokenParam, token, userAgent: req.get('User-Agent'), referer: req.get('Referer'), }); const params = await getExcelTempParams(token); if (!params) { logger.error('[Excel Temp] Token not found or expired', { token, tokenParam }); return res.status(404).json({ message: 'Link expired or invalid' }); } logger.info('[Excel Temp] Params retrieved from Redis', { objectType: params.objectType }); const result = await exportToExcel({ objectType: params.objectType, filter: params.filter || {}, sort: params.sort, order: params.order, }); if (result.error) { logger.error('[Excel Temp] Export failed', { error: result.error, code: result.code }); return res.status(result.code || 500).json(result.error); } const buffer = Buffer.isBuffer(result.buffer) ? result.buffer : Buffer.from(result.buffer); const filename = `${token}.xlsx`; logger.info('[Excel Temp] Sending buffer', { bufferLength: buffer.length }); // Strong ETag based on buffer length + timestamp const strongETag = `"${buffer.length.toString(16)}-${Date.now()}"`; res.setHeader('Content-Length', buffer.length); res.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); res.setHeader('Cache-Control', 'private, max-age=300'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('ETag', strongETag); // Remove headers that may break Excel res.removeHeader('Vary'); res.removeHeader('Access-Control-Allow-Credentials'); res.removeHeader('X-Powered-By'); res.end(buffer); if (req.method === 'GET') { const requestCount = await incrementExcelTempRequestCount(token); if (requestCount !== null && requestCount >= 2) { await deleteExcelTempToken(token); } } };