diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 38be91f..619e6de 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v2 with: - node-version: "16" + node-version: "20" - name: Install dependencies run: npm ci diff --git a/locales/en.json b/locales/en.json index 68ea126..f505f65 100644 --- a/locales/en.json +++ b/locales/en.json @@ -17,6 +17,7 @@ "hidden": "Hidden", "administrator": "Administrator", "moderator": "Moderator", + "donor": "Donator", "play_log": "Play Log", "registered_on": "Registered", @@ -43,6 +44,8 @@ "select_font": "Select Font", "images": "Images", + "donators": "Donator Benefits", + "name_color": "Name Colors", "home": "Home", "profile": "Profile", diff --git a/locales/en_uk.json b/locales/en_uk.json index cee5152..bc1c675 100644 --- a/locales/en_uk.json +++ b/locales/en_uk.json @@ -17,6 +17,7 @@ "hidden": "Hidden", "administrator": "Administrator", "moderator": "Moderator", + "donor": "Donator", "play_log": "Play Log", "registered_on": "Registered", @@ -43,6 +44,8 @@ "select_font": "Select Font", "images": "Images", + "donators": "Donator Benefits", + "name_color": "Name Colours", "home": "Home", "profile": "Profile", diff --git a/locales/jp.json b/locales/jp.json index eb3f74e..211258f 100644 --- a/locales/jp.json +++ b/locales/jp.json @@ -16,6 +16,7 @@ "hidden": "非表示", "administrator": "管理者", "moderator": "モデレーター", + "donor": "寄贈者", "play_log": "プレイログ", "registered_on": "登録日", @@ -42,6 +43,8 @@ "select_font": "フォントを選択", "images": "画像", + "donators": "寄贈者", + "name_color": "名前カラー", "home": "ホーム", "leaderboard": "リーダーボード", diff --git a/prisma/migrations/20240801065036_donors/migration.sql b/prisma/migrations/20240801065036_donors/migration.sql new file mode 100644 index 0000000..9f105d0 --- /dev/null +++ b/prisma/migrations/20240801065036_donors/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "isDonor" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "user" ADD COLUMN "nameColor" TEXT NOT NULL DEFAULT '#000000'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c99015a..223ca69 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -126,10 +126,12 @@ model user { created_at DateTime @default(dbgenerated("CURRENT_TIMESTAMP(3)")) updated_at DateTime @default(dbgenerated("CURRENT_TIMESTAMP(3)")) badge String? @db.VarChar(50) + language String @default("en") @db.VarChar(11) isBanned Int @default(0) @db.SmallInt isPublic Int @default(1) @db.SmallInt publicOverride Int? @db.SmallInt - language String @default("en") @db.VarChar(11) + isDonor Boolean @default(false) + nameColor String @default("#000000") accounts accounts[] banned_user banned_user[] game_sessions game_sessions[] diff --git a/src/components/account/DonorButton.jsx b/src/components/account/DonorButton.jsx new file mode 100644 index 0000000..78b87a2 --- /dev/null +++ b/src/components/account/DonorButton.jsx @@ -0,0 +1,42 @@ +import { React } from 'react' +import { useRouter } from 'next/router' +import { toast } from 'react-toastify' +import useInfo from '@/lib/swr-hooks/useInfo' +import { Button } from 'react-bootstrap' +import PropTypes from 'prop-types' + +export default function DonorButton ({ isDonor, id }) { + const router = useRouter() + const { mutate } = useInfo() + + const setDonor = async (status) => { + const response = await fetch('/api/account/donor', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + status, + user: id + }) + }) + if (response.status === 200) { + toast.success('The account\'s donor status has been updated.') + mutate() + router.reload() + } else { + toast.error('An error occured, please try again later.') + } + } + + return ( + + ) +} + +DonorButton.propTypes = { + id: PropTypes.number.isRequired, + isDonor: PropTypes.bool.isRequired +} diff --git a/src/components/edit/DonorsCard.jsx b/src/components/edit/DonorsCard.jsx new file mode 100644 index 0000000..bc78afb --- /dev/null +++ b/src/components/edit/DonorsCard.jsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { Alert, Card, Col, Row } from 'react-bootstrap' +import LocalizedString from '../shared/LocalizedString' +import LanguageContext from '../shared/LanguageContext' + +import styles from './DonorsCard.module.css' + +function ColorBox ({ color, onClick, current }) { + return ( +
onClick(e, color)} /> + ) +} + +function ColorPicker ({ current, setColor }) { + const [color, setColorState] = useState(current) + + return ( +
+ { setColorState(e.target.value); setColor(e, e.target.value) }} /> +

Custom

+
+ ) +} + +ColorPicker.propTypes = { + current: PropTypes.string.isRequired, + setColor: PropTypes.func.isRequired +} + +ColorBox.propTypes = { + color: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + current: PropTypes.string.isRequired +} + +function DonorsCard ({ values, errors, handleChange }) { + const setColor = (e, color) => { + values.nameColor = color + handleChange(e) + } + + return ( + + {(lang) => ( + + + + + +

+
+ + + + + + + +
+ {errors.overlay && ( + + {errors.overlay} + + )} + +
+
+
+ )} +
+ ) +} + +DonorsCard.propTypes = { + values: PropTypes.object.isRequired, + errors: PropTypes.object.isRequired, + handleChange: PropTypes.func.isRequired +} + +export default DonorsCard diff --git a/src/components/edit/DonorsCard.module.css b/src/components/edit/DonorsCard.module.css new file mode 100644 index 0000000..c80233a --- /dev/null +++ b/src/components/edit/DonorsCard.module.css @@ -0,0 +1,31 @@ +.colorBox { + width: 48px; + height: 48px; + margin-left: 8px; + margin-top: 8px; + border-radius: 8px; +} + +.colorPicker { + width: 48px; + height: 48px; + margin-left: 8px; + margin-top: 8px; +} + +.colorPicker input { + height: 100% +} + +.colorPicker p { + font-size: 10px; + text-align: center; + width: 48px; + color: #DDD; +} + +.colorBoxes { + display: flex; + flex-direction: row; + flex-wrap: wrap +} diff --git a/src/components/edit/ImagesCard.jsx b/src/components/edit/ImagesCard.jsx index 96986e0..bd5da0f 100644 --- a/src/components/edit/ImagesCard.jsx +++ b/src/components/edit/ImagesCard.jsx @@ -19,7 +19,7 @@ const backgrounds = BACKGROUNDS.map((background) => ({ label: background })) -function ImagesCard ({ values, errors, handleChange }) { +function ImagesCard ({ values, errors, handleChange, username }) { return ( {(lang) => ( @@ -66,12 +66,35 @@ function ImagesCard ({ values, errors, handleChange }) { {errors.background} )} +
+ { + const formData = new FormData() + formData.append('file', event.currentTarget.files[0]) + + values.background = `${username}.png` + + return fetch('/api/account/background', { + method: 'POST', + body: formData + }) + }} + /> +

+ + Please ensure that your image is 1200x450 and is in PNG format. + +

Background Preview @@ -134,7 +157,8 @@ function ImagesCard ({ values, errors, handleChange }) { ImagesCard.propTypes = { values: PropTypes.object.isRequired, errors: PropTypes.object.isRequired, - handleChange: PropTypes.func.isRequired + handleChange: PropTypes.func.isRequired, + username: PropTypes.string.isRequired } export default ImagesCard diff --git a/src/components/shared/AppNavbar.jsx b/src/components/shared/AppNavbar.jsx index 72ee9d5..a604077 100644 --- a/src/components/shared/AppNavbar.jsx +++ b/src/components/shared/AppNavbar.jsx @@ -41,7 +41,7 @@ function AppNavbar () { LinkTag Logo diff --git a/src/components/user/UserInformationCard.jsx b/src/components/user/UserInformationCard.jsx index c6c34bb..2a9e999 100644 --- a/src/components/user/UserInformationCard.jsx +++ b/src/components/user/UserInformationCard.jsx @@ -17,6 +17,7 @@ import BanAccountButton from '../account/BanAccountButton' import ForceHiddenAccountButton from '../account/ForceHiddenAccountButton' import LanguageContext from '../shared/LanguageContext' import LocalizedString from '../shared/LocalizedString' +import DonorButton from '@/components/account/DonorButton' function UserInformationCard ({ user, isLoggedIn, isAdmin, isMod }) { return ( @@ -29,6 +30,7 @@ function UserInformationCard ({ user, isLoggedIn, isAdmin, isMod }) { {(user.publicOverride === 0 || (user.publicOverride === 1 && isMod)) && ()} {user.role === 'admin' && ()} {user.role === 'mod' && ()} + {user.isDonor === true && ()}
  • : {user.display_name} @@ -37,7 +39,7 @@ function UserInformationCard ({ user, isLoggedIn, isAdmin, isMod }) { : {OVERLAYS.find((overlay) => overlay.value === user.overlay).label}
  • - : {user.background} + : {!Number.isNaN(Number(user.background.replace(/.*\//, '').replace(/\.png$/, ''))) ? 'Custom' : user.background}
  • : {COINS.find((coin) => coin.value === user.coin).label} @@ -71,6 +73,7 @@ function UserInformationCard ({ user, isLoggedIn, isAdmin, isMod }) { +
)} diff --git a/src/lib/constants/filePaths.js b/src/lib/constants/filePaths.js index 236de4e..a669676 100644 --- a/src/lib/constants/filePaths.js +++ b/src/lib/constants/filePaths.js @@ -12,7 +12,8 @@ export const CACHE = Object.freeze({ COVER: path.resolve(CACHE_PATH, 'cover'), MIIS: path.resolve(CACHE_PATH, 'mii', 'user'), TAGS: path.resolve(CACHE_PATH, 'tags'), - WADS: path.resolve(CACHE_PATH, 'wads') + WADS: path.resolve(CACHE_PATH, 'wads'), + BACKGROUNDS: path.resolve(CACHE_PATH, 'backgrounds') }) export const PUBLIC = Object.freeze({ diff --git a/src/lib/riitag/neo/std/Background.ts b/src/lib/riitag/neo/std/Background.ts index 5c55391..4523523 100644 --- a/src/lib/riitag/neo/std/Background.ts +++ b/src/lib/riitag/neo/std/Background.ts @@ -1,4 +1,4 @@ -import { PUBLIC } from '@/lib/constants/filePaths' +import { PUBLIC, CACHE } from '@/lib/constants/filePaths' import path from 'node:path' import Canvas from 'canvas' import fs from 'node:fs' @@ -7,7 +7,7 @@ import logger from '@/lib/logger' export default class Background extends ModuleBase { async render (ctx: Canvas.CanvasRenderingContext2D, user): Promise { - const bgPath = path.resolve(PUBLIC.BACKGROUND, user.background) + const bgPath = path.resolve(!Number.isNaN(Number(user.background.replace(/.*\//, '').replace(/\.png$/, ''))) ? CACHE.BACKGROUNDS : PUBLIC.BACKGROUND, user.background) if (!fs.existsSync(bgPath)) { logger.error(`Background image does not exist: ${bgPath}`) diff --git a/src/lib/riitag/neo/std/Username.ts b/src/lib/riitag/neo/std/Username.ts index 28320c6..f18af36 100644 --- a/src/lib/riitag/neo/std/Username.ts +++ b/src/lib/riitag/neo/std/Username.ts @@ -33,6 +33,7 @@ export default class Username extends ModuleBase { logger.info(`User Font: ${user.font}`) logger.info(`Font Info: ${this.font.name} ${this.font.size} ${this.font.style} ${this.font.color} ${this.font.force}`) + if (user.isDonor === true) this.font.color = user.nameColor drawText(ctx, this.font, user.display_name, this.x, this.y, this.align) } } diff --git a/src/lib/utils/fileUtils.ts b/src/lib/utils/fileUtils.ts index b246dd9..9d321c2 100644 --- a/src/lib/utils/fileUtils.ts +++ b/src/lib/utils/fileUtils.ts @@ -3,11 +3,12 @@ import path from 'node:path' import logger from '@/lib/logger' import { Readable } from 'stream' import { finished } from 'node:stream/promises' +import { Buffer } from 'buffer' -export const exists = async (filename) => +export const exists = async (filename: string) => !!(await fs.promises.stat(filename).catch(() => null)) -export async function saveFile (filepath, file: any | null) { +export async function saveFile (filepath: string, file: any | null) { if (file == null) return if (!(await exists(filepath))) { @@ -20,3 +21,17 @@ export async function saveFile (filepath, file: any | null) { logger.info('File saved successfully') } + +export async function saveFileBuffer (filepath: string, file: Buffer) { + logger.info(`Saving file to ${filepath}`) + if (!(await exists(filepath))) { + await fs.promises.mkdir(path.dirname(filepath), { recursive: true }) + } + + try { + await fs.promises.writeFile(filepath, file) + logger.info('File saved successfully') + } catch (error) { + logger.error('Error saving the file:', error) + } +} diff --git a/src/pages/api/account/background.ts b/src/pages/api/account/background.ts new file mode 100644 index 0000000..70dd3af --- /dev/null +++ b/src/pages/api/account/background.ts @@ -0,0 +1,112 @@ +import { IncomingForm, Fields, Files } from 'formidable' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { ncWithSession } from '@/lib/routing' +import HTTP_CODE from '@/lib/constants/httpStatusCodes' +import { saveFileBuffer } from '@/lib/utils/fileUtils' +import { CACHE } from '@/lib/constants/filePaths' +import prisma from '@/lib/db' +import { makeBanner } from '@/lib/riitag/banner' +import logger from '@/lib/logger' +import { Request, Response } from 'express' +import { setFileHeaders } from '../../../lib/utils/utils' +import fs from 'node:fs' + +async function postBackground (request: Request, response: Response) { + if (request.socket.bytesRead > 2_107_638) { + return response + .status(HTTP_CODE.REQUEST_ENTITY_TOO_LARGE) + .send({ error: 'Request entity too large.' }) + } + + // @ts-ignore + const username: string = request.session?.username + + if (!username) { + return response + .status(HTTP_CODE.UNAUTHORIZED) + .json({ error: 'Unauthorized' }) + } + + const data: unknown = await new Promise((resolve, reject): void => { + const form = new IncomingForm() + + form.parse(request, (error, fields: Fields, files: Files): void => { + if (error) return reject(error) + return resolve({ fields, files }) + }) + }) + .catch((error) => { + logger.error(error) + return response + .status(HTTP_CODE.BAD_REQUEST) + .send({ error: 'Invalid data' }) + }) + + // @ts-ignore + const { file } = data.files + + if (file.mimetype !== 'image/png') { + return response + .status(HTTP_CODE.BAD_REQUEST) + .send({ error: 'Invalid data' }) + } + + // Hard cap of 2MBs for custom backgrounds + if (file.size > 2_000_000) { + return response + .status(HTTP_CODE.REQUEST_ENTITY_TOO_LARGE) + .send({ error: 'Request entity too large.' }) + } + + let user: {username: string} = await prisma.user.findFirst({ + where: { + username + }, + select: { + username: true + } + }) + + const filepath: string = path.resolve(CACHE.BACKGROUNDS, `${user.username}.png`) + await saveFileBuffer(filepath, await readFile(file.filepath)) + + user = await prisma.user.update({ + where: { + username + }, + data: { + background: `${user.username}.png` + } + }) + + await makeBanner(user) + return response.status(HTTP_CODE.OK).send() +} + +async function getBackground (request: Request, response: Response) { + // @ts-ignore + const username = request.session?.username + + if (!username) { + return response + .status(HTTP_CODE.UNAUTHORIZED) + .json({ error: 'Unauthorized' }) + } + + response.setHeader('Content-Type', 'image/png') + setFileHeaders(response, `${username}.png`) + return response + .status(HTTP_CODE.OK) + .send(await fs.promises.readFile(path.resolve(CACHE.BACKGROUNDS, username + '.png'))) +} + +const handler = ncWithSession().post(postBackground).get(getBackground) + +export const config = { + api: { + bodyParser: false + } +} + +export default handler diff --git a/src/pages/api/account/donor.js b/src/pages/api/account/donor.js new file mode 100644 index 0000000..aeead83 --- /dev/null +++ b/src/pages/api/account/donor.js @@ -0,0 +1,50 @@ +import HTTP_CODE from '@/lib/constants/httpStatusCodes' +import { ncWithSession } from '@/lib/routing' +import { userIsMod } from '@/lib/utils/databaseUtils' +import prisma from '@/lib/db' +import { isBlank } from '@/lib/utils/utils' +import { doRender } from '@/lib/riitag/neo/renderer' + +async function exportData (request, response) { + const loggedInUser = request.session?.username + const { + status, + user + } = request.body + + if ( + isBlank(String(status)) || isBlank(String(user)) + ) { + return response + .status(HTTP_CODE.BAD_REQUEST) + .send({ error: 'Invalid data' }) + } + + if (!loggedInUser) { + return response + .status(HTTP_CODE.UNAUTHORIZED) + .json({ error: 'Unauthorized' }) + } + + if (!(await userIsMod(loggedInUser))) { + return response + .status(HTTP_CODE.UNAUTHORIZED) + .json({ error: 'Unauthorized' }) + } + + const userObj = await prisma.user.update({ + data: { + isDonor: status + }, + where: { + id: user + } + }) + + doRender(userObj) + return response.status(HTTP_CODE.OK).send(null) +} + +const handler = ncWithSession().post(exportData) + +export default handler diff --git a/src/pages/api/account/tag.js b/src/pages/api/account/tag.js index c159056..b416278 100644 --- a/src/pages/api/account/tag.js +++ b/src/pages/api/account/tag.js @@ -4,7 +4,6 @@ import { isBlank, isBoolean } from '@/lib/utils/utils' import { isValidCoverRegion } from '@/lib/constants/forms/coverRegions' import { isValidCoverType } from '@/lib/constants/forms/coverTypes' import { isValidOverlay } from '@/lib/constants/forms/overlays' -import { BACKGROUNDS } from '@/lib/constants/forms/backgrounds' import { isValidFlag } from '@/lib/constants/forms/flags' import { isValidCoin } from '@/lib/constants/forms/coins' import { isValidFont } from '@/lib/constants/forms/fonts' @@ -24,30 +23,31 @@ async function updateTagSettings (request, response) { background, flag, coin, - font + font, + nameColor } = request.body const username = request.session?.username - function validateFriendCode () { - if (!request.body.comment) { - return true - } - - return true - } - - if (!validateFriendCode()) { - return response - .status(HTTP_CODE.BAD_REQUEST) - .send({ error: 'Invalid data' }) - } - if (!username) { return response .status(HTTP_CODE.UNAUTHORIZED) .json({ error: 'Unauthorized' }) } + if (nameColor) { + if (nameColor.length !== 7) { + return response + .status(HTTP_CODE.BAD_REQUEST) + .send({ error: 'Invalid data' }) + } + + if (!(/(#[a-fA-F0-9]{6})/.test(nameColor))) { + return response + .status(HTTP_CODE.BAD_REQUEST) + .send({ error: 'Invalid data' }) + } + } + if ( isBlank(nameOnRiiTag) || isBlank(coverRegion) || @@ -62,12 +62,12 @@ async function updateTagSettings (request, response) { !isValidCoverType(coverType) || !isValidCoverRegion(coverRegion) || !isValidOverlay(overlay) || - BACKGROUNDS.includes(background) === false || !isValidFlag(flag) || !isValidCoin(coin) || !isValidFont(font) || !isBoolean(showAvatar) || - !isBoolean(showMii) + !isBoolean(showMii) || + isBlank(nameColor) ) { return response .status(HTTP_CODE.BAD_REQUEST) @@ -90,7 +90,8 @@ async function updateTagSettings (request, response) { coin, font, show_avatar: +showAvatar, - show_mii: +showMii + show_mii: +showMii, + nameColor } }) await renderTag(user) diff --git a/src/pages/edit.jsx b/src/pages/edit.jsx index 4ed0e7c..d7b07fd 100644 --- a/src/pages/edit.jsx +++ b/src/pages/edit.jsx @@ -14,13 +14,13 @@ import { isValidOverlay } from '@/lib/constants/forms/overlays' import { isValidFlag } from '@/lib/constants/forms/flags' import { isValidCoin } from '@/lib/constants/forms/coins' import { isValidFont } from '@/lib/constants/forms/fonts' -import { BACKGROUNDS } from '@/lib/constants/forms/backgrounds' import GeneralCard from '@/components/edit/GeneralCard' import FontCard from '@/components/edit/FontCard' import ImagesCard from '@/components/edit/ImagesCard' import ENV from '@/lib/constants/environmentVariables' import LanguageContext from '@/components/shared/LanguageContext' import AppNavbar from '@/components/shared/AppNavbar' +import DonorsCard from '@/components/edit/DonorsCard' export const getServerSideProps = withSession(async ({ req }) => { // get the current user session @@ -43,7 +43,9 @@ export const getServerSideProps = withSession(async ({ req }) => { coin: true, font: true, show_avatar: true, - show_mii: true + show_mii: true, + nameColor: true, + isDonor: true } }) : null @@ -57,10 +59,10 @@ export const getServerSideProps = withSession(async ({ req }) => { } } - return { props: { tagInfo: session, language: session?.language || 'en' } } + return { props: { tagInfo: session, user: sessionAccount, language: session?.language || 'en' } } }) -function EditPage ({ tagInfo, language }) { +function EditPage ({ tagInfo, username, language }) { tagInfo.show_avatar = Boolean(tagInfo.show_avatar) tagInfo.show_mii = Boolean(tagInfo.show_mii) @@ -79,7 +81,8 @@ function EditPage ({ tagInfo, language }) { background: tagInfo.background, flag: tagInfo.flag, coin: tagInfo.coin, - font: tagInfo.font + font: tagInfo.font, + nameColor: tagInfo.nameColor }} validate={(values) => { const errors = {} @@ -114,8 +117,6 @@ function EditPage ({ tagInfo, language }) { if (!values.background) { errors.background = 'Required' - } else if (BACKGROUNDS.includes(values.background) === false) { - errors.background = 'Invalid Background' } if (!values.flag) { @@ -153,12 +154,12 @@ function EditPage ({ tagInfo, language }) { render ({ data, toastProps }) { if (data.status !== 200) { toastProps.type = 'error' - return 'An error occured, please try again later' + return 'An error occurred, please try again later' } return 'Saved!' } }, - error: 'An error occured, please try again later.' + error: 'An error occurred, please try again later.' } ) @@ -204,9 +205,17 @@ function EditPage ({ tagInfo, language }) { + + {tagInfo.isDonor && + } @@ -219,6 +228,7 @@ function EditPage ({ tagInfo, language }) { EditPage.propTypes = { tagInfo: PropTypes.object.isRequired, + username: PropTypes.string.isRequired, language: PropTypes.string.isRequired } diff --git a/src/pages/user/[username]/index.jsx b/src/pages/user/[username]/index.jsx index 6c1d748..fa07ef4 100644 --- a/src/pages/user/[username]/index.jsx +++ b/src/pages/user/[username]/index.jsx @@ -41,6 +41,7 @@ export const getServerSideProps = withSession(async ({ req, query }) => { role: true, isBanned: true, isPublic: true, + isDonor: true, publicOverride: true, banned_user: true, playlog: { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d55eb30 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +}