diff --git a/.env.example b/.env.example index 9247231..c4bf42a 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,10 @@ DATABASE_URL="postgresql://postgres:frieren@localhost:5432/tomodachi-share?schem REDIS_URL="redis://localhost:6379/0" # Used for metadata, sitemaps, etc. -BASE_URL=http://localhost:3000 +NEXT_PUBLIC_BASE_URL=http://localhost:3000 # Check Auth.js docs for information -AUTH_URL=http://localhost:3000 # This should be the same as BASE_URL +AUTH_URL=http://localhost:3000 # This should be the same as NEXT_PUBLIC_BASE_URL AUTH_TRUST_HOST=true AUTH_SECRET=XXXXXXXXXXXXXXXX AUTH_DISCORD_ID=XXXXXXXXXXXXXXXX diff --git a/DEVELOPMENT.MD b/DEVELOPMENT.MD index 8d8d4c9..a891655 100644 --- a/DEVELOPMENT.MD +++ b/DEVELOPMENT.MD @@ -2,6 +2,8 @@ Welcome to the TomodachiShare development guide! This project uses [pnpm](https://pnpm.io/) for package management, [Next.js](https://nextjs.org/) with the app router for the front-end and back-end, [Prisma](https://prisma.io) for the database, [TailwindCSS](https://tailwindcss.com/) for styling, and [TypeScript](https://www.typescriptlang.org/) for type safety. +Note: this project is intended to be used on Linux - in production and development. + ## Getting started To get the project up and running locally, follow these steps: diff --git a/package.json b/package.json index 250f45b..a6c9a4d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "tomodachi-share", "version": "0.1.0", "private": true, - "packageManager": "pnpm@10.10.0", + "packageManager": "pnpm@10.11.0", "scripts": { "dev": "next dev --turbopack", "build": "next build", @@ -33,6 +33,7 @@ "react-dom": "^19.1.0", "react-dropzone": "^14.3.8", "react-webcam": "^7.2.0", + "satori": "^0.13.1", "sharp": "^0.34.1", "sjcl-with-all": "1.0.8", "swr": "^2.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd7e7f1..c840b64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: react-webcam: specifier: ^7.2.0 version: 7.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + satori: + specifier: ^0.13.1 + version: 0.13.1 sharp: specifier: ^0.34.1 version: 0.34.1 @@ -837,6 +840,11 @@ packages: '@rushstack/eslint-patch@1.11.0': resolution: {integrity: sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ==} + '@shuding/opentype.js@1.4.0-beta.0': + resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} + engines: {node: '>= 8.0.0'} + hasBin: true + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -1220,6 +1228,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + bit-buffer@0.2.5: resolution: {integrity: sha512-x1yGnmXvFg6e3DiyRztElbcn1bsCTFSoM/ncAzY62uE0JdTl5xlKJd0ooqLYoPbhdsnpehSIQrdIvclcZJYwiA==} @@ -1257,6 +1269,9 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + caniuse-lite@1.0.30001716: resolution: {integrity: sha512-49/c1+x3Kwz7ZIWt+4DvK3aMJy9oYXXG6/97JKsnjdCk/6n9vVyWL8NAwVt95Lwt9eigI10Hl782kDfZUUlRXw==} @@ -1310,9 +1325,26 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-background-parser@0.1.0: + resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==} + css-box-model@1.2.1: resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} + css-box-shadow@1.0.0-3: + resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==} + + css-color-keywords@1.0.0: + resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} + engines: {node: '>=4'} + + css-gradient-parser@0.0.16: + resolution: {integrity: sha512-3O5QdqgFRUbXvK1x5INf1YkBz1UKSWqrd63vWsum8MNHDBYD5urm3QtxZbKU259OrEXNM26lP/MPY3d1IGkBgA==} + engines: {node: '>=16'} + + css-to-react-native@3.2.0: + resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -1404,6 +1436,10 @@ packages: embla-carousel@8.6.0: resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emoji-regex-xs@2.0.1: + resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} + engines: {node: '>=10.0.0'} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -1456,6 +1492,9 @@ packages: engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1611,6 +1650,9 @@ packages: picomatch: optional: true + fflate@0.7.4: + resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -1728,6 +1770,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hex-rgb@4.3.0: + resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} + engines: {node: '>=6'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1975,6 +2021,9 @@ packages: resolution: {integrity: sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==} engines: {node: '>= 12.0.0'} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2124,10 +2173,16 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-css-color@0.2.1: + resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2165,6 +2220,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -2312,6 +2370,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + satori@0.13.1: + resolution: {integrity: sha512-FlXblaCRDOONmz4JSIG9lUxSIklBZsMVwfLkvXv0MaHa3H6GWZDZccpcCeLqdQ6RjBkYMSh6zZDxkkBFJ4M61A==} + engines: {node: '>=16'} + scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} @@ -2397,6 +2459,9 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + string.prototype.codepointat@0.2.1: + resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -2465,6 +2530,9 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -2546,6 +2614,9 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unrs-resolver@1.7.2: resolution: {integrity: sha512-BBKpaylOW8KbHsu378Zky/dGh4ckT/4NW/0SHRABdqRLcQJ2dAOjDo9g97p04sWflm0kqPqpUatxReNV/dqI5A==} @@ -2664,6 +2735,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoga-wasm-web@0.3.3: + resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==} + zod@3.24.3: resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==} @@ -3184,6 +3258,11 @@ snapshots: '@rushstack/eslint-patch@1.11.0': {} + '@shuding/opentype.js@1.4.0-beta.0': + dependencies: + fflate: 0.7.4 + string.prototype.codepointat: 0.2.1 + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.15': @@ -3571,6 +3650,8 @@ snapshots: balanced-match@1.0.2: {} + base64-js@0.0.8: {} + bit-buffer@0.2.5: {} brace-expansion@1.1.11: @@ -3611,6 +3692,8 @@ snapshots: callsites@3.1.0: {} + camelize@1.0.1: {} + caniuse-lite@1.0.30001716: {} canvas-confetti@1.9.3: {} @@ -3662,10 +3745,24 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-background-parser@0.1.0: {} + css-box-model@1.2.1: dependencies: tiny-invariant: 1.3.3 + css-box-shadow@1.0.0-3: {} + + css-color-keywords@1.0.0: {} + + css-gradient-parser@0.0.16: {} + + css-to-react-native@3.2.0: + dependencies: + camelize: 1.0.1 + css-color-keywords: 1.0.0 + postcss-value-parser: 4.2.0 + csstype@3.1.3: {} damerau-levenshtein@1.0.8: {} @@ -3751,6 +3848,8 @@ snapshots: embla-carousel@8.6.0: {} + emoji-regex-xs@2.0.1: {} + emoji-regex@9.2.2: {} enhanced-resolve@5.18.1: @@ -3893,6 +3992,8 @@ snapshots: '@esbuild/win32-ia32': 0.25.3 '@esbuild/win32-x64': 0.25.3 + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-config-next@15.2.4(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3): @@ -4128,6 +4229,8 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fflate@0.7.4: {} + fflate@0.8.2: {} file-entry-cache@8.0.0: @@ -4254,6 +4357,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hex-rgb@4.3.0: {} + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -4498,6 +4603,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.29.2 lightningcss-win32-x64-msvc: 1.29.2 + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -4643,10 +4753,17 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@0.2.9: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 + parse-css-color@0.2.1: + dependencies: + color-name: 1.1.4 + hex-rgb: 4.3.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -4667,6 +4784,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postcss-value-parser@4.2.0: {} + postcss@8.4.31: dependencies: nanoid: 3.3.11 @@ -4841,6 +4960,20 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + satori@0.13.1: + dependencies: + '@shuding/opentype.js': 1.4.0-beta.0 + css-background-parser: 0.1.0 + css-box-shadow: 1.0.0-3 + css-gradient-parser: 0.0.16 + css-to-react-native: 3.2.0 + emoji-regex-xs: 2.0.1 + escape-html: 1.0.3 + linebreak: 1.1.0 + parse-css-color: 0.2.1 + postcss-value-parser: 4.2.0 + yoga-wasm-web: 0.3.3 + scheduler@0.26.0: {} semver@6.3.1: {} @@ -4977,6 +5110,8 @@ snapshots: streamsearch@1.1.0: {} + string.prototype.codepointat@0.2.1: {} + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -5057,6 +5192,8 @@ snapshots: tapable@2.2.1: {} + tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -5146,6 +5283,11 @@ snapshots: undici-types@6.19.8: {} + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unrs-resolver@1.7.2: dependencies: napi-postinstall: 0.2.3 @@ -5304,4 +5446,6 @@ snapshots: yocto-queue@0.1.0: {} + yoga-wasm-web@0.3.3: {} + zod@3.24.3: {} diff --git a/public/fonts/README.md b/public/fonts/README.md new file mode 100644 index 0000000..c89d473 --- /dev/null +++ b/public/fonts/README.md @@ -0,0 +1 @@ +These fonts are used for generating the 'metadata' image type for Miis (the images you should see in search engines!) diff --git a/public/fonts/lexend-black.ttf b/public/fonts/lexend-black.ttf new file mode 100644 index 0000000..f07fa5c Binary files /dev/null and b/public/fonts/lexend-black.ttf differ diff --git a/public/fonts/lexend-bold.ttf b/public/fonts/lexend-bold.ttf new file mode 100644 index 0000000..09511db Binary files /dev/null and b/public/fonts/lexend-bold.ttf differ diff --git a/public/fonts/lexend-extraBold.ttf b/public/fonts/lexend-extraBold.ttf new file mode 100644 index 0000000..5ab3b2a Binary files /dev/null and b/public/fonts/lexend-extraBold.ttf differ diff --git a/public/fonts/lexend-medium.ttf b/public/fonts/lexend-medium.ttf new file mode 100644 index 0000000..aed7425 Binary files /dev/null and b/public/fonts/lexend-medium.ttf differ diff --git a/public/fonts/lexend-regular.ttf b/public/fonts/lexend-regular.ttf new file mode 100644 index 0000000..eb69863 Binary files /dev/null and b/public/fonts/lexend-regular.ttf differ diff --git a/public/fonts/lexend-semiBold.ttf b/public/fonts/lexend-semiBold.ttf new file mode 100644 index 0000000..70060e1 Binary files /dev/null and b/public/fonts/lexend-semiBold.ttf differ diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts index 9e78774..593a707 100644 --- a/src/app/api/submit/route.ts +++ b/src/app/api/submit/route.ts @@ -14,7 +14,7 @@ import { prisma } from "@/lib/prisma"; import { nameSchema, tagsSchema } from "@/lib/schemas"; import { RateLimit } from "@/lib/rate-limit"; -import { validateImage } from "@/lib/images"; +import { generateMetadataImage, validateImage } from "@/lib/images"; import { convertQrCode } from "@/lib/qr-codes"; import Mii from "@/lib/mii.js/mii"; import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii"; @@ -41,7 +41,7 @@ export async function POST(request: NextRequest) { const check = await rateLimit.handle(); if (check) return check; - const response = await fetch(`${process.env.BASE_URL}/api/admin/can-submit`); + const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`); const { value } = await response.json(); if (!value) return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409); @@ -140,7 +140,7 @@ export async function POST(request: NextRequest) { } try { - // Compress and upload + // Compress and store const studioWebpBuffer = await sharp(studioBuffer).webp({ quality: 85 }).toBuffer(); const studioFileLocation = path.join(miiUploadsDirectory, "mii.webp"); @@ -152,16 +152,17 @@ export async function POST(request: NextRequest) { generatedCode.addData(byteString, "Byte"); generatedCode.make(); - // Upload QR code + // Store QR code const codeDataUrl = generatedCode.createDataURL(); const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, ""); const codeBuffer = Buffer.from(codeBase64, "base64"); - // Compress and upload + // Compress and store const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer(); const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp"); await fs.writeFile(codeFileLocation, codeWebpBuffer); + await generateMetadataImage(miiRecord, session.user.username!); } catch (error) { // Clean up if something went wrong await prisma.mii.delete({ where: { id: miiRecord.id } }); @@ -170,7 +171,7 @@ export async function POST(request: NextRequest) { return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500); } - // Compress and upload user images + // Compress and store user images try { await Promise.all( images.map(async (image, index) => { @@ -192,7 +193,7 @@ export async function POST(request: NextRequest) { }, }); } catch (error) { - console.error("Error uploading user images:", error); + console.error("Error storing user images:", error); return rateLimit.sendResponse({ error: "Failed to store user images" }, 500); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0cbe434..c57de95 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,7 +14,7 @@ const lexend = Lexend({ }); export const metadata: Metadata = { - metadataBase: new URL(process.env.BASE_URL!), + metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!), title: "TomodachiShare - home for Tomodachi Life Miis!", description: "Discover and share Mii residents for your Tomodachi Life island!", keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection"], diff --git a/src/app/mii/[id]/image/route.ts b/src/app/mii/[id]/image/route.ts index 1b4dc8a..f43970b 100644 --- a/src/app/mii/[id]/image/route.ts +++ b/src/app/mii/[id]/image/route.ts @@ -1,16 +1,30 @@ import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; import fs from "fs/promises"; import path from "path"; +import { z } from "zod"; + +import { Prisma } from "@prisma/client"; import { idSchema } from "@/lib/schemas"; import { RateLimit } from "@/lib/rate-limit"; +import { generateMetadataImage } from "@/lib/images"; +import { prisma } from "@/lib/prisma"; + +type MiiWithUser = Prisma.MiiGetPayload<{ + include: { + user: { + select: { + username: true; + }; + }; + }; +}>; const searchParamsSchema = z.object({ type: z - .enum(["mii", "qr-code", "image0", "image1", "image2"], { - message: "Image type must be either 'mii', 'qr-code' or 'image[number from 0 to 2]'", + .enum(["mii", "qr-code", "image0", "image1", "image2", "metadata"], { + message: "Image type must be either 'mii', 'qr-code', 'image[number from 0 to 2]' or 'metadata'", }) .default("mii"), }); @@ -29,12 +43,72 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ if (!searchParamsParsed.success) return rateLimit.sendResponse({ error: searchParamsParsed.error.errors[0].message }, 400); const { type: imageType } = searchParamsParsed.data; - const filePath = path.join(process.cwd(), "uploads", "mii", miiId.toString(), `${imageType}.webp`); + const fileExtension = imageType === "metadata" ? ".png" : ".webp"; + const filePath = path.join(process.cwd(), "uploads", "mii", miiId.toString(), `${imageType}${fileExtension}`); + + let buffer: Buffer | undefined; + // Only find Mii if image type is 'metadata' + let mii: MiiWithUser | null = null; + + if (imageType === "metadata") { + mii = await prisma.mii.findUnique({ + where: { + id: miiId, + }, + include: { + user: { + select: { + username: true, + }, + }, + }, + }); + + if (!mii) { + return rateLimit.sendResponse({ error: "Mii not found" }, 404); + } + } try { - const buffer = await fs.readFile(filePath); - return new NextResponse(buffer); + // Try to read file + buffer = await fs.readFile(filePath); } catch { - return rateLimit.sendResponse({ error: "Image not found" }, 404); + // If the readFile() fails, that probably means it doesn't exist + if (imageType === "metadata" && mii) { + // Metadata images were added after 1274 Miis were submitted, so we generate it on-the-fly + console.log(`Metadata image not found for mii ID ${miiId}, generating metadata image...`); + const { buffer: metadataBuffer, error, status } = await generateMetadataImage(mii, mii.user.username!); + + if (error) { + return rateLimit.sendResponse({ error }, status); + } + + buffer = metadataBuffer; + } else { + return rateLimit.sendResponse({ error: "Image not found" }, 404); + } } + + // Set the file name for the metadata image in the response for SEO + if (mii && imageType === "metadata") { + const slugify = (str: string) => + str + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") // replace non-alphanumeric with hyphens + .replace(/^-+|-+$/g, ""); + + const name = slugify(mii.name); + const tags = mii.tags.map(slugify).join("-"); + const username = slugify(mii.user.username!); + + const filename = `${name}-mii-${tags}-by-${username}.png`; + + return new NextResponse(buffer, { + headers: { + "Content-Disposition": `inline; filename="${filename}"`, + }, + }); + } + + return new NextResponse(buffer); } diff --git a/src/app/mii/[id]/page.tsx b/src/app/mii/[id]/page.tsx index c9d39a0..e669312 100644 --- a/src/app/mii/[id]/page.tsx +++ b/src/app/mii/[id]/page.tsx @@ -38,13 +38,12 @@ export async function generateMetadata({ params }: Props): Promise { // Bots get redirected anyways if (!mii) return {}; - const miiImageUrl = `/mii/${mii.id}/image?type=mii`; - const qrCodeUrl = `/mii/${mii.id}/image?type=qr-code`; + const metadataImageUrl = `/mii/${mii.id}/image?type=metadata`; const username = `@${mii.user.username}`; return { - metadataBase: new URL(process.env.BASE_URL!), + metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!), title: `${mii.name} - TomodachiShare`, description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`, keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags], @@ -53,7 +52,7 @@ export async function generateMetadata({ params }: Props): Promise { type: "article", title: `${mii.name} - TomodachiShare`, description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`, - images: [miiImageUrl, qrCodeUrl], + images: [metadataImageUrl], publishedTime: mii.createdAt.toISOString(), authors: username, }, @@ -61,7 +60,7 @@ export async function generateMetadata({ params }: Props): Promise { card: "summary_large_image", title: `${mii.name} - TomodachiShare`, description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare. From ${mii.islandName} Island with ${mii._count.likedBy} likes.`, - images: [miiImageUrl, qrCodeUrl], + images: [metadataImageUrl], creator: username, }, alternates: { diff --git a/src/app/page.tsx b/src/app/page.tsx index 3c31308..0b63f2b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -19,7 +19,7 @@ export async function generateMetadata({ searchParams }: Props): Promise { }); return { - metadataBase: new URL(process.env.BASE_URL!), + metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!), title: `${user.name} (@${user.username}) - TomodachiShare`, description: `View ${user.name}'s profile on TomodachiShare. Creator of ${user._count.miis} Miis. Member since ${joinDate}.`, keywords: ["mii", "tomodachi life", "nintendo", "mii creator", "mii collection", "profile"], diff --git a/src/app/robots.ts b/src/app/robots.ts index 9808a8e..d185893 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -7,6 +7,6 @@ export default function robots(): MetadataRoute.Robots { allow: "/", disallow: ["/*?page", "/create-username", "/edit/*", "/profile/settings", "/random", "/submit", "/report/mii/*", "/report/user/*", "/admin"], }, - sitemap: `${process.env.BASE_URL}/sitemap.xml`, + sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`, }; } diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 31c1d52..67afa16 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -4,9 +4,9 @@ import type { MetadataRoute } from "next"; type SitemapRoute = MetadataRoute.Sitemap[0]; export default async function sitemap(): Promise { - const baseUrl = process.env.BASE_URL; + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; if (!baseUrl) { - console.error("BASE_URL environment variable missing"); + console.error("NEXT_PUBLIC_BASE_URL environment variable missing"); return []; } diff --git a/src/app/submit/page.tsx b/src/app/submit/page.tsx index 7490f2f..90d5dde 100644 --- a/src/app/submit/page.tsx +++ b/src/app/submit/page.tsx @@ -23,7 +23,7 @@ export default async function SubmitPage() { if (!session.user.username) redirect("/create-username"); // Check if submissions are disabled - const response = await fetch(`${process.env.BASE_URL}/api/admin/can-submit`); + const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`); const { value } = await response.json(); if (!value) diff --git a/src/lib/images.ts b/src/lib/images.ts deleted file mode 100644 index f93e451..0000000 --- a/src/lib/images.ts +++ /dev/null @@ -1,68 +0,0 @@ -import sharp from "sharp"; -import { fileTypeFromBuffer } from "file-type"; - -const MIN_IMAGE_DIMENSIONS = 128; -const MAX_IMAGE_DIMENSIONS = 1024; -const MAX_IMAGE_SIZE = 1024 * 1024; // 1 MB -const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; - -export async function validateImage(file: File): Promise<{ valid: boolean; error?: string; status?: number }> { - if (!file || file.size == 0) return { valid: false, error: "Empty image file" }; - if (file.size > MAX_IMAGE_SIZE) return { valid: false, error: `Image too large. Maximum size is ${MAX_IMAGE_SIZE / (1024 * 1024)}MB` }; - - try { - const buffer = Buffer.from(await file.arrayBuffer()); - - // Check mime type - const fileType = await fileTypeFromBuffer(buffer); - if (!fileType || !ALLOWED_MIME_TYPES.includes(fileType.mime)) - return { valid: false, error: "Invalid image file type. Only .jpeg, .png, .gif, and .webp are allowed" }; - - let metadata: sharp.Metadata; - try { - metadata = await sharp(buffer).metadata(); - } catch { - return { valid: false, error: "Invalid or corrupted image file" }; - } - - // Check image dimensions - if ( - !metadata.width || - !metadata.height || - metadata.width < MIN_IMAGE_DIMENSIONS || - metadata.width > MAX_IMAGE_DIMENSIONS || - metadata.height < MIN_IMAGE_DIMENSIONS || - metadata.height > MAX_IMAGE_DIMENSIONS - ) { - return { valid: false, error: "Image dimensions are invalid. Width and height must be between 128px and 1024px" }; - } - - // Check for inappropriate content - // https://github.com/trafficlunar/api-moderation - try { - const blob = new Blob([buffer]); - const formData = new FormData(); - formData.append("image", blob); - - const moderationResponse = await fetch("https://api.trafficlunar.net/moderate/image", { method: "POST", body: formData }); - - if (!moderationResponse.ok) { - console.error("Moderation API error"); - return { valid: false, error: "Content moderation check failed", status: 500 }; - } - - const result = await moderationResponse.json(); - if (result.error) { - return { valid: false, error: result.error }; - } - } catch (moderationError) { - console.error("Error fetching moderation API:", moderationError); - return { valid: false, error: "Moderation API is down", status: 503 }; - } - - return { valid: true }; - } catch (error) { - console.error("Error validating image:", error); - return { valid: false, error: "Failed to process image file.", status: 500 }; - } -} diff --git a/src/lib/images.tsx b/src/lib/images.tsx new file mode 100644 index 0000000..573cb09 --- /dev/null +++ b/src/lib/images.tsx @@ -0,0 +1,212 @@ +// This file's extension is .tsx because I am using JSX for satori to generate images +// These are disabled because satori is not Next.JS and is turned into an image anyways +/* eslint-disable jsx-a11y/alt-text */ +/* eslint-disable @next/next/no-img-element */ + +import type { ReactNode } from "react"; + +import fs from "fs/promises"; +import path from "path"; +import sharp from "sharp"; +import { fileTypeFromBuffer } from "file-type"; + +import satori, { Font } from "satori"; + +import { Mii } from "@prisma/client"; + +const MIN_IMAGE_DIMENSIONS = 128; +const MAX_IMAGE_DIMENSIONS = 1024; +const MAX_IMAGE_SIZE = 1024 * 1024; // 1 MB +const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; + +//#region Image validation +export async function validateImage(file: File): Promise<{ valid: boolean; error?: string; status?: number }> { + if (!file || file.size == 0) return { valid: false, error: "Empty image file" }; + if (file.size > MAX_IMAGE_SIZE) return { valid: false, error: `Image too large. Maximum size is ${MAX_IMAGE_SIZE / (1024 * 1024)}MB` }; + + try { + const buffer = Buffer.from(await file.arrayBuffer()); + + // Check mime type + const fileType = await fileTypeFromBuffer(buffer); + if (!fileType || !ALLOWED_MIME_TYPES.includes(fileType.mime)) + return { valid: false, error: "Invalid image file type. Only .jpeg, .png, .gif, and .webp are allowed" }; + + let metadata: sharp.Metadata; + try { + metadata = await sharp(buffer).metadata(); + } catch { + return { valid: false, error: "Invalid or corrupted image file" }; + } + + // Check image dimensions + if ( + !metadata.width || + !metadata.height || + metadata.width < MIN_IMAGE_DIMENSIONS || + metadata.width > MAX_IMAGE_DIMENSIONS || + metadata.height < MIN_IMAGE_DIMENSIONS || + metadata.height > MAX_IMAGE_DIMENSIONS + ) { + return { valid: false, error: "Image dimensions are invalid. Width and height must be between 128px and 1024px" }; + } + + // Check for inappropriate content + // https://github.com/trafficlunar/api-moderation + try { + const blob = new Blob([buffer]); + const formData = new FormData(); + formData.append("image", blob); + + const moderationResponse = await fetch("https://api.trafficlunar.net/moderate/image", { method: "POST", body: formData }); + + if (!moderationResponse.ok) { + console.error("Moderation API error"); + return { valid: false, error: "Content moderation check failed", status: 500 }; + } + + const result = await moderationResponse.json(); + if (result.error) { + return { valid: false, error: result.error }; + } + } catch (moderationError) { + console.error("Error fetching moderation API:", moderationError); + return { valid: false, error: "Moderation API is down", status: 503 }; + } + + return { valid: true }; + } catch (error) { + console.error("Error validating image:", error); + return { valid: false, error: "Failed to process image file.", status: 500 }; + } +} +//#endregion + +//#region Generating 'metadata' image type +const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); + +const fontCache: Record = { + regular: null, + medium: null, + semiBold: null, + bold: null, + extraBold: null, + black: null, +}; + +// Load fonts only once and cache them +const loadFonts = async (): Promise => { + const weights = [ + ["regular", 400], + ["medium", 500], + ["semiBold", 600], + ["bold", 700], + ["extraBold", 800], + ["black", 900], + ] as const; + + return Promise.all( + weights.map(async ([weight, value]) => { + if (!fontCache[weight]) { + const filePath = path.join(process.cwd(), `public/fonts/lexend-${weight}.ttf`); + const data = await fs.readFile(filePath); + fontCache[weight] = { + name: "Lexend", + data, + weight: value, + }; + } + return fontCache[weight]!; + }) + ); +}; + +export async function generateMetadataImage(mii: Mii, author: string): Promise<{ buffer?: Buffer; error?: string; status?: number }> { + const miiUploadsDirectory = path.join(uploadsDirectory, mii.id.toString()); + + // Load assets concurrently + const [miiImage, qrCodeImage, fonts] = await Promise.all([ + // Read and convert the .webp images to .png (because satori doesn't support it) + fs.readFile(path.join(miiUploadsDirectory, "mii.webp")).then((buffer) => + sharp(buffer) + .png() + .toBuffer() + .then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`) + ), + fs.readFile(path.join(miiUploadsDirectory, "qr-code.webp")).then((buffer) => + sharp(buffer) + .png() + .toBuffer() + .then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`) + ), + loadFonts(), + ]); + + const jsx: ReactNode = ( +
+
+ {/* Mii image */} +
+ +
+ + {/* QR code */} +
+ +
+
+ +
+ {/* Mii name */} + + {mii.name} + + {/* Tags */} +
+ {mii.tags.map((tag) => ( + + {tag} + + ))} +
+ + {/* Author */} +
+ By: @{author} +
+ + {/* Watermark */} +
+ + {/* I tried using text-orange-400 but it wasn't correct..? */} + + TomodachiShare + +
+
+
+ ); + + const svg = await satori(jsx, { + width: 600, + height: 400, + fonts, + }); + + // Convert .svg to .png + const buffer = await sharp(Buffer.from(svg)).png().toBuffer(); + + // Store the file + try { + // I tried using .webp here but the quality looked awful + // but it actually might be well-liked due to the hatred of .webp + const fileLocation = path.join(miiUploadsDirectory, "metadata.png"); + await fs.writeFile(fileLocation, buffer); + } catch (error) { + console.error("Error storing 'metadata' image type", error); + return { error: `Failed to store metadata image for ${mii.id}`, status: 500 }; + } + + return { buffer }; +} +//#endregion