feat: enhance authentication and user management with token-based system
- Implemented robust token-based authentication with access and refresh tokens - Added JWT token generation, verification, and rotation mechanisms - Created services for token management, Memcached, and MinIO storage - Enhanced user registration and login with device-specific tokens - Added support for profile picture upload and management via MinIO - Implemented secure password hashing with crypto - Updated Prisma schema to support refresh tokens and profile picture storage - Added GraphQL mutations for logout, token refresh, and profile picture handling - Integrated environment configuration with Zod validation - Improved error handling and authentication middleware
This commit is contained in:
parent
d4d99fb5e7
commit
d29d116214
22 changed files with 1992 additions and 388 deletions
|
@ -10,7 +10,7 @@
|
||||||
"test": "ts-node --test test/**/*.test.ts",
|
"test": "ts-node --test test/**/*.test.ts",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "nodemon --delay 2000ms src/index.ts",
|
"dev": "nodemon --delay 2000ms src/index.ts",
|
||||||
"prisma:generate": "dotenv -e ../../.env.local -- prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "dotenv -e ../../.env.local -- prisma migrate dev",
|
"prisma:migrate": "dotenv -e ../../.env.local -- prisma migrate dev",
|
||||||
"prisma:init": "dotenv -e ../../.env.local -- prisma migrate dev --name init",
|
"prisma:init": "dotenv -e ../../.env.local -- prisma migrate dev --name init",
|
||||||
"build": "tsc"
|
"build": "tsc"
|
||||||
|
@ -19,19 +19,25 @@
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.0.0",
|
"@fastify/cors": "^11.0.0",
|
||||||
|
"@keyv/memcache": "^2.0.1",
|
||||||
"@prisma/client": "^6.4.1",
|
"@prisma/client": "^6.4.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"fastify-plugin": "^5.0.0",
|
"fastify-plugin": "^5.0.0",
|
||||||
"graphql": "^16.10.0",
|
"graphql": "^16.10.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mercurius": "^16.1.0",
|
"mercurius": "^16.1.0",
|
||||||
"mercurius-codegen": "^6.0.1",
|
"mercurius-codegen": "^6.0.1",
|
||||||
|
"minio": "^8.0.4",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "*",
|
"@repo/eslint-config": "*",
|
||||||
"@repo/typescript-config": "*",
|
"@repo/typescript-config": "*",
|
||||||
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"prisma": "^6.4.1",
|
"prisma": "^6.4.1",
|
||||||
"typescript": "5.8.2"
|
"typescript": "5.8.2"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE `Message` MODIFY `content` TEXT NOT NULL;
|
|
|
@ -3,7 +3,8 @@ CREATE TABLE `User` (
|
||||||
`id` VARCHAR(191) NOT NULL,
|
`id` VARCHAR(191) NOT NULL,
|
||||||
`email` VARCHAR(191) NOT NULL,
|
`email` VARCHAR(191) NOT NULL,
|
||||||
`username` VARCHAR(191) NOT NULL,
|
`username` VARCHAR(191) NOT NULL,
|
||||||
`password` VARCHAR(191) NOT NULL,
|
`password` TEXT NOT NULL,
|
||||||
|
`s3ProfilePicObjectKey` VARCHAR(191) NULL,
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
@ -28,7 +29,7 @@ CREATE TABLE `Room` (
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE `Message` (
|
CREATE TABLE `Message` (
|
||||||
`id` VARCHAR(191) NOT NULL,
|
`id` VARCHAR(191) NOT NULL,
|
||||||
`content` VARCHAR(191) NOT NULL,
|
`content` TEXT NOT NULL,
|
||||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
`userId` VARCHAR(191) NOT NULL,
|
`userId` VARCHAR(191) NOT NULL,
|
||||||
|
@ -37,6 +38,21 @@ CREATE TABLE `Message` (
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `RefreshToken` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`jti` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NOT NULL,
|
||||||
|
`deviceId` VARCHAR(191) NOT NULL,
|
||||||
|
`hash` VARCHAR(191) NOT NULL,
|
||||||
|
`expiresAt` DATETIME(3) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `RefreshToken_jti_key`(`jti`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- CreateTable
|
-- CreateTable
|
||||||
CREATE TABLE `_RoomMembers` (
|
CREATE TABLE `_RoomMembers` (
|
||||||
`A` VARCHAR(191) NOT NULL,
|
`A` VARCHAR(191) NOT NULL,
|
||||||
|
@ -55,6 +71,9 @@ ALTER TABLE `Message` ADD CONSTRAINT `Message_userId_fkey` FOREIGN KEY (`userId`
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE `Message` ADD CONSTRAINT `Message_roomId_fkey` FOREIGN KEY (`roomId`) REFERENCES `Room`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
ALTER TABLE `Message` ADD CONSTRAINT `Message_roomId_fkey` FOREIGN KEY (`roomId`) REFERENCES `Room`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `RefreshToken` ADD CONSTRAINT `RefreshToken_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE `_RoomMembers` ADD CONSTRAINT `_RoomMembers_A_fkey` FOREIGN KEY (`A`) REFERENCES `Room`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE `_RoomMembers` ADD CONSTRAINT `_RoomMembers_A_fkey` FOREIGN KEY (`A`) REFERENCES `Room`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
|
@ -14,15 +14,17 @@ datasource db {
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
username String @unique
|
username String @unique
|
||||||
password String
|
password String @db.Text
|
||||||
createdAt DateTime @default(now())
|
s3ProfilePicObjectKey String?
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
messages Message[]
|
updatedAt DateTime @updatedAt
|
||||||
rooms Room[] @relation("RoomMembers")
|
messages Message[]
|
||||||
ownedRooms Room[] @relation("RoomOwner")
|
rooms Room[] @relation("RoomMembers")
|
||||||
|
ownedRooms Room[] @relation("RoomOwner")
|
||||||
|
refreshTokens RefreshToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Room {
|
model Room {
|
||||||
|
@ -48,3 +50,15 @@ model Message {
|
||||||
roomId String
|
roomId String
|
||||||
room Room @relation(fields: [roomId], references: [id])
|
room Room @relation(fields: [roomId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model RefreshToken {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
jti String @unique
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
deviceId String
|
||||||
|
hash String
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
68
apps/api/src/config.ts
Normal file
68
apps/api/src/config.ts
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const rootDir = path.resolve(process.cwd(), '../../');
|
||||||
|
dotenv.config({ path: `${rootDir}/.env.local` });
|
||||||
|
|
||||||
|
const schema = z
|
||||||
|
.object({
|
||||||
|
ALLOWED_ORIGINS: z.string(),
|
||||||
|
API_HOST: z.string(),
|
||||||
|
API_PORT: z.coerce.number(),
|
||||||
|
COOKIE_SECRET: z.string(),
|
||||||
|
MINIO_ENDPOINT: z.string(),
|
||||||
|
MINIO_PORT: z.coerce.number(),
|
||||||
|
MINIO_ACCESS_KEY: z.string(),
|
||||||
|
MINIO_SECRET_KEY: z.string(),
|
||||||
|
MINIO_BUCKET_NAME: z.string(),
|
||||||
|
MINIO_REGION: z.string().default('us-east-1'),
|
||||||
|
MINIO_USE_SSL: z.string().transform((val) => val === 'true'),
|
||||||
|
MEMC_HOST: z.string(),
|
||||||
|
MEMC_PORT: z.coerce.number(),
|
||||||
|
MEMC_TTL: z.coerce.number().default(30 * 60), // 30 minutes in seconds
|
||||||
|
MEMC_NAMESPACE: z.string().default('unreal-chat'),
|
||||||
|
NODE_ENV: z.enum(['development', 'staging', 'production']),
|
||||||
|
TOKEN_ACCESS_EXPIRES_IN: z.coerce.number().default(30 * 60), // 30 minutes in seconds
|
||||||
|
TOKEN_REFRESH_EXPIRES_IN: z.coerce.number().default(7 * 24 * 60 * 60), // 7 days in seconds
|
||||||
|
TOKEN_SECRET: z.string(),
|
||||||
|
})
|
||||||
|
.transform((env) => {
|
||||||
|
return {
|
||||||
|
isProduction: env.NODE_ENV === 'production',
|
||||||
|
server: {
|
||||||
|
allowedOrigins: env.ALLOWED_ORIGINS.split(','),
|
||||||
|
port: env.API_PORT,
|
||||||
|
host: env.API_HOST,
|
||||||
|
},
|
||||||
|
minio: {
|
||||||
|
endPoint: env.MINIO_ENDPOINT,
|
||||||
|
port: env.MINIO_PORT,
|
||||||
|
useSSL: env.MINIO_USE_SSL,
|
||||||
|
accessKey: env.MINIO_ACCESS_KEY,
|
||||||
|
secretKey: env.MINIO_SECRET_KEY,
|
||||||
|
bucketName: env.MINIO_BUCKET_NAME,
|
||||||
|
region: env.MINIO_REGION,
|
||||||
|
},
|
||||||
|
token: {
|
||||||
|
accessTokenExpiresIn: env.TOKEN_ACCESS_EXPIRES_IN,
|
||||||
|
refreshTokenExpiresIn: env.TOKEN_REFRESH_EXPIRES_IN,
|
||||||
|
secret: env.TOKEN_SECRET,
|
||||||
|
},
|
||||||
|
memc: {
|
||||||
|
host: env.MEMC_HOST,
|
||||||
|
port: env.MEMC_PORT,
|
||||||
|
ttl: env.MEMC_TTL,
|
||||||
|
namespace: env.MEMC_NAMESPACE,
|
||||||
|
},
|
||||||
|
cookie: {
|
||||||
|
secret: env.COOKIE_SECRET,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Config = z.infer<typeof schema>;
|
||||||
|
export type MinioConfig = Config['minio'];
|
||||||
|
export type TokenConfig = Config['token'];
|
||||||
|
export type MemcConfig = Config['memc'];
|
||||||
|
export default schema.parse(process.env);
|
|
@ -42,6 +42,7 @@ export type User = {
|
||||||
id: Scalars["ID"];
|
id: Scalars["ID"];
|
||||||
email: Scalars["String"];
|
email: Scalars["String"];
|
||||||
username: Scalars["String"];
|
username: Scalars["String"];
|
||||||
|
s3ProfilePicObjectKey?: Maybe<Scalars["String"]>;
|
||||||
createdAt: Scalars["DateTime"];
|
createdAt: Scalars["DateTime"];
|
||||||
updatedAt?: Maybe<Scalars["DateTime"]>;
|
updatedAt?: Maybe<Scalars["DateTime"]>;
|
||||||
messages?: Maybe<Array<Message>>;
|
messages?: Maybe<Array<Message>>;
|
||||||
|
@ -74,10 +75,23 @@ export type Message = {
|
||||||
room?: Maybe<Room>;
|
room?: Maybe<Room>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RefreshTokenPayload = {
|
||||||
|
__typename?: "RefreshTokenPayload";
|
||||||
|
accessToken: Scalars["String"];
|
||||||
|
refreshToken: Scalars["String"];
|
||||||
|
};
|
||||||
|
|
||||||
export type AuthPayload = {
|
export type AuthPayload = {
|
||||||
__typename?: "AuthPayload";
|
__typename?: "AuthPayload";
|
||||||
token: Scalars["String"];
|
|
||||||
user: User;
|
user: User;
|
||||||
|
accessToken: Scalars["String"];
|
||||||
|
refreshToken: Scalars["String"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PresignedUrl = {
|
||||||
|
__typename?: "PresignedUrl";
|
||||||
|
url: Scalars["String"];
|
||||||
|
expiresIn: Scalars["Int"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Query = {
|
export type Query = {
|
||||||
|
@ -88,6 +102,8 @@ export type Query = {
|
||||||
rooms: Array<Room>;
|
rooms: Array<Room>;
|
||||||
room?: Maybe<Room>;
|
room?: Maybe<Room>;
|
||||||
messages: Array<Message>;
|
messages: Array<Message>;
|
||||||
|
getProfilePicUploadUrl: PresignedUrl;
|
||||||
|
getProfilePicUrl?: Maybe<Scalars["String"]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QueryuserArgs = {
|
export type QueryuserArgs = {
|
||||||
|
@ -102,25 +118,43 @@ export type QuerymessagesArgs = {
|
||||||
roomId: Scalars["ID"];
|
roomId: Scalars["ID"];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QuerygetProfilePicUploadUrlArgs = {
|
||||||
|
fileExtension: Scalars["String"];
|
||||||
|
};
|
||||||
|
|
||||||
export type Mutation = {
|
export type Mutation = {
|
||||||
__typename?: "Mutation";
|
__typename?: "Mutation";
|
||||||
register: AuthPayload;
|
register: AuthPayload;
|
||||||
login: AuthPayload;
|
login: AuthPayload;
|
||||||
|
logout: Scalars["Boolean"];
|
||||||
|
refreshToken: AuthPayload;
|
||||||
|
logoutAllDevices: Scalars["Boolean"];
|
||||||
createRoom: Room;
|
createRoom: Room;
|
||||||
joinRoom: Room;
|
joinRoom: Room;
|
||||||
leaveRoom: Scalars["Boolean"];
|
leaveRoom: Scalars["Boolean"];
|
||||||
sendMessage: Message;
|
sendMessage: Message;
|
||||||
|
deleteProfilePic: Scalars["Boolean"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MutationregisterArgs = {
|
export type MutationregisterArgs = {
|
||||||
email: Scalars["String"];
|
email: Scalars["String"];
|
||||||
username: Scalars["String"];
|
username: Scalars["String"];
|
||||||
password: Scalars["String"];
|
password: Scalars["String"];
|
||||||
|
deviceId: Scalars["String"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MutationloginArgs = {
|
export type MutationloginArgs = {
|
||||||
email: Scalars["String"];
|
email: Scalars["String"];
|
||||||
password: Scalars["String"];
|
password: Scalars["String"];
|
||||||
|
deviceId: Scalars["String"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MutationlogoutArgs = {
|
||||||
|
deviceId: Scalars["String"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MutationrefreshTokenArgs = {
|
||||||
|
deviceId: Scalars["String"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MutationcreateRoomArgs = {
|
export type MutationcreateRoomArgs = {
|
||||||
|
@ -260,7 +294,10 @@ export type ResolversTypes = {
|
||||||
Room: ResolverTypeWrapper<Room>;
|
Room: ResolverTypeWrapper<Room>;
|
||||||
Boolean: ResolverTypeWrapper<Scalars["Boolean"]>;
|
Boolean: ResolverTypeWrapper<Scalars["Boolean"]>;
|
||||||
Message: ResolverTypeWrapper<Message>;
|
Message: ResolverTypeWrapper<Message>;
|
||||||
|
RefreshTokenPayload: ResolverTypeWrapper<RefreshTokenPayload>;
|
||||||
AuthPayload: ResolverTypeWrapper<AuthPayload>;
|
AuthPayload: ResolverTypeWrapper<AuthPayload>;
|
||||||
|
PresignedUrl: ResolverTypeWrapper<PresignedUrl>;
|
||||||
|
Int: ResolverTypeWrapper<Scalars["Int"]>;
|
||||||
Query: ResolverTypeWrapper<{}>;
|
Query: ResolverTypeWrapper<{}>;
|
||||||
Mutation: ResolverTypeWrapper<{}>;
|
Mutation: ResolverTypeWrapper<{}>;
|
||||||
Subscription: ResolverTypeWrapper<{}>;
|
Subscription: ResolverTypeWrapper<{}>;
|
||||||
|
@ -275,7 +312,10 @@ export type ResolversParentTypes = {
|
||||||
Room: Room;
|
Room: Room;
|
||||||
Boolean: Scalars["Boolean"];
|
Boolean: Scalars["Boolean"];
|
||||||
Message: Message;
|
Message: Message;
|
||||||
|
RefreshTokenPayload: RefreshTokenPayload;
|
||||||
AuthPayload: AuthPayload;
|
AuthPayload: AuthPayload;
|
||||||
|
PresignedUrl: PresignedUrl;
|
||||||
|
Int: Scalars["Int"];
|
||||||
Query: {};
|
Query: {};
|
||||||
Mutation: {};
|
Mutation: {};
|
||||||
Subscription: {};
|
Subscription: {};
|
||||||
|
@ -294,6 +334,11 @@ export type UserResolvers<
|
||||||
id?: Resolver<ResolversTypes["ID"], ParentType, ContextType>;
|
id?: Resolver<ResolversTypes["ID"], ParentType, ContextType>;
|
||||||
email?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
email?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
||||||
username?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
username?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
||||||
|
s3ProfilePicObjectKey?: Resolver<
|
||||||
|
Maybe<ResolversTypes["String"]>,
|
||||||
|
ParentType,
|
||||||
|
ContextType
|
||||||
|
>;
|
||||||
createdAt?: Resolver<ResolversTypes["DateTime"], ParentType, ContextType>;
|
createdAt?: Resolver<ResolversTypes["DateTime"], ParentType, ContextType>;
|
||||||
updatedAt?: Resolver<
|
updatedAt?: Resolver<
|
||||||
Maybe<ResolversTypes["DateTime"]>,
|
Maybe<ResolversTypes["DateTime"]>,
|
||||||
|
@ -371,13 +416,34 @@ export type MessageResolvers<
|
||||||
isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RefreshTokenPayloadResolvers<
|
||||||
|
ContextType = MercuriusContext,
|
||||||
|
ParentType extends
|
||||||
|
ResolversParentTypes["RefreshTokenPayload"] = ResolversParentTypes["RefreshTokenPayload"],
|
||||||
|
> = {
|
||||||
|
accessToken?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
||||||
|
refreshToken?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
||||||
|
isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||||
|
};
|
||||||
|
|
||||||
export type AuthPayloadResolvers<
|
export type AuthPayloadResolvers<
|
||||||
ContextType = MercuriusContext,
|
ContextType = MercuriusContext,
|
||||||
ParentType extends
|
ParentType extends
|
||||||
ResolversParentTypes["AuthPayload"] = ResolversParentTypes["AuthPayload"],
|
ResolversParentTypes["AuthPayload"] = ResolversParentTypes["AuthPayload"],
|
||||||
> = {
|
> = {
|
||||||
token?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
|
||||||
user?: Resolver<ResolversTypes["User"], ParentType, ContextType>;
|
user?: Resolver<ResolversTypes["User"], ParentType, ContextType>;
|
||||||
|
accessToken?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
||||||
|
refreshToken?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
||||||
|
isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PresignedUrlResolvers<
|
||||||
|
ContextType = MercuriusContext,
|
||||||
|
ParentType extends
|
||||||
|
ResolversParentTypes["PresignedUrl"] = ResolversParentTypes["PresignedUrl"],
|
||||||
|
> = {
|
||||||
|
url?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
||||||
|
expiresIn?: Resolver<ResolversTypes["Int"], ParentType, ContextType>;
|
||||||
isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -407,6 +473,17 @@ export type QueryResolvers<
|
||||||
ContextType,
|
ContextType,
|
||||||
RequireFields<QuerymessagesArgs, "roomId">
|
RequireFields<QuerymessagesArgs, "roomId">
|
||||||
>;
|
>;
|
||||||
|
getProfilePicUploadUrl?: Resolver<
|
||||||
|
ResolversTypes["PresignedUrl"],
|
||||||
|
ParentType,
|
||||||
|
ContextType,
|
||||||
|
RequireFields<QuerygetProfilePicUploadUrlArgs, "fileExtension">
|
||||||
|
>;
|
||||||
|
getProfilePicUrl?: Resolver<
|
||||||
|
Maybe<ResolversTypes["String"]>,
|
||||||
|
ParentType,
|
||||||
|
ContextType
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MutationResolvers<
|
export type MutationResolvers<
|
||||||
|
@ -418,13 +495,33 @@ export type MutationResolvers<
|
||||||
ResolversTypes["AuthPayload"],
|
ResolversTypes["AuthPayload"],
|
||||||
ParentType,
|
ParentType,
|
||||||
ContextType,
|
ContextType,
|
||||||
RequireFields<MutationregisterArgs, "email" | "username" | "password">
|
RequireFields<
|
||||||
|
MutationregisterArgs,
|
||||||
|
"email" | "username" | "password" | "deviceId"
|
||||||
|
>
|
||||||
>;
|
>;
|
||||||
login?: Resolver<
|
login?: Resolver<
|
||||||
ResolversTypes["AuthPayload"],
|
ResolversTypes["AuthPayload"],
|
||||||
ParentType,
|
ParentType,
|
||||||
ContextType,
|
ContextType,
|
||||||
RequireFields<MutationloginArgs, "email" | "password">
|
RequireFields<MutationloginArgs, "email" | "password" | "deviceId">
|
||||||
|
>;
|
||||||
|
logout?: Resolver<
|
||||||
|
ResolversTypes["Boolean"],
|
||||||
|
ParentType,
|
||||||
|
ContextType,
|
||||||
|
RequireFields<MutationlogoutArgs, "deviceId">
|
||||||
|
>;
|
||||||
|
refreshToken?: Resolver<
|
||||||
|
ResolversTypes["AuthPayload"],
|
||||||
|
ParentType,
|
||||||
|
ContextType,
|
||||||
|
RequireFields<MutationrefreshTokenArgs, "deviceId">
|
||||||
|
>;
|
||||||
|
logoutAllDevices?: Resolver<
|
||||||
|
ResolversTypes["Boolean"],
|
||||||
|
ParentType,
|
||||||
|
ContextType
|
||||||
>;
|
>;
|
||||||
createRoom?: Resolver<
|
createRoom?: Resolver<
|
||||||
ResolversTypes["Room"],
|
ResolversTypes["Room"],
|
||||||
|
@ -450,6 +547,11 @@ export type MutationResolvers<
|
||||||
ContextType,
|
ContextType,
|
||||||
RequireFields<MutationsendMessageArgs, "content" | "roomId">
|
RequireFields<MutationsendMessageArgs, "content" | "roomId">
|
||||||
>;
|
>;
|
||||||
|
deleteProfilePic?: Resolver<
|
||||||
|
ResolversTypes["Boolean"],
|
||||||
|
ParentType,
|
||||||
|
ContextType
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SubscriptionResolvers<
|
export type SubscriptionResolvers<
|
||||||
|
@ -483,7 +585,9 @@ export type Resolvers<ContextType = MercuriusContext> = {
|
||||||
User?: UserResolvers<ContextType>;
|
User?: UserResolvers<ContextType>;
|
||||||
Room?: RoomResolvers<ContextType>;
|
Room?: RoomResolvers<ContextType>;
|
||||||
Message?: MessageResolvers<ContextType>;
|
Message?: MessageResolvers<ContextType>;
|
||||||
|
RefreshTokenPayload?: RefreshTokenPayloadResolvers<ContextType>;
|
||||||
AuthPayload?: AuthPayloadResolvers<ContextType>;
|
AuthPayload?: AuthPayloadResolvers<ContextType>;
|
||||||
|
PresignedUrl?: PresignedUrlResolvers<ContextType>;
|
||||||
Query?: QueryResolvers<ContextType>;
|
Query?: QueryResolvers<ContextType>;
|
||||||
Mutation?: MutationResolvers<ContextType>;
|
Mutation?: MutationResolvers<ContextType>;
|
||||||
Subscription?: SubscriptionResolvers<ContextType>;
|
Subscription?: SubscriptionResolvers<ContextType>;
|
||||||
|
@ -515,6 +619,12 @@ export interface Loaders<
|
||||||
id?: LoaderResolver<Scalars["ID"], User, {}, TContext>;
|
id?: LoaderResolver<Scalars["ID"], User, {}, TContext>;
|
||||||
email?: LoaderResolver<Scalars["String"], User, {}, TContext>;
|
email?: LoaderResolver<Scalars["String"], User, {}, TContext>;
|
||||||
username?: LoaderResolver<Scalars["String"], User, {}, TContext>;
|
username?: LoaderResolver<Scalars["String"], User, {}, TContext>;
|
||||||
|
s3ProfilePicObjectKey?: LoaderResolver<
|
||||||
|
Maybe<Scalars["String"]>,
|
||||||
|
User,
|
||||||
|
{},
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
createdAt?: LoaderResolver<Scalars["DateTime"], User, {}, TContext>;
|
createdAt?: LoaderResolver<Scalars["DateTime"], User, {}, TContext>;
|
||||||
updatedAt?: LoaderResolver<Maybe<Scalars["DateTime"]>, User, {}, TContext>;
|
updatedAt?: LoaderResolver<Maybe<Scalars["DateTime"]>, User, {}, TContext>;
|
||||||
messages?: LoaderResolver<Maybe<Array<Message>>, User, {}, TContext>;
|
messages?: LoaderResolver<Maybe<Array<Message>>, User, {}, TContext>;
|
||||||
|
@ -550,9 +660,30 @@ export interface Loaders<
|
||||||
room?: LoaderResolver<Maybe<Room>, Message, {}, TContext>;
|
room?: LoaderResolver<Maybe<Room>, Message, {}, TContext>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
RefreshTokenPayload?: {
|
||||||
|
accessToken?: LoaderResolver<
|
||||||
|
Scalars["String"],
|
||||||
|
RefreshTokenPayload,
|
||||||
|
{},
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
refreshToken?: LoaderResolver<
|
||||||
|
Scalars["String"],
|
||||||
|
RefreshTokenPayload,
|
||||||
|
{},
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
AuthPayload?: {
|
AuthPayload?: {
|
||||||
token?: LoaderResolver<Scalars["String"], AuthPayload, {}, TContext>;
|
|
||||||
user?: LoaderResolver<User, AuthPayload, {}, TContext>;
|
user?: LoaderResolver<User, AuthPayload, {}, TContext>;
|
||||||
|
accessToken?: LoaderResolver<Scalars["String"], AuthPayload, {}, TContext>;
|
||||||
|
refreshToken?: LoaderResolver<Scalars["String"], AuthPayload, {}, TContext>;
|
||||||
|
};
|
||||||
|
|
||||||
|
PresignedUrl?: {
|
||||||
|
url?: LoaderResolver<Scalars["String"], PresignedUrl, {}, TContext>;
|
||||||
|
expiresIn?: LoaderResolver<Scalars["Int"], PresignedUrl, {}, TContext>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
declare module "mercurius" {
|
declare module "mercurius" {
|
||||||
|
|
|
@ -1,58 +1,82 @@
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import './types';
|
import './types';
|
||||||
import fastify, { FastifyRequest } from 'fastify';
|
import fastify, { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
import fastifyCors from '@fastify/cors';
|
||||||
import mercurius from 'mercurius';
|
import mercurius from 'mercurius';
|
||||||
import mercuriusCodegen from 'mercurius-codegen';
|
import mercuriusCodegen from 'mercurius-codegen';
|
||||||
import schema from './schema';
|
|
||||||
import { resolvers } from './resolvers';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import fastifyCors from '@fastify/cors';
|
import config from './config';
|
||||||
import { z } from 'zod';
|
import { resolvers } from './resolvers';
|
||||||
|
import schema from './schema';
|
||||||
|
import { MemcService, MinioService, TokenService } from './services';
|
||||||
|
|
||||||
dotenv.config({ path: '../../.env.local' });
|
const {
|
||||||
|
server: { allowedOrigins, ...serverConfig },
|
||||||
const { allowedOrigins, port, host } = z
|
minio: minioConfig,
|
||||||
.object({
|
token: tokenConfig,
|
||||||
ALLOWED_ORIGINS: z.string(),
|
memc: memcConfig,
|
||||||
API_HOST: z.string(),
|
cookie: cookieConfig,
|
||||||
API_PORT: z.coerce.number(),
|
} = config;
|
||||||
})
|
|
||||||
.transform((env) => {
|
|
||||||
return {
|
|
||||||
allowedOrigins: env.ALLOWED_ORIGINS.split(','),
|
|
||||||
port: env.API_PORT,
|
|
||||||
host: env.API_HOST,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.parse(process.env);
|
|
||||||
|
|
||||||
const app = fastify({
|
const app = fastify({
|
||||||
logger: true,
|
logger: true,
|
||||||
exposeHeadRoutes: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
const minio = new MinioService(minioConfig);
|
||||||
|
const memc = new MemcService(memcConfig);
|
||||||
|
const token = new TokenService({ ...tokenConfig, memc, prisma });
|
||||||
|
|
||||||
const context = async (req: FastifyRequest) => {
|
setInterval(
|
||||||
const userId = (req.headers['user-id'] as string) || null;
|
() => {
|
||||||
|
token.cleanupExpiredTokens().catch((err) => {
|
||||||
|
app.log.error('Failed to clean up expired tokens:', err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
60 * 60 * 1000
|
||||||
|
); // 1 hour
|
||||||
|
|
||||||
|
const context = async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const { authorization } = req.headers;
|
||||||
|
const accessToken = authorization?.replace('Bearer ', '');
|
||||||
|
const jwt = await token.verifyAccessToken(accessToken);
|
||||||
|
await minio.initialize();
|
||||||
return {
|
return {
|
||||||
prisma,
|
prisma,
|
||||||
userId,
|
jwt,
|
||||||
|
minio,
|
||||||
|
token,
|
||||||
|
memc,
|
||||||
|
req,
|
||||||
|
reply,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
app.register(fastifyCookie, {
|
||||||
|
secret: cookieConfig.secret,
|
||||||
|
parseOptions: {
|
||||||
|
secure: config.isProduction,
|
||||||
|
httpOnly: config.isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||||
|
signed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
app.register(fastifyCors, {
|
app.register(fastifyCors, {
|
||||||
origin: (origin, callback) => {
|
origin: (origin, callback) => {
|
||||||
if (!origin || allowedOrigins.includes(origin)) return callback(null, true);
|
if (!origin || allowedOrigins.includes(origin)) return callback(null, true);
|
||||||
return callback(new Error('Not allowed'), false);
|
return callback(new Error('Not allowed'), false);
|
||||||
},
|
},
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
allowedHeaders: ['Content-Type', 'Authorization', 'user-id'],
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
});
|
});
|
||||||
|
|
||||||
app.register(mercurius, {
|
app.register(mercurius, {
|
||||||
schema,
|
schema,
|
||||||
subscription: true,
|
subscription: true,
|
||||||
|
jit: 1,
|
||||||
|
cache: true,
|
||||||
context,
|
context,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -75,10 +99,10 @@ mercuriusCodegen(app, {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen({ host, port }, (err, address) => {
|
app.listen({ ...serverConfig }, (err, address) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log(`Server listening at ${address}`);
|
console.log(`Server listening at ${address} with config:`, config);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,112 +1,72 @@
|
||||||
import { GraphQLError } from 'graphql';
|
import { GraphQLError } from 'graphql';
|
||||||
import { IResolvers, withFilter } from 'mercurius';
|
import { IResolvers, withFilter } from 'mercurius';
|
||||||
|
import { MutationResolvers, QueryResolvers } from '../generated/graphql';
|
||||||
|
import { withAuth } from '../utils';
|
||||||
|
|
||||||
export const MESSAGE_ADDED = 'MESSAGE_ADDED';
|
export const MESSAGE_ADDED = 'MESSAGE_ADDED';
|
||||||
|
|
||||||
export const messageResolvers: IResolvers = {
|
export const messageResolvers: IResolvers = {
|
||||||
Query: {
|
Query: {
|
||||||
messages: async (_, { roomId }, { prisma, userId }) => {
|
messages: withAuth<QueryResolvers['messages']>(
|
||||||
if (!userId) {
|
async (_, { roomId }, { prisma, jwt }) => {
|
||||||
throw new GraphQLError('You must be logged in to view messages', {
|
const room = await prisma.room.findUnique({
|
||||||
extensions: {
|
where: { id: roomId, members: { some: { id: jwt.sub } } },
|
||||||
code: 'UNAUTHENTICATED',
|
});
|
||||||
},
|
|
||||||
|
if (!room) {
|
||||||
|
throw new GraphQLError('Room not found', {
|
||||||
|
extensions: {
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.message.findMany({
|
||||||
|
where: { roomId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
),
|
||||||
// Check if user is a member of the room
|
|
||||||
const room = await prisma.room.findUnique({
|
|
||||||
where: { id: roomId },
|
|
||||||
include: { members: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!room) {
|
|
||||||
throw new GraphQLError('Room not found', {
|
|
||||||
extensions: {
|
|
||||||
code: 'NOT_FOUND',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMember = room.members.some(
|
|
||||||
(member: { id: string }) => member.id === userId
|
|
||||||
);
|
|
||||||
if (!isMember) {
|
|
||||||
throw new GraphQLError('You are not a member of this room', {
|
|
||||||
extensions: {
|
|
||||||
code: 'FORBIDDEN',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return prisma.message.findMany({
|
|
||||||
where: { roomId },
|
|
||||||
orderBy: { createdAt: 'asc' },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
sendMessage: async (
|
sendMessage: withAuth<MutationResolvers['sendMessage']>(
|
||||||
_: any,
|
async (_, { content, roomId }, { prisma, jwt, pubsub }) => {
|
||||||
{ content, roomId }: { content: string; roomId: string },
|
const room = await prisma.room.findUnique({
|
||||||
{ prisma, userId, pubsub }
|
where: { id: roomId, members: { some: { id: jwt.sub } } },
|
||||||
) => {
|
});
|
||||||
if (!userId) {
|
|
||||||
throw new GraphQLError('You must be logged in to send a message', {
|
if (!room) {
|
||||||
extensions: {
|
throw new GraphQLError('Room not found', {
|
||||||
code: 'UNAUTHENTICATED',
|
extensions: {
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await prisma.message.create({
|
||||||
|
data: {
|
||||||
|
content,
|
||||||
|
user: {
|
||||||
|
connect: { id: jwt.sub },
|
||||||
|
},
|
||||||
|
room: {
|
||||||
|
connect: { id: roomId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
room: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is a member of the room
|
pubsub.publish({
|
||||||
const room = await prisma.room.findUnique({
|
topic: MESSAGE_ADDED,
|
||||||
where: { id: roomId },
|
payload: { messageAdded: message, roomId },
|
||||||
include: { members: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!room) {
|
|
||||||
throw new GraphQLError('Room not found', {
|
|
||||||
extensions: {
|
|
||||||
code: 'NOT_FOUND',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return message;
|
||||||
}
|
}
|
||||||
|
),
|
||||||
const isMember = room.members.some(
|
|
||||||
(member: { id: string }) => member.id === userId
|
|
||||||
);
|
|
||||||
if (!isMember) {
|
|
||||||
throw new GraphQLError('You are not a member of this room', {
|
|
||||||
extensions: {
|
|
||||||
code: 'FORBIDDEN',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = await prisma.message.create({
|
|
||||||
data: {
|
|
||||||
content,
|
|
||||||
user: {
|
|
||||||
connect: { id: userId },
|
|
||||||
},
|
|
||||||
room: {
|
|
||||||
connect: { id: roomId },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
room: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
pubsub.publish({
|
|
||||||
topic: MESSAGE_ADDED,
|
|
||||||
payload: { messageAdded: message, roomId },
|
|
||||||
});
|
|
||||||
|
|
||||||
return message;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Subscription: {
|
Subscription: {
|
||||||
messageAdded: {
|
messageAdded: {
|
||||||
|
@ -119,21 +79,17 @@ export const messageResolvers: IResolvers = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Message: {
|
Message: {
|
||||||
user: async (parent, _, { prisma }) => {
|
user: async (parent, _, { prisma }) =>
|
||||||
if (parent.user) {
|
parent.user
|
||||||
return parent.user;
|
? parent.user
|
||||||
}
|
: prisma.user.findUnique({
|
||||||
return prisma.user.findUnique({
|
where: { id: parent.userId },
|
||||||
where: { id: parent.userId },
|
}),
|
||||||
});
|
room: async (parent, _, { prisma }) =>
|
||||||
},
|
parent.room
|
||||||
room: async (parent, _, { prisma }) => {
|
? parent.room
|
||||||
if (parent.room) {
|
: prisma.room.findUnique({
|
||||||
return parent.room;
|
where: { id: parent.roomId },
|
||||||
}
|
}),
|
||||||
return prisma.room.findUnique({
|
|
||||||
where: { id: parent.roomId },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { GraphQLError } from 'graphql';
|
import { GraphQLError } from 'graphql';
|
||||||
import { IResolvers } from 'mercurius';
|
import { IResolvers } from 'mercurius';
|
||||||
|
import { withAuth } from '../utils';
|
||||||
|
import { MutationResolvers } from '../generated/graphql';
|
||||||
|
|
||||||
export const ROOM_ADDED = 'ROOM_ADDED';
|
export const ROOM_ADDED = 'ROOM_ADDED';
|
||||||
export const ROOM_UPDATED = 'ROOM_UPDATED';
|
export const ROOM_UPDATED = 'ROOM_UPDATED';
|
||||||
|
@ -11,176 +13,133 @@ export const roomResolvers: IResolvers = {
|
||||||
where: { isPrivate: false },
|
where: { isPrivate: false },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
room: async (_: any, { id }: { id: string }, { prisma }) => {
|
room: async (_, { id }, { prisma }) => {
|
||||||
return prisma.room.findUnique({
|
return prisma.room.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
createRoom: async (
|
createRoom: withAuth<MutationResolvers['createRoom']>(
|
||||||
_,
|
async (_, { name, description, isPrivate }, { prisma, jwt, pubsub }) => {
|
||||||
{ name, description, isPrivate },
|
const room = await prisma.room.create({
|
||||||
{ prisma, userId, pubsub }
|
data: {
|
||||||
) => {
|
name,
|
||||||
if (!userId) {
|
description,
|
||||||
throw new GraphQLError('You must be logged in to create a room', {
|
isPrivate: isPrivate ?? false,
|
||||||
extensions: {
|
owner: {
|
||||||
code: 'UNAUTHENTICATED',
|
connect: { id: jwt.sub },
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
connect: { id: jwt.sub },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const room = await prisma.room.create({
|
pubsub.publish({
|
||||||
data: {
|
topic: ROOM_ADDED,
|
||||||
name,
|
payload: { roomAdded: room },
|
||||||
description,
|
|
||||||
isPrivate: isPrivate ?? false,
|
|
||||||
owner: {
|
|
||||||
connect: { id: userId },
|
|
||||||
},
|
|
||||||
members: {
|
|
||||||
connect: { id: userId },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
pubsub.publish({
|
|
||||||
topic: ROOM_ADDED,
|
|
||||||
payload: { roomAdded: room },
|
|
||||||
});
|
|
||||||
|
|
||||||
return room;
|
|
||||||
},
|
|
||||||
joinRoom: async (
|
|
||||||
_,
|
|
||||||
{ roomId }: { roomId: string },
|
|
||||||
{ prisma, userId, pubsub }
|
|
||||||
) => {
|
|
||||||
if (!userId) {
|
|
||||||
throw new GraphQLError('You must be logged in to join a room', {
|
|
||||||
extensions: {
|
|
||||||
code: 'UNAUTHENTICATED',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return room;
|
||||||
}
|
}
|
||||||
|
),
|
||||||
const room = await prisma.room.findUnique({
|
joinRoom: withAuth<MutationResolvers['joinRoom']>(
|
||||||
where: { id: roomId },
|
async (_, { roomId }, { prisma, jwt, pubsub }) => {
|
||||||
include: { members: true },
|
const room = await prisma.room.findUnique({
|
||||||
});
|
where: { id: roomId },
|
||||||
|
include: { members: true },
|
||||||
if (!room) {
|
|
||||||
throw new GraphQLError('Room not found', {
|
|
||||||
extensions: {
|
|
||||||
code: 'NOT_FOUND',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (room.isPrivate) {
|
if (!room) {
|
||||||
// In a real application, you would check if the user has been invited
|
throw new GraphQLError('Room not found', {
|
||||||
throw new GraphQLError(
|
extensions: {
|
||||||
'You cannot join a private room without an invitation',
|
code: 'NOT_FOUND',
|
||||||
{
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room.isPrivate) {
|
||||||
|
// In a real application, you would check if the user has been invited
|
||||||
|
throw new GraphQLError(
|
||||||
|
'You cannot join a private room without an invitation',
|
||||||
|
{
|
||||||
|
extensions: {
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is already a member
|
||||||
|
const isMember = room.members.some(
|
||||||
|
(member: { id: string }) => member.id === jwt.sub
|
||||||
|
);
|
||||||
|
if (isMember) {
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRoom = await prisma.room.update({
|
||||||
|
where: { id: roomId },
|
||||||
|
data: {
|
||||||
|
members: {
|
||||||
|
connect: { id: jwt.sub },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { members: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish room updated event
|
||||||
|
pubsub.publish({
|
||||||
|
topic: ROOM_UPDATED,
|
||||||
|
payload: { roomUpdated: updatedRoom },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedRoom;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
leaveRoom: withAuth<MutationResolvers['leaveRoom']>(
|
||||||
|
async (_, { roomId }, { prisma, jwt, pubsub }) => {
|
||||||
|
const room = await prisma.room.findUnique({
|
||||||
|
where: { id: roomId, members: { some: { id: jwt.sub } } },
|
||||||
|
include: { members: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
throw new GraphQLError('Room not found', {
|
||||||
|
extensions: {
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room.ownerId === jwt.sub) {
|
||||||
|
throw new GraphQLError('You cannot leave a room you own', {
|
||||||
extensions: {
|
extensions: {
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is already a member
|
const updatedRoom = await prisma.room.update({
|
||||||
const isMember = room.members.some(
|
where: { id: roomId },
|
||||||
(member: { id: string }) => member.id === userId
|
data: {
|
||||||
);
|
members: {
|
||||||
if (isMember) {
|
disconnect: { id: jwt.sub },
|
||||||
return room;
|
},
|
||||||
}
|
|
||||||
|
|
||||||
const updatedRoom = await prisma.room.update({
|
|
||||||
where: { id: roomId },
|
|
||||||
data: {
|
|
||||||
members: {
|
|
||||||
connect: { id: userId },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: { members: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Publish room updated event
|
|
||||||
pubsub.publish({
|
|
||||||
topic: ROOM_UPDATED,
|
|
||||||
payload: { roomUpdated: updatedRoom },
|
|
||||||
});
|
|
||||||
|
|
||||||
return updatedRoom;
|
|
||||||
},
|
|
||||||
leaveRoom: async (
|
|
||||||
_,
|
|
||||||
{ roomId }: { roomId: string },
|
|
||||||
{ prisma, userId, pubsub }
|
|
||||||
) => {
|
|
||||||
if (!userId) {
|
|
||||||
throw new GraphQLError('You must be logged in to leave a room', {
|
|
||||||
extensions: {
|
|
||||||
code: 'UNAUTHENTICATED',
|
|
||||||
},
|
},
|
||||||
|
include: { members: true },
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const room = await prisma.room.findUnique({
|
// Publish room updated event
|
||||||
where: { id: roomId },
|
pubsub.publish({
|
||||||
include: { members: true },
|
topic: ROOM_UPDATED,
|
||||||
});
|
payload: { roomUpdated: updatedRoom },
|
||||||
|
|
||||||
if (!room) {
|
|
||||||
throw new GraphQLError('Room not found', {
|
|
||||||
extensions: {
|
|
||||||
code: 'NOT_FOUND',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
),
|
||||||
// Check if user is a member
|
|
||||||
const isMember = room.members.some(
|
|
||||||
(member: { id: string }) => member.id === userId
|
|
||||||
);
|
|
||||||
if (!isMember) {
|
|
||||||
throw new GraphQLError('You are not a member of this room', {
|
|
||||||
extensions: {
|
|
||||||
code: 'FORBIDDEN',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// If user is the owner, they cannot leave
|
|
||||||
if (room.ownerId === userId) {
|
|
||||||
throw new GraphQLError('You cannot leave a room you own', {
|
|
||||||
extensions: {
|
|
||||||
code: 'FORBIDDEN',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedRoom = await prisma.room.update({
|
|
||||||
where: { id: roomId },
|
|
||||||
data: {
|
|
||||||
members: {
|
|
||||||
disconnect: { id: userId },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: { members: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Publish room updated event
|
|
||||||
pubsub.publish({
|
|
||||||
topic: ROOM_UPDATED,
|
|
||||||
payload: { roomUpdated: updatedRoom },
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Subscription: {
|
Subscription: {
|
||||||
roomAdded: {
|
roomAdded: {
|
||||||
|
|
|
@ -1,48 +1,79 @@
|
||||||
import { GraphQLError } from 'graphql';
|
import { GraphQLError } from 'graphql';
|
||||||
import { IResolvers } from 'mercurius';
|
import { IResolvers } from 'mercurius';
|
||||||
|
|
||||||
// In a real application, you would use bcrypt for password hashing
|
import { MutationResolvers, QueryResolvers } from '../generated/graphql';
|
||||||
// import bcrypt from 'bcryptjs';
|
import { withAuth, hashPassword, verifyPassword } from '../utils';
|
||||||
// import jwt from 'jsonwebtoken';
|
|
||||||
|
|
||||||
export const userResolvers: IResolvers = {
|
export const userResolvers: IResolvers = {
|
||||||
Query: {
|
Query: {
|
||||||
me: async (_, __, { prisma, userId }) => {
|
me: withAuth<QueryResolvers['me']>(async (_, __, { prisma, jwt }) =>
|
||||||
// In a real application, you would get the user from the context
|
prisma.user.findUnique({
|
||||||
// which would be set by an authentication middleware
|
where: { id: jwt.sub },
|
||||||
if (!userId) {
|
})
|
||||||
return null;
|
),
|
||||||
|
users: withAuth<QueryResolvers['users']>(async (_, __, { prisma }) =>
|
||||||
|
prisma.user.findMany()
|
||||||
|
),
|
||||||
|
user: withAuth<QueryResolvers['user']>(async (_, { id }, { prisma }) =>
|
||||||
|
prisma.user.findUnique({ where: { id } })
|
||||||
|
),
|
||||||
|
getProfilePicUploadUrl: withAuth<QueryResolvers['getProfilePicUploadUrl']>(
|
||||||
|
async (_, { fileExtension }, { jwt, minio, prisma }) => {
|
||||||
|
const validExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||||
|
const normalizedExtension = fileExtension
|
||||||
|
.toLowerCase()
|
||||||
|
.replace('.', '');
|
||||||
|
|
||||||
|
if (!validExtensions.includes(normalizedExtension)) {
|
||||||
|
throw new GraphQLError('Invalid file extension', {
|
||||||
|
extensions: {
|
||||||
|
code: 'BAD_USER_INPUT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectName = `${jwt.sub}.${normalizedExtension}`;
|
||||||
|
const [url] = await Promise.all([
|
||||||
|
minio.generateUploadUrl(objectName),
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: jwt.sub },
|
||||||
|
data: { s3ProfilePicObjectKey: objectName },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
expiresIn: 60 * 60, // 1 hour in seconds
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return prisma.user.findUnique({
|
),
|
||||||
where: { id: userId },
|
getProfilePicUrl: withAuth<QueryResolvers['getProfilePicUrl']>(
|
||||||
});
|
async (_, __, { jwt, minio, prisma }) => {
|
||||||
},
|
const user = await prisma.user.findUnique({
|
||||||
users: async (_, __, { prisma }) => {
|
where: { id: jwt.sub },
|
||||||
return prisma.user.findMany();
|
select: { s3ProfilePicObjectKey: true },
|
||||||
},
|
});
|
||||||
user: async (_, { id }: { id: string }, { prisma }) => {
|
|
||||||
return prisma.user.findUnique({
|
const { s3ProfilePicObjectKey } = user ?? {};
|
||||||
where: { id },
|
if (!s3ProfilePicObjectKey) return null;
|
||||||
});
|
|
||||||
},
|
const exists = await minio.objectExists(s3ProfilePicObjectKey);
|
||||||
|
if (!exists) return null;
|
||||||
|
|
||||||
|
return minio.generateDownloadUrl(s3ProfilePicObjectKey);
|
||||||
|
}
|
||||||
|
),
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
register: async (
|
register: async (
|
||||||
_: any,
|
_,
|
||||||
{
|
{ email, username, password, deviceId },
|
||||||
email,
|
{ prisma, token, reply }
|
||||||
username,
|
|
||||||
password,
|
|
||||||
}: { email: string; username: string; password: string },
|
|
||||||
{ prisma }
|
|
||||||
) => {
|
) => {
|
||||||
// Check if user already exists
|
|
||||||
const existingUser = await prisma.user.findFirst({
|
const existingUser = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ email }, { username }],
|
OR: [{ email }, { username }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
throw new GraphQLError('User already exists', {
|
throw new GraphQLError('User already exists', {
|
||||||
extensions: {
|
extensions: {
|
||||||
|
@ -50,63 +81,107 @@ export const userResolvers: IResolvers = {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const hashedPassword = hashPassword(password);
|
||||||
// In a real application, you would hash the password
|
const { user, accessToken, refreshToken } = await prisma.$transaction(
|
||||||
// const hashedPassword = await bcrypt.hash(password, 10);
|
async (tx) => {
|
||||||
|
const user = await tx.user.create({
|
||||||
const user = await prisma.user.create({
|
data: {
|
||||||
data: {
|
email,
|
||||||
email,
|
username,
|
||||||
username,
|
password: hashedPassword,
|
||||||
password, // In a real app: hashedPassword
|
},
|
||||||
},
|
});
|
||||||
});
|
const tokens = await token.generateTokens({
|
||||||
|
userId: user.id,
|
||||||
// In a real application, you would generate a JWT token
|
role: 'user',
|
||||||
// const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
|
deviceId,
|
||||||
|
txn: tx,
|
||||||
|
});
|
||||||
|
return { user, ...tokens };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
reply.setCookie('refreshToken', refreshToken);
|
||||||
return {
|
return {
|
||||||
token: 'dummy-token', // In a real app: token
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
login: async (
|
login: async (
|
||||||
_: any,
|
_,
|
||||||
{ email, password }: { email: string; password: string },
|
{ email, password, deviceId },
|
||||||
{ prisma }
|
{ prisma, token, reply }
|
||||||
) => {
|
) => {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user || !verifyPassword(password, user.password)) {
|
||||||
throw new GraphQLError('Invalid credentials', {
|
throw new GraphQLError('Invalid credentials', {
|
||||||
extensions: {
|
extensions: {
|
||||||
code: 'INVALID_CREDENTIALS',
|
code: 'INVALID_CREDENTIALS',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const { accessToken, refreshToken } = await token.generateTokens({
|
||||||
// In a real application, you would verify the password
|
userId: user.id,
|
||||||
// const valid = await bcrypt.compare(password, user.password);
|
role: 'user',
|
||||||
const valid = password === user.password; // This is just for demo purposes
|
deviceId,
|
||||||
|
});
|
||||||
if (!valid) {
|
reply.setCookie('refreshToken', refreshToken);
|
||||||
throw new GraphQLError('Invalid credentials', {
|
|
||||||
extensions: {
|
|
||||||
code: 'INVALID_CREDENTIALS',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// In a real application, you would generate a JWT token
|
|
||||||
// const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: 'dummy-token', // In a real app: token
|
|
||||||
user,
|
user,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
logout: async (_, { deviceId }, { token, jwt, reply }) => {
|
||||||
|
jwt &&
|
||||||
|
(await token.revokeTokensByDevice({
|
||||||
|
userId: jwt.sub,
|
||||||
|
deviceId,
|
||||||
|
}));
|
||||||
|
reply.clearCookie('refreshToken');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
logoutAllDevices: withAuth<MutationResolvers['logoutAllDevices']>(
|
||||||
|
async (_, __, { token, jwt, reply }) => {
|
||||||
|
await token.revokeAllTokens(jwt.sub);
|
||||||
|
reply.clearCookie('refreshToken');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
refreshToken: withAuth<MutationResolvers['refreshToken']>(
|
||||||
|
async (_, { deviceId }, { token, jwt, reply }) => {
|
||||||
|
const { accessToken, refreshToken } = await token.rotateRefreshToken({
|
||||||
|
userId: jwt.sub,
|
||||||
|
oldToken: jwt.jti,
|
||||||
|
deviceId,
|
||||||
|
});
|
||||||
|
reply.setCookie('refreshToken', refreshToken);
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
),
|
||||||
|
deleteProfilePic: withAuth<MutationResolvers['deleteProfilePic']>(
|
||||||
|
async (_, __, { jwt, prisma, minio }) => {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: jwt.sub! },
|
||||||
|
select: { s3ProfilePicObjectKey: true },
|
||||||
|
});
|
||||||
|
await Promise.all([
|
||||||
|
user?.s3ProfilePicObjectKey &&
|
||||||
|
minio.deleteObject(user.s3ProfilePicObjectKey),
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: jwt.sub! },
|
||||||
|
data: { s3ProfilePicObjectKey: null },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
),
|
||||||
},
|
},
|
||||||
User: {
|
User: {
|
||||||
messages: async (user, _, { prisma }) =>
|
messages: async (user, _, { prisma }) =>
|
||||||
|
|
|
@ -7,6 +7,7 @@ export default gql`
|
||||||
id: ID!
|
id: ID!
|
||||||
email: String!
|
email: String!
|
||||||
username: String!
|
username: String!
|
||||||
|
s3ProfilePicObjectKey: String
|
||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
updatedAt: DateTime
|
updatedAt: DateTime
|
||||||
messages: [Message!]
|
messages: [Message!]
|
||||||
|
@ -37,9 +38,20 @@ export default gql`
|
||||||
room: Room
|
room: Room
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RefreshTokenPayload {
|
||||||
|
accessToken: String!
|
||||||
|
refreshToken: String!
|
||||||
|
}
|
||||||
|
|
||||||
type AuthPayload {
|
type AuthPayload {
|
||||||
token: String!
|
|
||||||
user: User!
|
user: User!
|
||||||
|
accessToken: String!
|
||||||
|
refreshToken: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type PresignedUrl {
|
||||||
|
url: String!
|
||||||
|
expiresIn: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
|
@ -49,15 +61,26 @@ export default gql`
|
||||||
rooms: [Room!]!
|
rooms: [Room!]!
|
||||||
room(id: ID!): Room
|
room(id: ID!): Room
|
||||||
messages(roomId: ID!): [Message!]!
|
messages(roomId: ID!): [Message!]!
|
||||||
|
getProfilePicUploadUrl(fileExtension: String!): PresignedUrl!
|
||||||
|
getProfilePicUrl: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
register(email: String!, username: String!, password: String!): AuthPayload!
|
register(
|
||||||
login(email: String!, password: String!): AuthPayload!
|
email: String!
|
||||||
|
username: String!
|
||||||
|
password: String!
|
||||||
|
deviceId: String!
|
||||||
|
): AuthPayload!
|
||||||
|
login(email: String!, password: String!, deviceId: String!): AuthPayload!
|
||||||
|
logout(deviceId: String!): Boolean!
|
||||||
|
refreshToken(deviceId: String!): AuthPayload!
|
||||||
|
logoutAllDevices: Boolean!
|
||||||
createRoom(name: String!, description: String, isPrivate: Boolean): Room!
|
createRoom(name: String!, description: String, isPrivate: Boolean): Room!
|
||||||
joinRoom(roomId: ID!): Room!
|
joinRoom(roomId: ID!): Room!
|
||||||
leaveRoom(roomId: ID!): Boolean!
|
leaveRoom(roomId: ID!): Boolean!
|
||||||
sendMessage(content: String!, roomId: ID!): Message!
|
sendMessage(content: String!, roomId: ID!): Message!
|
||||||
|
deleteProfilePic: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Subscription {
|
type Subscription {
|
||||||
|
|
3
apps/api/src/services/index.ts
Normal file
3
apps/api/src/services/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './memc.service';
|
||||||
|
export * from './minio.service';
|
||||||
|
export * from './token.service';
|
65
apps/api/src/services/memc.service.ts
Normal file
65
apps/api/src/services/memc.service.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import Keyv from 'keyv';
|
||||||
|
import MemcacheStore from '@keyv/memcache';
|
||||||
|
import { MemcConfig } from '../config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for interacting with Memcached using Keyv as an abstraction layer
|
||||||
|
*/
|
||||||
|
export class MemcService {
|
||||||
|
private keyv: Keyv<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new MemcService instance
|
||||||
|
* @param config - Memcached configuration including host, port and TTL
|
||||||
|
*/
|
||||||
|
constructor(config: MemcConfig) {
|
||||||
|
const memcache = new MemcacheStore(`${config.host}:${config.port}`);
|
||||||
|
this.keyv = new Keyv<string>({
|
||||||
|
store: memcache as any,
|
||||||
|
ttl: config.ttl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a value from Memcached by key
|
||||||
|
* @param key - The key to retrieve
|
||||||
|
* @returns The value if found, undefined otherwise
|
||||||
|
*/
|
||||||
|
public async get(key: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
return await this.keyv.get(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting value from Memcached:', error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a value in Memcached
|
||||||
|
* @param key - The key to store the value under
|
||||||
|
* @param value - The value to store
|
||||||
|
* @param ttl - Optional time-to-live in seconds (overrides default TTL)
|
||||||
|
*/
|
||||||
|
public async set(key: string, value: string, ttl?: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const ttlMilliseconds = ttl ? ttl * 1000 : undefined;
|
||||||
|
return await this.keyv.set(key, value, ttlMilliseconds);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting value in Memcached:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a value from Memcached
|
||||||
|
* @param key - The key to delete
|
||||||
|
*/
|
||||||
|
public async delete(key: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await this.keyv.delete(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting value from Memcached:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
106
apps/api/src/services/minio.service.ts
Normal file
106
apps/api/src/services/minio.service.ts
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import { Client } from 'minio';
|
||||||
|
import { MinioConfig } from '../config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for handling MinIO operations
|
||||||
|
*/
|
||||||
|
export class MinioService {
|
||||||
|
private client: Client;
|
||||||
|
private bucketName: string;
|
||||||
|
private readonly defaultExpiry = 60 * 60; // 1 hour in seconds
|
||||||
|
private initialized = false;
|
||||||
|
/**
|
||||||
|
* Creates a new MinioService instance
|
||||||
|
* @param config - MinIO configuration
|
||||||
|
*/
|
||||||
|
constructor(config: MinioConfig) {
|
||||||
|
this.client = new Client(config);
|
||||||
|
this.bucketName = config.bucketName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the MinIO service by ensuring the bucket exists
|
||||||
|
*/
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
if (this.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bucketExists = await this.client.bucketExists(this.bucketName);
|
||||||
|
if (!bucketExists) {
|
||||||
|
await this.client.makeBucket(this.bucketName, 'us-east-1');
|
||||||
|
// Set the bucket policy to allow public read access
|
||||||
|
const policy = {
|
||||||
|
Version: '2012-10-17',
|
||||||
|
Statement: [
|
||||||
|
{
|
||||||
|
Effect: 'Allow',
|
||||||
|
Principal: { AWS: ['*'] },
|
||||||
|
Action: ['s3:GetObject'],
|
||||||
|
Resource: [`arn:aws:s3:::${this.bucketName}/*`],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await this.client.setBucketPolicy(
|
||||||
|
this.bucketName,
|
||||||
|
JSON.stringify(policy)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a presigned URL for uploading a file
|
||||||
|
* @param objectName - Name of the object to upload
|
||||||
|
* @param expiryInSeconds - Expiry time in seconds (default: 1 hour)
|
||||||
|
* @returns Presigned URL for uploading
|
||||||
|
*/
|
||||||
|
public async generateUploadUrl(
|
||||||
|
objectName: string,
|
||||||
|
expiryInSeconds: number = this.defaultExpiry
|
||||||
|
): Promise<string> {
|
||||||
|
return this.client.presignedPutObject(
|
||||||
|
this.bucketName,
|
||||||
|
objectName,
|
||||||
|
expiryInSeconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a presigned URL for downloading a file
|
||||||
|
* @param objectName - Name of the object to download
|
||||||
|
* @param expiryInSeconds - Expiry time in seconds (default: 1 hour)
|
||||||
|
* @returns Presigned URL for downloading
|
||||||
|
*/
|
||||||
|
public async generateDownloadUrl(
|
||||||
|
objectName: string,
|
||||||
|
expiryInSeconds: number = this.defaultExpiry
|
||||||
|
): Promise<string> {
|
||||||
|
return this.client.presignedGetObject(
|
||||||
|
this.bucketName,
|
||||||
|
objectName,
|
||||||
|
expiryInSeconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an object exists in the bucket
|
||||||
|
* @param objectName - Name of the object to check
|
||||||
|
* @returns True if the object exists, false otherwise
|
||||||
|
*/
|
||||||
|
public async objectExists(objectName: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.client.statObject(this.bucketName, objectName);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an object from the bucket
|
||||||
|
* @param objectName - Name of the object to delete
|
||||||
|
*/
|
||||||
|
public async deleteObject(objectName: string): Promise<void> {
|
||||||
|
await this.client.removeObject(this.bucketName, objectName);
|
||||||
|
}
|
||||||
|
}
|
232
apps/api/src/services/token.service.ts
Normal file
232
apps/api/src/services/token.service.ts
Normal file
|
@ -0,0 +1,232 @@
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { TokenConfig } from '../config';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { MemcService } from './memc.service';
|
||||||
|
|
||||||
|
type TransactionClient = Omit<
|
||||||
|
PrismaClient,
|
||||||
|
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface TokenPayload {
|
||||||
|
sub: string;
|
||||||
|
role: string;
|
||||||
|
deviceId: string;
|
||||||
|
jti: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerateTokensParams {
|
||||||
|
userId: string;
|
||||||
|
role: string;
|
||||||
|
deviceId: string;
|
||||||
|
txn?: TransactionClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RotateRefreshTokenParams {
|
||||||
|
userId: string;
|
||||||
|
oldToken: string;
|
||||||
|
deviceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RevokeTokensByDeviceParams {
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for handling token generation, verification, and management
|
||||||
|
*/
|
||||||
|
export class TokenService {
|
||||||
|
private prisma: PrismaClient;
|
||||||
|
private memc: MemcService;
|
||||||
|
private config: TokenConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new TokenService instance
|
||||||
|
* @param config - Token configuration
|
||||||
|
* @param prisma - Prisma client instance
|
||||||
|
* @param memc - Memcached service instance
|
||||||
|
*/
|
||||||
|
constructor({
|
||||||
|
prisma,
|
||||||
|
memc,
|
||||||
|
...config
|
||||||
|
}: TokenConfig & { prisma: PrismaClient; memc: MemcService }) {
|
||||||
|
this.prisma = prisma;
|
||||||
|
this.memc = memc;
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashes a token using SHA-256
|
||||||
|
* @param token - The token to hash
|
||||||
|
* @returns Hashed token string
|
||||||
|
*/
|
||||||
|
private async hashToken(token: string): Promise<string> {
|
||||||
|
return crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates new access and refresh tokens for a user
|
||||||
|
* @param params - Object containing userId, role and deviceId
|
||||||
|
* @returns Object containing accessToken and refreshToken
|
||||||
|
*/
|
||||||
|
async generateTokens(params: GenerateTokensParams) {
|
||||||
|
const { userId, role, deviceId, txn } = params;
|
||||||
|
const jti = uuidv4();
|
||||||
|
const refreshToken = uuidv4();
|
||||||
|
const hash = await this.hashToken(refreshToken);
|
||||||
|
const accessToken = jwt.sign(
|
||||||
|
{
|
||||||
|
sub: userId,
|
||||||
|
role,
|
||||||
|
deviceId,
|
||||||
|
jti,
|
||||||
|
},
|
||||||
|
this.config.secret,
|
||||||
|
{ expiresIn: this.config.accessTokenExpiresIn }
|
||||||
|
);
|
||||||
|
|
||||||
|
const tx = txn
|
||||||
|
? (callback: Function) => callback(txn)
|
||||||
|
: this.prisma.$transaction.bind(this.prisma);
|
||||||
|
|
||||||
|
await tx(async (tx) => {
|
||||||
|
await tx.refreshToken.create({
|
||||||
|
data: {
|
||||||
|
jti,
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
hash,
|
||||||
|
expiresAt: new Date(Date.now() + this.config.refreshTokenExpiresIn),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.whitelistAccessToken(jti);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies an access token and checks if it's whitelisted
|
||||||
|
* @param token - The token to verify
|
||||||
|
* @returns Decoded token payload or null if invalid
|
||||||
|
*/
|
||||||
|
async verifyAccessToken(token?: string) {
|
||||||
|
try {
|
||||||
|
if (!token) return null;
|
||||||
|
const decoded = jwt.verify(token, this.config.secret) as TokenPayload;
|
||||||
|
const cached = await this.memc.get(`token:whitelist:${decoded.jti}`);
|
||||||
|
if (!cached) throw new Error('Token inválido');
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying access token', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies and rotates a refresh token
|
||||||
|
* @param params - Object containing userId, oldToken and deviceId
|
||||||
|
* @returns New access and refresh tokens
|
||||||
|
*/
|
||||||
|
async rotateRefreshToken(params: RotateRefreshTokenParams) {
|
||||||
|
const { userId, oldToken, deviceId } = params;
|
||||||
|
return await this.prisma.$transaction(async (txn) => {
|
||||||
|
const storedTokens = await txn.refreshToken.findMany({
|
||||||
|
where: { userId, deviceId },
|
||||||
|
});
|
||||||
|
const hash = await this.hashToken(oldToken);
|
||||||
|
const validToken = storedTokens.find((token) => token.hash === hash);
|
||||||
|
if (!validToken || validToken.expiresAt < new Date())
|
||||||
|
throw new Error('Refresh token inválido');
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
storedTokens.map((token) => {
|
||||||
|
this.memc.delete(`token:whitelist:${token.jti}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await txn.refreshToken.deleteMany({
|
||||||
|
where: { id: { in: storedTokens.map((token) => token.id) } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.generateTokens({ userId, role: 'user', deviceId, txn });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes all tokens for a user, useful for logout from all devices
|
||||||
|
* @param userId - The user ID
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async revokeAllTokens(userId: string) {
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const tokens = await tx.refreshToken.findMany({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
tokens.map((token) => {
|
||||||
|
this.memc.delete(`token:whitelist:${token.jti}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await tx.refreshToken.deleteMany({
|
||||||
|
where: { id: { in: tokens.map((token) => token.id) } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes tokens for a specific device
|
||||||
|
* @param params - Object containing userId and deviceId
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async revokeTokensByDevice(params: RevokeTokensByDeviceParams) {
|
||||||
|
const { userId, deviceId } = params;
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const tokens = await tx.refreshToken.findMany({
|
||||||
|
where: { userId, deviceId },
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
tokens.map((token) => {
|
||||||
|
this.memc.delete(`token:whitelist:${token.jti}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await tx.refreshToken.deleteMany({
|
||||||
|
where: { id: { in: tokens.map((token) => token.id) } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an access token to the whitelist
|
||||||
|
* @param jti - The JWT ID
|
||||||
|
*/
|
||||||
|
async whitelistAccessToken(jti: string) {
|
||||||
|
const ttl = this.config.accessTokenExpiresIn;
|
||||||
|
await this.memc.set(`token:whitelist:${jti}`, '1', ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up expired tokens from the database and Memcached
|
||||||
|
*/
|
||||||
|
async cleanupExpiredTokens() {
|
||||||
|
const now = new Date();
|
||||||
|
const expired = await this.prisma.refreshToken.findMany({
|
||||||
|
where: { expiresAt: { lt: now } },
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
expired.map((token) => {
|
||||||
|
this.memc.delete(`token:whitelist:${token.jti}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await this.prisma.refreshToken.deleteMany({
|
||||||
|
where: { id: { in: expired.map((token) => token.id) } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,20 @@
|
||||||
import type { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
MinioService,
|
||||||
|
TokenService,
|
||||||
|
MemcService,
|
||||||
|
TokenPayload,
|
||||||
|
} from './services';
|
||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
declare module 'mercurius' {
|
declare module 'mercurius' {
|
||||||
interface MercuriusContext {
|
interface MercuriusContext {
|
||||||
prisma: PrismaClient;
|
prisma: PrismaClient;
|
||||||
userId: string | null;
|
jwt: TokenPayload;
|
||||||
|
minio: MinioService;
|
||||||
|
token: TokenService;
|
||||||
|
memc: MemcService;
|
||||||
|
req: FastifyRequest;
|
||||||
|
reply: FastifyReply;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
14
apps/api/src/utils/crypto.ts
Normal file
14
apps/api/src/utils/crypto.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { pbkdf2Sync, randomBytes, timingSafeEqual } from 'crypto';
|
||||||
|
export const hashPassword = (password: string) => {
|
||||||
|
const salt = randomBytes(16).toString('hex');
|
||||||
|
const hash = pbkdf2Sync(password, salt, 1000, 128, 'sha512').toString('hex');
|
||||||
|
const hashedPassword = `${salt}:${hash}`;
|
||||||
|
return hashedPassword;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyPassword = (password: string, hashedPassword: string) => {
|
||||||
|
const [salt, hash] = hashedPassword.split(':');
|
||||||
|
const hashBuffer = Buffer.from(hash, 'hex');
|
||||||
|
const result = pbkdf2Sync(password, salt, 1000, 128, 'sha512');
|
||||||
|
return timingSafeEqual(hashBuffer, result);
|
||||||
|
};
|
2
apps/api/src/utils/index.ts
Normal file
2
apps/api/src/utils/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './crypto';
|
||||||
|
export * from './middlewares';
|
18
apps/api/src/utils/middlewares.ts
Normal file
18
apps/api/src/utils/middlewares.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { MercuriusContext } from 'mercurius';
|
||||||
|
|
||||||
|
const isCallback = (
|
||||||
|
maybeFunction: unknown | Function
|
||||||
|
): maybeFunction is Function => typeof maybeFunction === 'function';
|
||||||
|
|
||||||
|
export const withAuth = <T>(callback: T) =>
|
||||||
|
async function (
|
||||||
|
_: unknown,
|
||||||
|
__: unknown,
|
||||||
|
context: MercuriusContext,
|
||||||
|
info: unknown
|
||||||
|
) {
|
||||||
|
if (!context.jwt) {
|
||||||
|
throw new Error('Not authenticated!');
|
||||||
|
}
|
||||||
|
return isCallback(callback) && callback(_, __, context, info);
|
||||||
|
};
|
|
@ -6,12 +6,21 @@ services:
|
||||||
container_name: unreal-chat-api
|
container_name: unreal-chat-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
|
||||||
- JWT_SECRET=your-secret-key
|
|
||||||
- API_PORT=4000
|
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
|
||||||
- API_HOST=${API_HOST}
|
- API_HOST=${API_HOST}
|
||||||
|
- API_PORT=${API_PORT}
|
||||||
|
- COOKIE_SECRET=${COOKIE_SECRET}
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- MEMC_HOST=${MEMC_HOST}
|
||||||
|
- MEMC_PORT=${MEMC_PORT}
|
||||||
|
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
|
||||||
|
- MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME}
|
||||||
|
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
|
||||||
|
- MINIO_PORT=${MINIO_PORT}
|
||||||
|
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
|
||||||
|
- MINIO_USE_SSL=${MINIO_USE_SSL}
|
||||||
|
- NODE_ENV=production
|
||||||
|
- TOKEN_SECRET=${TOKEN_SECRET}
|
||||||
networks:
|
networks:
|
||||||
- default-network
|
- default-network
|
||||||
|
|
||||||
|
@ -31,9 +40,7 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- default-network
|
- default-network
|
||||||
|
|
||||||
volumes:
|
|
||||||
db_data:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default-network:
|
default:
|
||||||
|
name: default-network
|
||||||
external: true
|
external: true
|
863
package-lock.json
generated
863
package-lock.json
generated
File diff suppressed because it is too large
Load diff
15
turbo.json
15
turbo.json
|
@ -6,8 +6,21 @@
|
||||||
"ALLOWED_ORIGINS",
|
"ALLOWED_ORIGINS",
|
||||||
"API_HOST",
|
"API_HOST",
|
||||||
"API_PORT",
|
"API_PORT",
|
||||||
"DATABASE_URL",
|
"COOKIE_SECRET",
|
||||||
|
"MINIO_ENDPOINT",
|
||||||
|
"MINIO_PORT",
|
||||||
|
"MINIO_ACCESS_KEY",
|
||||||
|
"MINIO_SECRET_KEY",
|
||||||
|
"MINIO_BUCKET_NAME",
|
||||||
|
"MINIO_USE_SSL",
|
||||||
|
"MEMC_HOST",
|
||||||
|
"MEMC_PORT",
|
||||||
|
"MEMC_TTL",
|
||||||
|
"MEMC_NAMESPACE",
|
||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
|
"TOKEN_ACCESS_EXPIRES_IN",
|
||||||
|
"TOKEN_REFRESH_EXPIRES_IN",
|
||||||
|
"TOKEN_SECRET",
|
||||||
"VITE_API_URL",
|
"VITE_API_URL",
|
||||||
"VITE_WS_URL"
|
"VITE_WS_URL"
|
||||||
],
|
],
|
||||||
|
|
Loading…
Add table
Reference in a new issue