UNPKG

@directus/api

Version:

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

507 lines (505 loc) 16.8 kB
import { useLogger } from "../../logger/index.js"; import { ACTION, COLORS, TYPE } from "../../packages/types/dist/index.js"; import database_default from "../../database/index.js"; import { getService } from "../../utils/get-service.js"; import { getSchema } from "../../utils/get-schema.js"; import { isFieldAllowed } from "../../utils/is-field-allowed.js"; import { useStore } from "./store.js"; import { Messenger } from "./messenger.js"; import { verifyPermissions } from "./verify-permissions.js"; import { sanitizePayload } from "./payload-permissions.js"; import { ErrorCode } from "@directus/errors"; import { isDetailedUpdateSyntax, isObject } from "@directus/utils"; import { isEqual, random, uniq } from "lodash-es"; import { createHash } from "crypto"; //#region src/websocket/collab/room.ts /** * Store and manage all active collaborative editing rooms */ var RoomManager = class { 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) { 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) room = await useStore(uid)(async (store) => { if (!await store.has("uid")) return; const room$1 = new Room(uid, await store.get("collection"), await store.get("item"), await store.get("version"), await store.get("changes"), this.messenger); this.rooms[uid] = room$1; return room$1; }); 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: 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 */ var Room = class { 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; if (target !== null && !keys.some((key) => String(key) === String(target))) return; try { const schema = await getSchema(); const result = await (async () => { if (this.version) return (await getService("directus_versions", { schema }).readOne(this.version))["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]) => { if (isDetailedUpdateSyntax(value)) return false; if (!(key in result)) return !!this.version; if (isEqual(value, result[key])) return false; 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; const isVersionMatch = this.version && eventCollection === "directus_versions" && keys.some((key) => String(key) === this.version); 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) => { return (await store.get("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$1 = await store.get("clients"); added = true; const existingColors = clients$1.map((c) => c.color); const colorsAvailable = COLORS.filter((color$1) => !existingColors.includes(color$1)); if (colorsAvailable.length === 0) colorsAvailable.push(...COLORS); if (color && colorsAvailable.includes(color)) clientColor = color; else clientColor = colorsAvailable[random(colorsAvailable.length - 1)]; clients$1.push({ uid: client.uid, accountability: client.accountability, color: clientColor }); await store.set("clients", clients$1); }); 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 = database_default(); 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$1) => ({ user: client$1.accountability.user, connection: client$1.uid, color: client$1.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 = database_default(); 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 = database_default(); 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 = database_default(); for (const client of clients) { const allowedFields = await verifyPermissions(client.accountability, this.collection, this.item, "read", { schema, knex }); const sendFields = []; 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 = database_default(); 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) { this.messenger.sendClient(client, { ...message, type: 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) { if ((await store.get("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); } }; function getRoomHash(collection, item, version) { return createHash("sha256").update([ collection, item, version ].join("-")).digest("hex"); } //#endregion export { Room, RoomManager, getRoomHash };