From 021f72162dfb7ff83dd59d7107f0eb08eaf3bbbb Mon Sep 17 00:00:00 2001 From: Grant Date: Thu, 11 Jul 2024 21:14:12 -0600 Subject: [PATCH 1/2] duct-tape google recaptcha --- package-lock.json | 7 ++ packages/client/package.json | 1 + .../src/components/Info/InfoSidebar.tsx | 1 + packages/client/src/lib/network.ts | 9 ++ packages/client/src/lib/recaptcha.ts | 32 ++++++ packages/lib/src/net.ts | 3 + packages/server/src/lib/Logger.ts | 1 + packages/server/src/lib/Recaptcha.ts | 104 ++++++++++++++++++ packages/server/src/lib/SocketServer.ts | 6 + packages/server/src/types.ts | 6 + 10 files changed, 170 insertions(+) create mode 100644 packages/client/src/lib/recaptcha.ts create mode 100644 packages/server/src/lib/Recaptcha.ts diff --git a/package-lock.json b/package-lock.json index 7e86061..b848f75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6332,6 +6332,12 @@ "@types/express": "*" } }, + "node_modules/@types/grecaptcha": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/grecaptcha/-/grecaptcha-3.0.9.tgz", + "integrity": "sha512-fFxMtjAvXXMYTzDFK5NpcVB7WHnrHVLl00QzEGpuFxSAC789io6M+vjcn+g5FTEamIJtJr/IHkCDsqvJxeWDyw==", + "dev": true + }, "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", @@ -16104,6 +16110,7 @@ }, "devDependencies": { "@tsconfig/vite-react": "^3.0.0", + "@types/grecaptcha": "^3.0.9", "@types/lodash.throttle": "^4.1.9", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", diff --git a/packages/client/package.json b/packages/client/package.json index 415e1ca..dba4e41 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -36,6 +36,7 @@ }, "devDependencies": { "@tsconfig/vite-react": "^3.0.0", + "@types/grecaptcha": "^3.0.9", "@types/lodash.throttle": "^4.1.9", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", diff --git a/packages/client/src/components/Info/InfoSidebar.tsx b/packages/client/src/components/Info/InfoSidebar.tsx index 078ef62..b03bea3 100644 --- a/packages/client/src/components/Info/InfoSidebar.tsx +++ b/packages/client/src/components/Info/InfoSidebar.tsx @@ -62,6 +62,7 @@ export const InfoSidebar = () => { Build {__COMMIT_HASH__} +
); diff --git a/packages/client/src/lib/network.ts b/packages/client/src/lib/network.ts index a136f8b..2c0180f 100644 --- a/packages/client/src/lib/network.ts +++ b/packages/client/src/lib/network.ts @@ -11,6 +11,7 @@ import { } from "@sc07-canvas/lib/src/net"; import { toast } from "react-toastify"; import { handleAlert, handleDismiss } from "./alerts"; +import { Recaptcha } from "./recaptcha"; export interface INetworkEvents { connected: () => void; @@ -91,6 +92,14 @@ class Network extends EventEmitter { console.log("Reconnect failed"); }); + this.socket.on("recaptcha", (site_key) => { + Recaptcha.load(site_key); + }); + + this.socket.on("recaptcha_challenge", (ack) => { + Recaptcha.executeChallenge(ack); + }); + this.socket.on("user", (user) => { this.emit("user", user); }); diff --git a/packages/client/src/lib/recaptcha.ts b/packages/client/src/lib/recaptcha.ts new file mode 100644 index 0000000..0746f33 --- /dev/null +++ b/packages/client/src/lib/recaptcha.ts @@ -0,0 +1,32 @@ +class Recaptcha_ { + load(site_key: string) { + const script = document.createElement("script"); + script.setAttribute( + "src", + `https://www.google.com/recaptcha/api.js?render=explicit` + ); + document.head.appendChild(script); + + script.onload = () => { + grecaptcha.ready(() => { + grecaptcha.render("grecaptcha-badge", { + sitekey: site_key, + badge: "inline", + size: "invisible", + }); + + console.log("Google Recaptcha Loaded!"); + }); + }; + } + + executeChallenge(ack: (token: string) => void) { + console.log("[Recaptcha] Received challenge request..."); + grecaptcha.execute().then((token) => { + console.log("[Recaptcha] Sending challenge token back"); + ack(token as any); + }); + } +} + +export const Recaptcha = new Recaptcha_(); diff --git a/packages/lib/src/net.ts b/packages/lib/src/net.ts index 41e9bdc..6605058 100644 --- a/packages/lib/src/net.ts +++ b/packages/lib/src/net.ts @@ -23,6 +23,9 @@ export interface ServerToClientEvents { alert: (alert: IAlert) => void; alert_dismiss: (id: string) => void; + recaptcha: (site_key: string) => void; + recaptcha_challenge: (ack: (token: string) => void) => void; + /* --- subscribe events --- */ /** diff --git a/packages/server/src/lib/Logger.ts b/packages/server/src/lib/Logger.ts index f7e5651..f2ee3d2 100644 --- a/packages/server/src/lib/Logger.ts +++ b/packages/server/src/lib/Logger.ts @@ -38,6 +38,7 @@ export const LoggerType = createEnum([ "JOB_WORKER", "CANVAS_WORK", "WORKER_ROOT", + "RECAPTCHA", ]); export const getLogger = (module?: keyof typeof LoggerType) => diff --git a/packages/server/src/lib/Recaptcha.ts b/packages/server/src/lib/Recaptcha.ts new file mode 100644 index 0000000..4d2b29c --- /dev/null +++ b/packages/server/src/lib/Recaptcha.ts @@ -0,0 +1,104 @@ +import { Socket } from "socket.io"; +import { User } from "../models/User"; +import { getLogger } from "./Logger"; +import { + ClientToServerEvents, + ServerToClientEvents, +} from "@sc07-canvas/lib/src/net"; + +const Logger = getLogger("RECAPTCHA"); + +class Recaptcha_ { + disabled = false; + chance: number | null = null; + + constructor() { + this.disabled = + !process.env.RECAPTCHA_SITE_KEY || + !process.env.RECAPTCHA_SECRET_KEY || + !process.env.RECAPTCHA_PIXEL_CHANCE; + + if (!process.env.RECAPTCHA_PIXEL_CHANCE) { + Logger.warn("No RECAPTCHA_PIXEL_CHANCE set, captchas will not be sent!"); + } else { + this.chance = parseFloat(process.env.RECAPTCHA_PIXEL_CHANCE); + + if (this.chance > 1 || this.chance < 0) { + this.chance = null; + this.disabled = true; + Logger.warn("RECAPTCHA_PIXEL_CHANCE is not within (0 + ): boolean { + if (this.disabled || !this.chance) return false; + + if (Math.random() > this.chance) { + socket.emitWithAck("recaptcha_challenge").then((token) => { + this.verifyToken(token).then(async (data) => { + if (!data.success) { + this.notifyStaffOfError(data).then(() => {}); + } else { + if (data.score < 0.5 || true) { + try { + const user = (await User.fromAuthSession( + socket.request.session.user! + ))!; + this.notifyStaff(user, data.score).then(() => {}); + } catch (e) {} + } + } + }); + }); + return true; + } + + return false; + } + + async verifyToken( + token: string + ): Promise< + | { success: true; challenge_ts: string; hostname: string; score: number } + | { success: false; "error-codes": string[] } + > { + return await fetch( + `https://www.google.com/recaptcha/api/siteverify?secret=${process.env.RECAPTCHA_SECRET_KEY!}&response=${token}`, + { + method: "POST", + } + ).then((a) => a.json()); + } + + async notifyStaff(user: User, score: number) { + return await fetch(process.env.DISCORD_WEBHOOK!, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: `User ${user.sub} got a low score ${score}`, + }), + }); + } + + async notifyStaffOfError(obj: any) { + return await fetch(process.env.DISCORD_WEBHOOK!, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: + "Error while verifying captcha\n```\n" + + JSON.stringify(obj, null, 2) + + "\n```", + }), + }); + } +} + +export const Recaptcha = new Recaptcha_(); diff --git a/packages/server/src/lib/SocketServer.ts b/packages/server/src/lib/SocketServer.ts index 1099983..b9b4b9b 100644 --- a/packages/server/src/lib/SocketServer.ts +++ b/packages/server/src/lib/SocketServer.ts @@ -15,6 +15,7 @@ import { prisma } from "./prisma"; import { getLogger } from "./Logger"; import { Redis } from "./redis"; import { User } from "../models/User"; +import { Recaptcha } from "./Recaptcha"; const Logger = getLogger("SOCKET"); @@ -192,6 +193,9 @@ export class SocketServer { ); } + if (process.env.RECAPTCHA_SITE_KEY) + socket.emit("recaptcha", process.env.RECAPTCHA_SITE_KEY); + socket.emit("config", getClientConfig()); { let _clientNotifiedAboutCache = false; @@ -296,6 +300,8 @@ export class SocketServer { return; } + Recaptcha.maybeChallenge(socket); + await user.modifyStack(-1); await Canvas.setPixel( user, diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 4347b15..727a6e7 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -60,6 +60,12 @@ declare global { MATRIX_HOMESERVER: string; ELEMENT_HOST: string; MATRIX_GENERAL_ALIAS: string; + + RECAPTCHA_SITE_KEY?: string; + RECAPTCHA_SECRET_KEY?: string; + RECAPTCHA_PIXEL_CHANCE?: string; + + DISCORD_WEBHOOK?: string; } } } From 54574e35f999262d879f1a5190fc1929592992ff Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 12 Jul 2024 03:28:28 +0000 Subject: [PATCH 2/2] Pixel placement log / pixels.log --- Dockerfile | 4 ++ packages/server/src/api/admin.ts | 38 ++++++++++++++ packages/server/src/index.ts | 4 ++ packages/server/src/lib/Canvas.ts | 15 +++++- packages/server/src/lib/LogMan.ts | 85 +++++++++++++++++++++++++++++++ packages/server/src/lib/Logger.ts | 17 +++++++ packages/server/src/types.ts | 2 + 7 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/lib/LogMan.ts diff --git a/Dockerfile b/Dockerfile index b470299..c814cb2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -107,9 +107,13 @@ ENV PORT 3000 ENV NODE_ENV production ENV SERVE_CLIENT /home/node/app/packages/client ENV SERVE_ADMIN /home/node/app/packages/admin +ENV PIXEL_LOG_PATH /home/node/app/pixel.log + +VOLUME /home/node/app/pixel.log EXPOSE 3000 # profiler port, only used if profiler is explicity running EXPOSE 9229 + ENTRYPOINT [ "/bin/sh" ] CMD [ "./docker-start.sh" ] \ No newline at end of file diff --git a/packages/server/src/api/admin.ts b/packages/server/src/api/admin.ts index b3a75f5..eb101a4 100644 --- a/packages/server/src/api/admin.ts +++ b/packages/server/src/api/admin.ts @@ -11,6 +11,7 @@ import { InstanceNotFound, } from "../models/Instance"; import { AuditLog } from "../models/AuditLog"; +import { LogMan } from "../lib/LogMan"; const app = Router(); const Logger = getLogger("HTTP/ADMIN"); @@ -50,6 +51,25 @@ app.get("/check", (req, res) => { res.send({ success: true }); }); +// TODO: Delete before merge +app.get("/log", (req, res) => { + const user = "grant@grants.cafe"; + + for (let i = 0; i < 100; i++) { + LogMan.log("pixel_place", user, { x: 0, y: 0, hex: "ABC123" }); + LogMan.log("pixel_undo", user, { x: 0, y: 0, hex: "FFFFFF" }); + LogMan.log("mod_fill", user, { from: [0, 0], to: [1, 1], hex: "000000" }); + LogMan.log("mod_override", user, { x: 0, y: 0, hex: "111111" }); + LogMan.log("mod_rollback", user, { x: 0, y: 0, hex: "222222" }); + LogMan.log("mod_rollback_undo", user, { x: 0, y: 0, hex: "333333" }); + LogMan.log("canvas_size", { width: 100, height: 100 }); + LogMan.log("canvas_freeze", {}); + LogMan.log("canvas_unfreeze", {}); + } + + res.send("ok"); +}); + app.get("/canvas/size", async (req, res) => { const config = Canvas.getCanvasConfig(); @@ -86,6 +106,11 @@ app.post("/canvas/size", async (req, res) => { } await Canvas.setSize(width, height); + + // we log this here because Canvas#setSize is ran at launch + // this is currently the only way the size is changed is via the API + LogMan.log("canvas_size", { width, height }); + const user = (await User.fromAuthSession(req.session.user!))!; const auditLog = AuditLog.Factory(user.sub) .doing("CANVAS_SIZE") @@ -111,6 +136,9 @@ app.get("/canvas/freeze", async (req, res) => { app.post("/canvas/freeze", async (req, res) => { await Canvas.setFrozen(true); + // same reason as canvas size changes, we log this here because #setFrozen is ran at startup + LogMan.log("canvas_freeze", {}); + const user = (await User.fromAuthSession(req.session.user!))!; const auditLog = AuditLog.Factory(user.sub) .doing("CANVAS_FREEZE") @@ -129,6 +157,9 @@ app.post("/canvas/freeze", async (req, res) => { app.delete("/canvas/freeze", async (req, res) => { await Canvas.setFrozen(false); + // same reason as canvas size changes, we log this here because #setFrozen is ran at startup + LogMan.log("canvas_unfreeze", {}); + const user = (await User.fromAuthSession(req.session.user!))!; const auditLog = AuditLog.Factory(user.sub) .doing("CANVAS_UNFREEZE") @@ -272,6 +303,13 @@ app.put("/canvas/undo", async (req, res) => { ? paletteColors.find((p) => p.hex === coveredPixel.color)?.id || -1 : -1, }); + + // TODO: this spams the log, it would be nicer if it combined + LogMan.log("mod_rollback", user_sub, { + x: pixel.pixel.x, + y: pixel.pixel.y, + hex: coveredPixel?.color, + }); break; } case "rejected": diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 0a354d3..7a9fcdd 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -81,6 +81,10 @@ if (!process.env.INHIBIT_LOGIN) { } } +if (!process.env.PIXEL_LOG_PATH) { + Logger.warn("PIXEL_LOG_PATH is not defined, defaulting to packages/server"); +} + // run startup tasks, all of these need to be completed to serve Promise.all([ Redis.getClient(), diff --git a/packages/server/src/lib/Canvas.ts b/packages/server/src/lib/Canvas.ts index d89af57..9f0aaa7 100644 --- a/packages/server/src/lib/Canvas.ts +++ b/packages/server/src/lib/Canvas.ts @@ -5,6 +5,7 @@ import { SocketServer } from "./SocketServer"; import { getLogger } from "./Logger"; import { Pixel } from "@prisma/client"; import { CanvasWorker } from "../workers/worker"; +import { LogMan } from "./LogMan"; const Logger = getLogger("CANVAS"); @@ -182,7 +183,7 @@ class Canvas { x: pixel.x, y: pixel.y, createdAt: { lt: pixel.createdAt }, - deletedAt: null, + deletedAt: null, // undone pixels will have this set }, orderBy: { createdAt: "desc" }, take: 1, @@ -198,6 +199,11 @@ class Canvas { }); } + LogMan.log("pixel_undo", pixel.userId, { + x: pixel.x, + y: pixel.y, + hex: coveringPixel?.color, + }); return coveringPixel; } @@ -392,6 +398,8 @@ class Canvas { hex, })) ); + + LogMan.log("mod_fill", user.sub, { from: start, to: end, hex }); } async setPixel( @@ -430,6 +438,11 @@ class Canvas { await this.updateCanvasRedisAtPos(x, y); Logger.info(`${user.sub} placed pixel at (${x}, ${y})`); + LogMan.log(isModAction ? "mod_override" : "pixel_place", user.sub, { + x, + y, + hex, + }); } /** diff --git a/packages/server/src/lib/LogMan.ts b/packages/server/src/lib/LogMan.ts new file mode 100644 index 0000000..3bfec0f --- /dev/null +++ b/packages/server/src/lib/LogMan.ts @@ -0,0 +1,85 @@ +import { PixelLogger } from "./Logger"; + +interface UserEvents { + pixel_place: { x: number; y: number; hex: string }; + pixel_undo: { x: number; y: number; hex?: string }; + mod_fill: { + from: [x: number, y: number]; + to: [x: number, y: number]; + hex: string; + }; + mod_override: { x: number; y: number; hex: string }; + mod_rollback: { x: number; y: number; hex?: string }; + mod_rollback_undo: { x: number; y: number; hex?: string }; +} + +interface SystemEvents { + canvas_size: { width: number; height: number }; + canvas_freeze: {}; + canvas_unfreeze: {}; +} + +/** + * Handle logs that should be written to a text file + * + * This could be used as an EventEmitter in the future, but as of right now + * it just adds typing to logging of these events + * + * TODO: better name, this one is not it + * + * @see #57 + */ +class LogMan_ { + log( + event: EventName, + data: SystemEvents[EventName] + ): void; + log( + event: EventName, + user: string, + data: UserEvents[EventName] + ): void; + log( + event: EventName, + ...params: EventName extends keyof UserEvents + ? [user: string, data: UserEvents[EventName]] + : EventName extends keyof SystemEvents + ? [data: SystemEvents[EventName]] + : never + ): void { + let parts: string[] = []; + + if (params.length === 2) { + // user event + let user = params[0] as string; + parts.push(user, event); + + if (event === "mod_fill") { + // this event format has a different line format + let data: UserEvents["mod_fill"] = params[1] as any; + + parts.push(data.from.join(","), data.to.join(","), data.hex); + } else { + let data: UserEvents[Exclude] = + params[1] as any; + parts.push(...[data.x, data.y, data.hex || "unset"].map((a) => a + "")); + } + } else { + // system event + + parts.push("system", event); + + switch (event) { + case "canvas_size": + let data: SystemEvents["canvas_size"] = params[0] as any; + let { width, height } = data; + parts.push(width + "", height + ""); + break; + } + } + + PixelLogger.info(parts.join("\t")); + } +} + +export const LogMan = new LogMan_(); diff --git a/packages/server/src/lib/Logger.ts b/packages/server/src/lib/Logger.ts index f2ee3d2..ec9261a 100644 --- a/packages/server/src/lib/Logger.ts +++ b/packages/server/src/lib/Logger.ts @@ -1,6 +1,11 @@ import winston, { format } from "winston"; +import path from "node:path"; import { createEnum } from "./utils"; +// if PIXEL_LOG_PATH is defined, use that, otherwise default to packages/server root +const PIXEL_LOG_PATH = + process.env.PIXEL_LOG_PATH || path.join(__dirname, "..", "..", "pixels.log"); + const formatter = format.printf((options) => { let maxModuleWidth = 0; for (const module of Object.values(LoggerType)) { @@ -26,6 +31,18 @@ const Winston = winston.createLogger({ transports: [new winston.transports.Console()], }); +// Used by LogMan for writing to pixels.log +export const PixelLogger = winston.createLogger({ + format: format.printf((options) => { + return [new Date().toISOString(), options.message].join("\t"); + }), + transports: [ + new winston.transports.File({ + filename: PIXEL_LOG_PATH, + }), + ], +}); + export const LoggerType = createEnum([ "MAIN", "SETTINGS", diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 727a6e7..e7f7c31 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -61,6 +61,8 @@ declare global { ELEMENT_HOST: string; MATRIX_GENERAL_ALIAS: string; + PIXEL_LOG_PATH?: string; + RECAPTCHA_SITE_KEY?: string; RECAPTCHA_SECRET_KEY?: string; RECAPTCHA_PIXEL_CHANCE?: string;