@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,121 @@
|
|
|
1
|
+
import { REGEX_BETWEEN_PARENS } from '@directus/constants';
|
|
2
|
+
import { adjustDate } from '@directus/utils';
|
|
3
|
+
export const DYNAMIC_VARIABLE_MAP = {
|
|
4
|
+
$CURRENT_USER: 'directus_users',
|
|
5
|
+
$CURRENT_ROLE: 'directus_roles',
|
|
6
|
+
$CURRENT_ROLES: 'directus_roles',
|
|
7
|
+
$CURRENT_POLICIES: 'directus_policies',
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Calculate logical expiry (TTL) and dependencies for permissions caching.
|
|
11
|
+
*/
|
|
12
|
+
export function calculateCacheMetadata(collection, itemData, rawPermissions, schema, accountability) {
|
|
13
|
+
let ttlMs;
|
|
14
|
+
const dependencies = new Set();
|
|
15
|
+
if (itemData) {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
let closestExpiry = Infinity;
|
|
18
|
+
// Scan permission filters for dynamic variables and relational dependencies to determine cache invalidation rules
|
|
19
|
+
const scan = (val, fieldKey, currentCollection = collection) => {
|
|
20
|
+
if (!val || typeof val !== 'object')
|
|
21
|
+
return;
|
|
22
|
+
for (const [key, value] of Object.entries(val)) {
|
|
23
|
+
// Parse dynamic variables
|
|
24
|
+
if (typeof value === 'string' && value.startsWith('$')) {
|
|
25
|
+
// $NOW requires calculating a logical expiry (TTL) based on the field value
|
|
26
|
+
if (value.startsWith('$NOW')) {
|
|
27
|
+
const field = fieldKey || key;
|
|
28
|
+
const dateValue = itemData[field];
|
|
29
|
+
if (dateValue) {
|
|
30
|
+
let ruleDate = new Date();
|
|
31
|
+
if (value.includes('(')) {
|
|
32
|
+
const adjustment = value.match(REGEX_BETWEEN_PARENS)?.[1];
|
|
33
|
+
if (adjustment)
|
|
34
|
+
ruleDate = adjustDate(ruleDate, adjustment) || ruleDate;
|
|
35
|
+
}
|
|
36
|
+
const adjustmentMs = ruleDate.getTime() - now;
|
|
37
|
+
const expiry = new Date(dateValue).getTime() - adjustmentMs;
|
|
38
|
+
if (expiry > now && expiry < closestExpiry) {
|
|
39
|
+
closestExpiry = expiry;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
// Other dynamic variables ($CURRENT_USER, etc) create collection-based dependencies
|
|
45
|
+
const parts = value.split('.');
|
|
46
|
+
const dynamicVariable = parts[0];
|
|
47
|
+
const rootCollection = DYNAMIC_VARIABLE_MAP[dynamicVariable];
|
|
48
|
+
if (rootCollection) {
|
|
49
|
+
// Only $CURRENT_USER needs granular tagging
|
|
50
|
+
// Other dynamic variables trigger full cache wipe so collection-level is sufficient
|
|
51
|
+
if (dynamicVariable === '$CURRENT_USER' && accountability.user) {
|
|
52
|
+
dependencies.add(`${rootCollection}:${accountability.user}`);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
dependencies.add(rootCollection);
|
|
56
|
+
}
|
|
57
|
+
// Track all intermediate collections in the path
|
|
58
|
+
if (parts.length > 1) {
|
|
59
|
+
let currentCollection = rootCollection;
|
|
60
|
+
for (const segment of parts.slice(1, -1)) {
|
|
61
|
+
if (!currentCollection)
|
|
62
|
+
break;
|
|
63
|
+
const relation = schema.relations.find((r) => (r.collection === currentCollection && r.field === segment) ||
|
|
64
|
+
(r.related_collection === currentCollection && r.meta?.one_field === segment));
|
|
65
|
+
if (relation) {
|
|
66
|
+
currentCollection =
|
|
67
|
+
relation.collection === currentCollection
|
|
68
|
+
? relation.related_collection
|
|
69
|
+
: relation.collection;
|
|
70
|
+
if (currentCollection)
|
|
71
|
+
dependencies.add(currentCollection);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
currentCollection = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Parse relational filter dependencies to track which collections affect this permission
|
|
82
|
+
let field = key;
|
|
83
|
+
if (key.includes('(') && key.includes(')')) {
|
|
84
|
+
const columnName = key.match(REGEX_BETWEEN_PARENS)?.[1];
|
|
85
|
+
if (columnName)
|
|
86
|
+
field = columnName;
|
|
87
|
+
}
|
|
88
|
+
if (!field.startsWith('_')) {
|
|
89
|
+
const relation = schema.relations.find((r) => (r.collection === currentCollection && r.field === field) ||
|
|
90
|
+
(r.related_collection === currentCollection && r.meta?.one_field === field));
|
|
91
|
+
let targetCol = null;
|
|
92
|
+
if (relation) {
|
|
93
|
+
targetCol = relation.collection === currentCollection ? relation.related_collection : relation.collection;
|
|
94
|
+
}
|
|
95
|
+
if (targetCol) {
|
|
96
|
+
dependencies.add(targetCol);
|
|
97
|
+
scan(value, undefined, targetCol);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// Not a relation, but might be a nested filter object
|
|
101
|
+
scan(value, field.startsWith('_') ? fieldKey : field, currentCollection);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// Keep scanning filter operators
|
|
106
|
+
scan(value, fieldKey, currentCollection);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
// Scan all raw permissions to collect dependencies and calculate TTL
|
|
111
|
+
for (const permission of rawPermissions) {
|
|
112
|
+
scan(permission.permissions);
|
|
113
|
+
}
|
|
114
|
+
if (closestExpiry !== Infinity) {
|
|
115
|
+
ttlMs = closestExpiry - now;
|
|
116
|
+
// Limit TTL to between 1s and 1 hour
|
|
117
|
+
ttlMs = Math.max(1000, Math.min(ttlMs, 3600000));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { ttlMs, dependencies: Array.from(dependencies) };
|
|
121
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type WebSocketClient } from '@directus/types';
|
|
2
|
+
import { Messenger } from './messenger.js';
|
|
3
|
+
import { RoomManager } from './room.js';
|
|
4
|
+
import type { DiscardMessage, FocusMessage, JoinMessage, LeaveMessage, UpdateAllMessage, UpdateMessage } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Handler responsible for subscriptions
|
|
7
|
+
*/
|
|
8
|
+
export declare class CollabHandler {
|
|
9
|
+
roomManager: RoomManager;
|
|
10
|
+
messenger: Messenger;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
private initialized;
|
|
13
|
+
private initializePromise?;
|
|
14
|
+
private settingsService?;
|
|
15
|
+
private cleanupJob?;
|
|
16
|
+
private cleanupInterval?;
|
|
17
|
+
private busHandler?;
|
|
18
|
+
private eventQueue;
|
|
19
|
+
/**
|
|
20
|
+
* Initialize the handler
|
|
21
|
+
*/
|
|
22
|
+
constructor();
|
|
23
|
+
initialize(force?: boolean): Promise<void>;
|
|
24
|
+
bindWebSocket(): void;
|
|
25
|
+
startBackgroundJobs(): void;
|
|
26
|
+
/**
|
|
27
|
+
* Terminate the handler and stop background jobs
|
|
28
|
+
*/
|
|
29
|
+
terminate(): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Ensure collaborative editing is enabled and initialized
|
|
32
|
+
*/
|
|
33
|
+
ensureEnabled(): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Join a collaborative editing room
|
|
36
|
+
*/
|
|
37
|
+
onJoin(client: WebSocketClient, message: JoinMessage): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Leave a collaborative editing room
|
|
40
|
+
*/
|
|
41
|
+
onLeave(client: WebSocketClient, message?: LeaveMessage): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Update a field value
|
|
44
|
+
*/
|
|
45
|
+
onUpdate(client: WebSocketClient, message: UpdateMessage): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Update multiple field values
|
|
48
|
+
*/
|
|
49
|
+
onUpdateAll(client: WebSocketClient, message: UpdateAllMessage): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Update focus state
|
|
52
|
+
*/
|
|
53
|
+
onFocus(client: WebSocketClient, message: FocusMessage): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Discard specified changes in the room
|
|
56
|
+
*/
|
|
57
|
+
onDiscard(client: WebSocketClient, message: DiscardMessage): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Verify field access for both READ and UPDATE permissions
|
|
60
|
+
*/
|
|
61
|
+
private checkFieldsAccess;
|
|
62
|
+
private getAllowedFields;
|
|
63
|
+
}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { useEnv } from '@directus/env';
|
|
2
|
+
import { ForbiddenError, InvalidPayloadError, ServiceUnavailableError } from '@directus/errors';
|
|
3
|
+
import { WS_TYPE } from '@directus/types';
|
|
4
|
+
import { ClientMessage } from '@directus/types/collab';
|
|
5
|
+
import { toArray } from '@directus/utils';
|
|
6
|
+
import { difference, intersection, isEmpty, upperFirst } from 'lodash-es';
|
|
7
|
+
import getDatabase from '../../database/index.js';
|
|
8
|
+
import emitter from '../../emitter.js';
|
|
9
|
+
import { useLogger } from '../../logger/index.js';
|
|
10
|
+
import { validateItemAccess } from '../../permissions/modules/validate-access/lib/validate-item-access.js';
|
|
11
|
+
import { SettingsService } from '../../services/settings.js';
|
|
12
|
+
import { getSchema } from '../../utils/get-schema.js';
|
|
13
|
+
import { isFieldAllowed } from '../../utils/is-field-allowed.js';
|
|
14
|
+
import { scheduleSynchronizedJob } from '../../utils/schedule.js';
|
|
15
|
+
import { getMessageType } from '../utils/message.js';
|
|
16
|
+
import { IRRELEVANT_COLLECTIONS } from './constants.js';
|
|
17
|
+
import { Messenger } from './messenger.js';
|
|
18
|
+
import { validateChanges } from './payload-permissions.js';
|
|
19
|
+
import { RoomManager } from './room.js';
|
|
20
|
+
import { verifyPermissions } from './verify-permissions.js';
|
|
21
|
+
const env = useEnv();
|
|
22
|
+
const CLUSTER_CLEANUP_CRON = String(env['WEBSOCKETS_COLLAB_CLUSTER_CLEANUP_CRON']);
|
|
23
|
+
const LOCAL_CLEANUP_INTERVAL = Number(env['WEBSOCKETS_COLLAB_LOCAL_CLEANUP_INTERVAL']);
|
|
24
|
+
/**
|
|
25
|
+
* Handler responsible for subscriptions
|
|
26
|
+
*/
|
|
27
|
+
export class CollabHandler {
|
|
28
|
+
roomManager;
|
|
29
|
+
messenger = new Messenger();
|
|
30
|
+
enabled = false;
|
|
31
|
+
initialized;
|
|
32
|
+
initializePromise;
|
|
33
|
+
settingsService;
|
|
34
|
+
cleanupJob;
|
|
35
|
+
cleanupInterval;
|
|
36
|
+
busHandler;
|
|
37
|
+
eventQueue = Promise.resolve();
|
|
38
|
+
/**
|
|
39
|
+
* Initialize the handler
|
|
40
|
+
*/
|
|
41
|
+
constructor() {
|
|
42
|
+
this.roomManager = new RoomManager(this.messenger);
|
|
43
|
+
this.initialized = this.initialize();
|
|
44
|
+
this.bindWebSocket();
|
|
45
|
+
this.startBackgroundJobs();
|
|
46
|
+
}
|
|
47
|
+
initialize(force = false) {
|
|
48
|
+
if (this.initialized && !force)
|
|
49
|
+
return this.initialized;
|
|
50
|
+
if (this.initializePromise)
|
|
51
|
+
return this.initializePromise;
|
|
52
|
+
this.initializePromise = (async () => {
|
|
53
|
+
try {
|
|
54
|
+
if (!this.settingsService) {
|
|
55
|
+
const schema = await getSchema();
|
|
56
|
+
this.settingsService = new SettingsService({ schema });
|
|
57
|
+
}
|
|
58
|
+
const settings = await this.settingsService.readSingleton({ fields: ['collaborative_editing_enabled'] });
|
|
59
|
+
this.enabled = settings?.['collaborative_editing_enabled'] ?? false;
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
useLogger().error(err, '[Collab] Failed to initialize collaborative editing settings');
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
this.initializePromise = undefined;
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
if (!this.initialized) {
|
|
69
|
+
this.initialized = this.initializePromise;
|
|
70
|
+
}
|
|
71
|
+
return this.initializePromise;
|
|
72
|
+
}
|
|
73
|
+
bindWebSocket() {
|
|
74
|
+
/**
|
|
75
|
+
* Listen for all system events via bus to ensure once-only delivery and consistency across instances
|
|
76
|
+
*
|
|
77
|
+
* Local updates:
|
|
78
|
+
* Service -> Emitter -> Hooks -> Bus -> CollabHandler -> Room -> Local Clients
|
|
79
|
+
*
|
|
80
|
+
* Remote updates:
|
|
81
|
+
* Service (Node B) -> Emitter (Node B) -> Hooks (Node B) -> Bus -> CollabHandler (Node A) -> Room (Node A) -> Remote Clients
|
|
82
|
+
*/
|
|
83
|
+
this.busHandler = (event) => {
|
|
84
|
+
// Chain events to enforce sequence integrity
|
|
85
|
+
this.eventQueue = this.eventQueue
|
|
86
|
+
.then(async () => {
|
|
87
|
+
if (event.collection === 'directus_settings' &&
|
|
88
|
+
event.action === 'update' &&
|
|
89
|
+
'collaborative_editing_enabled' in event.payload) {
|
|
90
|
+
useLogger().debug(`[Collab] [Node ${this.messenger.uid}] Settings update via bus, triggering handler`);
|
|
91
|
+
// Non-blocking initialization to avoid resource contention
|
|
92
|
+
this.initialize(true)
|
|
93
|
+
.then(() => {
|
|
94
|
+
if (!this.enabled) {
|
|
95
|
+
try {
|
|
96
|
+
useLogger().debug(`[Collab] [Node ${this.messenger.uid}] Collaborative editing disabled, terminating all rooms`);
|
|
97
|
+
this.roomManager.terminateAll();
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
useLogger().error(err, '[Collab] Collaborative editing disabling terminateAll failed');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
.catch((err) => {
|
|
105
|
+
useLogger().error(err, '[Collab] Collaborative editing re-initialization failed');
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Skip irrelevant collections and actions early
|
|
110
|
+
if (event.action === 'create' || IRRELEVANT_COLLECTIONS.includes(event.collection)) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (event.action === 'update' || event.action === 'delete') {
|
|
114
|
+
let keys = [];
|
|
115
|
+
if (Array.isArray(event.keys)) {
|
|
116
|
+
keys = event.keys;
|
|
117
|
+
}
|
|
118
|
+
else if (event.key) {
|
|
119
|
+
keys = [event.key];
|
|
120
|
+
}
|
|
121
|
+
else if (event.payload && event.action === 'delete') {
|
|
122
|
+
keys = toArray(event.payload);
|
|
123
|
+
}
|
|
124
|
+
event.keys = keys;
|
|
125
|
+
const roomsToUpdate = Object.values(this.roomManager.rooms).filter((room) => {
|
|
126
|
+
// Versioned Rooms
|
|
127
|
+
if (room.version) {
|
|
128
|
+
return event.collection === 'directus_versions' && keys.some((key) => String(key) === room.version);
|
|
129
|
+
}
|
|
130
|
+
// Skip non-matching collections and version events
|
|
131
|
+
if (room.collection !== event.collection || event.collection === 'directus_versions')
|
|
132
|
+
return false;
|
|
133
|
+
// Match singleton
|
|
134
|
+
if (room.item === null)
|
|
135
|
+
return true;
|
|
136
|
+
// Match regular items
|
|
137
|
+
return keys.some((key) => String(key) === String(room.item));
|
|
138
|
+
});
|
|
139
|
+
if (roomsToUpdate.length === 0)
|
|
140
|
+
return;
|
|
141
|
+
await Promise.all(roomsToUpdate.map(async (room) => {
|
|
142
|
+
let relevantKeys;
|
|
143
|
+
if (room.version) {
|
|
144
|
+
relevantKeys = [room.version];
|
|
145
|
+
}
|
|
146
|
+
else if (room.item) {
|
|
147
|
+
relevantKeys = [room.item];
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
relevantKeys = keys;
|
|
151
|
+
}
|
|
152
|
+
const singleKeyedEvent = { ...event, keys: relevantKeys };
|
|
153
|
+
if (event.action === 'delete') {
|
|
154
|
+
await room.onDeleteHandler(singleKeyedEvent);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
await room.onUpdateHandler(singleKeyedEvent);
|
|
158
|
+
}
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
.catch((err) => {
|
|
163
|
+
useLogger().error(err, `[Collab] Bus message processing failed for ${event.collection}/${event.action}`);
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
this.messenger.messenger.subscribe('websocket.event', this.busHandler);
|
|
167
|
+
emitter.onAction('websocket.connect', ({ client }) => {
|
|
168
|
+
this.messenger.addClient(client);
|
|
169
|
+
});
|
|
170
|
+
// listen to incoming messages on the connected websockets
|
|
171
|
+
emitter.onAction('websocket.message', async ({ client, message }) => {
|
|
172
|
+
if (getMessageType(message) !== WS_TYPE.COLLAB)
|
|
173
|
+
return;
|
|
174
|
+
try {
|
|
175
|
+
await this.ensureEnabled();
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error instanceof ServiceUnavailableError && error.message.includes('Collaborative editing is disabled')) {
|
|
179
|
+
this.messenger.handleError(client.uid, error, message.action);
|
|
180
|
+
this.messenger.terminateClient(client.uid);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
const { data, error } = ClientMessage.safeParse(message);
|
|
186
|
+
if (!data) {
|
|
187
|
+
this.messenger.handleError(client.uid, new InvalidPayloadError({
|
|
188
|
+
reason: `Couldn't parse payload. ${error.message}`,
|
|
189
|
+
}));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
await this[`on${upperFirst(data.action)}`](client, message);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
this.messenger.handleError(client.uid, error, data?.action);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
// unsubscribe when a connection drops
|
|
200
|
+
emitter.onAction('websocket.error', ({ client }) => this.onLeave(client));
|
|
201
|
+
emitter.onAction('websocket.close', ({ client }) => this.onLeave(client));
|
|
202
|
+
}
|
|
203
|
+
startBackgroundJobs() {
|
|
204
|
+
this.cleanupJob = scheduleSynchronizedJob('collab', CLUSTER_CLEANUP_CRON, async () => {
|
|
205
|
+
const { inactive } = await this.messenger.pruneDeadInstances();
|
|
206
|
+
// Remove clients and close rooms hosted by nodes that are now dead
|
|
207
|
+
for (const roomUid of inactive.rooms) {
|
|
208
|
+
const room = await this.roomManager.getRoom(roomUid);
|
|
209
|
+
if (room) {
|
|
210
|
+
// Remove dead clients globally
|
|
211
|
+
for (const client of inactive.clients) {
|
|
212
|
+
if (await room.hasClient(client)) {
|
|
213
|
+
useLogger().debug(`[Collab] Removing dead client ${client} from room ${roomUid}`);
|
|
214
|
+
await room.leave(client);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Close room if it was truly abandoned
|
|
218
|
+
if (await room.close()) {
|
|
219
|
+
this.roomManager.removeRoom(room.uid);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
this.cleanupInterval = setInterval(async () => {
|
|
225
|
+
try {
|
|
226
|
+
// Remove local clients that are no longer in the global registry
|
|
227
|
+
const globalClients = await this.messenger.getGlobalClients();
|
|
228
|
+
const localClients = (await this.roomManager.getLocalRoomClients()).map((client) => client.uid);
|
|
229
|
+
const invalidClients = difference(localClients, globalClients);
|
|
230
|
+
for (const client of invalidClients) {
|
|
231
|
+
const rooms = await this.roomManager.getClientRooms(client);
|
|
232
|
+
for (const room of rooms) {
|
|
233
|
+
useLogger().debug(`[Collab] Removing invalid client ${client} from room ${room.getDisplayName()}`);
|
|
234
|
+
await room.leave(client);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
await this.roomManager.cleanupRooms();
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
useLogger().error(err, '[Collab] Local cleanup interval failed');
|
|
241
|
+
}
|
|
242
|
+
}, LOCAL_CLEANUP_INTERVAL);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Terminate the handler and stop background jobs
|
|
246
|
+
*/
|
|
247
|
+
async terminate() {
|
|
248
|
+
await this.cleanupJob?.stop();
|
|
249
|
+
if (this.cleanupInterval) {
|
|
250
|
+
clearInterval(this.cleanupInterval);
|
|
251
|
+
}
|
|
252
|
+
if (this.busHandler) {
|
|
253
|
+
await this.messenger.messenger.unsubscribe('websocket.event', this.busHandler);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Ensure collaborative editing is enabled and initialized
|
|
258
|
+
*/
|
|
259
|
+
async ensureEnabled() {
|
|
260
|
+
await this.initialized;
|
|
261
|
+
if (!this.enabled) {
|
|
262
|
+
throw new ServiceUnavailableError({
|
|
263
|
+
reason: 'Collaborative editing is disabled',
|
|
264
|
+
service: 'collab',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Join a collaborative editing room
|
|
270
|
+
*/
|
|
271
|
+
async onJoin(client, message) {
|
|
272
|
+
if (client.accountability?.share) {
|
|
273
|
+
throw new ForbiddenError({
|
|
274
|
+
reason: 'Collaborative editing is not supported for shares',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
const schema = await getSchema();
|
|
278
|
+
const db = getDatabase();
|
|
279
|
+
try {
|
|
280
|
+
const { accessAllowed } = await validateItemAccess({
|
|
281
|
+
accountability: client.accountability,
|
|
282
|
+
action: 'read',
|
|
283
|
+
collection: message.collection,
|
|
284
|
+
primaryKeys: schema.collections[message.collection]?.singleton ? [] : [message.item],
|
|
285
|
+
}, { knex: db, schema });
|
|
286
|
+
if (!accessAllowed)
|
|
287
|
+
throw new ForbiddenError();
|
|
288
|
+
if (message.version) {
|
|
289
|
+
const { accessAllowed: versionAccessAllowed } = await validateItemAccess({
|
|
290
|
+
accountability: client.accountability,
|
|
291
|
+
action: 'read',
|
|
292
|
+
collection: 'directus_versions',
|
|
293
|
+
primaryKeys: [message.version],
|
|
294
|
+
}, { knex: db, schema });
|
|
295
|
+
if (!versionAccessAllowed)
|
|
296
|
+
throw new ForbiddenError();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
throw new ForbiddenError({
|
|
301
|
+
reason: `No permission to access item or it does not exist`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
if (message.initialChanges) {
|
|
305
|
+
await validateChanges(message.initialChanges, message.collection, message.item, {
|
|
306
|
+
knex: db,
|
|
307
|
+
schema,
|
|
308
|
+
accountability: client.accountability,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
const room = await this.roomManager.createRoom(message.collection, message.item, message.version ?? null, message.initialChanges);
|
|
312
|
+
await room.join(client, message.color);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Leave a collaborative editing room
|
|
316
|
+
*/
|
|
317
|
+
async onLeave(client, message) {
|
|
318
|
+
if (message?.room) {
|
|
319
|
+
const room = await this.roomManager.getRoom(message.room);
|
|
320
|
+
if (!room || !(await room.hasClient(client.uid))) {
|
|
321
|
+
throw new ForbiddenError({
|
|
322
|
+
reason: `No access to room "${message.room}" or it does not exist`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
await room.leave(client.uid);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
const rooms = await this.roomManager.getClientRooms(client.uid);
|
|
329
|
+
for (const room of rooms) {
|
|
330
|
+
await room.leave(client.uid);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Update a field value
|
|
336
|
+
*/
|
|
337
|
+
async onUpdate(client, message) {
|
|
338
|
+
const knex = getDatabase();
|
|
339
|
+
const schema = await getSchema();
|
|
340
|
+
const room = await this.roomManager.getRoom(message.room);
|
|
341
|
+
if (!room || !(await room.hasClient(client.uid))) {
|
|
342
|
+
throw new ForbiddenError({
|
|
343
|
+
reason: `No access to room ${message.room} or room does not exist`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
await this.checkFieldsAccess(client, room, message.field, 'update', { knex, schema });
|
|
347
|
+
// Focus field before update to prevent concurrent overwrite conflicts
|
|
348
|
+
let focus = await room.getFocusByUser(client.uid);
|
|
349
|
+
if (message.changes !== undefined) {
|
|
350
|
+
if (focus !== message.field) {
|
|
351
|
+
await room.focus(client, message.field);
|
|
352
|
+
focus = await room.getFocusByUser(client.uid);
|
|
353
|
+
}
|
|
354
|
+
// Focus field before update to prevent concurrent overwrite conflicts
|
|
355
|
+
if (!focus || focus !== message.field) {
|
|
356
|
+
throw new ForbiddenError({
|
|
357
|
+
reason: `Cannot update field ${message.field} without focusing on it first`,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
await validateChanges({ [message.field]: message.changes }, room.collection, room.item, {
|
|
361
|
+
knex,
|
|
362
|
+
schema,
|
|
363
|
+
accountability: client.accountability,
|
|
364
|
+
});
|
|
365
|
+
await room.update(client, { [message.field]: message.changes });
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
const currentFocuser = await room.getFocusByField(message.field);
|
|
369
|
+
if (currentFocuser && currentFocuser !== client.uid) {
|
|
370
|
+
throw new ForbiddenError({
|
|
371
|
+
reason: `Field ${message.field} is already focused by another user`,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
await room.unset(client, message.field);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Update multiple field values
|
|
379
|
+
*/
|
|
380
|
+
async onUpdateAll(client, message) {
|
|
381
|
+
if (isEmpty(message.changes))
|
|
382
|
+
return;
|
|
383
|
+
const room = await this.roomManager.getRoom(message.room);
|
|
384
|
+
if (!room || !(await room.hasClient(client.uid)))
|
|
385
|
+
throw new ForbiddenError({
|
|
386
|
+
reason: `No access to room ${message.room} or room does not exist`,
|
|
387
|
+
});
|
|
388
|
+
const collection = room.collection;
|
|
389
|
+
const knex = getDatabase();
|
|
390
|
+
const schema = await getSchema();
|
|
391
|
+
const fields = Object.keys(message.changes ?? {});
|
|
392
|
+
for (const key of fields) {
|
|
393
|
+
const focus = await room.getFocusByField(key);
|
|
394
|
+
if (focus && focus !== client.uid) {
|
|
395
|
+
delete message.changes?.[key];
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (!isEmpty(message.changes)) {
|
|
399
|
+
await validateChanges(message.changes, collection, room.item, {
|
|
400
|
+
knex,
|
|
401
|
+
schema,
|
|
402
|
+
accountability: client.accountability,
|
|
403
|
+
});
|
|
404
|
+
await room.update(client, message.changes);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Update focus state
|
|
409
|
+
*/
|
|
410
|
+
async onFocus(client, message) {
|
|
411
|
+
const room = await this.roomManager.getRoom(message.room);
|
|
412
|
+
if (!room || !(await room.hasClient(client.uid)))
|
|
413
|
+
throw new ForbiddenError({
|
|
414
|
+
reason: `No access to room ${message.room} or room does not exist`,
|
|
415
|
+
});
|
|
416
|
+
if (message.field) {
|
|
417
|
+
await this.checkFieldsAccess(client, room, message.field, 'focus on');
|
|
418
|
+
}
|
|
419
|
+
if (!(await room.focus(client, message.field ?? null))) {
|
|
420
|
+
throw new ForbiddenError({
|
|
421
|
+
reason: `Field ${message.field} is already focused by another user`,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Discard specified changes in the room
|
|
427
|
+
*/
|
|
428
|
+
async onDiscard(client, message) {
|
|
429
|
+
const room = await this.roomManager.getRoom(message.room);
|
|
430
|
+
if (!room || !(await room.hasClient(client.uid))) {
|
|
431
|
+
throw new ForbiddenError({
|
|
432
|
+
reason: `No access to room ${message.room} or room does not exist`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
const knex = getDatabase();
|
|
436
|
+
const schema = await getSchema();
|
|
437
|
+
const allowedFields = await this.getAllowedFields(client, room, knex, schema);
|
|
438
|
+
if (!allowedFields || allowedFields.length === 0) {
|
|
439
|
+
throw new ForbiddenError({
|
|
440
|
+
reason: `No permission to discard fields or item does not exist`,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
await room.discard(allowedFields);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Verify field access for both READ and UPDATE permissions
|
|
447
|
+
*/
|
|
448
|
+
async checkFieldsAccess(client, room, fields, errorAction, options = {}) {
|
|
449
|
+
const knex = options.knex ?? getDatabase();
|
|
450
|
+
const schema = options.schema ?? (await getSchema());
|
|
451
|
+
const allowedFields = await this.getAllowedFields(client, room, knex, schema);
|
|
452
|
+
const fieldsArray = Array.isArray(fields) ? fields : [fields];
|
|
453
|
+
for (const field of fieldsArray) {
|
|
454
|
+
const fieldExists = !!schema.collections[room.collection]?.fields[field];
|
|
455
|
+
if (!fieldExists || (allowedFields && !isFieldAllowed(allowedFields, field))) {
|
|
456
|
+
throw new ForbiddenError({
|
|
457
|
+
reason: `No permission to ${errorAction} field ${field} or field does not exist`,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
async getAllowedFields(client, room, knex, schema) {
|
|
463
|
+
const [read, update] = await Promise.all([
|
|
464
|
+
verifyPermissions(client.accountability, room.collection, room.item, 'read', { knex, schema }),
|
|
465
|
+
verifyPermissions(client.accountability, room.collection, room.item, 'update', { knex, schema }),
|
|
466
|
+
]);
|
|
467
|
+
if (read === null && update === null)
|
|
468
|
+
return null;
|
|
469
|
+
if (read === null)
|
|
470
|
+
return update;
|
|
471
|
+
if (update === null)
|
|
472
|
+
return read;
|
|
473
|
+
if (read.includes('*') && update.includes('*'))
|
|
474
|
+
return ['*'];
|
|
475
|
+
if (read.includes('*'))
|
|
476
|
+
return update;
|
|
477
|
+
if (update.includes('*'))
|
|
478
|
+
return read;
|
|
479
|
+
return intersection(read, update);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const IRRELEVANT_COLLECTIONS: string[];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const IRRELEVANT_COLLECTIONS = [
|
|
2
|
+
'directus_activity',
|
|
3
|
+
'directus_extensions',
|
|
4
|
+
'directus_flows',
|
|
5
|
+
'directus_folders',
|
|
6
|
+
'directus_migrations',
|
|
7
|
+
'directus_notifications',
|
|
8
|
+
'directus_operations',
|
|
9
|
+
'directus_presets',
|
|
10
|
+
'directus_revisions',
|
|
11
|
+
'directus_sessions',
|
|
12
|
+
'directus_shares',
|
|
13
|
+
];
|