feat(#946): passport scan-image upload (single-slot on contacts.*, preserves #906)#948
Conversation
…eserves #906) Let agents (admin contact record) and clients (portal) attach a passport SCAN IMAGE to a contact's passport — as an optional single-slot asset ON the passport field-data, NOT a passport-typed contact_documents row (preserves the #906 invariant; the portal still rejects documentType=passport). Data: migration adds nullable contacts.passport_image_key (R2 storage key — sign on read, mirrors contact_documents.file_url; also the stable handle #947 OCR fetches by) + passport_image_uploaded_at. Replay-clean; staging-rehearsed. Backend (reuses #906 StorageService plumbing — uploadDocument/getSignedUrl/ deleteDocument; null-safe throughout): - Admin: ContactDocumentsService.{setPassportImage,getPassportImage, deletePassportImage} + ContactPassportImageController (GET/POST/DELETE /contacts/:id/passport-image, sensitive-access guarded). - Portal: PortalService.{upload,get,delete}PortalPassportImage + GET/POST/DELETE /portal/me/passport-image (own contact). - Single slot: replace uploads-new-first then deletes the prior key; delete is null-safe; getSignedUrl never called on a null key. UI (signed-URL view, replace, delete, mobile camera capture=environment): - Admin Passport Manager card (#943): <PassportScan> + use-contacts hooks. - Portal Passport & Travel ID (#906): <PassportScan> + use-portal-documents hooks. Spec: contact-passport-image.service.spec — upload/sign/replace + read/delete null-guards (6/6). All apps tsc clean. fixes #946 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughAdds a single-slot passport scan image field to contacts. Two new ChangesPassport Scan Image Feature
Sequence Diagram(s)sequenceDiagram
actor User
participant PassportScan as PassportScan (UI)
participant Hook as useUploadPassportImage
participant Controller as ContactPassportImageController / PortalController
participant Service as ContactDocumentsService / PortalService
participant R2 as StorageService (R2)
participant DB as contacts table
User->>PassportScan: selects file (≤10MB, allowed MIME)
PassportScan->>Hook: mutateAsync(file)
Hook->>Controller: POST /passport-image (multipart)
Controller->>Controller: verifySensitiveAccess / PortalAuthGuard
Controller->>Service: setPassportImage / uploadPortalPassportImage
Service->>R2: uploadDocument(buffer, key)
R2-->>Service: storageKey
Service->>DB: UPDATE contacts SET passport_image_key, passport_image_uploaded_at
alt DB fails
Service->>R2: deleteDocument(newKey) [rollback]
else DB succeeds & prior key existed
Service->>R2: deleteDocument(oldKey) [best-effort]
end
Service->>R2: getSignedUrl(storageKey)
R2-->>Service: signedUrl
Service-->>Controller: { url, uploadedAt }
Controller-->>Hook: 200 { url, uploadedAt }
Hook->>Hook: invalidate passport-image query
PassportScan->>User: renders signed URL link + Replace/Delete controls
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
apps/api/src/contacts/contact-documents.service.tsOops! Something went wrong! :( ESLint: 8.57.1 Error: ESLint configuration in --config is invalid:
apps/api/src/contacts/contact-passport-image.controller.tsOops! Something went wrong! :( ESLint: 8.57.1 Error: ESLint configuration in --config is invalid:
apps/api/src/contacts/contact-passport-image.service.spec.tsOops! Something went wrong! :( ESLint: 8.57.1 Error: ESLint configuration in --config is invalid:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
apps/api/src/contacts/contact-passport-image.service.spec.ts (1)
66-111: ⚡ Quick winAdd a rollback regression test for DB update failure after upload.
Please add a case that forces the contact update to throw after
uploadDocumentsucceeds, then assertsdeleteDocument(newKey)is called. This protects the orphan-cleanup contract from regressions.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/src/contacts/contact-passport-image.service.spec.ts` around lines 66 - 111, Add a new test case within the test suite that verifies rollback behavior when the database update fails after a successful upload in setPassportImage. The test should mock the contact.set() call to throw an error after storage.uploadDocument succeeds, then assert that storage.deleteDocument is called with the newly uploaded key (e.g., 'contacts/c1/passport/123-scan.png'). This ensures the orphan-cleanup contract is protected: if the DB update fails, the freshly uploaded document is cleaned up to prevent orphaned files in storage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/admin/src/app/contacts/`[id]/_components/passport-scan.tsx:
- Around line 48-54: In both the handleFile and handleDelete functions, replace
the rethrow of non-ApiError exceptions with a fallback toast notification.
Instead of throwing unknown errors, catch all errors that are not ApiError
instances, display a generic destructive toast with a message like "An error
occurred", and return gracefully to prevent unhandled promise rejections in the
UI. Apply this same pattern to both the error handler shown in the diff and the
second occurrence in the handleDelete function.
- Around line 41-44: The file size validation in the passport-scan.tsx component
at lines 41-44 needs to be enhanced with MIME type validation that matches the
backend allowlist. Currently, the MIME type check at lines 77-79 allows all
image types with `image/*`, but the API only accepts PDF, JPEG, PNG, GIF, and
WebP. Add a validation check that verifies the file's MIME type against this
specific allowlist before upload, placing it alongside the size check so users
receive immediate feedback on invalid formats rather than guaranteed server-side
failures. This validation should reject files like HEIC and SVG at the
client-side with a clear error message.
In `@apps/api/src/contacts/contact-passport-image.controller.ts`:
- Around line 60-70: The FileInterceptor decorator on the upload method is
missing multer limits configuration, allowing large files to be fully buffered
in memory before the size validation check runs. Add a limits configuration
object to the FileInterceptor call that specifies the maximum file size limit
(using MAX_FILE_SIZE) to ensure files exceeding the limit are rejected by multer
before they are fully buffered in memory, preventing potential DoS attacks
through memory exhaustion.
In `@apps/api/src/portal/portal.controller.ts`:
- Around line 130-141: The FileInterceptor decorator on the uploadPassportImage
method needs to be configured with fileSize limits to reject oversized uploads
at the Busboy level before buffering, rather than only validating after
multipart parsing. Update the FileInterceptor('file') decorator to include a
second parameter that specifies limits with a fileSize property set to 10 * 1024
* 1024 (matching the MaxFileSizeValidator limit). This pattern matches the
implementation already in place in media.controller.ts and
ocr-import.controller.ts and will prevent memory exhaustion from large file
uploads under load.
In `@apps/client/src/components/documents/passport-scan.tsx`:
- Around line 38-41: The passport-scan component accepts a broad image/* MIME
type range that permits files the backend rejects (such as HEIC or SVG), causing
confusing upload failures. Add a MIME type validation check in the file size
validation block (near the existing MAX_MB check at lines 38-41) to only allow
backend-supported image formats and reject unsupported types with a clear error
message. Additionally, update the accept attribute on the file input element (at
lines 67-70) from the broad image/* pattern to explicitly list only the
backend-supported MIME types (for example, image/jpeg, image/png) to prevent
users from selecting incompatible files in the first place.
---
Nitpick comments:
In `@apps/api/src/contacts/contact-passport-image.service.spec.ts`:
- Around line 66-111: Add a new test case within the test suite that verifies
rollback behavior when the database update fails after a successful upload in
setPassportImage. The test should mock the contact.set() call to throw an error
after storage.uploadDocument succeeds, then assert that storage.deleteDocument
is called with the newly uploaded key (e.g.,
'contacts/c1/passport/123-scan.png'). This ensures the orphan-cleanup contract
is protected: if the DB update fails, the freshly uploaded document is cleaned
up to prevent orphaned files in storage.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 97f668fe-e3db-47e6-b4e5-0ff4ba3537b9
📒 Files selected for processing (15)
apps/admin/src/app/contacts/[id]/_components/passport-scan.tsxapps/admin/src/app/contacts/[id]/page.tsxapps/admin/src/hooks/use-contacts.tsapps/api/src/contacts/contact-documents.service.tsapps/api/src/contacts/contact-passport-image.controller.tsapps/api/src/contacts/contact-passport-image.service.spec.tsapps/api/src/contacts/contacts.module.tsapps/api/src/portal/portal.controller.tsapps/api/src/portal/portal.service.tsapps/client/src/app/(dashboard)/travelers/page.tsxapps/client/src/components/documents/passport-scan.tsxapps/client/src/hooks/use-portal-documents.tspackages/database/src/migrations/20260617150000_946_contact_passport_image.sqlpackages/database/src/migrations/meta/_journal.jsonpackages/database/src/schema/contacts.schema.ts
| if (file.size > MAX_MB * 1024 * 1024) { | ||
| setError(`File must be under ${MAX_MB}MB`) | ||
| return | ||
| } |
There was a problem hiding this comment.
Validate MIME type client-side against the backend allowlist before upload.
Line 77-79 currently allows broad image/*, while the API accepts only PDF/JPEG/PNG/GIF/WebP. Files like HEIC/SVG become guaranteed server-side failures; pre-validating at Line 41-44 gives immediate, clearer feedback.
Proposed fix
const MAX_MB = 10
+const ALLOWED_MIME_TYPES = new Set([
+ 'application/pdf',
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ 'image/webp',
+])
@@
if (file.size > MAX_MB * 1024 * 1024) {
setError(`File must be under ${MAX_MB}MB`)
return
}
+ if (!ALLOWED_MIME_TYPES.has(file.type)) {
+ setError('Invalid file type. Allowed: PDF, JPEG, PNG, GIF, WebP')
+ return
+ }
@@
- <input ref={fileInputRef} type="file" accept="image/*,application/pdf" onChange={handleFile} className="hidden" />
+ <input ref={fileInputRef} type="file" accept="application/pdf,image/jpeg,image/png,image/gif,image/webp" onChange={handleFile} className="hidden" />
@@
- <input ref={cameraInputRef} type="file" accept="image/*" capture="environment" onChange={handleFile} className="hidden" />
+ <input ref={cameraInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" capture="environment" onChange={handleFile} className="hidden" />Also applies to: 77-79
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/admin/src/app/contacts/`[id]/_components/passport-scan.tsx around lines
41 - 44, The file size validation in the passport-scan.tsx component at lines
41-44 needs to be enhanced with MIME type validation that matches the backend
allowlist. Currently, the MIME type check at lines 77-79 allows all image types
with `image/*`, but the API only accepts PDF, JPEG, PNG, GIF, and WebP. Add a
validation check that verifies the file's MIME type against this specific
allowlist before upload, placing it alongside the size check so users receive
immediate feedback on invalid formats rather than guaranteed server-side
failures. This validation should reject files like HEIC and SVG at the
client-side with a clear error message.
| } catch (err) { | ||
| if (err instanceof ApiError) { | ||
| toast({ title: 'Upload failed', description: err.message, variant: 'destructive' }) | ||
| return | ||
| } | ||
| throw err | ||
| } |
There was a problem hiding this comment.
Avoid rethrowing unknown errors from async event handlers.
At Line 49-53 and Line 63-67, rethrowing non-ApiError values from handleFile/handleDelete can surface as unhandled promise rejections in the UI path. Prefer showing a generic destructive toast and returning.
Proposed fix
} catch (err) {
if (err instanceof ApiError) {
toast({ title: 'Upload failed', description: err.message, variant: 'destructive' })
return
}
- throw err
+ toast({
+ title: 'Upload failed',
+ description: 'Please try again.',
+ variant: 'destructive',
+ })
+ return
}
}
@@
} catch (err) {
if (err instanceof ApiError) {
toast({ title: 'Delete failed', description: err.message, variant: 'destructive' })
return
}
- throw err
+ toast({
+ title: 'Delete failed',
+ description: 'Please try again.',
+ variant: 'destructive',
+ })
+ return
}
}Also applies to: 62-68
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/admin/src/app/contacts/`[id]/_components/passport-scan.tsx around lines
48 - 54, In both the handleFile and handleDelete functions, replace the rethrow
of non-ApiError exceptions with a fallback toast notification. Instead of
throwing unknown errors, catch all errors that are not ApiError instances,
display a generic destructive toast with a message like "An error occurred", and
return gracefully to prevent unhandled promise rejections in the UI. Apply this
same pattern to both the error handler shown in the diff and the second
occurrence in the handleDelete function.
| @UseInterceptors(FileInterceptor('file')) | ||
| async upload( | ||
| @GetAuthContext() auth: AuthContext, | ||
| @Param('contactId') contactId: string, | ||
| @UploadedFile() file: Express.Multer.File, | ||
| ) { | ||
| await this.verifySensitiveAccess(contactId, auth) | ||
| if (!file) throw new BadRequestException('No file provided') | ||
| if (file.size > MAX_FILE_SIZE) { | ||
| throw new BadRequestException(`File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB`) | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, examine the actual file to verify the current implementation
wc -l apps/api/src/contacts/contact-passport-image.controller.tsRepository: Systemsaholic/tailfire
Length of output: 128
🏁 Script executed:
# Read the file around the specified lines
cat -n apps/api/src/contacts/contact-passport-image.controller.ts | head -80Repository: Systemsaholic/tailfire
Length of output: 3545
🏁 Script executed:
# Search for MAX_FILE_SIZE definition in the file
rg "MAX_FILE_SIZE" apps/api/src/contacts/contact-passport-image.controller.ts -B 2 -A 2Repository: Systemsaholic/tailfire
Length of output: 665
🏁 Script executed:
# Check for other FileInterceptor usages in the codebase to see patterns
rg "FileInterceptor" --type ts --type tsx -A 3 | head -100Repository: Systemsaholic/tailfire
Length of output: 94
🏁 Script executed:
# Search for FileInterceptor usages in the codebase with proper ts type
rg "FileInterceptor" -A 3 --type ts apps/api/src/ | head -150Repository: Systemsaholic/tailfire
Length of output: 11598
🏁 Script executed:
# Check if there are any examples of FileInterceptor with limits configuration in codebase
rg "FileInterceptor.*limits" --type ts -A 3Repository: Systemsaholic/tailfire
Length of output: 484
🏁 Script executed:
# Check NestJS documentation info and common patterns
rg "limits.*fileSize" --type tsRepository: Systemsaholic/tailfire
Length of output: 386
Add file-size limit to FileInterceptor to prevent buffering oversized uploads.
Line 60 uses FileInterceptor('file') without multer limits, allowing large files to be fully buffered in memory before the handler's size check at line 68 runs. This creates a DoS/memory exhaustion risk. Add limits to the interceptor to fail fast, matching the pattern already used in media.controller.ts and ocr-import.controller.ts.
Suggested patch
- `@UseInterceptors`(FileInterceptor('file'))
+ `@UseInterceptors`(
+ FileInterceptor('file', {
+ limits: { fileSize: MAX_FILE_SIZE },
+ }),
+ )📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @UseInterceptors(FileInterceptor('file')) | |
| async upload( | |
| @GetAuthContext() auth: AuthContext, | |
| @Param('contactId') contactId: string, | |
| @UploadedFile() file: Express.Multer.File, | |
| ) { | |
| await this.verifySensitiveAccess(contactId, auth) | |
| if (!file) throw new BadRequestException('No file provided') | |
| if (file.size > MAX_FILE_SIZE) { | |
| throw new BadRequestException(`File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB`) | |
| } | |
| `@UseInterceptors`( | |
| FileInterceptor('file', { | |
| limits: { fileSize: MAX_FILE_SIZE }, | |
| }), | |
| ) | |
| async upload( | |
| `@GetAuthContext`() auth: AuthContext, | |
| `@Param`('contactId') contactId: string, | |
| `@UploadedFile`() file: Express.Multer.File, | |
| ) { | |
| await this.verifySensitiveAccess(contactId, auth) | |
| if (!file) throw new BadRequestException('No file provided') | |
| if (file.size > MAX_FILE_SIZE) { | |
| throw new BadRequestException(`File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB`) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/api/src/contacts/contact-passport-image.controller.ts` around lines 60 -
70, The FileInterceptor decorator on the upload method is missing multer limits
configuration, allowing large files to be fully buffered in memory before the
size validation check runs. Add a limits configuration object to the
FileInterceptor call that specifies the maximum file size limit (using
MAX_FILE_SIZE) to ensure files exceeding the limit are rejected by multer before
they are fully buffered in memory, preventing potential DoS attacks through
memory exhaustion.
| @UseInterceptors(FileInterceptor('file')) | ||
| async uploadPassportImage( | ||
| @GetPortalAuth() auth: PortalAuthContext, | ||
| @UploadedFile( | ||
| new ParseFilePipe({ | ||
| validators: [ | ||
| new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }), // 10MB | ||
| new FileTypeValidator({ fileType: /(jpg|jpeg|png|gif|webp|pdf)$/ }), | ||
| ], | ||
| }), | ||
| ) | ||
| file: Express.Multer.File, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, check the actual file content at the specified lines
cat -n apps/api/src/portal/portal.controller.ts | sed -n '125,145p'Repository: Systemsaholic/tailfire
Length of output: 795
🏁 Script executed:
# Also search for other FileInterceptor usages in the codebase to see patterns
rg "FileInterceptor" apps/api/src --type ts -B 2 -A 5Repository: Systemsaholic/tailfire
Length of output: 22604
🏁 Script executed:
# Search for MAX_FILE_SIZE constant definition
rg "MAX_FILE_SIZE\s*=" apps/api/src --type tsRepository: Systemsaholic/tailfire
Length of output: 794
🏁 Script executed:
# Check if there's a configuration file for file size limits
find apps/api/src -name "*.ts" -type f | xargs grep -l "fileSize.*limit" | head -5Repository: Systemsaholic/tailfire
Length of output: 48
🌐 Web query:
NestJS FileInterceptor multer limits fileSize parsing buffering behavior
💡 Result:
In NestJS, the FileInterceptor uses the multer library, which handles file uploads via Busboy [1]. Regarding file size limits and buffering behavior, the following points are critical: Buffering Behavior Multer’s memory usage depends on the storage engine chosen: 1. MemoryStorage (Default): If no storage engine is configured, multer buffers the entire file in memory as a Buffer object [2]. This poses significant risk for large file uploads or concurrent requests, potentially causing the application to run out of memory [3]. 2. DiskStorage: If configured to use disk storage, files are streamed directly to the filesystem [2]. While this is safer for memory, the file is still processed by the server, and the request must complete (or be terminated) before the interceptor finishes its logic and passes control to your controller handler [4]. File Size Limits The fileSize limit in multer (configured within the limits object) is enforced by Busboy, which parses the incoming multipart stream [3]. - Enforcement: When a file exceeds the configured fileSize limit, Busboy emits a limit event [5][6]. Multer catches this, aborts the stream, and typically throws a MulterError (specifically LIMIT_FILE_SIZE) [6][7]. - Timing: Multer does not immediately disconnect the client as soon as the limit is hit; rather, it stops processing the file stream [5]. The browser/client may continue sending data until the request is closed or the server terminates the connection [5]. - Accuracy/Off-by-one: Historically, Multer and its underlying dependency, Busboy, have had issues where files exactly equal to the configured fileSize limit are rejected [6][8]. Recent maintainer efforts (as of 2026) have sought to address this by adjusting the limit passed to Busboy to ensure files exactly at the limit are accepted [8]. Best Practices - Prefer DiskStorage: For production applications, always use diskStorage to prevent memory exhaustion [3][9]. - Validate Early: For custom validation (like specific metadata checks), create a custom FileValidator (or Pipe) as recommended by the official NestJS documentation [10]. This allows you to reject files without processing the entire request body unnecessarily [10]. - Error Handling: Since NestJS FileInterceptor wraps the multer middleware, you must handle the error returned by the interceptor. If using an interceptor, the error is usually caught and transformed into a NestJS-compatible exception (e.g., BadRequestException) [1]. Ensure your exception filters are set up to handle these specific multer-related errors [11][7].
Citations:
- 1: https://github.com/nestjs/nest/blob/master/packages/platform-express/multer/interceptors/file.interceptor.ts
- 2: https://libraries.io/npm/nestjs-busboy
- 3: https://github.com/expressjs/multer?tab=readme-ov-file
- 4: https://stackoverflow.com/questions/78268502/nest-js-node-js-file-upload-with-multer-pass-a-file-stream-to-function-handle
- 5: File size limit cancels file upload only after all bytes are received expressjs/multer#344
- 6: multer throws "File too large" error if file size of exact "limits.fileSize" bytes provided. expressjs/multer#1348
- 7: MulterError: File too large when file size IS BELOW
fileSizeexpressjs/multer#1106 - 8: fix: accept files exactly at fileSize limit expressjs/multer#1382
- 9:
UploadFile()should acceptReadableinstead ofBuffernestjs/nest#13158 - 10: https://docs.nestjs.com/techniques/file-upload
- 11: multer options filesize limits FileInterceptor throws http status code 500 nestjs/nest#465
Add multer fileSize limits to the FileInterceptor for early rejection.
The endpoint currently validates file size through ParseFilePipe after multipart parsing. Configure FileInterceptor with limits so oversized uploads are rejected by Busboy before buffering, preventing memory exhaustion under load. This pattern is already established in media.controller.ts and ocr-import.controller.ts.
Suggested patch
- `@UseInterceptors`(FileInterceptor('file'))
+ `@UseInterceptors`(
+ FileInterceptor('file', {
+ limits: { fileSize: 10 * 1024 * 1024 },
+ }),
+ )📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @UseInterceptors(FileInterceptor('file')) | |
| async uploadPassportImage( | |
| @GetPortalAuth() auth: PortalAuthContext, | |
| @UploadedFile( | |
| new ParseFilePipe({ | |
| validators: [ | |
| new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }), // 10MB | |
| new FileTypeValidator({ fileType: /(jpg|jpeg|png|gif|webp|pdf)$/ }), | |
| ], | |
| }), | |
| ) | |
| file: Express.Multer.File, | |
| `@UseInterceptors`( | |
| FileInterceptor('file', { | |
| limits: { fileSize: 10 * 1024 * 1024 }, | |
| }), | |
| ) | |
| async uploadPassportImage( | |
| `@GetPortalAuth`() auth: PortalAuthContext, | |
| `@UploadedFile`( | |
| new ParseFilePipe({ | |
| validators: [ | |
| new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }), // 10MB | |
| new FileTypeValidator({ fileType: /(jpg|jpeg|png|gif|webp|pdf)$/ }), | |
| ], | |
| }), | |
| ) | |
| file: Express.Multer.File, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/api/src/portal/portal.controller.ts` around lines 130 - 141, The
FileInterceptor decorator on the uploadPassportImage method needs to be
configured with fileSize limits to reject oversized uploads at the Busboy level
before buffering, rather than only validating after multipart parsing. Update
the FileInterceptor('file') decorator to include a second parameter that
specifies limits with a fileSize property set to 10 * 1024 * 1024 (matching the
MaxFileSizeValidator limit). This pattern matches the implementation already in
place in media.controller.ts and ocr-import.controller.ts and will prevent
memory exhaustion from large file uploads under load.
| if (file.size > MAX_MB * 1024 * 1024) { | ||
| setError(`File must be under ${MAX_MB}MB`) | ||
| return | ||
| } |
There was a problem hiding this comment.
Restrict/validate selected file types to the backend-supported MIME set.
At Line 67-70, broad image/* permits file types the API rejects (e.g., HEIC/SVG depending on device/browser). Add a MIME check near Line 38-41 and tighten accept values to reduce failed uploads and confusing retries.
Proposed fix
const MAX_MB = 10
+const ALLOWED_MIME_TYPES = new Set([
+ "application/pdf",
+ "image/jpeg",
+ "image/png",
+ "image/gif",
+ "image/webp",
+])
@@
if (file.size > MAX_MB * 1024 * 1024) {
setError(`File must be under ${MAX_MB}MB`)
return
}
+ if (!ALLOWED_MIME_TYPES.has(file.type)) {
+ setError("Invalid file type. Allowed: PDF, JPEG, PNG, GIF, WebP")
+ return
+ }
@@
- <input ref={fileInputRef} type="file" accept="image/*,application/pdf" onChange={handleFile} className="hidden" />
+ <input ref={fileInputRef} type="file" accept="application/pdf,image/jpeg,image/png,image/gif,image/webp" onChange={handleFile} className="hidden" />
@@
- <input ref={cameraInputRef} type="file" accept="image/*" capture="environment" onChange={handleFile} className="hidden" />
+ <input ref={cameraInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" capture="environment" onChange={handleFile} className="hidden" />Also applies to: 67-70
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/client/src/components/documents/passport-scan.tsx` around lines 38 - 41,
The passport-scan component accepts a broad image/* MIME type range that permits
files the backend rejects (such as HEIC or SVG), causing confusing upload
failures. Add a MIME type validation check in the file size validation block
(near the existing MAX_MB check at lines 38-41) to only allow backend-supported
image formats and reject unsupported types with a clear error message.
Additionally, update the accept attribute on the file input element (at lines
67-70) from the broad image/* pattern to explicitly list only the
backend-supported MIME types (for example, image/jpeg, image/png) to prevent
users from selecting incompatible files in the first place.
…dex CBM) (B) CONCURRENT-REPLACE ORPHAN: passport-image slot update is now a compare-and-set (UPDATE ... WHERE passport_image_key = <oldKey>, or IS NULL when no prior). On CAS miss (a concurrent replace won the slot) we delete OUR just-uploaded blob instead of orphaning the winner's, and throw 409 to retry. Applied to both the admin ContactDocumentsService and portal PortalService copies. (C) PII-IN-LOGS: cleanup/delete-failure logger.warn no longer embeds the R2 key (which carries the sanitized user filename) — redacted to contactId only. (A) DISMISSED (PDF intentionally allowed) — controller comments now say so. Spec: +1 CAS-miss test (7/7). Date-render guard clean. Admin+Client tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Design (invariant-preserving single-slot scan)
Per the #946 constraint, the passport scan attaches to the passport field-data, not as a competing document row:
contacts:passport_image_key(the R2 storage key, not a URL — we sign on read, mirroringcontact_documents.file_url; signed URLs expire, the key is durable and is the stable handle the Passport OCR: auto-extract passport fields from uploaded scan image #947 OCR pass will fetch the image by) +passport_image_uploaded_at.contact_documentsrow → the [Feature] Client Portal: manual keyed-in document entry + mobile camera capture #906 invariant holds (the portal still rejectsdocumentType=passport). One source of truth = the passport record (field-data + optional scan).GET …/passport-imageendpoint per surface (no signing on every contact fetch).Backend (reuses #906
StorageService:uploadDocument/getSignedUrl/deleteDocument; null-safe throughout)ContactDocumentsService.{setPassportImage,getPassportImage,deletePassportImage}+ContactPassportImageController→GET/POST/DELETE /contacts/:id/passport-image(sensitive-access guarded, mirrors the documents controller).PortalService.{upload,get,delete}PortalPassportImage→GET/POST/DELETE /portal/me/passport-image(own contact).getSignedUrlnever called on a null key; delete skips R2 when there's no scan; upload rolls back the orphan on DB failure.UI (signed-URL view, replace, delete, mobile camera
capture=environment)<PassportScan contactId>+use-contactshooks (ApiError→toast).<PassportScan>+use-portal-documentshooks (inline status — no toast system in the portal).Verification
\dshows both nullable cols; re-run = no-op).contact-passport-image.service.spec— upload/sign/replace + read/delete null-guards (6/6).tscclean.#947 seam
The scan is retrievable by
contacts.passport_image_key(server-side fetch or signed URL) — the OCR pass reads it → Claude Vision → review-confirm (never auto-commit). No rework needed.🤖 Generated with Claude Code
Summary by CodeRabbit