@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
482 lines (481 loc) • 20.8 kB
JavaScript
import { useEnv } from '@directus/env';
import { ForbiddenError, InvalidPayloadError, ServiceUnavailableError } from '@directus/errors';
import { WS_TYPE } from '@directus/types';
import { ClientMessage } from '@directus/types/collab';
import { toArray } from '@directus/utils';
import { difference, intersection, isEmpty, upperFirst } from 'lodash-es';
import getDatabase from '../../database/index.js';
import emitter from '../../emitter.js';
import { useLogger } from '../../logger/index.js';
import { validateItemAccess } from '../../permissions/modules/validate-access/lib/validate-item-access.js';
import { SettingsService } from '../../services/settings.js';
import { getSchema } from '../../utils/get-schema.js';
import { isFieldAllowed } from '../../utils/is-field-allowed.js';
import { scheduleSynchronizedJob } from '../../utils/schedule.js';
import { getMessageType } from '../utils/message.js';
import { IRRELEVANT_COLLECTIONS } from './constants.js';
import { Messenger } from './messenger.js';
import { validateChanges } from './payload-permissions.js';
import { RoomManager } from './room.js';
import { verifyPermissions } from './verify-permissions.js';
const env = useEnv();
const CLUSTER_CLEANUP_CRON = String(env['WEBSOCKETS_COLLAB_CLUSTER_CLEANUP_CRON']);
const LOCAL_CLEANUP_INTERVAL = Number(env['WEBSOCKETS_COLLAB_LOCAL_CLEANUP_INTERVAL']);
/**
* Handler responsible for subscriptions
*/
export class CollabHandler {
roomManager;
messenger = new Messenger();
enabled = false;
initialized;
initializePromise;
settingsService;
cleanupJob;
cleanupInterval;
busHandler;
eventQueue = Promise.resolve();
/**
* Initialize the handler
*/
constructor() {
this.roomManager = new RoomManager(this.messenger);
this.initialized = this.initialize();
this.bindWebSocket();
this.startBackgroundJobs();
}
initialize(force = false) {
if (this.initialized && !force)
return this.initialized;
if (this.initializePromise)
return this.initializePromise;
this.initializePromise = (async () => {
try {
if (!this.settingsService) {
const schema = await getSchema();
this.settingsService = new SettingsService({ schema });
}
const settings = await this.settingsService.readSingleton({ fields: ['collaborative_editing_enabled'] });
this.enabled = settings?.['collaborative_editing_enabled'] ?? false;
}
catch (err) {
useLogger().error(err, '[Collab] Failed to initialize collaborative editing settings');
}
finally {
this.initializePromise = undefined;
}
})();
if (!this.initialized) {
this.initialized = this.initializePromise;
}
return this.initializePromise;
}
bindWebSocket() {
/**
* Listen for all system events via bus to ensure once-only delivery and consistency across instances
*
* Local updates:
* Service -> Emitter -> Hooks -> Bus -> CollabHandler -> Room -> Local Clients
*
* Remote updates:
* Service (Node B) -> Emitter (Node B) -> Hooks (Node B) -> Bus -> CollabHandler (Node A) -> Room (Node A) -> Remote Clients
*/
this.busHandler = (event) => {
// Chain events to enforce sequence integrity
this.eventQueue = this.eventQueue
.then(async () => {
if (event.collection === 'directus_settings' &&
event.action === 'update' &&
'collaborative_editing_enabled' in event.payload) {
useLogger().debug(`[Collab] [Node ${this.messenger.uid}] Settings update via bus, triggering handler`);
// Non-blocking initialization to avoid resource contention
this.initialize(true)
.then(() => {
if (!this.enabled) {
try {
useLogger().debug(`[Collab] [Node ${this.messenger.uid}] Collaborative editing disabled, terminating all rooms`);
this.roomManager.terminateAll();
}
catch (err) {
useLogger().error(err, '[Collab] Collaborative editing disabling terminateAll failed');
}
}
})
.catch((err) => {
useLogger().error(err, '[Collab] Collaborative editing re-initialization failed');
});
return;
}
// Skip irrelevant collections and actions early
if (event.action === 'create' || IRRELEVANT_COLLECTIONS.includes(event.collection)) {
return;
}
if (event.action === 'update' || event.action === 'delete') {
let keys = [];
if (Array.isArray(event.keys)) {
keys = event.keys;
}
else if (event.key) {
keys = [event.key];
}
else if (event.payload && event.action === 'delete') {
keys = toArray(event.payload);
}
event.keys = keys;
const roomsToUpdate = Object.values(this.roomManager.rooms).filter((room) => {
// Versioned Rooms
if (room.version) {
return event.collection === 'directus_versions' && keys.some((key) => String(key) === room.version);
}
// Skip non-matching collections and version events
if (room.collection !== event.collection || event.collection === 'directus_versions')
return false;
// Match singleton
if (room.item === null)
return true;
// Match regular items
return keys.some((key) => String(key) === String(room.item));
});
if (roomsToUpdate.length === 0)
return;
await Promise.all(roomsToUpdate.map(async (room) => {
let relevantKeys;
if (room.version) {
relevantKeys = [room.version];
}
else if (room.item) {
relevantKeys = [room.item];
}
else {
relevantKeys = keys;
}
const singleKeyedEvent = { ...event, keys: relevantKeys };
if (event.action === 'delete') {
await room.onDeleteHandler(singleKeyedEvent);
}
else {
await room.onUpdateHandler(singleKeyedEvent);
}
}));
}
})
.catch((err) => {
useLogger().error(err, `[Collab] Bus message processing failed for ${event.collection}/${event.action}`);
});
};
this.messenger.messenger.subscribe('websocket.event', this.busHandler);
emitter.onAction('websocket.connect', ({ client }) => {
this.messenger.addClient(client);
});
// listen to incoming messages on the connected websockets
emitter.onAction('websocket.message', async ({ client, message }) => {
if (getMessageType(message) !== WS_TYPE.COLLAB)
return;
try {
await this.ensureEnabled();
}
catch (error) {
if (error instanceof ServiceUnavailableError && error.message.includes('Collaborative editing is disabled')) {
this.messenger.handleError(client.uid, error, message.action);
this.messenger.terminateClient(client.uid);
return;
}
throw error;
}
const { data, error } = ClientMessage.safeParse(message);
if (!data) {
this.messenger.handleError(client.uid, new InvalidPayloadError({
reason: `Couldn't parse payload. ${error.message}`,
}));
return;
}
try {
await this[`on${upperFirst(data.action)}`](client, message);
}
catch (error) {
this.messenger.handleError(client.uid, error, data?.action);
}
});
// unsubscribe when a connection drops
emitter.onAction('websocket.error', ({ client }) => this.onLeave(client));
emitter.onAction('websocket.close', ({ client }) => this.onLeave(client));
}
startBackgroundJobs() {
this.cleanupJob = scheduleSynchronizedJob('collab', CLUSTER_CLEANUP_CRON, async () => {
const { inactive } = await this.messenger.pruneDeadInstances();
// Remove clients and close rooms hosted by nodes that are now dead
for (const roomUid of inactive.rooms) {
const room = await this.roomManager.getRoom(roomUid);
if (room) {
// Remove dead clients globally
for (const client of inactive.clients) {
if (await room.hasClient(client)) {
useLogger().debug(`[Collab] Removing dead client ${client} from room ${roomUid}`);
await room.leave(client);
}
}
// Close room if it was truly abandoned
if (await room.close()) {
this.roomManager.removeRoom(room.uid);
}
}
}
});
this.cleanupInterval = setInterval(async () => {
try {
// Remove local clients that are no longer in the global registry
const globalClients = await this.messenger.getGlobalClients();
const localClients = (await this.roomManager.getLocalRoomClients()).map((client) => client.uid);
const invalidClients = difference(localClients, globalClients);
for (const client of invalidClients) {
const rooms = await this.roomManager.getClientRooms(client);
for (const room of rooms) {
useLogger().debug(`[Collab] Removing invalid client ${client} from room ${room.getDisplayName()}`);
await room.leave(client);
}
}
await this.roomManager.cleanupRooms();
}
catch (err) {
useLogger().error(err, '[Collab] Local cleanup interval failed');
}
}, LOCAL_CLEANUP_INTERVAL);
}
/**
* Terminate the handler and stop background jobs
*/
async terminate() {
await this.cleanupJob?.stop();
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
if (this.busHandler) {
await this.messenger.messenger.unsubscribe('websocket.event', this.busHandler);
}
}
/**
* Ensure collaborative editing is enabled and initialized
*/
async ensureEnabled() {
await this.initialized;
if (!this.enabled) {
throw new ServiceUnavailableError({
reason: 'Collaborative editing is disabled',
service: 'collab',
});
}
}
/**
* Join a collaborative editing room
*/
async onJoin(client, message) {
if (client.accountability?.share) {
throw new ForbiddenError({
reason: 'Collaborative editing is not supported for shares',
});
}
const schema = await getSchema();
const db = getDatabase();
try {
const { accessAllowed } = await validateItemAccess({
accountability: client.accountability,
action: 'read',
collection: message.collection,
primaryKeys: schema.collections[message.collection]?.singleton ? [] : [message.item],
}, { knex: db, schema });
if (!accessAllowed)
throw new ForbiddenError();
if (message.version) {
const { accessAllowed: versionAccessAllowed } = await validateItemAccess({
accountability: client.accountability,
action: 'read',
collection: 'directus_versions',
primaryKeys: [message.version],
}, { knex: db, schema });
if (!versionAccessAllowed)
throw new ForbiddenError();
}
}
catch {
throw new ForbiddenError({
reason: `No permission to access item or it does not exist`,
});
}
if (message.initialChanges) {
await validateChanges(message.initialChanges, message.collection, message.item, {
knex: db,
schema,
accountability: client.accountability,
});
}
const room = await this.roomManager.createRoom(message.collection, message.item, message.version ?? null, message.initialChanges);
await room.join(client, message.color);
}
/**
* Leave a collaborative editing room
*/
async onLeave(client, message) {
if (message?.room) {
const room = await this.roomManager.getRoom(message.room);
if (!room || !(await room.hasClient(client.uid))) {
throw new ForbiddenError({
reason: `No access to room "${message.room}" or it does not exist`,
});
}
await room.leave(client.uid);
}
else {
const rooms = await this.roomManager.getClientRooms(client.uid);
for (const room of rooms) {
await room.leave(client.uid);
}
}
}
/**
* Update a field value
*/
async onUpdate(client, message) {
const knex = getDatabase();
const schema = await getSchema();
const room = await this.roomManager.getRoom(message.room);
if (!room || !(await room.hasClient(client.uid))) {
throw new ForbiddenError({
reason: `No access to room ${message.room} or room does not exist`,
});
}
await this.checkFieldsAccess(client, room, message.field, 'update', { knex, schema });
// Focus field before update to prevent concurrent overwrite conflicts
let focus = await room.getFocusByUser(client.uid);
if (message.changes !== undefined) {
if (focus !== message.field) {
await room.focus(client, message.field);
focus = await room.getFocusByUser(client.uid);
}
// Focus field before update to prevent concurrent overwrite conflicts
if (!focus || focus !== message.field) {
throw new ForbiddenError({
reason: `Cannot update field ${message.field} without focusing on it first`,
});
}
await validateChanges({ [message.field]: message.changes }, room.collection, room.item, {
knex,
schema,
accountability: client.accountability,
});
await room.update(client, { [message.field]: message.changes });
}
else {
const currentFocuser = await room.getFocusByField(message.field);
if (currentFocuser && currentFocuser !== client.uid) {
throw new ForbiddenError({
reason: `Field ${message.field} is already focused by another user`,
});
}
await room.unset(client, message.field);
}
}
/**
* Update multiple field values
*/
async onUpdateAll(client, message) {
if (isEmpty(message.changes))
return;
const room = await this.roomManager.getRoom(message.room);
if (!room || !(await room.hasClient(client.uid)))
throw new ForbiddenError({
reason: `No access to room ${message.room} or room does not exist`,
});
const collection = room.collection;
const knex = getDatabase();
const schema = await getSchema();
const fields = Object.keys(message.changes ?? {});
for (const key of fields) {
const focus = await room.getFocusByField(key);
if (focus && focus !== client.uid) {
delete message.changes?.[key];
}
}
if (!isEmpty(message.changes)) {
await validateChanges(message.changes, collection, room.item, {
knex,
schema,
accountability: client.accountability,
});
await room.update(client, message.changes);
}
}
/**
* Update focus state
*/
async onFocus(client, message) {
const room = await this.roomManager.getRoom(message.room);
if (!room || !(await room.hasClient(client.uid)))
throw new ForbiddenError({
reason: `No access to room ${message.room} or room does not exist`,
});
if (message.field) {
await this.checkFieldsAccess(client, room, message.field, 'focus on');
}
if (!(await room.focus(client, message.field ?? null))) {
throw new ForbiddenError({
reason: `Field ${message.field} is already focused by another user`,
});
}
}
/**
* Discard specified changes in the room
*/
async onDiscard(client, message) {
const room = await this.roomManager.getRoom(message.room);
if (!room || !(await room.hasClient(client.uid))) {
throw new ForbiddenError({
reason: `No access to room ${message.room} or room does not exist`,
});
}
const knex = getDatabase();
const schema = await getSchema();
const allowedFields = await this.getAllowedFields(client, room, knex, schema);
if (!allowedFields || allowedFields.length === 0) {
throw new ForbiddenError({
reason: `No permission to discard fields or item does not exist`,
});
}
await room.discard(allowedFields);
}
/**
* Verify field access for both READ and UPDATE permissions
*/
async checkFieldsAccess(client, room, fields, errorAction, options = {}) {
const knex = options.knex ?? getDatabase();
const schema = options.schema ?? (await getSchema());
const allowedFields = await this.getAllowedFields(client, room, knex, schema);
const fieldsArray = Array.isArray(fields) ? fields : [fields];
for (const field of fieldsArray) {
const fieldExists = !!schema.collections[room.collection]?.fields[field];
if (!fieldExists || (allowedFields && !isFieldAllowed(allowedFields, field))) {
throw new ForbiddenError({
reason: `No permission to ${errorAction} field ${field} or field does not exist`,
});
}
}
}
async getAllowedFields(client, room, knex, schema) {
const [read, update] = await Promise.all([
verifyPermissions(client.accountability, room.collection, room.item, 'read', { knex, schema }),
verifyPermissions(client.accountability, room.collection, room.item, 'update', { knex, schema }),
]);
if (read === null && update === null)
return null;
if (read === null)
return update;
if (update === null)
return read;
if (read.includes('*') && update.includes('*'))
return ['*'];
if (read.includes('*'))
return update;
if (update.includes('*'))
return read;
return intersection(read, update);
}
}