0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-18 02:21:47 -05:00

💡 Added canary api endpoint

no issue

Adds new canary api endpoint, currently replicating v2 endpoint but paving way for future updates to new version
This commit is contained in:
Rish 2019-08-09 19:41:24 +05:30 committed by Rishabh Garg
parent acd1a7fd69
commit 7b761a8751
109 changed files with 6657 additions and 1 deletions

View file

@ -0,0 +1,38 @@
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

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

View file

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

View file

@ -0,0 +1,24 @@
const {isPlainObject} = require('lodash');
const config = require('../../config');
const labs = require('../../services/labs');
const ghostVersion = require('../../lib/ghost-version');
module.exports = {
docName: 'config',
read: {
permissions: false,
query() {
return {
version: ghostVersion.full,
environment: config.get('env'),
database: config.get('database').client,
mail: isPlainObject(config.get('mail')) ? config.get('mail').transport : '',
useGravatar: !config.isPrivacyDisabled('useGravatar'),
labs: labs.getAll(),
clientExtensions: config.get('clientExtensions') || {},
enableDeveloperExperiments: config.get('enableDeveloperExperiments') || false
};
}
}
};

View file

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

View file

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

View file

@ -0,0 +1,149 @@
const shared = require('../shared');
const localUtils = require('./utils');
module.exports = {
get http() {
return shared.http;
},
get authentication() {
return shared.pipeline(require('./authentication'), localUtils);
},
get db() {
return shared.pipeline(require('./db'), localUtils);
},
get 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 subscribers() {
return shared.pipeline(require('./subscribers'), localUtils);
},
get members() {
return shared.pipeline(require('./members'), 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.canary.content` and
* `api.canary.admin` soon. Need to figure out how serializers & validation works then.
*/
get pagesPublic() {
return shared.pipeline(require('./pages-public'), localUtils, 'content');
},
get tagsPublic() {
return shared.pipeline(require('./tags-public'), localUtils, 'content');
},
get publicSettings() {
return shared.pipeline(require('./settings-public'), localUtils, 'content');
},
get postsPublic() {
return shared.pipeline(require('./posts-public'), localUtils, 'content');
},
get authorsPublic() {
return shared.pipeline(require('./authors-public'), localUtils, 'content');
}
};

View file

@ -0,0 +1,145 @@
const common = require('../../lib/common');
const models = require('../../models');
module.exports = {
docName: 'integrations',
browse: {
permissions: true,
options: [
'include',
'limit'
],
validation: {
options: {
include: {
values: ['api_keys', 'webhooks']
}
}
},
query({options}) {
return models.Integration.findPage(options);
}
},
read: {
permissions: true,
data: [
'id'
],
options: [
'include'
],
validation: {
data: {
id: {
required: true
}
},
options: {
include: {
values: ['api_keys', 'webhooks']
}
}
},
query({data, options}) {
return models.Integration.findOne(data, Object.assign(options, {require: true}))
.catch(models.Integration.NotFoundError, () => {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.api.resource.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 common.errors.NotFoundError({
message: common.i18n.t('errors.api.resource.resourceNotFound', {
resource: 'Integration'
})
});
});
}
},
add: {
statusCode: 201,
permissions: true,
data: [
'name',
'icon_image',
'description',
'webhooks'
],
options: [
'include'
],
validation: {
data: {
name: {
required: true
}
},
options: {
include: {
values: ['api_keys', 'webhooks']
}
}
},
query({data, options}) {
const dataWithApiKeys = Object.assign({
api_keys: [
{type: 'content'},
{type: 'admin'}
]
}, data);
return models.Integration.add(dataWithApiKeys, options);
}
},
destroy: {
statusCode: 204,
permissions: true,
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
query({options}) {
return models.Integration.destroy(Object.assign(options, {require: true}))
.catch(models.Integration.NotFoundError, () => {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.api.resource.resourceNotFound', {
resource: 'Integration'
})
});
});
}
}
};

View file

@ -0,0 +1,176 @@
const Promise = require('bluebird');
const common = require('../../lib/common');
const security = require('../../lib/security');
const mailService = require('../../services/mail');
const urlUtils = require('../../lib/url-utils');
const settingsCache = require('../../services/settings/cache');
const models = require('../../models');
const api = require('./index');
const ALLOWED_INCLUDES = [];
const UNSAFE_ATTRS = ['role_id'];
module.exports = {
docName: 'invites',
browse: {
options: [
'include',
'page',
'limit',
'fields',
'filter',
'order',
'debug'
],
validation: {
options: {
include: ALLOWED_INCLUDES
}
},
permissions: true,
query(frame) {
return models.Invite.findPage(frame.options);
}
},
read: {
options: [
'include'
],
data: [
'id',
'email'
],
validation: {
options: {
include: ALLOWED_INCLUDES
}
},
permissions: true,
query(frame) {
return models.Invite.findOne(frame.data, frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.api.invites.inviteNotFound')
}));
}
return model;
});
}
},
destroy: {
statusCode: 204,
options: [
'include',
'id'
],
validation: {
options: {
include: ALLOWED_INCLUDES
}
},
permissions: true,
query(frame) {
frame.options.require = true;
return models.Invite.destroy(frame.options)
.return(null);
}
},
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) {
let invite;
let emailData;
// CASE: ensure we destroy the invite before
return models.Invite.findOne({email: frame.data.invites[0].email}, frame.options)
.then((invite) => {
if (!invite) {
return;
}
return invite.destroy(frame.options);
})
.then(() => {
return models.Invite.add(frame.data.invites[0], frame.options);
})
.then((_invite) => {
invite = _invite;
const adminUrl = urlUtils.urlFor('admin', true);
emailData = {
blogName: settingsCache.get('title'),
invitedByName: frame.user.get('name'),
invitedByEmail: frame.user.get('email'),
resetLink: urlUtils.urlJoin(adminUrl, 'signup', security.url.encodeBase64(invite.get('token')), '/')
};
return mailService.utils.generateContent({data: emailData, template: 'invite-user'});
})
.then((emailContent) => {
const payload = {
mail: [{
message: {
to: invite.get('email'),
subject: common.i18n.t('common.api.users.mail.invitedByName', {
invitedByName: emailData.invitedByName,
blogName: emailData.blogName
}),
html: emailContent.html,
text: emailContent.text
},
options: {}
}]
};
return api.mail.send(payload, {context: {internal: true}});
})
.then(() => {
return models.Invite.edit({
status: 'sent'
}, Object.assign({id: invite.id}, frame.options));
})
.then((invite) => {
return invite;
})
.catch((err) => {
if (err && err.errorType === 'EmailError') {
const errorMessage = common.i18n.t('errors.api.invites.errorSendingEmail.error', {
message: err.message
});
const helpText = common.i18n.t('errors.api.invites.errorSendingEmail.help');
err.message = `${errorMessage} ${helpText}`;
common.logging.warn(err.message);
}
return Promise.reject(err);
});
}
}
};

View file

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

View file

@ -0,0 +1,57 @@
// NOTE: We must not cache references to membersService.api
// as it is a getter and may change during runtime.
const membersService = require('../../services/members');
const members = {
docName: 'members',
browse: {
options: [
'limit',
'fields',
'filter',
'order',
'debug',
'page'
],
permissions: true,
validation: {},
query(frame) {
return membersService.api.members.list(frame.options);
}
},
read: {
headers: {},
data: [
'id',
'email'
],
validation: {},
permissions: true,
query(frame) {
return membersService.api.members.get(frame.data, frame.options);
}
},
destroy: {
statusCode: 204,
headers: {},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
query(frame) {
frame.options.require = true;
return membersService.api.members.destroy(frame.options).return(null);
}
}
};
module.exports = members;

View file

@ -0,0 +1,231 @@
const moment = require('moment-timezone');
const semver = require('semver');
const Promise = require('bluebird');
const _ = require('lodash');
const settingsCache = require('../../services/settings/cache');
const ghostVersion = require('../../lib/ghost-version');
const common = require('../../lib/common');
const ObjectId = require('bson-objectid');
const api = require('./index');
const internalContext = {context: {internal: true}};
const _private = {};
_private.fetchAllNotifications = () => {
let allNotifications = settingsCache.get('notifications');
allNotifications.forEach((notification) => {
notification.addedAt = moment(notification.addedAt).toDate();
});
return allNotifications;
};
_private.wasSeen = (notification, user) => {
if (notification.seenBy === undefined) {
return notification.seen;
} else {
return notification.seenBy.includes(user.id);
}
};
module.exports = {
docName: 'notifications',
browse: {
permissions: true,
query(frame) {
let allNotifications = _private.fetchAllNotifications();
allNotifications = _.orderBy(allNotifications, 'addedAt', 'desc');
allNotifications = allNotifications.filter((notification) => {
// NOTE: Filtering by version below is just a patch for bigger problem - notifications are not removed
// after Ghost update. Logic below should be removed when Ghost upgrade detection
// is done (https://github.com/TryGhost/Ghost/issues/10236) and notifications are
// be removed permanently on upgrade event.
const ghost20RegEx = /Ghost 2.0 is now available/gi;
// CASE: do not return old release notification
if (notification.message && (!notification.custom || notification.message.match(ghost20RegEx))) {
let notificationVersion = notification.message.match(/(\d+\.)(\d+\.)(\d+)/);
if (notification.message.match(ghost20RegEx)) {
notificationVersion = '2.0.0';
} else if (notificationVersion){
notificationVersion = notificationVersion[0];
}
const blogVersion = ghostVersion.full.match(/^(\d+\.)(\d+\.)(\d+)/);
if (notificationVersion && blogVersion && semver.gt(notificationVersion, blogVersion[0])) {
return true;
} else {
return false;
}
}
return !_private.wasSeen(notification, frame.user);
});
return allNotifications;
}
},
add: {
statusCode(result) {
if (result.notifications.length) {
return 201;
} else {
return 200;
}
},
permissions: true,
query(frame) {
const defaults = {
dismissible: true,
location: 'bottom',
status: 'alert',
id: ObjectId.generate()
};
const overrides = {
seen: false,
addedAt: moment().toDate()
};
let notificationsToCheck = frame.data.notifications;
let notificationsToAdd = [];
const allNotifications = _private.fetchAllNotifications();
notificationsToCheck.forEach((notification) => {
const isDuplicate = allNotifications.find((n) => {
return n.id === notification.id;
});
if (!isDuplicate) {
notificationsToAdd.push(Object.assign({}, defaults, notification, overrides));
}
});
const hasReleaseNotification = notificationsToCheck.find((notification) => {
return !notification.custom;
});
// CASE: remove any existing release notifications if a new release notification comes in
if (hasReleaseNotification) {
_.remove(allNotifications, (el) => {
return !el.custom;
});
}
// CASE: nothing to add, skip
if (!notificationsToAdd.length) {
return Promise.resolve();
}
const releaseNotificationsToAdd = notificationsToAdd.filter((notification) => {
return !notification.custom;
});
// CASE: reorder notifications before save
if (releaseNotificationsToAdd.length > 1) {
notificationsToAdd = notificationsToAdd.filter((notification) => {
return notification.custom;
});
notificationsToAdd.push(_.orderBy(releaseNotificationsToAdd, 'created_at', 'desc')[0]);
}
return api.settings.edit({
settings: [{
key: 'notifications',
// @NOTE: We always need to store all notifications!
value: allNotifications.concat(notificationsToAdd)
}]
}, internalContext).then(() => {
return notificationsToAdd;
});
}
},
destroy: {
statusCode: 204,
options: ['notification_id'],
validation: {
options: {
notification_id: {
required: true
}
}
},
permissions: true,
query(frame) {
const allNotifications = _private.fetchAllNotifications();
const notificationToMarkAsSeen = allNotifications.find((notification) => {
return notification.id === frame.options.notification_id;
}),
notificationToMarkAsSeenIndex = allNotifications.findIndex((notification) => {
return notification.id === frame.options.notification_id;
});
if (notificationToMarkAsSeenIndex > -1 && !notificationToMarkAsSeen.dismissible) {
return Promise.reject(new common.errors.NoPermissionError({
message: common.i18n.t('errors.api.notifications.noPermissionToDismissNotif')
}));
}
if (notificationToMarkAsSeenIndex < 0) {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.api.notifications.notificationDoesNotExist')
}));
}
if (_private.wasSeen(notificationToMarkAsSeen, frame.user)) {
return Promise.resolve();
}
// @NOTE: We don't remove the notifications, because otherwise we will receive them again from the service.
allNotifications[notificationToMarkAsSeenIndex].seen = true;
if (!allNotifications[notificationToMarkAsSeenIndex].seenBy) {
allNotifications[notificationToMarkAsSeenIndex].seenBy = [];
}
allNotifications[notificationToMarkAsSeenIndex].seenBy.push(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 = _private.fetchAllNotifications();
allNotifications.forEach((notification) => {
// @NOTE: We don't remove the notifications, because otherwise we will receive them again from the service.
notification.seen = true;
});
return api.settings.edit({
settings: [{
key: 'notifications',
value: allNotifications
}]
}, internalContext).return();
}
}
};

View file

@ -0,0 +1,97 @@
const common = require('../../lib/common');
const {extract, hasProvider} = require('oembed-parser');
const Promise = require('bluebird');
const request = require('../../lib/request');
const cheerio = require('cheerio');
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};
};
const getOembedUrlFromHTML = (html) => {
return cheerio('link[type="application/json+oembed"]', html).attr('href');
};
module.exports = {
docName: 'oembed',
read: {
permissions: false,
data: [
'url'
],
options: [],
query({data}) {
let {url} = data;
function unknownProvider() {
return Promise.reject(new common.errors.ValidationError({
message: common.i18n.t('errors.api.oembed.unknownProvider'),
context: url
}));
}
function knownProvider(url) {
return extract(url).catch((err) => {
return Promise.reject(new common.errors.InternalServerError({
message: err.message
}));
});
}
let provider;
({url, provider} = findUrlWithProvider(url));
if (provider) {
return knownProvider(url);
}
// see if the URL is a redirect to cater for shortened urls
return request(url, {
method: 'GET',
timeout: 2 * 1000,
followRedirect: true
}).then((response) => {
if (response.url !== url) {
({url, provider} = findUrlWithProvider(response.url));
return provider ? knownProvider(url) : unknownProvider();
}
const oembedUrl = getOembedUrlFromHTML(response.body);
if (!oembedUrl) {
return unknownProvider();
}
return request(oembedUrl, {
method: 'GET',
json: true
}).then((response) => {
return response.body;
});
}).catch(() => {
return unknownProvider();
});
}
}
};

View file

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

View file

@ -0,0 +1,199 @@
const models = require('../../models');
const common = require('../../lib/common');
const urlUtils = require('../../lib/url-utils');
const ALLOWED_INCLUDES = ['tags', 'authors', 'authors.roles'];
const UNSAFE_ATTRS = ['status', 'authors'];
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 common.errors.NotFoundError({
message: common.i18n.t('errors.api.pages.pageNotFound')
});
}
return model;
});
}
},
add: {
statusCode: 201,
headers: {},
options: [
'include'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
}
}
},
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',
// NOTE: only for internal context
'forUpdate',
'transacting'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
id: {
required: true
}
}
},
permissions: {
docName: 'posts',
unsafeAttrs: UNSAFE_ATTRS
},
query(frame) {
return models.Post.edit(frame.data.pages[0], frame.options)
.then((model) => {
if (
model.get('status') === 'published' && model.wasChanged() ||
model.get('status') === 'draft' && model.previous('status') === 'published'
) {
this.headers.cacheInvalidate = true;
} else if (
model.get('status') === 'draft' && model.previous('status') !== 'published' ||
model.get('status') === 'scheduled' && model.wasChanged()
) {
this.headers.cacheInvalidate = {
value: urlUtils.urlFor({
relativeUrl: urlUtils.urlJoin('/p', model.get('uuid'), '/')
})
};
} else {
this.headers.cacheInvalidate = false;
}
return model;
});
}
},
destroy: {
statusCode: 204,
headers: {
cacheInvalidate: true
},
options: [
'include',
'id'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
id: {
required: true
}
}
},
permissions: {
docName: 'posts',
unsafeAttrs: UNSAFE_ATTRS
},
query(frame) {
frame.options.require = true;
return models.Post.destroy(frame.options)
.return(null)
.catch(models.Post.NotFoundError, () => {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.api.pages.pageNotFound')
});
});
}
}
};

View file

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

View file

@ -0,0 +1,202 @@
const models = require('../../models');
const common = require('../../lib/common');
const urlUtils = require('../../lib/url-utils');
const allowedIncludes = ['tags', 'authors', 'authors.roles'];
const unsafeAttrs = ['status', 'authors'];
module.exports = {
docName: 'posts',
browse: {
options: [
'include',
'filter',
'fields',
'formats',
'limit',
'order',
'page',
'debug',
'absolute_urls'
],
validation: {
options: {
include: {
values: allowedIncludes
},
formats: {
values: models.Post.allowedFormats
}
}
},
permissions: {
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 common.errors.NotFoundError({
message: common.i18n.t('errors.api.posts.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
},
query(frame) {
return models.Post.edit(frame.data.posts[0], frame.options)
.then((model) => {
if (
model.get('status') === 'published' && model.wasChanged() ||
model.get('status') === 'draft' && model.previous('status') === 'published'
) {
this.headers.cacheInvalidate = true;
} else if (
model.get('status') === 'draft' && model.previous('status') !== 'published' ||
model.get('status') === 'scheduled' && model.wasChanged()
) {
this.headers.cacheInvalidate = {
value: urlUtils.urlFor({
relativeUrl: urlUtils.urlJoin('/p', model.get('uuid'), '/')
})
};
} else {
this.headers.cacheInvalidate = false;
}
return model;
});
}
},
destroy: {
statusCode: 204,
headers: {
cacheInvalidate: true
},
options: [
'include',
'id'
],
validation: {
options: {
include: {
values: allowedIncludes
},
id: {
required: true
}
}
},
permissions: {
unsafeAttrs: unsafeAttrs
},
query(frame) {
frame.options.require = true;
return models.Post.destroy(frame.options)
.return(null)
.catch(models.Post.NotFoundError, () => {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.api.posts.postNotFound')
});
});
}
}
};

View file

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

View file

@ -0,0 +1,33 @@
const web = require('../../web');
const redirects = require('../../../frontend/services/redirects');
module.exports = {
docName: 'redirects',
download: {
headers: {
disposition: {
type: 'file',
value: 'redirects.json'
}
},
permissions: true,
query() {
return redirects.settings.get();
}
},
upload: {
permissions: true,
headers: {
cacheInvalidate: true
},
query(frame) {
return redirects.settings.setFromFilePath(frame.file.path)
.then(() => {
// CASE: trigger that redirects are getting re-registered
web.shared.middlewares.customRedirects.reload();
});
}
}
};

View file

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

View file

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

View file

@ -0,0 +1,50 @@
const Promise = require('bluebird');
const common = require('../../lib/common');
const models = require('../../models');
const auth = require('../../services/auth');
const session = {
read(options) {
/*
* 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: options.context.user});
},
add(object) {
if (!object || !object.username || !object.password) {
return Promise.reject(new common.errors.UnauthorizedError({
message: common.i18n.t('errors.middleware.auth.accessDenied')
}));
}
return models.User.check({
email: object.username,
password: object.password
}).then((user) => {
return Promise.resolve((req, res, next) => {
req.brute.reset(function (err) {
if (err) {
return next(err);
}
req.user = user;
auth.session.createSession(req, res, next);
});
});
}).catch((err) => {
throw new common.errors.UnauthorizedError({
message: common.i18n.t('errors.middleware.auth.accessDenied'),
err
});
});
},
delete() {
return Promise.resolve((req, res, next) => {
auth.session.destroySession(req, res, next);
});
}
};
module.exports = session;

View file

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

View file

@ -0,0 +1,171 @@
const Promise = require('bluebird');
const _ = require('lodash');
const models = require('../../models');
const routing = require('../../../frontend/services/routing');
const common = require('../../lib/common');
const settingsCache = require('../../services/settings/cache');
const SETTINGS_BLACKLIST = [
'members_public_key',
'members_private_key',
'members_session_secret'
];
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.type === 'blog';
}));
}
// CASE: omit core settings unless internal request
if (!frame.options.context.internal) {
settings = _.filter(settings, (setting) => {
const isCore = setting.type === 'core';
const isBlacklisted = SETTINGS_BLACKLIST.includes(setting.key);
return !isBlacklisted && !isCore;
});
}
return settings;
}
},
read: {
options: ['key'],
validation: {
options: {
key: {
required: true
}
}
},
permissions: {
identifier(frame) {
return frame.options.key;
}
},
query(frame) {
let setting = settingsCache.get(frame.options.key, {resolve: false});
if (!setting) {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.api.settings.problemFindingSetting', {
key: frame.options.key
})
}));
}
// @TODO: handle in settings model permissible fn
if (setting.type === 'core' && !(frame.options.context && frame.options.context.internal)) {
return Promise.reject(new common.errors.NoPermissionError({
message: common.i18n.t('errors.api.settings.accessCoreSettingFromExtReq')
}));
}
return {
[frame.options.key]: setting
};
}
},
edit: {
headers: {
cacheInvalidate: true
},
permissions: {
before(frame) {
const errors = [];
frame.data.settings.map((setting) => {
if (setting.type === 'core' && !(frame.options.context && frame.options.context.internal)) {
errors.push(new common.errors.NoPermissionError({
message: common.i18n.t('errors.api.settings.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';
});
const errors = [];
_.each(frame.data.settings, (setting) => {
const settingFromCache = settingsCache.get(setting.key, {resolve: false});
if (!settingFromCache) {
errors.push(new common.errors.NotFoundError({
message: common.i18n.t('errors.api.settings.problemFindingSetting', {
key: setting.key
})
}));
} else if (settingFromCache.type === 'core' && !(frame.options.context && frame.options.context.internal)) {
// @TODO: handle in settings model permissible fn
errors.push(new common.errors.NoPermissionError({
message: common.i18n.t('errors.api.settings.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'
},
query(frame) {
return routing.settings.setFromFilePath(frame.file.path);
}
},
download: {
headers: {
disposition: {
type: 'yaml',
value: 'routes.yaml'
}
},
response: {
format: 'plain'
},
permissions: {
method: 'browse'
},
query() {
return routing.settings.get();
}
}
};

View file

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

View file

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

View file

@ -0,0 +1,47 @@
const models = require('../../models');
const common = require('../../lib/common');
const allowedTypes = {
post: models.Post,
tag: models.Tag,
user: models.User,
app: models.App
};
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 common.errors.GhostError({
message: common.i18n.t('errors.api.slugs.couldNotGenerateSlug')
}));
}
return slug;
});
}
}
};

View file

@ -0,0 +1,215 @@
const Promise = require('bluebird');
const models = require('../../models');
const fsLib = require('../../lib/fs');
const common = require('../../lib/common');
const subscribers = {
docName: 'subscribers',
browse: {
options: [
'limit',
'fields',
'filter',
'order',
'debug',
'page'
],
permissions: true,
validation: {},
query(frame) {
return models.Subscriber.findPage(frame.options);
}
},
read: {
headers: {},
data: [
'id',
'email'
],
validation: {},
permissions: true,
query(frame) {
return models.Subscriber.findOne(frame.data);
}
},
add: {
statusCode: 201,
headers: {},
validation: {
data: {
email: {required: true}
}
},
permissions: true,
query(frame) {
return models.Subscriber.getByEmail(frame.data.subscribers[0].email)
.then((subscriber) => {
if (subscriber && frame.options.context.external) {
// we don't expose this information
return Promise.resolve(subscriber);
} else if (subscriber) {
return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.subscribers.subscriberAlreadyExists')}));
}
return models.Subscriber
.add(frame.data.subscribers[0])
.catch((error) => {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.subscribers.subscriberAlreadyExists')}));
}
return Promise.reject(error);
});
});
}
},
edit: {
headers: {},
options: [
'id'
],
validation: {
id: {
required: true
}
},
permissions: true,
query(frame) {
return models.Subscriber.edit(frame.data.subscribers[0], frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.api.subscribers.subscriberNotFound')
}));
}
return model;
});
}
},
destroy: {
statusCode: 204,
headers: {
cacheInvalidate: true
},
options: [
'id',
'email'
],
validation: {},
permissions: true,
query(frame) {
/**
* ### Delete Subscriber
* If we have an email param, check the subscriber exists
* @type {[type]}
*/
function getSubscriberByEmail(options) {
if (options.email) {
return models.Subscriber.getByEmail(options.email, options)
.then((subscriber) => {
if (!subscriber) {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.api.subscribers.subscriberNotFound')
}));
}
options.id = subscriber.get('id');
return options;
});
}
return Promise.resolve(options);
}
return getSubscriberByEmail(frame.options)
.then((options) => {
return models.Subscriber
.destroy(options)
.return(null);
});
}
},
exportCSV: {
headers: {
disposition: {
type: 'csv',
value() {
const datetime = (new Date()).toJSON().substring(0, 10);
return `subscribers.${datetime}.csv`;
}
}
},
response: {
format: 'plain'
},
permissions: {
method: 'browse'
},
validation: {},
query(frame) {
return models.Subscriber.findAll(frame.options)
.catch((err) => {
return Promise.reject(new common.errors.GhostError({err: err}));
});
}
},
importCSV: {
statusCode: 201,
permissions: {
method: 'add'
},
validation: {},
query(frame) {
let filePath = frame.file.path,
fulfilled = 0,
invalid = 0,
duplicates = 0;
return fsLib.readCSV({
path: filePath,
columnsToExtract: [{name: 'email', lookup: /email/i}]
}).then((result) => {
return Promise.all(result.map((entry) => {
const apiCanary = require('./index');
return apiCanary.subscribers.add.query({
data: {subscribers: [{email: entry.email}]},
options: {
context: frame.options.context
}
}).reflect();
})).each((inspection) => {
if (inspection.isFulfilled()) {
fulfilled = fulfilled + 1;
} else {
if (inspection.reason() instanceof common.errors.ValidationError) {
duplicates = duplicates + 1;
} else {
invalid = invalid + 1;
}
}
});
}).then(() => {
return {
meta: {
stats: {
imported: fulfilled,
duplicates: duplicates,
invalid: invalid
}
}
};
});
}
}
};
module.exports = subscribers;

View file

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

View file

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

View file

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

View file

@ -0,0 +1,175 @@
const Promise = require('bluebird');
const common = require('../../lib/common');
const models = require('../../models');
const permissionsService = require('../../services/permissions');
const ALLOWED_INCLUDES = ['count.posts', 'permissions', 'roles', 'roles.permissions'];
const UNSAFE_ATTRS = ['status', 'roles'];
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 common.errors.NotFoundError({
message: common.i18n.t('errors.api.users.userNotFound')
}));
}
return model;
});
}
},
edit: {
headers: {},
options: [
'id',
'include'
],
validation: {
options: {
include: {
values: ALLOWED_INCLUDES
},
id: {
required: true
}
}
},
permissions: {
unsafeAttrs: UNSAFE_ATTRS
},
query(frame) {
return models.User.edit(frame.data.users[0], frame.options)
.then((model) => {
if (!model) {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.api.users.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.Accesstoken.destroyByUser(frame.options),
models.Refreshtoken.destroyByUser(frame.options),
models.Post.destroyByAuthor(frame.options)
]).then(() => {
return models.User.destroy(Object.assign({status: 'all'}, frame.options));
}).return(null);
}).catch((err) => {
return Promise.reject(new common.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) {
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

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

View file

@ -0,0 +1,102 @@
const debug = require('ghost-ignition').debug('api:canary:utils:permissions');
const Promise = require('bluebird');
const _ = require('lodash');
const permissions = require('../../../services/permissions');
const common = require('../../../lib/common');
/**
* @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);
}
const unsafeAttrObject = apiConfig.unsafeAttrs && _.has(frame, `data.[${apiConfig.docName}][0]`) ? _.pick(frame.data[apiConfig.docName][0], apiConfig.unsafeAttrs) : {};
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 common.errors.NoPermissionError) {
err.message = common.i18n.t('errors.api.utils.noPermissionToCall', {
method: apiConfig.method,
docName: apiConfig.docName
});
return Promise.reject(err);
}
if (common.errors.utils.isIgnitionError(err)) {
return Promise.reject(err);
}
return Promise.reject(new common.errors.GhostError({
err: err
}));
});
};
// @TODO: https://github.com/TryGhost/Ghost/issues/10735
module.exports = {
/**
* @description Handle permission stage for canary API.
*
* @param {Object} apiConfig - Docname & method of target ctrl.
* @param {Object} frame
* @return {Promise}
*/
handle(apiConfig, frame) {
debug('handle');
// @TODO: https://github.com/TryGhost/Ghost/issues/10099
frame.options.context = permissions.parseContext(frame.options.context);
// CASE: Content API access
if (frame.options.context.public) {
debug('check content permissions');
// @TODO: Remove when we drop v0.1
// @TODO: https://github.com/TryGhost/Ghost/issues/10733
return permissions.applyPublicRules(apiConfig.docName, apiConfig.method, {
status: frame.options.status,
id: frame.options.id,
uuid: frame.options.uuid,
slug: frame.options.slug,
data: {
status: frame.data.status,
id: frame.data.id,
uuid: frame.data.uuid,
slug: frame.data.slug
}
});
}
return nonePublicAuth(apiConfig, frame);
}
};

View file

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

View file

@ -0,0 +1,26 @@
const debug = require('ghost-ignition').debug('api:canary:utils:serializers:input:authors');
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(apiConfig, frame) {
debug('read');
if (utils.isContentAPI(frame)) {
setDefaultOrder(frame);
}
}
};

View file

@ -0,0 +1,22 @@
const _ = require('lodash');
const debug = require('ghost-ignition').debug('api:canary: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);
}
debug(frame.options);
}
};

View file

@ -0,0 +1,29 @@
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');
}
};

View file

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

View file

@ -0,0 +1,172 @@
const _ = require('lodash');
const debug = require('ghost-ignition').debug('api:canary:utils:serializers:input:pages');
const converters = require('../../../../../lib/mobiledoc/converters');
const url = require('./utils/url');
const localUtils = require('../../index');
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 defaultFormat(frame) {
if (frame.options.formats) {
return;
}
frame.options.formats = 'mobiledoc';
}
/**
* 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})+page:true`;
} else {
frame.options.filter = 'page:true';
}
};
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);
}
if (!localUtils.isContentAPI(frame)) {
forceStatusFilter(frame);
defaultFormat(frame);
defaultRelations(frame);
}
debug(frame.options);
},
read(apiConfig, frame) {
debug('read');
forcePageFilter(frame);
if (localUtils.isContentAPI(frame)) {
removeMobiledocFormat(frame);
setDefaultOrder(frame);
}
if (!localUtils.isContentAPI(frame)) {
forceStatusFilter(frame);
defaultFormat(frame);
defaultRelations(frame);
}
debug(frame.options);
},
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(converters.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].page = true;
}
// 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
};
}
});
}
defaultFormat(frame);
defaultRelations(frame);
},
edit(apiConfig, frame) {
this.add(...arguments, {add: false});
debug('edit');
forceStatusFilter(frame);
forcePageFilter(frame);
},
destroy(apiConfig, frame) {
frame.options.destroyBy = {
id: frame.options.id,
page: true
};
defaultFormat(frame);
defaultRelations(frame);
}
};

View file

@ -0,0 +1,205 @@
const _ = require('lodash');
const debug = require('ghost-ignition').debug('api:canary:utils:serializers:input:posts');
const url = require('./utils/url');
const localUtils = require('../../index');
const labs = require('../../../../../services/labs');
const converters = require('../../../../../lib/mobiledoc/converters');
function removeMobiledocFormat(frame) {
if (frame.options.formats && frame.options.formats.includes('mobiledoc')) {
frame.options.formats = frame.options.formats.filter((format) => {
return (format !== 'mobiledoc');
});
}
}
function includeTags(frame) {
if (!frame.options.withRelated) {
frame.options.withRelated = ['tags'];
} else if (!frame.options.withRelated.includes('tags')) {
frame.options.withRelated.push('tags');
}
}
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 defaultFormat(frame) {
if (frame.options.formats) {
return;
}
frame.options.formats = 'mobiledoc';
}
/**
* 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})+page:false`;
} else {
frame.options.filter = 'page:false';
}
};
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);
// CASE: Members needs to have the tags to check if its allowed access
if (labs.isSet('members')) {
includeTags(frame);
}
setDefaultOrder(frame);
}
if (!localUtils.isContentAPI(frame)) {
forceStatusFilter(frame);
defaultFormat(frame);
defaultRelations(frame);
}
debug(frame.options);
},
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);
if (labs.isSet('members')) {
// CASE: Members needs to have the tags to check if its allowed access
includeTags(frame);
}
setDefaultOrder(frame);
}
if (!localUtils.isContentAPI(frame)) {
forceStatusFilter(frame);
defaultFormat(frame);
defaultRelations(frame);
}
debug(frame.options);
},
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(converters.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].page = false;
}
// 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
};
}
});
}
defaultFormat(frame);
defaultRelations(frame);
},
edit(apiConfig, frame) {
this.add(apiConfig, frame, {add: false});
forceStatusFilter(frame);
forcePageFilter(frame);
},
destroy(apiConfig, frame) {
frame.options.destroyBy = {
id: frame.options.id,
page: false
};
defaultFormat(frame);
defaultRelations(frame);
}
};

View file

@ -0,0 +1,61 @@
const _ = require('lodash');
const url = require('./utils/url');
module.exports = {
read(apiConfig, frame) {
if (frame.options.key === 'codeinjection_head') {
frame.options.key = 'ghost_head';
}
if (frame.options.key === 'codeinjection_foot') {
frame.options.key = 'ghost_foot';
}
},
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}]};
}
frame.data.settings.forEach((setting) => {
// CASE: transform objects/arrays into string (we store stringified objects in the db)
// @TODO: This belongs into the model layer. We should stringify before saving and parse when fetching from db.
// @TODO: Fix when dropping v0.1
if (_.isObject(setting.value)) {
setting.value = JSON.stringify(setting.value);
}
// @TODO: handle these transformations in a centralised API place (these rules should apply for ALL resources)
// CASE: Ensure we won't forward strings, otherwise model events or model interactions can fail
if (setting.value === '0' || setting.value === '1') {
setting.value = !!+setting.value;
}
// CASE: Ensure we won't forward strings, otherwise model events or model interactions can fail
if (setting.value === 'false' || setting.value === 'true') {
setting.value = setting.value === 'true';
}
if (setting.key === 'codeinjection_head') {
setting.key = 'ghost_head';
}
if (setting.key === 'codeinjection_foot') {
setting.key = 'ghost_foot';
}
if (['cover_image', 'icon', 'logo'].includes(setting.key)) {
setting = url.forSetting(setting);
}
});
// CASE: deprecated, won't accept
const index = _.findIndex(frame.data.settings, {key: 'force_i18n'});
if (index !== -1) {
frame.data.settings.splice(index, 1);
}
}
};

View file

@ -0,0 +1,35 @@
const debug = require('ghost-ignition').debug('api:canary: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

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

View file

@ -0,0 +1,121 @@
const _ = require('lodash');
const url = require('url');
const urlUtils = require('../../../../../../lib/url-utils');
const handleCanonicalUrl = (canonicalUrl) => {
const blogURl = urlUtils.getBlogUrl();
const isSameProtocol = url.parse(canonicalUrl).protocol === url.parse(blogURl).protocol;
const blogDomain = blogURl.replace(/^http(s?):\/\//, '').replace(/\/$/, '');
const absolute = canonicalUrl.replace(/^http(s?):\/\//, '');
// We only want to transform to a relative URL when the canonical URL matches the current
// Blog URL incl. the same protocol. This allows users to keep e.g. Facebook comments after
// a http -> https switch
if (absolute.startsWith(blogDomain) && isSameProtocol) {
return urlUtils.absoluteToRelative(canonicalUrl);
}
return canonicalUrl;
};
const handleImageUrl = (imageUrl) => {
const blogDomain = urlUtils.getBlogUrl().replace(/^http(s?):\/\//, '').replace(/\/$/, '');
const imageUrlAbsolute = imageUrl.replace(/^http(s?):\/\//, '');
const imagePathRe = new RegExp(`^${blogDomain}/${urlUtils.STATIC_IMAGE_URL_PREFIX}`);
if (imagePathRe.test(imageUrlAbsolute)) {
return urlUtils.absoluteToRelative(imageUrl);
}
return imageUrl;
};
const handleContentUrls = (content) => {
const blogDomain = urlUtils.getBlogUrl().replace(/^http(s?):\/\//, '').replace(/\/$/, '');
const imagePathRe = new RegExp(`(http(s?)://)?${blogDomain}/${urlUtils.STATIC_IMAGE_URL_PREFIX}`, 'g');
const matches = _.uniq(content.match(imagePathRe));
if (matches) {
matches.forEach((match) => {
const relative = urlUtils.absoluteToRelative(match);
content = content.replace(new RegExp(match, 'g'), relative);
});
}
return content;
};
const forPost = (attrs, options) => {
// make all content image URLs relative, ref: https://github.com/TryGhost/Ghost/issues/10477
if (attrs.mobiledoc) {
attrs.mobiledoc = handleContentUrls(attrs.mobiledoc);
}
if (attrs.feature_image) {
attrs.feature_image = handleImageUrl(attrs.feature_image);
}
if (attrs.og_image) {
attrs.og_image = handleImageUrl(attrs.og_image);
}
if (attrs.twitter_image) {
attrs.twitter_image = handleImageUrl(attrs.twitter_image);
}
if (attrs.canonical_url) {
attrs.canonical_url = handleCanonicalUrl(attrs.canonical_url);
}
if (options && options.withRelated) {
options.withRelated.forEach((relation) => {
if (relation === 'tags' && attrs.tags) {
attrs.tags = attrs.tags.map(tag => forTag(tag));
}
if (relation === 'author' && attrs.author) {
attrs.author = forUser(attrs.author, options);
}
if (relation === 'authors' && attrs.authors) {
attrs.authors = attrs.authors.map(author => forUser(author, options));
}
});
}
return attrs;
};
const forUser = (attrs) => {
if (attrs.profile_image) {
attrs.profile_image = handleImageUrl(attrs.profile_image);
}
if (attrs.cover_image) {
attrs.cover_image = handleImageUrl(attrs.cover_image);
}
return attrs;
};
const forTag = (attrs) => {
if (attrs.feature_image) {
attrs.feature_image = handleImageUrl(attrs.feature_image);
}
return attrs;
};
const forSetting = (attrs) => {
if (attrs.value) {
attrs.value = handleImageUrl(attrs.value);
}
return attrs;
};
module.exports.forPost = forPost;
module.exports.forUser = forUser;
module.exports.forTag = forTag;
module.exports.forSetting = forSetting;

View file

@ -0,0 +1,15 @@
const debug = require('ghost-ignition').debug('api:canary: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
};
debug(frame.response);
}
};

View file

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

View file

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

View file

@ -0,0 +1,25 @@
const debug = require('ghost-ignition').debug('api:canary: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
};
debug(frame.response);
},
read(model, apiConfig, frame) {
debug('read');
frame.response = {
authors: [mapper.mapUser(model, frame)]
};
debug(frame.response);
}
};

View file

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

View file

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

View file

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

View file

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

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

View file

@ -0,0 +1,26 @@
const debug = require('ghost-ignition').debug('api:canary: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)]
};
debug(frame.response);
}
};

View file

@ -0,0 +1,20 @@
const _ = require('lodash');
const debug = require('ghost-ignition').debug('api:canary:utils:serializers:output:mail');
module.exports = {
all(response, apiConfig, frame) {
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;
debug(frame.response);
}
};

View file

@ -0,0 +1,24 @@
const common = require('../../../../../lib/common');
const debug = require('ghost-ignition').debug('api:canary:utils:serializers:output:members');
module.exports = {
browse(data, apiConfig, frame) {
debug('browse');
frame.response = data;
},
read(data, apiConfig, frame) {
debug('read');
if (!data) {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.api.members.memberNotFound')
}));
}
frame.response = {
members: [data]
};
}
};

View file

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

View file

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

View file

@ -0,0 +1,28 @@
const debug = require('ghost-ignition').debug('api:canary: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)]
};
debug(frame.response);
}
};

View file

@ -0,0 +1,29 @@
const debug = require('ghost-ignition').debug('api:canary: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
};
debug(frame.response);
return;
}
frame.response = {
posts: [mapper.mapPost(models, frame)]
};
debug(frame.response);
}
};

View file

@ -0,0 +1,7 @@
module.exports = {
all(model, apiConfig, frame) {
frame.response = {
preview: [model.toJSON(frame.options)]
};
}
};

View file

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

View file

@ -0,0 +1,28 @@
const debug = require('ghost-ignition').debug('api:canary: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((roles) => {
return frame.response = {
roles: roles
};
});
}
}
};

View file

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

View file

@ -0,0 +1,61 @@
const _ = require('lodash');
const utils = require('../../index');
const mapper = require('./utils/mapper');
const _private = {};
const deprecatedSettings = ['force_i18n', 'permalinks'];
/**
* ### Settings Filter
* Filters an object based on a given filter object
* @private
* @param {Object} settings
* @param {String} filter
* @returns {*}
*/
_private.settingsFilter = (settings, filter) => {
let filteredTypes = filter ? filter.split(',') : false;
return _.filter(settings, (setting) => {
if (filteredTypes) {
return _.includes(filteredTypes, setting.type) && !_.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

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

View file

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

View file

@ -0,0 +1,83 @@
const common = require('../../../../../lib/common');
const debug = require('ghost-ignition').debug('api:canary:utils:serializers:output:subscribers');
module.exports = {
browse(models, apiConfig, frame) {
debug('browse');
frame.response = {
subscribers: models.data.map(model => model.toJSON(frame.options)),
meta: models.meta
};
},
read(models, apiConfig, frame) {
debug('read');
if (!models) {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.api.subscribers.subscriberNotFound')
}));
}
frame.response = {
subscribers: [models.toJSON(frame.options)]
};
},
add(models, apiConfig, frame) {
debug('add');
frame.response = {
subscribers: [models.toJSON(frame.options)]
};
},
edit(models, apiConfig, frame) {
debug('edit');
frame.response = {
subscribers: [models.toJSON(frame.options)]
};
},
destroy(models, apiConfig, frame) {
frame.response = models;
},
exportCSV(models, apiConfig, frame) {
debug('exportCSV');
function formatCSV(data) {
let fields = ['id', 'email', 'created_at', 'deleted_at'],
csv = `${fields.join(',')}\r\n`,
subscriber,
field,
j,
i;
for (j = 0; j < data.length; j = j + 1) {
subscriber = data[j];
for (i = 0; i < fields.length; i = i + 1) {
field = fields[i];
csv += subscriber[field] !== null ? subscriber[field] : '';
if (i !== fields.length - 1) {
csv += ',';
}
}
csv += '\r\n';
}
return csv;
}
frame.response = formatCSV(models.toJSON(frame.options), frame.options);
},
importCSV(models, apiConfig, frame) {
debug('importCSV');
frame.response = models;
}
};

View file

@ -0,0 +1,27 @@
const debug = require('ghost-ignition').debug('api:canary: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)]
};
debug(frame.response);
}
};

View file

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

View file

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

View file

@ -0,0 +1,148 @@
const _ = require('lodash');
const localUtils = require('../../../index');
const tag = (attrs, frame) => {
if (localUtils.isContentAPI(frame)) {
delete attrs.created_at;
delete attrs.updated_at;
// We are standardising on returning null from the Content API for any empty values
if (attrs.meta_title === '') {
attrs.meta_title = null;
}
if (attrs.meta_description === '') {
attrs.meta_description = null;
}
if (attrs.description === '') {
attrs.description = null;
}
}
// Already deleted in model.toJSON, but leaving here so that we can clean that up when we deprecate v0.1
delete attrs.parent_id;
// @NOTE: unused fields
delete attrs.parent;
return attrs;
};
const author = (attrs, frame) => {
if (localUtils.isContentAPI(frame)) {
// Already deleted in model.toJSON, but leaving here so that we can clean that up when we deprecate v0.1
delete attrs.created_at;
delete attrs.updated_at;
delete attrs.last_seen;
delete attrs.status;
// @NOTE: used for night shift
delete attrs.accessibility;
// Extra properties removed from canary
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;
delete attrs.ghost_auth_id;
return attrs;
};
const post = (attrs, frame) => {
if (localUtils.isContentAPI(frame)) {
// @TODO: https://github.com/TryGhost/Ghost/issues/10335
// delete attrs.page;
delete attrs.status;
// We are standardising on returning null from the Content API for any empty values
if (attrs.twitter_title === '') {
attrs.twitter_title = null;
}
if (attrs.twitter_description === '') {
attrs.twitter_description = null;
}
if (attrs.meta_title === '') {
attrs.meta_title = null;
}
if (attrs.meta_description === '') {
attrs.meta_description = null;
}
if (attrs.og_title === '') {
attrs.og_title = null;
}
if (attrs.og_description === '') {
attrs.og_description = null;
}
} else {
delete attrs.page;
if (!attrs.tags) {
delete attrs.primary_tag;
}
if (!attrs.authors) {
delete attrs.primary_author;
}
}
delete attrs.locale;
delete attrs.visibility;
delete attrs.author;
return attrs;
};
const action = (attrs) => {
if (attrs.actor) {
delete attrs.actor_id;
delete attrs.resource_id;
if (attrs.actor_type === 'user') {
attrs.actor = _.pick(attrs.actor, ['id', 'name', 'slug', 'profile_image']);
attrs.actor.image = attrs.actor.profile_image;
delete attrs.actor.profile_image;
} else {
attrs.actor = _.pick(attrs.actor, ['id', 'name', 'slug', 'icon_image']);
attrs.actor.image = attrs.actor.icon_image;
delete attrs.actor.icon_image;
}
} else if (attrs.resource) {
delete attrs.actor_id;
delete attrs.resource_id;
// @NOTE: we only support posts right now
attrs.resource = _.pick(attrs.resource, ['id', 'title', 'slug', 'feature_image']);
attrs.resource.image = attrs.resource.feature_image;
delete attrs.resource.feature_image;
}
};
module.exports.post = post;
module.exports.tag = tag;
module.exports.author = author;
module.exports.action = action;

View file

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

View file

@ -0,0 +1,80 @@
module.exports.forPost = (frame, model, attrs) => {
const _ = require('lodash');
if (!Object.prototype.hasOwnProperty.call(frame.options, 'columns') ||
(frame.options.columns.includes('excerpt') && frame.options.formats && frame.options.formats.includes('plaintext'))) {
if (_.isEmpty(attrs.custom_excerpt)) {
const plaintext = model.get('plaintext');
if (plaintext) {
attrs.excerpt = plaintext.substring(0, 500);
} else {
attrs.excerpt = null;
}
} else {
attrs.excerpt = attrs.custom_excerpt;
}
}
};
// @NOTE: ghost_head & ghost_foot are deprecated, remove in Ghost 3.0
module.exports.forSettings = (attrs, frame) => {
const _ = require('lodash');
// @TODO: https://github.com/TryGhost/Ghost/issues/10106
// @NOTE: Admin & Content API return a different format, need to mappers
if (_.isArray(attrs)) {
// CASE: read single setting
if (frame.original.params && frame.original.params.key) {
if (frame.original.params.key === 'ghost_head') {
return;
}
if (frame.original.params.key === 'ghost_foot') {
return;
}
if (frame.original.params.key === 'codeinjection_head') {
attrs[0].key = 'codeinjection_head';
return;
}
if (frame.original.params.key === 'codeinjection_foot') {
attrs[0].key = 'codeinjection_foot';
return;
}
}
// CASE: edit
if (frame.original.body && frame.original.body.settings) {
frame.original.body.settings.forEach((setting) => {
if (setting.key === 'codeinjection_head') {
const target = _.find(attrs, {key: 'ghost_head'});
target.key = 'codeinjection_head';
} else if (setting.key === 'codeinjection_foot') {
const target = _.find(attrs, {key: 'ghost_foot'});
target.key = 'codeinjection_foot';
}
});
return;
}
// CASE: browse all settings, add extra keys and keep deprecated
const ghostHead = _.cloneDeep(_.find(attrs, {key: 'ghost_head'}));
const ghostFoot = _.cloneDeep(_.find(attrs, {key: 'ghost_foot'}));
if (ghostHead) {
ghostHead.key = 'codeinjection_head';
attrs.push(ghostHead);
}
if (ghostFoot) {
ghostFoot.key = 'codeinjection_foot';
attrs.push(ghostFoot);
}
} else {
attrs.codeinjection_head = attrs.ghost_head;
attrs.codeinjection_foot = attrs.ghost_foot;
}
};

View file

@ -0,0 +1,99 @@
const _ = require('lodash');
const utils = require('../../../index');
const url = require('./url');
const date = require('./date');
const members = require('./members');
const clean = require('./clean');
const extraAttrs = require('./extra-attrs');
const mapUser = (model, frame) => {
const jsonModel = model.toJSON ? model.toJSON(frame.options) : model;
url.forUser(model.id, jsonModel, frame.options);
clean.author(jsonModel, frame);
return jsonModel;
};
const mapTag = (model, frame) => {
const jsonModel = model.toJSON ? model.toJSON(frame.options) : model;
url.forTag(model.id, jsonModel, frame.options);
clean.tag(jsonModel, frame);
return jsonModel;
};
const mapPost = (model, frame) => {
const extendedOptions = Object.assign(_.cloneDeep(frame.options), {
extraProperties: ['canonical_url']
});
const jsonModel = model.toJSON(extendedOptions);
url.forPost(model.id, jsonModel, frame);
if (utils.isContentAPI(frame)) {
date.forPost(jsonModel);
members.forPost(jsonModel, frame);
}
extraAttrs.forPost(frame, model, jsonModel);
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);
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

@ -0,0 +1,56 @@
const labs = require('../../../../../../services/labs');
const membersService = require('../../../../../../services/members');
const MEMBER_TAG = '#members';
const PERMIT_CONTENT = false;
const BLOCK_CONTENT = true;
// Checks if request should hide memnbers only content
function hideMembersOnlyContent(attrs, frame) {
const membersEnabled = labs.isSet('members');
if (!membersEnabled) {
return PERMIT_CONTENT;
}
const postHasMemberTag = attrs.tags && attrs.tags.find((tag) => {
return (tag.name === MEMBER_TAG);
});
const requestFromMember = frame.original.context.member;
if (!postHasMemberTag) {
return PERMIT_CONTENT;
}
if (!requestFromMember) {
return BLOCK_CONTENT;
}
const memberHasPlan = !!(frame.original.context.member.plans || []).length;
if (!membersService.isPaymentConfigured()) {
return PERMIT_CONTENT;
}
if (memberHasPlan) {
return PERMIT_CONTENT;
}
return BLOCK_CONTENT;
}
const forPost = (attrs, frame) => {
const hideFormatsData = hideMembersOnlyContent(attrs, frame);
if (hideFormatsData) {
['plaintext', 'html'].forEach((field) => {
attrs[field] = '';
});
}
if (labs.isSet('members')) {
// CASE: Members always adds tags, remove if the user didn't originally ask for them
const origQueryOrOptions = frame.original.query || frame.original.options || {};
const origInclude = origQueryOrOptions.include;
if (!origInclude || !origInclude.includes('tags')) {
delete attrs.tags;
attrs.primary_tag = null;
}
}
return attrs;
};
module.exports.forPost = forPost;

View file

@ -0,0 +1,135 @@
const _ = require('lodash');
const urlService = require('../../../../../../../frontend/services/url');
const urlUtils = require('../../../../../../lib/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/canary/content/preview/:uuid/.
* /p/ is a concept of the site, not of core.
*
* The site is not aware of existing drafts. It won't be able to get the uuid.
*
* Needs further discussion.
*/
if (!localUtils.isContentAPI(frame)) {
if (attrs.status !== 'published' && attrs.url.match(/\/404\//)) {
attrs.url = urlUtils.urlFor({
relativeUrl: urlUtils.urlJoin('/p', attrs.uuid, '/')
}, null, true);
}
}
if (attrs.feature_image) {
attrs.feature_image = urlUtils.urlFor('image', {image: attrs.feature_image}, true);
}
if (attrs.og_image) {
attrs.og_image = urlUtils.urlFor('image', {image: attrs.og_image}, true);
}
if (attrs.twitter_image) {
attrs.twitter_image = urlUtils.urlFor('image', {image: attrs.twitter_image}, true);
}
if (attrs.canonical_url) {
attrs.canonical_url = urlUtils.relativeToAbsolute(attrs.canonical_url);
}
if (attrs.html) {
const urlOptions = {
assetsOnly: true
};
if (frame.options.absolute_urls) {
urlOptions.assetsOnly = false;
}
attrs.html = urlUtils.makeAbsoluteUrls(
attrs.html,
urlUtils.urlFor('home', true),
attrs.url,
urlOptions
).html();
}
if (frame.options.columns && !frame.options.columns.includes('url')) {
delete attrs.url;
}
return attrs;
};
const forUser = (id, attrs, options) => {
if (!options.columns || (options.columns && options.columns.includes('url'))) {
attrs.url = urlService.getUrlByResourceId(id, {absolute: true});
}
if (attrs.profile_image) {
attrs.profile_image = urlUtils.urlFor('image', {image: attrs.profile_image}, true);
}
if (attrs.cover_image) {
attrs.cover_image = urlUtils.urlFor('image', {image: attrs.cover_image}, true);
}
return attrs;
};
const forTag = (id, attrs, options) => {
if (!options.columns || (options.columns && options.columns.includes('url'))) {
attrs.url = urlService.getUrlByResourceId(id, {absolute: true});
}
if (attrs.feature_image) {
attrs.feature_image = urlUtils.urlFor('image', {image: attrs.feature_image}, true);
}
return attrs;
};
const forSettings = (attrs) => {
// @TODO: https://github.com/TryGhost/Ghost/issues/10106
// @NOTE: Admin & Content API return a different format, need to mappers
if (_.isArray(attrs)) {
attrs.forEach((obj) => {
if (['cover_image', 'logo', 'icon'].includes(obj.key) && obj.value) {
obj.value = urlUtils.urlFor('image', {image: obj.value}, true);
}
});
} else {
if (attrs.cover_image) {
attrs.cover_image = urlUtils.urlFor('image', {image: attrs.cover_image}, true);
}
if (attrs.logo) {
attrs.logo = urlUtils.urlFor('image', {image: attrs.logo}, true);
}
if (attrs.icon) {
attrs.icon = urlUtils.urlFor('image', {image: attrs.icon}, true);
}
}
return attrs;
};
const forImage = (path) => {
return urlUtils.urlFor('image', {image: path}, true);
};
module.exports.forPost = forPost;
module.exports.forUser = forUser;
module.exports.forTag = forTag;
module.exports.forSettings = forSettings;
module.exports.forImage = forImage;

View file

@ -0,0 +1,17 @@
const debug = require('ghost-ignition').debug('api:canary: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)]
};
debug(frame.response);
}
};

View file

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

View file

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

View file

@ -0,0 +1,45 @@
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

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

View file

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

View file

@ -0,0 +1,12 @@
const Promise = require('bluebird');
const common = require('../../../../../lib/common');
module.exports = {
read(apiConfig, frame) {
if (!frame.data.url || !frame.data.url.trim()) {
return Promise.reject(new common.errors.BadRequestError({
message: common.i18n.t('errors.api.oembed.noUrlProvided')
}));
}
}
};

View file

@ -0,0 +1,15 @@
const jsonSchema = require('../utils/json-schema');
module.exports = {
add(apiConfig, frame) {
const schema = require(`./schemas/pages-add`);
const definitions = require('./schemas/pages');
return jsonSchema.validate(schema, definitions, frame.data);
},
edit(apiConfig, frame) {
const schema = require(`./schemas/pages-edit`);
const definitions = require('./schemas/pages');
return jsonSchema.validate(schema, definitions, frame.data);
}
};

View file

@ -0,0 +1,30 @@
const Promise = require('bluebird');
const validator = require('validator');
const debug = require('ghost-ignition').debug('api:canary:utils:validators:input:passwordreset');
const common = require('../../../../../lib/common');
module.exports = {
resetPassword(apiConfig, frame) {
debug('resetPassword');
const data = frame.data.passwordreset[0];
if (data.newPassword !== data.ne2Password) {
return Promise.reject(new common.errors.ValidationError({
message: common.i18n.t('errors.models.user.newPasswordsDoNotMatch')
}));
}
},
generateResetToken(apiConfig, frame) {
debug('generateResetToken');
const email = frame.data.passwordreset[0].email;
if (typeof email !== 'string' || !validator.isEmail(email)) {
throw new common.errors.BadRequestError({
message: common.i18n.t('errors.api.authentication.invalidEmailReceived')
});
}
}
};

View file

@ -0,0 +1,15 @@
const jsonSchema = require('../utils/json-schema');
module.exports = {
add(apiConfig, frame) {
const schema = require(`./schemas/posts-add`);
const definitions = require('./schemas/posts');
return jsonSchema.validate(schema, definitions, frame.data);
},
edit(apiConfig, frame) {
const schema = require(`./schemas/posts-edit`);
const definitions = require('./schemas/posts');
return jsonSchema.validate(schema, definitions, frame.data);
}
};

View file

@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "images.upload",
"title": "images.upload",
"description": "Schema for images.upload",
"$ref": "images#/definitions/image"
}

View file

@ -0,0 +1,24 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "images",
"title": "images",
"description": "Base images definitions",
"definitions": {
"image": {
"type": "object",
"additionalProperties": false,
"properties": {
"purpose": {
"type": "string",
"enum": ["image", "profile_image", "icon"],
"default": "image"
},
"ref": {
"type": ["string", "null"],
"maxLength": 2000
}
}
}
}
}

View file

@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "pages.add",
"title": "pages.add",
"description": "Schema for pages.add",
"type": "object",
"additionalProperties": false,
"properties": {
"pages": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {
"type": "object",
"allOf": [{"$ref": "pages#/definitions/page"}],
"required": ["title"]
}
}
},
"required": ["pages"]
}

View file

@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "pages.edit",
"title": "pages.edit",
"description": "Schema for pages.edit",
"type": "object",
"additionalProperties": false,
"properties": {
"pages": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {
"type": "object",
"allOf": [{"$ref": "pages#/definitions/page"}],
"required": ["updated_at"]
}
}
},
"required": ["pages"]
}

View file

@ -0,0 +1,251 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "pages",
"title": "pages",
"description": "Base pages definitions",
"definitions": {
"page": {
"type": "object",
"additionalProperties": false,
"properties": {
"title": {
"type": "string",
"maxLength": 2000
},
"slug": {
"type": "string",
"maxLength": 191
},
"mobiledoc": {
"type": ["string", "null"],
"maxLength": 1000000000
},
"html": {
"type": ["string", "null"],
"maxLength": 1000000000
},
"feature_image": {
"type": ["string", "null"],
"format": "uri-reference",
"maxLength": 2000
},
"featured": {
"type": "boolean"
},
"status": {
"type": "string",
"enum": ["published", "draft", "scheduled"]
},
"locale": {
"type": ["string", "null"],
"maxLength": 6
},
"visibility": {
"type": ["string", "null"],
"enum": ["public"]
},
"meta_title": {
"type": ["string", "null"],
"maxLength": 300
},
"meta_description": {
"type": ["string", "null"],
"maxLength": 500
},
"updated_at": {
"type": ["string", "null"],
"format": "date-time"
},
"published_at": {
"type": ["string", "null"],
"format": "date-time"
},
"custom_excerpt": {
"type": ["string", "null"],
"maxLength": 300
},
"codeinjection_head": {
"type": ["string", "null"],
"maxLength": 65535
},
"codeinjection_foot": {
"type": ["string", "null"],
"maxLength": 65535
},
"og_image": {
"type": ["string", "null"],
"format": "uri-reference",
"maxLength": 2000
},
"og_title": {
"type": ["string", "null"],
"maxLength": 300
},
"og_description": {
"type": ["string", "null"],
"maxLength": 500
},
"twitter_image": {
"type": ["string", "null"],
"format": "uri-reference",
"maxLength": 2000
},
"twitter_title": {
"type": ["string", "null"],
"maxLength": 300
},
"twitter_description": {
"type": ["string", "null"],
"maxLength": 500
},
"custom_template": {
"type": ["string", "null"],
"maxLength": 100
},
"canonical_url": {
"type": ["string", "null"],
"format": "uri-reference",
"maxLength": 2000
},
"authors": {
"$ref": "#/definitions/page-authors"
},
"tags": {
"$ref": "#/definitions/page-tags"
},
"id": {
"strip": true
},
"page": {
"strip": true
},
"author": {
"strip": true
},
"author_id": {
"strip": true
},
"created_at": {
"strip": true
},
"created_by": {
"strip": true
},
"updated_by": {
"strip": true
},
"published_by": {
"strip": true
},
"url": {
"strip": true
},
"primary_tag": {
"strip": true
},
"primary_author": {
"strip": true
},
"excerpt": {
"strip": true
},
"plaintext": {
"strip": true
}
}
},
"page-authors": {
"description": "Authors of the page",
"type": "array",
"items": {
"anyOf": [{
"type": "object",
"properties": {
"id": {
"type": "string",
"maxLength": 24
},
"slug": {
"type": "string",
"maxLength": 191
},
"email": {
"type": "string",
"maxLength": 191
},
"roles": {
"strip": true
},
"permissions": {
"strip": true
}
},
"anyOf": [
{"required": ["id"]},
{"required": ["slug"]},
{"required": ["email"]}
]
}, {
"type": "string",
"maxLength": 191
}]
}
},
"page-tags": {
"description": "Tags of the page",
"type": "array",
"items": {
"anyOf": [{
"type": "object",
"properties": {
"id": {
"type": "string",
"maxLength": 24
},
"name": {
"type": "string",
"maxLength": 191
},
"slug": {
"type": [
"string",
"null"
],
"maxLength": 191
},
"parent": {
"strip": true
},
"parent_id": {
"strip": true
},
"pages": {
"strip": true
}
},
"anyOf": [
{
"required": [
"id"
]
},
{
"required": [
"name"
]
},
{
"required": [
"slug"
]
}
]
}, {
"type": "string",
"maxLength": 191
}]
}
}
}
}

View file

@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "posts.add",
"title": "posts.add",
"description": "Schema for posts.add",
"type": "object",
"additionalProperties": false,
"properties": {
"posts": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {
"type": "object",
"allOf": [{"$ref": "posts#/definitions/post"}],
"required": ["title"]
}
}
},
"required": [ "posts" ]
}

View file

@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "posts.edit",
"title": "posts.edit",
"description": "Schema for posts.edit",
"type": "object",
"additionalProperties": false,
"properties": {
"posts": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {
"type": "object",
"allOf": [{"$ref": "posts#/definitions/post"}],
"required": ["updated_at"]
}
}
},
"required": [ "posts" ]
}

View file

@ -0,0 +1,236 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "posts",
"title": "posts",
"description": "Base posts definitions",
"definitions": {
"post": {
"type": "object",
"additionalProperties": false,
"properties": {
"title": {
"type": "string",
"maxLength": 2000
},
"slug": {
"type": "string",
"maxLength": 191
},
"mobiledoc": {
"type": ["string", "null"],
"maxLength": 1000000000
},
"html": {
"type": ["string", "null"],
"maxLength": 1000000000
},
"feature_image": {
"type": ["string", "null"],
"format": "uri-reference",
"maxLength": 2000
},
"featured": {
"type": "boolean"
},
"status": {
"type": "string",
"enum": ["published", "draft", "scheduled"]
},
"locale": {
"type": ["string", "null"],
"maxLength": 6
},
"visibility": {
"type": ["string", "null"],
"enum": ["public"]
},
"meta_title": {
"type": ["string", "null"],
"maxLength": 300
},
"meta_description": {
"type": ["string", "null"],
"maxLength": 500
},
"updated_at": {
"type": ["string", "null"],
"format": "date-time"
},
"published_at": {
"type": ["string", "null"],
"format": "date-time"
},
"custom_excerpt": {
"type": ["string", "null"],
"maxLength": 300
},
"codeinjection_head": {
"type": ["string", "null"],
"maxLength": 65535
},
"codeinjection_foot": {
"type": ["string", "null"],
"maxLength": 65535
},
"og_image": {
"type": ["string", "null"],
"format": "uri-reference",
"maxLength": 2000
},
"og_title": {
"type": ["string", "null"],
"maxLength": 300
},
"og_description": {
"type": ["string", "null"],
"maxLength": 500
},
"twitter_image": {
"type": ["string", "null"],
"format": "uri-reference",
"maxLength": 2000
},
"twitter_title": {
"type": ["string", "null"],
"maxLength": 300
},
"twitter_description": {
"type": ["string", "null"],
"maxLength": 500
},
"custom_template": {
"type": ["string", "null"],
"maxLength": 100
},
"canonical_url": {
"type": ["string", "null"],
"format": "uri-reference",
"maxLength": 2000
},
"authors": {
"$ref": "#/definitions/post-authors"
},
"tags": {
"$ref": "#/definitions/post-tags"
},
"id": {
"strip": true
},
"author": {
"strip": true
},
"author_id": {
"strip": true
},
"page": {
"strip": true
},
"created_at": {
"strip": true
},
"created_by": {
"strip": true
},
"updated_by": {
"strip": true
},
"published_by": {
"strip": true
},
"url": {
"strip": true
},
"primary_tag": {
"strip": true
},
"primary_author": {
"strip": true
},
"excerpt": {
"strip": true
},
"plaintext": {
"strip": true
}
}
},
"post-authors": {
"description": "Authors of the post",
"type": "array",
"items": {
"anyOf": [{
"type": "object",
"properties": {
"id": {
"type": "string",
"maxLength": 24
},
"slug": {
"type": "string",
"maxLength": 191
},
"email": {
"type": "string",
"maxLength": 191
},
"roles": {
"strip": true
},
"permissions": {
"strip": true
}
},
"anyOf": [
{"required": ["id"]},
{"required": ["slug"]},
{"required": ["email"]}
]
}, {
"type": "string",
"maxLength": 191
}]
}
},
"post-tags": {
"description": "Tags of the post",
"type": "array",
"items": {
"anyOf": [{
"type": "object",
"properties": {
"id": {
"type": "string",
"maxLength": 24
},
"name": {
"type": "string",
"maxLength": 191
},
"slug": {
"type": ["string", "null"],
"maxLength": 191
},
"parent": {
"strip": true
},
"parent_id": {
"strip": true
},
"posts": {
"strip": true
}
},
"anyOf": [
{"required": ["id"]},
{"required": ["name"]},
{"required": ["slug"]}
]
}, {
"type": "string",
"maxLength": 191
}]
}
}
}
}

View file

@ -0,0 +1,23 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "tags.add",
"title": "tags.add",
"description": "Schema for tags.add",
"type": "object",
"additionalProperties": false,
"properties": {
"tags": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"additionalProperties": false,
"items": {
"type": "object",
"allOf": [{"$ref": "tags#/definitions/tag"}],
"required": ["name"]
}
}
},
"required": [ "tags" ]
}

View file

@ -0,0 +1,18 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "tags.edit",
"title": "tags.edit",
"description": "Schema for tags.edit",
"type": "object",
"additionalProperties": false,
"properties": {
"tags": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {"$ref": "tags#/definitions/tag"}
}
},
"required": [ "tags" ]
}

View file

@ -0,0 +1,70 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "tags",
"title": "tags",
"description": "Base tags definitions",
"definitions": {
"tag": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^([^,]|$)"
},
"slug": {
"type": ["string", "null"],
"maxLength": 191
},
"description": {
"type": ["string", "null"],
"maxLength": 500
},
"feature_image": {
"type": ["string", "null"],
"format": "uri-reference",
"maxLength": 2000
},
"visibility": {
"type": "string",
"enum": ["public", "internal"]
},
"meta_title": {
"type": ["string", "null"],
"maxLength": 300
},
"meta_description": {
"type": ["string", "null"],
"maxLength": 500
},
"id": {
"strip": true
},
"parent": {
"strip": true
},
"parent_id": {
"strip": true
},
"created_at": {
"strip": true
},
"created_by": {
"strip": true
},
"updated_at": {
"strip": true
},
"updated_by": {
"strip": true
},
"url": {
"strip": true
}
}
}
}
}

View file

@ -0,0 +1,39 @@
const Promise = require('bluebird');
const _ = require('lodash');
const common = require('../../../../../lib/common');
module.exports = {
read(apiConfig, frame) {
// @NOTE: was removed (https://github.com/TryGhost/Ghost/commit/8bb7088ba026efd4a1c9cf7d6f1a5e9b4fa82575)
if (frame.options.key === 'permalinks') {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.errors.resourceNotFound')
}));
}
},
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 common.errors.BadRequestError({
message: common.i18n.t('errors.api.settings.activeThemeSetViaAPI.error'),
help: common.i18n.t('errors.api.settings.activeThemeSetViaAPI.help')
})
);
} else if (setting.key === 'permalinks') {
// @NOTE: was removed (https://github.com/TryGhost/Ghost/commit/8bb7088ba026efd4a1c9cf7d6f1a5e9b4fa82575)
errors.push(new common.errors.NotFoundError({
message: common.i18n.t('errors.api.settings.problemFindingSetting', {key: setting.key})
}));
}
});
if (errors.length) {
return Promise.reject(errors[0]);
}
}
};

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