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';
15
export class ExtensionReadError extends Error {
16
originalError: unknown;
17
constructor(originalError: unknown) {
19
this.originalError = originalError;
23
export class ExtensionsService {
25
accountability: Accountability | null;
26
schema: SchemaOverview;
27
extensionsItemService: ItemsService<ExtensionSettings>;
28
extensionsManager: ExtensionManager;
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();
36
this.extensionsItemService = new ItemsService('directus_extensions', {
39
accountability: this.accountability,
43
private async preInstall(extensionId: string, versionId: string) {
46
const describeOptions: DescribeOptions = {};
48
if (typeof env['MARKETPLACE_REGISTRY'] === 'string') {
49
describeOptions.registry = env['MARKETPLACE_REGISTRY'];
52
const extension = await describe(extensionId, describeOptions);
53
const version = extension.data.versions.find((version) => version.id === versionId);
56
throw new ForbiddenError();
59
const limit = env['EXTENSIONS_LIMIT'] ? Number(env['EXTENSIONS_LIMIT']) : null;
62
const currentlyInstalledCount = this.extensionsManager.extensions.length;
69
const points = version.bundled.length ?? 1;
71
const afterInstallCount = currentlyInstalledCount + points;
73
if (afterInstallCount >= limit) {
74
throw new LimitExceededError({ category: 'Extensions' });
78
return { extension, version };
81
async install(extensionId: string, versionId: string) {
82
const { extension, version } = await this.preInstall(extensionId, versionId);
84
await this.extensionsItemService.createOne({
92
if (extension.data.type === 'bundle' && version.bundled.length > 0) {
93
await this.extensionsItemService.createMany(
94
version.bundled.map((entry) => ({
103
await this.extensionsManager.install(versionId);
106
async uninstall(id: string) {
107
const settings = await this.extensionsItemService.readOne(id);
109
if (settings.source !== 'registry') {
110
throw new InvalidPayloadError({
111
reason: 'Cannot uninstall extensions that were not installed via marketplace',
115
if (settings.bundle !== null) {
116
throw new InvalidPayloadError({
117
reason: 'Cannot uninstall sub extensions of bundles separately',
121
await this.deleteOne(id);
122
await this.extensionsManager.uninstall(settings.folder);
125
async reinstall(id: string) {
126
const settings = await this.extensionsItemService.readOne(id);
128
if (settings.source !== 'registry') {
129
throw new InvalidPayloadError({
130
reason: 'Cannot reinstall extensions that were not installed via marketplace',
134
if (settings.bundle !== null) {
135
throw new InvalidPayloadError({
136
reason: 'Cannot reinstall sub extensions of bundles separately',
140
const extensionId = settings.id;
141
const versionId = settings.folder;
143
await this.preInstall(extensionId, versionId);
144
await this.extensionsManager.install(versionId);
148
const settings = await this.extensionsItemService.readByQuery({ limit: -1 });
150
const regular = settings.filter(({ bundle }) => bundle === null);
151
const bundled = settings.filter(({ bundle }) => bundle !== null);
153
const output: ApiOutput[] = [];
155
for (const meta of regular) {
160
schema: this.extensionsManager.getExtension(meta.source, meta.folder) ?? null,
164
for (const meta of bundled) {
165
const parentBundle = output.find((ext) => ext.id === meta.bundle);
167
if (!parentBundle) continue;
169
const schema = (parentBundle.schema as BundleExtension | null)?.entries.find(
170
(entry) => entry.name === meta.folder,
173
if (!schema) continue;
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;
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` });
204
const service = new ExtensionsService({
206
accountability: this.accountability,
210
await service.extensionsItemService.updateOne(id, data.meta);
215
extension = await service.readOne(id);
217
throw new ExtensionReadError(error);
220
if ('enabled' in data.meta) {
221
await service.checkBundleAndSyncStatus(trx, id, extension);
227
this.extensionsManager.reload().then(() => {
228
this.extensionsManager.broadcastReloadNotification();
234
async deleteOne(id: string) {
235
await this.extensionsItemService.deleteOne(id);
236
await this.extensionsItemService.deleteByQuery({ filter: { bundle: { _eq: id } } });
248
private async checkBundleAndSyncStatus(trx: Knex, extensionId: string, extension: ApiOutput) {
249
if (extension.bundle === null && extension.schema?.type === 'bundle') {
251
await trx('directus_extensions')
252
.update({ enabled: extension.meta.enabled })
253
.where({ bundle: extensionId })
254
.orWhere({ id: extensionId });
259
const parentId = extension.bundle ?? extension.meta.bundle;
261
if (!parentId) return;
263
const parent = await this.readOne(parentId);
265
if (parent.schema?.type !== 'bundle') {
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',
275
const hasEnabledChildren = !!(await trx('directus_extensions')
276
.where({ bundle: parentId })
277
.where({ enabled: true })
280
if (hasEnabledChildren) {
281
await trx('directus_extensions').update({ enabled: true }).where({ id: parentId });
283
await trx('directus_extensions').update({ enabled: false }).where({ id: parentId });