You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
219 lines
6.6 KiB
219 lines
6.6 KiB
// canvas-io-server.js
|
|
// Copyright (C) 2022 Rob Colbert @[email protected]
|
|
// License: Apache-2.0
|
|
|
|
'use strict';
|
|
|
|
import path, { dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line
|
|
|
|
import fs from 'fs';
|
|
|
|
import { Server } from 'socket.io';
|
|
import { createAdapter } from '@socket.io/redis-adapter';
|
|
|
|
import { RateLimiterRedis } from 'rate-limiter-flexible';
|
|
|
|
import Jimp from 'jimp';
|
|
import { CronJob } from 'cron';
|
|
|
|
class CanvasImage {
|
|
|
|
get dirty ( ) { return this._dirty; }
|
|
|
|
constructor (id, width, height) {
|
|
this.id = id;
|
|
this.image = new Jimp(width, height, 0xffffffff);
|
|
this._dirty = false;
|
|
}
|
|
|
|
setPixel (pixel) {
|
|
let idx = ((pixel.y * this.image.bitmap.width) + pixel.x) * 4;
|
|
this.image.bitmap.data[idx++] = pixel.r;
|
|
this.image.bitmap.data[idx++] = pixel.g;
|
|
this.image.bitmap.data[idx++] = pixel.b;
|
|
this._dirty = true;
|
|
}
|
|
|
|
async save (filename) {
|
|
await this.image.deflateLevel(9).writeAsync(filename);
|
|
this._dirty = false;
|
|
}
|
|
}
|
|
|
|
export default class CanvasIoServer {
|
|
|
|
constructor (app) {
|
|
this.app = app;
|
|
|
|
this.limiters = {
|
|
getImageData: new RateLimiterRedis({ storeClient: this.app.redis, points: 10, duration: 60 }),
|
|
setPixel: new RateLimiterRedis({ storeClient: this.app.redis, points: 10, duration: 60 }),
|
|
};
|
|
|
|
this.images = { };
|
|
|
|
console.log('creating test image', { width: 32, height: 32 });
|
|
this.images['test-image'] = new CanvasImage('test-image', 32, 32);
|
|
this.images['test-image'].setPixel({ x: 5, y: 5, r: 255, g: 0, b: 0 });
|
|
}
|
|
|
|
async start ( ) {
|
|
const pubClient = this.app.redis.duplicate();
|
|
pubClient.on('error', this.onRedisError.bind(this));
|
|
|
|
const subClient = this.app.redis.duplicate();
|
|
subClient.on('error', this.onRedisError.bind(this));
|
|
|
|
const transports = ['websocket'/*, 'polling'*/];
|
|
const adapter = createAdapter(pubClient, subClient);
|
|
this.io = new Server(this.app.httpServer, { adapter, transports });
|
|
this.io.on('connection', this.onSocketConnect.bind(this));
|
|
|
|
console.log('starting image backup cron');
|
|
this.backupCron = new CronJob(
|
|
'*/15 * * * * *',
|
|
this.onImageBackup.bind(this),
|
|
null,
|
|
true,
|
|
'America/New_York',
|
|
);
|
|
|
|
console.log('CanvasIoServer ready');
|
|
}
|
|
|
|
async onRedisError (error) {
|
|
console.log('Redis error', error);
|
|
}
|
|
|
|
async onSocketConnect (socket) {
|
|
const CONNECT_TOKEN = socket.handshake.auth.token;
|
|
console.log('socket connection', { sid: socket.id, token: CONNECT_TOKEN });
|
|
|
|
const TOKEN_KEY = `connect-token:${CONNECT_TOKEN}`;
|
|
|
|
const tokenData = await this.app.redis.get(TOKEN_KEY);
|
|
if (!tokenData) {
|
|
console.log('rejecting invalid socket token', {
|
|
sid: socket.sid,
|
|
handshake: socket.handshake,
|
|
});
|
|
socket.close();
|
|
return;
|
|
}
|
|
|
|
const token = JSON.parse(tokenData);
|
|
const IMAGE_ID = token.imageId;
|
|
|
|
/*
|
|
* Remove the connect token to prevent multiple connects
|
|
*/
|
|
await this.app.redis.del(TOKEN_KEY);
|
|
|
|
const session = { CONNECT_TOKEN, IMAGE_ID, socket };
|
|
|
|
session.onSocketDisconnect = this.onSocketDisconnect.bind(this, session);
|
|
session.onJoinChannel = this.onJoinChannel.bind(this, session);
|
|
session.onLeaveChannel = this.onLeaveChannel.bind(this, session);
|
|
|
|
session.onGetImageData = this.onGetImageData.bind(this, session);
|
|
session.onSetPixel = this.onSetPixel.bind(this, session);
|
|
|
|
socket.on('disconnect', session.onSocketDisconnect);
|
|
socket.on('join', session.onJoinChannel);
|
|
socket.on('leave', session.onLeaveChannel);
|
|
|
|
socket.on('getimagedata', session.onGetImageData);
|
|
socket.on('setpixel', session.onSetPixel);
|
|
|
|
socket.emit('authenticated', { message: 'token verified' });
|
|
}
|
|
|
|
async onSocketDisconnect (session, reason) {
|
|
const { CONNECT_TOKEN, IMAGE_ID, socket } = session;
|
|
console.log('socket disconnect', { sid: socket.id, CONNECT_TOKEN, IMAGE_ID, reason });
|
|
session.socket.off('disconnect', session.onSocketDisconnect);
|
|
session.socket.off('join', session.onJoinChannel);
|
|
session.socket.off('leave', session.onLeaveChannel);
|
|
}
|
|
|
|
async onJoinChannel (session, message) {
|
|
const { channelId } = message;
|
|
console.log('socket joins channel', { sid: session.socket.id, token: session.CONNECT_TOKEN, channelId });
|
|
session.socket.join(channelId);
|
|
session.socket.emit('join-result', { channelId });
|
|
}
|
|
|
|
async onLeaveChannel (session, message) {
|
|
const { channelId } = message;
|
|
console.log('socket leaves channel', { sid: session.socket.id, token: session.CONNECT_TOKEN, channelId });
|
|
session.socket.leave(channelId);
|
|
}
|
|
|
|
async onGetImageData (session) {
|
|
const { CONNECT_TOKEN, IMAGE_ID, socket } = session;
|
|
console.log('onGetImageData', { sid: session.socket.id, CONNECT_TOKEN, IMAGE_ID });
|
|
|
|
try {
|
|
const limiterKey = `${IMAGE_ID}:${CONNECT_TOKEN}`;
|
|
await this.limiters.getImageData.consume(limiterKey, 1);
|
|
} catch (error) {
|
|
console.log('onGetImageData rate limit exceeded', { CONNECT_TOKEN, IMAGE_ID });
|
|
socket.emit('canvas-error', { command: 'getimagedata', message: error.message });
|
|
return;
|
|
}
|
|
|
|
const image = this.images[IMAGE_ID];
|
|
if (!image) {
|
|
socket.emit('canvas-error', { message: 'image not found' });
|
|
return;
|
|
}
|
|
|
|
session.socket.emit('canvas-image-data', {
|
|
width: image.image.bitmap.width,
|
|
height: image.image.bitmap.height,
|
|
data: Buffer.from(image.image.bitmap.data),
|
|
});
|
|
}
|
|
|
|
async onSetPixel (session, message) {
|
|
const { CONNECT_TOKEN, IMAGE_ID, socket } = session;
|
|
try {
|
|
const limiterKey = `${IMAGE_ID}:${CONNECT_TOKEN}`;
|
|
await this.limiters.setPixel.consume(limiterKey, 1);
|
|
} catch (error) {
|
|
console.log('failed to setpixel', error);
|
|
socket.emit('canvas-error', { command: 'setpixel', error: JSON.stringify(error) });
|
|
return;
|
|
}
|
|
|
|
const image = this.images[IMAGE_ID];
|
|
if (!image) {
|
|
socket.emit('canvas-error', { command: 'setpixel', message: 'Image does not exist' });
|
|
return;
|
|
}
|
|
|
|
console.log('onSetPixel', IMAGE_ID, message);
|
|
image.setPixel(message);
|
|
this.io.in(IMAGE_ID).emit('canvas-setpixel', {
|
|
x: message.x,
|
|
y: message.y,
|
|
r: message.r,
|
|
g: message.g,
|
|
b: message.b,
|
|
});
|
|
}
|
|
|
|
async onImageBackup ( ) {
|
|
for (const imageId in this.images) {
|
|
const image = this.images[imageId];
|
|
|
|
const imageIdPrefix = image.id.slice(0, 4);
|
|
const imagePath = path.join(__dirname, 'images', imageIdPrefix);
|
|
|
|
await fs.promises.mkdir(imagePath, { recursive: true });
|
|
await image.save(path.join(imagePath, `${image.id}.png`));
|
|
}
|
|
}
|
|
}
|