Skip to content

Commit e914522

Browse files
authored
Merge pull request #10 from thejoshwolfe/schema
Server uses jsonschema to validate client requests
2 parents 58b28da + 023ae4c commit e914522

12 files changed

+731
-103
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
node_modules/
22
*.js
33
*.js.map
4+
*.d.ts
5+
*.cache.json

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"typescript": "^5.3.3"
1212
},
1313
"scripts": {
14-
"build": "npm install --no-audit --no-fund && npm run build:client && npm run build:server",
14+
"build": "npm install --no-audit --no-fund && npm run build:shared && npm run build:client && npm run build:server",
15+
"build:shared": "cd src/shared && npm run build",
1516
"build:client": "cd src/client && npm run build",
1617
"build:server": "cd src/server && npm run build",
1718
"run": "cd src/server && npm run run"

src/client/client.ts

+12-13
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ function renderAndMaybeCommitSelection(selection: {[index: ObjectId]: ObjectTemp
494494
clearNumberBuffer();
495495
}
496496
function commitSelection(selection: {[index: ObjectId]: ObjectTempProps}) {
497-
let move: any[] = []; // TODO
497+
let move: (string | number)[] = [];
498498
move.push(getMyUserId());
499499
for (let id in selection) {
500500
let object = objectsById[id];
@@ -532,14 +532,13 @@ function commitSelection(selection: {[index: ObjectId]: ObjectTempProps}) {
532532
}
533533
}
534534
if (move.length <= 1) return;
535-
let message = {
535+
sendMessage({
536536
cmd: "makeAMove",
537537
args: move,
538-
};
539-
sendMessage(message);
538+
});
540539
pushChangeToHistory(move);
541540
}
542-
function pushObjectProps(move: any[], object: ObjectState) { // TODO
541+
function pushObjectProps(move: (string | number)[], object: ObjectState) {
543542
move.push(
544543
object.id,
545544
object.prototype,
@@ -549,14 +548,14 @@ function pushObjectProps(move: any[], object: ObjectState) { // TODO
549548
object.faceIndex);
550549
}
551550
const objectPropCount = 6;
552-
function consumeObjectProps(move: any[], i: number): ObjectState { // TODO
551+
function consumeObjectProps(move: (string | number)[], i: number): ObjectState {
553552
let object = makeObject(
554-
move[i++], // id
555-
move[i++], // prototypeId
556-
move[i++], // x
557-
move[i++], // y
558-
move[i++], // z
559-
move[i++], // faceIndex
553+
move[i++] as string, // id
554+
move[i++] as string, // prototypeId
555+
move[i++] as number, // x
556+
move[i++] as number, // y
557+
move[i++] as number, // z
558+
move[i++] as number, // faceIndex
560559
);
561560
return object;
562561
}
@@ -707,7 +706,7 @@ function deleteSelection() {
707706
partialDeletion = true;
708707
}
709708

710-
let move: any[] = []; // TODO
709+
let move: (string | number)[] = [];
711710
objects.forEach(function(object) {
712711
if (!object.temporary) {
713712
move.push("d"); // delete

src/client/connection.ts

+7-14
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,17 @@
1-
import {programmerError} from "./math.js";
2-
import {
3-
initGame, deleteTableAndEverything,
4-
makeAMove,
5-
} from "./client.js";
6-
import {
7-
UserInfo, UserId,
8-
JoinRoomArgs, UserJoinedArgs, UserLeftArgs, ChangeMyNameArgs, ChangeMyRoleArgs, RoleId,
9-
} from "../shared/protocol.js";
10-
import {
11-
ScreenMode, getScreenMode, setScreenMode,
12-
renderUserList,
13-
} from "./ui_layout.js";
1+
import { programmerError } from "./math.js";
2+
import { initGame, deleteTableAndEverything, makeAMove } from "./client.js";
3+
import { ScreenMode, getScreenMode, setScreenMode, renderUserList } from "./ui_layout.js";
4+
5+
import { UserInfo, UserId, JoinRoomArgs, UserJoinedArgs, UserLeftArgs, ChangeMyNameArgs, ChangeMyRoleArgs, RoleId } from "../shared/protocol.js";
6+
import { ProtocolMessage } from "../shared/generated-schema.js";
147

158
let socket: WebSocket | null = null;
169
let isConnected = false;
1710
let roomCode: string | null = null;
1811
let myUser: UserInfo | null = null;
1912
let usersById: {[index: UserId]: UserInfo} = {};
2013

21-
export function sendMessage(message: object) {
14+
export function sendMessage(message: ProtocolMessage) {
2215
if (socket == null) programmerError();
2316
socket.send(JSON.stringify(message));
2417
}

src/server/package-lock.json

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/server/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"type": "module",
33
"dependencies": {
44
"express": "^4.18.2",
5+
"jsonschema": "^1.4.1",
56
"ws": "^8.16.0"
67
},
78
"devDependencies": {

src/server/server.ts

+39-75
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import http from "http";
22
import express from "express";
33
import {WebSocket, WebSocketServer} from "ws";
4+
import jsonschema from "jsonschema";
45

56
import database from "./database.js";
67
import defaultRoomState from "./defaultRoom.js";
8+
import { protocolSchema } from "../shared/schema.js";
9+
import { ProtocolMessage } from "../shared/generated-schema.js";
710

811
const bindIpAddress = "127.0.0.1";
912

@@ -111,25 +114,25 @@ function handleNewSocket(socket: WebSocket) {
111114
let user: UserInRoom | null = null;
112115

113116
socket.on("message", function(data, isBinary) {
114-
if (isBinary) throw new Error("no");
115-
const msg = data.toString();
117+
try {
118+
if (isBinary) throwShenanigan("received binary message");
119+
let msg = data.toString();
120+
handleMessage(msg);
121+
} catch (e) {
122+
if (!(e instanceof Shenanigan)) {
123+
console.log(e);
124+
}
125+
disconnect();
126+
}
127+
});
116128

129+
function handleMessage(msg: string) {
117130
if (clientState === ClientState.DISCONNECTING) return;
118-
console.log(msg);
119-
let allowedCommands = (function() {
120-
switch (clientState) {
121-
case ClientState.WAITING_FOR_LOGIN:
122-
return ["joinRoom"];
123-
case ClientState.PLAY:
124-
return ["makeAMove", "changeMyName", "changeMyRole"];
125-
default: programmerError();
126-
}
127-
})();
128-
let message = parseAndValidateMessage(msg, allowedCommands);
129-
if (message == null) return;
131+
let message = parseAndValidateMessage(msg);
130132

131133
switch (message.cmd) {
132134
case "joinRoom": {
135+
if (clientState !== ClientState.WAITING_FOR_LOGIN) throwShenanigan("not now kid");
133136
let roomCode = message.args.roomCode;
134137
if (roomCode === "new") {
135138
room = newRoom();
@@ -176,6 +179,7 @@ function handleNewSocket(socket: WebSocket) {
176179
break;
177180
}
178181
case "makeAMove": {
182+
if (clientState !== ClientState.PLAY) throwShenanigan("not now kid");
179183
if (!(user != null && room != null)) programmerError();
180184
let msg = JSON.stringify(message);
181185
for (let otherId in room.usersById) {
@@ -186,6 +190,7 @@ function handleNewSocket(socket: WebSocket) {
186190
break;
187191
}
188192
case "changeMyName": {
193+
if (clientState !== ClientState.PLAY) throwShenanigan("not now kid");
189194
if (!(user != null && room != null)) programmerError();
190195
let newName = message.args;
191196
user.userName = newName;
@@ -199,6 +204,7 @@ function handleNewSocket(socket: WebSocket) {
199204
break;
200205
}
201206
case "changeMyRole": {
207+
if (clientState !== ClientState.PLAY) throwShenanigan("not now kid");
202208
if (!(user != null && room != null)) programmerError();
203209
let newRole = message.args;
204210
user.role = newRole;
@@ -211,10 +217,14 @@ function handleNewSocket(socket: WebSocket) {
211217
}
212218
break;
213219
}
214-
default: throw new Error("TODO: handle command: " + message.cmd);
220+
default: throw new Error("TODO: handle command: " + msg);
215221
}
216-
});
222+
}
217223

224+
function throwShenanigan(...msgs: any[]): never {
225+
console.log("error handling client message:", ...msgs);
226+
throw new Shenanigan();
227+
}
218228
function disconnect() {
219229
// we initiate a disconnect
220230
socket.close();
@@ -260,74 +270,28 @@ function handleNewSocket(socket: WebSocket) {
260270
socket.send(msg);
261271
}
262272

263-
function parseAndValidateMessage(msg: string, allowedCommands: string[]) {
273+
function parseAndValidateMessage(msg: string): ProtocolMessage {
274+
// json.
264275
let message;
265276
try {
266277
message = JSON.parse(msg);
267278
} catch (e) {
268-
return failValidation(e);
269-
}
270-
// TODO: rethink validation so that it only protects the server, not other clients
271-
if (typeof message != "object") return failValidation("JSON root data type expected to be object");
272-
// ignore all unexpected fields.
273-
message = {
274-
cmd: message.cmd,
275-
args: message.args,
276-
};
277-
if (allowedCommands.indexOf(message.cmd) === -1) return failValidation("invalid command", message.cmd);
278-
switch (message.cmd) {
279-
case "createRoom": {
280-
if (message.args != null) return failValidation("expected no args. got:", message.args);
281-
delete message.args;
282-
break;
283-
}
284-
case "joinRoom": {
285-
message.args = {
286-
roomCode: message.args.roomCode,
287-
};
288-
if (typeof message.args.roomCode !== "string") return failValidation("expected string:", message.args.roomCode);
289-
// although the room code might be bogus, that's a reasonable mistake, not a malfunction.
290-
break;
291-
}
292-
case "makeAMove": {
293-
let move = message.args;
294-
if (!Array.isArray(move)) return failValidation("expected args to be an array");
295-
break;
296-
}
297-
case "changeMyName": {
298-
let newName = message.args;
299-
if (typeof newName !== "string") return failValidation("expected string:", newName);
300-
if (newName.length > 16) newName = newName.substring(0, 16);
301-
if (newName.length === 0) return failValidation("new name is empty string");
302-
message.args = newName;
303-
break;
304-
}
305-
case "changeMyRole": {
306-
let newRole = message.args;
307-
if (typeof newRole !== "string") return failValidation("expected string:", newRole);
308-
message.args = newRole;
309-
break;
310-
}
311-
default: throw new Error("TODO: handle command: " + message.cmd);
279+
throwShenanigan(msg + "\n", e);
312280
}
313281

314-
// seems legit
315-
return message;
282+
// json schema.
283+
let res = jsonschema.validate(message, protocolSchema, {
284+
required: true,
285+
nestedErrors: true,
286+
});
287+
if (!res.valid) throwShenanigan("client message fails validation:", message, res.toString() + "---");
316288

317-
function failValidation(blurb: string | any, offendingValue?: any) {
318-
if (arguments.length >= 2) {
319-
if (typeof offendingValue === "string") {
320-
// make whitespace easier to see
321-
offendingValue = JSON.stringify(offendingValue);
322-
}
323-
console.log("message failed validation:", blurb, offendingValue);
324-
} else {
325-
console.log("message failed validation:", blurb);
326-
}
327-
return null;
328-
}
289+
console.log(msg);
290+
return message;
329291
}
330292
}
293+
class Shenanigan extends Error {}
294+
331295

332296
const roomCodeAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
333297
function generateRoomCode() {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {writeFileSync, statSync, readFileSync} from "fs";
2+
3+
const selfPath = "generate-typescript-interfaces.js"; // we don't have __filename for some reason.
4+
const inPath = "schema.js";
5+
const outPath = "generated-schema.d.ts";
6+
const cachePath = "schema.cache.json";
7+
8+
let cache: {[index: string]: string} = function() {
9+
try {
10+
return JSON.parse(readFileSync(cachePath, {encoding: "utf8"}));
11+
} catch {
12+
return {};
13+
}
14+
}();
15+
16+
function checkCache(path: string): boolean {
17+
let stats = statSync(path, {throwIfNoEntry: false});
18+
if (stats == null) return false;
19+
const entry = JSON.stringify([stats.ino, stats.size, stats.mtime]);
20+
if (cache[path] === entry) return true;
21+
22+
// Optimistically put the new entry in case we get around to writing the cache.
23+
cache[path] = entry;
24+
return false;
25+
}
26+
function writeCache() {
27+
writeFileSync(cachePath, JSON.stringify(cache) + "\n");
28+
}
29+
30+
if (checkCache(selfPath) && checkCache(inPath) && checkCache(outPath)) {
31+
// up to date.
32+
process.exit(0);
33+
}
34+
35+
// Need to generate.
36+
console.log("generating schema types");
37+
import {protocolSchema} from "./schema.js"
38+
39+
// tsc chokes analyzing this dependency, but it works at runtime.
40+
// Obfuscate the import to disable typescript compile-time analysis.
41+
const {compile} = await import("" + "json-schema-to-typescript");
42+
43+
let content = await compile(protocolSchema, "ProtocolMessage");
44+
writeFileSync(outPath, content);
45+
46+
checkCache(outPath);
47+
writeCache();

0 commit comments

Comments
 (0)