WebSocket API, new ID/encrypt/decrypt algorithm.

This commit is contained in:
Clément Pons
2025-11-18 17:54:11 +01:00
parent 2a158be3fa
commit 7a40f8abac
26 changed files with 2303 additions and 293 deletions

View File

@@ -8,11 +8,14 @@ import { tooltip } from "#shared/floating.util";
import markdown from "#shared/markdown.util";
import { preview } from "./proses";
import { format } from "./general.util";
import { Socket } from "#shared/websocket.util";
export const CampaignValidation = z.object({
id: z.number(),
name: z.string().nonempty(),
description: z.string()
public_notes: z.string(),
dm_notes: z.string(),
settings: z.object(),
});
class CharacterPrinter
@@ -41,29 +44,39 @@ class CharacterPrinter
}
}
type PlayerState = {
status: boolean;
statusDOM: HTMLElement;
statusTooltip: Text;
dom: HTMLElement;
user: { id: number, username: string };
};
const activity = {
online: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content bg-light-green dark:bg-dark-green border-light-green dark:border-dark-green', text: 'En ligne' },
afk: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content bg-light-yellow dark:bg-dark-yellow border-light-yellow dark:border-dark-yellow', text: 'Inactif' },
offline: { class: 'absolute -bottom-1 -right-1 rounded-full w-3 h-3 block border-2 box-content border-dashed border-light-50 dark:border-dark-50 bg-light-0 dark:bg-dark-0', text: 'Hors ligne' },
}
function defaultPlayerState(user: { id: number, username: string }): PlayerState
{
const statusTooltip = text('Absent');
const statusTooltip = text(activity.offline.text), statusDOM = span(activity.offline.class);
return {
status: false,
statusDOM: tooltip(span('rounded-full w-3 h-3 block border-light-50 dark:border-dark-50 border-2 border-dashed'), statusTooltip, 'right'),
statusDOM,
statusTooltip,
dom: div('w-8 h-8 relative flex items-center justify-center border border-light-40 dark:border-dark-40 box-content rounded-full', [ tooltip(icon('radix-icons:person', { width: 24, height: 24, class: 'text-light-70 dark:text-dark-70' }), user.username, 'bottom'), tooltip(statusDOM, statusTooltip, 'bottom') ]),
user
}
}
export class CampaignSheet
{
user: ComputedRef<User | null>;
campaign?: Campaign;
container: HTMLElement = div('flex flex-col flex-1 h-full w-full items-center justify-start');
dm!: PlayerState;
players!: Array<PlayerState>;
characters!: Array<CharacterPrinter>;
private user: ComputedRef<User | null>;
private campaign?: Campaign;
container: HTMLElement = div('flex flex-col flex-1 h-full w-full items-center justify-start gap-6');
private dm!: PlayerState;
private players!: Array<PlayerState>;
private characters!: Array<CharacterPrinter>;
ws?: Socket;
constructor(id: string, user: ComputedRef<User | null>)
{
this.user = user;
@@ -76,6 +89,26 @@ export class CampaignSheet
this.dm = defaultPlayerState(campaign.owner);
this.players = campaign.members.map(e => defaultPlayerState(e.member));
this.characters = campaign.characters.map(e => new CharacterPrinter(e.character!.id, e.character!.name));
this.ws = new Socket(`/ws/campaign/${id}`, true);
this.ws.handleMessage<{ user: number, status: boolean }[]>('user', (users) => {
users.forEach(user => {
if(this.dm.user.id === user.user)
{
this.dm.statusTooltip.textContent = activity[user.status ? 'online' : 'offline'].text;
this.dm.statusDOM.className = activity[user.status ? 'online' : 'offline'].class;
}
else
{
const player = this.players.find(e => e.user.id === user.user)
if(player)
{
player.statusTooltip.textContent = activity[user.status ? 'online' : 'offline'].text;
player.statusDOM.className = activity[user.status ? 'online' : 'offline'].class;
}
}
})
});
document.title = `d[any] - Campagne ${campaign.name}`;
this.render();
@@ -102,11 +135,11 @@ export class CampaignSheet
if(!campaign)
return;
this.container.replaceChildren(div('grid grid-cols-3 gap-2 py-4', [
this.container.replaceChildren(div('grid grid-cols-3 gap-2', [
div('flex flex-row gap-2 items-center py-2', [
tooltip(div('w-8 h-8 border border-light-40 dark:border-dark-40 box-content rounded-full'), this.dm.user.username, "bottom"),
this.dm.dom,
div('border-l h-full w-0 border-light-40 dark:border-dark-40'),
div('flex flex-row gap-1', this.players.map(e => tooltip(div('w-8 h-8 border border-light-40 dark:border-dark-40 box-content rounded-full'), e.user.username, "bottom"))),
div('flex flex-row gap-1', this.players.map(e => e.dom)),
]),
div('flex flex-1 flex-col items-center justify-center gap-2', [
span('text-2xl font-serif font-bold italic', campaign.name),

View File

@@ -10,6 +10,7 @@ import markdown from "#shared/markdown.util";
import { getText } from "#shared/i18n";
import type { User } from "~/types/auth";
import { MarkdownEditor } from "#shared/editor.util";
import { Socket } from "#shared/websocket.util";
const config = characterConfig as CharacterConfig;
@@ -1293,10 +1294,12 @@ const subnameFactory = (item: ItemConfig, state?: ItemState): string[] => {
}
export class CharacterSheet
{
user: ComputedRef<User | null>;
character?: CharacterCompiler;
private user: ComputedRef<User | null>;
private character?: CharacterCompiler;
container: HTMLElement = div('flex flex-1 h-full w-full items-start justify-center');
tabs?: HTMLDivElement & { refresh: () => void };
private tabs?: HTMLDivElement & { refresh: () => void };
ws?: Socket;
constructor(id: string, user: ComputedRef<User | null>)
{
this.user = user;
@@ -1307,6 +1310,11 @@ export class CharacterSheet
{
this.character = new CharacterCompiler(character);
if(character.campaign)
{
this.ws = new Socket(`/ws/campaign/${character.campaign}`, true);
}
document.title = `d[any] - ${character.name}`;
load.remove();

View File

@@ -1,4 +1,5 @@
const ID_SIZE = 32;
const ID_SIZE = 24;
const URLSafeCharacters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.~';
export function unifySlug(slug: string | string[]): string
{
@@ -7,9 +8,39 @@ export function unifySlug(slug: string | string[]): string
export function getID()
{
for (var id = [], i = 0; i < ID_SIZE; i++)
id.push((36 * Math.random() | 0).toString(36));
id.push(URLSafeCharacters[URLSafeCharacters.length * Math.random() | 0]);
return id.join("");
}
export function encodeBase(value: string): string
{
if(value === '')
return '';
const buffer = Buffer.from(value, 'utf8'), base = BigInt(URLSafeCharacters.length);
let nb = BigInt('0x' + buffer.toHex()), result = [];
while(nb > 0)
{
const remaining = nb % base;
result.push(URLSafeCharacters[Number(remaining)]);
nb = (nb - remaining) / base;
}
const text = result.reverse().join('');
return text;
}
export function decodeBase(value: string): string
{
if(value === '')
return '';
const result = '', base = BigInt(URLSafeCharacters.length);
let nb = BigInt(0);
value.split('').forEach(e => nb = nb * base + BigInt(URLSafeCharacters.indexOf(e)));
const text = Buffer.from(nb.toString(16), 'hex').toString('utf8');
return text;
}
export function group<
T,
K extends keyof T,
@@ -56,7 +87,7 @@ export function format(date: Date, template: string): string
for(const key of keys)
{
template = template.replaceAll(key, () => transforms[key]!(date));
template = key in transforms ? template.replaceAll(key, () => transforms[key]!(date)) : template;
}
return template;
@@ -70,33 +101,29 @@ export function lerp(x: number, a: number, b: number): number
return (1-x)*a+x*b;
}
// The value position is randomized
// The metadata separator is randomized as a letter (to avoid collision with numbers)
// The URI is (| == picked separator) |v_length|first part of the hash + seed + second part of the hash|v_pos|seed as hex.
// Every number are converted to string as hexadecimal values
export function cryptURI(key: string, value: number, seed?: number): string
// The metadata separator is randomized from the URLSafeCharacters set
// The URI is (| == picked separator) |v_length|first part of the hash + value + second part of the hash|v_pos as hex.
export function cryptURI(key: string, value: number): string
{
const _seed = seed ?? Date.now();
const hash = Bun.hash(key + value.toString(), _seed).toString(16);
const hash = Bun.hash.crc32(key + value.toString()).toString(16);
const pos = Math.floor(Math.random() * hash.length);
const separator = String.fromCharCode(Math.floor(Math.random() * 26 + 96));
const separator = URLSafeCharacters[URLSafeCharacters.length * Math.random() | 0]!;
return Bun.zstdCompressSync(separator + value.toString(16).length + separator + hash.substring(0, pos) + value + hash.substring(pos) + separator + pos + separator + _seed.toString(16), { level: 1 }).toString('base64');
return encodeBase(separator + value.toString().length + separator + hash.substring(0, pos) + value + hash.substring(pos) + separator + pos);
}
export function decryptURI(uri: string, key: string): number | undefined
{
const decoder = new TextDecoder();
const _uri = decoder.decode(Bun.zstdDecompressSync(Buffer.from(uri, 'base64')));
const _uri = decodeBase(uri);
const separator = _uri.charAt(0);
const length = parseInt(_uri.substring(1, _uri.indexOf(separator, 1)), 10);
const seed = parseInt(_uri.substring(_uri.lastIndexOf(separator) + 1), 16);
const pos = parseInt(_uri.substring(_uri.lastIndexOf(separator, _uri.length - seed.toString(16).length - 2) + 1, _uri.lastIndexOf(separator)), 10);
const _hash = _uri.substring(2 + length.toString(10).length, _uri.length - (2 + seed.toString(16).length + pos.toString(10).length));
const pos = parseInt(_uri.substring(_uri.lastIndexOf(separator) + 1), 16);
const _hash = _uri.substring(_uri.lastIndexOf(separator, _uri.length - pos.toString(16).length - 2) + 1, _uri.lastIndexOf(separator));
const value = _hash.substring(pos, pos + length);
const hash = _hash.substring(0, pos) + _hash.substring(pos + length);
if(Bun.hash(key + value, seed).toString(16) === hash)
if(Bun.hash.crc32(key + value).toString(16) === hash)
return parseInt(value, 10);
else
return undefined;

77
shared/websocket.util.ts Normal file
View File

@@ -0,0 +1,77 @@
export type SocketMessage = {
type: string;
data: any;
}
export type CompatEvent = {
request: {
headers: Headers;
};
context: any;
} | {
headers: Headers;
context: any;
};
export class Socket
{
private _frequency?: number;
private _timeout?: number;
private _heartbeat: boolean = false;
private _heartbeatWaiting = false;
private _timeoutTimer?: NodeJS.Timeout;
private _ws: WebSocket;
private _handlers: Map<string, (data: any) => void> = new Map();
constructor(url: string, heartbeat?: true | { timeout?: number, frequency?: number })
{
this._frequency = heartbeat === true ? 10000 : heartbeat?.frequency ?? 10000;
this._timeout = heartbeat === true ? 100000 : heartbeat?.timeout ?? 100000;
this._heartbeat = heartbeat !== undefined;
this._ws = new WebSocket(`${location.protocol.endsWith('s:') ? 'wss' : 'ws'}://${location.host}${url.startsWith('/') ? url : '/' + url}`);
this._ws.addEventListener('open', (e) => {
console.log(`[ws] Connected to ${this._ws.url}`);
this._heartbeat && setTimeout(() => this.heartbeat(), this._frequency);
});
this._ws.addEventListener('close', (e) => {
console.log(`[ws] Disconnected from ${this._ws.url} (code: ${e.code}, reason: ${e.reason}, ${e.wasClean ? 'clean close' : 'dirty close'})`)
this._heartbeatWaiting = false;
this._timeoutTimer && clearTimeout(this._timeoutTimer);
});
this._ws.addEventListener('message', (e) => {
const data = JSON.parse(e.data) as SocketMessage;
switch(data.type)
{
case 'PONG':
if(this._heartbeatWaiting)
{
this._heartbeatWaiting = false;
this._timeoutTimer && clearTimeout(this._timeoutTimer);
this._heartbeat && setTimeout(() => this.heartbeat(), this._frequency);
}
return;
default: return this._handlers.has(data.type) && queueMicrotask(() => this._handlers.get(data.type)!(data.data));
}
});
}
public handleMessage<T>(type: string, callback: (data: T) => void)
{
this._handlers.set(type, callback);
}
public close()
{
this._ws.close(1000);
}
private heartbeat()
{
if(this._heartbeat && this._ws.readyState === WebSocket.OPEN)
{
this._ws.send(JSON.stringify({ type: 'PING' }));
this._heartbeatWaiting = true;
this._timeoutTimer = setTimeout(() => this._ws.close(3000, 'Timeout'), this._timeout);
}
}
}