@directus/api 35.1.0 → 35.2.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.
@@ -6,7 +6,9 @@ import { getMilliseconds } from "../utils/get-milliseconds.js";
6
6
  import { ASSET_TRANSFORM_QUERY_KEYS, SYSTEM_ASSET_ALLOW_LIST } from "../constants.js";
7
7
  import { isValidUuid } from "../utils/is-valid-uuid.js";
8
8
  import database_default from "../database/index.js";
9
+ import { validateAccess } from "../permissions/modules/validate-access/validate-access.js";
9
10
  import { PayloadService } from "../services/payload.js";
11
+ import { FilesService } from "../services/files.js";
10
12
  import { AssetsService } from "../services/assets.js";
11
13
  import { getCacheControlHeader } from "../utils/get-cache-headers.js";
12
14
  import use_collection_default from "../middleware/use-collection.js";
@@ -170,6 +172,44 @@ router.get("/:pk/:filename?", async_handler_default(async (req, res, next) => {
170
172
  }
171
173
  }
172
174
  }
175
+ const revalidate = env["ASSETS_CACHE_REVALIDATE"] === true;
176
+ if (revalidate) {
177
+ const ifNoneMatch = req.headers["if-none-match"];
178
+ const ifModifiedSince = req.headers["if-modified-since"];
179
+ if (ifNoneMatch || ifModifiedSince) {
180
+ if (req.accountability) await validateAccess({
181
+ accountability: req.accountability,
182
+ action: "read",
183
+ collection: "directus_files",
184
+ primaryKeys: [id]
185
+ }, {
186
+ knex: database_default(),
187
+ schema: req.schema
188
+ });
189
+ const fileRecord = await new FilesService({ schema: req.schema }).readOne(id, { fields: ["modified_on"] });
190
+ if (fileRecord?.modified_on) {
191
+ const modifiedOnTime = new Date(fileRecord.modified_on).getTime();
192
+ const etag = `"${Math.floor(modifiedOnTime / 1e3)}"`;
193
+ if (ifNoneMatch === etag) {
194
+ res.setHeader("Cache-Control", "max-age=0, must-revalidate");
195
+ res.setHeader("ETag", etag);
196
+ res.setHeader("Last-Modified", new Date(modifiedOnTime).toUTCString());
197
+ res.status(304);
198
+ return res.end();
199
+ }
200
+ if (ifModifiedSince) {
201
+ const ifModifiedSinceTime = new Date(ifModifiedSince).getTime();
202
+ if (Math.floor(modifiedOnTime / 1e3) <= Math.floor(ifModifiedSinceTime / 1e3)) {
203
+ res.setHeader("Cache-Control", "max-age=0, must-revalidate");
204
+ res.setHeader("ETag", etag);
205
+ res.setHeader("Last-Modified", new Date(modifiedOnTime).toUTCString());
206
+ res.status(304);
207
+ return res.end();
208
+ }
209
+ }
210
+ }
211
+ }
212
+ }
173
213
  const { stream, file, stat } = await service.getAsset(id, {
174
214
  transformationParams,
175
215
  acceptFormat
@@ -178,12 +218,14 @@ router.get("/:pk/:filename?", async_handler_default(async (req, res, next) => {
178
218
  res.attachment(filename);
179
219
  res.setHeader("Content-Type", file.type);
180
220
  res.setHeader("Accept-Ranges", "bytes");
181
- res.setHeader("Cache-Control", getCacheControlHeader(req, getMilliseconds(env["ASSETS_CACHE_TTL"]), false, true));
221
+ if (revalidate) res.setHeader("Cache-Control", "max-age=0, must-revalidate");
222
+ else res.setHeader("Cache-Control", getCacheControlHeader(req, getMilliseconds(env["ASSETS_CACHE_TTL"]), false, true));
182
223
  res.setHeader("Vary", vary.join(", "));
183
224
  const unixTime = Date.parse(file.modified_on);
184
225
  if (!Number.isNaN(unixTime)) {
185
226
  const lastModifiedDate = new Date(unixTime);
186
227
  res.setHeader("Last-Modified", lastModifiedDate.toUTCString());
228
+ res.setHeader("ETag", `"${Math.floor(unixTime / 1e3)}"`);
187
229
  }
188
230
  if (range) {
189
231
  res.setHeader("Content-Range", `bytes ${range.start}-${range.end || stat.size - 1}/${stat.size}`);
@@ -4,7 +4,7 @@ import { respond } from "../middleware/respond.js";
4
4
  import { getVersionedHash } from "../utils/get-versioned-hash.js";
5
5
  import { SchemaService } from "../services/schema.js";
6
6
  import { InvalidPayloadError, UnsupportedMediaTypeError } from "@directus/errors";
7
- import { parseJSON } from "@directus/utils";
7
+ import { parseJSON, toBoolean } from "@directus/utils";
8
8
  import express from "express";
9
9
  import { load } from "js-yaml";
10
10
  import Busboy from "busboy";
@@ -83,7 +83,7 @@ router.post("/diff", async_handler_default(schemaMultipartHandler), async_handle
83
83
  router.post("/apply", async_handler_default(schemaMultipartHandler), async_handler_default(async (req, res, next) => {
84
84
  const service = new SchemaService({ accountability: req.accountability });
85
85
  const diff = res.locals["upload"];
86
- await service.apply(diff);
86
+ await service.apply(diff, { force: toBoolean(req.query["force"]) });
87
87
  return next();
88
88
  }), respond);
89
89
  var schema_default = router;
@@ -7,10 +7,10 @@ import { useEnv } from "@directus/env";
7
7
  import { ErrorCode, ServiceUnavailableError, isDirectusError } from "@directus/errors";
8
8
  import { move, remove } from "fs-extra";
9
9
  import { Readable } from "node:stream";
10
+ import PQueue from "p-queue";
10
11
  import { download } from "@directus/extensions-registry";
11
12
  import { EXTENSION_PKG_KEY, ExtensionManifest } from "@directus/extensions";
12
13
  import DriverLocal from "@directus/storage-driver-local";
13
- import Queue from "p-queue";
14
14
  import { extract } from "tar";
15
15
 
16
16
  //#region src/extensions/lib/installation/manager.ts
@@ -50,7 +50,7 @@ var InstallationManager = class {
50
50
  if (!(await ExtensionManifest.parseAsync(packageFile))[EXTENSION_PKG_KEY]?.type) throw new Error(`Extension type not found in package.json`);
51
51
  if (env["EXTENSIONS_LOCATION"]) {
52
52
  const remoteDisk = (await getStorage()).location(env["EXTENSIONS_LOCATION"]);
53
- const queue = new Queue({ concurrency: 1e3 });
53
+ const queue = new PQueue({ concurrency: 1e3 });
54
54
  for await (const filepath of tmpStorage.list(extractedPath)) {
55
55
  const readStream = await tmpStorage.read(filepath);
56
56
  const remotePath = join(env["EXTENSIONS_PATH"], ".registry", versionId, filepath.substring(7));
@@ -74,7 +74,7 @@ var InstallationManager = class {
74
74
  async uninstall(folder) {
75
75
  if (env["EXTENSIONS_LOCATION"]) {
76
76
  const remoteDisk = (await getStorage()).location(env["EXTENSIONS_LOCATION"]);
77
- const queue = new Queue({ concurrency: 1e3 });
77
+ const queue = new PQueue({ concurrency: 1e3 });
78
78
  const prefix = join(env["EXTENSIONS_PATH"], ".registry", folder);
79
79
  for await (const filepath of remoteDisk.list(prefix)) queue.add(() => remoteDisk.delete(filepath));
80
80
  await queue.onIdle();
@@ -13,8 +13,8 @@ import { useEnv } from "@directus/env";
13
13
  import { normalizePath } from "@directus/utils";
14
14
  import { dirname, join, relative, resolve, sep } from "node:path";
15
15
  import { pipeline } from "node:stream/promises";
16
+ import PQueue from "p-queue";
16
17
  import { createWriteStream } from "node:fs";
17
- import Queue from "p-queue";
18
18
  import mid from "node-machine-id";
19
19
 
20
20
  //#region src/extensions/lib/sync/sync.ts
@@ -50,7 +50,7 @@ async function syncExtensions(options) {
50
50
  return;
51
51
  }
52
52
  }
53
- const queue = new Queue({ concurrency: 1e3 });
53
+ const queue = new PQueue({ concurrency: 1e3 });
54
54
  const fileTracker = new SyncFileTracker();
55
55
  const hasLocalFiles = await fileTracker.readLocalFiles(localExtensionsPath) > 0;
56
56
  for await (const filepath of disk.list(remoteExtensionsPath)) {
@@ -8,7 +8,6 @@ import { getSchema } from "../utils/get-schema.js";
8
8
  import { deleteFromRequireCache } from "../utils/delete-from-require-cache.js";
9
9
  import getModuleDefault from "../utils/get-module-default.js";
10
10
  import { importFileUrl } from "../utils/import-file-url.js";
11
- import { JobQueue } from "../utils/job-queue.js";
12
11
  import { scheduleSynchronizedJob, validateCron } from "../utils/schedule.js";
13
12
  import { getExtensionsSettings } from "./lib/get-extensions-settings.js";
14
13
  import { getExtensions } from "./lib/get-extensions.js";
@@ -30,6 +29,7 @@ import { pathToRelativeUrl, processId } from "@directus/utils/node";
30
29
  import { fileURLToPath } from "node:url";
31
30
  import { HYBRID_EXTENSION_TYPES } from "@directus/constants";
32
31
  import { dirname, join as join$1, relative, resolve, sep } from "node:path";
32
+ import PQueue from "p-queue";
33
33
  import os from "node:os";
34
34
  import { APP_SHARED_DEPS } from "@directus/extensions";
35
35
  import { generateExtensionsEntrypoint } from "@directus/extensions/node";
@@ -97,7 +97,7 @@ var ExtensionManager = class {
97
97
  * Used to prevent race conditions when reloading extensions. Forces each reload to happen in
98
98
  * sequence.
99
99
  */
100
- reloadQueue = new JobQueue();
100
+ reloadQueue = new PQueue({ concurrency: 1 });
101
101
  /**
102
102
  * Used to prevent race condition when reading extension data while reloading extensions
103
103
  */
@@ -248,7 +248,7 @@ var ExtensionManager = class {
248
248
  resolve$1 = res;
249
249
  reject = rej;
250
250
  });
251
- this.reloadQueue.enqueue(async () => {
251
+ this.reloadQueue.add(async () => {
252
252
  if (this.isLoaded) {
253
253
  const prevExtensions = clone(this.extensions);
254
254
  await this.unload();
package/dist/flows.js CHANGED
@@ -8,7 +8,6 @@ import emitter_default from "./emitter.js";
8
8
  import { getSchema } from "./utils/get-schema.js";
9
9
  import { ActivityService } from "./services/activity.js";
10
10
  import { getService } from "./utils/get-service.js";
11
- import { JobQueue } from "./utils/job-queue.js";
12
11
  import { scheduleSynchronizedJob, validateCron } from "./utils/schedule.js";
13
12
  import { RevisionsService } from "./services/revisions.js";
14
13
  import { services_exports } from "./services/index.js";
@@ -22,6 +21,7 @@ import { pick } from "lodash-es";
22
21
  import { Action } from "@directus/constants";
23
22
  import { isSystemCollection } from "@directus/system-data";
24
23
  import { get as get$1 } from "micromustache";
24
+ import PQueue from "p-queue";
25
25
 
26
26
  //#region src/flows.ts
27
27
  let flowManager;
@@ -41,15 +41,15 @@ var FlowManager = class {
41
41
  triggerHandlers = [];
42
42
  operationFlowHandlers = {};
43
43
  webhookFlowHandlers = {};
44
- reloadQueue;
44
+ reloadQueue = new PQueue({ concurrency: 1 });
45
45
  envs;
46
46
  constructor() {
47
47
  const env = useEnv();
48
- const logger = useLogger();
49
- this.reloadQueue = new JobQueue();
50
48
  this.envs = env["FLOWS_ENV_ALLOW_LIST"] ? pick(env, toArray(env["FLOWS_ENV_ALLOW_LIST"])) : {};
51
- useBus().subscribe("flows", (event) => {
52
- if (event["type"] === "reload") this.reloadQueue.enqueue(async () => {
49
+ const messenger = useBus();
50
+ const logger = useLogger();
51
+ messenger.subscribe("flows", (event) => {
52
+ if (event.type === "reload") this.reloadQueue.add(async () => {
53
53
  if (this.isLoaded) {
54
54
  await this.unload();
55
55
  await this.load();
@@ -70,6 +70,7 @@ var FlowManager = class {
70
70
  this.operations.delete(id);
71
71
  }
72
72
  async runOperationFlow(id, data, context) {
73
+ if (this.reloadQueue.pending > 0) await this.reloadQueue.onIdle();
73
74
  const logger = useLogger();
74
75
  if (!(id in this.operationFlowHandlers)) {
75
76
  logger.warn(`Couldn't find operation triggered flow with id "${id}"`);
@@ -79,6 +80,7 @@ var FlowManager = class {
79
80
  return handler(data, context);
80
81
  }
81
82
  async runWebhookFlow(id, data, context) {
83
+ if (this.reloadQueue.pending > 0) await this.reloadQueue.onIdle();
82
84
  const logger = useLogger();
83
85
  if (!(id in this.webhookFlowHandlers)) {
84
86
  logger.warn(`Couldn't find webhook or manual triggered flow with id "${id}"`);
@@ -47,6 +47,7 @@ var CollectionsService = class CollectionsService {
47
47
  if (!("collection" in payload)) throw new InvalidPayloadError({ reason: `"collection" is required` });
48
48
  if (typeof payload.collection !== "string" || payload.collection === "") throw new InvalidPayloadError({ reason: `"collection" must be a non-empty string` });
49
49
  if (payload.collection.startsWith("directus_")) throw new InvalidPayloadError({ reason: `Collections can't start with "directus_"` });
50
+ if (payload.collection.includes("/")) throw new InvalidPayloadError({ reason: `Collection name can't contain "/"` });
50
51
  payload.collection = await this.helpers.schema.parseCollectionName(payload.collection);
51
52
  const nestedActionEvents = [];
52
53
  try {
@@ -19,10 +19,10 @@ var SchemaService = class {
19
19
  if (this.accountability?.admin !== true) throw new ForbiddenError();
20
20
  return await getSnapshot({ database: this.knex });
21
21
  }
22
- async apply(payload) {
22
+ async apply(payload, options) {
23
23
  if (this.accountability?.admin !== true) throw new ForbiddenError();
24
24
  const currentSnapshot = await this.snapshot();
25
- if (!validateApplyDiff(payload, this.getHashedSnapshot(currentSnapshot))) return;
25
+ if (!validateApplyDiff(payload, this.getHashedSnapshot(currentSnapshot), options?.force)) return;
26
26
  await applyDiff(currentSnapshot, payload.diff, { database: this.knex });
27
27
  }
28
28
  async diff(snapshot, options) {
@@ -136,34 +136,35 @@ var VersionsService = class VersionsService extends ItemsService {
136
136
  knex: this.knex,
137
137
  schema: this.schema
138
138
  });
139
- const activityService = new ActivityService({
140
- knex: this.knex,
141
- schema: this.schema
142
- });
143
- const revisionsService = new RevisionsService({
144
- knex: this.knex,
145
- schema: this.schema
146
- });
147
139
  const { item, collection, delta: existingDelta } = version;
148
- const activity = await activityService.createOne({
149
- action: Action.VERSION_SAVE,
150
- user: this.accountability?.user ?? null,
151
- collection,
152
- ip: this.accountability?.ip ?? null,
153
- user_agent: this.accountability?.userAgent ?? null,
154
- origin: this.accountability?.origin ?? null,
155
- item
156
- });
157
140
  const helpers = getHelpers(this.knex);
158
141
  let revisionDelta = await payloadService.prepareDelta(delta);
159
- await revisionsService.createOne({
160
- activity,
161
- version: key,
162
- collection,
163
- item,
164
- data: revisionDelta,
165
- delta: revisionDelta
166
- });
142
+ const trackingAccountability = this.schema.collections[collection]?.accountability ?? null;
143
+ if (trackingAccountability !== null) {
144
+ const activity = await new ActivityService({
145
+ knex: this.knex,
146
+ schema: this.schema
147
+ }).createOne({
148
+ action: Action.VERSION_SAVE,
149
+ user: this.accountability?.user ?? null,
150
+ collection,
151
+ ip: this.accountability?.ip ?? null,
152
+ user_agent: this.accountability?.userAgent ?? null,
153
+ origin: this.accountability?.origin ?? null,
154
+ item
155
+ });
156
+ if (trackingAccountability === "all") await new RevisionsService({
157
+ knex: this.knex,
158
+ schema: this.schema
159
+ }).createOne({
160
+ activity,
161
+ version: key,
162
+ collection,
163
+ item,
164
+ data: revisionDelta,
165
+ delta: revisionDelta
166
+ });
167
+ }
167
168
  revisionDelta = revisionDelta ? revisionDelta : null;
168
169
  const date = new Date(helpers.date.writeTimestamp((/* @__PURE__ */ new Date()).toISOString()));
169
170
  deepMapObjects(revisionDelta, (object, path) => {
@@ -53,13 +53,17 @@ const applyJoiSchema = Joi.object({
53
53
  /**
54
54
  * Validates the diff against the current schema snapshot.
55
55
  *
56
+ * @param applyDiff The diff to validate with the expected hash
57
+ * @param currentSnapshotWithHash The current snapshot with hash to validate against
58
+ * @param force When true bypass hash validation. Use with caution as this can lead to unintended consequences, only use when the diff can be applied irrespective of the current schema.
59
+ *
56
60
  * @returns True if the diff can be applied (valid & not empty).
57
61
  */
58
- function validateApplyDiff(applyDiff, currentSnapshotWithHash) {
62
+ function validateApplyDiff(applyDiff, currentSnapshotWithHash, force = false) {
59
63
  const { error } = applyJoiSchema.validate(applyDiff);
60
64
  if (error) throw new InvalidPayloadError({ reason: error.message });
61
65
  if (applyDiff.diff.collections.length === 0 && applyDiff.diff.fields.length === 0 && applyDiff.diff.systemFields.length === 0 && applyDiff.diff.relations.length === 0) return false;
62
- if (applyDiff.hash === currentSnapshotWithHash.hash) return true;
66
+ if (applyDiff.hash === currentSnapshotWithHash.hash || force) return true;
63
67
  for (const diffCollection of applyDiff.diff.collections) {
64
68
  const collection = diffCollection.collection;
65
69
  if (diffCollection.diff[0]?.kind === DiffKind.NEW) {
@@ -97,7 +101,7 @@ function validateApplyDiff(applyDiff, currentSnapshotWithHash) {
97
101
  if (!currentSnapshotWithHash.relations.find((r) => r.collection === diffRelation.collection && r.field === diffRelation.field)) throw new InvalidPayloadError({ reason: `Provided diff is trying to delete relation "${relation}" but it does not exist. Please generate a new diff and try again` });
98
102
  }
99
103
  }
100
- throw new InvalidPayloadError({ reason: `Provided hash does not match the current instance's schema hash, indicating the schema has changed after this diff was generated. Please generate a new diff and try again` });
104
+ throw new InvalidPayloadError({ reason: `Provided hash does not match the current instance's schema hash, indicating the schema has changed after this diff was generated. Please generate a new diff and try again or use the "force" query parameter to bypass this check` });
101
105
  }
102
106
 
103
107
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directus/api",
3
- "version": "35.1.0",
3
+ "version": "35.2.0",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "keywords": [
6
6
  "directus",
@@ -121,7 +121,7 @@
121
121
  "keyv": "5.5.3",
122
122
  "knex": "3.1.0",
123
123
  "ldapts": "8.1.3",
124
- "liquidjs": "10.25.5",
124
+ "liquidjs": "10.25.7",
125
125
  "lodash-es": "4.18.1",
126
126
  "marked": "16.4.1",
127
127
  "micromustache": "8.0.3",
@@ -166,30 +166,29 @@
166
166
  "zod": "4.1.12",
167
167
  "zod-validation-error": "4.0.2",
168
168
  "@directus/ai": "1.3.1",
169
- "@directus/app": "15.9.0",
169
+ "@directus/env": "5.8.0",
170
+ "@directus/app": "15.10.0",
170
171
  "@directus/errors": "2.3.1",
171
- "@directus/extensions": "3.0.24",
172
- "@directus/env": "5.7.1",
172
+ "@directus/extensions": "3.0.25",
173
173
  "@directus/constants": "14.3.0",
174
- "@directus/extensions-registry": "3.0.25",
174
+ "@directus/extensions-registry": "3.0.26",
175
+ "@directus/extensions-sdk": "17.1.4",
175
176
  "@directus/format-title": "12.1.2",
176
- "@directus/extensions-sdk": "17.1.3",
177
- "@directus/pressure": "3.0.21",
178
- "@directus/memory": "3.1.7",
177
+ "@directus/memory": "3.1.8",
179
178
  "@directus/schema": "13.0.8",
180
- "@directus/specs": "13.0.0",
181
- "@directus/schema-builder": "0.0.19",
182
- "@directus/storage-driver-azure": "12.0.21",
179
+ "@directus/pressure": "3.0.22",
183
180
  "@directus/storage": "12.0.4",
184
- "@directus/storage-driver-gcs": "12.0.21",
185
- "@directus/storage-driver-s3": "12.1.7",
186
- "@directus/storage-driver-cloudinary": "12.0.21",
187
- "@directus/storage-driver-supabase": "3.0.21",
181
+ "@directus/storage-driver-cloudinary": "12.0.22",
182
+ "@directus/storage-driver-azure": "12.0.22",
183
+ "@directus/specs": "13.0.0",
184
+ "@directus/storage-driver-gcs": "12.0.22",
188
185
  "@directus/storage-driver-local": "12.0.4",
189
- "@directus/system-data": "4.4.0",
190
- "@directus/utils": "13.4.0",
191
- "directus": "11.17.3",
192
- "@directus/validation": "2.0.22"
186
+ "@directus/storage-driver-s3": "12.1.8",
187
+ "@directus/storage-driver-supabase": "3.0.22",
188
+ "@directus/utils": "13.4.1",
189
+ "@directus/validation": "2.0.23",
190
+ "directus": "11.17.4",
191
+ "@directus/system-data": "4.4.0"
193
192
  },
194
193
  "devDependencies": {
195
194
  "@directus/tsconfig": "4.0.0",
@@ -231,8 +230,8 @@
231
230
  "knex-mock-client": "3.0.2",
232
231
  "typescript": "5.9.3",
233
232
  "vitest": "3.2.4",
234
- "@directus/schema-builder": "0.0.19",
235
- "@directus/types": "15.0.2"
233
+ "@directus/schema-builder": "0.0.20",
234
+ "@directus/types": "15.0.3"
236
235
  },
237
236
  "optionalDependencies": {
238
237
  "@keyv/redis": "3.0.1",
@@ -1,24 +0,0 @@
1
- //#region src/utils/job-queue.ts
2
- var JobQueue = class {
3
- running;
4
- jobs;
5
- constructor() {
6
- this.running = false;
7
- this.jobs = [];
8
- }
9
- enqueue(job) {
10
- this.jobs.push(job);
11
- if (!this.running) this.run();
12
- }
13
- async run() {
14
- this.running = true;
15
- while (this.jobs.length > 0) await this.jobs.shift()();
16
- this.running = false;
17
- }
18
- get size() {
19
- return this.jobs.length;
20
- }
21
- };
22
-
23
- //#endregion
24
- export { JobQueue };