🎨Added absolute_url flag to public api (#9833)

closes #9832

The API _should_ be returning absolute URLs for everything, 3rd party applications require absolute urls to read and display ghost data correctly. Currently they have to concat the blog url and the resource url, which is very uncomfortable.

Changing the public api like this would be considered a breaking change however so we've opted to put it behind a query parameter named `absolute_urls`.
This commit is contained in:
Fabien O'Carroll 2018-08-31 18:02:39 +08:00 committed by Katharina Irrgang
parent a796d73ed0
commit c9b8ddde4b
17 changed files with 245 additions and 33 deletions

View File

@ -39,7 +39,7 @@ posts = {
* @returns {Promise<Posts>} Posts Collection with Meta
*/
browse: function browse(options) {
var extraOptions = ['status', 'formats'],
var extraOptions = ['status', 'formats', 'absolute_urls'],
permittedOptions,
tasks;
@ -83,7 +83,7 @@ posts = {
read: function read(options) {
var attrs = ['id', 'slug', 'status', 'uuid'],
// NOTE: the scheduler API uses the post API and forwards custom options
extraAllowedOptions = options.opts || ['formats'],
extraAllowedOptions = options.opts || ['formats', 'absolute_urls'],
tasks;
/**

View File

@ -22,7 +22,8 @@ tags = {
* @returns {Promise<Tags>} Tags Collection
*/
browse: function browse(options) {
var tasks;
var tasks,
permittedOptions = localUtils.browseDefaultOptions.concat('absolute_urls');
/**
* ### Model Query
@ -36,7 +37,7 @@ tags = {
// Push all of our tasks into a `tasks` array in the correct order
tasks = [
localUtils.validate(docName, {opts: localUtils.browseDefaultOptions}),
localUtils.validate(docName, {opts: permittedOptions}),
localUtils.convertOptions(allowedIncludes),
localUtils.handlePublicPermissions(docName, 'browse'),
doQuery
@ -53,6 +54,7 @@ tags = {
*/
read: function read(options) {
var attrs = ['id', 'slug', 'visibility'],
permittedOptions = ['absolute_urls'],
tasks;
/**
@ -78,7 +80,7 @@ tags = {
// Push all of our tasks into a `tasks` array in the correct order
tasks = [
localUtils.validate(docName, {attrs: attrs}),
localUtils.validate(docName, {attrs: attrs, opts: permittedOptions}),
localUtils.convertOptions(allowedIncludes),
localUtils.handlePublicPermissions(docName, 'read'),
doQuery

View File

@ -25,7 +25,7 @@ users = {
* @returns {Promise<Users>} Users Collection
*/
browse: function browse(options) {
var extraOptions = ['status'],
var extraOptions = ['status', 'absolute_urls'],
permittedOptions = localUtils.browseDefaultOptions.concat(extraOptions),
tasks;
@ -58,6 +58,7 @@ users = {
*/
read: function read(options) {
var attrs = ['id', 'slug', 'status', 'email', 'role'],
permittedOptions = ['absolute_urls'],
tasks;
// Special handling for /users/me request
@ -88,7 +89,7 @@ users = {
// Push all of our tasks into a `tasks` array in the correct order
tasks = [
localUtils.validate(docName, {attrs: attrs}),
localUtils.validate(docName, {attrs: attrs, opts: permittedOptions}),
localUtils.convertOptions(allowedIncludes),
localUtils.handlePublicPermissions(docName, 'read'),
doQuery

View File

@ -499,7 +499,7 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
*/
permittedOptions: function permittedOptions(methodName) {
if (methodName === 'toJSON') {
return ['shallow', 'withRelated', 'context', 'columns'];
return ['shallow', 'withRelated', 'context', 'columns', 'absolute_urls'];
}
// terms to whitelist for all methods.
@ -659,7 +659,10 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
* information about the request (page, limit), along with the
* info needed for pagination (pages, total).
*
* @TODO: This model function does return JSON O_O.
* @TODO:
* - this model function does return JSON O_O
* - if you refactor that out, you should double check the allowed filter options
* - because `toJSON` is called in here and is using the filtered options for the `findPage` function
*
* **response:**
*

View File

@ -10,6 +10,7 @@ var _ = require('lodash'),
config = require('../config'),
converters = require('../lib/mobiledoc/converters'),
urlService = require('../services/url'),
{urlFor, makeAbsoluteUrls} = require('../services/url/utils'),
relations = require('./relations'),
Post,
Posts;
@ -440,6 +441,7 @@ Post = ghostBookshelf.Model.extend({
attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options);
attrs = this.formatsToJSON(attrs, options);
attrs.url = urlService.getUrlByResourceId(attrs.id);
// If the current column settings allow it...
if (!options.columns || (options.columns && options.columns.indexOf('primary_tag') > -1)) {
@ -451,8 +453,26 @@ Post = ghostBookshelf.Model.extend({
}
}
if (!options.columns || (options.columns && options.columns.indexOf('url') > -1)) {
attrs.url = urlService.getUrlByResourceId(attrs.id);
if (options.columns && !options.columns.includes('url')) {
delete attrs.url;
}
if (options && options.context && options.context.public && options.absolute_urls) {
if (attrs.feature_image) {
attrs.feature_image = urlFor('image', {image: attrs.feature_image}, true);
}
if (attrs.og_image) {
attrs.og_image = urlFor('image', {image: attrs.og_image}, true);
}
if (attrs.twitter_image) {
attrs.twitter_image = urlFor('image', {image: attrs.twitter_image}, true);
}
if (attrs.html) {
attrs.html = makeAbsoluteUrls(attrs.html, urlFor('home', true), attrs.url).html();
}
if (attrs.url) {
attrs.url = urlFor({relativeUrl: attrs.url}, true);
}
}
return attrs;
@ -534,13 +554,13 @@ Post = ghostBookshelf.Model.extend({
* @return {Array} Keys allowed in the `options` hash of the model's method.
*/
permittedOptions: function permittedOptions(methodName) {
var options = ghostBookshelf.Model.permittedOptions(),
var options = ghostBookshelf.Model.permittedOptions(methodName),
// whitelists for the `options` hash argument on methods, by method name.
// these are the only options that can be passed to Bookshelf / Knex.
validOptions = {
findOne: ['columns', 'importing', 'withRelated', 'require'],
findPage: ['page', 'limit', 'columns', 'filter', 'order', 'status', 'staticPages'],
findPage: ['page', 'limit', 'columns', 'filter', 'order', 'status', 'staticPages', 'absolute_urls'],
findAll: ['columns', 'filter'],
destroy: ['destroyAll']
};

View File

@ -1,4 +1,6 @@
const ghostBookshelf = require('./base');
const ghostBookshelf = require('./base'),
urlService = require('../services/url'),
{urlFor} = require('../services/url/utils');
let Tag, Tags;
Tag = ghostBookshelf.Model.extend({
@ -66,6 +68,15 @@ Tag = ghostBookshelf.Model.extend({
attrs.parent = attrs.parent || attrs.parent_id;
delete attrs.parent_id;
if (options && options.context && options.context.public && options.absolute_urls) {
attrs.url = urlFor({
relativeUrl: urlService.getUrlByResourceId(attrs.id)
}, true);
if (attrs.feature_image) {
attrs.feature_image = urlFor('image', {image: attrs.feature_image}, true);
}
}
return attrs;
}
}, {
@ -81,12 +92,12 @@ Tag = ghostBookshelf.Model.extend({
},
permittedOptions: function permittedOptions(methodName) {
var options = ghostBookshelf.Model.permittedOptions(),
var options = ghostBookshelf.Model.permittedOptions(methodName),
// whitelists for the `options` hash argument on methods, by method name.
// these are the only options that can be passed to Bookshelf / Knex.
validOptions = {
findPage: ['page', 'limit', 'columns', 'filter', 'order'],
findPage: ['page', 'limit', 'columns', 'filter', 'order', 'absolute_urls'],
findAll: ['columns'],
findOne: ['visibility'],
destroy: ['destroyAll']

View File

@ -9,12 +9,14 @@ const _ = require('lodash'),
imageLib = require('../lib/image'),
pipeline = require('../lib/promise/pipeline'),
validation = require('../data/validation'),
urlService = require('../services/url'),
activeStates = ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4'],
/**
* inactive: owner user before blog setup, suspended users
* locked user: imported users, they get a random passport
*/
inactiveStates = ['inactive', 'locked'],
{urlFor} = require('../services/url/utils'),
allStates = activeStates.concat(inactiveStates);
let User, Users;
@ -209,6 +211,17 @@ User = ghostBookshelf.Model.extend({
delete attrs.last_seen;
delete attrs.status;
delete attrs.ghost_auth_id;
if (options.absolute_urls) {
attrs.url = urlFor({
relativeUrl: urlService.getUrlByResourceId(attrs.id)
}, true);
if (attrs.profile_image) {
attrs.profile_image = urlFor('image', {image: attrs.profile_image}, true);
}
if (attrs.cover_image) {
attrs.cover_image = urlFor('image', {image: attrs.cover_image}, true);
}
}
}
return attrs;
@ -315,7 +328,7 @@ User = ghostBookshelf.Model.extend({
* @return {Array} Keys allowed in the `options` hash of the model's method.
*/
permittedOptions: function permittedOptions(methodName, options) {
var permittedOptionsToReturn = ghostBookshelf.Model.permittedOptions(),
var permittedOptionsToReturn = ghostBookshelf.Model.permittedOptions(methodName),
// whitelists for the `options` hash argument on methods, by method name.
// these are the only options that can be passed to Bookshelf / Knex.
@ -324,7 +337,7 @@ User = ghostBookshelf.Model.extend({
setup: ['id'],
edit: ['withRelated', 'id', 'importPersistUser'],
add: ['importPersistUser'],
findPage: ['page', 'limit', 'columns', 'filter', 'order', 'status'],
findPage: ['page', 'limit', 'columns', 'filter', 'order', 'status', 'absolute_urls'],
findAll: ['filter']
};

View File

@ -1,5 +1,6 @@
// # API routes
const debug = require('ghost-ignition').debug('api'),
boolParser = require('express-query-boolean'),
express = require('express'),
// routes
@ -33,6 +34,9 @@ module.exports = function setupApiApp() {
apiApp.use(bodyParser.json({limit: '1mb'}));
apiApp.use(bodyParser.urlencoded({extended: true, limit: '1mb'}));
// Query parsing
apiApp.use(boolParser());
// send 503 json response in case of maintenance
apiApp.use(maintenance);

View File

@ -1,6 +1,8 @@
var should = require('should'),
supertest = require('supertest'),
_ = require('lodash'),
url = require('url'),
cheerio = require('cheerio'),
moment = require('moment'),
testUtils = require('../../../utils'),
configUtils = require('../../../utils/configUtils'),
@ -53,6 +55,39 @@ describe('Public API', function () {
});
});
it('browse posts: request absolute urls', function (done) {
request.get(testUtils.API.getApiQuery('posts/?client_id=ghost-admin&client_secret=not_available&absolute_urls=true'))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.end(function (err, res) {
if (err) {
return done(err);
}
should.exist(res.body.posts);
// kitchen sink
res.body.posts[9].slug.should.eql(testUtils.DataGenerator.Content.posts[1].slug);
let urlParts = url.parse(res.body.posts[9].feature_image);
should.exist(urlParts.protocol);
should.exist(urlParts.host);
urlParts = url.parse(res.body.posts[9].url);
should.exist(urlParts.protocol);
should.exist(urlParts.host);
const $ = cheerio.load(res.body.posts[9].html);
urlParts = url.parse($('img').attr('src'));
should.exist(urlParts.protocol);
should.exist(urlParts.host);
done();
});
});
it('browse posts from different origin', function (done) {
request.get(testUtils.API.getApiQuery('posts/?client_id=ghost-test&client_secret=not_available'))
.set('Origin', 'https://example.com')

View File

@ -1,6 +1,7 @@
/* eslint no-invalid-this:0 */
const should = require('should'),
url = require('url'),
sinon = require('sinon'),
_ = require('lodash'),
testUtils = require('../../utils'),
@ -33,6 +34,63 @@ describe('Unit: models/post', function () {
sandbox.restore();
});
describe('toJSON', function () {
const toJSON = function toJSON(model, options) {
return new models.Post(model).toJSON(options);
};
describe('Public context', function () {
const context = {
public: true
};
it('converts relative feature_image url to absolute when absolute_urls flag passed', function () {
const model = {
feature_image: '/content/images/feature_image.jpg'
};
const json = toJSON(model, {context, absolute_urls: true});
const featureImageUrlObject = url.parse(json.feature_image);
should.exist(featureImageUrlObject.protocol);
should.exist(featureImageUrlObject.host);
});
it('converts relative twitter_image url to absolute when absolute_urls flag passed', function () {
const model = {
twitter_image: '/content/images/twitter_image.jpg'
};
const json = toJSON(model, {context, absolute_urls: true});
const twitterImageUrlObject = url.parse(json.twitter_image);
should.exist(twitterImageUrlObject.protocol);
should.exist(twitterImageUrlObject.host);
});
it('converts relative og_image url to absolute when absolute_urls flag passed', function () {
const model = {
og_image: '/content/images/og_image.jpg'
};
const json = toJSON(model, {context, absolute_urls: true});
const ogImageUrlObject = url.parse(json.og_image);
should.exist(ogImageUrlObject.protocol);
should.exist(ogImageUrlObject.host);
});
it('converts relative content urls to absolute when absolute_urls flag passed', function () {
const model = {
html: '<img src="/content/images/my-coole-image.jpg">'
};
const json = toJSON(model, {context, absolute_urls: true});
const imgSrc = json.html.match(/src="([^"]+)"/)[1];
const imgSrcUrlObject = url.parse(imgSrc);
should.exist(imgSrcUrlObject.protocol);
should.exist(imgSrcUrlObject.host);
});
});
});
describe('add', function () {
describe('ensure full set of data for model events', function () {
it('default', function () {

View File

@ -1,4 +1,5 @@
var should = require('should'),
url = require('url'),
sinon = require('sinon'),
models = require('../../../server/models'),
testUtils = require('../../utils'),
@ -16,6 +17,29 @@ describe('Unit: models/tags', function () {
before(testUtils.teardown);
before(testUtils.setup('tags'));
describe('toJSON', function () {
const toJSON = function toJSON(model, options) {
return new models.Tag(model).toJSON(options);
};
describe('Public context', function () {
const context = {
public: true
};
it('converts relative feature_image url to absolute when absolute_urls flag passed', function () {
const model = {
feature_image: '/content/images/feature_image.jpg'
};
const json = toJSON(model, {context, absolute_urls: true});
const featureImageUrlObject = url.parse(json.feature_image);
should.exist(featureImageUrlObject.protocol);
should.exist(featureImageUrlObject.host);
});
});
});
describe('Edit', function () {
it('resets given empty value to null', function () {
return models.Tag.findOne({slug: 'kitchen-sink'})

View File

@ -1,4 +1,5 @@
const should = require('should'),
url = require('url'),
sinon = require('sinon'),
_ = require('lodash'),
schema = require('../../../server/data/schema'),
@ -21,6 +22,40 @@ describe('Unit: models/user', function () {
sandbox.restore();
});
describe('toJSON', function () {
const toJSON = function toJSON(model, options) {
return new models.User(model).toJSON(options);
};
describe('Public context', function () {
const context = {
public: true
};
it('converts relative profile_image url to absolute when absolute_urls flag passed', function () {
const model = {
profile_image: '/content/images/profile_image.jpg'
};
const json = toJSON(model, {context, absolute_urls: true});
const profileImageUrlObject = url.parse(json.profile_image);
should.exist(profileImageUrlObject.protocol);
should.exist(profileImageUrlObject.host);
});
it('converts relative cover_image url to absolute when absolute_urls flag passed', function () {
const model = {
cover_image: '/content/images/cover_image.jpg'
};
const json = toJSON(model, {context, absolute_urls: true});
const coverImageUrlObject = url.parse(json.cover_image);
should.exist(coverImageUrlObject.protocol);
should.exist(coverImageUrlObject.host);
});
});
});
describe('validation', function () {
beforeEach(function () {
sandbox.stub(security.password, 'hash').resolves('$2a$10$we16f8rpbrFZ34xWj0/ZC.LTPUux8ler7bcdTs5qIleN6srRHhilG');

View File

@ -92,7 +92,7 @@ describe('RSS: Generate Feed', function () {
xmlData.should.match(/<guid isPermaLink="false">/);
xmlData.should.match(/<\/guid><dc:creator><!\[CDATA\[Joe Bloggs\]\]><\/dc:creator>/);
xmlData.should.match(/<pubDate>Thu, 01 Jan 2015/);
xmlData.should.match(/<content:encoded><!\[CDATA\[<h1>HTML Ipsum Presents<\/h1><p><strong>Pellentes/);
xmlData.should.match(/<content:encoded><!\[CDATA\[<h1>HTML Ipsum Presents<\/h1>/);
xmlData.should.match(/<\/code><\/pre>\]\]><\/content:encoded><\/item>/);
xmlData.should.not.match(/<author>/);

View File

@ -85,7 +85,7 @@ describe('Unit: services/url/Resources', function () {
options.event.should.eql('added');
const obj = _.find(resources.data.posts, {data: {slug: 'test-1234'}}).data;
Object.keys(obj).should.eql([
Object.keys(obj).sort().should.eql([
'id',
'uuid',
'slug',
@ -106,17 +106,17 @@ describe('Unit: services/url/Resources', function () {
'primary_author',
'primary_tag',
'url'
]);
].sort());
should.exist(resources.getByIdAndType(options.eventData.type, options.eventData.id));
obj.tags.length.should.eql(1);
Object.keys(obj.tags[0]).should.eql(['id', 'slug']);
Object.keys(obj.tags[0]).sort().should.eql(['id', 'slug'].sort());
obj.authors.length.should.eql(1);
Object.keys(obj.authors[0]).should.eql(['id', 'slug']);
Object.keys(obj.authors[0]).sort().should.eql(['id', 'slug'].sort());
should.exist(obj.primary_author);
Object.keys(obj.primary_author).should.eql(['id', 'slug']);
Object.keys(obj.primary_author).sort().should.eql(['id', 'slug'].sort());
should.exist(obj.primary_tag);
Object.keys(obj.primary_tag).should.eql(['id', 'slug']);
Object.keys(obj.primary_tag).sort().should.eql(['id', 'slug'].sort());
done();
});
@ -184,7 +184,7 @@ describe('Unit: services/url/Resources', function () {
const obj = _.find(resources.data.posts, {data: {id: resourceToUpdate.data.id}}).data;
Object.keys(obj).should.eql([
Object.keys(obj).sort().should.eql([
'id',
'uuid',
'slug',
@ -205,16 +205,16 @@ describe('Unit: services/url/Resources', function () {
'primary_author',
'primary_tag',
'url'
]);
].sort());
should.exist(obj.tags);
Object.keys(obj.tags[0]).should.eql(['id', 'slug']);
Object.keys(obj.tags[0]).sort().should.eql(['id', 'slug'].sort());
should.exist(obj.authors);
Object.keys(obj.authors[0]).should.eql(['id', 'slug']);
Object.keys(obj.authors[0]).sort().should.eql(['id', 'slug'].sort());
should.exist(obj.primary_author);
Object.keys(obj.primary_author).should.eql(['id', 'slug']);
Object.keys(obj.primary_author).sort().should.eql(['id', 'slug'].sort());
should.exist(obj.primary_tag);
Object.keys(obj.primary_tag).should.eql(['id', 'slug']);
Object.keys(obj.primary_tag).sort().should.eql(['id', 'slug'].sort());
done();
});

View File

@ -37,8 +37,9 @@ DataGenerator.Content = {
id: ObjectId.generate(),
title: 'Ghostly Kitchen Sink',
slug: 'ghostly-kitchen-sink',
mobiledoc: DataGenerator.markdownToMobiledoc('<h1>HTML Ipsum Presents</h1><p><strong>Pellentesque habitant morbi tristique</strong> senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. <em>Aenean ultricies mi vitae est.</em> Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, <code>commodo vitae</code>, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. <a href=\\\"#\\\">Donec non enim</a> in turpis pulvinar facilisis. Ut felis.</p><h2>Header Level 2</h2><ol><li>Lorem ipsum dolor sit amet, consectetuer adipiscing elit.</li><li>Aliquam tincidunt mauris eu risus.</li></ol><blockquote><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.</p></blockquote><h3>Header Level 3</h3><ul><li>Lorem ipsum dolor sit amet, consectetuer adipiscing elit.</li><li>Aliquam tincidunt mauris eu risus.</li></ul><pre><code>#header h1 a{display: block;width: 300px;height: 80px;}</code></pre>'),
published_at: new Date('2015-01-02')
mobiledoc: DataGenerator.markdownToMobiledoc('<h1>HTML Ipsum Presents</h1><img src="/content/images/lol.jpg"><p><strong>Pellentesque habitant morbi tristique</strong> senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. <em>Aenean ultricies mi vitae est.</em> Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, <code>commodo vitae</code>, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. <a href=\\\"#\\\">Donec non enim</a> in turpis pulvinar facilisis. Ut felis.</p><h2>Header Level 2</h2><ol><li>Lorem ipsum dolor sit amet, consectetuer adipiscing elit.</li><li>Aliquam tincidunt mauris eu risus.</li></ol><blockquote><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.</p></blockquote><h3>Header Level 3</h3><ul><li>Lorem ipsum dolor sit amet, consectetuer adipiscing elit.</li><li>Aliquam tincidunt mauris eu risus.</li></ul><pre><code>#header h1 a{display: block;width: 300px;height: 80px;}</code></pre>'),
published_at: new Date('2015-01-02'),
feature_image: '/content/images/2018/hey.jpg'
},
{
id: ObjectId.generate(),

View File

@ -51,6 +51,7 @@
"express": "4.16.3",
"express-brute": "1.0.1",
"express-hbs": "1.0.4",
"express-query-boolean": "2.0.0",
"extract-zip": "1.6.7",
"fs-extra": "3.0.1",
"ghost-gql": "0.0.10",

View File

@ -1766,6 +1766,10 @@ express-hbs@1.0.4, express-hbs@^1.0.3:
js-beautify "1.6.8"
readdirp "2.1.0"
express-query-boolean@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/express-query-boolean/-/express-query-boolean-2.0.0.tgz#ea56ac8138e2b95b171b8eee2af88738302941c3"
express@4.16.3, express@^4.16.2:
version "4.16.3"
resolved "https://registry.yarnpkg.com/express/-/express-4.16.3.tgz#6af8a502350db3246ecc4becf6b5a34d22f7ed53"