diff --git a/backend/routes/upload.js b/backend/routes/upload.js index e23ec66..5909153 100644 --- a/backend/routes/upload.js +++ b/backend/routes/upload.js @@ -4,6 +4,7 @@ const { authenticateToken } = require("../middleware/auth"); const { uploadPresignLimiter } = require("../middleware/rateLimiter"); const s3Service = require("../services/s3Service"); const S3OwnershipService = require("../services/s3OwnershipService"); +const { v4: uuidv4 } = require("uuid"); const logger = require("../utils/logger"); const MAX_BATCH_SIZE = 20; @@ -63,6 +64,7 @@ router.post( /** * POST /api/upload/presign-batch * Get presigned URLs for uploading multiple files to S3 + * All files in a batch share the same UUID base for coordinated variant uploads */ router.post( "/presign-batch", @@ -96,13 +98,17 @@ router.post( } } + // Generate one shared UUID for all files in this batch + const sharedBaseKey = uuidv4(); + const results = await Promise.all( files.map((f) => s3Service.getPresignedUploadUrl( uploadType, f.contentType, f.fileName, - f.fileSize + f.fileSize, + sharedBaseKey ) ) ); @@ -111,9 +117,10 @@ router.post( userId: req.user.id, uploadType, count: results.length, + baseKey: sharedBaseKey, }); - res.json({ uploads: results }); + res.json({ uploads: results, baseKey: sharedBaseKey }); } catch (error) { if (error.message.includes("Invalid")) { return res.status(400).json({ error: error.message }); diff --git a/backend/services/s3Service.js b/backend/services/s3Service.js index e4d8d69..f99837d 100644 --- a/backend/services/s3Service.js +++ b/backend/services/s3Service.js @@ -112,9 +112,10 @@ class S3Service { * @param {string} contentType - MIME type of the file * @param {string} fileName - Original filename (used for extension) * @param {number} fileSize - File size in bytes (required for size enforcement) + * @param {string} [baseKey] - Optional base key (UUID) for coordinated variant uploads * @returns {Promise<{uploadUrl: string, key: string, publicUrl: string, expiresAt: Date}>} */ - async getPresignedUploadUrl(uploadType, contentType, fileName, fileSize) { + async getPresignedUploadUrl(uploadType, contentType, fileName, fileSize, baseKey = null) { if (!this.enabled) { throw new Error("S3 storage is not enabled"); } @@ -135,8 +136,21 @@ class S3Service { ); } + // Extract known variant suffix from fileName if present (e.g., "photo_th.jpg" -> "_th") const ext = path.extname(fileName) || this.getExtFromMime(contentType); - const key = `${config.folder}/${uuidv4()}${ext}`; + const baseName = path.basename(fileName, ext); + + // Only recognize known variant suffixes + let suffix = ""; + if (baseName.endsWith("_th")) { + suffix = "_th"; + } else if (baseName.endsWith("_md")) { + suffix = "_md"; + } + + // Use provided baseKey or generate new UUID + const uuid = baseKey || uuidv4(); + const key = `${config.folder}/${uuid}${suffix}${ext}`; const cacheDirective = config.public ? "public" : "private"; const command = new PutObjectCommand({ diff --git a/frontend/src/services/uploadService.ts b/frontend/src/services/uploadService.ts index 1d13458..00b7cfc 100644 --- a/frontend/src/services/uploadService.ts +++ b/frontend/src/services/uploadService.ts @@ -59,13 +59,19 @@ export async function getPresignedUrl( return response.data; } +interface BatchPresignResponse { + uploads: PresignedUrlResponse[]; + baseKey: string; +} + /** * Get presigned URLs for uploading multiple files + * All files share the same base UUID for coordinated variant uploads */ export async function getPresignedUrls( uploadType: UploadType, files: File[] -): Promise { +): Promise { const response = await api.post("/upload/presign-batch", { uploadType, files: files.map((f) => ({ @@ -74,7 +80,7 @@ export async function getPresignedUrls( fileSize: f.size, })), }); - return response.data.uploads; + return { uploads: response.data.uploads, baseKey: response.data.baseKey }; } /** @@ -223,9 +229,9 @@ export async function uploadImageWithVariants( throw new Error("Failed to resize image"); } - // Get presigned URLs for all variants + // Get presigned URLs for all variants (all share same base UUID) const files = resizedImages.map((r) => r.file); - const presignedUrls = await getPresignedUrls(uploadType, files); + const { uploads: presignedUrls } = await getPresignedUrls(uploadType, files); // Upload all variants in parallel with combined progress const totalBytes = files.reduce((sum, f) => sum + f.size, 0); @@ -251,15 +257,15 @@ export async function uploadImageWithVariants( const keys = presignedUrls.map((p) => p.key); await confirmUploads(keys); - // Find the original variant key (no suffix) for database storage - const originalIdx = resizedImages.findIndex((r) => r.variant.size === "original"); - const baseKey = presignedUrls[originalIdx]?.key || presignedUrls[0].key; - if (onProgress) onProgress(100); + // Use the baseKey returned by the backend (shared UUID for all variants) + // The stored key format is: items/uuid.jpg (original), and variants are items/uuid_th.jpg, items/uuid_md.jpg + const originalKey = keys.find((k) => !k.includes("_th") && !k.includes("_md")) || keys[0]; + return { - baseKey, - publicUrl: getPublicImageUrl(baseKey), + baseKey: originalKey, + publicUrl: getPublicImageUrl(originalKey), variants: keys, }; }