feat: 'metadata' image generation for miis
for use on search engines
This commit is contained in:
parent
ada54d46c8
commit
de2c281257
22 changed files with 463 additions and 97 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
144
pnpm-lock.yaml
144
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: {}
|
||||
|
|
|
|||
1
public/fonts/README.md
Normal file
1
public/fonts/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
These fonts are used for generating the 'metadata' image type for Miis (the images you should see in search engines!)
|
||||
BIN
public/fonts/lexend-black.ttf
Normal file
BIN
public/fonts/lexend-black.ttf
Normal file
Binary file not shown.
BIN
public/fonts/lexend-bold.ttf
Normal file
BIN
public/fonts/lexend-bold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/lexend-extraBold.ttf
Normal file
BIN
public/fonts/lexend-extraBold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/lexend-medium.ttf
Normal file
BIN
public/fonts/lexend-medium.ttf
Normal file
Binary file not shown.
BIN
public/fonts/lexend-regular.ttf
Normal file
BIN
public/fonts/lexend-regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/lexend-semiBold.ttf
Normal file
BIN
public/fonts/lexend-semiBold.ttf
Normal file
Binary file not shown.
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
// 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,13 +38,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||
// 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<Metadata> {
|
|||
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<Metadata> {
|
|||
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: {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export async function generateMetadata({ searchParams }: Props): Promise<Metadat
|
|||
const description = `Discover Miis tagged '${tags}' for your Tomodachi Life island!`;
|
||||
|
||||
return {
|
||||
metadataBase: new URL(process.env.BASE_URL!),
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
|
||||
title: `Miis tagged with '${tags}' - TomodachiShare`,
|
||||
description,
|
||||
keywords: [...tags, "mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection"],
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||
});
|
||||
|
||||
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"],
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import type { MetadataRoute } from "next";
|
|||
type SitemapRoute = MetadataRoute.Sitemap[0];
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
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 [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
212
src/lib/images.tsx
Normal 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
|
||||
Loading…
Reference in a new issue