diff --git a/messages/en.json b/messages/en.json index 8fde208..bf80d30 100644 --- a/messages/en.json +++ b/messages/en.json @@ -26,5 +26,7 @@ "manageusers": "Manage Users", "managerequests": "Manage Requests", "managesubmissions": "Manage Submissions", - "profilePic": "Profile picture" + "profilePic": "Profile picture", + "emailSuccess": "An email with further instructions has been sent to the address linked to the account. Check your spam folder.", + "close": "Close" } \ No newline at end of file diff --git a/package.json b/package.json index 902104b..b7ae5d1 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "nodemailer": "^6.9.16", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hot-toast": "^2.4.1", "react-svg-spinners": "^0.3.1", "sharp": "^0.33.5", "tailwindcss": "^3.4.12", diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 9e53a7a..84c47ad 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -7,8 +7,8 @@ export default function Button(props: PropsWithChildren<{ className?: string; lo return ( + {modalOpen ? ( + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ ) : null} + + ) +} diff --git a/src/graphql/apolloClientSSR.ts b/src/graphql/apolloClientSSR.ts index 5e72695..5809317 100644 --- a/src/graphql/apolloClientSSR.ts +++ b/src/graphql/apolloClientSSR.ts @@ -3,15 +3,11 @@ import { SchemaLink } from '@apollo/client/link/schema' import { getSession } from 'auth-astro/server' import type { Session } from '@auth/core/types' import prismaClient, { type users } from 'prisma/client' -import { makeExecutableSchema } from '@graphql-tools/schema' -import { typeDefs } from '@/graphql/__generated__/typeDefs.generated' -import resolvers from '@/graphql/resolvers' +import schema from './schema' export type ResolverContext = { request?: Request; session?: Session; user?: users } -const schema = makeExecutableSchema({ typeDefs, resolvers }) - export async function getApolloClient(request?: Request) { const session = async () => (request ? getSession(request) : null) const user = async () => (session ? prismaClient.users.findUnique({ where: { username: session.id } }) : null) diff --git a/src/graphql/resolvers/mutations/user.js b/src/graphql/resolvers/mutations/user.ts similarity index 54% rename from src/graphql/resolvers/mutations/user.js rename to src/graphql/resolvers/mutations/user.ts index 53194ab..0559170 100644 --- a/src/graphql/resolvers/mutations/user.js +++ b/src/graphql/resolvers/mutations/user.ts @@ -1,28 +1,38 @@ import { composeResolvers } from '@graphql-tools/resolvers-composition' -// import type { Resolvers } from '@/graphql/__generated__/types.generated' +import type { Resolvers } from '@/graphql/__generated__/types.generated' +import generator from 'generate-password-ts' +import bcrypt from 'bcrypt' +import sharp from 'sharp' +import fs from 'fs-extra' +import type { User } from '@auth/core/types' +import prismaClient from 'prisma/client' +import nodemailer from 'nodemailer' -const resolversComposition = { - //'Mutation.updateUser': [isAuthedApp] +import { UserInputError } from 'utils/graphQLErrors' +import forgorTemplate from 'utils/forgorTemplate' + +async function processImage(imagePath) { + const sharpImg = sharp(imagePath) + const meta = await sharpImg.metadata() + const placeholderImgWidth = 20 + const imgAspectRatio = meta.width / meta.height + const placeholderImgHeight = Math.round(placeholderImgWidth / imgAspectRatio) + + const buffer = await sharpImg.resize(placeholderImgWidth, placeholderImgHeight).toBuffer() + + return `data:image/${meta.format};base64,${buffer.toString('base64')}` } -/* const streamToString = (stream) => { - const chunks = [] - return new Promise((resolve, reject) => { - stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))) - stream.on('error', (err) => reject(err)) - stream.on('end', () => resolve(Buffer.concat(chunks))) - }) -} +async function cropPFP(pfpFile: File, username: string, imgId: string) { + if (!imgId) + return 'data:image/webp;base64,UklGRlQAAABXRUJQVlA4IEgAAACwAQCdASoEAAQAAUAmJZgCdAEO9p5AAPa//NFYLcn+a7b+3z7ynq/qXv+iG0yH/y1D9eBf9pqWugq9G0RnxmxwsjaA2bW8AAA=' -async function cropPFP(streamItem, username, imgId) { - const { createReadStream } = await streamItem const pathString = '/var/www/soc_img/img/user' - const fullPath = path.join(pathString, `${username}_${imgId}.png`) + const fullPath = `${pathString}/${username}_${imgId}.png` await fs.ensureDir(pathString) - const image = await streamToString(createReadStream()) - let sharpImage = sharp(image) + let sharpImage = sharp(await pfpFile.arrayBuffer()) const metadata = await sharpImage.metadata() const { width, height } = metadata @@ -30,36 +40,63 @@ async function cropPFP(streamItem, username, imgId) { sharpImage = sharpImage.extract( width > height ? { - left: Math.floor((width - height) / 2), - top: 0, - width: height, - height - } + left: Math.floor((width - height) / 2), + top: 0, + width: height, + height + } : { - left: 0, - top: Math.floor((height - width) / 2), - width, - height: width - } + left: 0, + top: Math.floor((height - width) / 2), + width, + height: width + } ) } await sharpImage.resize({ width: 200, height: 200 }).png().toFile(fullPath) return await processImage(fullPath) -}*/ +} -const resolvers = { +export const mailConfig = JSON.parse(process.env.MAILSERVER || '{}') +export const transporter = nodemailer.createTransport(mailConfig) + +async function createForgor(user: User) { + await prismaClient.forgors.deleteMany({ where: { username: user.username } }) + + const key = generator.generate({ + length: 15, + numbers: true, + uppercase: false, + strict: true + }) + + const row = await prismaClient.forgors.create({ data: { key, username: user.username } }) + const html = forgorTemplate.replaceAll('{{forgor_link}}', `https://sittingonclouds.net/forgor?key=${key}`) + const message = { + from: mailConfig.auth.user, + to: user.email, + subject: 'Password Reset', + html + } + await transporter.sendMail(message) + + return row +} + +const resolversComposition = { + //'Mutation.updateUser': [isAuthedApp] +} + +const resolvers: Resolvers = { Mutation: { - /*registerUser: async (_, { username, email, pfp }, { db }) => { - await Promise.all([ - db.models.user.findByPk(username).then((result) => { - if (result) throw UserInputError('Username already in use') - }), - db.models.user.findOne({ where: { email } }).then((result) => { - if (result) throw UserInputError('Email already in use') - }) - ]) + registerUser: async (_, args) => { + const { username, email } = args + const pfp: File = args.pfp + + const checkUser = await prismaClient.users.findFirst({ where: { OR: [{ username }, { email }] } }) + if (checkUser) throw UserInputError('Username/email already in use') const password = generator.generate({ length: 30, @@ -68,26 +105,14 @@ const resolvers = { strict: true }) - return db.transaction(async (transaction) => { - const user = await db.models.user.create( - { username, email, password: await bcrypt.hash(password, 10) }, - { transaction } - ) - if (pfp) { - const imgId = Date.now() - user.placeholder = await cropPFP(pfp, username, imgId) - user.imgId = imgId - } else { - user.placeholder = - 'data:image/webp;base64,UklGRlQAAABXRUJQVlA4IEgAAACwAQCdASoEAAQAAUAmJZgCdAEO9p5AAPa//NFYLcn+a7b+3z7ynq/qXv+iG0yH/y1D9eBf9pqWugq9G0RnxmxwsjaA2bW8AAA=' - } + const imgId = pfp.size > 0 ? Date.now().toString() : null + const [hash, placeholder] = await Promise.all([bcrypt.hash(password, 10), cropPFP(pfp, username, imgId)]) + const user = await prismaClient.users.create({ data: { username, email, password: hash, placeholder, imgId } }) + await createForgor(user) - await user.save({ transaction }) - await createForgor(user, db, transaction) - - return true - }) - }, + return true + } + /* updateUserRoles: async ( parent, { username, roles }, diff --git a/src/graphql/schema.ts b/src/graphql/schema.ts new file mode 100644 index 0000000..d4b4705 --- /dev/null +++ b/src/graphql/schema.ts @@ -0,0 +1,6 @@ +import { makeExecutableSchema } from '@graphql-tools/schema' +import { typeDefs } from '@/graphql/__generated__/typeDefs.generated' +import resolvers from '@/graphql/resolvers' + +const schema = makeExecutableSchema({ typeDefs, resolvers }) +export default schema diff --git a/src/graphql/typeDefs/album.graphql b/src/graphql/typeDefs/album.graphql index 257ef2c..fed50a2 100644 --- a/src/graphql/typeDefs/album.graphql +++ b/src/graphql/typeDefs/album.graphql @@ -185,8 +185,8 @@ type Mutation { updatePublisher(id: ID!, name: String): Publisher! deletePublisher(id: ID!): Int - createSeries(slug: String, name: String, cover: Upload!): Series! - updateSeries(slug: String, name: String, cover: Upload): Series! + createSeries(slug: String, name: String, cover: File!): Series! + updateSeries(slug: String, name: String, cover: File): Series! deleteSeries(slug: String!): Int createGame( @@ -196,7 +196,7 @@ type Mutation { publishers: [ID] series: [String] platforms: [ID] - cover: Upload! + cover: File! ): Game! updateGame( releaseDate: String @@ -205,7 +205,7 @@ type Mutation { publishers: [ID] series: [String] platforms: [ID] - cover: Upload + cover: File ): Game! deleteGame(slug: String!): Int @@ -213,21 +213,21 @@ type Mutation { updateStudio(slug: String, name: String): Studio! deleteStudio(slug: String!): Int - createAnimation(title: String, subTitle: String, releaseDate: String, studios: [String], cover: Upload): Animation + createAnimation(title: String, subTitle: String, releaseDate: String, studios: [String], cover: File): Animation updateAnimation( id: ID! title: String subTitle: String releaseDate: String studios: [String] - cover: Upload + cover: File ): Animation deleteAnimation(id: ID!): Int createAlbum( title: String subTitle: String - cover: Upload + cover: File releaseDate: String label: String vgmdb: String @@ -249,7 +249,7 @@ type Mutation { id: ID! title: String subTitle: String - cover: Upload + cover: File releaseDate: String label: String vgmdb: String diff --git a/src/graphql/typeDefs/site.graphql b/src/graphql/typeDefs/site.graphql index 641bc79..6ad33db 100644 --- a/src/graphql/typeDefs/site.graphql +++ b/src/graphql/typeDefs/site.graphql @@ -1,4 +1,4 @@ -scalar Upload +scalar File scalar JSON scalar JSONObject @@ -13,7 +13,7 @@ type Query { } type Mutation { - uploadBanner(banner: Upload!): Int + uploadBanner(banner: File!): Int selectBanner(name: String!): Int config(name: String!, value: String!): Config! } diff --git a/src/graphql/typeDefs/user.graphql b/src/graphql/typeDefs/user.graphql index fd52be4..fe3f87e 100644 --- a/src/graphql/typeDefs/user.graphql +++ b/src/graphql/typeDefs/user.graphql @@ -44,12 +44,7 @@ type Mutation { createForgorLink(key: String!): Boolean! updatePass(key: String!, pass: String!): Boolean! - updateUser( - username: String - password: String - email: String - pfp: Upload - ): Boolean! + updateUser(username: String, password: String, email: String, pfp: File): Boolean! createRole(name: String!, permissions: [String]!): Role updateRole(key: String!, name: String!, permissions: [String]!): Role diff --git a/src/layouts/base.astro b/src/layouts/base.astro index 3fa98be..3501e12 100644 --- a/src/layouts/base.astro +++ b/src/layouts/base.astro @@ -1,6 +1,7 @@ --- -import { languageTag } from '../paraglide/runtime' +import { Toaster } from 'react-hot-toast' +import { languageTag } from '../paraglide/runtime' import Header from 'components/Header.astro' import 'styles/global.css' @@ -14,28 +15,18 @@ import 'styles/global.css' - - + + - +
+
diff --git a/src/pages/api/graphql.ts b/src/pages/api/graphql.ts new file mode 100644 index 0000000..6b0e7db --- /dev/null +++ b/src/pages/api/graphql.ts @@ -0,0 +1,15 @@ +import type { APIRoute } from 'astro' +import { createYoga } from 'graphql-yoga' + +import schema from '@/graphql/schema' + +const { handleRequest } = createYoga({ + schema, + graphqlEndpoint: '/api/graphql', + fetchAPI: { Request, Response } +}) + +export const POST: APIRoute = async (context) => { + const { request } = context + return handleRequest(request, context) +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..b299200 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,3 @@ +import type { Dispatch, SetStateAction } from 'react' + +export type SetState = Dispatch> diff --git a/src/utils/forgorTemplate.ts b/src/utils/forgorTemplate.ts new file mode 100644 index 0000000..9a9e8b5 --- /dev/null +++ b/src/utils/forgorTemplate.ts @@ -0,0 +1,241 @@ +const forgorTemplate = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + +export default forgorTemplate diff --git a/src/utils/resolvers.ts b/src/utils/resolvers.ts index 55ba660..4294839 100644 --- a/src/utils/resolvers.ts +++ b/src/utils/resolvers.ts @@ -1,6 +1,5 @@ - -import User from "sequelize/models/user"; -import type { Session } from "@auth/core"; +// import User from "sequelize/models/user"; +import type { Session } from '@auth/core' export async function getUser(session: Session | null) { if (!session) return null @@ -8,4 +7,26 @@ export async function getUser(session: Session | null) { const { id } = session const user = await User.findByPk(id) return user -} \ No newline at end of file +} + +const isAuthed = (next) => async (root, args, context, info) => { + const session = await getSession() + const { username = null } = session + + if (!username) throw AuthenticationError() + return next(root, args, context, info) +} + +const hasPerm = (perm) => (next) => async (root, args, context, info) => { + const { db } = context + const user = await getUser(db) + const roles = await user.getRoles() + const permissions = roles.map((r) => r.permissions).flat() + if (!permissions.includes(perm)) throw ForbiddenError() + + return next(root, args, context, info) +} + +consoley.log() + +export const hasRole = (role) => [isAuthedApp, hasPermApp(role)] diff --git a/yarn.lock b/yarn.lock index b26bb95..b9d2c9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5488,6 +5488,11 @@ globby@^11.0.3, globby@^11.1.0: merge2 "^1.4.1" slash "^3.0.0" +goober@^2.1.10: + version "2.1.16" + resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.16.tgz#7d548eb9b83ff0988d102be71f271ca8f9c82a95" + integrity sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g== + gopd@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" @@ -8159,6 +8164,13 @@ react-dom@^18.3.1: loose-envify "^1.1.0" scheduler "^0.23.2" +react-hot-toast@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994" + integrity sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ== + dependencies: + goober "^2.1.10" + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"