From a9162c245e8da3c29d7f4d7e97715c9dab593209 Mon Sep 17 00:00:00 2001 From: hippoz <10706925-hippoz@users.noreply.gitlab.com> Date: Sun, 10 Apr 2022 01:22:07 +0300 Subject: [PATCH] add basic gatewayt --- package.json | 5 +- src/errors.ts | 7 ++ src/gateway/gatewaypayloadtype.ts | 5 ++ src/gateway/index.ts | 123 ++++++++++++++++++++++++++++++ src/index.ts | 7 +- src/server.ts | 2 + src/types/gatewayclientstate.d.ts | 6 ++ src/types/gatewaypayload.d.ts | 6 ++ src/types/ws.d.ts | 7 ++ test.rest | 12 +-- yarn.lock | 14 +++- 11 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 src/gateway/gatewaypayloadtype.ts create mode 100644 src/gateway/index.ts create mode 100644 src/types/gatewayclientstate.d.ts create mode 100644 src/types/gatewaypayload.d.ts create mode 100644 src/types/ws.d.ts diff --git a/package.json b/package.json index 8f422b7..fc0bf5a 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,16 @@ "express": "^4.17.3", "express-validator": "^6.14.0", "jsonwebtoken": "^8.5.1", - "pg": "^8.7.3" + "pg": "^8.7.3", + "ws": "^8.5.0" }, "devDependencies": { "@types/bcrypt": "^5.0.0", "@types/express": "^4.17.13", "@types/jsonwebtoken": "^8.5.8", + "@types/node": "^17.0.23", "@types/pg": "^8.6.5", + "@types/ws": "^8.5.3", "typescript": "^4.6.3" } } diff --git a/src/errors.ts b/src/errors.ts index 998b2bd..7bdc59d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -6,3 +6,10 @@ export const errors = { FORBIDDEN_DUE_TO_MISSING_PERMISSIONS: { code: 6005, message: "Forbidden due to missing permission(s)" }, GOT_NO_DATABASE_DATA: { code: 7001, message: "Unexpectedly got no data from database" } }; + +export const gatewayErrors = { + BAD_PAYLOAD: { code: 4001, message: "Bad payload" }, + BAD_AUTH: { code: 4002, message: "Bad authentication" }, + AUTHENTICATION_TIMEOUT: { code: 4003, message: "Authentication timeout" }, + NO_PING: { code: 4004, message: "No ping" }, +}; diff --git a/src/gateway/gatewaypayloadtype.ts b/src/gateway/gatewaypayloadtype.ts new file mode 100644 index 0000000..4b1bee9 --- /dev/null +++ b/src/gateway/gatewaypayloadtype.ts @@ -0,0 +1,5 @@ +export enum GatewayPayloadType { + Hello = 0, + Authenticate, + Ready +} diff --git a/src/gateway/index.ts b/src/gateway/index.ts new file mode 100644 index 0000000..2882d4d --- /dev/null +++ b/src/gateway/index.ts @@ -0,0 +1,123 @@ +import { Server } from "node:http"; +import { performance } from "node:perf_hooks"; +import WebSocket, { WebSocketServer } from "ws"; +import { decodeTokenOrNull } from "../auth"; +import { gatewayErrors } from "../errors"; +import { GatewayPayload } from "../types/gatewaypayload"; +import { GatewayPayloadType } from "./gatewaypayloadtype"; + +const GATEWAY_BATCH_INTERVAL = 25000 || process.env.GATEWAY_BATCH_INTERVAL; +const GATEWAY_PING_INTERVAL = 20000 || process.env.GATEWAY_PING_INTERVAL; + +function closeWithError(ws: WebSocket, { code, message }: { code: number, message: string }) { + return ws.close(1000, `(${code}) ${message}`); +} + +function closeWithBadPayload(ws: WebSocket, hint: string) { + return ws.close(gatewayErrors.BAD_PAYLOAD.code, `${gatewayErrors.BAD_PAYLOAD.message}: ${hint}`); +} + +function parseJsonOrNull(payload: string): any { + try { + return JSON.parse(payload); + } catch (e) { + return null; + } +} + +// The function below ensures `payload` is of the GatewayPayload +// interface payload. If it does not match, null is returned. +function ensureFormattedGatewayPayload(payload: any): GatewayPayload | null { + if (!payload) { + return null; + } + + let foundT = false; + let foundD = false; + for (const [k, v] of Object.entries(payload)) { + if (k === "t" && typeof v === "number") { + foundT = true; + } else if (k === "d") { + foundD = true; + } else { + return null; + } + } + if (!foundT || !foundD) { + return null; + } + const asPayload = payload as GatewayPayload; + return asPayload; +} + +function sendPayload(ws: WebSocket, payload: GatewayPayload) { + ws.send(JSON.stringify(payload)); +} + +export default function(server: Server) { + const wss = new WebSocketServer({ server }); + + setInterval(() => { + wss.clients.forEach((e) => { + const now = performance.now(); + if (e.state && (now - e.state.lastAliveCheck) >= GATEWAY_PING_INTERVAL) { + if (!e.state.ready) { + return closeWithError(e, gatewayErrors.AUTHENTICATION_TIMEOUT); + } + } + }); + }, GATEWAY_BATCH_INTERVAL); + + wss.on("connection", (ws) => { + ws.state = { + user: undefined, + alive: false, + ready: false, + lastAliveCheck: performance.now() + }; + + sendPayload(ws, { + t: GatewayPayloadType.Hello, + d: { + pingInterval: GATEWAY_PING_INTERVAL + } + }); + + ws.on("message", async (rawData, isBinary) => { + if (isBinary) { + return closeWithBadPayload(ws, "Binary messages are not supported"); + } + + const payload = ensureFormattedGatewayPayload(parseJsonOrNull(rawData.toString())); + if (!payload) { + return closeWithBadPayload(ws, "Invalid JSON or message does not match schema"); + } + + switch (payload.t) { + case GatewayPayloadType.Authenticate: { + const token = payload.d; + if (typeof token !== "string") { + return closeWithBadPayload(ws, "d: expected string"); + } + const user = await decodeTokenOrNull(token); + if (!user) { + return closeWithError(ws, gatewayErrors.BAD_AUTH); + } + ws.state.user = user; + ws.state.ready = true; + + sendPayload(ws, { + t: GatewayPayloadType.Ready, + d: { + user: ws.state.user, + } + }) + break; + } + default: { + return closeWithBadPayload(ws, "t: unknown type"); + } + } + }); + }); +}; diff --git a/src/index.ts b/src/index.ts index 877f3ab..03561a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,20 @@ import "dotenv/config"; import express from "express"; +import { createServer } from "node:http"; import databaseInit from "./database/init"; +import gateway from "./gateway"; import server from "./server"; const port = process.env.PORT || 3000; const app = express(); server(app); +const httpServer = createServer(app); +gateway(httpServer); + function serve() { - app.listen(port, () => console.log(`listening on port ${port}`)); + httpServer.listen(port, () => console.log(`listening on port ${port}`)); } async function main() { diff --git a/src/server.ts b/src/server.ts index ac631c7..099faa3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,8 @@ import usersRouter from "./routes/api/v1/users"; import channelsRouter from "./routes/api/v1/channels"; export default function(app: Application) { + app.get("/", (req, res) => res.send("hello!")); + app.use(json()); app.use("/api/v1/users", usersRouter); app.use("/api/v1/channels", channelsRouter); diff --git a/src/types/gatewayclientstate.d.ts b/src/types/gatewayclientstate.d.ts new file mode 100644 index 0000000..cf44b3a --- /dev/null +++ b/src/types/gatewayclientstate.d.ts @@ -0,0 +1,6 @@ +interface GatewayClientState { + user?: User; + ready: boolean, + alive: boolean, + lastAliveCheck: number, +} diff --git a/src/types/gatewaypayload.d.ts b/src/types/gatewaypayload.d.ts new file mode 100644 index 0000000..4b88a91 --- /dev/null +++ b/src/types/gatewaypayload.d.ts @@ -0,0 +1,6 @@ +import { GatewayPayloadType } from "../gateway/gatewaypayloadtype"; + +declare interface GatewayPayload { + t: GatewayPayloadType; + d: any; +} diff --git a/src/types/ws.d.ts b/src/types/ws.d.ts new file mode 100644 index 0000000..cb08647 --- /dev/null +++ b/src/types/ws.d.ts @@ -0,0 +1,7 @@ +import ws from 'ws'; + +declare module 'ws' { + export interface WebSocket extends ws { + state: GatewayClientState; + } +} diff --git a/test.rest b/test.rest index 4911d6f..ff7ea59 100644 --- a/test.rest +++ b/test.rest @@ -19,13 +19,13 @@ content-type: application/json ### GET http://localhost:3000/api/v1/users/self HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6MSwiaWF0IjoxNjQ5MTg3MDc5LCJleHAiOjE2NDkzNTk4Nzl9.tVzJWnBP7IFhA88XRwByKGXQ4cihWdJSoxUkrWHkIVU +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE ### POST http://localhost:3000/api/v1/channels HTTP/1.1 content-type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6MSwiaWF0IjoxNjQ5MTg3MDc5LCJleHAiOjE2NDkzNTk4Nzl9.tVzJWnBP7IFhA88XRwByKGXQ4cihWdJSoxUkrWHkIVU +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE { "name": "another channel" @@ -35,7 +35,7 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6M PUT http://localhost:3000/api/v1/channels/2 HTTP/1.1 content-type: application/json -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6MSwiaWF0IjoxNjQ5MTg3MDc5LCJleHAiOjE2NDkzNTk4Nzl9.tVzJWnBP7IFhA88XRwByKGXQ4cihWdJSoxUkrWHkIVU +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE #Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5MjU5NDUwLCJleHAiOjE2NDk0MzIyNTB9.JmF9NujFZnln7A-ynNpeyayGBqmR5poAyACYV6RnSQY { @@ -45,15 +45,15 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6M ### DELETE http://localhost:3000/api/v1/channels/1 HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6MSwiaWF0IjoxNjQ5MTg3MDc5LCJleHAiOjE2NDkzNTk4Nzl9.tVzJWnBP7IFhA88XRwByKGXQ4cihWdJSoxUkrWHkIVU +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE #Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5MjU5NDUwLCJleHAiOjE2NDk0MzIyNTB9.JmF9NujFZnln7A-ynNpeyayGBqmR5poAyACYV6RnSQY ### GET http://localhost:3000/api/v1/channels/1 HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6MSwiaWF0IjoxNjQ5MTg3MDc5LCJleHAiOjE2NDkzNTk4Nzl9.tVzJWnBP7IFhA88XRwByKGXQ4cihWdJSoxUkrWHkIVU +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE ### GET http://localhost:3000/api/v1/channels HTTP/1.1 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidHlwZSI6MSwiaWF0IjoxNjQ5MTg3MDc5LCJleHAiOjE2NDkzNTk4Nzl9.tVzJWnBP7IFhA88XRwByKGXQ4cihWdJSoxUkrWHkIVU +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidHlwZSI6MSwiaWF0IjoxNjQ5NTI0NDA1LCJleHAiOjE2NDk2OTcyMDV9.4nIDs0K8MCT18GsdlKdicT_GK2KbEqi_P7cND3_aZvE diff --git a/yarn.lock b/yarn.lock index cd3d64a..2d17335 100644 --- a/yarn.lock +++ b/yarn.lock @@ -70,7 +70,7 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== -"@types/node@*": +"@types/node@*", "@types/node@^17.0.23": version "17.0.23" resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.23.tgz#3b41a6e643589ac6442bdbd7a4a3ded62f33f7da" integrity sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw== @@ -102,6 +102,13 @@ "@types/mime" "^1" "@types/node" "*" +"@types/ws@^8.5.3": + version "8.5.3" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d" + integrity sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w== + dependencies: + "@types/node" "*" + abbrev@1: version "1.1.1" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" @@ -1010,6 +1017,11 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= +ws@^8.5.0: + version "8.5.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f" + integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg== + xtend@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"