mirror of
https://github.com/directus/directus.git
synced 2026-02-05 02:55:07 -05:00
Improve cache reliability in DDL operations (#12400)
* Add TTL to schema cache * Clear caches on unexpected errors in DDL * Consistent return value use * Don't set a default value for schema ttl
This commit is contained in:
@@ -12,12 +12,12 @@ let lockCache: Keyv | null = null;
|
||||
export function getCache(): { cache: Keyv | null; systemCache: Keyv; lockCache: Keyv } {
|
||||
if (env.CACHE_ENABLED === true && cache === null) {
|
||||
validateEnv(['CACHE_NAMESPACE', 'CACHE_TTL', 'CACHE_STORE']);
|
||||
cache = getKeyvInstance(ms(env.CACHE_TTL as string));
|
||||
cache = getKeyvInstance(env.CACHE_TTL ? ms(env.CACHE_TTL as string) : undefined);
|
||||
cache.on('error', (err) => logger.warn(err, `[cache] ${err}`));
|
||||
}
|
||||
|
||||
if (systemCache === null) {
|
||||
systemCache = getKeyvInstance(undefined, '_system');
|
||||
systemCache = getKeyvInstance(env.CACHE_SYSTEM_TTL ? ms(env.CACHE_SYSTEM_TTL as string) : undefined, '_system');
|
||||
systemCache.on('error', (err) => logger.warn(err, `[cache] ${err}`));
|
||||
}
|
||||
|
||||
|
||||
@@ -53,127 +53,131 @@ export class CollectionsService {
|
||||
throw new InvalidPayloadException(`Collections can't start with "directus_"`);
|
||||
}
|
||||
|
||||
const existingCollections: string[] = [
|
||||
...((await this.knex.select('collection').from('directus_collections'))?.map(({ collection }) => collection) ??
|
||||
[]),
|
||||
...Object.keys(this.schema.collections),
|
||||
];
|
||||
try {
|
||||
const existingCollections: string[] = [
|
||||
...((await this.knex.select('collection').from('directus_collections'))?.map(({ collection }) => collection) ??
|
||||
[]),
|
||||
...Object.keys(this.schema.collections),
|
||||
];
|
||||
|
||||
if (existingCollections.includes(payload.collection)) {
|
||||
throw new InvalidPayloadException(`Collection "${payload.collection}" already exists.`);
|
||||
}
|
||||
if (existingCollections.includes(payload.collection)) {
|
||||
throw new InvalidPayloadException(`Collection "${payload.collection}" already exists.`);
|
||||
}
|
||||
|
||||
// Create the collection/fields in a transaction so it'll be reverted in case of errors or
|
||||
// permission problems. This might not work reliably in MySQL, as it doesn't support DDL in
|
||||
// transactions.
|
||||
await this.knex.transaction(async (trx) => {
|
||||
if (payload.schema) {
|
||||
// Directus heavily relies on the primary key of a collection, so we have to make sure that
|
||||
// every collection that is created has a primary key. If no primary key field is created
|
||||
// while making the collection, we default to an auto incremented id named `id`
|
||||
if (!payload.fields)
|
||||
payload.fields = [
|
||||
{
|
||||
field: 'id',
|
||||
type: 'integer',
|
||||
meta: {
|
||||
hidden: true,
|
||||
interface: 'numeric',
|
||||
readonly: true,
|
||||
// Create the collection/fields in a transaction so it'll be reverted in case of errors or
|
||||
// permission problems. This might not work reliably in MySQL, as it doesn't support DDL in
|
||||
// transactions.
|
||||
await this.knex.transaction(async (trx) => {
|
||||
if (payload.schema) {
|
||||
// Directus heavily relies on the primary key of a collection, so we have to make sure that
|
||||
// every collection that is created has a primary key. If no primary key field is created
|
||||
// while making the collection, we default to an auto incremented id named `id`
|
||||
if (!payload.fields)
|
||||
payload.fields = [
|
||||
{
|
||||
field: 'id',
|
||||
type: 'integer',
|
||||
meta: {
|
||||
hidden: true,
|
||||
interface: 'numeric',
|
||||
readonly: true,
|
||||
},
|
||||
schema: {
|
||||
is_primary_key: true,
|
||||
has_auto_increment: true,
|
||||
},
|
||||
},
|
||||
schema: {
|
||||
is_primary_key: true,
|
||||
has_auto_increment: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
|
||||
// Ensure that every field meta has the field/collection fields filled correctly
|
||||
payload.fields = payload.fields.map((field) => {
|
||||
if (field.meta) {
|
||||
field.meta = {
|
||||
...field.meta,
|
||||
field: field.field,
|
||||
collection: payload.collection!,
|
||||
};
|
||||
}
|
||||
|
||||
return field;
|
||||
});
|
||||
|
||||
const fieldsService = new FieldsService({ knex: trx, schema: this.schema });
|
||||
|
||||
await trx.schema.createTable(payload.collection, (table) => {
|
||||
for (const field of payload.fields!) {
|
||||
if (field.type && ALIAS_TYPES.includes(field.type) === false) {
|
||||
fieldsService.addColumnToTable(table, field);
|
||||
// Ensure that every field meta has the field/collection fields filled correctly
|
||||
payload.fields = payload.fields.map((field) => {
|
||||
if (field.meta) {
|
||||
field.meta = {
|
||||
...field.meta,
|
||||
field: field.field,
|
||||
collection: payload.collection!,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const fieldItemsService = new ItemsService('directus_fields', {
|
||||
knex: trx,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
return field;
|
||||
});
|
||||
|
||||
const fieldPayloads = payload.fields!.filter((field) => field.meta).map((field) => field.meta) as FieldMeta[];
|
||||
await fieldItemsService.createMany(fieldPayloads);
|
||||
}
|
||||
const fieldsService = new FieldsService({ knex: trx, schema: this.schema });
|
||||
|
||||
if (payload.meta) {
|
||||
const collectionItemsService = new ItemsService('directus_collections', {
|
||||
knex: trx,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
await trx.schema.createTable(payload.collection, (table) => {
|
||||
for (const field of payload.fields!) {
|
||||
if (field.type && ALIAS_TYPES.includes(field.type) === false) {
|
||||
fieldsService.addColumnToTable(table, field);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await collectionItemsService.createOne({
|
||||
...payload.meta,
|
||||
collection: payload.collection,
|
||||
});
|
||||
}
|
||||
const fieldItemsService = new ItemsService('directus_fields', {
|
||||
knex: trx,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
const fieldPayloads = payload.fields!.filter((field) => field.meta).map((field) => field.meta) as FieldMeta[];
|
||||
await fieldItemsService.createMany(fieldPayloads);
|
||||
}
|
||||
|
||||
if (payload.meta) {
|
||||
const collectionItemsService = new ItemsService('directus_collections', {
|
||||
knex: trx,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
await collectionItemsService.createOne({
|
||||
...payload.meta,
|
||||
collection: payload.collection,
|
||||
});
|
||||
}
|
||||
|
||||
return payload.collection;
|
||||
});
|
||||
|
||||
return payload.collection;
|
||||
});
|
||||
} finally {
|
||||
if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) {
|
||||
await this.cache.clear();
|
||||
}
|
||||
|
||||
if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) {
|
||||
await this.cache.clear();
|
||||
await clearSystemCache();
|
||||
}
|
||||
|
||||
await clearSystemCache();
|
||||
|
||||
return payload.collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple new collections
|
||||
*/
|
||||
async createMany(payloads: RawCollection[], opts?: MutationOptions): Promise<string[]> {
|
||||
const collections = await this.knex.transaction(async (trx) => {
|
||||
const service = new CollectionsService({
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
knex: trx,
|
||||
try {
|
||||
const collections = await this.knex.transaction(async (trx) => {
|
||||
const service = new CollectionsService({
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
knex: trx,
|
||||
});
|
||||
|
||||
const collectionNames: string[] = [];
|
||||
|
||||
for (const payload of payloads) {
|
||||
const name = await service.createOne(payload, { autoPurgeCache: false });
|
||||
collectionNames.push(name);
|
||||
}
|
||||
|
||||
return collectionNames;
|
||||
});
|
||||
|
||||
const collectionNames: string[] = [];
|
||||
|
||||
for (const payload of payloads) {
|
||||
const name = await service.createOne(payload, { autoPurgeCache: false });
|
||||
collectionNames.push(name);
|
||||
return collections;
|
||||
} finally {
|
||||
if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) {
|
||||
await this.cache.clear();
|
||||
}
|
||||
|
||||
return collectionNames;
|
||||
});
|
||||
|
||||
if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) {
|
||||
await this.cache.clear();
|
||||
await clearSystemCache();
|
||||
}
|
||||
|
||||
await clearSystemCache();
|
||||
|
||||
return collections;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,37 +301,39 @@ export class CollectionsService {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const collectionItemsService = new ItemsService('directus_collections', {
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
try {
|
||||
const collectionItemsService = new ItemsService('directus_collections', {
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
const payload = data as Partial<Collection>;
|
||||
const payload = data as Partial<Collection>;
|
||||
|
||||
if (!payload.meta) {
|
||||
return collectionKey;
|
||||
}
|
||||
|
||||
const exists = !!(await this.knex
|
||||
.select('collection')
|
||||
.from('directus_collections')
|
||||
.where({ collection: collectionKey })
|
||||
.first());
|
||||
|
||||
if (exists) {
|
||||
await collectionItemsService.updateOne(collectionKey, payload.meta, opts);
|
||||
} else {
|
||||
await collectionItemsService.createOne({ ...payload.meta, collection: collectionKey }, opts);
|
||||
}
|
||||
|
||||
if (!payload.meta) {
|
||||
return collectionKey;
|
||||
} finally {
|
||||
if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) {
|
||||
await this.cache.clear();
|
||||
}
|
||||
|
||||
await clearSystemCache();
|
||||
}
|
||||
|
||||
const exists = !!(await this.knex
|
||||
.select('collection')
|
||||
.from('directus_collections')
|
||||
.where({ collection: collectionKey })
|
||||
.first());
|
||||
|
||||
if (exists) {
|
||||
await collectionItemsService.updateOne(collectionKey, payload.meta, opts);
|
||||
} else {
|
||||
await collectionItemsService.createOne({ ...payload.meta, collection: collectionKey }, opts);
|
||||
}
|
||||
|
||||
if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) {
|
||||
await this.cache.clear();
|
||||
}
|
||||
|
||||
await clearSystemCache();
|
||||
|
||||
return collectionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,25 +344,27 @@ export class CollectionsService {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.knex.transaction(async (trx) => {
|
||||
const service = new CollectionsService({
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
knex: trx,
|
||||
try {
|
||||
await this.knex.transaction(async (trx) => {
|
||||
const service = new CollectionsService({
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
knex: trx,
|
||||
});
|
||||
|
||||
for (const collectionKey of collectionKeys) {
|
||||
await service.updateOne(collectionKey, data, { autoPurgeCache: false });
|
||||
}
|
||||
});
|
||||
|
||||
for (const collectionKey of collectionKeys) {
|
||||
await service.updateOne(collectionKey, data, { autoPurgeCache: false });
|
||||
return collectionKeys;
|
||||
} finally {
|
||||
if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) {
|
||||
await this.cache.clear();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) {
|
||||
await this.cache.clear();
|
||||
await clearSystemCache();
|
||||
}
|
||||
|
||||
await clearSystemCache();
|
||||
|
||||
return collectionKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -368,96 +376,98 @@ export class CollectionsService {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const collections = await this.readByQuery();
|
||||
try {
|
||||
const collections = await this.readByQuery();
|
||||
|
||||
const collectionToBeDeleted = collections.find((collection) => collection.collection === collectionKey);
|
||||
const collectionToBeDeleted = collections.find((collection) => collection.collection === collectionKey);
|
||||
|
||||
if (!!collectionToBeDeleted === false) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.knex.transaction(async (trx) => {
|
||||
if (collectionToBeDeleted!.schema) {
|
||||
await trx.schema.dropTable(collectionKey);
|
||||
if (!!collectionToBeDeleted === false) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
// Make sure this collection isn't used as a group in any other collections
|
||||
await trx('directus_collections').update({ group: null }).where({ group: collectionKey });
|
||||
|
||||
if (collectionToBeDeleted!.meta) {
|
||||
const collectionItemsService = new ItemsService('directus_collections', {
|
||||
knex: trx,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
await collectionItemsService.deleteOne(collectionKey);
|
||||
}
|
||||
|
||||
if (collectionToBeDeleted!.schema) {
|
||||
const fieldsService = new FieldsService({
|
||||
knex: trx,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
await trx('directus_fields').delete().where('collection', '=', collectionKey);
|
||||
await trx('directus_presets').delete().where('collection', '=', collectionKey);
|
||||
|
||||
const revisionsToDelete = await trx
|
||||
.select('id')
|
||||
.from('directus_revisions')
|
||||
.where({ collection: collectionKey });
|
||||
|
||||
if (revisionsToDelete.length > 0) {
|
||||
const keys = revisionsToDelete.map((record) => record.id);
|
||||
await trx('directus_revisions').update({ parent: null }).whereIn('parent', keys);
|
||||
await this.knex.transaction(async (trx) => {
|
||||
if (collectionToBeDeleted!.schema) {
|
||||
await trx.schema.dropTable(collectionKey);
|
||||
}
|
||||
|
||||
await trx('directus_revisions').delete().where('collection', '=', collectionKey);
|
||||
// Make sure this collection isn't used as a group in any other collections
|
||||
await trx('directus_collections').update({ group: null }).where({ group: collectionKey });
|
||||
|
||||
await trx('directus_activity').delete().where('collection', '=', collectionKey);
|
||||
await trx('directus_permissions').delete().where('collection', '=', collectionKey);
|
||||
await trx('directus_relations').delete().where({ many_collection: collectionKey });
|
||||
if (collectionToBeDeleted!.meta) {
|
||||
const collectionItemsService = new ItemsService('directus_collections', {
|
||||
knex: trx,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
const relations = this.schema.relations.filter((relation) => {
|
||||
return relation.collection === collectionKey || relation.related_collection === collectionKey;
|
||||
});
|
||||
await collectionItemsService.deleteOne(collectionKey);
|
||||
}
|
||||
|
||||
for (const relation of relations) {
|
||||
// Delete related o2m fields that point to current collection
|
||||
if (relation.related_collection && relation.meta?.one_field) {
|
||||
await fieldsService.deleteField(relation.related_collection, relation.meta.one_field);
|
||||
if (collectionToBeDeleted!.schema) {
|
||||
const fieldsService = new FieldsService({
|
||||
knex: trx,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
await trx('directus_fields').delete().where('collection', '=', collectionKey);
|
||||
await trx('directus_presets').delete().where('collection', '=', collectionKey);
|
||||
|
||||
const revisionsToDelete = await trx
|
||||
.select('id')
|
||||
.from('directus_revisions')
|
||||
.where({ collection: collectionKey });
|
||||
|
||||
if (revisionsToDelete.length > 0) {
|
||||
const keys = revisionsToDelete.map((record) => record.id);
|
||||
await trx('directus_revisions').update({ parent: null }).whereIn('parent', keys);
|
||||
}
|
||||
|
||||
// Delete related m2o fields that point to current collection
|
||||
if (relation.related_collection === collectionKey) {
|
||||
await fieldsService.deleteField(relation.collection, relation.field);
|
||||
await trx('directus_revisions').delete().where('collection', '=', collectionKey);
|
||||
|
||||
await trx('directus_activity').delete().where('collection', '=', collectionKey);
|
||||
await trx('directus_permissions').delete().where('collection', '=', collectionKey);
|
||||
await trx('directus_relations').delete().where({ many_collection: collectionKey });
|
||||
|
||||
const relations = this.schema.relations.filter((relation) => {
|
||||
return relation.collection === collectionKey || relation.related_collection === collectionKey;
|
||||
});
|
||||
|
||||
for (const relation of relations) {
|
||||
// Delete related o2m fields that point to current collection
|
||||
if (relation.related_collection && relation.meta?.one_field) {
|
||||
await fieldsService.deleteField(relation.related_collection, relation.meta.one_field);
|
||||
}
|
||||
|
||||
// Delete related m2o fields that point to current collection
|
||||
if (relation.related_collection === collectionKey) {
|
||||
await fieldsService.deleteField(relation.collection, relation.field);
|
||||
}
|
||||
}
|
||||
|
||||
const a2oRelationsThatIncludeThisCollection = this.schema.relations.filter((relation) => {
|
||||
return relation.meta?.one_allowed_collections?.includes(collectionKey);
|
||||
});
|
||||
|
||||
for (const relation of a2oRelationsThatIncludeThisCollection) {
|
||||
const newAllowedCollections = relation
|
||||
.meta!.one_allowed_collections!.filter((collection) => collectionKey !== collection)
|
||||
.join(',');
|
||||
await trx('directus_relations')
|
||||
.update({ one_allowed_collections: newAllowedCollections })
|
||||
.where({ id: relation.meta!.id });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const a2oRelationsThatIncludeThisCollection = this.schema.relations.filter((relation) => {
|
||||
return relation.meta?.one_allowed_collections?.includes(collectionKey);
|
||||
});
|
||||
|
||||
for (const relation of a2oRelationsThatIncludeThisCollection) {
|
||||
const newAllowedCollections = relation
|
||||
.meta!.one_allowed_collections!.filter((collection) => collectionKey !== collection)
|
||||
.join(',');
|
||||
await trx('directus_relations')
|
||||
.update({ one_allowed_collections: newAllowedCollections })
|
||||
.where({ id: relation.meta!.id });
|
||||
}
|
||||
return collectionKey;
|
||||
} finally {
|
||||
if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) {
|
||||
await this.cache.clear();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) {
|
||||
await this.cache.clear();
|
||||
await clearSystemCache();
|
||||
}
|
||||
|
||||
await clearSystemCache();
|
||||
|
||||
return collectionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -468,24 +478,26 @@ export class CollectionsService {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.knex.transaction(async (trx) => {
|
||||
const service = new CollectionsService({
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
knex: trx,
|
||||
try {
|
||||
await this.knex.transaction(async (trx) => {
|
||||
const service = new CollectionsService({
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
knex: trx,
|
||||
});
|
||||
|
||||
for (const collectionKey of collectionKeys) {
|
||||
await service.deleteOne(collectionKey, { autoPurgeCache: false });
|
||||
}
|
||||
});
|
||||
|
||||
for (const collectionKey of collectionKeys) {
|
||||
await service.deleteOne(collectionKey, { autoPurgeCache: false });
|
||||
return collectionKeys;
|
||||
} finally {
|
||||
if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) {
|
||||
await this.cache.clear();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) {
|
||||
await this.cache.clear();
|
||||
await clearSystemCache();
|
||||
}
|
||||
|
||||
await clearSystemCache();
|
||||
|
||||
return collectionKeys;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,62 +228,149 @@ export class FieldsService {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const exists =
|
||||
field.field in this.schema.collections[collection].fields ||
|
||||
isNil(await this.knex.select('id').from('directus_fields').where({ collection, field: field.field }).first()) ===
|
||||
false;
|
||||
try {
|
||||
const exists =
|
||||
field.field in this.schema.collections[collection].fields ||
|
||||
isNil(
|
||||
await this.knex.select('id').from('directus_fields').where({ collection, field: field.field }).first()
|
||||
) === false;
|
||||
|
||||
// Check if field already exists, either as a column, or as a row in directus_fields
|
||||
if (exists) {
|
||||
throw new InvalidPayloadException(`Field "${field.field}" already exists in collection "${collection}"`);
|
||||
// Check if field already exists, either as a column, or as a row in directus_fields
|
||||
if (exists) {
|
||||
throw new InvalidPayloadException(`Field "${field.field}" already exists in collection "${collection}"`);
|
||||
}
|
||||
|
||||
await this.knex.transaction(async (trx) => {
|
||||
const itemsService = new ItemsService('directus_fields', {
|
||||
knex: trx,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
const hookAdjustedField = await emitter.emitFilter(
|
||||
`fields.create`,
|
||||
field,
|
||||
{
|
||||
collection: collection,
|
||||
},
|
||||
{
|
||||
database: trx,
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
}
|
||||
);
|
||||
|
||||
if (hookAdjustedField.type && ALIAS_TYPES.includes(hookAdjustedField.type) === false) {
|
||||
if (table) {
|
||||
this.addColumnToTable(table, hookAdjustedField as Field);
|
||||
} else {
|
||||
await trx.schema.alterTable(collection, (table) => {
|
||||
this.addColumnToTable(table, hookAdjustedField as Field);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hookAdjustedField.meta) {
|
||||
await itemsService.createOne(
|
||||
{
|
||||
...hookAdjustedField.meta,
|
||||
collection: collection,
|
||||
field: hookAdjustedField.field,
|
||||
},
|
||||
{ emitEvents: false }
|
||||
);
|
||||
}
|
||||
|
||||
emitter.emitAction(
|
||||
`fields.create`,
|
||||
{
|
||||
payload: hookAdjustedField,
|
||||
key: hookAdjustedField.field,
|
||||
collection: collection,
|
||||
},
|
||||
{
|
||||
database: getDatabase(),
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
}
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
if (this.cache && env.CACHE_AUTO_PURGE) {
|
||||
await this.cache.clear();
|
||||
}
|
||||
|
||||
await clearSystemCache();
|
||||
}
|
||||
}
|
||||
|
||||
async updateField(collection: string, field: RawField): Promise<string> {
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.knex.transaction(async (trx) => {
|
||||
const itemsService = new ItemsService('directus_fields', {
|
||||
knex: trx,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
try {
|
||||
const hookAdjustedField = await emitter.emitFilter(
|
||||
`fields.create`,
|
||||
`fields.update`,
|
||||
field,
|
||||
{
|
||||
keys: [field.field],
|
||||
collection: collection,
|
||||
},
|
||||
{
|
||||
database: trx,
|
||||
database: this.knex,
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
}
|
||||
);
|
||||
|
||||
if (hookAdjustedField.type && ALIAS_TYPES.includes(hookAdjustedField.type) === false) {
|
||||
if (table) {
|
||||
this.addColumnToTable(table, hookAdjustedField as Field);
|
||||
} else {
|
||||
await trx.schema.alterTable(collection, (table) => {
|
||||
this.addColumnToTable(table, hookAdjustedField as Field);
|
||||
});
|
||||
const record = field.meta
|
||||
? await this.knex.select('id').from('directus_fields').where({ collection, field: field.field }).first()
|
||||
: null;
|
||||
|
||||
if (hookAdjustedField.schema) {
|
||||
const existingColumn = await this.schemaInspector.columnInfo(collection, hookAdjustedField.field);
|
||||
|
||||
if (!isEqual(existingColumn, hookAdjustedField.schema)) {
|
||||
try {
|
||||
await this.knex.schema.alterTable(collection, (table) => {
|
||||
if (!hookAdjustedField.schema) return;
|
||||
this.addColumnToTable(table, field, existingColumn);
|
||||
});
|
||||
} catch (err: any) {
|
||||
throw await translateDatabaseError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hookAdjustedField.meta) {
|
||||
await itemsService.createOne(
|
||||
{
|
||||
...hookAdjustedField.meta,
|
||||
collection: collection,
|
||||
field: hookAdjustedField.field,
|
||||
},
|
||||
{ emitEvents: false }
|
||||
);
|
||||
if (record) {
|
||||
await this.itemsService.updateOne(
|
||||
record.id,
|
||||
{
|
||||
...hookAdjustedField.meta,
|
||||
collection: collection,
|
||||
field: hookAdjustedField.field,
|
||||
},
|
||||
{ emitEvents: false }
|
||||
);
|
||||
} else {
|
||||
await this.itemsService.createOne(
|
||||
{
|
||||
...hookAdjustedField.meta,
|
||||
collection: collection,
|
||||
field: hookAdjustedField.field,
|
||||
},
|
||||
{ emitEvents: false }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
emitter.emitAction(
|
||||
`fields.create`,
|
||||
`fields.update`,
|
||||
{
|
||||
payload: hookAdjustedField,
|
||||
key: hookAdjustedField.field,
|
||||
keys: [hookAdjustedField.field],
|
||||
collection: collection,
|
||||
},
|
||||
{
|
||||
@@ -292,97 +379,15 @@ export class FieldsService {
|
||||
accountability: this.accountability,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (this.cache && env.CACHE_AUTO_PURGE) {
|
||||
await this.cache.clear();
|
||||
}
|
||||
|
||||
await clearSystemCache();
|
||||
}
|
||||
|
||||
async updateField(collection: string, field: RawField): Promise<string> {
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const hookAdjustedField = await emitter.emitFilter(
|
||||
`fields.update`,
|
||||
field,
|
||||
{
|
||||
keys: [field.field],
|
||||
collection: collection,
|
||||
},
|
||||
{
|
||||
database: this.knex,
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
return field.field;
|
||||
} finally {
|
||||
if (this.cache && env.CACHE_AUTO_PURGE) {
|
||||
await this.cache.clear();
|
||||
}
|
||||
);
|
||||
|
||||
const record = field.meta
|
||||
? await this.knex.select('id').from('directus_fields').where({ collection, field: field.field }).first()
|
||||
: null;
|
||||
|
||||
if (hookAdjustedField.schema) {
|
||||
const existingColumn = await this.schemaInspector.columnInfo(collection, hookAdjustedField.field);
|
||||
|
||||
if (!isEqual(existingColumn, hookAdjustedField.schema)) {
|
||||
try {
|
||||
await this.knex.schema.alterTable(collection, (table) => {
|
||||
if (!hookAdjustedField.schema) return;
|
||||
this.addColumnToTable(table, field, existingColumn);
|
||||
});
|
||||
} catch (err: any) {
|
||||
throw await translateDatabaseError(err);
|
||||
}
|
||||
}
|
||||
await clearSystemCache();
|
||||
}
|
||||
|
||||
if (hookAdjustedField.meta) {
|
||||
if (record) {
|
||||
await this.itemsService.updateOne(
|
||||
record.id,
|
||||
{
|
||||
...hookAdjustedField.meta,
|
||||
collection: collection,
|
||||
field: hookAdjustedField.field,
|
||||
},
|
||||
{ emitEvents: false }
|
||||
);
|
||||
} else {
|
||||
await this.itemsService.createOne(
|
||||
{
|
||||
...hookAdjustedField.meta,
|
||||
collection: collection,
|
||||
field: hookAdjustedField.field,
|
||||
},
|
||||
{ emitEvents: false }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.cache && env.CACHE_AUTO_PURGE) {
|
||||
await this.cache.clear();
|
||||
}
|
||||
|
||||
await clearSystemCache();
|
||||
|
||||
emitter.emitAction(
|
||||
`fields.update`,
|
||||
{
|
||||
payload: hookAdjustedField,
|
||||
keys: [hookAdjustedField.field],
|
||||
collection: collection,
|
||||
},
|
||||
{
|
||||
database: getDatabase(),
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
}
|
||||
);
|
||||
|
||||
return field.field;
|
||||
}
|
||||
|
||||
async deleteField(collection: string, field: string): Promise<void> {
|
||||
@@ -390,124 +395,126 @@ export class FieldsService {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await emitter.emitFilter(
|
||||
'fields.delete',
|
||||
[field],
|
||||
{
|
||||
collection: collection,
|
||||
},
|
||||
{
|
||||
database: this.knex,
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
}
|
||||
);
|
||||
try {
|
||||
await emitter.emitFilter(
|
||||
'fields.delete',
|
||||
[field],
|
||||
{
|
||||
collection: collection,
|
||||
},
|
||||
{
|
||||
database: this.knex,
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
}
|
||||
);
|
||||
|
||||
await this.knex.transaction(async (trx) => {
|
||||
if (
|
||||
this.schema.collections[collection] &&
|
||||
field in this.schema.collections[collection].fields &&
|
||||
this.schema.collections[collection].fields[field].alias === false
|
||||
) {
|
||||
await trx.schema.table(collection, (table) => {
|
||||
table.dropColumn(field);
|
||||
await this.knex.transaction(async (trx) => {
|
||||
if (
|
||||
this.schema.collections[collection] &&
|
||||
field in this.schema.collections[collection].fields &&
|
||||
this.schema.collections[collection].fields[field].alias === false
|
||||
) {
|
||||
await trx.schema.table(collection, (table) => {
|
||||
table.dropColumn(field);
|
||||
});
|
||||
}
|
||||
|
||||
const relations = this.schema.relations.filter((relation) => {
|
||||
return (
|
||||
(relation.collection === collection && relation.field === field) ||
|
||||
(relation.related_collection === collection && relation.meta?.one_field === field)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const relations = this.schema.relations.filter((relation) => {
|
||||
return (
|
||||
(relation.collection === collection && relation.field === field) ||
|
||||
(relation.related_collection === collection && relation.meta?.one_field === field)
|
||||
);
|
||||
});
|
||||
const relationsService = new RelationsService({
|
||||
knex: trx,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
const relationsService = new RelationsService({
|
||||
knex: trx,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
const fieldsService = new FieldsService({
|
||||
knex: trx,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
const fieldsService = new FieldsService({
|
||||
knex: trx,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
for (const relation of relations) {
|
||||
const isM2O = relation.collection === collection && relation.field === field;
|
||||
|
||||
for (const relation of relations) {
|
||||
const isM2O = relation.collection === collection && relation.field === field;
|
||||
// If the current field is a m2o, delete the related o2m if it exists and remove the relationship
|
||||
if (isM2O) {
|
||||
await relationsService.deleteOne(collection, field);
|
||||
|
||||
// If the current field is a m2o, delete the related o2m if it exists and remove the relationship
|
||||
if (isM2O) {
|
||||
await relationsService.deleteOne(collection, field);
|
||||
if (relation.related_collection && relation.meta?.one_field) {
|
||||
await fieldsService.deleteField(relation.related_collection, relation.meta.one_field);
|
||||
}
|
||||
}
|
||||
|
||||
if (relation.related_collection && relation.meta?.one_field) {
|
||||
await fieldsService.deleteField(relation.related_collection, relation.meta.one_field);
|
||||
// If the current field is a o2m, just delete the one field config from the relation
|
||||
if (!isM2O && relation.meta?.one_field) {
|
||||
await trx('directus_relations')
|
||||
.update({ one_field: null })
|
||||
.where({ many_collection: relation.collection, many_field: relation.field });
|
||||
}
|
||||
}
|
||||
|
||||
// If the current field is a o2m, just delete the one field config from the relation
|
||||
if (!isM2O && relation.meta?.one_field) {
|
||||
await trx('directus_relations')
|
||||
.update({ one_field: null })
|
||||
.where({ many_collection: relation.collection, many_field: relation.field });
|
||||
const collectionMeta = await trx
|
||||
.select('archive_field', 'sort_field')
|
||||
.from('directus_collections')
|
||||
.where({ collection })
|
||||
.first();
|
||||
|
||||
const collectionMetaUpdates: Record<string, null> = {};
|
||||
|
||||
if (collectionMeta?.archive_field === field) {
|
||||
collectionMetaUpdates.archive_field = null;
|
||||
}
|
||||
|
||||
if (collectionMeta?.sort_field === field) {
|
||||
collectionMetaUpdates.sort_field = null;
|
||||
}
|
||||
|
||||
if (Object.keys(collectionMetaUpdates).length > 0) {
|
||||
await trx('directus_collections').update(collectionMetaUpdates).where({ collection });
|
||||
}
|
||||
|
||||
// Cleanup directus_fields
|
||||
const metaRow = await trx
|
||||
.select('collection', 'field')
|
||||
.from('directus_fields')
|
||||
.where({ collection, field })
|
||||
.first();
|
||||
|
||||
if (metaRow) {
|
||||
// Handle recursive FK constraints
|
||||
await trx('directus_fields')
|
||||
.update({ group: null })
|
||||
.where({ group: metaRow.field, collection: metaRow.collection });
|
||||
}
|
||||
|
||||
await trx('directus_fields').delete().where({ collection, field });
|
||||
});
|
||||
|
||||
emitter.emitAction(
|
||||
'fields.delete',
|
||||
{
|
||||
payload: [field],
|
||||
collection: collection,
|
||||
},
|
||||
{
|
||||
database: this.knex,
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
if (this.cache && env.CACHE_AUTO_PURGE) {
|
||||
await this.cache.clear();
|
||||
}
|
||||
|
||||
const collectionMeta = await trx
|
||||
.select('archive_field', 'sort_field')
|
||||
.from('directus_collections')
|
||||
.where({ collection })
|
||||
.first();
|
||||
|
||||
const collectionMetaUpdates: Record<string, null> = {};
|
||||
|
||||
if (collectionMeta?.archive_field === field) {
|
||||
collectionMetaUpdates.archive_field = null;
|
||||
}
|
||||
|
||||
if (collectionMeta?.sort_field === field) {
|
||||
collectionMetaUpdates.sort_field = null;
|
||||
}
|
||||
|
||||
if (Object.keys(collectionMetaUpdates).length > 0) {
|
||||
await trx('directus_collections').update(collectionMetaUpdates).where({ collection });
|
||||
}
|
||||
|
||||
// Cleanup directus_fields
|
||||
const metaRow = await trx
|
||||
.select('collection', 'field')
|
||||
.from('directus_fields')
|
||||
.where({ collection, field })
|
||||
.first();
|
||||
|
||||
if (metaRow) {
|
||||
// Handle recursive FK constraints
|
||||
await trx('directus_fields')
|
||||
.update({ group: null })
|
||||
.where({ group: metaRow.field, collection: metaRow.collection });
|
||||
}
|
||||
|
||||
await trx('directus_fields').delete().where({ collection, field });
|
||||
});
|
||||
|
||||
if (this.cache && env.CACHE_AUTO_PURGE) {
|
||||
await this.cache.clear();
|
||||
await clearSystemCache();
|
||||
}
|
||||
|
||||
await clearSystemCache();
|
||||
|
||||
emitter.emitAction(
|
||||
'fields.delete',
|
||||
{
|
||||
payload: [field],
|
||||
collection: collection,
|
||||
},
|
||||
{
|
||||
database: this.knex,
|
||||
schema: this.schema,
|
||||
accountability: this.accountability,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public addColumnToTable(table: Knex.CreateTableBuilder, field: RawField | Field, alter: Column | null = null): void {
|
||||
|
||||
@@ -165,43 +165,45 @@ export class RelationsService {
|
||||
);
|
||||
}
|
||||
|
||||
const metaRow = {
|
||||
...(relation.meta || {}),
|
||||
many_collection: relation.collection,
|
||||
many_field: relation.field,
|
||||
one_collection: relation.related_collection || null,
|
||||
};
|
||||
try {
|
||||
const metaRow = {
|
||||
...(relation.meta || {}),
|
||||
many_collection: relation.collection,
|
||||
many_field: relation.field,
|
||||
one_collection: relation.related_collection || null,
|
||||
};
|
||||
|
||||
await this.knex.transaction(async (trx) => {
|
||||
if (relation.related_collection) {
|
||||
await trx.schema.alterTable(relation.collection!, async (table) => {
|
||||
this.alterType(table, relation);
|
||||
await this.knex.transaction(async (trx) => {
|
||||
if (relation.related_collection) {
|
||||
await trx.schema.alterTable(relation.collection!, async (table) => {
|
||||
this.alterType(table, relation);
|
||||
|
||||
const constraintName: string = getDefaultIndexName('foreign', relation.collection!, relation.field!);
|
||||
const builder = table
|
||||
.foreign(relation.field!, constraintName)
|
||||
.references(
|
||||
`${relation.related_collection!}.${this.schema.collections[relation.related_collection!].primary}`
|
||||
);
|
||||
const constraintName: string = getDefaultIndexName('foreign', relation.collection!, relation.field!);
|
||||
const builder = table
|
||||
.foreign(relation.field!, constraintName)
|
||||
.references(
|
||||
`${relation.related_collection!}.${this.schema.collections[relation.related_collection!].primary}`
|
||||
);
|
||||
|
||||
if (relation.schema?.on_delete) {
|
||||
builder.onDelete(relation.schema.on_delete);
|
||||
}
|
||||
if (relation.schema?.on_delete) {
|
||||
builder.onDelete(relation.schema.on_delete);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const relationsItemService = new ItemsService('directus_relations', {
|
||||
knex: trx,
|
||||
schema: this.schema,
|
||||
// We don't set accountability here. If you have read access to certain fields, you are
|
||||
// allowed to extract the relations regardless of permissions to directus_relations. This
|
||||
// happens in `filterForbidden` down below
|
||||
});
|
||||
}
|
||||
|
||||
const relationsItemService = new ItemsService('directus_relations', {
|
||||
knex: trx,
|
||||
schema: this.schema,
|
||||
// We don't set accountability here. If you have read access to certain fields, you are
|
||||
// allowed to extract the relations regardless of permissions to directus_relations. This
|
||||
// happens in `filterForbidden` down below
|
||||
await relationsItemService.createOne(metaRow);
|
||||
});
|
||||
|
||||
await relationsItemService.createOne(metaRow);
|
||||
});
|
||||
|
||||
await clearSystemCache();
|
||||
} finally {
|
||||
await clearSystemCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,56 +232,58 @@ export class RelationsService {
|
||||
throw new InvalidPayloadException(`Field "${field}" in collection "${collection}" doesn't have a relationship.`);
|
||||
}
|
||||
|
||||
await this.knex.transaction(async (trx) => {
|
||||
if (existingRelation.related_collection) {
|
||||
await trx.schema.alterTable(collection, async (table) => {
|
||||
let constraintName: string = getDefaultIndexName('foreign', collection, field);
|
||||
try {
|
||||
await this.knex.transaction(async (trx) => {
|
||||
if (existingRelation.related_collection) {
|
||||
await trx.schema.alterTable(collection, async (table) => {
|
||||
let constraintName: string = getDefaultIndexName('foreign', collection, field);
|
||||
|
||||
// If the FK already exists in the DB, drop it first
|
||||
if (existingRelation?.schema) {
|
||||
constraintName = existingRelation.schema.constraint_name || constraintName;
|
||||
table.dropForeign(field, constraintName);
|
||||
}
|
||||
// If the FK already exists in the DB, drop it first
|
||||
if (existingRelation?.schema) {
|
||||
constraintName = existingRelation.schema.constraint_name || constraintName;
|
||||
table.dropForeign(field, constraintName);
|
||||
}
|
||||
|
||||
this.alterType(table, relation);
|
||||
this.alterType(table, relation);
|
||||
|
||||
const builder = table
|
||||
.foreign(field, constraintName || undefined)
|
||||
.references(
|
||||
`${existingRelation.related_collection!}.${
|
||||
this.schema.collections[existingRelation.related_collection!].primary
|
||||
}`
|
||||
);
|
||||
const builder = table
|
||||
.foreign(field, constraintName || undefined)
|
||||
.references(
|
||||
`${existingRelation.related_collection!}.${
|
||||
this.schema.collections[existingRelation.related_collection!].primary
|
||||
}`
|
||||
);
|
||||
|
||||
if (relation.schema?.on_delete) {
|
||||
builder.onDelete(relation.schema.on_delete);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const relationsItemService = new ItemsService('directus_relations', {
|
||||
knex: trx,
|
||||
schema: this.schema,
|
||||
// We don't set accountability here. If you have read access to certain fields, you are
|
||||
// allowed to extract the relations regardless of permissions to directus_relations. This
|
||||
// happens in `filterForbidden` down below
|
||||
});
|
||||
|
||||
if (relation.meta) {
|
||||
if (existingRelation?.meta) {
|
||||
await relationsItemService.updateOne(existingRelation.meta.id, relation.meta);
|
||||
} else {
|
||||
await relationsItemService.createOne({
|
||||
...(relation.meta || {}),
|
||||
many_collection: relation.collection,
|
||||
many_field: relation.field,
|
||||
one_collection: existingRelation.related_collection || null,
|
||||
if (relation.schema?.on_delete) {
|
||||
builder.onDelete(relation.schema.on_delete);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await clearSystemCache();
|
||||
const relationsItemService = new ItemsService('directus_relations', {
|
||||
knex: trx,
|
||||
schema: this.schema,
|
||||
// We don't set accountability here. If you have read access to certain fields, you are
|
||||
// allowed to extract the relations regardless of permissions to directus_relations. This
|
||||
// happens in `filterForbidden` down below
|
||||
});
|
||||
|
||||
if (relation.meta) {
|
||||
if (existingRelation?.meta) {
|
||||
await relationsItemService.updateOne(existingRelation.meta.id, relation.meta);
|
||||
} else {
|
||||
await relationsItemService.createOne({
|
||||
...(relation.meta || {}),
|
||||
many_collection: relation.collection,
|
||||
many_field: relation.field,
|
||||
one_collection: existingRelation.related_collection || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await clearSystemCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,25 +310,27 @@ export class RelationsService {
|
||||
throw new InvalidPayloadException(`Field "${field}" in collection "${collection}" doesn't have a relationship.`);
|
||||
}
|
||||
|
||||
await this.knex.transaction(async (trx) => {
|
||||
const existingConstraints = await this.schemaInspector.foreignKeys();
|
||||
const constraintNames = existingConstraints.map((key) => key.constraint_name);
|
||||
try {
|
||||
await this.knex.transaction(async (trx) => {
|
||||
const existingConstraints = await this.schemaInspector.foreignKeys();
|
||||
const constraintNames = existingConstraints.map((key) => key.constraint_name);
|
||||
|
||||
if (
|
||||
existingRelation.schema?.constraint_name &&
|
||||
constraintNames.includes(existingRelation.schema.constraint_name)
|
||||
) {
|
||||
await trx.schema.alterTable(existingRelation.collection, (table) => {
|
||||
table.dropForeign(existingRelation.field, existingRelation.schema!.constraint_name!);
|
||||
});
|
||||
}
|
||||
if (
|
||||
existingRelation.schema?.constraint_name &&
|
||||
constraintNames.includes(existingRelation.schema.constraint_name)
|
||||
) {
|
||||
await trx.schema.alterTable(existingRelation.collection, (table) => {
|
||||
table.dropForeign(existingRelation.field, existingRelation.schema!.constraint_name!);
|
||||
});
|
||||
}
|
||||
|
||||
if (existingRelation.meta) {
|
||||
await trx('directus_relations').delete().where({ many_collection: collection, many_field: field });
|
||||
}
|
||||
});
|
||||
|
||||
await clearSystemCache();
|
||||
if (existingRelation.meta) {
|
||||
await trx('directus_relations').delete().where({ many_collection: collection, many_field: field });
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await clearSystemCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user