diff --git a/backend/package-lock.json b/backend/package-lock.json index f2fb120..a811ffc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,12 +17,488 @@ "mysql2": "^3.20.0", "nanoid": "^5.1.7", "session-file-store": "^1.5.0", + "sharp": "^0.34.5", "zod": "^4.3.6" }, "devDependencies": { "nodemon": "^3.1.14" } }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@types/node": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", @@ -368,6 +844,15 @@ "node": ">= 0.8" } }, + "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/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1381,7 +1866,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1458,6 +1942,50 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -1635,6 +2163,13 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 804c5b8..4dc996e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "mysql2": "^3.20.0", "nanoid": "^5.1.7", "session-file-store": "^1.5.0", + "sharp": "^0.34.5", "zod": "^4.3.6" }, "devDependencies": { diff --git a/backend/src/lib/image-storage.js b/backend/src/lib/image-storage.js new file mode 100644 index 0000000..4bae265 --- /dev/null +++ b/backend/src/lib/image-storage.js @@ -0,0 +1,76 @@ +const fs = require('fs/promises') +const path = require('path') +const sharp = require('sharp') +const { nanoid } = require('nanoid') + +const UPLOAD_ROOT = path.join(__dirname, '..', '..', 'uploads') + +function ensureImageMimeType(file) { + return typeof file?.mimetype === 'string' && file.mimetype.startsWith('image/') +} + +function createMemoryUpload(multer, { fileSize = 6 * 1024 * 1024, maxCount } = {}) { + return multer({ + storage: multer.memoryStorage(), + limits: { + fileSize, + ...(typeof maxCount === 'number' ? { files: maxCount } : {}), + }, + fileFilter: (req, file, cb) => { + if (ensureImageMimeType(file)) return cb(null, true) + cb(new Error('image_file_required')) + }, + }) +} + +async function writeOptimizedImage({ + file, + directory, + width, + height, + fit = 'inside', + quality = 82, +}) { + if (!file?.buffer?.length) { + const error = new Error('file_required') + error.code = 'file_required' + throw error + } + + if (!ensureImageMimeType(file)) { + const error = new Error('image_file_required') + error.code = 'image_file_required' + throw error + } + + const { data, info } = await sharp(file.buffer, { failOn: 'none' }) + .rotate() + .resize({ + width, + height, + fit, + withoutEnlargement: true, + }) + .webp({ quality }) + .toBuffer({ resolveWithObject: true }) + + const filename = String(Date.now()) + '-' + nanoid() + '.webp' + const absoluteDir = path.join(UPLOAD_ROOT, directory) + const absolutePath = path.join(absoluteDir, filename) + await fs.mkdir(absoluteDir, { recursive: true }) + await fs.writeFile(absolutePath, data) + + return { + src: '/uploads/' + directory + '/' + filename, + size: data.length, + originalSize: file.size || file.buffer.length, + width: info.width || 0, + height: info.height || 0, + } +} + +module.exports = { + createMemoryUpload, + ensureImageMimeType, + writeOptimizedImage, +} diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 3928944..b2bbf5b 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -32,6 +32,7 @@ const { adminDeleteUser, } = require('../db') const { requireAdmin } = require('../middleware/auth') +const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') const router = express.Router() @@ -53,21 +54,8 @@ function buildItemLabelFromFilename(file) { return normalized || 'item' } -const upload = multer({ - storage: multer.diskStorage({ - destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'games')), - filename: (req, file, cb) => cb(null, buildUploadFilename(file)), - }), - limits: { fileSize: 6 * 1024 * 1024 }, -}) - -const avatarUpload = multer({ - storage: multer.diskStorage({ - destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'avatars')), - filename: (req, file, cb) => cb(null, buildUploadFilename(file)), - }), - limits: { fileSize: 3 * 1024 * 1024 }, -}) +const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 }) +const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 }) router.post('/games', requireAdmin, async (req, res) => { const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) }) @@ -97,7 +85,17 @@ router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail') if (!req.file) return res.status(400).json({ error: 'file_required' }) const game = await findGameById(req.params.gameId) if (!game) return res.status(404).json({ error: 'not_found' }) - const updated = await updateGameThumbnail(req.params.gameId, `/uploads/games/${req.file.filename}`) + + const optimized = await writeOptimizedImage({ + file: req.file, + directory: 'games', + width: 1280, + height: 1280, + fit: 'inside', + quality: 84, + }) + + const updated = await updateGameThumbnail(req.params.gameId, optimized.src) res.json({ game: updated }) }) @@ -113,14 +111,23 @@ router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), a if (normalizedLabels.some((label) => label.length > 60)) return res.status(400).json({ error: 'bad_request' }) const items = await Promise.all( - files.map((file, index) => - createGameItem({ + files.map(async (file, index) => { + const optimized = await writeOptimizedImage({ + file, + directory: 'games', + width: 512, + height: 512, + fit: 'inside', + quality: 84, + }) + + return createGameItem({ id: nanoid(), gameId: game.id, - src: `/uploads/games/${file.filename}`, + src: optimized.src, label: normalizedLabels[index] || buildItemLabelFromFilename(file), }) - ) + }) ) res.json({ item: items[0], items }) @@ -514,8 +521,18 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar') const user = await findUserById(req.params.userId) if (!user) return res.status(404).json({ error: 'not_found' }) + const optimized = req.file + ? await writeOptimizedImage({ + file: req.file, + directory: 'avatars', + width: 512, + height: 512, + fit: 'cover', + quality: 82, + }) + : null const shouldRemoveAvatar = parsed.data.removeAvatar === '1' - const nextAvatarSrc = shouldRemoveAvatar ? '' : req.file ? `/uploads/avatars/${req.file.filename}` : user.avatarSrc || '' + const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || '' const updated = await adminUpdateUser({ id: user.id, email: user.email, diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index b375919..cf2d170 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1,5 +1,4 @@ const express = require('express') -const path = require('path') const bcrypt = require('bcryptjs') const { z } = require('zod') const { nanoid } = require('nanoid') @@ -12,15 +11,10 @@ const { updateUserProfile, } = require('../db') const { requireAuth } = require('../middleware/auth') +const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') const router = express.Router() -function buildUploadFilename(file) { - const ext = path.extname(file.originalname || '').toLowerCase() - const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : '' - return `${Date.now()}-${nanoid()}${safeExt}` -} - const signupSchema = z.object({ email: z.string().email(), password: z.string().min(6), @@ -45,7 +39,6 @@ router.post('/signup', async (req, res) => { req.session.userId = user.id req.session.isAdmin = !!user.isAdmin - // 세션을 응답 전에 명시적으로 저장해 Set-Cookie가 확실히 내려오도록 보강 req.session.save((err) => { if (err) return res.status(500).json({ error: 'session_save_failed' }) res.json(user) @@ -94,13 +87,7 @@ router.get('/meta', async (req, res) => { res.json({ hasUsers: (await countUsers()) > 0 }) }) -const upload = multer({ - storage: multer.diskStorage({ - destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'avatars')), - filename: (req, file, cb) => cb(null, buildUploadFilename(file)), - }), - limits: { fileSize: 3 * 1024 * 1024 }, -}) +const upload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 }) router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) => { const parsed = profileSchema.safeParse(req.body) @@ -109,12 +96,19 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) = const user = await findUserById(req.session.userId) if (!user) return res.status(404).json({ error: 'not_found' }) + const optimized = req.file + ? await writeOptimizedImage({ + file: req.file, + directory: 'avatars', + width: 512, + height: 512, + fit: 'cover', + quality: 82, + }) + : null + const shouldRemoveAvatar = parsed.data.removeAvatar === '1' - const nextAvatarSrc = shouldRemoveAvatar - ? '' - : req.file - ? `/uploads/avatars/${req.file.filename}` - : user.avatarSrc || '' + const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || '' const updated = await updateUserProfile({ id: user.id, nickname: parsed.data.nickname, diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index 475c93e..8e84cee 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -1,5 +1,4 @@ const express = require('express') -const path = require('path') const multer = require('multer') const { z } = require('zod') const { nanoid } = require('nanoid') @@ -18,6 +17,7 @@ const { duplicateTierListForUser, } = require('../db') const { requireAuth } = require('../middleware/auth') +const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') const router = express.Router() const FREEFORM_GAME_ID = 'freeform' @@ -55,27 +55,8 @@ function getCustomTemplateItems(tierList) { }) } -function buildUploadFilename(file) { - const ext = path.extname(file.originalname || '').toLowerCase() - const safeExt = ext && /^[.a-z0-9]+$/.test(ext) ? ext : '' - return `${Date.now()}-${nanoid()}${safeExt}` -} - -const upload = multer({ - storage: multer.diskStorage({ - destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'custom')), - filename: (req, file, cb) => cb(null, buildUploadFilename(file)), - }), - limits: { fileSize: 6 * 1024 * 1024 }, -}) - -const thumbnailUpload = multer({ - storage: multer.diskStorage({ - destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'tierlists')), - filename: (req, file, cb) => cb(null, buildUploadFilename(file)), - }), - limits: { fileSize: 6 * 1024 * 1024 }, -}) +const upload = createMemoryUpload(multer, { fileSize: 6 * 1024 * 1024 }) +const thumbnailUpload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024 }) const tierListUpsertSchema = z.object({ id: z.string().optional(), @@ -179,10 +160,19 @@ router.post('/custom-items', requireAuth, upload.single('image'), async (req, re const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + const optimized = await writeOptimizedImage({ + file: req.file, + directory: 'custom', + width: 512, + height: 512, + fit: 'inside', + quality: 84, + }) + const item = await createCustomItem({ id: nanoid(), ownerId: req.session.userId, - src: `/uploads/custom/${req.file.filename}`, + src: optimized.src, label: parsed.data.label, }) @@ -191,7 +181,17 @@ router.post('/custom-items', requireAuth, upload.single('image'), async (req, re router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'file_required' }) - res.json({ thumbnailSrc: `/uploads/tierlists/${req.file.filename}` }) + + const optimized = await writeOptimizedImage({ + file: req.file, + directory: 'tierlists', + width: 1280, + height: 1280, + fit: 'inside', + quality: 84, + }) + + res.json({ thumbnailSrc: optimized.src }) }) router.post('/:id/template-request', requireAuth, async (req, res) => { diff --git a/docs/update.md b/docs/update.md index 309d0a0..9454175 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-03-31 v1.3.0 +- 백엔드 업로드 파이프라인을 메모리 기반으로 전환하고, 대표 썸네일·게임 썸네일·커스텀 아이템·게임 기본 아이템·아바타를 서버에서 즉시 WebP로 변환해 저장하도록 정리함. +- 아이템 이미지는 최대 512px 규격으로 리사이즈하고, 티어표/게임 썸네일은 긴 변 기준 1280px 안쪽으로 최적화해 원본 이미지를 별도로 보관하지 않는 흐름으로 전환함. +- 업로드 최적화 공통 헬퍼를 추가해 앞으로 중복 해시 검사, 비동기 최적화 큐, 용량 통계 대시보드를 같은 경로 위에 확장할 수 있는 기반을 마련함. + ## 2026-03-31 v1.2.73 - 게임 허브 리스트형 보기의 썸네일을 48px 밀도로 축소해 한 줄이 과하게 커 보이던 인상을 줄이고, 더 많은 티어표를 한눈에 볼 수 있게 조정함. - 깨진 대표 썸네일은 `img` alt 텍스트가 길게 노출되지 않도록 에러 시 즉시 플레이스홀더로 대체하고, 제목/메타 말줄임을 더 보강해 레이아웃 붕괴를 막음.