Websockets with auth pipeline!

This commit is contained in:
Elizabeth Hunt 2023-04-03 23:14:07 -06:00
parent 36412c9f58
commit a00fa5c194
Signed by: simponic
GPG Key ID: 52B3774857EB24B1
20 changed files with 589 additions and 86 deletions

View File

@ -11,7 +11,8 @@
"chota": "^0.9.2", "chota": "^0.9.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^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": { "devDependencies": {
"@types/react": "^18.0.28", "@types/react": "^18.0.28",
@ -784,6 +785,11 @@
"node": ">=14" "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": { "node_modules/@types/prop-types": {
"version": "15.7.5", "version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
@ -945,7 +951,6 @@
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"dev": true,
"dependencies": { "dependencies": {
"ms": "2.1.2" "ms": "2.1.2"
}, },
@ -964,6 +969,26 @@
"integrity": "sha512-gM7TdwuG3amns/1rlgxMbeeyNoBFPa+4Uu0c7FeROWh4qWmvSOnvcslKmWy51ggLKZ2n/F/4i2HJ+PVNxH9uCQ==", "integrity": "sha512-gM7TdwuG3amns/1rlgxMbeeyNoBFPa+4Uu0c7FeROWh4qWmvSOnvcslKmWy51ggLKZ2n/F/4i2HJ+PVNxH9uCQ==",
"dev": true "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": { "node_modules/esbuild": {
"version": "0.17.15", "version": "0.17.15",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.15.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.15.tgz",
@ -1154,8 +1179,7 @@
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
"dev": true
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.6", "version": "3.3.6",
@ -1329,6 +1353,32 @@
"semver": "bin/semver.js" "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": { "node_modules/source-map-js": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -12,7 +12,8 @@
"chota": "^0.9.2", "chota": "^0.9.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^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": { "devDependencies": {
"@types/react": "^18.0.28", "@types/react": "^18.0.28",

View File

@ -0,0 +1,3 @@
export default function TimerCard({ timer }) {
return <h1>{timer.name}</h1>;
}

View File

@ -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 (
<>
<div>{friendName}</div>
{friends.map((friend) => (
<div key={friend.id}>
<p>{friend.name}</p>
</div>
))}
</>
);
}

View File

@ -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 = <T>(props: UseInitialDataProps) => {
const [data, setData] = useState<T>();
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 };
};

View File

@ -5,9 +5,9 @@ import "chota";
import { AuthProvider } from "./context/authContext"; import { AuthProvider } from "./context/authContext";
import Root from "./routes/root";
import NotFound from "./routes/notFound"; import NotFound from "./routes/notFound";
import Login from "./routes/login"; import Login from "./routes/login";
import Timers from "./routes/timers";
import ProtectedRoute from "./routes/protected.tsx"; import ProtectedRoute from "./routes/protected.tsx";
const router = createBrowserRouter([ const router = createBrowserRouter([
@ -15,7 +15,7 @@ const router = createBrowserRouter([
path: "/", path: "/",
element: ( element: (
<ProtectedRoute> <ProtectedRoute>
<Root /> <Timers />
</ProtectedRoute> </ProtectedRoute>
), ),
errorElement: <NotFound />, errorElement: <NotFound />,
@ -27,9 +27,7 @@ const router = createBrowserRouter([
]); ]);
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<AuthProvider> <AuthProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</AuthProvider> </AuthProvider>
</React.StrictMode>
); );

View File

@ -4,7 +4,7 @@ import { useAuthContext } from "../context/authContext";
export default function ProtectedRoute({ children }) { export default function ProtectedRoute({ children }) {
const { signedIn } = useAuthContext(); const { signedIn } = useAuthContext();
if (!signedIn) return <Navigate to="/login" />; // if (!signedIn) return <Navigate to="/login" />;
return children; return children;
} }

View File

@ -1,10 +0,0 @@
import { Outlet } from "react-router-dom";
export default function Root() {
return (
<>
<h1>Hello</h1>
<Outlet />
</>
);
}

View File

@ -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<TimerResponse[]>({
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 (
<>
<TimerHeader />
{timers?.map((timer) => (
<TimerCard timer={timer} key={timer.id} />
))}
</>
);
}

View File

@ -18,6 +18,9 @@ services:
- 8000:80 - 8000:80
networks: networks:
- app - app
depends_on:
- server
- client
build: build:
context: ./nginx context: ./nginx
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev

View File

@ -1,23 +1,37 @@
server { server {
listen 80; 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 { location /api {
proxy_pass http://friendsserver:4000; 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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "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 X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host; 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";
} }
} }

View File

@ -31,16 +31,22 @@ export class AuthController {
return await this.authService.deleteToken(req.token); return await this.authService.deleteToken(req.token);
} }
@UseGuards(AuthGuard)
@Get('/friends')
public async allFriends() {
return await this.authService.allFriends();
}
@Get('/') @Get('/')
async makeGodToken(@Query() query: RetrieveFriendDTO) { public async makeGodToken(@Query() { name, id }: RetrieveFriendDTO) {
const friend = await this.authService.findFriendByName(query.name); const friend = await this.authService.findFriendByNameOrId(name, id);
if (!friend) throw new NotFoundException('Friend not found with that name'); if (!friend) throw new NotFoundException('Friend not found by that query');
return await this.authService.createTokenForFriend(friend); return await this.authService.createTokenForFriend(friend);
} }
@Post() @Post()
async verifyFriend( public async verifyFriend(
@Res({ passthrough: true }) res, @Res({ passthrough: true }) res,
@Body() { signature }: SignedGodTokenDTO, @Body() { signature }: SignedGodTokenDTO,
) { ) {
@ -59,6 +65,10 @@ export class AuthController {
); );
if (!referencedToken) if (!referencedToken)
throw new NotFoundException('Could not find God Token to sign'); 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 { friend } = referencedToken;
const publicKeyObj = await readKey({ armoredKey: friend.public_key }); const publicKeyObj = await readKey({ armoredKey: friend.public_key });

View File

@ -13,7 +13,8 @@ export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest(); 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( const token = await this.authService.findGodTokenWithFriend(
req.cookies.god_token, req.cookies.god_token,

View File

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Friend } from '@prisma/client'; import { Friend, Prisma } from '@prisma/client';
import { randomInt } from 'crypto'; import { randomInt } from 'crypto';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@ -18,12 +18,31 @@ export class AuthService {
.map(() => words[randomInt(0, words.length)]) .map(() => words[randomInt(0, words.length)])
.join(' '); .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) { public findFriendByName(name: string) {
return this.prismaService.friend.findUnique({ return this.prismaService.friend.findUnique({
where: { name }, 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) { public createTokenForFriend(friend: Friend) {
return this.prismaService.godToken.create({ return this.prismaService.godToken.create({
data: { data: {

View File

@ -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);
}
}

View File

@ -1,4 +1,5 @@
import { IsNotEmpty } from 'class-validator'; import { Type } from 'class-transformer';
import { IsNotEmpty, ValidateIf } from 'class-validator';
export class SignedGodTokenDTO { export class SignedGodTokenDTO {
@IsNotEmpty() @IsNotEmpty()
@ -6,11 +7,19 @@ export class SignedGodTokenDTO {
} }
export class RetrieveFriendDTO { export class RetrieveFriendDTO {
@IsNotEmpty() @ValidateIf((rfd) => !rfd.name || rfd.id)
name: string; name: string;
@ValidateIf((rfd) => !rfd.id || rfd.name)
id: number;
} }
export class CreateTimerDTO { export class CreateTimerDTO {
@IsNotEmpty() @IsNotEmpty()
name: string; name: string;
} }
export class RefreshTimerDTO {
@Type(() => Number)
id: number;
}

View File

@ -1,6 +1,7 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common'; import { Logger, ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { AuthenticatedSocketIoAdapter } from './auth/authServer.adapter';
import * as cookieParser from 'cookie-parser'; import * as cookieParser from 'cookie-parser';
@ -10,6 +11,11 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.use(cookieParser()); app.use(cookieParser());
// TODO: Remove
app.enableCors();
// All WS connections must be auth'd
app.useWebSocketAdapter(new AuthenticatedSocketIoAdapter(app));
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({

View File

@ -3,6 +3,7 @@ import {
Get, Get,
Post, Post,
Body, Body,
Param,
Req, Req,
Query, Query,
NotFoundException, NotFoundException,
@ -10,8 +11,13 @@ import {
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { TimerService } from './timer.service'; import { TimerService } from './timer.service';
import { TimerGateway } from './timer.gateway';
import { AuthService } from '../auth/auth.service'; 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 { AuthGuard } from 'src/auth/auth.guard';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
@ -19,20 +25,36 @@ import { Prisma } from '@prisma/client';
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
export class TimerController { export class TimerController {
constructor( constructor(
private readonly timerGateway: TimerGateway,
private readonly timerService: TimerService, private readonly timerService: TimerService,
private readonly authService: AuthService, private readonly authService: AuthService,
) {} ) {}
@Get() @Get()
public async getAllTimers() { public async getAllTimers() {
return this.timerService.getAll(); return await this.timerService.getAll();
} }
@Get('/friend') @Get('/friend')
public async getFriendTimers(@Query() { name }: RetrieveFriendDTO) { public async getFriendTimers(@Query() { id, name }: RetrieveFriendDTO) {
const friend = await this.authService.findFriendByName(name); const friend = await this.authService.findFriendByNameOrId(name, id);
if (!friend) throw new NotFoundException('Friend not found with that name'); if (!friend) throw new NotFoundException('Friend not found by that query');
return this.timerService.friendTimers(friend);
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() @Post()
@ -50,8 +72,9 @@ export class TimerController {
'Can link no more than 10 unique friends to timer', 'Can link no more than 10 unique friends to timer',
); );
let timer;
try { try {
return await this.timerService.createTimerWithFriends( timer = await this.timerService.createTimerWithFriends(
{ {
name, name,
created_by: { created_by: {
@ -71,5 +94,9 @@ export class TimerController {
throw e; throw e;
} }
this.timerGateway.timerCreated(timer);
return timer;
} }
} }

View File

@ -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() @WebSocketGateway({
export class TimerGateway { transports: ['websocket'],
@SubscribeMessage('message') namespace: '/events/timers',
handleMessage(client: any, payload: any): string { })
return 'Hello world!'; export class TimerGateway implements OnGatewayConnection, OnGatewayDisconnect {
constructor(
@Inject(forwardRef(() => TimerService))
private timerService: TimerService,
private authService: AuthService,
) {}
@WebSocketServer()
server: Server;
private roomsClientsConnected: Map<string, Set<string>> = new Map<
string,
Set<string>
>();
private clientRoomsConnected: Map<string, Set<string>> = new Map<
string,
Set<string>
>();
private addClientToRoom(roomName: string, clientId: string) {
if (!this.roomsClientsConnected.has(roomName)) {
this.roomsClientsConnected.set(roomName, new Set<string>([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<Timer> & { referenced_friends: Partial<Friend>[] },
) {
this.server.to('all').emit('refreshed', timer);
timer.referenced_friends.map((friend) =>
this.server
.to(this.friendRoom(friend as Friend))
.emit('refreshed', timer),
);
} }
} }

View File

@ -1,30 +1,32 @@
import { Friend, Prisma } from '@prisma/client'; import { Friend, Timer, Prisma } from '@prisma/client';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuthService } from '../auth/auth.service';
@Injectable() @Injectable()
export class TimerService { export class TimerService {
constructor(private readonly prismaService: PrismaService) {} constructor(private readonly prismaService: PrismaService) {}
public getAll() { static TIMER_SELECT = {
return this.prismaService.timer.findMany({
select: {
id: true, id: true,
name: true, name: true,
start: true, start: true,
} as Prisma.TimerSelect;
static INCLUDE_FRIENDS_SELECT = {
referenced_friends: { referenced_friends: {
select: { select: AuthService.FRIEND_SELECT,
name: true,
id: true,
},
}, },
created_by: { created_by: {
select: AuthService.FRIEND_SELECT,
},
};
public getAll() {
return this.prismaService.timer.findMany({
select: { select: {
name: true, ...TimerService.TIMER_SELECT,
id: true, ...TimerService.INCLUDE_FRIENDS_SELECT,
},
},
}, },
}); });
} }
@ -32,41 +34,82 @@ export class TimerService {
public friendTimers(friend: Friend) { public friendTimers(friend: Friend) {
return this.prismaService.timer.findMany({ return this.prismaService.timer.findMany({
select: { select: {
id: true, ...TimerService.TIMER_SELECT,
name: true,
start: true,
referenced_friends: { referenced_friends: {
where: { where: {
id: friend.id, id: friend.id,
}, },
select: { select: AuthService.FRIEND_SELECT,
id: true,
name: true,
},
}, },
created_by: { created_by: {
select: { select: AuthService.FRIEND_SELECT,
name: true,
id: true,
},
}, },
}, },
}); });
} }
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( public createTimerWithFriends(
timer: Prisma.TimerCreateInput, 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({ return this.prismaService.timer.create({
data: { data: {
...timer, ...timer,
referenced_friends: { referenced_friends: {
connect: friendIds.map((id) => ({ id })), connect: referencedFriendIds.map((id) => ({ id })),
}, },
}, },
select,
});
return this.prismaService.timer.create({
data: timer,
select,
}); });
return this.prismaService.timer.create({ data: timer });
} }
} }