diff --git a/.env.example b/.env.example index 2449151..6c3f81c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # Server PORT=4208 +BODY_LIMIT=100mb # CORS — comma-separated allowed origins CORS_ORIGINS=http://localhost:5173 diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 7e5a4a1..f920c0f 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -4480,7 +4480,7 @@ }, "files": { "type": "array", - "description": "Attached documents (pdf or txt) stored with the delivery\nand returned in `files` on every delivery response.\n", + "description": "Attached documents (pdf or txt) stored with the delivery\nand returned in `files` on every delivery response.\nThumbnails (AVIF) are generated server-side.\n", "items": { "$ref": "#/components/schemas/DeliveryFileInput" } @@ -4509,6 +4509,28 @@ ] } } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "string", + "description": "JSON string containing manifest header and line items ({ externalNumber, date, supplierId, lines, filesMeta? }).\n" + }, + "files": { + "type": "array", + "items": { + "type": "string", + "format": "binary" + }, + "description": "Raw binary PDF or text files attached natively via standard HTTP multipart upload." + } + } + } } } }, @@ -4702,7 +4724,7 @@ }, "files": { "type": "array", - "description": "Appends attachments (pdf or txt) to the delivery.\nOmit to leave existing files unchanged.\n", + "description": "Appends attachments (pdf or txt) to the delivery.\nOmit to leave existing files unchanged.\nThumbnails (AVIF) are generated server-side.\n", "items": { "$ref": "#/components/schemas/DeliveryFileInput" } @@ -4713,6 +4735,25 @@ "lines": [] } } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "string", + "description": "JSON string containing patch fields ({ externalNumber, date, supplierId, lines, filesMeta? }).\n" + }, + "files": { + "type": "array", + "items": { + "type": "string", + "format": "binary" + }, + "description": "Raw binary PDF or text files to append to the delivery." + } + } + } } } }, @@ -10047,7 +10088,7 @@ }, "DeliveryFile": { "type": "object", - "description": "Attachment metadata (binary content is served via the attachment content endpoints)", + "description": "Attachment metadata (binary content is served via the attachment content endpoints). Thumbnails (AVIF) are generated server-side.", "required": [ "id", "manifestId", @@ -10097,7 +10138,8 @@ "thumbavif": { "type": "string", "format": "byte", - "nullable": true + "nullable": true, + "description": "Base64-encoded AVIF thumbnail generated server-side (null for text files)" }, "thumbx": { "type": "integer", @@ -10124,7 +10166,7 @@ "name", "content" ], - "description": "Attachment input. Only application/pdf and text/plain are accepted.", + "description": "Attachment input. Only application/pdf and text/plain are accepted. Thumbnails are generated server-side and must not be supplied by the client.", "properties": { "mimetype": { "type": "string", diff --git a/docs/api/routes/delivery/create.md b/docs/api/routes/delivery/create.md index d8fb4b9..fdda471 100644 --- a/docs/api/routes/delivery/create.md +++ b/docs/api/routes/delivery/create.md @@ -38,6 +38,14 @@ The manifest `number` is allocated automatically via } ``` +## Body (`multipart/form-data`) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `data` | yes | string | JSON string containing manifest header and line items ({ externalNumber, date, supplierId, lines, filesMeta? }). + | +| `files` | no | file | Raw binary PDF or text files attached natively via standard HTTP multipart upload. | + ## Response `201` Created delivery diff --git a/docs/api/routes/delivery/update.md b/docs/api/routes/delivery/update.md index e1677fb..8bc20c0 100644 --- a/docs/api/routes/delivery/update.md +++ b/docs/api/routes/delivery/update.md @@ -25,6 +25,14 @@ replaces all lines; if omitted, leaves lines unchanged. } ``` +## Body (`multipart/form-data`) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `data` | no | string | JSON string containing patch fields ({ externalNumber, date, supplierId, lines, filesMeta? }). + | +| `files` | no | file | Raw binary PDF or text files to append to the delivery. | + ## Response `200` Updated delivery diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js index 15bd332..1e42b3e 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -153,7 +153,6 @@ function renderRequestBody(requestBody) { lines.push(''); lines.push(formatSchema(jsonSchema, spec.components)); lines.push(''); - return lines; } // multipart/form-data — render fields as a table when properties are present @@ -166,29 +165,30 @@ function renderRequestBody(requestBody) { lines.push('| Field | Required | Type | Description |'); lines.push('|-------|----------|------|-------------|'); for (const [name, prop] of Object.entries(schema.properties)) { - const type = prop.format === 'binary' ? 'file' : (prop.type || ''); + const type = prop.format === 'binary' || prop.items?.format === 'binary' ? 'file' : (prop.type || ''); lines.push(`| \`${name}\` | ${required.has(name) ? 'yes' : 'no'} | ${type} | ${prop.description || ''} |`); } lines.push(''); - return lines; } - lines.push('## Body'); - lines.push(''); - for (const type of types) { - const schema = requestBody.content[type]?.schema; - if (schema?.format === 'binary') { - lines.push(`Raw binary (\`${type}\`).`); - } else { - lines.push(`\`${type}\``); - if (schema) { - lines.push(''); - lines.push(formatSchema(schema, spec.components)); - } - } + if (!jsonSchema && !multipart?.schema?.properties) { + lines.push('## Body'); lines.push(''); + for (const type of types) { + const schema = requestBody.content[type]?.schema; + if (schema?.format === 'binary') { + lines.push(`Raw binary (\`${type}\`).`); + } else { + lines.push(`\`${type}\``); + if (schema) { + lines.push(''); + lines.push(formatSchema(schema, spec.components)); + } + } + lines.push(''); + } } - return lines; + return lines.length ? lines : null; } function docsFileFor(apiPath, method) { diff --git a/src/api/index.js b/src/api/index.js index 613960d..f60342f 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -65,7 +65,7 @@ async function createApi() { next(); }); app.use(corsMiddleware); - app.use(express.json()); + app.use(express.json({ limit: env.bodyLimit })); app.use(cookieParser()); app.use(loggerMiddleware); app.use(authMiddleware); @@ -79,6 +79,16 @@ async function createApi() { await loadRoutes(app, '/api'); + app.use((err, _req, res, next) => { + if (err.type === 'entity.too.large') { + return res.status(413).json({ error: 'Payload too large' }); + } + if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { + return res.status(400).json({ error: 'Invalid JSON body' }); + } + next(err); + }); + return app; } diff --git a/src/api/openapi.js b/src/api/openapi.js index 523f474..f575416 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -435,7 +435,7 @@ * $ref: '#/components/schemas/DeliveryFile' * DeliveryFile: * type: object - * description: Attachment metadata (binary content is served via the attachment content endpoints) + * description: Attachment metadata (binary content is served via the attachment content endpoints). Thumbnails (AVIF) are generated server-side. * required: [id, manifestId, fileId, sort, hash, mimetype, name, size] * properties: * id: @@ -467,6 +467,7 @@ * type: string * format: byte * nullable: true + * description: Base64-encoded AVIF thumbnail generated server-side (null for text files) * thumbx: * type: integer * nullable: true @@ -482,7 +483,7 @@ * DeliveryFileInput: * type: object * required: [mimetype, name, content] - * description: Attachment input. Only application/pdf and text/plain are accepted. + * description: Attachment input. Only application/pdf and text/plain are accepted. Thumbnails are generated server-side and must not be supplied by the client. * properties: * mimetype: * type: string diff --git a/src/api/routes/delivery.js b/src/api/routes/delivery.js index bb6ef6c..073dbb6 100644 --- a/src/api/routes/delivery.js +++ b/src/api/routes/delivery.js @@ -1,5 +1,78 @@ +import multer from 'multer'; import * as db from '../../db/delivery.js'; +// Shared memory storage for delivery attachments (max 50 MB) +const multerUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 50 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const name = file.originalname?.toLowerCase() || ''; + const isPdf = file.mimetype === 'application/pdf' || name.endsWith('.pdf'); + const isText = file.mimetype.startsWith('text/') || /\.(txt|text|md|csv)$/.test(name); + if (isPdf || isText) { + cb(null, true); + } else { + cb(new Error('Only PDF or text files are accepted as attachments')); + } + }, +}).array('files'); + +function uploadDeliveryFiles(req, res, next) { + multerUpload(req, res, (err) => { + if (err) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(413).json({ error: 'File too large (max 50 MB)' }); + } + return res.status(400).json({ error: err.message }); + } + next(); + }); +} + +/** + * Helper to normalize payload from either multipart/form-data or application/json + */ +function parseDeliveryPayload(req) { + let payload = req.body ?? {}; + // If sent as multipart/form-data with a JSON `data` field: + if (typeof payload.data === 'string') { + try { + payload = JSON.parse(payload.data); + } catch { + throw new Error('Invalid JSON in "data" field'); + } + } + if (typeof payload !== 'object' || payload === null) { + throw new Error('Invalid delivery'); + } + + const { externalNumber, date, supplierId, lines } = payload; + let files = payload.files; + // If binary files were uploaded via multer: + if (Array.isArray(req.files) && req.files.length > 0) { + const filesMeta = Array.isArray(payload.filesMeta) + ? payload.filesMeta + : (Array.isArray(payload.files) ? payload.files : []); + const metaByName = new Map( + filesMeta.filter((m) => m?.name).map((m) => [m.name, m]) + ); + files = req.files.map((f, idx) => { + const metaObj = metaByName.get(f.originalname) || filesMeta[idx] || {}; + const name = f.originalname || ''; + const isPdf = f.mimetype === 'application/pdf' || name.toLowerCase().endsWith('.pdf'); + const mimetype = isPdf ? 'application/pdf' : 'text/plain'; + return { + mimetype, + name: f.originalname, + content: f.buffer, // Raw Buffer directly accepted by libdashstore/attachments.js + size: f.size, + meta: metaObj.meta ?? null, + }; + }); + } + return { payload, externalNumber, date, supplierId, lines, files }; +} + function requireSelectedCompany(req) { const companyId = req.user.selectedCompanyId; if (companyId == null) { @@ -17,6 +90,9 @@ function deliveryErrorStatus(error) { if (error.message === 'Article not found') return 404; if (error.message === 'Invalid delivery') return 400; if (error.message === 'Invalid lines') return 400; + if (error.message === 'Invalid file') return 400; + if (error.message === 'Invalid JSON in "data" field') return 400; + if (error.message === 'Only PDF or text files are accepted as attachments') return 400; return 500; } @@ -112,6 +188,7 @@ export function register(app, basePath = '') { * description: | * Attached documents (pdf or txt) stored with the delivery * and returned in `files` on every delivery response. + * Thumbnails (AVIF) are generated server-side. * items: * $ref: '#/components/schemas/DeliveryFileInput' * example: @@ -129,6 +206,21 @@ export function register(app, basePath = '') { * - mimetype: application/pdf * name: delivery-note.pdf * content: '' + * multipart/form-data: + * schema: + * type: object + * required: [data] + * properties: + * data: + * type: string + * description: | + * JSON string containing manifest header and line items ({ externalNumber, date, supplierId, lines, filesMeta? }). + * files: + * type: array + * items: + * type: string + * format: binary + * description: Raw binary PDF or text files attached natively via standard HTTP multipart upload. * responses: * 201: * description: Created delivery @@ -180,15 +272,14 @@ export function register(app, basePath = '') { } }); - app.post(`${basePath}/delivery`, async (req, res) => { + app.post(`${basePath}/delivery`, uploadDeliveryFiles, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } - const { externalNumber, date, supplierId, lines, files } = req.body ?? {}; - try { + const { externalNumber, date, supplierId, lines, files } = parseDeliveryPayload(req); const delivery = await db.create(req.user.id, { externalNumber, date, @@ -297,11 +388,26 @@ export function register(app, basePath = '') { * description: | * Appends attachments (pdf or txt) to the delivery. * Omit to leave existing files unchanged. + * Thumbnails (AVIF) are generated server-side. * items: * $ref: '#/components/schemas/DeliveryFileInput' * example: * externalNumber: DHL-124 * lines: [] + * multipart/form-data: + * schema: + * type: object + * properties: + * data: + * type: string + * description: | + * JSON string containing patch fields ({ externalNumber, date, supplierId, lines, filesMeta? }). + * files: + * type: array + * items: + * type: string + * format: binary + * description: Raw binary PDF or text files to append to the delivery. * responses: * 200: * description: Updated delivery @@ -399,7 +505,7 @@ export function register(app, basePath = '') { } }); - app.patch(`${basePath}/delivery/:id`, async (req, res) => { + app.patch(`${basePath}/delivery/:id`, uploadDeliveryFiles, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); @@ -410,25 +516,25 @@ export function register(app, basePath = '') { return res.status(400).json({ error: 'id is required' }); } - const body = req.body ?? {}; - const patch = {}; - if (Object.prototype.hasOwnProperty.call(body, 'externalNumber')) { - patch.externalNumber = body.externalNumber; - } - if (Object.prototype.hasOwnProperty.call(body, 'date')) { - patch.date = body.date; - } - if (Object.prototype.hasOwnProperty.call(body, 'supplierId')) { - patch.supplierId = body.supplierId; - } - if (Object.prototype.hasOwnProperty.call(body, 'lines')) { - patch.lines = body.lines; - } - if (Object.prototype.hasOwnProperty.call(body, 'files')) { - patch.files = body.files; - } - try { + const parsed = parseDeliveryPayload(req); + const patch = {}; + if (Object.prototype.hasOwnProperty.call(parsed.payload, 'externalNumber')) { + patch.externalNumber = parsed.externalNumber; + } + if (Object.prototype.hasOwnProperty.call(parsed.payload, 'date')) { + patch.date = parsed.date; + } + if (Object.prototype.hasOwnProperty.call(parsed.payload, 'supplierId')) { + patch.supplierId = parsed.supplierId; + } + if (Object.prototype.hasOwnProperty.call(parsed.payload, 'lines')) { + patch.lines = parsed.lines; + } + if (parsed.files !== undefined) { + patch.files = parsed.files; + } + const delivery = await db.update(req.user.id, id, patch); res.status(200).json({ delivery }); } catch (error) { diff --git a/src/config/env.js b/src/config/env.js index ba922b9..9bfbe79 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -7,6 +7,7 @@ dotenv.config({ path: resolve(__dirname, '../../.env'), quiet: true }); export default { port: Number(process.env.PORT) || 4208, + bodyLimit: process.env.BODY_LIMIT || '100mb', corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173') .split(',') .map((s) => s.trim())