diff --git a/packages/client/src/lib/canvas.ts b/packages/client/src/lib/canvas.ts index e11e595ca4c5111f93b0de73a266da6f435eb13d..d60e96d8c2a96c1ae4b30f91b6e1c6363530bbaa 100644 --- a/packages/client/src/lib/canvas.ts +++ b/packages/client/src/lib/canvas.ts @@ -403,6 +403,9 @@ export class Canvas extends EventEmitter { case "no_user": toast.error("You are not logged in."); break; + case "pixel_already_pending": + toast.error("You are already placing a pixel"); + break; case "palette_color_invalid": toast.error("This isn't a color that you can use...?"); break; diff --git a/packages/lib/src/canvas.ts b/packages/lib/src/canvas.ts index 73d86b533e11fc2bf01e88e2c6c1bd3fd9b695fa..3ca936d9e4ba87d873036f92cecc7ba486c8e953 100644 --- a/packages/lib/src/canvas.ts +++ b/packages/lib/src/canvas.ts @@ -20,6 +20,12 @@ export const CanvasLib = new (class { // oh god last minute change to match activity cooldown // 100 = user count + + // band aid over negative nums + if (pixelNumber < 1) { + pixelNumber = 1 + } + return (2.5 * Math.sqrt(100 + 11.96) + 6.5) * 1 * pixelNumber; } })(); diff --git a/packages/lib/src/net.ts b/packages/lib/src/net.ts index 01d218d9dce68d87f04e49886f2db878d4341740..b324551c4be84dc97f8f807a7c4dd33dfe0fedce 100644 --- a/packages/lib/src/net.ts +++ b/packages/lib/src/net.ts @@ -55,6 +55,7 @@ export interface ClientToServerEvents { | "palette_color_invalid" | "you_already_placed_that" | "banned" + | "pixel_already_pending" > ) => void ) => void; diff --git a/packages/server/src/lib/SocketServer.ts b/packages/server/src/lib/SocketServer.ts index 70b9c08c636efe3f9e167acfa09b8f4471644e4e..133ffd7182cc9e756fc3de8496cf2e535409e57b 100644 --- a/packages/server/src/lib/SocketServer.ts +++ b/packages/server/src/lib/SocketServer.ts @@ -85,6 +85,13 @@ type Socket = RawSocket; export class SocketServer { static instance: SocketServer; io: Server; + /** + * Prevent users from time attacking pixel placements to place more pixels than stacked + * + * @key user sub (grant@grants.cafe) + * @value timestamp + */ + userPlaceLock = new Map(); constructor(server: http.Server) { SocketServer.instance = this; @@ -96,6 +103,29 @@ export class SocketServer { this.io.engine.use(session); this.io.on("connection", this.handleConnection.bind(this)); + // clear pixel locks if they have existed for more than a minute + setInterval(() => { + const oneMinuteAgo = new Date(); + oneMinuteAgo.setMinutes(oneMinuteAgo.getMinutes() - 1); + + const expired = [...this.userPlaceLock.entries()].filter( + ([user, time]) => time < oneMinuteAgo.getTime() + ); + + if (expired.length > 0) { + Logger.warn( + "A pixel lock has existed for too long for " + + expired.length + + " users : " + + expired.map((a) => a[0]).join(",") + ); + } + + for (const expire of expired) { + this.userPlaceLock.delete(expire[0]); + } + }, 1000 * 30); + // pixel stacking // - needs to be exponential (takes longer to aquire more pixels stacked) // - convert to config options instead of hard-coded @@ -254,19 +284,29 @@ export class SocketServer { // force a user data update await user.update(true); + if (this.userPlaceLock.has(user.sub)) { + ack({ success: false, error: "pixel_already_pending" }); + return; + } + + this.userPlaceLock.set(user.sub, Date.now()); + if (bypassCooldown && !user.isModerator) { // only moderators can do this ack({ success: false, error: "invalid_pixel" }); + this.userPlaceLock.delete(user.sub); return; } if (!bypassCooldown && user.pixelStack < 1) { ack({ success: false, error: "pixel_cooldown" }); + this.userPlaceLock.delete(user.sub); return; } if ((user.getBan()?.expires || 0) > new Date()) { ack({ success: false, error: "banned" }); + this.userPlaceLock.delete(user.sub); return; } @@ -280,6 +320,7 @@ export class SocketServer { success: false, error: "palette_color_invalid", }); + this.userPlaceLock.delete(user.sub); return; } @@ -291,6 +332,7 @@ export class SocketServer { pixelAtTheSameLocation.color === paletteColor.hex ) { ack({ success: false, error: "you_already_placed_that" }); + this.userPlaceLock.delete(user.sub); return; } @@ -319,6 +361,7 @@ export class SocketServer { data: newPixel, }); socket.broadcast.emit("pixel", newPixel); + this.userPlaceLock.delete(user.sub); }); socket.on("undo", async (ack) => {