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",
|
||||
"start": "node dist/index.js",
|
||||
"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:init": "dotenv -e ../../.env.local -- prisma migrate dev --name init",
|
||||
"build": "tsc"
|
||||
|
@ -19,19 +19,25 @@
|
|||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@keyv/memcache": "^2.0.1",
|
||||
"@prisma/client": "^6.4.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"fastify": "^5.2.1",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"graphql": "^16.10.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mercurius": "^16.1.0",
|
||||
"mercurius-codegen": "^6.0.1",
|
||||
"minio": "^8.0.4",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "*",
|
||||
"@repo/typescript-config": "*",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"prisma": "^6.4.1",
|
||||
"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,
|
||||
`email` 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),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
|
@ -28,7 +29,7 @@ CREATE TABLE `Room` (
|
|||
-- CreateTable
|
||||
CREATE TABLE `Message` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`content` VARCHAR(191) NOT NULL,
|
||||
`content` TEXT NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
`userId` VARCHAR(191) NOT NULL,
|
||||
|
@ -37,6 +38,21 @@ CREATE TABLE `Message` (
|
|||
PRIMARY KEY (`id`)
|
||||
) 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
|
||||
CREATE TABLE `_RoomMembers` (
|
||||
`A` VARCHAR(191) NOT NULL,
|
||||
|
@ -55,6 +71,9 @@ ALTER TABLE `Message` ADD CONSTRAINT `Message_userId_fkey` FOREIGN KEY (`userId`
|
|||
-- AddForeignKey
|
||||
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
|
||||
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 {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
username String @unique
|
||||
password String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
messages Message[]
|
||||
rooms Room[] @relation("RoomMembers")
|
||||
ownedRooms Room[] @relation("RoomOwner")
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
username String @unique
|
||||
password String @db.Text
|
||||
s3ProfilePicObjectKey String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
messages Message[]
|
||||
rooms Room[] @relation("RoomMembers")
|
||||
ownedRooms Room[] @relation("RoomOwner")
|
||||
refreshTokens RefreshToken[]
|
||||
}
|
||||
|
||||
model Room {
|
||||
|
@ -48,3 +50,15 @@ model Message {
|
|||
roomId String
|
||||
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"];
|
||||
email: Scalars["String"];
|
||||
username: Scalars["String"];
|
||||
s3ProfilePicObjectKey?: Maybe<Scalars["String"]>;
|
||||
createdAt: Scalars["DateTime"];
|
||||
updatedAt?: Maybe<Scalars["DateTime"]>;
|
||||
messages?: Maybe<Array<Message>>;
|
||||
|
@ -74,10 +75,23 @@ export type Message = {
|
|||
room?: Maybe<Room>;
|
||||
};
|
||||
|
||||
export type RefreshTokenPayload = {
|
||||
__typename?: "RefreshTokenPayload";
|
||||
accessToken: Scalars["String"];
|
||||
refreshToken: Scalars["String"];
|
||||
};
|
||||
|
||||
export type AuthPayload = {
|
||||
__typename?: "AuthPayload";
|
||||
token: Scalars["String"];
|
||||
user: User;
|
||||
accessToken: Scalars["String"];
|
||||
refreshToken: Scalars["String"];
|
||||
};
|
||||
|
||||
export type PresignedUrl = {
|
||||
__typename?: "PresignedUrl";
|
||||
url: Scalars["String"];
|
||||
expiresIn: Scalars["Int"];
|
||||
};
|
||||
|
||||
export type Query = {
|
||||
|
@ -88,6 +102,8 @@ export type Query = {
|
|||
rooms: Array<Room>;
|
||||
room?: Maybe<Room>;
|
||||
messages: Array<Message>;
|
||||
getProfilePicUploadUrl: PresignedUrl;
|
||||
getProfilePicUrl?: Maybe<Scalars["String"]>;
|
||||
};
|
||||
|
||||
export type QueryuserArgs = {
|
||||
|
@ -102,25 +118,43 @@ export type QuerymessagesArgs = {
|
|||
roomId: Scalars["ID"];
|
||||
};
|
||||
|
||||
export type QuerygetProfilePicUploadUrlArgs = {
|
||||
fileExtension: Scalars["String"];
|
||||
};
|
||||
|
||||
export type Mutation = {
|
||||
__typename?: "Mutation";
|
||||
register: AuthPayload;
|
||||
login: AuthPayload;
|
||||
logout: Scalars["Boolean"];
|
||||
refreshToken: AuthPayload;
|
||||
logoutAllDevices: Scalars["Boolean"];
|
||||
createRoom: Room;
|
||||
joinRoom: Room;
|
||||
leaveRoom: Scalars["Boolean"];
|
||||
sendMessage: Message;
|
||||
deleteProfilePic: Scalars["Boolean"];
|
||||
};
|
||||
|
||||
export type MutationregisterArgs = {
|
||||
email: Scalars["String"];
|
||||
username: Scalars["String"];
|
||||
password: Scalars["String"];
|
||||
deviceId: Scalars["String"];
|
||||
};
|
||||
|
||||
export type MutationloginArgs = {
|
||||
email: Scalars["String"];
|
||||
password: Scalars["String"];
|
||||
deviceId: Scalars["String"];
|
||||
};
|
||||
|
||||
export type MutationlogoutArgs = {
|
||||
deviceId: Scalars["String"];
|
||||
};
|
||||
|
||||
export type MutationrefreshTokenArgs = {
|
||||
deviceId: Scalars["String"];
|
||||
};
|
||||
|
||||
export type MutationcreateRoomArgs = {
|
||||
|
@ -260,7 +294,10 @@ export type ResolversTypes = {
|
|||
Room: ResolverTypeWrapper<Room>;
|
||||
Boolean: ResolverTypeWrapper<Scalars["Boolean"]>;
|
||||
Message: ResolverTypeWrapper<Message>;
|
||||
RefreshTokenPayload: ResolverTypeWrapper<RefreshTokenPayload>;
|
||||
AuthPayload: ResolverTypeWrapper<AuthPayload>;
|
||||
PresignedUrl: ResolverTypeWrapper<PresignedUrl>;
|
||||
Int: ResolverTypeWrapper<Scalars["Int"]>;
|
||||
Query: ResolverTypeWrapper<{}>;
|
||||
Mutation: ResolverTypeWrapper<{}>;
|
||||
Subscription: ResolverTypeWrapper<{}>;
|
||||
|
@ -275,7 +312,10 @@ export type ResolversParentTypes = {
|
|||
Room: Room;
|
||||
Boolean: Scalars["Boolean"];
|
||||
Message: Message;
|
||||
RefreshTokenPayload: RefreshTokenPayload;
|
||||
AuthPayload: AuthPayload;
|
||||
PresignedUrl: PresignedUrl;
|
||||
Int: Scalars["Int"];
|
||||
Query: {};
|
||||
Mutation: {};
|
||||
Subscription: {};
|
||||
|
@ -294,6 +334,11 @@ export type UserResolvers<
|
|||
id?: Resolver<ResolversTypes["ID"], ParentType, ContextType>;
|
||||
email?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
||||
username?: Resolver<ResolversTypes["String"], ParentType, ContextType>;
|
||||
s3ProfilePicObjectKey?: Resolver<
|
||||
Maybe<ResolversTypes["String"]>,
|
||||
ParentType,
|
||||
ContextType
|
||||
>;
|
||||
createdAt?: Resolver<ResolversTypes["DateTime"], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<
|
||||
Maybe<ResolversTypes["DateTime"]>,
|
||||
|
@ -371,13 +416,34 @@ export type MessageResolvers<
|
|||
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<
|
||||
ContextType = MercuriusContext,
|
||||
ParentType extends
|
||||
ResolversParentTypes["AuthPayload"] = ResolversParentTypes["AuthPayload"],
|
||||
> = {
|
||||
token?: Resolver<ResolversTypes["String"], 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>;
|
||||
};
|
||||
|
||||
|
@ -407,6 +473,17 @@ export type QueryResolvers<
|
|||
ContextType,
|
||||
RequireFields<QuerymessagesArgs, "roomId">
|
||||
>;
|
||||
getProfilePicUploadUrl?: Resolver<
|
||||
ResolversTypes["PresignedUrl"],
|
||||
ParentType,
|
||||
ContextType,
|
||||
RequireFields<QuerygetProfilePicUploadUrlArgs, "fileExtension">
|
||||
>;
|
||||
getProfilePicUrl?: Resolver<
|
||||
Maybe<ResolversTypes["String"]>,
|
||||
ParentType,
|
||||
ContextType
|
||||
>;
|
||||
};
|
||||
|
||||
export type MutationResolvers<
|
||||
|
@ -418,13 +495,33 @@ export type MutationResolvers<
|
|||
ResolversTypes["AuthPayload"],
|
||||
ParentType,
|
||||
ContextType,
|
||||
RequireFields<MutationregisterArgs, "email" | "username" | "password">
|
||||
RequireFields<
|
||||
MutationregisterArgs,
|
||||
"email" | "username" | "password" | "deviceId"
|
||||
>
|
||||
>;
|
||||
login?: Resolver<
|
||||
ResolversTypes["AuthPayload"],
|
||||
ParentType,
|
||||
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<
|
||||
ResolversTypes["Room"],
|
||||
|
@ -450,6 +547,11 @@ export type MutationResolvers<
|
|||
ContextType,
|
||||
RequireFields<MutationsendMessageArgs, "content" | "roomId">
|
||||
>;
|
||||
deleteProfilePic?: Resolver<
|
||||
ResolversTypes["Boolean"],
|
||||
ParentType,
|
||||
ContextType
|
||||
>;
|
||||
};
|
||||
|
||||
export type SubscriptionResolvers<
|
||||
|
@ -483,7 +585,9 @@ export type Resolvers<ContextType = MercuriusContext> = {
|
|||
User?: UserResolvers<ContextType>;
|
||||
Room?: RoomResolvers<ContextType>;
|
||||
Message?: MessageResolvers<ContextType>;
|
||||
RefreshTokenPayload?: RefreshTokenPayloadResolvers<ContextType>;
|
||||
AuthPayload?: AuthPayloadResolvers<ContextType>;
|
||||
PresignedUrl?: PresignedUrlResolvers<ContextType>;
|
||||
Query?: QueryResolvers<ContextType>;
|
||||
Mutation?: MutationResolvers<ContextType>;
|
||||
Subscription?: SubscriptionResolvers<ContextType>;
|
||||
|
@ -515,6 +619,12 @@ export interface Loaders<
|
|||
id?: LoaderResolver<Scalars["ID"], User, {}, TContext>;
|
||||
email?: LoaderResolver<Scalars["String"], User, {}, TContext>;
|
||||
username?: LoaderResolver<Scalars["String"], User, {}, TContext>;
|
||||
s3ProfilePicObjectKey?: LoaderResolver<
|
||||
Maybe<Scalars["String"]>,
|
||||
User,
|
||||
{},
|
||||
TContext
|
||||
>;
|
||||
createdAt?: LoaderResolver<Scalars["DateTime"], User, {}, TContext>;
|
||||
updatedAt?: LoaderResolver<Maybe<Scalars["DateTime"]>, User, {}, TContext>;
|
||||
messages?: LoaderResolver<Maybe<Array<Message>>, User, {}, TContext>;
|
||||
|
@ -550,9 +660,30 @@ export interface Loaders<
|
|||
room?: LoaderResolver<Maybe<Room>, Message, {}, TContext>;
|
||||
};
|
||||
|
||||
RefreshTokenPayload?: {
|
||||
accessToken?: LoaderResolver<
|
||||
Scalars["String"],
|
||||
RefreshTokenPayload,
|
||||
{},
|
||||
TContext
|
||||
>;
|
||||
refreshToken?: LoaderResolver<
|
||||
Scalars["String"],
|
||||
RefreshTokenPayload,
|
||||
{},
|
||||
TContext
|
||||
>;
|
||||
};
|
||||
|
||||
AuthPayload?: {
|
||||
token?: LoaderResolver<Scalars["String"], 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" {
|
||||
|
|
|
@ -1,58 +1,82 @@
|
|||
import dotenv from 'dotenv';
|
||||
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 mercuriusCodegen from 'mercurius-codegen';
|
||||
import schema from './schema';
|
||||
import { resolvers } from './resolvers';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import fastifyCors from '@fastify/cors';
|
||||
import { z } from 'zod';
|
||||
import config from './config';
|
||||
import { resolvers } from './resolvers';
|
||||
import schema from './schema';
|
||||
import { MemcService, MinioService, TokenService } from './services';
|
||||
|
||||
dotenv.config({ path: '../../.env.local' });
|
||||
|
||||
const { allowedOrigins, port, host } = z
|
||||
.object({
|
||||
ALLOWED_ORIGINS: z.string(),
|
||||
API_HOST: z.string(),
|
||||
API_PORT: z.coerce.number(),
|
||||
})
|
||||
.transform((env) => {
|
||||
return {
|
||||
allowedOrigins: env.ALLOWED_ORIGINS.split(','),
|
||||
port: env.API_PORT,
|
||||
host: env.API_HOST,
|
||||
};
|
||||
})
|
||||
.parse(process.env);
|
||||
const {
|
||||
server: { allowedOrigins, ...serverConfig },
|
||||
minio: minioConfig,
|
||||
token: tokenConfig,
|
||||
memc: memcConfig,
|
||||
cookie: cookieConfig,
|
||||
} = config;
|
||||
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
exposeHeadRoutes: true,
|
||||
});
|
||||
|
||||
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) => {
|
||||
const userId = (req.headers['user-id'] as string) || null;
|
||||
setInterval(
|
||||
() => {
|
||||
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 {
|
||||
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, {
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) return callback(null, true);
|
||||
return callback(new Error('Not allowed'), false);
|
||||
},
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'user-id'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
});
|
||||
|
||||
app.register(mercurius, {
|
||||
schema,
|
||||
subscription: true,
|
||||
jit: 1,
|
||||
cache: true,
|
||||
context,
|
||||
});
|
||||
|
||||
|
@ -75,10 +99,10 @@ mercuriusCodegen(app, {
|
|||
},
|
||||
});
|
||||
|
||||
app.listen({ host, port }, (err, address) => {
|
||||
app.listen({ ...serverConfig }, (err, address) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
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 { IResolvers, withFilter } from 'mercurius';
|
||||
import { MutationResolvers, QueryResolvers } from '../generated/graphql';
|
||||
import { withAuth } from '../utils';
|
||||
|
||||
export const MESSAGE_ADDED = 'MESSAGE_ADDED';
|
||||
|
||||
export const messageResolvers: IResolvers = {
|
||||
Query: {
|
||||
messages: async (_, { roomId }, { prisma, userId }) => {
|
||||
if (!userId) {
|
||||
throw new GraphQLError('You must be logged in to view messages', {
|
||||
extensions: {
|
||||
code: 'UNAUTHENTICATED',
|
||||
},
|
||||
messages: withAuth<QueryResolvers['messages']>(
|
||||
async (_, { roomId }, { prisma, jwt }) => {
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: roomId, members: { some: { id: jwt.sub } } },
|
||||
});
|
||||
|
||||
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: {
|
||||
sendMessage: async (
|
||||
_: any,
|
||||
{ content, roomId }: { content: string; roomId: string },
|
||||
{ prisma, userId, pubsub }
|
||||
) => {
|
||||
if (!userId) {
|
||||
throw new GraphQLError('You must be logged in to send a message', {
|
||||
extensions: {
|
||||
code: 'UNAUTHENTICATED',
|
||||
sendMessage: withAuth<MutationResolvers['sendMessage']>(
|
||||
async (_, { content, roomId }, { prisma, jwt, pubsub }) => {
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: roomId, members: { some: { id: jwt.sub } } },
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
throw new GraphQLError('Room not found', {
|
||||
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
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: roomId },
|
||||
include: { members: true },
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
throw new GraphQLError('Room not found', {
|
||||
extensions: {
|
||||
code: 'NOT_FOUND',
|
||||
},
|
||||
pubsub.publish({
|
||||
topic: MESSAGE_ADDED,
|
||||
payload: { messageAdded: message, roomId },
|
||||
});
|
||||
|
||||
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: {
|
||||
messageAdded: {
|
||||
|
@ -119,21 +79,17 @@ export const messageResolvers: IResolvers = {
|
|||
},
|
||||
},
|
||||
Message: {
|
||||
user: async (parent, _, { prisma }) => {
|
||||
if (parent.user) {
|
||||
return parent.user;
|
||||
}
|
||||
return prisma.user.findUnique({
|
||||
where: { id: parent.userId },
|
||||
});
|
||||
},
|
||||
room: async (parent, _, { prisma }) => {
|
||||
if (parent.room) {
|
||||
return parent.room;
|
||||
}
|
||||
return prisma.room.findUnique({
|
||||
where: { id: parent.roomId },
|
||||
});
|
||||
},
|
||||
user: async (parent, _, { prisma }) =>
|
||||
parent.user
|
||||
? parent.user
|
||||
: prisma.user.findUnique({
|
||||
where: { id: parent.userId },
|
||||
}),
|
||||
room: async (parent, _, { prisma }) =>
|
||||
parent.room
|
||||
? parent.room
|
||||
: prisma.room.findUnique({
|
||||
where: { id: parent.roomId },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { GraphQLError } from 'graphql';
|
||||
import { IResolvers } from 'mercurius';
|
||||
import { withAuth } from '../utils';
|
||||
import { MutationResolvers } from '../generated/graphql';
|
||||
|
||||
export const ROOM_ADDED = 'ROOM_ADDED';
|
||||
export const ROOM_UPDATED = 'ROOM_UPDATED';
|
||||
|
@ -11,176 +13,133 @@ export const roomResolvers: IResolvers = {
|
|||
where: { isPrivate: false },
|
||||
});
|
||||
},
|
||||
room: async (_: any, { id }: { id: string }, { prisma }) => {
|
||||
room: async (_, { id }, { prisma }) => {
|
||||
return prisma.room.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
},
|
||||
},
|
||||
Mutation: {
|
||||
createRoom: async (
|
||||
_,
|
||||
{ name, description, isPrivate },
|
||||
{ prisma, userId, pubsub }
|
||||
) => {
|
||||
if (!userId) {
|
||||
throw new GraphQLError('You must be logged in to create a room', {
|
||||
extensions: {
|
||||
code: 'UNAUTHENTICATED',
|
||||
createRoom: withAuth<MutationResolvers['createRoom']>(
|
||||
async (_, { name, description, isPrivate }, { prisma, jwt, pubsub }) => {
|
||||
const room = await prisma.room.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
isPrivate: isPrivate ?? false,
|
||||
owner: {
|
||||
connect: { id: jwt.sub },
|
||||
},
|
||||
members: {
|
||||
connect: { id: jwt.sub },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const room = await prisma.room.create({
|
||||
data: {
|
||||
name,
|
||||
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',
|
||||
},
|
||||
pubsub.publish({
|
||||
topic: ROOM_ADDED,
|
||||
payload: { roomAdded: room },
|
||||
});
|
||||
|
||||
return 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',
|
||||
},
|
||||
),
|
||||
joinRoom: withAuth<MutationResolvers['joinRoom']>(
|
||||
async (_, { roomId }, { prisma, jwt, pubsub }) => {
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: roomId },
|
||||
include: { members: true },
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
{
|
||||
if (!room) {
|
||||
throw new GraphQLError('Room not found', {
|
||||
extensions: {
|
||||
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: {
|
||||
code: 'FORBIDDEN',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const isMember = room.members.some(
|
||||
(member: { id: string }) => member.id === userId
|
||||
);
|
||||
if (isMember) {
|
||||
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',
|
||||
const updatedRoom = await prisma.room.update({
|
||||
where: { id: roomId },
|
||||
data: {
|
||||
members: {
|
||||
disconnect: { id: jwt.sub },
|
||||
},
|
||||
},
|
||||
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',
|
||||
},
|
||||
// Publish room updated event
|
||||
pubsub.publish({
|
||||
topic: ROOM_UPDATED,
|
||||
payload: { roomUpdated: updatedRoom },
|
||||
});
|
||||
|
||||
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: {
|
||||
roomAdded: {
|
||||
|
|
|
@ -1,48 +1,79 @@
|
|||
import { GraphQLError } from 'graphql';
|
||||
import { IResolvers } from 'mercurius';
|
||||
|
||||
// In a real application, you would use bcrypt for password hashing
|
||||
// import bcrypt from 'bcryptjs';
|
||||
// import jwt from 'jsonwebtoken';
|
||||
import { MutationResolvers, QueryResolvers } from '../generated/graphql';
|
||||
import { withAuth, hashPassword, verifyPassword } from '../utils';
|
||||
|
||||
export const userResolvers: IResolvers = {
|
||||
Query: {
|
||||
me: async (_, __, { prisma, userId }) => {
|
||||
// In a real application, you would get the user from the context
|
||||
// which would be set by an authentication middleware
|
||||
if (!userId) {
|
||||
return null;
|
||||
me: withAuth<QueryResolvers['me']>(async (_, __, { prisma, jwt }) =>
|
||||
prisma.user.findUnique({
|
||||
where: { id: jwt.sub },
|
||||
})
|
||||
),
|
||||
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 },
|
||||
});
|
||||
},
|
||||
users: async (_, __, { prisma }) => {
|
||||
return prisma.user.findMany();
|
||||
},
|
||||
user: async (_, { id }: { id: string }, { prisma }) => {
|
||||
return prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
},
|
||||
),
|
||||
getProfilePicUrl: withAuth<QueryResolvers['getProfilePicUrl']>(
|
||||
async (_, __, { jwt, minio, prisma }) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: jwt.sub },
|
||||
select: { s3ProfilePicObjectKey: true },
|
||||
});
|
||||
|
||||
const { s3ProfilePicObjectKey } = user ?? {};
|
||||
if (!s3ProfilePicObjectKey) return null;
|
||||
|
||||
const exists = await minio.objectExists(s3ProfilePicObjectKey);
|
||||
if (!exists) return null;
|
||||
|
||||
return minio.generateDownloadUrl(s3ProfilePicObjectKey);
|
||||
}
|
||||
),
|
||||
},
|
||||
Mutation: {
|
||||
register: async (
|
||||
_: any,
|
||||
{
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
}: { email: string; username: string; password: string },
|
||||
{ prisma }
|
||||
_,
|
||||
{ email, username, password, deviceId },
|
||||
{ prisma, token, reply }
|
||||
) => {
|
||||
// Check if user already exists
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email }, { username }],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new GraphQLError('User already exists', {
|
||||
extensions: {
|
||||
|
@ -50,63 +81,107 @@ export const userResolvers: IResolvers = {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
// In a real application, you would hash the password
|
||||
// const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
password, // In a real app: hashedPassword
|
||||
},
|
||||
});
|
||||
|
||||
// In a real application, you would generate a JWT token
|
||||
// const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
|
||||
|
||||
const hashedPassword = hashPassword(password);
|
||||
const { user, accessToken, refreshToken } = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
username,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
const tokens = await token.generateTokens({
|
||||
userId: user.id,
|
||||
role: 'user',
|
||||
deviceId,
|
||||
txn: tx,
|
||||
});
|
||||
return { user, ...tokens };
|
||||
}
|
||||
);
|
||||
reply.setCookie('refreshToken', refreshToken);
|
||||
return {
|
||||
token: 'dummy-token', // In a real app: token
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user,
|
||||
};
|
||||
},
|
||||
login: async (
|
||||
_: any,
|
||||
{ email, password }: { email: string; password: string },
|
||||
{ prisma }
|
||||
_,
|
||||
{ email, password, deviceId },
|
||||
{ prisma, token, reply }
|
||||
) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!user || !verifyPassword(password, user.password)) {
|
||||
throw new GraphQLError('Invalid credentials', {
|
||||
extensions: {
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// In a real application, you would verify the password
|
||||
// const valid = await bcrypt.compare(password, user.password);
|
||||
const valid = password === user.password; // This is just for demo purposes
|
||||
|
||||
if (!valid) {
|
||||
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);
|
||||
|
||||
const { accessToken, refreshToken } = await token.generateTokens({
|
||||
userId: user.id,
|
||||
role: 'user',
|
||||
deviceId,
|
||||
});
|
||||
reply.setCookie('refreshToken', refreshToken);
|
||||
return {
|
||||
token: 'dummy-token', // In a real app: token
|
||||
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: {
|
||||
messages: async (user, _, { prisma }) =>
|
||||
|
|
|
@ -7,6 +7,7 @@ export default gql`
|
|||
id: ID!
|
||||
email: String!
|
||||
username: String!
|
||||
s3ProfilePicObjectKey: String
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime
|
||||
messages: [Message!]
|
||||
|
@ -37,9 +38,20 @@ export default gql`
|
|||
room: Room
|
||||
}
|
||||
|
||||
type RefreshTokenPayload {
|
||||
accessToken: String!
|
||||
refreshToken: String!
|
||||
}
|
||||
|
||||
type AuthPayload {
|
||||
token: String!
|
||||
user: User!
|
||||
accessToken: String!
|
||||
refreshToken: String!
|
||||
}
|
||||
|
||||
type PresignedUrl {
|
||||
url: String!
|
||||
expiresIn: Int!
|
||||
}
|
||||
|
||||
type Query {
|
||||
|
@ -49,15 +61,26 @@ export default gql`
|
|||
rooms: [Room!]!
|
||||
room(id: ID!): Room
|
||||
messages(roomId: ID!): [Message!]!
|
||||
getProfilePicUploadUrl(fileExtension: String!): PresignedUrl!
|
||||
getProfilePicUrl: String
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
register(email: String!, username: String!, password: String!): AuthPayload!
|
||||
login(email: String!, password: String!): AuthPayload!
|
||||
register(
|
||||
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!
|
||||
joinRoom(roomId: ID!): Room!
|
||||
leaveRoom(roomId: ID!): Boolean!
|
||||
sendMessage(content: String!, roomId: ID!): Message!
|
||||
deleteProfilePic: Boolean!
|
||||
}
|
||||
|
||||
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' {
|
||||
interface MercuriusContext {
|
||||
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
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- JWT_SECRET=your-secret-key
|
||||
- API_PORT=4000
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
|
||||
- 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:
|
||||
- default-network
|
||||
|
||||
|
@ -31,9 +40,7 @@ services:
|
|||
networks:
|
||||
- default-network
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
|
||||
networks:
|
||||
default-network:
|
||||
external: true
|
||||
default:
|
||||
name: default-network
|
||||
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",
|
||||
"API_HOST",
|
||||
"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",
|
||||
"TOKEN_ACCESS_EXPIRES_IN",
|
||||
"TOKEN_REFRESH_EXPIRES_IN",
|
||||
"TOKEN_SECRET",
|
||||
"VITE_API_URL",
|
||||
"VITE_WS_URL"
|
||||
],
|
||||
|
|
Loading…
Add table
Reference in a new issue