Commit 441be079 authored by ThinhNC's avatar ThinhNC

fix(receipts): store transaction receipts in private R2

parent efbc2710
...@@ -28,8 +28,9 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê ...@@ -28,8 +28,9 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
khác nhau, thuộc cùng người dùng và ví nguồn đủ số dư; create/delete cập nhật cả hai số dư cùng bản ghi khác nhau, thuộc cùng người dùng và ví nguồn đủ số dư; create/delete cập nhật cả hai số dư cùng bản ghi
Transfer trong Prisma transaction mức Serializable. Delete là thao tác hiệu chỉnh nên vẫn hoàn tác được Transfer trong Prisma transaction mức Serializable. Delete là thao tác hiệu chỉnh nên vẫn hoàn tác được
vào ví đã archive; mọi thay đổi Transfer đều xóa cache báo cáo tài chính của người dùng. vào ví đã archive; mọi thay đổi Transfer đều xóa cache báo cáo tài chính của người dùng.
- Hóa đơn Transaction được lưu cục bộ dưới `storage/receipts`, chỉ đọc qua API có auth; - Hóa đơn Transaction mới được lưu riêng tư trên Cloudflare R2 dưới prefix ownership
hỗ trợ JPEG, PNG, WebP, PDF và giới hạn mặc định 5 MB. `receipts/<userId>/`, chỉ đọc qua API có auth; hỗ trợ JPEG, PNG, WebP, PDF và giới hạn mặc định
5 MB. Backend vẫn đọc/xóa tương thích key local cũ dưới `storage/receipts`.
- Upload file mới dùng mô hình browser tải trực tiếp lên Cloudflare R2 qua presigned PUT URL - Upload file mới dùng mô hình browser tải trực tiếp lên Cloudflare R2 qua presigned PUT URL
do backend cấp. Hiện chỉ bật purpose `avatar` (JPEG/PNG/WebP, mặc định 5 MB), object key do do backend cấp. Hiện chỉ bật purpose `avatar` (JPEG/PNG/WebP, mặc định 5 MB), object key do
server sinh theo user và public URL chỉ được lưu vào profile sau khi PUT thành công. Khi mở rộng, server sinh theo user và public URL chỉ được lưu vào profile sau khi PUT thành công. Khi mở rộng,
......
...@@ -33,6 +33,7 @@ TRUST_PROXY=false ...@@ -33,6 +33,7 @@ TRUST_PROXY=false
ALLOWED_ORIGINS=http://localhost:2999,http://localhost:3000,http://localhost:5173 ALLOWED_ORIGINS=http://localhost:2999,http://localhost:3000,http://localhost:5173
# Legacy local receipt directory (read-only compatibility for files uploaded before R2)
RECEIPT_UPLOAD_DIR=storage/receipts RECEIPT_UPLOAD_DIR=storage/receipts
RECEIPT_MAX_FILE_SIZE_MB=5 RECEIPT_MAX_FILE_SIZE_MB=5
......
...@@ -96,7 +96,7 @@ Tất cả các endpoint bên dưới có tiền tố (prefix) mặc định: `/ ...@@ -96,7 +96,7 @@ Tất cả các endpoint bên dưới có tiền tố (prefix) mặc định: `/
* `POST /transactions` [Bearer] - Tạo giao dịch thu/chi (tự động cộng/trừ số dư ví liên quan dưới database transaction Serializable). * `POST /transactions` [Bearer] - Tạo giao dịch thu/chi (tự động cộng/trừ số dư ví liên quan dưới database transaction Serializable).
* `PUT /transactions/:id` [Bearer] - Cập nhật giao dịch (tự động tính toán lại và hoàn tác/cập nhật số dư ví cũ và mới tương ứng). * `PUT /transactions/:id` [Bearer] - Cập nhật giao dịch (tự động tính toán lại và hoàn tác/cập nhật số dư ví cũ và mới tương ứng).
* `DELETE /transactions/:id` [Bearer] - Xóa giao dịch (tự động hoàn trả số dư ví về trạng thái trước giao dịch). * `DELETE /transactions/:id` [Bearer] - Xóa giao dịch (tự động hoàn trả số dư ví về trạng thái trước giao dịch).
* `PUT /transactions/:id/receipt` [Bearer] - Tải lên ảnh hóa đơn (`receipt`) dạng multipart-form (hỗ trợ JPEG, PNG, WebP, PDF tối đa 5MB). * `PUT /transactions/:id/receipt` [Bearer] - Tải ảnh hóa đơn riêng tư lên Cloudflare R2 (`receipt`) dạng multipart-form (hỗ trợ JPEG, PNG, WebP, PDF tối đa 5MB).
* `GET /transactions/:id/receipt` [Bearer] - Xem/Tải xuống tệp hóa đơn đã upload bảo mật. * `GET /transactions/:id/receipt` [Bearer] - Xem/Tải xuống tệp hóa đơn đã upload bảo mật.
### 3.6 Ngân sách chi tiêu (Budgets) ### 3.6 Ngân sách chi tiêu (Budgets)
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
## Phạm vi hiện tại ## Phạm vi hiện tại
Luồng presigned PUT hiện chỉ phục vụ **avatar**. Frontend không gửi file qua backend: Luồng presigned PUT hiện phục vụ **avatar**. Frontend không gửi file avatar qua backend:
1. Browser gọi `POST /api/v1/uploads/presign` bằng phiên đăng nhập hiện tại. 1. Browser gọi `POST /api/v1/uploads/presign` bằng phiên đăng nhập hiện tại.
2. Backend kiểm tra purpose, MIME type và kích thước khai báo, tự sinh object key rồi ký URL ngắn hạn. 2. Backend kiểm tra purpose, MIME type và kích thước khai báo, tự sinh object key rồi ký URL ngắn hạn.
...@@ -11,6 +11,11 @@ Luồng presigned PUT hiện chỉ phục vụ **avatar**. Frontend không gửi ...@@ -11,6 +11,11 @@ Luồng presigned PUT hiện chỉ phục vụ **avatar**. Frontend không gửi
R2 credentials chỉ tồn tại ở backend. Presigned URL là bearer credential ngắn hạn, không log hoặc lưu URL này. R2 credentials chỉ tồn tại ở backend. Presigned URL là bearer credential ngắn hạn, không log hoặc lưu URL này.
Hóa đơn giao dịch cũng được lưu trong R2 dưới prefix riêng tư `receipts/<userId>/`, nhưng binary
đi qua endpoint giao dịch có xác thực để backend kiểm tra chữ ký file và ownership. Hóa đơn không
dùng public URL; `GET /transactions/:id/receipt` đọc object sau khi kiểm tra giao dịch thuộc người dùng.
Các key local cũ vẫn được đọc tương thích từ `RECEIPT_UPLOAD_DIR`.
## Cấu hình backend ## Cấu hình backend
Các biến cần thiết được liệt kê trong `.env.example`: Các biến cần thiết được liệt kê trong `.env.example`:
......
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { access, mkdir, unlink, writeFile } from 'fs/promises'; import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { access, unlink } from 'fs/promises';
import path from 'path'; import path from 'path';
import { AppError } from '../../common/errors/app-error'; import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from '../../common/errors/error-code';
...@@ -21,8 +27,10 @@ const mimeTypeByExtension: Record<string, string> = { ...@@ -21,8 +27,10 @@ const mimeTypeByExtension: Record<string, string> = {
export interface StoredReceipt { export interface StoredReceipt {
key: string; key: string;
absolutePath: string; buffer?: Buffer;
absolutePath?: string;
mimeType: string; mimeType: string;
extension: string;
} }
export class ReceiptFileService { export class ReceiptFileService {
...@@ -38,18 +46,52 @@ export class ReceiptFileService { ...@@ -38,18 +46,52 @@ export class ReceiptFileService {
); );
} }
const fileName = `${randomUUID()}${extension}`; const key = `receipts/${userId}/${randomUUID()}${extension}`;
const key = `${userId}/${fileName}`; const client = this.createR2Client();
const absolutePath = this.resolveSafePath(key); await client.send(new PutObjectCommand({
const userDir = path.dirname(absolutePath); Bucket: envConfig.r2.bucketName,
await mkdir(userDir, { recursive: true }); Key: key,
Body: file.buffer,
await writeFile(absolutePath, file.buffer, { flag: 'wx' }); ContentType: file.mimetype,
ContentLength: file.size,
}));
return key; return key;
} }
async resolve(key: string): Promise<StoredReceipt> { async resolve(key: string): Promise<StoredReceipt> {
if (this.isR2ReceiptKey(key)) {
const client = this.createR2Client();
try {
const object = await client.send(new GetObjectCommand({
Bucket: envConfig.r2.bucketName,
Key: key,
}));
if (!object.Body) {
throw new Error('R2 object body is empty');
}
const extension = path.extname(key).toLowerCase();
const mimeType = object.ContentType || mimeTypeByExtension[extension];
if (!mimeType || !mimeTypeByExtension[extension]) {
throw new Error('R2 object has an unsupported content type');
}
return {
key,
buffer: Buffer.from(await object.Body.transformToByteArray()),
mimeType,
extension,
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError('Receipt file not found', 404, ERROR_CODE.RECEIPT_NOT_FOUND);
}
}
// Compatibility path for receipts created before R2 storage was enabled.
const absolutePath = this.resolveSafePath(key); const absolutePath = this.resolveSafePath(key);
try { try {
...@@ -64,7 +106,7 @@ export class ReceiptFileService { ...@@ -64,7 +106,7 @@ export class ReceiptFileService {
throw new AppError('Receipt file not found', 404, ERROR_CODE.RECEIPT_NOT_FOUND); throw new AppError('Receipt file not found', 404, ERROR_CODE.RECEIPT_NOT_FOUND);
} }
return { key, absolutePath, mimeType }; return { key, absolutePath, mimeType, extension };
} }
async remove(key: string | null): Promise<void> { async remove(key: string | null): Promise<void> {
...@@ -72,6 +114,19 @@ export class ReceiptFileService { ...@@ -72,6 +114,19 @@ export class ReceiptFileService {
return; return;
} }
if (this.isR2ReceiptKey(key)) {
try {
const client = this.createR2Client();
await client.send(new DeleteObjectCommand({
Bucket: envConfig.r2.bucketName,
Key: key,
}));
} catch {
// Object cleanup is best effort after the database state has been committed.
}
return;
}
try { try {
await unlink(this.resolveSafePath(key)); await unlink(this.resolveSafePath(key));
} catch { } catch {
...@@ -79,6 +134,35 @@ export class ReceiptFileService { ...@@ -79,6 +134,35 @@ export class ReceiptFileService {
} }
} }
private createR2Client(): S3Client {
const config = envConfig.r2;
if (
!config.accountId
|| !config.bucketName
|| !config.accessKeyId
|| !config.secretAccessKey
) {
throw new AppError(
'Cloudflare R2 storage is not configured',
503,
ERROR_CODE.STORAGE_NOT_CONFIGURED,
);
}
return new S3Client({
region: 'auto',
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
}
private isR2ReceiptKey(key: string): boolean {
return /^receipts\/[0-9a-f-]+\/[0-9a-f-]+\.(?:jpg|png|webp|pdf)$/i.test(key);
}
private resolveSafePath(key: string): string { private resolveSafePath(key: string): string {
const normalizedKey = key.replace(/\\/g, '/'); const normalizedKey = key.replace(/\\/g, '/');
const absolutePath = path.resolve(this.rootDir, normalizedKey); const absolutePath = path.resolve(this.rootDir, normalizedKey);
......
import path from 'path'; import { readFile } from 'fs/promises';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import { AppError } from '../../common/errors/app-error'; import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code'; import { ERROR_CODE } from '../../common/errors/error-code';
...@@ -101,19 +101,16 @@ export class TransactionController { ...@@ -101,19 +101,16 @@ export class TransactionController {
getReceipt = async (req: Request, res: Response, next: NextFunction) => { getReceipt = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const receipt = await this.service.getReceipt(req.user.id, req.params.id); const receipt = await this.service.getReceipt(req.user.id, req.params.id);
const fileName = `receipt${path.extname(receipt.absolutePath)}`; const content = receipt.buffer
?? await readFile(receipt.absolutePath!);
res.sendFile(receipt.absolutePath, { res.set({
headers: {
'Content-Type': receipt.mimeType, 'Content-Type': receipt.mimeType,
'Content-Disposition': `inline; filename="${fileName}"`, 'Content-Disposition': `inline; filename="receipt${receipt.extension}"`,
'Content-Length': content.length.toString(),
'Cache-Control': 'private, max-age=3600', 'Cache-Control': 'private, max-age=3600',
},
}, (error) => {
if (error && !res.headersSent) {
next(error);
}
}); });
res.send(content);
} catch (error) { } catch (error) {
next(error); next(error);
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment