reorganize server & fix various linting errors

This commit is contained in:
Grant 2024-03-03 17:36:57 -07:00
parent a4b3adaace
commit c3b8467b8f
17 changed files with 976 additions and 437 deletions

8
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"eslint.workingDirectories": [
"packages/server",
"packages/client",
"packages/admin",
"packages/lib"
]
}

706
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
dist

View File

@ -22,6 +22,7 @@
"@fortawesome/react-fontawesome": "^0.2.0",
"@nextui-org/react": "^2.2.9",
"@sc07-canvas/lib": "^1.0.0",
"@typescript-eslint/parser": "^7.1.0",
"eventemitter3": "^5.0.1",
"framer-motion": "^11.0.5",
"lodash.throttle": "^4.1.1",

View File

@ -1,11 +1,11 @@
import { Socket, io } from "socket.io-client";
import EventEmitter from "eventemitter3";
import {
AuthSession,
ClientConfig,
ClientToServerEvents,
ServerToClientEvents,
} from "../types";
import EventEmitter from "eventemitter3";
import { AuthSession } from "@sc07-canvas/lib/src/net";
} from "@sc07-canvas/lib/src/net";
export interface INetworkEvents {
user: (user: AuthSession) => void;

View File

@ -1,67 +0,0 @@
import { AuthSession, PacketAck } from "@sc07-canvas/lib/src/net";
// socket.io
export interface ServerToClientEvents {
canvas: (pixels: string[]) => void;
user: (user: AuthSession) => void;
config: (config: ClientConfig) => void;
pixel: (pixel: Pixel) => void;
}
export interface ClientToServerEvents {
place: (pixel: Pixel, ack: (_: PacketAck<Pixel>) => void) => void;
}
// app context
export interface IAppContext {
config: ClientConfig;
user?: AuthSession;
canvasPosition?: ICanvasPosition;
setCanvasPosition: (v: ICanvasPosition) => void;
cursorPosition?: IPosition;
setCursorPosition: (v?: IPosition) => void;
}
export interface IPalleteContext {
color?: number;
}
export interface ICanvasPosition {
x: number;
y: number;
zoom: number;
}
export interface IPosition {
x: number;
y: number;
}
// other
export type Pixel = {
x: number;
y: number;
color: number;
};
export type PalleteColor = {
id: number;
name: string;
hex: string;
};
export type CanvasConfig = {
size: [number, number];
zoom: number;
};
export type ClientConfig = {
pallete: {
colors: PalleteColor[];
pixel_cooldown: number;
};
canvas: CanvasConfig;
};

View File

@ -1,42 +1,71 @@
export type CPixelPacket = ClientPacket & {
type: "place";
// socket.io
export interface ServerToClientEvents {
canvas: (pixels: string[]) => void;
user: (user: AuthSession) => void;
config: (config: ClientConfig) => void;
pixel: (pixel: Pixel) => void;
online: (count: { count: number }) => void;
}
export interface ClientToServerEvents {
place: (pixel: Pixel, ack: (_: PacketAck<Pixel>) => void) => void;
}
// app context
export interface IAppContext {
config: ClientConfig;
user?: AuthSession;
canvasPosition?: ICanvasPosition;
setCanvasPosition: (v: ICanvasPosition) => void;
cursorPosition?: IPosition;
setCursorPosition: (v?: IPosition) => void;
}
export interface IPalleteContext {
color?: number;
}
export interface ICanvasPosition {
x: number;
y: number;
zoom: number;
}
export interface IPosition {
x: number;
y: number;
}
// other
export type Pixel = {
x: number;
y: number;
color: number;
};
export type SCanvasPacket = ServerPacket & {
type: "canvas";
pixels: string[];
export type PalleteColor = {
id: number;
name: string;
hex: string;
};
export type SPixelPacket = ServerPacket & {
type: "pixel";
x: number;
y: number;
color: number;
export type CanvasConfig = {
size: [number, number];
zoom: number;
};
export type SUserPacket = ServerPacket & {
type: "user";
user: AuthSession;
export type ClientConfig = {
pallete: {
colors: PalleteColor[];
pixel_cooldown: number;
};
canvas: CanvasConfig;
};
export type Packet = {
type: string;
};
// server -> client
export type ServerPacket = Packet & {
_direction: "server->client";
};
// client -> server
export type ClientPacket = Packet & {
_direction: "client->server";
};
export type PacketAck<T = ServerPacket> =
export type PacketAck<T> =
| {
success: true;
data: T;

View File

@ -0,0 +1,28 @@
{
"env": {
"es2021": true,
"node": true
},
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"overrides": [
{
"env": {
"node": true
},
"files": [".eslintrc.{js,cjs}"],
"parserOptions": {
"sourceType": "script"
}
}
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"rules": {
"no-console": "error",
"@typescript-eslint/no-namespace": "off"
}
}

View File

@ -3,7 +3,8 @@
"version": "1.0.0",
"main": "./build/index.js",
"scripts": {
"dev": "DOTENV_CONFIG_PATH=.env.local nodemon -r dotenv/config src/index.ts"
"dev": "DOTENV_CONFIG_PATH=.env.local nodemon -r dotenv/config src/index.ts",
"lint": "eslint ."
},
"keywords": [],
"author": "",
@ -12,7 +13,10 @@
"@tsconfig/recommended": "^1.0.2",
"@types/express": "^4.17.17",
"@types/express-session": "^1.17.7",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"dotenv": "^16.3.1",
"eslint": "^8.57.0",
"nodemon": "^3.0.1",
"prettier": "^3.0.1",
"prisma": "^5.3.1",
@ -27,6 +31,7 @@
"express": "^4.18.2",
"express-session": "^1.17.3",
"redis": "^4.6.12",
"socket.io": "^4.7.2"
"socket.io": "^4.7.2",
"winston": "^3.11.0"
}
}

View File

@ -7,6 +7,10 @@ const AUTH_ENDPOINT = "https://auth.fediverse.events";
const AUTH_CLIENT = "canvas";
const AUTH_SECRET = "secret";
app.get("/me", (req, res) => {
res.json(req.session);
});
app.get("/login", (req, res) => {
const params = new URLSearchParams();
params.set("service", "canvas");

View File

@ -1,192 +1,42 @@
import express from "express";
import expressSession from "express-session";
import http from "node:http";
import { Server } from "socket.io";
import {
AuthSession,
CPixelPacket,
PacketAck,
SCanvasPacket,
SPixelPacket,
SUserPacket,
} from "@sc07-canvas/lib/src/net";
import APIRoutes from "./api";
// load declare module
import "./types";
import { prisma } from "./lib/prisma";
import { PalleteColor, PrismaClient } from "@prisma/client";
import { getRedis, client as redisClient } from "./lib/redis";
import Canvas from "./lib/Canvas";
import RedisStore from "connect-redis";
import { Redis } from "./lib/redis";
import { Logger } from "./lib/Logger";
import { ExpressServer } from "./lib/Express";
import { SocketServer } from "./lib/SocketServer";
// Validate environment variables
if (!process.env.PORT || isNaN(parseInt(process.env.PORT))) {
console.log("PORT env is not a valid number");
Logger.error("PORT env is not a valid number");
process.exit(1);
}
getRedis().then(() => {
console.log("redis connected");
});
const session = expressSession({
secret: "jagoprhaupihuaciohruearp8349jud",
resave: false,
saveUninitialized: false,
store: new RedisStore({
client: redisClient,
prefix: "canvas_session:",
}),
cookie: {
sameSite: "none",
httpOnly: false,
},
});
const app = express();
const server = http.createServer(app);
const io = new Server<
{
place: (
data: CPixelPacket,
callback: (data: PacketAck<SPixelPacket>) => {}
) => void;
},
{
user: (user: AuthSession) => void;
config: (config: any) => void;
pixel: (data: SPixelPacket) => void;
canvas: (pixels: string[]) => void;
online: (data: { count: number }) => void;
}
>(server, {
cors: {
origin: "http://10.1.10.248:5173",
credentials: true,
},
});
var PALLETE: PalleteColor[] = [];
const PIXEL_TIMEOUT_MS = 1000;
prisma.palleteColor
.findMany()
.then((palleteColors) => {
PALLETE = palleteColors;
console.log(`Loaded ${palleteColors.length} pallete colors`);
})
.catch(console.error);
// on startup, cache current top-level pixels on redis
// notify all other shards with pixel updates via redis
// cacheCanvasToRedis().then(() => {
// console.log("canvas is now in redis");
// });
setInterval(async () => {
const sockets = await io.sockets.fetchSockets();
for (const socket of sockets) {
socket.emit("online", { count: sockets.length });
}
}, 5000);
io.engine.use(session);
io.on("connection", (socket) => {
const user = socket.request.session.user
? {
sub:
socket.request.session.user.user.username +
"@" +
socket.request.session.user.service.instance.hostname,
...socket.request.session.user,
}
: undefined;
console.log("connection", socket.request.session.user);
if (socket.request.session.user)
socket.emit("user", socket.request.session.user);
socket.emit("config", {
pallete: {
colors: PALLETE,
pixel_cooldown: PIXEL_TIMEOUT_MS,
},
canvas: Canvas.getCanvasConfig(),
});
Canvas.getPixelsArray().then((pixels) => {
socket.emit("canvas", pixels);
});
socket.on(
"place",
async (
{ x, y, color }: CPixelPacket,
ack: (data: PacketAck<SPixelPacket>) => {}
) => {
if (!user) {
ack({
success: false,
error: "no_user",
});
return;
}
const puser = await prisma.user.findFirst({ where: { sub: user.sub } });
if (puser?.lastPixelTime) {
if (
puser.lastPixelTime.getTime() + PIXEL_TIMEOUT_MS >
new Date().getTime()
!process.env.NODE_ENV ||
["development", "production"].indexOf(process.env.NODE_ENV) === -1
) {
ack({
success: false,
error: "pixel_cooldown",
});
return;
}
Logger.error("NODE_ENV is not valid [development, production]");
process.exit(1);
}
const palleteColor = await prisma.palleteColor.findFirst({
where: {
id: color,
},
});
if (!palleteColor) {
ack({
success: false,
error: "pallete_color_invalid",
});
return;
if (!process.env.SESSION_SECRET) {
Logger.error("SESSION_SECRET is not defined");
process.exit(1);
}
await Canvas.setPixel(user, x, y, palleteColor.hex);
ack({
success: true,
data: {
type: "pixel",
_direction: "server->client",
x,
y,
color,
},
});
socket.broadcast.emit("pixel", {
x,
y,
color,
type: "pixel",
_direction: "server->client",
});
if (!process.env.REDIS_HOST) {
Logger.error("REDIS_HOST is not defined");
process.exit(1);
}
if (!process.env.REDIS_SESSION_PREFIX) {
Logger.info(
"REDIS_SESSION_PREFIX was not defined, defaulting to canvas_session:"
);
});
}
app.use(session);
app.use(express.static("../client-next/public"));
app.use("/api", APIRoutes);
Redis.connect();
server.listen(parseInt(process.env.PORT!), () => {
console.log("Listening on " + process.env.PORT);
});
const express = new ExpressServer();
new SocketServer(express.httpServer);

View File

@ -1,5 +1,5 @@
import { prisma } from "./prisma";
import { getRedis } from "./redis";
import { Redis } from "./redis";
const redis_keys = {
pixelColor: (x: number, y: number) => `CANVAS:PIXELS[${x},${y}]:COLOR`,
@ -24,7 +24,7 @@ class Canvas {
* Latest database pixels -> Redis
*/
async pixelsToRedis() {
const redis = await getRedis();
const redis = await Redis.getClient();
const key = redis_keys.pixelColor;
@ -52,9 +52,9 @@ class Canvas {
* @returns 1D array of pixel values
*/
async canvasToRedis() {
const redis = await getRedis();
const redis = await Redis.getClient();
let pixels: string[] = [];
const pixels: string[] = [];
for (let x = 0; x < this.CANVAS_SIZE[0]; x++) {
for (let y = 0; y < this.CANVAS_SIZE[1]; y++) {
@ -73,11 +73,11 @@ class Canvas {
* force an update at a specific position
*/
async updateCanvasRedisAtPos(x: number, y: number) {
const redis = await getRedis();
const redis = await Redis.getClient();
let pixels: string[] = ((await redis.get(redis_keys.canvas())) || "").split(
","
);
const pixels: string[] = (
(await redis.get(redis_keys.canvas())) || ""
).split(",");
pixels[this.CANVAS_SIZE[0] * y + x] =
(await redis.get(redis_keys.pixelColor(x, y))) || "transparent";
@ -86,7 +86,7 @@ class Canvas {
}
async getPixelsArray() {
const redis = await getRedis();
const redis = await Redis.getClient();
if (await redis.exists(redis_keys.canvas())) {
const cached = await redis.get(redis_keys.canvas());
@ -97,7 +97,7 @@ class Canvas {
}
async setPixel(user: { sub: string }, x: number, y: number, hex: string) {
const redis = await getRedis();
const redis = await Redis.getClient();
await prisma.pixel.create({
data: {

View File

@ -0,0 +1,38 @@
import http from "node:http";
import express, { type Express } from "express";
import expressSession from "express-session";
import RedisStore from "connect-redis";
import { Redis } from "./redis";
import APIRoutes from "../api";
import { Logger } from "./Logger";
export const session = expressSession({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: new RedisStore({
client: Redis.client,
prefix: process.env.REDIS_SESSION_PREFIX || "canvas_session:",
}),
cookie: {
sameSite: "none",
httpOnly: false,
},
});
export class ExpressServer {
app: Express;
httpServer: http.Server;
constructor() {
this.app = express();
this.httpServer = http.createServer(this.app);
this.app.use(session);
this.app.use("/api", APIRoutes);
this.httpServer.listen(parseInt(process.env.PORT), () => {
Logger.info("Listening on :" + process.env.PORT);
});
}
}

View File

@ -0,0 +1,7 @@
import winston, { format } from "winston";
export const Logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: format.combine(format.splat(), format.cli()),
transports: [new winston.transports.Console()],
});

View File

@ -0,0 +1,165 @@
import http from "node:http";
import {
ClientConfig,
ClientToServerEvents,
Pixel,
ServerToClientEvents,
} from "@sc07-canvas/lib/src/net";
import { Server, Socket as RawSocket } from "socket.io";
import { session } from "./Express";
import Canvas from "./Canvas";
import { PalleteColor } from "@prisma/client";
import { prisma } from "./prisma";
import { Logger } from "./Logger";
/**
* get socket.io server config, generated from environment vars
*/
const getSocketConfig = () => {
// origins that should be permitted
// origins need to be specifically defined if we want to allow CORS credential usage (cookies)
const origins: string[] = [];
if (process.env.CLIENT_ORIGIN) {
origins.push(process.env.CLIENT_ORIGIN);
}
if (origins.length === 0) {
return undefined;
}
return {
cors: {
origin: origins,
credentials: true,
},
};
};
// this is terrible, another way to get the client config needs to be found
let PALLETE: PalleteColor[] = [];
const PIXEL_TIMEOUT_MS = 1000;
prisma.palleteColor
.findMany()
.then((palleteColors) => {
PALLETE = palleteColors;
Logger.info(`Loaded ${palleteColors.length} pallete colors`);
})
.catch((e) => {
Logger.error("Failed to get pallete colors", e);
});
const getClientConfig = (): ClientConfig => {
return {
pallete: {
colors: PALLETE,
pixel_cooldown: PIXEL_TIMEOUT_MS,
},
canvas: Canvas.getCanvasConfig(),
};
};
type Socket = RawSocket<ClientToServerEvents, ServerToClientEvents>;
export class SocketServer {
io: Server<ClientToServerEvents, ServerToClientEvents>;
constructor(server: http.Server) {
this.io = new Server(server, getSocketConfig());
this.setupOnlineTick();
this.io.engine.use(session);
this.io.on("connection", this.handleConnection.bind(this));
}
handleConnection(socket: Socket) {
const clientConfig = getClientConfig();
const user = this.getUserFromSocket(socket);
Logger.debug("Socket connection " + (user ? "@" + user.sub : "No Auth"));
if (socket.request.session.user) {
// inform the client of their session if it exists
socket.emit("user", socket.request.session.user);
}
socket.emit("config", getClientConfig());
Canvas.getPixelsArray().then((pixels) => {
socket.emit("canvas", pixels);
});
socket.on("place", async (pixel, ack) => {
if (!user) {
ack({ success: false, error: "no_user" });
return;
}
const puser = await prisma.user.findFirst({ where: { sub: user.sub } });
if (puser?.lastPixelTime) {
if (
puser.lastPixelTime.getTime() + clientConfig.pallete.pixel_cooldown >
Date.now()
) {
ack({
success: false,
error: "pixel_cooldown",
});
return;
}
}
const palleteColor = await prisma.palleteColor.findFirst({
where: {
id: pixel.color,
},
});
if (!palleteColor) {
ack({
success: false,
error: "pallete_color_invalid",
});
return;
}
await Canvas.setPixel(user, pixel.x, pixel.y, palleteColor.hex);
const newPixel: Pixel = {
x: pixel.x,
y: pixel.y,
color: pixel.color,
};
ack({
success: true,
data: newPixel,
});
socket.broadcast.emit("pixel", newPixel);
});
}
getUserFromSocket(socket: Socket) {
return socket.request.session.user
? {
sub:
socket.request.session.user.user.username +
"@" +
socket.request.session.user.service.instance.hostname,
...socket.request.session.user,
}
: undefined;
}
/**
* setup the online people announcement
*
* this does work with multiple socket.io instances, so this needs to only be executed by one shard
*/
setupOnlineTick() {
setInterval(async () => {
const sockets = await this.io.sockets.fetchSockets();
for (const socket of sockets) {
socket.emit("online", { count: sockets.length });
}
}, 5000);
}
}

View File

@ -1,14 +1,34 @@
import { RedisClientType } from "@redis/client";
import { createClient } from "redis";
import { Logger } from "./Logger";
let isConnected = false;
export const client = createClient();
class _Redis {
isConnected = false;
client: RedisClientType;
export const getRedis = async () => {
if (!isConnected) {
await client.connect();
isConnected = true;
constructor() {
this.client = createClient({
url: process.env.REDIS_HOST,
});
}
return client;
};
async connect() {
if (this.isConnected)
throw new Error("Attempted to run Redis#connect when already connected");
await this.client.connect();
Logger.info("Connected to Redis");
this.isConnected = true;
}
async getClient() {
if (!this.isConnected) {
await this.connect();
this.isConnected = true;
}
return this.client;
}
}
export const Redis = new _Redis();

View File

@ -1,6 +1,4 @@
import type { IncomingMessage } from "http";
import type { Session, SessionData } from "express-session";
import type { Socket } from "socket.io";
import type { Session } from "express-session";
import session from "express-session";
import { AuthSession } from "@sc07-canvas/lib/src/net";
@ -16,3 +14,23 @@ declare module "http" {
session: Session & Partial<session.SessionData>;
}
}
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production";
PORT: string;
LOG_LEVEL?: string;
SESSION_SECRET: string;
REDIS_HOST: string;
REDIS_SESSION_PREFIX: string;
/**
* If this is set, enable socket.io CORS to this origin
*
* Specifically setting CORS origin is required because of use of credentials (cookies)
*/
CLIENT_ORIGIN?: string;
}
}
}