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:
parent
50546d8cba
commit
6d0f19ebfa
|
@ -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}));
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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]
|
||||
};
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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));
|
||||
|
|
Loading…
Reference in a new issue