2
1
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2023-12-13 21:00:40 +01:00

💡 Split the v3 endpoint from the canary endpoint

refs https://github.com/TryGhost/Team/issues/221
This commit is contained in:
Thibaut Patel 2021-01-21 09:57:52 +01:00 committed by naz
parent cbfdf79661
commit af9c5fd2f1
121 changed files with 7819 additions and 1 deletions

View file

@ -1,5 +1,5 @@
module.exports = require('./v2');
module.exports.v2 = require('./v2');
module.exports.canary = require('./canary');
module.exports.v3 = require('./canary');
module.exports.v3 = require('./v3');
module.exports.shared = require('./shared');

View file

@ -0,0 +1,19 @@
const models = require('../../models');
module.exports = {
docName: 'actions',
browse: {
options: [
'page',
'limit',
'fields',
'include',
'filter'
],
permissions: true,
query(frame) {
return models.Action.findPage(frame.options);
}
}
};

View file

@ -0,0 +1,187 @@
const api = require('./index');
const config = require('../../../shared/config');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const web = require('../../web');
const models = require('../../models');
const auth = require('../../services/auth');
const invitations = require('../../services/invitations');
module.exports = {
docName: 'authentication',
setup: {
statusCode: 201,
permissions: false,
validation: {
docName: 'setup'
},
query(frame) {
return Promise.resolve()
.then(() => {
return auth.setup.assertSetupCompleted(false)();
})
.then(() => {
const setupDetails = {
name: frame.data.setup[0].name,
email: frame.data.setup[0].email,
password: frame.data.setup[0].password,
blogTitle: frame.data.setup[0].blogTitle,
status: 'active'
};
return auth.setup.setupUser(setupDetails);
})
.then((data) => {
return auth.setup.doSettings(data, api.settings);
})
.then((user) => {
return auth.setup.sendWelcomeEmail(user.get('email'), api.mail)
.then(() => user);
});
}
},
updateSetup: {
permissions: (frame) => {
return models.User.findOne({role: 'Owner', status: 'all'})
.then((owner) => {
if (owner.id !== frame.options.context.user) {
throw new errors.NoPermissionError({message: i18n.t('errors.api.authentication.notTheBlogOwner')});
}
});
},
validation: {
docName: 'setup'
},
query(frame) {
return Promise.resolve()
.then(() => {
return auth.setup.assertSetupCompleted(true)();
})
.then(() => {
const setupDetails = {
name: frame.data.setup[0].name,
email: frame.data.setup[0].email,
password: frame.data.setup[0].password,
blogTitle: frame.data.setup[0].blogTitle,
status: 'active'
};
return auth.setup.setupUser(setupDetails);
})
.then((data) => {
return auth.setup.doSettings(data, api.settings);
});
}
},
isSetup: {
permissions: false,
query() {
return auth.setup.checkIsSetup()
.then((isSetup) => {
return {
status: isSetup,
// Pre-populate from config if, and only if the values exist in config.
title: config.title || undefined,
name: config.user_name || undefined,
email: config.user_email || undefined
};
});
}
},
generateResetToken: {
validation: {
docName: 'passwordreset'
},
permissions: true,
options: [
'email'
],
query(frame) {
return Promise.resolve()
.then(() => {
return auth.setup.assertSetupCompleted(true)();
})
.then(() => {
return auth.passwordreset.generateToken(frame.data.passwordreset[0].email, api.settings);
})
.then((token) => {
return auth.passwordreset.sendResetNotification(token, api.mail);
});
}
},
resetPassword: {
validation: {
docName: 'passwordreset',
data: {
newPassword: {required: true},
ne2Password: {required: true}
}
},
permissions: false,
options: [
'ip'
],
query(frame) {
return Promise.resolve()
.then(() => {
return auth.setup.assertSetupCompleted(true)();
})
.then(() => {
return auth.passwordreset.extractTokenParts(frame);
})
.then((params) => {
return auth.passwordreset.protectBruteForce(params);
})
.then(({options, tokenParts}) => {
options = Object.assign(options, {context: {internal: true}});
return auth.passwordreset.doReset(options, tokenParts, api.settings)
.then((params) => {
web.shared.middlewares.api.spamPrevention.userLogin().reset(frame.options.ip, `${tokenParts.email}login`);
return params;
});
});
}
},
acceptInvitation: {
validation: {
docName: 'invitations'
},
permissions: false,
query(frame) {
return Promise.resolve()
.then(() => {
return auth.setup.assertSetupCompleted(true)();
})
.then(() => {
return invitations.accept(frame.data);
});
}
},
isInvitation: {
data: [
'email'
],
validation: {
docName: 'invitations'
},
permissions: false,
query(frame) {
return Promise.resolve()
.then(() => {
return auth.setup.assertSetupCompleted(true)();
})
.then(() => {
const email = frame.data.email;
return models.Invite.findOne({email: email, status: 'sent'}, frame.options);
});
}
}
};

View file

@ -0,0 +1,65 @@
const Promise = require('bluebird');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const models = require('../../models');
const ALLOWED_INCLUDES = ['count.posts'];
module.exports = {
docName: 'authors',
browse: {
options: [
'include',
'filter',
'fields',
'limit',
'order',
'page'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.Author.findPage(frame.options);
}
},
read: {
options: [
'include',
'filter',
'fields'
],
data: [
'id',
'slug',
'email',
'role'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.Author.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.authors.notFound')
}));
}
return model;
});
}
}
};

View file

@ -0,0 +1,32 @@
const {isPlainObject} = require('lodash');
const config = require('../../../shared/config');
const labs = require('../../services/labs');
const ghostVersion = require('../../lib/ghost-version');
module.exports = {
docName: 'config',
read: {
permissions: false,
query() {
const billingUrl = config.get('host_settings:billing:enabled') ? config.get('host_settings:billing:url') : '';
const response = {
version: ghostVersion.full,
environment: config.get('env'),
database: config.get('database').client,
mail: isPlainObject(config.get('mail')) ? config.get('mail').transport : '',
useGravatar: !config.isPrivacyDisabled('useGravatar'),
labs: labs.getAll(),
clientExtensions: config.get('clientExtensions') || {},
enableDeveloperExperiments: config.get('enableDeveloperExperiments') || false,
stripeDirect: config.get('stripeDirect'),
mailgunIsConfigured: config.get('bulkEmail') && config.get('bulkEmail').mailgun,
emailAnalytics: config.get('emailAnalytics')
};
if (billingUrl) {
response.billingUrl = billingUrl;
}
return response;
}
}
};

131
core/server/api/v3/db.js Normal file
View file

@ -0,0 +1,131 @@
const Promise = require('bluebird');
const dbBackup = require('../../data/db/backup');
const exporter = require('../../data/exporter');
const importer = require('../../data/importer');
const errors = require('@tryghost/errors');
const models = require('../../models');
module.exports = {
docName: 'db',
backupContent: {
permissions: true,
options: [
'include',
'filename'
],
validation: {
options: {
include: {
values: exporter.EXCLUDED_TABLES
}
}
},
query(frame) {
// NOTE: we need to have `include` property available as backupDatabase uses it internally
Object.assign(frame.options, {include: frame.options.withRelated});
return dbBackup.backup(frame.options);
}
},
exportContent: {
options: [
'include',
'filename'
],
validation: {
options: {
include: {
values: exporter.EXCLUDED_TABLES
}
}
},
headers: {
disposition: {
type: 'file',
value: () => (exporter.fileName())
}
},
permissions: true,
async query(frame) {
if (frame.options.filename) {
let backup = await dbBackup.readBackup(frame.options.filename);
if (!backup) {
throw new errors.NotFoundError();
}
return backup;
}
return Promise.resolve()
.then(() => exporter.doExport({include: frame.options.withRelated}))
.catch((err) => {
return Promise.reject(new errors.GhostError({err: err}));
});
}
},
importContent: {
options: [
'include'
],
validation: {
options: {
include: {
values: exporter.EXCLUDED_TABLES
}
}
},
permissions: true,
query(frame) {
return importer.importFromFile(frame.file, {include: frame.options.withRelated});
}
},
deleteAllContent: {
statusCode: 204,
permissions: true,
query() {
/**
* @NOTE:
* We fetch all posts with `columns:id` to increase the speed of this endpoint.
* And if you trigger `post.destroy(..)`, this will trigger bookshelf and model events.
* But we only have to `id` available in the model. This won't work, because:
* - model layer can't trigger event e.g. `post.page` to trigger `post|page.unpublished`.
* - `onDestroyed` or `onDestroying` can contain custom logic
*/
function deleteContent() {
return models.Base.transaction((transacting) => {
const queryOpts = {
columns: 'id',
context: {internal: true},
destroyAll: true,
transacting: transacting
};
return models.Post.findAll(queryOpts)
.then((response) => {
return Promise.map(response.models, (post) => {
return models.Post.destroy(Object.assign({id: post.id}, queryOpts));
}, {concurrency: 100});
})
.then(() => models.Tag.findAll(queryOpts))
.then((response) => {
return Promise.map(response.models, (tag) => {
return models.Tag.destroy(Object.assign({id: tag.id}, queryOpts));
}, {concurrency: 100});
})
.catch((err) => {
throw new errors.GhostError({
err: err
});
});
});
}
return dbBackup.backup().then(deleteContent);
}
}
};

View file

@ -0,0 +1,83 @@
const models = require('../../models');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const mega = require('../../services/mega');
module.exports = {
docName: 'email_preview',
read: {
options: [
'fields'
],
validation: {
options: {
fields: ['html', 'plaintext', 'subject']
}
},
data: [
'id',
'status'
],
permissions: true,
query(frame) {
const options = Object.assign(frame.options, {formats: 'html,plaintext', withRelated: ['authors', 'posts_meta']});
const data = Object.assign(frame.data, {status: 'all'});
return models.Post.findOne(data, options)
.then((model) => {
if (!model) {
throw new errors.NotFoundError({
message: i18n.t('errors.api.posts.postNotFound')
});
}
return mega.postEmailSerializer.serialize(model, {isBrowserPreview: true}).then((emailContent) => {
const replacements = mega.postEmailSerializer.parseReplacements(emailContent);
replacements.forEach((replacement) => {
emailContent[replacement.format] = emailContent[replacement.format].replace(
replacement.match,
replacement.fallback || ''
);
});
return emailContent;
});
});
}
},
sendTestEmail: {
statusCode: 200,
headers: {},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
async query(frame) {
const options = Object.assign(frame.options, {status: 'all'});
let model = await models.Post.findOne(options, {withRelated: ['authors']});
if (!model) {
throw new errors.NotFoundError({
message: i18n.t('errors.api.posts.postNotFound')
});
}
const {emails = []} = frame.data;
const response = await mega.mega.sendTestEmail(model, emails);
if (response && response[0] && response[0].error) {
throw new errors.EmailError({
statusCode: response[0].error.statusCode,
message: response[0].error.message,
context: response[0].error.originalMessage
});
}
return response;
}
}
};

View file

@ -0,0 +1,60 @@
const models = require('../../models');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const megaService = require('../../services/mega');
module.exports = {
docName: 'emails',
read: {
options: [
'fields'
],
validation: {
options: {
fields: ['html', 'plaintext', 'subject']
}
},
data: [
'id'
],
permissions: true,
query(frame) {
return models.Email.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
throw new errors.NotFoundError({
message: i18n.t('errors.models.email.emailNotFound')
});
}
return model;
});
}
},
retry: {
data: [
'id'
],
permissions: true,
query(frame) {
return models.Email.findOne(frame.data, frame.options)
.then(async (model) => {
if (!model) {
throw new errors.NotFoundError({
message: i18n.t('errors.models.email.emailNotFound')
});
}
if (model.get('status') !== 'failed') {
throw new errors.IncorrectUsageError({
message: i18n.t('errors.models.email.retryNotAllowed')
});
}
return await megaService.mega.retryFailedEmail(model);
});
}
}
};

View file

@ -0,0 +1,36 @@
const settings = require('../../services/settings/cache');
const urlUtils = require('../../../shared/url-utils');
const jwt = require('jsonwebtoken');
const jose = require('node-jose');
const issuer = urlUtils.urlFor('admin', true);
const dangerousPrivateKey = settings.get('ghost_private_key');
const keyStore = jose.JWK.createKeyStore();
const keyStoreReady = keyStore.add(dangerousPrivateKey, 'pem');
const getKeyID = async () => {
const key = await keyStoreReady;
return key.kid;
};
const sign = async (claims, options) => {
const kid = await getKeyID();
return jwt.sign(claims, dangerousPrivateKey, Object.assign({
issuer,
expiresIn: '5m',
algorithm: 'RS256',
keyid: kid
}, options));
};
module.exports = {
docName: 'identities',
permissions: true,
read: {
permissions: true,
async query(frame) {
const token = await sign({sub: frame.user.get('email')});
return {token};
}
}
};

View file

@ -0,0 +1,20 @@
const Promise = require('bluebird');
const storage = require('../../adapters/storage');
module.exports = {
docName: 'images',
upload: {
statusCode: 201,
permissions: false,
query(frame) {
const store = storage.getStorage();
if (frame.files) {
return Promise
.map(frame.files, file => store.save(file))
.then(paths => paths[0]);
}
return store.save(frame.file);
}
}
};

173
core/server/api/v3/index.js Normal file
View file

@ -0,0 +1,173 @@
const shared = require('../shared');
const localUtils = require('./utils');
module.exports = {
get http() {
return shared.http;
},
get authentication() {
return shared.pipeline(require('./authentication'), localUtils);
},
get db() {
return shared.pipeline(require('./db'), localUtils);
},
get identities() {
return shared.pipeline(require('./identities'), localUtils);
},
get integrations() {
return shared.pipeline(require('./integrations'), localUtils);
},
// @TODO: transform
get session() {
return require('./session');
},
get schedules() {
return shared.pipeline(require('./schedules'), localUtils);
},
get pages() {
return shared.pipeline(require('./pages'), localUtils);
},
get redirects() {
return shared.pipeline(require('./redirects'), localUtils);
},
get roles() {
return shared.pipeline(require('./roles'), localUtils);
},
get slugs() {
return shared.pipeline(require('./slugs'), localUtils);
},
get webhooks() {
return shared.pipeline(require('./webhooks'), localUtils);
},
get posts() {
return shared.pipeline(require('./posts'), localUtils);
},
get invites() {
return shared.pipeline(require('./invites'), localUtils);
},
get mail() {
return shared.pipeline(require('./mail'), localUtils);
},
get notifications() {
return shared.pipeline(require('./notifications'), localUtils);
},
get settings() {
return shared.pipeline(require('./settings'), localUtils);
},
get membersStripeConnect() {
return shared.pipeline(require('./membersStripeConnect'), localUtils);
},
get members() {
return shared.pipeline(require('./members'), localUtils);
},
get memberSigninUrls() {
return shared.pipeline(require('./memberSigninUrls.js'), localUtils);
},
get labels() {
return shared.pipeline(require('./labels'), localUtils);
},
get images() {
return shared.pipeline(require('./images'), localUtils);
},
get tags() {
return shared.pipeline(require('./tags'), localUtils);
},
get users() {
return shared.pipeline(require('./users'), localUtils);
},
get preview() {
return shared.pipeline(require('./preview'), localUtils);
},
get oembed() {
return shared.pipeline(require('./oembed'), localUtils);
},
get slack() {
return shared.pipeline(require('./slack'), localUtils);
},
get config() {
return shared.pipeline(require('./config'), localUtils);
},
get themes() {
return shared.pipeline(require('./themes'), localUtils);
},
get actions() {
return shared.pipeline(require('./actions'), localUtils);
},
get email_preview() {
return shared.pipeline(require('./email-preview'), localUtils);
},
get emails() {
return shared.pipeline(require('./email'), localUtils);
},
get site() {
return shared.pipeline(require('./site'), localUtils);
},
get snippets() {
return shared.pipeline(require('./snippets'), localUtils);
},
get serializers() {
return require('./utils/serializers');
},
/**
* Content API Controllers
*
* @NOTE:
*
* Please create separate controllers for Content & Admin API. The goal is to expose `api.v3.content` and
* `api.v3.admin` soon. Need to figure out how serializers & validation works then.
*/
get pagesPublic() {
return shared.pipeline(require('./pages-public'), localUtils, 'content');
},
get tagsPublic() {
return shared.pipeline(require('./tags-public'), localUtils, 'content');
},
get publicSettings() {
return shared.pipeline(require('./settings-public'), localUtils, 'content');
},
get postsPublic() {
return shared.pipeline(require('./posts-public'), localUtils, 'content');
},
get authorsPublic() {
return shared.pipeline(require('./authors-public'), localUtils, 'content');
}
};

View file

@ -0,0 +1,169 @@
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const models = require('../../models');
module.exports = {
docName: 'integrations',
browse: {
permissions: true,
options: [
'include',
'limit'
],
validation: {
options: {
include: {
values: ['api_keys', 'webhooks']
}
}
},
query({options}) {
return models.Integration.findPage(options);
}
},
read: {
permissions: true,
data: [
'id'
],
options: [
'include'
],
validation: {
data: {
id: {
required: true
}
},
options: {
include: {
values: ['api_keys', 'webhooks']
}
}
},
query({data, options}) {
return models.Integration.findOne(data, Object.assign(options, {require: true}))
.catch(models.Integration.NotFoundError, () => {
throw new errors.NotFoundError({
message: i18n.t('errors.api.resource.resourceNotFound', {
resource: 'Integration'
})
});
});
}
},
edit: {
permissions: true,
data: [
'name',
'icon_image',
'description',
'webhooks'
],
options: [
'id',
'keyid',
'include'
],
validation: {
options: {
id: {
required: true
},
include: {
values: ['api_keys', 'webhooks']
}
}
},
query({data, options}) {
if (options.keyid) {
return models.ApiKey.findOne({id: options.keyid})
.then(async (model) => {
if (!model) {
throw new errors.NotFoundError({
message: i18n.t('errors.api.resource.resourceNotFound', {
resource: 'ApiKey'
})
});
}
try {
await models.ApiKey.refreshSecret(model.toJSON(), Object.assign({}, options, {id: options.keyid}));
return models.Integration.findOne({id: options.id}, {
withRelated: ['api_keys', 'webhooks']
});
} catch (err) {
throw new errors.GhostError({
err: err
});
}
});
}
return models.Integration.edit(data, Object.assign(options, {require: true}))
.catch(models.Integration.NotFoundError, () => {
throw new errors.NotFoundError({
message: i18n.t('errors.api.resource.resourceNotFound', {
resource: 'Integration'
})
});
});
}
},
add: {
statusCode: 201,
permissions: true,
data: [
'name',
'icon_image',
'description',
'webhooks'
],
options: [
'include'
],
validation: {
data: {
name: {
required: true
}
},
options: {
include: {
values: ['api_keys', 'webhooks']
}
}
},
query({data, options}) {
const dataWithApiKeys = Object.assign({
api_keys: [
{type: 'content'},
{type: 'admin'}
]
}, data);
return models.Integration.add(dataWithApiKeys, options);
}
},
destroy: {
statusCode: 204,
permissions: true,
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
query({options}) {
return models.Integration.destroy(Object.assign(options, {require: true}))
.catch(models.Integration.NotFoundError, () => {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.resource.resourceNotFound', {
resource: 'Integration'
})
}));
});
}
}
};

View file

@ -0,0 +1,122 @@
const Promise = require('bluebird');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const invites = require('../../services/invites');
const models = require('../../models');
const api = require('./index');
const ALLOWED_INCLUDES = [];
const UNSAFE_ATTRS = ['role_id'];
module.exports = {
docName: 'invites',
browse: {
options: [
'include',
'page',
'limit',
'fields',
'filter',
'order',
'debug'
],
validation: {
options: {
include: ALLOWED_INCLUDES
}
},
permissions: true,
query(frame) {
return models.Invite.findPage(frame.options);
}
},
read: {
options: [
'include'
],
data: [
'id',
'email'
],
validation: {
options: {
include: ALLOWED_INCLUDES
}
},
permissions: true,
query(frame) {
return models.Invite.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.invites.inviteNotFound')
}));
}
return model;
});
}
},
destroy: {
statusCode: 204,
options: [
'include',
'id'
],
validation: {
options: {
include: ALLOWED_INCLUDES
}
},
permissions: true,
query(frame) {
frame.options.require = true;
return models.Invite.destroy(frame.options)
.then(() => null)
.catch(models.Invite.NotFoundError, () => {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.invites.inviteNotFound')
}));
});
}
},
add: {
statusCode: 201,
options: [
'include',
'email'
],
validation: {
options: {
include: ALLOWED_INCLUDES
},
data: {
role_id: {
required: true
},
email: {
required: true
}
}
},
permissions: {
unsafeAttrs: UNSAFE_ATTRS
},
query(frame) {
return invites.add({
api,
InviteModel: models.Invite,
invites: frame.data.invites,
options: frame.options,
user: {
name: frame.user.get('name'),
email: frame.user.get('email')
}
});
}
}
};

View file

@ -0,0 +1,158 @@
const Promise = require('bluebird');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const models = require('../../models');
const ALLOWED_INCLUDES = ['count.members'];
module.exports = {
docName: 'labels',
browse: {
options: [
'include',
'filter',
'fields',
'limit',
'order',
'page'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.Label.findPage(frame.options);
}
},
read: {
options: [
'include',
'filter',
'fields'
],
data: [
'id',
'slug'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.Label.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.labels.labelNotFound')
}));
}
return model;
});
}
},
add: {
statusCode: 201,
headers: {},
options: [
'include'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
async query(frame) {
try {
return await models.Label.add(frame.data.labels[0], frame.options);
} catch (error) {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
throw new errors.ValidationError({message: i18n.t('errors.api.labels.labelAlreadyExists')});
}
throw error;
}
}
},
edit: {
headers: {},
options: [
'id',
'include'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
id: {
required: true
}
}
},
permissions: true,
query(frame) {
return models.Label.edit(frame.data.labels[0], frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.labels.labelNotFound')
}));
}
if (model.wasChanged()) {
this.headers.cacheInvalidate = true;
} else {
this.headers.cacheInvalidate = false;
}
return model;
});
}
},
destroy: {
statusCode: 204,
headers: {
cacheInvalidate: true
},
options: [
'id'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
id: {
required: true
}
}
},
permissions: true,
query(frame) {
return models.Label.destroy(frame.options)
.then(() => null)
.catch(models.Label.NotFoundError, () => {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.labels.labelNotFound')
}));
});
}
}
};

View file

@ -0,0 +1,60 @@
const Promise = require('bluebird');
const {i18n} = require('../../lib/common');
const mailService = require('../../services/mail');
const api = require('./');
let mailer;
let _private = {};
_private.sendMail = (object) => {
if (!(mailer instanceof mailService.GhostMailer)) {
mailer = new mailService.GhostMailer();
}
return mailer.send(object.mail[0].message).catch((err) => {
if (mailer.state.usingDirect) {
api.notifications.add(
{
notifications: [{
type: 'warn',
message: [
i18n.t('warnings.index.unableToSendEmail'),
i18n.t('common.seeLinkForInstructions', {link: 'https://ghost.org/docs/concepts/config/#mail'})
].join(' ')
}]
},
{context: {internal: true}}
);
}
return Promise.reject(err);
});
};
module.exports = {
docName: 'mail',
send: {
permissions: true,
query(frame) {
return _private.sendMail(frame.data);
}
},
sendTest(frame) {
return mailService.utils.generateContent({template: 'test'})
.then((content) => {
const payload = {
mail: [{
message: {
to: frame.user.get('email'),
subject: i18n.t('common.api.mail.testGhostEmail'),
html: content.html,
text: content.text
}
}]
};
return _private.sendMail(payload);
});
}
};

View file

@ -0,0 +1,30 @@
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const membersService = require('../../services/members');
module.exports = {
docName: 'member_signin_urls',
permissions: true,
read: {
data: [
'id'
],
permissions: true,
async query(frame) {
let model = await membersService.api.members.get(frame.data, frame.options);
if (!model) {
throw new errors.NotFoundError({
message: i18n.t('errors.api.members.memberNotFound')
});
}
const magicLink = await membersService.api.getMagicLink(model.get('email'));
return {
member_id: model.get('id'),
url: magicLink
};
}
}
};

View file

@ -0,0 +1,389 @@
// NOTE: We must not cache references to membersService.api
// as it is a getter and may change during runtime.
const Promise = require('bluebird');
const moment = require('moment-timezone');
const errors = require('@tryghost/errors');
const models = require('../../models');
const membersService = require('../../services/members');
const settingsCache = require('../../services/settings/cache');
const {i18n} = require('../../lib/common');
const allowedIncludes = ['email_recipients'];
module.exports = {
docName: 'members',
hasActiveStripeSubscriptions: {
permissions: {
method: 'browse'
},
async query() {
const hasActiveStripeSubscriptions = await membersService.api.hasActiveStripeSubscriptions();
return {
hasActiveStripeSubscriptions
};
}
},
browse: {
options: [
'limit',
'fields',
'filter',
'order',
'debug',
'page',
'search',
'paid'
],
permissions: true,
validation: {},
async query(frame) {
frame.options.withRelated = ['labels', 'stripeSubscriptions', 'stripeSubscriptions.customer'];
const page = await membersService.api.members.list(frame.options);
return page;
}
},
read: {
options: [
'include'
],
headers: {},
data: [
'id',
'email'
],
validation: {
options: {
include: {
values: allowedIncludes
}
}
},
permissions: true,
async query(frame) {
const defaultWithRelated = ['labels', 'stripeSubscriptions', 'stripeSubscriptions.customer'];
if (!frame.options.withRelated) {
frame.options.withRelated = defaultWithRelated;
} else {
frame.options.withRelated = frame.options.withRelated.concat(defaultWithRelated);
}
if (frame.options.withRelated.includes('email_recipients')) {
frame.options.withRelated.push('email_recipients.email');
}
let model = await membersService.api.members.get(frame.data, frame.options);
if (!model) {
throw new errors.NotFoundError({
message: i18n.t('errors.api.members.memberNotFound')
});
}
return model;
}
},
add: {
statusCode: 201,
headers: {},
options: [
'send_email',
'email_type'
],
validation: {
data: {
email: {required: true}
},
options: {
email_type: {
values: ['signin', 'signup', 'subscribe']
}
}
},
permissions: true,
async query(frame) {
let member;
frame.options.withRelated = ['stripeSubscriptions', 'stripeSubscriptions.customer'];
try {
if (!membersService.config.isStripeConnected()
&& (frame.data.members[0].stripe_customer_id || frame.data.members[0].comped)) {
const property = frame.data.members[0].comped ? 'comped' : 'stripe_customer_id';
throw new errors.ValidationError({
message: i18n.t('errors.api.members.stripeNotConnected.message'),
context: i18n.t('errors.api.members.stripeNotConnected.context'),
help: i18n.t('errors.api.members.stripeNotConnected.help'),
property
});
}
member = await membersService.api.members.create(frame.data.members[0], frame.options);
if (frame.data.members[0].stripe_customer_id) {
await membersService.api.members.linkStripeCustomer(frame.data.members[0].stripe_customer_id, member);
}
if (frame.data.members[0].comped) {
await membersService.api.members.setComplimentarySubscription(member);
}
if (frame.options.send_email) {
await membersService.api.sendEmailWithMagicLink({email: member.get('email'), requestedType: frame.options.email_type});
}
return member;
} catch (error) {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
throw new errors.ValidationError({
message: i18n.t('errors.models.member.memberAlreadyExists.message'),
context: i18n.t('errors.models.member.memberAlreadyExists.context', {
action: 'add'
})
});
}
// NOTE: failed to link Stripe customer/plan/subscription or have thrown custom Stripe connection error.
// It's a bit ugly doing regex matching to detect errors, but it's the easiest way that works without
// introducing additional logic/data format into current error handling
const isStripeLinkingError = error.message && (error.message.match(/customer|plan|subscription/g));
if (member && isStripeLinkingError) {
if (error.message.indexOf('customer') && error.code === 'resource_missing') {
error.message = `Member not imported. ${error.message}`;
error.context = i18n.t('errors.api.members.stripeCustomerNotFound.context');
error.help = i18n.t('errors.api.members.stripeCustomerNotFound.help');
}
await membersService.api.members.destroy({
id: member.get('id')
}, frame.options);
}
throw error;
}
}
},
edit: {
statusCode: 200,
headers: {},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
async query(frame) {
try {
frame.options.withRelated = ['stripeSubscriptions'];
const member = await membersService.api.members.update(frame.data.members[0], frame.options);
const hasCompedSubscription = !!member.related('stripeSubscriptions').find(sub => sub.get('plan_nickname') === 'Complimentary' && sub.get('status') === 'active');
if (typeof frame.data.members[0].comped === 'boolean') {
if (frame.data.members[0].comped && !hasCompedSubscription) {
await membersService.api.members.setComplimentarySubscription(member);
} else if (!(frame.data.members[0].comped) && hasCompedSubscription) {
await membersService.api.members.cancelComplimentarySubscription(member);
}
await member.load(['stripeSubscriptions']);
}
await member.load(['stripeSubscriptions.customer']);
return member;
} catch (error) {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
throw new errors.ValidationError({
message: i18n.t('errors.models.member.memberAlreadyExists.message'),
context: i18n.t('errors.models.member.memberAlreadyExists.context', {
action: 'edit'
})
});
}
throw error;
}
}
},
editSubscription: {
statusCode: 200,
headers: {},
options: [
'id',
'subscription_id'
],
data: [
'cancel_at_period_end'
],
validation: {
options: {
id: {
required: true
},
subscription_id: {
required: true
}
},
data: {
cancel_at_period_end: {
required: true
}
}
},
permissions: {
method: 'edit'
},
async query(frame) {
await membersService.api.members.updateSubscription({
id: frame.options.id,
subscription: {
subscription_id: frame.options.subscription_id,
cancel_at_period_end: frame.data.cancel_at_period_end
}
});
let model = await membersService.api.members.get({id: frame.options.id}, {
withRelated: ['labels', 'stripeSubscriptions', 'stripeSubscriptions.customer']
});
if (!model) {
throw new errors.NotFoundError({
message: i18n.t('errors.api.members.memberNotFound')
});
}
return model;
}
},
destroy: {
statusCode: 204,
headers: {},
options: [
'id',
'cancel'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
async query(frame) {
frame.options.require = true;
frame.options.cancelStripeSubscriptions = frame.options.cancel;
await Promise.resolve(membersService.api.members.destroy({
id: frame.options.id
}, frame.options)).catch(models.Member.NotFoundError, () => {
throw new errors.NotFoundError({
message: i18n.t('errors.api.resource.resourceNotFound', {
resource: 'Member'
})
});
});
return null;
}
},
exportCSV: {
options: [
'limit',
'filter',
'search',
'paid'
],
headers: {
disposition: {
type: 'csv',
value() {
const datetime = (new Date()).toJSON().substring(0, 10);
return `members.${datetime}.csv`;
}
}
},
response: {
format: 'plain'
},
permissions: {
method: 'browse'
},
validation: {},
async query(frame) {
frame.options.withRelated = ['labels', 'stripeSubscriptions', 'stripeSubscriptions.customer'];
const page = await membersService.api.members.list(frame.options);
return page;
}
},
importCSV: {
statusCode(result) {
if (result && result.meta && result.meta.stats && result.meta.stats.imported !== null) {
return 201;
} else {
return 202;
}
},
permissions: {
method: 'add'
},
async query(frame) {
const siteTimezone = settingsCache.get('timezone');
const importLabel = {
name: `Import ${moment().tz(siteTimezone).format('YYYY-MM-DD HH:mm')}`
};
const globalLabels = [importLabel].concat(frame.data.labels);
const pathToCSV = frame.file.path;
const headerMapping = frame.data.mapping;
return membersService.importer.process({
pathToCSV,
headerMapping,
globalLabels,
importLabel,
LabelModel: models.Label,
user: {
email: frame.user.get('email')
}
});
}
},
stats: {
options: [
'days'
],
permissions: {
method: 'browse'
},
validation: {
options: {
days: {
values: ['30', '90', '365', 'all-time']
}
}
},
async query(frame) {
const days = frame.options.days === 'all-time' ? 'all-time' : Number(frame.options.days || 30);
return await membersService.stats.fetch(days);
}
}
};

View file

@ -0,0 +1,29 @@
const membersService = require('../../services/members');
module.exports = {
docName: 'members_stripe_connect',
auth: {
permissions: true,
options: [
'mode'
],
validation: {
options: {
mode: {
values: ['live', 'test']
}
}
},
query(frame) {
// This is something you have to do if you want to use the "framework" with access to the raw req/res
frame.response = async function (req, res) {
function setSessionProp(prop, val) {
req.session[prop] = val;
}
const mode = frame.options.mode || 'live';
const stripeConnectAuthURL = await membersService.stripeConnect.getStripeConnectOAuthUrl(setSessionProp, mode);
return res.redirect(stripeConnectAuthURL);
};
}
}
};

View file

@ -0,0 +1,96 @@
const {notifications} = require('../../services/notifications');
const api = require('./index');
const internalContext = {context: {internal: true}};
module.exports = {
docName: 'notifications',
browse: {
permissions: true,
query(frame) {
return notifications.browse({
user: {
id: frame.user && frame.user.id
}
});
}
},
add: {
statusCode(result) {
if (result.notifications.length) {
return 201;
} else {
return 200;
}
},
permissions: true,
query(frame) {
const {allNotifications, notificationsToAdd} = notifications.add({
notifications: frame.data.notifications
});
if (notificationsToAdd.length){
return api.settings.edit({
settings: [{
key: 'notifications',
// @NOTE: We always need to store all notifications!
value: allNotifications.concat(notificationsToAdd)
}]
}, internalContext).then(() => {
return notificationsToAdd;
});
}
}
},
destroy: {
statusCode: 204,
options: ['notification_id'],
validation: {
options: {
notification_id: {
required: true
}
}
},
permissions: true,
query(frame) {
const allNotifications = notifications.destroy({
notificationId: frame.options.notification_id,
user: {
id: frame.user && frame.user.id
}
});
return api.settings.edit({
settings: [{
key: 'notifications',
value: allNotifications
}]
}, internalContext).return();
}
},
/**
* Clears all notifications. Method used in tests only
*
* @private Not exposed over HTTP
*/
destroyAll: {
statusCode: 204,
permissions: {
method: 'destroy'
},
query() {
const allNotifications = notifications.destroyAll();
return api.settings.edit({
settings: [{
key: 'notifications',
value: allNotifications
}]
}, internalContext).return();
}
}
};

View file

@ -0,0 +1,38 @@
const config = require('../../../shared/config');
const externalRequest = require('../../lib/request-external');
const {i18n} = require('../../lib/common');
const OEmbed = require('../../services/oembed');
const oembed = new OEmbed({config, externalRequest, i18n});
module.exports = {
docName: 'oembed',
read: {
permissions: false,
data: [
'url',
'type'
],
options: [],
query({data}) {
let {url, type} = data;
if (type === 'bookmark') {
return oembed.fetchBookmarkData(url)
.catch(oembed.errorHandler(url));
}
return oembed.fetchOembedData(url).then((response) => {
if (!response && !type) {
return oembed.fetchBookmarkData(url);
}
return response;
}).then((response) => {
if (!response) {
return oembed.unknownProvider(url);
}
return response;
}).catch(oembed.errorHandler(url));
}
}
};

View file

@ -0,0 +1,74 @@
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const models = require('../../models');
const ALLOWED_INCLUDES = ['tags', 'authors'];
module.exports = {
docName: 'pages',
browse: {
options: [
'include',
'filter',
'fields',
'formats',
'absolute_urls',
'page',
'limit',
'order',
'debug'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
formats: {
values: models.Post.allowedFormats
}
}
},
permissions: true,
query(frame) {
return models.Post.findPage(frame.options);
}
},
read: {
options: [
'include',
'fields',
'formats',
'debug',
'absolute_urls'
],
data: [
'id',
'slug',
'uuid'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
formats: {
values: models.Post.allowedFormats
}
}
},
permissions: true,
query(frame) {
return models.Post.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
throw new errors.NotFoundError({
message: i18n.t('errors.api.pages.pageNotFound')
});
}
return model;
});
}
}
};

211
core/server/api/v3/pages.js Normal file
View file

@ -0,0 +1,211 @@
const models = require('../../models');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const urlUtils = require('../../../shared/url-utils');
const ALLOWED_INCLUDES = ['tags', 'authors', 'authors.roles'];
const UNSAFE_ATTRS = ['status', 'authors', 'visibility'];
module.exports = {
docName: 'pages',
browse: {
options: [
'include',
'filter',
'fields',
'formats',
'limit',
'order',
'page',
'debug',
'absolute_urls'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
formats: {
values: models.Post.allowedFormats
}
}
},
permissions: {
docName: 'posts',
unsafeAttrs: UNSAFE_ATTRS
},
query(frame) {
return models.Post.findPage(frame.options);
}
},
read: {
options: [
'include',
'fields',
'formats',
'debug',
'absolute_urls',
// NOTE: only for internal context
'forUpdate',
'transacting'
],
data: [
'id',
'slug',
'uuid'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
formats: {
values: models.Post.allowedFormats
}
}
},
permissions: {
docName: 'posts',
unsafeAttrs: UNSAFE_ATTRS
},
query(frame) {
return models.Post.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
throw new errors.NotFoundError({
message: i18n.t('errors.api.pages.pageNotFound')
});
}
return model;
});
}
},
add: {
statusCode: 201,
headers: {},
options: [
'include',
'formats',
'source'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
source: {
values: ['html']
}
}
},
permissions: {
docName: 'posts',
unsafeAttrs: UNSAFE_ATTRS
},
query(frame) {
return models.Post.add(frame.data.pages[0], frame.options)
.then((model) => {
if (model.get('status') !== 'published') {
this.headers.cacheInvalidate = false;
} else {
this.headers.cacheInvalidate = true;
}
return model;
});
}
},
edit: {
headers: {},
options: [
'include',
'id',
'formats',
'source',
'force_rerender',
// NOTE: only for internal context
'forUpdate',
'transacting'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
id: {
required: true
},
source: {
values: ['html']
}
}
},
permissions: {
docName: 'posts',
unsafeAttrs: UNSAFE_ATTRS
},
query(frame) {
return models.Post.edit(frame.data.pages[0], frame.options)
.then((model) => {
if (
model.get('status') === 'published' && model.wasChanged() ||
model.get('status') === 'draft' && model.previous('status') === 'published'
) {
this.headers.cacheInvalidate = true;
} else if (
model.get('status') === 'draft' && model.previous('status') !== 'published' ||
model.get('status') === 'scheduled' && model.wasChanged()
) {
this.headers.cacheInvalidate = {
value: urlUtils.urlFor({
relativeUrl: urlUtils.urlJoin('/p', model.get('uuid'), '/')
})
};
} else {
this.headers.cacheInvalidate = false;
}
return model;
});
}
},
destroy: {
statusCode: 204,
headers: {
cacheInvalidate: true
},
options: [
'include',
'id'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
id: {
required: true
}
}
},
permissions: {
docName: 'posts',
unsafeAttrs: UNSAFE_ATTRS
},
query(frame) {
frame.options.require = true;
return models.Post.destroy(frame.options)
.then(() => null)
.catch(models.Post.NotFoundError, () => {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.pages.pageNotFound')
}));
});
}
}
};

View file

@ -0,0 +1,74 @@
const models = require('../../models');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const allowedIncludes = ['tags', 'authors'];
module.exports = {
docName: 'posts',
browse: {
options: [
'include',
'filter',
'fields',
'formats',
'limit',
'order',
'page',
'debug',
'absolute_urls'
],
validation: {
options: {
include: {
values: allowedIncludes
},
formats: {
values: models.Post.allowedFormats
}
}
},
permissions: true,
query(frame) {
return models.Post.findPage(frame.options);
}
},
read: {
options: [
'include',
'fields',
'formats',
'debug',
'absolute_urls'
],
data: [
'id',
'slug',
'uuid'
],
validation: {
options: {
include: {
values: allowedIncludes
},
formats: {
values: models.Post.allowedFormats
}
}
},
permissions: true,
query(frame) {
return models.Post.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
throw new errors.NotFoundError({
message: i18n.t('errors.api.posts.postNotFound')
});
}
return model;
});
}
}
};

263
core/server/api/v3/posts.js Normal file
View file

@ -0,0 +1,263 @@
const models = require('../../models');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const urlUtils = require('../../../shared/url-utils');
const {mega} = require('../../services/mega');
const membersService = require('../../services/members');
const allowedIncludes = ['tags', 'authors', 'authors.roles', 'email'];
const unsafeAttrs = ['status', 'authors', 'visibility'];
module.exports = {
docName: 'posts',
browse: {
options: [
'include',
'filter',
'fields',
'formats',
'limit',
'order',
'page',
'debug',
'absolute_urls'
],
validation: {
options: {
include: {
values: allowedIncludes
},
formats: {
values: models.Post.allowedFormats
}
}
},
permissions: {
unsafeAttrs: unsafeAttrs
},
query(frame) {
return models.Post.findPage(frame.options);
}
},
read: {
options: [
'include',
'fields',
'formats',
'debug',
'absolute_urls',
// NOTE: only for internal context
'forUpdate',
'transacting'
],
data: [
'id',
'slug',
'uuid'
],
validation: {
options: {
include: {
values: allowedIncludes
},
formats: {
values: models.Post.allowedFormats
}
}
},
permissions: {
unsafeAttrs: unsafeAttrs
},
query(frame) {
return models.Post.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
throw new errors.NotFoundError({
message: i18n.t('errors.api.posts.postNotFound')
});
}
return model;
});
}
},
add: {
statusCode: 201,
headers: {},
options: [
'include',
'formats',
'source'
],
validation: {
options: {
include: {
values: allowedIncludes
},
source: {
values: ['html']
}
}
},
permissions: {
unsafeAttrs: unsafeAttrs
},
query(frame) {
return models.Post.add(frame.data.posts[0], frame.options)
.then((model) => {
if (model.get('status') !== 'published') {
this.headers.cacheInvalidate = false;
} else {
this.headers.cacheInvalidate = true;
}
return model;
});
}
},
edit: {
headers: {},
options: [
'include',
'id',
'formats',
'source',
'email_recipient_filter',
'send_email_when_published',
'force_rerender',
// NOTE: only for internal context
'forUpdate',
'transacting'
],
validation: {
options: {
include: {
values: allowedIncludes
},
id: {
required: true
},
source: {
values: ['html']
},
email_recipient_filter: {
values: ['none', 'free', 'paid', 'all']
},
send_email_when_published: {
values: [true, false]
}
}
},
permissions: {
unsafeAttrs: unsafeAttrs
},
async query(frame) {
/**Check host limits for members when send email is true**/
if ((frame.options.email_recipient_filter && frame.options.email_recipient_filter !== 'none') || frame.options.send_email_when_published) {
await membersService.checkHostLimit();
}
let model;
if (!frame.options.email_recipient_filter && frame.options.send_email_when_published) {
await models.Base.transaction(async (transacting) => {
const options = {
...frame.options,
transacting
};
/**
* 1. We need to edit the post first in order to know what the visibility is.
* 2. We can only pass the email_recipient_filter when we change the status.
*
* So, we first edit the post as requested, with all information except the status,
* from there we can determine what the email_recipient_filter should be and then finish
* the edit, with the status and the email_recipient_filter option.
*/
const status = frame.data.posts[0].status;
delete frame.data.posts[0].status;
const interimModel = await models.Post.edit(frame.data.posts[0], options);
frame.data.posts[0].status = status;
options.email_recipient_filter = interimModel.get('visibility') === 'paid' ? 'paid' : 'all';
model = await models.Post.edit(frame.data.posts[0], options);
});
} else {
model = await models.Post.edit(frame.data.posts[0], frame.options);
}
/**Handle newsletter email */
if (model.get('email_recipient_filter') !== 'none') {
const postPublished = model.wasChanged() && (model.get('status') === 'published') && (model.previous('status') !== 'published');
if (postPublished) {
let postEmail = model.relations.email;
if (!postEmail) {
const email = await mega.addEmail(model, frame.options);
model.set('email', email);
} else if (postEmail && postEmail.get('status') === 'failed') {
const email = await mega.retryFailedEmail(postEmail);
model.set('email', email);
}
}
}
/**Handle cache invalidation */
if (
model.get('status') === 'published' && model.wasChanged() ||
model.get('status') === 'draft' && model.previous('status') === 'published'
) {
this.headers.cacheInvalidate = true;
} else if (
model.get('status') === 'draft' && model.previous('status') !== 'published' ||
model.get('status') === 'scheduled' && model.wasChanged()
) {
this.headers.cacheInvalidate = {
value: urlUtils.urlFor({
relativeUrl: urlUtils.urlJoin('/p', model.get('uuid'), '/')
})
};
} else {
this.headers.cacheInvalidate = false;
}
return model;
}
},
destroy: {
statusCode: 204,
headers: {
cacheInvalidate: true
},
options: [
'include',
'id'
],
validation: {
options: {
include: {
values: allowedIncludes
},
id: {
required: true
}
}
},
permissions: {
unsafeAttrs: unsafeAttrs
},
query(frame) {
frame.options.require = true;
return models.Post.destroy(frame.options)
.then(() => null)
.catch(models.Post.NotFoundError, () => {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.posts.postNotFound')
}));
});
}
}
};

View file

@ -0,0 +1,42 @@
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const models = require('../../models');
const ALLOWED_INCLUDES = ['authors', 'tags'];
module.exports = {
docName: 'preview',
read: {
permissions: true,
options: [
'include'
],
data: [
'uuid'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
},
data: {
uuid: {
required: true
}
}
},
query(frame) {
return models.Post.findOne(Object.assign({status: 'all'}, frame.data), frame.options)
.then((model) => {
if (!model) {
throw new errors.NotFoundError({
message: i18n.t('errors.api.posts.postNotFound')
});
}
return model;
});
}
}
};

View file

@ -0,0 +1,52 @@
const path = require('path');
const web = require('../../web');
const redirects = require('../../../frontend/services/redirects');
module.exports = {
docName: 'redirects',
download: {
headers: {
disposition: {
type: 'file',
value() {
return redirects.settings.getRedirectsFilePath()
.then((filePath) => {
// TODO: Default file type is .json for backward compatibility.
// When .yaml becomes default or .json is removed at v4,
// This part should be changed.
return filePath === null || path.extname(filePath) === '.json'
? 'redirects.json'
: 'redirects.yaml';
});
}
}
},
permissions: true,
response: {
async format() {
const filePath = await redirects.settings.getRedirectsFilePath();
return filePath === null || path.extname(filePath) === '.json' ? 'json' : 'plain';
}
},
query() {
return redirects.settings.get();
}
},
upload: {
permissions: true,
headers: {
cacheInvalidate: true
},
query(frame) {
return redirects.settings.setFromFilePath(frame.file.path, frame.file.ext)
.then(() => {
// CASE: trigger that redirects are getting re-registered
web.shared.middlewares.customRedirects.reload();
});
}
}
};

View file

@ -0,0 +1,19 @@
const models = require('../../models');
module.exports = {
docName: 'roles',
browse: {
options: [
'permissions'
],
validation: {
options: {
permissions: ['assign']
}
},
permissions: true,
query(frame) {
return models.Role.findAll(frame.options);
}
}
};

View file

@ -0,0 +1,125 @@
const _ = require('lodash');
const moment = require('moment');
const config = require('../../../shared/config');
const models = require('../../models');
const urlUtils = require('../../../shared/url-utils');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const api = require('./index');
module.exports = {
docName: 'schedules',
publish: {
headers: {},
options: [
'id',
'resource'
],
data: [
'force'
],
validation: {
options: {
id: {
required: true
},
resource: {
required: true,
values: ['posts', 'pages']
}
}
},
permissions: {
docName: 'posts'
},
query(frame) {
let resource;
const resourceType = frame.options.resource;
const publishAPostBySchedulerToleranceInMinutes = config.get('times').publishAPostBySchedulerToleranceInMinutes;
const options = {
status: 'scheduled',
id: frame.options.id,
context: {
internal: true
}
};
return api[resourceType].read({id: frame.options.id}, options)
.then((result) => {
resource = result[resourceType][0];
const publishedAtMoment = moment(resource.published_at);
if (publishedAtMoment.diff(moment(), 'minutes') > publishAPostBySchedulerToleranceInMinutes) {
return Promise.reject(new errors.NotFoundError({message: i18n.t('errors.api.job.notFound')}));
}
if (publishedAtMoment.diff(moment(), 'minutes') < publishAPostBySchedulerToleranceInMinutes * -1 && frame.data.force !== true) {
return Promise.reject(new errors.NotFoundError({message: i18n.t('errors.api.job.publishInThePast')}));
}
const editedResource = {};
editedResource[resourceType] = [{
status: 'published',
updated_at: moment(resource.updated_at).toISOString(true)
}];
return api[resourceType].edit(
editedResource,
_.pick(options, ['context', 'id', 'transacting', 'forUpdate'])
);
})
.then((result) => {
const scheduledResource = result[resourceType][0];
if (
(scheduledResource.status === 'published' && resource.status !== 'published') ||
(scheduledResource.status === 'draft' && resource.status === 'published')
) {
this.headers.cacheInvalidate = true;
} else if (
(scheduledResource.status === 'draft' && resource.status !== 'published') ||
(scheduledResource.status === 'scheduled' && resource.status !== 'scheduled')
) {
this.headers.cacheInvalidate = {
value: urlUtils.urlFor({
relativeUrl: urlUtils.urlJoin('/p', scheduledResource.uuid, '/')
})
};
} else {
this.headers.cacheInvalidate = false;
}
return result;
});
}
},
getScheduled: {
// NOTE: this method is for internal use only by DefaultScheduler
// it is not exposed anywhere!
permissions: false,
validation: {
options: {
resource: {
required: true,
values: ['posts', 'pages']
}
}
},
query(frame) {
const resourceModel = 'Post';
const resourceType = (frame.options.resource === 'post') ? 'post' : 'page';
const cleanOptions = {};
cleanOptions.filter = `status:scheduled+type:${resourceType}`;
cleanOptions.columns = ['id', 'published_at', 'created_at', 'type'];
return models[resourceModel].findAll(cleanOptions)
.then((result) => {
let response = {};
response[resourceType] = result;
return response;
});
}
}
};

View file

@ -0,0 +1,66 @@
const Promise = require('bluebird');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const models = require('../../models');
const auth = require('../../services/auth');
const api = require('./index');
const session = {
read(frame) {
/*
* TODO
* Don't query db for user, when new api http wrapper is in we can
* have direct access to req.user, we can also get access to some session
* inofrmation too and send it back
*/
return models.User.findOne({id: frame.options.context.user});
},
add(frame) {
const object = frame.data;
if (!object || !object.username || !object.password) {
return Promise.reject(new errors.UnauthorizedError({
message: i18n.t('errors.middleware.auth.accessDenied')
}));
}
return models.User.check({
email: object.username,
password: object.password
}).then((user) => {
return Promise.resolve((req, res, next) => {
req.brute.reset(function (err) {
if (err) {
return next(err);
}
req.user = user;
auth.session.createSession(req, res, next);
});
});
}).catch(async (err) => {
if (!errors.utils.isIgnitionError(err)) {
throw new errors.UnauthorizedError({
message: i18n.t('errors.middleware.auth.accessDenied'),
err
});
}
if (err.errorType === 'PasswordResetRequiredError') {
await api.authentication.generateResetToken({
passwordreset: [{
email: object.username
}]
}, frame.options.context);
}
throw err;
});
},
delete() {
return Promise.resolve((req, res, next) => {
auth.session.destroySession(req, res, next);
});
}
};
module.exports = session;

View file

@ -0,0 +1,17 @@
const settingsCache = require('../../services/settings/cache');
const urlUtils = require('../../../shared/url-utils');
module.exports = {
docName: 'settings',
browse: {
permissions: true,
query() {
// @TODO: decouple settings cache from API knowledge
// The controller fetches models (or cached models) and the API frame for the target API version formats the response.
return Object.assign({}, settingsCache.getPublic(), {
url: urlUtils.urlFor('home', true)
});
}
}
};

View file

@ -0,0 +1,320 @@
const Promise = require('bluebird');
const _ = require('lodash');
const validator = require('validator');
const models = require('../../models');
const frontendRouting = require('../../../frontend/services/routing');
const frontendSettings = require('../../../frontend/services/settings');
const {i18n} = require('../../lib/common');
const {BadRequestError, NoPermissionError, NotFoundError} = require('@tryghost/errors');
const settingsService = require('../../services/settings');
const settingsCache = require('../../services/settings/cache');
const membersService = require('../../services/members');
module.exports = {
docName: 'settings',
browse: {
options: ['type', 'group'],
permissions: true,
query(frame) {
let settings = settingsCache.getAll();
// CASE: no context passed (functional call)
if (!frame.options.context) {
return Promise.resolve(settings.filter((setting) => {
return setting.group === 'site';
}));
}
// CASE: omit core settings unless internal request
if (!frame.options.context.internal) {
settings = _.filter(settings, (setting) => {
const isCore = setting.group === 'core';
return !isCore;
});
}
return settings;
}
},
read: {
options: ['key'],
validation: {
options: {
key: {
required: true
}
}
},
permissions: {
identifier(frame) {
return frame.options.key;
}
},
query(frame) {
let setting = settingsCache.get(frame.options.key, {resolve: false});
if (!setting) {
return Promise.reject(new NotFoundError({
message: i18n.t('errors.api.settings.problemFindingSetting', {
key: frame.options.key
})
}));
}
// @TODO: handle in settings model permissible fn
if (setting.group === 'core' && !(frame.options.context && frame.options.context.internal)) {
return Promise.reject(new NoPermissionError({
message: i18n.t('errors.api.settings.accessCoreSettingFromExtReq')
}));
}
return {
[frame.options.key]: setting
};
}
},
validateMembersEmailUpdate: {
options: [
'token',
'action'
],
permissions: false,
validation: {
options: {
token: {
required: true
},
action: {
values: ['fromaddressupdate', 'supportaddressupdate']
}
}
},
async query(frame) {
// This is something you have to do if you want to use the "framework" with access to the raw req/res
frame.response = async function (req, res) {
try {
const {token, action} = frame.options;
const updatedEmailAddress = await membersService.settings.getEmailFromToken({token});
const actionToKeyMapping = {
fromAddressUpdate: 'members_from_address',
supportAddressUpdate: 'members_support_address'
};
if (updatedEmailAddress) {
return models.Settings.edit({
key: actionToKeyMapping[action],
value: updatedEmailAddress
}).then(() => {
// Redirect to Ghost-Admin settings page
const adminLink = membersService.settings.getAdminRedirectLink({type: action});
res.redirect(adminLink);
});
} else {
return Promise.reject(new BadRequestError({
message: 'Invalid token!'
}));
}
} catch (err) {
return Promise.reject(new BadRequestError({
err,
message: 'Invalid token!'
}));
}
};
}
},
updateMembersEmail: {
permissions: {
method: 'edit'
},
data: [
'email',
'type'
],
async query(frame) {
const {email, type} = frame.data;
if (typeof email !== 'string' || !validator.isEmail(email)) {
throw new BadRequestError({
message: i18n.t('errors.api.settings.invalidEmailReceived')
});
}
if (!type || !['fromAddressUpdate', 'supportAddressUpdate'].includes(type)) {
throw new BadRequestError({
message: 'Invalid email type recieved'
});
}
try {
// Send magic link to update fromAddress
await membersService.settings.sendEmailAddressUpdateMagicLink({
email,
type
});
} catch (err) {
throw new BadRequestError({
err,
message: i18n.t('errors.mail.failedSendingEmail.error')
});
}
}
},
disconnectStripeConnectIntegration: {
permissions: {
method: 'edit'
},
async query(frame) {
const hasActiveStripeSubscriptions = await membersService.api.hasActiveStripeSubscriptions();
if (hasActiveStripeSubscriptions) {
throw new BadRequestError({
message: 'Cannot disconnect Stripe whilst you have active subscriptions.'
});
}
return models.Settings.edit([{
key: 'stripe_connect_publishable_key',
value: null
}, {
key: 'stripe_connect_secret_key',
value: null
}, {
key: 'stripe_connect_livemode',
value: null
}, {
key: 'stripe_connect_display_name',
value: null
}, {
key: 'stripe_connect_account_id',
value: null
}], frame.options);
}
},
edit: {
headers: {
cacheInvalidate: true
},
permissions: {
unsafeAttrsObject(frame) {
return _.find(frame.data.settings, {key: 'labs'});
},
async before(frame) {
if (frame.options.context && frame.options.context.internal) {
return;
}
const firstCoreSetting = frame.data.settings.find(setting => setting.group === 'core');
if (firstCoreSetting) {
throw new NoPermissionError({
message: i18n.t('errors.api.settings.accessCoreSettingFromExtReq')
});
}
}
},
async query(frame) {
const stripeConnectIntegrationToken = frame.data.settings.find(setting => setting.key === 'stripe_connect_integration_token');
// The `stripe_connect_integration_token` "setting" is only used to set the `stripe_connect_*` settings.
const settings = frame.data.settings.filter((setting) => {
return ![
'stripe_connect_integration_token',
'stripe_connect_publishable_key',
'stripe_connect_secret_key',
'stripe_connect_livemode',
'stripe_connect_account_id',
'stripe_connect_display_name'
].includes(setting.key);
});
const getSetting = setting => settingsCache.get(setting.key, {resolve: false});
const firstUnknownSetting = settings.find(setting => !getSetting(setting));
if (firstUnknownSetting) {
throw new NotFoundError({
message: i18n.t('errors.api.settings.problemFindingSetting', {
key: firstUnknownSetting.key
})
});
}
if (!(frame.options.context && frame.options.context.internal)) {
const firstCoreSetting = settings.find(setting => getSetting(setting).group === 'core');
if (firstCoreSetting) {
throw new NoPermissionError({
message: i18n.t('errors.api.settings.accessCoreSettingFromExtReq')
});
}
}
if (stripeConnectIntegrationToken && stripeConnectIntegrationToken.value) {
const getSessionProp = prop => frame.original.session[prop];
try {
const data = await membersService.stripeConnect.getStripeConnectTokenData(stripeConnectIntegrationToken.value, getSessionProp);
settings.push({
key: 'stripe_connect_publishable_key',
value: data.public_key
});
settings.push({
key: 'stripe_connect_secret_key',
value: data.secret_key
});
settings.push({
key: 'stripe_connect_livemode',
value: data.livemode
});
settings.push({
key: 'stripe_connect_display_name',
value: data.display_name
});
settings.push({
key: 'stripe_connect_account_id',
value: data.account_id
});
} catch (err) {
throw new BadRequestError({
err,
message: 'The Stripe Connect token could not be parsed.'
});
}
}
return models.Settings.edit(settings, frame.options);
}
},
upload: {
headers: {
cacheInvalidate: true
},
permissions: {
method: 'edit'
},
async query(frame) {
await frontendRouting.settings.setFromFilePath(frame.file.path);
const getRoutesHash = () => frontendSettings.getCurrentHash('routes');
await settingsService.syncRoutesHash(getRoutesHash);
}
},
download: {
headers: {
disposition: {
type: 'yaml',
value: 'routes.yaml'
}
},
response: {
format: 'plain'
},
permissions: {
method: 'browse'
},
query() {
return frontendRouting.settings.get();
}
}
};

View file

@ -0,0 +1,25 @@
const ghostVersion = require('../../lib/ghost-version');
const settingsCache = require('../../services/settings/cache');
const urlUtils = require('../../../shared/url-utils');
const site = {
docName: 'site',
read: {
permissions: false,
query() {
const response = {
title: settingsCache.get('title'),
description: settingsCache.get('description'),
logo: settingsCache.get('logo'),
accent_color: settingsCache.get('accent_color'),
url: urlUtils.urlFor('home', true),
version: ghostVersion.safe
};
return response;
}
}
};
module.exports = site;

View file

@ -0,0 +1,11 @@
const {events} = require('../../lib/common');
module.exports = {
docName: 'slack',
sendTest: {
permissions: false,
query() {
events.emit('slack.test');
}
}
};

View file

@ -0,0 +1,47 @@
const models = require('../../models');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const allowedTypes = {
post: models.Post,
tag: models.Tag,
user: models.User
};
module.exports = {
docName: 'slugs',
generate: {
options: [
'include',
'type'
],
data: [
'name'
],
permissions: true,
validation: {
options: {
type: {
required: true,
values: Object.keys(allowedTypes)
}
},
data: {
name: {
required: true
}
}
},
query(frame) {
return models.Base.Model.generateSlug(allowedTypes[frame.options.type], frame.data.name, {status: 'all'})
.then((slug) => {
if (!slug) {
return Promise.reject(new errors.GhostError({
message: i18n.t('errors.api.slugs.couldNotGenerateSlug')
}));
}
return slug;
});
}
}
};

View file

@ -0,0 +1,109 @@
const Promise = require('bluebird');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const models = require('../../models');
module.exports = {
docName: 'snippets',
browse: {
options: [
'limit',
'order',
'page'
],
permissions: true,
query(frame) {
return models.Snippet.findPage(frame.options);
}
},
read: {
headers: {},
data: [
'id'
],
permissions: true,
query(frame) {
return models.Snippet.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.snippets.snippetNotFound')
}));
}
return model;
});
}
},
add: {
statusCode: 201,
headers: {},
permissions: true,
async query(frame) {
try {
return await models.Snippet.add(frame.data.snippets[0], frame.options);
} catch (error) {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
throw new errors.ValidationError({message: i18n.t('errors.api.snippets.snippetAlreadyExists')});
}
throw error;
}
}
},
edit: {
headers: {},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
query(frame) {
return models.Snippet.edit(frame.data.snippets[0], frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.snippets.snippetNotFound')
}));
}
return model;
});
}
},
destroy: {
statusCode: 204,
headers: {},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
query(frame) {
return models.Snippet.destroy(frame.options)
.then(() => null)
.catch(models.Snippet.NotFoundError, () => {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.snippets.snippetNotFound')
}));
});
}
}
};

View file

@ -0,0 +1,67 @@
const Promise = require('bluebird');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const models = require('../../models');
const ALLOWED_INCLUDES = ['count.posts'];
module.exports = {
docName: 'tags',
browse: {
options: [
'include',
'filter',
'fields',
'limit',
'order',
'page',
'debug'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.TagPublic.findPage(frame.options);
}
},
read: {
options: [
'include',
'filter',
'fields',
'debug'
],
data: [
'id',
'slug',
'visibility'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.TagPublic.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.tags.tagNotFound')
}));
}
return model;
});
}
}
};

155
core/server/api/v3/tags.js Normal file
View file

@ -0,0 +1,155 @@
const Promise = require('bluebird');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const models = require('../../models');
const ALLOWED_INCLUDES = ['count.posts'];
module.exports = {
docName: 'tags',
browse: {
options: [
'include',
'filter',
'fields',
'limit',
'order',
'page',
'debug'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.Tag.findPage(frame.options);
}
},
read: {
options: [
'include',
'filter',
'fields',
'debug'
],
data: [
'id',
'slug',
'visibility'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.Tag.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.tags.tagNotFound')
}));
}
return model;
});
}
},
add: {
statusCode: 201,
headers: {
cacheInvalidate: true
},
options: [
'include'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.Tag.add(frame.data.tags[0], frame.options);
}
},
edit: {
headers: {},
options: [
'id',
'include'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
id: {
required: true
}
}
},
permissions: true,
query(frame) {
return models.Tag.edit(frame.data.tags[0], frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.tags.tagNotFound')
}));
}
if (model.wasChanged()) {
this.headers.cacheInvalidate = true;
} else {
this.headers.cacheInvalidate = false;
}
return model;
});
}
},
destroy: {
statusCode: 204,
headers: {
cacheInvalidate: true
},
options: [
'id'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
id: {
required: true
}
}
},
permissions: true,
query(frame) {
return models.Tag.destroy(frame.options)
.then(() => null)
.catch(models.Tag.NotFoundError, () => {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.tags.tagNotFound')
}));
});
}
}
};

View file

@ -0,0 +1,118 @@
const {events} = require('../../lib/common');
const themeService = require('../../../frontend/services/themes');
const models = require('../../models');
module.exports = {
docName: 'themes',
browse: {
permissions: true,
query() {
return themeService.getJSON();
}
},
activate: {
headers: {
cacheInvalidate: true
},
options: [
'name'
],
validation: {
options: {
name: {
required: true
}
}
},
permissions: true,
query(frame) {
let themeName = frame.options.name;
const newSettings = [{
key: 'active_theme',
value: themeName
}];
return themeService.activate(themeName)
.then((checkedTheme) => {
// @NOTE: we use the model, not the API here, as we don't want to trigger permissions
return models.Settings.edit(newSettings, frame.options)
.then(() => checkedTheme);
})
.then((checkedTheme) => {
return themeService.getJSON(themeName, checkedTheme);
});
}
},
upload: {
headers: {},
permissions: {
method: 'add'
},
query(frame) {
// @NOTE: consistent filename uploads
frame.options.originalname = frame.file.originalname.toLowerCase();
let zip = {
path: frame.file.path,
name: frame.file.originalname
};
return themeService.storage.setFromZip(zip)
.then(({theme, themeOverridden}) => {
if (themeOverridden) {
// CASE: clear cache
this.headers.cacheInvalidate = true;
}
events.emit('theme.uploaded');
return theme;
});
}
},
download: {
options: [
'name'
],
validation: {
options: {
name: {
required: true
}
}
},
permissions: {
method: 'read'
},
query(frame) {
let themeName = frame.options.name;
return themeService.storage.getZip(themeName);
}
},
destroy: {
statusCode: 204,
headers: {
cacheInvalidate: true
},
options: [
'name'
],
validation: {
options: {
name: {
required: true
}
}
},
permissions: true,
query(frame) {
let themeName = frame.options.name;
return themeService.storage.destroy(themeName);
}
}
};

234
core/server/api/v3/users.js Normal file
View file

@ -0,0 +1,234 @@
const Promise = require('bluebird');
const {i18n} = require('../../lib/common');
const errors = require('@tryghost/errors');
const models = require('../../models');
const permissionsService = require('../../services/permissions');
const dbBackup = require('../../data/db/backup');
const UsersService = require('../../services/users');
const userService = new UsersService({dbBackup, models});
const ALLOWED_INCLUDES = ['count.posts', 'permissions', 'roles', 'roles.permissions'];
const UNSAFE_ATTRS = ['status', 'roles'];
function permissionOnlySelf(frame) {
const targetId = getTargetId(frame);
const userId = frame.user.id;
if (targetId !== userId) {
return Promise.reject(new errors.NoPermissionError({message: i18n.t('errors.permissions.noPermissionToAction')}));
}
return Promise.resolve();
}
function getTargetId(frame) {
return frame.options.id === 'me' ? frame.user.id : frame.options.id;
}
async function fetchOrCreatePersonalToken(userId) {
const token = await models.ApiKey.findOne({user_id: userId}, {});
if (!token) {
const newToken = await models.ApiKey.add({user_id: userId, type: 'admin'});
return newToken;
}
return token;
}
module.exports = {
docName: 'users',
browse: {
options: [
'include',
'filter',
'fields',
'limit',
'order',
'page',
'debug'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.User.findPage(frame.options);
}
},
read: {
options: [
'include',
'filter',
'fields',
'debug'
],
data: [
'id',
'slug',
'email',
'role'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
permissions: true,
query(frame) {
return models.User.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.users.userNotFound')
}));
}
return model;
});
}
},
edit: {
headers: {},
options: [
'id',
'include'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
id: {
required: true
}
}
},
permissions: {
unsafeAttrs: UNSAFE_ATTRS
},
query(frame) {
return models.User.edit(frame.data.users[0], frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new errors.NotFoundError({
message: i18n.t('errors.api.users.userNotFound')
}));
}
if (model.wasChanged()) {
this.headers.cacheInvalidate = true;
} else {
this.headers.cacheInvalidate = false;
}
return model;
});
}
},
destroy: {
headers: {
cacheInvalidate: true
},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
async query(frame) {
return userService.destroyUser(frame.options).catch((err) => {
return Promise.reject(new errors.NoPermissionError({
err: err
}));
});
}
},
changePassword: {
validation: {
docName: 'password',
data: {
newPassword: {required: true},
ne2Password: {required: true},
user_id: {required: true}
}
},
permissions: {
docName: 'user',
method: 'edit',
identifier(frame) {
return frame.data.password[0].user_id;
}
},
query(frame) {
frame.options.skipSessionID = frame.original.session.id;
return models.User.changePassword(frame.data.password[0], frame.options);
}
},
transferOwnership: {
permissions(frame) {
return models.Role.findOne({name: 'Owner'})
.then((ownerRole) => {
return permissionsService.canThis(frame.options.context).assign.role(ownerRole);
});
},
query(frame) {
return models.User.transferOwnership(frame.data.owner[0], frame.options);
}
},
readToken: {
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: permissionOnlySelf,
query(frame) {
const targetId = getTargetId(frame);
return fetchOrCreatePersonalToken(targetId);
}
},
regenerateToken: {
headers: {
cacheInvalidate: true
},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: permissionOnlySelf,
query(frame) {
const targetId = getTargetId(frame);
return fetchOrCreatePersonalToken(targetId).then((model) => {
return models.ApiKey.refreshSecret(model.toJSON(), Object.assign({}, {id: model.id}));
});
}
}
};

View file

@ -0,0 +1,34 @@
module.exports = {
get permissions() {
return require('./permissions');
},
get serializers() {
return require('./serializers');
},
get validators() {
return require('./validators');
},
/**
* @description Does the request access the Content API?
*
* Each controller is either for the Content or for the Admin API.
* When Ghost registers each controller, it currently passes a String "content" if the controller
* is a Content API implementation - see index.js file.
*
* @TODO: Move this helper function into a utils.js file.
* @param {Object} frame
* @return {boolean}
*/
isContentAPI: (frame) => {
return frame.apiType === 'content';
},
// @TODO: Remove, not used.
isAdminAPIKey: (frame) => {
return frame.options.context && Object.keys(frame.options.context).length !== 0 && frame.options.context.api_key &&
frame.options.context.api_key.type === 'admin';
}
};

View file

@ -0,0 +1,112 @@
const debug = require('ghost-ignition').debug('api:v3:utils:permissions');
const Promise = require('bluebird');
const _ = require('lodash');
const permissions = require('../../../services/permissions');
const {i18n} = require('../../../lib/common');
const errors = require('@tryghost/errors');
/**
* @description Handle requests, which need authentication.
*
* @param {Object} apiConfig - Docname & method of API ctrl
* @param {Object} frame
* @return {Promise}
*/
const nonePublicAuth = (apiConfig, frame) => {
debug('check admin permissions');
let singular;
if (apiConfig.docName.match(/ies$/)) {
singular = apiConfig.docName.replace(/ies$/, 'y');
} else {
singular = apiConfig.docName.replace(/s$/, '');
}
let permissionIdentifier = frame.options.id;
// CASE: Target ctrl can override the identifier. The identifier is the unique identifier of the target resource
// e.g. edit a setting -> the key of the setting
// e.g. edit a post -> post id from url param
// e.g. change user password -> user id inside of the body structure
if (apiConfig.identifier) {
permissionIdentifier = apiConfig.identifier(frame);
}
let unsafeAttrObject = apiConfig.unsafeAttrs && _.has(frame, `data.[${apiConfig.docName}][0]`) ? _.pick(frame.data[apiConfig.docName][0], apiConfig.unsafeAttrs) : {};
if (apiConfig.unsafeAttrsObject) {
unsafeAttrObject = apiConfig.unsafeAttrsObject(frame);
}
const permsPromise = permissions.canThis(frame.options.context)[apiConfig.method][singular](permissionIdentifier, unsafeAttrObject);
return permsPromise.then((result) => {
/*
* Allow the permissions function to return a list of excluded attributes.
* If it does, omit those attrs from the data passed through
*
* NOTE: excludedAttrs differ from unsafeAttrs in that they're determined by the model's permissible function,
* and the attributes are simply excluded rather than throwing a NoPermission exception
*
* TODO: This is currently only needed because of the posts model and the contributor role. Once we extend the
* contributor role to be able to edit existing tags, this concept can be removed.
*/
if (result && result.excludedAttrs && _.has(frame, `data.[${apiConfig.docName}][0]`)) {
frame.data[apiConfig.docName][0] = _.omit(frame.data[apiConfig.docName][0], result.excludedAttrs);
}
}).catch((err) => {
if (err instanceof errors.NoPermissionError) {
err.message = i18n.t('errors.api.utils.noPermissionToCall', {
method: apiConfig.method,
docName: apiConfig.docName
});
return Promise.reject(err);
}
if (errors.utils.isIgnitionError(err)) {
return Promise.reject(err);
}
return Promise.reject(new errors.GhostError({
err: err
}));
});
};
// @TODO: https://github.com/TryGhost/Ghost/issues/10735
module.exports = {
/**
* @description Handle permission stage for v3 API.
*
* @param {Object} apiConfig - Docname & method of target ctrl.
* @param {Object} frame
* @return {Promise}
*/
handle(apiConfig, frame) {
debug('handle');
// @TODO: https://github.com/TryGhost/Ghost/issues/10099
frame.options.context = permissions.parseContext(frame.options.context);
// CASE: Content API access
if (frame.options.context.public) {
debug('check content permissions');
// @TODO: Remove when we drop v0.1
// @TODO: https://github.com/TryGhost/Ghost/issues/10733
return permissions.applyPublicRules(apiConfig.docName, apiConfig.method, {
status: frame.options.status,
id: frame.options.id,
uuid: frame.options.uuid,
slug: frame.options.slug,
data: {
status: frame.data.status,
id: frame.data.id,
uuid: frame.data.uuid,
slug: frame.data.slug
}
});
}
return nonePublicAuth(apiConfig, frame);
}
};

View file

@ -0,0 +1,9 @@
module.exports = {
get input() {
return require('./input');
},
get output() {
return require('./output');
}
};

View file

@ -0,0 +1,31 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:input:authors');
const slugFilterOrder = require('./utils/slug-filter-order');
const utils = require('../../index');
function setDefaultOrder(frame) {
if (!frame.options.order && frame.options.filter) {
frame.options.autoOrder = slugFilterOrder('users', frame.options.filter);
}
if (!frame.options.order && !frame.options.autoOrder) {
frame.options.order = 'name asc';
}
}
module.exports = {
browse(apiConfig, frame) {
debug('browse');
if (utils.isContentAPI(frame)) {
setDefaultOrder(frame);
}
},
read(apiConfig, frame) {
debug('read');
if (utils.isContentAPI(frame)) {
setDefaultOrder(frame);
}
}
};

View file

@ -0,0 +1,20 @@
const _ = require('lodash');
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:input:db');
const optionsUtil = require('../../../../shared/utils/options');
const INTERNAL_OPTIONS = ['transacting', 'forUpdate'];
module.exports = {
all(apiConfig, frame) {
debug('serialize all');
if (frame.options.include) {
frame.options.include = optionsUtil.trimAndLowerCase(frame.options.include);
}
if (!frame.options.context.internal) {
debug('omit internal options');
frame.options = _.omit(frame.options, INTERNAL_OPTIONS);
}
}
};

View file

@ -0,0 +1,41 @@
module.exports = {
get db() {
return require('./db');
},
get integrations() {
return require('./integrations');
},
get pages() {
return require('./pages');
},
get posts() {
return require('./posts');
},
get settings() {
return require('./settings');
},
get users() {
return require('./users');
},
get authors() {
return require('./authors');
},
get tags() {
return require('./tags');
},
get members() {
return require('./members');
},
get webhooks() {
return require('./webhooks');
}
};

View file

@ -0,0 +1,33 @@
const _ = require('lodash');
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:input:integrations');
function setDefaultFilter(frame) {
if (frame.options.filter) {
frame.options.filter = `(${frame.options.filter})+type:[custom,builtin]`;
} else {
frame.options.filter = 'type:[custom,builtin]';
}
}
module.exports = {
browse(apiConfig, frame) {
debug('browse');
setDefaultFilter(frame);
},
read(apiConfig, frame) {
debug('read');
setDefaultFilter(frame);
},
add(apiConfig, frame) {
debug('add');
frame.data = _.pick(frame.data.integrations[0], apiConfig.data);
},
edit(apiConfig, frame) {
debug('edit');
frame.data = _.pick(frame.data.integrations[0], apiConfig.data);
}
};

View file

@ -0,0 +1,62 @@
const _ = require('lodash');
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:input:members');
function defaultRelations(frame) {
if (frame.options.withRelated) {
return;
}
if (frame.options.columns && !frame.options.withRelated) {
return false;
}
frame.options.withRelated = ['labels'];
}
module.exports = {
browse(apiConfig, frame) {
debug('browse');
defaultRelations(frame);
},
read() {
debug('read');
this.browse(...arguments);
},
add(apiConfig, frame) {
debug('add');
if (frame.data.members[0].labels) {
frame.data.members[0].labels.forEach((label, index) => {
if (_.isString(label)) {
frame.data.members[0].labels[index] = {
name: label
};
}
});
}
defaultRelations(frame);
},
edit(apiConfig, frame) {
debug('edit');
this.add(apiConfig, frame);
},
async importCSV(apiConfig, frame) {
debug('importCSV');
if (!frame.data.labels) {
frame.data.labels = [];
return;
}
if (typeof frame.data.labels === 'string') {
frame.data.labels = [{name: frame.data.labels}];
return;
}
if (Array.isArray(frame.data.labels)) {
frame.data.labels = frame.data.labels.map(name => ({name}));
return;
}
}
};

View file

@ -0,0 +1,208 @@
const _ = require('lodash');
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:input:pages');
const mapNQLKeyValues = require('@nexes/nql').utils.mapKeyValues;
const mobiledoc = require('../../../../../lib/mobiledoc');
const url = require('./utils/url');
const slugFilterOrder = require('./utils/slug-filter-order');
const localUtils = require('../../index');
const postsMetaSchema = require('../../../../../data/schema').tables.posts_meta;
const replacePageWithType = mapNQLKeyValues({
key: {
from: 'page',
to: 'type'
},
values: [{
from: false,
to: 'post'
}, {
from: true,
to: 'page'
}]
});
function removeMobiledocFormat(frame) {
if (frame.options.formats && frame.options.formats.includes('mobiledoc')) {
frame.options.formats = frame.options.formats.filter((format) => {
return (format !== 'mobiledoc');
});
}
}
function defaultRelations(frame) {
if (frame.options.withRelated) {
return;
}
if (frame.options.columns && !frame.options.withRelated) {
return false;
}
frame.options.withRelated = ['tags', 'authors', 'authors.roles'];
}
function setDefaultOrder(frame) {
let includesOrderedRelations = false;
if (frame.options.withRelated) {
const orderedRelations = ['author', 'authors', 'tag', 'tags'];
includesOrderedRelations = _.intersection(orderedRelations, frame.options.withRelated).length > 0;
}
if (!frame.options.order && !includesOrderedRelations && frame.options.filter) {
frame.options.autoOrder = slugFilterOrder('posts', frame.options.filter);
}
if (!frame.options.order && !frame.options.autoOrder && !includesOrderedRelations) {
frame.options.order = 'title asc';
}
}
function forceVisibilityColumn(frame) {
if (frame.options.columns && !frame.options.columns.includes('visibility')) {
frame.options.columns.push('visibility');
}
}
function defaultFormat(frame) {
if (frame.options.formats) {
return;
}
frame.options.formats = 'mobiledoc';
}
function handlePostsMeta(frame) {
let metaAttrs = _.keys(_.omit(postsMetaSchema, ['id', 'post_id']));
let meta = _.pick(frame.data.pages[0], metaAttrs);
frame.data.pages[0].posts_meta = meta;
}
/**
* CASE:
*
* - the content api endpoints for pages forces the model layer to return static pages only
* - we have to enforce the filter
*
* @TODO: https://github.com/TryGhost/Ghost/issues/10268
*/
const forcePageFilter = (frame) => {
if (frame.options.filter) {
frame.options.filter = `(${frame.options.filter})+type:page`;
} else {
frame.options.filter = 'type:page';
}
};
const forceStatusFilter = (frame) => {
if (!frame.options.filter) {
frame.options.filter = 'status:[draft,published,scheduled]';
} else if (!frame.options.filter.match(/status:/)) {
frame.options.filter = `(${frame.options.filter})+status:[draft,published,scheduled]`;
}
};
module.exports = {
browse(apiConfig, frame) {
debug('browse');
forcePageFilter(frame);
if (localUtils.isContentAPI(frame)) {
removeMobiledocFormat(frame);
setDefaultOrder(frame);
forceVisibilityColumn(frame);
}
if (!localUtils.isContentAPI(frame)) {
forceStatusFilter(frame);
defaultFormat(frame);
defaultRelations(frame);
}
frame.options.mongoTransformer = replacePageWithType;
},
read(apiConfig, frame) {
debug('read');
forcePageFilter(frame);
if (localUtils.isContentAPI(frame)) {
removeMobiledocFormat(frame);
setDefaultOrder(frame);
forceVisibilityColumn(frame);
}
if (!localUtils.isContentAPI(frame)) {
forceStatusFilter(frame);
defaultFormat(frame);
defaultRelations(frame);
}
},
add(apiConfig, frame, options = {add: true}) {
debug('add');
if (_.get(frame,'options.source')) {
const html = frame.data.pages[0].html;
if (frame.options.source === 'html' && !_.isEmpty(html)) {
frame.data.pages[0].mobiledoc = JSON.stringify(mobiledoc.htmlToMobiledocConverter(html));
}
}
frame.data.pages[0] = url.forPost(Object.assign({}, frame.data.pages[0]), frame.options);
// @NOTE: force storing page
if (options.add) {
frame.data.pages[0].type = 'page';
}
// CASE: Transform short to long format
if (frame.data.pages[0].authors) {
frame.data.pages[0].authors.forEach((author, index) => {
if (_.isString(author)) {
frame.data.pages[0].authors[index] = {
email: author
};
}
});
}
if (frame.data.pages[0].tags) {
frame.data.pages[0].tags.forEach((tag, index) => {
if (_.isString(tag)) {
frame.data.pages[0].tags[index] = {
name: tag
};
}
});
}
handlePostsMeta(frame);
defaultFormat(frame);
defaultRelations(frame);
},
edit(apiConfig, frame) {
debug('edit');
this.add(...arguments, {add: false});
handlePostsMeta(frame);
forceStatusFilter(frame);
forcePageFilter(frame);
},
destroy(apiConfig, frame) {
debug('destroy');
frame.options.destroyBy = {
id: frame.options.id,
type: 'page'
};
defaultFormat(frame);
defaultRelations(frame);
}
};

View file

@ -0,0 +1,231 @@
const _ = require('lodash');
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:input:posts');
const mapNQLKeyValues = require('@nexes/nql').utils.mapKeyValues;
const url = require('./utils/url');
const slugFilterOrder = require('./utils/slug-filter-order');
const localUtils = require('../../index');
const mobiledoc = require('../../../../../lib/mobiledoc');
const postsMetaSchema = require('../../../../../data/schema').tables.posts_meta;
const replacePageWithType = mapNQLKeyValues({
key: {
from: 'page',
to: 'type'
},
values: [{
from: false,
to: 'post'
}, {
from: true,
to: 'page'
}]
});
function removeMobiledocFormat(frame) {
if (frame.options.formats && frame.options.formats.includes('mobiledoc')) {
frame.options.formats = frame.options.formats.filter((format) => {
return (format !== 'mobiledoc');
});
}
}
function defaultRelations(frame) {
if (frame.options.withRelated) {
return;
}
if (frame.options.columns && !frame.options.withRelated) {
return false;
}
frame.options.withRelated = ['tags', 'authors', 'authors.roles', 'email'];
}
function setDefaultOrder(frame) {
let includesOrderedRelations = false;
if (frame.options.withRelated) {
const orderedRelations = ['author', 'authors', 'tag', 'tags'];
includesOrderedRelations = _.intersection(orderedRelations, frame.options.withRelated).length > 0;
}
if (!frame.options.order && !includesOrderedRelations && frame.options.filter) {
frame.options.autoOrder = slugFilterOrder('posts', frame.options.filter);
}
if (!frame.options.order && !frame.options.autoOrder && !includesOrderedRelations) {
frame.options.order = 'published_at desc';
}
}
function forceVisibilityColumn(frame) {
if (frame.options.columns && !frame.options.columns.includes('visibility')) {
frame.options.columns.push('visibility');
}
}
function defaultFormat(frame) {
if (frame.options.formats) {
return;
}
frame.options.formats = 'mobiledoc';
}
function handlePostsMeta(frame) {
let metaAttrs = _.keys(_.omit(postsMetaSchema, ['id', 'post_id']));
let meta = _.pick(frame.data.posts[0], metaAttrs);
frame.data.posts[0].posts_meta = meta;
}
/**
* CASE:
*
* - posts endpoint only returns posts, not pages
* - we have to enforce the filter
*
* @TODO: https://github.com/TryGhost/Ghost/issues/10268
*/
const forcePageFilter = (frame) => {
if (frame.options.filter) {
frame.options.filter = `(${frame.options.filter})+type:post`;
} else {
frame.options.filter = 'type:post';
}
};
const forceStatusFilter = (frame) => {
if (!frame.options.filter) {
frame.options.filter = 'status:[draft,published,scheduled]';
} else if (!frame.options.filter.match(/status:/)) {
frame.options.filter = `(${frame.options.filter})+status:[draft,published,scheduled]`;
}
};
module.exports = {
browse(apiConfig, frame) {
debug('browse');
forcePageFilter(frame);
if (frame.options.columns && frame.options.columns.includes('send_email_when_published')) {
frame.options.columns.push('email_recipient_filter');
}
/**
* ## current cases:
* - context object is empty (functional call, content api access)
* - api_key.type == 'content' ? content api access
* - user exists? admin api access
*/
if (localUtils.isContentAPI(frame)) {
// CASE: the content api endpoint for posts should not return mobiledoc
removeMobiledocFormat(frame);
setDefaultOrder(frame);
forceVisibilityColumn(frame);
}
if (!localUtils.isContentAPI(frame)) {
forceStatusFilter(frame);
defaultFormat(frame);
defaultRelations(frame);
}
frame.options.mongoTransformer = replacePageWithType;
},
read(apiConfig, frame) {
debug('read');
forcePageFilter(frame);
if (frame.options.columns && frame.options.columns.includes('send_email_when_published')) {
frame.options.columns.push('email_recipient_filter');
}
/**
* ## current cases:
* - context object is empty (functional call, content api access)
* - api_key.type == 'content' ? content api access
* - user exists? admin api access
*/
if (localUtils.isContentAPI(frame)) {
// CASE: the content api endpoint for posts should not return mobiledoc
removeMobiledocFormat(frame);
setDefaultOrder(frame);
forceVisibilityColumn(frame);
}
if (!localUtils.isContentAPI(frame)) {
forceStatusFilter(frame);
defaultFormat(frame);
defaultRelations(frame);
}
},
add(apiConfig, frame, options = {add: true}) {
debug('add');
if (_.get(frame,'options.source')) {
const html = frame.data.posts[0].html;
if (frame.options.source === 'html' && !_.isEmpty(html)) {
frame.data.posts[0].mobiledoc = JSON.stringify(mobiledoc.htmlToMobiledocConverter(html));
}
}
frame.data.posts[0] = url.forPost(Object.assign({}, frame.data.posts[0]), frame.options);
// @NOTE: force adding post
if (options.add) {
frame.data.posts[0].type = 'post';
}
// CASE: Transform short to long format
if (frame.data.posts[0].authors) {
frame.data.posts[0].authors.forEach((author, index) => {
if (_.isString(author)) {
frame.data.posts[0].authors[index] = {
email: author
};
}
});
}
if (frame.data.posts[0].tags) {
frame.data.posts[0].tags.forEach((tag, index) => {
if (_.isString(tag)) {
frame.data.posts[0].tags[index] = {
name: tag
};
}
});
}
handlePostsMeta(frame);
defaultFormat(frame);
defaultRelations(frame);
},
edit(apiConfig, frame) {
debug('edit');
this.add(apiConfig, frame, {add: false});
handlePostsMeta(frame);
forceStatusFilter(frame);
forcePageFilter(frame);
},
destroy(apiConfig, frame) {
debug('destroy');
frame.options.destroyBy = {
id: frame.options.id,
type: 'post'
};
defaultFormat(frame);
defaultRelations(frame);
}
};

View file

@ -0,0 +1,99 @@
const _ = require('lodash');
const url = require('./utils/url');
const typeGroupMapper = require('../../../../shared/serializers/input/utils/settings-filter-type-group-mapper');
const settingsCache = require('../../../../../services/settings/cache');
module.exports = {
browse(apiConfig, frame) {
if (frame.options.type) {
let mappedGroupOptions = typeGroupMapper(frame.options.type);
if (frame.options.group) {
frame.options.group = `${frame.options.group},${mappedGroupOptions}`;
} else {
frame.options.group = mappedGroupOptions;
}
}
},
read(apiConfig, frame) {
if (frame.options.key === 'ghost_head') {
frame.options.key = 'codeinjection_head';
}
if (frame.options.key === 'ghost_foot') {
frame.options.key = 'codeinjection_foot';
}
if (frame.options.key === 'active_timezone') {
frame.options.key = 'timezone';
}
if (frame.options.key === 'default_locale') {
frame.options.key = 'lang';
}
},
edit(apiConfig, frame) {
// CASE: allow shorthand syntax where a single key and value are passed to edit instead of object and options
if (_.isString(frame.data)) {
frame.data = {settings: [{key: frame.data, value: frame.options}]};
}
const settings = settingsCache.getAll();
// Ignore and drop all values with Read-only flag
frame.data.settings = frame.data.settings.filter((setting) => {
const settingFlagsStr = settings[setting.key] ? settings[setting.key].flags : '';
const settingFlagsArr = settingFlagsStr ? settingFlagsStr.split(',') : [];
return !settingFlagsArr.includes('RO');
});
frame.data.settings = frame.data.settings.filter((setting) => {
return setting.key !== 'bulk_email_settings';
});
frame.data.settings.forEach((setting) => {
// CASE: transform objects/arrays into string (we store stringified objects in the db)
// @TODO: This belongs into the model layer. We should stringify before saving and parse when fetching from db.
// @TODO: Fix when dropping v0.1
const settingType = settings[setting.key] ? settings[setting.key].type : '';
//TODO: Needs to be removed once we get rid of all `object` type settings
if (_.isObject(setting.value)) {
setting.value = JSON.stringify(setting.value);
}
// @TODO: handle these transformations in a centralised API place (these rules should apply for ALL resources)
// CASE: Ensure we won't forward strings, otherwise model events or model interactions can fail
if (settingType === 'boolean' && (setting.value === '0' || setting.value === '1')) {
setting.value = !!+setting.value;
}
// CASE: Ensure we won't forward strings, otherwise model events or model interactions can fail
if (settingType === 'boolean' && (setting.value === 'false' || setting.value === 'true')) {
setting.value = setting.value === 'true';
}
if (setting.key === 'ghost_head') {
setting.key = 'codeinjection_head';
}
if (setting.key === 'ghost_foot') {
setting.key = 'codeinjection_foot';
}
if (setting.key === 'active_timezone') {
setting.key = 'timezone';
}
if (setting.key === 'default_locale') {
setting.key = 'lang';
}
if (['cover_image', 'icon', 'logo', 'portal_button_icon'].includes(setting.key)) {
setting = url.forSetting(setting);
}
});
}
};

View file

@ -0,0 +1,42 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:input:tags');
const url = require('./utils/url');
const slugFilterOrder = require('./utils/slug-filter-order');
const utils = require('../../index');
function setDefaultOrder(frame) {
let defaultOrder = 'name asc';
if (!frame.options.order && frame.options.filter) {
frame.options.autoOrder = slugFilterOrder('tags', frame.options.filter);
}
if (!frame.options.order && !frame.options.autoOrder) {
frame.options.order = defaultOrder;
}
}
module.exports = {
browse(apiConfig, frame) {
debug('browse');
if (utils.isContentAPI(frame)) {
setDefaultOrder(frame);
}
},
read() {
debug('read');
this.browse(...arguments);
},
add(apiConfig, frame) {
debug('add');
frame.data.tags[0] = url.forTag(Object.assign({}, frame.data.tags[0]));
},
edit(apiConfig, frame) {
debug('edit');
this.add(apiConfig, frame);
}
};

View file

@ -0,0 +1,26 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:input:users');
const url = require('./utils/url');
module.exports = {
read(apiConfig, frame) {
debug('read');
if (frame.data.id === 'me' && frame.options.context && frame.options.context.user) {
frame.data.id = frame.options.context.user;
}
},
edit(apiConfig, frame) {
debug('edit');
if (frame.options.id === 'me' && frame.options.context && frame.options.context.user) {
frame.options.id = frame.options.context.user;
}
if (frame.data.users[0].password) {
delete frame.data.users[0].password;
}
frame.data.users[0] = url.forUser(Object.assign({}, frame.data.users[0]));
}
};

View file

@ -0,0 +1,18 @@
const slugFilterOrder = (table, filter) => {
let orderMatch = filter.match(/slug:\s?\[(.*)\]/);
if (orderMatch) {
let orderSlugs = orderMatch[1].split(',');
let order = 'CASE ';
orderSlugs.forEach((slug, index) => {
order += `WHEN \`${table}\`.\`slug\` = '${slug}' THEN ${index} `;
});
order += 'END ASC';
return order;
}
};
module.exports = slugFilterOrder;

View file

@ -0,0 +1,66 @@
const urlUtils = require('../../../../../../../shared/url-utils');
const handleImageUrl = (imageUrl) => {
const blogDomain = urlUtils.getSiteUrl().replace(/^http(s?):\/\//, '').replace(/\/$/, '');
const imageUrlAbsolute = imageUrl.replace(/^http(s?):\/\//, '');
const imagePathRe = new RegExp(`^${blogDomain}/${urlUtils.STATIC_IMAGE_URL_PREFIX}`);
if (imagePathRe.test(imageUrlAbsolute)) {
return urlUtils.absoluteToRelative(imageUrl);
}
return imageUrl;
};
const forPost = (attrs, options) => {
if (options && options.withRelated) {
options.withRelated.forEach((relation) => {
if (relation === 'tags' && attrs.tags) {
attrs.tags = attrs.tags.map(tag => forTag(tag));
}
if (relation === 'author' && attrs.author) {
attrs.author = forUser(attrs.author, options);
}
if (relation === 'authors' && attrs.authors) {
attrs.authors = attrs.authors.map(author => forUser(author, options));
}
});
}
return attrs;
};
const forUser = (attrs) => {
if (attrs.profile_image) {
attrs.profile_image = handleImageUrl(attrs.profile_image);
}
if (attrs.cover_image) {
attrs.cover_image = handleImageUrl(attrs.cover_image);
}
return attrs;
};
const forTag = (attrs) => {
if (attrs.feature_image) {
attrs.feature_image = handleImageUrl(attrs.feature_image);
}
return attrs;
};
const forSetting = (attrs) => {
if (attrs.value) {
attrs.value = handleImageUrl(attrs.value);
}
return attrs;
};
module.exports.forPost = forPost;
module.exports.forUser = forUser;
module.exports.forTag = forTag;
module.exports.forSetting = forSetting;

View file

@ -0,0 +1,12 @@
const _ = require('lodash');
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:input:webhooks');
module.exports = {
add(apiConfig, frame) {
debug('add');
if (_.get(frame, 'options.context.integration.id')) {
frame.data.webhooks[0].integration_id = frame.options.context.integration.id;
}
}
};

View file

@ -0,0 +1,13 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:actions');
const mapper = require('./utils/mapper');
module.exports = {
browse(models, apiConfig, frame) {
debug('browse');
frame.response = {
actions: models.data.map(model => mapper.mapAction(model, frame)),
meta: models.meta
};
}
};

View file

@ -0,0 +1,25 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:all');
const _ = require('lodash');
const removeXBY = (object) => {
_.each(object, (value, key) => {
// CASE: go deeper
if (_.isObject(value) || _.isArray(value)) {
removeXBY(value);
} else if (['updated_by', 'created_by', 'published_by'].includes(key)) {
delete object[key];
}
});
return object;
};
module.exports = {
after(apiConfig, frame) {
debug('all after');
if (frame.response) {
frame.response = removeXBY(frame.response);
}
}
};

View file

@ -0,0 +1,63 @@
const {i18n} = require('../../../../../lib/common');
const mapper = require('./utils/mapper');
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:authentication');
module.exports = {
setup(user, apiConfig, frame) {
frame.response = {
users: [
mapper.mapUser(user, {options: {context: {internal: true}}})
]
};
},
updateSetup(user, apiConfig, frame) {
frame.response = {
users: [
mapper.mapUser(user, {options: {context: {internal: true}}})
]
};
},
isSetup(data, apiConfig, frame) {
frame.response = {
setup: [data]
};
},
generateResetToken(data, apiConfig, frame) {
frame.response = {
passwordreset: [{
message: i18n.t('common.api.authentication.mail.checkEmailForInstructions')
}]
};
},
resetPassword(data, apiConfig, frame) {
frame.response = {
passwordreset: [{
message: i18n.t('common.api.authentication.mail.passwordChanged')
}]
};
},
acceptInvitation(data, apiConfig, frame) {
debug('acceptInvitation');
frame.response = {
invitation: [
{message: i18n.t('common.api.authentication.mail.invitationAccepted')}
]
};
},
isInvitation(data, apiConfig, frame) {
debug('acceptInvitation');
frame.response = {
invitation: [{
valid: !!data
}]
};
}
};

View file

@ -0,0 +1,21 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:authors');
const mapper = require('./utils/mapper');
module.exports = {
browse(models, apiConfig, frame) {
debug('browse');
frame.response = {
authors: models.data.map(model => mapper.mapUser(model, frame)),
meta: models.meta
};
},
read(model, apiConfig, frame) {
debug('read');
frame.response = {
authors: [mapper.mapUser(model, frame)]
};
}
};

View file

@ -0,0 +1,10 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:config');
module.exports = {
all(data, apiConfig, frame) {
debug('all');
frame.response = {
config: data
};
}
};

View file

@ -0,0 +1,40 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:db');
module.exports = {
backupContent(filename, apiConfig, frame) {
debug('backupContent');
frame.response = {
db: [{filename: filename}]
};
},
exportContent(exportedData, apiConfig, frame) {
debug('exportContent');
frame.response = {
db: [exportedData]
};
},
importContent(response, apiConfig, frame) {
debug('importContent');
// NOTE: response can contain 2 objects if images are imported
const problems = (response.length === 2)
? response[1].problems
: response[0].problems;
frame.response = {
db: [],
problems: problems
};
},
deleteAllContent(response, apiConfig, frame) {
frame.response = {
db: []
};
}
};

View file

@ -0,0 +1,7 @@
module.exports = {
read(emailPreview, apiConfig, frame) {
frame.response = {
email_previews: [emailPreview]
};
}
};

View file

@ -0,0 +1,13 @@
const mapper = require('./utils/mapper');
module.exports = {
read(email, apiConfig, frame) {
frame.response = {
emails: [mapper.mapEmail(email, frame)]
};
},
get retry() {
return this.read;
}
};

View file

@ -0,0 +1,7 @@
module.exports = {
read(data, apiConfig, frame) {
frame.response = {
identities: [data]
};
}
};

View file

@ -0,0 +1,15 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:images');
const mapper = require('./utils/mapper');
module.exports = {
upload(path, apiConfig, frame) {
debug('upload');
return frame.response = {
images: [{
url: mapper.mapImage(path),
ref: frame.data.ref || null
}]
};
}
};

View file

@ -0,0 +1,129 @@
module.exports = {
get all() {
return require('./all');
},
get authentication() {
return require('./authentication');
},
get db() {
return require('./db');
},
get integrations() {
return require('./integrations');
},
get pages() {
return require('./pages');
},
get redirects() {
return require('./redirects');
},
get roles() {
return require('./roles');
},
get slugs() {
return require('./slugs');
},
get schedules() {
return require('./schedules');
},
get webhooks() {
return require('./webhooks');
},
get posts() {
return require('./posts');
},
get invites() {
return require('./invites');
},
get settings() {
return require('./settings');
},
get notifications() {
return require('./notifications');
},
get mail() {
return require('./mail');
},
get members() {
return require('./members');
},
get member_signin_urls() {
return require('./member-signin_urls');
},
get identities() {
return require('./identities');
},
get images() {
return require('./images');
},
get tags() {
return require('./tags');
},
get users() {
return require('./users');
},
get preview() {
return require('./preview');
},
get oembed() {
return require('./oembed');
},
get authors() {
return require('./authors');
},
get config() {
return require('./config');
},
get themes() {
return require('./themes');
},
get actions() {
return require('./actions');
},
get site() {
return require('./site');
},
get email_preview() {
return require('./email-preview');
},
get emails() {
return require('./emails');
},
get labels() {
return require('./labels');
},
get snippets() {
return require('./snippets');
}
};

View file

@ -0,0 +1,35 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:integrations');
const mapper = require('./utils/mapper');
module.exports = {
browse({data, meta}, apiConfig, frame) {
debug('browse');
frame.response = {
integrations: data.map(model => mapper.mapIntegration(model, frame)),
meta
};
},
read(model, apiConfig, frame) {
debug('read');
frame.response = {
integrations: [mapper.mapIntegration(model, frame)]
};
},
add(model, apiConfig, frame) {
debug('add');
frame.response = {
integrations: [mapper.mapIntegration(model, frame)]
};
},
edit(model, apiConfig, frame) {
debug('edit');
frame.response = {
integrations: [mapper.mapIntegration(model, frame)]
};
}
};

View file

@ -0,0 +1,24 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:invites');
module.exports = {
all(models, apiConfig, frame) {
debug('all');
if (!models) {
return;
}
if (models.meta) {
frame.response = {
invites: models.data.map(model => model.toJSON(frame.options)),
meta: models.meta
};
return;
}
frame.response = {
invites: [models.toJSON(frame.options)]
};
}
};

View file

@ -0,0 +1,25 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:labels');
const mapper = require('./utils/mapper');
module.exports = {
all(models, apiConfig, frame) {
debug('all');
if (!models) {
return;
}
if (models.meta) {
frame.response = {
labels: models.data.map(model => mapper.mapLabel(model, frame)),
meta: models.meta
};
return;
}
frame.response = {
labels: [mapper.mapLabel(models, frame)]
};
}
};

View file

@ -0,0 +1,19 @@
const _ = require('lodash');
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:mail');
module.exports = {
all(response, apiConfig, frame) {
debug('all');
const toReturn = _.cloneDeep(frame.data);
delete toReturn.mail[0].options;
// Sendmail returns extra details we don't need and that don't convert to JSON
delete toReturn.mail[0].message.transport;
toReturn.mail[0].status = {
message: response.message
};
frame.response = toReturn;
}
};

View file

@ -0,0 +1,7 @@
module.exports = {
read(data, apiConfig, frame) {
frame.response = {
member_signin_urls: [data]
};
}
};

View file

@ -0,0 +1,236 @@
//@ts-check
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:members');
const {unparse} = require('@tryghost/members-csv');
module.exports = {
hasActiveStripeSubscriptions: createSerializer('hasActiveStripeSubscriptions', passthrough),
browse: createSerializer('browse', paginatedMembers),
read: createSerializer('read', singleMember),
edit: createSerializer('edit', singleMember),
add: createSerializer('add', singleMember),
editSubscription: createSerializer('editSubscription', singleMember),
exportCSV: createSerializer('exportCSV', exportCSV),
importCSV: createSerializer('importCSV', passthrough),
stats: createSerializer('stats', passthrough)
};
/**
* @template PageMeta
*
* @param {{data: import('bookshelf').Model[], meta: PageMeta}} page
* @param {APIConfig} _apiConfig
* @param {Frame} frame
*
* @returns {{members: SerializedMember[], meta: PageMeta}}
*/
function paginatedMembers(page, _apiConfig, frame) {
return {
members: page.data.map(model => serializeMember(model, frame.options)),
meta: page.meta
};
}
/**
* @param {import('bookshelf').Model} model
* @param {APIConfig} _apiConfig
* @param {Frame} frame
*
* @returns {{members: SerializedMember[]}}
*/
function singleMember(model, _apiConfig, frame) {
return {
members: [serializeMember(model, frame.options)]
};
}
/**
* @template PageMeta
*
* @param {{data: import('bookshelf').Model[], meta: PageMeta}} page
* @param {APIConfig} _apiConfig
* @param {Frame} frame
*
* @returns {string} - A CSV string
*/
function exportCSV(page, _apiConfig, frame) {
debug('exportCSV');
const members = page.data.map(model => serializeMember(model, frame.options));
return unparse(members);
}
/**
* @param {import('bookshelf').Model} member
* @param {object} options
*
* @returns {SerializedMember}
*/
function serializeMember(member, options) {
const json = member.toJSON(options);
let comped = false;
if (json.stripe && json.stripe.subscriptions) {
const hasCompedSubscription = !!json.stripe.subscriptions.find(
/**
* @param {SerializedMemberStripeSubscription} sub
*/
function (sub) {
return sub.plan.nickname === 'Complimentary' && sub.status === 'active';
}
);
if (hasCompedSubscription) {
comped = true;
}
}
return {
id: json.id,
uuid: json.uuid,
email: json.email,
name: json.name,
note: json.note,
geolocation: json.geolocation,
subscribed: json.subscribed,
created_at: json.created_at,
updated_at: json.updated_at,
labels: json.labels,
stripe: json.stripe,
avatar_image: json.avatar_image,
comped: comped,
email_count: json.email_count,
email_opened_count: json.email_opened_count,
email_open_rate: json.email_open_rate,
email_recipients: json.email_recipients
};
}
/**
* @template Data
* @param {Data} data
* @returns Data
*/
function passthrough(data) {
return data;
}
/**
* @template Data
* @template Response
* @param {string} debugString
* @param {(data: Data, apiConfig: APIConfig, frame: Frame) => Response} serialize - A function to serialize the data into an object suitable for API response
*
* @returns {(data: Data, apiConfig: APIConfig, frame: Frame) => void}
*/
function createSerializer(debugString, serialize) {
return function serializer(data, apiConfig, frame) {
debug(debugString);
const response = serialize(data, apiConfig, frame);
frame.response = response;
};
}
/**
* @typedef {Object} SerializedMember
* @prop {string} id
* @prop {string} uuid
* @prop {string} email
* @prop {string=} name
* @prop {string=} note
* @prop {null|string} geolocation
* @prop {boolean} subscribed
* @prop {string} created_at
* @prop {string} updated_at
* @prop {string[]} labels
* @prop {null|SerializedMemberStripeData} stripe
* @prop {string} avatar_image
* @prop {boolean} comped
* @prop {number} email_count
* @prop {number} email_opened_count
* @prop {number} email_open_rate
* @prop {null|SerializedEmailRecipient[]} email_recipients
*/
/**
* @typedef {Object} SerializedMemberStripeData
* @prop {SerializedMemberStripeSubscription[]} subscriptions
*/
/**
* @typedef {Object} SerializedMemberStripeSubscription
*
* @prop {string} id
* @prop {string} status
* @prop {string} start_date
* @prop {string} default_payment_card_last4
* @prop {string} current_period_end
* @prop {boolean} cancel_at_period_end
*
* @prop {Object} customer
* @prop {string} customer.id
* @prop {null|string} customer.name
* @prop {string} customer.email
*
* @prop {Object} plan
* @prop {string} plan.id
* @prop {string} plan.nickname
* @prop {number} plan.amount
* @prop {string} plan.currency
* @prop {string} plan.currency_symbol
*/
/**
* @typedef {Object} SerializedEmailRecipient
*
* @prop {string} id
* @prop {string} email_id
* @prop {string} batch_id
* @prop {string} processed_at
* @prop {string} delivered_at
* @prop {string} opened_at
* @prop {string} failed_at
* @prop {string} member_uuid
* @prop {string} member_email
* @prop {string} member_name
* @prop {SerializedEmail[]} email
*/
/**
* @typedef {Object} SerializedEmail
*
* @prop {string} id
* @prop {string} post_id
* @prop {string} uuid
* @prop {string} status
* @prop {string} recipient_filter
* @prop {null|string} error
* @prop {string} error_data
* @prop {number} email_count
* @prop {number} delivered_count
* @prop {number} opened_count
* @prop {number} failed_count
* @prop {string} subject
* @prop {string} from
* @prop {string} reply_to
* @prop {string} html
* @prop {string} plaintext
* @prop {boolean} track_opens
* @prop {string} created_at
* @prop {string} created_by
* @prop {string} updated_at
* @prop {string} updated_by
*/
/**
* @typedef {Object} APIConfig
* @prop {string} docName
* @prop {string} method
*/
/**
* @typedef {Object<string, any>} Frame
* @prop {Object} options
*/

View file

@ -0,0 +1,28 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:notifications');
module.exports = {
all(response, apiConfig, frame) {
debug('all');
if (!response) {
return;
}
if (!response || !response.length) {
frame.response = {
notifications: []
};
return;
}
response.forEach((notification) => {
delete notification.seen;
delete notification.seenBy;
delete notification.addedAt;
});
frame.response = {
notifications: response
};
}
};

View file

@ -0,0 +1,8 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:oembed');
module.exports = {
all(res, apiConfig, frame) {
debug('all');
frame.response = res;
}
};

View file

@ -0,0 +1,26 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:pages');
const mapper = require('./utils/mapper');
module.exports = {
all(models, apiConfig, frame) {
debug('all');
// CASE: e.g. destroy returns null
if (!models) {
return;
}
if (models.meta) {
frame.response = {
pages: models.data.map(model => mapper.mapPage(model, frame)),
meta: models.meta
};
return;
}
frame.response = {
pages: [mapper.mapPage(models, frame)]
};
}
};

View file

@ -0,0 +1,26 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:posts');
const mapper = require('./utils/mapper');
module.exports = {
all(models, apiConfig, frame) {
debug('all');
// CASE: e.g. destroy returns null
if (!models) {
return;
}
if (models.meta) {
frame.response = {
posts: models.data.map(model => mapper.mapPost(model, frame)),
meta: models.meta
};
return;
}
frame.response = {
posts: [mapper.mapPost(models, frame)]
};
}
};

View file

@ -0,0 +1,10 @@
const mapper = require('./utils/mapper');
module.exports = {
all(model, apiConfig, frame) {
frame.response = {
preview: [mapper.mapPost(model, frame)]
};
frame.response.preview[0].page = model.get('type') === 'page';
}
};

View file

@ -0,0 +1,5 @@
module.exports = {
download(response, apiConfig, frame) {
frame.response = response;
}
};

View file

@ -0,0 +1,29 @@
const Promise = require('bluebird');
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:roles');
const canThis = require('../../../../../services/permissions').canThis;
module.exports = {
browse(models, apiConfig, frame) {
debug('browse');
const roles = models.toJSON(frame.options);
if (frame.options.permissions !== 'assign') {
return frame.response = {
roles: roles
};
} else {
return Promise.filter(roles.map((role) => {
return canThis(frame.options.context).assign.role(role)
.return(role)
.catch(() => {});
}), (value) => {
return value && (value.name !== 'Owner');
}).then((filteredRoles) => {
return frame.response = {
roles: filteredRoles
};
});
}
}
};

View file

@ -0,0 +1,5 @@
module.exports = {
all(model, apiConfig, frame) {
frame.response = model;
}
};

View file

@ -0,0 +1,66 @@
const _ = require('lodash');
const utils = require('../../index');
const mapper = require('./utils/mapper');
const _private = {};
/**
* ### Settings Filter
* Filters an object based on a given filter object
* @private
* @param {Object} settings
* @param {String} filter
* @returns {*}
*/
_private.settingsFilter = (settings, filter) => {
let filteredGroups = filter ? filter.split(',') : false;
return _.filter(settings, (setting) => {
if (filteredGroups) {
return _.includes(filteredGroups, setting.group);
}
return true;
});
};
module.exports = {
browse(models, apiConfig, frame) {
let filteredSettings;
// If this is public, we already have the right data, we just need to add an Array wrapper
if (utils.isContentAPI(frame)) {
filteredSettings = models;
} else {
filteredSettings = _.values(_private.settingsFilter(models, frame.options.group));
}
frame.response = {
settings: mapper.mapSettings(filteredSettings, frame),
meta: {}
};
if (frame.options.type || frame.options.group) {
frame.response.meta.filters = {};
if (frame.options.type) {
frame.response.meta.filters.type = frame.options.type;
}
if (frame.options.group) {
frame.response.meta.filters.group = frame.options.group;
}
}
},
read() {
this.browse(...arguments);
},
edit(models, apiConfig, frame) {
const settingsKeyedJSON = _.keyBy(_.invokeMap(models, 'toJSON'), 'key');
this.browse(settingsKeyedJSON, apiConfig, frame);
},
download(bytes, apiConfig, frame) {
frame.response = bytes;
}
};

View file

@ -0,0 +1,11 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:site');
module.exports = {
read(data, apiConfig, frame) {
debug('read');
frame.response = {
site: data
};
}
};

View file

@ -0,0 +1,11 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:slugs');
module.exports = {
all(slug, apiConfig, frame) {
debug('all');
frame.response = {
slugs: [{slug}]
};
}
};

View file

@ -0,0 +1,96 @@
//@ts-check
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:snippets');
module.exports = {
browse: createSerializer('browse', paginatedSnippets),
read: createSerializer('read', singleSnippet),
edit: createSerializer('edit', singleSnippet),
add: createSerializer('add', singleSnippet)
};
/**
* @template PageMeta
*
* @param {{data: import('bookshelf').Model[], meta: PageMeta}} page
* @param {APIConfig} _apiConfig
* @param {Frame} frame
*
* @returns {{snippets: SerializedSnippet[], meta: PageMeta}}
*/
function paginatedSnippets(page, _apiConfig, frame) {
return {
snippets: page.data.map(model => serializeSnippet(model, frame.options)),
meta: page.meta
};
}
/**
* @param {import('bookshelf').Model} model
* @param {APIConfig} _apiConfig
* @param {Frame} frame
*
* @returns {{snippets: SerializedSnippet[]}}
*/
function singleSnippet(model, _apiConfig, frame) {
return {
snippets: [serializeSnippet(model, frame.options)]
};
}
/**
* @template Data
* @template Response
* @param {string} debugString
* @param {(data: Data, apiConfig: APIConfig, frame: Frame) => Response} serialize - A function to serialize the data into an object suitable for API response
*
* @returns {(data: Data, apiConfig: APIConfig, frame: Frame) => void}
*/
function createSerializer(debugString, serialize) {
return function serializer(data, apiConfig, frame) {
debug(debugString);
const response = serialize(data, apiConfig, frame);
frame.response = response;
};
}
/**
* @param {import('bookshelf').Model} snippet
* @param {object} options
*
* @returns {SerializedSnippet}
*/
function serializeSnippet(snippet, options) {
const json = snippet.toJSON(options);
return {
id: json.id,
name: json.name,
mobiledoc: json.mobiledoc,
created_at: json.created_at,
updated_at: json.updated_at,
created_by: json.created_by,
updated_by: json.updated_by
};
}
/**
* @typedef {Object} SerializedSnippet
* @prop {string} id
* @prop {string=} name
* @prop {string=} mobiledoc
* @prop {string} created_at
* @prop {string} updated_at
* @prop {string} created_by
* @prop {string} updated_by
*/
/**
* @typedef {Object} APIConfig
* @prop {string} docName
* @prop {string} method
*/
/**
* @typedef {Object<string, any>} Frame
* @prop {Object} options
*/

View file

@ -0,0 +1,25 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:tags');
const mapper = require('./utils/mapper');
module.exports = {
all(models, apiConfig, frame) {
debug('all');
if (!models) {
return;
}
if (models.meta) {
frame.response = {
tags: models.data.map(model => mapper.mapTag(model, frame)),
meta: models.meta
};
return;
}
frame.response = {
tags: [mapper.mapTag(models, frame)]
};
}
};

View file

@ -0,0 +1,25 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:themes');
module.exports = {
browse(themes, apiConfig, frame) {
debug('browse');
frame.response = themes;
},
upload() {
debug('upload');
this.browse(...arguments);
},
activate() {
debug('activate');
this.browse(...arguments);
},
download(fn, apiConfig, frame) {
debug('download');
frame.response = fn;
}
};

View file

@ -0,0 +1,70 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:users');
const {i18n} = require('../../../../../lib/common');
const mapper = require('./utils/mapper');
module.exports = {
browse(models, apiConfig, frame) {
debug('browse');
frame.response = {
users: models.data.map(model => mapper.mapUser(model, frame)),
meta: models.meta
};
},
read(model, apiConfig, frame) {
debug('read');
frame.response = {
users: [mapper.mapUser(model, frame)]
};
},
edit() {
debug('edit');
this.read(...arguments);
},
destroy(filename, apiConfig, frame) {
debug('destroy');
frame.response = {
meta: {
filename: filename
}
};
},
changePassword(models, apiConfig, frame) {
debug('changePassword');
frame.response = {
password: [{message: i18n.t('notices.api.users.pwdChangedSuccessfully')}]
};
},
transferOwnership(models, apiConfig, frame) {
debug('transferOwnership');
frame.response = {
users: models.map(model => model.toJSON(frame.options))
};
},
readToken(model, apiConfig, frame) {
debug('readToken');
frame.response = {
apiKey: model.toJSON(frame.options)
};
},
regenerateToken(model, apiConfig, frame) {
debug('regenerateToken');
frame.response = {
apiKey: model.toJSON(frame.options)
};
}
};

View file

@ -0,0 +1,158 @@
const _ = require('lodash');
const localUtils = require('../../../index');
const tag = (attrs, frame) => {
if (localUtils.isContentAPI(frame)) {
delete attrs.created_at;
delete attrs.updated_at;
// We are standardising on returning null from the Content API for any empty values
if (attrs.meta_title === '') {
attrs.meta_title = null;
}
if (attrs.meta_description === '') {
attrs.meta_description = null;
}
if (attrs.description === '') {
attrs.description = null;
}
}
delete attrs.parent_id;
delete attrs.parent;
return attrs;
};
const author = (attrs, frame) => {
if (localUtils.isContentAPI(frame)) {
delete attrs.created_at;
delete attrs.updated_at;
delete attrs.last_seen;
delete attrs.status;
delete attrs.email;
// @NOTE: used for night shift
delete attrs.accessibility;
// Extra properties removed from v3
delete attrs.tour;
// We are standardising on returning null from the Content API for any empty values
if (attrs.twitter === '') {
attrs.twitter = null;
}
if (attrs.bio === '') {
attrs.bio = null;
}
if (attrs.website === '') {
attrs.website = null;
}
if (attrs.facebook === '') {
attrs.facebook = null;
}
if (attrs.meta_title === '') {
attrs.meta_title = null;
}
if (attrs.meta_description === '') {
attrs.meta_description = null;
}
if (attrs.location === '') {
attrs.location = null;
}
}
// @NOTE: unused fields
delete attrs.visibility;
delete attrs.locale;
return attrs;
};
const post = (attrs, frame) => {
const columns = frame && frame.options && frame.options.columns || null;
const fields = frame && frame.original && frame.original.query && frame.original.query.fields || null;
if (localUtils.isContentAPI(frame)) {
// @TODO: https://github.com/TryGhost/Ghost/issues/10335
// delete attrs.page;
delete attrs.status;
// We are standardising on returning null from the Content API for any empty values
if (attrs.twitter_title === '') {
attrs.twitter_title = null;
}
if (attrs.twitter_description === '') {
attrs.twitter_description = null;
}
if (attrs.meta_title === '') {
attrs.meta_title = null;
}
if (attrs.meta_description === '') {
attrs.meta_description = null;
}
if (attrs.og_title === '') {
attrs.og_title = null;
}
if (attrs.og_description === '') {
attrs.og_description = null;
}
// NOTE: the visibility column has to be always present in Content API response to perform content gating
if (columns && columns.includes('visibility') && fields && !fields.includes('visibility')) {
delete attrs.visibility;
}
} else {
delete attrs.page;
}
if (columns && columns.includes('email_recipient_filter') && fields && !fields.includes('email_recipient_filter')) {
delete attrs.email_recipient_filter;
}
if (fields && !fields.includes('send_email_when_published')) {
delete attrs.send_email_when_published;
}
if (!attrs.tags) {
delete attrs.primary_tag;
}
if (!attrs.authors) {
delete attrs.primary_author;
}
delete attrs.locale;
delete attrs.author;
delete attrs.type;
return attrs;
};
const action = (attrs) => {
if (attrs.actor) {
delete attrs.actor_id;
delete attrs.resource_id;
if (attrs.actor_type === 'user') {
attrs.actor = _.pick(attrs.actor, ['id', 'name', 'slug', 'profile_image']);
attrs.actor.image = attrs.actor.profile_image;
delete attrs.actor.profile_image;
} else {
attrs.actor = _.pick(attrs.actor, ['id', 'name', 'slug', 'icon_image']);
attrs.actor.image = attrs.actor.icon_image;
delete attrs.actor.icon_image;
}
} else if (attrs.resource) {
delete attrs.actor_id;
delete attrs.resource_id;
// @NOTE: we only support posts right now
attrs.resource = _.pick(attrs.resource, ['id', 'title', 'slug', 'feature_image']);
attrs.resource.image = attrs.resource.feature_image;
delete attrs.resource.feature_image;
}
};
module.exports.post = post;
module.exports.tag = tag;
module.exports.author = author;
module.exports.action = action;

View file

@ -0,0 +1,21 @@
const moment = require('moment-timezone');
const settingsCache = require('../../../../../../services/settings/cache');
const format = (date) => {
return moment(date)
.tz(settingsCache.get('timezone'))
.toISOString(true);
};
const forPost = (attrs) => {
['created_at', 'updated_at', 'published_at'].forEach((field) => {
if (attrs[field]) {
attrs[field] = format(attrs[field]);
}
});
return attrs;
};
module.exports.format = format;
module.exports.forPost = forPost;

View file

@ -0,0 +1,124 @@
const readingMinutes = require('@tryghost/helpers').utils.readingMinutes;
module.exports.forPost = (frame, model, attrs) => {
const _ = require('lodash');
if (!Object.prototype.hasOwnProperty.call(frame.options, 'columns') ||
(frame.options.columns.includes('excerpt') && frame.options.formats && frame.options.formats.includes('plaintext'))) {
if (_.isEmpty(attrs.custom_excerpt)) {
const plaintext = model.get('plaintext');
if (plaintext) {
attrs.excerpt = plaintext.substring(0, 500);
} else {
attrs.excerpt = null;
}
} else {
attrs.excerpt = attrs.custom_excerpt;
}
}
if (!Object.prototype.hasOwnProperty.call(frame.options, 'columns') ||
(frame.options.columns.includes('reading_time'))) {
if (attrs.html) {
let additionalImages = 0;
if (attrs.feature_image) {
additionalImages += 1;
}
attrs.reading_time = readingMinutes(attrs.html, additionalImages);
}
}
};
module.exports.forSettings = (attrs, frame) => {
const _ = require('lodash');
const mapGroupToType = require('./settings-type-group-mapper');
// @TODO: https://github.com/TryGhost/Ghost/issues/10106
// @NOTE: Admin & Content API return a different format, needs two mappers
if (_.isArray(attrs)) {
attrs.forEach((attr) => {
attr.type = mapGroupToType(attr.group);
});
// CASE: read single setting
if (frame.original.params && frame.original.params.key) {
if (frame.original.params.key === 'ghost_head') {
attrs[0].key = 'ghost_head';
return;
}
if (frame.original.params.key === 'ghost_foot') {
attrs[0].key = 'ghost_foot';
return;
}
if (frame.original.params.key === 'active_timezone') {
attrs[0].key = 'active_timezone';
return;
}
if (frame.original.params.key === 'default_locale') {
attrs[0].key = 'default_locale';
return;
}
if (frame.original.params.key === 'timezone') {
return;
}
if (frame.original.params.key === 'lang') {
return;
}
}
// CASE: edit
if (frame.original.body && frame.original.body.settings) {
frame.original.body.settings.forEach((setting) => {
if (setting.key === 'ghost_head') {
const target = _.find(attrs, {key: 'codeinjection_head'});
target.key = 'ghost_head';
} else if (setting.key === 'ghost_foot') {
const target = _.find(attrs, {key: 'codeinjection_foot'});
target.key = 'ghost_foot';
} else if (setting.key === 'active_timezone') {
const target = _.find(attrs, {key: 'timezone'});
target.key = 'active_timezone';
} else if (setting.key === 'default_locale') {
const target = _.find(attrs, {key: 'lang'});
target.key = 'default_locale';
}
});
return;
}
// CASE: browse all settings, add extra keys and keep deprecated
const ghostHead = _.cloneDeep(_.find(attrs, {key: 'codeinjection_head'}));
const ghostFoot = _.cloneDeep(_.find(attrs, {key: 'codeinjection_foot'}));
const timezone = _.cloneDeep(_.find(attrs, {key: 'timezone'}));
const lang = _.cloneDeep(_.find(attrs, {key: 'lang'}));
if (ghostHead) {
ghostHead.key = 'ghost_head';
attrs.push(ghostHead);
}
if (ghostFoot) {
ghostFoot.key = 'ghost_foot';
attrs.push(ghostFoot);
}
if (timezone) {
timezone.key = 'active_timezone';
attrs.push(timezone);
}
if (lang) {
lang.key = 'default_locale';
attrs.push(lang);
}
}
};

View file

@ -0,0 +1,176 @@
const _ = require('lodash');
const utils = require('../../../index');
const url = require('./url');
const date = require('./date');
const gating = require('./post-gating');
const clean = require('./clean');
const extraAttrs = require('./extra-attrs');
const postsMetaSchema = require('../../../../../../data/schema').tables.posts_meta;
const mega = require('../../../../../../services/mega');
const mapUser = (model, frame) => {
const jsonModel = model.toJSON ? model.toJSON(frame.options) : model;
url.forUser(model.id, jsonModel, frame.options);
clean.author(jsonModel, frame);
return jsonModel;
};
const mapTag = (model, frame) => {
const jsonModel = model.toJSON ? model.toJSON(frame.options) : model;
url.forTag(model.id, jsonModel, frame.options);
clean.tag(jsonModel, frame);
return jsonModel;
};
const mapPost = (model, frame) => {
const extendedOptions = Object.assign(_.cloneDeep(frame.options), {
extraProperties: ['canonical_url']
});
const jsonModel = model.toJSON(extendedOptions);
url.forPost(model.id, jsonModel, frame);
extraAttrs.forPost(frame, model, jsonModel);
if (utils.isContentAPI(frame)) {
// Content api v2 still expects page prop
if (jsonModel.type === 'page') {
jsonModel.page = true;
}
date.forPost(jsonModel);
gating.forPost(jsonModel, frame);
}
if (typeof jsonModel.email_recipient_filter === 'undefined') {
jsonModel.send_email_when_published = null;
} else if (jsonModel.email_recipient_filter === 'none') {
jsonModel.send_email_when_published = false;
} else {
jsonModel.send_email_when_published = true;
}
clean.post(jsonModel, frame);
if (frame.options && frame.options.withRelated) {
frame.options.withRelated.forEach((relation) => {
// @NOTE: this block also decorates primary_tag/primary_author objects as they
// are being passed by reference in tags/authors. Might be refactored into more explicit call
// in the future, but is good enough for current use-case
if (relation === 'tags' && jsonModel.tags) {
jsonModel.tags = jsonModel.tags.map(tag => mapTag(tag, frame));
}
if (relation === 'authors' && jsonModel.authors) {
jsonModel.authors = jsonModel.authors.map(author => mapUser(author, frame));
}
if (relation === 'email' && jsonModel.email) {
jsonModel.email = mapEmail(jsonModel.email, frame);
}
if (relation === 'email' && _.isEmpty(jsonModel.email)) {
jsonModel.email = null;
}
});
}
// Transforms post/page metadata to flat structure
let metaAttrs = _.keys(_.omit(postsMetaSchema, ['id', 'post_id']));
_(metaAttrs).filter((k) => {
return (!frame.options.columns || (frame.options.columns && frame.options.columns.includes(k)));
}).each((attr) => {
jsonModel[attr] = _.get(jsonModel.posts_meta, attr) || null;
});
delete jsonModel.posts_meta;
return jsonModel;
};
const mapPage = (model, frame) => {
const jsonModel = mapPost(model, frame);
delete jsonModel.email_subject;
delete jsonModel.send_email_when_published;
delete jsonModel.email_recipient_filter;
return jsonModel;
};
const mapSettings = (attrs, frame) => {
url.forSettings(attrs);
extraAttrs.forSettings(attrs, frame);
// NOTE: The cleanup of deprecated ghost_head/ghost_foot has to happen here
// because codeinjection_head/codeinjection_foot are assigned on a previous
// `forSettings` step. This logic can be rewritten once we get rid of deprecated
// fields completely.
if (_.isArray(attrs)) {
attrs = _.filter(attrs, (o) => {
return o.key !== 'ghost_head' && o.key !== 'ghost_foot';
});
}
return attrs;
};
const mapIntegration = (model, frame) => {
const jsonModel = model.toJSON ? model.toJSON(frame.options) : model;
if (jsonModel.api_keys) {
jsonModel.api_keys.forEach((key) => {
if (key.type === 'admin') {
key.secret = `${key.id}:${key.secret}`;
}
});
}
return jsonModel;
};
const mapImage = (path) => {
return url.forImage(path);
};
const mapAction = (model, frame) => {
const attrs = model.toJSON(frame.options);
clean.action(attrs);
return attrs;
};
const mapLabel = (model, frame) => {
const jsonModel = model.toJSON ? model.toJSON(frame.options) : model;
return jsonModel;
};
const mapEmail = (model, frame) => {
const jsonModel = model.toJSON ? model.toJSON(frame.options) : model;
// Ensure we're not outputting unwanted replacement strings when viewing email contents
// TODO: extract this to a utility, it's duplicated in the email-preview API controller
const replacements = mega.postEmailSerializer.parseReplacements(jsonModel);
replacements.forEach((replacement) => {
jsonModel[replacement.format] = jsonModel[replacement.format].replace(
replacement.match,
replacement.fallback || ''
);
});
return jsonModel;
};
module.exports.mapPost = mapPost;
module.exports.mapPage = mapPage;
module.exports.mapUser = mapUser;
module.exports.mapTag = mapTag;
module.exports.mapLabel = mapLabel;
module.exports.mapIntegration = mapIntegration;
module.exports.mapSettings = mapSettings;
module.exports.mapImage = mapImage;
module.exports.mapAction = mapAction;
module.exports.mapEmail = mapEmail;

View file

@ -0,0 +1,31 @@
const membersService = require('../../../../../../services/members');
const labs = require('../../../../../../services/labs');
// @TODO: reconsider the location of this - it's part of members and adds a property to the API
const forPost = (attrs, frame) => {
// CASE: Access always defaults to true, unless members is enabled and the member does not have access
if (!Object.prototype.hasOwnProperty.call(frame.options, 'columns') || (frame.options.columns.includes('access'))) {
attrs.access = true;
}
// Handle members being enabled
if (labs.isSet('members')) {
const memberHasAccess = membersService.contentGating.checkPostAccess(attrs, frame.original.context.member);
if (!memberHasAccess) {
['plaintext', 'html'].forEach((field) => {
if (attrs[field] !== undefined) {
attrs[field] = '';
}
});
}
if (!Object.prototype.hasOwnProperty.call(frame.options, 'columns') || (frame.options.columns.includes('access'))) {
attrs.access = memberHasAccess;
}
}
return attrs;
};
module.exports.forPost = forPost;

View file

@ -0,0 +1,22 @@
const groupTypeMapping = {
core: 'core',
amp: 'blog',
labs: 'blog',
slack: 'blog',
site: 'blog',
unsplash: 'blog',
views: 'blog',
theme: 'theme',
members: 'members',
private: 'private',
portal: 'portal',
email: 'bulk_email',
newsletter: 'newsletter',
firstpromoter: 'firstpromoter'
};
const mapGroupToType = (group) => {
return groupTypeMapping[group];
};
module.exports = mapGroupToType;

View file

@ -0,0 +1,125 @@
const _ = require('lodash');
const urlService = require('../../../../../../../frontend/services/url');
const urlUtils = require('../../../../../../../shared/url-utils');
const localUtils = require('../../../index');
const forPost = (id, attrs, frame) => {
attrs.url = urlService.getUrlByResourceId(id, {absolute: true});
/**
* CASE: admin api should serve preview urls
*
* @NOTE
* The url service has no clue of the draft/scheduled concept. It only generates urls for published resources.
* Adding a hardcoded fallback into the url service feels wrong IMO.
*
* Imagine the site won't be part of core and core does not serve urls anymore.
* Core needs to offer a preview API, which returns draft posts.
* That means the url is no longer /p/:uuid, it's e.g. GET /api/v3/content/preview/:uuid/.
* /p/ is a concept of the site, not of core.
*
* The site is not aware of existing drafts. It won't be able to get the uuid.
*
* Needs further discussion.
*/
if (!localUtils.isContentAPI(frame)) {
if (attrs.status !== 'published' && attrs.url.match(/\/404\//)) {
attrs.url = urlUtils.urlFor({
relativeUrl: urlUtils.urlJoin('/p', attrs.uuid, '/')
}, null, true);
}
}
if (attrs.mobiledoc) {
attrs.mobiledoc = urlUtils.mobiledocRelativeToAbsolute(
attrs.mobiledoc,
attrs.url
);
}
['html', 'codeinjection_head', 'codeinjection_foot'].forEach((attr) => {
if (attrs[attr]) {
attrs[attr] = urlUtils.htmlRelativeToAbsolute(
attrs[attr],
attrs.url
);
}
});
['feature_image', 'canonical_url', 'posts_meta.og_image', 'posts_meta.twitter_image'].forEach((path) => {
const value = _.get(attrs, path);
if (value) {
_.set(attrs, path, urlUtils.relativeToAbsolute(value));
}
});
if (frame.options.columns && !frame.options.columns.includes('url')) {
delete attrs.url;
}
return attrs;
};
const forUser = (id, attrs, options) => {
if (!options.columns || (options.columns && options.columns.includes('url'))) {
attrs.url = urlService.getUrlByResourceId(id, {absolute: true});
}
if (attrs.profile_image) {
attrs.profile_image = urlUtils.urlFor('image', {image: attrs.profile_image}, true);
}
if (attrs.cover_image) {
attrs.cover_image = urlUtils.urlFor('image', {image: attrs.cover_image}, true);
}
return attrs;
};
const forTag = (id, attrs, options) => {
if (!options.columns || (options.columns && options.columns.includes('url'))) {
attrs.url = urlService.getUrlByResourceId(id, {absolute: true});
}
if (attrs.feature_image) {
attrs.feature_image = urlUtils.urlFor('image', {image: attrs.feature_image}, true);
}
return attrs;
};
const forSettings = (attrs) => {
// @TODO: https://github.com/TryGhost/Ghost/issues/10106
// @NOTE: Admin & Content API return a different format, need to mappers
if (_.isArray(attrs)) {
attrs.forEach((obj) => {
if (['cover_image', 'logo', 'icon', 'portal_button_icon'].includes(obj.key) && obj.value) {
obj.value = urlUtils.urlFor('image', {image: obj.value}, true);
}
});
} else {
if (attrs.cover_image) {
attrs.cover_image = urlUtils.urlFor('image', {image: attrs.cover_image}, true);
}
if (attrs.logo) {
attrs.logo = urlUtils.urlFor('image', {image: attrs.logo}, true);
}
if (attrs.icon) {
attrs.icon = urlUtils.urlFor('image', {image: attrs.icon}, true);
}
}
return attrs;
};
const forImage = (path) => {
return urlUtils.urlFor('image', {image: path}, true);
};
module.exports.forPost = forPost;
module.exports.forUser = forUser;
module.exports.forTag = forTag;
module.exports.forSettings = forSettings;
module.exports.forImage = forImage;

View file

@ -0,0 +1,15 @@
const debug = require('ghost-ignition').debug('api:v3:utils:serializers:output:webhooks');
module.exports = {
all(models, apiConfig, frame) {
debug('all');
// CASE: e.g. destroy returns null
if (!models) {
return;
}
frame.response = {
webhooks: [models.toJSON(frame.options)]
};
}
};

View file

@ -0,0 +1,9 @@
module.exports = {
get input() {
return require('./input');
},
get output() {
return require('./output');
}
};

View file

@ -0,0 +1,80 @@
const jsonSchema = require('../utils/json-schema');
const config = require('../../../../../../shared/config');
const {i18n} = require('../../../../../lib/common');
const errors = require('@tryghost/errors');
const {imageSize, blogIcon} = require('../../../../../lib/image');
const profileImage = (frame) => {
return imageSize.getImageSizeFromPath(frame.file.path).then((response) => {
// save the image dimensions in new property for file
frame.file.dimensions = response;
// CASE: file needs to be a square
if (frame.file.dimensions.width !== frame.file.dimensions.height) {
return Promise.reject(new errors.ValidationError({
message: i18n.t('errors.api.images.isNotSquare')
}));
}
});
};
const icon = (frame) => {
const iconExtensions = (config.get('uploads').icons && config.get('uploads').icons.extensions) || [];
const validIconFileSize = (size) => {
return (size / 1024) <= 100;
};
// CASE: file should not be larger than 100kb
if (!validIconFileSize(frame.file.size)) {
return Promise.reject(new errors.ValidationError({
message: i18n.t('errors.api.icons.invalidFile', {extensions: iconExtensions})
}));
}
return blogIcon.getIconDimensions(frame.file.path).then((response) => {
// save the image dimensions in new property for file
frame.file.dimensions = response;
// CASE: file needs to be a square
if (frame.file.dimensions.width !== frame.file.dimensions.height) {
return Promise.reject(new errors.ValidationError({
message: i18n.t('errors.api.icons.invalidFile', {extensions: iconExtensions})
}));
}
// CASE: icon needs to be bigger than or equal to 60px
// .ico files can contain multiple sizes, we need at least a minimum of 60px (16px is ok, as long as 60px are present as well)
if (frame.file.dimensions.width < 60) {
return Promise.reject(new errors.ValidationError({
message: i18n.t('errors.api.icons.invalidFile', {extensions: iconExtensions})
}));
}
// CASE: icon needs to be smaller than or equal to 1000px
if (frame.file.dimensions.width > 1000) {
return Promise.reject(new errors.ValidationError({
message: i18n.t('errors.api.icons.invalidFile', {extensions: iconExtensions})
}));
}
});
};
module.exports = {
upload(apiConfig, frame) {
return Promise.resolve()
.then(() => {
return jsonSchema.validate(apiConfig, frame);
})
.then(() => {
if (frame.data.purpose === 'profile_image') {
return profileImage(frame);
}
})
.then(() => {
if (frame.data.purpose === 'icon') {
return icon(frame);
}
});
}
};

View file

@ -0,0 +1,61 @@
module.exports = {
get passwordreset() {
return require('./passwordreset');
},
get setup() {
return require('./setup');
},
get posts() {
return require('./posts');
},
get pages() {
return require('./pages');
},
get invites() {
return require('./invites');
},
get invitations() {
return require('./invitations');
},
get members() {
return require('./members');
},
get settings() {
return require('./settings');
},
get tags() {
return require('./tags');
},
get labels() {
return require('./labels');
},
get users() {
return require('./users');
},
get images() {
return require('./images');
},
get oembed() {
return require('./oembed');
},
get webhooks() {
return require('./webhooks');
},
get snippets() {
return require('./snippets');
}
};

View file

@ -0,0 +1,41 @@
const Promise = require('bluebird');
const validator = require('validator');
const debug = require('ghost-ignition').debug('api:v3:utils:validators:input:invitation');
const {i18n} = require('../../../../../lib/common');
const errors = require('@tryghost/errors');
module.exports = {
acceptInvitation(apiConfig, frame) {
debug('acceptInvitation');
const data = frame.data.invitation[0];
if (!data.token) {
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.api.authentication.noTokenProvided')}));
}
if (!data.email) {
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.api.authentication.noEmailProvided')}));
}
if (!data.password) {
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.api.authentication.noPasswordProvided')}));
}
if (!data.name) {
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.api.authentication.noNameProvided')}));
}
},
isInvitation(apiConfig, frame) {
debug('isInvitation');
const email = frame.data.email;
if (typeof email !== 'string' || !validator.isEmail(email)) {
throw new errors.BadRequestError({
message: i18n.t('errors.api.authentication.invalidEmailReceived')
});
}
}
};

View file

@ -0,0 +1,17 @@
const Promise = require('bluebird');
const {i18n} = require('../../../../../lib/common');
const errors = require('@tryghost/errors');
const models = require('../../../../../models');
module.exports = {
add(apiConfig, frame) {
return models.User.findOne({email: frame.data.invites[0].email}, frame.options)
.then((user) => {
if (user) {
return Promise.reject(new errors.ValidationError({
message: i18n.t('errors.api.users.userAlreadyRegistered')
}));
}
});
}
};

Some files were not shown because too many files have changed in this diff Show more