Skip to content

Make uploaded licence and incident files retrievable and persistent (BE 028) - #495

Open
Houda135 wants to merge 2 commits into
mainfrom
Houda135/feature/uploads-retrieval-persistence
Open

Make uploaded licence and incident files retrievable and persistent (BE 028)#495
Houda135 wants to merge 2 commits into
mainfrom
Houda135/feature/uploads-retrieval-persistence

Conversation

@Houda135

Copy link
Copy Markdown
Collaborator

Summary

Implements BE 028, Make Uploaded Licence and Incident Files Retrievable and Persistent.

Uploaded files were written to disk but there was no way to fetch them back, and they were lost whenever the backend container was rebuilt. One upload path also had no size limit at all.

All seven checklist items on the ticket are covered.

What changed

Persistence. Added a named docker volume for the uploads directory so files survive container rebuilds. Previously the container filesystem was discarded on rebuild and every uploaded file went with it.

Retrieval. Added GET /api/v1/documents/:id/file, which returns the file behind a document record, for example a guard's licence image.

Safe path handling. Stored file references are resolved through utils/uploadPath.js, which keeps the final path inside the uploads directory. A stored value containing ../ cannot reach other files on the server.

Incident file URLs. Uploading an incident attachment returned fileUrl: /uploads/<filename>, which nothing serves, so the URL always failed. It now points at GET /api/v1/incidents/{id}/attachments/{attachmentId}, which is the route that actually serves the file.

Upload limits. The EOI upload had no size limit and buffered whole files in memory. It is now capped at 25MB to match the disk uploads, and checks the content type as well as the file extension, since an extension check alone passes anything renamed to .pdf. The limit now lives in one exported constant so the two upload paths cannot drift apart again.

Error codes. Upload rejections were reaching the global handler and being reported as 500. They now return 400 with the reason, including the size limit in the message.

Access rules

As agreed with @LoopyB, least privilege:

Caller Licence file
The guard it belongs to allowed
Admin allowed
Anyone else 403

Status codes are 401 unauthenticated, 403 authenticated but not permitted, 404 for a missing record or a record whose file is gone.

Note this is deliberately different from the availability endpoints in #449, which return 404 rather than 403 to avoid confirming a record exists. 403 here follows the spec agreed for this ticket rather than that earlier pattern.

Decisions worth recording

Employer licence access is out of scope, as agreed. There is no reliable way to establish a guard to employer relationship in the current model. Shift offers applicants, acceptedBy and guardIds, which mean applied for, accepted, and assigned. Those are three different things, and choosing which one grants access to somebody's licence document is an access policy decision rather than an implementation detail. Employer access to incident attachments is kept, because incident.shiftId to Shift.createdBy is an explicit single relationship and the existing route already enforces it.

Guard registration Swagger discrepancy. The documentation said images only with a 5MB limit. The code accepts images, PDF, video and audio up to 25MB. On @LoopyB's direction I aligned the documentation to the implemented behaviour rather than tightening the code, since restricting the accepted types or size would be a behavioural change and belongs in its own ticket. The Swagger now describes what the endpoint actually does, and says as much.

Found while working on this

Three things outside the strict scope of the ticket. Happy to split any of them out if you would rather.

1. A query bug that had broken two existing endpoints. getDocumentById and the expiry update both ran User.findOne({ "documents._id": docId }) with a string. documents is declared on the Guard discriminator rather than the base User schema, so Mongoose cannot resolve the path to cast it and the query silently matched nothing. Both endpoints returned "Document not found" for documents that exist. Confirmed directly in the database, where the query matches by ObjectId and returns zero by string. Fixed with an explicit cast, and GET /documents/admin/documents/:id now returns real data instead of 404.

2. The EOI upload had no size limit, covered above. Included here because it was a memory risk rather than a correctness one.

3. Seeded role permissions are a stale subset of the code defaults. Not fixed in this PR, flagging only. The Role documents in the database carry far fewer permissions than DEFAULT_ROLE_PERMISSIONS in middleware/rbac.js:

DB   admin -> user:read, user:write, shift:read, shift:write, shift:assign
DB   guard -> shift:read, shift:accept, shift:checkin

code admin -> also incident:create / view / update / delete
code guard -> also incident:create / view / update

authorizePermissions reads the database row first and only falls back to the code map when no row exists, so the smaller list wins. The effect is that in a seeded environment nobody except super_admin can use an incident permission gated route, including uploading an incident attachment. I hit this while testing. It looks like the same class of role definition drift already noted in the architecture doc.

Testing

Unit tests. 16 new tests in tests/documentFile.controller.test.js, covering path resolution and every access rule.

Upload path resolution
  resolves a bare filename inside the uploads directory
  resolves a stored /uploads/ reference to the same place
  refuses a path traversal attempt
  returns null for values that cannot be a filename
  uploadExists is false when the file is not on disk
downloadDocumentFile
  401 when there is no authenticated user
  404 when the document id is not a valid ObjectId
  404 when no document matches the id
  looks the document up by ObjectId, not by string
  403 when the document belongs to another user
  200 and sends the file to the owner
  200 and sends the file to an admin for someone else's document
  403 for an employer, licence access is out of scope for BE 028
  404 when the record exists but its file is missing from disk
  404 when the document record has no file reference at all
  a traversal value in the database cannot reach outside the uploads folder

Tests: 16 passed, 16 total

Full suite compared against main with and without this branch, to confirm nothing regressed:

main alone:   11 failed suites, 22 passed, 221 tests passed
this branch:  11 failed suites, 23 passed, 237 tests passed

Same eleven pre-existing suite failures on both, plus this branch's suite and its 16 tests. Lint and format:check are clean.

Live checks against the running stack, authenticated as the seeded guard, a second guard, and an admin:

owner fetches own licence      200 + file contents
different guard                403
admin fetches it               200 + file contents
no token                       401
document does not exist        404
record exists, file deleted    404
imageUrl set to ../../etc/passwd   404, nothing served

Incident URL, end to end. Uploaded an attachment, took the returned URL from the response and requested it:

fileUrl : /api/v1/incidents/6a7c4028.../attachments/6a7c40a9...
GET that URL -> 200, file contents returned

Persistence. Wrote a file into the uploads volume, destroyed the backend container with docker compose rm -sf backend, recreated it, and the file was still there.

Notes

  • Rebased onto current main. There was a conflict in config/multer.js because the uploads directory was moved to <backend root>/uploads and the import.meta.url usage removed. I took that location and kept it in one shared module so writing and reading cannot drift apart. All checks above were re-run after the rebase.
  • No existing behaviour was changed other than the two fixes described, and the incident fileUrl value, which previously pointed at a path nothing serves and is not read by either client.

…BE 028)

Uploaded files were written to disk but could not be fetched back, and were
lost whenever the backend container was rebuilt. One upload path also had no
size limit at all.

- Add a named docker volume for app-backend/src/uploads so files survive
  container rebuilds.
- Add GET /api/v1/documents/:id/file to retrieve a document's file. A guard
  may fetch their own, an admin may fetch anyone's. Employer licence access is
  out of scope, see the PR notes.
- Resolve stored file references through utils/uploadPath.js, which keeps the
  final path inside the uploads directory so a stored "../" value cannot reach
  other files on the server.
- Return 401 unauthenticated, 403 authenticated but not permitted, 404 for a
  missing record or a record whose file is gone, as agreed with the Backend Lead.
- Cap the EOI upload at 25MB, matching the disk uploads. It had no limit and
  buffered whole files in memory. Also check the content type as well as the
  file extension.
- Map upload rejections to 400 instead of 500, with the size limit in the
  message.
- Point incident attachment fileUrl at the route that serves the file. It
  pointed at /uploads/<filename>, which nothing serves.
- Fix a query that passed a string where an ObjectId was required. "documents"
  is on the Guard discriminator, not the base User schema, so Mongoose could
  not cast it and the lookup never matched. This also unbroke two existing
  admin endpoints.
- Align the guard registration Swagger with the implemented behaviour (25MB,
  images plus PDF, video and audio) rather than tightening the code.
- Add 16 tests covering path resolution, authorisation, missing files and
  traversal attempts.
@Houda135
Houda135 requested a review from LoopyB August 12, 2026 14:07
- Mount handleUploadError on the incident attachment upload route. A rejected
  file type already returned 400 because the filter sets a status, but an
  oversized file surfaced as a MulterError with no status and reached the
  global handler as a 500. Both now return 400 with the reason.
- Rework the test file to match how the rest of the suite mocks. It was the
  only test mocking node built-ins, and the partial fs mock would break if the
  import chain ever pulled in a module that touches the filesystem at load
  time. It now mocks the project's own uploadsDir module to point at a temp
  folder and uses the real filesystem.
- Add four cases for handleUploadError: an oversized file returns 400 stating
  the limit, any other multer error returns 400 rather than 500, a rejected
  file type carries its reason, and an unrelated failure is passed on instead
  of being reported as a bad request.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant