🔥 Removed versioned APIs

refs: https://github.com/TryGhost/Toolbox/issues/229

- we are getting rid of the concept of having multiple api versions in a single ghost install
- removed all the code for multiple api versions & left canary wired up, but without the version in the URL
- TODO: reorganise the folders so there's no canary folder when we're closer to shipping
        we need to minimise the pain of merging changes across from main for now
This commit is contained in:
Hannah Wolfe 2022-04-06 13:36:12 +01:00 committed by Daniel Lockyer
parent d7ff49dc88
commit a4a9ba7940
No known key found for this signature in database
GPG Key ID: D21186F0B47295AD
241 changed files with 31 additions and 14207 deletions

View File

@ -26,8 +26,6 @@
"!core/server/web/parent/middleware/**", "!core/server/web/parent/middleware/**",
"core/server/web/shared/**", "core/server/web/shared/**",
"!core/server/web/shared/middleware/**", "!core/server/web/shared/middleware/**",
"core/server/api/v2/**",
"core/server/api/v3/**",
"core/server/api/canary/**", "core/server/api/canary/**",
"!core/server/api/canary/utils" "!core/server/api/canary/utils"
] ]

View File

@ -39,7 +39,7 @@ module.exports = {
settingsCache: settingsCache, settingsCache: settingsCache,
// TODO: Expose less of the API to make this safe // TODO: Expose less of the API to make this safe
api: require('../../server/api').canary, api: require('../../server/api').endpoints,
// Labs utils for enabling/disabling helpers // Labs utils for enabling/disabling helpers
labs: require('../../shared/labs'), labs: require('../../shared/labs'),

View File

@ -9,7 +9,7 @@ const getSchedulerIntegration = require('./scheduler-intergation');
* @return {Promise} * @return {Promise}
*/ */
const loadScheduledResources = async function () { const loadScheduledResources = async function () {
const api = require('../../../api'); const api = require('../../../api').endpoints;
const SCHEDULED_RESOURCES = ['post', 'page']; const SCHEDULED_RESOURCES = ['post', 'page'];
// Fetches all scheduled resources(posts/pages) with default API // Fetches all scheduled resources(posts/pages) with default API

View File

@ -1,8 +1,4 @@
# API Versioning # API
Ghost supports multiple API versions.
Each version lives in a separate folder e.g. api/v2, api/v3, api/canary etc.
Next to the API folders there is a shared folder, which contains shared code, which all API versions use.
## Stages ## Stages
@ -19,7 +15,7 @@ The framework we are building pipes a request through these stages in respect of
## Frame ## Frame
Is a class, which holds all the information for request processing. We pass this instance by reference. Is a class, which holds all the information for request processing. We pass this instance by reference.
Each function can modify the original instance. No need to return the class instance. Each function can modify the original instance. No need to return the class instance.
### Structure ### Structure

View File

@ -8,10 +8,6 @@ const localUtils = require('./utils');
/* eslint-disable max-lines */ /* eslint-disable max-lines */
module.exports = { module.exports = {
get http() {
return shared.http;
},
get authentication() { get authentication() {
return shared.pipeline(require('./authentication'), localUtils); return shared.pipeline(require('./authentication'), localUtils);
}, },

View File

@ -1,9 +1,3 @@
const defaultAPI = require('./canary'); module.exports.endpoints = require('./canary');
module.exports = defaultAPI;
module.exports.canary = defaultAPI;
module.exports.v4 = defaultAPI;
module.exports.v3 = require('./v3');
module.exports.v2 = require('./v2');
module.exports.shared = require('./shared'); module.exports.shared = require('./shared');

View File

@ -1,38 +0,0 @@
const models = require('../../models');
module.exports = {
docName: 'actions',
browse: {
options: [
'page',
'limit',
'fields'
],
data: [
'id',
'type'
],
validation: {
id: {
required: true
},
type: {
required: true,
values: ['resource', 'actor']
}
},
permissions: true,
query(frame) {
if (frame.data.type === 'resource') {
frame.options.withRelated = ['actor'];
frame.options.filter = `resource_id:${frame.data.id}`;
} else {
frame.options.withRelated = ['resource'];
frame.options.filter = `actor_id:${frame.data.id}`;
}
return models.Action.findPage(frame.options);
}
}
};

View File

@ -1,191 +0,0 @@
const api = require('./index');
const config = require('../../../shared/config');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const web = require('../../web');
const models = require('../../models');
const auth = require('../../services/auth');
const invitations = require('../../services/invitations');
const messages = {
notTheBlogOwner: 'You are not the site owner.'
};
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: tpl(messages.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,
async query() {
const isSetup = await auth.setup.checkIsSetup();
return {
status: isSetup,
title: config.title,
name: config.user_name,
email: config.user_email
};
}
},
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.middleware.api.spamPrevention.userLogin().reset(frame.options.ip, `${tokenParts.email}login`);
return params;
});
});
}
},
acceptInvitation: {
validation: {
docName: 'invitations'
},
permissions: false,
query(frame) {
return Promise.resolve()
.then(() => {
return auth.setup.assertSetupCompleted(true)();
})
.then(() => {
return invitations.accept(frame.data);
});
}
},
isInvitation: {
data: [
'email'
],
validation: {
docName: 'invitations'
},
permissions: false,
query(frame) {
return Promise.resolve()
.then(() => {
return auth.setup.assertSetupCompleted(true)();
})
.then(() => {
const email = frame.data.email;
return models.Invite.findOne({email: email, status: 'sent'}, frame.options);
});
}
}
};

View File

@ -1,69 +0,0 @@
const Promise = require('bluebird');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const models = require('../../models');
const ALLOWED_INCLUDES = ['count.posts'];
const messages = {
authorsNotFound: 'Author not found.'
};
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: tpl(messages.authorsNotFound)
}));
}
return model;
});
}
}
};

View File

@ -1,12 +0,0 @@
const publicConfig = require('../../services/public-config');
module.exports = {
docName: 'config',
read: {
permissions: false,
query() {
return publicConfig.config;
}
}
};

View File

@ -1,120 +0,0 @@
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.BACKUP_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'
],
validation: {
options: {
include: {
values: exporter.BACKUP_TABLES
}
}
},
headers: {
disposition: {
type: 'file',
value: () => (exporter.fileName())
}
},
permissions: true,
query(frame) {
return Promise.resolve()
.then(() => exporter.doExport({include: frame.options.withRelated}))
.catch((err) => {
return Promise.reject(new errors.InternalServerError({err: err}));
});
}
},
importContent: {
options: [
'include'
],
validation: {
options: {
include: {
values: exporter.BACKUP_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.InternalServerError({
err: err
});
});
});
}
return dbBackup.backup().then(deleteContent);
}
}
};

View File

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

View File

@ -1,147 +0,0 @@
const shared = require('../shared');
const localUtils = require('./utils');
// ESLint Override Notice
// This is a valid index.js file - it just exports a lot of stuff!
// Long term we would like to change the API architecture to reduce this file,
// but that's not the problem the index.js max - line eslint "proxy" rule is there to solve.
/* eslint-disable max-lines */
module.exports = {
get http() {
return shared.http;
},
get authentication() {
return shared.pipeline(require('./authentication'), localUtils);
},
get db() {
return shared.pipeline(require('./db'), 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 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 site() {
return shared.pipeline(require('./site'), 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.v2.content` and
* `api.v2.admin` soon. Need to figure out how serializers & validation works then.
*/
get pagesPublic() {
return shared.pipeline(require('./pages-public'), localUtils, 'content');
},
get tagsPublic() {
return shared.pipeline(require('./tags-public'), localUtils, 'content');
},
get publicSettings() {
return shared.pipeline(require('./settings-public'), localUtils, 'content');
},
get postsPublic() {
return shared.pipeline(require('./posts-public'), localUtils, 'content');
},
get authorsPublic() {
return shared.pipeline(require('./authors-public'), localUtils, 'content');
}
};

View File

@ -1,144 +0,0 @@
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const models = require('../../models');
const messages = {
resourceNotFound: '{resource} not found.'
};
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: tpl(messages.resourceNotFound, {resource: 'Integration'})
});
});
}
},
edit: {
permissions: true,
data: [
'name',
'icon_image',
'description',
'webhooks'
],
options: [
'id',
'include'
],
validation: {
options: {
id: {
required: true
},
include: {
values: ['api_keys', 'webhooks']
}
}
},
query({data, options}) {
return models.Integration.edit(data, Object.assign(options, {require: true}))
.catch(models.Integration.NotFoundError, () => {
throw new errors.NotFoundError({
message: tpl(messages.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: tpl(messages.resourceNotFound, {resource: 'Integration'})
}));
});
}
}
};

View File

@ -1,126 +0,0 @@
const Promise = require('bluebird');
const tpl = require('@tryghost/tpl');
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'];
const messages = {
inviteNotFound: 'Invite not found.'
};
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: tpl(messages.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: tpl(messages.inviteNotFound)
}));
});
}
},
add: {
statusCode: 201,
options: [
'include',
'email'
],
validation: {
options: {
include: ALLOWED_INCLUDES
},
data: {
role_id: {
required: true
},
email: {
required: true
}
}
},
permissions: {
unsafeAttrs: UNSAFE_ATTRS
},
query(frame) {
return invites.add({
api,
InviteModel: models.Invite,
invites: frame.data.invites,
options: frame.options,
user: {
name: frame.user.get('name'),
email: frame.user.get('email')
}
});
}
}
};

View File

@ -1,66 +0,0 @@
const Promise = require('bluebird');
const tpl = require('@tryghost/tpl');
const mailService = require('../../services/mail');
const api = require('./');
let mailer;
let _private = {};
const messages = {
unableToSendEmail: 'Ghost is currently unable to send email.',
seeLinkForInstructions: 'See {link} for instructions.',
testGhostEmail: 'Test Ghost Email'
};
_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: [
tpl(messages.unableToSendEmail),
tpl(messages.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: tpl(messages.testGhostEmail),
html: content.html,
text: content.text
}
}]
};
return _private.sendMail(payload);
});
}
};

View File

@ -1,96 +0,0 @@
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,
async query(frame) {
const allNotifications = await notifications.destroy({
notificationId: frame.options.notification_id,
user: {
id: frame.user && frame.user.id
}
});
return api.settings.edit({
settings: [{
key: 'notifications',
value: allNotifications
}]
}, internalContext).return();
}
},
/**
* Clears all notifications. Method used in tests only
*
* @private Not exposed over HTTP
*/
destroyAll: {
statusCode: 204,
permissions: {
method: 'destroy'
},
query() {
const allNotifications = notifications.destroyAll();
return api.settings.edit({
settings: [{
key: 'notifications',
value: allNotifications
}]
}, internalContext).return();
}
}
};

View File

@ -1,186 +0,0 @@
const errors = require('@tryghost/errors');
const {extract, hasProvider} = require('oembed-parser');
const Promise = require('bluebird');
const cheerio = require('cheerio');
const _ = require('lodash');
const config = require('../../../shared/config');
const tpl = require('@tryghost/tpl');
const externalRequest = require('../../lib/request-external');
const messages = {
unknownProvider: 'No provider found for supplied URL.'
};
const findUrlWithProvider = (url) => {
let provider;
// build up a list of URL variations to test against because the oembed
// providers list is not always up to date with scheme or www vs non-www
let baseUrl = url.replace(/^\/\/|^https?:\/\/(?:www\.)?/, '');
let testUrls = [
`http://${baseUrl}`,
`https://${baseUrl}`,
`http://www.${baseUrl}`,
`https://www.${baseUrl}`
];
for (let testUrl of testUrls) {
provider = hasProvider(testUrl);
if (provider) {
url = testUrl;
break;
}
}
return {url, provider};
};
function unknownProvider(url) {
return Promise.reject(new errors.ValidationError({
message: tpl(messages.unknownProvider),
context: url
}));
}
function knownProvider(url) {
return extract(url, {maxwidth: 1280}).catch((err) => {
return Promise.reject(new errors.InternalServerError({
message: err.message
}));
});
}
function isIpOrLocalhost(url) {
try {
const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /:/; // fqdns will not have colons
const HTTP_REGEX = /^https?:/i;
const siteUrl = new URL(config.get('url'));
const {protocol, hostname, host} = new URL(url);
// allow requests to Ghost's own url through
if (siteUrl.host === host) {
return false;
}
if (!HTTP_REGEX.test(protocol) || hostname === 'localhost' || IPV4_REGEX.test(hostname) || IPV6_REGEX.test(hostname)) {
return true;
}
return false;
} catch (e) {
return true;
}
}
function fetchOembedData(_url) {
// parse the url then validate the protocol and host to make sure it's
// http(s) and not an IP address or localhost to avoid potential access to
// internal network endpoints
if (isIpOrLocalhost(_url)) {
return unknownProvider();
}
// check against known oembed list
let {url, provider} = findUrlWithProvider(_url);
if (provider) {
return knownProvider(url);
}
// url not in oembed list so fetch it in case it's a redirect or has a
// <link rel="alternate" type="application/json+oembed"> element
return externalRequest(url, {
method: 'GET',
timeout: 2 * 1000,
followRedirect: true
}).then((pageResponse) => {
// url changed after fetch, see if we were redirected to a known oembed
if (pageResponse.url !== url) {
({url, provider} = findUrlWithProvider(pageResponse.url));
if (provider) {
return knownProvider(url);
}
}
// check for <link rel="alternate" type="application/json+oembed"> element
let oembedUrl;
try {
oembedUrl = cheerio('link[type="application/json+oembed"]', pageResponse.body).attr('href');
} catch (e) {
return unknownProvider(url);
}
if (oembedUrl) {
// make sure the linked url is not an ip address or localhost
if (isIpOrLocalhost(oembedUrl)) {
return unknownProvider(oembedUrl);
}
// fetch oembed response from embedded rel="alternate" url
return externalRequest(oembedUrl, {
method: 'GET',
json: true,
timeout: 2 * 1000,
followRedirect: true
}).then((oembedResponse) => {
// validate the fetched json against the oembed spec to avoid
// leaking non-oembed responses
const body = oembedResponse.body;
const hasRequiredFields = body.type && body.version;
const hasValidType = ['photo', 'video', 'link', 'rich'].includes(body.type);
if (hasRequiredFields && hasValidType) {
// extract known oembed fields from the response to limit leaking of unrecognised data
const knownFields = [
'type',
'version',
'html',
'url',
'title',
'width',
'height',
'author_name',
'author_url',
'provider_name',
'provider_url',
'thumbnail_url',
'thumbnail_width',
'thumbnail_height'
];
const oembed = _.pick(body, knownFields);
// ensure we have required data for certain types
if (oembed.type === 'photo' && !oembed.url) {
return;
}
if ((oembed.type === 'video' || oembed.type === 'rich') && (!oembed.html || !oembed.width || !oembed.height)) {
return;
}
// return the extracted object, don't pass through the response body
return oembed;
}
}).catch(() => {});
}
});
}
module.exports = {
docName: 'oembed',
read: {
permissions: false,
data: [
'url'
],
options: [],
query({data: {url}}) {
return fetchOembedData(url).then((response) => {
return response || unknownProvider(url);
}).catch(() => {
return unknownProvider(url);
});
}
}
};

View File

@ -1,78 +0,0 @@
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const models = require('../../models');
const ALLOWED_INCLUDES = ['tags', 'authors'];
const messages = {
pageNotFound: 'Page not found'
};
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: tpl(messages.pageNotFound)
});
}
return model;
});
}
}
};

View File

@ -1,197 +0,0 @@
const models = require('../../models');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const getPostServiceInstance = require('../../services/posts/posts-service');
const ALLOWED_INCLUDES = ['tags', 'authors', 'authors.roles'];
const UNSAFE_ATTRS = ['status', 'authors', 'visibility'];
const messages = {
pageNotFound: 'Page not found.'
};
const postsService = getPostServiceInstance();
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: tpl(messages.pageNotFound)
});
}
return model;
});
}
},
add: {
statusCode: 201,
headers: {},
options: [
'include',
'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',
'source',
// 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
},
async query(frame) {
const model = await models.Post.edit(frame.data.pages[0], frame.options);
this.headers.cacheInvalidate = postsService.handleCacheInvalidation(model);
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: tpl(messages.pageNotFound)
}));
});
}
}
};

View File

@ -1,78 +0,0 @@
const models = require('../../models');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const allowedIncludes = ['tags', 'authors'];
const messages = {
postNotFound: 'Post not found.'
};
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: tpl(messages.postNotFound)
});
}
return model;
});
}
}
};

View File

@ -1,192 +0,0 @@
const models = require('../../models');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const getPostServiceInstance = require('../../services/posts/posts-service');
const allowedIncludes = ['tags', 'authors', 'authors.roles'];
const unsafeAttrs = ['status', 'authors', 'visibility'];
const messages = {
postNotFound: 'Post not found.'
};
const postsService = getPostServiceInstance();
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: tpl(messages.postNotFound)
});
}
return model;
});
}
},
add: {
statusCode: 201,
headers: {},
options: [
'include',
'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',
'source',
// NOTE: only for internal context
'forUpdate',
'transacting'
],
validation: {
options: {
include: {
values: allowedIncludes
},
id: {
required: true
},
source: {
values: ['html']
}
}
},
permissions: {
unsafeAttrs: unsafeAttrs
},
async query(frame) {
const model = await models.Post.edit(frame.data.posts[0], frame.options);
this.headers.cacheInvalidate = postsService.handleCacheInvalidation(model);
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: tpl(messages.postNotFound)
}));
});
}
}
};

View File

@ -1,46 +0,0 @@
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const models = require('../../models');
const ALLOWED_INCLUDES = ['authors', 'tags'];
const messages = {
postNotFound: 'Post not found.'
};
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: tpl(messages.postNotFound)
});
}
return model;
});
}
}
};

View File

@ -1,28 +0,0 @@
const redirects = require('../../services/redirects');
module.exports = {
docName: 'redirects',
download: {
headers: {
disposition: {
type: 'file',
value: 'redirects.json'
}
},
permissions: true,
query() {
return redirects.api.get();
}
},
upload: {
permissions: true,
headers: {
cacheInvalidate: true
},
query(frame) {
return redirects.api.setFromFilePath(frame.file.path);
}
}
};

View File

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

View File

@ -1,77 +0,0 @@
const models = require('../../models');
const postSchedulingService = require('../../services/posts/post-scheduling-service')('canary');
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'
},
async query(frame) {
const resourceType = frame.options.resource;
const options = {
status: 'scheduled',
id: frame.options.id,
context: {
internal: true
}
};
const {scheduledResource, preScheduledResource} = await postSchedulingService.publish(resourceType, frame.options.id, frame.data.force, options);
const cacheInvalidate = postSchedulingService.handleCacheInvalidation(scheduledResource, preScheduledResource);
this.headers.cacheInvalidate = cacheInvalidate;
const response = {};
response[resourceType] = [scheduledResource];
return response;
}
},
getScheduled: {
// NOTE: this method is for internal use only by DefaultScheduler
// it is not exposed anywhere!
permissions: false,
validation: {
options: {
resource: {
required: true,
values: ['posts', 'pages']
}
}
},
query(frame) {
const resourceModel = 'Post';
const resourceType = (frame.options.resource === 'post') ? 'post' : 'page';
const cleanOptions = {};
cleanOptions.filter = `status:scheduled+type:${resourceType}`;
cleanOptions.columns = ['id', 'published_at', 'created_at', 'type'];
return models[resourceModel].findAll(cleanOptions)
.then((result) => {
let response = {};
response[resourceType] = result;
return response;
});
}
}
};

View File

@ -1,70 +0,0 @@
const Promise = require('bluebird');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const models = require('../../models');
const auth = require('../../services/auth');
const api = require('./index');
const messages = {
authAccessDenied: 'Access denied.'
};
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: tpl(messages.authAccessDenied)
}));
}
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.isGhostError(err)) {
throw new errors.UnauthorizedError({
message: tpl(messages.authAccessDenied),
err
});
}
if (err.errorType === 'PasswordResetRequiredError') {
await api.authentication.generateResetToken({
passwordreset: [{
email: object.username
}]
}, frame.options.context);
}
throw err;
});
},
delete() {
return Promise.resolve((req, res, next) => {
auth.session.destroySession(req, res, next);
});
}
};
module.exports = session;

View File

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

View File

@ -1,195 +0,0 @@
const Promise = require('bluebird');
const _ = require('lodash');
const models = require('../../models');
const routeSettings = require('../../services/route-settings');
const tpl = require('@tryghost/tpl');
const {NoPermissionError, NotFoundError} = require('@tryghost/errors');
const settingsService = require('../../services/settings');
const settingsCache = require('../../../shared/settings-cache');
const messages = {
problemFindingSetting: 'Problem finding setting: {key}',
accessCoreSettingFromExtReq: 'Attempted to access core setting from external request'
};
module.exports = {
docName: 'settings',
browse: {
options: ['type'],
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';
}));
}
if (!frame.options.context.internal) {
// CASE: omit core settings unless internal request
settings = _.filter(settings, (setting) => {
const isCore = setting.group === 'core';
return !isCore;
});
// CASE: omit secret settings unless internal request
settings = settings.map(settingsService.hideValueIfSecret);
}
return settings;
}
},
read: {
options: ['key'],
validation: {
options: {
key: {
required: true
}
}
},
permissions: {
identifier(frame) {
return frame.options.key;
}
},
query(frame) {
let setting;
if (frame.options.key === 'slack') {
const slackURL = settingsCache.get('slack_url', {resolve: false});
const slackUsername = settingsCache.get('slack_username', {resolve: false});
setting = slackURL || slackUsername;
setting.key = 'slack';
setting.value = [{
url: slackURL && slackURL.value,
username: slackUsername && slackUsername.value
}];
} else if (frame.options.key === 'slack_url' || frame.options.key === 'slack_username') {
// leave the value empty returning 404 for unknown in current API keys
} else {
setting = settingsCache.get(frame.options.key, {resolve: false});
}
if (!setting) {
return Promise.reject(new NotFoundError({
message: tpl(messages.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: tpl(messages.accessCoreSettingFromExtReq)
}));
}
setting = settingsService.hideValueIfSecret(setting);
return {
[frame.options.key]: setting
};
}
},
edit: {
headers: {
cacheInvalidate: true
},
permissions: {
unsafeAttrsObject(frame) {
return _.find(frame.data.settings, {key: 'labs'});
},
before(frame) {
const errors = [];
// Using eslint disable line here as we are about to drop v2 - no point in fixing
frame.data.settings.map((setting) => { /* eslint-disable-line array-callback-return */
if (setting.group === 'core' && !(frame.options.context && frame.options.context.internal)) {
errors.push(new NoPermissionError({
message: tpl(messages.accessCoreSettingFromExtReq)
}));
}
});
if (errors.length) {
return Promise.reject(errors[0]);
}
}
},
query(frame) {
let type = frame.data.settings.find((setting) => {
return setting.key === 'type';
});
if (_.isObject(type)) {
type = type.value;
}
frame.data.settings = _.reject(frame.data.settings, (setting) => {
return setting.key === 'type'
// Remove obfuscated settings
|| (setting.value === settingsService.obfuscatedSetting
&& settingsService.isSecretSetting(setting));
});
const errors = [];
_.each(frame.data.settings, (setting) => {
const settingFromCache = settingsCache.get(setting.key, {resolve: false});
if (!settingFromCache) {
errors.push(new NotFoundError({
message: tpl(messages.problemFindingSetting, {key: setting.key})
}));
} else if (settingFromCache.core === 'core' && !(frame.options.context && frame.options.context.internal)) {
// @TODO: handle in settings model permissible fn
errors.push(new NoPermissionError({
message: tpl(messages.accessCoreSettingFromExtReq)
}));
}
});
if (errors.length) {
return Promise.reject(errors[0]);
}
return models.Settings.edit(frame.data.settings, frame.options);
}
},
upload: {
headers: {
cacheInvalidate: true
},
permissions: {
method: 'edit'
},
async query(frame) {
await routeSettings.api.setFromFilePath(frame.file.path);
const getRoutesHash = () => routeSettings.api.getCurrentHash();
await settingsService.syncRoutesHash(getRoutesHash);
}
},
download: {
headers: {
disposition: {
type: 'yaml',
value: 'routes.yaml'
}
},
response: {
format: 'plain'
},
permissions: {
method: 'browse'
},
query() {
return routeSettings.api.get();
}
}
};

View File

@ -1,14 +0,0 @@
const publicConfig = require('../../services/public-config');
const site = {
docName: 'site',
read: {
permissions: false,
query() {
return publicConfig.site;
}
}
};
module.exports = site;

View File

@ -1,12 +0,0 @@
// Used to call the slack ping service, iirc this was done to avoid circular deps a long time ago
const events = require('../../lib/common/events');
module.exports = {
docName: 'slack',
sendTest: {
permissions: false,
query() {
events.emit('slack.test');
}
}
};

View File

@ -1,51 +0,0 @@
const models = require('../../models');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const messages = {
couldNotGenerateSlug: 'Could not generate slug.'
};
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.InternalServerError({
message: tpl(messages.couldNotGenerateSlug)
}));
}
return slug;
});
}
}
};

View File

@ -1,71 +0,0 @@
const Promise = require('bluebird');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const models = require('../../models');
const ALLOWED_INCLUDES = ['count.posts'];
const messages = {
tagNotFound: 'Tag not found.'
};
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: tpl(messages.tagNotFound)
}));
}
return model;
});
}
}
};

View File

@ -1,159 +0,0 @@
const Promise = require('bluebird');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const models = require('../../models');
const ALLOWED_INCLUDES = ['count.posts'];
const messages = {
tagNotFound: 'Tag not found.'
};
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: tpl(messages.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: tpl(messages.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: tpl(messages.tagNotFound)
}));
});
}
}
};

View File

@ -1,133 +0,0 @@
const themeService = require('../../services/themes');
const limitService = require('../../services/limits');
const models = require('../../models');
// Used to emit theme.uploaded which is used in core/server/analytics-events
const events = require('../../lib/common/events');
module.exports = {
docName: 'themes',
browse: {
permissions: true,
query() {
return themeService.api.getJSON();
}
},
activate: {
headers: {
cacheInvalidate: true
},
options: [
'name'
],
validation: {
options: {
name: {
required: true
}
}
},
permissions: true,
async query(frame) {
let themeName = frame.options.name;
if (limitService.isLimited('customThemes')) {
await limitService.errorIfWouldGoOverLimit('customThemes', {value: themeName});
}
const newSettings = [{
key: 'active_theme',
value: themeName
}];
return themeService.api.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.api.getJSON(themeName, checkedTheme);
});
}
},
upload: {
headers: {},
permissions: {
method: 'add'
},
async query(frame) {
if (limitService.isLimited('customThemes')) {
// Sending a bad string to make sure it fails (empty string isn't valid)
await limitService.errorIfWouldGoOverLimit('customThemes', {value: '.'});
}
// @NOTE: consistent filename uploads
{
frame.options.originalname = frame.file.originalname.toLowerCase();
}
let zip = {
path: frame.file.path,
name: frame.file.originalname
};
return themeService.api.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.api.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.api.destroy(themeName);
}
}
};

View File

@ -1,179 +0,0 @@
const Promise = require('bluebird');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const models = require('../../models');
const permissionsService = require('../../services/permissions');
const ALLOWED_INCLUDES = ['count.posts', 'permissions', 'roles', 'roles.permissions'];
const UNSAFE_ATTRS = ['status', 'roles'];
const messages = {
userNotFound: 'User not found.'
};
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: tpl(messages.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: tpl(messages.userNotFound)
}));
}
if (model.wasChanged()) {
this.headers.cacheInvalidate = true;
} else {
this.headers.cacheInvalidate = false;
}
return model;
});
}
},
destroy: {
statusCode: 204,
headers: {
cacheInvalidate: true
},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
query(frame) {
return models.Base.transaction((t) => {
frame.options.transacting = t;
return Promise.all([
models.Post.destroyByAuthor(frame.options)
]).then(() => {
return models.User.destroy(Object.assign({status: 'all'}, frame.options));
}).then(() => null);
}).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);
}
}
};

View File

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

View File

@ -1,112 +0,0 @@
const debug = require('@tryghost/debug')('api:v2:utils:permissions');
const Promise = require('bluebird');
const _ = require('lodash');
const permissions = require('../../../services/permissions');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const messages = {
noPermissionToCall: 'You do not have permission to {method} {docName}'
};
/**
* @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');
const 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 = tpl(messages.noPermissionToCall, {
method: apiConfig.method,
docName: apiConfig.docName
});
return Promise.reject(err);
}
if (errors.utils.isGhostError(err)) {
return Promise.reject(err);
}
return Promise.reject(new errors.InternalServerError({
err: err
}));
});
};
// @TODO: https://github.com/TryGhost/Ghost/issues/10735
module.exports = {
/**
* @description Handle permission stage for API version v2.
*
* @param {Object} apiConfig - Docname & method of target ctrl.
* @param {Object} frame
* @return {Promise}
*/
handle(apiConfig, frame) {
debug('handle');
// @TODO: https://github.com/TryGhost/Ghost/issues/10099
frame.options.context = permissions.parseContext(frame.options.context);
// CASE: Content API access
if (frame.options.context.public) {
debug('check content permissions');
// @TODO: Remove when we drop v0.1
// @TODO: https://github.com/TryGhost/Ghost/issues/10733
return permissions.applyPublicRules(apiConfig.docName, apiConfig.method, {
status: frame.options.status,
id: frame.options.id,
uuid: frame.options.uuid,
slug: frame.options.slug,
data: {
status: frame.data.status,
id: frame.data.id,
uuid: frame.data.uuid,
slug: frame.data.slug
}
});
}
return nonePublicAuth(apiConfig, frame);
}
};

View File

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

View File

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

View File

@ -1,33 +0,0 @@
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 tags() {
return require('./tags');
},
get webhooks() {
return require('./webhooks');
}
};

View File

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

View File

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

View File

@ -1,218 +0,0 @@
const _ = require('lodash');
const mapNQLKeyValues = require('@tryghost/nql').utils.mapKeyValues;
const debug = require('@tryghost/debug')('api:v2:utils:serializers:input:posts');
const url = require('./utils/url');
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'];
}
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.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);
/**
* ## 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);
/**
* ## current cases:
* - context object is empty (functional call, content api access)
* - api_key.type == 'content' ? content api access
* - user exists? admin api access
*/
if (localUtils.isContentAPI(frame)) {
// CASE: the content api endpoint for posts should not return mobiledoc
removeMobiledocFormat(frame);
setDefaultOrder(frame);
forceVisibilityColumn(frame);
}
if (!localUtils.isContentAPI(frame)) {
forceStatusFilter(frame);
defaultFormat(frame);
defaultRelations(frame);
}
},
add(apiConfig, frame, options = {add: true}) {
debug('add');
if (_.get(frame,'options.source')) {
const html = frame.data.posts[0].html;
if (frame.options.source === 'html' && !_.isEmpty(html)) {
frame.data.posts[0].mobiledoc = JSON.stringify(mobiledoc.htmlToMobiledocConverter(html));
}
}
frame.data.posts[0] = url.forPost(Object.assign({}, frame.data.posts[0]), frame.options);
// @NOTE: force adding post
if (options.add) {
frame.data.posts[0].type = 'post';
}
// CASE: Transform short to long format
if (frame.data.posts[0].authors) {
frame.data.posts[0].authors.forEach((author, index) => {
if (_.isString(author)) {
frame.data.posts[0].authors[index] = {
email: author
};
}
});
}
if (frame.data.posts[0].tags) {
frame.data.posts[0].tags.forEach((tag, index) => {
if (_.isString(tag)) {
frame.data.posts[0].tags[index] = {
name: tag
};
}
});
}
handlePostsMeta(frame);
defaultFormat(frame);
defaultRelations(frame);
},
edit(apiConfig, frame) {
debug('edit');
this.add(apiConfig, frame, {add: false});
handlePostsMeta(frame);
forceStatusFilter(frame);
forcePageFilter(frame);
},
destroy(apiConfig, frame) {
debug('destroy');
frame.options.destroyBy = {
id: frame.options.id,
type: 'post'
};
defaultFormat(frame);
defaultRelations(frame);
}
};

View File

@ -1,152 +0,0 @@
const _ = require('lodash');
const url = require('./utils/url');
const typeGroupMapper = require('../../../../shared/serializers/input/utils/settings-filter-type-group-mapper');
const settingsCache = require('../../../../../../shared/settings-cache');
const DEPRECATED_SETTINGS = [
'bulk_email_settings',
'slack'
];
const deprecatedSupportedSettingsOneToManyMap = {
slack: [{
from: '[0].url',
to: {
key: 'slack_url',
group: 'slack',
type: 'string'
}
}, {
from: '[0].username',
to: {
key: 'slack_username',
group: 'slack',
type: 'string'
}
}]
};
const getMappedDeprecatedSettings = (settings) => {
const mappedSettings = [];
for (const key in deprecatedSupportedSettingsOneToManyMap) {
const deprecatedSetting = settings.find(setting => setting.key === key);
if (deprecatedSetting) {
let deprecatedSettingValue;
try {
deprecatedSettingValue = JSON.parse(deprecatedSetting.value);
} catch (err) {
// ignore the value if it's invalid
}
if (deprecatedSettingValue) {
deprecatedSupportedSettingsOneToManyMap[key].forEach(({from, to}) => {
const value = _.get(deprecatedSettingValue, from);
mappedSettings.push({
key: to.key,
value: value
});
});
}
}
}
return mappedSettings;
};
module.exports = {
browse(apiConfig, frame) {
if (frame.options.type) {
let mappedGroupOptions = typeGroupMapper(frame.options.type);
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 === 'default_locale') {
frame.options.key = 'lang';
}
if (frame.options.key === 'active_timezone') {
frame.options.key = 'timezone';
}
},
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();
frame.data.settings = frame.data.settings.filter((setting) => {
const settingFlagsStr = settings[setting.key] ? settings[setting.key].flags : '';
const settingFlagsArr = settingFlagsStr ? settingFlagsStr.split(',') : [];
// Ignore and drop all values with Read-only flag AND 'labs' setting
return !settingFlagsArr.includes('RO') && (setting.key !== 'labs');
});
frame.data.settings.push(...getMappedDeprecatedSettings(frame.data.settings));
frame.data.settings.forEach((setting) => {
const settingType = settings[setting.key] ? settings[setting.key].type : '';
// TODO: Needs to be removed once we get rid of all `object` type settings
// NOTE: this transformation is more related to the fact that internal API calls call
// settings API with plain objects instead of stringified ones
if (_.isObject(setting.value)) {
setting.value = JSON.stringify(setting.value);
}
// CASE: Ensure we won't forward strings for booleans, 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 for booleans, 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 === 'default_locale') {
setting.key = 'lang';
}
if (setting.key === 'active_timezone') {
setting.key = 'timezone';
}
if (setting.key === 'unsplash') {
setting.value = JSON.parse(setting.value).isActive;
}
setting = url.forSetting(setting);
});
// Ignore all deprecated settings
frame.data.settings = frame.data.settings.filter((setting) => {
return DEPRECATED_SETTINGS.includes(setting.key) === false;
});
}
};

View File

@ -1,35 +0,0 @@
const debug = require('@tryghost/debug')('api:v2:utils:serializers:input:tags');
const url = require('./utils/url');
const utils = require('../../index');
function setDefaultOrder(frame) {
if (!frame.options.order) {
frame.options.order = 'name asc';
}
}
module.exports = {
browse(apiConfig, frame) {
debug('browse');
if (utils.isContentAPI(frame)) {
setDefaultOrder(frame);
}
},
read() {
debug('read');
this.browse(...arguments);
},
add(apiConfig, frame) {
debug('add');
frame.data.tags[0] = url.forTag(Object.assign({}, frame.data.tags[0]));
},
edit(apiConfig, frame) {
debug('edit');
this.add(apiConfig, frame);
}
};

View File

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

View File

@ -1,71 +0,0 @@
const urlUtils = require('../../../../../../../shared/url-utils');
const handleImageUrl = (imageUrl) => {
try {
const imageURL = new URL(imageUrl, urlUtils.getSiteUrl());
const siteURL = new URL(urlUtils.getSiteUrl());
const subdir = siteURL.pathname.replace(/\/$/, '');
const imagePathRe = new RegExp(`${subdir}/${urlUtils.STATIC_IMAGE_URL_PREFIX}`);
if (imagePathRe.test(imageURL.pathname)) {
return urlUtils.relativeToAbsolute(imageUrl);
}
return imageUrl;
} catch (e) {
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 && ['cover_image', 'logo', 'icon', 'portal_button_icon', 'og_image', 'twitter_image'].includes(attrs.key)) {
attrs.value = urlUtils.relativeToAbsolute(attrs.value);
}
return attrs;
};
module.exports.forPost = forPost;
module.exports.forUser = forUser;
module.exports.forTag = forTag;
module.exports.forSetting = forSetting;

View File

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

View File

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

View File

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

View File

@ -1,68 +0,0 @@
const tpl = require('@tryghost/tpl');
const mapper = require('./utils/mapper');
const debug = require('@tryghost/debug')('api:v2:utils:serializers:output:authentication');
const messages = {
checkEmailForInstructions: 'Check your email for further instructions.',
passwordChanged: 'Password changed successfully.',
invitationAccepted: 'Invitation accepted.'
};
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: tpl(messages.checkEmailForInstructions)
}]
};
},
resetPassword(data, apiConfig, frame) {
frame.response = {
passwordreset: [{
message: tpl(messages.passwordChanged)
}]
};
},
acceptInvitation(data, apiConfig, frame) {
debug('acceptInvitation');
frame.response = {
invitation: [
{message: tpl(messages.invitationAccepted)}
]
};
},
isInvitation(data, apiConfig, frame) {
debug('acceptInvitation');
frame.response = {
invitation: [{
valid: !!data
}]
};
}
};

View File

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

View File

@ -1,21 +0,0 @@
const _ = require('lodash');
const debug = require('@tryghost/debug')('api:v2:utils:serializers:output:config');
module.exports = {
all(data, apiConfig, frame) {
debug('all');
frame.response = {
config: _.pick(data, [
'version',
'environment',
'database',
'mail',
'useGravatar',
'labs',
'clientExtensions',
'enableDeveloperExperiments'
])
};
}
};

View File

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

View File

@ -1,19 +0,0 @@
const debug = require('@tryghost/debug')('api:v2: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),
// NOTE: ref field is here to have reference point on the client
// for example when substituting existing images in the mobiledoc
// this field would serve as an identifier to find images to replace
// once the response is back. Think of it as ID on the client's side.
ref: frame.data.ref || null
}]
};
}
};

View File

@ -1,107 +0,0 @@
// ESLint Override Notice
// This is a valid index.js file - it just exports a lot of stuff!
// Long term we would like to change the API architecture to reduce this file,
// but that's not the problem the index.js max - line eslint "proxy" rule is there to solve.
/* eslint-disable max-lines */
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 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');
}
};

View File

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

View File

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

View File

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

View File

@ -1,29 +0,0 @@
const debug = require('@tryghost/debug')('api:v2: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;
delete notification.createdAtVersion;
});
frame.response = {
notifications: response
};
}
};

View File

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

View File

@ -1,26 +0,0 @@
const debug = require('@tryghost/debug')('api:v2: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.mapPost(model, frame)),
meta: models.meta
};
return;
}
frame.response = {
pages: [mapper.mapPost(models, frame)]
};
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,61 +0,0 @@
const _ = require('lodash');
const utils = require('../../index');
const mapper = require('./utils/mapper');
const _private = {};
const deprecatedSettings = ['secondary_nav'];
/**
* ### 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) && !_.includes(deprecatedSettings, setting.key);
}
return !_.includes(deprecatedSettings, setting.key);
});
};
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.type));
}
frame.response = {
settings: mapper.mapSettings(filteredSettings, frame),
meta: {}
};
if (frame.options.type) {
frame.response.meta.filters = {
type: frame.options.type
};
}
},
read() {
this.browse(...arguments);
},
edit(models, apiConfig, frame) {
const settingsKeyedJSON = _.keyBy(_.invokeMap(models, 'toJSON'), 'key');
this.browse(settingsKeyedJSON, apiConfig, frame);
},
download(bytes, apiConfig, frame) {
frame.response = bytes;
}
};

View File

@ -1,16 +0,0 @@
const _ = require('lodash');
const debug = require('@tryghost/debug')('api:v2:utils:serializers:output:site');
module.exports = {
read(data, apiConfig, frame) {
debug('read');
frame.response = {
site: _.pick(data, [
'title',
'url',
'version'
])
};
}
};

View File

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

View File

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

View File

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

View File

@ -1,45 +0,0 @@
const debug = require('@tryghost/debug')('api:v2:utils:serializers:output:users');
const tpl = require('@tryghost/tpl');
const mapper = require('./utils/mapper');
const messages = {
pwdChangedSuccessfully: 'Password changed successfully.'
};
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);
},
changePassword(models, apiConfig, frame) {
debug('changePassword');
frame.response = {
password: [{message: tpl(messages.pwdChangedSuccessfully)}]
};
},
transferOwnership(models, apiConfig, frame) {
debug('transferOwnership');
frame.response = {
users: models.map(model => model.toJSON(frame.options))
};
}
};

View File

@ -1,174 +0,0 @@
const _ = require('lodash');
const localUtils = require('../../../index');
const tag = (attrs, frame) => {
if (localUtils.isContentAPI(frame)) {
const contentAttrs = _.pick(attrs, [
'description',
'feature_image',
'id',
'meta_description',
'meta_title',
'name',
'slug',
'url',
'visibility',
'count'
]);
// We are standardising on returning null from the Content API for any empty values
if (contentAttrs.meta_title === '') {
contentAttrs.meta_title = null;
}
if (contentAttrs.meta_description === '') {
contentAttrs.meta_description = null;
}
if (contentAttrs.description === '') {
contentAttrs.description = null;
}
return contentAttrs;
}
return _.pick(attrs, [
'created_at',
'description',
'feature_image',
'id',
'meta_description',
'meta_title',
'name',
'slug',
'updated_at',
'url',
'visibility',
'count'
]);
};
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 v2
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) => {
if (localUtils.isContentAPI(frame)) {
// @TODO: https://github.com/TryGhost/Ghost/issues/10335
// delete attrs.page;
delete attrs.status;
delete attrs.email_only;
// 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;
}
delete attrs.visibility;
} else {
delete attrs.page;
if (!attrs.tags) {
delete attrs.primary_tag;
}
if (!attrs.authors) {
delete attrs.primary_author;
}
}
delete attrs.locale;
delete attrs.author;
delete attrs.type;
delete attrs.send_email_when_published;
delete attrs.email_recipient_filter;
delete attrs.email_subject;
delete attrs.feature_image_alt;
delete attrs.feature_image_caption;
return attrs;
};
const action = (attrs) => {
if (attrs.actor) {
delete attrs.actor_id;
delete attrs.resource_id;
if (attrs.actor_type === 'user') {
attrs.actor = _.pick(attrs.actor, ['id', 'name', 'slug', 'profile_image']);
attrs.actor.image = attrs.actor.profile_image;
delete attrs.actor.profile_image;
} else {
attrs.actor = _.pick(attrs.actor, ['id', 'name', 'slug', 'icon_image']);
attrs.actor.image = attrs.actor.icon_image;
delete attrs.actor.icon_image;
}
} else if (attrs.resource) {
delete attrs.actor_id;
delete attrs.resource_id;
// @NOTE: we only support posts right now
attrs.resource = _.pick(attrs.resource, ['id', 'title', 'slug', 'feature_image']);
attrs.resource.image = attrs.resource.feature_image;
delete attrs.resource.feature_image;
}
};
module.exports.post = post;
module.exports.tag = tag;
module.exports.author = author;
module.exports.action = action;

View File

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

View File

@ -1,161 +0,0 @@
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)) {
let plaintext = model.get('plaintext');
if (plaintext) {
attrs.excerpt = plaintext.substring(0, 500);
} else {
attrs.excerpt = null;
}
} else {
attrs.excerpt = attrs.custom_excerpt;
}
}
};
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);
delete 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 === 'codeinjection_head') {
return;
}
if (frame.original.params.key === 'codeinjection_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 === 'unsplash') {
attrs[0].value = JSON.stringify({
isActive: attrs[0].value
});
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: 'timezone'});
target.key = 'lang';
} else if (setting.key === 'slack') {
const slackURL = _.cloneDeep(_.find(attrs, {key: 'slack_url'}));
const slackUsername = _.cloneDeep(_.find(attrs, {key: 'slack_username'}));
if (slackURL || slackUsername) {
const slack = slackURL || slackUsername;
slack.key = 'slack';
slack.value = JSON.stringify([{
url: slackURL && slackURL.value,
username: slackUsername && slackUsername.value
}]);
attrs.push(slack);
}
} else if (setting.key === 'unsplash') {
const target = _.find(attrs, {key: 'unsplash'});
target.value = JSON.stringify({
isActive: target.value
});
}
});
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'}));
const slackURL = _.cloneDeep(_.find(attrs, {key: 'slack_url'}));
const slackUsername = _.cloneDeep(_.find(attrs, {key: 'slack_username'}));
const unsplash = _.find(attrs, {key: 'unsplash'});
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);
}
if (slackURL || slackUsername) {
const slack = slackURL || slackUsername;
slack.key = 'slack';
slack.value = JSON.stringify([{
url: slackURL && slackURL.value,
username: slackUsername && slackUsername.value
}]);
attrs.push(slack);
}
if (unsplash) {
unsplash.value = JSON.stringify({
isActive: unsplash.value
});
}
} else {
attrs.ghost_head = attrs.codeinjection_head;
attrs.ghost_foot = attrs.codeinjection_foot;
attrs.active_timezone = attrs.timezone;
attrs.default_locale = attrs.lang;
}
};

View File

@ -1,136 +0,0 @@
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 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);
const cleanedAttrs = clean.tag(jsonModel, frame);
return cleanedAttrs;
};
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 (!frame.options.columns || frame.options.columns.includes('page')) {
if (jsonModel.type === 'page') {
jsonModel.page = true;
} else {
jsonModel.page = false;
}
}
date.forPost(jsonModel);
gating.forPost(jsonModel, frame);
delete jsonModel.newsletter_id;
}
// 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) => {
if (!(attr === 'email_only')) {
jsonModel[attr] = _.get(jsonModel.posts_meta, attr) || null;
}
});
delete jsonModel.posts_meta;
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));
}
});
}
return jsonModel;
};
const mapSettings = (attrs, frame) => {
url.forSettings(attrs);
extraAttrs.forSettings(attrs, frame);
if (_.isArray(attrs)) {
const DEPRECATED_KEYS = ['lang', 'timezone', 'accent_color', 'slack_url', 'slack_username'];
attrs = _.filter(attrs, (o) => {
return !DEPRECATED_KEYS.includes(o.key);
});
} else {
delete attrs.lang;
delete attrs.timezone;
delete attrs.codeinjection_head;
delete attrs.codeinjection_foot;
delete attrs.accent_color;
}
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;
};
module.exports.mapPost = mapPost;
module.exports.mapUser = mapUser;
module.exports.mapTag = mapTag;
module.exports.mapIntegration = mapIntegration;
module.exports.mapSettings = mapSettings;
module.exports.mapImage = mapImage;
module.exports.mapAction = mapAction;

View File

@ -1,29 +0,0 @@
const membersService = require('../../../../../../services/members');
const htmlToPlaintext = require('../../../../../../../shared/html-to-plaintext');
const forPost = (attrs, frame) => {
const memberHasAccess = membersService.contentGating.checkPostAccess(attrs, frame.original.context.member);
if (!memberHasAccess) {
const paywallIndex = (attrs.html || '').indexOf('<!--members-only-->');
if (paywallIndex !== -1) {
attrs.html = attrs.html.slice(0, paywallIndex);
attrs.plaintext = htmlToPlaintext(attrs.html);
if (!attrs.custom_excerpt && attrs.excerpt) {
attrs.excerpt = attrs.plaintext.substring(0, 500);
}
} else {
['plaintext', 'html', 'excerpt'].forEach((field) => {
if (attrs[field] !== undefined) {
attrs[field] = '';
}
});
}
}
return attrs;
};
module.exports.forPost = forPost;

View File

@ -1,24 +0,0 @@
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',
oauth: 'oauth',
editor: 'editor'
};
const mapGroupToType = (group) => {
return groupTypeMapping[group];
};
module.exports = mapGroupToType;

View File

@ -1,67 +0,0 @@
const urlService = require('../../../../../../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/v2/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 (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});
}
return attrs;
};
const forTag = (id, attrs, options) => {
if (!options.columns || (options.columns && options.columns.includes('url'))) {
attrs.url = urlService.getUrlByResourceId(id, {absolute: true});
}
return attrs;
};
const forSettings = (attrs) => {
return attrs;
};
const forImage = (path) => {
return urlUtils.urlFor('image', {image: path}, true);
};
module.exports.forPost = forPost;
module.exports.forUser = forUser;
module.exports.forTag = forTag;
module.exports.forSettings = forSettings;
module.exports.forImage = forImage;

View File

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

View File

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

View File

@ -1,85 +0,0 @@
const jsonSchema = require('../utils/json-schema');
const config = require('../../../../../../shared/config');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const {imageSize, blogIcon} = require('../../../../../lib/image');
const messages = {
isNotSquare: 'Please select a valid image file with square dimensions.',
invalidFile: 'Please select a valid 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: tpl(messages.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: tpl(messages.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: tpl(messages.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: tpl(messages.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: tpl(messages.invalidFile, {extensions: iconExtensions})
}));
}
});
};
module.exports = {
upload(apiConfig, frame) {
return Promise.resolve()
.then(() => {
return jsonSchema.validate(apiConfig, frame);
})
.then(() => {
if (frame.data.purpose === 'profile_image') {
return profileImage(frame);
}
})
.then(() => {
if (frame.data.purpose === 'icon') {
return icon(frame);
}
});
}
};

View File

@ -1,45 +0,0 @@
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 settings() {
return require('./settings');
},
get tags() {
return require('./tags');
},
get users() {
return require('./users');
},
get images() {
return require('./images');
},
get oembed() {
return require('./oembed');
}
};

View File

@ -1,49 +0,0 @@
const Promise = require('bluebird');
const validator = require('@tryghost/validator');
const debug = require('@tryghost/debug')('api:v2:utils:validators:input:invitation');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const messages = {
noTokenProvided: 'No token provided.',
noEmailProvided: 'No email provided.',
noPasswordProvided: 'No password provided.',
noNameProvided: 'No name provided.',
invalidEmailReceived: 'The server did not receive a valid email'
};
module.exports = {
acceptInvitation(apiConfig, frame) {
debug('acceptInvitation');
const data = frame.data.invitation[0];
if (!data.token) {
return Promise.reject(new errors.ValidationError({message: tpl(messages.noTokenProvided)}));
}
if (!data.email) {
return Promise.reject(new errors.ValidationError({message: tpl(messages.noEmailProvided)}));
}
if (!data.password) {
return Promise.reject(new errors.ValidationError({message: tpl(messages.noPasswordProvided)}));
}
if (!data.name) {
return Promise.reject(new errors.ValidationError({message: tpl(messages.noNameProvided)}));
}
},
isInvitation(apiConfig, frame) {
debug('isInvitation');
const email = frame.data.email;
if (typeof email !== 'string' || !validator.isEmail(email)) {
throw new errors.BadRequestError({
message: tpl(messages.invalidEmailReceived)
});
}
}
};

View File

@ -1,21 +0,0 @@
const Promise = require('bluebird');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const models = require('../../../../../models');
const messages = {
userAlreadyRegistered: 'User is already registered.'
};
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: tpl(messages.userAlreadyRegistered)
}));
}
});
}
};

View File

@ -1,17 +0,0 @@
const Promise = require('bluebird');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const messages = {
noUrlProvided: 'No url provided.'
};
module.exports = {
read(apiConfig, frame) {
if (!frame.data.url || !frame.data.url.trim()) {
return Promise.reject(new errors.BadRequestError({
message: tpl(messages.noUrlProvided)
}));
}
}
};

View File

@ -1,46 +0,0 @@
const jsonSchema = require('../utils/json-schema');
const models = require('../../../../../models');
const {ValidationError} = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const messages = {
invalidVisibilityFilter: 'Invalid filter in visibility property'
};
const validateVisibility = async function (frame) {
if (!frame.data.pages || !frame.data.pages[0]) {
return Promise.resolve();
}
// validate visibility - not done at schema level because this can be an NQL query so needs model access
const visibility = frame.data.pages[0].visibility;
if (visibility) {
if (!['public', 'members', 'paid'].includes(visibility)) {
// check filter is valid
try {
await models.Member.findPage({filter: visibility, limit: 1});
return Promise.resolve();
} catch (err) {
return Promise.reject(new ValidationError({
message: tpl(messages.invalidVisibilityFilter),
property: 'visibility'
}));
}
}
return Promise.resolve();
}
};
module.exports = {
add(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
},
edit(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
}
};

View File

@ -1,36 +0,0 @@
const Promise = require('bluebird');
const validator = require('@tryghost/validator');
const debug = require('@tryghost/debug')('api:v2:utils:validators:input:passwordreset');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const messages = {
newPasswordsDoNotMatch: 'Your new passwords do not match',
invalidEmailReceived: 'The server did not receive a valid email'
};
module.exports = {
resetPassword(apiConfig, frame) {
debug('resetPassword');
const data = frame.data.passwordreset[0];
if (data.newPassword !== data.ne2Password) {
return Promise.reject(new errors.ValidationError({
message: tpl(messages.newPasswordsDoNotMatch)
}));
}
},
generateResetToken(apiConfig, frame) {
debug('generateResetToken');
const email = frame.data.passwordreset[0].email;
if (typeof email !== 'string' || !validator.isEmail(email)) {
throw new errors.BadRequestError({
message: tpl(messages.invalidEmailReceived)
});
}
}
};

View File

@ -1,46 +0,0 @@
const jsonSchema = require('../utils/json-schema');
const models = require('../../../../../models');
const {ValidationError} = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const messages = {
invalidVisibilityFilter: 'Invalid filter in visibility property'
};
const validateVisibility = async function (frame) {
if (!frame.data.posts || !frame.data.posts[0]) {
return Promise.resolve();
}
// validate visibility - not done at schema level because this can be an NQL query so needs model access
const visibility = frame.data.posts[0].visibility;
if (visibility) {
if (!['public', 'members', 'paid'].includes(visibility)) {
// check filter is valid
try {
await models.Member.findPage({filter: visibility, limit: 1});
return Promise.resolve();
} catch (err) {
return Promise.reject(new ValidationError({
message: tpl(messages.invalidVisibilityFilter),
property: 'visibility'
}));
}
}
return Promise.resolve();
}
};
module.exports = {
add(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
},
edit(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
}
};

View File

@ -1,79 +0,0 @@
const Promise = require('bluebird');
const _ = require('lodash');
const tpl = require('@tryghost/tpl');
const {BadRequestError, ValidationError} = require('@tryghost/errors');
const messages = {
error: 'Attempted to change active_theme via settings API',
help: 'Please activate theme via the themes API endpoints instead',
schemaValidationFailed: 'Validation failed for \'{key}\'.'
};
module.exports = {
edit(apiConfig, frame) {
const errors = [];
_.each(frame.data.settings, (setting) => {
if (setting.key === 'active_theme') {
// @NOTE: active theme has to be changed via theme endpoints
errors.push(
new BadRequestError({
message: tpl(messages.error),
help: tpl(messages.help)
})
);
}
if (setting.key === 'unsplash') {
// NOTE: unsplash is expected to have object format in v2 API to keep back compatibility
try {
JSON.parse(setting.value);
} catch (e) {
errors.push(
new ValidationError({
message: tpl(messages.schemaValidationFailed, {
key: 'unsplash'
}),
property: 'unsplash'
})
);
}
}
// TODO: the below array is INCOMPLETE
// it should include all setting values that have array as a type
const arrayTypeSettings = [
'notifications',
'navigation',
'secondary_navigation'
];
if (arrayTypeSettings.includes(setting.key)) {
const typeError = new ValidationError({
message: `Value in ${setting.key} should be an array.`,
property: 'value'
});
// NOTE: The additional check on raw value is here because internal calls to
// settings API use raw unstringified objects (e.g. when adding notifications)
// The conditional can be removed once internals are changed to do the calls properly
// and the JSON.parse should be left as the only valid way to check the value.
if (!_.isArray(setting.value)) {
try {
const parsedSettingValue = JSON.parse(setting.value);
if (!_.isArray(parsedSettingValue)) {
errors.push(typeError);
}
} catch (err) {
errors.push(typeError);
}
}
}
});
if (errors.length) {
return Promise.reject(errors[0]);
}
}
};

View File

@ -1,17 +0,0 @@
const debug = require('@tryghost/debug')('api:v2:utils:validators:input:updateSetup');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const messages = {
notTheBlogOwner: 'You are not the site owner.'
};
module.exports = {
updateSetup(apiConfig, frame) {
debug('resetPassword');
if (!frame.options.context || !frame.options.context.user) {
throw new errors.NoPermissionError({message: tpl(messages.notTheBlogOwner)});
}
}
};

View File

@ -1,6 +0,0 @@
const jsonSchema = require('../utils/json-schema');
module.exports = {
add: jsonSchema.validate,
edit: jsonSchema.validate
};

View File

@ -1,21 +0,0 @@
const Promise = require('bluebird');
const debug = require('@tryghost/debug')('api:v2:utils:validators:input:users');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const messages = {
newPasswordsDoNotMatch: 'Your new passwords do not match'
};
module.exports = {
changePassword(apiConfig, frame) {
debug('changePassword');
const data = frame.data.password[0];
if (data.newPassword !== data.ne2Password) {
return Promise.reject(new errors.ValidationError({
message: tpl(messages.newPasswordsDoNotMatch)
}));
}
}
};

View File

@ -1 +0,0 @@
module.exports = {};

View File

@ -1,17 +0,0 @@
const jsonSchema = require('@tryghost/admin-api-schema');
/**
*
* @param {Object} apiConfig "frame" api configuration object
* @param {string} apiConfig.docName the name of the resource
* @param {string} apiConfig.method API's method name
* @param {Object} frame "frame" object with data attached to it
* @param {Object} frame.data request data to validate
*/
const validate = async (apiConfig, frame) => await jsonSchema.validate({
data: frame.data,
schema: `${apiConfig.docName}-${apiConfig.method}`,
version: 'v2'
});
module.exports.validate = validate;

View File

@ -1,163 +0,0 @@
const models = require('../../models');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const messages = {
resourceNotFound: '{resource} not found.',
noPermissionToEdit: {
message: 'You do not have permission to {method} this webhook.',
context: 'You may only {method} webhooks that belong to the authenticated integration. Check the supplied Admin API Key.'
},
webhookAlreadyExists: 'Target URL has already been used for this event.'
};
module.exports = {
docName: 'webhooks',
add: {
statusCode: 201,
headers: {
// NOTE: remove if there is ever a 'read' method
location: false
},
options: [],
data: [],
validation: {
data: {
event: {
required: true
},
target_url: {
required: true
}
}
},
permissions: true,
query(frame) {
return models.Webhook.getByEventAndTarget(
frame.data.webhooks[0].event,
frame.data.webhooks[0].target_url,
frame.options
).then((webhook) => {
if (webhook) {
return Promise.reject(
new errors.ValidationError({message: tpl(messages.webhookAlreadyExists)})
);
}
return models.Webhook.add(frame.data.webhooks[0], frame.options);
});
}
},
edit: {
permissions: {
before: (frame) => {
if (frame.options.context && frame.options.context.integration && frame.options.context.integration.id) {
return models.Webhook.findOne({id: frame.options.id})
.then((webhook) => {
if (!webhook) {
throw new errors.NotFoundError({
message: tpl(messages.resourceNotFound, {
resource: 'Webhook'
})
});
}
if (webhook.get('integration_id') !== frame.options.context.integration.id) {
throw new errors.NoPermissionError({
message: tpl(messages.noPermissionToEdit.message, {
method: 'edit'
}),
context: tpl(messages.noPermissionToEdit.context, {
method: 'edit'
})
});
}
});
}
}
},
data: [
'name',
'event',
'target_url',
'secret',
'api_version'
],
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
query({data, options}) {
return models.Webhook.edit(data.webhooks[0], Object.assign(options, {require: true}))
.catch(models.Webhook.NotFoundError, () => {
throw new errors.NotFoundError({
message: tpl(messages.resourceNotFound, {
resource: 'Webhook'
})
});
});
}
},
destroy: {
statusCode: 204,
headers: {},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: {
before: (frame) => {
if (frame.options.context && frame.options.context.integration && frame.options.context.integration.id) {
return models.Webhook.findOne({id: frame.options.id})
.then((webhook) => {
if (!webhook) {
throw new errors.NotFoundError({
message: tpl(messages.resourceNotFound, {
resource: 'Webhook'
})
});
}
if (webhook.get('integration_id') !== frame.options.context.integration.id) {
throw new errors.NoPermissionError({
message: tpl(messages.noPermissionToEdit.message, {
method: 'destroy'
}),
context: tpl(messages.noPermissionToEdit.context, {
method: 'destroy'
})
});
}
});
}
}
},
query(frame) {
frame.options.require = true;
return models.Webhook.destroy(frame.options)
.then(() => null)
.catch(models.Webhook.NotFoundError, () => {
return Promise.reject(new errors.NotFoundError({
message: tpl(messages.resourceNotFound, {
resource: 'Webhook'
})
}));
});
}
}
};

View File

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

View File

@ -1,192 +0,0 @@
const api = require('./index');
const config = require('../../../shared/config');
const errors = require('@tryghost/errors');
const web = require('../../web');
const models = require('../../models');
const auth = require('../../services/auth');
const invitations = require('../../services/invitations');
const tpl = require('@tryghost/tpl');
const messages = {
notTheBlogOwner: 'You are not the site owner.'
};
module.exports = {
docName: 'authentication',
setup: {
statusCode: 201,
permissions: false,
headers: {
cacheInvalidate: true
},
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: tpl(messages.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,
async query() {
const isSetup = await auth.setup.checkIsSetup();
return {
status: isSetup,
title: config.title,
name: config.user_name,
email: config.user_email
};
}
},
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.middleware.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);
});
}
}
};

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