diff --git a/client/package-lock.json b/client/package-lock.json index 5347991..85564d7 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,7 +11,8 @@ "chota": "^0.9.2", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.10.0" + "react-router-dom": "^6.10.0", + "socket.io-client": "^4.6.1" }, "devDependencies": { "@types/react": "^18.0.28", @@ -784,6 +785,11 @@ "node": ">=14" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" + }, "node_modules/@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", @@ -945,7 +951,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -964,6 +969,26 @@ "integrity": "sha512-gM7TdwuG3amns/1rlgxMbeeyNoBFPa+4Uu0c7FeROWh4qWmvSOnvcslKmWy51ggLKZ2n/F/4i2HJ+PVNxH9uCQ==", "dev": true }, + "node_modules/engine.io-client": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.4.0.tgz", + "integrity": "sha512-GyKPDyoEha+XZ7iEqam49vz6auPnNJ9ZBfy89f+rMMas8AuiMWOZ9PVzu8xb9ZC6rafUqiGHSCfu22ih66E+1g==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.0.3", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.6.tgz", + "integrity": "sha512-tjuoZDMAdEhVnSFleYPCtdL2GXwVTGtNjoeJd9IhIG3C1xs9uwxqRNEu5WpnDZCaozwVlK/nuQhpodhXSIMaxw==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/esbuild": { "version": "0.17.15", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.15.tgz", @@ -1154,8 +1179,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nanoid": { "version": "3.3.6", @@ -1329,6 +1353,32 @@ "semver": "bin/semver.js" } }, + "node_modules/socket.io-client": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.6.1.tgz", + "integrity": "sha512-5UswCV6hpaRsNg5kkEHVcbBIXEYoVbMQaHJBXJCyEQ+CiFPV1NIOY0XOFWG4XR4GZcB8Kn6AsRs/9cy9TbqVMQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.4.0", + "socket.io-parser": "~4.2.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.2.tgz", + "integrity": "sha512-DJtziuKypFkMMHCm2uIshOYC7QaylbtzQwiMYDuCKy3OPkjLzu4B2vAhTlqipRHHzrI0NJeBAizTK7X+6m1jVw==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", @@ -1459,6 +1509,34 @@ } } }, + "node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/client/package.json b/client/package.json index 73dd572..8d6f957 100644 --- a/client/package.json +++ b/client/package.json @@ -12,7 +12,8 @@ "chota": "^0.9.2", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.10.0" + "react-router-dom": "^6.10.0", + "socket.io-client": "^4.6.1" }, "devDependencies": { "@types/react": "^18.0.28", diff --git a/client/src/components/timerCard.tsx b/client/src/components/timerCard.tsx new file mode 100644 index 0000000..f37a67a --- /dev/null +++ b/client/src/components/timerCard.tsx @@ -0,0 +1,3 @@ +export default function TimerCard({ timer }) { + return

{timer.name}

; +} diff --git a/client/src/components/timerHeader.tsx b/client/src/components/timerHeader.tsx new file mode 100644 index 0000000..618f607 --- /dev/null +++ b/client/src/components/timerHeader.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; +import { useAuthContext } from "../context/authContext"; + +export default function TimerHeader({ onSelect }) { + const [friends, setFriends] = useState([]); + const { friendName } = useAuthContext(); + + useEffect(() => { + fetch("/api/auth/friends") + .then((r) => r.json()) + .then((friends) => setFriends(friends)); + }, []); + + return ( + <> +
{friendName}
+ {friends.map((friend) => ( +
+

{friend.name}

+
+ ))} + + ); +} diff --git a/client/src/hooks/useInitialData.ts b/client/src/hooks/useInitialData.ts new file mode 100644 index 0000000..1f5de20 --- /dev/null +++ b/client/src/hooks/useInitialData.ts @@ -0,0 +1,39 @@ +import { io } from "socket.io-client"; +import { useState, useEffect } from "react"; + +export interface UseInitialDataProps { + namespace: string; + initialDataEndpoint: string; + query?: any; +} + +export const useInitialData = (props: UseInitialDataProps) => { + const [data, setData] = useState(); + const [query, setQuery] = useState(props?.query); + const [socket, setSocket] = useState(); + + const refreshData = () => + fetch(props.initialDataEndpoint) + .then((r) => r.json()) + .then((r: T) => { + setData(r); + }); + + useEffect(() => { + refreshData(); + + const socket = io(props.namespace, { + query, + transports: ["websocket"], + }); + + setSocket(socket); + + return () => { + socket.close(); + setSocket(undefined); + }; + }, [query]); + + return { data, refreshData, query, setQuery, socket, setData }; +}; diff --git a/client/src/main.tsx b/client/src/main.tsx index 50c5a66..341b825 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -5,9 +5,9 @@ import "chota"; import { AuthProvider } from "./context/authContext"; -import Root from "./routes/root"; import NotFound from "./routes/notFound"; import Login from "./routes/login"; +import Timers from "./routes/timers"; import ProtectedRoute from "./routes/protected.tsx"; const router = createBrowserRouter([ @@ -15,7 +15,7 @@ const router = createBrowserRouter([ path: "/", element: ( - + ), errorElement: , @@ -27,9 +27,7 @@ const router = createBrowserRouter([ ]); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - - - + + + ); diff --git a/client/src/routes/protected.tsx b/client/src/routes/protected.tsx index a0f4dbd..4870b94 100644 --- a/client/src/routes/protected.tsx +++ b/client/src/routes/protected.tsx @@ -4,7 +4,7 @@ import { useAuthContext } from "../context/authContext"; export default function ProtectedRoute({ children }) { const { signedIn } = useAuthContext(); - if (!signedIn) return ; + // if (!signedIn) return ; return children; } diff --git a/client/src/routes/root.tsx b/client/src/routes/root.tsx deleted file mode 100644 index fce48d1..0000000 --- a/client/src/routes/root.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Outlet } from "react-router-dom"; - -export default function Root() { - return ( - <> -

Hello

- - - ); -} diff --git a/client/src/routes/timers.tsx b/client/src/routes/timers.tsx new file mode 100644 index 0000000..2457de4 --- /dev/null +++ b/client/src/routes/timers.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; +import TimerCard from "../components/timerCard"; +import TimerHeader from "../components/timerHeader"; +import { useInitialData } from "../hooks/useInitialData"; + +export type TimersFilter = { + friendId: undefined | number; // when undefined, get all +}; + +export type TimersProps = { + filter: TimersFilter; +}; + +export type Friend = { + id: number; + name: number; +}; + +export type TimerResponse = { + id: number; + name: string; + start: Date; + created_by: Friend; + referenced_friends: Friend[]; +}; + +const putSince = (timers) => + timers.map((timer) => ({ + ...timer, + since: "10 seconds ago", + })); + +const makeInitialDataEndpoint = (filter: TimersFilter) => { + let url = "/api/timers"; + if (filter && typeof filter.friendId !== "undefined") + url += `?id=${filter.friendId}`; + return url; +}; + +const makeQuery = (filter: TimersFilter) => { + if (filter && typeof filter.friendId !== "undefined") + return { friend: filter.friendId }; + return {}; +}; + +export default function Timers({ filter }: TimersProps) { + const { + data: timers, + refreshData: refreshTimers, + setData: setTimers, + query, + setQuery, + socket, + } = useInitialData({ + initialDataEndpoint: makeInitialDataEndpoint(filter), + namespace: "/events/timers", + query: makeQuery(filter), + }); + + useEffect(() => { + socket?.on("refreshed", (newTimer: TimerResponse) => { + setTimers((timers) => + timers.map((timer) => { + if (timer.id === newTimer.id) return newTimer; + return timer; + }) + ); + }); + + socket?.on("created", (newTimer: TimerResponse) => { + setTimers((timers) => [...timers, newTimer]); + }); + }, [socket]); + + useEffect(() => { + const updateTimersInterval = setInterval(() => { + setTimers((timers) => putSince(timers)); + }, 1_000); + + return () => clearInterval(updateTimersInterval); + }, []); + + return ( + <> + + {timers?.map((timer) => ( + + ))} + + ); +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 4c872ea..45c2f82 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -18,6 +18,9 @@ services: - 8000:80 networks: - app + depends_on: + - server + - client build: context: ./nginx dockerfile: Dockerfile.dev diff --git a/nginx/nginx.dev.conf b/nginx/nginx.dev.conf index 65620e2..b227c13 100644 --- a/nginx/nginx.dev.conf +++ b/nginx/nginx.dev.conf @@ -1,23 +1,37 @@ server { listen 80; - location / { - proxy_pass http://friendsclient:3000; - - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - } - location /api { proxy_pass http://friendsserver:4000; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; + } + + location /socket.io { + proxy_pass http://friendsserver:4000; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + } + + location / { + proxy_pass http://friendsclient:3000; + + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; } } diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index 3f5648d..b788636 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -31,16 +31,22 @@ export class AuthController { return await this.authService.deleteToken(req.token); } + @UseGuards(AuthGuard) + @Get('/friends') + public async allFriends() { + return await this.authService.allFriends(); + } + @Get('/') - async makeGodToken(@Query() query: RetrieveFriendDTO) { - const friend = await this.authService.findFriendByName(query.name); - if (!friend) throw new NotFoundException('Friend not found with that name'); + public async makeGodToken(@Query() { name, id }: RetrieveFriendDTO) { + const friend = await this.authService.findFriendByNameOrId(name, id); + if (!friend) throw new NotFoundException('Friend not found by that query'); return await this.authService.createTokenForFriend(friend); } @Post() - async verifyFriend( + public async verifyFriend( @Res({ passthrough: true }) res, @Body() { signature }: SignedGodTokenDTO, ) { @@ -59,6 +65,10 @@ export class AuthController { ); if (!referencedToken) throw new NotFoundException('Could not find God Token to sign'); + if (referencedToken.signed) + throw new BadRequestException( + 'God Token was already signed - no replay attacks plz', + ); const { friend } = referencedToken; const publicKeyObj = await readKey({ armoredKey: friend.public_key }); diff --git a/server/src/auth/auth.guard.ts b/server/src/auth/auth.guard.ts index 8b871e4..4849775 100644 --- a/server/src/auth/auth.guard.ts +++ b/server/src/auth/auth.guard.ts @@ -13,7 +13,8 @@ export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest(); - if (!req.cookies.god_token) throw new UnauthorizedException('No session'); + if (req && req.cookies && !req.cookies.god_token) + throw new UnauthorizedException('No session'); const token = await this.authService.findGodTokenWithFriend( req.cookies.god_token, diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index a396169..fb1456c 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Friend } from '@prisma/client'; +import { Friend, Prisma } from '@prisma/client'; import { randomInt } from 'crypto'; import { PrismaService } from '../prisma/prisma.service'; @@ -18,12 +18,31 @@ export class AuthService { .map(() => words[randomInt(0, words.length)]) .join(' '); + static FRIEND_SELECT = { + id: true, + name: true, + } as Prisma.FriendSelect; + + public allFriends() { + return this.prismaService.friend.findMany({ + select: AuthService.FRIEND_SELECT, + }); + } + public findFriendByName(name: string) { return this.prismaService.friend.findUnique({ where: { name }, }); } + public findFriendByNameOrId(name: string, id: number) { + let where: Prisma.FriendWhereUniqueInput = { name }; + if (typeof id !== 'undefined') where = { id }; + return this.prismaService.friend.findUnique({ + where, + }); + } + public createTokenForFriend(friend: Friend) { return this.prismaService.godToken.create({ data: { diff --git a/server/src/auth/authServer.adapter.ts b/server/src/auth/authServer.adapter.ts new file mode 100644 index 0000000..9089536 --- /dev/null +++ b/server/src/auth/authServer.adapter.ts @@ -0,0 +1,47 @@ +import { INestApplicationContext } from '@nestjs/common'; +import { IoAdapter } from '@nestjs/platform-socket.io'; +import { AuthService } from './auth.service'; + +const parseCookie = (str) => + str + .split(';') + .map((v) => v.split('=')) + .reduce((acc, v) => { + acc[decodeURIComponent(v[0].trim())] = decodeURIComponent(v[1].trim()); + return acc; + }, {}); + +/* Shamelessly stolen from https://github.com/nestjs/nest/issues/882#issuecomment-632698668 */ +export class AuthenticatedSocketIoAdapter extends IoAdapter { + private readonly authService: AuthService; + constructor(private app: INestApplicationContext) { + super(app); + this.authService = this.app.get(AuthService); + } + + createIOServer(port: number, options?: any): any { + options.allowRequest = async (request, allowFunction) => { + let verified = false; + + try { + const { god_token } = parseCookie(request.headers.cookie); + + const godToken = await this.authService.findGodTokenWithFriend( + god_token, + ); + if (godToken && godToken.expiration.getTime() > new Date().getTime()) + verified = true; + } catch (e) { + verified = false; + } + + if (verified) { + return allowFunction(null, true); + } + + return allowFunction('Unauthorized', false); + }; + + return super.createIOServer(port, options); + } +} diff --git a/server/src/dto/dtos.ts b/server/src/dto/dtos.ts index 826fc23..59fdd8b 100644 --- a/server/src/dto/dtos.ts +++ b/server/src/dto/dtos.ts @@ -1,4 +1,5 @@ -import { IsNotEmpty } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsNotEmpty, ValidateIf } from 'class-validator'; export class SignedGodTokenDTO { @IsNotEmpty() @@ -6,11 +7,19 @@ export class SignedGodTokenDTO { } export class RetrieveFriendDTO { - @IsNotEmpty() + @ValidateIf((rfd) => !rfd.name || rfd.id) name: string; + + @ValidateIf((rfd) => !rfd.id || rfd.name) + id: number; } export class CreateTimerDTO { @IsNotEmpty() name: string; } + +export class RefreshTimerDTO { + @Type(() => Number) + id: number; +} diff --git a/server/src/main.ts b/server/src/main.ts index a9fe41c..ae21f73 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,6 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { Logger, ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module'; +import { AuthenticatedSocketIoAdapter } from './auth/authServer.adapter'; import * as cookieParser from 'cookie-parser'; @@ -10,6 +11,11 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); app.use(cookieParser()); + // TODO: Remove + app.enableCors(); + + // All WS connections must be auth'd + app.useWebSocketAdapter(new AuthenticatedSocketIoAdapter(app)); app.useGlobalPipes( new ValidationPipe({ diff --git a/server/src/timer/timer.controller.ts b/server/src/timer/timer.controller.ts index 03b9790..f6bcc42 100644 --- a/server/src/timer/timer.controller.ts +++ b/server/src/timer/timer.controller.ts @@ -3,6 +3,7 @@ import { Get, Post, Body, + Param, Req, Query, NotFoundException, @@ -10,8 +11,13 @@ import { BadRequestException, } from '@nestjs/common'; import { TimerService } from './timer.service'; +import { TimerGateway } from './timer.gateway'; import { AuthService } from '../auth/auth.service'; -import { CreateTimerDTO, RetrieveFriendDTO } from '../dto/dtos'; +import { + RefreshTimerDTO, + CreateTimerDTO, + RetrieveFriendDTO, +} from '../dto/dtos'; import { AuthGuard } from 'src/auth/auth.guard'; import { Prisma } from '@prisma/client'; @@ -19,20 +25,36 @@ import { Prisma } from '@prisma/client'; @UseGuards(AuthGuard) export class TimerController { constructor( + private readonly timerGateway: TimerGateway, private readonly timerService: TimerService, private readonly authService: AuthService, ) {} @Get() public async getAllTimers() { - return this.timerService.getAll(); + return await this.timerService.getAll(); } @Get('/friend') - public async getFriendTimers(@Query() { name }: RetrieveFriendDTO) { - const friend = await this.authService.findFriendByName(name); - if (!friend) throw new NotFoundException('Friend not found with that name'); - return this.timerService.friendTimers(friend); + public async getFriendTimers(@Query() { id, name }: RetrieveFriendDTO) { + const friend = await this.authService.findFriendByNameOrId(name, id); + if (!friend) throw new NotFoundException('Friend not found by that query'); + + return await this.timerService.friendTimers(friend); + } + + @Post('/:id/refresh') + public async refreshTimer(@Param() { id }: RefreshTimerDTO, @Req() req) { + const timer = await this.timerService.findTimerById(id); + if (!timer) throw new NotFoundException('No such timer with id'); + + const refreshedTimer = await this.timerService.refreshTimer( + timer, + req.friend, + ); + this.timerGateway.timerRefreshed(refreshedTimer); + + return refreshedTimer; } @Post() @@ -50,8 +72,9 @@ export class TimerController { 'Can link no more than 10 unique friends to timer', ); + let timer; try { - return await this.timerService.createTimerWithFriends( + timer = await this.timerService.createTimerWithFriends( { name, created_by: { @@ -71,5 +94,9 @@ export class TimerController { throw e; } + + this.timerGateway.timerCreated(timer); + + return timer; } } diff --git a/server/src/timer/timer.gateway.ts b/server/src/timer/timer.gateway.ts index 5b88289..5809f8d 100644 --- a/server/src/timer/timer.gateway.ts +++ b/server/src/timer/timer.gateway.ts @@ -1,9 +1,109 @@ -import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets'; +import { + WebSocketGateway, + WebSocketServer, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { Inject, forwardRef } from '@nestjs/common'; +import { Server, Socket } from 'socket.io'; +import { TimerService } from './timer.service'; +import { AuthService } from '../auth/auth.service'; +import { Timer, Friend } from '@prisma/client'; -@WebSocketGateway() -export class TimerGateway { - @SubscribeMessage('message') - handleMessage(client: any, payload: any): string { - return 'Hello world!'; +@WebSocketGateway({ + transports: ['websocket'], + namespace: '/events/timers', +}) +export class TimerGateway implements OnGatewayConnection, OnGatewayDisconnect { + constructor( + @Inject(forwardRef(() => TimerService)) + private timerService: TimerService, + private authService: AuthService, + ) {} + + @WebSocketServer() + server: Server; + + private roomsClientsConnected: Map> = new Map< + string, + Set + >(); + + private clientRoomsConnected: Map> = new Map< + string, + Set + >(); + + private addClientToRoom(roomName: string, clientId: string) { + if (!this.roomsClientsConnected.has(roomName)) { + this.roomsClientsConnected.set(roomName, new Set([clientId])); + } else { + this.roomsClientsConnected.get(roomName).add(clientId); + } + + if (!this.clientRoomsConnected.has(clientId)) { + this.clientRoomsConnected.set(clientId, new Set([roomName])); + } else { + this.clientRoomsConnected.get(clientId).add(roomName); + } + } + + private removeClientFromRoom(roomName: string, clientId: string) { + if (this.roomsClientsConnected.has(roomName)) { + const clients = this.roomsClientsConnected.get(roomName); + + if (clients.size === 1) { + this.roomsClientsConnected.delete(roomName); + } else { + clients.delete(clientId); + } + } + + if (this.clientRoomsConnected.has(clientId)) { + const clientRooms = this.clientRoomsConnected.get(clientId); + clientRooms.delete(roomName); + + if (clientRooms.size === 0) this.clientRoomsConnected.delete(clientId); + } + } + + private friendRoom = ({ id }: { id: number }) => `friend-${id}`; + + public async handleConnection(client: Socket) { + let roomName = 'all'; + + const { friend } = client.handshake?.query; + if (friend) { + const listenFriend = isNaN(Number(friend)) + ? await this.authService.findFriendByName(friend as string) + : await this.authService.findFriendByNameOrId('', Number(friend)); + roomName = this.friendRoom(listenFriend); + } + + client.join(roomName); + this.addClientToRoom(roomName, client.id); + } + + public handleDisconnect(client: Socket) { + for (const room of this.clientRoomsConnected.get(client.id)) + this.removeClientFromRoom(room, client.id); + } + + public timerCreated(timer: Timer & { referenced_friends: Friend[] }) { + this.server.to('all').emit('created', timer); + timer.referenced_friends.map((friend) => + this.server.to(this.friendRoom(friend)).emit('created', timer), + ); + } + + public timerRefreshed( + timer: Partial & { referenced_friends: Partial[] }, + ) { + this.server.to('all').emit('refreshed', timer); + timer.referenced_friends.map((friend) => + this.server + .to(this.friendRoom(friend as Friend)) + .emit('refreshed', timer), + ); } } diff --git a/server/src/timer/timer.service.ts b/server/src/timer/timer.service.ts index 25f6cbe..d885603 100644 --- a/server/src/timer/timer.service.ts +++ b/server/src/timer/timer.service.ts @@ -1,30 +1,32 @@ -import { Friend, Prisma } from '@prisma/client'; +import { Friend, Timer, Prisma } from '@prisma/client'; import { Injectable } from '@nestjs/common'; - import { PrismaService } from '../prisma/prisma.service'; +import { AuthService } from '../auth/auth.service'; @Injectable() export class TimerService { constructor(private readonly prismaService: PrismaService) {} + static TIMER_SELECT = { + id: true, + name: true, + start: true, + } as Prisma.TimerSelect; + + static INCLUDE_FRIENDS_SELECT = { + referenced_friends: { + select: AuthService.FRIEND_SELECT, + }, + created_by: { + select: AuthService.FRIEND_SELECT, + }, + }; + public getAll() { return this.prismaService.timer.findMany({ select: { - id: true, - name: true, - start: true, - referenced_friends: { - select: { - name: true, - id: true, - }, - }, - created_by: { - select: { - name: true, - id: true, - }, - }, + ...TimerService.TIMER_SELECT, + ...TimerService.INCLUDE_FRIENDS_SELECT, }, }); } @@ -32,41 +34,82 @@ export class TimerService { public friendTimers(friend: Friend) { return this.prismaService.timer.findMany({ select: { - id: true, - name: true, - start: true, + ...TimerService.TIMER_SELECT, referenced_friends: { where: { id: friend.id, }, - select: { - id: true, - name: true, - }, + select: AuthService.FRIEND_SELECT, }, created_by: { - select: { - name: true, - id: true, - }, + select: AuthService.FRIEND_SELECT, }, }, }); } + public findTimerById(id: number) { + return this.prismaService.timer.findUnique({ + where: { id }, + }); + } + + public async refreshTimer(timer: Timer, friend: Friend) { + const now = new Date(); + const select = { + ...TimerService.TIMER_SELECT, + ...TimerService.INCLUDE_FRIENDS_SELECT, + }; + const refreshedTimer = await this.prismaService.timer.update({ + where: { id: timer.id }, + data: { + start: now, + }, + select, + }); + + await this.prismaService.timerRefreshes.create({ + data: { + start: timer.start, + end: now, + refreshed_by: { + connect: { + id: friend.id, + }, + }, + timer: { + connect: { + id: timer.id, + }, + }, + }, + }); + + return refreshedTimer; + } + public createTimerWithFriends( timer: Prisma.TimerCreateInput, - friendIds: number[], + referencedFriendIds: number[], ) { - if (friendIds.length > 0) + const select = { + ...TimerService.TIMER_SELECT, + ...TimerService.INCLUDE_FRIENDS_SELECT, + }; + if (referencedFriendIds.length > 0) return this.prismaService.timer.create({ data: { ...timer, referenced_friends: { - connect: friendIds.map((id) => ({ id })), + connect: referencedFriendIds.map((id) => ({ id })), }, }, + select, }); - return this.prismaService.timer.create({ data: timer }); + + return this.prismaService.timer.create({ + data: timer, + select, + }); } }