diff --git a/.gitignore b/.gitignore index 7b8da95..6d678dc 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +public/uploads/ \ No newline at end of file diff --git a/package.json b/package.json index 8838018..dc95872 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react-dom": "^19.0.0", "react-dropzone": "^14.3.8", "react-select": "^5.10.1", + "sharp": "^0.34.0", "zod": "^3.24.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf72d60..ff909a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: react-select: specifier: ^5.10.1 version: 5.10.1(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + sharp: + specifier: ^0.34.0 + version: 0.34.0 zod: specifier: ^3.24.2 version: 3.24.2 @@ -439,105 +442,215 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.34.0': + resolution: {integrity: sha512-BLT8CQ234EOJFN4NCAkZUkJr2lyXavD+aQH/Jc2skPqAJTMjKeH2BUulaZNkd4MJ9hcCicTdupcbCRg4bto0Ow==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.33.5': resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.34.0': + resolution: {integrity: sha512-FZLxjWEtz+QbxZbtFb+f6AbD47/M9k6GuZ9dedTFdsgI9HwUMvyinxFMAeyP1fJZkJBw999Ht5Cus4sqpFlBPg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.0.4': resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.1.0': + resolution: {integrity: sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.0.4': resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.1.0': + resolution: {integrity: sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.0.4': resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linux-arm64@1.1.0': + resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] + '@img/sharp-libvips-linux-arm@1.1.0': + resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.1.0': + resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==} + cpu: [ppc64] + os: [linux] + '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] + '@img/sharp-libvips-linux-s390x@1.1.0': + resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==} + cpu: [s390x] + os: [linux] + '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] + '@img/sharp-libvips-linux-x64@1.1.0': + resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==} + cpu: [x64] + os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linuxmusl-arm64@1.1.0': + resolution: {integrity: sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==} + cpu: [arm64] + os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] + '@img/sharp-libvips-linuxmusl-x64@1.1.0': + resolution: {integrity: sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==} + cpu: [x64] + os: [linux] + '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + '@img/sharp-linux-arm64@0.34.0': + resolution: {integrity: sha512-fpvIy7rPdTegqthhUNAaQikg8CzNUGxuf7VTIs5HEQllCTL322rBDuGHVoH/pZ6Qms9enHe++DsUoG/Ux93E1A==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + '@img/sharp-linux-arm@0.34.0': + resolution: {integrity: sha512-MfbqXi4zdy0CsSONwESFzrdpzcNSN66qbt8a7CdesOFfZHmlPXgC+xOy+2SLYn6+MFi/06qngGRIje7vfAV/5Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + '@img/sharp-linux-s390x@0.34.0': + resolution: {integrity: sha512-04jdT+VCZIqj0RoTEpWXh0lErZC9prhkxEZWrQdGt1MZ368SlvXpKkXCD4Ww5ISc3LexBmfnAW/+ErUmD9sRPQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + '@img/sharp-linux-x64@0.34.0': + resolution: {integrity: sha512-Y98V1d5vh8RIpf+pUb7U9a0SGzfPa7x7KPXsqtvb7i52L7HXAMv5U0aaOdnnf/CAqVUVaTJajINJ3KyrLcwByQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + '@img/sharp-linuxmusl-arm64@0.34.0': + resolution: {integrity: sha512-pmsehGlQIOlAQ8lgtDxpGInXXMAV6JrFwoJ0Ib9PpsVYuwFM+Soa9mVZMfsTO+u9dBhCMEn2AP3mRUljgpGYvQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + '@img/sharp-linuxmusl-x64@0.34.0': + resolution: {integrity: sha512-t80LMHorxyKGIPWIX3Qyamg72vj/TGYLyOvzjvkywvNmlQurgHu3ZI2aZnUc5YQlrKPOovnwkVmTEbH+YllQ5Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.34.0': + resolution: {integrity: sha512-oI6xsOqLHhRA3LSZb07KW3dMAmo1PpyAxwdHkuiC5+N8HzodpqXusOtzBEXKeFG8Za5ycry0xLYsu7hG5aUxoQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + '@img/sharp-win32-ia32@0.33.5': resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.34.0': + resolution: {integrity: sha512-ofcDYsjJJ1zya9s/GCnXjbFIhTw5/gRVr+SivAGPMXmAml/rLLyDu/HtWntvhiacnL4VYvtgMFw/B2Zz/kgoWQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.33.5': resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.34.0': + resolution: {integrity: sha512-S0X+Uty7Qe6tBfTigFEInchNsGYM/uRjuF1ixi8mLubMfTClmbnVIMxR2/cD5I5Z1m6lHP5D6ASneM3qsF3KFA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -2060,6 +2173,10 @@ packages: resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.34.0: + resolution: {integrity: sha512-l7K33wCojhluT82RQXKm3X/y9Y6yBioJ4GaOlGT67yDv8bXZcU3aOlxUM0W1zUUKQjOoIh3VcfQEKHVW9AyijQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2598,76 +2715,154 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.0.4 optional: true + '@img/sharp-darwin-arm64@0.34.0': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.1.0 + optional: true + '@img/sharp-darwin-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.0.4 optional: true + '@img/sharp-darwin-x64@0.34.0': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.1.0 + optional: true + '@img/sharp-libvips-darwin-arm64@1.0.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.1.0': + optional: true + '@img/sharp-libvips-darwin-x64@1.0.4': optional: true + '@img/sharp-libvips-darwin-x64@1.1.0': + optional: true + '@img/sharp-libvips-linux-arm64@1.0.4': optional: true + '@img/sharp-libvips-linux-arm64@1.1.0': + optional: true + '@img/sharp-libvips-linux-arm@1.0.5': optional: true + '@img/sharp-libvips-linux-arm@1.1.0': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.1.0': + optional: true + '@img/sharp-libvips-linux-s390x@1.0.4': optional: true + '@img/sharp-libvips-linux-s390x@1.1.0': + optional: true + '@img/sharp-libvips-linux-x64@1.0.4': optional: true + '@img/sharp-libvips-linux-x64@1.1.0': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.1.0': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.0.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.1.0': + optional: true + '@img/sharp-linux-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.0.4 optional: true + '@img/sharp-linux-arm64@0.34.0': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.1.0 + optional: true + '@img/sharp-linux-arm@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.0.5 optional: true + '@img/sharp-linux-arm@0.34.0': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.1.0 + optional: true + '@img/sharp-linux-s390x@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.0.4 optional: true + '@img/sharp-linux-s390x@0.34.0': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.1.0 + optional: true + '@img/sharp-linux-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.0.4 optional: true + '@img/sharp-linux-x64@0.34.0': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.1.0 + optional: true + '@img/sharp-linuxmusl-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 optional: true + '@img/sharp-linuxmusl-arm64@0.34.0': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.1.0 + optional: true + '@img/sharp-linuxmusl-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.0.4 optional: true + '@img/sharp-linuxmusl-x64@0.34.0': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.1.0 + optional: true + '@img/sharp-wasm32@0.33.5': dependencies: '@emnapi/runtime': 1.4.0 optional: true + '@img/sharp-wasm32@0.34.0': + dependencies: + '@emnapi/runtime': 1.4.0 + optional: true + '@img/sharp-win32-ia32@0.33.5': optional: true + '@img/sharp-win32-ia32@0.34.0': + optional: true + '@img/sharp-win32-x64@0.33.5': optional: true + '@img/sharp-win32-x64@0.34.0': + optional: true + '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 @@ -3186,13 +3381,11 @@ snapshots: dependencies: color-name: 1.1.4 simple-swizzle: 0.2.2 - optional: true color@4.2.3: dependencies: color-convert: 2.0.1 color-string: 1.9.1 - optional: true concat-map@0.0.1: {} @@ -3806,8 +3999,7 @@ snapshots: is-arrayish@0.2.1: {} - is-arrayish@0.3.2: - optional: true + is-arrayish@0.3.2: {} is-async-function@2.1.1: dependencies: @@ -4389,6 +4581,33 @@ snapshots: '@img/sharp-win32-x64': 0.33.5 optional: true + sharp@0.34.0: + dependencies: + color: 4.2.3 + detect-libc: 2.0.3 + semver: 7.7.1 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.0 + '@img/sharp-darwin-x64': 0.34.0 + '@img/sharp-libvips-darwin-arm64': 1.1.0 + '@img/sharp-libvips-darwin-x64': 1.1.0 + '@img/sharp-libvips-linux-arm': 1.1.0 + '@img/sharp-libvips-linux-arm64': 1.1.0 + '@img/sharp-libvips-linux-ppc64': 1.1.0 + '@img/sharp-libvips-linux-s390x': 1.1.0 + '@img/sharp-libvips-linux-x64': 1.1.0 + '@img/sharp-libvips-linuxmusl-arm64': 1.1.0 + '@img/sharp-libvips-linuxmusl-x64': 1.1.0 + '@img/sharp-linux-arm': 0.34.0 + '@img/sharp-linux-arm64': 0.34.0 + '@img/sharp-linux-s390x': 0.34.0 + '@img/sharp-linux-x64': 0.34.0 + '@img/sharp-linuxmusl-arm64': 0.34.0 + '@img/sharp-linuxmusl-x64': 0.34.0 + '@img/sharp-wasm32': 0.34.0 + '@img/sharp-win32-ia32': 0.34.0 + '@img/sharp-win32-x64': 0.34.0 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4426,7 +4645,6 @@ snapshots: simple-swizzle@0.2.2: dependencies: is-arrayish: 0.3.2 - optional: true source-map-js@1.2.1: {} diff --git a/prisma/migrations/20250331200017_rename_pictures/migration.sql b/prisma/migrations/20250331200017_rename_pictures/migration.sql deleted file mode 100644 index 5d7a318..0000000 --- a/prisma/migrations/20250331200017_rename_pictures/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `pictures` on the `miis` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "miis" DROP COLUMN "pictures", -ADD COLUMN "images" TEXT[]; diff --git a/prisma/migrations/20250331194845_init/migration.sql b/prisma/migrations/20250404173207_init/migration.sql similarity index 96% rename from prisma/migrations/20250331194845_init/migration.sql rename to prisma/migrations/20250404173207_init/migration.sql index e53b21c..a789cb4 100644 --- a/prisma/migrations/20250331194845_init/migration.sql +++ b/prisma/migrations/20250404173207_init/migration.sql @@ -45,7 +45,9 @@ CREATE TABLE "miis" ( "id" SERIAL NOT NULL, "userId" INTEGER NOT NULL, "name" VARCHAR(64) NOT NULL, - "pictures" TEXT[], + "qrCodeUrl" TEXT NOT NULL, + "studioUrl" TEXT NOT NULL, + "images" TEXT[], "tags" TEXT[], "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/prisma/migrations/20250405104409_nullable_images/migration.sql b/prisma/migrations/20250405104409_nullable_images/migration.sql new file mode 100644 index 0000000..ec4c927 --- /dev/null +++ b/prisma/migrations/20250405104409_nullable_images/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "miis" ALTER COLUMN "qrCodeUrl" DROP NOT NULL, +ALTER COLUMN "studioUrl" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a96198b..133854c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -61,11 +61,13 @@ model Session { } model Mii { - id Int @id @default(autoincrement()) - userId Int - name String @db.VarChar(64) - images String[] - tags String[] + id Int @id @default(autoincrement()) + userId Int + name String @db.VarChar(64) + qrCodeUrl String? + studioUrl String? + images String[] + tags String[] createdAt DateTime @default(now()) diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts new file mode 100644 index 0000000..565c7e7 --- /dev/null +++ b/src/app/api/submit/route.ts @@ -0,0 +1,139 @@ +import fs from "fs/promises"; +import path from "path"; +import sharp from "sharp"; + +import { AES_CCM } from "@trafficlunar/asmcrypto.js"; +import Mii from "@pretendonetwork/mii-js"; +import qrcode from "qrcode-generator"; + +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { MII_DECRYPTION_KEY, MII_QR_SIZES } from "@/lib/constants"; +import { nameSchema, tagsSchema } from "@/lib/schemas"; + +const uploadsDirectory = path.join(process.cwd(), "public", "uploads"); + +export async function POST(request: Request) { + const session = await auth(); + if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 }); + + const { name, tags, qrBytesRaw } = await request.json(); + if (!name) return Response.json({ error: "Name is required" }, { status: 400 }); + if (!tags || tags.length == 0) return Response.json({ error: "At least one tag is required" }, { status: 400 }); + if (!qrBytesRaw || qrBytesRaw.length == 0) return Response.json({ error: "A QR code is required" }, { status: 400 }); + + const nameValidation = nameSchema.safeParse(name); + if (!nameValidation.success) return Response.json({ error: nameValidation.error.errors[0].message }, { status: 400 }); + const tagsValidation = tagsSchema.safeParse(tags); + if (!tagsValidation.success) return Response.json({ error: tagsValidation.error.errors[0].message }, { status: 400 }); + + // Validate QR code size + if (!MII_QR_SIZES.includes(qrBytesRaw.length)) return Response.json({ error: "QR code is not a valid Mii QR code size" }, { status: 400 }); + + const qrBytes = new Uint8Array(qrBytesRaw); + + // Decrypt the QR code + const nonce = qrBytes.subarray(0, 8); + const content = qrBytes.subarray(8, 0x70); + + const nonceWithZeros = new Uint8Array(12); + nonceWithZeros.set(nonce, 0); + + let decrypted: Uint8Array = new Uint8Array(); + try { + decrypted = AES_CCM.decrypt(content, MII_DECRYPTION_KEY, nonceWithZeros, undefined, 16); + } catch (error) { + console.warn("Failed to decrypt QR code:", error); + return Response.json({ error: "Failed to decrypt QR code. It may be invalid or corrupted." }, { status: 400 }); + } + + const result = new Uint8Array(96); + result.set(decrypted.subarray(0, 12), 0); + result.set(nonce, 12); + result.set(decrypted.subarray(12), 20); + + // Check if QR code is valid (after decryption) + if (result.length !== 0x60 || (result[0x16] !== 0 && result[0x17] !== 0)) + return Response.json({ error: "QR code is not a valid Mii QR code" }, { status: 400 }); + + // Convert to Mii class + const buffer = Buffer.from(result); + const mii = new Mii(buffer); + + // Create Mii in database + const miiRecord = await prisma.mii.create({ + data: { + userId: Number(session.user.id), + name, + tags, + }, + }); + + // Ensure directories exist + await Promise.all([ + fs.mkdir(path.join(uploadsDirectory, "studio"), { recursive: true }), + fs.mkdir(path.join(uploadsDirectory, "qr-code"), { recursive: true }), + ]); + + // Download the image of the Mii + let studioBuffer: Buffer; + try { + const studioUrl = mii.studioUrl({ width: 128 }); + const studioResponse = await fetch(studioUrl); + + if (!studioResponse.ok) { + throw new Error(`Failed to fetch Mii image ${studioResponse.status}`); + } + + const studioArrayBuffer = await studioResponse.arrayBuffer(); + studioBuffer = Buffer.from(studioArrayBuffer); + } catch (error) { + // Clean up if something went wrong + await prisma.mii.delete({ where: { id: miiRecord.id } }); + console.error("Failed to download Mii image:", error); + return Response.json({ error: "Failed to download Mii image" }, { status: 500 }); + } + + try { + // Compress and upload + const studioWebpBuffer = await sharp(studioBuffer).webp({ quality: 85 }).toBuffer(); + const studioFileLocation = path.join(uploadsDirectory, "studio", `${miiRecord.id}.webp`); + + await fs.writeFile(studioFileLocation, studioWebpBuffer); + + // Generate a new QR code for aesthetic reasons + const byteString = String.fromCharCode(...qrBytes); + const generatedCode = qrcode(0, "L"); + generatedCode.addData(byteString, "Byte"); + generatedCode.make(); + + // Upload QR code + const codeDataUrl = generatedCode.createDataURL(); + const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, ""); + const codeBuffer = Buffer.from(codeBase64, "base64"); + + // Compress and upload + const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer(); + const codeFileLocation = path.join(uploadsDirectory, "qr-code", `${miiRecord.id}.webp`); + await fs.writeFile(codeFileLocation, codeWebpBuffer); + + // todo: upload user images + + // Update database to use images + await prisma.mii.update({ + where: { + id: miiRecord.id, + }, + data: { + studioUrl: studioFileLocation, + qrCodeUrl: codeFileLocation, + }, + }); + + return Response.json({ success: true, id: miiRecord.id }); + } catch (error) { + await prisma.mii.delete({ where: { id: miiRecord.id } }); + console.error("Error processing Mii files:", error); + return Response.json({ error: "Failed to process and store Mii files" }, { status: 500 }); + } +} diff --git a/src/app/components/submit-form.tsx b/src/app/components/submit-form.tsx index 5043fdb..894b75c 100644 --- a/src/app/components/submit-form.tsx +++ b/src/app/components/submit-form.tsx @@ -1,5 +1,7 @@ "use client"; +import { redirect } from "next/navigation"; + import { useEffect, useState } from "react"; import { useDropzone } from "react-dropzone"; import { Icon } from "@iconify/react"; @@ -8,12 +10,13 @@ import { AES_CCM } from "@trafficlunar/asmcrypto.js"; import Mii from "@pretendonetwork/mii-js"; import qrcode from "qrcode-generator"; +import { MII_DECRYPTION_KEY } from "@/lib/constants"; +import { nameSchema, tagsSchema } from "@/lib/schemas"; + import TagSelector from "./submit/tag-selector"; import QrUpload from "./submit/qr-upload"; import QrScanner from "./submit/qr-scanner"; -const key = new Uint8Array([0x59, 0xfc, 0x81, 0x7e, 0x64, 0x46, 0xea, 0x61, 0x90, 0x34, 0x7b, 0x20, 0xe9, 0xbd, 0xce, 0x52]); - export default function SubmitForm() { const { acceptedFiles, getRootProps, getInputProps } = useDropzone({ accept: { @@ -22,15 +25,53 @@ export default function SubmitForm() { }); const [isQrScannerOpen, setIsQrScannerOpen] = useState(false); - const [qrBytes, setQrBytes] = useState(new Uint8Array()); - const [studioUrl, setStudioUrl] = useState(); const [generatedQrCodeUrl, setGeneratedQrCodeUrl] = useState(); + const [error, setError] = useState(undefined); + + const [name, setName] = useState(""); + const [tags, setTags] = useState([]); + const [qrBytesRaw, setQrBytesRaw] = useState([]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + // Validate before sending request + const nameValidation = nameSchema.safeParse(name); + if (!nameValidation.success) { + setError(nameValidation.error.errors[0].message); + return; + } + const tagsValidation = tagsSchema.safeParse(tags); + if (!tagsValidation.success) { + setError(tagsValidation.error.errors[0].message); + return; + } + + // Send request to server + const response = await fetch("/api/submit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, tags, qrBytesRaw }), + }); + const { id, error } = await response.json(); + + if (!response.ok) { + setError(error); + return; + } + + redirect(`/mii/${id}`); + }; + useEffect(() => { - if (qrBytes.length == 0) return; + if (qrBytesRaw.length == 0) return; + const qrBytes = new Uint8Array(qrBytesRaw); const decode = async () => { + setError(""); + // Decrypt the QR code const nonce = qrBytes.subarray(0, 8); const content = qrBytes.subarray(8, 0x70); @@ -38,32 +79,51 @@ export default function SubmitForm() { const nonceWithZeros = new Uint8Array(12); nonceWithZeros.set(nonce, 0); - const decrypted = AES_CCM.decrypt(content, key, nonceWithZeros, undefined, 16); + let decrypted: Uint8Array = new Uint8Array(); + try { + decrypted = AES_CCM.decrypt(content, MII_DECRYPTION_KEY, nonceWithZeros, undefined, 16); + } catch (error) { + console.warn("Failed to decrypt QR code:", error); + setError("Failed to decrypt QR code. It may be invalid or corrupted."); + return; + } + const result = new Uint8Array(96); result.set(decrypted.subarray(0, 12), 0); result.set(nonce, 12); result.set(decrypted.subarray(12), 20); + // Check if QR code is valid (after decryption) + if (result.length !== 0x60 || (result[0x16] !== 0 && result[0x17] !== 0)) { + setError("QR code is not a valid Mii QR code"); + return; + } + // Convert to Mii class const buffer = Buffer.from(result); const mii = new Mii(buffer); - setStudioUrl(mii.studioUrl({ width: 128 })); + try { + setStudioUrl(mii.studioUrl({ width: 128 })); - // Generate a new QR code for aesthetic reasons - const byteString = String.fromCharCode(...qrBytes); - const generatedCode = qrcode(0, "L"); - generatedCode.addData(byteString, "Byte"); - generatedCode.make(); + // Generate a new QR code for aesthetic reasons + const byteString = String.fromCharCode(...qrBytes); + const generatedCode = qrcode(0, "L"); + generatedCode.addData(byteString, "Byte"); + generatedCode.make(); - setGeneratedQrCodeUrl(generatedCode.createDataURL()); + setGeneratedQrCodeUrl(generatedCode.createDataURL()); + } catch (error) { + console.warn("Failed to get and/or generate Mii images:", error); + setError("Failed to get and/or generate Mii images"); + } }; decode(); - }, [qrBytes]); + }, [qrBytesRaw]); return ( -
e.preventDefault()} className="grid grid-cols-2"> +
setName(e.target.value)} />
@@ -117,13 +179,13 @@ export default function SubmitForm() { - +
QR Code - + or @@ -132,12 +194,16 @@ export default function SubmitForm() { Use your camera - +
- +
+ {error && Error: {error}} + + +
); diff --git a/src/app/components/submit/qr-scanner.tsx b/src/app/components/submit/qr-scanner.tsx index 28025be..b1f2956 100644 --- a/src/app/components/submit/qr-scanner.tsx +++ b/src/app/components/submit/qr-scanner.tsx @@ -9,10 +9,10 @@ import QrFinder from "../qr-finder"; interface Props { isOpen: boolean; setIsOpen: React.Dispatch>; - setQrBytes: React.Dispatch>; + setQrBytesRaw: React.Dispatch>; } -export default function QrScanner({ isOpen, setIsOpen, setQrBytes }: Props) { +export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) { const [permissionGranted, setPermissionGranted] = useState(null); useEffect(() => { @@ -29,10 +29,10 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytes }: Props) { setIsOpen(false); // Convert to bytes - const encoder = new TextEncoder(); - const byteArray = encoder.encode(result[0].rawValue); + // const encoder = new TextEncoder(); + // const byteArray = encoder.encode(result[0].rawValue); - setQrBytes(byteArray); + // setQrBytes(byteArray); }; if (isOpen) diff --git a/src/app/components/submit/qr-upload.tsx b/src/app/components/submit/qr-upload.tsx index 7a2a1f5..429fb33 100644 --- a/src/app/components/submit/qr-upload.tsx +++ b/src/app/components/submit/qr-upload.tsx @@ -6,10 +6,10 @@ import { Icon } from "@iconify/react"; import jsQR from "jsqr"; interface Props { - setQrBytes: React.Dispatch>; + setQrBytesRaw: React.Dispatch>; } -export default function QrUpload({ setQrBytes }: Props) { +export default function QrUpload({ setQrBytesRaw }: Props) { const onDrop = useCallback((acceptedFiles: FileWithPath[]) => { acceptedFiles.forEach((file) => { // Scan QR code @@ -28,7 +28,7 @@ export default function QrUpload({ setQrBytes }: Props) { const imageData = ctx.getImageData(0, 0, image.width, image.height); const decoded = jsQR(imageData.data, image.width, image.height); - setQrBytes(new Uint8Array(decoded?.binaryData!)); + setQrBytesRaw(decoded?.binaryData!); }; image.src = event.target!.result as string; }; diff --git a/src/app/components/submit/tag-selector.tsx b/src/app/components/submit/tag-selector.tsx index 1c84921..6b92bbf 100644 --- a/src/app/components/submit/tag-selector.tsx +++ b/src/app/components/submit/tag-selector.tsx @@ -2,20 +2,21 @@ import CreatableSelect from "react-select/creatable"; -const options = [ - { value: "anime", label: "anime" }, - { value: "art", label: "art" }, - { value: "cartoon", label: "cartoon" }, - { value: "celebrity", label: "celebrity" }, - { value: "games", label: "games" }, - { value: "history", label: "history" }, - { value: "meme", label: "meme" }, - { value: "movie", label: "movie" }, - { value: "oc", label: "oc" }, - { value: "tv", label: "tv" }, -]; +interface Props { + tags: string[]; + setTags: React.Dispatch>; +} -export default function TagSelector() { +interface Option { + label: string; + value: string; +} + +const stringToOption = (input: string) => ({ value: input, label: input }); + +const options = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"].map(stringToOption); + +export default function TagSelector({ tags, setTags }: Props) { // todo: tag validating return ( @@ -23,7 +24,9 @@ export default function TagSelector() { isMulti placeholder="Select or create tags..." options={options} - className="pill input col-span-2 w-full !py-0.5" + value={tags.map(stringToOption)} + onChange={(newValue) => setTags(newValue.map((option) => option.value))} + className="pill input col-span-2 w-full min-h-11 !py-0.5" styles={{ control: (provided) => ({ ...provided, diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..ba76f66 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,2 @@ +export const MII_DECRYPTION_KEY = new Uint8Array([0x59, 0xfc, 0x81, 0x7e, 0x64, 0x46, 0xea, 0x61, 0x90, 0x34, 0x7b, 0x20, 0xe9, 0xbd, 0xce, 0x52]); +export const MII_QR_SIZES = [0x70, 172, 324, 372]; diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts new file mode 100644 index 0000000..749e88f --- /dev/null +++ b/src/lib/schemas.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const nameSchema = z + .string() + .min(2, { message: "Name must be at least 2 characters long" }) + .max(64, { message: "Name cannot be more than 64 characters long" }) + .regex(/^[a-zA-Z0-9-_. ']+$/, { + message: "Name can only contain letters, numbers, dashes, underscores, apostrophes, and spaces.", + }); + +export const tagsSchema = z + .array( + z + .string() + .min(2, { message: "Tags must be at least 2 characters long" }) + .max(64, { message: "Tags cannot be more than 20 characters long" }) + .regex(/^[a-z0-9-_]+$/, { + message: "Tags can only contain lowercase letters, numbers, dashes, and underscores.", + }) + ) + .min(1, { message: "There must be at least 1 tag" }) + .max(8, { message: "There cannot be more than 8 tags" });