Websockets with auth pipeline!
This commit is contained in:
parent
36412c9f58
commit
a00fa5c194
86
client/package-lock.json
generated
86
client/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
3
client/src/components/timerCard.tsx
Normal file
3
client/src/components/timerCard.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function TimerCard({ timer }) {
|
||||
return <h1>{timer.name}</h1>;
|
||||
}
|
24
client/src/components/timerHeader.tsx
Normal file
24
client/src/components/timerHeader.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
39
client/src/hooks/useInitialData.ts
Normal file
39
client/src/hooks/useInitialData.ts
Normal 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 };
|
||||
};
|
@ -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: (
|
||||
<ProtectedRoute>
|
||||
<Root />
|
||||
<Timers />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
errorElement: <NotFound />,
|
||||
@ -27,9 +27,7 @@ const router = createBrowserRouter([
|
||||
]);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
@ -4,7 +4,7 @@ import { useAuthContext } from "../context/authContext";
|
||||
export default function ProtectedRoute({ children }) {
|
||||
const { signedIn } = useAuthContext();
|
||||
|
||||
if (!signedIn) return <Navigate to="/login" />;
|
||||
// if (!signedIn) return <Navigate to="/login" />;
|
||||
|
||||
return children;
|
||||
}
|
||||
|
@ -1,10 +0,0 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export default function Root() {
|
||||
return (
|
||||
<>
|
||||
<h1>Hello</h1>
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
91
client/src/routes/timers.tsx
Normal file
91
client/src/routes/timers.tsx
Normal 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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
@ -18,6 +18,9 @@ services:
|
||||
- 8000:80
|
||||
networks:
|
||||
- app
|
||||
depends_on:
|
||||
- server
|
||||
- client
|
||||
build:
|
||||
context: ./nginx
|
||||
dockerfile: Dockerfile.dev
|
||||
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
@ -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 });
|
||||
|
@ -13,7 +13,8 @@ export class AuthGuard implements CanActivate {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
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,
|
||||
|
@ -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: {
|
||||
|
47
server/src/auth/authServer.adapter.ts
Normal file
47
server/src/auth/authServer.adapter.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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<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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user