directus

Форк
0
/
versions.ts 
324 строки · 9.4 Кб
1
import { Action } from '@directus/constants';
2
import { InvalidPayloadError, UnprocessableContentError } from '@directus/errors';
3
import type { ContentVersion, Filter, Item, PrimaryKey, Query } from '@directus/types';
4
import Joi from 'joi';
5
import { assign, pick } from 'lodash-es';
6
import objectHash from 'object-hash';
7
import { getCache } from '../cache.js';
8
import getDatabase from '../database/index.js';
9
import emitter from '../emitter.js';
10
import type { AbstractServiceOptions, MutationOptions } from '../types/index.js';
11
import { shouldClearCache } from '../utils/should-clear-cache.js';
12
import { ActivityService } from './activity.js';
13
import { AuthorizationService } from './authorization.js';
14
import { ItemsService } from './items.js';
15
import { PayloadService } from './payload.js';
16
import { RevisionsService } from './revisions.js';
17

18
export class VersionsService extends ItemsService {
19
	authorizationService: AuthorizationService;
20

21
	constructor(options: AbstractServiceOptions) {
22
		super('directus_versions', options);
23

24
		this.authorizationService = new AuthorizationService({
25
			accountability: this.accountability,
26
			knex: this.knex,
27
			schema: this.schema,
28
		});
29
	}
30

31
	private async validateCreateData(data: Partial<Item>): Promise<void> {
32
		if (!data['key']) throw new InvalidPayloadError({ reason: `"key" is required` });
33

34
		// Reserves the "main" version key for the version query parameter
35
		if (data['key'] === 'main') throw new InvalidPayloadError({ reason: `"main" is a reserved version key` });
36

37
		if (!data['collection']) {
38
			throw new InvalidPayloadError({ reason: `"collection" is required` });
39
		}
40

41
		if (!data['item']) throw new InvalidPayloadError({ reason: `"item" is required` });
42

43
		const { CollectionsService } = await import('./collections.js');
44

45
		const collectionsService = new CollectionsService({
46
			accountability: null,
47
			knex: this.knex,
48
			schema: this.schema,
49
		});
50

51
		const existingCollection = await collectionsService.readOne(data['collection']);
52

53
		if (!existingCollection.meta?.versioning) {
54
			throw new UnprocessableContentError({
55
				reason: `Content Versioning is not enabled for collection "${data['collection']}"`,
56
			});
57
		}
58

59
		const existingVersions = await super.readByQuery({
60
			aggregate: { count: ['*'] },
61
			filter: { key: { _eq: data['key'] }, collection: { _eq: data['collection'] }, item: { _eq: data['item'] } },
62
		});
63

64
		if (existingVersions[0]!['count'] > 0) {
65
			throw new UnprocessableContentError({
66
				reason: `Version "${data['key']}" already exists for item "${data['item']}" in collection "${data['collection']}"`,
67
			});
68
		}
69

70
		// will throw an error if the accountability does not have permission to read the item
71
		await this.authorizationService.checkAccess('read', data['collection'], data['item']);
72
	}
73

74
	async getMainItem(collection: string, item: PrimaryKey, query?: Query): Promise<Item> {
75
		// will throw an error if the accountability does not have permission to read the item
76
		await this.authorizationService.checkAccess('read', collection, item);
77

78
		const itemsService = new ItemsService(collection, {
79
			knex: this.knex,
80
			accountability: this.accountability,
81
			schema: this.schema,
82
		});
83

84
		return await itemsService.readOne(item, query);
85
	}
86

87
	async verifyHash(
88
		collection: string,
89
		item: PrimaryKey,
90
		hash: string,
91
	): Promise<{ outdated: boolean; mainHash: string }> {
92
		const mainItem = await this.getMainItem(collection, item);
93

94
		const mainHash = objectHash(mainItem);
95

96
		return { outdated: hash !== mainHash, mainHash };
97
	}
98

99
	async getVersionSavesById(id: PrimaryKey): Promise<Partial<Item>[]> {
100
		const revisionsService = new RevisionsService({
101
			knex: this.knex,
102
			schema: this.schema,
103
		});
104

105
		const result = await revisionsService.readByQuery({
106
			filter: { version: { _eq: id } },
107
		});
108

109
		return result.map((revision) => revision['delta']);
110
	}
111

112
	async getVersionSaves(key: string, collection: string, item: string | undefined): Promise<Partial<Item>[] | null> {
113
		const filter: Filter = {
114
			key: { _eq: key },
115
			collection: { _eq: collection },
116
		};
117

118
		if (item) {
119
			filter['item'] = { _eq: item };
120
		}
121

122
		const versions = await this.readByQuery({ filter });
123

124
		if (!versions?.[0]) return null;
125

126
		const saves = await this.getVersionSavesById(versions[0]['id']);
127

128
		return saves;
129
	}
130

131
	override async createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> {
132
		await this.validateCreateData(data);
133

134
		const mainItem = await this.getMainItem(data['collection'], data['item']);
135

136
		data['hash'] = objectHash(mainItem);
137

138
		return super.createOne(data, opts);
139
	}
140

141
	override async createMany(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]> {
142
		if (!Array.isArray(data)) {
143
			throw new InvalidPayloadError({ reason: 'Input should be an array of items' });
144
		}
145

146
		const keyCombos = new Set();
147

148
		for (const item of data) {
149
			await this.validateCreateData(item);
150

151
			const keyCombo = `${item['key']}-${item['collection']}-${item['item']}`;
152

153
			if (keyCombos.has(keyCombo)) {
154
				throw new UnprocessableContentError({
155
					reason: `Cannot create multiple versions on "${item['item']}" in collection "${item['collection']}" with the same key "${item['key']}"`,
156
				});
157
			}
158

159
			keyCombos.add(keyCombo);
160

161
			const mainItem = await this.getMainItem(item['collection'], item['item']);
162

163
			item['hash'] = objectHash(mainItem);
164
		}
165

166
		return super.createMany(data, opts);
167
	}
168

169
	override async updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]> {
170
		// Only allow updates on "key" and "name" fields
171
		const versionUpdateSchema = Joi.object({
172
			key: Joi.string(),
173
			name: Joi.string().allow(null).optional(),
174
		});
175

176
		const { error } = versionUpdateSchema.validate(data);
177
		if (error) throw new InvalidPayloadError({ reason: error.message });
178

179
		if ('key' in data) {
180
			// Reserves the "main" version key for the version query parameter
181
			if (data['key'] === 'main') throw new InvalidPayloadError({ reason: `"main" is a reserved version key` });
182

183
			const keyCombos = new Set();
184

185
			for (const pk of keys) {
186
				const { collection, item } = await this.readOne(pk, { fields: ['collection', 'item'] });
187

188
				const keyCombo = `${data['key']}-${collection}-${item}`;
189

190
				if (keyCombos.has(keyCombo)) {
191
					throw new UnprocessableContentError({
192
						reason: `Cannot update multiple versions on "${item}" in collection "${collection}" to the same key "${data['key']}"`,
193
					});
194
				}
195

196
				keyCombos.add(keyCombo);
197

198
				const existingVersions = await super.readByQuery({
199
					aggregate: { count: ['*'] },
200
					filter: { id: { _neq: pk }, key: { _eq: data['key'] }, collection: { _eq: collection }, item: { _eq: item } },
201
				});
202

203
				if (existingVersions[0]!['count'] > 0) {
204
					throw new UnprocessableContentError({
205
						reason: `Version "${data['key']}" already exists for item "${item}" in collection "${collection}"`,
206
					});
207
				}
208
			}
209
		}
210

211
		return super.updateMany(keys, data, opts);
212
	}
213

214
	async save(key: PrimaryKey, data: Partial<Item>) {
215
		const version = await super.readOne(key);
216

217
		const payloadService = new PayloadService(this.collection, {
218
			accountability: this.accountability,
219
			knex: this.knex,
220
			schema: this.schema,
221
		});
222

223
		const activityService = new ActivityService({
224
			knex: this.knex,
225
			schema: this.schema,
226
		});
227

228
		const revisionsService = new RevisionsService({
229
			knex: this.knex,
230
			schema: this.schema,
231
		});
232

233
		const { item, collection } = version;
234

235
		const activity = await activityService.createOne({
236
			action: Action.VERSION_SAVE,
237
			user: this.accountability?.user ?? null,
238
			collection,
239
			ip: this.accountability?.ip ?? null,
240
			user_agent: this.accountability?.userAgent ?? null,
241
			origin: this.accountability?.origin ?? null,
242
			item,
243
		});
244

245
		const revisionDelta = await payloadService.prepareDelta(data);
246

247
		await revisionsService.createOne({
248
			activity,
249
			version: key,
250
			collection,
251
			item,
252
			data: revisionDelta,
253
			delta: revisionDelta,
254
		});
255

256
		const { cache } = getCache();
257

258
		if (shouldClearCache(cache, undefined, collection)) {
259
			cache.clear();
260
		}
261

262
		return data;
263
	}
264

265
	async promote(version: PrimaryKey, mainHash: string, fields?: string[]) {
266
		const { id, collection, item } = (await this.readOne(version)) as ContentVersion;
267

268
		// will throw an error if the accountability does not have permission to update the item
269
		await this.authorizationService.checkAccess('update', collection, item);
270

271
		const { outdated } = await this.verifyHash(collection, item, mainHash);
272

273
		if (outdated) {
274
			throw new UnprocessableContentError({
275
				reason: `Main item has changed since this version was last updated`,
276
			});
277
		}
278

279
		const saves = await this.getVersionSavesById(id);
280

281
		const versionResult = assign({}, ...saves);
282

283
		const payloadToUpdate = fields ? pick(versionResult, fields) : versionResult;
284

285
		const itemsService = new ItemsService(collection, {
286
			accountability: this.accountability,
287
			schema: this.schema,
288
		});
289

290
		const payloadAfterHooks = await emitter.emitFilter(
291
			['items.promote', `${collection}.items.promote`],
292
			payloadToUpdate,
293
			{
294
				collection,
295
				item,
296
				version,
297
			},
298
			{
299
				database: getDatabase(),
300
				schema: this.schema,
301
				accountability: this.accountability,
302
			},
303
		);
304

305
		const updatedItemKey = await itemsService.updateOne(item, payloadAfterHooks);
306

307
		emitter.emitAction(
308
			['items.promote', `${collection}.items.promote`],
309
			{
310
				payload: payloadAfterHooks,
311
				collection,
312
				item: updatedItemKey,
313
				version,
314
			},
315
			{
316
				database: getDatabase(),
317
				schema: this.schema,
318
				accountability: this.accountability,
319
			},
320
		);
321

322
		return updatedItemKey;
323
	}
324
}
325

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.