From 37a676bb505ece9963135b39fbe2da60bb613350 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 19 Sep 2026 06:34:11 +0200 Subject: [PATCH] file api --- docs/api/README.md | 1 + docs/api/openapi.json | 139 +++++++++++++++++++++++++++- docs/api/routes/delivery/file.md | 40 +++++++++ src/api/openapi.js | 2 +- src/api/routes/delivery.js | 149 +++++++++++++++++++++++++++++++ src/db/delivery.js | 5 ++ 6 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 docs/api/routes/delivery/file.md diff --git a/docs/api/README.md b/docs/api/README.md index b674f0a..b684550 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -84,6 +84,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/delivery/delete.md`](routes/delivery/delete.md) | `DELETE /delivery/{id}` | | [`routes/delivery/get.md`](routes/delivery/get.md) | `GET /delivery/{id}` | | [`routes/delivery/update.md`](routes/delivery/update.md) | `PATCH /delivery/{id}` | +| [`routes/delivery/file.md`](routes/delivery/file.md) | `GET /delivery/{id}/file/{fileId}` | | [`routes/entity/list.md`](routes/entity/list.md) | `GET /entity` | | [`routes/entity/create.md`](routes/entity/create.md) | `POST /entity` | | [`routes/entity/next-number.md`](routes/entity/next-number.md) | `GET /entity/next-number` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index f920c0f..eef12c7 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -4884,6 +4884,143 @@ } } }, + "/delivery/{id}/file/{fileId}": { + "get": { + "tags": [ + "Delivery" + ], + "summary": "Get delivery attachment file content", + "description": "Returns the full binary content of an attached file (e.g. PDF or text document)\nfor the specified delivery. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified.\n", + "security": [ + { + "cookieAuth": [] + } + ], + "x-markdown": "delivery/file.md", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Delivery (manifest) ID" + }, + { + "in": "path", + "name": "fileId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "File ID or attachment ID" + }, + { + "in": "query", + "name": "download", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "When true, sets Content-Disposition to attachment instead of inline" + } + ], + "responses": { + "200": { + "description": "Full binary file content", + "headers": { + "Content-Type": { + "schema": { + "type": "string", + "example": "application/pdf" + } + }, + "Content-Disposition": { + "schema": { + "type": "string", + "example": "inline; filename=\"document.pdf\"" + } + }, + "ETag": { + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "schema": { + "type": "string", + "example": "private, max-age=86400" + } + } + }, + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "text/plain": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "304": { + "description": "Not modified (If-None-Match matched ETag)" + }, + "400": { + "description": "Invalid id, invalid fileId, or no company selected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not authorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Delivery or file not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/customergroup": { "get": { "tags": [ @@ -10088,7 +10225,7 @@ }, "DeliveryFile": { "type": "object", - "description": "Attachment metadata (binary content is served via the attachment content endpoints). Thumbnails (AVIF) are generated server-side.", + "description": "Attachment metadata (full binary content is served via `GET /delivery/{id}/file/{fileId}`). Thumbnails (AVIF) are generated server-side.", "required": [ "id", "manifestId", diff --git a/docs/api/routes/delivery/file.md b/docs/api/routes/delivery/file.md new file mode 100644 index 0000000..dacc46f --- /dev/null +++ b/docs/api/routes/delivery/file.md @@ -0,0 +1,40 @@ +# `GET /delivery/{id}/file/{fileId}` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Get delivery attachment file content + +Returns the full binary content of an attached file (e.g. PDF or text document) +for the specified delivery. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified. + + +## Path parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `id` | yes | integer | Delivery (manifest) ID | +| `fileId` | yes | integer | File ID or attachment ID | + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `download` | no | boolean | When true, sets Content-Disposition to attachment instead of inline | + +## Response `200` + +Full binary file content + +## Errors + +| Status | Description | +|--------|-------------| +| 304 | Not modified (If-None-Match matched ETag) | +| 400 | Invalid id, invalid fileId, or no company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Delivery or file not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/src/api/openapi.js b/src/api/openapi.js index f575416..520c511 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). Thumbnails (AVIF) are generated server-side. + * description: Attachment metadata (full binary content is served via `GET /delivery/{id}/file/{fileId}`). Thumbnails (AVIF) are generated server-side. * required: [id, manifestId, fileId, sort, hash, mimetype, name, size] * properties: * id: diff --git a/src/api/routes/delivery.js b/src/api/routes/delivery.js index c26808a..e3f6169 100644 --- a/src/api/routes/delivery.js +++ b/src/api/routes/delivery.js @@ -110,6 +110,9 @@ function deliveryErrorStatus(error) { if (error.message === 'Not authorized') return 403; if (error.message === 'Company not found') return 404; if (error.message === 'Delivery not found') return 404; + if (error.message === 'Manifest not found') return 404; + if (error.message === 'Attachment not found') return 404; + if (error.message === 'File not found') return 404; if (error.message === 'Supplier not found') return 404; if (error.message === 'Article not found') return 404; if (error.message === 'Invalid delivery') return 400; @@ -588,4 +591,150 @@ export function register(app, basePath = '') { res.status(status).json({ error: error.message }); } }); + + /** + * @openapi + * /delivery/{id}/file/{fileId}: + * get: + * tags: [Delivery] + * summary: Get delivery attachment file content + * description: | + * Returns the full binary content of an attached file (e.g. PDF or text document) + * for the specified delivery. Supports HTTP caching via ETag (SHA-256 hash) and 304 Not Modified. + * security: + * - cookieAuth: [] + * x-markdown: delivery/file.md + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: Delivery (manifest) ID + * - in: path + * name: fileId + * required: true + * schema: + * type: integer + * description: File ID or attachment ID + * - in: query + * name: download + * required: false + * schema: + * type: boolean + * description: When true, sets Content-Disposition to attachment instead of inline + * responses: + * 200: + * description: Full binary file content + * headers: + * Content-Type: + * schema: + * type: string + * example: application/pdf + * Content-Disposition: + * schema: + * type: string + * example: inline; filename="document.pdf" + * ETag: + * schema: + * type: string + * Cache-Control: + * schema: + * type: string + * example: private, max-age=86400 + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * text/plain: + * schema: + * type: string + * format: binary + * application/octet-stream: + * schema: + * type: string + * format: binary + * 304: + * description: Not modified (If-None-Match matched ETag) + * 400: + * description: Invalid id, invalid fileId, or no company selected + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Delivery or file not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + const handleGetDeliveryFile = async (req, res) => { + const selected = requireSelectedCompany(req); + if (selected.error) { + return res.status(400).json({ error: selected.error }); + } + + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'id is required' }); + } + + const fileId = Number(req.params.fileId); + if (!Number.isInteger(fileId) || fileId <= 0) { + return res.status(400).json({ error: 'fileId is required' }); + } + + try { + const attachment = await db.getFile(req.user.id, id, fileId); + + if (attachment.hash) { + const etag = `"${attachment.hash}"`; + res.setHeader('ETag', etag); + const ifNoneMatch = req.headers['if-none-match']; + if (ifNoneMatch === etag || ifNoneMatch === attachment.hash) { + return res.status(304).end(); + } + } + + const isDownload = req.query.download === '1' || req.query.download === 'true'; + const dispositionType = isDownload ? 'attachment' : 'inline'; + const filename = attachment.name || `file-${attachment.fileId}`; + const asciiName = filename.replace(/["\r\n\\]/g, '_'); + const encodedName = encodeURIComponent(filename).replace(/['()]/g, escape); + + res.setHeader('Content-Type', attachment.mimetype || 'application/octet-stream'); + res.setHeader('Content-Disposition', `${dispositionType}; filename="${asciiName}"; filename*=UTF-8''${encodedName}`); + res.setHeader('Cache-Control', 'private, max-age=86400'); + + if (attachment.content && Buffer.isBuffer(attachment.content)) { + res.setHeader('Content-Length', String(attachment.content.length)); + return res.status(200).send(attachment.content); + } else { + const buffer = Buffer.from(attachment.content || ''); + res.setHeader('Content-Length', String(buffer.length)); + return res.status(200).send(buffer); + } + } catch (error) { + const status = deliveryErrorStatus(error); + if (status === 500) console.error('Get delivery file failed:', error); + res.status(status).json({ error: error.message }); + } + }; + + app.get(`${basePath}/delivery/:id/file/:fileId`, handleGetDeliveryFile); + app.get(`${basePath}/delivery/:id/files/:fileId`, handleGetDeliveryFile); } diff --git a/src/db/delivery.js b/src/db/delivery.js index a8f2b15..1061c91 100644 --- a/src/db/delivery.js +++ b/src/db/delivery.js @@ -24,3 +24,8 @@ export async function remove(googleAuthId, id) { const store = await getStore(); return store.delivery.delete(googleAuthId, id); } + +export async function getFile(googleAuthId, deliveryId, fileId) { + const store = await getStore(); + return store.manifestattachment.getById(googleAuthId, deliveryId, fileId); +}