diff --git a/backend/package-lock.json b/backend/package-lock.json index 902d117..fc1f839 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,7 @@ "multer": "^2.2.0", "pg": "^8.22.0", "prisma": "^7.8.0", + "sharp": "^0.35.3", "slugify": "^1.6.9", "xss": "^1.0.15" }, @@ -676,6 +677,564 @@ "hono": "^4" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3166,6 +3725,15 @@ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -6519,9 +7087,9 @@ "peer": true }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6634,6 +7202,55 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7244,7 +7861,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, diff --git a/backend/package.json b/backend/package.json index 934d781..a5037e6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -49,6 +49,7 @@ "multer": "^2.2.0", "pg": "^8.22.0", "prisma": "^7.8.0", + "sharp": "^0.35.3", "slugify": "^1.6.9", "xss": "^1.0.15" }, diff --git a/backend/src/__tests__/env.js b/backend/src/__tests__/env.js index 30148d0..06cf019 100644 --- a/backend/src/__tests__/env.js +++ b/backend/src/__tests__/env.js @@ -1,5 +1,8 @@ // Carregado pelo Jest antes de qualquer modulo ser importado // Garante que os testes usam um banco PostgreSQL isolado +const os = require('os'); +const path = require('path'); + process.env.NODE_ENV = 'test'; if (process.env.TEST_DATABASE_URL) { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; @@ -8,3 +11,7 @@ process.env.JWT_SECRET = 'jest-test-secret-key-nao-usar-em-producao'; process.env.FRONTEND_URL = 'http://localhost:8080'; process.env.PORT = '3001'; process.env.COOKIE_DOMAIN = ''; +process.env.CLOUDINARY_CLOUD_NAME = ''; +process.env.CLOUDINARY_API_KEY = ''; +process.env.CLOUDINARY_API_SECRET = ''; +process.env.UPLOADS_DIR = path.join(os.tmpdir(), `comercio-bes-test-uploads-${process.pid}`); diff --git a/backend/src/__tests__/frontendSecurity.test.js b/backend/src/__tests__/frontendSecurity.test.js index 9fd2d15..1de5182 100644 --- a/backend/src/__tests__/frontendSecurity.test.js +++ b/backend/src/__tests__/frontendSecurity.test.js @@ -32,6 +32,7 @@ function loadFrontendModule(relativePath, exports, context = {}) { vm.runInNewContext(source, { module, URL, + API_ORIGIN: 'https://api.comerciobes.com.br', window: { location: { origin: 'https://comerciobes.com.br' } }, ...context, }, { filename: relativePath }); @@ -73,6 +74,8 @@ describe('XSS e Content Security Policy do frontend', () => { .find(directive => directive.trim().startsWith('script-src')); expect(scriptSrc).toContain("script-src 'self' https://unpkg.com"); expect(scriptSrc).not.toContain("'unsafe-inline'"); + expect(meta[1]).toContain('img-src'); + expect(meta[1]).toContain('https://api.comerciobes.com.br'); // A politica portatil nao restringe conexoes: o fallback file:// da #41 // ainda precisa alcancar http://localhost:3000. expect(meta[1]).not.toContain('connect-src'); @@ -108,6 +111,9 @@ describe('XSS e Content Security Policy do frontend', () => { expect(utils.safeImageUrl('data:image/svg+xml,')).toBeNull(); expect(utils.safeImageUrl('https://res.cloudinary.com.evil.test/foto.jpg')).toBeNull(); expect(utils.safeImageUrl('/uploads/../admin/segredo.jpg')).toBeNull(); + expect(utils.safeImageUrl('/uploads/comercio-123.webp')) + .toBe('https://api.comerciobes.com.br/uploads/comercio-123.webp'); + expect(utils.safeImageUrl('https://evil.test/uploads/comercio-123.webp')).toBeNull(); expect(utils.safeCoordinates(null, null)).toBeNull(); expect(utils.safeCoordinates('', '')).toBeNull(); expect(utils.safeCoordinates(0, 0)).toBeNull(); diff --git a/backend/src/__tests__/imageUploadCloudinary.test.js b/backend/src/__tests__/imageUploadCloudinary.test.js new file mode 100644 index 0000000..8566d7d --- /dev/null +++ b/backend/src/__tests__/imageUploadCloudinary.test.js @@ -0,0 +1,70 @@ +const fs = require('fs/promises'); +const { PassThrough } = require('stream'); +const sharp = require('sharp'); + +process.env.CLOUDINARY_CLOUD_NAME = 'cloud-teste'; +process.env.CLOUDINARY_API_KEY = 'key-teste'; +process.env.CLOUDINARY_API_SECRET = 'secret-teste'; + +jest.mock('cloudinary', () => ({ + v2: { + config: jest.fn(), + uploader: { + destroy: jest.fn(), + upload_stream: jest.fn(), + }, + }, +})); + +const cloudinary = require('cloudinary').v2; +const { storeUploadedImages } = require('../lib/imageUpload'); + +describe('Compensacao do Cloudinary', () => { + afterAll(async () => { + process.env.CLOUDINARY_CLOUD_NAME = ''; + process.env.CLOUDINARY_API_KEY = ''; + process.env.CLOUDINARY_API_SECRET = ''; + await fs.rm(process.env.UPLOADS_DIR, { recursive: true, force: true }); + }); + + it('remove os dois IDs deterministas quando o thumbnail falha', async () => { + const uploadOptions = []; + let callNumber = 0; + cloudinary.uploader.destroy.mockResolvedValue({ result: 'ok' }); + cloudinary.uploader.upload_stream.mockImplementation((options, callback) => { + uploadOptions.push(options); + callNumber += 1; + const currentCall = callNumber; + const stream = new PassThrough(); + stream.on('finish', () => { + if (currentCall === 1) { + callback(null, { + public_id: `comercio-bes/${options.public_id}`, + secure_url: `https://res.cloudinary.com/cloud-teste/${options.public_id}.webp`, + }); + } else { + callback(new Error('falha simulada no thumbnail')); + } + }); + return stream; + }); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const buffer = await sharp({ + create: { + width: 32, + height: 32, + channels: 3, + background: { r: 10, g: 80, b: 160 }, + }, + }).png().toBuffer(); + + await expect(storeUploadedImages([{ buffer, mimetype: 'image/png' }])) + .rejects.toMatchObject({ code: 'REMOTE_IMAGE_STORAGE_FAILED', statusCode: 502 }); + + const expectedIds = uploadOptions.map(options => `comercio-bes/${options.public_id}`); + expect(uploadOptions).toHaveLength(2); + expect(cloudinary.uploader.destroy.mock.calls.map(call => call[0])) + .toEqual(expectedIds); + consoleSpy.mockRestore(); + }); +}); diff --git a/backend/src/__tests__/upload.test.js b/backend/src/__tests__/upload.test.js new file mode 100644 index 0000000..973b948 --- /dev/null +++ b/backend/src/__tests__/upload.test.js @@ -0,0 +1,482 @@ +const fs = require('fs/promises'); +const path = require('path'); +const crypto = require('crypto'); +const { EventEmitter } = require('events'); +const request = require('supertest'); +const sharp = require('sharp'); +const app = require('../server'); +const { + MAX_DIMENSION, + MAX_PIXELS, + cleanupStaleArtifacts, + createImageVariants, + detectImageFormat, + storeUploadedImages, + validateMetadata, +} = require('../lib/imageUpload'); +const { cleanDatabase, criarUsuarioTeste } = require('./helpers'); +const { limitUploadConcurrency } = require('../middleware/upload'); +const { uploadImagem } = require('../controllers/uploadController'); +const errorHandler = require('../middleware/errorHandler'); + +const uploadsDir = process.env.UPLOADS_DIR; +let validPng; +let pixelBombPng; +let tooWidePng; +let metadataJpeg; +let truncatedPng; + +async function filesBelow(directory) { + let entries; + try { + entries = await fs.readdir(directory, { withFileTypes: true }); + } catch (error) { + if (error.code === 'ENOENT') return []; + throw error; + } + + const files = []; + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) files.push(...await filesBelow(entryPath)); + if (entry.isFile()) files.push(entryPath); + } + return files; +} + +async function exists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +beforeAll(async () => { + validPng = await sharp({ + create: { + width: 2_000, + height: 1_300, + channels: 3, + background: { r: 28, g: 160, b: 110 }, + }, + }).png().toBuffer(); + + pixelBombPng = await sharp({ + create: { + width: 5_000, + height: 4_001, + channels: 3, + background: { r: 200, g: 20, b: 20 }, + }, + }).png({ compressionLevel: 9 }).toBuffer(); + + tooWidePng = await sharp({ + create: { + width: MAX_DIMENSION + 1, + height: 1, + channels: 3, + background: { r: 20, g: 20, b: 200 }, + }, + }).png().toBuffer(); + + metadataJpeg = await sharp(validPng) + .withMetadata({ exif: { IFD0: { Artist: 'Comercio BES' } } }) + .jpeg() + .toBuffer(); + + const randomPixels = crypto.randomBytes(256 * 256 * 3); + const fullRandomPng = await sharp(randomPixels, { + raw: { width: 256, height: 256, channels: 3 }, + }).png({ compressionLevel: 0 }).toBuffer(); + truncatedPng = fullRandomPng.subarray(0, Math.floor(fullRandomPng.length * 0.9)); +}); + +describe('Pipeline de imagem', () => { + it('detecta magic bytes sem confiar no nome do arquivo', async () => { + const validJpeg = await sharp(validPng).jpeg().toBuffer(); + const validWebp = await sharp(validPng).webp().toBuffer(); + + expect(detectImageFormat(validPng)).toBe('png'); + expect(detectImageFormat(validJpeg)).toBe('jpeg'); + expect(detectImageFormat(validWebp)).toBe('webp'); + expect(detectImageFormat(Buffer.from(''))).toBeNull(); + }); + + it('rejeita dimensoes, pixels e animacao acima da politica', async () => { + expect(() => validateMetadata({ + format: 'png', width: MAX_DIMENSION + 1, height: 1, pages: 1, + }, 'png')).toThrow(/limite/i); + + expect(() => validateMetadata({ + format: 'png', width: 5_000, height: Math.floor(MAX_PIXELS / 5_000) + 1, pages: 1, + }, 'png')).toThrow(/pixels/i); + + expect(() => validateMetadata({ + format: 'webp', width: 100, height: 100, pages: 2, + }, 'webp')).toThrow(/animadas/i); + + await expect(createImageVariants({ + buffer: pixelBombPng, mimetype: 'image/png', + })).rejects.toMatchObject({ statusCode: 422 }); + await expect(createImageVariants({ + buffer: tooWidePng, mimetype: 'image/png', + })).rejects.toMatchObject({ code: 'IMAGE_DIMENSIONS_EXCEEDED', statusCode: 422 }); + await expect(createImageVariants({ + buffer: truncatedPng, mimetype: 'image/png', + })).rejects.toMatchObject({ code: 'INVALID_IMAGE_DATA', statusCode: 422 }); + }); + + it('decodifica e gera WebP principal e thumbnail sem metadata do original', async () => { + const original = await sharp(metadataJpeg).metadata(); + const image = await createImageVariants({ buffer: metadataJpeg, mimetype: 'image/jpeg' }); + const main = await sharp(image.mainBuffer).metadata(); + const thumbnail = await sharp(image.thumbnailBuffer).metadata(); + + expect(original.exif).toBeDefined(); + expect(main.format).toBe('webp'); + expect(main.width).toBeLessThanOrEqual(1_600); + expect(main.height).toBeLessThanOrEqual(1_600); + expect(main.exif).toBeUndefined(); + expect(thumbnail.format).toBe('webp'); + expect(thumbnail.width).toBe(480); + expect(thumbnail.height).toBe(360); + }); + + it('faz rollback do que ja foi publicado quando o lote falha', async () => { + const stored = { url: '/uploads/primeira.webp', storage: 'fake' }; + const store = jest.fn() + .mockResolvedValueOnce(stored) + .mockRejectedValueOnce(new Error('storage indisponivel')); + const remove = jest.fn().mockResolvedValue(undefined); + + await expect(storeUploadedImages([ + { buffer: validPng, mimetype: 'image/png' }, + { buffer: validPng, mimetype: 'image/png' }, + ], { store, remove })).rejects.toThrow(/storage indisponivel/i); + + expect(remove).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledWith(stored); + }); + + it('interrompe o lote e desfaz o publicado depois do abandono', async () => { + const stored = { url: '/uploads/primeira.webp', storage: 'fake' }; + const store = jest.fn().mockResolvedValue(stored); + const remove = jest.fn().mockResolvedValue(undefined); + const shouldContinue = jest.fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValue(false); + + await expect(storeUploadedImages([ + { buffer: validPng, mimetype: 'image/png' }, + { buffer: validPng, mimetype: 'image/png' }, + ], { store, remove, shouldContinue })).rejects.toMatchObject({ code: 'UPLOAD_ABORTED' }); + + expect(store).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledWith(stored); + }); + + it('nao serve o staging oculto', async () => { + const hiddenDir = path.join(uploadsDir, '.staging'); + await fs.mkdir(hiddenDir, { recursive: true }); + await fs.writeFile(path.join(hiddenDir, 'segredo.tmp'), 'nao publicar'); + + const res = await request(app).get('/uploads/.staging/segredo.tmp'); + + expect([403, 404]).toContain(res.status); + await fs.rm(uploadsDir, { recursive: true, force: true }); + }); + + it('remove residuos recentes assim que uma varredura futura cruza o limite', async () => { + const hiddenDir = path.join(uploadsDir, '.staging'); + const staged = path.join(hiddenDir, 'interrompido.tmp'); + const orphanThumbnail = path.join(uploadsDir, 'comercio-orfao-thumb.webp'); + const pairedMain = path.join(uploadsDir, 'comercio-par.webp'); + const pairedThumbnail = path.join(uploadsDir, 'comercio-par-thumb.webp'); + + await fs.mkdir(hiddenDir, { recursive: true }); + await Promise.all([ + fs.writeFile(staged, 'staging'), + fs.writeFile(orphanThumbnail, 'thumb'), + fs.writeFile(pairedMain, 'main'), + fs.writeFile(pairedThumbnail, 'thumb'), + ]); + await cleanupStaleArtifacts(Date.now() + 2 * 60 * 60 * 1000); + + expect(await exists(staged)).toBe(false); + expect(await exists(orphanThumbnail)).toBe(false); + expect(await exists(pairedMain)).toBe(true); + expect(await exists(pairedThumbnail)).toBe(true); + await fs.rm(uploadsDir, { recursive: true, force: true }); + }); + + it('limita o processamento simultaneo sem consumir uma vaga para sempre', () => { + const response = () => { + const res = new EventEmitter(); + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; + }; + const first = response(); + const second = response(); + const rejected = response(); + const afterRelease = response(); + + const firstNext = jest.fn(); + const secondNext = jest.fn(); + limitUploadConcurrency({}, first, firstNext); + limitUploadConcurrency({}, second, secondNext); + limitUploadConcurrency({}, rejected, jest.fn()); + + expect(firstNext).toHaveBeenCalledTimes(1); + expect(secondNext).toHaveBeenCalledTimes(1); + expect(rejected.status).toHaveBeenCalledWith(503); + + first.emit('finish'); + const afterReleaseNext = jest.fn(); + limitUploadConcurrency({}, afterRelease, afterReleaseNext); + expect(afterReleaseNext).toHaveBeenCalledTimes(1); + + second.emit('finish'); + afterRelease.emit('finish'); + }); + + it('segura a vaga e remove o resultado quando o cliente abandona o processamento', async () => { + const companion = new EventEmitter(); + companion.status = jest.fn().mockReturnValue(companion); + companion.json = jest.fn().mockReturnValue(companion); + limitUploadConcurrency({}, companion, jest.fn()); + + const req = { file: { buffer: validPng, mimetype: 'image/png' } }; + const res = new EventEmitter(); + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.writableEnded = false; + const next = jest.fn(); + + limitUploadConcurrency(req, res, jest.fn()); + const processing = uploadImagem(req, res, next); + res.emit('close'); + + const whileProcessing = new EventEmitter(); + whileProcessing.status = jest.fn().mockReturnValue(whileProcessing); + whileProcessing.json = jest.fn().mockReturnValue(whileProcessing); + limitUploadConcurrency({}, whileProcessing, jest.fn()); + expect(whileProcessing.status).toHaveBeenCalledWith(503); + + await processing; + + expect(res.json).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + expect(await filesBelow(uploadsDir)).toEqual([]); + + companion.emit('finish'); + const afterAbort = new EventEmitter(); + afterAbort.status = jest.fn().mockReturnValue(afterAbort); + afterAbort.json = jest.fn().mockReturnValue(afterAbort); + const afterAbortNext = jest.fn(); + limitUploadConcurrency({}, afterAbort, afterAbortNext); + expect(afterAbortNext).toHaveBeenCalledTimes(1); + afterAbort.emit('finish'); + }); + + it('trata formulario multipart truncado como erro do cliente', () => { + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + errorHandler(new Error('Unexpected end of form'), {}, res, jest.fn()); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Formulario de upload incompleto' }); + consoleSpy.mockRestore(); + }); +}); + +describe('POST /api/upload', () => { + beforeEach(async () => { + await cleanDatabase(); + await fs.rm(uploadsDir, { recursive: true, force: true }); + await fs.mkdir(uploadsDir, { recursive: true }); + }); + + afterAll(async () => { + await fs.rm(uploadsDir, { recursive: true, force: true }); + const prisma = require('../lib/prisma'); + await prisma.$disconnect(); + }); + + it('aplica auth, CSRF e role antes de receber os bytes', async () => { + const semSessao = await request(app) + .post('/api/upload') + .attach('imagem', validPng, { filename: 'foto.png', contentType: 'image/png' }); + expect(semSessao.status).toBe(401); + + const cliente = await criarUsuarioTeste(app, { tipo: 'cliente' }); + const semRole = await cliente.agent + .post('/api/upload') + .set('X-CSRF-Token', cliente.csrfToken) + .attach('imagem', validPng, { filename: 'foto.png', contentType: 'image/png' }); + expect(semRole.status).toBe(403); + + const comerciante = await criarUsuarioTeste(app, { + tipo: 'comerciante', + email: 'upload-sem-csrf@example.com', + }); + const semCsrf = await comerciante.agent + .post('/api/upload') + .attach('imagem', validPng, { filename: 'foto.png', contentType: 'image/png' }); + expect(semCsrf.status).toBe(403); + + expect(await filesBelow(uploadsDir)).toEqual([]); + }); + + it('rejeita arquivo falso, MIME divergente e imagem truncada sem deixar residuos', async () => { + const session = await criarUsuarioTeste(app, { tipo: 'comerciante' }); + + const fake = await session.agent + .post('/api/upload') + .set('X-CSRF-Token', session.csrfToken) + .attach('imagem', Buffer.from('nao sou imagem'), { + filename: 'fachada.jpg', contentType: 'image/jpeg', + }); + expect(fake.status).toBe(415); + + const mimeDivergente = await session.agent + .post('/api/upload') + .set('X-CSRF-Token', session.csrfToken) + .attach('imagem', validPng, { filename: 'fachada.jpg', contentType: 'image/jpeg' }); + expect(mimeDivergente.status).toBe(415); + + const truncada = await session.agent + .post('/api/upload') + .set('X-CSRF-Token', session.csrfToken) + .attach('imagem', truncatedPng, { + filename: 'fachada.png', contentType: 'image/png', + }); + expect(truncada.status).toBe(422); + + const pixelBomb = await session.agent + .post('/api/upload') + .set('X-CSRF-Token', session.csrfToken) + .attach('imagem', pixelBombPng, { + filename: 'pixel-bomb.png', contentType: 'image/png', + }); + expect(pixelBomb.status).toBe(422); + + const dimensaoGigante = await session.agent + .post('/api/upload') + .set('X-CSRF-Token', session.csrfToken) + .attach('imagem', tooWidePng, { + filename: 'larga.png', contentType: 'image/png', + }); + expect(dimensaoGigante.status).toBe(422); + + expect(await filesBelow(uploadsDir)).toEqual([]); + }); + + it('publica somente os dois WebPs normalizados', async () => { + const session = await criarUsuarioTeste(app, { tipo: 'comerciante' }); + const res = await session.agent + .post('/api/upload') + .set('X-CSRF-Token', session.csrfToken) + .attach('imagem', validPng, { filename: '../../fachada.png', contentType: 'image/png' }); + + expect(res.status).toBe(201); + expect(res.body.storage).toBe('local'); + expect(res.body.url).toMatch(/^\/uploads\/comercio-[a-f0-9-]+\.webp$/); + expect(res.body.thumbnailUrl).toMatch(/-thumb\.webp$/); + + const storedFiles = await filesBelow(uploadsDir); + expect(storedFiles).toHaveLength(2); + expect(storedFiles.every(file => file.endsWith('.webp'))).toBe(true); + expect(storedFiles.some(file => file.includes('fachada.png'))).toBe(false); + + const main = await sharp(path.join(uploadsDir, path.basename(res.body.url))).metadata(); + const thumbnail = await sharp(path.join(uploadsDir, path.basename(res.body.thumbnailUrl))).metadata(); + expect(main.format).toBe('webp'); + expect(main.width).toBeLessThanOrEqual(1_600); + expect(thumbnail.width).toBe(480); + expect(thumbnail.height).toBe(360); + }); + + it('trata o lote como all-or-nothing', async () => { + const session = await criarUsuarioTeste(app, { tipo: 'comerciante' }); + const res = await session.agent + .post('/api/upload/multiplo') + .set('X-CSRF-Token', session.csrfToken) + .attach('imagens', validPng, { filename: 'valida.png', contentType: 'image/png' }) + .attach('imagens', Buffer.from('arquivo falso'), { + filename: 'falsa.png', contentType: 'image/png', + }); + + expect(res.status).toBe(415); + expect(await filesBelow(uploadsDir)).toEqual([]); + }); + + it('aceita o limite documentado de cinco imagens no mesmo lote', async () => { + const session = await criarUsuarioTeste(app, { tipo: 'comerciante' }); + let uploadRequest = session.agent + .post('/api/upload/multiplo') + .set('X-CSRF-Token', session.csrfToken); + + for (let index = 0; index < 5; index += 1) { + uploadRequest = uploadRequest.attach('imagens', validPng, { + filename: `imagem-${index + 1}.png`, + contentType: 'image/png', + }); + } + + const res = await uploadRequest; + + expect(res.status).toBe(201); + expect(res.body.images).toHaveLength(5); + expect(await filesBelow(uploadsDir)).toHaveLength(10); + }); + + it('rejeita o sexto arquivo e campos extras sem publicar residuos', async () => { + const session = await criarUsuarioTeste(app, { tipo: 'comerciante' }); + let sixFilesRequest = session.agent + .post('/api/upload/multiplo') + .set('X-CSRF-Token', session.csrfToken); + + for (let index = 0; index < 6; index += 1) { + sixFilesRequest = sixFilesRequest.attach('imagens', validPng, { + filename: `imagem-${index + 1}.png`, + contentType: 'image/png', + }); + } + + const sixFiles = await sixFilesRequest; + const extraField = await session.agent + .post('/api/upload') + .set('X-CSRF-Token', session.csrfToken) + .field('descricao', 'nao permitido') + .attach('imagem', validPng, { filename: 'foto.png', contentType: 'image/png' }); + + expect(sixFiles.status).toBe(400); + expect(extraField.status).toBe(400); + expect(await filesBelow(uploadsDir)).toEqual([]); + }); + + it('rejeita arquivo comprimido acima de 5MB antes do decoder', async () => { + const session = await criarUsuarioTeste(app, { tipo: 'comerciante' }); + const tooLarge = Buffer.alloc(5 * 1024 * 1024 + 1); + tooLarge[0] = 0xff; + tooLarge[1] = 0xd8; + tooLarge[2] = 0xff; + + const res = await session.agent + .post('/api/upload') + .set('X-CSRF-Token', session.csrfToken) + .attach('imagem', tooLarge, { filename: 'grande.jpg', contentType: 'image/jpeg' }); + + expect(res.status).toBe(413); + expect(await filesBelow(uploadsDir)).toEqual([]); + }); +}); diff --git a/backend/src/controllers/uploadController.js b/backend/src/controllers/uploadController.js index 214dcef..4d48f4e 100644 --- a/backend/src/controllers/uploadController.js +++ b/backend/src/controllers/uploadController.js @@ -1,66 +1,79 @@ // =========================================== -// Controller - Upload de Imagens +// Controller - Upload de Imagens Normalizadas // =========================================== -const { uploadToCloudinary, cloudinaryConfigured } = require('../middleware/upload'); -const path = require('path'); +const { + cleanupStoredImages, + publicImage, + storeUploadedImages, +} = require('../lib/imageUpload'); +const { + beginUploadProcessing, + finishUploadProcessing, + uploadClientDisconnected, +} = require('../middleware/upload'); + +async function processImages(req, files) { + const stored = await storeUploadedImages(files, { + shouldContinue: () => !uploadClientDisconnected(req), + }); + if (uploadClientDisconnected(req)) { + await cleanupStoredImages(stored); + return null; + } + return stored.map(publicImage); +} // POST /api/upload async function uploadImagem(req, res, next) { + if (!beginUploadProcessing(req)) { + finishUploadProcessing(req); + return; + } + try { if (!req.file) { return res.status(400).json({ error: 'Nenhum arquivo enviado' }); } - let url; - if (cloudinaryConfigured) { - // Enviar para Cloudinary e retornar URL - const cloudinaryUrl = await uploadToCloudinary(req.file.path); - if (cloudinaryUrl) { - url = cloudinaryUrl; - } else { - url = `/uploads/${req.file.filename}`; - } - } else { - // Upload local - url = `/uploads/${req.file.filename}`; - } - + const images = await processImages(req, [req.file]); + if (!images) return; + const [image] = images; res.status(201).json({ message: 'Imagem enviada com sucesso', - url, - filename: req.file.filename, - storage: cloudinaryConfigured ? 'cloudinary' : 'local' + ...image, }); - } catch (err) { - next(err); + } catch (error) { + if (!uploadClientDisconnected(req)) next(error); + } finally { + finishUploadProcessing(req); } } // POST /api/upload/multiplo async function uploadMultiplo(req, res, next) { + if (!beginUploadProcessing(req)) { + finishUploadProcessing(req); + return; + } + try { if (!req.files || req.files.length === 0) { return res.status(400).json({ error: 'Nenhum arquivo enviado' }); } - const urls = []; - - for (const file of req.files) { - if (cloudinaryConfigured) { - const cloudinaryUrl = await uploadToCloudinary(file.path); - urls.push(cloudinaryUrl || `/uploads/${file.filename}`); - } else { - urls.push(`/uploads/${file.filename}`); - } - } - + const images = await processImages(req, req.files); + if (!images) return; res.status(201).json({ - message: `${urls.length} imagens enviadas com sucesso`, - urls, - storage: cloudinaryConfigured ? 'cloudinary' : 'local' + message: `${images.length} imagens enviadas com sucesso`, + images, + urls: images.map(image => image.url), + thumbnails: images.map(image => image.thumbnailUrl), + storage: images[0].storage, }); - } catch (err) { - next(err); + } catch (error) { + if (!uploadClientDisconnected(req)) next(error); + } finally { + finishUploadProcessing(req); } } diff --git a/backend/src/lib/imageUpload.js b/backend/src/lib/imageUpload.js new file mode 100644 index 0000000..4819796 --- /dev/null +++ b/backend/src/lib/imageUpload.js @@ -0,0 +1,407 @@ +// =========================================== +// Validacao, normalizacao e armazenamento de imagens +// =========================================== +const crypto = require('crypto'); +const fs = require('fs/promises'); +const path = require('path'); +const sharp = require('sharp'); + +const MAX_PIXELS = 20_000_000; +const MAX_DIMENSION = 6_000; +const MAIN_MAX_SIZE = 1_600; +const THUMB_WIDTH = 480; +const THUMB_HEIGHT = 360; +const STALE_ARTIFACT_MS = 60 * 60 * 1000; +const CLEANUP_INTERVAL_MS = 15 * 60 * 1000; +const REMOTE_UPLOAD_TIMEOUT_MS = 30 * 1000; + +const uploadsDir = process.env.UPLOADS_DIR || path.join(__dirname, '..', '..', 'uploads'); +const stagingDir = path.join(uploadsDir, '.staging'); + +const cloudinaryConfigured = Boolean( + process.env.CLOUDINARY_CLOUD_NAME && + process.env.CLOUDINARY_API_KEY && + process.env.CLOUDINARY_API_SECRET +); + +let cloudinary = null; +if (cloudinaryConfigured) { + cloudinary = require('cloudinary').v2; + cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, + }); +} + +class ImageUploadError extends Error { + constructor(message, statusCode, code) { + super(message); + this.name = 'ImageUploadError'; + this.statusCode = statusCode; + this.code = code; + } +} + +function detectImageFormat(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null; + + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return 'jpeg'; + } + + const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + if (buffer.subarray(0, 8).equals(pngSignature)) return 'png'; + + if (buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') { + return 'webp'; + } + + return null; +} + +function mimeFormat(mimetype) { + return ({ + 'image/jpeg': 'jpeg', + 'image/png': 'png', + 'image/webp': 'webp', + })[String(mimetype || '').toLowerCase()] || null; +} + +function validateMetadata(metadata, detectedFormat) { + if (!metadata || !['jpeg', 'png', 'webp'].includes(metadata.format)) { + throw new ImageUploadError('Formato de imagem nao suportado', 415, 'UNSUPPORTED_IMAGE_FORMAT'); + } + + if (metadata.format !== detectedFormat) { + throw new ImageUploadError('Assinatura e formato da imagem nao conferem', 415, 'IMAGE_FORMAT_MISMATCH'); + } + + const { width, height } = metadata; + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) { + throw new ImageUploadError('Dimensoes da imagem invalidas', 422, 'INVALID_IMAGE_DIMENSIONS'); + } + + if (width > MAX_DIMENSION || height > MAX_DIMENSION || width * height > MAX_PIXELS) { + throw new ImageUploadError( + `Imagem excede o limite de ${MAX_DIMENSION}px por eixo e ${MAX_PIXELS} pixels`, + 422, + 'IMAGE_DIMENSIONS_EXCEEDED' + ); + } + + if ((metadata.pages || 1) > 1) { + throw new ImageUploadError('Imagens animadas nao sao permitidas', 422, 'ANIMATED_IMAGE_NOT_ALLOWED'); + } +} + +function sharpInput(buffer) { + return sharp(buffer, { + animated: false, + failOn: 'warning', + limitInputChannels: 4, + limitInputPixels: MAX_PIXELS, + sequentialRead: true, + }); +} + +async function createImageVariants(file) { + const detectedFormat = detectImageFormat(file && file.buffer); + if (!detectedFormat) { + throw new ImageUploadError( + 'Conteudo nao e uma imagem JPEG, PNG ou WebP valida', + 415, + 'INVALID_IMAGE_SIGNATURE' + ); + } + + if (mimeFormat(file.mimetype) !== detectedFormat) { + throw new ImageUploadError( + 'O tipo informado nao corresponde ao conteudo da imagem', + 415, + 'IMAGE_MIME_MISMATCH' + ); + } + + let metadata; + try { + metadata = await sharpInput(file.buffer).metadata(); + } catch { + throw new ImageUploadError('Imagem corrompida ou incompleta', 422, 'INVALID_IMAGE_DATA'); + } + + validateMetadata(metadata, detectedFormat); + + try { + // A primeira saida força a decodificacao completa e remove EXIF/GPS. + const main = await sharpInput(file.buffer) + .rotate() + .resize({ + width: MAIN_MAX_SIZE, + height: MAIN_MAX_SIZE, + fit: 'inside', + withoutEnlargement: true, + }) + .toColourspace('srgb') + .webp({ quality: 82, effort: 4 }) + .toBuffer({ resolveWithObject: true }); + + const thumbnail = await sharp(main.data) + .resize({ + width: THUMB_WIDTH, + height: THUMB_HEIGHT, + fit: 'cover', + position: 'attention', + withoutEnlargement: false, + }) + .webp({ quality: 75, effort: 4 }) + .toBuffer(); + + return { + id: `comercio-${crypto.randomUUID()}`, + mainBuffer: main.data, + thumbnailBuffer: thumbnail, + width: main.info.width, + height: main.info.height, + }; + } catch (error) { + if (error instanceof ImageUploadError) throw error; + throw new ImageUploadError('Imagem corrompida ou incompleta', 422, 'INVALID_IMAGE_DATA'); + } +} + +async function settleAndLog(promises, message) { + const results = await Promise.allSettled(promises); + results.forEach(result => { + if (result.status === 'rejected') { + console.error(`[Upload] ${message}:`, result.reason?.message || result.reason); + } + }); + return results; +} + +async function removePaths(paths) { + await settleAndLog((paths || []).map(async filePath => { + try { + await fs.unlink(filePath); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + }), 'Falha ao remover arquivo local'); +} + +async function storeLocalImage(image) { + await fs.mkdir(stagingDir, { recursive: true }); + await fs.mkdir(uploadsDir, { recursive: true }); + + const filename = `${image.id}.webp`; + const thumbnailFilename = `${image.id}-thumb.webp`; + const finalMain = path.join(uploadsDir, filename); + const finalThumbnail = path.join(uploadsDir, thumbnailFilename); + const stagedMain = path.join(stagingDir, `${filename}.tmp`); + const stagedThumbnail = path.join(stagingDir, `${thumbnailFilename}.tmp`); + const touched = [stagedMain, stagedThumbnail, finalMain, finalThumbnail]; + + try { + await fs.writeFile(stagedMain, image.mainBuffer, { flag: 'wx' }); + await fs.writeFile(stagedThumbnail, image.thumbnailBuffer, { flag: 'wx' }); + + // Publica o thumbnail primeiro. Se o processo cair entre os renames, a + // varredura remove thumbnails antigos que nao possuem imagem principal. + await fs.rename(stagedThumbnail, finalThumbnail); + await fs.rename(stagedMain, finalMain); + + return { + url: `/uploads/${filename}`, + thumbnailUrl: `/uploads/${thumbnailFilename}`, + filename, + storage: 'local', + width: image.width, + height: image.height, + _localPaths: [finalMain, finalThumbnail], + }; + } catch (error) { + await removePaths(touched); + throw new ImageUploadError('Nao foi possivel armazenar a imagem', 500, 'IMAGE_STORAGE_FAILED'); + } +} + +function uploadCloudinaryBuffer(buffer, publicId) { + return new Promise((resolve, reject) => { + let settled = false; + let timeout; + const finish = (error, result) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (error) reject(error); + else if (!result || !result.secure_url || !result.public_id) { + reject(new Error('Resposta incompleta do Cloudinary')); + } else resolve(result); + }; + + const stream = cloudinary.uploader.upload_stream({ + folder: 'comercio-bes', + format: 'webp', + overwrite: false, + public_id: publicId, + resource_type: 'image', + unique_filename: false, + }, finish); + + timeout = setTimeout(() => { + stream.destroy(); + finish(new Error('Timeout ao armazenar imagem no Cloudinary')); + }, REMOTE_UPLOAD_TIMEOUT_MS); + + stream.end(buffer); + }); +} + +async function destroyCloudinary(publicIds) { + if (!cloudinary) return; + await settleAndLog((publicIds || []).map(publicId => + cloudinary.uploader.destroy(publicId, { invalidate: true, resource_type: 'image' }) + ), 'Falha ao remover ativo remoto'); +} + +async function storeCloudinaryImage(image) { + // Os IDs sao conhecidos antes da chamada. Assim um timeout ambiguo ainda + // permite compensar tanto o principal quanto o thumbnail. + const publicIds = [ + `comercio-bes/${image.id}`, + `comercio-bes/${image.id}-thumb`, + ]; + + try { + const main = await uploadCloudinaryBuffer(image.mainBuffer, image.id); + const thumbnail = await uploadCloudinaryBuffer(image.thumbnailBuffer, `${image.id}-thumb`); + + return { + url: main.secure_url, + thumbnailUrl: thumbnail.secure_url, + filename: `${image.id}.webp`, + storage: 'cloudinary', + width: image.width, + height: image.height, + _cloudinaryPublicIds: publicIds, + }; + } catch (error) { + await destroyCloudinary(publicIds); + console.error('[Upload] Falha no Cloudinary:', error.message); + throw new ImageUploadError('Armazenamento remoto indisponivel', 502, 'REMOTE_IMAGE_STORAGE_FAILED'); + } +} + +async function storePreparedImage(image) { + return cloudinaryConfigured + ? storeCloudinaryImage(image) + : storeLocalImage(image); +} + +async function cleanupStoredImage(image) { + await removePaths(image && image._localPaths); + await destroyCloudinary(image && image._cloudinaryPublicIds); +} + +async function cleanupStoredImages(images) { + await settleAndLog((images || []).map(cleanupStoredImage), 'Falha no rollback do lote'); +} + +async function storeUploadedImages(files, options = {}) { + const sourceFiles = Array.isArray(files) ? files : []; + const store = options.store || storePreparedImage; + const remove = options.remove || cleanupStoredImage; + const shouldContinue = options.shouldContinue || (() => true); + const assertActive = () => { + if (shouldContinue()) return; + const error = new Error('Upload cancelado pelo cliente'); + error.code = 'UPLOAD_ABORTED'; + throw error; + }; + + // Valida e gera todo o lote antes de publicar qualquer item. + const prepared = []; + for (const file of sourceFiles) { + assertActive(); + prepared.push(await createImageVariants(file)); + } + + const stored = []; + try { + for (const image of prepared) { + assertActive(); + stored.push(await store(image)); + } + return stored; + } catch (error) { + await settleAndLog( + stored.reverse().map(image => remove(image)), + 'Falha no rollback do lote' + ); + throw error; + } +} + +function publicImage(image) { + return { + url: image.url, + thumbnailUrl: image.thumbnailUrl, + filename: image.filename, + storage: image.storage, + width: image.width, + height: image.height, + }; +} + +async function cleanupStaleArtifacts(now = Date.now()) { + await fs.mkdir(stagingDir, { recursive: true }); + + const stagingEntries = await fs.readdir(stagingDir, { withFileTypes: true }); + await settleAndLog(stagingEntries + .filter(entry => entry.isFile()) + .map(async entry => { + const filePath = path.join(stagingDir, entry.name); + const stat = await fs.stat(filePath); + if (now - stat.mtimeMs > STALE_ARTIFACT_MS) await fs.unlink(filePath); + }), 'Falha ao limpar staging antigo'); + + const uploadEntries = await fs.readdir(uploadsDir, { withFileTypes: true }); + await settleAndLog(uploadEntries + .filter(entry => entry.isFile() && /^comercio-.+-thumb\.webp$/.test(entry.name)) + .map(async entry => { + const thumbnailPath = path.join(uploadsDir, entry.name); + const mainPath = path.join(uploadsDir, entry.name.replace(/-thumb\.webp$/, '.webp')); + const stat = await fs.stat(thumbnailPath); + if (now - stat.mtimeMs <= STALE_ARTIFACT_MS) return; + try { + await fs.access(mainPath); + } catch { + await fs.unlink(thumbnailPath); + } + }), 'Falha ao limpar thumbnail orfao'); +} + +cleanupStaleArtifacts().catch(error => { + console.warn('[Upload] Nao foi possivel limpar staging antigo:', error.message); +}); + +const cleanupTimer = setInterval(() => { + cleanupStaleArtifacts().catch(error => { + console.warn('[Upload] Nao foi possivel limpar staging antigo:', error.message); + }); +}, CLEANUP_INTERVAL_MS); +cleanupTimer.unref(); + +module.exports = { + ImageUploadError, + MAX_DIMENSION, + MAX_PIXELS, + cleanupStoredImages, + cleanupStaleArtifacts, + createImageVariants, + detectImageFormat, + publicImage, + storeUploadedImages, + validateMetadata, +}; diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js index 743d4fc..58fa061 100644 --- a/backend/src/middleware/errorHandler.js +++ b/backend/src/middleware/errorHandler.js @@ -39,7 +39,7 @@ function errorHandler(err, req, res, next) { // Erro do Multer (upload) if (err.code === 'LIMIT_FILE_SIZE') { - return res.status(400).json({ + return res.status(413).json({ error: 'Arquivo muito grande. Maximo: 5MB' }); } @@ -50,6 +50,24 @@ function errorHandler(err, req, res, next) { }); } + if ([ + 'LIMIT_FILE_COUNT', + 'LIMIT_FIELD_COUNT', + 'LIMIT_FIELD_KEY', + 'LIMIT_FIELD_VALUE', + 'LIMIT_PART_COUNT', + ].includes(err.code)) { + return res.status(400).json({ + error: 'Limite do formulario de upload excedido' + }); + } + + if (/unexpected end of form/i.test(err.message || '')) { + return res.status(400).json({ + error: 'Formulario de upload incompleto' + }); + } + // Erro generico const status = err.statusCode || 500; const message = (status === 500 && process.env.NODE_ENV === 'production') diff --git a/backend/src/middleware/upload.js b/backend/src/middleware/upload.js index c701683..45f29ef 100644 --- a/backend/src/middleware/upload.js +++ b/backend/src/middleware/upload.js @@ -1,81 +1,90 @@ // =========================================== -// Middleware - Upload de Imagens +// Middleware - Recepcao limitada de imagens // =========================================== const multer = require('multer'); -const path = require('path'); -const fs = require('fs'); -// Verificar se Cloudinary esta configurado -const cloudinaryConfigured = !!( - process.env.CLOUDINARY_CLOUD_NAME && - process.env.CLOUDINARY_API_KEY && - process.env.CLOUDINARY_API_SECRET -); +const MAX_FILE_BYTES = 5 * 1024 * 1024; +const MAX_CONCURRENT_UPLOADS = 2; +const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); +let activeUploads = 0; -let cloudinary = null; -if (cloudinaryConfigured) { - cloudinary = require('cloudinary').v2; - cloudinary.config({ - cloud_name: process.env.CLOUDINARY_CLOUD_NAME, - api_key: process.env.CLOUDINARY_API_KEY, - api_secret: process.env.CLOUDINARY_API_SECRET - }); -} - -// Sempre usar disco local para multer; Cloudinary e feito no controller -const uploadsDir = path.join(__dirname, '..', '..', 'uploads'); -if (!fs.existsSync(uploadsDir)) { - fs.mkdirSync(uploadsDir, { recursive: true }); +function unsupportedMediaError() { + const error = new Error('Apenas imagens JPEG, PNG e WebP sao permitidas'); + error.statusCode = 415; + error.code = 'UNSUPPORTED_IMAGE_TYPE'; + return error; } -const storage = multer.diskStorage({ - destination: (req, file, cb) => { - cb(null, uploadsDir); - }, - filename: (req, file, cb) => { - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); - const ext = path.extname(file.originalname); - cb(null, `comercio-${uniqueSuffix}${ext}`); - } -}); - +// Os bytes permanecem em memoria ate o Sharp validar e normalizar o lote. +// Assim nenhum arquivo bruto ou falso fica acessivel em /uploads. const upload = multer({ - storage: storage, - limits: { fileSize: 5 * 1024 * 1024 }, // 5MB - fileFilter: (req, file, cb) => { - const allowed = /jpeg|jpg|png|webp/; - const ext = allowed.test(path.extname(file.originalname).toLowerCase()); - const mime = allowed.test(file.mimetype); - if (ext && mime) { - cb(null, true); - } else { - cb(new Error('Apenas imagens JPG, PNG e WebP sao permitidas')); + storage: multer.memoryStorage(), + limits: { + fileSize: MAX_FILE_BYTES, + files: 5, + fields: 0, + }, + fileFilter: (req, file, callback) => { + if (!ALLOWED_MIME_TYPES.has(String(file.mimetype || '').toLowerCase())) { + return callback(unsupportedMediaError()); } - } + callback(null, true); + }, }); -// Funcao para enviar ao Cloudinary (chamada no controller apos multer salvar localmente) -async function uploadToCloudinary(filePath) { - if (!cloudinaryConfigured || !cloudinary) { - return null; +function limitUploadConcurrency(req, res, next) { + if (activeUploads >= MAX_CONCURRENT_UPLOADS) { + return res.status(503).json({ error: 'Processamento de imagens ocupado. Tente novamente.' }); } - try { - const result = await cloudinary.uploader.upload(filePath, { - folder: 'comercio-bes', - transformation: [ - { width: 800, height: 600, crop: 'limit', quality: 'auto' } - ] - }); + activeUploads += 1; + const lease = { + disconnected: false, + processing: false, + released: false, + release() { + if (lease.released) return; + lease.released = true; + activeUploads = Math.max(0, activeUploads - 1); + }, + }; + req.uploadConcurrencyLease = lease; - // Remover arquivo local apos upload para Cloudinary - fs.unlinkSync(filePath); + res.once('finish', () => { + if (!lease.processing) lease.release(); + }); + res.once('close', () => { + if (!res.writableEnded) lease.disconnected = true; + // Antes do controller, Multer/HTTP ja pararam de consumir recursos. Depois + // disso, o controller segura a vaga ate Sharp/storage terminarem e limpem. + if (!lease.processing) lease.release(); + }); + next(); +} - return result.secure_url; - } catch (err) { - console.error('Erro ao enviar para Cloudinary:', err.message); - return null; - } +function beginUploadProcessing(req) { + const lease = req.uploadConcurrencyLease; + if (!lease) return true; + lease.processing = true; + return !lease.disconnected; +} + +function finishUploadProcessing(req) { + const lease = req.uploadConcurrencyLease; + if (lease) lease.release(); +} + +function uploadClientDisconnected(req) { + return Boolean(req.uploadConcurrencyLease && req.uploadConcurrencyLease.disconnected); } -module.exports = { upload, uploadToCloudinary, cloudinaryConfigured }; +module.exports = { + upload, + ALLOWED_MIME_TYPES, + MAX_CONCURRENT_UPLOADS, + MAX_FILE_BYTES, + beginUploadProcessing, + finishUploadProcessing, + limitUploadConcurrency, + uploadClientDisconnected, +}; diff --git a/backend/src/routes/upload.js b/backend/src/routes/upload.js index d7c6a2f..4cbc47d 100644 --- a/backend/src/routes/upload.js +++ b/backend/src/routes/upload.js @@ -4,7 +4,7 @@ const express = require('express'); const router = express.Router(); const { auth, requireTipo, csrfGuard } = require('../middleware/auth'); -const { upload } = require('../middleware/upload'); +const { limitUploadConcurrency, upload } = require('../middleware/upload'); const ctrl = require('../controllers/uploadController'); // Upload unico (comerciante ou admin) @@ -12,6 +12,7 @@ router.post('/', auth, csrfGuard, requireTipo('comerciante', 'admin'), + limitUploadConcurrency, upload.single('imagem'), ctrl.uploadImagem ); @@ -21,6 +22,7 @@ router.post('/multiplo', auth, csrfGuard, requireTipo('comerciante', 'admin'), + limitUploadConcurrency, upload.array('imagens', 5), ctrl.uploadMultiplo ); diff --git a/backend/src/server.js b/backend/src/server.js index 2912440..c26f98b 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -70,7 +70,7 @@ app.use(helmet({ scriptSrc: ["'self'", "https://unpkg.com"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"], fontSrc: ["'self'", "https://fonts.gstatic.com"], - imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org", "https://res.cloudinary.com", "https://http2.mlstatic.com", "blob:"], + imgSrc: ["'self'", "data:", "https://api.comerciobes.com.br", "https://*.tile.openstreetmap.org", "https://res.cloudinary.com", "https://http2.mlstatic.com", "blob:"], connectSrc: ["'self'", "https://api.comerciobes.com.br", "https://unpkg.com"], frameSrc: ["'self'", "https://www.openstreetmap.org", "https://www.google.com"], objectSrc: ["'none'"], @@ -184,6 +184,13 @@ if (process.env.NODE_ENV !== 'test') { message: { error: 'Muitos eventos registrados. Tente novamente em breve.' } }); app.use('/api/estatisticas/registrar', statsLimiter); + + const uploadLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + message: { error: 'Muitos uploads. Tente novamente em 15 minutos.' } + }); + app.use('/api/upload', uploadLimiter); } // Body parsing @@ -241,7 +248,11 @@ app.use('/data', express.static(path.join(FRONTEND_ROOT, 'data'), publicStaticOp app.use('/uploads', (req, res, next) => { res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); next(); -}, express.static(path.join(__dirname, '..', 'uploads'))); +}, express.static(process.env.UPLOADS_DIR || path.join(__dirname, '..', 'uploads'), { + dotfiles: 'deny', + index: false, + maxAge: process.env.NODE_ENV === 'production' ? '1h' : 0, +})); // --- Rotas da API --- app.use('/api/auth', authRoutes); diff --git a/docs/OPERACAO.md b/docs/OPERACAO.md index 8263d21..a6c082c 100644 --- a/docs/OPERACAO.md +++ b/docs/OPERACAO.md @@ -108,6 +108,25 @@ A porta pode ser trocada por qualquer valor livre. O frontend sempre chama `/api Se `/api/health` retornar `database: "unreachable"`, a API ainda pode mostrar a vitrine via `data/data.json`, mas o banco precisa ser corrigido antes de usar o sistema de verdade. +## Uploads de imagens + +Uploads aceitam somente JPEG, PNG ou WebP de ate 5 MB, 6.000 px por eixo e 20 megapixels. A API confere a assinatura real, decodifica o arquivo inteiro, rejeita animacoes e converte o resultado para: + +- imagem principal WebP de ate 1.600 x 1.600; +- thumbnail WebP de 480 x 360. + +Os bytes originais nunca ficam na pasta publica. Um lote inteiro e validado antes da publicacao e, se qualquer item falhar, a API executa o rollback dos arquivos locais ou ativos remotos ja criados. Falhas da compensacao ficam registradas no log para acao operacional; uma indisponibilidade externa nao permite prometer remocao remota imediata. Residuos de staging causados por queda abrupta sao varridos na inicializacao e a cada 15 minutos assim que completam uma hora; `/uploads/.staging` e bloqueado pelo servidor. + +Para proteger memoria e CPU, cada processo trata no maximo dois requests de imagem ao mesmo tempo e cada IP pode iniciar ate 20 requests a cada 15 minutos. + +Quando Cloudinary esta configurado, falha remota retorna erro e nao faz fallback para o arquivo bruto. A API mantem `url`/`urls` por compatibilidade e tambem retorna `thumbnailUrl`/`thumbnails`. + +No armazenamento local, `/uploads/...` e resolvido pelo frontend contra a origem da API. Assim as imagens continuam funcionando quando a vitrine esta em `comerciobes.com.br` e a API em `api.comerciobes.com.br`, sem aceitar outros hosts. + +Arquivos publicados com sucesso mas nunca associados a uma loja nao podem ser identificados com seguranca no schema atual. Limpeza por vinculo exige um modelo de ativo/lease separado; a v0.2 cobre temporarios, lotes parciais e falhas de storage sem inventar esse subsistema. + +Antes de atualizar uma instalacao que recebeu uploads pelo pipeline antigo, inventarie `backend/uploads/`, mova arquivos desconhecidos para quarentena e reprocesse apenas imagens confiaveis. O pipeline novo gera somente pares `comercio-.webp` e `comercio--thumb.webp`. + ## Backup O servico `db-backup` cria um arquivo SQL por dia em `backups/` e apaga dumps com mais de 14 dias. diff --git a/html/cadastro.html b/html/cadastro.html index ea98e3b..aafe296 100644 --- a/html/cadastro.html +++ b/html/cadastro.html @@ -4,7 +4,7 @@ - + Cadastro — Comércio BES diff --git a/html/login.html b/html/login.html index ae56e70..5642e5f 100644 --- a/html/login.html +++ b/html/login.html @@ -4,7 +4,7 @@ - + Login — Comércio BES diff --git a/index.html b/index.html index 4928048..d1a6cc1 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - + Comércio BES — Boa Esperança do Sul diff --git a/js/modules/utils.js b/js/modules/utils.js index 61a786f..511e46f 100644 --- a/js/modules/utils.js +++ b/js/modules/utils.js @@ -1,5 +1,6 @@ // ===== COMÉRCIO BES — UTILITÁRIOS ===== // Funções puras: sem DOM, sem estado, sem fetch. +import { API_ORIGIN } from '../config.js'; // ===== SEGURANÇA: Escape HTML para prevenir XSS ===== export function escapeHTML(str) { @@ -18,12 +19,13 @@ export function safeImageUrl(value) { if (typeof value !== 'string') return null; try { - const url = new URL(value, window.location.origin); - const isLocal = url.origin === window.location.origin && ( - url.pathname.startsWith('/images/') || url.pathname.startsWith('/uploads/') - ); + const rawValue = value.trim(); + const baseOrigin = rawValue.startsWith('/uploads/') ? API_ORIGIN : window.location.origin; + const url = new URL(rawValue, baseOrigin); + const isLocalImage = url.origin === window.location.origin && url.pathname.startsWith('/images/'); + const isApiUpload = url.origin === API_ORIGIN && url.pathname.startsWith('/uploads/'); const isCloudinary = url.protocol === 'https:' && url.hostname === 'res.cloudinary.com'; - return isLocal || isCloudinary ? url.href : null; + return isLocalImage || isApiUpload || isCloudinary ? url.href : null; } catch { return null; }