apostrophe
Version:
The Apostrophe Content Management System.
1,308 lines (1,257 loc) • 74.8 kB
JavaScript
const _ = require('lodash');
const { createId } = require('@paralleldrive/cuid2');
const { SemanticAttributes } = require('@opentelemetry/semantic-conventions');
const { klona } = require('klona');
const legacyMigrations = require('./lib/legacy-migrations.js');
const migrations = require('./lib/migrations.js');
// This module is responsible for managing all of the documents (apostrophe
// "docs") in the `aposDocs` mongodb collection.
//
// The `getManager` method should be used to obtain a reference to the module
// that manages a particular doc type, so that you can benefit from behavior
// specific to that module. One method of this module that you may sometimes
// use directly is `apos.doc.find()`, which returns a
// query[query](server-@apostrophecms/query.html) for fetching documents of all
// types. This is useful when implementing something like the
// [@apostrophecms/search](../@apostrophecms/search/index.html) module.
//
// ## Options
//
// ** `advisoryLockTimeout`: Apostrophe locks documents while they are
// being edited so that another user, or another tab for the same user,
// does not inadvertently interfere. These locks are refreshed frequently
// by the browser while they are held. By default, if the browser
// is not heard from for 15 seconds, the lock expires. Note that
// the browser refreshes the lock every 5 seconds. This timeout should
// be quite short as there is no longer any reliable way to force a browser
// to unlock the document when leaving the page.
module.exports = {
options: {
alias: 'doc',
advisoryLockTimeout: 15
},
async init(self) {
self.managers = {};
self.contextOperations = [];
self.enableBrowserData();
await self.enableCollection();
self.apos.isNew = await self.detectNew();
await self.createIndexes();
self.addLegacyMigrations();
self.addMigrations();
},
restApiRoutes(self) {
return {
// GET /api/v1/@apostrophecms/doc/_id supports only the universal query
// features, but works for any document type. Simplifies browser-side
// logic for redirects to foreign documents. The frontend only has to
// know the doc _id.
//
// Since this API is solely for editing purposes you will receive
// a 404 if you request a document you cannot edit.
async getOne(req, _id) {
_id = self.apos.i18n.inferIdLocaleAndMode(req, _id);
const aposDocId = _id.split(':')[0];
const doc = await self.find(req, { $or: [ { _id }, { _id: aposDocId } ] }).permission('edit').toObject();
if (!doc) {
throw self.apos.error('notfound');
}
return doc;
}
};
},
apiRoutes(self) {
return {
post: {
async slugTaken(req) {
if (!req.user) {
throw self.apos.error('notfound');
}
const slug = self.apos.launder.string(req.body.slug);
const _id = self.apos.launder.id(req.body._id);
const criteria = { slug };
if (_id) {
criteria._id = { $ne: _id };
}
const doc = await self
.find(req, criteria)
.permission(false)
.archived(null)
.project({ slug: 1 })
.toObject();
if (doc) {
throw self.apos.error('conflict');
} else {
return {
available: true
};
}
},
// Fast bulk query for doc `ids` that the user is permitted to edit.
//
// IDs should be sent as an array in the `ids` property of the POST
// request.
//
// The response object contains an `editable` array made up of
// the ids of those documents in the original set that the user
// is actually permitted to edit. Those the user cannot edit
// are not included. The original order is preserved.
//
// This route is a POST route because large numbers of ids
// might not be accepted as a query string.
async editable(req) {
if (!req.user) {
throw self.apos.error('notfound');
}
const ids = self.apos.launder.ids(req.body.ids);
if (!ids.length) {
return {
editable: []
};
}
const found = await self.apos.doc.find(req, {
_id: {
$in: ids
}
}).project({ _id: 1 }).permission('edit').toArray();
return {
editable: self.apos.util.orderById(ids, found).map(doc => doc._id)
};
}
}
};
},
handlers(self) {
return {
'@apostrophecms/doc-type:beforeInsert': {
setLocaleAndMode(req, doc, options) {
const manager = self.getManager(doc.type);
if (!manager.isLocalized()) {
return;
}
if (doc._id) {
const [ _id, locale, mode ] = doc._id.split(':');
doc.aposLocale = `${locale}:${mode}`;
doc.aposMode = mode;
return;
}
const [ locale, mode ] = doc.aposLocale
? doc.aposLocale.split(':')
: [ req.locale, req.mode ];
doc.aposLocale = `${locale}:${mode}`;
doc.aposMode = mode;
},
testPermissionsAndAddIdAndCreatedAt(req, doc, options) {
self.testInsertPermissions(req, doc, options);
const manager = self.getManager(doc.type);
if (doc._id && manager.isLocalized()) {
if (!doc.aposDocId) {
const components = doc._id.split(':');
if (components.length < 3) {
throw new Error('If you supply your own _id it must end with :locale:mode, like :en:published');
}
doc.aposDocId = components[0];
doc.aposLocale = `${components[1]}:${components[2]}`;
}
}
if (!doc.aposDocId) {
doc.aposDocId = self.apos.util.generateId();
}
if (!doc._id) {
if (!doc.aposLocale) {
if (manager.isLocalized()) {
doc.aposLocale = `${req.locale}:${req.mode}`;
}
}
if (doc.aposLocale) {
doc._id = `${doc.aposDocId}:${doc.aposLocale}`;
} else {
doc._id = doc.aposDocId;
}
}
doc.metaType = 'doc';
doc.createdAt = new Date();
if (doc.archived == null) {
// Not always in the schema, so ensure it's true or false
// to simplify queries and indexing
doc.archived = false;
}
},
// Makes using our model APIs directly less tedious
ensureAreaAndWidgetIds(req, doc, options) {
self.apos.area.walk(doc, area => {
if (!area._id) {
area._id = self.apos.util.generateId();
}
for (const item of (area.items || [])) {
if (!item._id) {
item._id = self.apos.util.generateId();
}
}
});
}
},
'@apostrophecms/doc-type:beforeDelete': {
testPermissions(req, doc, options) {
if (!(options.permissions === false)) {
if (!self.apos.permission.can(req, 'delete', doc)) {
throw self.apos.error('forbidden');
}
}
}
},
'@apostrophecms/doc-type:beforePublish': {
testPermissions(req, info) {
if (info.options.permissions !== false) {
if (!self.apos.permission.can(
req,
info.options.autopublishing ? 'edit' : 'publish',
info.draft
)) {
throw self.apos.error('forbidden');
}
}
}
},
'@apostrophecms/doc-type:beforeSave': {
ensureSlugSortifyAndUpdatedAt(req, doc, options) {
const manager = self.getManager(doc.type);
manager.ensureSlug(doc);
_.each(manager.schema, function (field) {
if (field.sortify) {
doc[field.name + 'Sortified'] = self.apos.util.sortify(
doc[field.name]
? doc[field.name]
: ''
);
}
});
if (options.setUpdatedAtAndBy !== false) {
const date = new Date();
doc.updatedAt = date;
doc.cacheInvalidatedAt = date;
doc.updatedBy = req.user
? {
_id: req.user._id,
title: req.user.title || null,
username: req.user.username
}
: {
username: 'ApostropheCMS'
};
}
},
deduplicateWidgetIds(req, doc, options) {
this.deduplicateWidgetIds(doc);
}
},
'@apostrophecms/doc-type:afterInsert': {
async ensureDraftExists(req, doc, options) {
const manager = self.getManager(doc.type);
if (!manager.isLocalized()) {
return;
}
if (self.isDraft(doc)) {
return;
}
const draftLocale = doc.aposLocale.replace(':published', ':draft');
const draftId = `${doc.aposDocId}:${draftLocale}`;
if (await self.db.findOne({
_id: draftId
}, {
projection: {
_id: 1
}
})) {
return;
}
const lastPublishedAt = doc.createdAt || new Date();
const draft = {
...doc,
_id: draftId,
aposLocale: draftLocale,
lastPublishedAt
};
await manager.insertDraftOf(req, doc, draft, options);
// Published doc must know it is published, otherwise various bugs
// ensue
return self.apos.doc.db.updateOne({
_id: doc._id
}, {
$set: {
lastPublishedAt
}
});
}
},
fixUniqueError: {
async fixUniqueSlug(req, doc) {
doc.slug += Math.floor(Math.random() * 10).toString();
}
},
'@apostrophecms/doc-type:beforeUpdate': {
async checkPermissionsBeforeUpdate(req, doc, options) {
if (options.permissions !== false) {
if (!self.apos.permission.can(req, 'edit', doc)) {
throw new Error('forbidden');
}
}
}
},
'@apostrophecms/version:unversionedFields': {
baseUnversionedFields(req, doc, fields) {
fields.push('visibility');
}
},
'@apostrophecms/doc-type:afterDelete': {
// Deleting a draft implies deleting the document completely, since
// a draft must always exist. Deleting a published doc implies deleting
// the "previous" copy, since it only makes sense as a tool to revert
// the published doc's content. Note that deleting a draft recursively
// deletes both the published and previous docs.
async deleteOtherModes(req, doc, options) {
if (doc.aposLocale && doc.aposLocale.endsWith(':draft')) {
await cleanup('published');
await self.emit('afterAllModesDeleted', req, doc, options);
return;
}
if (doc.aposLocale && doc.aposLocale.endsWith(':published')) {
return cleanup('previous');
}
async function cleanup(mode) {
const peer = await self.apos.doc.db.findOne({
_id: doc._id.replace(/:[\w]+$/, `:${mode}`)
});
if (peer) {
const manager = peer.slug.startsWith('/') ? self.apos.page : self.getManager(peer.type);
await manager.delete(req, peer, options);
}
}
}
}
};
},
methods(self) {
return {
// `pairs` is an array of arrays, each containing an old _id
// and a new _id that should replace it.
//
// `aposDocId` is implicitly updated, `path` is updated if a page,
// and all references found in relationships are updated via reverse
// relationship id lookups, after which attachment references are updated.
// This is a slow operation, which is why this method should be called
// only by migrations and tasks that remedy an unexpected situation. _id
// is meant to be an immutable property, this method is a workaround for
// situations like a renamed locale or a replication bug fix.
//
// If `keep` is set to `'old'` the old document's content wins
// in the event of a conflict. If `keep` is set to `'new'` the
// new document's content wins in the event of a conflict.
// If `keep` is not set, a `conflict` error is thrown in the
// event of a conflict.
//
// If `skipReplace` is set to `true`, the method will not attempt to
// remove the old document, but will still update the new document. The
// new _id for each pair will be used for retrieving the "existing"
// document in this case.
async changeDocIds(pairs, { keep, skipReplace = false } = {}) {
let renamed = 0;
let kept = 0;
// Get page paths up front so we can avoid multiple queries when working
// on path changes
const pages = await self.apos.doc.db.find({
path: { $exists: 1 },
slug: /^\//
}).project({
path: 1
}).toArray();
for (const pair of pairs) {
const [ from, to ] = pair;
const oldAposDocId = from.split(':')[0];
const existing = await self.apos.doc.db
.findOne({ _id: skipReplace ? to : from });
if (!existing) {
throw self.apos.error('notfound');
}
const replacement = klona(existing);
if (!skipReplace) {
await self.apos.doc.db.removeOne({ _id: from });
}
replacement._id = to;
const parts = to.split(':');
replacement.aposDocId = parts[0];
// Watch out for nonlocalized types, don't set aposLocale for them
if (parts.length > 1) {
replacement.aposLocale = parts.slice(1).join(':');
}
const isPage = self.apos.page.isPage(existing);
if (isPage) {
replacement.path = existing.path.replace(
existing.aposDocId,
replacement.aposDocId
);
}
try {
if (!skipReplace) {
await self.apos.doc.db.insertOne(replacement);
renamed++;
}
} catch (e) {
// First reinsert old doc to prevent content loss on new doc insert
// failure
await self.apos.doc.db.insertOne(existing);
if (!self.apos.doc.isUniqueError(e)) {
// We cannot fix this error
throw e;
}
const existingReplacement = await self.apos.doc.db
.findOne({ _id: replacement._id });
if (!existingReplacement) {
// We don't know the cause of this error
throw e;
}
if (keep === 'new') {
// New content already exists in new locale, delete old locale
// and keep new
await self.apos.doc.db.removeOne({ _id: existing._id });
kept++;
} else if (keep === 'old') {
// We want to keep the old content, but with the new
// identifiers. Once again we need to remove the old doc first
// to cut down on conflicts
try {
await self.apos.doc.db.deleteOne({ _id: existing._id });
await self.apos.doc.db.deleteOne({ _id: replacement._id });
await self.apos.doc.db.insertOne(replacement);
renamed++;
} catch (e) {
// Reinsert old doc to prevent content loss on new doc insert
// failure
await self.apos.doc.db.insertOne(existing);
throw e;
}
kept++;
} else {
throw self.apos.error('conflict');
}
}
if (isPage && !skipReplace) {
for (const page of pages) {
if (page.path.includes(oldAposDocId)) {
await self.apos.doc.db.updateOne({
_id: page._id
}, {
$set: {
path: page.path.replace(oldAposDocId, replacement.aposDocId)
}
});
}
}
}
if (existing.relatedReverseIds?.length) {
const relatedDocs = await self.apos.doc.db.find({
aposDocId: { $in: existing.relatedReverseIds }
}).toArray();
for (const doc of relatedDocs) {
replaceId(doc, oldAposDocId, replacement.aposDocId);
await self.apos.doc.db.replaceOne({
_id: doc._id
}, doc);
}
}
}
await self.apos.attachment.recomputeAllDocReferences();
return {
renamed,
kept
};
function replaceId(obj, oldId, newId) {
if (obj == null) {
return;
}
if ((typeof obj) !== 'object') {
return;
}
for (const key of Object.keys(obj)) {
if (obj[key] === oldId) {
obj[key] = newId;
} else {
replaceId(obj[key], oldId, newId);
}
}
}
},
async enableCollection() {
self.db = await self.apos.db.collection('aposDocs');
},
// Detect whether the database is brand new (zero documents).
// This can't be done later because after this point init()
// functions are permitted to insert documents
async detectNew() {
const existing = await self.db.countDocuments();
return !existing;
},
async createSlugIndex() {
const params = self.getSlugIndexParams();
return self.db.createIndex(params, { unique: true });
},
getSlugIndexParams() {
return {
slug: 1,
aposLocale: 1
};
},
getPathLevelIndexParams() {
return {
path: 1,
level: 1,
aposLocale: 1
};
},
async createIndexes() {
await self.db.createIndex({
type: 1,
aposLocale: 1
}, {});
await self.createSlugIndex();
await self.db.createIndex({
titleSortified: 1,
aposLocale: 1
}, {});
await self.db.createIndex({
updatedAt: -1,
aposLocale: 1
}, {});
await self.db.createIndex({
relatedReverseIds: 1,
aposLocale: 1
}, {});
await self.db.createIndex({ 'advisoryLock._id': 1 }, {});
await self.createTextIndex();
await self.db.createIndex({ parkedId: 1 }, {});
await self.db.createIndex({
submitted: 1,
aposLocale: 1
});
await self.db.createIndex({
type: 1,
aposDocId: 1,
aposLocale: 1
});
await self.db.createIndex({
aposDocId: 1,
aposLocale: 1
});
await self.createPathLevelIndex();
},
async createTextIndex() {
try {
return await attempt();
} catch (e) {
// We are experiencing what may be a mongodb bug in which these
// indexes have different weights than expected and the createIndex
// call fails. If this happens drop and recreate the text index
if (e.toString().match(/different options/)) {
self.apos.util.warn('Text index has unexpected weights or other misconfiguration, reindexing');
await self.db.dropIndex('highSearchText_text_lowSearchText_text_title_text_searchBoost_text');
return await attempt();
} else {
throw e;
}
}
function attempt() {
return self.db.createIndex({
highSearchText: 'text',
lowSearchText: 'text',
title: 'text',
searchBoost: 'text'
}, {
default_language: self.options.searchLanguage || 'none',
weights: {
title: 100,
searchBoost: 150,
highSearchText: 10,
lowSearchText: 2
}
});
}
},
async createPathLevelIndex() {
const params = self.getPathLevelIndexParams();
return self.db.createIndex(params, {});
},
// Returns a query based on the permissions
// associated with the given request. You can then
// invoke chainable query builders like `.project()`,
// `limit()`, etc. to alter the query before ending
// the chain with an awaitable method like `toArray()`
// to obtain documents.
//
// `req` determines what documents the user is allowed
// to see. `criteria` is a MongoDB criteria object,
// see the MongoDB documentation for basics on this.
// If an `options` object is present, query builder
// methods with the same name as each property are
// invoked, with the value of that property. This is
// an alternative to chaining methods.
//
// This method returns a query, not docs! You
// need to chain it with toArray() or other
// query methods and await the result:
//
// await apos.doc.find(req, { type: 'foobar' }).toArray()
find(req, criteria = {}, options = {}) {
return self.apos.modules['@apostrophecms/any-doc-type']
.find(req, criteria, options);
},
// **Most often you will insert or update docs via the
// insert and update methods of the appropriate doc manager.**
// This method is for implementation use in those objects,
// and for times when you wish to explicitly bypass type-specific
// lifecycle events.
//
// Insert the given document. If the slug is not
// unique it is made unique. `beforeInsert`, `beforeSave`, `afterInsert`
// and `afterSave` events are emitted via the appropriate doc type
// manager, then awaited. They receive `(req, doc, options)`.
//
// Returns the inserted document.
//
// If the slug property is not set, the title
// property is converted to a slug. If neither
// property is set, an error is thrown.
//
// The `edit-type-name` permission is checked based on
// doc.type.
//
// If a unique key error occurs, the `@apostrophecms/doc:fixUniqueError`
// event is emitted and the doc is passed to all handlers.
// Modify the document to fix any properties that may need to be
// more unique due to a unique index you have added. It is
// not possible to know which property was responsible. This method
// takes care of the slug property directly.
//
// The `options` object may be omitted completely.
//
// If `options.permissions` is set explicitly to
// `false`, permissions checks are bypassed.
async insert(req, doc, options) {
const telemetry = self.apos.telemetry;
return telemetry.startActiveSpan(`model:${doc.type}:insert`, async (span) => {
span.setAttribute(SemanticAttributes.CODE_FUNCTION, 'insert');
span.setAttribute(SemanticAttributes.CODE_NAMESPACE, self.__meta.name);
span.setAttribute(telemetry.Attributes.TARGET_NAMESPACE, doc.type);
span.setAttribute(telemetry.Attributes.TARGET_FUNCTION, 'insert');
try {
options = options || {};
const m = self.getManager(doc.type);
await m.emit('beforeInsert', req, doc, options);
await m.emit('beforeSave', req, doc, options);
await telemetry.startActiveSpan(`db:${doc.type}:insert`, async (spanInsert) => {
spanInsert.setAttribute(SemanticAttributes.CODE_FUNCTION, 'insertBody');
spanInsert.setAttribute(
SemanticAttributes.CODE_NAMESPACE, self.__meta.name
);
spanInsert.setAttribute(telemetry.Attributes.TARGET_NAMESPACE, doc.type);
spanInsert.setAttribute(telemetry.Attributes.TARGET_FUNCTION, 'insert');
try {
const result = await self.insertBody(req, doc, options);
spanInsert.setStatus({ code: telemetry.api.SpanStatusCode.OK });
return result;
} catch (e) {
telemetry.handleError(spanInsert, e);
throw e;
} finally {
spanInsert.end();
}
}, span, {});
await m.emit('afterInsert', req, doc, options);
await m.emit('afterSave', req, doc, options);
// TODO: Remove `afterLoad` in next major version. Deprecated.
await m.emit('afterLoad', req, [ doc ]);
span.setStatus({ code: telemetry.api.SpanStatusCode.OK });
return doc;
} catch (err) {
telemetry.handleError(span, err);
throw err;
} finally {
span.end();
}
});
},
// Updates the given document. If the slug is not
// unique it is made unique. `beforeUpdate`, `beforeSave`,
// `afterUpdate` and `afterSave` events are emitted
// via the appropriate doc type manager.
//
// The second argument must be the document itself.
// `$set`, `$inc`, etc. are NOT available via
// this interface. This simplifies the implementation
// of permissions and workflow. If you need to
// update an object, find it first and then update it.
//
// Returns the updated doc.
//
// If a unique key error occurs, the `@apostrophecms/doc:fixUniqueError`
// event is emitted and the doc is passed to all handlers.
// Modify the document to fix any properties that may need to be
// more unique due to a unique index you have added. It is
// not possible to know which property was responsible. This method
// takes care of the slug property directly.
//
// The `options` object may be omitted completely.
//
// If `options.permissions` is set explicitly to
// `false`, permissions checks are bypassed.
async update(req, doc, options) {
const telemetry = self.apos.telemetry;
return telemetry.startActiveSpan(`model:${doc.type}:update`, async (span) => {
span.setAttribute(SemanticAttributes.CODE_FUNCTION, 'update');
span.setAttribute(SemanticAttributes.CODE_NAMESPACE, self.__meta.name);
span.setAttribute(telemetry.Attributes.TARGET_NAMESPACE, doc.type);
span.setAttribute(telemetry.Attributes.TARGET_FUNCTION, 'update');
try {
options = options || {};
const m = self.getManager(doc.type);
await m.emit('beforeUpdate', req, doc, options);
await m.emit('beforeSave', req, doc, options);
await telemetry.startActiveSpan(`db:${doc.type}:update`, async (spanUpdate) => {
spanUpdate.setAttribute(SemanticAttributes.CODE_FUNCTION, 'updateBody');
spanUpdate.setAttribute(
SemanticAttributes.CODE_NAMESPACE, self.__meta.name
);
spanUpdate.setAttribute(telemetry.Attributes.TARGET_NAMESPACE, doc.type);
spanUpdate.setAttribute(telemetry.Attributes.TARGET_FUNCTION, 'update');
try {
const result = await self.updateBody(req, doc, options);
spanUpdate.setStatus({ code: telemetry.api.SpanStatusCode.OK });
return result;
} catch (e) {
telemetry.handleError(spanUpdate, e);
throw e;
} finally {
spanUpdate.end();
}
}, span, {});
await m.emit('afterUpdate', req, doc, options);
await m.emit('afterSave', req, doc, options);
// TODO: Remove `afterLoad` in next major version. Deprecated.
await m.emit('afterLoad', req, [ doc ]);
span.setStatus({ code: telemetry.api.SpanStatusCode.OK });
return doc;
} catch (err) {
telemetry.handleError(span, err);
throw err;
} finally {
span.end();
}
});
},
// True delete. To place a document in the archive,
// update the archived property (for a piece) or move it
// to be a child of the archive (for a page). True delete
// cannot be undone.
//
// This operation ignores the locale and mode of `req`
// in favor of the actual document's locale and mode.
async delete(req, doc, options = {}) {
const m = self.getManager(doc.type);
await m.emit('beforeDelete', req, doc, options);
await self.deleteBody(req, doc, options);
await m.emit('afterDelete', req, doc, options);
},
// Publish the given draft. If `options.permissions` is explicitly
// set to `false`, permissions checks are bypassed.
async publish(req, draft, options = {}) {
const m = self.getManager(draft.type);
return m.publish(req, draft, options);
},
// Unpublish a given document.
async unpublish(req, doc) {
const m = self.getManager(doc.type);
return m.unpublish(req, doc);
},
// Revert to the previously published content, or if
// already equal to the previously published content, to the
// publication before that. Returns `false` if the draft
// cannot be reverted any further.
async revert(req, draft) {
const m = self.getManager(draft.type);
return m.revert(req, draft);
},
// Recursively visit every property of a doc,
// invoking an iterator function for each one. Optionally
// deletes properties.
//
// The `_originalWidgets` property and its subproperties
// are not walked because they are temporary information
// present only to preserve widgets during save operations
// performed by users without permissions for those widgets.
//
// The second argument must be a function that takes
// an object, a key, a value, a "dot path" and an
// array containing the ancestors of this property
// (beginning with the original `doc` and including
// "object") and explicitly returns `false` if that property
// should be discarded. If any other value is returned the
// property remains.
//
// Remember, keys can be numbers; toString() is
// your friend.
//
// If the original object looks like:
//
// { a: { b: 5 } }
//
// Then when the iterator is invoked for b, the
// object will be { b: 5 }, the key
// will be `b`, the value will be `5`, the dotPath
// will be the string `a.b`, and ancestors will be
// [ { a: { b: 5 } } ].
walk(doc, iterator) {
return walkBody(doc, iterator, undefined, []);
function walkBody(doc, iterator, _dotPath, _ancestors) {
if (_ancestors.includes(doc)) {
// No infinite loops on circular references
return;
}
// Don't use concat, doc can be an array in which case
// it is important to preserve the nesting
_ancestors = [ ..._ancestors, doc ];
if (_dotPath !== undefined) {
_dotPath += '.';
} else {
_dotPath = '';
}
const remove = [];
for (const key in doc) {
const __dotPath = _dotPath + key.toString();
const ow = '_originalWidgets';
if (__dotPath === ow || __dotPath.substring(0, ow.length) === ow + '.') {
continue;
}
if (iterator(doc, key, doc[key], __dotPath, _ancestors) === false) {
remove.push(key);
} else {
const val = doc[key];
if (typeof val === 'object') {
walkBody(val, iterator, __dotPath, _ancestors);
}
}
}
for (const key of remove) {
delete doc[key];
}
}
},
// Retry the given "actor" async function until it
// does not yield a MongoDB error related to
// unique indexes. The actor is not passed
// any arguments and it will be awaited. If
// an error related to uniqueness does occur, this module emits the
// `fixUniqueError` event with `req, doc` before
// the next retry. This is your opportunity to
// tweak properties relating to unique indexes
// this module does not know about.
//
// Passes on the return value of `actor`.
//
// Will make no more than 20 attempts, which statistically eliminates
// any chance we just didn't try hard enough while avoiding
// an infinite loop if the unique key error is due to a property
// there is no handling for.
async retryUntilUnique(req, doc, actor) {
const maxAttempts = 20;
let attempt = 0;
let firstError;
while (true) {
try {
return await actor();
} catch (err) {
if (!self.isUniqueError(err)) {
throw err;
}
if (!firstError) {
firstError = err;
}
attempt++;
if (attempt === maxAttempts) {
// Odds are now 1 in 100000000000000000000 that it is really due
// to a duplicate path or slug; a far more likely explanation is
// that another docFixUniqueError handler is needed to address
// an additional property that has to be unique. Report the
// original error to avoid confusion ("ZOMG, what are all these
// digits!")
firstError.aposAddendum = 'retryUntilUnique failed, most likely you need another docFixUniqueError method to handle another property that has a unique index, reporting original error';
throw firstError;
}
await self.emit('fixUniqueError', req, doc);
}
}
},
// Called by an `@apostrophecms/doc-type:insert` event handler to confirm
// that the user has the appropriate permissions for the doc's type and
// content.
testInsertPermissions(req, doc, options) {
if (options.permissions !== false) {
if (!self.apos.permission.can(req, 'create', doc)) {
throw self.apos.error('forbidden');
}
}
},
// Do not call this yourself, it is called
// by .update(). You will usually want to call the
// update method of the appropriate doc type manager instead:
//
// self.apos.doc.getManager(doc.type).update(...)
//
// You may override this method to change the implementation.
async updateBody(req, doc, options) {
const manager = self.apos.doc.getManager(doc.type);
if (manager.isLocalized(doc.type)) {
// Performance hit now at write time is better than inaccurate
// indicators of which docs are modified later (per Ben)
if (doc.aposLocale.endsWith(':draft') && (options.setModified !== false)) {
doc.modified = await manager.isModified(req, doc);
}
}
const result = await self.retryUntilUnique(req, doc, async () => {
return self.db.replaceOne({ _id: doc._id }, self.apos.util.clonePermanent(doc));
});
if (manager.isLocalized(doc.type)) {
if (doc.aposLocale.endsWith(':published')) {
// The reverse can happen too: published changes
// (for instance because a move operation gets
// repeated on it) and draft is no longer out of sync
const modified = await manager.isModified(req, doc);
await self.apos.doc.db.updateOne({
_id: doc._id.replace(':published', ':draft')
}, {
$set: {
modified
}
});
}
}
return result;
},
async deleteBody(req, doc, options) {
if ((options.permissions !== false) && (!self.apos.permission.can(req, 'delete', doc))) {
throw self.apos.error('forbidden');
}
return self.db.removeOne({
_id: doc._id
});
},
// Insert the given document. Called by `.insert()`. You will usually
// want to call the insert method of the appropriate doc type manager
// instead:
//
// ```javascript
// self.apos.doc.getManager(doc.type).insert(...)
// ```
//
// However you can override this method to alter the
// implementation.
async insertBody(req, doc, options) {
const manager = self.apos.doc.getManager(doc.type);
if (manager.isLocalized(doc.type) && doc.aposLocale.endsWith(':draft')) {
// We are inserting the draft for the first time so it is always
// different from the published, which won't exist yet. An exception
// is when the published doc is inserted first (like a parked page)
// in which case setModified: false will be passed in
if (options.setModified !== false) {
doc.modified = true;
}
}
if (!doc.visibility) {
// If the visibility property has been removed from the schema
// (images and files), make sure public queries can still match this
// type
doc.visibility = 'public';
}
return self.retryUntilUnique(req, doc, async function () {
return self.db.insertOne(self.apos.util.clonePermanent(doc));
});
},
// Set meta data for a given field, that will be live under `aposMeta`
// doc property. It returns the path to the meta property without the
// key. See `getMetaPath` method for more information.
//
// Signature:
// `apos.doc.setMeta(doc, namespace, [subobject], ...pathComponents, key,
// value);` where arguments are as follows: - `doc`: the document to
// attach the meta property to. - `namespace`: the namespace of the meta
// property, by convention the module name that is setting the meta
// property. - `subobject`: (optional) the name of the field subobject
// (e.g. array item, widget, or any other field type object that have
// `_id` property). This argument dictates how `pathComponents` are
// interpreted. If `subobject` is not provided, `pathComponents` are
// interpreted as a path starting from `doc`. If `subobject` is provided,
// `pathComponents` are interpreted as a relative path from the
// `subobject` field. - `pathComponents`: the dot path to the field value.
// It can be any number of strings with or without dot-separated
// components. If `subobject` is provided, `pathComponents` are
// interpreted as a relative path from the `subobject` field. If
// `subobject` is not provided, `pathComponents` are interpreted as a
// top-level path. `pathComponents` is optional when `subobject` field is
// provided. This way you can set a meta property directly for e.g. array
// or widget field. See examples below. - `key`: the key of the meta
// property. Should be a string. Dot-path is not supported, dots will be
// treated as part of the key. It's prefixed automatically with the
// `namespace` (`namespace:key`) to avoid conflicts with other modules. -
// `value`: the value of the meta property. Can be any JSON-serializable
// value.
//
// The document field metadata can be consumed by admin UI components. See
// `schema.addFieldMetadataComponent()` method for more information.
//
// Examples:
// - Set value of a top-level meta property of a generic field (e.g.
// string, number, boolean, etc.): `apos.doc.setMeta(doc, 'my-module',
// 'title', 'myMetaKey', 'myMetaValue');`
//
// - Set value of a top-level meta property of an object field (can be
// further nested):
// `apos.doc.setMeta(
// doc,
// 'my-module',
// 'address',
// 'city',
// 'myMetaKey',
// 'myMetaValue'
// );`
//
// - Set value of a meta property of a field inside of an array field
// type:
// `apos.doc.setMeta(doc, 'my-module', arrayItemObject, 'city',
// 'myMetaKey', 'myMetaValue');`
//
// - Set value of a meta property of a rich text widget
// `apos.doc.setMeta(doc, 'my-module', widgetObject, 'myMetaKey',
// 'myMetaValue');`
//
// - Dots in the `key` are treated as part of the key, dots in
// `pathComponents` are treated as dot-path and are not altered:
// `apos.doc.setMeta(doc, 'my-module', 'address', 'city.name',
// 'myMetaKey.with.dots', 'myMetaValue');`
// will set
// `doc.aposMeta.address.aposMeta.city.name['my-module:myMetaKey.with.dots']:
// 'myMetaValue'`.
setMeta(doc, namespace, ...pathArgsWithKeyAndValue) {
if (!_.isPlainObject(doc) || !namespace) {
throw self.apos.error('invalid', 'Valid document and namespace are required.', {
cause: 'invalidArguments'
});
}
const pathArgs = [ ...pathArgsWithKeyAndValue ];
const value = pathArgs.pop();
const key = pathArgs.pop();
if (!key) {
throw self.apos.error('invalid', 'Key and value are required.', {
cause: 'invalidArguments'
});
}
if (typeof key !== 'string') {
throw self.apos.error('invalid', 'Key must be a string.', {
cause: 'invalidArguments'
});
}
const metaPath = self.getMetaPath(...pathArgs);
const metaPathFull = `aposMeta.${metaPath}`;
const nsKey = `${namespace}:${key}`;
const existingValue = _.get(doc, metaPathFull) || {};
existingValue[nsKey] = value;
_.set(doc, metaPathFull, existingValue);
return metaPath;
},
// Get meta data for a given field. It has exactly the same signature as
// `setMeta` method, except the last `value` argument.
getMeta(doc, namespace, ...pathArgsWithKey) {
if (!doc || !namespace) {
throw self.apos.error('invalid', 'Document and namespace are required.', {
cause: 'invalidArguments'
});
}
const pathArgs = [ ...pathArgsWithKey ];
const key = pathArgs.pop();
if (!key) {
throw self.apos.error('invalid', 'Key and value are required.', {
cause: 'invalidArguments'
});
}
if (typeof key !== 'string') {
throw self.apos.error('invalid', 'Key must be a string.', {
cause: 'invalidArguments'
});
}
const nsKey = `${namespace}:${key}`;
return _.get(
doc,
`aposMeta.${self.getMetaPath(...pathArgs)}`
)?.[nsKey];
},
// Remove meta data key for a given field. It has exactly the same
// signature as `setMeta` method, except the last `value` argument. A
// cleanup is performed to remove empty meta properties on each call.
removeMeta(doc, namespace, ...pathArgsWithKey) {
if (!doc || !namespace) {
throw self.apos.error('invalid', 'Document and namespace are required.', {
cause: 'invalidArguments'
});
}
const pathArgs = [ ...pathArgsWithKey ];
const key = pathArgs.pop();
const metaPath = self.getMetaPath(...pathArgs);
const metaPathFull = `aposMeta.${metaPath}`;
if (!_.has(doc, metaPathFull)) {
return;
}
if (!key) {
throw self.apos.error('invalid', 'Key and value are required.', {
cause: 'invalidArguments'
});
}
if (typeof key !== 'string') {
throw self.apos.error('invalid', 'Key must be a string.', {
cause: 'invalidArguments'
});
}
const nsKey = `${namespace}:${key}`;
const existingValue = _.get(doc, metaPathFull) || {};
delete existingValue[nsKey];
_.set(doc, metaPathFull, existingValue);
cleanup(doc.aposMeta, 'aposMeta');
return metaPath;
function cleanup(object, path) {
if (_.isEmpty(object)) {
_.unset(object, path);
return true;
}
for (const key of Object.keys(object)) {
if (key.includes(':')) {
return false;
}
if (!_.isPlainObject(object[key])) {
delete object[key];
continue;
}
if (!cleanup(object[key], `${path}.${key}`)) {
return false;
}
delete object[key];
}
return true;
}
},
// Get all meta keys for a given field. It has exactly the same signature
// as `setMeta` method, except no key/value should be provided.
getMetaKeys(doc, namespace, ...pathArgs) {
return Object.keys(
_.get(
doc,
`aposMeta.${self.getMetaPath(...pathArgs)}`
) || {}
)
.filter(key => key.startsWith(`${namespace}:`))
.map(key => key.replace(`${namespace}:`, ''));
},
// Get the meta path for a given field.
// Signature:
// `apos.doc.getMetaPath([subobject,] ...pathComponents);`
// See `setMeta` for more information about `subobject` and
// `pathComponents` arguments.
//
// Returns the path to the meta property without the namespace and key.
// The returned path can be directly used to access or modify the meta
// property. It's supported by all meta API methods.
//
// Example:
// ```js
// const path = apos.doc.getMetaPath(subobject, 'address', 'city',
// 'name'); apos.doc.setMeta(doc, ns, path, 'myMetaKey', 'myMetaValue');
// apos.doc.getMeta(doc, ns, path, 'myMetaKey'); apos.doc.removeMeta(doc,
// ns, path, 'myMetaKey');
getMetaPath(...pathArgs) {
const args = pathArgs
.filter(arg => typeof arg !== 'undefined' && arg !== null);
let subObject;
if (_.isPlainObject(args[0])) {
subObject = args.shift();
}
if (args.some(arg => typeof arg !== 'string')) {
throw self.apos.error('invalid', 'All path components must be strings.', {
cause: 'invalidArguments'
});
}
const pathComponents = args.join('.aposMeta.');
if (!subObject && !pathComponents) {
throw self.apos.error(
'invalid',
'You must provide at least a "subobject" or at least one "pathComponent" string.',
{ cause: 'invalidArguments' }
);
}
if (subObject && !subObject._id) {
throw self.apos.error(
'invalid',
'Provided subobject must have an _id property.',
{ cause: 'subObjectNoId' }
);
}
if (!subObject) {
return pathComponents;
}
const metaPath = [];
metaPath.push(`@${subObject._id}`);
if (pathComponents) {
metaPath.push(`aposMeta.${pathComponents}`);
}
return metaPath.join('.');
},
// Given either an id (as a string) or a criteria
// object, return a criteria object.
idOrCriteria(idOrCriteria) {
if (typeof idOrCriteria === 'object') {
return idOrCriteria;
} else {
return { _id: idOrCriteria };
}
},
// Is this MongoDB error related to uniqueness? Great for retrying on
// duplicates. Used heavily by the pages module and no doubt will be by
// other things.
//
// There are three error codes for this: 13596 ("cannot change _id of a
// document") and 11000 and 11001 which specifically relate to the
// uniqueness of an index. 13596 can arise on an upsert operation,
// especially when the _id is assigned by the caller rather than by
// MongoDB.
//
// IMPORTANT: you are responsible for making sure ALL of your unique
// indexes are accounted for before retrying... otherwise an infinite loop
// will likely result.
isUniqueError(err) {
if (!err) {
return false;
}
return err.code === 13596 ||
err.code === 13596 ||
err.code === 11000 ||
err.code === 11001;
},
// Set the manager object corresponding
// to a given doc type. Typically `manager`
// is a module that subclasses `@apostrophecms/doc-type`
// (or its subclasses `@apostrophecms/piece-type` and
// `@apostrophecms/page-type`).
setManager(type, manager) {
self.managers[type] = manager;
},
// Returns an array of all of the doc types that have a registered
// manager.
getManaged() {
return Object.keys(self.managers);
},
// Fetch the manager object corresponding to a given
// doc type. The