@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
318 lines (317 loc) • 13.6 kB
JavaScript
import { Action } from '@directus/constants';
import { ForbiddenError, InvalidPayloadError, UnprocessableContentError } from '@directus/errors';
import Joi from 'joi';
import { assign, get, isEqual, isPlainObject, pick } from 'lodash-es';
import objectHash from 'object-hash';
import { getCache } from '../cache.js';
import { getHelpers } from '../database/helpers/index.js';
import emitter from '../emitter.js';
import { validateAccess } from '../permissions/modules/validate-access/validate-access.js';
import { shouldClearCache } from '../utils/should-clear-cache.js';
import { splitRecursive } from '../utils/versioning/split-recursive.js';
import { ActivityService } from './activity.js';
import { ItemsService } from './items.js';
import { PayloadService } from './payload.js';
import { RevisionsService } from './revisions.js';
import { deepMapWithSchema } from '../utils/versioning/deep-map-with-schema.js';
export class VersionsService extends ItemsService {
constructor(options) {
super('directus_versions', options);
}
async validateCreateData(data) {
const versionCreateSchema = Joi.object({
key: Joi.string().required(),
name: Joi.string().allow(null),
collection: Joi.string().required(),
item: Joi.string().required(),
});
const { error } = versionCreateSchema.validate(data);
if (error)
throw new InvalidPayloadError({ reason: error.message });
// Reserves the "main" version key for the version query parameter
if (data['key'] === 'main')
throw new InvalidPayloadError({ reason: `"main" is a reserved version key` });
if (this.accountability) {
try {
await validateAccess({
accountability: this.accountability,
action: 'read',
collection: data['collection'],
primaryKeys: [data['item']],
}, {
schema: this.schema,
knex: this.knex,
});
}
catch {
throw new ForbiddenError();
}
}
const { CollectionsService } = await import('./collections.js');
const collectionsService = new CollectionsService({
knex: this.knex,
schema: this.schema,
});
const existingCollection = await collectionsService.readOne(data['collection']);
if (!existingCollection.meta?.versioning) {
throw new UnprocessableContentError({
reason: `Content Versioning is not enabled for collection "${data['collection']}"`,
});
}
const sudoService = new VersionsService({
knex: this.knex,
schema: this.schema,
});
const existingVersions = (await sudoService.readByQuery({
aggregate: { count: ['*'] },
filter: { key: { _eq: data['key'] }, collection: { _eq: data['collection'] }, item: { _eq: data['item'] } },
}));
if (existingVersions[0]['count'] > 0) {
throw new UnprocessableContentError({
reason: `Version "${data['key']}" already exists for item "${data['item']}" in collection "${data['collection']}"`,
});
}
}
async getMainItem(collection, item, query) {
const itemsService = new ItemsService(collection, {
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
return await itemsService.readOne(item, query);
}
async verifyHash(collection, item, hash) {
const mainItem = await this.getMainItem(collection, item);
const mainHash = objectHash(mainItem);
return { outdated: hash !== mainHash, mainHash };
}
async getVersionSave(key, collection, item, mapDelta = true) {
const version = (await this.readByQuery({
filter: {
key: { _eq: key },
collection: { _eq: collection },
item: { _eq: item },
},
}))[0];
if (mapDelta && version?.delta)
version.delta = this.mapDelta(version);
return version;
}
async createOne(data, opts) {
await this.validateCreateData(data);
const mainItem = await this.getMainItem(data['collection'], data['item']);
data['hash'] = objectHash(mainItem);
return super.createOne(data, opts);
}
async readOne(key, query = {}, opts) {
const version = await super.readOne(key, query, opts);
if (version?.delta)
version.delta = this.mapDelta(version);
return version;
}
async createMany(data, opts) {
if (!Array.isArray(data)) {
throw new InvalidPayloadError({ reason: 'Input should be an array of items' });
}
const keyCombos = new Set();
for (const item of data) {
const keyCombo = `${item['key']}-${item['collection']}-${item['item']}`;
if (keyCombos.has(keyCombo)) {
throw new UnprocessableContentError({
reason: `Cannot create multiple versions on "${item['item']}" in collection "${item['collection']}" with the same key "${item['key']}"`,
});
}
keyCombos.add(keyCombo);
}
return super.createMany(data, opts);
}
async updateMany(keys, data, opts) {
// Only allow updates on "key" and "name" fields
const versionUpdateSchema = Joi.object({
key: Joi.string(),
name: Joi.string().allow(null),
});
const { error } = versionUpdateSchema.validate(data);
if (error)
throw new InvalidPayloadError({ reason: error.message });
if ('key' in data) {
// Reserves the "main" version key for the version query parameter
if (data['key'] === 'main')
throw new InvalidPayloadError({ reason: `"main" is a reserved version key` });
const keyCombos = new Set();
for (const pk of keys) {
const { collection, item } = await this.readOne(pk, { fields: ['collection', 'item'] });
const keyCombo = `${data['key']}-${collection}-${item}`;
if (keyCombos.has(keyCombo)) {
throw new UnprocessableContentError({
reason: `Cannot update multiple versions on "${item}" in collection "${collection}" to the same key "${data['key']}"`,
});
}
keyCombos.add(keyCombo);
const existingVersions = await super.readByQuery({
aggregate: { count: ['*'] },
filter: { id: { _neq: pk }, key: { _eq: data['key'] }, collection: { _eq: collection }, item: { _eq: item } },
});
if (existingVersions[0]['count'] > 0) {
throw new UnprocessableContentError({
reason: `Version "${data['key']}" already exists for item "${item}" in collection "${collection}"`,
});
}
}
}
return super.updateMany(keys, data, opts);
}
async save(key, delta) {
const version = await super.readOne(key);
const payloadService = new PayloadService(this.collection, {
accountability: this.accountability,
knex: this.knex,
schema: this.schema,
});
const activityService = new ActivityService({
knex: this.knex,
schema: this.schema,
});
const revisionsService = new RevisionsService({
knex: this.knex,
schema: this.schema,
});
const { item, collection, delta: existingDelta } = version;
const activity = await activityService.createOne({
action: Action.VERSION_SAVE,
user: this.accountability?.user ?? null,
collection,
ip: this.accountability?.ip ?? null,
user_agent: this.accountability?.userAgent ?? null,
origin: this.accountability?.origin ?? null,
item,
});
const helpers = getHelpers(this.knex);
let revisionDelta = await payloadService.prepareDelta(delta);
await revisionsService.createOne({
activity,
version: key,
collection,
item,
data: revisionDelta,
delta: revisionDelta,
});
revisionDelta = revisionDelta ? JSON.parse(revisionDelta) : null;
const date = new Date(helpers.date.writeTimestamp(new Date().toISOString()));
deepMapObjects(revisionDelta, (object, path) => {
const existing = get(existingDelta, path);
if (existing && isEqual(existing, object))
return;
object['_user'] = this.accountability?.user;
object['_date'] = date;
});
const finalVersionDelta = assign({}, existingDelta, revisionDelta);
const sudoService = new ItemsService(this.collection, {
knex: this.knex,
schema: this.schema,
accountability: {
...this.accountability,
admin: true,
},
});
await sudoService.updateOne(key, { delta: finalVersionDelta });
const { cache } = getCache();
if (shouldClearCache(cache, undefined, collection)) {
cache.clear();
}
return finalVersionDelta;
}
async promote(version, mainHash, fields) {
const { collection, item, delta } = (await super.readOne(version));
// will throw an error if the accountability does not have permission to update the item
if (this.accountability) {
await validateAccess({
accountability: this.accountability,
action: 'update',
collection,
primaryKeys: [item],
}, {
schema: this.schema,
knex: this.knex,
});
}
if (!delta) {
throw new UnprocessableContentError({
reason: `No changes to promote`,
});
}
const { outdated } = await this.verifyHash(collection, item, mainHash);
if (outdated) {
throw new UnprocessableContentError({
reason: `Main item has changed since this version was last updated`,
});
}
const { rawDelta, defaultOverwrites } = splitRecursive(delta);
const payloadToUpdate = fields ? pick(rawDelta, fields) : rawDelta;
const itemsService = new ItemsService(collection, {
accountability: this.accountability,
knex: this.knex,
schema: this.schema,
});
const payloadAfterHooks = await emitter.emitFilter(['items.promote', `${collection}.items.promote`], payloadToUpdate, {
collection,
item,
version,
}, {
database: this.knex,
schema: this.schema,
accountability: this.accountability,
});
const updatedItemKey = await itemsService.updateOne(item, payloadAfterHooks, {
overwriteDefaults: defaultOverwrites,
});
emitter.emitAction(['items.promote', `${collection}.items.promote`], {
payload: payloadAfterHooks,
collection,
item: updatedItemKey,
version,
}, {
database: this.knex,
schema: this.schema,
accountability: this.accountability,
});
return updatedItemKey;
}
mapDelta(version) {
const delta = version.delta ?? {};
delta[this.schema.collections[version.collection].primary] = version.item;
return deepMapWithSchema(delta, ([key, value], context) => {
if (key === '_user' || key === '_date')
return;
if (context.collection.primary in context.object) {
if (context.field.special.includes('user-updated')) {
return [key, context.object['_user']];
}
if (context.field.special.includes('date-updated')) {
return [key, context.object['_date']];
}
}
else {
if (context.field.special.includes('user-created')) {
return [key, context.object['_user']];
}
if (context.field.special.includes('date-created')) {
return [key, context.object['_date']];
}
}
if (key in context.object)
return [key, value];
return undefined;
}, { collection: version.collection, schema: this.schema }, { mapNonExistentFields: true, detailedUpdateSyntax: true });
}
}
/** Deeply maps all objects of a structure. Only calls the callback for objects, not for arrays. Objects in arrays will continued to be mapped. */
function deepMapObjects(object, fn, path = []) {
if (isPlainObject(object) && typeof object === 'object' && object !== null) {
fn(object, path);
Object.entries(object).map(([key, value]) => deepMapObjects(value, fn, [...path, key]));
}
else if (Array.isArray(object)) {
object.map((value, index) => deepMapObjects(value, fn, [...path, String(index)]));
}
}