diff --git a/.env.example b/.env.example index 7117de8..e1c9e8c 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ NODE_ENV=production HOST=localhost PORT=3000 # Postgres connection string -POSTGRES_URL="postgresql://postgres:postgres@localhost:5432/nin0chat" +POSTGRES_URL="postgresql://postgres:postgres@localhost:5432/nin0chat?schema=public" # Cloudflare Turnstile site secret TURNSTILE_SECRET=0x0000000000000000000000000000000000 # SMTP2Go API key diff --git a/package.json b/package.json index d3bf09b..e836661 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,10 @@ "scripts": { "dev": "tsx watch --env-file=.env --include \"**/*\" src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "db:markinit": "prisma migrate resolve --applied 0_init", + "db:migrate:prod": "prisma migrate deploy", + "db:migrate:dev": "prisma migrate dev" }, "devDependencies": { "@types/bcrypt": "^5.0.2", @@ -14,10 +17,12 @@ "eslint": "^9.13.0", "pino-pretty": "^11.3.0", "prettier": "^3.3.3", + "prisma": "^5.21.1", "tsx": "^4.19.1", "typescript": "^5.6.3" }, "dependencies": { + "@prisma/client": "^5.21.1", "bcrypt": "^5.1.1", "fastify": "^5.0.0", "fastify-decorators": "^3.16.1", @@ -25,4 +30,4 @@ "pg": "^8.13.0", "pg-ipc": "^1.0.5" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f65d037..7d00d20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@prisma/client': + specifier: ^5.21.1 + version: 5.21.1(prisma@5.21.1) bcrypt: specifier: ^5.1.1 version: 5.1.1 @@ -45,6 +48,9 @@ importers: prettier: specifier: ^3.3.3 version: 3.3.3 + prisma: + specifier: ^5.21.1 + version: 5.21.1 tsx: specifier: ^4.19.1 version: 4.19.1 @@ -264,6 +270,30 @@ packages: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true + '@prisma/client@5.21.1': + resolution: {integrity: sha512-3n+GgbAZYjaS/k0M03yQsQfR1APbr411r74foknnsGpmhNKBG49VuUkxIU6jORgvJPChoD4WC4PqoHImN1FP0w==} + engines: {node: '>=16.13'} + peerDependencies: + prisma: '*' + peerDependenciesMeta: + prisma: + optional: true + + '@prisma/debug@5.21.1': + resolution: {integrity: sha512-uY8SAhcnORhvgtOrNdvWS98Aq/nkQ9QDUxrWAgW8XrCZaI3j2X7zb7Xe6GQSh6xSesKffFbFlkw0c2luHQviZA==} + + '@prisma/engines-version@5.21.1-1.bf0e5e8a04cada8225617067eaa03d041e2bba36': + resolution: {integrity: sha512-qvnEflL0//lh44S/T9NcvTMxfyowNeUxTunPcDfKPjyJNrCNf2F1zQLcUv5UHAruECpX+zz21CzsC7V2xAeM7Q==} + + '@prisma/engines@5.21.1': + resolution: {integrity: sha512-hGVTldUkIkTwoV8//hmnAAiAchi4oMEKD3aW5H2RrnI50tTdwza7VQbTTAyN3OIHWlK5DVg6xV7X8N/9dtOydA==} + + '@prisma/fetch-engine@5.21.1': + resolution: {integrity: sha512-70S31vgpCGcp9J+mh/wHtLCkVezLUqe/fGWk3J3JWZIN7prdYSlr1C0niaWUyNK2VflLXYi8kMjAmSxUVq6WGQ==} + + '@prisma/get-platform@5.21.1': + resolution: {integrity: sha512-sRxjL3Igst3ct+e8ya/x//cDXmpLbZQ5vfps2N4tWl4VGKQAmym77C/IG/psSMsQKszc8uFC/q1dgmKFLUgXZQ==} + '@types/bcrypt@5.0.2': resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==} @@ -896,6 +926,11 @@ packages: engines: {node: '>=14'} hasBin: true + prisma@5.21.1: + resolution: {integrity: sha512-PB+Iqzld/uQBPaaw2UVIk84kb0ITsLajzsxzsadxxl54eaU5Gyl2/L02ysivHxK89t7YrfQJm+Ggk37uvM70oQ==} + engines: {node: '>=16.13'} + hasBin: true + process-warning@4.0.0: resolution: {integrity: sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==} @@ -1248,6 +1283,31 @@ snapshots: - encoding - supports-color + '@prisma/client@5.21.1(prisma@5.21.1)': + optionalDependencies: + prisma: 5.21.1 + + '@prisma/debug@5.21.1': {} + + '@prisma/engines-version@5.21.1-1.bf0e5e8a04cada8225617067eaa03d041e2bba36': {} + + '@prisma/engines@5.21.1': + dependencies: + '@prisma/debug': 5.21.1 + '@prisma/engines-version': 5.21.1-1.bf0e5e8a04cada8225617067eaa03d041e2bba36 + '@prisma/fetch-engine': 5.21.1 + '@prisma/get-platform': 5.21.1 + + '@prisma/fetch-engine@5.21.1': + dependencies: + '@prisma/debug': 5.21.1 + '@prisma/engines-version': 5.21.1-1.bf0e5e8a04cada8225617067eaa03d041e2bba36 + '@prisma/get-platform': 5.21.1 + + '@prisma/get-platform@5.21.1': + dependencies: + '@prisma/debug': 5.21.1 + '@types/bcrypt@5.0.2': dependencies: '@types/node': 22.8.1 @@ -1908,6 +1968,12 @@ snapshots: prettier@3.3.3: {} + prisma@5.21.1: + dependencies: + '@prisma/engines': 5.21.1 + optionalDependencies: + fsevents: 2.3.3 + process-warning@4.0.0: {} process@0.11.10: {} diff --git a/prisma/migrations/0_init/migration.sql b/prisma/migrations/0_init/migration.sql new file mode 100644 index 0000000..f26002f --- /dev/null +++ b/prisma/migrations/0_init/migration.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS botguilds ( + channel_id TEXT NOT NULL, + guild_id TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS bots ( + id TEXT NOT NULL, + owner_id TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS email_verifications ( + id TEXT NOT NULL, + token TEXT +); + +CREATE TABLE IF NOT EXISTS klines ( + user_id TEXT, + ip TEXT, + reason TEXT +); + +CREATE TABLE IF NOT EXISTS tokens ( + id TEXT NOT NULL, + token TEXT NOT NULL, + seed TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT NOT NULL, + username TEXT NOT NULL, + pfp TEXT, + email TEXT, + password TEXT, + activated BOOLEAN DEFAULT false NOT NULL, + role BIGINT DEFAULT 2 NOT NULL, + bot BOOLEAN DEFAULT false NOT NULL +); \ No newline at end of file diff --git a/prisma/migrations/20241108083501_set_unique_fields/migration.sql b/prisma/migrations/20241108083501_set_unique_fields/migration.sql new file mode 100644 index 0000000..f0b853c --- /dev/null +++ b/prisma/migrations/20241108083501_set_unique_fields/migration.sql @@ -0,0 +1,33 @@ +-- AlterTable +ALTER TABLE botguilds +ADD PRIMARY KEY (channel_id); + +-- AlterTable +ALTER TABLE bots +ADD PRIMARY KEY (id); + +-- AlterTable +ALTER TABLE email_verifications +ADD PRIMARY KEY (id); + +-- AlterTable +ALTER TABLE klines +ADD COLUMN id SERIAL, +ADD PRIMARY KEY (id); + +-- AlterTable +ALTER TABLE tokens +ADD PRIMARY KEY (seed); + +-- AlterTable +ALTER TABLE users +ADD PRIMARY KEY (id); + +-- CreateIndex +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "email_verifications_token_key" ON "email_verifications"("token"); \ No newline at end of file diff --git a/prisma/migrations/20241109014759_bigint_ids/migration.sql b/prisma/migrations/20241109014759_bigint_ids/migration.sql new file mode 100644 index 0000000..0c78bb6 --- /dev/null +++ b/prisma/migrations/20241109014759_bigint_ids/migration.sql @@ -0,0 +1,27 @@ +-- AlterTable +ALTER TABLE botguilds +ALTER COLUMN channel_id TYPE BIGINT USING channel_id::BIGINT, +ALTER COLUMN guild_id TYPE BIGINT USING guild_id::BIGINT; + +-- AlterTable +ALTER TABLE bots +ALTER COLUMN id TYPE BIGINT USING id::BIGINT, +ALTER COLUMN owner_id TYPE BIGINT USING owner_id::BIGINT; + +-- AlterTable +ALTER TABLE email_verifications +ALTER COLUMN id TYPE BIGINT USING id::BIGINT; + +-- AlterTable +ALTER TABLE klines +ALTER COLUMN user_id TYPE BIGINT USING user_id::BIGINT; + +-- AlterTable +ALTER TABLE tokens +ALTER COLUMN id TYPE BIGINT USING id::BIGINT; + +-- AlterTable +ALTER TABLE users +ALTER COLUMN id TYPE BIGINT USING id::BIGINT, +ALTER COLUMN role TYPE INT USING role::INT; +ALTER TABLE users RENAME COLUMN pfp TO avatar; diff --git a/prisma/migrations/20241109023859_bot_relations/migration.sql b/prisma/migrations/20241109023859_bot_relations/migration.sql new file mode 100644 index 0000000..d6f5532 --- /dev/null +++ b/prisma/migrations/20241109023859_bot_relations/migration.sql @@ -0,0 +1,5 @@ +-- AddForeignKey +ALTER TABLE "bots" ADD CONSTRAINT "bots_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "bots" ADD CONSTRAINT "bots_id_fkey" FOREIGN KEY ("id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..23a2623 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,64 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("POSTGRES_URL") +} + +model User { + id BigInt @id + username String @unique + avatar String? + email String? @unique + password String? + activated Boolean @default(false) + role Int @default(2) + bot Boolean @default(false) + bots Bot[] @relation("bot_owner") + bot_info Bot? @relation("bot_user_connection") + + @@map("users") +} + +model Bot { + id BigInt @id + owner_id BigInt + owner User @relation("bot_owner", fields: [owner_id], references: [id]) + user User @relation("bot_user_connection", fields: [id], references: [id]) + + @@map("bots") +} + +model Token { + id BigInt + token String + seed String @id + + @@map("tokens") +} + +model EmailVerification { + id BigInt @id + token String? @unique + + @@map("email_verifications") +} + +model KLine { + id Int @id @default(autoincrement()) + user_id BigInt? + ip String? + reason String? + + @@map("klines") +} + +// Only used for the bot, not really needed in the backend +model BotGuild { + channel_id BigInt @id + guild_id BigInt + + @@map("botguilds") +} diff --git a/src/common/auth.ts b/src/common/auth.ts index c0294ce..82a3f64 100644 --- a/src/common/auth.ts +++ b/src/common/auth.ts @@ -2,25 +2,25 @@ import { compare, hash } from "bcrypt"; import { randomBytes } from "crypto"; import { FastifyReply, FastifyRequest } from "fastify"; import { SALT } from "./constants"; -import { psqlClient } from "./database"; +import { prismaClient } from "./database"; import { ErrorCode, RESTError } from "./error"; -import { Token, User } from "./types"; +import { Token } from "./types"; +import { User } from "@prisma/client"; export async function checkCredentials(email: string, password: string): Promise { - const userQuery = await psqlClient.query("SELECT * FROM users WHERE email=$1", [email]); - if (!userQuery.rowCount) - throw new RESTError(ErrorCode.AuthError, "Invalid username or password"); + const user = await prismaClient.user.findUnique({ where: { email: email } }); - const potentialUser: User = userQuery.rows[0]; + if (!user) throw new RESTError(ErrorCode.AuthError, "Invalid username or password"); - const auth = await compare(password, potentialUser.password); + const auth = await compare(password, user.password); if (!auth) { throw new RESTError(ErrorCode.AuthError, "Invalid username or password"); } - if (!potentialUser.activated) + + if (!user.activated) throw new RESTError(ErrorCode.AuthError, "Check your email to activate your account"); - return potentialUser.id; + return user.id; } export async function generateToken(userID: bigint, addToDatabase: boolean): Promise { @@ -29,11 +29,14 @@ export async function generateToken(userID: bigint, addToDatabase: boolean): Pro const seed = Math.floor(Math.random() * (999999 - 100000 + 1)) + 100000; if (addToDatabase) - await psqlClient.query("INSERT INTO tokens (id, seed, token) VALUES ($1, $2, $3)", [ - userID, - seed, - hashedToken - ]); + await prismaClient.token.create({ + data: { + id: userID, + seed: seed.toString(), + token: hashedToken + } + }); + return { userID, seed, @@ -44,34 +47,31 @@ export async function generateToken(userID: bigint, addToDatabase: boolean): Pro export const authHook = async (req: FastifyRequest, reply: FastifyReply) => { const auth = req.headers.authorization; - if (!auth) return; const [userID, seed, token] = auth.split("."); - - const tokenQuery = await psqlClient.query("SELECT * FROM tokens WHERE id=$1 AND seed=$2", [ - userID, - seed - ]); - if (!tokenQuery.rowCount) { + const potentialToken = await prismaClient.token.findFirst({ + where: { + id: BigInt(userID), + seed: seed + } + }); + + if (!potentialToken) { throw new RESTError(ErrorCode.AuthError, "Invalid token"); } - const potentialToken = tokenQuery.rows[0]; - const authed = await compare(token, potentialToken.token); - if (!authed) { throw new RESTError(ErrorCode.AuthError, "Invalid token"); } - const user = await psqlClient.query("SELECT * FROM users WHERE id=$1", [userID]); - - if (!user.rowCount) { + const user = await prismaClient.user.findUnique({ where: { id: BigInt(userID) } }); + if (!user) { throw new RESTError(ErrorCode.AuthError, "Invalid token"); } - req.user = user.rows[0]; + req.user = user; }; declare module "fastify" { diff --git a/src/common/database.ts b/src/common/database.ts index b01df95..11bbdfe 100644 --- a/src/common/database.ts +++ b/src/common/database.ts @@ -1,7 +1,3 @@ -import { Client } from "pg"; +import { PrismaClient } from "@prisma/client"; -export const psqlClient = new Client({ - connectionString: process.env.POSTGRES_URL -}); - -psqlClient.connect(); +export const prismaClient = new PrismaClient(); diff --git a/src/common/types.ts b/src/common/types.ts index 83d7378..b602cd1 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,17 +1,22 @@ +import { User as UserEntity } from "@prisma/client"; + export type User = { - id: bigint; + id: string; username: string; email?: string; - bot: boolean; password?: string; activated?: boolean; role: number; }; -export const sanitiseUser = (user: User): User => { - const { password, ...sanitised } = user; - - return sanitised; +export const sanitiseUser = (user: UserEntity): User => { + return { + id: user.id.toString(), + username: user.username, + email: user.email, + activated: user.activated, + role: Number(user.role) + }; }; export type Token = { diff --git a/src/rest/auth.ts b/src/rest/auth.ts index 732d2fd..3c069db 100644 --- a/src/rest/auth.ts +++ b/src/rest/auth.ts @@ -4,7 +4,7 @@ import { Controller, GET, POST } from "fastify-decorators"; import { checkCredentials, generateToken } from "../common/auth"; import { validateCaptcha } from "../common/captcha"; import { __DEV__, SALT } from "../common/constants"; -import { psqlClient } from "../common/database"; +import { prismaClient } from "../common/database"; import { sendEmail } from "../common/email"; import { ErrorCode, RESTError } from "../common/error"; import { shouldModerate } from "../common/moderate"; @@ -44,7 +44,7 @@ export default class AuthController { const auth = await checkCredentials(body.email, body.password); return { - id: auth, + id: auth.toString(), token: (await generateToken(auth, true)).full }; } @@ -71,13 +71,14 @@ export default class AuthController { if (!(await validateCaptcha(body.turnstileKey))) throw new RESTError(ErrorCode.ValidationError, "Invalid captcha"); - const existing = await psqlClient.query( - "SELECT id FROM users WHERE username=$1 OR email=$2", - [body.username, body.email] - ); + const existing = await prismaClient.user.count({ + where: { + OR: [{ email: body.email }, { username: body.username }] + } + }); // Check for existing info - if (existing.rowCount) + if (existing > 0) throw new RESTError(ErrorCode.ConflictError, "Username or email already exists"); // Moderate username @@ -89,26 +90,35 @@ export default class AuthController { // Add user to database const newUserID = Snowflake.generate(); - await psqlClient.query( - "INSERT INTO users (id, username, email, password) VALUES ($1, $2, $3, $4)", - [newUserID, body.username, body.email, hashedPassword] - ); + await prismaClient.user.create({ + data: { + id: BigInt(newUserID), + username: body.username, + email: body.email, + password: hashedPassword + } + }); // Generate confirm email const emailConfirmToken = encodeURIComponent( randomBytes(60).toString("base64").replace("+", "") ); - await psqlClient.query("INSERT INTO email_verifications (id, token) VALUES ($1, $2)", [ - newUserID, - emailConfirmToken - ]); + await prismaClient.emailVerification.create({ + data: { + id: BigInt(newUserID), + token: emailConfirmToken + } + }); sendEmail([body.email], "Confirm your nin0chat registration", "7111988", { name: body.username, - confirm_url: `https://${req.hostname}/api/confirm?token=${emailConfirmToken}` + confirm_url: `https://${req.hostname}/api/auth/confirm?token=${emailConfirmToken}` }); if (__DEV__) - await psqlClient.query("UPDATE users SET activated=true WHERE id=$1", [newUserID]); + await prismaClient.user.update({ + where: { id: BigInt(newUserID) }, + data: { activated: true } + }); return res.code(201).send(); } @@ -130,15 +140,17 @@ export default class AuthController { async confirmHandler(req, res) { const token = (req.query as any).token; - // Check if token is valid - const query = await psqlClient.query("SELECT id FROM email_verifications WHERE token=$1", [ - token - ]); - if (query.rows.length === 0) throw new RESTError(ErrorCode.NotFoundError, "Invalid token"); + // Check if the token is valid + const ticket = await prismaClient.emailVerification.findFirst({ where: { token: token } }); - // Delete token - await psqlClient.query("DELETE FROM email_verifications WHERE token=$1", [token]); - await psqlClient.query("UPDATE users SET activated=true WHERE id=$1", [query.rows[0].id]); + if (!ticket) throw new RESTError(ErrorCode.NotFoundError, "Invalid token"); + + // Delete token and activate the user + await prismaClient.emailVerification.delete({ where: { token: token } }); + await prismaClient.user.update({ + where: { id: ticket.id }, + data: { activated: true } + }); return res.redirect(`https://${process.env.CLIENT_HOSTNAME}/login?confirmed=true`); } } diff --git a/src/rest/user.ts b/src/rest/user.ts index bd233fd..b8327c2 100644 --- a/src/rest/user.ts +++ b/src/rest/user.ts @@ -1,13 +1,14 @@ import { Controller, GET } from "fastify-decorators"; import { RESTError } from "../common/error.js"; import { sanitiseUser } from "../common/types.js"; +import { FastifyRequest } from "fastify"; @Controller({ route: "/users" }) export default class UserController { @GET({ url: "/me" }) - async helloHandler(req, res) { + async helloHandler(req: FastifyRequest, res) { // TODO: add a decorator? if (!req.user) throw RESTError.Authed;