diff --git a/apps/public-api/src/__tests__/storage.controller.test.js b/apps/public-api/src/__tests__/storage.controller.test.js index 2d5012fe..96cbb26a 100644 --- a/apps/public-api/src/__tests__/storage.controller.test.js +++ b/apps/public-api/src/__tests__/storage.controller.test.js @@ -28,6 +28,9 @@ jest.mock('@urbackend/common', () => { updateOne: jest.fn(), }, isProjectStorageExternal: jest.fn(), + getBucket: jest.fn(() => 'dev-files'), + getPresignedUploadUrl: jest.fn(), + verifyUploadedFile: jest.fn(), __mockStorageFrom: mockStorageFrom, // expose for assertions }; }); @@ -36,7 +39,7 @@ jest.mock('@urbackend/common', () => { // Import module under test after mocks // --------------------------------------------------------------------------- -const { getStorage, Project, isProjectStorageExternal, __mockStorageFrom: mockStorageFrom } = require('@urbackend/common'); +const { getStorage, Project, isProjectStorageExternal, getBucket, getPresignedUploadUrl, verifyUploadedFile, __mockStorageFrom: mockStorageFrom } = require('@urbackend/common'); const storageController = require('../controllers/storage.controller'); // --------------------------------------------------------------------------- @@ -349,4 +352,222 @@ describe('storage.controller', () => { expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Failed to delete files' })); }); }); + + describe('requestUpload and confirmUpload', () => { + test('returns 400 when requestUpload receives a non-numeric size', async () => { + const req = { project: makeProject(), body: { filename: 'file.txt', contentType: 'text/plain', size: 'abc' } }; + const res = makeRes(); + + await storageController.requestUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'filename, contentType, and size are required.' }); + }); + + test('returns signed URL for requestUpload on valid input', async () => { + isProjectStorageExternal.mockReturnValue(false); + getPresignedUploadUrl.mockResolvedValue({ signedUrl: 'https://signed.example/upload', token: 'token-1' }); + + const req = { project: makeProject(), body: { filename: 'my..file.txt', contentType: 'text/plain', size: 1024 } }; + const res = makeRes(); + + await storageController.requestUpload(req, res); + + expect(getPresignedUploadUrl).toHaveBeenCalledWith(req.project, 'project_id_1/mocked-uuid_my..file.txt', 'text/plain', 1024); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ signedUrl: 'https://signed.example/upload', token: 'token-1', filePath: 'project_id_1/mocked-uuid_my..file.txt' }); + }); + + test('confirmUpload charges the verified size and rejects mismatches', async () => { + isProjectStorageExternal.mockReturnValue(false); + verifyUploadedFile.mockResolvedValue(2048); + Project.updateOne.mockResolvedValue({ matchedCount: 1 }); + mockStorageFrom.getPublicUrl.mockReturnValue({ data: { publicUrl: 'https://mock.supabase.co/project_id_1/file.txt' } }); + + const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; + const res = makeRes(); + + await storageController.confirmUpload(req, res); + + expect(verifyUploadedFile).toHaveBeenCalledWith(req.project, 'project_id_1/file.txt'); + expect(Project.updateOne).toHaveBeenCalledWith( + { + _id: 'project_id_1', + $or: [ + { storageLimit: -1 }, + { $expr: { $lte: [{ $add: ['$storageUsed', 2048] }, '$storageLimit'] } } + ] + }, + { $inc: { storageUsed: 2048 } } + ); + expect(mockStorageFrom.getPublicUrl).toHaveBeenCalledWith('project_id_1/file.txt'); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ path: 'project_id_1/file.txt', provider: 'internal' })); + }); + + test('confirmUpload succeeds for unlimited storage plans', async () => { + isProjectStorageExternal.mockReturnValue(false); + verifyUploadedFile.mockResolvedValue(2048); + Project.updateOne.mockResolvedValue({ matchedCount: 1 }); + mockStorageFrom.getPublicUrl.mockReturnValue({ data: { publicUrl: 'https://mock.supabase.co/project_id_1/file.txt' } }); + + const req = { project: makeProject({ storageLimit: -1 }), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; + const res = makeRes(); + + await storageController.confirmUpload(req, res); + + expect(Project.updateOne).toHaveBeenCalledWith( + { + _id: 'project_id_1', + $or: [ + { storageLimit: -1 }, + { $expr: { $lte: [{ $add: ['$storageUsed', 2048] }, '$storageLimit'] } } + ] + }, + { $inc: { storageUsed: 2048 } } + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Upload confirmed', + path: 'project_id_1/file.txt', + provider: 'internal', + url: 'https://mock.supabase.co/project_id_1/file.txt' + })); + }); + + test('confirmUpload rejects a declared size that differs from the verified size', async () => { + isProjectStorageExternal.mockReturnValue(false); + verifyUploadedFile.mockResolvedValue(2048); + mockStorageFrom.getPublicUrl.mockReturnValue({ data: { publicUrl: 'https://mock.supabase.co/project_id_1/file.txt' } }); + + const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 1024 } }; + const res = makeRes(); + + await storageController.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Declared file size does not match uploaded file size.' }); + expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); + }); + + test('confirmUpload accepts small declared size drift within tolerance', async () => { + isProjectStorageExternal.mockReturnValue(false); + verifyUploadedFile.mockResolvedValue(2048); + Project.updateOne.mockResolvedValue({ matchedCount: 1 }); + mockStorageFrom.getPublicUrl.mockReturnValue({ data: { publicUrl: 'https://mock.supabase.co/project_id_1/file.txt' } }); + + const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2100 } }; + const res = makeRes(); + + await storageController.confirmUpload(req, res); + + expect(Project.updateOne).toHaveBeenCalledWith( + { + _id: 'project_id_1', + $or: [ + { storageLimit: -1 }, + { $expr: { $lte: [{ $add: ['$storageUsed', 2048] }, '$storageLimit'] } } + ] + }, + { $inc: { storageUsed: 2048 } } + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ path: 'project_id_1/file.txt', provider: 'internal' })); + }); + + test('confirmUpload removes uploaded object when quota reservation fails', async () => { + isProjectStorageExternal.mockReturnValue(false); + verifyUploadedFile.mockResolvedValue(2048); + Project.updateOne.mockResolvedValue({ matchedCount: 0 }); + mockStorageFrom.remove.mockResolvedValue({ data: null, error: null }); + + const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; + const res = makeRes(); + + await storageController.confirmUpload(req, res); + + expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'Internal storage limit exceeded.' }); + }); + + test('confirmUpload removes uploaded object when verification fails', async () => { + isProjectStorageExternal.mockReturnValue(false); + verifyUploadedFile.mockResolvedValue(0); + mockStorageFrom.remove.mockResolvedValue({ data: null, error: null }); + + const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; + const res = makeRes(); + + await storageController.confirmUpload(req, res); + + expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Upload confirmation failed' })); + }); + + test('confirmUpload returns a retryable conflict when the uploaded object is not yet visible', async () => { + isProjectStorageExternal.mockReturnValue(false); + verifyUploadedFile.mockRejectedValue(new Error('File not found after upload')); + mockStorageFrom.remove.mockResolvedValue({ data: null, error: null }); + + const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; + const res = makeRes(); + + await storageController.confirmUpload(req, res); + + expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ + error: 'UPLOAD_NOT_READY', + message: 'Uploaded file is not visible yet. Please retry confirmation.' + }); + }); + + test('confirmUpload still returns 500 for generic verification errors', async () => { + isProjectStorageExternal.mockReturnValue(false); + verifyUploadedFile.mockRejectedValue(new Error('Unexpected verification failure')); + + const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; + const res = makeRes(); + + await storageController.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Upload confirmation failed' })); + }); + + test('confirmUpload swallows cleanup failures during compensating delete', async () => { + isProjectStorageExternal.mockReturnValue(false); + verifyUploadedFile.mockResolvedValue(2048); + Project.updateOne.mockResolvedValue({ matchedCount: 0 }); + mockStorageFrom.remove.mockRejectedValue(new Error('Delete failed')); + + const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; + const res = makeRes(); + + await storageController.confirmUpload(req, res); + + expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('confirmUpload returns a warning when public URL is unavailable', async () => { + isProjectStorageExternal.mockReturnValue(true); + verifyUploadedFile.mockResolvedValue(2048); + mockStorageFrom.getPublicUrl.mockReturnValue({ data: { publicUrl: null, error: 'Cloudflare R2 requires a Public URL Host.' } }); + + const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; + const res = makeRes(); + + await storageController.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + url: null, + warning: 'Cloudflare R2 requires a Public URL Host.', + provider: 'external' + })); + }); + }); }); diff --git a/apps/public-api/src/controllers/storage.controller.js b/apps/public-api/src/controllers/storage.controller.js index f84d75a9..8c5d16a0 100644 --- a/apps/public-api/src/controllers/storage.controller.js +++ b/apps/public-api/src/controllers/storage.controller.js @@ -1,14 +1,62 @@ -const { getStorage, redis } = require("@urbackend/common"); +const { getStorage, getPresignedUploadUrl, verifyUploadedFile, Project, isProjectStorageExternal, getBucket, redis } = require("@urbackend/common"); const { randomUUID } = require("crypto"); -const {Project} = require("@urbackend/common"); -const { isProjectStorageExternal } = require("@urbackend/common"); +const path = require("path"); const { getMonthKey, getEndOfMonthTtlSeconds, incrWithTtlAtomic } = require("../utils/usageCounter"); const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const SAFETY_MAX_BYTES = 100 * 1024 * 1024; // 100MB safety ceiling for internal storage +const CONFIRM_UPLOAD_SIZE_TOLERANCE_BYTES = 64; + +const getEffectiveStorageLimit = (project, req) => { + const limits = req.planLimits || {}; + if (typeof limits.storageBytes === 'number') { + return limits.storageBytes; + } + if (typeof project.storageLimit === 'number') { + return project.storageLimit; + } + return 20 * 1024 * 1024; +}; + +const parsePositiveSize = (size) => { + const numericSize = Number(size); + if (!Number.isFinite(numericSize) || numericSize <= 0) { + return null; + } + return numericSize; +}; + +const normalizeProjectPath = (projectId, inputPath) => { + if (typeof inputPath !== 'string') { + return null; + } + + let decodedPath = inputPath; + try { + decodedPath = decodeURIComponent(inputPath); + } catch { + return null; + } + + const normalizedPath = path.posix.normalize(decodedPath).replace(/^\/+/, ''); + const segments = normalizedPath.split('/').filter(Boolean); + + if (segments.length < 2) { + return null; + } + + if (segments[0] !== String(projectId)) { + return null; + } + + if (segments.some((segment) => segment === '.' || segment === '..')) { + return null; + } + + return normalizedPath; +}; + -const getBucket = (project) => - isProjectStorageExternal(project) ? "files" : "dev-files"; const updateMonthlyUsageCounter = (projectId, metricName, value) => { if (!value || value <= 0) return; @@ -19,6 +67,16 @@ const updateMonthlyUsageCounter = (projectId, metricName, value) => { incrWithTtlAtomic(redis, key, ttlSeconds, value).catch(() => {}); }; +const bestEffortDeleteUploadedObject = async (project, filePath) => { + try { + const supabase = await getStorage(project); + const bucket = getBucket(project); + await supabase.storage.from(bucket).remove([filePath]); + } catch { + // ignore cleanup failures; the primary response should still be returned + } +}; + // Upload File @@ -39,16 +97,7 @@ module.exports.uploadFile = async (req, res) => { // ATOMIC QUOTA RESERVATION if (!external) { - const limits = req.planLimits || {}; - // Use explicit type checks instead of || to distinguish between undefined and 0 - let effectiveLimit; - if (typeof limits.storageBytes === 'number') { - effectiveLimit = limits.storageBytes; - } else if (typeof project.storageLimit === 'number') { - effectiveLimit = project.storageLimit; - } else { - effectiveLimit = 20 * 1024 * 1024; - } + const effectiveLimit = getEffectiveStorageLimit(project, req); // For internal storage: honor -1 as unlimited but clamp to safety ceiling if (effectiveLimit === -1) { @@ -138,8 +187,9 @@ module.exports.deleteFile = async (req, res) => { const project = req.project; const external = isProjectStorageExternal(project); const bucket = getBucket(project); + const normalizedPath = normalizeProjectPath(project._id, path); - if (!path.startsWith(`${project._id}/`) || path.split('/').includes('..')) { + if (!normalizedPath) { return res.status(403).json({ error: "Access denied." }); } @@ -148,8 +198,8 @@ module.exports.deleteFile = async (req, res) => { // Fetch metadata before delete so deleted-byte metrics work for both internal and external providers. let fileSize = 0; try { - const rootPrefix = path.split("/")[0]; - const nestedPath = path.split("/").slice(1).join("/"); + const rootPrefix = normalizedPath.split("/")[0]; + const nestedPath = normalizedPath.split("/").slice(1).join("/"); const { data, error } = await supabase.storage .from(bucket) .list(rootPrefix, { @@ -167,7 +217,7 @@ module.exports.deleteFile = async (req, res) => { const { error: deleteError } = await supabase.storage .from(bucket) - .remove([path]); + .remove([normalizedPath]); if (deleteError) throw deleteError; @@ -251,4 +301,130 @@ module.exports.deleteAllFiles = async (req, res) => { : undefined }); } -}; \ No newline at end of file +}; + + +// REQUEST UPLOAD - generates presigned URL for direct browser upload +module.exports.requestUpload = async (req, res) => { + try { + const { filename, contentType, size } = req.body; + const numericSize = parsePositiveSize(size); + + if (!filename || !contentType || numericSize === null) + return res.status(400).json({ error: "filename, contentType, and size are required." }); + + if (numericSize > MAX_FILE_SIZE) + return res.status(413).json({ error: "File size exceeds limit." }); + + const project = req.project; + const external = isProjectStorageExternal(project); + const effectiveLimit = getEffectiveStorageLimit(project, req); + + // just peek at quota — don't charge yet, upload hasn't happened + if (!external) { + const quotaLimit = effectiveLimit === -1 ? SAFETY_MAX_BYTES : effectiveLimit; + if (project.storageUsed + numericSize > quotaLimit) + return res.status(403).json({ error: "Internal storage limit exceeded." }); + } + + const safeName = filename.replace(/\s+/g, "_"); + const filePath = `${project._id}/${randomUUID()}_${safeName}`; + + const { signedUrl, token } = await getPresignedUploadUrl(project, filePath, contentType, numericSize); + + return res.status(200).json({ signedUrl, token, filePath }); + } catch (err) { + return res.status(500).json({ + error: "Could not generate upload URL", + details: process.env.NODE_ENV === "development" ? err.message : undefined + }); + } +}; + +// CONFIRM UPLOAD - verifies file landed on cloud, then charges quota +module.exports.confirmUpload = async (req, res) => { + try { + const { filePath, size } = req.body; + const declaredSize = parsePositiveSize(size); + + if (!filePath || declaredSize === null) + return res.status(400).json({ error: "filePath and size are required." }); + + const project = req.project; + const external = isProjectStorageExternal(project); + const normalizedPath = normalizeProjectPath(project._id, filePath); + + // make sure client isn't confirming someone else's file + if (!normalizedPath) + return res.status(403).json({ error: "Access denied." }); + + // verify file actually exists on cloud before touching quota + let actualSize; + try { + actualSize = await verifyUploadedFile(project, normalizedPath); + } catch (err) { + if (err?.message === "File not found after upload") { + await bestEffortDeleteUploadedObject(project, normalizedPath); + return res.status(409).json({ + error: "UPLOAD_NOT_READY", + message: "Uploaded file is not visible yet. Please retry confirmation." + }); + } + throw err; + } + + if (!Number.isFinite(actualSize) || actualSize <= 0) { + await bestEffortDeleteUploadedObject(project, normalizedPath); + return res.status(500).json({ error: "Upload confirmation failed", details: process.env.NODE_ENV === "development" ? "Uploaded file size could not be determined" : undefined }); + } + + if (Math.abs(actualSize - declaredSize) > CONFIRM_UPLOAD_SIZE_TOLERANCE_BYTES) { + await bestEffortDeleteUploadedObject(project, normalizedPath); + return res.status(400).json({ error: "Declared file size does not match uploaded file size." }); + } + + // now it's safe to charge quota + if (!external) { + const result = await Project.updateOne( + { + _id: project._id, + $or: [ + { storageLimit: -1 }, + { $expr: { $lte: [{ $add: ["$storageUsed", actualSize] }, "$storageLimit"] } } + ] + }, + { $inc: { storageUsed: actualSize } } + ); + if (result.matchedCount === 0) { + await bestEffortDeleteUploadedObject(project, normalizedPath); + return res.status(403).json({ error: "Internal storage limit exceeded." }); + } + } + + updateMonthlyUsageCounter(project._id, "storage:uploadedBytes", actualSize); + + const supabase = await getStorage(project); + const bucket = getBucket(project); + const { data: publicUrlData } = supabase.storage.from(bucket).getPublicUrl(normalizedPath); + + const response = { + message: "Upload confirmed", + path: normalizedPath, + provider: external ? "external" : "internal" + }; + + if (publicUrlData?.publicUrl) { + response.url = publicUrlData.publicUrl; + } else { + response.url = null; + response.warning = publicUrlData?.error || "Upload confirmed, but a public URL is unavailable."; + } + + return res.status(200).json(response); + } catch (err) { + return res.status(500).json({ + error: "Upload confirmation failed", + details: process.env.NODE_ENV === "development" ? err.message : undefined + }); + } +}; diff --git a/apps/public-api/src/routes/storage.js b/apps/public-api/src/routes/storage.js index 193976b6..a41e3d66 100644 --- a/apps/public-api/src/routes/storage.js +++ b/apps/public-api/src/routes/storage.js @@ -4,7 +4,8 @@ const multer = require('multer'); const verifyApiKey = require('../middlewares/verifyApiKey'); const requireSecretKey = require('../middlewares/requireSecretKey'); const { checkUsageLimits } = require('../middlewares/usageGate'); -const { uploadFile, deleteFile, deleteAllFiles } = require("../controllers/storage.controller") +const projectRateLimiter = require('../middlewares/projectRateLimiter'); +const { uploadFile, deleteFile, deleteAllFiles, requestUpload, confirmUpload } = require("../controllers/storage.controller"); const storage = multer.memoryStorage(); const upload = multer({ @@ -15,6 +16,10 @@ const upload = multer({ // POST REQ UPLOAD FILE router.post('/upload', verifyApiKey, requireSecretKey, checkUsageLimits, upload.single('file'), uploadFile); +// NEW: presigned URL flow (no multer) +router.post('/upload-request', verifyApiKey, projectRateLimiter, requireSecretKey, requestUpload); +router.post('/upload-confirm', verifyApiKey, projectRateLimiter, requireSecretKey, confirmUpload); + // DELETE REQ SINGLE FILE router.delete('/file', verifyApiKey, requireSecretKey, checkUsageLimits, deleteFile); diff --git a/package-lock.json b/package-lock.json index 51ee4c03..b67d0b88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -396,22 +396,23 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.26.tgz", - "integrity": "sha512-A/E6n2W42ruU+sfWk+mMUOyVXbsSgGrY3MJ9/0Az5qUdG67y8I6HYzzoAa+e/lzxxl1uCYmEL6BTMi9ZiZnplQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/xml-builder": "^3.972.16", - "@smithy/core": "^3.23.13", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/signature-v4": "^5.3.12", - "@smithy/smithy-client": "^4.12.8", - "@smithy/types": "^4.13.1", + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.3.tgz", + "integrity": "sha512-W3aJJm2clu8OmsrwMOMnfof13O6LGnbknnZIQeSRbxjqKah2nVvkjbUBBZVhWrt08KC69H7WsINTdrxC/2SXQw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.18", + "@smithy/core": "^3.23.16", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.12", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.12", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.3", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -708,23 +709,23 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.27.tgz", - "integrity": "sha512-gomO6DZwx+1D/9mbCpcqO5tPBqYBK7DtdgjTIjZ4yvfh/S7ETwAPS0XbJgP2JD8Ycr5CwVrEkV1sFtu3ShXeOw==", + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.32.tgz", + "integrity": "sha512-dc2O2x0V5pGJhmdQYQveUIFtMZsur7GrGuSgoKM4oQJuEcfvwnJ3sj+ip6WnxR5l6TrX5zkl4KgcgswOy3wAzQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.26", - "@aws-sdk/types": "^3.973.6", + "@aws-sdk/core": "^3.974.3", + "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.13", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/signature-v4": "^5.3.12", - "@smithy/smithy-client": "^4.12.8", - "@smithy/types": "^4.13.1", + "@smithy/core": "^3.23.16", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.12", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-stream": "^4.5.21", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.24", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -830,17 +831,36 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1034.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1034.0.tgz", + "integrity": "sha512-YFU/ipfcNSNBzP4vqjsAL9oXuLwUqudQiGBeleVMkcnBdyvWYTQ30aj7iyA1jxDQCVvxBSB/QKdoRge89WcUvw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "^3.996.20", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-format-url": "^3.972.10", + "@smithy/middleware-endpoint": "^4.4.31", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.12", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.15.tgz", - "integrity": "sha512-Ukw2RpqvaL96CjfH/FgfBmy/ZosHBqoHBCFsN61qGg99F33vpntIVii8aNeh65XuOja73arSduskoa4OJea9RQ==", + "version": "3.996.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.20.tgz", + "integrity": "sha512-MEj6DhEcaO8RgVtFCJ+xpCQnZC3Iesr09avdY75qkMQfckQULu447IegK7Rs1MCGerVBfKnJQ4q+pQq9hI5lng==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.27", - "@aws-sdk/types": "^3.973.6", - "@smithy/protocol-http": "^5.3.12", - "@smithy/signature-v4": "^5.3.12", - "@smithy/types": "^4.13.1", + "@aws-sdk/middleware-sdk-s3": "^3.972.32", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -866,12 +886,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", - "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -906,6 +926,21 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.10.tgz", + "integrity": "sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.965.5", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", @@ -956,12 +991,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz", - "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==", + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.18.tgz", + "integrity": "sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" }, @@ -3197,18 +3232,18 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.13", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.13.tgz", - "integrity": "sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q==", + "version": "3.23.16", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.16.tgz", + "integrity": "sha512-JStomOrINQA1VqNEopLsgcdgwd42au7mykKqVr30XFw89wLt9sDxJDi4djVPRwQmmzyTGy/uOvTc2ultMpFi1w==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-stream": "^4.5.21", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.24", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" @@ -3304,14 +3339,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.15", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", - "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz", + "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/querystring-builder": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, @@ -3417,18 +3452,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.28", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.28.tgz", - "integrity": "sha512-p1gfYpi91CHcs5cBq982UlGlDrxoYUX6XdHSo91cQ2KFuz6QloHosO7Jc60pJiVmkWrKOV8kFYlGFFbQ2WUKKQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.13", - "@smithy/middleware-serde": "^4.2.16", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-middleware": "^4.2.12", + "version": "4.4.31", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.31.tgz", + "integrity": "sha512-KJPdCIN2kOE2aGmqZd7eUTr4WQwOGgtLWgUkswGJggs7rBcQYQjcZMEDa3C0DwbOiXS9L8/wDoQHkfxBYLfiLw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.16", + "@smithy/middleware-serde": "^4.2.19", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -3456,14 +3491,14 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.16", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.16.tgz", - "integrity": "sha512-beqfV+RZ9RSv+sQqor3xroUUYgRFCGRw6niGstPG8zO9LgTl0B0MCucxjmrH/2WwksQN7UUgI7KNANoZv+KALA==", + "version": "4.2.19", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.19.tgz", + "integrity": "sha512-Q6y+W9h3iYVMCKWDoVge+OC1LKFqbEKaq8SIWG2X2bWJRpd/6dDLyICcNLT6PbjH3Rr6bmg/SeDB25XFOFfeEw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.13", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", + "@smithy/core": "^3.23.16", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -3471,12 +3506,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", - "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz", + "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -3484,14 +3519,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.12", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", - "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz", + "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -3499,14 +3534,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.1.tgz", - "integrity": "sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.6.0.tgz", + "integrity": "sha512-P734cAoTFtuGfWa/R3jgBnGlURt2w9bYEBwQNMKf58sRM9RShirB2mKwLsVP+jlG/wxpCu8abv8NxdUts8tdLA==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/querystring-builder": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -3514,12 +3549,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", - "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.14.tgz", + "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -3527,12 +3562,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.12", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", - "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.14.tgz", + "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -3540,12 +3575,12 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", - "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz", + "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, @@ -3554,12 +3589,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", - "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz", + "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -3567,24 +3602,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", - "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.0.tgz", + "integrity": "sha512-9jKsBYQRPR0xBLgc2415RsA5PIcP2sis4oBdN9s0D13cg1B1284mNTjx9Yc+BEERXzuPm5ObktI96OxsKh8E9A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1" + "@smithy/types": "^4.14.1" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", - "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz", + "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -3592,16 +3627,16 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.12", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", - "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", + "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.12", + "@smithy/util-middleware": "^4.2.14", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -3611,17 +3646,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.8", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.8.tgz", - "integrity": "sha512-aJaAX7vHe5i66smoSSID7t4rKY08PbD8EBU7DOloixvhOozfYWdcSYE4l6/tjkZ0vBZhGjheWzB2mh31sLgCMA==", + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.12.tgz", + "integrity": "sha512-daO7SJn4eM6ArbmrEs+/BTbH7af8AEbSL3OMQdcRvvn8tuUcR5rU2n6DgxIV53aXMS42uwK8NgKKCh5XgqYOPQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.13", - "@smithy/middleware-endpoint": "^4.4.28", - "@smithy/middleware-stack": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-stream": "^4.5.21", + "@smithy/core": "^3.23.16", + "@smithy/middleware-endpoint": "^4.4.31", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.24", "tslib": "^2.6.2" }, "engines": { @@ -3629,9 +3664,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", - "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -3641,13 +3676,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", - "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.14.tgz", + "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/querystring-parser": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -3777,12 +3812,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", - "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.14.tgz", + "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -3790,13 +3825,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", - "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.3.tgz", + "integrity": "sha512-idjUvd4M9Jj6rXkhqw4H4reHoweuK4ZxYWyOrEp4N2rOF5VtaOlQGLDQJva/8WanNXk9ScQtsAb7o5UHGvFm4A==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/service-error-classification": "^4.3.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -3804,14 +3839,14 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.21", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.21.tgz", - "integrity": "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q==", + "version": "4.5.24", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.24.tgz", + "integrity": "sha512-na5vv2mBSDzXewLEEoWGI7LQQkfpmFEomBsmOpzLFjqGctm0iMwXY5lAwesY9pIaErkccW0qzEOUcYP+WKneXg==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/node-http-handler": "^4.5.1", - "@smithy/types": "^4.13.1", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.0", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", @@ -7379,9 +7414,9 @@ "license": "Unlicense" }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", "funding": [ { "type": "github", @@ -11197,9 +11232,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", - "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "funding": [ { "type": "github", @@ -12649,9 +12684,9 @@ } }, "node_modules/strnum": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", - "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", "funding": [ { "type": "github", @@ -13946,6 +13981,7 @@ "license": "AGPL-3.0-only", "dependencies": { "@aws-sdk/client-s3": "^3.1020.0", + "@aws-sdk/s3-request-presigner": "^3.1034.0", "@kiroo/sdk": "^0.1.2", "@supabase/supabase-js": "^2.84.0", "bcryptjs": "^3.0.2", diff --git a/packages/common/package.json b/packages/common/package.json index ef69adf5..2affd6e1 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -6,6 +6,7 @@ "main": "src/index.js", "dependencies": { "@aws-sdk/client-s3": "^3.1020.0", + "@aws-sdk/s3-request-presigner": "^3.1034.0", "@kiroo/sdk": "^0.1.2", "@supabase/supabase-js": "^2.84.0", "bcryptjs": "^3.0.2", diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 3b735bdd..3c420201 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -84,7 +84,7 @@ const { } = require("./utils/project.helpers"); const QueryEngine = require("./utils/queryEngine"); const { registry, storageRegistry } = require("./utils/registry"); -const { getStorage } = require("./utils/storage.manager"); +const { getStorage, getPresignedUploadUrl, verifyUploadedFile } = require("./utils/storage.manager"); const validateEnv = require("./utils/validateEnv"); const { validateData, validateUpdateData } = require("./utils/validateData"); const sessionManager = require("./utils/session.manager"); @@ -168,4 +168,6 @@ module.exports = { ...sessionManager, ...planLimits, AppError, + getPresignedUploadUrl, + verifyUploadedFile, }; diff --git a/packages/common/src/utils/storage.manager.js b/packages/common/src/utils/storage.manager.js index d5fc7aee..df4da987 100644 --- a/packages/common/src/utils/storage.manager.js +++ b/packages/common/src/utils/storage.manager.js @@ -5,9 +5,13 @@ const { S3Client, PutObjectCommand, DeleteObjectsCommand, - ListObjectsV2Command + ListObjectsV2Command, + HeadObjectCommand } = require("@aws-sdk/client-s3"); +const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); +const { getBucket } = require("./project.helpers"); + const defaultSupabase = createClient( process.env.SUPABASE_URL || "https://dummy.supabase.co", process.env.SUPABASE_KEY || "dummy-key" @@ -156,4 +160,110 @@ async function getStorage(project) { return client; } -module.exports = { getStorage }; +async function getPresignedUploadUrl(project, filePath, contentType, size) { + const isExternal = !!project.resources?.storage?.isExternal; + + if (!isExternal) { + // internal — use the default supabase instance + const bucket = getBucket(project); + const { data, error } = await defaultSupabase.storage + .from(bucket) + .createSignedUploadUrl(filePath); + if (error) throw error; + return { signedUrl: data.signedUrl, token: data.token }; + } + + // external — need to decode which provider they configured + const decrypted = decrypt(project.resources.storage.config); + const config = JSON.parse(decrypted); + const provider = config.storageProvider || "supabase"; + + if (provider === "supabase") { + const supabase = await getStorage(project); + const bucket = getBucket(project); + const { data, error } = await supabase.storage + .from(bucket) + .createSignedUploadUrl(filePath); + if (error) throw error; + return { signedUrl: data.signedUrl, token: data.token }; + } + + // S3 or Cloudflare R2 + const s3Client = new S3Client({ + region: config.region || "auto", + endpoint: config.endpoint, + forcePathStyle: true, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }); + const command = new PutObjectCommand({ + Bucket: config.bucket, + Key: filePath, + ContentType: contentType, + ContentLength: size, + }); + const signedUrl = await getSignedUrl(s3Client, command, { + expiresIn: 600, + signableHeaders: new Set(["content-length"]), + }); + return { signedUrl }; +} + +async function verifyUploadedFile(project, filePath) { + const isExternal = !!project.resources?.storage?.isExternal; + + if (!isExternal) { + // internal supabase + const folder = filePath.split("/")[0]; + const fileName = filePath.split("/").slice(1).join("/"); + const bucket = getBucket(project); + const { data, error } = await defaultSupabase.storage + .from(bucket) + .list(folder, { search: fileName }); + if (error) throw error; + const match = (data || []).find((item) => item.name === fileName); + const actualSize = match?.metadata?.size; + if (!Number.isFinite(actualSize)) throw new Error("File not found after upload"); + return actualSize; + } + + const decrypted = decrypt(project.resources.storage.config); + const config = JSON.parse(decrypted); + const provider = config.storageProvider || "supabase"; + + if (provider === "supabase") { + const supabase = await getStorage(project); + const folder = filePath.split("/")[0]; + const fileName = filePath.split("/").slice(1).join("/"); + const bucket = getBucket(project); + const { data, error } = await supabase.storage + .from(bucket) + .list(folder, { search: fileName }); + if (error) throw error; + const match = (data || []).find((item) => item.name === fileName); + const actualSize = match?.metadata?.size; + if (!Number.isFinite(actualSize)) throw new Error("File not found after upload"); + return actualSize; + } + + // S3 / R2 — just ask "does this object exist and what's its size?" + const s3Client = new S3Client({ + region: config.region || "auto", + endpoint: config.endpoint, + forcePathStyle: true, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }); + const command = new HeadObjectCommand({ Bucket: config.bucket, Key: filePath }); + const head = await s3Client.send(command); + if (!Number.isFinite(head.ContentLength)) { + throw new Error("Uploaded file size could not be determined"); + } + return head.ContentLength; +} + +module.exports = { getStorage, getPresignedUploadUrl, verifyUploadedFile }; diff --git a/sdks/urbackend-sdk/src/modules/storage.ts b/sdks/urbackend-sdk/src/modules/storage.ts index ea33899e..85b6a188 100644 --- a/sdks/urbackend-sdk/src/modules/storage.ts +++ b/sdks/urbackend-sdk/src/modules/storage.ts @@ -80,27 +80,55 @@ export class StorageModule { * } */ public async upload(file: unknown, filename?: string): Promise { - const formData = new FormData(); + // figure out name, contentType and size depending on environment + let resolvedName = filename || "file"; + let contentType = "application/octet-stream"; + let fileSize: number; + let fileData: Blob | BufferSource; - if ( - typeof window === 'undefined' && - typeof Buffer !== 'undefined' && - Buffer.isBuffer(file) - ) { - // In Node.js environment, convert Buffer to Blob for standard FormData - const blob = new Blob([file as unknown as BlobPart]); - formData.append('file', blob, filename || 'file'); + if (typeof File !== "undefined" && file instanceof File) { + // browser File object + resolvedName = filename || file.name; + contentType = file.type || contentType; + fileSize = file.size; + fileData = file; + } else if (file instanceof Blob) { + contentType = file.type || contentType; + fileSize = file.size; + fileData = file; + } else if (typeof Buffer !== "undefined" && Buffer.isBuffer(file)) { + // Node.js Buffer + fileSize = (file as Buffer).length; + fileData = file as unknown as BufferSource; } else { - // Browser File/Blob or Node.js Blob/File - formData.append('file', file as unknown as Blob, filename); + throw new Error("Unsupported file type. Pass a File, Blob, or Buffer."); } - return this.client.request('POST', '/api/storage/upload', { - body: formData, - isMultipart: true, + // step 1 — ask server for a signed URL + const { signedUrl, filePath } = await this.client.request<{ signedUrl: string; filePath: string }>( + "POST", + "/api/storage/upload-request", + { body: { filename: resolvedName, contentType, size: fileSize } } + ); + + // step 2 — upload directly to cloud, server not involved + const putResponse = await fetch(signedUrl, { + method: "PUT", + headers: { "Content-Type": contentType }, + body: fileData as BodyInit, }); - } + if (!putResponse.ok) { + throw new Error(`Direct upload to cloud failed: ${putResponse.status} ${putResponse.statusText}`); + } + + // step 3 — tell server we're done so it can verify + update quota + return this.client.request( + "POST", + "/api/storage/upload-confirm", + { body: { filePath, size: fileSize } } + ); +} /** * Deletes a file from storage by its path * diff --git a/sdks/urbackend-sdk/tests/storage.test.ts b/sdks/urbackend-sdk/tests/storage.test.ts index 48929e14..67aebe60 100644 --- a/sdks/urbackend-sdk/tests/storage.test.ts +++ b/sdks/urbackend-sdk/tests/storage.test.ts @@ -7,11 +7,22 @@ const client = urBackend({ apiKey: mockApiKey }); test('upload sends FormData and returns { url, path }', async () => { const mockResponse = { url: 'http://cdn.com/file.jpg', path: '/uploads/file.jpg' }; - const fetchMock = vi.fn().mockResolvedValue({ + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve({ success: true, data: { signedUrl: 'https://signed.example/upload', filePath: '/uploads/file.jpg' } }), + }) + .mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'text/plain' }), + text: () => Promise.resolve(''), + }) + .mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), json: () => Promise.resolve({ success: true, data: mockResponse }), - }); + }); vi.stubGlobal('fetch', fetchMock); // In Node.js testing environment, use a Buffer as mock file @@ -19,10 +30,25 @@ test('upload sends FormData and returns { url, path }', async () => { expect(result).toEqual(mockResponse); expect(fetchMock).toHaveBeenCalledWith( - expect.stringContaining('/api/storage/upload'), + expect.stringContaining('/api/storage/upload-request'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ filename: 'test.jpg', contentType: 'application/octet-stream', size: 16 }), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://signed.example/upload', + expect.objectContaining({ + method: 'PUT', + headers: { 'Content-Type': 'application/octet-stream' }, + }), + ); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/storage/upload-confirm'), expect.objectContaining({ method: 'POST', - body: expect.any(Object), // FormData + body: JSON.stringify({ filePath: '/uploads/file.jpg', size: 16 }), }), ); }); @@ -50,7 +76,15 @@ test('deleteFile sends path in body', async () => { test('StorageError thrown on failure', async () => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ + vi.fn().mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve({ success: true, data: { signedUrl: 'https://signed.example/upload', filePath: '/uploads/file.jpg' } }), + }).mockResolvedValueOnce({ + ok: true, + headers: new Headers({ 'content-type': 'text/plain' }), + text: () => Promise.resolve(''), + }).mockResolvedValueOnce({ ok: false, status: 500, url: 'https://api.urbackend.bitbros.in/api/storage/upload',