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

@ -1,80 +0,0 @@
<template>
<TreeRoot v-bind="forward" v-slot="{ flattenItems }" class="list-none select-none text-light-100 dark:text-dark-100 overflow-auto max-h-full">
<DraggableTreeItem v-for="item in flattenItems" :key="item._id" v-bind="item" class="group flex items-center outline-none relative cursor-pointer max-w-full" @select.prevent @toggle.prevent>
<template #default="{ handleToggle, handleSelect, isExpanded, isSelected, isDragging, isDraggedOver }">
<slot :handleToggle="handleToggle"
:handleSelect="handleSelect"
:isExpanded="isExpanded"
:isSelected="isSelected"
:isDragging="isDragging"
:isDraggedOver="isDraggedOver"
:item="item"
/>
</template>
<template #hint="{ instruction }">
<div v-if="instruction">
<slot name="hint" :instruction="instruction" />
</div>
</template>
</DraggableTreeItem>
</TreeRoot>
</template>
<script setup lang="ts" generic="T extends Record<string, any>">
import { useForwardPropsEmits, type FlattenedItem, type TreeRootEmits, type TreeRootProps } from 'radix-vue';
import { type Instruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
const props = defineProps<TreeRootProps<T>>();
const emits = defineEmits<TreeRootEmits<T> & {
'updateTree': [instruction: Instruction, itemId: string, targetId: string];
}>();
defineSlots<{
default: (props: {
handleToggle: () => void,
handleSelect: () => void,
isExpanded: boolean,
isSelected: boolean,
isDragging: boolean,
isDraggedOver: boolean,
item: FlattenedItem<T>,
}) => any,
hint: (props: {
instruction: Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null
}) => any,
}>();
const forward = useForwardPropsEmits(props, emits);
watchEffect((onCleanup) => {
const dndFunction = combine(
monitorForElements({
onDrop(args) {
const { location, source } = args;
if (!location.current.dropTargets.length)
return;
const itemId = source.data.id as string;
const target = location.current.dropTargets[0];
const targetId = target.data.id as string;
const instruction: Instruction | null = extractInstruction(
target.data,
);
if (instruction !== null)
{
emits('updateTree', instruction, itemId, targetId);
}
},
}),
)
onCleanup(() => {
dndFunction();
})
})
</script>

View File

@ -1,140 +0,0 @@
<template>
<TreeItem ref="el" v-bind="forward" v-slot="{ isExpanded, isSelected, isIndeterminate, handleToggle, handleSelect }">
<slot
:is-expanded="isExpanded"
:is-selected="isSelected"
:is-indeterminate="isIndeterminate"
:handle-select="handleSelect"
:handle-toggle="handleToggle"
:isDragging="isDragging"
:isDraggedOver="isDraggedOver"
/>
<div v-if="instruction">
<slot name="hint" :instruction="instruction" />
</div>
</TreeItem>
</template>
<script setup lang="ts" generic="T extends Record<string, any>">
import { useForwardPropsEmits, type FlattenedItem, type TreeItemEmits, type TreeItemProps } from 'radix-vue';
import { draggable, dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { type Instruction, attachInstruction, extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'
const props = defineProps<TreeItemProps<T> & FlattenedItem<T>>();
const emits = defineEmits<TreeItemEmits<T>>();
defineSlots<{
default: (props: {
isExpanded: boolean
isSelected: boolean
isIndeterminate: boolean | undefined
isDragging: boolean
isDraggedOver: boolean
handleToggle: () => void
handleSelect: () => void
}) => any,
hint: (props: {
instruction: Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null
}) => any,
}>()
const forward = useForwardPropsEmits(props, emits);
const element = templateRef('el');
const isDragging = ref(false);
const isDraggedOver = ref(false);
const isInitialExpanded = ref(false);
const instruction = ref<Extract<Instruction, { type: 'reorder-above' | 'reorder-below' | 'make-child' }> | null>(null);
const mode = computed(() => {
if (props.hasChildren)
return 'expanded'
if (props.index + 1 === props.parentItem?.children?.length)
return 'last-in-group'
return 'standard'
});
watchEffect((onCleanup) => {
const currentElement = unrefElement(element) as HTMLElement;
if (!currentElement)
return
const item = { ...props.value, level: props.level, id: props._id }
const expandItem = () => {
if (!element.value?.isExpanded) {
element.value?.handleToggle()
}
}
const closeItem = () => {
if (element.value?.isExpanded) {
element.value?.handleToggle()
}
}
const dndFunction = combine(
draggable({
element: currentElement,
getInitialData: () => item,
onDragStart: () => {
isDragging.value = true
isInitialExpanded.value = element.value?.isExpanded ?? false
closeItem()
},
onDrop: () => {
isDragging.value = false
if (isInitialExpanded.value)
expandItem()
},
}),
dropTargetForElements({
element: currentElement,
getData: ({ input, element }) => {
const data = { id: item.id }
return attachInstruction(data, {
input,
element,
indentPerLevel: 16,
currentLevel: props.level,
mode: mode.value,
block: [],
})
},
canDrop: ({ source }) => {
return source.data.id !== item.id
},
onDrag: ({ self }) => {
instruction.value = extractInstruction(self.data) as typeof instruction.value
},
onDragEnter: ({ source }) => {
if (source.data.id !== item.id) {
isDraggedOver.value = true
}
},
onDragLeave: () => {
isDraggedOver.value = false
instruction.value = null
},
onDrop: ({ location }) => {
isDraggedOver.value = false
instruction.value = null
},
getIsSticky: () => true,
}),
monitorForElements({
canMonitor: ({ source }) => {
return source.data.id !== item.id
},
}),
)
// Cleanup dnd function
onCleanup(() => dndFunction())
})
</script>

View File

@ -84,10 +84,10 @@ export const characterChoicesTable = table("character_choices", {
export const campaignTable = table("campaign", {
id: int().primaryKey({ autoIncrement: true }),
name: text().notNull(),
description: text(),
owner: int().notNull().references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
link: text().notNull(),
status: text({ enum: ['PREPARING', 'PLAYING', 'ARCHIVED'] }).default('PREPARING'),
settings: text({ mode: 'json' }).default('{}'),
inventory: text({ mode: 'json' }).default('[]'),
money: int().default(0),
public_notes: text().default(''),
@ -103,11 +103,11 @@ export const campaignCharactersTable = table("campaign_characters", {
}, (table) => [primaryKey({ columns: [table.id, table.character] })]);
export const campaignLogsTable = table("campaign_logs", {
id: int().references(() => campaignTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
from: int().references(() => campaignCharactersTable.character, { onDelete: 'cascade', onUpdate: 'cascade' }),
target: int().references(() => campaignCharactersTable.character, { onDelete: 'cascade', onUpdate: 'cascade' }),
timestamp: int({ mode: 'timestamp_ms' }).notNull(),
type: text({ enum: ['ITEM', 'CHARACTER', 'PLACE', 'EVENT', 'FIGHT', 'TEXT'] }),
details: text().notNull(),
}, (table) => [primaryKey({ columns: [table.id, table.from, table.timestamp] })]);
}, (table) => [primaryKey({ columns: [table.id, table.target, table.timestamp] })]);
export const usersRelation = relations(usersTable, ({ one, many }) => ({
data: one(usersDataTable, { fields: [usersTable.id], references: [usersDataTable.id], }),
@ -166,5 +166,5 @@ export const campaignCharacterRelation = relations(campaignCharactersTable, ({ o
}));
export const campaignLogsRelation = relations(campaignLogsTable, ({ one }) => ({
campaign: one(campaignTable, { fields: [campaignLogsTable.id], references: [campaignTable.id], }),
character: one(campaignCharactersTable, { fields: [campaignLogsTable.from], references: [campaignCharactersTable.character], }),
character: one(campaignCharactersTable, { fields: [campaignLogsTable.target], references: [campaignCharactersTable.character], }),
}));

View File

@ -16,6 +16,10 @@ onMounted(() => {
{
const campaign = new CampaignSheet(id, user);
container.value.appendChild(campaign.container);
onUnmounted(() => {
campaign.ws?.close();
})
}
});
})

View File

@ -38,7 +38,7 @@ function create()
{
useRequestFetch()('/api/campaign', {
method: 'POST',
body: { id: 'new', name: 'Test', description: '', joinby: 'link' },
body: { name: 'Margooning', public_notes: '', dm_notes: '', settings: {} },
}).then(() => Toaster.add({ duration: 8000, content: 'Campagne créée', type: 'info' })).catch((e) => Toaster.add({ duration: 8000, title: 'Une erreur est survenue', content: e, type: 'error' }))
}
</script>
@ -52,6 +52,7 @@ function create()
<Loading size="large" />
</div>
<template v-else-if="status === 'success' && loggedIn && user">
<div class="flex flex-row items-center w-full"><Button @click="() => create()">Nouvelle campagne</Button></div>
<div v-if="campaigns && campaigns.length > 0" class="flex flex-col gap-4">
<div v-if="valids && valids.length > 0" class="grid p-6 2xl:grid-cols-3 lg:grid-cols-2 grid-cols-1 gap-4 w-full">
<div class="flex flex-col w-[360px] border border-light-35 dark:border-dark-35" v-for="campaign of valids">

View File

@ -28,6 +28,10 @@ onMounted(() => {
{
const character = new CharacterSheet(id, user);
container.value.appendChild(character.container);
onUnmounted(() => {
character.ws?.close();
})
}
});
});

View File

@ -19,7 +19,7 @@ export type Campaign = {
logs: CampaignLog[];
} & CampaignVariables;
export type CampaignLog = {
from: number;
target: number;
timestamp: Serialize<Date>;
type: 'ITEM' | 'CHARACTER' | 'PLACE' | 'EVENT' | 'FIGHT' | 'TEXT';
details: string;

View File

@ -42,6 +42,8 @@ export type Character = {
owner: number;
username?: string;
visibility: "private" | "public";
campaign?: number;
};
export type CharacterVariables = {
health: number;

BIN
db.sqlite

Binary file not shown.

View File

@ -3,7 +3,7 @@ import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './db/schema.ts',
schema: './app/db/schema.ts',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DB_FILE!,

View File

@ -0,0 +1,19 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_campaign` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`owner` integer NOT NULL,
`link` text NOT NULL,
`status` text DEFAULT 'PREPARING',
`inventory` text DEFAULT '[]',
`money` integer DEFAULT 0,
`public_notes` text DEFAULT '',
`dm_notes` text DEFAULT '',
FOREIGN KEY (`owner`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_campaign`("id", "name", "owner", "link", "status", "inventory", "money", "public_notes", "dm_notes") SELECT "id", "name", "owner", "link", "status", "inventory", "money", "public_notes", "dm_notes" FROM `campaign`;--> statement-breakpoint
DROP TABLE `campaign`;--> statement-breakpoint
ALTER TABLE `__new_campaign` RENAME TO `campaign`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
ALTER TABLE `campaign_members` DROP COLUMN `rights`;

View File

@ -0,0 +1,17 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_campaign_logs` (
`id` integer,
`target` integer,
`timestamp` integer NOT NULL,
`type` text,
`details` text NOT NULL,
PRIMARY KEY(`id`, `target`, `timestamp`),
FOREIGN KEY (`id`) REFERENCES `campaign`(`id`) ON UPDATE cascade ON DELETE cascade,
FOREIGN KEY (`target`) REFERENCES `campaign_characters`(`character`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_campaign_logs`("id", "target", "timestamp", "type", "details") SELECT "id", "target", "timestamp", "type", "details" FROM `campaign_logs`;--> statement-breakpoint
DROP TABLE `campaign_logs`;--> statement-breakpoint
ALTER TABLE `__new_campaign_logs` RENAME TO `campaign_logs`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
ALTER TABLE `campaign` ADD `settings` text DEFAULT '{}';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -162,6 +162,20 @@
"when": 1762948869537,
"tag": "0022_warm_bushwacker",
"breakpoints": true
},
{
"idx": 23,
"version": "6",
"when": 1763462411934,
"tag": "0023_chunky_thunderbird",
"breakpoints": true
},
{
"idx": 24,
"version": "6",
"when": 1763479527696,
"tag": "0024_secret_arclight",
"breakpoints": true
}
]
}

View File

@ -172,7 +172,6 @@ export default defineNuxtConfig({
sources: ['/api/__sitemap__/urls']
},
experimental: {
buildCache: true,
componentIslands: {
selectiveClient: true,
},

View File

@ -1,7 +1,7 @@
import { eq } from 'drizzle-orm';
import { z } from 'zod/v4';
import useDatabase from '~/composables/useDatabase';
import { campaignMembersTable, campaignTable } from '~/db/schema';
import { campaignTable } from '~/db/schema';
import { CampaignValidation } from '#shared/campaign.util';
import { cryptURI } from '#shared/general.util';
@ -26,13 +26,15 @@ export default defineEventHandler(async (e) => {
const id = db.transaction((tx) => {
const id = tx.insert(campaignTable).values({
name: body.data.name,
description: body.data.description,
public_notes: body.data.public_notes,
owner: session.user!.id,
dm_notes: body.data.dm_notes,
settings: body.data.settings,
link: '',
}).returning({ id: campaignTable.id }).get().id;
tx.update(campaignTable).set({ link: cryptURI('campaign', id) }).where(eq(campaignTable.id, id)).run();
return id;
});

View File

@ -24,7 +24,7 @@ export default defineEventHandler(async (e) => {
members: { with: { member: { columns: { username: true, id: true } } }, columns: { id: false, user: false } },
characters: { with: { character: { columns: { id: true, name: true, owner: true } } }, columns: { character: false } },
owner: { columns: { username: true, id: true } },
logs: { columns: { details: true, from: true, timestamp: true, type: true }, orderBy: ({ timestamp }) => timestamp },
logs: { columns: { details: true, target: true, timestamp: true, type: true }, orderBy: ({ timestamp }) => timestamp },
},
where: ({ id: _id }) => eq(_id, parseInt(id, 10)),
}).sync();

View File

@ -39,7 +39,9 @@ export default defineEventHandler(async (e) => {
db.transaction((tx) => {
tx.update(campaignTable).set({
name: body.data.name,
description: body.data.description,
public_notes: body.data.public_notes,
dm_notes: body.data.dm_notes,
settings: body.data.settings,
}).where(eq(campaignTable.id, id)).run();
});
}

View File

@ -33,7 +33,7 @@ export default defineEventHandler(async (e) => {
columns: { username: true }
},
campaign: {
columns: { character: false, id: false, },
columns: { character: false, id: true, },
with: {
campaign: {
columns: { owner: true, },
@ -70,6 +70,8 @@ export default defineEventHandler(async (e) => {
owner: character.owner,
username: character.user.username,
visibility: character.visibility,
campaign: character.campaign?.id,
} as Character;
}

View File

@ -1,30 +1,36 @@
const TRIGGER_CHAR = {
PING: String.fromCharCode(0x02),
PONG: String.fromCharCode(0x03),
STATUS: String.fromCharCode(0x04),
};
import type { SocketMessage } from "#shared/websocket.util";
import type { User } from "~/types/auth";
export default defineWebSocketHandler({
message(peer, message) {
switch(message.rawData)
const data = message.json<SocketMessage>();
switch(data.type)
{
case TRIGGER_CHAR.PING:
peer.send(TRIGGER_CHAR.PONG);
return;
default:
case 'PING':
peer.send(JSON.stringify({ type: 'PONG' }));
return;
default: return;
}
},
open(peer) {
async open(peer) {
const id = new URL(peer.request.url).pathname.split('/').slice(-1)[0];
if(!id) return peer.close();
peer.subscribe(`campaigns/${id}`);
peer.publish(`campaigns/${id}`, `${TRIGGER_CHAR.STATUS}`);
const session = await getUserSession(peer);
if(!session ||!session.user) return peer.close();
peer.context.user = session.user;
const topic = `campaigns/${id}`;
peer.subscribe(topic);
peer.publish(topic, { type: 'user', data: [{ user: (peer.context.user as User).id, status: true }] });
peer.send({ type: 'user', data: peer.peers.values().filter(e => e.topics.has(topic)).map(e => ({ user: (e.context.user as User).id, status: true })).toArray() })
},
close(peer, details) {
const id = new URL(peer.request.url).pathname.split('/').slice(-1)[0];
if(!id) return peer.close();
peer.publish(`campaigns/${id}`, false);
peer.publish(`campaigns/${id}`, { type: 'user', data: [{ user: (peer.context.user as User).id, status: false }] });
peer.unsubscribe(`campaigns/${id}`);
}
});

View File

@ -4,6 +4,7 @@ import { defu } from 'defu'
import { createHooks } from 'hookable'
import { useRuntimeConfig } from '#imports'
import type { UserSession, UserSessionRequired } from '~/types/auth'
import type { CompatEvent } from '~~/shared/websocket.util'
export interface SessionHooks {
/**
@ -11,11 +12,11 @@ export interface SessionHooks {
* - Add extra properties to the session
* - Throw an error if the session could not be verified (with a database for example)
*/
fetch: (session: UserSessionRequired, event: H3Event) => void | Promise<void>
fetch: (session: UserSessionRequired, event: H3Event | CompatEvent) => void | Promise<void>
/**
* Called before clearing the session
*/
clear: (session: UserSession, event: H3Event) => void | Promise<void>
clear: (session: UserSession, event: H3Event | CompatEvent) => void | Promise<void>
}
export const sessionHooks = createHooks<SessionHooks>()
@ -25,7 +26,7 @@ export const sessionHooks = createHooks<SessionHooks>()
* @param event The Request (h3) event
* @returns The user session
*/
export async function getUserSession(event: H3Event) {
export async function getUserSession(event: H3Event | CompatEvent) {
const session = await _useSession(event);
if(!session.data || !session.data.id)
@ -41,7 +42,7 @@ export async function getUserSession(event: H3Event) {
* @param data User session data, please only store public information since it can be decoded with API calls
* @see https://github.com/atinux/nuxt-auth-utils
*/
export async function setUserSession(event: H3Event, data: UserSession) {
export async function setUserSession(event: H3Event | CompatEvent, data: UserSession) {
const session = await _useSession(event)
await session.update(defu(data, session.data))
@ -54,7 +55,7 @@ export async function setUserSession(event: H3Event, data: UserSession) {
* @param event The Request (h3) event
* @param data User session data, please only store public information since it can be decoded with API calls
*/
export async function replaceUserSession(event: H3Event, data: UserSession) {
export async function replaceUserSession(event: H3Event | CompatEvent, data: UserSession) {
const session = await _useSession(event)
await session.clear()
@ -68,7 +69,7 @@ export async function replaceUserSession(event: H3Event, data: UserSession) {
* @param event The Request (h3) event
* @returns true if the session was cleared
*/
export async function clearUserSession(event: H3Event) {
export async function clearUserSession(event: H3Event | CompatEvent) {
const session = await _useSession(event)
await sessionHooks.callHookParallel('clear', session.data, event)
@ -85,7 +86,7 @@ export async function clearUserSession(event: H3Event) {
* @param opts.message The message to use for the error (defaults to Unauthorized)
* @see https://github.com/atinux/nuxt-auth-utils
*/
export async function requireUserSession(event: H3Event, opts: { statusCode?: number, message?: string } = {}): Promise<UserSessionRequired> {
export async function requireUserSession(event: H3Event | CompatEvent, opts: { statusCode?: number, message?: string } = {}): Promise<UserSessionRequired> {
const userSession = await getUserSession(event)
if (!userSession.user) {
@ -100,9 +101,9 @@ export async function requireUserSession(event: H3Event, opts: { statusCode?: nu
let sessionConfig: SessionConfig
function _useSession(event: H3Event) {
if (!sessionConfig) {
const runtimeConfig = useRuntimeConfig(event)
function _useSession(event: H3Event | CompatEvent) {
if (!sessionConfig && '__is_event__' in event) {
const runtimeConfig = useRuntimeConfig(event);
sessionConfig = runtimeConfig.session;
}

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);
}
}
}