You've already forked obsidian-visualiser
WebSocket API, new ID/encrypt/decrypt algorithm.
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
77
shared/websocket.util.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user