diff --git a/server/setupSocket.ts b/server/setupSocket.ts new file mode 100644 index 0000000..4b5e831 --- /dev/null +++ b/server/setupSocket.ts @@ -0,0 +1,30 @@ +import {Server} from 'socket.io'; + +import type {HttpServer} from 'vite'; +import type { ClientToServerEvents, ServerClientEvents } from '$types/socket'; + +/** + * Init the global websocket server object + * Intended to be called once, in vite plugin or express middleware, before sveltekit server hook import + */ +export function initServerWebsocket(server: HttpServer | null) { + if (!server) { + console.error('could not start websocket services'); + return; + } + + if (globalThis.myServerSocket) { + globalThis.myServerSocket.close(); + globalThis.myServerSocket = null; + } + const io = new Server(server); + globalThis.myServerSocket = io; +} + +export function getServerSocket() { + const io = globalThis.myServerSocket; + if (!io) { + throw new Error('Websocket server not initialized'); + } + return io; +} \ No newline at end of file diff --git a/server/websocket.ts b/server/websocket.ts deleted file mode 100644 index 3a877bd..0000000 --- a/server/websocket.ts +++ /dev/null @@ -1,68 +0,0 @@ -import {Server} from 'socket.io'; -import type { TabKind } from '$types/tabs'; -import type {HttpServer} from 'vite'; - -import type { ClientToServerEvents, ServerClientEvents } from '$types/socket'; - - -const fileUserMap = new Map< - string, // file path - Set // users connected ->(); - -export function initServerWebsocket(server: HttpServer | null) { - - if (!server) { - console.error('could not start websocket services'); - return; - } - - const io = new Server(server); - - io.on('connection', (socket) => { - console.log('a user connected', socket.id); - - socket.on('tab-opened', (tab: {id: string, kind: TabKind}, callback) => { - if (!socket.id || tab.kind !== 'file') return; - - console.info('[Info] Tab opened by', socket.id, tab); - - if (!fileUserMap.has(tab.id)) { - fileUserMap.set(tab.id, new Set()); - } - const users = fileUserMap.get(tab.id)!; - users.add(socket.id); - - socket.broadcast.emit('users-on-file', { - file: tab.id, - usersNb: fileUserMap.get(tab.id)?.size ?? 0 - }); - console.log(fileUserMap.get(tab.id)?.size, 'users on', tab.id); - callback(fileUserMap.get(tab.id)?.size ?? 0); - }); - - socket.on('tab-closed', (tab: {id: string, kind: TabKind}) => { - if (!socket.id || tab.kind !== 'file') return; - console.info('[Info] Tab closed', tab); - - if (fileUserMap.has(tab.id)) { - const users = fileUserMap.get(tab.id)!; - users.delete(socket.id); - } - - socket.broadcast.emit('users-on-file', { - file: tab.id, - usersNb: fileUserMap.get(tab.id)?.size ?? 0 - }); - console.log(fileUserMap.get(tab.id)?.size, 'users on', tab.id); - }); - - socket.on('disconnect', () => { - for (const [_file, users] of fileUserMap) { - if (users.has(socket.id)) { - users.delete(socket.id); - } - } - }); - }); -} \ No newline at end of file diff --git a/src/app.d.ts b/src/app.d.ts index 0b8eb62..cd00f93 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,20 @@ // See https://svelte.dev/docs/kit/types#app.d.ts + +import type { validateSessionToken } from '$lib/server/auth'; +import type { ClientToServerEvents, ServerClientEvents } from '$types/socket'; +import type { Server } from 'socket.io'; + // for information about these interfaces declare global { + var myServerSocket: Server< + ClientToServerEvents, + ServerClientEvents, + object, + { + auth: Awaited> + tape: string; + } + > | null; namespace App { // interface Error {} interface Locals { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5bfd050..6a5dbad 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,5 +1,8 @@ import type { Handle } from '@sveltejs/kit'; import * as auth from '$lib/server/auth'; +import { registerSvelteKitWebsocket } from './socket.server'; + +registerSvelteKitWebsocket(); const handleAuth: Handle = async ({ event, resolve }) => { const sessionToken = event.cookies.get(auth.sessionCookieName); diff --git a/src/lib/websocket.ts b/src/lib/clientSocket.ts similarity index 50% rename from src/lib/websocket.ts rename to src/lib/clientSocket.ts index 011b35a..8a333b6 100644 --- a/src/lib/websocket.ts +++ b/src/lib/clientSocket.ts @@ -2,29 +2,50 @@ import ioClient, {Socket} from 'socket.io-client'; import { createSubscriber } from 'svelte/reactivity'; import { browser } from '$app/environment'; +import { page } from '$app/state'; +import { getFileTree } from './remotes/files.remote'; + import type { CoreAPI } from '$core/CoreAPI.svelte'; import type { ClientToServerEvents, ServerClientEvents } from '$types/socket'; -let socket: ClientSocket | null = null; +let clientSocket: ClientSocket | null = null; export function getSocket(core: CoreAPI) { if (!browser) return null; - if (!socket) { + if (!clientSocket) { const { protocol, host } = window.location; const url = `${protocol}//${host}`; - const iosocket = ioClient(url); - socket = new ClientSocket(iosocket, core); - } else if (!socket.socket.connected) { - socket.socket.connect(); + + const tape = page.params.tape; + if (!tape) { + console.warn('Could not find tape name'); + return null; + }; + + const socket = ioClient(url, {extraHeaders: { + 'x-tape-name': tape + }}); + clientSocket = new ClientSocket( + core, + socket, + ); + } else if (!clientSocket.socket.connected) { + clientSocket.socket.connect(); } - return socket; + return clientSocket; } -export class ClientSocket { +/** + * Small wrapper around socket.io-client instance to handle reactivity in Svelte + */ +class ClientSocket { #socket; #subscribe; - constructor(socket: Socket, private core: CoreAPI) { + constructor( + private core: CoreAPI, + socket: Socket, + ) { this.#socket = socket; this.#subscribe = createSubscriber((update) => { @@ -36,6 +57,10 @@ export class ClientSocket { } update(); }); + socket.on('remoteModification', async (modifications) => { + await getFileTree().refresh(); + await this.core.syncStates(modifications, 'socket'); + }); }); } @@ -44,4 +69,6 @@ export class ClientSocket { return this.#socket; } -} \ No newline at end of file +} + +export type {ClientSocket}; \ No newline at end of file diff --git a/src/lib/components/SideBar/FileTree/Entry.svelte b/src/lib/components/SideBar/FileTree/Entry.svelte index 4d8c9c6..93b2d26 100644 --- a/src/lib/components/SideBar/FileTree/Entry.svelte +++ b/src/lib/components/SideBar/FileTree/Entry.svelte @@ -82,6 +82,7 @@ { e.stopPropagation(); renaming = true; @@ -92,6 +93,7 @@ { e.stopPropagation(); open = false; diff --git a/src/lib/components/SideBar/Header.svelte b/src/lib/components/SideBar/Header.svelte index 7d59cd5..8eca9ca 100644 --- a/src/lib/components/SideBar/Header.svelte +++ b/src/lib/components/SideBar/Header.svelte @@ -1,5 +1,6 @@
+ {#if !coreAPI.clientSocket?.connected} +
+ +
+ {/if}
{@render header?.()} diff --git a/src/lib/core/CoreAPI.svelte.ts b/src/lib/core/CoreAPI.svelte.ts index d0cfb67..ad29f51 100644 --- a/src/lib/core/CoreAPI.svelte.ts +++ b/src/lib/core/CoreAPI.svelte.ts @@ -5,8 +5,8 @@ import { FoldState } from '$core/internal/FoldState.svelte'; import { getCurrentTape } from '$lib/remotes/files.remote'; import { ViewMap } from '$components/Main/View'; import { Page } from './Page.svelte'; -import { InfoUi } from './InfosUi.svelte'; -import { ClientSocket, getSocket } from '$lib/websocket'; +import { InfoUi, type ModificationOrigin } from './InfosUi.svelte'; +import { type ClientSocket, getSocket } from '$lib/clientSocket'; import type { FileEntry } from '$types/files'; import type { EntryModification } from '$types/modification'; import type { TabFileEntry } from '$types/tabs'; @@ -22,7 +22,7 @@ class CoreAPI { readonly infoUi: InfoUi; - readonly clientSocket: ClientSocket | null; + #clientSocket: ClientSocket | null = null; constructor() { // Internal @@ -34,14 +34,23 @@ class CoreAPI { this.pageStore = new Page(this); this.infoUi = new InfoUi(this); - - this.clientSocket = getSocket(this); } - + async init() { const tapeName = await getCurrentTape(); this.foldState.init(tapeName); this.pageStore.init(); + this.#clientSocket = getSocket(this); + } + + /** + * @reactive call to the socket instance in a reactive context to subscribe to its events and get the latest socket data + */ + get clientSocket() { + if (!this.#clientSocket) { + this.#clientSocket = getSocket(this); + } + return this.#clientSocket?.socket ?? null; } /** @@ -155,13 +164,15 @@ class CoreAPI { * Method to sync changes between the server and the client * @fires {@linkcode CoreAPI.tabs} * @fires {@linkcode CoreAPI.activeTab} + * @fires {@linkcode CoreAPI.activeTabInfos} + * @fires {@linkcode InfoUi.addModificationMessage} */ - async syncStates(modifications: EntryModification[]) { + async syncStates(modifications: EntryModification[], origin: ModificationOrigin = 'local') { await this.#tabStore.syncModifications(modifications); await this.foldState.syncModifications(modifications); for (const mod of modifications) { - this.infoUi.addModificationMessage(mod); + this.infoUi.addModificationMessage(mod, origin); } } @@ -178,6 +189,7 @@ class CoreAPI { async clear() { this.#tabStore.clear(); this.foldState.clear(); + this.#clientSocket?.socket.disconnect(); } } diff --git a/src/lib/core/InfosUi.svelte.ts b/src/lib/core/InfosUi.svelte.ts index 21c93a8..0042c9c 100644 --- a/src/lib/core/InfosUi.svelte.ts +++ b/src/lib/core/InfosUi.svelte.ts @@ -1,6 +1,8 @@ import type { EntryModification } from '$types/modification'; import type { CoreAPI } from './CoreAPI.svelte'; +export type ModificationOrigin = 'local' | 'socket'; + export class InfoUi { #maxMessages = 100; messageQueue: string[] = $state([]); @@ -8,23 +10,23 @@ export class InfoUi { constructor(private coreAPI: CoreAPI) {} - addModificationMessage(mod: EntryModification) { - let message = ''; + addModificationMessage(mod: EntryModification, kind: ModificationOrigin = 'local') { + let message = `[${kind}] `; switch (mod.type) { case 'created': - message = `Created entry "${mod.newPath}"`; + message += `Created entry "${mod.newPath}"`; break; case 'removed': - message = `Deleted entry "${mod.oldPath}"`; + message += `Deleted entry "${mod.oldPath}"`; break; case 'renamed': - message = `Renamed entry "${mod.oldPath}" to "${mod.newPath}"`; + message += `Renamed entry "${mod.oldPath}" to "${mod.newPath}"`; break; case 'moved': - message = `Moved entry "${mod.oldPath}" to "${mod.newPath}"`; + message += `Moved entry "${mod.oldPath}" to "${mod.newPath}"`; break; default: - message = `Unknown modification on entry "${mod.oldPath}"`; + message += `Unknown modification on entry "${mod.oldPath}"`; } this.addMessage(message); } diff --git a/src/lib/core/internal/EntryAPI.svelte.ts b/src/lib/core/internal/EntryAPI.svelte.ts index 3fe639d..ea7fb3e 100644 --- a/src/lib/core/internal/EntryAPI.svelte.ts +++ b/src/lib/core/internal/EntryAPI.svelte.ts @@ -1,4 +1,3 @@ -import { moveEntry, removeEntry, renameEntry } from '$lib/remotes/files.remote'; import type { CoreAPI } from '$core/CoreAPI.svelte'; import type { FolderEntry } from '$types/files'; @@ -12,8 +11,7 @@ export class EntryAPI { * @fires {@linkcode FileAPI.getActiveFile} */ removeEntry = async (entryPath: string) => { - const modifications = await removeEntry({ entryPath: entryPath }); - await this.core.syncStates(modifications); + this.core.clientSocket?.emit('entry-deleted', entryPath); }; /** * Rename a file or folder entry @@ -21,8 +19,7 @@ export class EntryAPI { * @fires {@linkcode FileAPI.getActiveFile} */ renameEntry = async (entryPath: string, newName: string) => { - const modifications = await renameEntry({ entryPath: entryPath, newName: newName }); - await this.core.syncStates(modifications); + this.core.clientSocket?.emit('entry-renamed', {entryPath, newName}); }; /** * Move a file or folder entry to a destination folder @@ -30,7 +27,6 @@ export class EntryAPI { * @fires {@linkcode FileAPI.getActiveFile} */ moveEntry = async (entryPath: string, folderEntry: FolderEntry) => { - const modifications = await moveEntry({ entryPath: entryPath, destFolder: folderEntry.path }); - await this.core.syncStates(modifications); + this.core.clientSocket?.emit('entry-moved', {entryPath, destFolder: folderEntry.path}); }; } \ No newline at end of file diff --git a/src/lib/core/internal/stores/TabStore.svelte.ts b/src/lib/core/internal/stores/TabStore.svelte.ts index dad04bf..03e3adb 100644 --- a/src/lib/core/internal/stores/TabStore.svelte.ts +++ b/src/lib/core/internal/stores/TabStore.svelte.ts @@ -28,7 +28,7 @@ export class TabStore { id: tab.id, }; - this.core.clientSocket?.socket.emit('tab-opened', { + this.core.clientSocket?.emit('tab-opened', { id: tab.id, kind: tab.kind }, (users: number) => { @@ -51,7 +51,7 @@ export class TabStore { this.tabs = afterTabs; - this.core.clientSocket?.socket.emit('tab-closed', { + this.core.clientSocket?.emit('tab-closed', { id: tab!.id, kind: tab!.kind }); diff --git a/src/lib/remotes/files.remote.ts b/src/lib/remotes/files.remote.ts index 2d1c4ad..1c61da4 100644 --- a/src/lib/remotes/files.remote.ts +++ b/src/lib/remotes/files.remote.ts @@ -1,15 +1,14 @@ import z from 'zod'; import path, { dirname, join } from 'node:path'; import { existsSync } from 'node:fs'; -import { writeFile, mkdir, readFile, rm, lstat } from 'node:fs/promises'; -import { move } from 'fs-extra/esm'; +import { writeFile, mkdir, readFile, lstat } from 'node:fs/promises'; import { error } from '@sveltejs/kit'; import { command, form, getRequestEvent, query } from '$app/server'; import { createFileTree } from '$lib'; import { env } from '$env/dynamic/private'; -import { getRelativePathFromTape, getValidPathInTape, sanitizeFileName } from './files.utils'; +import { getRelativePathInTape, getValidPathInTape, sanitizeFileName } from './files.utils'; import type { FileEntry, FsNode } from '$types/files'; -import type { EntryModification } from '$types/modification'; +import { getServerSocket, tapePrefix } from '../../socket.server'; const NOTE_DIR = env.NOTE_DIR; @@ -71,7 +70,7 @@ export const resolveFile = query(z.string(), async (filePath): Promise => { - const saneEntryPath = getValidPathInTape(entryPath); - const saneDestFolder = getValidPathInTape(destFolder); - - const entryName = path.basename(saneEntryPath); - const newEntryPath = path.resolve(saneDestFolder, entryName); - - if (saneEntryPath === newEntryPath) { - return []; - } - - await move(saneEntryPath, newEntryPath); - - const isFolder = await lstat(newEntryPath).then(stats => stats.isDirectory()).catch(() => false); - const modifications = [{ - type: 'moved', - oldPath: path.join(getRelativePathFromTape(saneEntryPath), isFolder ? '/' : ''), - newPath: path.join(getRelativePathFromTape(saneDestFolder), entryName, isFolder ? '/' : ''), - isFolder - }] satisfies EntryModification[]; - console.log(modifications); - - await getFileTree().refresh(); - return modifications; -}); - -export const renameEntry = command(z.object({ - entryPath: z.string(), - newName: z.string() -}), async ({ entryPath, newName }): Promise => { - const saneEntryPath = getValidPathInTape(entryPath); - const sanitizedName = sanitizeFileName(newName); - const targetFolder = path.dirname(saneEntryPath); - const newPath = path.resolve(targetFolder, sanitizedName); - - if (saneEntryPath === newPath) { - return []; - } - - await move(saneEntryPath, newPath); - - const isFolder = await lstat(newPath).then(stats => stats.isDirectory()).catch(() => false); - const modifications = [{ - type: 'renamed', - oldPath: path.join(getRelativePathFromTape(saneEntryPath), isFolder ? '/' : ''), - newPath: path.join(getRelativePathFromTape(newPath), isFolder ? '/' : ''), - isFolder - }] satisfies EntryModification[]; - console.log(modifications); - - await getFileTree().refresh(); - return modifications; -}); - -export const removeEntry = command(z.object({ - entryPath: z.string() -}), async ({ entryPath }): Promise => { - const saneEntryPath = getValidPathInTape(entryPath); - const stats = await lstat(saneEntryPath); - const isFolder = stats.isDirectory(); - - await rm(saneEntryPath, { recursive: true, force: true }); - - const modifications = [{ - type: 'removed', - oldPath: path.join(getRelativePathFromTape(saneEntryPath), isFolder ? '/' : ''), - newPath: '', - isFolder - }] satisfies EntryModification[]; - console.log(modifications); - - await getFileTree().refresh(); - return modifications; -}); \ No newline at end of file diff --git a/src/lib/remotes/files.utils.spec.ts b/src/lib/remotes/files.utils.spec.ts index 26ddc11..c4825ad 100644 --- a/src/lib/remotes/files.utils.spec.ts +++ b/src/lib/remotes/files.utils.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { getFileExtension, getRelativePathFromTape, getValidPathInTape, isValidPath, sanitizeFileName, sanitizeFilePath } from './files.utils'; +import { getFileExtension, getRelativePathInTape, getValidPathInTape, isValidPath, sanitizeFileName, sanitizeFilePath } from './files.utils'; describe('files.utils', () => { describe('sanitizeFileName', () => { @@ -98,7 +98,7 @@ describe('files.utils', () => { it('should return the correct relative path', () => { const input = 'test-data/testing_tape/snup/___dap/t_pe.md'; const expected = 'snup/___dap/t_pe.md'; - expect(getRelativePathFromTape(input)).toBe(expected); + expect(getRelativePathInTape(input)).toBe(expected); }); }); diff --git a/src/lib/remotes/files.utils.ts b/src/lib/remotes/files.utils.ts index 4a5c7fa..21c3f5f 100644 --- a/src/lib/remotes/files.utils.ts +++ b/src/lib/remotes/files.utils.ts @@ -40,6 +40,13 @@ export function getFileExtension(fileName: string): string { return ext.startsWith('.') ? ext.slice(1) : ext; } +export function getValidPathFromTape(tape: string, p: string): string { + const sanePath = path.join(NOTE_DIR, tape, sanitizeFilePath(p)); + if (!isValidPath(sanePath)) { + throw new Error(`Invalid path: ${sanePath}`); + } + return sanePath; +} /** * @throws if no tape param is set * @throws if path is invalid @@ -50,18 +57,17 @@ export function getValidPathInTape(p: string): string { if (!params.tape) { throw new Error('No Tape param'); } - const sanePath = path.join(NOTE_DIR, params.tape, sanitizeFilePath(p)); - if (!isValidPath(sanePath)) { - throw new Error(`Invalid path: ${sanePath}`); - } - return sanePath; + return getValidPathFromTape(params.tape, p); } -export function getRelativePathFromTape(fullPath: string): string { +export function getRelativePathFromTape(tape: string, fullPath: string): string { + const tapePath = path.join(NOTE_DIR, tape); + return path.relative(tapePath, fullPath); +} +export function getRelativePathInTape(fullPath: string): string { const { params } = getRequestEvent(); if (!params.tape) { throw new Error('No Tape param'); } - const tapePath = path.join(NOTE_DIR, params.tape); - return path.relative(tapePath, fullPath); + return getRelativePathFromTape(params.tape, fullPath); } \ No newline at end of file diff --git a/src/lib/server/entries.ts b/src/lib/server/entries.ts new file mode 100644 index 0000000..b3982be --- /dev/null +++ b/src/lib/server/entries.ts @@ -0,0 +1,67 @@ +import path from 'node:path'; +import { lstat } from 'fs/promises'; +import { move, remove } from 'fs-extra/esm'; +import { getRelativePathFromTape, getValidPathFromTape, sanitizeFileName } from '$lib/remotes/files.utils'; +import type { EntryModification } from '$types/modification'; + +export async function deleteEntry(tape: string, entryPath: string): Promise { + const saneEntryPath = getValidPathFromTape(tape, entryPath); + const stats = await lstat(saneEntryPath); + const isFolder = stats.isDirectory(); + + await remove(saneEntryPath); + + return { + type: 'removed', + oldPath: path.join(getRelativePathFromTape(tape, saneEntryPath), isFolder ? '/' : ''), + newPath: '', + isFolder + }; +} + +export async function renameEntry(tape: string, entryPath: string, newName: string): Promise { + const saneEntryPath = getValidPathFromTape(tape, entryPath); + const sanitizedName = sanitizeFileName(newName); + const targetFolder = path.dirname(saneEntryPath); + console.log(targetFolder); + + const newPath = path.resolve(targetFolder, sanitizedName); + + if (saneEntryPath === newPath) { + return null; + } + + await move(saneEntryPath, newPath); + + const isFolder = await lstat(newPath).then(stats => stats.isDirectory()).catch(() => false); + return { + type: 'renamed', + oldPath: path.join(getRelativePathFromTape(tape, saneEntryPath), isFolder ? '/' : ''), + newPath: path.join(getRelativePathFromTape(tape, newPath), isFolder ? '/' : ''), + isFolder + }; +} + +export async function moveEntry(tape: string, entryPath: string, targetFolderPath: string): Promise { + const saneEntryPath = getValidPathFromTape(tape, entryPath); + const saneDestFolder = getValidPathFromTape(tape, targetFolderPath); + + const entryName = path.basename(saneEntryPath); + const newEntryPath = path.resolve(saneDestFolder, entryName); + + if (saneEntryPath === newEntryPath) { + return null; + } + + // Operation + await move(saneEntryPath, newEntryPath); + + // Emit modification + const isFolder = await lstat(newEntryPath).then(stats => stats.isDirectory()).catch(() => false); + return { + type: 'moved', + oldPath: path.join(getRelativePathFromTape(tape, saneEntryPath), isFolder ? '/' : ''), + newPath: path.join(getRelativePathFromTape(tape, newEntryPath), isFolder ? '/' : ''), + isFolder + }; +} \ No newline at end of file diff --git a/src/lib/server/tape.utils.ts b/src/lib/server/tape.utils.ts new file mode 100644 index 0000000..d9b96bb --- /dev/null +++ b/src/lib/server/tape.utils.ts @@ -0,0 +1,24 @@ +import { env } from '$env/dynamic/private'; +import { access } from 'node:fs/promises'; +import path from 'node:path'; + +const NOTE_DIR = env.NOTE_DIR; + +if (!NOTE_DIR) { + throw new Error('NOTE_DIR environment variable is not set'); +} + +export async function tapeExists(tapeName: string): Promise { + const tapePath = path.join(NOTE_DIR, tapeName); + try { + await access(tapePath); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } else { + console.error('Error accessing tape: ', err); + throw new Error('Failed to access tape'); + } + } +} \ No newline at end of file diff --git a/src/routes/(app)/tape/[tape]/BottomNav.svelte b/src/routes/(app)/tape/[tape]/BottomNav.svelte index b54a97d..b807fa1 100644 --- a/src/routes/(app)/tape/[tape]/BottomNav.svelte +++ b/src/routes/(app)/tape/[tape]/BottomNav.svelte @@ -14,9 +14,19 @@
+
+ {#if coreAPI.clientSocket?.connected} + + {coreAPI.activeTabInfos?.usersNb ? `(${coreAPI.activeTabInfos?.usersNb})` : ''} + + + {:else} + + {/if} +
diff --git a/src/routes/(app)/tape/[tape]/StatusBar.svelte b/src/routes/(app)/tape/[tape]/StatusBar.svelte index e3b977c..f3c002b 100644 --- a/src/routes/(app)/tape/[tape]/StatusBar.svelte +++ b/src/routes/(app)/tape/[tape]/StatusBar.svelte @@ -22,13 +22,14 @@
{/key}
- {#if coreAPI.clientSocket?.socket?.connected} - - WS Connected {coreAPI.activeTab ? coreAPI.activeTabInfos?.usersNb : ''} + {#if coreAPI.clientSocket?.connected} + + {coreAPI.activeTabInfos?.usersNb ? `(${coreAPI.activeTabInfos?.usersNb} on file)` : ''} + {:else} - - WS Disconnected + + {/if} {#if settings.saveSet.saving} diff --git a/src/socket.server.ts b/src/socket.server.ts new file mode 100644 index 0000000..514a722 --- /dev/null +++ b/src/socket.server.ts @@ -0,0 +1,192 @@ +import { z } from 'zod'; +import { validateSessionToken } from '$lib/server/auth'; +import { tapeExists } from '$lib/server/tape.utils'; +import { deleteEntry, moveEntry, renameEntry } from '$lib/server/entries'; + +import type { TabKind } from '$types/tabs'; +import type { Socket } from 'socket.io'; + +export const tapePrefix = 'tape:'; + +export function getServerSocket() { + if (!globalThis.myServerSocket) { + throw new Error('Websocket server not initialized'); + } + return globalThis.myServerSocket; +} + +async function validateAndAttachUserInfos(socket: Socket) { + // Check tape + const tape = socket.handshake.headers['x-tape-name']; + if (!tape || typeof tape !== 'string') { + console.warn('Invalid or missing tape name'); + socket.disconnect(true); + return; + } + const tapeExistsResult = await tapeExists(tape); + if (!tapeExistsResult) { + console.warn('Tape does not exist:', tape); + socket.disconnect(true); + return; + } + console.info('Client connected to tape:', tape); + socket.data.tape = tape; + + // Check auth + if (!socket.handshake.headers.cookie) { + console.warn('Missing session cookie'); + socket.disconnect(true); + return; + } + const cookieObj = JSON.parse(JSON.stringify(socket.handshake.headers.cookie.split(';').reduce((acc, cookie) => { + const [key, value] = cookie.trim().split('='); + acc[key] = value; + return acc; + }, {} as Record))); + const auth = await validateSessionToken( + cookieObj['auth-session'] + ); + if (!auth || !auth.session) { + console.warn('Invalid session token'); + socket.disconnect(true); + return; + } + console.info('Session token valid, user authenticated for tape:', tape); + socket.data.auth = auth; +} + + +export function registerSvelteKitWebsocket() { + const io = globalThis.myServerSocket; + if (!io) { + console.error('Websocket server not initialized'); + return; + } + + const fileUserMap = new Map< + string, // file path + Set // users connected + >(); + + io.disconnectSockets(); + io.removeAllListeners(); + + io.on('connection', async (socket) => { + socket.on('disconnect', () => { + for (const [file, users] of fileUserMap) { + if (users.has(socket.id)) { + users.delete(socket.id); + socket.broadcast.emit('users-on-file', { + file, + usersNb: fileUserMap.get(file)?.size ?? 0 + }); + } + } + }); + + await validateAndAttachUserInfos(socket); + + // Join tape room + await socket.join(tapePrefix + socket.data.tape); + console.info(socket.data.auth.user?.username, 'joined tape', socket.data.tape, 'with socket id', socket.id); + + /* * Tab operations handlers * */ + socket.on('tab-opened', (tab: {id: string, kind: TabKind}, callback) => { + if (!socket.id || tab.kind !== 'file') return; + + console.info('[Info] Tab opened by', + socket.id, + tab, + 'on', + [...socket.rooms].filter(r => r.startsWith('tape:')).map(r => r.slice(tapePrefix.length)).join() + ); + + if (!fileUserMap.has(tab.id)) { + fileUserMap.set(tab.id, new Set()); + } + const users = fileUserMap.get(tab.id)!; + users.add(socket.id); + + socket.broadcast.emit('users-on-file', { + file: tab.id, + usersNb: fileUserMap.get(tab.id)?.size ?? 0 + }); + console.log(fileUserMap.get(tab.id)?.size, 'users on', tab.id); + callback(fileUserMap.get(tab.id)?.size ?? 0); + }); + socket.on('tab-closed', (tab: {id: string, kind: TabKind}) => { + if (!socket.id || tab.kind !== 'file') return; + console.info('[Info] Tab closed', tab); + + if (fileUserMap.has(tab.id)) { + const users = fileUserMap.get(tab.id)!; + users.delete(socket.id); + } + + socket.broadcast.emit('users-on-file', { + file: tab.id, + usersNb: fileUserMap.get(tab.id)?.size ?? 0 + }); + console.log(fileUserMap.get(tab.id)?.size, 'users on', tab.id); + }); + + /* * Entry operations handlers * */ + socket.on('entry-deleted', async (entryDeletion: EntryDeletedParams) => { + const parsed = await entryDeletedSchema.safeParseAsync(entryDeletion); + if (!parsed.success) { + console.warn('Invalid entry deletion params:', parsed.error); + return; + } + + const modif = await deleteEntry(socket.data.tape, parsed.data); + + io.to(tapePrefix + socket.data.tape) + .emit('remoteModification', [modif]); + }); + socket.on('entry-renamed', async (params: EntryRenamedParams) => { + const parsed = await entryRenamedSchema.safeParseAsync(params); + if (!parsed.success) { + console.warn('Invalid entry renamed params:', parsed.error); + return; + } + + const modif = await renameEntry(socket.data.tape, parsed.data.entryPath, parsed.data.newName); + if (!modif) { + return; + } + + io.to(tapePrefix + socket.data.tape) + .emit('remoteModification', [modif]); + }); + socket.on('entry-moved', async (params: EntryMovedParams) => { + // Validation + const parsed = await entryMovedSchema.safeParseAsync(params); + if (!parsed.success) { + console.warn('Invalid entry moved params:', parsed.error); + return; + } + + const {entryPath, destFolder} = parsed.data; + const modif = await moveEntry(socket.data.tape, entryPath, destFolder); + if (!modif) { + return; + } + + io.to(tapePrefix + socket.data.tape) + .emit('remoteModification', [modif]); + }); + }); +} + +const entryDeletedSchema = z.string(); +const entryRenamedSchema = z.object({ + entryPath: z.string(), + newName: z.string()}); +const entryMovedSchema = z.object({ + entryPath: z.string(), + destFolder: z.string() +}); + +type EntryDeletedParams = z.infer; +type EntryRenamedParams = z.infer; +type EntryMovedParams = z.infer; \ No newline at end of file diff --git a/src/types/socket/index.ts b/src/types/socket/index.ts index 1b1451a..e996047 100644 --- a/src/types/socket/index.ts +++ b/src/types/socket/index.ts @@ -1,9 +1,18 @@ +import type { EntryModification } from '$types/modification'; import type { TabKind } from '$types/tabs'; export type ClientToServerEvents = { - 'tab-opened': (_params: {id: string, kind: TabKind}, cb: (usersNb: number) => void) => void + 'tab-opened': ( + _params: {id: string, kind: TabKind}, + cb: (usersNb: number) => void + ) => void 'tab-closed': (_params: {id: string, kind: TabKind}) => void; + + 'entry-deleted': (_params: string) => void; + 'entry-renamed': (_params: {entryPath: string, newName: string}) => void; + 'entry-moved': (_params: {entryPath: string, destFolder: string}) => void; }; export type ServerClientEvents = { 'users-on-file': (_params: {file: string, usersNb: number}) => void; + 'remoteModification': (_params: EntryModification[]) => void; } \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 777ab42..96484bb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,15 +3,17 @@ import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vite'; import lucidePreprocess from 'vite-plugin-lucide-preprocess'; -import {initServerWebsocket} from './server/websocket.js'; +import {initServerWebsocket} from './server/setupSocket.js'; -import type {HttpServer} from 'vite'; +import type {HttpServer, Plugin} from 'vite'; -export const webSocketServer = { - name: 'webSocketServer', - configureServer(server: {httpServer: HttpServer | null}) { - initServerWebsocket(server.httpServer); - } +export const webSocketServer: () => Plugin = () => { + return { + name: 'webSocketServer', + configureServer(server: {httpServer: HttpServer | null}) { + initServerWebsocket(server.httpServer); + } + }; }; export default defineConfig({ @@ -19,6 +21,6 @@ export default defineConfig({ tailwindcss(), sveltekit(), lucidePreprocess(), - webSocketServer + webSocketServer() ], });