From b4cbe20378a321ce2ec60e6f09d46407a840f56f Mon Sep 17 00:00:00 2001 From: Mercy <19710443+MercurialPony@users.noreply.github.com> Date: Sun, 29 Sep 2024 22:51:50 +0300 Subject: [PATCH] Complete rewrite --- .env | 4 - canvas.io.js | 168 ++ canvas.js | 347 ++--- canvas.stats.js | 97 ++ channel.tracker.js | 40 + config.json | 8 - data/config.json | 11 + discord.api.js | 237 +++ main.js | 437 +++--- middleware.js | 98 ++ misc/converter.js | 98 -- misc/timelapse.js | 102 -- package-lock.json | 1331 ---------------- package.json | 18 +- public/assets/images/arrow.svg | 3 + public/assets/images/checkmark.svg | 3 + public/assets/images/cog.svg | 3 + public/assets/images/cursor.svg | 3 + public/{ => assets}/images/dance.gif | Bin public/assets/images/discord.svg | 3 + public/assets/images/dots.svg | 3 + public/{ => assets}/images/github.svg | 0 public/assets/images/i.svg | 3 + public/assets/images/info1.svg | 1 + public/assets/images/info2.svg | 1 + public/assets/images/left-arrow.svg | 3 + public/assets/images/logout.svg | 6 + public/assets/images/magnifier.svg | 3 + public/assets/images/mouse.svg | 4 + public/assets/images/selector.png | Bin 0 -> 308 bytes public/assets/images/stats.svg | 3 + public/assets/images/wrench.svg | 3 + public/assets/images/x.svg | 1 + public/assets/shaders/col.fsh | 13 + public/assets/shaders/col.vsh | 17 + public/assets/shaders/tex.fsh | 13 + public/assets/shaders/tex.vsh | 17 + public/{ => assets}/sounds/cancel.mp3 | Bin public/{ => assets}/sounds/click.mp3 | Bin public/{ => assets}/sounds/confetti.mp3 | Bin public/{ => assets}/sounds/error.mp3 | Bin public/{ => assets}/sounds/pick.mp3 | Bin public/{ => assets}/sounds/place.mp3 | Bin public/{ => assets}/sounds/refresh.mp3 | Bin public/{ => assets}/sounds/select.mp3 | Bin public/elements/color-picker.js | 199 +++ public/elements/x-tooltip.js | 96 ++ public/global.css | 132 ++ public/images/arrow.svg | 4 - public/images/checkmark.svg | 5 - public/images/discord.svg | 3 - public/images/dots.svg | 11 - public/images/selector.png | Bin 207 -> 0 bytes public/images/stats.svg | 6 - public/images/x.svg | 3 - public/index.html | 195 ++- public/lib/panzoom_modified.js | 1820 ---------------------- public/reset.css | 56 + public/script.js | 1011 ++++++------ public/scripts/animator.js | 49 + public/scripts/audio.mixer.js | 69 + public/scripts/gesture.tracker.js | 232 +++ public/scripts/keyboard.tracker.js | 176 +++ public/scripts/main.components.js | 126 ++ public/scripts/main.constants.js | 16 + public/scripts/math.matrix3.js | 194 +++ public/scripts/state.tracker.js | 71 + public/scripts/util.calc.js | 40 + public/scripts/util.js | 57 + public/scripts/webgl.renderer.js | 238 +++ public/scripts/webgl.util.js | 32 + public/scripts/webgl.wrapper.js | 216 +++ public/stats/index.html | 57 +- public/stats/script.js | 371 +---- public/stats/scripts/stats.components.js | 342 ++++ public/stats/scripts/stats.constants.js | 0 public/styles.css | 400 ++--- server.js | 13 - util.js | 21 + utils.js | 23 - 80 files changed, 4220 insertions(+), 5166 deletions(-) delete mode 100644 .env create mode 100644 canvas.io.js create mode 100644 canvas.stats.js create mode 100644 channel.tracker.js delete mode 100644 config.json create mode 100644 data/config.json create mode 100644 discord.api.js create mode 100644 middleware.js delete mode 100644 misc/converter.js delete mode 100644 misc/timelapse.js delete mode 100644 package-lock.json create mode 100644 public/assets/images/arrow.svg create mode 100644 public/assets/images/checkmark.svg create mode 100644 public/assets/images/cog.svg create mode 100644 public/assets/images/cursor.svg rename public/{ => assets}/images/dance.gif (100%) create mode 100644 public/assets/images/discord.svg create mode 100644 public/assets/images/dots.svg rename public/{ => assets}/images/github.svg (100%) create mode 100644 public/assets/images/i.svg create mode 100644 public/assets/images/info1.svg create mode 100644 public/assets/images/info2.svg create mode 100644 public/assets/images/left-arrow.svg create mode 100644 public/assets/images/logout.svg create mode 100644 public/assets/images/magnifier.svg create mode 100644 public/assets/images/mouse.svg create mode 100644 public/assets/images/selector.png create mode 100644 public/assets/images/stats.svg create mode 100644 public/assets/images/wrench.svg create mode 100644 public/assets/images/x.svg create mode 100644 public/assets/shaders/col.fsh create mode 100644 public/assets/shaders/col.vsh create mode 100644 public/assets/shaders/tex.fsh create mode 100644 public/assets/shaders/tex.vsh rename public/{ => assets}/sounds/cancel.mp3 (100%) rename public/{ => assets}/sounds/click.mp3 (100%) rename public/{ => assets}/sounds/confetti.mp3 (100%) rename public/{ => assets}/sounds/error.mp3 (100%) rename public/{ => assets}/sounds/pick.mp3 (100%) rename public/{ => assets}/sounds/place.mp3 (100%) rename public/{ => assets}/sounds/refresh.mp3 (100%) rename public/{ => assets}/sounds/select.mp3 (100%) create mode 100644 public/elements/color-picker.js create mode 100644 public/elements/x-tooltip.js create mode 100644 public/global.css delete mode 100644 public/images/arrow.svg delete mode 100644 public/images/checkmark.svg delete mode 100644 public/images/discord.svg delete mode 100644 public/images/dots.svg delete mode 100644 public/images/selector.png delete mode 100644 public/images/stats.svg delete mode 100644 public/images/x.svg delete mode 100644 public/lib/panzoom_modified.js create mode 100644 public/reset.css create mode 100644 public/scripts/animator.js create mode 100644 public/scripts/audio.mixer.js create mode 100644 public/scripts/gesture.tracker.js create mode 100644 public/scripts/keyboard.tracker.js create mode 100644 public/scripts/main.components.js create mode 100644 public/scripts/main.constants.js create mode 100644 public/scripts/math.matrix3.js create mode 100644 public/scripts/state.tracker.js create mode 100644 public/scripts/util.calc.js create mode 100644 public/scripts/util.js create mode 100644 public/scripts/webgl.renderer.js create mode 100644 public/scripts/webgl.util.js create mode 100644 public/scripts/webgl.wrapper.js create mode 100644 public/stats/scripts/stats.components.js create mode 100644 public/stats/scripts/stats.constants.js delete mode 100644 server.js create mode 100644 util.js delete mode 100644 utils.js diff --git a/.env b/.env deleted file mode 100644 index 4f237ba..0000000 --- a/.env +++ /dev/null @@ -1,4 +0,0 @@ -CLIENT_ID=YOUR_CLIENT_ID -CLIENT_SECRET=YOUR_CLIENT_SECRET -BOT_TOKEN=YOUR_BOT_TOKEN -SESSION_SECRET=YOUR_SESSION_SECRET diff --git a/canvas.io.js b/canvas.io.js new file mode 100644 index 0000000..8bebcab --- /dev/null +++ b/canvas.io.js @@ -0,0 +1,168 @@ +import FileSystem from "fs"; +import Path from "path"; +import { Event } from "./canvas.js"; + + + +class BufferSlicer +{ + constructor(buffer) + { + this._buffer = buffer; + + this._offset = 0; + } + + static from(buffer) + { + return buffer ? new BufferSlicer(buffer) : null; + } + + buffer() + { + return this._buffer; + } + + remaining() + { + return this._buffer.length - this._offset; + } + + next(bytes) + { + const slice = this._buffer.subarray(this._offset, this._offset + bytes); + this._offset += bytes; + return slice; + } +} + +export async function readEvents(canvas, path) +{ + const buf = BufferSlicer.from(await FileSystem.promises.readFile(path).catch(() => null)); + if (!buf) return; + + const version = buf.next(1).readUInt8(); + + while (buf.remaining() > 0) + { + const eventId = buf.next(1).readUint8(); + const timestamp = Number(buf.next(8).readBigUint64LE()); + const userId = buf.next(8).readBigUint64LE().toString(); + + if (eventId === Event.PLACE) + { + const x = buf.next(2).readInt16LE(); + const y = buf.next(2).readInt16LE(); + const color = buf.next(3).readUintLE(0, 3); + canvas.place(x, y, color, userId, timestamp); + } + else if (eventId === Event.EXPAND) + { + const nx = buf.next(2).readInt16LE(); + const ny = buf.next(2).readInt16LE(); + const px = buf.next(2).readInt16LE(); + const py = buf.next(2).readInt16LE(); + canvas.expand(nx, ny, px, py, userId, timestamp); + } + else if (eventId === Event.COLORS) + { + const count = buf.next(1).readUint8(); + const colors = Array(count).fill() + .map(() => buf.next(3).readUintLE(0, 3)); + canvas.setColors(colors, userId, timestamp); + } + else if (eventId === Event.COOLDOWN) + { + const cooldown = buf.next(2).readInt16LE(); + canvas.setCooldown(cooldown, userId, timestamp); + } + } +} + +const FILE_VERSION = 0; + +export async function startWritingEvents(canvas, path) +{ + const stat = await FileSystem.promises.stat(path).catch(() => null); + await FileSystem.promises.mkdir(Path.dirname(path), { recursive: true }); + const stream = FileSystem.createWriteStream(path, { flags: "a" }); + if (!stat) stream.write(Buffer.of(FILE_VERSION)); + + canvas.on("dispatch", event => + { + const header = BufferSlicer.from(Buffer.alloc(17)); + header.next(1).writeUint8(event.id); + header.next(8).writeBigUint64LE(BigInt(event.timestamp)); + header.next(8).writeBigUint64LE(BigInt(event.userId)); + + if (event.id === Event.PLACE) + { + const body = BufferSlicer.from(Buffer.alloc(7)); + body.next(2).writeInt16LE(event.x); + body.next(2).writeInt16LE(event.y); + body.next(3).writeUintLE(event.color, 0, 3); + stream.write(Buffer.concat([ header.buffer(), body.buffer() ])); + } + else if (event.id === Event.EXPAND) + { + const body = BufferSlicer.from(Buffer.alloc(8)); + body.next(2).writeInt16LE(event.nx); + body.next(2).writeInt16LE(event.ny); + body.next(2).writeInt16LE(event.px); + body.next(2).writeInt16LE(event.py); + stream.write(Buffer.concat([ header.buffer(), body.buffer() ])); + } + else if (event.id === Event.COLORS) + { + const body = BufferSlicer.from(Buffer.alloc(1 + event.colors.length * 3)); + body.next(1).writeUint8(event.colors.length); + for (const color of event.colors) body.next(3).writeUintLE(color, 0, 3); + stream.write(Buffer.concat([ header.buffer(), body.buffer() ])); + } + else if (event.id === Event.COOLDOWN) + { + const body = BufferSlicer.from(Buffer.alloc(2)); + body.next(2).writeInt16LE(event.cooldown); + stream.write(Buffer.concat([ header.buffer(), body.buffer() ])); + } + }); +} + +export async function readStats(stats, path) +{ + const statFile = await FileSystem.promises.readFile(path, { encoding: "utf-8" }) + .then(s => JSON.parse(s)) + .catch(() => null); + + if (statFile?.totalUserCountOverTime) + { + stats.totalUserCountOverTime = statFile.totalUserCountOverTime; + stats.userCountOverTime = statFile.totalUserCountOverTime; + } + + if (statFile?.mostConcurrentUsers) + { + stats.mostConcurrentUsers = statFile.mostConcurrentUsers; + } +} + +export function writeStats(stats, path) +{ + FileSystem.mkdirSync(Path.dirname(path), { recursive: true }); + FileSystem.writeFileSync(path, JSON.stringify({ + totalUserCountOverTime: stats.totalUserCountOverTime, + mostConcurrentUsers: stats.mostConcurrentUsers + }), { encoding: "utf-8" }); +} + +export function gracefulShutdown(...handlers) // TODO: On unhandled error as well +{ + const handler = () => + { + for (const handler of handlers) handler(); + process.exit(0); + }; + + process.on("SIGINT", handler); + process.on("SIGTERM", handler); +} \ No newline at end of file diff --git a/canvas.js b/canvas.js index e92a59f..ef26f69 100644 --- a/canvas.js +++ b/canvas.js @@ -1,14 +1,9 @@ -const FileSystem = require("fs"); -const SmartBuffer = require("smart-buffer").SmartBuffer; -const EventEmitter = require("events"); +import EventEmitter from "events"; +import { LazyMap } from "./util.js"; -/* - * =============================== -*/ - -class ImageBuffer +class RawImage { constructor(sizeX, sizeY) { @@ -18,320 +13,172 @@ class ImageBuffer this.data = Buffer.alloc(sizeX * sizeY * 4, 255); } - calculateOffset(x, y) + getOffset(x, y) { return (x + y * this.sizeX) * 4; } getColor(x, y) { - return this.data.readUintBE(this.calculateOffset(x, y), 3); + return this.data.readUintBE(this.getOffset(x, y), 3); } setColor(x, y, color) { - this.data.writeUIntBE(color, this.calculateOffset(x, y), 3); - } -} - - - - -class UserDataStore -{ - constructor(defaultUserData) - { - this._defaultUserData = defaultUserData; - this._map = new Map(); + this.data.writeUIntBE(color, this.getOffset(x, y), 3); } - get(userId) + copy(x, y, image) { - userId = userId.toString(); + // area of the source image to be pasted on (intersection) + const sx1 = Math.max(x, 0); + const sy1 = Math.max(y, 0); + const sx2 = Math.min(x + image.sizeX, this.sizeX); + const sy2 = Math.min(y + image.sizeY, this.sizeY); - let userData = this._map.get(userId); + // area of the target image to be pasted + const tx1 = sx1 - x; + const ty1 = sy1 - y; + const tx2 = sx2 - x; + const ty2 = sy2 - y; - if(!userData) + // copy target line-by-line + for (let dy = 0; dy < ty2 - ty1; ++dy) { - this._map.set(userId, userData = structuredClone(this._defaultUserData)); + image.data.copy(this.data, this.getOffset(sx1, sy1 + dy), image.getOffset(tx1, ty1 + dy), image.getOffset(tx2, ty1 + dy)); } - - return userData; - } - - [Symbol.iterator]() - { - return this._map.entries; } } -const defaultCanvasSettings = { - sizeX: 1000, - sizeY: 1000, - colors: [ 16711680, 65280, 255 ], - maxCooldown: 60 -}; -function hexToInt(hex) +export class ErrorCode { - if(typeof hex === "number") - { - return hex; - } + static OUT_OF_BOUNDS = 0; + static COLOR_NOT_FOUND = 1; + static ON_COOLDOWN = 2; + static CANVAS_CLOSED = 3; - if(hex.startsWith("#")) - { - hex = hex.slice(1); - } + static UNSUPPORTED_EXPANSION = 100; - return Number(`0x${hex}`); + static INVALID_COLOR = 1000; } -const defaultCanvasUserData = { cooldown: 0 }; +export class Event +{ + static PLACE = 0; + static EXPAND = 1; + static COLORS = 2; + static COOLDOWN = 3; +} -class Canvas extends EventEmitter +export class Canvas extends EventEmitter { constructor() { super(); - this.pixelEvents = []; - this.users = new UserDataStore(defaultCanvasUserData); - setInterval(this._update.bind(this), 1000); - } + this.image = new RawImage(0, 0); + this.colors = []; + this.cooldown = 0; - initialize(settings) - { - this.settings = Object.assign(structuredClone(defaultCanvasSettings), settings); - this.settings.colors = this.settings.colors.map(hexToInt); + this.pixelMap = new LazyMap(); + this.userMap = new LazyMap(); - this.pixels = new ImageBuffer(this.settings.sizeX, this.settings.sizeY); - this.info = new Array(this.settings.sizeX).fill(null).map(() => new Array(this.settings.sizeY).fill(null)); - - return this; + this.pivotX = 0; + this.pivotY = 0; } - _update() + /* + use(...conditions) { - for(const [ userId, data ] of this.users._map) + for (const condition of conditions) { - if(data.cooldown > 0) + for (const key in condition) { - --data.cooldown; + const func = this[key]; + const conditionFunc = condition[key]; + + if (typeof func === "function" && typeof conditionFunc === "function") + { + this[key] = (...args) => + { + const result = conditionFunc(...args); + if (result) return result; + return func(...args); + }; + } } } - } - _setPixel(x, y, color, userId, timestamp) - { - this.pixels.setColor(x, y, color); - this.info[x][y] = { userId, timestamp }; - this.pixelEvents.push({ x, y, color, userId, timestamp }); + return this; } + */ - isInBounds(x, y) + getPlaceTimestampsFor(userId) { - return parseInt(x) == x && parseInt(y) == y && x >= 0 && x < this.settings.sizeX && y >= 0 && y < this.settings.sizeY; + return this.userMap.get(userId)?.placeTimestamps; } - place(x, y, color, userId) + getPlacer(x, y) { - if(!this.isInBounds(x, y)) - { - return false; - } - - if(!this.settings.colors.includes(+color)) - { - return false; - } - - if(this.users.get(userId).cooldown > 0) - { - return false; - } - - const timestamp = Date.now(); - this._setPixel(x, y, color, userId, timestamp); - this.emit("pixel", x, y, color, userId, timestamp); - - this.users.get(userId).cooldown = this.settings.maxCooldown; - - return true; - } -} - - - -Canvas.IO = class extends EventEmitter -{ - constructor(canvas, path) - { - super(); - this._canvas = canvas; - this._path = path; - - if(!FileSystem.existsSync(path)) - { - FileSystem.writeFileSync(path, ""); - } - - this._stream = FileSystem.createWriteStream(path, { flags: "a" }); - - canvas.addListener("pixel", this.writePixel.bind(this)); + return this.pixelMap.get(x)?.get(y)?.userId; } - read() + place(x, y, color, userId, timestamp = Date.now()) // TODO: Bypass checks during read? { - const buf = SmartBuffer.fromBuffer(FileSystem.readFileSync(this._path)); + const absoluteX = x + this.pivotX; + const absoluteY = y + this.pivotY; - while(buf.remaining() > 0) - { - const x = buf.readUInt16BE(); - const y = buf.readUInt16BE(); - - const color = buf.readBuffer(3).readUintBE(0, 3); - - const userId = buf.readBigUInt64BE().toString(); - const timestamp = Number(buf.readBigUInt64BE()); + if (absoluteX < 0 || absoluteX > this.image.sizeX || absoluteY < 0 || absoluteY > this.image.sizeY) return { error: ErrorCode.OUT_OF_BOUNDS }; + if (!this.colors.includes(color)) return { error: ErrorCode.COLOR_NOT_FOUND }; + if (this.cooldown < 0) return { error: ErrorCode.CANVAS_CLOSED }; + if (this.getPlaceTimestampsFor(userId)?.next > timestamp) return { error: ErrorCode.ON_COOLDOWN }; - this._canvas._setPixel(x, y, color, userId, timestamp); + this.image.setColor(absoluteX, absoluteY, color); + this.pixelMap.get(x, () => new LazyMap()).get(y, () => ( {} )).userId = userId; + const placeTimestamps = this.userMap.get(userId, () => ( {} )).placeTimestamps ??= {}; + placeTimestamps.last = timestamp; + placeTimestamps.next = timestamp + this.cooldown; - this.emit("read", x, y, color, userId, timestamp); - } + this.emit("dispatch", { id: Event.PLACE, x, y, color, userId, timestamp }); - return this; + return { placeTimestamp: placeTimestamps.last, nextPlaceTimestamp: placeTimestamps.next }; } - writePixel(x, y, color, userId, timestamp) + expand(nx, ny, px, py, userId, timestamp = Date.now()) { - const buf = new SmartBuffer(); // TODO: re-use buffer - - buf.writeUInt16BE(x); - buf.writeUInt16BE(y); - const colorBuf = Buffer.alloc(3); - colorBuf.writeUIntBE(color, 0, 3); - buf.writeBuffer(colorBuf); - buf.writeBigUInt64BE(BigInt(userId)); - buf.writeBigUInt64BE(BigInt(timestamp)); + if (nx < 0 || ny < 0 || px < 0 || py < 0) return { error: ErrorCode.UNSUPPORTED_EXPANSION }; - this._stream.write(buf.toBuffer()); - } + this.pivotX += nx; + this.pivotY += ny; - serializePixelWithoutTheOtherStuff(x, y, color) - { - const buf = new SmartBuffer(); + const oldImage = this.image; + this.image = new RawImage(oldImage.sizeX + nx + px, oldImage.sizeY + ny + py); + this.image.copy(nx, ny, oldImage); - buf.writeUInt16BE(x); - buf.writeUInt16BE(y); - const colorBuf = Buffer.alloc(3); - colorBuf.writeUIntBE(color, 0, 3); - buf.writeBuffer(colorBuf); + this.emit("dispatch", { id: Event.EXPAND, nx, ny, px, py, userId, timestamp }); - return buf.toBuffer(); + return {}; } -} - - - -const defaultUserStats = { pixelEvents: [] }; - -Canvas.Stats = class -{ - constructor(canvas, io, getConnectedUserCount) + setColors(colors, userId, timestamp = Date.now()) { - this.canvas = canvas; - this.getConnectedUserCount = getConnectedUserCount; - - this.global = { - uniqueUserCount: 0, - colorCounts: {}, - // - userCountOverTime: {}, - pixelCountOverTime: {} - }; - - this.personal = new UserDataStore(defaultUserStats); - - // - - canvas.addListener("pixel", this._updateRealTime.bind(this)); - io.addListener("read", this._updateRealTime.bind(this)); - - // TODO: Yucky! - if(FileSystem.existsSync("./canvas/userCountOverTime.json")) - { - this.global.userCountOverTime = JSON.parse(FileSystem.readFileSync("./canvas/userCountOverTime.json", { encoding: "utf-8" })); - } - } + if (colors.some(c => c < 0 || c > 16777215)) return { error: ErrorCode.INVALID_COLOR }; - startRecording(intervalMs, durationMs) - { - this._recordingIntervalMs = intervalMs; - this._recordingDurationMs = durationMs; + this.colors = colors; - Utils.startInterval(this._recordingIntervalMs, this._updateAtInterval.bind(this)); - } - - _updateRealTime(x, y, color, userId, timestamp) - { - this.global.colorCounts[color] ??= 0; - this.global.colorCounts[color]++; + this.emit("dispatch", { id: Event.COLORS, colors, userId, timestamp }); - this.personal.get(userId).pixelEvents.push({ x, y, color, userId, timestamp }); + return {}; } - _updateAtInterval() + setCooldown(cooldown, userId, timestamp = Date.now()) { - console.log("Updated stats"); + this.cooldown = cooldown; - const currentTimeMs = Date.now(); - const startTimeMs = currentTimeMs - this._recordingDurationMs; - const intervalTimeMs = this._recordingIntervalMs; + this.emit("dispatch", { id: Event.COOLDOWN, cooldown, userId, timestamp }); - - - this.global.uniqueUserCount = new Set(this.canvas.pixelEvents.map(pixelEvent => pixelEvent.userId)).size; // TODO: update in real time? - - - - for(const timestamp in this.global.userCountOverTime) - { - if(timestamp < startTimeMs) - { - delete this.global.userCountOverTime[timestamp]; - } - } - - this.global.userCountOverTime[currentTimeMs] = this.getConnectedUserCount(); - - // TODO: Yucky! - FileSystem.writeFileSync("./canvas/userCountOverTime.json", JSON.stringify(this.global.userCountOverTime)); - - - - // TODO This will break if there are periods of 0 placement - // TOOD So we need to fill out those intervals manually, make sure they are present - this.global.pixelCountOverTime = this.canvas.pixelEvents.groupBy(pixelEvent => - { - const intervalStartTimeMs = Math.floor( (pixelEvent.timestamp - startTimeMs) / intervalTimeMs ) * intervalTimeMs; - - return pixelEvent.timestamp < startTimeMs ? undefined : intervalStartTimeMs + startTimeMs; - } ); - - for(const timestamp in this.global.pixelCountOverTime) - { - this.global.pixelCountOverTime[timestamp] = this.global.pixelCountOverTime[timestamp].length; - } + return {}; } -} - - - -/* - * =============================== -*/ - -module.exports = Canvas; \ No newline at end of file +} \ No newline at end of file diff --git a/canvas.stats.js b/canvas.stats.js new file mode 100644 index 0000000..e960c26 --- /dev/null +++ b/canvas.stats.js @@ -0,0 +1,97 @@ +import { LazyMap } from "./util.js"; +import { Event } from "./canvas.js"; +import * as Util from "./util.js"; + + + +export default class Statistics +{ + constructor(pixelCountInteval, pixelCountWindow, userCountInterval, userCountWindow) + { + this.pixelCountInterval = pixelCountInteval; + this.pixelCountWindow = pixelCountWindow; + this.userCountInterval = userCountInterval; + this.userCountWindow = userCountWindow; + + this.pixelCount = 0; + this.pixelCountByColor = {}; + this.pixelCountsOverTime = {}; + + this.totalUserCountOverTime = {}; + this.userCountOverTime = {}; + this.mostConcurrentUsers = 0; + + this.personal = new LazyMap(); + + this._channels = null; + } + + getPersonal(userId) + { + return this.personal.get(userId, () => ( { pixels: [] } )); + } + + listen(canvas, channels) + { + this._channels = channels; + canvas.on("dispatch", this.savePixel.bind(this)); + channels.on("open", this.saveUserCount.bind(this)); + channels.on("close", this.saveUserCount.bind(this)); + return this; + } + + savePixel(event) // on every place event... + { + if (event.id !== Event.PLACE) return; + + const alignedTimestamp = Util.align(event.timestamp, this.pixelCountInterval); + + // Update our cached counts + this.pixelCount++; + this.pixelCountByColor[event.color] ??= 0; + this.pixelCountByColor[event.color]++; + + const lowerBound = Date.now() - this.pixelCountWindow; + + // Only add if within our window + if (event.timestamp >= lowerBound) + { + this.pixelCountsOverTime[alignedTimestamp] ??= 0; + this.pixelCountsOverTime[alignedTimestamp]++; + } + + // Delete old entries outside our window + for (const timestamp in this.pixelCountsOverTime) + { + if (+timestamp < lowerBound) delete this.pixelCountsOverTime[timestamp]; + } + + if (event.userId > 0) this.getPersonal(event.userId).pixels.push(event); // TODO: Proper snowflake validation? + } + + saveUserCount(event) // on every user change event... + { + const alignedTimestamp = Util.align(event.timestamp, this.userCountInterval); + const count = this._channels.getChannelCount(); + + // Add to the total count list + this.totalUserCountOverTime[alignedTimestamp] = count; + // And update the max users + if (count > this.mostConcurrentUsers) this.mostConcurrentUsers = count; + + const lowerBound = Date.now() - this.userCountWindow; + + // Only add to current count list if within our window + if (event.timestamp >= lowerBound) + { + const count = this._channels.getChannelCount(); + this.userCountOverTime[alignedTimestamp] = count; + } + + // Delete old entries outside our window + for (const timestamp in this.userCountOverTime) + { + if (+timestamp < lowerBound) delete this.userCountOverTime[timestamp]; + } + } +} \ No newline at end of file diff --git a/channel.tracker.js b/channel.tracker.js new file mode 100644 index 0000000..ce05899 --- /dev/null +++ b/channel.tracker.js @@ -0,0 +1,40 @@ +import EventEmitter from "events"; + + + +export default class ChannelTracker extends EventEmitter +{ + constructor() + { + super(); + + this._channels = new Set(); + } + + getChannelCount() + { + return this._channels.size; + } + + open(channel) + { + this._channels.add(channel); + channel.write("event: hello\n\n"); + this.emit("open", { channel, timestamp: Date.now() }); + } + + close(channel) + { + this._channels.delete(channel); + this.emit("close", { channel, timestamp: Date.now() }); + } + + sendAll(event, data) + { + for (const channel of this._channels) + { + channel.write(`event: ${event}\n`); + channel.write(`data: ${JSON.stringify(data)}\n\n`); + } + } +} \ No newline at end of file diff --git a/config.json b/config.json deleted file mode 100644 index e7b6086..0000000 --- a/config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "guild": - { - "id": "98609319519453184", - "bannedRoles": [ "430808381708697602", "371399647618531328", "139498527297503232", "368961099925553153" ], - "moderatorRoles": [ "175814520118312960" ] - } -} \ No newline at end of file diff --git a/data/config.json b/data/config.json new file mode 100644 index 0000000..63045b1 --- /dev/null +++ b/data/config.json @@ -0,0 +1,11 @@ +{ + "guildId": "", + "guildName": "", + "guildInvite": "", + "bannedRoles": [], + "adminRoles": [], + "pixelCountInterval": 0, + "pixelCountWindow": 0, + "userCountInterval": 0, + "userCountWindow": 0 +} \ No newline at end of file diff --git a/discord.api.js b/discord.api.js new file mode 100644 index 0000000..7123a87 --- /dev/null +++ b/discord.api.js @@ -0,0 +1,237 @@ +import WebSocket from 'ws'; +import Crypto from "crypto"; +import { EventEmitter } from 'events'; +import { LazyMap } from "./util.js"; + + + +class Waitable +{ + constructor(emitter, event) + { + this._emitter = emitter; + this._event = event; + this._condition = null; + } + + that(condition) + { + this._condition = condition; + return this; + } + + wait(timeMs) + { + return new Promise((resolve, reject) => + { + let timeout = null; + let handler = null; + + handler = d => + { + if (this._condition && !this._condition(d)) return; + this._emitter.off(this._event, handler); + clearTimeout(timeout); + resolve(d); + }; + + this._emitter.on(this._event, handler); + + if (!timeMs || timeMs <= 0) return; + + timeout = setTimeout(() => + { + this._emitter.off(this._event, handler); + reject(`Promise timed out after ${timeMs}ms`); + }, timeMs); + }); + } +} + +class OutgoingCloseCode +{ + // static NORMAL = 1000; + static RESUME = 4200; +} + +class Opcode +{ + static DISPATCH = 0; + static HEARTBEAT = 1; + static IDENTIFY = 2; + static RESUME = 6; + static RECONNECT = 7; + static REQUEST_GUILD_MEMBERS = 8; + static INVALID_SESSION = 9; + static HELLO = 10; + static HEARTBEAT_ACK = 11; +} + +export class Intent +{ + static GUILDS = 1 << 0; + static GUILD_MEMBERS = 1 << 1; +} + +export class GatewayClient extends EventEmitter // TODO: Timeouts on waits +{ + static INITIAL_GATEWAY_URL = "wss://gateway.discord.gg"; + static GATEWAY_PARAMETERS = "v=10&encoding=json"; + + constructor(token, intents) + { + super(); + + this._token = token; + this._intents = intents; + + this._ws = null; + + this._heartbeat = null; + + this._sequence = null; + this._sessionId = null; + this._resumeGatewayUrl = null; + } + + next(event) + { + return new Waitable(this, event); + } + + _send(data) + { + this._ws.send(JSON.stringify(data)); + } + + _sendHeartbeat() + { + this._send({ op: Opcode.HEARTBEAT, d: this._sequence }); + } + + getGuildMembers(guildId, userIds) + { + const nonce = Crypto.randomBytes(24).toString("base64"); + this._send({ op: Opcode.REQUEST_GUILD_MEMBERS, d: { guild_id: guildId, user_ids: userIds, limit: 0, nonce } }); + return this.next("GUILD_MEMBERS_CHUNK").that(e => e.nonce == nonce).wait(); + } + + async _connect(force) + { + if (this._ws) + { + this._ws.onclose = null; + this._ws.onerror = null; + this._ws.onmessage = null; + this._ws.close(OutgoingCloseCode.RESUME); + clearInterval(this._heartbeat); + } + + // always try to resume UNLESS we don't have the resume url OR we forced a re-identify + const resume = !force && this._sequence && this._sessionId && this._resumeGatewayUrl; + + if (resume) console.log("Attempting resume..."); + else console.log("Attempting identify..."); + + const url = resume ? this._resumeGatewayUrl : GatewayClient.INITIAL_GATEWAY_URL; + this._ws = new WebSocket(`${url}?${GatewayClient.GATEWAY_PARAMETERS}`); + this._ws.onclose = e => this._close(e.code, e.reason); + this._ws.onerror = e => this._error(e); + this._ws.onmessage = e => this._handle(JSON.parse(e.data)); + + const hello = await this.next("message").that(p => p.op == Opcode.HELLO).wait(); // TODO: Potentially don't wait for hello + this._heartbeat = setInterval(this._sendHeartbeat.bind(this), hello.d.heartbeat_interval); + + if (resume) + { + this._send({ op: Opcode.RESUME, d: { token: this._token, session_id: this._sessionId, seq: this._sequence } }); + } + else + { + this._send({ op: Opcode.IDENTIFY, d: { token: this._token, intents: this._intents, properties: { os: "linux", browser: "maneplace", device: "maneplace" } } }); + const ready = await this.next("READY").wait(); + this._sessionId = ready.session_id; + this._resumeGatewayUrl = ready.resume_gateway_url; + + return ready.user; + } + } + + login() + { + return this._connect(); + } + + _handle(payload) + { + this.emit("message", payload); + + if (payload.s) this._sequence = payload.s; + + if (payload.op == Opcode.DISPATCH) this.emit(payload.t, payload.d); + else if (payload.op == Opcode.HEARTBEAT) this._sendHeartbeat(); + else if (payload.op == Opcode.RECONNECT) this._connect(); + else if (payload.op == Opcode.INVALID_SESSION) this._connect(true); + } + + _close(code, reason) + { + this.emit("close", code, reason); + this._connect(); // TODO: Max retries + } + + _error(event) + { + this.emit("error", event); + this._connect(); + } +} + +export class Client // TODO: Also implement a general user cache for users that are not in the server (left/banned/etc) +{ + constructor(token, intents) + { + this._gatewayClient = new GatewayClient(token, intents); + + this._guildCache = new LazyMap(); // TODO: Clear on gateway re-identify + + this._gatewayClient.on("GUILD_MEMBER_ADD", e => + { + this._guildCache.get(e.guild_id, () => new LazyMap()).set(e.user.id, e); + }); + + this._gatewayClient.on("GUILD_MEMBER_REMOVE", e => + { + this._guildCache.get(e.guild_id, () => new LazyMap()).set(e.user.id, null); + }); + + this._gatewayClient.on("GUILD_MEMBER_UPDATE", e => + { + const memberCache = this._guildCache.get(e.guild_id, () => new LazyMap()); + const member = memberCache.get(e.user.id); + if (member) memberCache.set(e.user.id, Object.assign(member, e)); + }); + } + + login() + { + return this._gatewayClient.login(); + } + + async getGuildMember(guildId, userId) + { + if (!userId) return null; // TODO: validate guildId/userId snowflakes + + const memberCache = this._guildCache.get(guildId, () => new LazyMap()); + let member = memberCache.get(userId); + + if (member === undefined) // Use null to check if we already fetched before + { + const fetched = await this._gatewayClient.getGuildMembers(guildId, userId); + member = fetched.members[0] || null; + memberCache.set(userId, member); + } + + return member; + } +} \ No newline at end of file diff --git a/main.js b/main.js index 814286e..2253eb7 100644 --- a/main.js +++ b/main.js @@ -1,350 +1,291 @@ -// Express -const Express = require("express"); -const ExpressSession = require("express-session"); -const ExpressCompression = require("compression"); -const SessionFileStore = require("session-file-store")(ExpressSession); +import Polka from "polka"; +import Sirv from "sirv"; +import Compress from "@polka/compression"; +import { helpers, json, session } from "./middleware.js"; +import { Canvas } from "./canvas.js"; +import * as IO from "./canvas.io.js"; +import Path from "path"; +import FileSystem from "fs/promises"; +import Query from "querystring"; +import * as Discord from "./discord.api.js"; +import { intersects } from "./util.js"; +import ChannelTracker from "./channel.tracker.js"; +import Statistics from "./canvas.stats.js"; -// Discord -const { Client, Events, GatewayIntentBits } = require("discord.js"); -// Utils -const Path = require("path"); -const QueryString = require("querystring"); -const promisify = require("util").promisify; -// Our stuff -const Canvas = require("./canvas"); +// TODO: Add /api/... path to all endpoints (Polka is dogshit and wouldn't allow me to mount middleware like that) -// Configs -const Config = require("./config.json"); -require("dotenv").config(); +// ---------------- Discord ---------------- +const DISCORD = new Discord.Client(process.env.DISCORD_TOKEN, Discord.Intent.GUILDS | Discord.Intent.GUILD_MEMBERS); +DISCORD._gatewayClient.on("close", (c, r) => console.log(new Date().toLocaleString(), c, r)); +DISCORD._gatewayClient.on("error", e => console.log(new Date().toLocaleString(), e)); -/* TODO - * - Auto update the page like vite on any changes - * - Sync stuff like cooldown and ban - * - Polling system where the client polls new pixels every few seconds - * - Log out - * - Automatic session expiry (though ttl already does that so ???) - * - Move more stuff to config like redirect url, etc - */ +await DISCORD.login().then(u => console.log(`Logged in as ${u.username}`)); -const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers ] }); -client.login(process.env.BOT_TOKEN); -client.once(Events.ClientReady, c => +class UserStatus { - console.log("Ready! Logged in as", c.user.tag); -}); + static LOGGED_OUT = 0; + static NOT_IN_SERVER = 1; + static BANNED = 2; + static LOGGED_IN = 10; + static ADMIN = 11; +} -/* - * =============================== -*/ +async function getUserStatus(userId) // TODO: middleware? +{ + if (!userId) return UserStatus.LOGGED_OUT; -const app = Express(); -// const port = 80; + const member = await DISCORD.getGuildMember(CONFIG.guildId, userId); + if (!member) return UserStatus.NOT_IN_SERVER; + else if (intersects(member.roles, CONFIG.adminRoles)) return UserStatus.ADMIN; + else if (intersects(member.roles, CONFIG.bannedRoles)) return UserStatus.BANNED; + return UserStatus.LOGGED_IN; +} -/* - * =============================== -*/ -app.use(Express.static(Path.join(__dirname, "public"))); -app.use(ExpressSession({ store: new SessionFileStore( -{ - path: "./canvas/sessions", - ttl: 7 * 24 * 60 * 60, - retries: 0, - encoder: data => JSON.stringify(data, null, "\t") }), - secret: process.env.SESSION_SECRET, - saveUninitialized: false, - resave: false -})); -app.use(Express.json()); - -async function userInfo(req, res, next) -{ - if(!req.session?.user) - { - return next(); - } - - req.user = req.session.user; - - try - { - req.member = await client.guilds.cache.get(Config.guild.id).members.fetch(req.session.user.id); - } - catch(e) - { - } +// ---------------- Canvas ---------------- - next(); -} +const CHANNELS = new ChannelTracker(); +const CONFIG = await FileSystem.readFile(Path.join(import.meta.dirname, "data", "config.json")) + .then(s => JSON.parse(s)) + .catch(() => ( {} )); +const CANVAS = new Canvas() +const STATS = new Statistics( + CONFIG.pixelCountInterval || 10 * 60 * 1000, + CONFIG.pixelCountWindow || 24 * 60 * 60 * 1000, + CONFIG.userCountInterval || 10 * 60 * 1000, + CONFIG.userCountWindow || 24 * 60 * 60 * 1000 +).listen(CANVAS, CHANNELS); -/* - * =============================== -*/ +const STATS_PATH = Path.join(import.meta.dirname, "data", "stats.json"); +await IO.readStats(STATS, STATS_PATH); -const clients = new Map(); +const EVENTS_PATH = Path.join(import.meta.dirname, "data", "events.bin"); +await IO.readEvents(CANVAS, EVENTS_PATH); +await IO.startWritingEvents(CANVAS, EVENTS_PATH); -const canvas = new Canvas().initialize({ sizeX: 500, sizeY: 500, colors: [ "#6d001a", "#be0039", "#ff4500", "#ffa800", "#ffd635", "#fff8b8", "#00a368", "#00cc78", "#7eed56", "#00756f", "#009eaa", "#00ccc0", "#2450a4", "#3690ea", "#51e9f4", "#493ac1", "#6a5cff", "#94b3ff", "#811e9f", "#b44ac0", "#e4abff", "#de107f", "#ff3881", "#ff99aa", "#6d482f", "#9c6926", "#ffb470", "#000000", "#515252", "#898d90", "#d4d7d9", "#ffffff" ] }); -const io = new Canvas.IO(canvas, "./canvas/current.hst"); -const stats = new Canvas.Stats(canvas, io, () => clients.size); -io.read(); -stats.startRecording(10 * 60 * 1000 /* 10 min */, 24 * 60 * 60 * 1000 /* 24 hrs */ ); +IO.gracefulShutdown( () => IO.writeStats(STATS, STATS_PATH) ); -// day 2 colors -// const colors = [ "#ff4500", "#ffa800", "#ffd635", "#00a368", "#7eed56", "#2450a4", "#3690ea", "#51e9f4", "#811e9f", "#b44ac0", "#ff99aa", "#9c6926", "#000000", "#898d90", "#d4d7d9", "ffffff" ]; -// day 3 colors -// const colors = [ "#be0039", "#ff4500", "#ffa800", "#ffd635", "#00a368", "#00cc78", "#7eed56", "#00756f", "#009eaa", "#2450a4", "#3690ea", "#51e9f4", "#493ac1", "#6a5cff", "#811e9f", "#b44ac0", "#ff3881", "#ff99aa", "#6d482f", "#9c6926", "#000000", "#898d90", "#d4d7d9", "#ffffff", ]; -// day 4 colors -// const colors = [ "#6d001a", "#be0039", "#ff4500", "#ffa800", "#ffd635", "#fff8b8", "#00a368", "#00cc78", "#7eed56", "#00756f", "#009eaa", "#00ccc0", "#2450a4", "#3690ea", "#51e9f4", "#493ac1", "#6a5cff", "#94b3ff", "#811e9f", "#b44ac0", "#e4abff", "#de107f", "#ff3881", "#ff99aa", "#6d482f", "#9c6926", "#ffb470", "#000000", "#515252", "#898d90", "#d4d7d9", "#ffffff", ]; +// ---------------- Server ---------------- -/* - * =============================== -*/ +const SERVER = Polka(); +SERVER.use(Sirv(Path.join(import.meta.dirname, "public")), helpers, session({ secure: false })); -const oauthRedirectUrl = "https://place.manechat.net/auth/discord/redirect" -const oauthScope = "identify"; +// ---------------- Auth ---------------- -app.get("/auth/discord", (req, res) => +SERVER.get("/login", (req, res) => { - const query = QueryString.encode( - { - client_id: process.env.CLIENT_ID, - scope: oauthScope, - redirect_uri: oauthRedirectUrl, + const query = Query.encode({ + client_id: process.env.DISCORD_CLIENT_ID, response_type: "code", - state: req.query.from + redirect_uri: `http://${req.headers.host}/login/redirect`, + scope: "identify", + state: req.query.from // So we can redirect back to stats }); - res.redirect(`https://discord.com/api/oauth2/authorize?${query}`); + res.redirect(`https://discord.com/oauth2/authorize?${ query }`); }); - - -app.get("/auth/discord/redirect", async (req, res) => +SERVER.get("/login/redirect", async (req, res) => { const code = req.query.code; + const redirect = `/${ req.query.state || "" }`; - const redirectUrl = "/" + (req.query.state || ""); + if (!code) return res.redirect(redirect); - if (!code) - { - return res.redirect(redirectUrl); - } + const query = { + client_id: process.env.DISCORD_CLIENT_ID, + client_secret: process.env.DISCORD_CLIENT_SECRET, + grant_type: "authorization_code", + redirect_uri: `http://${req.headers.host}/login/redirect`, + code, + }; - const authRes = await fetch("https://discord.com/api/oauth2/token", - { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams( - { - client_id: process.env.CLIENT_ID, - client_secret: process.env.CLIENT_SECRET, - grant_type: "authorization_code", - scope: oauthScope, - redirect_uri: oauthRedirectUrl, - code - }) - }); + const exchange = await fetch("https://discord.com/api/oauth2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: Query.encode(query) }) + .then(r => r.json()) + .catch(() => null); - if(!authRes.ok) + if (exchange?.token_type && exchange?.access_token) { - return res.redirect(redirectUrl); - } - - const auth = await authRes.json(); - - const userRes = await fetch("https://discord.com/api/users/@me", - { - headers: { Authorization: `${auth.token_type} ${auth.access_token}` } - }); + const user = await fetch("https://discord.com/api/users/@me", { headers: { "Authorization": `${exchange.token_type} ${exchange.access_token}` } }) + .then(r => r.json()) + .catch(() => null); - if(!userRes.ok) - { - return res.redirect(redirectUrl); + if (user) res.createSession({ userId: user.id }); } - await promisify(req.session.regenerate.bind(req.session))(); // TODO: Clean old sessions associated with this user/id - req.session.user = await userRes.json(); - - res.redirect(redirectUrl); + res.redirect(redirect); }); - - -app.get("/initialize", userInfo, async (req, res) => +SERVER.delete("/logout", (req, res) => { - if(!req.user) - { - return res.json({ loggedIn: false, banned: false, cooldown: 0, settings: canvas.settings }); - } - - res.json({ loggedIn: true, banned: isBanned(req.member), cooldown: canvas.users.get(req.user.id).cooldown, settings: canvas.settings }); -}); - + if (!req?.session?.userId) return res.end("Already logged out"); + res.deleteSession(req.session.sessionId); -app.get("/canvas", ExpressCompression(), (req, res) => -{ - res.contentType("application/octet-stream"); - res.send(canvas.pixels.data); + res.end("Logged out"); }); -app.post("/place", userInfo, async (req, res) => -{ - if(!req.member) - { - return res.status(401).send(); - } - - if(isBanned(req.member)) - { - return res.status(403).send(); - } - - const placed = canvas.place(+req.body.x, +req.body.y, +req.body.color, req.member.user.id); +// ---------------- Get canvas ---------------- - res.send({ placed }); +SERVER.use("/canvas", Compress({ level: 4 })); +SERVER.get("/canvas", (_, res) => +{ + res.end(CANVAS.image.data); }); -app.post("/placer", async (req, res) => -{ - if(!canvas.isInBounds(+req.body.x, +req.body.y)) - { - return res.json({ username: "" }); - } - - const pixelInfo = canvas.info[+req.body.x][+req.body.y]; +// ---------------- Get canvas state ---------------- - if(!pixelInfo) - { - return res.json({ username: "" }); - } +SERVER.get("/canvas/state", async (req, res) => +{ + const placeTimestamps = CANVAS.getPlaceTimestampsFor(req.session?.userId); + res.json({ + sizeX: CANVAS.image.sizeX, + sizeY: CANVAS.image.sizeY, + pivotX: CANVAS.pivotX, + pivotY: CANVAS.pivotY, + colors: CANVAS.colors, + cooldown: CANVAS.cooldown, + userStatus: await getUserStatus(req.session?.userId), + placeTimestamp: placeTimestamps?.last ?? 0, + nextPlaceTimestamp: placeTimestamps?.next ?? 0, + guildName: CONFIG.guildName, // TODO: Automatically get name and invite/vanity link? + guildInvite: CONFIG.guildInvite, + }); +}); - try - { - const member = await client.guilds.cache.get(Config.guild.id).members.fetch(pixelInfo.userId.toString()); - if(member) - { - return res.json({ username: member.nickname ? member.nickname : member.user.globalName }); - } - } - catch(e) - { - } - const user = await client.users.fetch(pixelInfo.userId.toString()); +// ---------------- Place ---------------- - if(!user) - { - return res.json({ username: "" }); - } +SERVER.use("/place", json); +SERVER.post("/place", async (req, res) => +{ + if (!Number.isInteger(req.body.x) || !Number.isInteger(req.body.y) || !Number.isInteger(req.body.color)) return res.status(400).end(); + if (!req.session?.userId) return res.status(401).end(); + if (await getUserStatus(req.session.userId) < UserStatus.LOGGED_IN) return res.status(403).end(); - res.json({ username: user.username }); + res.json(CANVAS.place(req.body.x, req.body.y, req.body.color, req.session.userId)); }); -/* - * =============================== -*/ +// ---------------- Get placer ---------------- -app.get("/stats-json", ExpressCompression(), userInfo, (req, res) => +SERVER.use("/placer", json); +SERVER.post("/placer", async (req, res) => { - const statsJson = { global: Object.assign( { userCount: clients.size, pixelCount: canvas.pixelEvents.length } , stats.global ) }; + if (!Number.isInteger(req.body.x) || !Number.isInteger(req.body.y)) return res.status(400).end(); - if(req.member) - { - statsJson.personal = stats.personal.get(req.member.user.id); - } + const userId = CANVAS.getPlacer(req.body.x, req.body.y); + const member = await DISCORD.getGuildMember(CONFIG.guildId, userId); - res.json(statsJson); + res.json({ placer: member?.nick || member?.user?.global_name || member?.user?.username }); // TODO: Name for users not in the server }); -/* - * =============================== -*/ +// ---------------- Events ---------------- -function isBanned(member) +SERVER.get("/events", (req, res) => { - if(!member) - { - return true; - } - - if(Config.guild.moderatorRoles.some(roleId => member.roles.cache.has(roleId))) - { - return false; - } + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Connection": "keep-alive", + "Cache-Control": "no-cache" + }); - return member.communication_disabled_until || Config.guild.bannedRoles.some(roleId => member.roles.cache.has(roleId)); -} + CHANNELS.open(res); + req.on("close", () => CHANNELS.close(res)); +}); +CANVAS.on("dispatch", event => +{ + const data = Object.assign({}, event); + delete data.userId, + delete data.timestamp; + CHANNELS.sendAll("dispatch", data); +}); -/* - * =============================== -*/ -let idCounter = 0; +// ---------------- Mod tools ---------------- -canvas.addListener("pixel", (x, y, color) => +// TODO: Change paths to /tools/etc or similar +SERVER.use("/expand", json); +SERVER.post("/expand", async (req, res) => { - console.log("Pixel sent to " + clients.size + " - " + new Date().toString()); - const buf = io.serializePixelWithoutTheOtherStuff(x, y, color); - for(const socket of clients.values()) - { - socket.send(buf); - } + if (!Number.isInteger(req.body.nx) || !Number.isInteger(req.body.ny) || !Number.isInteger(req.body.px) || !Number.isInteger(req.body.py)) return res.status(400).end(); + if (!req.session?.userId) return res.status(401).end(); + if (await getUserStatus(req.session.userId) !== UserStatus.ADMIN) return res.status(403).end(); + + res.json(CANVAS.expand(req.body.nx, req.body.ny, req.body.px, req.body.py, req.session.userId)); }); -app.setUpSockets = () => // TODO: THis is really ugly because of Greenlock +SERVER.use("/colors", json); +SERVER.post("/colors", async (req, res) => { + if (!Array.isArray(req.body.colors) || req.body.colors.some(c => !Number.isInteger(c))) return res.status(400).end(); + if (!req.session?.userId) return res.status(401).end(); + if (await getUserStatus(req.session.userId) !== UserStatus.ADMIN) return res.status(403).end(); -app.ws("/", ws => -{ - const clientId = idCounter++; + res.json(CANVAS.setColors(req.body.colors, req.session.userId)); +}); - clients.set(clientId, ws); +SERVER.use("/cooldown", json); +SERVER.post("/cooldown", async (req, res) => +{ + if (!Number.isInteger(req.body.cooldown)) return res.status(400).end(); + if (!req.session?.userId) return res.status(401).end(); + if (await getUserStatus(req.session.userId) !== UserStatus.ADMIN) return res.status(403).end(); - ws.on("close", () => - { - clients.delete(clientId); - }); + res.json(CANVAS.setCooldown(req.body.cooldown, req.session.userId)); }); -} - -/* - * =============================== -*/ +// ---------------- Stats ---------------- -/* -app.listen(port, () => +SERVER.use("/statistics", Compress({ level: 4 })); +SERVER.get("/statistics", (req, res) => { - console.log(`Example app listening on port ${port}`); + const stats = {}; + + stats.global = { + pixelCount: STATS.pixelCount, + pixelCountByColor: STATS.pixelCountByColor, + pixelCountOverTime: STATS.pixelCountsOverTime, + pixelCountInterval: STATS.pixelCountInterval, + userCount: CHANNELS.getChannelCount(), + uniqueUserCount: STATS.personal.size, + userCountOverTime: STATS.userCountOverTime, + mostConcurrentUsers: STATS.mostConcurrentUsers, + }; + + if (req.session?.userId) stats.personal = STATS.getPersonal(req.session.userId); + + res.json(stats); }); -*/ -module.exports = app; \ No newline at end of file + + +// ---------------- Start ---------------- + +SERVER.listen(5000, () => console.log(`Server started on port 5000`)); \ No newline at end of file diff --git a/middleware.js b/middleware.js new file mode 100644 index 0000000..bae82fa --- /dev/null +++ b/middleware.js @@ -0,0 +1,98 @@ +import Crypto from "crypto"; + + + +export function helpers(_, res, next) +{ + res.status = c => + { + res.statusCode = c; + return res; + }; + + res.json = o => + { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(o)); + }; + + res.redirect = u => + { + res.writeHead(302, { "Location": u }); + res.end(); + }; + + next(); +} + +export function json(req, _, next) +{ + req.body = {}; + + const chunks = []; + + req.on("data", chunk => chunks.push(chunk)); + + req.on("end", () => + { + try { req.body = JSON.parse(Buffer.concat(chunks).toString()); } + catch(_) {} + + next(); + }); + + req.on("error", next); +} + +function parseCookies(string) +{ + const cookies = {}; + + for (const pair of string.split(";")) + { + const i = pair.indexOf("="); + if (i < 0) continue; + const key = pair.slice(0, i).trim(); + const value = pair.slice(i + 1); + cookies[key] = value; + } + + return cookies; +} + +export function session({ maxAge = 30 * 24 * 60 * 60, secure = true, httpOnly = true, sameSite = "Strict" } = {}) +{ + const sessionMap = new Map(); + + return (req, res, next) => + { + res.createSession = data => + { + const sessionId = Crypto.randomBytes(36).toString("base64"); + const session = Object.assign({ sessionId }, data); + sessionMap.set(sessionId, session); + + let cookie = [ `sessionId=${sessionId}`, "Path=/" ]; + if (maxAge) cookie.push(`Max-Age=${maxAge}`); + if (secure) cookie.push("Secure"); + if (httpOnly) cookie.push("HttpOnly"); + if (sameSite) cookie.push(`SameSite=${sameSite}`); + + res.setHeader("Set-Cookie", cookie.join("; ")); + + return session; + }; + + res.deleteSession = sessionId => + { + sessionMap.delete(sessionId); + }; + + if (!req.session && typeof req.headers.cookie === "string") + { + req.session = sessionMap.get(parseCookies(req.headers.cookie).sessionId); + } + + next(); + }; +} \ No newline at end of file diff --git a/misc/converter.js b/misc/converter.js deleted file mode 100644 index 89edc77..0000000 --- a/misc/converter.js +++ /dev/null @@ -1,98 +0,0 @@ -const FileSystem = require("fs"); -const SmartBuffer = require("smart-buffer").SmartBuffer; -const PNG = require("pngjs").PNG; - -// copied from canvas.js -function readEvents(path) -{ - const events = []; - - const buf = SmartBuffer.fromBuffer(FileSystem.readFileSync(path)); - - while(buf.remaining() > 0) - { - const x = buf.readUInt16BE(); - const y = buf.readUInt16BE(); - - const color = buf.readBuffer(3).readUintBE(0, 3); - - const userId = Number(buf.readBigUInt64BE()); - const timestamp = buf.readBigUInt64BE().toString(); - - events.push({ x, y, color, userId, timestamp }); - } - - return events; -} - -// same -function writeEvents(events, path) -{ - const buf = new SmartBuffer(); - - for(const event of events) - { - buf.writeUInt16BE(event.x); - buf.writeUInt16BE(event.y); - - const colorBuf = Buffer.alloc(3); - colorBuf.writeUIntBE(event.color, 0, 3); - buf.writeBuffer(colorBuf); - - buf.writeBigInt64BE(BigInt(event.userId)); - buf.writeBigUInt64BE(BigInt(event.timestamp)); - } - - FileSystem.writeFileSync(path, buf.toBuffer()); -} - - - - - - -function eventsToPng(events, path, sizeX, sizeY) -{ - const png = new PNG({ width: sizeX, height: sizeY }); - - for(const event of events) - { - const idx = (event.x + event.y * sizeX) * 4; - - png.data.writeUintBE(event.color, idx, 3); - png.data[idx + 3] = 255; - } - - FileSystem.writeFileSync(path, PNG.sync.write(png)); -} - -function pngToEvents(path, userId) -{ - const png = PNG.sync.read(FileSystem.readFileSync(path)); - - const timestamp = Date.now(); - - const events = []; - - for(let y = 0; y < png.height; ++y) - { - for(let x = 0; x < png.width; ++x) - { - const idx = (x + y * png.width) * 4; - - const color = png.data.readUintBE(idx, 3); - const alpha = png.data[idx + 3]; - - if(alpha === 0) - { - continue; - } - - events.push({ x, y, color, userId, timestamp }); - } - } - - return events; -} - -module.exports = { readEvents, writeEvents, eventsToPng, pngToEvents }; \ No newline at end of file diff --git a/misc/timelapse.js b/misc/timelapse.js deleted file mode 100644 index eed8c88..0000000 --- a/misc/timelapse.js +++ /dev/null @@ -1,102 +0,0 @@ -const Converter = require("./converter"); -const Encoder = require("h264-mp4-encoder"); -const FileSystem = require("fs"); - - - -// copied from canvas.js then modified -class ScaledImageBuffer -{ - constructor(sizeX, sizeY, scale) - { - this.sizeX = sizeX; - this.sizeY = sizeY; - this.scale = scale; - - this.data = Buffer.alloc(sizeX * sizeY * scale * scale * 4, 255); - } - - _calculateOffset(x, y) - { - return (x + y * this.sizeX * this.scale) * 4; - } - - _setColor(x, y, color) - { - this.data.writeUIntBE(color, this._calculateOffset(x, y), 3); - } - - setColor(x, y, color) - { - for(let dx = 0; dx < this.scale; ++dx) - { - for(let dy = 0; dy < this.scale; ++dy) - { - this._setColor(x * this.scale + dx, y * this.scale + dy, color); - } - } - } -} - - - -async function create(width, height, scale, speed, frameRate, pathToCanvas, pathToTimelapse) // TODO: this asumes the events are in sorted order -{ - const encoder = await Encoder.createH264MP4Encoder(); - encoder.width = width * scale; - encoder.height = height * scale; - encoder.frameRate = frameRate; - encoder.quantizationParameter = 10; - encoder.outputFilename = pathToTimelapse; - - encoder.initialize(); - - const pixelEvents = Converter.readEvents(pathToCanvas); - const imageBuffer = new ScaledImageBuffer(width, height, scale); - - const startTimeMs = pixelEvents[0].timestamp; - - let pixelNum = 0; - - let lastFrameNum = 0; - - console.log("0%"); - - for(const pixelEvent of pixelEvents) - { - const timeSinceStartMs = (pixelEvent.timestamp - startTimeMs) / speed; - const frameNum = Math.floor(timeSinceStartMs / 1000 * frameRate); - - const frameDelta = frameNum - lastFrameNum; - - if(frameDelta > 0) // switched to the next frame - { - // if multiple frames happened with no activity, copy the current canvas to these frames - // also set the canvas image for the current frame - for(let skippedFrameNum = lastFrameNum + 1; skippedFrameNum <= frameNum; ++skippedFrameNum) - { - encoder.addFrameRgba(Buffer.from(imageBuffer.data)); - } - } - - imageBuffer.setColor(pixelEvent.x, pixelEvent.y, pixelEvent.color); - - lastFrameNum = frameNum; - - const progress = Math.floor((pixelNum / pixelEvents.length) * 10); - const newProgress = Math.floor((++pixelNum / pixelEvents.length) * 10); - - if(progress !== newProgress) - { - console.log((newProgress * 10) + "%"); - } - } - - encoder.finalize(); - const out = encoder.FS.readFile(encoder.outputFilename); - encoder.delete(); - - FileSystem.writeFileSync(pathToTimelapse, out); -} - -create(500, 500, 4, 10000, 60, "../canvas/current.hst", "./timelapse.mp4"); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index a8c006e..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1331 +0,0 @@ -{ - "name": "maneplace", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "compression": "^1.7.4", - "discord.js": "^14.13.0", - "dotenv": "^16.3.1", - "express": "^4.18.2", - "express-session": "^1.17.3", - "express-ws": "^5.0.2", - "greenlock-express": "^4.0.3", - "pngjs": "^7.0.0", - "session-file-store": "^1.5.0", - "smart-buffer": "^4.2.0" - } - }, - "node_modules/@discordjs/builders": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.6.5.tgz", - "integrity": "sha512-SdweyCs/+mHj+PNhGLLle7RrRFX9ZAhzynHahMCLqp5Zeq7np7XC6/mgzHc79QoVlQ1zZtOkTTiJpOZu5V8Ufg==", - "dependencies": { - "@discordjs/formatters": "^0.3.2", - "@discordjs/util": "^1.0.1", - "@sapphire/shapeshift": "^3.9.2", - "discord-api-types": "0.37.50", - "fast-deep-equal": "^3.1.3", - "ts-mixer": "^6.0.3", - "tslib": "^2.6.1" - }, - "engines": { - "node": ">=16.11.0" - } - }, - "node_modules/@discordjs/collection": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", - "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", - "engines": { - "node": ">=16.11.0" - } - }, - "node_modules/@discordjs/formatters": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.3.2.tgz", - "integrity": "sha512-lE++JZK8LSSDRM5nLjhuvWhGuKiXqu+JZ/DsOR89DVVia3z9fdCJVcHF2W/1Zxgq0re7kCzmAJlCMMX3tetKpA==", - "dependencies": { - "discord-api-types": "0.37.50" - }, - "engines": { - "node": ">=16.11.0" - } - }, - "node_modules/@discordjs/rest": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.0.1.tgz", - "integrity": "sha512-/eWAdDRvwX/rIE2tuQUmKaxmWeHmGealttIzGzlYfI4+a7y9b6ZoMp8BG/jaohs8D8iEnCNYaZiOFLVFLQb8Zg==", - "dependencies": { - "@discordjs/collection": "^1.5.3", - "@discordjs/util": "^1.0.1", - "@sapphire/async-queue": "^1.5.0", - "@sapphire/snowflake": "^3.5.1", - "@vladfrangu/async_event_emitter": "^2.2.2", - "discord-api-types": "0.37.50", - "magic-bytes.js": "^1.0.15", - "tslib": "^2.6.1", - "undici": "5.22.1" - }, - "engines": { - "node": ">=16.11.0" - } - }, - "node_modules/@discordjs/util": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.0.1.tgz", - "integrity": "sha512-d0N2yCxB8r4bn00/hvFZwM7goDcUhtViC5un4hPj73Ba4yrChLSJD8fy7Ps5jpTLg1fE9n4K0xBLc1y9WGwSsA==", - "engines": { - "node": ">=16.11.0" - } - }, - "node_modules/@discordjs/ws": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.0.1.tgz", - "integrity": "sha512-avvAolBqN3yrSvdBPcJ/0j2g42ABzrv3PEL76e3YTp2WYMGH7cuspkjfSyNWaqYl1J+669dlLp+YFMxSVQyS5g==", - "dependencies": { - "@discordjs/collection": "^1.5.3", - "@discordjs/rest": "^2.0.1", - "@discordjs/util": "^1.0.1", - "@sapphire/async-queue": "^1.5.0", - "@types/ws": "^8.5.5", - "@vladfrangu/async_event_emitter": "^2.2.2", - "discord-api-types": "0.37.50", - "tslib": "^2.6.1", - "ws": "^8.13.0" - }, - "engines": { - "node": ">=16.11.0" - } - }, - "node_modules/@discordjs/ws/node_modules/ws": { - "version": "8.14.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", - "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/@greenlock/manager": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@greenlock/manager/-/manager-3.1.0.tgz", - "integrity": "sha512-PBy5CMK+j4oD7sj7hF5qE+xKEOSiiuL2hHd5X5ttEbtnTSDKjNeqbrR5k2ZddwVNdjOVeBIeuqlm81IFZ+Ftew==", - "dependencies": { - "greenlock-manager-fs": "^3.1.0" - } - }, - "node_modules/@root/acme": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@root/acme/-/acme-3.1.0.tgz", - "integrity": "sha512-GAyaW63cpSYd2KvVp5lHLbCWeEhJPKZK9nsJvZJOKsD9Uv88KEttn4FpDZEJ+2q3Jsey0DWpuQ2I4ft0JV9p2w==", - "hasInstallScript": true, - "dependencies": { - "@root/csr": "^0.8.1", - "@root/encoding": "^1.0.1", - "@root/keypairs": "^0.10.0", - "@root/pem": "^1.0.4", - "@root/request": "^1.6.1", - "@root/x509": "^0.7.2" - } - }, - "node_modules/@root/asn1": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@root/asn1/-/asn1-1.0.0.tgz", - "integrity": "sha512-0lfZNuOULKJDJmdIkP8V9RnbV3XaK6PAHD3swnFy4tZwtlMDzLKoM/dfNad7ut8Hu3r91wy9uK0WA/9zym5mig==", - "dependencies": { - "@root/encoding": "^1.0.1" - } - }, - "node_modules/@root/csr": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@root/csr/-/csr-0.8.1.tgz", - "integrity": "sha512-hKl0VuE549TK6SnS2Yn9nRvKbFZXn/oAg+dZJU/tlKl/f/0yRXeuUzf8akg3JjtJq+9E592zDqeXZ7yyrg8fSQ==", - "dependencies": { - "@root/asn1": "^1.0.0", - "@root/pem": "^1.0.4", - "@root/x509": "^0.7.2" - } - }, - "node_modules/@root/encoding": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@root/encoding/-/encoding-1.0.1.tgz", - "integrity": "sha512-OaEub02ufoU038gy6bsNHQOjIn8nUjGiLcaRmJ40IUykneJkIW5fxDqKxQx48cszuNflYldsJLPPXCrGfHs8yQ==" - }, - "node_modules/@root/greenlock": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@root/greenlock/-/greenlock-4.0.5.tgz", - "integrity": "sha512-KR9w3mYE9aH33FCibI8oSYBQV+f7lc3MVPdZ9nxY2tqRLmJp05cMOMz340mtG14VnWDuznLj4TbBj3sHIuoQPQ==", - "dependencies": { - "@greenlock/manager": "^3.1.0", - "@root/acme": "^3.1.0", - "@root/csr": "^0.8.1", - "@root/keypairs": "^0.10.0", - "@root/mkdirp": "^1.0.0", - "@root/request": "^1.6.1", - "acme-http-01-standalone": "^3.0.5", - "cert-info": "^1.5.1", - "greenlock-store-fs": "^3.2.2", - "safe-replace": "^1.1.0" - }, - "bin": { - "greenlock": "bin/greenlock.js" - } - }, - "node_modules/@root/greenlock-express": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@root/greenlock-express/-/greenlock-express-4.0.4.tgz", - "integrity": "sha512-a0/9tHNQwt5Uyod7Mp59lo5UwrjdpOaRgaw8oKvDopVvrjsk1Urdhz9MoLh5p4APt7KhAI/hMFSG4gvczgSwaA==", - "dependencies": { - "@root/greenlock": "^4.0.5", - "redirect-https": "^1.3.1" - } - }, - "node_modules/@root/keypairs": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@root/keypairs/-/keypairs-0.10.3.tgz", - "integrity": "sha512-hbmjVbk/G99Z1XlUzJM4VOAaR8jm4vMnfwpZL301FA24ubJ/bOjj6enCrz9gKsQvBO6RY4/R4MgUuWpXeqeZZQ==", - "dependencies": { - "@root/encoding": "^1.0.1", - "@root/pem": "^1.0.4", - "@root/x509": "^0.7.2" - } - }, - "node_modules/@root/mkdirp": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@root/mkdirp/-/mkdirp-1.0.0.tgz", - "integrity": "sha512-hxGAYUx5029VggfG+U9naAhQkoMSXtOeXtbql97m3Hi6/sQSRL/4khKZPyOF6w11glyCOU38WCNLu9nUcSjOfA==" - }, - "node_modules/@root/pem": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@root/pem/-/pem-1.0.4.tgz", - "integrity": "sha512-rEUDiUsHtild8GfIjFE9wXtcVxeS+ehCJQBwbQQ3IVfORKHK93CFnRtkr69R75lZFjcmKYVc+AXDB+AeRFOULA==" - }, - "node_modules/@root/request": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@root/request/-/request-1.9.2.tgz", - "integrity": "sha512-wVaL9yVV9oDR9UNbPZa20qgY+4Ch6YN8JUkaE4el/uuS5dmhD8Lusm/ku8qJVNtmQA56XLzEDCRS6/vfpiHK2A==" - }, - "node_modules/@root/x509": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@root/x509/-/x509-0.7.2.tgz", - "integrity": "sha512-ENq3LGYORK5NiMFHEVeNMt+fTXaC7DTS6sQXoqV+dFdfT0vmiL5cDLjaXQhaklJQq0NiwicZegzJRl1ZOTp3WQ==", - "dependencies": { - "@root/asn1": "^1.0.0", - "@root/encoding": "^1.0.1" - } - }, - "node_modules/@sapphire/async-queue": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.0.tgz", - "integrity": "sha512-JkLdIsP8fPAdh9ZZjrbHWR/+mZj0wvKS5ICibcLrRI1j84UmLMshx5n9QmL8b95d4onJ2xxiyugTgSAX7AalmA==", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@sapphire/shapeshift": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.2.tgz", - "integrity": "sha512-YRbCXWy969oGIdqR/wha62eX8GNHsvyYi0Rfd4rNW6tSVVa8p0ELiMEuOH/k8rgtvRoM+EMV7Csqz77YdwiDpA==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@sapphire/snowflake": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.1.tgz", - "integrity": "sha512-BxcYGzgEsdlG0dKAyOm0ehLGm2CafIrfQTZGWgkfKYbj+pNNsorZ7EotuZukc2MT70E0UbppVbtpBrqpzVzjNA==", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@types/node": { - "version": "20.7.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.7.1.tgz", - "integrity": "sha512-LT+OIXpp2kj4E2S/p91BMe+VgGX2+lfO+XTpfXhh+bCk2LkQtHZSub8ewFBMGP5ClysPjTDFa4sMI8Q3n4T0wg==" - }, - "node_modules/@types/ws": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.6.tgz", - "integrity": "sha512-8B5EO9jLVCy+B58PLHvLDuOD8DRVMgQzq8d55SjLCOn9kqGyqOvy27exVaTio1q1nX5zLu8/6N0n2ThSxOM6tg==", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@vladfrangu/async_event_emitter": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.2.2.tgz", - "integrity": "sha512-HIzRG7sy88UZjBJamssEczH5q7t5+axva19UbZLO6u0ySbYPrwzWiXBcC0WuHyhKKoeCyneH+FvYzKQq/zTtkQ==", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acme-http-01-standalone": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/acme-http-01-standalone/-/acme-http-01-standalone-3.0.5.tgz", - "integrity": "sha512-W4GfK+39GZ+u0mvxRVUcVFCG6gposfzEnSBF20T/NUwWAKG59wQT1dUbS1NixRIAsRuhpGc4Jx659cErFQH0Pg==" - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "node_modules/asn1.js": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", - "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", - "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" - } - }, - "node_modules/bagpipe": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/bagpipe/-/bagpipe-0.3.5.tgz", - "integrity": "sha512-42sAlmPDKes1nLm/aly+0VdaopSU9br+jkRELedhQxI5uXHgtk47I83Mpmf4zoNTRMASdLFtUkimlu/Z9zQ8+g==" - }, - "node_modules/bn.js": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", - "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" - }, - "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/cert-info": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/cert-info/-/cert-info-1.5.1.tgz", - "integrity": "sha512-eoQC/yAgW3gKTKxjzyClvi+UzuY97YCjcl+lSqbsGIy7HeGaWxCPOQFivhUYm27hgsBMhsJJFya3kGvK6PMIcQ==", - "bin": { - "cert-info": "bin/cert-info.js" - } - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", - "dependencies": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", - "debug": "2.6.9", - "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/compression/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/discord-api-types": { - "version": "0.37.50", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.50.tgz", - "integrity": "sha512-X4CDiMnDbA3s3RaUXWXmgAIbY1uxab3fqe3qwzg5XutR3wjqi7M3IkgQbsIBzpqBN2YWr/Qdv7JrFRqSgb4TFg==" - }, - "node_modules/discord.js": { - "version": "14.13.0", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.13.0.tgz", - "integrity": "sha512-Kufdvg7fpyTEwANGy9x7i4od4yu5c6gVddGi5CKm4Y5a6sF0VBODObI3o0Bh7TGCj0LfNT8Qp8z04wnLFzgnbA==", - "dependencies": { - "@discordjs/builders": "^1.6.5", - "@discordjs/collection": "^1.5.3", - "@discordjs/formatters": "^0.3.2", - "@discordjs/rest": "^2.0.1", - "@discordjs/util": "^1.0.1", - "@discordjs/ws": "^1.0.1", - "@sapphire/snowflake": "^3.5.1", - "@types/ws": "^8.5.5", - "discord-api-types": "0.37.50", - "fast-deep-equal": "^3.1.3", - "lodash.snakecase": "^4.1.1", - "tslib": "^2.6.1", - "undici": "5.22.1", - "ws": "^8.13.0" - }, - "engines": { - "node": ">=16.11.0" - } - }, - "node_modules/discord.js/node_modules/ws": { - "version": "8.14.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", - "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/dotenv": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", - "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/motdotla/dotenv?sponsor=1" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.1", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.5.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/express-session": { - "version": "1.17.3", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", - "integrity": "sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==", - "dependencies": { - "cookie": "0.4.2", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-headers": "~1.0.2", - "parseurl": "~1.3.3", - "safe-buffer": "5.2.1", - "uid-safe": "~2.1.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/express-session/node_modules/cookie": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express-ws": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-5.0.2.tgz", - "integrity": "sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ==", - "dependencies": { - "ws": "^7.4.6" - }, - "engines": { - "node": ">=4.5.0" - }, - "peerDependencies": { - "express": "^4.0.0 || ^5.0.0-alpha.1" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "node_modules/get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", - "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "node_modules/greenlock-express": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/greenlock-express/-/greenlock-express-4.0.3.tgz", - "integrity": "sha512-swTCuaLcZWbwRe9gf28AN1VO2PLiAoPB7StVhEFTBqCqqoHgiu0PJqdz3IbnfXB/8Kckx4sZ+B+Re2BvoCHY4A==", - "dependencies": { - "@root/greenlock": "^4.0.4", - "@root/greenlock-express": "^4.0.3", - "redirect-https": "^1.1.5" - } - }, - "node_modules/greenlock-manager-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/greenlock-manager-fs/-/greenlock-manager-fs-3.1.1.tgz", - "integrity": "sha512-np6qdnPIOZx40PAcSQcqK1eMPWjTKxsxcgRd/OVg0ai49WC1Ds74CTrwmB84pq2n53ikbnDBQFmKEQ4AC0DK8w==", - "dependencies": { - "@root/mkdirp": "^1.0.0", - "safe-replace": "^1.1.0" - } - }, - "node_modules/greenlock-store-fs": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/greenlock-store-fs/-/greenlock-store-fs-3.2.2.tgz", - "integrity": "sha512-92ejLB4DyV4qv/2b6VLGF2nKfYQeIfg3o+e/1cIoYLjlIaUFdbBXkzLTRozFlHsQPZt2ALi5qYrpC9IwH7GK8A==", - "dependencies": { - "@root/mkdirp": "^1.0.0", - "safe-replace": "^1.1.0" - } - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" - }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/kruptein": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/kruptein/-/kruptein-2.2.3.tgz", - "integrity": "sha512-BTwprBPTzkFT9oTugxKd3WnWrX630MqUDsnmBuoa98eQs12oD4n4TeI0GbpdGcYn/73Xueg2rfnw+oK4dovnJg==", - "dependencies": { - "asn1.js": "^5.4.1" - }, - "engines": { - "node": ">6" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==" - }, - "node_modules/magic-bytes.js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.2.0.tgz", - "integrity": "sha512-NFrX8tqiYYrIiMQ3f0UkqG8INKzF8Lz7Jo2c5Ut6b5/Bzp/sr2z6dQoXPSLVDaioM2N6/k+3sDnD/y4Xpx6lSQ==" - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" - }, - "node_modules/pngjs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", - "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", - "engines": { - "node": ">=14.19.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/random-bytes": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", - "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/redirect-https": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/redirect-https/-/redirect-https-1.3.1.tgz", - "integrity": "sha512-Stex2nI+tMpZXKvy++32TiBXEy+GdpAfp3EUnl5BqCiJ5f5i6XvUSFrs7TR7IoRSlthM7ZtD89uYGTtJBXlFYg==", - "dependencies": { - "escape-html": "^1.0.3" - } - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "engines": { - "node": ">= 4" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safe-replace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-replace/-/safe-replace-1.1.0.tgz", - "integrity": "sha512-9/V2E0CDsKs9DWOOwJH7jYpSl9S3N05uyevNjvsnDauBqRowBPOyot1fIvV5N2IuZAbYyvrTXrYFVG0RZInfFw==" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/session-file-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/session-file-store/-/session-file-store-1.5.0.tgz", - "integrity": "sha512-60IZaJNzyu2tIeHutkYE8RiXVx3KRvacOxfLr2Mj92SIsRIroDsH0IlUUR6fJAjoTW4RQISbaOApa2IZpIwFdQ==", - "dependencies": { - "bagpipe": "^0.3.5", - "fs-extra": "^8.0.1", - "kruptein": "^2.0.4", - "object-assign": "^4.1.1", - "retry": "^0.12.0", - "write-file-atomic": "3.0.3" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/ts-mixer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.3.tgz", - "integrity": "sha512-k43M7uCG1AkTyxgnmI5MPwKoUvS/bRvLvUb7+Pgpdlmok8AoqmUaZxUUw8zKM5B1lqZrt41GjYgnvAi0fppqgQ==" - }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, - "node_modules/uid-safe": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", - "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", - "dependencies": { - "random-bytes": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/undici": { - "version": "5.22.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.1.tgz", - "integrity": "sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==", - "dependencies": { - "busboy": "^1.6.0" - }, - "engines": { - "node": ">=14.0" - } - }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "dependencies": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" - } - }, - "node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } -} diff --git a/package.json b/package.json index 1a6bba5..b5d6a68 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,9 @@ { - "name": "maneplace", - "version": "1.0.0", + "type": "module", "dependencies": { - "compression": "^1.7.4", - "discord.js": "^14.13.0", - "dotenv": "^16.3.1", - "express": "^4.18.2", - "express-session": "^1.17.3", - "express-ws": "^5.0.2", - "greenlock-express": "^4.0.3", - "h264-mp4-encoder": "^1.0.12", - "pngjs": "^7.0.0", - "session-file-store": "^1.5.0", - "smart-buffer": "^4.2.0" + "@polka/compression": "^1.0.0-next.25", + "polka": "^0.5.2", + "sirv": "^2.0.4", + "ws": "^8.18.0" } } diff --git a/public/assets/images/arrow.svg b/public/assets/images/arrow.svg new file mode 100644 index 0000000..f32ff84 --- /dev/null +++ b/public/assets/images/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/images/checkmark.svg b/public/assets/images/checkmark.svg new file mode 100644 index 0000000..092e7fd --- /dev/null +++ b/public/assets/images/checkmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/images/cog.svg b/public/assets/images/cog.svg new file mode 100644 index 0000000..209b72b --- /dev/null +++ b/public/assets/images/cog.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/images/cursor.svg b/public/assets/images/cursor.svg new file mode 100644 index 0000000..ab5d426 --- /dev/null +++ b/public/assets/images/cursor.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/dance.gif b/public/assets/images/dance.gif similarity index 100% rename from public/images/dance.gif rename to public/assets/images/dance.gif diff --git a/public/assets/images/discord.svg b/public/assets/images/discord.svg new file mode 100644 index 0000000..a702444 --- /dev/null +++ b/public/assets/images/discord.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/images/dots.svg b/public/assets/images/dots.svg new file mode 100644 index 0000000..5636b20 --- /dev/null +++ b/public/assets/images/dots.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/github.svg b/public/assets/images/github.svg similarity index 100% rename from public/images/github.svg rename to public/assets/images/github.svg diff --git a/public/assets/images/i.svg b/public/assets/images/i.svg new file mode 100644 index 0000000..9357bf3 --- /dev/null +++ b/public/assets/images/i.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/images/info1.svg b/public/assets/images/info1.svg new file mode 100644 index 0000000..2d6794d --- /dev/null +++ b/public/assets/images/info1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/images/info2.svg b/public/assets/images/info2.svg new file mode 100644 index 0000000..443f621 --- /dev/null +++ b/public/assets/images/info2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/images/left-arrow.svg b/public/assets/images/left-arrow.svg new file mode 100644 index 0000000..293f672 --- /dev/null +++ b/public/assets/images/left-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/images/logout.svg b/public/assets/images/logout.svg new file mode 100644 index 0000000..b3c1c85 --- /dev/null +++ b/public/assets/images/logout.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/assets/images/magnifier.svg b/public/assets/images/magnifier.svg new file mode 100644 index 0000000..96c57b8 --- /dev/null +++ b/public/assets/images/magnifier.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/images/mouse.svg b/public/assets/images/mouse.svg new file mode 100644 index 0000000..ebabf7d --- /dev/null +++ b/public/assets/images/mouse.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/assets/images/selector.png b/public/assets/images/selector.png new file mode 100644 index 0000000000000000000000000000000000000000..bb8445df6ccf344c9c2e90e5af90754aa0d73c01 GIT binary patch literal 308 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC#^NA%Cx&(BWL^R}Ea{HEjtmSN z`?>!lvI6;>1s;*b3=DjSL74G){)!Z!;7Lyx$B+!?w>Ja14jJ$?JiI1csnY)-(5A`# zliOCW8HaWqS?09sV|~D-=@(fqttsJ@n5?>QYLJ{!>1oUPskgqoH}w-L?`ByfYu~sl zaaY^YuRUfuQvytcI@0Po&CM?PCY_FbG_Cu{wADvkYt?7gUMYX%ZNr<-xsrVwyBN!aAEJcK9)r@Iro?a zp5L%#-o>_7p)NP3i+jYQXHERiy#G;BwveVRqgX+ymwCk6Z9q>kc)I$ztaD0e0st}q BcA)?O literal 0 HcmV?d00001 diff --git a/public/assets/images/stats.svg b/public/assets/images/stats.svg new file mode 100644 index 0000000..c91a95a --- /dev/null +++ b/public/assets/images/stats.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/images/wrench.svg b/public/assets/images/wrench.svg new file mode 100644 index 0000000..a3278ca --- /dev/null +++ b/public/assets/images/wrench.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/images/x.svg b/public/assets/images/x.svg new file mode 100644 index 0000000..b4e164a --- /dev/null +++ b/public/assets/images/x.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/shaders/col.fsh b/public/assets/shaders/col.fsh new file mode 100644 index 0000000..0e8e0d9 --- /dev/null +++ b/public/assets/shaders/col.fsh @@ -0,0 +1,13 @@ +#version 300 es +precision highp float; + +in vec4 FragColor; + +uniform sampler2D Sampler; + +out vec4 fragColor; + +void main() +{ + fragColor = FragColor; +} \ No newline at end of file diff --git a/public/assets/shaders/col.vsh b/public/assets/shaders/col.vsh new file mode 100644 index 0000000..b2eb003 --- /dev/null +++ b/public/assets/shaders/col.vsh @@ -0,0 +1,17 @@ +#version 300 es + +layout(location=0) in vec3 Position; +layout(location=1) in vec4 Color; + +uniform mat3 ProjectionViewMatrix; + +out vec4 FragColor; + +void main() +{ + vec3 pos = ProjectionViewMatrix * vec3(Position.xy, 1.0); + + gl_Position = vec4(pos.xy, Position.z, 1.0); + + FragColor = Color; +} \ No newline at end of file diff --git a/public/assets/shaders/tex.fsh b/public/assets/shaders/tex.fsh new file mode 100644 index 0000000..4eed6ad --- /dev/null +++ b/public/assets/shaders/tex.fsh @@ -0,0 +1,13 @@ +#version 300 es +precision highp float; + +in vec2 TexCoord; + +uniform sampler2D Sampler; + +out vec4 fragColor; + +void main() +{ + fragColor = texture(Sampler, TexCoord); +} \ No newline at end of file diff --git a/public/assets/shaders/tex.vsh b/public/assets/shaders/tex.vsh new file mode 100644 index 0000000..6cb8b2c --- /dev/null +++ b/public/assets/shaders/tex.vsh @@ -0,0 +1,17 @@ +#version 300 es + +layout(location=0) in vec3 Position; +layout(location=1) in vec2 UV; + +uniform mat3 ProjectionViewMatrix; + +out vec2 TexCoord; + +void main() +{ + vec3 pos = ProjectionViewMatrix * vec3(Position.xy, 1.0); + + gl_Position = vec4(pos.xy, Position.z, 1.0); + + TexCoord = UV; +} \ No newline at end of file diff --git a/public/sounds/cancel.mp3 b/public/assets/sounds/cancel.mp3 similarity index 100% rename from public/sounds/cancel.mp3 rename to public/assets/sounds/cancel.mp3 diff --git a/public/sounds/click.mp3 b/public/assets/sounds/click.mp3 similarity index 100% rename from public/sounds/click.mp3 rename to public/assets/sounds/click.mp3 diff --git a/public/sounds/confetti.mp3 b/public/assets/sounds/confetti.mp3 similarity index 100% rename from public/sounds/confetti.mp3 rename to public/assets/sounds/confetti.mp3 diff --git a/public/sounds/error.mp3 b/public/assets/sounds/error.mp3 similarity index 100% rename from public/sounds/error.mp3 rename to public/assets/sounds/error.mp3 diff --git a/public/sounds/pick.mp3 b/public/assets/sounds/pick.mp3 similarity index 100% rename from public/sounds/pick.mp3 rename to public/assets/sounds/pick.mp3 diff --git a/public/sounds/place.mp3 b/public/assets/sounds/place.mp3 similarity index 100% rename from public/sounds/place.mp3 rename to public/assets/sounds/place.mp3 diff --git a/public/sounds/refresh.mp3 b/public/assets/sounds/refresh.mp3 similarity index 100% rename from public/sounds/refresh.mp3 rename to public/assets/sounds/refresh.mp3 diff --git a/public/sounds/select.mp3 b/public/assets/sounds/select.mp3 similarity index 100% rename from public/sounds/select.mp3 rename to public/assets/sounds/select.mp3 diff --git a/public/elements/color-picker.js b/public/elements/color-picker.js new file mode 100644 index 0000000..2501617 --- /dev/null +++ b/public/elements/color-picker.js @@ -0,0 +1,199 @@ +import { unpackRGB } from "../scripts/util.calc.js"; + + + +function getAt(array, index) +{ + if (index < 0) return array[array.length + index]; // TODO: This will break at large values + else if (index >= array.length) return array[index - array.length]; // same + return array[index]; +} + +export default class ColorPicker extends HTMLElement +{ + #colorButtonContainer = null; + #confirmButton = null; + #cancelButton = null; + + #selectedColor = null; + + constructor() + { + super(); + this.shadow = this.attachShadow({ mode: "closed" }); + this.#render(); + } + + setColors(colors) + { + this.#selectedColor = null; + + this.#colorButtonContainer.textContent = ""; + + for (let i = 0; i < colors.length; ++i) + { + const color = colors[i]; + + const colorButton = document.createElement("div"); + + colorButton.className = "color"; + colorButton.dataset.index = i; + colorButton.dataset.color = color; + colorButton.style.backgroundColor = `rgb(${unpackRGB(color).join(",")})`; + colorButton.addEventListener("click", () => this.#pick(colorButton)); + + this.#colorButtonContainer.appendChild(colorButton); + } + } + + getSelectedColor() + { + return +this.#selectedColor?.dataset.color; + } + + moveSelection(delta) + { + const currentIndex = this.#selectedColor?.dataset.index; + + let nextIndex = delta > 0 ? delta - 1 : delta; // I don't know how to make this better... + if (currentIndex != null) nextIndex = +currentIndex + delta; + + const nextColorButton = getAt(this.#colorButtonContainer.children, nextIndex); + if (nextColorButton) this.#pick(nextColorButton); + } + + #pick(button) + { + const alreadySelected = this.#selectedColor === button; + + if (this.#selectedColor) + { + this.#selectedColor.classList.remove("picked"); + this.#selectedColor = null; + } + + if (!alreadySelected) + { + this.#selectedColor = button; + this.#selectedColor.classList.add("picked"); + } + + this.dispatchEvent(new CustomEvent("pick", { detail: +this.#selectedColor?.dataset.color })); + } + + #confirm() + { + this.dispatchEvent(new CustomEvent("confirm", { detail: +this.#selectedColor?.dataset.color })); + } + + #cancel() + { + this.dispatchEvent(new Event("cancel")); + } + + isActive() + { + return !this.#confirmButton.classList.contains("inactive"); + } + + setActive(active) + { + this.#confirmButton.classList.toggle("inactive", !active); + } + + #render() + { + this.shadow.innerHTML = ` + + + +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+ + `; + + this.#colorButtonContainer = this.shadow.querySelector("#colors"); + this.#confirmButton = this.shadow.querySelector("#confirm"); + this.#cancelButton = this.shadow.querySelector("#cancel"); + + this.#confirmButton.addEventListener("click", this.#confirm.bind(this)); + this.#cancelButton.addEventListener("click", this.#cancel.bind(this)); + } +} + +customElements.define("color-picker", ColorPicker); \ No newline at end of file diff --git a/public/elements/x-tooltip.js b/public/elements/x-tooltip.js new file mode 100644 index 0000000..ca922be --- /dev/null +++ b/public/elements/x-tooltip.js @@ -0,0 +1,96 @@ +function executeTimeout(func, millis) +{ + if (millis) return setTimeout(func, millis); + return void func(); +} + +class Showable +{ + constructor(element) + { + this._element = element; + this._showInMillis = 0; + this._showForMillis = 0; + this._showInTimeout = null; + this._showForTimeout = null; + this._onshow = null; + } + + in(millis) + { + this._showInMillis = millis; + return this; + } + + for(millis) + { + this._showForMillis = millis; + return this; + } + + go() + { + const showInMillis = this._showInMillis; + const showForMillis = this._showForMillis; + + this.clear(); + + const showFor = async () => + { + if (await this._onshow?.() === false) return; + this._element.classList.remove("faded"); + if (showForMillis) this._showForTimeout = setTimeout(() => this._element.classList.add("faded"), showForMillis); + }; + + if (showInMillis) this._showInTimeout = setTimeout(showFor, showInMillis); + else showFor(); + + return this; + } + + onShow(func) + { + this._onshow = func; + return this; + } + + clear() + { + this._element.classList.add("faded"); + this._showInMillis = 0; + this._showForMillis = 0; + this._showInTimeout = clearTimeout(this._showInTimeout); + this._showForTimeout = clearTimeout(this._showForTimeout); + return this; + } +} + +export default class Tooltip extends HTMLElement +{ + #showable = new Showable(this); + + positionAt(x, y) + { + this.style.left = `${x}px`; + this.style.top = `${y}px`; + return this; + } + + positionOnTopOf(target, offsetY = 3) // TODO: setPivot/setAnchor method instead + { + const bounds = target.getBoundingClientRect(); + return this.positionAt(bounds.left + bounds.width / 2, bounds.top - offsetY); + } + + show() + { + return this.#showable; + } + + hide() + { + this.#showable.clear(); + } +} + +customElements.define("x-tooltip", Tooltip); \ No newline at end of file diff --git a/public/global.css b/public/global.css new file mode 100644 index 0000000..4041fa8 --- /dev/null +++ b/public/global.css @@ -0,0 +1,132 @@ +/* + * General styles + */ + +.fs-s +{ + font-size: 13px; +} + +.fs-m +{ + font-size: 16px; +} + +.fs-l +{ + font-size: 18px; +} + +.fs-xl +{ + font-size: 24px; +} + +.fw-l +{ + font-weight: 100; +} + +.fw-b +{ + font-weight: 700; +} + + + +/* + * Site global styles + */ + +.white +{ + background-color: white; +} + +.gray +{ + background-color: #E9EDEE; +} + +.orange +{ + color: white; + background-color: #FF4500; +} + +.orange.inactive +{ + background-color: #2C3C41; +} + +.shadow +{ + box-shadow: 10px 10px #111111C0; +} + +.center, +.button +{ + display: flex; + justify-content: center; + align-items: center; +} + +.border, +.button +{ + border: 3px solid #111111; +} + +.button +{ + pointer-events: all; + user-select: none; +} + +.faded +{ + opacity: 0%; +} + +.hidden +{ + display: none; +} + +@media (hover: hover) +{ + .button:hover + { + cursor: pointer; + filter: brightness(80%); + } +} + +.button:active +{ + transform: scale(0.95); +} + +img +{ + pointer-events: none; +} + +a +{ + color: inherit; + text-decoration: inherit; +} + +dialog +{ + margin: auto; + + overflow: inherit; +} + +dialog::backdrop +{ + background-color: rgba(0, 0, 0, 0.5); +} \ No newline at end of file diff --git a/public/images/arrow.svg b/public/images/arrow.svg deleted file mode 100644 index dd02e92..0000000 --- a/public/images/arrow.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/public/images/checkmark.svg b/public/images/checkmark.svg deleted file mode 100644 index d56cb59..0000000 --- a/public/images/checkmark.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/public/images/discord.svg b/public/images/discord.svg deleted file mode 100644 index f9b2832..0000000 --- a/public/images/discord.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/public/images/dots.svg b/public/images/dots.svg deleted file mode 100644 index bda1852..0000000 --- a/public/images/dots.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/public/images/selector.png b/public/images/selector.png deleted file mode 100644 index 3c34ea233637b40a83f2d86a925db5210d5bb5e3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 207 zcmeAS@N?(olHy`uVBq!ia0vp^JRr=$1|-8uW1a&k#^NA%Cx&(BWL^R}Ea{HEjtmSN z`?>!lvI6;>1s;*b3=Dh+L6~vJ#O${~!4yvy#}JL+sS_J{4=8Z3ES+4krSDRp%vs-z zOnIUGCP%#ESe)gyu2tH$;#Gx_g3|7zYxX7Pd!9I%b&eGHzielF{r5}E+@vPLxk diff --git a/public/images/stats.svg b/public/images/stats.svg deleted file mode 100644 index f0a0cf8..0000000 --- a/public/images/stats.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/public/images/x.svg b/public/images/x.svg deleted file mode 100644 index 11c62da..0000000 --- a/public/images/x.svg +++ /dev/null @@ -1,3 +0,0 @@ - - \ No newline at end of file diff --git a/public/index.html b/public/index.html index dd5f030..4e09889 100644 --- a/public/index.html +++ b/public/index.html @@ -1,100 +1,143 @@ - + - - - + + + + + + + + + ManePlace + + + + - -
- -
-
- -