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/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/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 2e41516..34a4d00 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;
}
@@ -394,6 +400,8 @@ class Canvas {
hex,
}))
);
+
+ LogMan.log("mod_fill", user.sub, { from: start, to: end, hex });
}
async setPixel(
@@ -432,6 +440,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 f7e5651..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",
@@ -38,6 +55,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 a81ee56..18cac90 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");
@@ -194,6 +195,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;
@@ -298,6 +302,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..e7f7c31 100644
--- a/packages/server/src/types.ts
+++ b/packages/server/src/types.ts
@@ -60,6 +60,14 @@ declare global {
MATRIX_HOMESERVER: string;
ELEMENT_HOST: string;
MATRIX_GENERAL_ALIAS: string;
+
+ PIXEL_LOG_PATH?: string;
+
+ RECAPTCHA_SITE_KEY?: string;
+ RECAPTCHA_SECRET_KEY?: string;
+ RECAPTCHA_PIXEL_CHANCE?: string;
+
+ DISCORD_WEBHOOK?: string;
}
}
}