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:
parent
cbfdf79661
commit
af9c5fd2f1
121 changed files with 7819 additions and 1 deletions
|
@ -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');
|
||||
|
|
19
core/server/api/v3/actions.js
Normal file
19
core/server/api/v3/actions.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
187
core/server/api/v3/authentication.js
Normal file
187
core/server/api/v3/authentication.js
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
65
core/server/api/v3/authors-public.js
Normal file
65
core/server/api/v3/authors-public.js
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
32
core/server/api/v3/config.js
Normal file
32
core/server/api/v3/config.js
Normal 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
131
core/server/api/v3/db.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
83
core/server/api/v3/email-preview.js
Normal file
83
core/server/api/v3/email-preview.js
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
60
core/server/api/v3/email.js
Normal file
60
core/server/api/v3/email.js
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
36
core/server/api/v3/identities.js
Normal file
36
core/server/api/v3/identities.js
Normal 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};
|
||||
}
|
||||
}
|
||||
};
|
20
core/server/api/v3/images.js
Normal file
20
core/server/api/v3/images.js
Normal 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
173
core/server/api/v3/index.js
Normal 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');
|
||||
}
|
||||
};
|
169
core/server/api/v3/integrations.js
Normal file
169
core/server/api/v3/integrations.js
Normal 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'
|
||||
})
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
122
core/server/api/v3/invites.js
Normal file
122
core/server/api/v3/invites.js
Normal 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')
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
158
core/server/api/v3/labels.js
Normal file
158
core/server/api/v3/labels.js
Normal 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')
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
60
core/server/api/v3/mail.js
Normal file
60
core/server/api/v3/mail.js
Normal 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);
|
||||
});
|
||||
}
|
||||
};
|
30
core/server/api/v3/memberSigninUrls.js
Normal file
30
core/server/api/v3/memberSigninUrls.js
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
389
core/server/api/v3/members.js
Normal file
389
core/server/api/v3/members.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
29
core/server/api/v3/membersStripeConnect.js
Normal file
29
core/server/api/v3/membersStripeConnect.js
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
96
core/server/api/v3/notifications.js
Normal file
96
core/server/api/v3/notifications.js
Normal 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();
|
||||
}
|
||||
}
|
||||
};
|
38
core/server/api/v3/oembed.js
Normal file
38
core/server/api/v3/oembed.js
Normal 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));
|
||||
}
|
||||
}
|
||||
};
|
74
core/server/api/v3/pages-public.js
Normal file
74
core/server/api/v3/pages-public.js
Normal 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
211
core/server/api/v3/pages.js
Normal 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')
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
74
core/server/api/v3/posts-public.js
Normal file
74
core/server/api/v3/posts-public.js
Normal 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
263
core/server/api/v3/posts.js
Normal 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')
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
42
core/server/api/v3/preview.js
Normal file
42
core/server/api/v3/preview.js
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
52
core/server/api/v3/redirects.js
Normal file
52
core/server/api/v3/redirects.js
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
19
core/server/api/v3/roles.js
Normal file
19
core/server/api/v3/roles.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
125
core/server/api/v3/schedules.js
Normal file
125
core/server/api/v3/schedules.js
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
66
core/server/api/v3/session.js
Normal file
66
core/server/api/v3/session.js
Normal 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;
|
17
core/server/api/v3/settings-public.js
Normal file
17
core/server/api/v3/settings-public.js
Normal 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)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
320
core/server/api/v3/settings.js
Normal file
320
core/server/api/v3/settings.js
Normal 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();
|
||||
}
|
||||
}
|
||||
};
|
25
core/server/api/v3/site.js
Normal file
25
core/server/api/v3/site.js
Normal 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;
|
11
core/server/api/v3/slack.js
Normal file
11
core/server/api/v3/slack.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
const {events} = require('../../lib/common');
|
||||
|
||||
module.exports = {
|
||||
docName: 'slack',
|
||||
sendTest: {
|
||||
permissions: false,
|
||||
query() {
|
||||
events.emit('slack.test');
|
||||
}
|
||||
}
|
||||
};
|
47
core/server/api/v3/slugs.js
Normal file
47
core/server/api/v3/slugs.js
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
109
core/server/api/v3/snippets.js
Normal file
109
core/server/api/v3/snippets.js
Normal 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')
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
67
core/server/api/v3/tags-public.js
Normal file
67
core/server/api/v3/tags-public.js
Normal 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
155
core/server/api/v3/tags.js
Normal 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')
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
118
core/server/api/v3/themes.js
Normal file
118
core/server/api/v3/themes.js
Normal 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
234
core/server/api/v3/users.js
Normal 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}));
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
34
core/server/api/v3/utils/index.js
Normal file
34
core/server/api/v3/utils/index.js
Normal 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';
|
||||
}
|
||||
};
|
112
core/server/api/v3/utils/permissions.js
Normal file
112
core/server/api/v3/utils/permissions.js
Normal 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);
|
||||
}
|
||||
};
|
9
core/server/api/v3/utils/serializers/index.js
Normal file
9
core/server/api/v3/utils/serializers/index.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
module.exports = {
|
||||
get input() {
|
||||
return require('./input');
|
||||
},
|
||||
|
||||
get output() {
|
||||
return require('./output');
|
||||
}
|
||||
};
|
31
core/server/api/v3/utils/serializers/input/authors.js
Normal file
31
core/server/api/v3/utils/serializers/input/authors.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
20
core/server/api/v3/utils/serializers/input/db.js
Normal file
20
core/server/api/v3/utils/serializers/input/db.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
41
core/server/api/v3/utils/serializers/input/index.js
Normal file
41
core/server/api/v3/utils/serializers/input/index.js
Normal 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');
|
||||
}
|
||||
};
|
33
core/server/api/v3/utils/serializers/input/integrations.js
Normal file
33
core/server/api/v3/utils/serializers/input/integrations.js
Normal 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);
|
||||
}
|
||||
};
|
62
core/server/api/v3/utils/serializers/input/members.js
Normal file
62
core/server/api/v3/utils/serializers/input/members.js
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
208
core/server/api/v3/utils/serializers/input/pages.js
Normal file
208
core/server/api/v3/utils/serializers/input/pages.js
Normal 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);
|
||||
}
|
||||
};
|
231
core/server/api/v3/utils/serializers/input/posts.js
Normal file
231
core/server/api/v3/utils/serializers/input/posts.js
Normal 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);
|
||||
}
|
||||
};
|
99
core/server/api/v3/utils/serializers/input/settings.js
Normal file
99
core/server/api/v3/utils/serializers/input/settings.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
42
core/server/api/v3/utils/serializers/input/tags.js
Normal file
42
core/server/api/v3/utils/serializers/input/tags.js
Normal 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);
|
||||
}
|
||||
};
|
26
core/server/api/v3/utils/serializers/input/users.js
Normal file
26
core/server/api/v3/utils/serializers/input/users.js
Normal 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]));
|
||||
}
|
||||
};
|
|
@ -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;
|
66
core/server/api/v3/utils/serializers/input/utils/url.js
Normal file
66
core/server/api/v3/utils/serializers/input/utils/url.js
Normal 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;
|
12
core/server/api/v3/utils/serializers/input/webhooks.js
Normal file
12
core/server/api/v3/utils/serializers/input/webhooks.js
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
13
core/server/api/v3/utils/serializers/output/actions.js
Normal file
13
core/server/api/v3/utils/serializers/output/actions.js
Normal 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
|
||||
};
|
||||
}
|
||||
};
|
25
core/server/api/v3/utils/serializers/output/all.js
Normal file
25
core/server/api/v3/utils/serializers/output/all.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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
|
||||
}]
|
||||
};
|
||||
}
|
||||
};
|
21
core/server/api/v3/utils/serializers/output/authors.js
Normal file
21
core/server/api/v3/utils/serializers/output/authors.js
Normal 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)]
|
||||
};
|
||||
}
|
||||
};
|
10
core/server/api/v3/utils/serializers/output/config.js
Normal file
10
core/server/api/v3/utils/serializers/output/config.js
Normal 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
|
||||
};
|
||||
}
|
||||
};
|
40
core/server/api/v3/utils/serializers/output/db.js
Normal file
40
core/server/api/v3/utils/serializers/output/db.js
Normal 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: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
module.exports = {
|
||||
read(emailPreview, apiConfig, frame) {
|
||||
frame.response = {
|
||||
email_previews: [emailPreview]
|
||||
};
|
||||
}
|
||||
};
|
13
core/server/api/v3/utils/serializers/output/emails.js
Normal file
13
core/server/api/v3/utils/serializers/output/emails.js
Normal 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;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
module.exports = {
|
||||
read(data, apiConfig, frame) {
|
||||
frame.response = {
|
||||
identities: [data]
|
||||
};
|
||||
}
|
||||
};
|
15
core/server/api/v3/utils/serializers/output/images.js
Normal file
15
core/server/api/v3/utils/serializers/output/images.js
Normal 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
|
||||
}]
|
||||
};
|
||||
}
|
||||
};
|
129
core/server/api/v3/utils/serializers/output/index.js
Normal file
129
core/server/api/v3/utils/serializers/output/index.js
Normal 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');
|
||||
}
|
||||
};
|
35
core/server/api/v3/utils/serializers/output/integrations.js
Normal file
35
core/server/api/v3/utils/serializers/output/integrations.js
Normal 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)]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
24
core/server/api/v3/utils/serializers/output/invites.js
Normal file
24
core/server/api/v3/utils/serializers/output/invites.js
Normal 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)]
|
||||
};
|
||||
}
|
||||
};
|
25
core/server/api/v3/utils/serializers/output/labels.js
Normal file
25
core/server/api/v3/utils/serializers/output/labels.js
Normal 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)]
|
||||
};
|
||||
}
|
||||
};
|
19
core/server/api/v3/utils/serializers/output/mail.js
Normal file
19
core/server/api/v3/utils/serializers/output/mail.js
Normal 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;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
module.exports = {
|
||||
read(data, apiConfig, frame) {
|
||||
frame.response = {
|
||||
member_signin_urls: [data]
|
||||
};
|
||||
}
|
||||
};
|
236
core/server/api/v3/utils/serializers/output/members.js
Normal file
236
core/server/api/v3/utils/serializers/output/members.js
Normal 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
|
||||
*/
|
28
core/server/api/v3/utils/serializers/output/notifications.js
Normal file
28
core/server/api/v3/utils/serializers/output/notifications.js
Normal 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
|
||||
};
|
||||
}
|
||||
};
|
8
core/server/api/v3/utils/serializers/output/oembed.js
Normal file
8
core/server/api/v3/utils/serializers/output/oembed.js
Normal 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;
|
||||
}
|
||||
};
|
26
core/server/api/v3/utils/serializers/output/pages.js
Normal file
26
core/server/api/v3/utils/serializers/output/pages.js
Normal 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)]
|
||||
};
|
||||
}
|
||||
};
|
26
core/server/api/v3/utils/serializers/output/posts.js
Normal file
26
core/server/api/v3/utils/serializers/output/posts.js
Normal 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)]
|
||||
};
|
||||
}
|
||||
};
|
10
core/server/api/v3/utils/serializers/output/preview.js
Normal file
10
core/server/api/v3/utils/serializers/output/preview.js
Normal 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';
|
||||
}
|
||||
};
|
5
core/server/api/v3/utils/serializers/output/redirects.js
Normal file
5
core/server/api/v3/utils/serializers/output/redirects.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
download(response, apiConfig, frame) {
|
||||
frame.response = response;
|
||||
}
|
||||
};
|
29
core/server/api/v3/utils/serializers/output/roles.js
Normal file
29
core/server/api/v3/utils/serializers/output/roles.js
Normal 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
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
5
core/server/api/v3/utils/serializers/output/schedules.js
Normal file
5
core/server/api/v3/utils/serializers/output/schedules.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
all(model, apiConfig, frame) {
|
||||
frame.response = model;
|
||||
}
|
||||
};
|
66
core/server/api/v3/utils/serializers/output/settings.js
Normal file
66
core/server/api/v3/utils/serializers/output/settings.js
Normal 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;
|
||||
}
|
||||
};
|
11
core/server/api/v3/utils/serializers/output/site.js
Normal file
11
core/server/api/v3/utils/serializers/output/site.js
Normal 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
|
||||
};
|
||||
}
|
||||
};
|
11
core/server/api/v3/utils/serializers/output/slugs.js
Normal file
11
core/server/api/v3/utils/serializers/output/slugs.js
Normal 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}]
|
||||
};
|
||||
}
|
||||
};
|
96
core/server/api/v3/utils/serializers/output/snippets.js
Normal file
96
core/server/api/v3/utils/serializers/output/snippets.js
Normal 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
|
||||
*/
|
25
core/server/api/v3/utils/serializers/output/tags.js
Normal file
25
core/server/api/v3/utils/serializers/output/tags.js
Normal 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)]
|
||||
};
|
||||
}
|
||||
};
|
25
core/server/api/v3/utils/serializers/output/themes.js
Normal file
25
core/server/api/v3/utils/serializers/output/themes.js
Normal 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;
|
||||
}
|
||||
};
|
70
core/server/api/v3/utils/serializers/output/users.js
Normal file
70
core/server/api/v3/utils/serializers/output/users.js
Normal 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)
|
||||
};
|
||||
}
|
||||
|
||||
};
|
158
core/server/api/v3/utils/serializers/output/utils/clean.js
Normal file
158
core/server/api/v3/utils/serializers/output/utils/clean.js
Normal 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;
|
21
core/server/api/v3/utils/serializers/output/utils/date.js
Normal file
21
core/server/api/v3/utils/serializers/output/utils/date.js
Normal 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;
|
124
core/server/api/v3/utils/serializers/output/utils/extra-attrs.js
Normal file
124
core/server/api/v3/utils/serializers/output/utils/extra-attrs.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
176
core/server/api/v3/utils/serializers/output/utils/mapper.js
Normal file
176
core/server/api/v3/utils/serializers/output/utils/mapper.js
Normal 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;
|
|
@ -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;
|
|
@ -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;
|
125
core/server/api/v3/utils/serializers/output/utils/url.js
Normal file
125
core/server/api/v3/utils/serializers/output/utils/url.js
Normal 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;
|
15
core/server/api/v3/utils/serializers/output/webhooks.js
Normal file
15
core/server/api/v3/utils/serializers/output/webhooks.js
Normal 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)]
|
||||
};
|
||||
}
|
||||
};
|
9
core/server/api/v3/utils/validators/index.js
Normal file
9
core/server/api/v3/utils/validators/index.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
module.exports = {
|
||||
get input() {
|
||||
return require('./input');
|
||||
},
|
||||
|
||||
get output() {
|
||||
return require('./output');
|
||||
}
|
||||
};
|
80
core/server/api/v3/utils/validators/input/images.js
Normal file
80
core/server/api/v3/utils/validators/input/images.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
61
core/server/api/v3/utils/validators/input/index.js
Normal file
61
core/server/api/v3/utils/validators/input/index.js
Normal 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');
|
||||
}
|
||||
};
|
41
core/server/api/v3/utils/validators/input/invitations.js
Normal file
41
core/server/api/v3/utils/validators/input/invitations.js
Normal 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')
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
17
core/server/api/v3/utils/validators/input/invites.js
Normal file
17
core/server/api/v3/utils/validators/input/invites.js
Normal 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
Loading…
Reference in a new issue