multipart

This commit is contained in:
seb
2026-09-18 07:19:20 +02:00
parent 8c19388901
commit c2147de284
9 changed files with 224 additions and 47 deletions

View File

@@ -1,5 +1,6 @@
# Server # Server
PORT=4208 PORT=4208
BODY_LIMIT=100mb
# CORS — comma-separated allowed origins # CORS — comma-separated allowed origins
CORS_ORIGINS=http://localhost:5173 CORS_ORIGINS=http://localhost:5173

View File

@@ -4480,7 +4480,7 @@
}, },
"files": { "files": {
"type": "array", "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": { "items": {
"$ref": "#/components/schemas/DeliveryFileInput" "$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": { "files": {
"type": "array", "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": { "items": {
"$ref": "#/components/schemas/DeliveryFileInput" "$ref": "#/components/schemas/DeliveryFileInput"
} }
@@ -4713,6 +4735,25 @@
"lines": [] "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": { "DeliveryFile": {
"type": "object", "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": [ "required": [
"id", "id",
"manifestId", "manifestId",
@@ -10097,7 +10138,8 @@
"thumbavif": { "thumbavif": {
"type": "string", "type": "string",
"format": "byte", "format": "byte",
"nullable": true "nullable": true,
"description": "Base64-encoded AVIF thumbnail generated server-side (null for text files)"
}, },
"thumbx": { "thumbx": {
"type": "integer", "type": "integer",
@@ -10124,7 +10166,7 @@
"name", "name",
"content" "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": { "properties": {
"mimetype": { "mimetype": {
"type": "string", "type": "string",

View File

@@ -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` ## Response `201`
Created delivery Created delivery

View File

@@ -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` ## Response `200`
Updated delivery Updated delivery

View File

@@ -153,7 +153,6 @@ function renderRequestBody(requestBody) {
lines.push(''); lines.push('');
lines.push(formatSchema(jsonSchema, spec.components)); lines.push(formatSchema(jsonSchema, spec.components));
lines.push(''); lines.push('');
return lines;
} }
// multipart/form-data — render fields as a table when properties are present // 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('| Field | Required | Type | Description |');
lines.push('|-------|----------|------|-------------|'); lines.push('|-------|----------|------|-------------|');
for (const [name, prop] of Object.entries(schema.properties)) { 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(`| \`${name}\` | ${required.has(name) ? 'yes' : 'no'} | ${type} | ${prop.description || ''} |`);
} }
lines.push(''); lines.push('');
return lines;
} }
lines.push('## Body'); if (!jsonSchema && !multipart?.schema?.properties) {
lines.push(''); lines.push('## Body');
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(''); 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) { function docsFileFor(apiPath, method) {

View File

@@ -65,7 +65,7 @@ async function createApi() {
next(); next();
}); });
app.use(corsMiddleware); app.use(corsMiddleware);
app.use(express.json()); app.use(express.json({ limit: env.bodyLimit }));
app.use(cookieParser()); app.use(cookieParser());
app.use(loggerMiddleware); app.use(loggerMiddleware);
app.use(authMiddleware); app.use(authMiddleware);
@@ -79,6 +79,16 @@ async function createApi() {
await loadRoutes(app, '/api'); 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; return app;
} }

View File

@@ -435,7 +435,7 @@
* $ref: '#/components/schemas/DeliveryFile' * $ref: '#/components/schemas/DeliveryFile'
* DeliveryFile: * DeliveryFile:
* type: object * 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] * required: [id, manifestId, fileId, sort, hash, mimetype, name, size]
* properties: * properties:
* id: * id:
@@ -467,6 +467,7 @@
* type: string * type: string
* format: byte * format: byte
* nullable: true * nullable: true
* description: Base64-encoded AVIF thumbnail generated server-side (null for text files)
* thumbx: * thumbx:
* type: integer * type: integer
* nullable: true * nullable: true
@@ -482,7 +483,7 @@
* DeliveryFileInput: * DeliveryFileInput:
* type: object * type: object
* required: [mimetype, name, content] * 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: * properties:
* mimetype: * mimetype:
* type: string * type: string

View File

@@ -1,5 +1,78 @@
import multer from 'multer';
import * as db from '../../db/delivery.js'; 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) { function requireSelectedCompany(req) {
const companyId = req.user.selectedCompanyId; const companyId = req.user.selectedCompanyId;
if (companyId == null) { if (companyId == null) {
@@ -17,6 +90,9 @@ function deliveryErrorStatus(error) {
if (error.message === 'Article not found') return 404; if (error.message === 'Article not found') return 404;
if (error.message === 'Invalid delivery') return 400; if (error.message === 'Invalid delivery') return 400;
if (error.message === 'Invalid lines') 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; return 500;
} }
@@ -112,6 +188,7 @@ export function register(app, basePath = '') {
* description: | * description: |
* Attached documents (pdf or txt) stored with the delivery * Attached documents (pdf or txt) stored with the delivery
* and returned in `files` on every delivery response. * and returned in `files` on every delivery response.
* Thumbnails (AVIF) are generated server-side.
* items: * items:
* $ref: '#/components/schemas/DeliveryFileInput' * $ref: '#/components/schemas/DeliveryFileInput'
* example: * example:
@@ -129,6 +206,21 @@ export function register(app, basePath = '') {
* - mimetype: application/pdf * - mimetype: application/pdf
* name: delivery-note.pdf * name: delivery-note.pdf
* content: '<base64 encoded content>' * content: '<base64 encoded 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: * responses:
* 201: * 201:
* description: Created delivery * 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); const selected = requireSelectedCompany(req);
if (selected.error) { if (selected.error) {
return res.status(400).json({ error: selected.error }); return res.status(400).json({ error: selected.error });
} }
const { externalNumber, date, supplierId, lines, files } = req.body ?? {};
try { try {
const { externalNumber, date, supplierId, lines, files } = parseDeliveryPayload(req);
const delivery = await db.create(req.user.id, { const delivery = await db.create(req.user.id, {
externalNumber, externalNumber,
date, date,
@@ -297,11 +388,26 @@ export function register(app, basePath = '') {
* description: | * description: |
* Appends attachments (pdf or txt) to the delivery. * Appends attachments (pdf or txt) to the delivery.
* Omit to leave existing files unchanged. * Omit to leave existing files unchanged.
* Thumbnails (AVIF) are generated server-side.
* items: * items:
* $ref: '#/components/schemas/DeliveryFileInput' * $ref: '#/components/schemas/DeliveryFileInput'
* example: * example:
* externalNumber: DHL-124 * externalNumber: DHL-124
* lines: [] * 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: * responses:
* 200: * 200:
* description: Updated delivery * 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); const selected = requireSelectedCompany(req);
if (selected.error) { if (selected.error) {
return res.status(400).json({ error: 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' }); 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 { 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); const delivery = await db.update(req.user.id, id, patch);
res.status(200).json({ delivery }); res.status(200).json({ delivery });
} catch (error) { } catch (error) {

View File

@@ -7,6 +7,7 @@ dotenv.config({ path: resolve(__dirname, '../../.env'), quiet: true });
export default { export default {
port: Number(process.env.PORT) || 4208, port: Number(process.env.PORT) || 4208,
bodyLimit: process.env.BODY_LIMIT || '100mb',
corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173') corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173')
.split(',') .split(',')
.map((s) => s.trim()) .map((s) => s.trim())