Skip to content
Permalink
Browse files

Added Canonical URL support to posts&pages in Admin & Content API v2 (#…

…10594)

refs #10593

- Added `canonical_url` field to post&pages resources in Admin & Content APIs
- Support for canonical URL on metadata layer (used in {{ghost_head}} helper)
- Made sure the new field is not accessible from API v0.1 
- Added handling same domain relative and absolute URLs
  • Loading branch information...
gargol committed Mar 12, 2019
1 parent c1abcc8 commit 34fad7eaafa7c84ea25c3c671d93bcf430f7ef5e
@@ -1,13 +1,26 @@
const _ = require('lodash');
const {absoluteToRelative, getBlogUrl, STATIC_IMAGE_URL_PREFIX} = require('../../../../../../services/url/utils');

const handleCanonicalUrl = (url) => {
const blogDomain = getBlogUrl().replace(/^http(s?):\/\//, '').replace(/\/$/, '');
const absolute = url.replace(/^http(s?):\/\//, '');

if (absolute.startsWith(blogDomain)) {
return absoluteToRelative(url, {withoutSubdirectory: true});
}

return url;
};

const handleImageUrl = (imageUrl) => {
const blogDomain = getBlogUrl().replace(/^http(s?):\/\//, '').replace(/\/$/, '');
const imageUrlAbsolute = imageUrl.replace(/^http(s?):\/\//, '');
const imagePathRe = new RegExp(`^${blogDomain}/${STATIC_IMAGE_URL_PREFIX}`);

if (imagePathRe.test(imageUrlAbsolute)) {
return absoluteToRelative(imageUrl);
}

return imageUrl;
};

@@ -45,6 +58,10 @@ const forPost = (attrs, options) => {
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) {
@@ -1,3 +1,4 @@
const _ = require('lodash');
const utils = require('../../../index');
const url = require('./url');
const date = require('./date');
@@ -25,7 +26,11 @@ const mapTag = (model, frame) => {
};

const mapPost = (model, frame) => {
const jsonModel = model.toJSON(frame.options);
const extendedOptions = Object.assign(_.cloneDeep(frame.options), {
extraProperties: ['canonical_url']
});

const jsonModel = model.toJSON(extendedOptions);

url.forPost(model.id, jsonModel, frame.options);

@@ -16,6 +16,10 @@ const forPost = (id, attrs, options) => {
attrs.twitter_image = urlService.utils.urlFor('image', {image: attrs.twitter_image}, true);
}

if (attrs.canonical_url) {
attrs.canonical_url = urlService.utils.relativeToAbsolute(attrs.canonical_url);
}

if (attrs.html) {
const urlOptions = {
assetsOnly: true
@@ -103,6 +103,11 @@
"type": ["string", "null"],
"maxLength": 100
},
"canonical_url": {
"type": ["string", "null"],
"format": "uri-reference",
"maxLength": 2000
},
"authors": {
"$ref": "#/definitions/page-authors"
},
@@ -103,6 +103,11 @@
"type": ["string", "null"],
"maxLength": 100
},
"canonical_url": {
"type": ["string", "null"],
"format": "uri-reference",
"maxLength": 2000
},
"authors": {
"$ref": "#/definitions/post-authors"
},
@@ -1,8 +1,14 @@
var urlService = require('../../services/url'),
getUrl = require('./url');
const _ = require('lodash');
const urlService = require('../../services/url');
const getUrl = require('./url');

function getCanonicalUrl(data) {
var url = urlService.utils.urlJoin(urlService.utils.urlFor('home', true), getUrl(data, false));
if ((_.includes(data.context, 'post') || _.includes(data.context, 'page'))
&& data.post && data.post.canonical_url) {
return data.post.canonical_url;
}

let url = urlService.utils.urlJoin(urlService.utils.urlFor('home', true), getUrl(data, false));

if (url.indexOf('/amp/')) {
url = url.replace(/\/amp\/$/i, '/');
@@ -0,0 +1,39 @@
const Promise = require('bluebird'),
common = require('../../../../lib/common'),
commands = require('../../../schema').commands,
table = 'posts',
columns = ['canonical_url'],
_private = {};

_private.handle = function handle(options) {
let type = options.type,
isAdding = type === 'Adding',
operation = isAdding ? commands.addColumn : commands.dropColumn;

return function (options) {
let connection = options.connection;

return connection.schema.hasTable(table)
.then(function (exists) {
if (!exists) {
return Promise.reject(new Error('Table does not exist!'));
}

return Promise.each(columns, function (column) {
return connection.schema.hasColumn(table, column)
.then(function (exists) {
if (exists && isAdding || !exists && !isAdding) {
common.logging.warn(`${type} column ${table}.${column}`);
return Promise.resolve();
}

common.logging.info(`${type} column ${table}.${column}`);
return operation(table, column, connection);
});
});
});
};
};

module.exports.up = _private.handle({type: 'Adding'});
module.exports.down = _private.handle({type: 'Dropping'});
@@ -56,7 +56,8 @@ module.exports = {
twitter_image: {type: 'string', maxlength: 2000, nullable: true},
twitter_title: {type: 'string', maxlength: 300, nullable: true},
twitter_description: {type: 'string', maxlength: 500, nullable: true},
custom_template: {type: 'string', maxlength: 100, nullable: true}
custom_template: {type: 'string', maxlength: 100, nullable: true},
canonical_url: {type: 'text', maxlength: 2000, nullable: true}
},
users: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
@@ -572,6 +572,13 @@ Post = ghostBookshelf.Model.extend({
// CASE: never expose the revisions
delete attrs.mobiledoc_revisions;

// expose canonical_url only for API v2 calls
// NOTE: this can be removed when API v0.1 is dropped. A proper solution for field
// differences on resources like this would be an introduction of API output schema
if (!_.get(unfilteredOptions, 'extraProperties', []).includes('canonical_url')) {
delete attrs.canonical_url;
}

// If the current column settings allow it...
if (!options.columns || (options.columns && options.columns.indexOf('primary_tag') > -1)) {
// ... attach a computed property of primary_tag which is the first tag if it is public, else null
@@ -460,11 +460,20 @@ function absoluteToRelative(urlToModify, options) {
return relativePath;
}

function relativeToAbsolute(url) {
if (!url.startsWith('/') || url.startsWith('//')) {
return url;
}

return createUrl(url, true);
}

function deduplicateDoubleSlashes(url) {
return url.replace(/\/\//g, '/');
}

module.exports.absoluteToRelative = absoluteToRelative;
module.exports.relativeToAbsolute = relativeToAbsolute;
module.exports.makeAbsoluteUrls = makeAbsoluteUrls;
module.exports.getProtectedSlugs = getProtectedSlugs;
module.exports.getSubdir = getSubdir;
@@ -22,6 +22,7 @@ const expectedProperties = {
.without('mobiledoc', 'plaintext')
// swaps author_id to author, and always returns computed properties: url, comment_id, primary_tag, primary_author
.without('author_id').concat('author', 'url', 'primary_tag', 'primary_author')
.without('canonical_url')
.value(),
user: {
default: _(schema.users).keys().without('password').without('ghost_auth_access_token').value(),
@@ -151,6 +151,32 @@ describe('Posts API', function () {
});
});

it('canonical_url', function () {
return request
.get(localUtils.API.getApiQuery(`posts/${testUtils.DataGenerator.Content.posts[0].id}/`))
.set('Origin', config.get('url'))
.expect(200)
.then((res) => {
return request
.put(localUtils.API.getApiQuery('posts/' + testUtils.DataGenerator.Content.posts[0].id + '/'))
.set('Origin', config.get('url'))
.send({
posts: [{
canonical_url: `/canonical/url`,
updated_at: res.body.posts[0].updated_at
}]
})
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200);
})
.then((res) => {
should.exist(res.body.posts);
should.exist(res.body.posts[0].canonical_url);
res.body.posts[0].canonical_url.should.equal(`${config.get('url')}/canonical/url`);
});
});

it('update dates & x_by', function () {
const post = {
created_by: ObjectId.generate(),
@@ -23,7 +23,7 @@ describe('getCanonicalUrl', function () {
sinon.restore();
});

it('should return canonical url', function () {
it('should return default canonical url', function () {
const post = testUtils.DataGenerator.forKnex.createPost();

getUrlStub.withArgs(post, false).returns('/post-url/');
@@ -36,6 +36,17 @@ describe('getCanonicalUrl', function () {
getUrlStub.calledOnce.should.be.true();
});

it('should return canonical url field if present', function () {
const post = testUtils.DataGenerator.forKnex.createPost({canonical_url: 'https://example.com/canonical'});

getCanonicalUrl({
context: ['post'],
post: post
}).should.eql('https://example.com/canonical');

getUrlStub.called.should.equal(false);
});

it('should return canonical url for amp post without /amp/ in url', function () {
const post = testUtils.DataGenerator.forKnex.createPost();

@@ -19,7 +19,7 @@ var should = require('should'),
*/
describe('DB version integrity', function () {
// Only these variables should need updating
const currentSchemaHash = '7c5d34376392d01c274700350de228c1';
const currentSchemaHash = 'fda0398e93a74b2dc435cb4c026679ba';
const currentFixturesHash = '42e15796b3c9bdcf0d0ec7eb66a1abf5';

// If this test is failing, then it is likely a change has been made that requires a DB version bump,
@@ -43,6 +43,30 @@ describe('Url', function () {
});
});

describe('relativeToAbsolute', function () {
it('default', function () {
configUtils.set('url', 'http://myblog.com/');
urlService.utils.relativeToAbsolute('/test/').should.eql('http://myblog.com/test/');
});

it('with subdir', function () {
configUtils.set('url', 'http://myblog.com/blog/');
urlService.utils.relativeToAbsolute('/test/').should.eql('http://myblog.com/blog/test/');
});

it('should not convert absolute url', function () {
urlService.utils.relativeToAbsolute('http://anotherblog.com/blog/').should.eql('http://anotherblog.com/blog/');
});

it('should not convert absolute url', function () {
urlService.utils.relativeToAbsolute('http://anotherblog.com/blog/').should.eql('http://anotherblog.com/blog/');
});

it('should not convert schemeless url', function () {
urlService.utils.relativeToAbsolute('//anotherblog.com/blog/').should.eql('//anotherblog.com/blog/');
});
});

describe('getProtectedSlugs', function () {
it('defaults', function () {
urlService.utils.getProtectedSlugs().should.eql(['ghost', 'rss', 'amp']);

0 comments on commit 34fad7e

Please sign in to comment.
You can’t perform that action at this time.