UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

594 lines (593 loc) 23.1 kB
import { createHash } from 'crypto'; import { ErrorCode } from '@directus/errors'; import { WS_TYPE } from '@directus/types'; import { ACTION, COLORS, } from '@directus/types/collab'; import { isDetailedUpdateSyntax, isObject } from '@directus/utils'; import { isEqual, random, uniq } from 'lodash-es'; import getDatabase from '../../database/index.js'; import { useLogger } from '../../logger/index.js'; import { getSchema } from '../../utils/get-schema.js'; import { getService } from '../../utils/get-service.js'; import { isFieldAllowed } from '../../utils/is-field-allowed.js'; import { Messenger } from './messenger.js'; import { sanitizePayload } from './payload-permissions.js'; import { useStore } from './store.js'; import { verifyPermissions } from './verify-permissions.js'; /** * Store and manage all active collaborative editing rooms */ export class RoomManager { rooms = {}; messenger; constructor(messenger = new Messenger()) { this.messenger = messenger; } /** * Create a new collaborative editing room or return an existing one matching collection, item and version. */ async createRoom(collection, item, version, initialChanges) { // Deterministic UID ensures clients on same resource join same room const uid = getRoomHash(collection, item, version); if (!(uid in this.rooms)) { const room = new Room(uid, collection, item, version, initialChanges, this.messenger); this.rooms[uid] = room; await room.ensureInitialized(); await this.messenger.registerRoom(uid); } this.messenger.setRoomListener(uid, (message) => { if (message.action === 'close') { this.rooms[uid]?.dispose(); delete this.rooms[uid]; } }); return this.rooms[uid]; } /** * Remove a room from local memory */ removeRoom(uid) { if (this.rooms[uid]) { delete this.rooms[uid]; } } /** * Get an existing room by UID. * If the room is not part of the local rooms, it will be loaded from shared memory. * The room will not be persisted in local memory. */ async getRoom(uid) { let room = this.rooms[uid]; if (!room) { const store = useStore(uid); // Loads room from shared memory room = await store(async (store) => { if (!(await store.has('uid'))) return; const collection = await store.get('collection'); const item = await store.get('item'); const version = await store.get('version'); const changes = await store.get('changes'); const room = new Room(uid, collection, item, version, changes, this.messenger); this.rooms[uid] = room; return room; }); } await room?.ensureInitialized(); return room; } /** * Get all rooms a client is currently in from local memory */ async getClientRooms(uid) { const rooms = []; for (const room of Object.values(this.rooms)) { if (await room.hasClient(uid)) { rooms.push(room); } } return rooms; } /** * Returns all clients that are part of a room in the local memory */ async getLocalRoomClients() { return (await Promise.all(Object.values(this.rooms).map((room) => room.getClients()))).flat(); } /** * Remove empty rooms from local memory */ async cleanupRooms(uids) { const rooms = uids ? uids.map((uid) => this.rooms[uid]).filter((room) => !!room) : Object.values(this.rooms); for (const room of rooms) { if (await room.close()) { delete this.rooms[room.uid]; useLogger().debug(`[Collab] Closed inactive room ${room.getDisplayName()}`); } } } /** * Forcefully close all local rooms and notify clients. */ async terminateAll() { const rooms = Object.values(this.rooms); for (const room of rooms) { await room.close({ force: true, reason: { type: WS_TYPE.COLLAB, action: ACTION.SERVER.ERROR, code: ErrorCode.ServiceUnavailable, message: 'Collaborative editing is disabled', }, terminate: true, }); delete this.rooms[room.uid]; } useLogger().debug(`[Collab] Forcefully closed all ${rooms.length} active rooms`); } } const roomDefaults = { changes: {}, clients: [], focuses: {}, }; /** * Represents a single collaborative editing room for a specific item */ export class Room { uid; collection; item; version; initialChanges; messenger; store; onUpdateHandler; onDeleteHandler; constructor(uid, collection, item, version, initialChanges, messenger = new Messenger()) { this.uid = uid; this.collection = collection; this.item = item; this.version = version; this.initialChanges = initialChanges; this.messenger = messenger; this.store = useStore(uid, roomDefaults); this.onUpdateHandler = async (meta) => { const { keys } = meta; const target = this.version ?? this.item; // Skip updates for different items (singletons have item=null) if (target !== null && !keys.some((key) => String(key) === String(target))) return; try { const schema = await getSchema(); const result = await (async () => { if (this.version) { const service = getService('directus_versions', { schema }); const versionData = await service.readOne(this.version); return versionData['delta'] ?? {}; } const service = getService(collection, { schema }); return item ? await service.readOne(item) : await service.readSingleton({}); })(); const clients = await this.store(async (store) => { let changes = await store.get('changes'); changes = Object.fromEntries(Object.entries(changes).filter(([key, value]) => { // Always clear relational fields after save to prevent duplicate creation if (isDetailedUpdateSyntax(value)) return false; // Partial delta for versions and full record for regular items if (!(key in result)) return !!this.version; // For primitives, only clear if saved value matches pending change if (isEqual(value, result[key])) return false; // Reconcile M2O objects with the PK in result if (isObject(value)) { const relation = schema.relations.find((r) => r.collection === collection && r.field === key); if (relation) { const pkField = schema.collections[relation.related_collection]?.primary; if (pkField && isEqual(value[pkField], result[key])) { return false; } } } return true; })); await store.set('changes', changes); return await store.get('clients'); }); for (const client of clients) { this.send(client.uid, { action: ACTION.SERVER.SAVE, }); } } catch (err) { useLogger().error(err, `[Collab] External update handler failed for ${collection}/${item ?? 'singleton'}`); } }; this.onDeleteHandler = async (meta) => { try { const { keys, collection: eventCollection } = meta; // Skip deletions for different versions const isVersionMatch = this.version && eventCollection === 'directus_versions' && keys.some((key) => String(key) === this.version); // Skip deletions for different items (singletons have item=null) const isItemMatch = eventCollection === collection && (item === null || keys.some((key) => String(key) === String(item))); if (!isVersionMatch && !isItemMatch) return; await this.sendAll({ action: ACTION.SERVER.DELETE, }); await this.close({ force: true }); } catch (err) { useLogger().error(err, `[Collab] External delete handler failed for ${collection}/${item ?? 'singleton'}`); } }; } /** * Ensures that foundational room state (metadata) exists in shared memory even after restarts */ async ensureInitialized() { await this.store(async (store) => { if (await store.has('uid')) return; await store.set('uid', this.uid); await store.set('collection', this.collection); await store.set('item', this.item); await store.set('version', this.version); await store.set('changes', this.initialChanges ?? {}); await store.set('clients', []); await store.set('focuses', {}); }); } getDisplayName() { return [this.collection, this.item, this.version].filter(Boolean).join(':'); } async getClients() { return this.store((store) => store.get('clients')); } async getFocuses() { return this.store((store) => store.get('focuses')); } async getChanges() { return this.store((store) => store.get('changes')); } async hasClient(id) { return this.store(async (store) => { const clients = await store.get('clients'); return clients.findIndex((c) => c.uid === id) !== -1; }); } async getFocusByUser(id) { return this.store(async (store) => (await store.get('focuses'))[id]); } async getFocusByField(field) { return this.store(async (store) => { const focuses = await store.get('focuses'); return Object.entries(focuses).find(([_, f]) => f === field)?.[0]; }); } /** * Client requesting to join a room. If the client hasn't entered the room already, add a new client. * Otherwise all users just will be informed again that the user has joined. */ async join(client, color) { this.messenger.addClient(client); let added = false; let clientColor; if (!(await this.hasClient(client.uid))) { await this.store(async (store) => { const clients = await store.get('clients'); added = true; const existingColors = clients.map((c) => c.color); const colorsAvailable = COLORS.filter((color) => !existingColors.includes(color)); if (colorsAvailable.length === 0) { colorsAvailable.push(...COLORS); } if (color && colorsAvailable.includes(color)) { clientColor = color; } else { clientColor = colorsAvailable[random(colorsAvailable.length - 1)]; } clients.push({ uid: client.uid, accountability: client.accountability, color: clientColor, }); await store.set('clients', clients); }); } if (added && clientColor) { await this.sendExcluding({ action: ACTION.SERVER.JOIN, user: client.accountability.user, connection: client.uid, color: clientColor, }, client.uid); } const { changes, focuses, clients } = await this.store(async (store) => { return { changes: await store.get('changes'), focuses: await store.get('focuses'), clients: await store.get('clients'), }; }); const schema = await getSchema(); const knex = getDatabase(); const allowedFields = await verifyPermissions(client.accountability, this.collection, this.item, 'read', { schema, knex, }); this.send(client.uid, { action: ACTION.SERVER.INIT, collection: this.collection, item: this.item, version: this.version, changes: (await sanitizePayload(changes, this.collection, { accountability: client.accountability, schema, knex, itemId: this.item, })), focuses: Object.fromEntries(Object.entries(focuses).filter(([_, field]) => allowedFields === null || isFieldAllowed(allowedFields, field))), connection: client.uid, users: Array.from(clients).map((client) => ({ user: client.accountability.user, connection: client.uid, color: client.color, })), }); } /** * Leave the room */ async leave(uid) { await this.store(async (store) => { const clients = (await store.get('clients')).filter((c) => c.uid !== uid); await store.set('clients', clients); const focuses = await store.get('focuses'); if (uid in focuses) { delete focuses[uid]; await store.set('focuses', focuses); } if (clients.length === 0) { await store.set('changes', {}); } }); this.sendAll({ action: ACTION.SERVER.LEAVE, connection: uid, }); } /** * Propagate an update to other clients */ async update(sender, changes) { const { clients } = await this.store(async (store) => { const existing_changes = await store.get('changes'); Object.assign(existing_changes, changes); await store.set('changes', existing_changes); return { clients: await store.get('clients'), }; }); const schema = await getSchema(); const knex = getDatabase(); for (const client of clients) { if (client.uid === sender.uid) continue; const sanitizedChanges = (await sanitizePayload(changes, this.collection, { accountability: client.accountability, schema, knex, itemId: this.item, })) || {}; for (const field of Object.keys(changes)) { if (field in sanitizedChanges) { this.send(client.uid, { action: ACTION.SERVER.UPDATE, field, changes: sanitizedChanges[field], }); } } } } /** * Propagate an unset to other clients */ async unset(sender, field) { const clients = await this.store(async (store) => { const changes = await store.get('changes'); delete changes[field]; await store.set('changes', changes); return await store.get('clients'); }); const schema = await getSchema(); const knex = getDatabase(); for (const client of clients) { if (client.uid === sender.uid) continue; const allowedFields = await verifyPermissions(client.accountability, this.collection, this.item, 'read', { schema, knex, }); if (field && allowedFields !== null && !isFieldAllowed(allowedFields, field)) continue; this.send(client.uid, { action: ACTION.SERVER.DISCARD, fields: [field], }); } } /** * Discard specified changes in the room and propagate to other clients */ async discard(fields) { if (fields.length === 0) return; const clients = await this.store(async (store) => { let changes = await store.get('changes'); if (fields.includes('*')) { changes = {}; } else { for (const field of fields) { delete changes[field]; } } await store.set('changes', changes); return await store.get('clients'); }); const schema = await getSchema(); const knex = getDatabase(); for (const client of clients) { const allowedFields = await verifyPermissions(client.accountability, this.collection, this.item, 'read', { schema, knex, }); const sendFields = []; // Send "*" when discarding all fields and recipient has full permissions if (fields.includes('*') && allowedFields?.includes('*')) { sendFields.push('*'); } else if (fields.includes('*')) { sendFields.push(...(allowedFields ?? [])); } else { for (const field of fields) { if (allowedFields?.includes('*') || allowedFields?.includes(field)) { sendFields.push(field); } } } this.send(client.uid, { action: ACTION.SERVER.DISCARD, fields: uniq(sendFields), }); } } /** * Atomically acquire or release focus and propagate focus state to other clients */ async focus(sender, field) { const result = await this.store(async (store) => { const focuses = await store.get('focuses'); const clients = await store.get('clients'); if (field === null) { const focusedField = focuses[sender.uid]; delete focuses[sender.uid]; await store.set('focuses', focuses); return { success: true, clients, focusedField }; } const currentFocuser = Object.entries(focuses).find(([_, f]) => f === field)?.[0]; if (currentFocuser && currentFocuser !== sender.uid) { return { success: false }; } focuses[sender.uid] = field; await store.set('focuses', focuses); return { success: true, clients, focusedField: field }; }); if (!result.success) return false; const schema = await getSchema(); const knex = getDatabase(); for (const client of result.clients) { if (client.uid === sender.uid) continue; const allowedFields = await verifyPermissions(client.accountability, this.collection, this.item, 'read', { schema, knex, }); if (result.focusedField && allowedFields !== null && !isFieldAllowed(allowedFields, result.focusedField)) continue; this.send(client.uid, { action: ACTION.SERVER.FOCUS, connection: sender.uid, field, }); } return true; } async sendAll(message) { for (const client of await this.getClients()) { this.send(client.uid, message); } } async sendExcluding(message, exclude) { for (const client of await this.getClients()) { if (client.uid !== exclude) { this.send(client.uid, message); } } } send(client, message) { // Route via Messenger for multi-instance scaling this.messenger.sendClient(client, { ...message, type: WS_TYPE.COLLAB, room: this.uid }); } /** * Close the room and clean up shared state * * @param options.force If true, close the room even if active clients are present * @param options.reason Optional reason to be sent to clients * @param options.terminate If true, forcefully terminate the client connection after closing */ async close(options = {}) { const { force = false, reason, terminate = false } = options; let roomClients = []; if (force) { roomClients = await this.getClients(); for (const client of roomClients) { if (this.messenger.hasClient(client.uid)) { if (reason) this.messenger.sendError(client.uid, reason); if (terminate) this.messenger.terminateClient(client.uid); } } } const closed = await this.store(async (store) => { if (!force) { const clients = await store.get('clients'); if (clients.length > 0) return false; } if (!(await store.has('uid'))) return false; await store.delete('uid'); await store.delete('collection'); await store.delete('item'); await store.delete('version'); await store.delete('changes'); await store.delete('clients'); await store.delete('focuses'); return true; }); if (closed) { await this.messenger.unregisterRoom(this.uid); this.messenger.sendRoom(this.uid, { action: 'close' }); if (force) { for (const client of roomClients) { if (!this.messenger.hasClient(client.uid)) { if (reason) this.messenger.sendError(client.uid, reason); if (terminate) this.messenger.terminateClient(client.uid); } } } } if (closed || force) { this.dispose(); } return closed; } dispose() { this.messenger.removeRoomListener(this.uid); } } export function getRoomHash(collection, item, version) { return createHash('sha256').update([collection, item, version].join('-')).digest('hex'); }