diff --git a/APP/src/app/features/expense/services/expense-api.service.ts b/APP/src/app/features/expense/services/expense-api.service.ts index 8a48e7b..bfd1bc0 100644 --- a/APP/src/app/features/expense/services/expense-api.service.ts +++ b/APP/src/app/features/expense/services/expense-api.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, from, switchMap } from 'rxjs'; import { ApiConfigService } from '../../../core/services/api-config.service'; +import { ImageUtils } from '../../../shared/utilities/image-utils'; import { PagedResult, ExpenseListItemDto, ExpenseDto, CreateExpenseRequest, UpdateExpenseRequest, RejectExpenseRequest, PayExpenseRequest, @@ -39,8 +40,15 @@ export class ExpenseApiService { reject(id: number, r: RejectExpenseRequest): Observable { return this.http.post(`${this.endpoint}/${id}/reject`, r); } pay(id: number, r: PayExpenseRequest): Observable { return this.http.post(`${this.endpoint}/${id}/pay`, r); } uploadReceipt(id: number, file: File): Observable { - const form = new FormData(); form.append('file', file); - return this.http.post(`${this.endpoint}/${id}/receipt`, form); + // Resize/re-encode phone photos client-side first — a raw 12MP camera JPEG + // is several MB and would 413 on mobile. PDFs pass through untouched. + return from(ImageUtils.compressForUpload(file)).pipe( + switchMap(prepared => { + const form = new FormData(); + form.append('file', prepared); + return this.http.post(`${this.endpoint}/${id}/receipt`, form); + }), + ); } /** * Fetches the receipt as a Blob via HttpClient so the auth interceptor attaches diff --git a/APP/src/app/features/giving/services/proof-pdf.builder.ts b/APP/src/app/features/giving/services/proof-pdf.builder.ts index 42a7ff1..d602d96 100644 --- a/APP/src/app/features/giving/services/proof-pdf.builder.ts +++ b/APP/src/app/features/giving/services/proof-pdf.builder.ts @@ -1,5 +1,5 @@ -import imageCompression from 'browser-image-compression'; import { PDFDocument } from 'pdf-lib'; +import { ImageUtils } from '../../../shared/utilities/image-utils'; /** * Builds a single merged PDF from a list of paper-proof attachments (mostly phone photos). @@ -12,11 +12,6 @@ import { PDFDocument } from 'pdf-lib'; * All work happens client-side; the resulting Blob is uploaded as the session's proof.pdf. */ -// Tunables — adjust if proofs look too soft (raise) or files are too large (lower). -const MAX_EDGE_PX = 2000; // longest image edge after compression -const JPEG_QUALITY = 0.72; // 0..1 -const MAX_SIZE_MB = 1; // target ceiling per image - // US Letter, in PDF points (72pt = 1in). const PAGE_W = 612; const PAGE_H = 792; @@ -42,13 +37,7 @@ export async function buildProofPdf(files: File[]): Promise { } if (file.type.startsWith('image/')) { - const compressed = await imageCompression(file, { - maxWidthOrHeight: MAX_EDGE_PX, - maxSizeMB: MAX_SIZE_MB, - initialQuality: JPEG_QUALITY, - fileType: 'image/jpeg', - useWebWorker: true, - }); + const compressed = await ImageUtils.compressForUpload(file); const jpgBytes = new Uint8Array(await compressed.arrayBuffer()); const img = await doc.embedJpg(jpgBytes); diff --git a/APP/src/app/shared/utilities/image-utils.ts b/APP/src/app/shared/utilities/image-utils.ts new file mode 100644 index 0000000..78b4eee --- /dev/null +++ b/APP/src/app/shared/utilities/image-utils.ts @@ -0,0 +1,45 @@ +import imageCompression from 'browser-image-compression'; + +/** + * Client-side image compression shared by every photo upload (expense receipts, + * offering paper-proofs). Phone cameras produce 12MP+ JPEGs that are several MB + * each; resizing the longest edge and re-encoding as JPEG keeps uploads small + * enough to clear the API's per-endpoint size limits (and the reverse proxy). + * + * Tunables: raise quality/edge if proofs look too soft; lower them if files are + * too large. + */ +export class ImageUtils { + /** Longest image edge (px) after compression. */ + public static readonly MAX_EDGE_PX = 2000; + /** JPEG encoder quality, 0..1. */ + public static readonly JPEG_QUALITY = 0.72; + /** Target ceiling per image, in megabytes. */ + public static readonly MAX_SIZE_MB = 1; + + /** + * Compresses a single image File to a resized JPEG. Non-image files (e.g. a + * PDF) are returned untouched. + * + * The result is always renamed to a ".jpg" extension with an "image/jpeg" + * type, because the API derives a stored receipt's content-type from its + * filename extension — keeping a ".png" name on JPEG bytes would make the + * download serve a broken image. + */ + public static async compressForUpload(file: File): Promise { + if (!file.type.startsWith('image/')) { + return file; + } + + const compressed = await imageCompression(file, { + maxWidthOrHeight: ImageUtils.MAX_EDGE_PX, + maxSizeMB: ImageUtils.MAX_SIZE_MB, + initialQuality: ImageUtils.JPEG_QUALITY, + fileType: 'image/jpeg', + useWebWorker: true, + }); + + const baseName = file.name.replace(/\.[^.]+$/, ''); + return new File([compressed], `${baseName}.jpg`, { type: 'image/jpeg' }); + } +} diff --git a/deploy/nginx/conf.d/rolac.conf b/deploy/nginx/conf.d/rolac.conf index 1dd46f0..9d82316 100644 --- a/deploy/nginx/conf.d/rolac.conf +++ b/deploy/nginx/conf.d/rolac.conf @@ -12,6 +12,11 @@ server { ssl_certificate_key /etc/letsencrypt/live/app.rolac.org/privkey.pem; location /api/ { + # nginx defaults to 1 MB, which 413s phone-camera receipt uploads before they + # reach the API. Keep this >= the largest API [RequestSizeLimit] (offerings = 50 MB) + # so the per-endpoint limits in the controllers stay the real authority. + client_max_body_size 50m; + proxy_pass http://api:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/deploy/vm/nginx/conf.d/rolac.conf b/deploy/vm/nginx/conf.d/rolac.conf index 7889c4c..15fe986 100644 --- a/deploy/vm/nginx/conf.d/rolac.conf +++ b/deploy/vm/nginx/conf.d/rolac.conf @@ -10,6 +10,11 @@ server { # API -> api container. The SPA calls same-origin /api/... (environment.prod.ts). location /api/ { + # nginx defaults to 1 MB, which 413s phone-camera receipt uploads before they + # reach the API. Keep this >= the largest API [RequestSizeLimit] (offerings = 50 MB) + # so the per-endpoint limits in the controllers stay the real authority. + client_max_body_size 50m; + set $upstream_api api; proxy_pass http://$upstream_api:8080$request_uri; proxy_set_header Host $host;