feat: 'metadata' image generation for miis

for use on search engines
This commit is contained in:
trafficlunar 2025-05-21 22:02:35 +01:00
parent ada54d46c8
commit de2c281257
22 changed files with 463 additions and 97 deletions

View file

@ -4,10 +4,10 @@ DATABASE_URL="postgresql://postgres:frieren@localhost:5432/tomodachi-share?schem
REDIS_URL="redis://localhost:6379/0" REDIS_URL="redis://localhost:6379/0"
# Used for metadata, sitemaps, etc. # Used for metadata, sitemaps, etc.
BASE_URL=http://localhost:3000 NEXT_PUBLIC_BASE_URL=http://localhost:3000
# Check Auth.js docs for information # 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_TRUST_HOST=true
AUTH_SECRET=XXXXXXXXXXXXXXXX AUTH_SECRET=XXXXXXXXXXXXXXXX
AUTH_DISCORD_ID=XXXXXXXXXXXXXXXX AUTH_DISCORD_ID=XXXXXXXXXXXXXXXX

View file

@ -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. 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 ## Getting started
To get the project up and running locally, follow these steps: To get the project up and running locally, follow these steps:

View file

@ -2,7 +2,7 @@
"name": "tomodachi-share", "name": "tomodachi-share",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "pnpm@10.10.0", "packageManager": "pnpm@10.11.0",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
@ -33,6 +33,7 @@
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-webcam": "^7.2.0", "react-webcam": "^7.2.0",
"satori": "^0.13.1",
"sharp": "^0.34.1", "sharp": "^0.34.1",
"sjcl-with-all": "1.0.8", "sjcl-with-all": "1.0.8",
"swr": "^2.3.3", "swr": "^2.3.3",

View file

@ -71,6 +71,9 @@ importers:
react-webcam: react-webcam:
specifier: ^7.2.0 specifier: ^7.2.0
version: 7.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.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: sharp:
specifier: ^0.34.1 specifier: ^0.34.1
version: 0.34.1 version: 0.34.1
@ -837,6 +840,11 @@ packages:
'@rushstack/eslint-patch@1.11.0': '@rushstack/eslint-patch@1.11.0':
resolution: {integrity: sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ==} 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': '@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
@ -1220,6 +1228,10 @@ packages:
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} 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: bit-buffer@0.2.5:
resolution: {integrity: sha512-x1yGnmXvFg6e3DiyRztElbcn1bsCTFSoM/ncAzY62uE0JdTl5xlKJd0ooqLYoPbhdsnpehSIQrdIvclcZJYwiA==} resolution: {integrity: sha512-x1yGnmXvFg6e3DiyRztElbcn1bsCTFSoM/ncAzY62uE0JdTl5xlKJd0ooqLYoPbhdsnpehSIQrdIvclcZJYwiA==}
@ -1257,6 +1269,9 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
camelize@1.0.1:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
caniuse-lite@1.0.30001716: caniuse-lite@1.0.30001716:
resolution: {integrity: sha512-49/c1+x3Kwz7ZIWt+4DvK3aMJy9oYXXG6/97JKsnjdCk/6n9vVyWL8NAwVt95Lwt9eigI10Hl782kDfZUUlRXw==} resolution: {integrity: sha512-49/c1+x3Kwz7ZIWt+4DvK3aMJy9oYXXG6/97JKsnjdCk/6n9vVyWL8NAwVt95Lwt9eigI10Hl782kDfZUUlRXw==}
@ -1310,9 +1325,26 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
css-background-parser@0.1.0:
resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==}
css-box-model@1.2.1: css-box-model@1.2.1:
resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} 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: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@ -1404,6 +1436,10 @@ packages:
embla-carousel@8.6.0: embla-carousel@8.6.0:
resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} 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: emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@ -1456,6 +1492,9 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@4.0.0: escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -1611,6 +1650,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.7.4:
resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==}
fflate@0.8.2: fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
@ -1728,6 +1770,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
hex-rgb@4.3.0:
resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==}
engines: {node: '>=6'}
ieee754@1.2.1: ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@ -1975,6 +2021,9 @@ packages:
resolution: {integrity: sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==} resolution: {integrity: sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
linebreak@1.1.0:
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
locate-path@6.0.0: locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2124,10 +2173,16 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'} engines: {node: '>=10'}
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
parent-module@1.0.1: parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
parse-css-color@0.2.1:
resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
path-exists@4.0.0: path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2165,6 +2220,9 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.4.31: postcss@8.4.31:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@ -2312,6 +2370,10 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
satori@0.13.1:
resolution: {integrity: sha512-FlXblaCRDOONmz4JSIG9lUxSIklBZsMVwfLkvXv0MaHa3H6GWZDZccpcCeLqdQ6RjBkYMSh6zZDxkkBFJ4M61A==}
engines: {node: '>=16'}
scheduler@0.26.0: scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
@ -2397,6 +2459,9 @@ packages:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'} 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: string.prototype.includes@2.0.1:
resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2465,6 +2530,9 @@ packages:
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@ -2546,6 +2614,9 @@ packages:
undici-types@6.19.8: undici-types@6.19.8:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
unicode-trie@2.0.0:
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
unrs-resolver@1.7.2: unrs-resolver@1.7.2:
resolution: {integrity: sha512-BBKpaylOW8KbHsu378Zky/dGh4ckT/4NW/0SHRABdqRLcQJ2dAOjDo9g97p04sWflm0kqPqpUatxReNV/dqI5A==} resolution: {integrity: sha512-BBKpaylOW8KbHsu378Zky/dGh4ckT/4NW/0SHRABdqRLcQJ2dAOjDo9g97p04sWflm0kqPqpUatxReNV/dqI5A==}
@ -2664,6 +2735,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
yoga-wasm-web@0.3.3:
resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==}
zod@3.24.3: zod@3.24.3:
resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==} resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==}
@ -3184,6 +3258,11 @@ snapshots:
'@rushstack/eslint-patch@1.11.0': {} '@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/counter@0.1.3': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
@ -3571,6 +3650,8 @@ snapshots:
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
base64-js@0.0.8: {}
bit-buffer@0.2.5: {} bit-buffer@0.2.5: {}
brace-expansion@1.1.11: brace-expansion@1.1.11:
@ -3611,6 +3692,8 @@ snapshots:
callsites@3.1.0: {} callsites@3.1.0: {}
camelize@1.0.1: {}
caniuse-lite@1.0.30001716: {} caniuse-lite@1.0.30001716: {}
canvas-confetti@1.9.3: {} canvas-confetti@1.9.3: {}
@ -3662,10 +3745,24 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
css-background-parser@0.1.0: {}
css-box-model@1.2.1: css-box-model@1.2.1:
dependencies: dependencies:
tiny-invariant: 1.3.3 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: {} csstype@3.1.3: {}
damerau-levenshtein@1.0.8: {} damerau-levenshtein@1.0.8: {}
@ -3751,6 +3848,8 @@ snapshots:
embla-carousel@8.6.0: {} embla-carousel@8.6.0: {}
emoji-regex-xs@2.0.1: {}
emoji-regex@9.2.2: {} emoji-regex@9.2.2: {}
enhanced-resolve@5.18.1: enhanced-resolve@5.18.1:
@ -3893,6 +3992,8 @@ snapshots:
'@esbuild/win32-ia32': 0.25.3 '@esbuild/win32-ia32': 0.25.3
'@esbuild/win32-x64': 0.25.3 '@esbuild/win32-x64': 0.25.3
escape-html@1.0.3: {}
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-config-next@15.2.4(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3): eslint-config-next@15.2.4(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3):
@ -4128,6 +4229,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.2 picomatch: 4.0.2
fflate@0.7.4: {}
fflate@0.8.2: {} fflate@0.8.2: {}
file-entry-cache@8.0.0: file-entry-cache@8.0.0:
@ -4254,6 +4357,8 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
hex-rgb@4.3.0: {}
ieee754@1.2.1: {} ieee754@1.2.1: {}
ignore@5.3.2: {} ignore@5.3.2: {}
@ -4498,6 +4603,11 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.29.2 lightningcss-win32-arm64-msvc: 1.29.2
lightningcss-win32-x64-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: locate-path@6.0.0:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
@ -4643,10 +4753,17 @@ snapshots:
dependencies: dependencies:
p-limit: 3.1.0 p-limit: 3.1.0
pako@0.2.9: {}
parent-module@1.0.1: parent-module@1.0.1:
dependencies: dependencies:
callsites: 3.1.0 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-exists@4.0.0: {}
path-key@3.1.1: {} path-key@3.1.1: {}
@ -4667,6 +4784,8 @@ snapshots:
possible-typed-array-names@1.1.0: {} possible-typed-array-names@1.1.0: {}
postcss-value-parser@4.2.0: {}
postcss@8.4.31: postcss@8.4.31:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.11
@ -4841,6 +4960,20 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
is-regex: 1.2.1 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: {} scheduler@0.26.0: {}
semver@6.3.1: {} semver@6.3.1: {}
@ -4977,6 +5110,8 @@ snapshots:
streamsearch@1.1.0: {} streamsearch@1.1.0: {}
string.prototype.codepointat@0.2.1: {}
string.prototype.includes@2.0.1: string.prototype.includes@2.0.1:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@ -5057,6 +5192,8 @@ snapshots:
tapable@2.2.1: {} tapable@2.2.1: {}
tiny-inflate@1.0.3: {}
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tinybench@2.9.0: {} tinybench@2.9.0: {}
@ -5146,6 +5283,11 @@ snapshots:
undici-types@6.19.8: {} 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: unrs-resolver@1.7.2:
dependencies: dependencies:
napi-postinstall: 0.2.3 napi-postinstall: 0.2.3
@ -5304,4 +5446,6 @@ snapshots:
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
yoga-wasm-web@0.3.3: {}
zod@3.24.3: {} zod@3.24.3: {}

1
public/fonts/README.md Normal file
View file

@ -0,0 +1 @@
These fonts are used for generating the 'metadata' image type for Miis (the images you should see in search engines!)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -14,7 +14,7 @@ import { prisma } from "@/lib/prisma";
import { nameSchema, tagsSchema } from "@/lib/schemas"; import { nameSchema, tagsSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
import { validateImage } from "@/lib/images"; import { generateMetadataImage, validateImage } from "@/lib/images";
import { convertQrCode } from "@/lib/qr-codes"; import { convertQrCode } from "@/lib/qr-codes";
import Mii from "@/lib/mii.js/mii"; import Mii from "@/lib/mii.js/mii";
import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii"; import { TomodachiLifeMii } from "@/lib/tomodachi-life-mii";
@ -41,7 +41,7 @@ export async function POST(request: NextRequest) {
const check = await rateLimit.handle(); const check = await rateLimit.handle();
if (check) return check; 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(); const { value } = await response.json();
if (!value) return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409); if (!value) return rateLimit.sendResponse({ error: "Submissions are disabled" }, 409);
@ -140,7 +140,7 @@ export async function POST(request: NextRequest) {
} }
try { try {
// Compress and upload // Compress and store
const studioWebpBuffer = await sharp(studioBuffer).webp({ quality: 85 }).toBuffer(); const studioWebpBuffer = await sharp(studioBuffer).webp({ quality: 85 }).toBuffer();
const studioFileLocation = path.join(miiUploadsDirectory, "mii.webp"); const studioFileLocation = path.join(miiUploadsDirectory, "mii.webp");
@ -152,16 +152,17 @@ export async function POST(request: NextRequest) {
generatedCode.addData(byteString, "Byte"); generatedCode.addData(byteString, "Byte");
generatedCode.make(); generatedCode.make();
// Upload QR code // Store QR code
const codeDataUrl = generatedCode.createDataURL(); const codeDataUrl = generatedCode.createDataURL();
const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, ""); const codeBase64 = codeDataUrl.replace(/^data:image\/gif;base64,/, "");
const codeBuffer = Buffer.from(codeBase64, "base64"); const codeBuffer = Buffer.from(codeBase64, "base64");
// Compress and upload // Compress and store
const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer(); const codeWebpBuffer = await sharp(codeBuffer).webp({ quality: 85 }).toBuffer();
const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp"); const codeFileLocation = path.join(miiUploadsDirectory, "qr-code.webp");
await fs.writeFile(codeFileLocation, codeWebpBuffer); await fs.writeFile(codeFileLocation, codeWebpBuffer);
await generateMetadataImage(miiRecord, session.user.username!);
} catch (error) { } catch (error) {
// Clean up if something went wrong // Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } }); 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); return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
} }
// Compress and upload user images // Compress and store user images
try { try {
await Promise.all( await Promise.all(
images.map(async (image, index) => { images.map(async (image, index) => {
@ -192,7 +193,7 @@ export async function POST(request: NextRequest) {
}, },
}); });
} catch (error) { } 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); return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
} }

View file

@ -14,7 +14,7 @@ const lexend = Lexend({
}); });
export const metadata: Metadata = { 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!", title: "TomodachiShare - home for Tomodachi Life Miis!",
description: "Discover and share Mii residents for your Tomodachi Life island!", description: "Discover and share Mii residents for your Tomodachi Life island!",
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection"], keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection"],

View file

@ -1,16 +1,30 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { idSchema } from "@/lib/schemas"; import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit"; 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({ const searchParamsSchema = z.object({
type: z type: z
.enum(["mii", "qr-code", "image0", "image1", "image2"], { .enum(["mii", "qr-code", "image0", "image1", "image2", "metadata"], {
message: "Image type must be either 'mii', 'qr-code' or 'image[number from 0 to 2]'", message: "Image type must be either 'mii', 'qr-code', 'image[number from 0 to 2]' or 'metadata'",
}) })
.default("mii"), .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); if (!searchParamsParsed.success) return rateLimit.sendResponse({ error: searchParamsParsed.error.errors[0].message }, 400);
const { type: imageType } = searchParamsParsed.data; 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 { try {
const buffer = await fs.readFile(filePath); // Try to read file
return new NextResponse(buffer); buffer = await fs.readFile(filePath);
} catch { } catch {
// 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); 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);
}

View file

@ -38,13 +38,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
// Bots get redirected anyways // Bots get redirected anyways
if (!mii) return {}; if (!mii) return {};
const miiImageUrl = `/mii/${mii.id}/image?type=mii`; const metadataImageUrl = `/mii/${mii.id}/image?type=metadata`;
const qrCodeUrl = `/mii/${mii.id}/image?type=qr-code`;
const username = `@${mii.user.username}`; const username = `@${mii.user.username}`;
return { return {
metadataBase: new URL(process.env.BASE_URL!), metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `${mii.name} - TomodachiShare`, 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.`, 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], 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<Metadata> {
type: "article", type: "article",
title: `${mii.name} - TomodachiShare`, 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.`, 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(), publishedTime: mii.createdAt.toISOString(),
authors: username, authors: username,
}, },
@ -61,7 +60,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
card: "summary_large_image", card: "summary_large_image",
title: `${mii.name} - TomodachiShare`, 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.`, 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, creator: username,
}, },
alternates: { alternates: {

View file

@ -19,7 +19,7 @@ export async function generateMetadata({ searchParams }: Props): Promise<Metadat
const description = `Discover Miis tagged '${tags}' for your Tomodachi Life island!`; const description = `Discover Miis tagged '${tags}' for your Tomodachi Life island!`;
return { return {
metadataBase: new URL(process.env.BASE_URL!), metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `Miis tagged with '${tags}' - TomodachiShare`, title: `Miis tagged with '${tags}' - TomodachiShare`,
description, description,
keywords: [...tags, "mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection"], keywords: [...tags, "mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection"],

View file

@ -38,7 +38,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}); });
return { return {
metadataBase: new URL(process.env.BASE_URL!), metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
title: `${user.name} (@${user.username}) - TomodachiShare`, title: `${user.name} (@${user.username}) - TomodachiShare`,
description: `View ${user.name}'s profile on TomodachiShare. Creator of ${user._count.miis} Miis. Member since ${joinDate}.`, 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"], keywords: ["mii", "tomodachi life", "nintendo", "mii creator", "mii collection", "profile"],

View file

@ -7,6 +7,6 @@ export default function robots(): MetadataRoute.Robots {
allow: "/", allow: "/",
disallow: ["/*?page", "/create-username", "/edit/*", "/profile/settings", "/random", "/submit", "/report/mii/*", "/report/user/*", "/admin"], 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`,
}; };
} }

View file

@ -4,9 +4,9 @@ import type { MetadataRoute } from "next";
type SitemapRoute = MetadataRoute.Sitemap[0]; type SitemapRoute = MetadataRoute.Sitemap[0];
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.BASE_URL; const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
if (!baseUrl) { if (!baseUrl) {
console.error("BASE_URL environment variable missing"); console.error("NEXT_PUBLIC_BASE_URL environment variable missing");
return []; return [];
} }

View file

@ -23,7 +23,7 @@ export default async function SubmitPage() {
if (!session.user.username) redirect("/create-username"); if (!session.user.username) redirect("/create-username");
// Check if submissions are disabled // 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(); const { value } = await response.json();
if (!value) if (!value)

View file

@ -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 };
}
}

212
src/lib/images.tsx Normal file
View file

@ -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<string, Font | null> = {
regular: null,
medium: null,
semiBold: null,
bold: null,
extraBold: null,
black: null,
};
// Load fonts only once and cache them
const loadFonts = async (): Promise<Font[]> => {
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 = (
<div tw="w-full h-full bg-amber-50 border-2 border-amber-500 rounded-2xl p-4 flex flex-col">
<div tw="flex w-full">
{/* Mii image */}
<div tw="w-80 rounded-xl flex justify-center mr-2" style={{ backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);" }}>
<img src={miiImage} width={248} height={248} style={{ filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)" }} />
</div>
{/* QR code */}
<div tw="w-60 bg-amber-200 rounded-xl flex justify-center items-center">
<img src={qrCodeImage} width={190} height={190} tw="border-2 border-amber-300 rounded-lg" />
</div>
</div>
<div tw="flex flex-col w-full h-30 relative">
{/* Mii name */}
<span tw="text-4xl font-extrabold text-amber-700 mt-2" style={{ display: "block", lineClamp: 1, wordBreak: "break-word" }}>
{mii.name}
</span>
{/* Tags */}
<div id="tags" tw="flex flex-wrap mt-1 w-full">
{mii.tags.map((tag) => (
<span key={tag} tw="mr-1 px-2 py-1 bg-orange-300 rounded-full text-sm">
{tag}
</span>
))}
</div>
{/* Author */}
<div tw="flex text-sm mt-2">
By: <span tw="ml-1.5 font-semibold">@{author}</span>
</div>
{/* Watermark */}
<div tw="absolute bottom-0 right-0 flex items-center">
<img src={`${process.env.NEXT_PUBLIC_BASE_URL}/logo.svg`} height={40} />
{/* I tried using text-orange-400 but it wasn't correct..? */}
<span tw="ml-2 font-black text-xl" style={{ color: "#FF8904" }}>
TomodachiShare
</span>
</div>
</div>
</div>
);
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