directus

Форк
0
/
extensions.ts 
286 строк · 8.2 Кб
1
import { useEnv } from '@directus/env';
2
import { ForbiddenError, InvalidPayloadError, LimitExceededError, UnprocessableContentError } from '@directus/errors';
3
import type { ApiOutput, BundleExtension, ExtensionSettings } from '@directus/extensions';
4
import { describe, type DescribeOptions } from '@directus/extensions-registry';
5
import type { Accountability, DeepPartial, SchemaOverview } from '@directus/types';
6
import { isObject } from '@directus/utils';
7
import type { Knex } from 'knex';
8
import getDatabase from '../database/index.js';
9
import { getExtensionManager } from '../extensions/index.js';
10
import type { ExtensionManager } from '../extensions/manager.js';
11
import type { AbstractServiceOptions } from '../types/index.js';
12
import { transaction } from '../utils/transaction.js';
13
import { ItemsService } from './items.js';
14

15
export class ExtensionReadError extends Error {
16
	originalError: unknown;
17
	constructor(originalError: unknown) {
18
		super();
19
		this.originalError = originalError;
20
	}
21
}
22

23
export class ExtensionsService {
24
	knex: Knex;
25
	accountability: Accountability | null;
26
	schema: SchemaOverview;
27
	extensionsItemService: ItemsService<ExtensionSettings>;
28
	extensionsManager: ExtensionManager;
29

30
	constructor(options: AbstractServiceOptions) {
31
		this.knex = options.knex || getDatabase();
32
		this.schema = options.schema;
33
		this.accountability = options.accountability || null;
34
		this.extensionsManager = getExtensionManager();
35

36
		this.extensionsItemService = new ItemsService('directus_extensions', {
37
			knex: this.knex,
38
			schema: this.schema,
39
			accountability: this.accountability,
40
		});
41
	}
42

43
	private async preInstall(extensionId: string, versionId: string) {
44
		const env = useEnv();
45

46
		const describeOptions: DescribeOptions = {};
47

48
		if (typeof env['MARKETPLACE_REGISTRY'] === 'string') {
49
			describeOptions.registry = env['MARKETPLACE_REGISTRY'];
50
		}
51

52
		const extension = await describe(extensionId, describeOptions);
53
		const version = extension.data.versions.find((version) => version.id === versionId);
54

55
		if (!version) {
56
			throw new ForbiddenError();
57
		}
58

59
		const limit = env['EXTENSIONS_LIMIT'] ? Number(env['EXTENSIONS_LIMIT']) : null;
60

61
		if (limit !== null) {
62
			const currentlyInstalledCount = this.extensionsManager.extensions.length;
63

64
			/**
65
			 * Bundle extensions should be counted as the number of nested entries rather than a single
66
			 * extension to avoid a vulnerability where you can get around the technical limit by bundling
67
			 * all extensions you want
68
			 */
69
			const points = version.bundled.length ?? 1;
70

71
			const afterInstallCount = currentlyInstalledCount + points;
72

73
			if (afterInstallCount >= limit) {
74
				throw new LimitExceededError({ category: 'Extensions' });
75
			}
76
		}
77

78
		return { extension, version };
79
	}
80

81
	async install(extensionId: string, versionId: string) {
82
		const { extension, version } = await this.preInstall(extensionId, versionId);
83

84
		await this.extensionsItemService.createOne({
85
			id: extensionId,
86
			enabled: true,
87
			folder: versionId,
88
			source: 'registry',
89
			bundle: null,
90
		});
91

92
		if (extension.data.type === 'bundle' && version.bundled.length > 0) {
93
			await this.extensionsItemService.createMany(
94
				version.bundled.map((entry) => ({
95
					enabled: true,
96
					folder: entry.name,
97
					source: 'registry',
98
					bundle: extensionId,
99
				})),
100
			);
101
		}
102

103
		await this.extensionsManager.install(versionId);
104
	}
105

106
	async uninstall(id: string) {
107
		const settings = await this.extensionsItemService.readOne(id);
108

109
		if (settings.source !== 'registry') {
110
			throw new InvalidPayloadError({
111
				reason: 'Cannot uninstall extensions that were not installed via marketplace',
112
			});
113
		}
114

115
		if (settings.bundle !== null) {
116
			throw new InvalidPayloadError({
117
				reason: 'Cannot uninstall sub extensions of bundles separately',
118
			});
119
		}
120

121
		await this.deleteOne(id);
122
		await this.extensionsManager.uninstall(settings.folder);
123
	}
124

125
	async reinstall(id: string) {
126
		const settings = await this.extensionsItemService.readOne(id);
127

128
		if (settings.source !== 'registry') {
129
			throw new InvalidPayloadError({
130
				reason: 'Cannot reinstall extensions that were not installed via marketplace',
131
			});
132
		}
133

134
		if (settings.bundle !== null) {
135
			throw new InvalidPayloadError({
136
				reason: 'Cannot reinstall sub extensions of bundles separately',
137
			});
138
		}
139

140
		const extensionId = settings.id;
141
		const versionId = settings.folder;
142

143
		await this.preInstall(extensionId, versionId);
144
		await this.extensionsManager.install(versionId);
145
	}
146

147
	async readAll() {
148
		const settings = await this.extensionsItemService.readByQuery({ limit: -1 });
149

150
		const regular = settings.filter(({ bundle }) => bundle === null);
151
		const bundled = settings.filter(({ bundle }) => bundle !== null);
152

153
		const output: ApiOutput[] = [];
154

155
		for (const meta of regular) {
156
			output.push({
157
				id: meta.id,
158
				bundle: meta.bundle,
159
				meta: meta,
160
				schema: this.extensionsManager.getExtension(meta.source, meta.folder) ?? null,
161
			});
162
		}
163

164
		for (const meta of bundled) {
165
			const parentBundle = output.find((ext) => ext.id === meta.bundle);
166

167
			if (!parentBundle) continue;
168

169
			const schema = (parentBundle.schema as BundleExtension | null)?.entries.find(
170
				(entry) => entry.name === meta.folder,
171
			);
172

173
			if (!schema) continue;
174

175
			output.push({
176
				id: meta.id,
177
				bundle: meta.bundle,
178
				meta: meta,
179
				schema: schema,
180
			});
181
		}
182

183
		return output;
184
	}
185

186
	async readOne(id: string): Promise<ApiOutput> {
187
		const meta = await this.extensionsItemService.readOne(id);
188
		const schema = this.extensionsManager.getExtension(meta.source, meta.folder) ?? null;
189

190
		return {
191
			id: meta.id,
192
			bundle: meta.bundle,
193
			schema,
194
			meta,
195
		};
196
	}
197

198
	async updateOne(id: string, data: DeepPartial<ApiOutput>) {
199
		const result = await transaction(this.knex, async (trx) => {
200
			if (!isObject(data.meta)) {
201
				throw new InvalidPayloadError({ reason: `"meta" is required` });
202
			}
203

204
			const service = new ExtensionsService({
205
				knex: trx,
206
				accountability: this.accountability,
207
				schema: this.schema,
208
			});
209

210
			await service.extensionsItemService.updateOne(id, data.meta);
211

212
			let extension;
213

214
			try {
215
				extension = await service.readOne(id);
216
			} catch (error) {
217
				throw new ExtensionReadError(error);
218
			}
219

220
			if ('enabled' in data.meta) {
221
				await service.checkBundleAndSyncStatus(trx, id, extension);
222
			}
223

224
			return extension;
225
		});
226

227
		this.extensionsManager.reload().then(() => {
228
			this.extensionsManager.broadcastReloadNotification();
229
		});
230

231
		return result;
232
	}
233

234
	async deleteOne(id: string) {
235
		await this.extensionsItemService.deleteOne(id);
236
		await this.extensionsItemService.deleteByQuery({ filter: { bundle: { _eq: id } } });
237
	}
238

239
	/**
240
	 * Sync a bundles enabled status
241
	 *  - If the extension or extensions parent is not a bundle changes are skipped
242
	 *  - If a bundles status is toggled, all children are set to that status
243
	 *  - If an entries status is toggled, then if the:
244
	 *    - Parent bundle is non-partial throws UnprocessableContentError
245
	 *    - Entry status change resulted in all children being disabled then the parent bundle is disabled
246
	 *    - Entry status change resulted in at least one child being enabled then the parent bundle is enabled
247
	 */
248
	private async checkBundleAndSyncStatus(trx: Knex, extensionId: string, extension: ApiOutput) {
249
		if (extension.bundle === null && extension.schema?.type === 'bundle') {
250
			// If extension is the parent bundle, set it and all nested extensions to enabled
251
			await trx('directus_extensions')
252
				.update({ enabled: extension.meta.enabled })
253
				.where({ bundle: extensionId })
254
				.orWhere({ id: extensionId });
255

256
			return;
257
		}
258

259
		const parentId = extension.bundle ?? extension.meta.bundle;
260

261
		if (!parentId) return;
262

263
		const parent = await this.readOne(parentId);
264

265
		if (parent.schema?.type !== 'bundle') {
266
			return;
267
		}
268

269
		if (parent.schema.partial === false) {
270
			throw new UnprocessableContentError({
271
				reason: 'Unable to toggle status of an entry for a bundle marked as non partial',
272
			});
273
		}
274

275
		const hasEnabledChildren = !!(await trx('directus_extensions')
276
			.where({ bundle: parentId })
277
			.where({ enabled: true })
278
			.first());
279

280
		if (hasEnabledChildren) {
281
			await trx('directus_extensions').update({ enabled: true }).where({ id: parentId });
282
		} else {
283
			await trx('directus_extensions').update({ enabled: false }).where({ id: parentId });
284
		}
285
	}
286
}
287

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

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

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

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