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

🏗 Migrated scheduler to work with v2 API (#11142)

* Updated scheduler to use v2 API by default

* Updated scheduling for post/page resource types

* Extended base method to take options param with token and jwt options

* Updated token expiration to 6 hours after publish/blog start time to allow retries
This commit is contained in:
Rishabh Garg 2019-09-23 21:42:53 +05:30 committed by GitHub
parent 50546d8cba
commit 6d0f19ebfa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 200 additions and 99 deletions

View file

@ -1,10 +1,65 @@
const Promise = require('bluebird'),
moment = require('moment'),
localUtils = require('../utils'),
common = require('../../../lib/common'),
models = require('../../../models'),
urlUtils = require('../../../lib/url-utils'),
_private = {};
const Promise = require('bluebird');
const moment = require('moment');
const jwt = require('jsonwebtoken');
const localUtils = require('../utils');
const common = require('../../../lib/common');
const models = require('../../../models');
const urlUtils = require('../../../lib/url-utils');
const _private = {};
const SCHEDULED_RESOURCES = ['post', 'page'];
/**
* @description Load the internal scheduler integration
*
* @return {Promise}
*/
_private.getSchedulerIntegration = function () {
return models.Integration.findOne({slug: 'ghost-scheduler'}, {withRelated: 'api_keys'})
.then((integration) => {
if (!integration) {
throw new common.errors.NotFoundError({
message: common.i18n.t('errors.api.resource.resourceNotFound', {
resource: 'Integration'
})
});
}
return integration.toJSON();
});
};
/**
* @description Get signed admin token for making authenticated scheduling requests
*
* @return {Promise}
*/
_private.getSignedAdminToken = function (options) {
const {model, apiUrl, integration} = options;
let key = integration.api_keys[0];
const JWT_OPTIONS = {
keyid: key.id,
algorithm: 'HS256',
audience: apiUrl
};
// Default token expiry is till 6 hours after scheduled time
// or if published_at is in past then till 6 hours after blog start
// to allow for retries in case of network issues
// and never before 10 mins to publish time
let tokenExpiry = moment(model.get('published_at')).add(6, 'h');
if (tokenExpiry.isBefore(moment())) {
tokenExpiry = moment().add(6, 'h');
}
return jwt.sign(
{
exp: tokenExpiry.unix(),
nbf: moment(model.get('published_at')).subtract(10, 'm').unix()
},
Buffer.from(key.secret, 'hex'),
JWT_OPTIONS
);
};
/**
* @description Normalize model data into scheduler notation.
@ -12,13 +67,14 @@ const Promise = require('bluebird'),
* @return {Object}
*/
_private.normalize = function normalize(options) {
const {model, apiUrl, client} = options;
const {model, apiUrl, resourceType} = options;
const resource = `${resourceType}s`;
const signedAdminToken = _private.getSignedAdminToken(options);
let url = `${urlUtils.urlJoin(apiUrl, 'schedules', resource, model.get('id'))}/?token=${signedAdminToken}`;
return {
// NOTE: The scheduler expects a unix timestamp.
time: moment(model.get('published_at')).valueOf(),
// @TODO: We are still using API v0.1
url: `${urlUtils.urlJoin(apiUrl, 'schedules', 'posts', model.get('id'))}?client_id=${client.get('slug')}&client_secret=${client.get('secret')}`,
url: url,
extra: {
httpMethod: 'PUT',
oldTime: model.previous('published_at') ? moment(model.previous('published_at')).valueOf() : null
@ -27,27 +83,27 @@ _private.normalize = function normalize(options) {
};
/**
* @description Load the client credentials for v0.1 API.
*
* @TODO: Remove when we drop v0.1. API v2 uses integrations.
* @description Load all scheduled posts/pages from database.
* @return {Promise}
*/
_private.loadClient = function loadClient() {
return models.Client.findOne({slug: 'ghost-scheduler'}, {columns: ['slug', 'secret']});
};
/**
* @description Load all scheduled posts from database.
* @return {Promise}
*/
_private.loadScheduledPosts = function () {
// TODO: make this version aware?
// const api = require('../../../api');
// return api.schedules.getScheduled()
// .then((result) => {
// return result.posts || [];
// });
return Promise.resolve([]);
_private.loadScheduledResources = function () {
const api = require('../../../api');
// Fetches all scheduled resources(posts/pages) with default API
return Promise.mapSeries(SCHEDULED_RESOURCES, (resourceType) => {
return api.schedules.getScheduled.query({
options: {
resource: resourceType
}
}).then((result) => {
return result[resourceType] || [];
});
}).then((results) => {
return SCHEDULED_RESOURCES.reduce(function (obj, entry, index) {
return Object.assign(obj, {
[entry]: results[index]
});
}, {});
});
};
/**
@ -56,12 +112,9 @@ _private.loadScheduledPosts = function () {
* @return {*}
*/
exports.init = function init(options = {}) {
return Promise.resolve();
// TODO: fix once working on scheduler migration to v2
/*eslint-disable */
const {apiUrl} = options;
let adapter = null,
client = null;
let adapter = null;
let integration = null;
if (!Object.keys(options).length) {
return Promise.reject(new common.errors.IncorrectUsageError({message: 'post-scheduling: no config was provided'}));
@ -71,9 +124,9 @@ exports.init = function init(options = {}) {
return Promise.reject(new common.errors.IncorrectUsageError({message: 'post-scheduling: no apiUrl was provided'}));
}
return _private.loadClient()
.then((_client) => {
client = _client;
return _private.getSchedulerIntegration()
.then((_integration) => {
integration = _integration;
return localUtils.createAdapter(options);
})
.then((_adapter) => {
@ -83,42 +136,38 @@ exports.init = function init(options = {}) {
return [];
}
return _private.loadScheduledPosts();
return _private.loadScheduledResources();
})
.then((scheduledPosts) => {
if (!scheduledPosts.length) {
.then((scheduledResources) => {
if (!Object.keys(scheduledResources).length) {
return;
}
scheduledPosts.forEach((model) => {
// NOTE: We are using reschedule, because custom scheduling adapter could use a database, which needs to be updated
// and not an in-process implementation!
adapter.reschedule(_private.normalize({model, apiUrl, client}), {bootstrap: true});
// Reschedules all scheduled resources on boot
// NOTE: We are using reschedule, because custom scheduling adapter could use a database, which needs to be updated
// and not an in-process implementation!
Object.keys(scheduledResources).forEach((resourceType) => {
scheduledResources[resourceType].forEach((model) => {
adapter.reschedule(_private.normalize({model, apiUrl, integration, resourceType}), {bootstrap: true});
});
});
})
.then(() => {
adapter.run();
})
.then(() => {
common.events.onMany([
'post.scheduled',
'page.scheduled'
], (model) => {
adapter.schedule(_private.normalize({model, apiUrl, client}));
});
SCHEDULED_RESOURCES.forEach((resource) => {
common.events.on(`${resource}.scheduled`, (model) => {
adapter.schedule(_private.normalize({model, apiUrl, integration, resourceType: resource}));
});
common.events.onMany([
'post.rescheduled',
'page.rescheduled'
], (model) => {
adapter.reschedule(_private.normalize({model, apiUrl, client}));
});
common.events.on(`${resource}.rescheduled`, (model) => {
adapter.reschedule(_private.normalize({model, apiUrl, integration, resourceType: resource}));
});
common.events.onMany([
'post.unscheduled',
'page.unscheduled'
], (model) => {
adapter.unschedule(_private.normalize({model, apiUrl, client}));
common.events.on(`${resource}.unscheduled`, (model) => {
adapter.unschedule(_private.normalize({model, apiUrl, integration, resourceType: resource}));
});
});
});
};

View file

@ -111,18 +111,16 @@ module.exports = {
}
},
query(frame) {
const resourceType = frame.options.resource;
const resourceModel = (resourceType === 'posts') ? 'Post' : 'Page';
const resourceModel = 'Post';
const resourceType = (frame.options.resource === 'post') ? 'post' : 'page';
const cleanOptions = {};
cleanOptions.filter = 'status:scheduled';
cleanOptions.columns = ['id', 'published_at', 'created_at'];
cleanOptions.filter = `status:scheduled+type:${resourceType}`;
cleanOptions.columns = ['id', 'published_at', 'created_at', 'type'];
return models[resourceModel].findAll(cleanOptions)
.then((result) => {
let response = {};
response[resourceType] = result;
return response;
});
}

View file

@ -111,18 +111,16 @@ module.exports = {
}
},
query(frame) {
const resourceType = frame.options.resource;
const resourceModel = (resourceType === 'posts') ? 'Post' : 'Page';
const resourceModel = 'Post';
const resourceType = (frame.options.resource === 'post') ? 'post' : 'page';
const cleanOptions = {};
cleanOptions.filter = 'status:scheduled';
cleanOptions.columns = ['id', 'published_at', 'created_at'];
cleanOptions.filter = `status:scheduled+type:${resourceType}`;
cleanOptions.columns = ['id', 'published_at', 'created_at', 'type'];
return models[resourceModel].findAll(cleanOptions)
.then((result) => {
let response = {};
response[resourceType] = result;
return response;
});
}

View file

@ -45,7 +45,7 @@ function initialiseServices() {
active: config.get('scheduling').active,
// NOTE: When changing API version need to consider how to migrate custom scheduling adapters
// that rely on URL to lookup persisted scheduled records (jobs, etc.). Ref: https://github.com/TryGhost/Ghost/pull/10726#issuecomment-489557162
apiUrl: urlUtils.urlFor('api', {version: 'v2', versionType: 'content'}, true),
apiUrl: urlUtils.urlFor('api', {version: 'v2', versionType: 'admin'}, true),
internalPath: config.get('paths').internalSchedulingPath,
contentPath: config.getContentPath('scheduling')
})

View file

@ -2,10 +2,11 @@ const jwt = require('jsonwebtoken');
const url = require('url');
const models = require('../../../models');
const common = require('../../../lib/common');
const _ = require('lodash');
const JWT_OPTIONS = {
maxAge: '5m',
algorithms: ['HS256']
let JWT_OPTIONS = {
algorithms: ['HS256'],
maxAge: '5m'
};
/**
@ -21,9 +22,50 @@ const _extractTokenFromHeader = function extractTokenFromHeader(header) {
}
};
/**
* Extract JWT token from admin API URL query
* Eg. ${ADMIN_API_URL}/?token=${JWT}
* @param {string} reqUrl
*/
const _extractTokenFromUrl = function extractTokenFromUrl(reqUrl) {
const {query} = url.parse(reqUrl, true);
return query.token;
};
const authenticate = (req, res, next) => {
// CASE: we don't have an Authorization header so allow fallthrough to other
// auth middleware or final "ensure authenticated" check
if (!req.headers || !req.headers.authorization) {
req.api_key = null;
return next();
}
const token = _extractTokenFromHeader(req.headers.authorization);
if (!token) {
return next(new common.errors.UnauthorizedError({
message: common.i18n.t('errors.middleware.auth.incorrectAuthHeaderFormat'),
code: 'INVALID_AUTH_HEADER'
}));
}
return authenticateWithToken(req, res, next, {token, JWT_OPTIONS});
};
const authenticateWithUrl = (req, res, next) => {
const token = _extractTokenFromUrl(req.originalUrl);
if (!token) {
return next(new common.errors.UnauthorizedError({
message: common.i18n.t('errors.middleware.auth.invalidTokenWithMessage', {message: 'No token found in URL'}),
code: 'INVALID_JWT'
}));
}
// CASE: Scheduler publish URLs can have long maxAge but controllerd by expiry and neverBefore
return authenticateWithToken(req, res, next, {token, JWT_OPTIONS: _.omit(JWT_OPTIONS, 'maxAge')});
};
/**
* Admin API key authentication flow:
* 1. extract the JWT token from the `Authorization: Ghost xxxx` header
* 1. extract the JWT token from the `Authorization: Ghost xxxx` header or from URL(for schedules)
* 2. decode the JWT to extract the api_key id from the "key id" header claim
* 3. find a matching api_key record
* 4. verify the JWT (matching secret, matching URL path, not expired)
@ -35,23 +77,7 @@ const _extractTokenFromHeader = function extractTokenFromHeader(header) {
* - the "Audience" claim should match the requested API path
* https://tools.ietf.org/html/rfc7519#section-4.1.3
*/
const authenticate = (req, res, next) => {
// CASE: we don't have an Authorization header so allow fallthrough to other
// auth middleware or final "ensure authenticated" check
if (!req.headers || !req.headers.authorization) {
req.api_key = null;
return next();
}
const token = _extractTokenFromHeader(req.headers.authorization);
if (!token) {
return next(new common.errors.UnauthorizedError({
message: common.i18n.t('errors.middleware.auth.incorrectAuthHeaderFormat'),
code: 'INVALID_AUTH_HEADER'
}));
}
const authenticateWithToken = (req, res, next, {token, JWT_OPTIONS}) => {
const decoded = jwt.decode(token, {complete: true});
if (!decoded || !decoded.header) {
@ -123,5 +149,6 @@ const authenticate = (req, res, next) => {
};
module.exports = {
authenticate
authenticate,
authenticateWithUrl
};

View file

@ -4,6 +4,7 @@ const members = require('./members');
const authenticate = {
authenticateAdminApi: [apiKeyAuth.admin.authenticate, session.authenticate],
authenticateAdminApiWithUrl: [apiKeyAuth.admin.authenticateWithUrl],
authenticateContentApi: [apiKeyAuth.content.authenticateContentApiKey, members.authenticateMembersToken]
};

View file

@ -57,6 +57,20 @@ module.exports.authAdminApi = [
notImplemented
];
/**
* Authentication for private endpoints with token in URL
* Ex.: For scheduler publish endpoint
*/
module.exports.authAdminApiWithUrl = [
auth.authenticate.authenticateAdminApiWithUrl,
auth.authorize.authorizeAdminApi,
shared.middlewares.updateUserLastSeen,
shared.middlewares.api.cors,
shared.middlewares.urlRedirects.adminRedirect,
shared.middlewares.prettyUrls,
notImplemented
];
/**
* Middleware for public admin endpoints
*/

View file

@ -48,7 +48,7 @@ module.exports = function apiRoutes() {
router.del('/integrations/:id', mw.authAdminApi, http(apiCanary.integrations.destroy));
// ## Schedules
router.put('/schedules/:resource/:id', mw.authAdminApi, http(apiCanary.schedules.publish));
router.put('/schedules/:resource/:id', mw.authAdminApiWithUrl, http(apiCanary.schedules.publish));
// ## Settings
router.get('/settings/routes/yaml', mw.authAdminApi, http(apiCanary.settings.download));

View file

@ -56,6 +56,20 @@ module.exports.authAdminApi = [
notImplemented
];
/**
* Authentication for private endpoints with token in URL
* Ex.: For scheduler publish endpoint
*/
module.exports.authAdminApiWithUrl = [
auth.authenticate.authenticateAdminApiWithUrl,
auth.authorize.authorizeAdminApi,
shared.middlewares.updateUserLastSeen,
shared.middlewares.api.cors,
shared.middlewares.urlRedirects.adminRedirect,
shared.middlewares.prettyUrls,
notImplemented
];
/**
* Middleware for public admin endpoints
*/

View file

@ -48,7 +48,7 @@ module.exports = function apiRoutes() {
router.del('/integrations/:id', mw.authAdminApi, http(apiv2.integrations.destroy));
// ## Schedules
router.put('/schedules/:resource/:id', mw.authAdminApi, http(apiv2.schedules.publish));
router.put('/schedules/:resource/:id', mw.authAdminApiWithUrl, http(apiv2.schedules.publish));
// ## Settings
router.get('/settings/routes/yaml', mw.authAdminApi, http(apiv2.settings.download));