Skip to content
-- AlterTable
ALTER TABLE "Ban" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "deletedAt" TIMESTAMP(3),
ADD COLUMN "updatedAt" TIMESTAMP(3);
-- DropForeignKey
ALTER TABLE "Pixel" DROP CONSTRAINT "Pixel_color_fkey";
/*
Warnings:
- You are about to drop the column `deletedAt` on the `Ban` table. All the data in the column will be lost.
*/
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AuditLogAction" ADD VALUE 'CANVAS_SIZE';
ALTER TYPE "AuditLogAction" ADD VALUE 'CANVAS_FILL';
-- AlterTable
ALTER TABLE "Ban" DROP COLUMN "deletedAt";
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AuditLogAction" ADD VALUE 'CANVAS_FREEZE';
ALTER TYPE "AuditLogAction" ADD VALUE 'CANVAS_UNFREEZE';
-- AlterEnum
ALTER TYPE "AuditLogAction" ADD VALUE 'CANVAS_AREA_UNDO';
ALTER TABLE "User" RENAME COLUMN "lastPixelTime" TO "lastTimeGainStarted";
\ No newline at end of file
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AuditLogAction" ADD VALUE 'USER_MOD';
ALTER TYPE "AuditLogAction" ADD VALUE 'USER_UNMOD';
ALTER TYPE "AuditLogAction" ADD VALUE 'USER_ADMIN';
ALTER TYPE "AuditLogAction" ADD VALUE 'USER_UNADMIN';
-- CreateTable
CREATE TABLE "IPAddress" (
"ip" TEXT NOT NULL,
"userSub" TEXT NOT NULL,
"lastUsedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IPAddress_pkey" PRIMARY KEY ("ip","userSub")
);
-- AddForeignKey
ALTER TABLE "IPAddress" ADD CONSTRAINT "IPAddress_userSub_fkey" FOREIGN KEY ("userSub") REFERENCES "User"("sub") ON DELETE RESTRICT ON UPDATE CASCADE;
......@@ -5,43 +5,78 @@ generator client {
provider = "prisma-client-js"
}
generator dbml {
provider = "prisma-dbml-generator"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Setting {
key String @id
value String // this value will be parsed with JSON.parse
}
model User {
sub String @id
lastPixelTime DateTime @default(now()) // the time the last pixel was placed at
pixelStack Int @default(0) // amount of pixels stacked for this user
sub String @id
display_name String?
picture_url String?
profile_url String?
lastTimeGainStarted DateTime @default(now()) // the time base used to determine the amount of stack the user should gain
pixelStack Int @default(0) // amount of pixels stacked for this user
undoExpires DateTime? // when the undo for the most recent pixel expires at
isAdmin Boolean @default(false)
isModerator Boolean @default(false)
pixels Pixel[]
FactionMember FactionMember[]
Ban Ban?
AuditLog AuditLog[]
IPAddress IPAddress[]
}
model Instance {
id Int @id @default(autoincrement())
hostname String @unique
name String?
logo_url String?
banner_url String?
Ban Ban?
}
model IPAddress {
ip String
userSub String
lastUsedAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userSub], references: [sub])
@@id([ip, userSub])
}
model PaletteColor {
id Int @id @default(autoincrement())
name String
hex String @unique
pixels Pixel[]
}
model Pixel {
id Int @id @default(autoincrement())
userId String
x Int
y Int
color String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [sub])
pallete PaletteColor @relation(fields: [color], references: [hex])
id Int @id @default(autoincrement())
userId String
x Int
y Int
color String
isTop Boolean @default(false)
isModAction Boolean @default(false)
createdAt DateTime @default(now())
deletedAt DateTime?
user User @relation(fields: [userId], references: [sub])
// do not add a relation to PaletteColor, in the case the palette gets changed
// https://github.com/prisma/prisma/issues/18058
}
model Faction {
......@@ -104,3 +139,51 @@ model FactionSettingDefinition {
minimumLevel Int // what level is needed to modify this setting (>=)
FactionSetting FactionSetting[]
}
model Ban {
id Int @id @default(autoincrement())
userId String? @unique
instanceId Int? @unique
privateNote String?
publicNote String?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime?
user User? @relation(fields: [userId], references: [sub])
instance Instance? @relation(fields: [instanceId], references: [id])
AuditLog AuditLog[]
}
enum AuditLogAction {
BAN_CREATE
BAN_UPDATE
BAN_DELETE
CANVAS_SIZE
CANVAS_FILL
CANVAS_FREEZE
CANVAS_UNFREEZE
CANVAS_AREA_UNDO
USER_MOD
USER_UNMOD
USER_ADMIN
USER_UNADMIN
}
model AuditLog {
id Int @id @default(autoincrement())
userId String?
action AuditLogAction
reason String?
comment String? // service comment
banId Int?
createdAt DateTime @default(now())
updatedAt DateTime?
user User? @relation(fields: [userId], references: [sub])
ban Ban? @relation(fields: [banId], references: [id])
}
import { Router } from "express";
import { prisma } from "./lib/prisma";
const app = Router();
const { AUTH_ENDPOINT, AUTH_CLIENT, AUTH_SECRET } = process.env;
app.get("/me", (req, res) => {
res.json(req.session);
});
app.get("/login", (req, res) => {
const params = new URLSearchParams();
params.set("service", "canvas");
res.redirect(AUTH_ENDPOINT + "/login?" + params);
});
app.get("/callback", async (req, res) => {
const { code } = req.query;
const who = await fetch(AUTH_ENDPOINT + "/api/auth/identify", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${AUTH_CLIENT}:${AUTH_SECRET}`,
},
body: JSON.stringify({
code,
}),
}).then((a) => a.json());
if (!who.success) {
res.json({
error: "AUTHENTICATION FAILED",
error_message: who.error || "no error specified",
});
return;
}
const [username, hostname] = who.user.sub.split("@");
await prisma.user.upsert({
where: {
sub: [username, hostname].join("@"),
},
update: {},
create: {
sub: [username, hostname].join("@"),
},
});
req.session.user = {
service: {
...who.service,
instance: {
...who.service.instance,
hostname,
},
},
user: {
profile: who.user.profile,
username,
},
};
req.session.save();
res.redirect("/");
});
export default app;
import { Router } from "express";
import Canvas from "../lib/Canvas";
import { getLogger } from "../lib/Logger";
import { LogMan } from "../lib/LogMan";
import { prisma } from "../lib/prisma";
import { RateLimiter } from "../lib/RateLimiter";
import { SocketServer } from "../lib/SocketServer";
import { AuditLog } from "../models/AuditLog";
import {
Instance,
InstanceNotBanned,
InstanceNotFound,
} from "../models/Instance";
import { User, UserNotBanned, UserNotFound } from "../models/User";
const app = Router();
const Logger = getLogger("HTTP/ADMIN");
app.use(RateLimiter.ADMIN);
app.use(async (req, res, next) => {
if (!req.session.user) {
res.status(401).json({
success: false,
error: "You are not logged in",
});
return;
}
const user = await User.fromAuthSession(req.session.user);
if (!user) {
res.status(400).json({
success: false,
error: "User data does not exist?",
});
return;
}
if (!user.isAdmin) {
res.status(403).json({
success: false,
error: "user is not admin",
});
return;
}
next();
});
app.get("/check", (req, res) => {
res.send({ success: true });
});
app.get("/canvas/size", async (req, res) => {
const config = Canvas.getCanvasConfig();
res.json({
success: true,
size: {
width: config.size[0],
height: config.size[1],
},
});
});
/**
* Update canvas size
*
* @header X-Audit
* @body width number
* @body height number
*/
app.post("/canvas/size", async (req, res) => {
const width = parseInt(req.body.width || "-1");
const height = parseInt(req.body.height || "-1");
if (
isNaN(width) ||
isNaN(height) ||
width < 1 ||
height < 1 ||
width > 10000 ||
height > 10000
) {
res.status(400).json({ success: false, error: "what are you doing" });
return;
}
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")
.reason(req.header("X-Audit") || null)
.withComment(`Changed canvas size to ${width}x${height}`)
.create();
res.send({ success: true, auditLog });
});
/**
* Get canvas frozen status
*/
app.get("/canvas/freeze", async (req, res) => {
res.send({ success: true, frozen: Canvas.frozen });
});
/**
* Freeze the canvas
*
* @header X-Audit
*/
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")
.reason(req.header("X-Audit") || null)
.withComment(`Freezed the canvas`)
.create();
res.send({ success: true, auditLog });
});
/**
* Unfreeze the canvas
*
* @header X-Audit
*/
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")
.reason(req.header("X-Audit") || null)
.withComment(`Un-Freezed the canvas`)
.create();
res.send({ success: true, auditLog });
});
app.put("/canvas/heatmap", async (req, res) => {
try {
await Canvas.generateHeatmap();
res.send({ success: true });
} catch (e) {
Logger.error(e);
res.send({ success: false, error: "Failed to generate" });
}
});
app.post("/canvas/forceUpdateTop", async (req, res) => {
Logger.info("Starting force updating isTop");
await Canvas.forceUpdatePixelIsTop();
Logger.info("Finished force updating isTop");
res.send({ success: true });
});
app.get("/canvas/:x/:y", async (req, res) => {
const x = parseInt(req.params.x);
const y = parseInt(req.params.y);
res.json(await Canvas.getPixel(x, y));
});
app.post("/canvas/stress", async (req, res) => {
if (process.env.NODE_ENV === "production") {
res.status(500).json({
success: false,
error: "this is terrible idea to execute this in production",
});
return;
}
if (
typeof req.body?.width !== "number" ||
typeof req.body?.height !== "number"
) {
res.status(400).json({ success: false, error: "width/height is invalid" });
return;
}
const style: "random" | "xygradient" = req.body.style || "random";
const width: number = req.body.width;
const height: number = req.body.height;
const user = (await User.fromAuthSession(req.session.user!))!;
const paletteColors = await prisma.paletteColor.findMany({});
const promises: Promise<any>[] = [];
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
promises.push(
new Promise<void>((res, rej) => {
let colorIndex: number;
if (style === "xygradient") {
colorIndex =
Math.floor((x / width) * (paletteColors.length / 2)) +
Math.floor((y / height) * (paletteColors.length / 2));
} else {
colorIndex = Math.floor(Math.random() * paletteColors.length);
}
const color = paletteColors[colorIndex];
Canvas.setPixel(user, x, y, color.hex, false)
.then(() => {
SocketServer.instance.io.emit("pixel", {
x,
y,
color: color.id,
});
res();
})
.catch(rej);
})
);
}
}
await Promise.allSettled(promises);
res.send("ok");
});
/**
* Undo a square
*
* @header X-Audit
* @body start.x number
* @body start.y number
* @body end.x number
* @body end.y number
*/
app.put("/canvas/undo", async (req, res) => {
if (
typeof req.body?.start?.x !== "number" ||
typeof req.body?.start?.y !== "number"
) {
res
.status(400)
.json({ success: false, error: "start position is invalid" });
return;
}
if (
typeof req.body?.end?.x !== "number" ||
typeof req.body?.end?.y !== "number"
) {
res.status(400).json({ success: false, error: "end position is invalid" });
return;
}
const user_sub =
req.session.user!.user.username +
"@" +
req.session.user!.service.instance.hostname;
const start_position: [x: number, y: number] = [
req.body.start.x,
req.body.start.y,
];
const end_position: [x: number, y: number] = [req.body.end.x, req.body.end.y];
// const width = end_position[0] - start_position[0];
// const height = end_position[1] - start_position[1];
const pixels = await Canvas.undoArea(start_position, end_position);
const paletteColors = await prisma.paletteColor.findMany({});
for (const pixel of pixels) {
switch (pixel.status) {
case "fulfilled": {
const coveredPixel = pixel.value;
SocketServer.instance.io.emit("pixel", {
x: pixel.pixel.x,
y: pixel.pixel.y,
color: coveredPixel
? 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":
Logger.log("Failed to undo pixel", pixel);
break;
}
}
const user = (await User.fromAuthSession(req.session.user!))!;
const auditLog = await AuditLog.Factory(user.sub)
.doing("CANVAS_AREA_UNDO")
.reason(req.header("X-Audit") || null)
.withComment(
`Area undo (${start_position.join(",")}) -> (${end_position.join(",")})`
)
.create();
res.json({ success: true, auditLog });
});
/**
* Fill an area
*
* @header X-Audit
* @body start.x number
* @body start.y number
* @body end.x number
* @body end.y number
* @body color number Palette color index
*/
app.put("/canvas/fill", async (req, res) => {
if (
typeof req.body?.start?.x !== "number" ||
typeof req.body?.start?.y !== "number"
) {
res
.status(400)
.json({ success: false, error: "start position is invalid" });
return;
}
if (
typeof req.body?.end?.x !== "number" ||
typeof req.body?.end?.y !== "number"
) {
res.status(400).json({ success: false, error: "end position is invalid" });
return;
}
if (typeof req.body.color !== "number") {
res.status(400).json({ success: false, error: "color is invalid" });
return;
}
const user_sub =
req.session.user!.user.username +
"@" +
req.session.user!.service.instance.hostname;
const start_position: [x: number, y: number] = [
req.body.start.x,
req.body.start.y,
];
const end_position: [x: number, y: number] = [req.body.end.x, req.body.end.y];
const palette = await prisma.paletteColor.findFirst({
where: { id: req.body.color },
});
if (!palette) {
res.status(400).json({ success: false, error: "invalid color" });
return;
}
// const width = end_position[0] - start_position[0];
// const height = end_position[1] - start_position[1];
// const area = width * height;
// if (area > 50 * 50) {
// res.status(400).json({ success: false, error: "Area too big" });
// return;
// }
await Canvas.fillArea(
{ sub: user_sub },
start_position,
end_position,
palette.hex
);
SocketServer.instance.io.emit(
"square",
start_position,
end_position,
palette.id
);
const user = (await User.fromAuthSession(req.session.user!))!;
const auditLog = await AuditLog.Factory(user.sub)
.doing("CANVAS_FILL")
.reason(req.header("X-Audit") || null)
.withComment(
`Filled (${start_position.join(",")}) -> (${end_position.join(",")}) with ${palette.hex}`
)
.create();
res.json({ success: true, auditLog });
});
/**
* Get ip address info
*
* @query address IP address
*/
app.get("/ip", async (req, res) => {
if (typeof req.query.address !== "string") {
res.status(400).json({ success: false, error: "missing ?address=" });
return;
}
const ip: string = req.query.address;
const results = await prisma.iPAddress.findMany({
select: {
userSub: true,
createdAt: true,
lastUsedAt: true,
},
where: {
ip,
},
});
res.json({ success: true, results });
});
/**
* Get all of a user's IP addresses
*
* @param :sub User ID
*/
app.get("/user/:sub/ips", async (req, res) => {
let user: User;
try {
user = await User.fromSub(req.params.sub);
} catch (e) {
if (e instanceof UserNotFound) {
res.status(404).json({ success: false, error: "User not found" });
} else {
Logger.error(`/user/${req.params.sub}/ips Error ` + (e as any)?.message);
res.status(500).json({ success: false, error: "Internal error" });
}
return;
}
const ips = await prisma.iPAddress.findMany({
where: {
userSub: user.sub,
},
});
res.json({ success: true, ips });
});
/**
* Create or ban a user
*
* @header X-Audit
* @param :sub User sub claim
* @body expiresAt string! ISO date time string
* @body publicNote string?
* @body privateNote string?
*/
app.put("/user/:sub/ban", async (req, res) => {
let user: User;
let expires: Date;
let publicNote: string | undefined | null;
let privateNote: string | undefined | null;
try {
user = await User.fromSub(req.params.sub);
} catch (e) {
if (e instanceof UserNotFound) {
res.status(404).json({ success: false, error: "User not found" });
} else {
Logger.error(`/user/${req.params.sub}/ban Error ` + (e as any)?.message);
res.status(500).json({ success: false, error: "Internal error" });
}
return;
}
if (typeof req.body.expiresAt !== "string") {
res
.status(400)
.json({ success: false, error: "expiresAt is not a string" });
return;
}
// temporary: see #152
// eslint-disable-next-line prefer-const
expires = new Date(req.body.expiresAt);
if (!isFinite(expires.getTime())) {
res
.status(400)
.json({ success: false, error: "expiresAt is not a valid date" });
return;
}
if (typeof req.body.publicNote !== "undefined") {
if (
typeof req.body.publicNote !== "string" &&
req.body.privateNote !== null
) {
res.status(400).json({
success: false,
error: "publicNote is set and is not a string",
});
return;
}
publicNote = req.body.publicNote;
}
if (typeof req.body.privateNote !== "undefined") {
if (
typeof req.body.privateNote !== "string" &&
req.body.privateNote !== null
) {
res.status(400).json({
success: false,
error: "privateNote is set and is not a string",
});
return;
}
privateNote = req.body.privateNote;
}
const existingBan = user.getBan();
const ban = await user.ban(expires, publicNote, privateNote);
let shouldNotifyUser = false;
if (existingBan) {
if (existingBan.expires.getTime() !== ban.expiresAt.getTime()) {
shouldNotifyUser = true;
}
} else {
shouldNotifyUser = true;
}
if (shouldNotifyUser) {
user.notify({
is: "modal",
action: "moderation",
dismissable: true,
message_key: "banned",
metadata: {
until: expires.toISOString(),
},
});
}
user.updateStanding();
const adminUser = (await User.fromAuthSession(req.session.user!))!;
const auditLog = await AuditLog.Factory(adminUser.sub)
.doing(existingBan ? "BAN_UPDATE" : "BAN_CREATE")
.reason(req.header("X-Audit") || null)
.withComment(
existingBan
? `Updated ban on ${user.sub}`
: `Created a ban for ${user.sub}`
)
.withBan(ban)
.create();
res.json({ success: true, auditLog });
});
/**
* Delete a user ban
*
* @header X-Audit
* @param :sub User sub
*/
app.delete("/user/:sub/ban", async (req, res) => {
// delete ban ("unban")
let user: User;
try {
user = await User.fromSub(req.params.sub);
} catch (e) {
if (e instanceof UserNotFound) {
res.status(404).json({ success: false, error: "User not found" });
} else {
Logger.error(`/user/${req.params.sub}/ban Error ` + (e as any)?.message);
res.status(500).json({ success: false, error: "Internal error" });
}
return;
}
try {
await user.unban();
} catch (e) {
if (e instanceof UserNotBanned) {
res.status(404).json({ success: false, error: "User is not banned" });
} else {
Logger.error(
`/instance/${req.params.sub}/ban Error ` + (e as any)?.message
);
res.status(500).json({ success: false, error: "Internal error" });
}
return;
}
user.notify({
is: "modal",
action: "moderation",
dismissable: true,
message_key: "unbanned",
metadata: {},
});
await user.update(true);
user.updateStanding();
const adminUser = (await User.fromAuthSession(req.session.user!))!;
const auditLog = await AuditLog.Factory(adminUser.sub)
.doing("BAN_DELETE")
.reason(req.header("X-Audit") || null)
.withComment(`Deleted ban for ${user.sub}`)
.create();
res.json({ success: true, auditLog });
});
/**
* Send notice to every connected socket
*
* @body title string
* @body body string?
*/
app.post("/user/all/notice", async (req, res) => {
const title: string = req.body.title;
if (typeof req.body.title !== "string") {
res.status(400).json({ success: false, error: "Title is not a string" });
return;
}
if (
typeof req.body.body !== "undefined" &&
typeof req.body.body !== "string"
) {
res
.status(400)
.json({ success: false, error: "Body is set but is not a string" });
return;
}
const sockets = await SocketServer.instance.io.fetchSockets();
for (const socket of sockets) {
socket.emit("alert", {
is: "modal",
action: "moderation",
dismissable: true,
title,
body: req.body.body,
});
}
res.json({ success: true });
});
/**
* Send notice to user
*
* @param :sub User id
* @body title string
* @body body string?
*/
app.post("/user/:sub/notice", async (req, res) => {
let user: User;
try {
user = await User.fromSub(req.params.sub);
} catch (e) {
if (e instanceof UserNotFound) {
res.status(404).json({ success: false, error: "User not found" });
} else {
Logger.error(`/user/${req.params.sub}/ban Error ` + (e as any)?.message);
res.status(500).json({ success: false, error: "Internal error" });
}
return;
}
const title: string = req.body.title;
if (typeof req.body.title !== "string") {
res.status(400).json({ success: false, error: "Title is not a string" });
return;
}
if (
typeof req.body.body !== "undefined" &&
typeof req.body.body !== "string"
) {
res
.status(400)
.json({ success: false, error: "Body is set but is not a string" });
return;
}
user.notify({
is: "modal",
action: "moderation",
dismissable: true,
title,
body: req.body.body,
});
res.json({ success: true });
});
/**
* Mark a user as a moderator
*
* @param :sub User ID
*/
app.put("/user/:sub/moderator", async (req, res) => {
let user: User;
try {
user = await User.fromSub(req.params.sub);
} catch (e) {
if (e instanceof UserNotFound) {
res.status(404).json({ success: false, error: "User not found" });
} else {
res.status(500).json({ success: false, error: "Internal error" });
}
return;
}
await prisma.user.update({
where: { sub: user.sub },
data: {
isModerator: true,
},
});
await user.update(true);
const adminUser = (await User.fromAuthSession(req.session.user!))!;
const auditLog = await AuditLog.Factory(adminUser.sub)
.doing("USER_MOD")
.reason(req.header("X-Audit") || null)
.withComment(`Made ${user.sub} a moderator`)
.create();
res.json({ success: true, auditLog });
});
/**
* Unmark a user as a moderator
*
* @param :sub User ID
*/
app.delete("/user/:sub/moderator", async (req, res) => {
let user: User;
try {
user = await User.fromSub(req.params.sub);
} catch (e) {
if (e instanceof UserNotFound) {
res.status(404).json({ success: false, error: "User not found" });
} else {
res.status(500).json({ success: false, error: "Internal error" });
}
return;
}
await prisma.user.update({
where: { sub: user.sub },
data: {
isModerator: false,
},
});
await user.update(true);
const adminUser = (await User.fromAuthSession(req.session.user!))!;
const auditLog = await AuditLog.Factory(adminUser.sub)
.doing("USER_UNMOD")
.reason(req.header("X-Audit") || null)
.withComment(`Removed ${user.sub} as moderator`)
.create();
res.json({ success: true, auditLog });
});
/**
* Mark a user as an admin
*
* @param :sub User ID
*/
app.put("/user/:sub/admin", async (req, res) => {
let user: User;
try {
user = await User.fromSub(req.params.sub);
} catch (e) {
if (e instanceof UserNotFound) {
res.status(404).json({ success: false, error: "User not found" });
} else {
res.status(500).json({ success: false, error: "Internal error" });
}
return;
}
await prisma.user.update({
where: { sub: user.sub },
data: {
isAdmin: true,
},
});
await user.update(true);
const adminUser = (await User.fromAuthSession(req.session.user!))!;
const auditLog = await AuditLog.Factory(adminUser.sub)
.doing("USER_ADMIN")
.reason(req.header("X-Audit") || null)
.withComment(`Added ${user.sub} as admin`)
.create();
res.json({ success: true, auditLog });
});
/**
* Unmark a user as an admin
*
* @param :sub User ID
*/
app.delete("/user/:sub/admin", async (req, res) => {
let user: User;
try {
user = await User.fromSub(req.params.sub);
} catch (e) {
if (e instanceof UserNotFound) {
res.status(404).json({ success: false, error: "User not found" });
} else {
res.status(500).json({ success: false, error: "Internal error" });
}
return;
}
await prisma.user.update({
where: { sub: user.sub },
data: {
isAdmin: false,
},
});
await user.update(true);
const adminUser = (await User.fromAuthSession(req.session.user!))!;
const auditLog = await AuditLog.Factory(adminUser.sub)
.doing("USER_UNADMIN")
.reason(req.header("X-Audit") || null)
.withComment(`Removed ${user.sub} as admin`)
.create();
res.json({ success: true, auditLog });
});
app.get("/instance/:domain/ban", async (req, res) => {
// get ban information
let instance: Instance;
try {
instance = await Instance.fromDomain(req.params.domain);
} catch (e) {
if (e instanceof InstanceNotFound) {
res.status(404).json({ success: false, error: "instance not found" });
} else {
Logger.error(
`/instance/${req.params.domain}/ban Error ` + (e as any)?.message
);
res.status(500).json({ success: false, error: "Internal error" });
}
return;
}
const ban = await instance.getEffectiveBan();
if (!ban) {
res.status(404).json({ success: false, error: "Instance not banned" });
return;
}
res.json({ success: true, ban });
});
/**
* Create or update a ban for an instance (and subdomains)
*
* @header X-Audit
* @param :domain Domain for the instance
* @body expiresAt string! ISO date time string
* @body publicNote string?
* @body privateNote string?
*/
app.put("/instance/:domain/ban", async (req, res) => {
// ban domain & subdomains
let instance: Instance;
let expires: Date;
let publicNote: string | null | undefined;
let privateNote: string | null | undefined;
try {
instance = await Instance.fromDomain(req.params.domain);
} catch (e) {
if (e instanceof InstanceNotFound) {
res.status(404).json({ success: false, error: "instance not found" });
} else {
Logger.error(
`/instance/${req.params.domain}/ban Error ` + (e as any)?.message
);
res.status(500).json({ success: false, error: "Internal error" });
}
return;
}
if (typeof req.body.expiresAt !== "string") {
res
.status(400)
.json({ success: false, error: "expiresAt is not a string" });
return;
}
// temporary: see #153
// eslint-disable-next-line prefer-const
expires = new Date(req.body.expiresAt);
if (!isFinite(expires.getTime())) {
res
.status(400)
.json({ success: false, error: "expiresAt is not a valid date" });
return;
}
if (typeof req.body.publicNote !== "undefined") {
if (
typeof req.body.publicNote !== "string" &&
req.body.privateNote !== null
) {
res.status(400).json({
success: false,
error: "publicNote is set and is not a string",
});
return;
}
publicNote = req.body.publicNote;
}
if (typeof req.body.privateNote !== "undefined") {
if (
typeof req.body.privateNote !== "string" &&
req.body.privateNote !== null
) {
res.status(400).json({
success: false,
error: "privateNote is set and is not a string",
});
return;
}
privateNote = req.body.privateNote;
}
const hasExistingBan = await instance.getBan();
const user = (await User.fromAuthSession(req.session.user!))!;
const ban = await instance.ban(expires, publicNote, privateNote);
const auditLog = await AuditLog.Factory(user.sub)
.doing(hasExistingBan ? "BAN_UPDATE" : "BAN_CREATE")
.reason(req.header("X-Audit") || null)
.withComment(
hasExistingBan
? `Updated ban for ${instance.hostname}`
: `Created a ban for ${instance.hostname}`
)
.withBan(ban)
.create();
res.json({
success: true,
ban,
auditLog,
});
});
/**
* Delete an instance ban
*
* @header X-Audit
* @param :domain The instance domain
*/
app.delete("/instance/:domain/ban", async (req, res) => {
// unban domain & subdomains
let instance: Instance;
try {
instance = await Instance.fromDomain(req.params.domain);
} catch (e) {
if (e instanceof InstanceNotFound) {
res.status(404).json({ success: false, error: "instance not found" });
} else {
Logger.error(
`/instance/${req.params.domain}/ban Error ` + (e as any)?.message
);
res.status(500).json({ success: false, error: "Internal error" });
}
return;
}
try {
await instance.unban();
} catch (e) {
if (e instanceof InstanceNotBanned) {
res.status(404).json({ success: false, error: "instance not banned" });
} else {
Logger.error(
`/instance/${req.params.domain}/ban Error ` + (e as any)?.message
);
res.status(500).json({ success: false, error: "Internal error" });
}
return;
}
const user = (await User.fromAuthSession(req.session.user!))!;
const auditLog = await AuditLog.Factory(user.sub)
.doing("BAN_DELETE")
.reason(req.header("X-Audit") || null)
.withComment(`Deleted ban for ${instance.hostname}`)
.create();
res.json({ success: true, auditLog });
});
/**
* Get all audit logs
*
* TODO: pagination
*/
app.get("/audit", async (req, res) => {
const auditLogs = await prisma.auditLog.findMany({
orderBy: {
createdAt: "desc",
},
});
res.json({ success: true, auditLogs });
});
/**
* Get audit log entry by ID
*
* @param :id Audit log ID
*/
app.get("/audit/:id", async (req, res) => {
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(400).json({ success: false, error: "id is not a number" });
return;
}
const auditLog = await prisma.auditLog.findFirst({ where: { id } });
if (!auditLog) {
res.status(404).json({ success: false, error: "Audit log not found" });
return;
}
res.json({ success: true, auditLog });
});
/**
* Update audit log reason
*
* @param :id Audit log id
* @body reason string|null
*/
app.put("/audit/:id/reason", async (req, res) => {
const id = parseInt(req.params.id);
let reason: string;
if (isNaN(id)) {
res.status(400).json({ success: false, error: "id is not a number" });
return;
}
if (typeof req.body.reason !== "string" && req.body.reason !== null) {
res
.status(400)
.json({ success: false, error: "reason is not a string or null" });
return;
}
// temporary: see #153
// eslint-disable-next-line prefer-const
reason = req.body.reason;
const auditLog = await prisma.auditLog.findFirst({
where: {
id,
},
});
if (!auditLog) {
res.status(404).json({ success: false, error: "audit log is not found" });
return;
}
const newAudit = await prisma.auditLog.update({
where: { id },
data: {
reason,
updatedAt: new Date(),
},
});
res.json({
success: true,
auditLog: newAudit,
});
});
export default app;
import * as Sentry from "@sentry/node";
import { Router } from "express";
import { ResponseBodyError } from "openid-client";
import Canvas from "../lib/Canvas";
import { getLogger } from "../lib/Logger";
import { OpenID } from "../lib/oidc";
import { prisma } from "../lib/prisma";
import { RateLimiter } from "../lib/RateLimiter";
import { Instance } from "../models/Instance";
import SentryRouter from "./sentry";
const Logger = getLogger("HTTP/CLIENT");
const ClientParams = {
TYPE: "auth_type",
ERROR: "auth_error",
ERROR_DESC: "auth_error_desc",
CAN_RETRY: "auth_retry",
};
const buildQuery = (obj: { [k in keyof typeof ClientParams]?: string }) => {
const params = new URLSearchParams();
for (const [k, v] of Object.entries(obj)) {
const k_: keyof typeof ClientParams = k as any;
params.set(ClientParams[k_], v);
}
return "?" + params.toString();
};
const app = Router();
// register sentry tunnel
// if the sentry environment variables aren't set, the loaded router will be empty
app.use(SentryRouter);
/**
* Redirect to actual authorization page
*/
app.get("/login", (req, res) => {
if (process.env.INHIBIT_LOGINS) {
res.status(400).json({
success: false,
error: "Login is not permitted.",
});
return;
}
res.redirect(OpenID.getAuthorizationURL());
});
app.get("/logout", (req, res) => {
res.send(
`<form method="post"><input type="submit" value="Confirm Logout" /></form>`
);
});
app.post("/logout", (req, res) => {
req.session.destroy(() => {
res.redirect("/");
});
});
/**
* Process token exchange from openid server
*
* This executes multiple database queries and should be ratelimited
*/
app.get("/callback", RateLimiter.HIGH, async (req, res) => {
if (process.env.INHIBIT_LOGINS) {
res.status(400).json({
success: false,
error: "Login is not permitted.",
});
return;
}
let exchange: Awaited<ReturnType<typeof OpenID.exchangeToken>>;
try {
exchange = await OpenID.exchangeToken(req.originalUrl);
} catch (e) {
Sentry.captureException(e);
Logger.error(e);
if (e instanceof ResponseBodyError) {
switch (e.error) {
case "invalid_client":
// this happens when we're configured with invalid credentials
Logger.error(
"OpenID is improperly configured. Cannot exchange tokens, do I have valid credentials?"
);
res.redirect(
"/" +
buildQuery({
TYPE: "op",
ERROR: "Internal Server Error",
ERROR_DESC: "I'm misconfigured.",
})
);
return;
case "invalid_grant":
res.redirect(
"/" +
buildQuery({
TYPE: "op",
ERROR: "invalid_grant",
CAN_RETRY: "true",
})
);
return;
}
}
res.redirect(
"/" +
buildQuery({
TYPE: "op",
ERROR: "unknown error",
ERROR_DESC: "report this",
})
);
return;
}
try {
const whoami = await OpenID.userInfo<{
instance: {
software: {
name: string;
version: string;
logo_uri?: string;
repository?: string;
homepage?: string;
};
instance: {
logo_uri?: string;
banner_uri?: string;
name?: string;
};
};
}>(exchange.access_token, exchange.claims()!.sub);
const [username, hostname] = whoami.sub.split("@");
const instance = await Instance.fromAuth(
hostname,
whoami.instance.instance
);
const instanceBan = await instance.getEffectiveBan();
if (instanceBan) {
res.redirect(
"/" +
buildQuery({
TYPE: "banned",
ERROR_DESC: instanceBan.publicNote || undefined,
})
);
return;
}
const sub = [username, hostname].join("@");
await prisma.user.upsert({
where: {
sub,
},
update: {
sub,
display_name: whoami.name,
picture_url: whoami.picture,
profile_url: whoami.profile,
},
create: {
sub,
display_name: whoami.name,
picture_url: whoami.picture,
profile_url: whoami.profile,
},
});
req.session.user = {
service: {
...whoami.instance,
instance: {
...whoami.instance.instance,
hostname,
},
},
user: {
picture_url: whoami.picture,
username,
},
};
req.session.save();
res.redirect("/");
} catch (e) {
Sentry.captureException(e);
Logger.error("callback error", e);
res
.status(500)
.json({ success: false, error: "internal error, try again" });
}
});
app.get("/canvas/pixel/:x/:y", RateLimiter.HIGH, async (req, res) => {
const x = parseInt(req.params.x);
const y = parseInt(req.params.y);
if (isNaN(x) || isNaN(y)) {
res.status(400).json({ success: false, error: "x or y is not a number" });
return;
}
const pixel = await Canvas.getPixel(x, y);
if (!pixel) {
res.json({ success: false, error: "no_pixel" });
return;
}
const otherPixels = await prisma.pixel.count({ where: { x, y } });
const user = await prisma.user.findFirst({ where: { sub: pixel.userId } });
const instance = await prisma.instance.findFirst({
where: { hostname: pixel.userId.split("@")[1] },
});
res.json({
success: true,
pixel,
otherPixels: otherPixels - 1,
user: user && {
sub: user.sub,
display_name: user.display_name,
picture_url: user.picture_url,
profile_url: user.profile_url,
isAdmin: user.isAdmin,
isModerator: user.isModerator,
},
instance: instance && {
hostname: instance.hostname,
name: instance.name,
logo_url: instance.logo_url,
banner_url: instance.banner_url,
},
});
});
/**
* Get the heatmap
*
* This is cached, so no need to ratelimit this
* Even if the heatmap isn't ready, this doesn't cause the heatmap to get generated
*/
app.get("/heatmap", async (req, res) => {
const heatmap = await Canvas.getCachedHeatmap();
if (!heatmap) {
res.json({ success: false, error: "heatmap_not_generated" });
return;
}
res.json({ success: true, heatmap });
});
/**
* Get user information from the sub (grant@toast.ooo)
*
* This causes a database query, so ratelimit it
*/
app.get("/user/:sub", RateLimiter.HIGH, async (req, res) => {
const user = await prisma.user.findFirst({ where: { sub: req.params.sub } });
if (!user) {
res.status(404).json({ success: false, error: "unknown_user" });
return;
}
res.json({
success: true,
user: {
sub: user.sub,
display_name: user.display_name,
picture_url: user.picture_url,
profile_url: user.profile_url,
isAdmin: user.isAdmin,
isModerator: user.isModerator,
},
});
});
export default app;
import { Router } from "express";
/**
* Houses the Sentry tunnel
*/
const app = Router();
if (process.env.SENTRY_DSN && process.env.SENTRY_TUNNEL_PROJECT_IDS) {
// only register the endpoint if the environment variables are set
const SENTRY_HOST = new URL(process.env.SENTRY_DSN).hostname;
const SENTRY_PROJECT_IDS: string[] =
process.env.SENTRY_TUNNEL_PROJECT_IDS.split(",");
app.post("/_meta", async (req, res) => {
try {
// read POST data as raw text
const envelope = await new Promise<string>((res) => {
let data = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
data += chunk;
});
req.on("end", () => {
res(data);
});
});
// sentry sends data to the tunnel in 3 different parts, all JSON separated by newlines
// the following reads the header line and verifies the DSN provided matches the constants above
const piece = envelope.split("\n")[0];
const header = JSON.parse(piece);
const dsn = new URL(header["dsn"]);
const project_id = dsn.pathname?.replace("/", "");
if (dsn.hostname !== SENTRY_HOST) {
throw new Error(`Invalid sentry hostname: ${dsn.hostname}`);
}
if (!project_id || !SENTRY_PROJECT_IDS.includes(project_id)) {
throw new Error(`Invalid sentry project id: ${project_id}`);
}
// forward the data to sentry
const upstream_sentry_url = `https://${SENTRY_HOST}/api/${project_id}/envelope/`;
await fetch(upstream_sentry_url, {
method: "POST",
body: envelope,
});
res.json({});
} catch (e) {
// allow console.log due to this being an error handler preventing loops
// eslint-disable-next-line no-console
console.error("error tunneling to sentry", e);
res.status(500).json({ error: "error tunneling" });
}
});
}
export default app;
import * as child from "node:child_process";
export const SHORT_HASH = child
.execSync("git rev-parse --short HEAD")
.toString()
.trim();
export const LONG_HASH = child.execSync("git rev-parse HEAD").toString().trim();
import "./lib/sentry";
// load declare module
import "./types";
import { Redis } from "./lib/redis";
import { Logger } from "./lib/Logger";
import "./workers/worker";
import { ExpressServer } from "./lib/Express";
import { getLogger } from "./lib/Logger";
import { OpenID } from "./lib/oidc";
import { Redis } from "./lib/redis";
import { loadSettings } from "./lib/Settings";
import { SocketServer } from "./lib/SocketServer";
import { spawnCacheWorkers } from "./workers/worker";
const Logger = getLogger("MAIN");
// Validate environment variables
......@@ -25,6 +33,18 @@ if (!process.env.SESSION_SECRET) {
process.exit(1);
}
if (!process.env.NODE_APP_INSTANCE) {
Logger.warn(
"NODE_APP_INSTANCE is not defined, metrics will not include process label"
);
}
if (!process.env.PROMETHEUS_TOKEN) {
Logger.warn(
"PROMETHEUS_TOKEN is not defined, /metrics will not be accessable"
);
}
if (!process.env.REDIS_HOST) {
Logger.error("REDIS_HOST is not defined");
process.exit(1);
......@@ -36,22 +56,54 @@ if (!process.env.REDIS_SESSION_PREFIX) {
);
}
if (!process.env.AUTH_ENDPOINT) {
Logger.error("AUTH_ENDPOINT is not defined");
process.exit(1);
if (!process.env.REDIS_RATELIMIT_PREFIX) {
Logger.info(
"REDIS_RATELIMIT_PREFIX was not defined, defaulting to canvas_ratelimit:"
);
}
if (!process.env.AUTH_CLIENT) {
Logger.error("AUTH_CLIENT is not defined");
process.exit(1);
if (!process.env.INHIBIT_LOGIN) {
if (!process.env.AUTH_ENDPOINT) {
Logger.error("AUTH_ENDPOINT is not defined");
process.exit(1);
}
if (!process.env.AUTH_CLIENT) {
Logger.error("AUTH_CLIENT is not defined");
process.exit(1);
}
if (!process.env.AUTH_SECRET) {
Logger.error("AUTH_SECRET is not defined");
process.exit(1);
}
if (!process.env.OIDC_CALLBACK_HOST) {
Logger.error("OIDC_CALLBACK_HOST is not defined");
process.exit(1);
}
}
if (!process.env.AUTH_SECRET) {
Logger.error("AUTH_SECRET is not defined");
process.exit(1);
if (!process.env.PIXEL_LOG_PATH) {
Logger.warn("PIXEL_LOG_PATH is not defined, defaulting to packages/server");
}
if (!process.env.CACHE_WORKERS) {
Logger.warn("CACHE_WORKERS is not defined, defaulting to 1 worker");
}
Redis.connect();
// run startup tasks, all of these need to be completed to serve
Promise.all([
Redis.getClient(),
OpenID.setup().then(() => {
Logger.info("Setup OpenID");
}),
spawnCacheWorkers(),
loadSettings(),
]).then(() => {
Logger.info("Startup tasks have completed, starting server");
Logger.warn("Make sure the jobs process is running");
const express = new ExpressServer();
new SocketServer(express.httpServer);
const express = new ExpressServer();
new SocketServer(express.httpServer);
});
import Canvas from "../lib/Canvas";
import { getLogger } from "../lib/Logger";
const Logger = getLogger("JOB_WORKER");
/**
* Job scheduler
*
* This should run in a different process
*/
export class Jobs {
constructor() {
Logger.info("Starting job worker...");
// every 5 minutes
setInterval(this.generateHeatmap, 1000 * 60 * 5);
this.generateHeatmap();
}
async generateHeatmap() {
Logger.info("Generating heatmap...");
const now = Date.now();
await Canvas.generateHeatmap();
Logger.info(
"Generated heatmap in " +
((Date.now() - now) / 1000).toFixed(1) +
" seconds"
);
}
}
import { CanvasConfig } from "@sc07-canvas/lib/src/net";
import { Pixel } from "@prisma/client";
import {
CanvasConfig,
ClientToServerEvents,
ServerToClientEvents,
} from "@sc07-canvas/lib/src/net";
import { Socket } from "socket.io";
import { callCacheWorker, getCacheWorkerIdForCoords } from "../workers/worker";
import { getLogger } from "./Logger";
import { LogMan } from "./LogMan";
import { prisma } from "./prisma";
import { Redis } from "./redis";
import { SocketServer } from "./SocketServer";
const Logger = getLogger("CANVAS");
const canvasSectionSize = [100, 100]; // TODO: config maybe?
class Canvas {
private CANVAS_SIZE: [number, number];
/**
* Size of the canvas
*/
private canvasSize: [width: number, height: number];
private isFrozen: boolean;
constructor() {
this.CANVAS_SIZE = [100, 100];
this.canvasSize = [100, 100];
this.isFrozen = false;
}
getCanvasConfig(): CanvasConfig {
return {
size: this.CANVAS_SIZE,
size: this.canvasSize,
frozen: this.isFrozen,
zoom: 7,
pixel: {
cooldown: 10,
cooldown: 30,
multiplier: 3,
maxStack: 6,
},
undo: {
grace_period: 5000,
},
};
}
get frozen() {
return this.isFrozen;
}
async setFrozen(frozen: boolean) {
this.isFrozen = frozen;
await prisma.setting.upsert({
where: { key: "canvas.frozen" },
create: {
key: "canvas.frozen",
value: JSON.stringify(frozen),
},
update: {
key: "canvas.frozen",
value: JSON.stringify(frozen),
},
});
if (SocketServer.instance) {
SocketServer.instance.broadcastConfig();
} else {
Logger.warn(
"[Canvas#setFrozen] SocketServer is not instantiated, cannot broadcast config"
);
}
}
/**
* Latest database pixels -> Redis
* Change size of the canvas
*
* @param width
* @param height
*/
async pixelsToRedis() {
async setSize(width: number, height: number, useStatic = false) {
if (useStatic) {
this.canvasSize = [width, height];
return;
}
const now = Date.now();
Logger.info("[Canvas#setSize] has started", {
old: this.canvasSize,
new: [width, height],
});
this.canvasSize = [width, height];
await prisma.setting.upsert({
where: { key: "canvas.size" },
create: {
key: "canvas.size",
value: JSON.stringify({ width, height }),
},
update: {
key: "canvas.size",
value: JSON.stringify({ width, height }),
},
});
// update cached canvas chunks
await this.canvasToRedis();
// this gets called on startup, before the SocketServer is initialized
// so only call if it's available
if (SocketServer.instance) {
// announce the new config, which contains the canvas size
SocketServer.instance.broadcastConfig();
// announce all canvas chunks
SocketServer.instance.io.emit("clearCanvasChunks");
await this.broadcastCanvasChunks();
} else {
Logger.warn(
"[Canvas#setSize] No SocketServer instance, cannot broadcast config change"
);
}
Logger.info(
"[Canvas#setSize] has finished in " +
((Date.now() - now) / 1000).toFixed(1) +
" seconds"
);
}
async sendCanvasChunksToSocket(
socket: Socket<ClientToServerEvents, ServerToClientEvents>
) {
await this.getAllChunks((start, end, data) => {
socket.emit("canvas", start, end, data.split(","));
});
}
async broadcastCanvasChunks() {
await this.getAllChunks((start, end, data) => {
SocketServer.instance.io.emit("canvas", start, end, data.split(","));
});
}
async getAllChunks(
chunkCallback: (
start: [x: number, y: number],
end: [x: number, y: number],
data: string
) => any
) {
const redis = await Redis.getClient();
const pending: Promise<void>[] = [];
for (let x = 0; x < this.canvasSize[0]; x += canvasSectionSize[0]) {
for (let y = 0; y < this.canvasSize[1]; y += canvasSectionSize[1]) {
const start: [number, number] = [x, y];
const end: [number, number] = [
x + canvasSectionSize[0],
y + canvasSectionSize[1],
];
const key = Redis.keyRef("pixelColor");
for (let x = 0; x < this.CANVAS_SIZE[0]; x++) {
for (let y = 0; y < this.CANVAS_SIZE[1]; y++) {
const pixel = await prisma.pixel.findFirst({
where: {
x,
y,
},
orderBy: [
{
createdAt: "asc",
pending.push(
new Promise((res) => {
redis
.get(Redis.key("canvas_section", start, end))
.then((value): any => {
chunkCallback(start, end, value || "");
res();
});
})
);
}
}
await Promise.allSettled(pending);
}
async forceUpdatePixelIsTop() {
const now = Date.now();
Logger.info("[Canvas#forceUpdatePixelIsTop] is starting...");
for (let x = 0; x < this.canvasSize[0]; x++) {
for (let y = 0; y < this.canvasSize[1]; y++) {
const pixel = (
await prisma.pixel.findMany({
where: { x, y, deletedAt: null },
orderBy: {
createdAt: "desc",
},
],
});
take: 1,
})
)?.[0];
await redis.set(key(x, y), pixel?.color || "transparent");
if (pixel) {
await prisma.pixel.update({
where: {
id: pixel.id,
},
data: {
isTop: true,
},
});
}
}
}
Logger.info(
"[Canvas#forceUpdatePixelIsTop] has finished in " +
((Date.now() - now) / 1000).toFixed(1) +
" seconds"
);
}
/**
* Redis pixels -> single Redis comma separated list of hex
* @returns 1D array of pixel values
* Undo a pixel
* @throws Error "Pixel is not on top"
* @param pixel
* @returns the pixel that now exists at that location
*/
async canvasToRedis() {
const redis = await Redis.getClient();
async undoPixel(pixel: Pixel): Promise<Pixel | undefined> {
if (!pixel.isTop) throw new Error("Pixel is not on top");
const pixels: string[] = [];
await prisma.pixel.update({
where: { id: pixel.id },
data: {
deletedAt: new Date(),
isTop: false,
},
});
// (y -> x) because of how the conversion needs to be done later
// if this is inverted, the map will flip when rebuilding the cache (5 minute expiry)
// fixes #24
for (let y = 0; y < this.CANVAS_SIZE[1]; y++) {
for (let x = 0; x < this.CANVAS_SIZE[0]; x++) {
pixels.push(
(await redis.get(Redis.key("pixelColor", x, y))) || "transparent"
const coveringPixel: Pixel | undefined = (
await prisma.pixel.findMany({
where: {
x: pixel.x,
y: pixel.y,
createdAt: { lt: pixel.createdAt },
deletedAt: null, // undone pixels will have this set
},
orderBy: { createdAt: "desc" },
take: 1,
})
)?.[0];
if (coveringPixel) {
await prisma.pixel.update({
where: { id: coveringPixel.id },
data: {
isTop: true,
},
});
}
LogMan.log("pixel_undo", pixel.userId, {
x: pixel.x,
y: pixel.y,
hex: coveringPixel?.color,
});
return coveringPixel;
}
/**
* Chunks canvas pixels and caches chunks in redis
*
* @worker
* @returns
*/
async canvasToRedis(): Promise<void> {
const start = Date.now();
const pending: Promise<any>[] = [];
for (let x = 0; x < this.canvasSize[0]; x += canvasSectionSize[0]) {
for (let y = 0; y < this.canvasSize[1]; y += canvasSectionSize[1]) {
pending.push(
callCacheWorker("cache", {
start: [x, y],
end: [x + canvasSectionSize[0], y + canvasSectionSize[1]],
})
);
}
}
await redis.set(Redis.key("canvas"), pixels.join(","), { EX: 60 * 5 });
return pixels;
await Promise.allSettled(pending);
Logger.info(
`Finished canvasToRedis() in ${((Date.now() - start) / 1000).toFixed(2)}s`
);
}
/**
......@@ -78,30 +293,170 @@ class Canvas {
*/
async updateCanvasRedisAtPos(x: number, y: number) {
const redis = await Redis.getClient();
const dbpixel = await this.getPixel(x, y);
const pixels: string[] = (
(await redis.get(Redis.key("canvas"))) || ""
).split(",");
// ensure pixels in the same location are always in the same queue
const workerId = getCacheWorkerIdForCoords(x, y);
pixels[this.CANVAS_SIZE[0] * y + x] =
(await redis.get(Redis.key("pixelColor", x, y))) || "transparent";
await redis.set(Redis.key("canvas"), pixels.join(","), { EX: 60 * 5 });
// queue canvas writes in redis to avoid memory issues in badly written queue code
redis.lPush(
Redis.key("canvas_cache_write_queue", workerId),
x + "," + y + "," + (dbpixel?.color || "transparent")
);
}
async getPixelsArray() {
async updateCanvasRedisWithBatch(
pixelBatch: { x: number; y: number; hex: string }[]
) {
const redis = await Redis.getClient();
if (await redis.exists(Redis.key("canvas"))) {
const cached = await redis.get(Redis.key("canvas"));
return cached!.split(",");
for (const pixel of pixelBatch) {
// ensure pixels in the same location are always in the same queue
const workerId = getCacheWorkerIdForCoords(pixel.x, pixel.y);
// queue canvas writes in redis
redis.lPush(
Redis.key("canvas_cache_write_queue", workerId),
[pixel.x, pixel.y, pixel.hex].join(",")
);
}
}
return await this.canvasToRedis();
/**
* Get if a pixel is maybe empty
* @param x
* @param y
* @returns
*/
async isPixelEmpty(x: number, y: number) {
const pixel = await this.getPixel(x, y);
return pixel === null;
}
async setPixel(user: { sub: string }, x: number, y: number, hex: string) {
const redis = await Redis.getClient();
async getPixel(x: number, y: number) {
return await prisma.pixel.findFirst({
where: {
x,
y,
isTop: true,
},
});
}
/**
* Undo an area of pixels
* @param start
* @param end
* @returns
*/
async undoArea(start: [x: number, y: number], end: [x: number, y: number]) {
const now = Date.now();
Logger.info("Starting undo area...");
const pixels = await prisma.pixel.findMany({
where: {
x: {
gte: start[0],
lt: end[0],
},
y: {
gte: start[1],
lt: end[1],
},
isTop: true,
},
});
const returns = await Promise.allSettled(
pixels.map((pixel) => this.undoPixel(pixel))
);
await this.canvasToRedis();
Logger.info(
"Finished undo area in " + ((Date.now() - now) / 1000).toFixed(2) + "s"
);
return returns.map((val, i) => {
const pixel = pixels[i];
return {
pixel: { x: pixel.x, y: pixel.y },
...val,
};
});
}
async fillArea(
user: { sub: string },
start: [x: number, y: number],
end: [x: number, y: number],
hex: string
) {
await prisma.pixel.updateMany({
where: {
x: {
gte: start[0],
lt: end[0],
},
y: {
gte: start[1],
lt: end[1],
},
isTop: true,
},
data: {
isTop: false,
},
});
const pixels: {
x: number;
y: number;
}[] = [];
for (let x = start[0]; x <= end[0]; x++) {
for (let y = start[1]; y <= end[1]; y++) {
pixels.push({
x,
y,
});
}
}
await prisma.pixel.createMany({
data: pixels.map((px) => ({
userId: user.sub,
color: hex,
isTop: true,
isModAction: true,
...px,
})),
});
await this.updateCanvasRedisWithBatch(
pixels.map((px) => ({
...px,
hex,
}))
);
LogMan.log("mod_fill", user.sub, { from: start, to: end, hex });
}
async setPixel(
user: { sub: string },
x: number,
y: number,
hex: string,
isModAction: boolean
) {
// only one pixel can be on top at (x,y)
await prisma.pixel.updateMany({
where: { x, y, isTop: true },
data: {
isTop: false,
},
});
await prisma.pixel.create({
data: {
......@@ -109,19 +464,119 @@ class Canvas {
color: hex,
x,
y,
isTop: true,
isModAction,
},
});
await prisma.user.update({
where: { sub: user.sub },
data: { lastPixelTime: new Date() },
// maybe only update specific element?
// i don't think it needs to be awaited
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,
});
}
await redis.set(Redis.key("pixelColor", x, y), hex);
/**
* Force a pixel to be updated in redis
* @param x
* @param y
*/
async refreshPixel(x: number, y: number) {
// find if any pixels exist at this spot, and pick the most recent one
const pixel = await this.getPixel(x, y);
let paletteColorID = -1;
// if pixel exists in redis
if (pixel) {
paletteColorID = (await prisma.paletteColor.findFirst({
where: { hex: pixel.color },
}))!.id;
}
// maybe only update specific element?
// i don't think it needs to be awaited
await this.updateCanvasRedisAtPos(x, y);
// announce to everyone the pixel's color
// using -1 if no pixel is there anymore
SocketServer.instance.io.emit("pixel", {
x,
y,
color: paletteColorID,
});
}
/**
* Generate heatmap of active pixels
*
* @note expensive operation, takes a bit to execute
* @returns 2 character strings with 0-100 in radix 36 (depends on canvas size)
*/
async generateHeatmap() {
const redis_set = await Redis.getClient("MAIN");
const now = Date.now();
const minimumDate = new Date();
minimumDate.setHours(minimumDate.getHours() - 3); // 3 hours ago
const pad = (str: string) => (str.length < 2 ? "0" : "") + str;
const heatmap: string[] = [];
const topPixels = await prisma.pixel.findMany({
where: { isTop: true, createdAt: { gte: minimumDate } },
});
for (let y = 0; y < this.canvasSize[1]; y++) {
const arr: number[] = [];
for (let x = 0; x < this.canvasSize[0]; x++) {
const pixel = topPixels.find((px) => px.x === x && px.y === y);
if (pixel) {
arr.push(
((1 -
(now - pixel.createdAt.getTime()) /
(now - minimumDate.getTime())) *
100) >>
0
);
} else {
arr.push(0);
}
}
heatmap.push(arr.map((num) => pad(num.toString(36))).join(""));
}
const heatmapStr = heatmap.join("");
// cache for 5 minutes
await redis_set.setEx(Redis.key("heatmap"), 60 * 5, heatmapStr);
// notify anyone interested about the new heatmap
await redis_set.publish(Redis.key("channel_heatmap"), heatmapStr);
// SocketServer.instance.io.to("sub:heatmap").emit("heatmap", heatmapStr);
return heatmapStr;
}
/**
* Get cache heatmap safely
* @returns see Canvas#generateHeatmap
*/
async getCachedHeatmap(): Promise<string | undefined> {
const redis = await Redis.getClient();
if (!(await redis.exists(Redis.key("heatmap")))) {
Logger.warn("Canvas#getCachedHeatmap has no cached heatmap");
return undefined;
}
return (await redis.get(Redis.key("heatmap"))) as string;
}
}
......
import http from "node:http";
import path from "node:path";
import * as Sentry from "@sentry/node";
import bodyParser from "body-parser";
import { RedisStore } from "connect-redis";
import cors from "cors";
import express, { type Express } from "express";
import expressSession from "express-session";
import RedisStore from "connect-redis";
import APIRoutes_admin from "../api/admin";
import APIRoutes_client from "../api/client";
import { getLogger } from "./Logger";
import { handleMetricsEndpoint } from "./Prometheus";
import { Redis } from "./redis";
import APIRoutes from "../api";
import { Logger } from "./Logger";
const Logger = getLogger("HTTP");
export const session = expressSession({
secret: process.env.SESSION_SECRET,
......@@ -17,11 +26,7 @@ export const session = expressSession({
}),
cookie: {
httpOnly: false,
...(process.env.NODE_ENV === "development"
? { sameSite: "none" }
: {
secure: true,
}),
secure: process.env.NODE_ENV === "production",
},
});
......@@ -44,7 +49,11 @@ export class ExpressServer {
"Serving client UI at / using root " +
path.join(__dirname, process.env.SERVE_CLIENT)
);
const indexFile = path.join(process.env.SERVE_CLIENT, "index.html");
this.app.use(express.static(process.env.SERVE_CLIENT));
this.app.use("/chat_callback", (req, res) => {
res.sendFile(indexFile);
});
} else {
this.app.get("/", (req, res) => {
res.status(404).contentType("html").send(`
......@@ -66,23 +75,37 @@ export class ExpressServer {
// client is needing to serve
Logger.info(
"Serving admin UI at /admin using root " +
path.join(__dirname, process.env.SERVE_ADMIN)
);
const assetsDir = path.join(__dirname, process.env.SERVE_ADMIN, "assets");
const indexFile = path.join(
__dirname,
process.env.SERVE_ADMIN,
"index.html"
path.join(process.env.SERVE_ADMIN)
);
const assetsDir = path.join(process.env.SERVE_ADMIN, "assets");
const indexFile = path.join(process.env.SERVE_ADMIN, "index.html");
this.app.use("/admin/assets", express.static(assetsDir));
this.app.use("/admin/*", (req, res) => {
this.app.use("/admin*", (req, res) => {
res.sendFile(indexFile);
});
}
if (process.env.NODE_ENV === "development") {
this.app.use(
cors({
origin: [process.env.CLIENT_ORIGIN!, process.env.ADMIN_ORIGIN!],
credentials: true,
})
);
}
this.app.use(session);
this.app.use("/api", APIRoutes);
this.app.use(bodyParser.json());
this.app.use("/api", APIRoutes_client);
this.app.use("/api/admin", APIRoutes_admin);
this.app.use("/metrics", handleMetricsEndpoint);
// register sentry error handler
// must be last registered endpoint to capture all previous errors
// if sentry is never initialized, this is effectively a no-op
// @see lib/sentry.ts
Sentry.setupExpressErrorHandler(this.app);
this.httpServer.listen(parseInt(process.env.PORT), () => {
Logger.info("Listening on :" + process.env.PORT);
......
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: object;
canvas_unfreeze: object;
}
/**
* 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<EventName extends keyof SystemEvents>(
event: EventName,
data: SystemEvents[EventName]
): void;
log<EventName extends keyof UserEvents>(
event: EventName,
user: string,
data: UserEvents[EventName]
): void;
log<EventName extends keyof UserEvents | keyof SystemEvents>(
event: EventName,
...params: EventName extends keyof UserEvents
? [user: string, data: UserEvents[EventName]]
: EventName extends keyof SystemEvents
? [data: SystemEvents[EventName]]
: never
): void {
const parts: string[] = [];
if (params.length === 2) {
// user event
const user = params[0] as string;
parts.push(user, event);
if (event === "mod_fill") {
// this event format has a different line format
const data: UserEvents["mod_fill"] = params[1] as any;
parts.push(data.from.join(","), data.to.join(","), data.hex);
} else {
const data: UserEvents[Exclude<keyof UserEvents, "mod_fill">] =
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": {
const data: SystemEvents["canvas_size"] = params[0] as any;
const { width, height } = data;
parts.push(width + "", height + "");
break;
}
}
}
PixelLogger.info(parts.join("\t"));
}
}
export const LogMan = new LogMan_();
import path from "node:path";
import winston, { format } from "winston";
export const Logger = winston.createLogger({
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)) {
maxModuleWidth = Math.max(maxModuleWidth, `[${module}]`.length);
}
let moduleName = options.moduleName;
if (typeof options.workerId !== "undefined") {
moduleName += " #" + options.workerId;
}
const modulePadding = " ".repeat(
Math.max(0, maxModuleWidth - `[${moduleName}]`.length)
);
const parts: string[] = [
options.timestamp + ` [${moduleName || "---"}]` + modulePadding,
options.level + ":",
options.message + "",
];
return parts.join("\t");
});
const Winston = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: format.combine(format.splat(), format.cli()),
format: format.combine(format.timestamp(), formatter),
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",
"CANVAS",
"HTTP",
"HTTP/ADMIN",
"HTTP/CLIENT",
"REDIS",
"SOCKET",
"JOB_WORKER",
"CANVAS_WORK",
"WORKER_ROOT",
"RECAPTCHA",
]);
export const getLogger = (
module?: keyof typeof LoggerType,
workerId?: number
) => Winston.child({ moduleName: module, workerId });