@directus/api 32.2.0 → 33.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/chat/controllers/chat.post.js +19 -4
- package/dist/ai/chat/lib/create-ui-stream.d.ts +8 -7
- package/dist/ai/chat/lib/create-ui-stream.js +28 -25
- package/dist/ai/chat/middleware/load-settings.js +31 -7
- package/dist/ai/chat/models/chat-request.d.ts +135 -2
- package/dist/ai/chat/models/chat-request.js +56 -2
- package/dist/ai/chat/models/providers.d.ts +16 -2
- package/dist/ai/chat/models/providers.js +16 -2
- package/dist/ai/chat/utils/chat-request-tool-to-ai-sdk-tool.js +3 -4
- package/dist/ai/chat/utils/format-context.d.ts +5 -0
- package/dist/ai/chat/utils/format-context.js +122 -0
- package/dist/ai/mcp/server.d.ts +27 -1
- package/dist/ai/providers/index.d.ts +3 -0
- package/dist/ai/providers/index.js +3 -0
- package/dist/ai/providers/options.d.ts +14 -0
- package/dist/ai/providers/options.js +26 -0
- package/dist/ai/providers/registry.d.ts +6 -0
- package/dist/ai/providers/registry.js +65 -0
- package/dist/ai/providers/types.d.ts +34 -0
- package/dist/ai/providers/types.js +1 -0
- package/dist/ai/tools/assets/index.js +1 -1
- package/dist/ai/tools/collections/index.js +2 -2
- package/dist/ai/tools/fields/index.js +2 -2
- package/dist/ai/tools/files/index.js +1 -1
- package/dist/ai/tools/flows/index.js +1 -1
- package/dist/ai/tools/folders/index.js +1 -1
- package/dist/ai/tools/items/index.js +6 -3
- package/dist/ai/tools/items/prompt.md +7 -9
- package/dist/ai/tools/relations/index.js +1 -1
- package/dist/ai/tools/schema.js +1 -1
- package/dist/ai/tools/trigger-flow/index.js +1 -1
- package/dist/app.js +12 -8
- package/dist/auth/drivers/ldap.d.ts +1 -1
- package/dist/auth/drivers/ldap.js +144 -139
- package/dist/auth/drivers/local.js +1 -1
- package/dist/auth/drivers/oauth2.d.ts +1 -2
- package/dist/auth/drivers/oauth2.js +22 -17
- package/dist/auth/drivers/openid.d.ts +1 -2
- package/dist/auth/drivers/openid.js +18 -13
- package/dist/auth/drivers/saml.js +3 -3
- package/dist/auth/utils/generate-callback-url.d.ts +11 -0
- package/dist/auth/utils/generate-callback-url.js +40 -0
- package/dist/auth/utils/is-login-redirect-allowed.d.ts +7 -0
- package/dist/{utils → auth/utils}/is-login-redirect-allowed.js +12 -9
- package/dist/cache.d.ts +12 -0
- package/dist/cache.js +27 -3
- package/dist/cli/commands/bootstrap/index.js +2 -2
- package/dist/cli/commands/database/install.js +1 -1
- package/dist/cli/commands/database/migrate.js +1 -1
- package/dist/cli/commands/init/index.js +2 -2
- package/dist/cli/commands/roles/create.js +4 -4
- package/dist/cli/commands/schema/apply.js +3 -3
- package/dist/cli/commands/schema/snapshot.js +1 -1
- package/dist/cli/utils/create-db-connection.d.ts +1 -1
- package/dist/cli/utils/create-db-connection.js +1 -1
- package/dist/cli/utils/create-env/env-stub.liquid +3 -0
- package/dist/cli/utils/create-env/index.js +1 -1
- package/dist/constants.d.ts +7 -3
- package/dist/constants.js +7 -3
- package/dist/controllers/access.js +1 -1
- package/dist/controllers/assets.js +1 -1
- package/dist/controllers/deployment.js +481 -0
- package/dist/controllers/extensions.js +1 -1
- package/dist/controllers/fields.js +8 -6
- package/dist/controllers/files.js +1 -1
- package/dist/controllers/items.js +1 -1
- package/dist/controllers/not-found.js +1 -1
- package/dist/controllers/relations.js +1 -1
- package/dist/database/errors/dialects/mysql.d.ts +1 -1
- package/dist/database/errors/dialects/postgres.d.ts +1 -1
- package/dist/database/errors/dialects/sqlite.d.ts +1 -1
- package/dist/database/errors/translate.d.ts +1 -1
- package/dist/database/errors/translate.js +1 -1
- package/dist/database/get-ast-from-query/lib/parse-fields.js +2 -2
- package/dist/database/helpers/date/dialects/mssql.js +1 -1
- package/dist/database/helpers/date/dialects/mysql.js +1 -1
- package/dist/database/helpers/date/types.js +1 -1
- package/dist/database/helpers/schema/dialects/cockroachdb.d.ts +1 -0
- package/dist/database/helpers/schema/dialects/cockroachdb.js +24 -1
- package/dist/database/helpers/schema/dialects/mssql.d.ts +1 -1
- package/dist/database/helpers/schema/dialects/mysql.d.ts +2 -1
- package/dist/database/helpers/schema/dialects/mysql.js +16 -3
- package/dist/database/helpers/schema/dialects/postgres.d.ts +1 -1
- package/dist/database/helpers/schema/types.d.ts +13 -0
- package/dist/database/helpers/schema/types.js +24 -0
- package/dist/database/index.js +4 -4
- package/dist/database/migrations/20220429A-add-flows.js +1 -1
- package/dist/database/migrations/20230526A-migrate-translation-strings.js +1 -1
- package/dist/database/migrations/20231009A-update-csv-fields-to-text.js +1 -1
- package/dist/database/migrations/20240204A-marketplace.js +9 -7
- package/dist/database/migrations/20240311A-deprecate-webhooks.d.ts +15 -0
- package/dist/database/migrations/20240311A-deprecate-webhooks.js +1 -1
- package/dist/database/migrations/20240806A-permissions-policies.js +2 -2
- package/dist/database/migrations/20240924A-migrate-legacy-comments.js +1 -1
- package/dist/database/migrations/20251014A-add-project-owner.js +1 -1
- package/dist/database/migrations/20251224A-remove-webhooks.d.ts +3 -0
- package/dist/database/migrations/20251224A-remove-webhooks.js +19 -0
- package/dist/database/migrations/20260110A-add-ai-provider-settings.d.ts +3 -0
- package/dist/database/migrations/20260110A-add-ai-provider-settings.js +35 -0
- package/dist/database/migrations/20260113A-add-revisions-index.d.ts +3 -0
- package/dist/database/migrations/20260113A-add-revisions-index.js +41 -0
- package/dist/database/migrations/20260128A-add-collaborative-editing.d.ts +3 -0
- package/dist/database/migrations/20260128A-add-collaborative-editing.js +10 -0
- package/dist/database/migrations/20260204A-add-deployment.d.ts +3 -0
- package/dist/database/migrations/20260204A-add-deployment.js +32 -0
- package/dist/database/migrations/run.js +3 -3
- package/dist/database/run-ast/lib/apply-query/add-join.js +1 -1
- package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.d.ts +2 -2
- package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.js +1 -1
- package/dist/database/run-ast/lib/apply-query/filter/index.js +1 -1
- package/dist/database/run-ast/lib/apply-query/filter/operator.js +1 -1
- package/dist/database/run-ast/lib/apply-query/sort.js +1 -1
- package/dist/database/run-ast/utils/get-column-pre-processor.js +2 -2
- package/dist/database/run-ast/utils/get-column.js +1 -1
- package/dist/database/seeds/run.js +3 -3
- package/dist/deployment/deployment.d.ts +94 -0
- package/dist/deployment/deployment.js +29 -0
- package/dist/deployment/drivers/index.d.ts +1 -0
- package/dist/deployment/drivers/index.js +1 -0
- package/dist/deployment/drivers/vercel.d.ts +32 -0
- package/dist/deployment/drivers/vercel.js +208 -0
- package/dist/deployment/index.d.ts +2 -0
- package/dist/deployment/index.js +2 -0
- package/dist/deployment.d.ts +24 -0
- package/dist/deployment.js +39 -0
- package/dist/extensions/lib/get-extensions-path.js +1 -1
- package/dist/extensions/lib/get-extensions-settings.js +1 -1
- package/dist/extensions/lib/get-extensions.js +1 -1
- package/dist/extensions/lib/get-shared-deps-mapping.js +3 -3
- package/dist/extensions/lib/installation/manager.js +3 -3
- package/dist/extensions/lib/sandbox/register/route.d.ts +1 -1
- package/dist/extensions/lib/sync/status.js +1 -1
- package/dist/extensions/lib/sync/sync.js +7 -7
- package/dist/extensions/lib/sync/utils.js +2 -2
- package/dist/extensions/manager.d.ts +1 -1
- package/dist/extensions/manager.js +8 -8
- package/dist/flows.d.ts +1 -1
- package/dist/logger/index.js +1 -1
- package/dist/logger/logs-stream.d.ts +1 -1
- package/dist/logger/logs-stream.js +1 -1
- package/dist/mailer.js +1 -1
- package/dist/metrics/lib/create-metrics.js +2 -2
- package/dist/middleware/authenticate.js +3 -3
- package/dist/middleware/collection-exists.js +1 -1
- package/dist/middleware/extract-token.js +1 -1
- package/dist/middleware/graphql.js +2 -2
- package/dist/middleware/respond.js +27 -14
- package/dist/middleware/validate-batch.js +1 -1
- package/dist/operations/exec/index.js +2 -1
- package/dist/operations/mail/index.js +1 -1
- package/dist/operations/mail/rate-limiter.js +2 -2
- package/dist/permissions/cache.js +5 -0
- package/dist/permissions/modules/fetch-allowed-collections/fetch-allowed-collections.js +1 -1
- package/dist/permissions/modules/fetch-allowed-field-map/fetch-allowed-field-map.js +1 -1
- package/dist/permissions/modules/fetch-inconsistent-field-map/fetch-inconsistent-field-map.js +2 -2
- package/dist/permissions/modules/process-ast/lib/inject-cases.js +1 -1
- package/dist/permissions/modules/process-ast/process-ast.js +1 -1
- package/dist/permissions/modules/process-ast/utils/find-related-collection.js +1 -1
- package/dist/permissions/modules/process-payload/process-payload.js +1 -1
- package/dist/permissions/modules/validate-access/lib/validate-item-access.d.ts +14 -2
- package/dist/permissions/modules/validate-access/lib/validate-item-access.js +72 -13
- package/dist/permissions/modules/validate-access/validate-access.js +3 -2
- package/dist/rate-limiter.js +1 -1
- package/dist/request/is-denied-ip.js +1 -1
- package/dist/schedules/project.js +1 -1
- package/dist/schedules/telemetry.js +1 -1
- package/dist/schedules/tus.js +1 -1
- package/dist/server.js +6 -5
- package/dist/services/assets.d.ts +2 -1
- package/dist/services/assets.js +35 -8
- package/dist/services/authentication.js +2 -2
- package/dist/services/collections.js +1 -1
- package/dist/services/deployment-projects.d.ts +20 -0
- package/dist/services/deployment-projects.js +34 -0
- package/dist/services/deployment-runs.d.ts +13 -0
- package/dist/services/deployment-runs.js +6 -0
- package/dist/services/deployment.d.ts +40 -0
- package/dist/services/deployment.js +202 -0
- package/dist/services/extensions.d.ts +1 -1
- package/dist/services/files/utils/get-metadata.d.ts +1 -1
- package/dist/services/files/utils/get-metadata.js +1 -1
- package/dist/services/files.d.ts +1 -1
- package/dist/services/files.js +4 -4
- package/dist/services/graphql/index.d.ts +1 -1
- package/dist/services/graphql/index.js +1 -1
- package/dist/services/graphql/resolvers/mutation.js +1 -1
- package/dist/services/graphql/resolvers/system-admin.js +2 -3
- package/dist/services/graphql/schema/get-types.d.ts +1 -1
- package/dist/services/graphql/schema/read.js +1 -1
- package/dist/services/graphql/subscription.d.ts +1 -1
- package/dist/services/graphql/types/date.js +1 -1
- package/dist/services/graphql/types/hash.js +1 -1
- package/dist/services/graphql/utils/add-path-to-validation-error.js +1 -1
- package/dist/services/graphql/utils/filter-replace-m2a.js +3 -4
- package/dist/services/import-export.d.ts +1 -1
- package/dist/services/import-export.js +2 -2
- package/dist/services/index.d.ts +3 -1
- package/dist/services/index.js +3 -1
- package/dist/services/mail/index.js +2 -2
- package/dist/services/mail/rate-limiter.js +2 -2
- package/dist/services/payload.js +2 -2
- package/dist/services/schema.js +1 -1
- package/dist/services/server.js +13 -4
- package/dist/services/settings.js +2 -2
- package/dist/services/specifications.js +2 -2
- package/dist/services/tfa.js +1 -1
- package/dist/services/translations.js +1 -1
- package/dist/services/tus/data-store.d.ts +1 -3
- package/dist/services/tus/data-store.js +2 -5
- package/dist/services/tus/server.js +6 -6
- package/dist/services/users.js +4 -4
- package/dist/services/versions.js +1 -1
- package/dist/telemetry/lib/get-report.js +2 -0
- package/dist/telemetry/lib/send-report.d.ts +1 -1
- package/dist/telemetry/lib/send-report.js +1 -1
- package/dist/telemetry/lib/track.js +1 -1
- package/dist/telemetry/types/report.d.ts +8 -0
- package/dist/telemetry/utils/get-settings.d.ts +2 -0
- package/dist/telemetry/utils/get-settings.js +5 -0
- package/dist/test-utils/knex.js +1 -1
- package/dist/types/collection.d.ts +1 -1
- package/dist/utils/async-handler.d.ts +1 -1
- package/dist/utils/calculate-field-depth.js +1 -1
- package/dist/utils/compress.js +1 -1
- package/dist/utils/deep-map-response.d.ts +1 -1
- package/dist/utils/deep-map-response.js +2 -2
- package/dist/utils/get-cache-key.js +1 -1
- package/dist/utils/get-column-path.js +1 -1
- package/dist/utils/get-field-system-rows.js +1 -1
- package/dist/utils/get-ip-from-req.d.ts +1 -1
- package/dist/utils/get-ip-from-req.js +1 -1
- package/dist/utils/get-local-type.js +7 -3
- package/dist/utils/get-service.js +7 -3
- package/dist/utils/get-snapshot-diff.js +1 -1
- package/dist/utils/is-field-allowed.d.ts +4 -0
- package/dist/utils/is-field-allowed.js +9 -0
- package/dist/utils/is-url-allowed.js +1 -1
- package/dist/utils/jwt.js +1 -1
- package/dist/utils/sanitize-schema.d.ts +1 -1
- package/dist/utils/should-clear-cache.d.ts +1 -1
- package/dist/utils/should-skip-cache.js +2 -2
- package/dist/utils/validate-diff.js +1 -1
- package/dist/utils/validate-snapshot.js +3 -3
- package/dist/utils/validate-storage.js +2 -2
- package/dist/utils/verify-session-jwt.js +1 -1
- package/dist/utils/versioning/handle-version.js +1 -1
- package/dist/websocket/collab/calculate-cache-metadata.d.ts +9 -0
- package/dist/websocket/collab/calculate-cache-metadata.js +121 -0
- package/dist/websocket/collab/collab.d.ts +63 -0
- package/dist/websocket/collab/collab.js +481 -0
- package/dist/websocket/collab/constants.d.ts +1 -0
- package/dist/websocket/collab/constants.js +13 -0
- package/dist/websocket/collab/filter-to-fields.d.ts +2 -0
- package/dist/websocket/collab/filter-to-fields.js +11 -0
- package/dist/websocket/collab/messenger.d.ts +43 -0
- package/dist/websocket/collab/messenger.js +225 -0
- package/dist/websocket/collab/payload-permissions.d.ts +18 -0
- package/dist/websocket/collab/payload-permissions.js +158 -0
- package/dist/websocket/collab/permissions-cache.d.ts +52 -0
- package/dist/websocket/collab/permissions-cache.js +204 -0
- package/dist/websocket/collab/room.d.ts +125 -0
- package/dist/websocket/collab/room.js +593 -0
- package/dist/websocket/collab/store.d.ts +7 -0
- package/dist/websocket/collab/store.js +33 -0
- package/dist/websocket/collab/types.d.ts +21 -0
- package/dist/websocket/collab/types.js +1 -0
- package/dist/websocket/collab/verify-permissions.d.ts +11 -0
- package/dist/websocket/collab/verify-permissions.js +100 -0
- package/dist/websocket/controllers/base.d.ts +2 -2
- package/dist/websocket/controllers/base.js +3 -3
- package/dist/websocket/controllers/graphql.d.ts +1 -1
- package/dist/websocket/controllers/graphql.js +1 -1
- package/dist/websocket/controllers/logs.d.ts +1 -1
- package/dist/websocket/controllers/rest.d.ts +1 -1
- package/dist/websocket/controllers/rest.js +2 -2
- package/dist/websocket/handlers/heartbeat.js +1 -1
- package/dist/websocket/handlers/index.d.ts +2 -0
- package/dist/websocket/handlers/index.js +9 -0
- package/dist/websocket/handlers/items.js +2 -2
- package/dist/websocket/handlers/subscribe.js +1 -1
- package/dist/websocket/types.d.ts +1 -1
- package/dist/websocket/utils/items.d.ts +2 -2
- package/dist/websocket/utils/message.d.ts +1 -1
- package/dist/websocket/utils/message.js +2 -2
- package/dist/websocket/utils/wait-for-message.js +1 -1
- package/package.json +35 -33
- package/dist/controllers/webhooks.js +0 -74
- package/dist/services/webhooks.d.ts +0 -14
- package/dist/services/webhooks.js +0 -32
- package/dist/utils/get-relation-info.d.ts +0 -6
- package/dist/utils/get-relation-info.js +0 -43
- package/dist/utils/get-relation-type.d.ts +0 -6
- package/dist/utils/get-relation-type.js +0 -18
- package/dist/utils/is-login-redirect-allowed.d.ts +0 -4
- package/dist/utils/versioning/deep-map-with-schema.d.ts +0 -23
- package/dist/utils/versioning/deep-map-with-schema.js +0 -81
- /package/dist/controllers/{webhooks.d.ts → deployment.d.ts} +0 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { deepMapFilter } from '@directus/utils';
|
|
2
|
+
export function filterToFields(filter, collection, schema) {
|
|
3
|
+
const fields = new Set();
|
|
4
|
+
deepMapFilter(filter, ([key, _value], context) => {
|
|
5
|
+
if (context.leaf && context.field) {
|
|
6
|
+
fields.add([...context.path, key].join('.'));
|
|
7
|
+
}
|
|
8
|
+
return undefined;
|
|
9
|
+
}, { collection, schema });
|
|
10
|
+
return Array.from(fields);
|
|
11
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Bus } from '@directus/memory';
|
|
2
|
+
import { type WebSocketClient } from '@directus/types';
|
|
3
|
+
import { type BroadcastMessage, type ClientID, type ServerError, type ServerMessage } from '@directus/types/collab';
|
|
4
|
+
type Instance = {
|
|
5
|
+
clients: ClientID[];
|
|
6
|
+
rooms: string[];
|
|
7
|
+
};
|
|
8
|
+
type Registry = Record<string, Instance>;
|
|
9
|
+
type RegistrySnapshot = {
|
|
10
|
+
inactive: Instance;
|
|
11
|
+
active: ClientID[];
|
|
12
|
+
};
|
|
13
|
+
type RoomMessage = Extract<BroadcastMessage, {
|
|
14
|
+
type: 'room';
|
|
15
|
+
}>;
|
|
16
|
+
export type RoomListener = (message: RoomMessage) => void;
|
|
17
|
+
export declare class Messenger {
|
|
18
|
+
uid: `${string}-${string}-${string}-${string}-${string}`;
|
|
19
|
+
store: <T>(callback: (store: import("./store.js").RedisStore<{
|
|
20
|
+
instances: Registry;
|
|
21
|
+
}>) => Promise<T>) => Promise<T>;
|
|
22
|
+
clients: Record<ClientID, WebSocketClient>;
|
|
23
|
+
orders: Record<ClientID, number>;
|
|
24
|
+
messenger: Bus;
|
|
25
|
+
roomListeners: Record<string, RoomListener>;
|
|
26
|
+
constructor();
|
|
27
|
+
hasClient(client: ClientID): boolean;
|
|
28
|
+
setRoomListener(room: string, callback: RoomListener): void;
|
|
29
|
+
removeRoomListener(room: string): void;
|
|
30
|
+
addClient(client: WebSocketClient): void;
|
|
31
|
+
removeClient(uid: ClientID): void;
|
|
32
|
+
registerRoom(uid: string): Promise<void>;
|
|
33
|
+
unregisterRoom(uid: string): Promise<void>;
|
|
34
|
+
getLocalClients(): Promise<ClientID[]>;
|
|
35
|
+
getGlobalClients(): Promise<ClientID[]>;
|
|
36
|
+
pruneDeadInstances(): Promise<RegistrySnapshot>;
|
|
37
|
+
sendRoom(room: string, message: Omit<RoomMessage, 'type' | 'room'>): void;
|
|
38
|
+
sendClient(client: ClientID, message: Omit<ServerMessage, 'order'>): void;
|
|
39
|
+
terminateClient(client: ClientID): void;
|
|
40
|
+
sendError(client: ClientID, error: ServerError): void;
|
|
41
|
+
handleError(client: ClientID, error: unknown, action?: ServerError['trigger']): void;
|
|
42
|
+
}
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { useEnv } from '@directus/env';
|
|
3
|
+
import { isDirectusError } from '@directus/errors';
|
|
4
|
+
import { WS_TYPE } from '@directus/types';
|
|
5
|
+
import { COLLAB_BUS, } from '@directus/types/collab';
|
|
6
|
+
import { useBus } from '../../bus/index.js';
|
|
7
|
+
import { useLogger } from '../../logger/index.js';
|
|
8
|
+
import { useStore } from './store.js';
|
|
9
|
+
const env = useEnv();
|
|
10
|
+
const INSTANCE_TIMEOUT = Number(env['WEBSOCKETS_COLLAB_INSTANCE_TIMEOUT']);
|
|
11
|
+
export class Messenger {
|
|
12
|
+
uid;
|
|
13
|
+
store;
|
|
14
|
+
clients = {};
|
|
15
|
+
orders = {};
|
|
16
|
+
messenger = useBus();
|
|
17
|
+
roomListeners = {};
|
|
18
|
+
constructor() {
|
|
19
|
+
this.uid = randomUUID();
|
|
20
|
+
this.store = useStore('registry', { instances: {} });
|
|
21
|
+
this.store(async (store) => {
|
|
22
|
+
const instances = await store.get('instances');
|
|
23
|
+
instances[this.uid] = { clients: [], rooms: [] };
|
|
24
|
+
await store.set('instances', instances);
|
|
25
|
+
}).catch((err) => {
|
|
26
|
+
useLogger().error(err, '[Collab] Failed to register instance in registry');
|
|
27
|
+
});
|
|
28
|
+
this.messenger.subscribe(COLLAB_BUS, (message) => {
|
|
29
|
+
if (message.type === 'send') {
|
|
30
|
+
const client = this.clients[message.client];
|
|
31
|
+
if (client) {
|
|
32
|
+
const order = this.orders[client.uid] ?? 0;
|
|
33
|
+
this.orders[client.uid] = order + 1;
|
|
34
|
+
client.send(JSON.stringify({ ...message.message, order }));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else if (message.type === 'error') {
|
|
38
|
+
const client = this.clients[message.client];
|
|
39
|
+
if (client) {
|
|
40
|
+
client.send(JSON.stringify(message.message));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else if (message.type === 'terminate') {
|
|
44
|
+
this.clients[message.client]?.close();
|
|
45
|
+
}
|
|
46
|
+
else if (message.type === 'room') {
|
|
47
|
+
this.roomListeners[message.room]?.(message);
|
|
48
|
+
}
|
|
49
|
+
else if (message.type === 'ping' && message.instance === this.uid) {
|
|
50
|
+
this.messenger.publish(COLLAB_BUS, { type: 'pong', instance: this.uid });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
hasClient(client) {
|
|
55
|
+
return client in this.clients;
|
|
56
|
+
}
|
|
57
|
+
setRoomListener(room, callback) {
|
|
58
|
+
this.roomListeners[room] = callback;
|
|
59
|
+
}
|
|
60
|
+
removeRoomListener(room) {
|
|
61
|
+
delete this.roomListeners[room];
|
|
62
|
+
}
|
|
63
|
+
addClient(client) {
|
|
64
|
+
if (client.uid in this.clients)
|
|
65
|
+
return;
|
|
66
|
+
this.clients[client.uid] = client;
|
|
67
|
+
this.orders[client.uid] = 0;
|
|
68
|
+
this.store(async (store) => {
|
|
69
|
+
const instances = await store.get('instances');
|
|
70
|
+
if (!instances[this.uid])
|
|
71
|
+
instances[this.uid] = { clients: [], rooms: [] };
|
|
72
|
+
instances[this.uid].clients = [...(instances[this.uid].clients ?? []), client.uid];
|
|
73
|
+
await store.set('instances', instances);
|
|
74
|
+
}).catch((err) => {
|
|
75
|
+
useLogger().error(err, `[Collab] Failed to add client ${client.uid} to registry`);
|
|
76
|
+
});
|
|
77
|
+
client.on('close', () => {
|
|
78
|
+
this.removeClient(client.uid);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
removeClient(uid) {
|
|
82
|
+
delete this.clients[uid];
|
|
83
|
+
delete this.orders[uid];
|
|
84
|
+
this.store(async (store) => {
|
|
85
|
+
const instances = await store.get('instances');
|
|
86
|
+
if (instances[this.uid]) {
|
|
87
|
+
instances[this.uid].clients = (instances[this.uid].clients ?? []).filter((clientId) => clientId !== uid);
|
|
88
|
+
await store.set('instances', instances);
|
|
89
|
+
}
|
|
90
|
+
}).catch((err) => {
|
|
91
|
+
useLogger().error(err, `[Collab] Failed to remove client ${uid} from registry`);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async registerRoom(uid) {
|
|
95
|
+
await this.store(async (store) => {
|
|
96
|
+
const instances = await store.get('instances');
|
|
97
|
+
if (!instances[this.uid])
|
|
98
|
+
instances[this.uid] = { clients: [], rooms: [] };
|
|
99
|
+
if (!instances[this.uid].rooms.includes(uid)) {
|
|
100
|
+
instances[this.uid].rooms.push(uid);
|
|
101
|
+
await store.set('instances', instances);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async unregisterRoom(uid) {
|
|
106
|
+
await this.store(async (store) => {
|
|
107
|
+
const instances = await store.get('instances');
|
|
108
|
+
if (instances[this.uid]) {
|
|
109
|
+
instances[this.uid].rooms = (instances[this.uid].rooms ?? []).filter((roomUid) => roomUid !== uid);
|
|
110
|
+
await store.set('instances', instances);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
async getLocalClients() {
|
|
115
|
+
return Object.keys(this.clients);
|
|
116
|
+
}
|
|
117
|
+
async getGlobalClients() {
|
|
118
|
+
const instances = await this.store(async (store) => await store.get('instances'));
|
|
119
|
+
return Object.values(instances)
|
|
120
|
+
.map((instance) => instance.clients)
|
|
121
|
+
.flat();
|
|
122
|
+
}
|
|
123
|
+
async pruneDeadInstances() {
|
|
124
|
+
const instances = await this.store(async (store) => await store.get('instances'));
|
|
125
|
+
const inactiveInstances = new Set(Object.keys(instances));
|
|
126
|
+
inactiveInstances.delete(this.uid);
|
|
127
|
+
const pongCollector = (message) => {
|
|
128
|
+
if (message.type === 'pong') {
|
|
129
|
+
inactiveInstances.delete(message.instance);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
this.messenger.subscribe(COLLAB_BUS, pongCollector);
|
|
133
|
+
for (const instance of inactiveInstances) {
|
|
134
|
+
this.messenger.publish(COLLAB_BUS, { type: 'ping', instance });
|
|
135
|
+
}
|
|
136
|
+
await new Promise((resolve) => {
|
|
137
|
+
setTimeout(resolve, INSTANCE_TIMEOUT);
|
|
138
|
+
});
|
|
139
|
+
this.messenger.unsubscribe(COLLAB_BUS, pongCollector);
|
|
140
|
+
const dead = { clients: [], rooms: [] };
|
|
141
|
+
if (inactiveInstances.size === 0) {
|
|
142
|
+
return {
|
|
143
|
+
inactive: dead,
|
|
144
|
+
active: Object.values(instances)
|
|
145
|
+
.map((instance) => instance.clients)
|
|
146
|
+
.flat(),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// Reread state to avoid overwriting updates during the timeout phase
|
|
150
|
+
const current = await this.store(async (store) => {
|
|
151
|
+
const current = await store.get('instances');
|
|
152
|
+
let changed = false;
|
|
153
|
+
for (const deadId of inactiveInstances) {
|
|
154
|
+
if (current[deadId]) {
|
|
155
|
+
dead.clients.push(...(current[deadId].clients ?? []));
|
|
156
|
+
dead.rooms.push(...(current[deadId].rooms ?? []));
|
|
157
|
+
delete current[deadId];
|
|
158
|
+
changed = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (changed) {
|
|
162
|
+
await store.set('instances', current);
|
|
163
|
+
}
|
|
164
|
+
return current;
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
inactive: dead,
|
|
168
|
+
active: Object.values(current)
|
|
169
|
+
.map((instance) => instance.clients)
|
|
170
|
+
.flat(),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
sendRoom(room, message) {
|
|
174
|
+
this.messenger.publish(COLLAB_BUS, { type: 'room', room, ...message });
|
|
175
|
+
}
|
|
176
|
+
sendClient(client, message) {
|
|
177
|
+
const localClient = this.clients[client];
|
|
178
|
+
if (localClient) {
|
|
179
|
+
const order = this.orders[client] ?? 0;
|
|
180
|
+
this.orders[client] = order + 1;
|
|
181
|
+
localClient.send(JSON.stringify({ ...message, order }));
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
this.messenger.publish(COLLAB_BUS, { type: 'send', client, message });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
terminateClient(client) {
|
|
188
|
+
const localClient = this.clients[client];
|
|
189
|
+
if (localClient) {
|
|
190
|
+
// Allow message to flush before closing
|
|
191
|
+
setTimeout(() => {
|
|
192
|
+
localClient.close();
|
|
193
|
+
}, 250);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
this.messenger.publish(COLLAB_BUS, { type: 'terminate', client });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
sendError(client, error) {
|
|
200
|
+
const localClient = this.clients[client];
|
|
201
|
+
if (localClient) {
|
|
202
|
+
localClient.send(JSON.stringify(error));
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
this.messenger.publish(COLLAB_BUS, { type: 'error', client, message: error });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
handleError(client, error, action) {
|
|
209
|
+
let message;
|
|
210
|
+
if (isDirectusError(error)) {
|
|
211
|
+
message = {
|
|
212
|
+
action: 'error',
|
|
213
|
+
type: WS_TYPE.COLLAB,
|
|
214
|
+
code: error.code,
|
|
215
|
+
trigger: action,
|
|
216
|
+
message: error.message,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
useLogger().error(`WebSocket unhandled exception ${JSON.stringify({ type: WS_TYPE.COLLAB, error })}`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
this.sendError(client, message);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Accountability, PrimaryKey, SchemaOverview } from '@directus/types';
|
|
2
|
+
import type { Knex } from 'knex';
|
|
3
|
+
type PermissionContext = {
|
|
4
|
+
knex: Knex;
|
|
5
|
+
schema: SchemaOverview;
|
|
6
|
+
accountability: Accountability | null;
|
|
7
|
+
itemId?: PrimaryKey | null;
|
|
8
|
+
direction?: 'inbound' | 'outbound';
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Validates a changes payload against the user's update/create permissions and errors if unauthorized field is encountered
|
|
12
|
+
*/
|
|
13
|
+
export declare function validateChanges(payload: any, collection: string, itemId: PrimaryKey | null, context: PermissionContext): Promise<any>;
|
|
14
|
+
/**
|
|
15
|
+
* Sanitizes a payload based on the recipient's read permissions and the schema
|
|
16
|
+
*/
|
|
17
|
+
export declare function sanitizePayload(payload: any, collection: string, context: PermissionContext): Promise<any>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { ForbiddenError, InvalidPayloadError } from '@directus/errors';
|
|
2
|
+
import { deepMapWithSchema, isDetailedUpdateSyntax } from '@directus/utils';
|
|
3
|
+
import { verifyPermissions } from './verify-permissions.js';
|
|
4
|
+
/**
|
|
5
|
+
* Validates a changes payload against the user's update/create permissions and errors if unauthorized field is encountered
|
|
6
|
+
*/
|
|
7
|
+
export async function validateChanges(payload, collection, itemId, context) {
|
|
8
|
+
return processPermissions(payload, collection, { ...context, itemId, direction: 'inbound' });
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Sanitizes a payload based on the recipient's read permissions and the schema
|
|
12
|
+
*/
|
|
13
|
+
export async function sanitizePayload(payload, collection, context) {
|
|
14
|
+
return processPermissions(payload, collection, { ...context, direction: 'outbound' });
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Core utility to walk a payload and apply permissions
|
|
18
|
+
*/
|
|
19
|
+
async function processPermissions(payload, collection, context) {
|
|
20
|
+
const { direction, accountability, schema, knex, itemId } = context;
|
|
21
|
+
// Local cache for permissions to avoid redundant verifyPermissions calls for the same item:action pair
|
|
22
|
+
// The promise is cached, so concurrent field lookups for the same item wait for the same result
|
|
23
|
+
const permissionsCache = new Map();
|
|
24
|
+
const getPermissions = (col, id, action) => {
|
|
25
|
+
const cacheKey = `${col}:${id}:${action}`;
|
|
26
|
+
let cached = permissionsCache.get(cacheKey);
|
|
27
|
+
if (!cached) {
|
|
28
|
+
cached = verifyPermissions(accountability, col, id, action, { knex, schema });
|
|
29
|
+
permissionsCache.set(cacheKey, cached);
|
|
30
|
+
}
|
|
31
|
+
return cached;
|
|
32
|
+
};
|
|
33
|
+
return deepMapWithSchema(payload, async (entry, deepMapContext) => {
|
|
34
|
+
const [key, value] = entry;
|
|
35
|
+
if (direction === 'outbound') {
|
|
36
|
+
// Strip sensitive fields
|
|
37
|
+
if (deepMapContext.field?.special?.some((v) => v === 'conceal' || v === 'hash' || v === 'encrypt')) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
// Strip unknown leaf fields
|
|
41
|
+
if (deepMapContext.leaf && !deepMapContext.relation && !deepMapContext.field) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (value === undefined)
|
|
46
|
+
return undefined;
|
|
47
|
+
// Resolve the action (CRUD) and the ID to check against
|
|
48
|
+
const currentCollection = deepMapContext.collection.collection;
|
|
49
|
+
const pkField = deepMapContext.collection.primary;
|
|
50
|
+
const primaryKeyInObject = (deepMapContext.object[pkField] ?? null);
|
|
51
|
+
let action = direction === 'inbound' ? 'update' : 'read';
|
|
52
|
+
let effectiveItemId = primaryKeyInObject;
|
|
53
|
+
if (direction === 'inbound') {
|
|
54
|
+
const isTopLevel = deepMapContext.object === payload;
|
|
55
|
+
// At the top level, we use the ID from the request context (itemId)
|
|
56
|
+
// Deeply nested objects must provide their own ID for update checks
|
|
57
|
+
if (isTopLevel) {
|
|
58
|
+
effectiveItemId = itemId ?? null;
|
|
59
|
+
action = itemId ? 'update' : 'create';
|
|
60
|
+
}
|
|
61
|
+
else if (!primaryKeyInObject) {
|
|
62
|
+
action = 'create';
|
|
63
|
+
}
|
|
64
|
+
if (deepMapContext.action) {
|
|
65
|
+
action = deepMapContext.action;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
// sanitizePayload uses context.itemId as a fallback for the root item
|
|
70
|
+
if (deepMapContext.object === payload) {
|
|
71
|
+
effectiveItemId = primaryKeyInObject ?? itemId ?? null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Ensure no unexpected fields sneak into a delete operation
|
|
75
|
+
if (direction === 'inbound' && action === 'delete') {
|
|
76
|
+
if (key !== pkField) {
|
|
77
|
+
throw new InvalidPayloadError({ reason: `Unexpected field ${key} in delete payload` });
|
|
78
|
+
}
|
|
79
|
+
const allowed = await getPermissions(currentCollection, primaryKeyInObject, 'delete');
|
|
80
|
+
if (allowed === null || (allowed.length === 0 && !accountability?.admin)) {
|
|
81
|
+
throw new ForbiddenError({ reason: `No permission to delete item in collection ${currentCollection}` });
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Allow PK field for identification on updates
|
|
86
|
+
if (direction === 'inbound' && action === 'update' && key === pkField) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
let allowedFields = await getPermissions(currentCollection, effectiveItemId, action);
|
|
90
|
+
// Fallbacks
|
|
91
|
+
if (!allowedFields) {
|
|
92
|
+
if (direction === 'inbound' && action === 'update') {
|
|
93
|
+
// Toggle to create if update fails due to non-existence
|
|
94
|
+
action = 'create';
|
|
95
|
+
allowedFields = await getPermissions(currentCollection, effectiveItemId, action);
|
|
96
|
+
}
|
|
97
|
+
else if (direction === 'outbound') {
|
|
98
|
+
// Fall back to collection-wide read
|
|
99
|
+
allowedFields = (await getPermissions(currentCollection, null, 'read')) ?? [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const isAllowed = allowedFields && (accountability?.admin || allowedFields.includes('*') || allowedFields.includes(String(key)));
|
|
103
|
+
if (!isAllowed) {
|
|
104
|
+
if (direction === 'inbound') {
|
|
105
|
+
throw new ForbiddenError({ reason: `No permission to ${action} field ${key} or field does not exist` });
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
// Remove the relation field entirely from the payload if it's empty after sanitizing its children
|
|
110
|
+
if (direction === 'outbound' && deepMapContext.relationType) {
|
|
111
|
+
if (Array.isArray(value)) {
|
|
112
|
+
const items = value.filter(isVisible);
|
|
113
|
+
if (items.length === 0)
|
|
114
|
+
return undefined;
|
|
115
|
+
return [key, items];
|
|
116
|
+
}
|
|
117
|
+
else if (isDetailedUpdateSyntax(value)) {
|
|
118
|
+
const filtered = {
|
|
119
|
+
...value,
|
|
120
|
+
create: value.create.filter(isVisible),
|
|
121
|
+
update: value.update.filter(isVisible),
|
|
122
|
+
delete: value.delete.filter(isVisible),
|
|
123
|
+
};
|
|
124
|
+
if (filtered.create.length === 0 && filtered.update.length === 0 && filtered.delete.length === 0) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
return [key, filtered];
|
|
128
|
+
}
|
|
129
|
+
else if (!isVisible(value)) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return [key, value];
|
|
134
|
+
}, {
|
|
135
|
+
schema,
|
|
136
|
+
collection,
|
|
137
|
+
}, {
|
|
138
|
+
detailedUpdateSyntax: true,
|
|
139
|
+
omitUnknownFields: direction === 'outbound',
|
|
140
|
+
mapPrimaryKeys: true,
|
|
141
|
+
processAsync: true,
|
|
142
|
+
iterateOnly: direction === 'inbound', // Validation only needs to check permissions, not rebuild the payload
|
|
143
|
+
onUnknownField: (entry) => {
|
|
144
|
+
const [key] = entry;
|
|
145
|
+
// Allow Directus internal metadata keys like $type
|
|
146
|
+
if (String(key).startsWith('$'))
|
|
147
|
+
return entry;
|
|
148
|
+
if (direction === 'inbound') {
|
|
149
|
+
throw new ForbiddenError({ reason: `No permission to update field ${key} or field does not exist` });
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// Identifies non-empty or defined actionable content to avoid processing invalid relation links
|
|
156
|
+
function isVisible(item) {
|
|
157
|
+
return item !== undefined && !(typeof item === 'object' && item !== null && Object.keys(item).length === 0);
|
|
158
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Accountability } from '@directus/types';
|
|
2
|
+
/**
|
|
3
|
+
* Caches permission check results for collaborative editing clients.
|
|
4
|
+
* Supports granular invalidation based on collection, item, and relational dependencies.
|
|
5
|
+
*/
|
|
6
|
+
export declare class PermissionCache {
|
|
7
|
+
private cache;
|
|
8
|
+
private tags;
|
|
9
|
+
private keyTags;
|
|
10
|
+
private timers;
|
|
11
|
+
private bus;
|
|
12
|
+
private invalidationCount;
|
|
13
|
+
constructor(maxSize: number);
|
|
14
|
+
/**
|
|
15
|
+
* Used for race condition protection during async permission fetches.
|
|
16
|
+
*/
|
|
17
|
+
getInvalidationCount(): number;
|
|
18
|
+
/**
|
|
19
|
+
* Clears entire cache for system collections, or performs granular invalidation for user data.
|
|
20
|
+
*/
|
|
21
|
+
private handleInvalidation;
|
|
22
|
+
/**
|
|
23
|
+
* Get cached allowed fields for a given accountability and collection/item.
|
|
24
|
+
* LRUMap automatically updates access order on get().
|
|
25
|
+
*/
|
|
26
|
+
get(accountability: Accountability, collection: string, item: string | null, action: string): string[] | null | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Store allowed fields in the cache with optional TTL and dependencies.
|
|
29
|
+
*/
|
|
30
|
+
set(accountability: Accountability, collection: string, item: string | null, action: string, fields: string[] | null, dependencies?: string[], ttlMs?: number): void;
|
|
31
|
+
/**
|
|
32
|
+
* Called before LRU eviction or explicit invalidation to prevent orphaned metadata.
|
|
33
|
+
*/
|
|
34
|
+
private cleanupKeyMetadata;
|
|
35
|
+
/**
|
|
36
|
+
* Maintains bidirectional mappings: tag → keys and key → tags.
|
|
37
|
+
*/
|
|
38
|
+
private addTag;
|
|
39
|
+
/**
|
|
40
|
+
* Cleans up metadata first, then removes from cache.
|
|
41
|
+
*/
|
|
42
|
+
private invalidateKey;
|
|
43
|
+
/**
|
|
44
|
+
* Cache key format: user:collection:item:action
|
|
45
|
+
*/
|
|
46
|
+
private getCacheKey;
|
|
47
|
+
/**
|
|
48
|
+
* Clear the entire cache.
|
|
49
|
+
*/
|
|
50
|
+
clear(): void;
|
|
51
|
+
}
|
|
52
|
+
export declare const permissionCache: PermissionCache;
|