refactor(collab): remove live document collaboration and enforce strictly read-only sharing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s

This commit is contained in:
sandrodach committed 2026-10-27 17:08:38 +01:00
1 parent aea05e868a
commit 04fa774d10
16 files changed
+179 -3556

No files matched your search

-44
View File
@@ -5,10 +5,6 @@ import {
Document,
DocumentBlock,
DocShare,
DocLockInfo,
BlockLockInfo,
LiveSessionInfo,
DocLocksResponse,
KanbanColumn,
ListItem,
ListShare,
@@ -641,46 +637,6 @@ export const api = {
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
},
async getDocLocks(docId: string): Promise<DocLocksResponse> {
const res = await request<DocLocksResponse>(`/api/v1/documents/${docId}/locks`);
return {
liveSession: res.liveSession || null,
blockLocks: ensureArray(res.blockLocks),
};
},
async lockBlock(docId: string, blockId: string, userColor?: string, ttlSeconds: number = 30): Promise<BlockLockInfo> {
console.log(`[MindSift Lock] Lock anfordern für Block "${blockId}" in Notiz "${docId}"...`);
const res = await request<any>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/lock`, {
method: 'POST',
body: JSON.stringify({ userColor, ttlSeconds, ttl: ttlSeconds }),
});
console.log(`[MindSift Lock] Lock erfolgreich erhalten für "${blockId}":`, res?.lock || res);
return res?.lock || res;
},
async unlockBlock(docId: string, blockId: string): Promise<{ status: string }> {
console.log(`[MindSift Lock] Lock freigeben für Block "${blockId}" in Notiz "${docId}"...`);
const res = await request<{ status: string }>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/unlock`, {
method: 'POST',
});
console.log(`[MindSift Lock] Lock freigegeben für "${blockId}"`);
return res;
},
async startLiveSession(docId: string): Promise<LiveSessionInfo> {
const res = await request<any>(`/api/v1/documents/${docId}/live/start`, {
method: 'POST',
});
return res?.session || res;
},
async endLiveSession(docId: string): Promise<{ status: string }> {
return request<{ status: string }>(`/api/v1/documents/${docId}/live/end`, {
method: 'POST',
});
},
async moveDocument(id: string, folderPath: string): Promise<Document> {
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
method: 'PUT',
-29
View File
@@ -200,35 +200,6 @@ export interface DocShare {
createdAt: string;
}
export interface DocLockInfo {
docId: string;
userId: string;
userName: string;
expiresAt: string;
}
export interface BlockLockInfo {
docId: string;
blockId: string;
userId: string;
userName: string;
userColor?: string;
expiresAt: string;
}
export interface LiveSessionInfo {
docId: string;
initiatorId: string;
initiatorName: string;
startedAt: string;
expiresAt: string;
}
export interface DocLocksResponse {
liveSession?: LiveSessionInfo | null;
blockLocks: BlockLockInfo[];
}
export interface Document {
id: string;
userId: string;
-507
View File
@@ -1,507 +0,0 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { DocumentBlock } from './types';
import { getToken, api } from './api';
export type DocSyncStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'blocked';
export interface RemoteCursor {
clientId: string;
userId: string;
userName: string;
userColor: string;
blockId: string | null;
selectionStart?: number;
selectionEnd?: number;
updatedAt: number;
}
export const CURSOR_COLORS = [
'#3b82f6', // blue
'#10b981', // emerald
'#f59e0b', // amber
'#ec4899', // pink
'#8b5cf6', // purple
'#06b6d4', // cyan
'#f97316', // orange
'#14b8a6', // teal
'#6366f1', // indigo
];
export function getUserColor(idOrName: string): string {
if (!idOrName) return CURSOR_COLORS[0];
let hash = 0;
for (let i = 0; i < idOrName.length; i++) {
hash = (hash << 5) - hash + idOrName.charCodeAt(i);
hash |= 0;
}
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
}
export interface DocSyncEvent {
type:
| 'doc_state'
| 'block_created'
| 'block_updated'
| 'block_deleted'
| 'block_moved'
| 'title_updated'
| 'blocks_replaced'
| 'cursor_moved'
| 'client_left';
docId?: string;
clientId?: string;
userId?: string;
userName?: string;
userColor?: string;
selectionStart?: number;
selectionEnd?: number;
activeCursors?: RemoteCursor[];
title?: string;
block?: DocumentBlock;
blockId?: string;
afterBlockId?: string;
newIndex?: number;
content?: string;
level?: number;
checked?: boolean;
collapsed?: boolean;
blockType?: string;
blocks?: DocumentBlock[];
}
export function useDocumentSync(
docId: string | null,
onRemoteEvent: (evt: DocSyncEvent) => void,
canEdit: boolean = true,
currentUser?: { id: string; email?: string; displayName?: string } | null,
enabled: boolean = false
) {
const [status, setStatus] = useState<DocSyncStatus>(enabled ? 'connecting' : 'idle');
const [remoteCursors, setRemoteCursors] = useState<Record<string, RemoteCursor>>({});
const wsRef = useRef<WebSocket | null>(null);
const clientIdRef = useRef<string>(
`cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
);
const onRemoteEventRef = useRef(onRemoteEvent);
onRemoteEventRef.current = onRemoteEvent;
const docIdRef = useRef(docId);
docIdRef.current = docId;
const canEditRef = useRef(canEdit);
canEditRef.current = canEdit;
const currentUserRef = useRef(currentUser);
currentUserRef.current = currentUser;
const reconnectTimerRef = useRef<number | null>(null);
const fallbackTimerRef = useRef<number | null>(null);
const localUserName =
currentUser?.displayName || currentUser?.email?.split('@')[0] || 'Anonym';
const localUserColor = useMemo(
() => getUserColor(currentUser?.id || clientIdRef.current),
[currentUser?.id]
);
const localUserNameRef = useRef(localUserName);
localUserNameRef.current = localUserName;
const localUserColorRef = useRef(localUserColor);
localUserColorRef.current = localUserColor;
const pendingMessagesQueueRef = useRef<string[]>([]);
const lastCursorSendRef = useRef<number>(0);
const pendingCursorTimerRef = useRef<number | null>(null);
const pendingCursorPayloadRef = useRef<{
blockId: string | null;
selectionStart?: number;
selectionEnd?: number;
} | null>(null);
// Clean up stale remote cursors (> 30s)
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
setRemoteCursors((prev) => {
let hasStale = false;
const next: Record<string, RemoteCursor> = {};
for (const [key, cursor] of Object.entries(prev)) {
if (now - cursor.updatedAt < 30000) {
next[key] = cursor;
} else {
hasStale = true;
}
}
return hasStale ? next : prev;
});
}, 5000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (!docId || !enabled) {
setStatus('idle');
setRemoteCursors({});
pendingMessagesQueueRef.current = [];
if (wsRef.current) {
try {
wsRef.current.close();
} catch {}
wsRef.current = null;
}
return;
}
pendingMessagesQueueRef.current = [];
let isDisposed = false;
let retryCount = 0;
const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
clientIdRef.current = currentClientId;
const connect = () => {
if (isDisposed) return;
if (typeof window !== 'undefined' && sessionStorage.getItem('mindsift_ws_blocked') === 'true') {
console.info('[DocSync] WebSockets im Firmennetzwerk blockiert (Cache). Live-Transport übersprungen.');
setStatus('blocked');
return;
}
const token = getToken();
if (!token) {
setStatus('offline');
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(
docId
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(
currentClientId
)}&userName=${encodeURIComponent(localUserName)}&userColor=${encodeURIComponent(
localUserColor
)}`;
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
let ws: WebSocket;
try {
ws = new WebSocket(wsUrl);
} catch {
setStatus('blocked');
return;
}
wsRef.current = ws;
const handshakeTimer = window.setTimeout(() => {
if (ws.readyState !== WebSocket.OPEN) {
console.warn('[DocSync] WebSocket handshake timed out (corporate firewall/proxy block detected).');
setStatus('blocked');
try {
ws.close();
} catch {}
}
}, 4500);
ws.onopen = () => {
window.clearTimeout(handshakeTimer);
if (isDisposed) {
ws.close();
return;
}
retryCount = 0;
setStatus('connected');
// Flush any queued messages buffered while connecting or reconnecting
if (pendingMessagesQueueRef.current.length > 0) {
const queue = [...pendingMessagesQueueRef.current];
pendingMessagesQueueRef.current = [];
for (const msg of queue) {
try {
ws.send(msg);
} catch (err) {
console.warn('[DocSync] Failed to flush queued message:', err);
}
}
}
};
ws.onmessage = (event) => {
if (isDisposed) return;
try {
const parsed: DocSyncEvent = JSON.parse(event.data);
if (!parsed || !parsed.type) return;
// Echo protection: ignore events originating from our own clientId (except initial doc_state)
if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) {
return;
}
if (parsed.type === 'doc_state') {
if (Array.isArray(parsed.activeCursors)) {
const initialCursors: Record<string, RemoteCursor> = {};
for (const c of parsed.activeCursors) {
if (c.clientId && c.clientId !== currentClientId) {
initialCursors[c.clientId] = {
...c,
updatedAt: c.updatedAt || Date.now(),
};
}
}
setRemoteCursors(initialCursors);
}
} else if (parsed.type === 'cursor_moved') {
if (parsed.clientId && parsed.clientId !== currentClientId) {
setRemoteCursors((prev) => ({
...prev,
[parsed.clientId!]: {
clientId: parsed.clientId!,
userId: parsed.userId || '',
userName: parsed.userName || 'Anonym',
userColor: parsed.userColor || '#3b82f6',
blockId:
parsed.blockId !== undefined
? parsed.blockId
: prev[parsed.clientId!]?.blockId ?? null,
selectionStart: parsed.selectionStart,
selectionEnd: parsed.selectionEnd,
updatedAt: Date.now(),
},
}));
}
} else if (parsed.type === 'client_left') {
if (parsed.clientId) {
setRemoteCursors((prev) => {
if (!prev[parsed.clientId!]) return prev;
const next = { ...prev };
delete next[parsed.clientId!];
return next;
});
}
}
onRemoteEventRef.current(parsed);
} catch {
// Ignore malformed frames
}
};
ws.onerror = () => {
// Handled in onclose
};
ws.onclose = () => {
window.clearTimeout(handshakeTimer);
if (isDisposed) return;
wsRef.current = null;
retryCount += 1;
if (retryCount >= 2) {
setStatus('blocked');
} else {
setStatus('reconnecting');
const delay = Math.min(1000 * Math.pow(1.6, retryCount), 4000);
reconnectTimerRef.current = window.setTimeout(connect, delay);
}
};
};
connect();
return () => {
isDisposed = true;
if (reconnectTimerRef.current !== null) {
window.clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (fallbackTimerRef.current !== null) {
window.clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}
if (pendingCursorTimerRef.current !== null) {
window.clearTimeout(pendingCursorTimerRef.current);
pendingCursorTimerRef.current = null;
}
if (wsRef.current) {
try {
wsRef.current.onopen = null;
wsRef.current.onmessage = null;
wsRef.current.onerror = null;
wsRef.current.onclose = null;
wsRef.current.close();
} catch {
// ignore
}
wsRef.current = null;
}
};
}, [docId, enabled, localUserName, localUserColor]);
const executeSendCursor = useCallback(
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
const ws = wsRef.current;
const activeDocId = docIdRef.current;
if (!activeDocId || !ws || ws.readyState !== WebSocket.OPEN) return;
const payload: DocSyncEvent = {
type: 'cursor_moved',
docId: activeDocId,
clientId: clientIdRef.current,
userId: currentUserRef.current?.id,
userName: localUserNameRef.current,
userColor: localUserColorRef.current,
blockId: blockId || undefined,
selectionStart,
selectionEnd,
};
try {
ws.send(JSON.stringify(payload));
} catch {
// Ephemeral cursor, ignore send failure
}
lastCursorSendRef.current = Date.now();
},
[]
);
const sendCursorMoved = useCallback(
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
if (!docIdRef.current) return;
const now = Date.now();
const elapsed = now - lastCursorSendRef.current;
// When leaving focus / blur (blockId === null), send immediately
if (blockId === null) {
if (pendingCursorTimerRef.current !== null) {
window.clearTimeout(pendingCursorTimerRef.current);
pendingCursorTimerRef.current = null;
}
pendingCursorPayloadRef.current = null;
executeSendCursor(null);
return;
}
// Max once per 100ms
if (elapsed >= 100) {
if (pendingCursorTimerRef.current !== null) {
window.clearTimeout(pendingCursorTimerRef.current);
pendingCursorTimerRef.current = null;
}
executeSendCursor(blockId, selectionStart, selectionEnd);
} else {
pendingCursorPayloadRef.current = { blockId, selectionStart, selectionEnd };
if (pendingCursorTimerRef.current === null) {
pendingCursorTimerRef.current = window.setTimeout(() => {
pendingCursorTimerRef.current = null;
if (pendingCursorPayloadRef.current) {
executeSendCursor(
pendingCursorPayloadRef.current.blockId,
pendingCursorPayloadRef.current.selectionStart,
pendingCursorPayloadRef.current.selectionEnd
);
pendingCursorPayloadRef.current = null;
}
}, 100 - elapsed);
}
}
},
[executeSendCursor]
);
const sendRawEvent = useCallback(
(evt: DocSyncEvent) => {
const activeDocId = docIdRef.current;
const activeCanEdit = canEditRef.current;
if (!activeDocId || !activeCanEdit) return;
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const payload: DocSyncEvent = {
...evt,
docId: activeDocId,
clientId: clientIdRef.current,
};
try {
ws.send(JSON.stringify(payload));
} catch (err) {
console.warn('[DocSync] Failed to send WebSocket message:', err);
}
},
[]
);
const sendBlockCreated = useCallback(
(block: DocumentBlock, afterBlockId?: string, _allBlocks?: DocumentBlock[]) => {
sendRawEvent({
type: 'block_created',
block,
afterBlockId,
});
},
[sendRawEvent]
);
const sendBlockUpdated = useCallback(
(block: DocumentBlock) => {
sendRawEvent({
type: 'block_updated',
blockId: block.id,
block,
});
},
[sendRawEvent]
);
const sendBlockDeleted = useCallback(
(blockId: string, _allBlocks?: DocumentBlock[]) => {
sendRawEvent({
type: 'block_deleted',
blockId,
});
},
[sendRawEvent]
);
const sendBlockMoved = useCallback(
(blockId: string, newIndex: number, level?: number, _allBlocks?: DocumentBlock[]) => {
sendRawEvent({
type: 'block_moved',
blockId,
newIndex,
level,
});
},
[sendRawEvent]
);
const sendTitleUpdated = useCallback(
(title: string) => {
sendRawEvent({
type: 'title_updated',
title,
});
},
[sendRawEvent]
);
const sendBlocksReplaced = useCallback(
(blocks: DocumentBlock[]) => {
sendRawEvent({
type: 'blocks_replaced',
blocks,
});
},
[sendRawEvent]
);
return {
status,
clientId: clientIdRef.current,
remoteCursors,
sendCursorMoved,
sendBlockCreated,
sendBlockUpdated,
sendBlockDeleted,
sendBlockMoved,
sendTitleUpdated,
sendBlocksReplaced,
};
}