Compare commits

...

2 commits

Author SHA1 Message Date
2de60a98a3 reformat with prettier 2024-07-14 20:33:47 -04:00
1d4a2673b0 add image upload from clipboard 2024-07-14 20:33:41 -04:00
6 changed files with 110 additions and 104 deletions

View file

@ -181,8 +181,8 @@ export function wordballoonDrawText(ctx: CanvasRenderingContext2D, at: Point, te
export function wordballoonDrawImage(ctx: CanvasRenderingContext2D, at: Point, img: HTMLImageElement, hasTip: boolean = true): Rect { export function wordballoonDrawImage(ctx: CanvasRenderingContext2D, at: Point, img: HTMLImageElement, hasTip: boolean = true): Rect {
// Round the image size up to the nearest 12x12, plus 12px padding. // Round the image size up to the nearest 12x12, plus 12px padding.
let size = { let size = {
w: (Math.ceil(img.width / 12) * 12) + 6, w: Math.ceil(img.width / 12) * 12 + 6,
h: (Math.ceil(img.height / 12) * 12) + 6 h: Math.ceil(img.height / 12) * 12 + 6
}; };
// Draw the word balloon and get the inner rect // Draw the word balloon and get the inner rect
@ -195,6 +195,6 @@ export function wordballoonDrawImage(ctx: CanvasRenderingContext2D, at: Point, i
x: at.x, x: at.x,
y: at.y, y: at.y,
w: rectInner.w + 12 * 3 + 12, w: rectInner.w + 12 * 3 + 12,
h: rectInner.h + 13 * 3 + 18, h: rectInner.h + 13 * 3 + 18
}; };
} }

View file

@ -62,6 +62,6 @@ export interface DiscordConfig {
} }
export interface ImagesConfig { export interface ImagesConfig {
maxSize: { width: number, height: number }; maxSize: { width: number; height: number };
expirySeconds: number; expirySeconds: number;
} }

View file

@ -1,116 +1,111 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fileTypeFromBuffer } from "file-type"; import { fileTypeFromBuffer } from 'file-type';
import * as crypto from "crypto"; import * as crypto from 'crypto';
import Sharp from 'sharp'; import Sharp from 'sharp';
import { ImagesConfig } from "./config"; import { ImagesConfig } from './config';
export interface image { export interface image {
img: Buffer; img: Buffer;
mime: string; mime: string;
timeout: NodeJS.Timeout; timeout: NodeJS.Timeout;
} }
const allowedTypes = [ const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
];
function randomString(length: number): Promise<string> { function randomString(length: number): Promise<string> {
return new Promise((res, rej) => { return new Promise((res, rej) => {
let _len = length; let _len = length;
if (_len % 2 !== 0) _len++; if (_len % 2 !== 0) _len++;
crypto.randomBytes(_len / 2, (err, buf) => { crypto.randomBytes(_len / 2, (err, buf) => {
if (err) { if (err) {
rej(err); rej(err);
return; return;
} }
let out = buf.toString("hex"); let out = buf.toString('hex');
if (out.length !== length) out = out.substring(0, length); if (out.length !== length) out = out.substring(0, length);
res(out); res(out);
}); });
}); });
} }
export class ImageUploader { export class ImageUploader {
private images: Map<string, image> = new Map(); private images: Map<string, image> = new Map();
private config: ImagesConfig; private config: ImagesConfig;
constructor(app: FastifyInstance, config: ImagesConfig) { constructor(app: FastifyInstance, config: ImagesConfig) {
this.config = config; this.config = config;
for (let type of allowedTypes) { for (let type of allowedTypes) {
// i kinda hate this // i kinda hate this
app.addContentTypeParser(type, {parseAs: "buffer"}, (req, body, done) => done(null, body)); app.addContentTypeParser(type, { parseAs: 'buffer' }, (req, body, done) => done(null, body));
} }
app.put("/api/upload", async (req, res) => await this.handleRequest(req, res)); app.put('/api/upload', async (req, res) => await this.handleRequest(req, res));
app.get("/api/image/:id", (req, res) => this.handleGet(req, res)); app.get('/api/image/:id', (req, res) => this.handleGet(req, res));
} }
private handleGet(req: FastifyRequest, res: FastifyReply) { private handleGet(req: FastifyRequest, res: FastifyReply) {
let {id} = req.params as {id: string}; let { id } = req.params as { id: string };
let img = this.images.get(id); let img = this.images.get(id);
if (!img) { if (!img) {
res.status(404); res.status(404);
return { success: false, error: "Image not found" }; return { success: false, error: 'Image not found' };
} }
res.header("Content-Type", img.mime); res.header('Content-Type', img.mime);
return img.img; return img.img;
} }
private async handleRequest(req: FastifyRequest, res: FastifyReply) { private async handleRequest(req: FastifyRequest, res: FastifyReply) {
let contentType; let contentType;
if ((contentType = req.headers["content-type"]) === undefined || !allowedTypes.includes(contentType)) { if ((contentType = req.headers['content-type']) === undefined || !allowedTypes.includes(contentType)) {
res.status(400); res.status(400);
return { success: false, error: "Invalid Content-Type" }; return { success: false, error: 'Invalid Content-Type' };
} }
let data = req.body as Buffer; let data = req.body as Buffer;
// Check MIME // Check MIME
let mime = await fileTypeFromBuffer(data); let mime = await fileTypeFromBuffer(data);
if (mime?.mime !== contentType) { if (mime?.mime !== contentType) {
res.status(400); res.status(400);
return { success: false, error: "Image is corrupt" }; return { success: false, error: 'Image is corrupt' };
} }
// Parse and resize if necessary // Parse and resize if necessary
let sharp, meta; let sharp, meta;
try { try {
sharp = Sharp(data); sharp = Sharp(data);
meta = await sharp.metadata(); meta = await sharp.metadata();
} catch { } catch {
res.status(400); res.status(400);
return { success: false, error: "Image is corrupt" }; return { success: false, error: 'Image is corrupt' };
} }
if (!meta.width || !meta.height) { if (!meta.width || !meta.height) {
res.status(400); res.status(400);
return { success: false, error: "Image is corrupt" }; return { success: false, error: 'Image is corrupt' };
} }
if (meta.width > this.config.maxSize.width || meta.height > this.config.maxSize.height) { if (meta.width > this.config.maxSize.width || meta.height > this.config.maxSize.height) {
sharp.resize(this.config.maxSize.width, this.config.maxSize.height, { fit: "inside" }); sharp.resize(this.config.maxSize.width, this.config.maxSize.height, { fit: 'inside' });
} }
let outputImg = await sharp.toBuffer(); let outputImg = await sharp.toBuffer();
// Add to the map // Add to the map
let id; let id;
do { do {
id = await randomString(16); id = await randomString(16);
} while (this.images.has(id)); } while (this.images.has(id));
let timeout = setTimeout(() => { let timeout = setTimeout(() => {
this.images.delete(id); this.images.delete(id);
}, this.config.expirySeconds * 1000); }, this.config.expirySeconds * 1000);
this.images.set(id, { img: outputImg, mime: mime.mime, timeout }); this.images.set(id, { img: outputImg, mime: mime.mime, timeout });
return { success: true, id }; return { success: true, id };
} }
has(id: string) { has(id: string) {
return this.images.has(id); return this.images.has(id);
} }
} }

View file

@ -43,7 +43,7 @@ const app = Fastify({
app.register(FastifyCors, { app.register(FastifyCors, {
origin: config.http.origins, origin: config.http.origins,
methods: ['GET', 'POST', 'PUT'], methods: ['GET', 'POST', 'PUT']
}); });
app.register(FastifyWS); app.register(FastifyWS);

View file

@ -114,7 +114,7 @@ export class MSAgentChatRoom {
for (const _client of this.getActiveClients()) { for (const _client of this.getActiveClients()) {
_client.send(msg); _client.send(msg);
} }
}) });
client.on('admin', () => { client.on('admin', () => {
let msg: MSAgentPromoteMessage = { let msg: MSAgentPromoteMessage = {
op: MSAgentProtocolMessageType.Promote, op: MSAgentProtocolMessageType.Promote,

View file

@ -129,19 +129,30 @@ elements.imageUploadBtn.addEventListener('click', () => {
imgUploadInput.click(); imgUploadInput.click();
}); });
document.addEventListener('paste', (e) => {
if (e.clipboardData?.files.length === 0) return;
let file = e.clipboardData!.files[0];
if (!file.type.startsWith('image/')) return;
if (!window.confirm(`Upload ${file.name} (${Math.round(file.size / 1000)}KB)?`)) return;
uploadFile(file);
});
imgUploadInput.addEventListener('change', async () => { imgUploadInput.addEventListener('change', async () => {
if (!imgUploadInput.files || imgUploadInput.files.length === 0) return; if (!imgUploadInput.files || imgUploadInput.files.length === 0) return;
let file = imgUploadInput.files[0]; uploadFile(imgUploadInput.files[0]);
});
function uploadFile(file: File) {
let reader = new FileReader(); let reader = new FileReader();
reader.onload = async () => { reader.onload = async () => {
let buffer = reader.result as ArrayBuffer; let buffer = reader.result as ArrayBuffer;
await Room?.sendImage(buffer, file.type); await Room?.sendImage(buffer, file.type);
}; };
reader.readAsArrayBuffer(file); reader.readAsArrayBuffer(file);
}); }
let w = window as any; let w = window as any;
w.agentchat = { w.agentchat = {
getRoom: () => Room, getRoom: () => Room
} };