Merge branch 'master' into v3

This commit is contained in:
Kevin Ansfield 2019-10-08 13:58:08 +01:00
commit 6028fde666
108 changed files with 3668 additions and 1515 deletions

View File

@ -34,6 +34,7 @@ script: |
fi
notifications:
slack:
if: type = cron
rooms:
- secure: KzvGpf6RFHgQ3BkppKM4OejFjJYxH60KkDDShet0a0v+j/03HF5Nx0S0Vl9O/F2dPt2qWvblKb3j9EKgB7RsoFSdZuOk7ijA9Duvn+p9RrLom0C9JxOA3ob065WAGyE0OGRm1P5H98loX/L+6oOHC4/qqGKXgz6gUdBtZBn/yo8=
on_success: change

@ -1 +1 @@
Subproject commit 8729af090f8fcd872e6ff0c10f46fb0375527692
Subproject commit 215d837848b88354bb94a375338abf1a71ece023

@ -1 +1 @@
Subproject commit a8ea51b3ad0c099aa02ef790b80c6a26056acbef
Subproject commit ebe7c1475f6be9ec3e6471be7b58e76dcb4ae9c6

View File

@ -124,7 +124,7 @@ function getAmperizeHTML(html, post) {
amperize = amperize || new Amperize();
// make relative URLs abolute
html = urlUtils.htmlRelativeToAbsolute(html, urlUtils.urlFor('home', true), post.url);
html = urlUtils.htmlRelativeToAbsolute(html, post.url);
if (!amperizeCache[post.id] || moment(new Date(amperizeCache[post.id].updated_at)).diff(new Date(post.updated_at)) < 0) {
return new Promise((resolve) => {

View File

@ -21,24 +21,23 @@ generateTags = function generateTags(data) {
return [];
};
generateItem = function generateItem(post, siteUrl, secure) {
var itemUrl = urlService.getUrlByResourceId(post.id, {secure: secure, absolute: true}),
htmlContent = cheerio.load(urlUtils.htmlRelativeToAbsolute(post.html, siteUrl, itemUrl), {decodeEntities: false}),
item = {
title: post.title,
// @TODO: DRY this up with data/meta/index & other excerpt code
description: post.custom_excerpt || post.meta_description || downsize(htmlContent.html(), {words: 50}),
guid: post.id,
url: itemUrl,
date: post.published_at,
categories: generateTags(post),
author: post.primary_author ? post.primary_author.name : null,
custom_elements: []
},
imageUrl;
generateItem = function generateItem(post, secure) {
const itemUrl = urlService.getUrlByResourceId(post.id, {secure, absolute: true});
const htmlContent = cheerio.load(urlUtils.htmlRelativeToAbsolute(post.html, itemUrl, {secure}), {decodeEntities: false});
const item = {
title: post.title,
// @TODO: DRY this up with data/meta/index & other excerpt code
description: post.custom_excerpt || post.meta_description || downsize(htmlContent.html(), {words: 50}),
guid: post.id,
url: itemUrl,
date: post.published_at,
categories: generateTags(post),
author: post.primary_author ? post.primary_author.name : null,
custom_elements: []
};
if (post.feature_image) {
imageUrl = urlUtils.urlFor('image', {image: post.feature_image, secure: secure}, true);
const imageUrl = urlUtils.urlFor('image', {image: post.feature_image, secure}, true);
// Add a media content tag
item.custom_elements.push({
@ -73,13 +72,14 @@ generateItem = function generateItem(post, siteUrl, secure) {
* @param {{title, description, safeVersion, secure, posts}} data
*/
generateFeed = function generateFeed(baseUrl, data) {
const siteUrl = urlUtils.urlFor('home', {secure: data.secure}, true);
const {secure} = data;
const feed = new RSS({
title: data.title,
description: data.description,
generator: 'Ghost ' + data.safeVersion,
feed_url: urlUtils.urlFor({relativeUrl: baseUrl, secure: data.secure}, true),
site_url: siteUrl,
feed_url: urlUtils.urlFor({relativeUrl: baseUrl, secure}, true),
site_url: urlUtils.urlFor('home', {secure}, true),
image_url: urlUtils.urlFor({relativeUrl: 'favicon.png'}, true),
ttl: '60',
custom_namespaces: {
@ -90,7 +90,7 @@ generateFeed = function generateFeed(baseUrl, data) {
return data.posts.reduce((feedPromise, post) => {
return feedPromise.then(() => {
const item = generateItem(post, siteUrl, data.secure);
const item = generateItem(post, secure);
return feed.item(item);
});
}, Promise.resolve()).then(() => {

View File

@ -80,7 +80,9 @@ function updateLocalTemplateOptions(req, res, next) {
const member = req.member ? {
email: req.member.email,
subscribed: req.member.plans.length !== 0
name: req.member.name,
subscriptions: req.member.stripe.subscriptions,
paid: req.member.stripe.subscriptions.length !== 0
} : null;
hbs.updateLocalTemplateOptions(res.locals, _.merge({}, localTemplateOptions, {

View File

@ -1,7 +1,9 @@
// NOTE: We must not cache references to membersService.api
// as it is a getter and may change during runtime.
const Promise = require('bluebird');
const membersService = require('../../services/members');
const common = require('../../lib/common');
const fsLib = require('../../lib/fs');
const members = {
docName: 'members',
@ -40,6 +42,71 @@ const members = {
}
},
add: {
statusCode: 201,
headers: {},
options: [
'send_email',
'email_type'
],
validation: {
data: {
email: {required: true}
},
options: {
email_type: {
values: ['signin', 'signup', 'subscribe']
}
}
},
permissions: true,
query(frame) {
// NOTE: Promise.resolve() is here for a reason! Method has to return an instance
// of a Bluebird promise to allow reflection. If decided to be replaced
// with something else, e.g: async/await, CSV export function
// would need a deep rewrite (see failing tests if this line is removed)
return Promise.resolve()
.then(() => {
return membersService.api.members.create(frame.data.members[0], {
sendEmail: frame.options.send_email,
emailType: frame.options.email_type
});
})
.then((member) => {
if (member) {
return Promise.resolve(member);
}
})
.catch((error) => {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.members.memberAlreadyExists')}));
}
return Promise.reject(error);
});
}
},
edit: {
statusCode: 200,
headers: {},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
async query(frame) {
const member = await membersService.api.members.update(frame.data.members[0], frame.options);
return member;
}
},
destroy: {
statusCode: 204,
headers: {},
@ -54,9 +121,87 @@ const members = {
}
},
permissions: true,
query(frame) {
async query(frame) {
frame.options.require = true;
return membersService.api.members.destroy(frame.options).return(null);
await membersService.api.members.destroy(frame.options);
return null;
}
},
exportCSV: {
headers: {
disposition: {
type: 'csv',
value() {
const datetime = (new Date()).toJSON().substring(0, 10);
return `members.${datetime}.csv`;
}
}
},
response: {
format: 'plain'
},
permissions: {
method: 'browse'
},
validation: {},
query(frame) {
return membersService.api.members.list(frame.options);
}
},
importCSV: {
statusCode: 201,
permissions: {
method: 'add'
},
async query(frame) {
let filePath = frame.file.path,
fulfilled = 0,
invalid = 0,
duplicates = 0;
return fsLib.readCSV({
path: filePath,
columnsToExtract: [{name: 'email', lookup: /email/i}, {name: 'name', lookup: /name/i}]
}).then((result) => {
return Promise.all(result.map((entry) => {
const api = require('./index');
return api.members.add.query({
data: {
members: [{
email: entry.email,
name: entry.name
}]
},
options: {
context: frame.options.context,
options: {send_email: false}
}
}).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
}
}
};
});
}
}
};

View File

@ -3,7 +3,6 @@ const debug = require('ghost-ignition').debug('api:canary:utils:serializers:inpu
const mapNQLKeyValues = require('../../../../../../shared/nql-map-key-values');
const url = require('./utils/url');
const localUtils = require('../../index');
const labs = require('../../../../../services/labs');
const converters = require('../../../../../lib/mobiledoc/converters');
const postsMetaSchema = require('../../../../../data/schema').tables.posts_meta;
@ -29,14 +28,6 @@ function removeMobiledocFormat(frame) {
}
}
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;
@ -116,11 +107,6 @@ module.exports = {
// 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);
}
@ -150,11 +136,6 @@ module.exports = {
// 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);
}

View File

@ -1,23 +1,5 @@
const _ = require('lodash');
const url = require('url');
const urlUtils = require('../../../../../../lib/url-utils');
const handleCanonicalUrl = (canonicalUrl) => {
const blogURl = urlUtils.getSiteUrl();
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.getSiteUrl().replace(/^http(s?):\/\//, '').replace(/\/$/, '');
const imageUrlAbsolute = imageUrl.replace(/^http(s?):\/\//, '');
@ -30,44 +12,7 @@ const handleImageUrl = (imageUrl) => {
return imageUrl;
};
const handleContentUrls = (content) => {
const blogDomain = urlUtils.getSiteUrl().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) {

View File

@ -8,6 +8,22 @@ module.exports = {
frame.response = data;
},
add(data, apiConfig, frame) {
debug('add');
frame.response = {
members: [data]
};
},
edit(data, apiConfig, frame) {
debug('edit');
frame.response = {
members: [data]
};
},
read(data, apiConfig, frame) {
debug('read');
@ -20,5 +36,42 @@ module.exports = {
frame.response = {
members: [data]
};
},
exportCSV(models, apiConfig, frame) {
debug('exportCSV');
const fields = ['id', 'email', 'name', 'created_at', 'deleted_at'];
function formatCSV(data) {
let csv = `${fields.join(',')}\r\n`,
entry,
field,
j,
i;
for (j = 0; j < data.length; j = j + 1) {
entry = data[j];
for (i = 0; i < fields.length; i = i + 1) {
field = fields[i];
csv += entry[field] !== null ? entry[field] : '';
if (i !== fields.length - 1) {
csv += ',';
}
}
csv += '\r\n';
}
return csv;
}
frame.response = formatCSV(models.members);
},
importCSV(data, apiConfig, frame) {
debug('importCSV');
frame.response = data;
}
};

View File

@ -94,6 +94,8 @@ const post = (attrs, frame) => {
if (attrs.og_description === '') {
attrs.og_description = null;
}
delete attrs.visibility;
} else {
delete attrs.page;
}
@ -107,7 +109,6 @@ const post = (attrs, frame) => {
}
delete attrs.locale;
delete attrs.visibility;
delete attrs.author;
delete attrs.type;

View File

@ -1,52 +1,40 @@
const _ = require('lodash');
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) {
// Checks if request should hide members only content
function hideMembersOnlyContent({visibility}, frame) {
const PERMIT_CONTENT = false;
const BLOCK_CONTENT = true;
if (visibility === 'public') {
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;
} else if (visibility === 'members') {
return PERMIT_CONTENT;
}
const memberHasPlan = !!(frame.original.context.member.plans || []).length;
if (!membersService.isPaymentConfigured()) {
return PERMIT_CONTENT;
}
if (memberHasPlan) {
const memberHasPlan = !!(_.get(frame, 'original.context.member.stripe.subscriptions', [])).length;
if (visibility === 'paid' && 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;
const hideFormatsData = hideMembersOnlyContent(attrs, frame);
if (!origInclude || !origInclude.includes('tags')) {
delete attrs.tags;
attrs.primary_tag = null;
if (hideFormatsData) {
['plaintext', 'html'].forEach((field) => {
attrs[field] = '';
});
}
}

View File

@ -30,39 +30,28 @@ const forPost = (id, attrs, frame) => {
}
}
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.htmlRelativeToAbsolute(
attrs.html,
urlUtils.urlFor('home', true),
attrs.url,
urlOptions
if (attrs.mobiledoc) {
attrs.mobiledoc = urlUtils.mobiledocRelativeToAbsolute(
attrs.mobiledoc,
attrs.url
);
}
['html', 'codeinjection_head', 'codeinjection_foot'].forEach((attr) => {
if (attrs[attr]) {
attrs[attr] = urlUtils.htmlRelativeToAbsolute(
attrs[attr],
attrs.url
);
}
});
['feature_image', 'og_image', 'twitter_image', 'canonical_url'].forEach((attr) => {
if (attrs[attr]) {
attrs[attr] = urlUtils.relativeToAbsolute(attrs[attr]);
}
});
if (frame.options.columns && !frame.options.columns.includes('url')) {
delete attrs.url;
}

View File

@ -23,6 +23,10 @@ module.exports = {
return require('./invitations');
},
get members() {
return require('./members');
},
get settings() {
return require('./settings');
},

View File

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

View File

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

View File

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

View File

@ -0,0 +1,42 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "members",
"title": "members",
"description": "Base members definitions",
"definitions": {
"member": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^([^,]|$)"
},
"email": {
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^([^,]|$)"
},
"id": {
"strip": true
},
"created_at": {
"strip": true
},
"created_by": {
"strip": true
},
"updated_at": {
"strip": true
},
"updated_by": {
"strip": true
}
}
}
}
}

View File

@ -43,7 +43,7 @@
},
"visibility": {
"type": ["string", "null"],
"enum": ["public"]
"enum": ["public", "members", "paid"]
},
"meta_title": {
"type": ["string", "null"],

View File

@ -43,7 +43,7 @@
},
"visibility": {
"type": ["string", "null"],
"enum": ["public"]
"enum": ["public", "members", "paid"]
},
"meta_title": {
"type": ["string", "null"],

View File

@ -1,7 +1,9 @@
// NOTE: We must not cache references to membersService.api
// as it is a getter and may change during runtime.
const Promise = require('bluebird');
const membersService = require('../../services/members');
const common = require('../../lib/common');
const fsLib = require('../../lib/fs');
const members = {
docName: 'members',
@ -40,6 +42,71 @@ const members = {
}
},
add: {
statusCode: 201,
headers: {},
options: [
'send_email',
'email_type'
],
validation: {
data: {
email: {required: true}
},
options: {
email_type: {
values: ['signin', 'signup', 'subscribe']
}
}
},
permissions: true,
query(frame) {
// NOTE: Promise.resolve() is here for a reason! Method has to return an instance
// of a Bluebird promise to allow reflection. If decided to be replaced
// with something else, e.g: async/await, CSV export function
// would need a deep rewrite (see failing tests if this line is removed)
return Promise.resolve()
.then(() => {
return membersService.api.members.create(frame.data.members[0], {
sendEmail: frame.options.send_email,
emailType: frame.options.email_type
});
})
.then((member) => {
if (member) {
return Promise.resolve(member);
}
})
.catch((error) => {
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
return Promise.reject(new common.errors.ValidationError({message: common.i18n.t('errors.api.members.memberAlreadyExists')}));
}
return Promise.reject(error);
});
}
},
edit: {
statusCode: 200,
headers: {},
options: [
'id'
],
validation: {
options: {
id: {
required: true
}
}
},
permissions: true,
async query(frame) {
const member = await membersService.api.members.update(frame.data.members[0], frame.options);
return member;
}
},
destroy: {
statusCode: 204,
headers: {},
@ -54,9 +121,87 @@ const members = {
}
},
permissions: true,
query(frame) {
async query(frame) {
frame.options.require = true;
return membersService.api.members.destroy(frame.options).return(null);
await membersService.api.members.destroy(frame.options);
return null;
}
},
exportCSV: {
headers: {
disposition: {
type: 'csv',
value() {
const datetime = (new Date()).toJSON().substring(0, 10);
return `members.${datetime}.csv`;
}
}
},
response: {
format: 'plain'
},
permissions: {
method: 'browse'
},
validation: {},
query(frame) {
return membersService.api.members.list(frame.options);
}
},
importCSV: {
statusCode: 201,
permissions: {
method: 'add'
},
async query(frame) {
let filePath = frame.file.path,
fulfilled = 0,
invalid = 0,
duplicates = 0;
return fsLib.readCSV({
path: filePath,
columnsToExtract: [{name: 'email', lookup: /email/i}, {name: 'name', lookup: /name/i}]
}).then((result) => {
return Promise.all(result.map((entry) => {
const api = require('./index');
return api.members.add.query({
data: {
members: [{
email: entry.email,
name: entry.name
}]
},
options: {
context: frame.options.context,
options: {send_email: false}
}
}).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
}
}
};
});
}
}
};

View File

@ -3,7 +3,6 @@ const mapNQLKeyValues = require('../../../../../../shared/nql-map-key-values');
const debug = require('ghost-ignition').debug('api:v2:utils:serializers:input:posts');
const url = require('./utils/url');
const localUtils = require('../../index');
const labs = require('../../../../../services/labs');
const converters = require('../../../../../lib/mobiledoc/converters');
const postsMetaSchema = require('../../../../../data/schema').tables.posts_meta;
@ -29,14 +28,6 @@ function removeMobiledocFormat(frame) {
}
}
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;
@ -116,11 +107,6 @@ module.exports = {
// 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);
}
@ -150,11 +136,6 @@ module.exports = {
// 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);
}

View File

@ -1,23 +1,5 @@
const _ = require('lodash');
const url = require('url');
const urlUtils = require('../../../../../../lib/url-utils');
const handleCanonicalUrl = (canonicalUrl) => {
const siteUrl = urlUtils.getSiteUrl();
const isSameProtocol = url.parse(canonicalUrl).protocol === url.parse(siteUrl).protocol;
const siteDomain = siteUrl.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
// Site URL incl. the same protocol. This allows users to keep e.g. Facebook comments after
// a http -> https switch
if (absolute.startsWith(siteDomain) && isSameProtocol) {
return urlUtils.absoluteToRelative(canonicalUrl);
}
return canonicalUrl;
};
const handleImageUrl = (imageUrl) => {
const siteDomain = urlUtils.getSiteUrl().replace(/^http(s?):\/\//, '').replace(/\/$/, '');
const imageUrlAbsolute = imageUrl.replace(/^http(s?):\/\//, '');
@ -30,44 +12,7 @@ const handleImageUrl = (imageUrl) => {
return imageUrl;
};
const handleContentUrls = (content) => {
const siteDomain = urlUtils.getSiteUrl().replace(/^http(s?):\/\//, '').replace(/\/$/, '');
const imagePathRe = new RegExp(`(http(s?)://)?${siteDomain}/${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) {

View File

@ -8,6 +8,22 @@ module.exports = {
frame.response = data;
},
add(data, apiConfig, frame) {
debug('add');
frame.response = {
members: [data]
};
},
edit(data, apiConfig, frame) {
debug('edit');
frame.response = {
members: [data]
};
},
read(data, apiConfig, frame) {
debug('read');
@ -20,5 +36,42 @@ module.exports = {
frame.response = {
members: [data]
};
},
exportCSV(models, apiConfig, frame) {
debug('exportCSV');
const fields = ['id', 'email', 'name', 'created_at', 'deleted_at'];
function formatCSV(data) {
let csv = `${fields.join(',')}\r\n`,
entry,
field,
j,
i;
for (j = 0; j < data.length; j = j + 1) {
entry = data[j];
for (i = 0; i < fields.length; i = i + 1) {
field = fields[i];
csv += entry[field] !== null ? entry[field] : '';
if (i !== fields.length - 1) {
csv += ',';
}
}
csv += '\r\n';
}
return csv;
}
frame.response = formatCSV(models.members);
},
importCSV(data, apiConfig, frame) {
debug('importCSV');
frame.response = data;
}
};

View File

@ -94,6 +94,8 @@ const post = (attrs, frame) => {
if (attrs.og_description === '') {
attrs.og_description = null;
}
delete attrs.visibility;
} else {
delete attrs.page;
@ -107,7 +109,6 @@ const post = (attrs, frame) => {
}
delete attrs.locale;
delete attrs.visibility;
delete attrs.author;
delete attrs.type;

View File

@ -1,52 +1,40 @@
const _ = require('lodash');
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) {
// Checks if request should hide members only content
function hideMembersOnlyContent({visibility}, frame) {
const PERMIT_CONTENT = false;
const BLOCK_CONTENT = true;
if (visibility === 'public') {
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;
} else if (visibility === 'members') {
return PERMIT_CONTENT;
}
const memberHasPlan = !!(frame.original.context.member.plans || []).length;
if (!membersService.isPaymentConfigured()) {
return PERMIT_CONTENT;
}
if (memberHasPlan) {
const memberHasPlan = !!(_.get(frame, 'original.context.member.stripe.subscriptions', [])).length;
if (visibility === 'paid' && 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;
const hideFormatsData = hideMembersOnlyContent(attrs, frame);
if (!origInclude || !origInclude.includes('tags')) {
delete attrs.tags;
attrs.primary_tag = null;
if (hideFormatsData) {
['plaintext', 'html'].forEach((field) => {
attrs[field] = '';
});
}
}

View File

@ -30,39 +30,38 @@ const forPost = (id, attrs, frame) => {
}
}
if (attrs.feature_image) {
attrs.feature_image = urlUtils.urlFor('image', {image: attrs.feature_image}, true);
const urlOptions = {};
// v2 only transforms asset URLS, v3 will transform all urls so that
// input/output transformations are balanced and all URLs are absolute
if (!frame.options.absolute_urls) {
urlOptions.assetsOnly = 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.htmlRelativeToAbsolute(
attrs.html,
urlUtils.urlFor('home', true),
if (attrs.mobiledoc) {
attrs.mobiledoc = urlUtils.mobiledocRelativeToAbsolute(
attrs.mobiledoc,
attrs.url,
urlOptions
);
}
['html', 'codeinjection_head', 'codeinjection_foot'].forEach((attr) => {
if (attrs[attr]) {
attrs[attr] = urlUtils.htmlRelativeToAbsolute(
attrs[attr],
attrs.url,
urlOptions
);
}
});
['feature_image', 'og_image', 'twitter_image', 'canonical_url'].forEach((attr) => {
if (attrs[attr]) {
attrs[attr] = urlUtils.relativeToAbsolute(attrs[attr], attrs.url, urlOptions);
}
});
if (frame.options.columns && !frame.options.columns.includes('url')) {
delete attrs.url;
}

View File

@ -23,6 +23,10 @@ module.exports = {
return require('./invitations');
},
get members() {
return require('./members');
},
get settings() {
return require('./settings');
},

View File

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

View File

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

View File

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

View File

@ -0,0 +1,42 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "members",
"title": "members",
"description": "Base members definitions",
"definitions": {
"member": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^([^,]|$)"
},
"email": {
"type": "string",
"minLength": 1,
"maxLength": 191,
"pattern": "^([^,]|$)"
},
"id": {
"strip": true
},
"created_at": {
"strip": true
},
"created_by": {
"strip": true
},
"updated_at": {
"strip": true
},
"updated_by": {
"strip": true
}
}
}
}
}

View File

@ -43,7 +43,7 @@
},
"visibility": {
"type": ["string", "null"],
"enum": ["public"]
"enum": ["public", "members", "paid"]
},
"meta_title": {
"type": ["string", "null"],

View File

@ -43,7 +43,7 @@
},
"visibility": {
"type": ["string", "null"],
"enum": ["public"]
"enum": ["public", "members", "paid"]
},
"meta_title": {
"type": ["string", "null"],

View File

@ -32,6 +32,10 @@
"extensions": [".csv"],
"contentTypes": ["text/csv", "application/csv", "application/octet-stream"]
},
"members": {
"extensions": [".csv"],
"contentTypes": ["text/csv", "application/csv", "application/octet-stream"]
},
"images": {
"extensions": [".jpg", ".jpeg", ".gif", ".png", ".svg", ".svgz", ".ico"],
"contentTypes": ["image/jpeg", "image/png", "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon"]

View File

@ -0,0 +1,4 @@
module.exports = {
async up(){},
async down(){}
};

View File

@ -0,0 +1,28 @@
const commands = require('../../../schema').commands;
module.exports = {
up: commands.createColumnMigration({
table: 'members',
column: 'name',
dbIsInCorrectState(hasColumn) {
return hasColumn === true;
},
operation: commands.addColumn,
operationVerb: 'Adding'
}),
down: commands.createColumnMigration({
table: 'members',
column: 'name',
dbIsInCorrectState(hasColumn) {
return hasColumn === false;
},
operation: commands.dropColumn,
operationVerb: 'Dropping'
}),
config: {
transaction: true
}
};

View File

@ -0,0 +1,37 @@
const common = require('../../../../lib/common');
const commands = require('../../../schema/commands');
module.exports = {
config: {
transaction: true
},
async up(options){
const conn = options.transacting || options.connection;
const hasTable = await conn.schema.hasTable('members_stripe_customers');
if (hasTable) {
common.logging.info('Dropping table: members_stripe_customers');
await commands.deleteTable('members_stripe_customers', conn);
} else {
common.logging.warn('Dropping table: members_stripe_customers');
}
common.logging.info('Adding table: members_stripe_customers');
return commands.createTable('members_stripe_customers', conn);
},
async down(options){
const conn = options.transacting || options.connection;
const hasTable = await conn.schema.hasTable('members_stripe_customers');
if (!hasTable) {
common.logging.warn('Dropping table: members_stripe_customers');
return;
}
common.logging.info('Dropping table: members_stripe_customers');
return commands.deleteTable('members_stripe_customers', conn);
}
};

View File

@ -194,6 +194,9 @@
"members_session_secret": {
"defaultValue": null
},
"default_content_visibility": {
"defaultValue": "public"
},
"members_subscription_settings": {
"defaultValue": "{\"isPaid\":false,\"paymentProcessors\":[{\"adapter\":\"stripe\",\"config\":{\"secret_token\":\"\",\"public_token\":\"\",\"product\":{\"name\":\"Ghost Subscription\"},\"plans\":[{\"name\":\"Monthly\",\"currency\":\"usd\",\"interval\":\"month\",\"amount\":\"\"},{\"name\":\"Yearly\",\"currency\":\"usd\",\"interval\":\"year\",\"amount\":\"\"}]}}]}"
}

View File

@ -27,7 +27,7 @@ module.exports = {
maxlength: 50,
nullable: false,
defaultTo: 'public',
validations: {isIn: [['public']]}
validations: {isIn: [['public', 'members', 'paid']]}
},
/**
* @deprecated: `author_id`, might be removed in Ghost 3.0
@ -343,6 +343,17 @@ module.exports = {
members: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
email: {type: 'string', maxlength: 191, nullable: false, unique: true, validations: {isEmail: true}},
name: {type: 'string', maxlength: 191, nullable: true},
created_at: {type: 'dateTime', nullable: false},
created_by: {type: 'string', maxlength: 24, nullable: false},
updated_at: {type: 'dateTime', nullable: true},
updated_by: {type: 'string', maxlength: 24, nullable: true}
},
members_stripe_customers: {
id: {type: 'string', maxlength: 24, nullable: false, primary: true},
member_id: {type: 'string', maxlength: 24, nullable: false, unique: false},
// customer_id is unique: false because mysql with innodb utf8mb4 cannot have unqiue columns larger than 191 chars
customer_id: {type: 'string', maxlength: 255, nullable: false, unique: false},
created_at: {type: 'dateTime', nullable: false},
created_by: {type: 'string', maxlength: 24, nullable: false},
updated_at: {type: 'dateTime', nullable: true},

View File

@ -37,11 +37,12 @@ module.exports = createCard({
name: 'bookmark',
type: 'dom',
render(opts) {
if (!opts.payload.metadata) {
let {payload, env: {dom}} = opts;
if (!payload.metadata || !payload.metadata.url || !payload.metadata.title || !payload.metadata.description) {
return '';
}
let {payload, env: {dom}} = opts;
let figure = createElement(dom, 'figure', 'kg-card kg-bookmark-card');
let linkTag = createElement(dom, 'a', 'kg-bookmark-container', [{
key: 'href',
@ -89,5 +90,21 @@ module.exports = createCard({
}
return figure;
},
absoluteToRelative(urlUtils, payload, options) {
if (payload.metadata) {
payload.metadata.url = payload.metadata.url && urlUtils.absoluteToRelative(payload.metadata.url, options);
}
payload.caption = payload.caption && urlUtils.htmlAbsoluteToRelative(payload.caption, options);
return payload;
},
relativeToAbsolute(urlUtils, payload, options) {
if (payload.metadata) {
payload.metadata.url = payload.metadata.url && urlUtils.relativeToAbsolute(payload.metadata.url, options);
}
payload.caption = payload.caption && urlUtils.htmlRelativeToAbsolute(payload.caption, options);
return payload;
}
});

View File

@ -34,5 +34,15 @@ module.exports = createCard({
} else {
return pre;
}
},
absoluteToRelative(urlUtils, payload, options) {
payload.caption = payload.caption && urlUtils.htmlAbsoluteToRelative(payload.caption, options);
return payload;
},
relativeToAbsolute(urlUtils, payload, options) {
payload.caption = payload.caption && urlUtils.htmlRelativeToAbsolute(payload.caption, options);
return payload;
}
});

View File

@ -24,5 +24,15 @@ module.exports = createCard({
}
return figure;
},
absoluteToRelative(urlUtils, payload, options) {
payload.caption = payload.caption && urlUtils.htmlAbsoluteToRelative(payload.caption, options);
return payload;
},
relativeToAbsolute(urlUtils, payload, options) {
payload.caption = payload.caption && urlUtils.htmlRelativeToAbsolute(payload.caption, options);
return payload;
}
});

View File

@ -108,5 +108,31 @@ module.exports = createCard({
}
return figure;
},
absoluteToRelative(urlUtils, payload, options) {
if (payload.images) {
payload.images.forEach((image) => {
image.src = image.src && urlUtils.absoluteToRelative(image.src, options);
image.caption = image.caption && urlUtils.htmlAbsoluteToRelative(image.caption, options);
});
}
payload.caption = payload.caption && urlUtils.htmlAbsoluteToRelative(payload.caption, options);
return payload;
},
relativeToAbsolute(urlUtils, payload, options) {
if (payload.images) {
payload.images.forEach((image) => {
image.src = image.src && urlUtils.relativeToAbsolute(image.src, options);
image.caption = image.caption && urlUtils.htmlRelativeToAbsolute(image.caption, options);
});
}
payload.caption = payload.caption && urlUtils.htmlRelativeToAbsolute(payload.caption, options);
return payload;
}
});

View File

@ -14,5 +14,15 @@ module.exports = createCard({
// use the SimpleDOM document to create a raw HTML section.
// avoids parsing/rendering of potentially broken or unsupported HTML
return opts.env.dom.createRawHTMLSection(opts.payload.html);
},
absoluteToRelative(urlUtils, payload, options) {
payload.html = payload.html && urlUtils.htmlAbsoluteToRelative(payload.html, options);
return payload;
},
relativeToAbsolute(urlUtils, payload, options) {
payload.html = payload.html && urlUtils.htmlRelativeToAbsolute(payload.html, options);
return payload;
}
});

View File

@ -39,5 +39,17 @@ module.exports = createCard({
}
return figure;
},
absoluteToRelative(urlUtils, payload, options) {
payload.src = payload.src && urlUtils.absoluteToRelative(payload.src, options);
payload.caption = payload.caption && urlUtils.htmlAbsoluteToRelative(payload.caption, options);
return payload;
},
relativeToAbsolute(urlUtils, payload, options) {
payload.src = payload.src && urlUtils.relativeToAbsolute(payload.src, options);
payload.caption = payload.caption && urlUtils.htmlRelativeToAbsolute(payload.caption, options);
return payload;
}
});

View File

@ -19,5 +19,15 @@ module.exports = createCard({
// use the SimpleDOM document to create a raw HTML section.
// avoids parsing/rendering of potentially broken or unsupported HTML
return opts.env.dom.createRawHTMLSection(html);
},
absoluteToRelative(urlUtils, payload, options) {
payload.markdown = payload.markdown && urlUtils.markdownAbsoluteToRelative(payload.markdown, options);
return payload;
},
relativeToAbsolute(urlUtils, payload, options) {
payload.markdown = payload.markdown && urlUtils.markdownRelativeToAbsolute(payload.markdown, options);
return payload;
}
});

View File

@ -1,9 +1,22 @@
let urlUtils;
module.exports = function createCard(card) {
const {name, type, config = {}} = card;
const defaultTransformer = function (payload) {
return payload;
};
const {
name,
type,
config = {},
absoluteToRelative = defaultTransformer,
relativeToAbsolute = defaultTransformer
} = card;
return {
name,
type,
render({env, payload, options}) {
const {dom} = env;
const cleanName = name.replace(/^card-/, '');
@ -27,6 +40,26 @@ module.exports = function createCard(card) {
}
return cardOutput;
},
absoluteToRelative() {
// it's necessary to wait until the method is called to require
// urlUtils to ensure the class has actually been instantiated
// as cards are passed in as an arg to the class instantiation
if (!urlUtils) {
urlUtils = require('../url-utils');
}
return absoluteToRelative(urlUtils, ...arguments);
},
relativeToAbsolute() {
// it's necessary to wait until the method is called to require
// urlUtils to ensure the class has actually been instantiated
// as cards are passed in as an arg to the class instantiation
if (!urlUtils) {
urlUtils = require('../url-utils');
}
return relativeToAbsolute(urlUtils, ...arguments);
}
};
};

View File

@ -1,5 +1,6 @@
const UrlUtils = require('@tryghost/url-utils');
const config = require('../../config');
const cards = require('../mobiledoc/cards');
const urlUtils = new UrlUtils({
url: config.get('url'),
@ -8,7 +9,8 @@ const urlUtils = new UrlUtils({
defaultApiVersion: 'v3',
slugs: config.get('slugs').protected,
redirectCacheMaxAge: config.get('caching:301:maxAge'),
baseApiPath: '/ghost/api'
baseApiPath: '/ghost/api',
cardTransformers: cards
});
module.exports = urlUtils;

View File

@ -35,7 +35,8 @@ models = [
'mobiledoc-revision',
'member',
'action',
'posts-meta'
'posts-meta',
'member-stripe-customer'
];
function init() {

View File

@ -0,0 +1,9 @@
const ghostBookshelf = require('./base');
const MemberStripeCustomer = ghostBookshelf.Model.extend({
tableName: 'members_stripe_customers'
});
module.exports = {
MemberStripeCustomer: ghostBookshelf.model('MemberStripeCustomer', MemberStripeCustomer)
};

View File

@ -1,32 +1,23 @@
const ghostBookshelf = require('./base');
const security = require('../lib/security');
const Member = ghostBookshelf.Model.extend({
tableName: 'members',
onSaving() {
ghostBookshelf.Model.prototype.onSaving.apply(this, arguments);
if (this.hasChanged('password')) {
return security.password.hash(String(this.get('password')))
.then((hash) => {
this.set('password', hash);
});
}
relationships: ['stripe_customers'],
relationshipBelongsTo: {
stripe_customers: 'members_stripe_customers'
},
comparePassword(rawPassword) {
return security.password.compare(rawPassword, this.get('password'));
permittedAttributes(...args) {
return ghostBookshelf.Model.prototype.permittedAttributes.apply(this, args).concat(this.relationships);
},
toJSON(unfilteredOptions) {
var options = Member.filterOptions(unfilteredOptions, 'toJSON'),
attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options);
// remove password hash and tokens for security reasons
delete attrs.password;
return attrs;
stripe_customers() {
return this.hasMany('MemberStripeCustomer', 'member_id');
}
}, {
permittedOptions(...args) {
return ghostBookshelf.Model.permittedOptions.apply(this, args).concat(['withRelated']);
}
});

View File

@ -8,8 +8,10 @@ const common = require('../lib/common');
const htmlToText = require('html-to-text');
const ghostBookshelf = require('./base');
const config = require('../config');
const settingsCache = require('../services/settings/cache');
const converters = require('../lib/mobiledoc/converters');
const relations = require('./relations');
const urlUtils = require('../lib/url-utils');
const MOBILEDOC_REVISIONS_COUNT = 10;
const ALL_STATUSES = ['published', 'draft', 'scheduled'];
@ -40,12 +42,18 @@ Post = ghostBookshelf.Model.extend({
* 2. model events e.g. "post.published" are using the inserted resource, not the fetched resource
*/
defaults: function defaults() {
let visibility = 'public';
if (settingsCache.get('labs') && (settingsCache.get('labs').members === true) && settingsCache.get('default_content_visibility')) {
visibility = settingsCache.get('default_content_visibility');
}
return {
uuid: uuid.v4(),
status: 'draft',
featured: false,
type: 'post',
visibility: 'public'
visibility: visibility
};
},
@ -359,6 +367,39 @@ Post = ghostBookshelf.Model.extend({
this.set('mobiledoc', JSON.stringify(converters.mobiledocConverter.blankStructure()));
}
// ensure all URLs are stored as relative
// note: html is not necessary to change because it's a generated later from mobiledoc
const urlTransformMap = {
mobiledoc: 'mobiledocAbsoluteToRelative',
custom_excerpt: 'htmlAbsoluteToRelative',
codeinjection_head: 'htmlAbsoluteToRelative',
codeinjection_foot: 'htmlAbsoluteToRelative',
feature_image: 'absoluteToRelative',
og_image: 'absoluteToRelative',
twitter_image: 'absoluteToRelative',
canonical_url: {
method: 'absoluteToRelative',
options: {
ignoreProtocol: false
}
}
};
Object.entries(urlTransformMap).forEach(([attr, transform]) => {
let method = transform;
let options = {};
if (typeof transform === 'object') {
method = transform.method;
options = transform.options || {};
}
if (this.hasChanged(attr) && this.get(attr)) {
const transformedValue = urlUtils[method](this.get(attr), options);
this.set(attr, transformedValue);
}
});
// CASE: mobiledoc has changed, generate html
// CASE: html is null, but mobiledoc exists (only important for migrations & importing)
if (this.hasChanged('mobiledoc') || (!this.get('html') && (options.migrating || options.importing))) {

View File

@ -9,6 +9,11 @@ Array.prototype.forEach.call(document.querySelectorAll('form[data-members-form]'
form.classList.remove('success', 'invalid', 'error');
var input = event.target.querySelector('input[data-members-email]');
var email = input.value;
var emailType = undefined;
if (form.dataset.membersForm) {
emailType = form.dataset.membersForm;
}
if (!email.includes('@')) {
form.classList.add('invalid')
@ -23,7 +28,8 @@ Array.prototype.forEach.call(document.querySelectorAll('form[data-members-form]'
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email
email: email,
emailType: emailType
})
}).then(function (res) {
form.addEventListener('submit', submitHandler);
@ -48,6 +54,18 @@ Array.prototype.forEach.call(document.querySelectorAll('[data-members-plan]'), f
event.preventDefault();
var plan = el.dataset.membersPlan;
var successUrl = el.dataset.membersSuccess;
var cancelUrl = el.dataset.membersCancel;
var checkoutSuccessUrl;
var checkoutCancelUrl;
if (successUrl) {
checkoutSuccessUrl = (new URL(successUrl, window.location.href)).href;
}
if (cancelUrl) {
checkoutCancelUrl = (new URL(cancelUrl, window.location.href)).href;
}
if (errorEl) {
errorEl.innerText = '';
@ -57,7 +75,7 @@ Array.prototype.forEach.call(document.querySelectorAll('[data-members-plan]'), f
credentials: 'same-origin'
}).then(function (res) {
if (!res.ok) {
throw new Error('Could not get identity token');
return null;
}
return res.text();
}).then(function (identity) {
@ -68,7 +86,9 @@ Array.prototype.forEach.call(document.querySelectorAll('[data-members-plan]'), f
},
body: JSON.stringify({
plan: plan,
identity: identity
identity: identity,
successUrl: checkoutSuccessUrl,
cancelUrl: checkoutCancelUrl
})
}).then(function (res) {
if (!res.ok) {
@ -118,19 +138,3 @@ Array.prototype.forEach.call(document.querySelectorAll('[data-members-signout]')
}
el.addEventListener('click', clickHandler);
});
var magicLinkRegEx = /token=([a-zA-Z0-9_\-]+\.[a-zA-Z0-9_\-]+\.[a-zA-Z0-9_\-]+)/;
var match = location.search.match(magicLinkRegEx);
var isMagicLink = !!match
var token = match && match[1];
if (isMagicLink) {
fetch('{{blog-url}}/members/ssr', {
method: 'POST',
body: token
}).then(function (res) {
if (res.ok) {
window.location.search = window.location.search.replace(magicLinkRegEx, '');
}
});
}

View File

@ -1,117 +1,127 @@
// # Mail
// Handles sending email for Ghost
var _ = require('lodash'),
Promise = require('bluebird'),
validator = require('validator'),
config = require('../../config'),
common = require('../../lib/common'),
settingsCache = require('../settings/cache'),
urlUtils = require('../../lib/url-utils');
const _ = require('lodash');
const Promise = require('bluebird');
const validator = require('validator');
const config = require('../../config');
const common = require('../../lib/common');
const settingsCache = require('../settings/cache');
const urlUtils = require('../../lib/url-utils');
function GhostMailer() {
var nodemailer = require('nodemailer'),
transport = config.get('mail') && config.get('mail').transport || 'direct',
options = config.get('mail') && _.clone(config.get('mail').options) || {};
const helpMessage = common.i18n.t('errors.api.authentication.checkEmailConfigInstructions', {url: 'https://ghost.org/docs/concepts/config/#mail'});
const defaultErrorMessage = common.i18n.t('errors.mail.failedSendingEmail.error');
this.state = {};
this.transport = nodemailer.createTransport(transport, options);
this.state.usingDirect = transport === 'direct';
function getDomain() {
const domain = urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
return domain && domain[1];
}
GhostMailer.prototype.from = function () {
var from = config.get('mail') && config.get('mail').from,
defaultBlogTitle;
function getFromAddress(requestedFromAddress) {
const configAddress = config.get('mail') && config.get('mail').from;
const address = requestedFromAddress || configAddress;
// If we don't have a from address at all
if (!from) {
// Default to ghost@[blog.url]
from = 'ghost@' + this.getDomain();
if (!address) {
// Default to noreply@[blog.url]
return getFromAddress(`noreply@${getDomain()}`);
}
// If we do have a from address, and it's just an email
if (validator.isEmail(from)) {
defaultBlogTitle = settingsCache.get('title') ? settingsCache.get('title').replace(/"/g, '\\"') : common.i18n.t('common.mail.title', {domain: this.getDomain()});
from = '"' + defaultBlogTitle + '" <' + from + '>';
if (validator.isEmail(address)) {
const defaultBlogTitle = settingsCache.get('title') ? settingsCache.get('title').replace(/"/g, '\\"') : common.i18n.t('common.mail.title', {domain: getDomain()});
return `"${defaultBlogTitle}" <${address}>`;
}
return from;
};
return address;
}
// Moved it to its own module
GhostMailer.prototype.getDomain = function () {
var domain = urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
return domain && domain[1];
};
// Sends an email message enforcing `to` (blog owner) and `from` fields
// This assumes that api.settings.read('email') was already done on the API level
GhostMailer.prototype.send = function (message) {
var self = this,
to,
help = common.i18n.t('errors.api.authentication.checkEmailConfigInstructions', {url: 'https://ghost.org/docs/concepts/config/#mail'}),
errorMessage = common.i18n.t('errors.mail.failedSendingEmail.error');
// important to clone message as we modify it
message = _.clone(message) || {};
to = message.to || false;
if (!(message && message.subject && message.html && message.to)) {
return Promise.reject(new common.errors.EmailError({
message: common.i18n.t('errors.mail.incompleteMessageData.error'),
help: help
}));
}
message = _.extend(message, {
from: self.from(),
to: to,
function createMessage(message) {
return Object.assign({}, message, {
from: getFromAddress(),
generateTextFromHTML: true,
encoding: 'base64'
});
}
return new Promise(function (resolve, reject) {
self.transport.sendMail(message, function (err, response) {
if (err) {
errorMessage += common.i18n.t('errors.mail.reason', {reason: err.message || err});
function createMailError({message, err, ignoreDefaultMessage} = {message: ''}) {
const fullErrorMessage = defaultErrorMessage + message;
return new common.errors.EmailError({
message: ignoreDefaultMessage ? message : fullErrorMessage,
err: err,
help: helpMessage
});
}
return reject(new common.errors.EmailError({
message: errorMessage,
err: err,
help: help
}));
module.exports = class GhostMailer {
constructor() {
const nodemailer = require('nodemailer');
const transport = config.get('mail') && config.get('mail').transport || 'direct';
// nodemailer mutates the options passed to createTransport
const options = config.get('mail') && _.clone(config.get('mail').options) || {};
this.state = {
usingDirect: transport === 'direct'
};
this.transport = nodemailer.createTransport(transport, options);
}
send(message) {
if (!(message && message.subject && message.html && message.to)) {
return Promise.reject(createMailError({
message: common.i18n.t('errors.mail.incompleteMessageData.error'),
ignoreDefaultMessage: true
}));
}
const messageToSend = createMessage(message);
return this.sendMail(messageToSend).then((response) => {
if (this.transport.transportType === 'DIRECT') {
return this.handleDirectTransportResponse(response);
}
return response;
});
}
if (self.transport.transportType !== 'DIRECT') {
return resolve(response);
}
sendMail(message) {
return new Promise((resolve, reject) => {
this.transport.sendMail(message, (err, response) => {
if (err) {
reject(createMailError({
message: common.i18n.t('errors.mail.reason', {reason: err.message || err}),
err
}));
}
resolve(response);
});
});
}
handleDirectTransportResponse(response) {
return new Promise((resolve, reject) => {
response.statusHandler.once('failed', function (data) {
if (data.error && data.error.errno === 'ENOTFOUND') {
errorMessage += common.i18n.t('errors.mail.noMailServerAtAddress.error', {domain: data.domain});
reject(createMailError({
message: common.i18n.t('errors.mail.noMailServerAtAddress.error', {domain: data.domain})
}));
}
return reject(new common.errors.EmailError({
message: errorMessage,
help: help
}));
reject(createMailError());
});
response.statusHandler.once('requeue', function (data) {
if (data.error && data.error.message) {
errorMessage += common.i18n.t('errors.mail.reason', {reason: data.error.message});
reject(createMailError({
message: common.i18n.t('errors.mail.reason', {reason: data.error.message})
}));
}
return reject(new common.errors.EmailError({
message: errorMessage,
help: help
}));
reject(createMailError());
});
response.statusHandler.once('sent', function () {
return resolve(common.i18n.t('notices.mail.messageSent'));
resolve(common.i18n.t('notices.mail.messageSent'));
});
});
});
}
};
module.exports = GhostMailer;

View File

@ -3,12 +3,14 @@ const settingsCache = require('../settings/cache');
const urlUtils = require('../../lib/url-utils');
const MembersApi = require('@tryghost/members-api');
const common = require('../../lib/common');
const ghostVersion = require('../../lib/ghost-version');
const mail = require('../mail');
const models = require('../../models');
function createMember({email}) {
function createMember({email, name}) {
return models.Member.add({
email
email,
name
}).then((member) => {
return member.toJSON();
});
@ -26,6 +28,29 @@ function getMember(data, options = {}) {
});
}
async function setMemberMetadata(member, module, metadata) {
if (module !== 'stripe') {
return;
}
await models.Member.edit({
stripe_customers: metadata
}, {id: member.id, withRelated: ['stripe_customers']});
return;
}
async function getMemberMetadata(member, module) {
if (module !== 'stripe') {
return;
}
const model = await models.Member.where({id: member.id}).fetch({withRelated: ['stripe_customers']});
const metadata = await model.related('stripe_customers');
return metadata.toJSON();
}
function updateMember({name}, options) {
return models.Member.edit({name}, options);
}
function deleteMember(options) {
options = options || {};
return models.Member.destroy(options).catch(models.Member.NotFoundError, () => {
@ -61,9 +86,6 @@ const ghostMailer = new mail.GhostMailer();
function getStripePaymentConfig() {
const subscriptionSettings = settingsCache.get('members_subscription_settings');
if (!subscriptionSettings || subscriptionSettings.isPaid === false) {
return null;
}
const stripePaymentProcessor = subscriptionSettings.paymentProcessors.find(
paymentProcessor => paymentProcessor.adapter === 'stripe'
@ -73,13 +95,27 @@ function getStripePaymentConfig() {
return null;
}
const webhookHandlerUrl = new URL('/members/webhooks/stripe', siteUrl);
const checkoutSuccessUrl = new URL(siteUrl);
checkoutSuccessUrl.searchParams.set('stripe', 'success');
const checkoutCancelUrl = new URL(siteUrl);
checkoutCancelUrl.searchParams.set('stripe', 'cancel');
return {
publicKey: stripePaymentProcessor.config.public_token,
secretKey: stripePaymentProcessor.config.secret_token,
checkoutSuccessUrl: siteUrl,
checkoutCancelUrl: siteUrl,
checkoutSuccessUrl: checkoutSuccessUrl.href,
checkoutCancelUrl: checkoutCancelUrl.href,
webhookHandlerUrl: webhookHandlerUrl.href,
product: stripePaymentProcessor.config.product,
plans: stripePaymentProcessor.config.plans
plans: stripePaymentProcessor.config.plans,
appInfo: {
name: 'Ghost',
partner_id: 'pp_partner_DKmRVtTs4j9pwZ',
version: ghostVersion.original,
url: 'https://ghost.org/'
}
};
}
@ -93,9 +129,10 @@ function createApiInstance() {
privateKey: settingsCache.get('members_private_key')
},
auth: {
getSigninURL(token) {
getSigninURL(token, type) {
const signinURL = new URL(siteUrl);
signinURL.searchParams.set('token', token);
signinURL.searchParams.set('action', type);
return signinURL.href;
}
},
@ -107,18 +144,42 @@ function createApiInstance() {
}
return ghostMailer.send(Object.assign({subject: 'Signin'}, message));
}
},
getText(url, type) {
switch (type) {
case 'subscribe':
return `Click here to confirm your subscription ${url}`;
case 'signup':
return `Click here to confirm your email address and sign up ${url}`;
case 'signin':
default:
return `Click here to sign in ${url}`;
}
},
getHTML(url, type) {
switch (type) {
case 'subscribe':
return `<a href="${url}">Click here to confirm your subscription</a>`;
case 'signup':
return `<a href="${url}">Click here to confirm your email address and sign up</a>`;
case 'signin':
default:
return `<a href="${url}">Click here to sign in</a>`;
}
}
},
paymentConfig: {
stripe: getStripePaymentConfig()
},
setMemberMetadata,
getMemberMetadata,
createMember,
updateMember,
getMember,
deleteMember,
listMembers
listMembers,
logger: common.logging
});
membersApiInstance.setLogger(common.logging);
return membersApiInstance;
}

View File

@ -44,9 +44,7 @@ const membersService = {
cookieKeys: [settingsCache.get('theme_session_secret')],
cookieName: 'ghost-members-ssr',
cookieCacheName: 'ghost-members-ssr-cache',
// This is passed as a function so that updates to the instance
// are picked up in the ssr module
membersApi: () => membersApi
getMembersApi: () => membersService.api
})
};

View File

@ -20,6 +20,7 @@ const notImplemented = function (req, res, next) {
tags: ['GET', 'PUT', 'DELETE', 'POST'],
users: ['GET'],
themes: ['POST', 'PUT'],
members: ['GET', 'PUT', 'DELETE', 'POST'],
subscribers: ['GET', 'PUT', 'DELETE', 'POST'],
config: ['GET'],
webhooks: ['POST', 'DELETE'],

View File

@ -102,7 +102,19 @@ module.exports = function apiRoutes() {
// ## Members
router.get('/members', shared.middlewares.labs.members, mw.authAdminApi, http(apiCanary.members.browse));
router.post('/members', shared.middlewares.labs.members, mw.authAdminApi, http(apiCanary.members.add));
router.get('/members/csv', shared.middlewares.labs.members, mw.authAdminApi, http(apiCanary.members.exportCSV));
router.post('/members/csv',
shared.middlewares.labs.members,
mw.authAdminApi,
upload.single('membersfile'),
shared.middlewares.validation.upload({type: 'members'}),
http(apiCanary.members.importCSV)
);
router.get('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiCanary.members.read));
router.put('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiCanary.members.edit));
router.del('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiCanary.members.destroy));
// ## Roles

View File

@ -18,9 +18,9 @@ module.exports = function setupMembersApiApp() {
const siteUrl = new URL(urlUtils.getSiteUrl());
apiApp.use(cors(siteUrl.origin));
// Set up the api endpoints and the gateway
// NOTE: this is wrapped in a function to ensure we always go via the getter
apiApp.use((req, res, next) => membersService.api(req, res, next));
apiApp.post('/send-magic-link', (req, res, next) => membersService.api.middleware.sendMagicLink(req, res, next));
apiApp.post('/create-stripe-checkout-session', (req, res, next) => membersService.api.middleware.createCheckoutSession(req, res, next));
// API error handling
apiApp.use(shared.middlewares.errorHandler.resourceNotFound);

View File

@ -19,6 +19,7 @@ const notImplemented = function (req, res, next) {
tags: ['GET', 'PUT', 'DELETE', 'POST'],
users: ['GET'],
themes: ['POST', 'PUT'],
members: ['GET', 'PUT', 'DELETE', 'POST'],
subscribers: ['GET', 'PUT', 'DELETE', 'POST'],
config: ['GET'],
webhooks: ['POST', 'DELETE'],

View File

@ -102,7 +102,19 @@ module.exports = function apiRoutes() {
// ## Members
router.get('/members', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.browse));
router.post('/members', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.add));
router.get('/members/csv', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.exportCSV));
router.post('/members/csv',
shared.middlewares.labs.members,
mw.authAdminApi,
upload.single('membersfile'),
shared.middlewares.validation.upload({type: 'members'}),
http(apiv2.members.importCSV)
);
router.get('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.read));
router.put('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.edit));
router.del('/members/:id', shared.middlewares.labs.members, mw.authAdminApi, http(apiv2.members.destroy));
// ## Roles

View File

@ -18,9 +18,9 @@ module.exports = function setupMembersApiApp() {
const siteUrl = new URL(urlUtils.getSiteUrl());
apiApp.use(cors(siteUrl.origin));
// Set up the api endpoints and the gateway
// NOTE: this is wrapped in a function to ensure we always go via the getter
apiApp.use((req, res, next) => membersService.api(req, res, next));
apiApp.post('/send-magic-link', (req, res, next) => membersService.api.middleware.sendMagicLink(req, res, next));
apiApp.post('/create-stripe-checkout-session', (req, res, next) => membersService.api.middleware.createCheckoutSession(req, res, next));
// API error handling
apiApp.use(shared.middlewares.errorHandler.resourceNotFound);

View File

@ -22,6 +22,7 @@ module.exports = function setupParentApp(options = {}) {
// (X-Forwarded-Proto header will be checked, if present)
parentApp.enable('trust proxy');
parentApp.use(shared.middlewares.requestId);
parentApp.use(shared.middlewares.logRequest);
// Register event emmiter on req/res to trigger cache invalidation webhook event

View File

@ -55,6 +55,10 @@ module.exports = {
return require('./pretty-urls');
},
get requestId() {
return require('./request-id');
},
get serveFavicon() {
return require('./serve-favicon');
},

View File

@ -1,17 +1,13 @@
const uuid = require('uuid');
const common = require('../../../lib/common');
/**
* @TODO:
* - move middleware to ignition?
* @TODO: move this middleware to ignition?
*/
module.exports = function logRequest(req, res, next) {
const startTime = Date.now(),
requestId = req.get('X-Request-ID') || uuid.v4();
const startTime = Date.now();
function logResponse() {
res.responseTime = (Date.now() - startTime) + 'ms';
req.requestId = requestId;
req.userId = req.user ? (req.user.id ? req.user.id : req.user) : null;
if (req.err && req.err.statusCode !== 404) {

View File

@ -0,0 +1,18 @@
const uuid = require('uuid');
/**
* @TODO: move this middleware to ignition?
*/
module.exports = (req, res, next) => {
const requestId = req.get('X-Request-ID') || uuid.v4();
// Set a value for internal use
req.requestId = requestId;
// If the header was set on the request, return it on the response
if (req.get('X-Request-ID')) {
res.set('X-Request-ID', requestId);
}
next();
};

View File

@ -11,7 +11,8 @@ const apps = require('../../services/apps');
const constants = require('../../lib/constants');
const storage = require('../../adapters/storage');
const urlService = require('../../../frontend/services/url');
const urlUtils = require('../../../server/lib/url-utils');
const labsService = require('../../services/labs');
const urlUtils = require('../../lib/url-utils');
const sitemapHandler = require('../../../frontend/services/sitemap/handler');
const themeMiddleware = require('../../../frontend/services/themes').middleware;
const membersService = require('../../services/members');
@ -133,45 +134,60 @@ module.exports = function setupSiteApp(options = {}) {
// @TODO only loads this stuff if members is enabled
// Set req.member & res.locals.member if a cookie is set
siteApp.get('/members/ssr', shared.middlewares.labs.members, function (req, res) {
membersService.ssr.getIdentityTokenForMemberFromSession(req, res).then((token) => {
siteApp.get('/members/ssr', shared.middlewares.labs.members, async function (req, res) {
try {
const token = await membersService.ssr.getIdentityTokenForMemberFromSession(req, res);
res.writeHead(200);
res.end(token);
}).catch((err) => {
} catch (err) {
common.logging.warn(err.message);
res.writeHead(err.statusCode);
res.end(err.message);
});
}
});
siteApp.post('/members/ssr', shared.middlewares.labs.members, function (req, res) {
membersService.ssr.exchangeTokenForSession(req, res).then(() => {
res.writeHead(200);
res.end();
}).catch((err) => {
common.logging.warn(err.message);
res.writeHead(err.statusCode);
res.end(err.message);
});
});
siteApp.delete('/members/ssr', shared.middlewares.labs.members, function (req, res) {
membersService.ssr.deleteSession(req, res).then(() => {
siteApp.delete('/members/ssr', shared.middlewares.labs.members, async function (req, res) {
try {
await membersService.ssr.deleteSession(req, res);
res.writeHead(204);
res.end();
}).catch((err) => {
} catch (err) {
common.logging.warn(err.message);
res.writeHead(err.statusCode);
res.end(err.message);
});
}
});
siteApp.use(function (req, res, next) {
membersService.ssr.getMemberDataFromSession(req, res).then((member) => {
req.member = member;
next();
}).catch((err) => {
common.logging.warn(err.message);
siteApp.post('/members/webhooks/stripe', (req, res, next) => membersService.api.middleware.handleStripeWebhook(req, res, next));
siteApp.use(async function (req, res, next) {
if (!labsService.isSet('members')) {
req.member = null;
return next();
}
try {
const member = await membersService.ssr.getMemberDataFromSession(req, res);
Object.assign(req, {member});
next();
});
} catch (err) {
common.logging.warn(err.message);
Object.assign(req, {member: null});
next();
}
});
siteApp.use(async function (req, res, next) {
if (!labsService.isSet('members')) {
return next();
}
if (!req.url.includes('token=')) {
return next();
}
try {
const member = await membersService.ssr.exchangeTokenForSession(req, res);
Object.assign(req, {member});
next();
} catch (err) {
common.logging.warn(err.message);
return next();
}
});
siteApp.use(function (req, res, next) {
res.locals.member = req.member;

View File

@ -11,6 +11,9 @@
"eslint:recommended",
"plugin:ghost/test"
],
"parserOptions": {
"ecmaVersion": 2017
},
"rules": {
// these rules were were not previously enforced in our custom rules,
// they're turned off here because they _are_ enforced in our plugin.

View File

@ -55,7 +55,7 @@ describe('DB API', function () {
const jsonResponse = res.body;
should.exist(jsonResponse.db);
jsonResponse.db.should.have.length(1);
Object.keys(jsonResponse.db[0].data).length.should.eql(26);
Object.keys(jsonResponse.db[0].data).length.should.eql(27);
});
});

View File

@ -152,6 +152,10 @@ describe('Settings API', function () {
{
key: 'twitter_description',
value: 'twitter description'
},
{
key: 'labs',
value: '{"subscribers":false,"members":true,"default_content_visibility":"paid"}'
}
]
};
@ -216,6 +220,9 @@ describe('Settings API', function () {
putBody.settings[12].key.should.eql('twitter_description');
should.equal(putBody.settings[12].value, 'twitter description');
putBody.settings[13].key.should.eql('labs');
should.equal(putBody.settings[13].value, '{"subscribers":false,"members":true,"default_content_visibility":"paid"}');
localUtils.API.checkResponse(putBody, 'settings');
done();
});

View File

@ -28,7 +28,6 @@ const expectedProperties = {
.keys()
// by default we only return mobildoc
.without('html', 'plaintext')
.without('visibility')
.without('locale')
.without('page')
// v2 API doesn't return new type field
@ -49,7 +48,6 @@ const expectedProperties = {
.keys()
// by default we only return mobildoc
.without('html', 'plaintext')
.without('visibility')
.without('locale')
.without('page')
// v2 API doesn't return new type field

View File

@ -83,7 +83,7 @@ describe('Posts Content API', function () {
should.exist(urlParts.host);
res.body.posts[7].slug.should.eql('not-so-short-bit-complex');
res.body.posts[7].html.should.match(/<a href="\/about#nowhere" title="Relative URL/);
res.body.posts[7].html.should.match(/<a href="http:\/\/127.0.0.1:2369\/about#nowhere" title="Relative URL/);
res.body.posts[9].slug.should.eql('ghostly-kitchen-sink');
res.body.posts[9].html.should.match(/<img src="http:\/\/127.0.0.1:2369\/content\/images\/lol.jpg"/);

View File

@ -0,0 +1,255 @@
const path = require('path');
const should = require('should');
const supertest = require('supertest');
const sinon = require('sinon');
const testUtils = require('../../../../utils');
const localUtils = require('./utils');
const config = require('../../../../../server/config');
const labs = require('../../../../../server/services/labs');
const ghost = testUtils.startGhost;
let request;
describe('Members API', function () {
before(function () {
sinon.stub(labs, 'isSet').withArgs('members').returns(true);
});
after(function () {
sinon.restore();
});
before(function () {
return ghost()
.then(function () {
request = supertest.agent(config.get('url'));
})
.then(function () {
return localUtils.doAuth(request, 'member');
});
});
it('Can browse', function () {
return request
.get(localUtils.API.getApiQuery('members/'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member');
testUtils.API.isISO8601(jsonResponse.members[0].created_at).should.be.true();
jsonResponse.members[0].created_at.should.be.an.instanceof(String);
jsonResponse.meta.pagination.should.have.property('page', 1);
jsonResponse.meta.pagination.should.have.property('limit', 15);
jsonResponse.meta.pagination.should.have.property('pages', 1);
jsonResponse.meta.pagination.should.have.property('total', 1);
jsonResponse.meta.pagination.should.have.property('next', null);
jsonResponse.meta.pagination.should.have.property('prev', null);
});
});
it('Can read', function () {
return request
.get(localUtils.API.getApiQuery(`members/${testUtils.DataGenerator.Content.members[0].id}/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe');
});
});
it('Can add', function () {
const member = {
name: 'test',
email: 'memberTestAdd@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
jsonResponse.members[0].name.should.equal(member.name);
jsonResponse.members[0].email.should.equal(member.email);
})
.then(() => {
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(422);
});
});
it('Should fail when passing incorrect email_type query parameter', function () {
const member = {
name: 'test',
email: 'memberTestAdd@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/?send_email=true&email_type=lel`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(422);
});
it('Can edit by id', function () {
const memberToChange = {
name: 'change me',
email: 'member2Change@test.com'
};
const memberChanged = {
name: 'changed',
email: 'cantChangeMe@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [memberToChange]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
return jsonResponse.members[0];
})
.then((newMember) => {
return request
.put(localUtils.API.getApiQuery(`members/${newMember.id}/`))
.send({members: [memberChanged]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member');
jsonResponse.members[0].name.should.equal(memberChanged.name);
jsonResponse.members[0].email.should.not.equal(memberChanged.email);
jsonResponse.members[0].email.should.equal(memberToChange.email);
});
});
});
it('Can destroy', function () {
const member = {
name: 'test',
email: 'memberTestDestroy@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
return jsonResponse.members[0];
})
.then((newMember) => {
return request
.delete(localUtils.API.getApiQuery(`members/${newMember.id}`))
.set('Origin', config.get('url'))
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(204)
.then(() => newMember);
})
.then((newMember) => {
return request
.get(localUtils.API.getApiQuery(`members/${newMember.id}/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(404);
});
});
it('Can export CSV', function () {
return request
.get(localUtils.API.getApiQuery(`members/csv/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /text\/csv/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
res.headers['content-disposition'].should.match(/Attachment;\sfilename="members/);
res.text.should.match(/id,email,name,created_at,deleted_at/);
res.text.should.match(/member1@test.com/);
res.text.should.match(/Mr Egg/);
});
});
it('Can import CSV', function () {
return request
.post(localUtils.API.getApiQuery(`members/csv/`))
.attach('membersfile', path.join(__dirname, '/../../../../utils/fixtures/csv/valid-members-import.csv'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.meta);
should.exist(jsonResponse.meta.stats);
jsonResponse.meta.stats.imported.should.equal(2);
jsonResponse.meta.stats.duplicates.should.equal(0);
jsonResponse.meta.stats.invalid.should.equal(0);
});
});
});

View File

@ -342,6 +342,32 @@ describe('Posts API', function () {
res.body.posts[0].slug.should.equal('this-is-invisible');
});
});
it('accepts visibility parameter', 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: [{
visibility: 'members',
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].visibility);
res.body.posts[0].visibility.should.equal('members');
});
});
});
describe('Destroy', function () {

View File

@ -22,7 +22,6 @@ const expectedProperties = {
.keys()
// by default we only return mobiledoc
.without('html', 'plaintext')
.without('visibility')
.without('locale')
.without('page')
.without('author_id', 'author')
@ -54,6 +53,9 @@ const expectedProperties = {
subscriber: _(schema.subscribers)
.keys()
,
member: _(schema.members)
.keys()
,
role: _(schema.roles)
.keys()
,

View File

@ -0,0 +1,255 @@
const path = require('path');
const should = require('should');
const supertest = require('supertest');
const sinon = require('sinon');
const testUtils = require('../../../../utils');
const localUtils = require('./utils');
const config = require('../../../../../server/config');
const labs = require('../../../../../server/services/labs');
const ghost = testUtils.startGhost;
let request;
describe('Members API', function () {
before(function () {
sinon.stub(labs, 'isSet').withArgs('members').returns(true);
});
after(function () {
sinon.restore();
});
before(function () {
return ghost()
.then(function () {
request = supertest.agent(config.get('url'));
})
.then(function () {
return localUtils.doAuth(request, 'member');
});
});
it('Can browse', function () {
return request
.get(localUtils.API.getApiQuery('members/'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member');
testUtils.API.isISO8601(jsonResponse.members[0].created_at).should.be.true();
jsonResponse.members[0].created_at.should.be.an.instanceof(String);
jsonResponse.meta.pagination.should.have.property('page', 1);
jsonResponse.meta.pagination.should.have.property('limit', 15);
jsonResponse.meta.pagination.should.have.property('pages', 1);
jsonResponse.meta.pagination.should.have.property('total', 1);
jsonResponse.meta.pagination.should.have.property('next', null);
jsonResponse.meta.pagination.should.have.property('prev', null);
});
});
it('Can read', function () {
return request
.get(localUtils.API.getApiQuery(`members/${testUtils.DataGenerator.Content.members[0].id}/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe');
});
});
it('Can add', function () {
const member = {
name: 'test',
email: 'memberTestAdd@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
jsonResponse.members[0].name.should.equal(member.name);
jsonResponse.members[0].email.should.equal(member.email);
})
.then(() => {
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(422);
});
});
it('Should fail when passing incorrect email_type query parameter', function () {
const member = {
name: 'test',
email: 'memberTestAdd@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/?send_email=true&email_type=lel`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(422);
});
it('Can edit by id', function () {
const memberToChange = {
name: 'change me',
email: 'member2Change@test.com'
};
const memberChanged = {
name: 'changed',
email: 'cantChangeMe@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [memberToChange]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
return jsonResponse.members[0];
})
.then((newMember) => {
return request
.put(localUtils.API.getApiQuery(`members/${newMember.id}/`))
.send({members: [memberChanged]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse.members[0], 'member');
jsonResponse.members[0].name.should.equal(memberChanged.name);
jsonResponse.members[0].email.should.not.equal(memberChanged.email);
jsonResponse.members[0].email.should.equal(memberToChange.email);
});
});
});
it('Can destroy', function () {
const member = {
name: 'test',
email: 'memberTestDestroy@test.com'
};
return request
.post(localUtils.API.getApiQuery(`members/`))
.send({members: [member]})
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
return jsonResponse.members[0];
})
.then((newMember) => {
return request
.delete(localUtils.API.getApiQuery(`members/${newMember.id}`))
.set('Origin', config.get('url'))
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(204)
.then(() => newMember);
})
.then((newMember) => {
return request
.get(localUtils.API.getApiQuery(`members/${newMember.id}/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(404);
});
});
it('Can export CSV', function () {
return request
.get(localUtils.API.getApiQuery(`members/csv/`))
.set('Origin', config.get('url'))
.expect('Content-Type', /text\/csv/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(200)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
res.headers['content-disposition'].should.match(/Attachment;\sfilename="members/);
res.text.should.match(/id,email,name,created_at,deleted_at/);
res.text.should.match(/member1@test.com/);
res.text.should.match(/Mr Egg/);
});
});
it('Can import CSV', function () {
return request
.post(localUtils.API.getApiQuery(`members/csv/`))
.attach('membersfile', path.join(__dirname, '/../../../../utils/fixtures/csv/valid-members-import.csv'))
.set('Origin', config.get('url'))
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.private)
.expect(201)
.then((res) => {
should.not.exist(res.headers['x-cache-invalidate']);
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.meta);
should.exist(jsonResponse.meta.stats);
jsonResponse.meta.stats.imported.should.equal(2);
jsonResponse.meta.stats.duplicates.should.equal(0);
jsonResponse.meta.stats.invalid.should.equal(0);
});
});
});

View File

@ -342,6 +342,32 @@ describe('Posts API', function () {
res.body.posts[0].slug.should.equal('this-is-invisible');
});
});
it('accepts visibility parameter', 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: [{
visibility: 'members',
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].visibility);
res.body.posts[0].visibility.should.equal('members');
});
});
});
describe('Destroy', function () {

View File

@ -53,6 +53,9 @@ const expectedProperties = {
subscriber: _(schema.subscribers)
.keys()
,
member: _(schema.members)
.keys()
,
role: _(schema.roles)
.keys()
,

View File

@ -8,6 +8,7 @@ var should = require('should'),
urlService = require('../../../frontend/services/url'),
ghostBookshelf = require('../../../server/models/base'),
models = require('../../../server/models'),
settingsCache = require('../../../server/services/settings/cache'),
common = require('../../../server/lib/common'),
configUtils = require('../../utils/configUtils'),
DataGenerator = testUtils.DataGenerator,
@ -731,6 +732,81 @@ describe('Post Model', function () {
(!!createdPost.get('page')).should.equal(false);
should.equal(createdPost.get('locale'), null);
should.equal(createdPost.get('visibility'), 'public');
// testing for nulls
(createdPost.get('feature_image') === null).should.equal(true);
createdPost.get('created_at').should.be.above(new Date(0).getTime());
createdPost.get('created_by').should.equal(testUtils.DataGenerator.Content.users[0].id);
createdPost.get('author_id').should.equal(testUtils.DataGenerator.Content.users[0].id);
createdPost.has('author').should.equal(false);
createdPost.get('created_by').should.equal(createdPost.get('author_id'));
createdPost.get('updated_at').should.be.above(new Date(0).getTime());
createdPost.get('updated_by').should.equal(testUtils.DataGenerator.Content.users[0].id);
should.equal(createdPost.get('published_at'), null);
should.equal(createdPost.get('published_by'), null);
createdPostUpdatedDate = createdPost.get('updated_at');
Object.keys(eventsTriggered).length.should.eql(2);
should.exist(eventsTriggered['post.added']);
should.exist(eventsTriggered['user.attached']);
// Set the status to published to check that `published_at` is set.
return createdPost.save({status: 'published'}, context);
}).then(function (publishedPost) {
publishedPost.get('published_at').should.be.instanceOf(Date);
publishedPost.get('published_by').should.equal(testUtils.DataGenerator.Content.users[0].id);
publishedPost.get('updated_at').should.be.instanceOf(Date);
publishedPost.get('updated_by').should.equal(testUtils.DataGenerator.Content.users[0].id);
publishedPost.get('updated_at').should.not.equal(createdPostUpdatedDate);
Object.keys(eventsTriggered).length.should.eql(4);
should.exist(eventsTriggered['post.published']);
should.exist(eventsTriggered['post.edited']);
done();
}).catch(done);
});
it('can add, default visibility is taken from settings cache', function (done) {
var originalSettingsCacheGetFn = settingsCache.get;
sinon.stub(settingsCache, 'get')
.callsFake(function (key, options) {
if (key === 'labs') {
return {
members: true
};
} else if (key === 'default_content_visibility') {
return 'paid';
}
return originalSettingsCacheGetFn(key, options);
});
var createdPostUpdatedDate,
newPost = testUtils.DataGenerator.forModel.posts[2],
newPostDB = testUtils.DataGenerator.Content.posts[2];
models.Post.add(newPost, _.merge({withRelated: ['author']}, context)).then(function (createdPost) {
return models.Post.findOne({id: createdPost.id, status: 'all'});
}).then(function (createdPost) {
should.exist(createdPost);
createdPost.has('uuid').should.equal(true);
createdPost.get('status').should.equal('draft');
createdPost.get('title').should.equal(newPost.title, 'title is correct');
createdPost.get('mobiledoc').should.equal(newPost.mobiledoc, 'mobiledoc is correct');
createdPost.has('html').should.equal(true);
createdPost.get('html').should.equal(newPostDB.html);
createdPost.has('plaintext').should.equal(true);
createdPost.get('plaintext').should.match(/^testing/);
// createdPost.get('slug').should.equal(newPostDB.slug + '-3');
(!!createdPost.get('featured')).should.equal(false);
(!!createdPost.get('page')).should.equal(false);
should.equal(createdPost.get('locale'), null);
should.equal(createdPost.get('visibility'), 'paid');
// testing for nulls
(createdPost.get('feature_image') === null).should.equal(true);
@ -1038,6 +1114,44 @@ describe('Post Model', function () {
done();
}).catch(done);
});
it('transforms absolute urls to relative', function (done) {
const post = {
title: 'Absolute->Relative URL Transform Test',
mobiledoc: '{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"http://127.0.0.1:2369/content/images/card.jpg"}]],"markups":[["a",["href","http://127.0.0.1:2369/test"]]],"sections":[[1,"p",[[0,[0],1,"Testing"]]],[10,0]]}',
custom_excerpt: 'Testing <a href="http://127.0.0.1:2369/internal">links</a> in custom excerpts',
codeinjection_head: '<script src="http://127.0.0.1:2369/assets/head.js"></script>',
codeinjection_foot: '<script src="http://127.0.0.1:2369/assets/foot.js"></script>',
feature_image: 'http://127.0.0.1:2369/content/images/feature.png',
og_image: 'http://127.0.0.1:2369/content/images/og.png',
twitter_image: 'http://127.0.0.1:2369/content/images/twitter.png',
canonical_url: 'http://127.0.0.1:2369/canonical'
};
models.Post.add(post, context).then((createdPost) => {
createdPost.get('mobiledoc').should.equal('{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"/content/images/card.jpg"}]],"markups":[["a",["href","/test"]]],"sections":[[1,"p",[[0,[0],1,"Testing"]]],[10,0]]}');
createdPost.get('html').should.equal('<p><a href="/test">Testing</a></p><figure class="kg-card kg-image-card"><img src="/content/images/card.jpg" class="kg-image"></figure>');
createdPost.get('custom_excerpt').should.equal('Testing <a href="/internal">links</a> in custom excerpts');
createdPost.get('codeinjection_head').should.equal('<script src="/assets/head.js"></script>');
createdPost.get('codeinjection_foot').should.equal('<script src="/assets/foot.js"></script>');
createdPost.get('feature_image').should.equal('/content/images/feature.png');
createdPost.get('og_image').should.equal('/content/images/og.png');
createdPost.get('twitter_image').should.equal('/content/images/twitter.png');
createdPost.get('canonical_url').should.equal('/canonical');
// ensure canonical_url is not transformed when protocol does not match
return createdPost.save({
canonical_url: 'https://127.0.0.1:2369/https-internal',
// sanity check for general absolute->relative transform during edits
feature_image: 'http://127.0.0.1:2369/content/images/updated_feature.png'
});
}).then((updatedPost) => {
updatedPost.get('canonical_url').should.equal('https://127.0.0.1:2369/https-internal');
updatedPost.get('feature_image').should.equal('/content/images/updated_feature.png');
done();
}).catch(done);
});
});
describe('destroy', function () {

View File

@ -221,174 +221,6 @@ describe('Unit: canary/utils/serializers/input/posts', function () {
});
describe('edit', function () {
describe('Ensure relative urls are returned for standard image urls', function () {
describe('no subdir', function () {
let sandbox;
after(function () {
sandbox.restore();
});
before(function () {
sandbox = sinon.createSandbox();
urlUtils.stubUrlUtils({url: 'https://mysite.com'}, sandbox);
});
it('when mobiledoc contains an absolute URL to image', function () {
const apiConfig = {};
const frame = {
options: {
context: {
user: 0,
api_key: {
id: 1,
type: 'content'
}
}
},
data: {
posts: [
{
id: 'id1',
mobiledoc: '{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"https://mysite.com/content/images/2019/02/image.jpg"}]]}'
}
]
}
};
serializers.input.posts.edit(apiConfig, frame);
let postData = frame.data.posts[0];
postData.mobiledoc.should.equal('{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"/content/images/2019/02/image.jpg"}]]}');
});
it('when mobiledoc contains multiple absolute URLs to images with different protocols', function () {
const apiConfig = {};
const frame = {
options: {
context: {
user: 0,
api_key: {
id: 1,
type: 'content'
}
}
},
data: {
posts: [
{
id: 'id1',
mobiledoc: '{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"https://mysite.com/content/images/2019/02/image.jpg"}],["image",{"src":"http://mysite.com/content/images/2019/02/image.png"}]]'
}
]
}
};
serializers.input.posts.edit(apiConfig, frame);
let postData = frame.data.posts[0];
postData.mobiledoc.should.equal('{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"/content/images/2019/02/image.jpg"}],["image",{"src":"/content/images/2019/02/image.png"}]]');
});
it('when blog url is without subdir', function () {
const apiConfig = {};
const frame = {
options: {
context: {
user: 0,
api_key: {
id: 1,
type: 'content'
}
},
withRelated: ['tags', 'authors']
},
data: {
posts: [
{
id: 'id1',
feature_image: 'https://mysite.com/content/images/image.jpg',
og_image: 'https://mysite.com/mycustomstorage/images/image.jpg',
twitter_image: 'https://mysite.com/blog/content/images/image.jpg',
tags: [{
id: 'id3',
feature_image: 'http://mysite.com/content/images/image.jpg'
}],
authors: [{
id: 'id4',
name: 'Ghosty',
profile_image: 'https://somestorage.com/blog/images/image.jpg'
}]
}
]
}
};
serializers.input.posts.edit(apiConfig, frame);
let postData = frame.data.posts[0];
postData.feature_image.should.eql('/content/images/image.jpg');
postData.og_image.should.eql('https://mysite.com/mycustomstorage/images/image.jpg');
postData.twitter_image.should.eql('https://mysite.com/blog/content/images/image.jpg');
postData.tags[0].feature_image.should.eql('/content/images/image.jpg');
postData.authors[0].profile_image.should.eql('https://somestorage.com/blog/images/image.jpg');
});
});
describe('with subdir', function () {
let sandbox;
after(function () {
sandbox.restore();
});
before(function () {
sandbox = sinon.createSandbox();
urlUtils.stubUrlUtils({url: 'https://mysite.com/blog'}, sandbox);
});
it('when blog url is with subdir', function () {
const apiConfig = {};
const frame = {
options: {
context: {
user: 0,
api_key: {
id: 1,
type: 'content'
}
},
withRelated: ['tags', 'authors']
},
data: {
posts: [
{
id: 'id1',
feature_image: 'https://mysite.com/blog/content/images/image.jpg',
og_image: 'https://mysite.com/content/images/image.jpg',
twitter_image: 'https://mysite.com/mycustomstorage/images/image.jpg',
tags: [{
id: 'id3',
feature_image: 'http://mysite.com/blog/mycustomstorage/content/images/image.jpg'
}],
authors: [{
id: 'id4',
name: 'Ghosty',
profile_image: 'https://somestorage.com/blog/content/images/image.jpg'
}]
}
]
}
};
serializers.input.posts.edit(apiConfig, frame);
let postData = frame.data.posts[0];
postData.feature_image.should.eql('/blog/content/images/image.jpg');
postData.og_image.should.eql('https://mysite.com/content/images/image.jpg');
postData.twitter_image.should.eql('https://mysite.com/mycustomstorage/images/image.jpg');
postData.tags[0].feature_image.should.eql('http://mysite.com/blog/mycustomstorage/content/images/image.jpg');
postData.authors[0].profile_image.should.eql('https://somestorage.com/blog/content/images/image.jpg');
});
});
});
describe('Ensure html to mobiledoc conversion', function () {
it('no transformation when no html source option provided', function () {
const apiConfig = {};

View File

@ -0,0 +1,136 @@
const should = require('should');
const sinon = require('sinon');
const labs = require('../../../../../../../../server/services/labs');
const members = require('../../../../../../../../server/api/canary/utils/serializers/output/utils/members');
describe('Unit: canary/utils/serializers/output/utils/members', function () {
describe('for post', function () {
it('does not modify attributes when members is disabled', function () {
const attrs = {
plaintext: 'no touching',
html: '<p>I am here to stay</p>'
};
const frame = {
options: {}
};
members.forPost(attrs, frame);
attrs.plaintext.should.eql('no touching');
});
describe('labs.members enabled', function () {
before(function () {
sinon.stub(labs, 'isSet').returns(true);
});
it('should NOT hide content attributes when visibility is public', function () {
const attrs = {
visibility: 'public',
plaintext: 'no touching',
html: '<p>I am here to stay</p>'
};
const frame = {
original: {
context: {}
}
};
members.forPost(attrs, frame);
attrs.plaintext.should.eql('no touching');
});
it('should hide content attributes when visibility is "members"', function () {
const attrs = {
visibility: 'members',
plaintext: 'no touching. secret stuff',
html: '<p>I am here to stay</p>'
};
const frame = {
original: {
context: {}
}
};
members.forPost(attrs, frame);
attrs.plaintext.should.eql('');
attrs.html.should.eql('');
});
it('should NOT hide content attributes when visibility is "members" and member is present', function () {
const attrs = {
visibility: 'members',
plaintext: 'I see dead people',
html: '<p>What\'s the matter?</p>'
};
const frame = {
original: {
context: {
member: {}
}
}
};
members.forPost(attrs, frame);
attrs.plaintext.should.eql('I see dead people');
attrs.html.should.eql('<p>What\'s the matter?</p>');
});
it('should hide content attributes when visibility is "paid" and member has no subscription', function () {
const attrs = {
visibility: 'paid',
plaintext: 'I see dead people',
html: '<p>What\'s the matter?</p>'
};
const frame = {
original: {
context: {
member: {
stripe: {
subscriptions: []
}
}
}
}
};
members.forPost(attrs, frame);
attrs.plaintext.should.eql('');
attrs.html.should.eql('');
});
it('should NOT hide content attributes when visibility is "paid" and member has a subscription', function () {
const attrs = {
visibility: 'paid',
plaintext: 'Secret paid content',
html: '<p>Can read this</p>'
};
const frame = {
original: {
context: {
member: {
stripe: {
subscriptions: ['I pay money dollaz']
}
}
}
}
};
members.forPost(attrs, frame);
attrs.plaintext.should.eql('Secret paid content');
attrs.html.should.eql('<p>Can read this</p>');
});
});
});
});

View File

@ -9,7 +9,9 @@ describe('Unit: canary/utils/serializers/output/utils/url', function () {
beforeEach(function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('getUrlByResourceId');
sinon.stub(urlUtils, 'urlFor').returns('urlFor');
sinon.stub(urlUtils, 'relativeToAbsolute').returns('relativeToAbsolute');
sinon.stub(urlUtils, 'htmlRelativeToAbsolute').returns({html: sinon.stub()});
sinon.stub(urlUtils, 'mobiledocRelativeToAbsolute').returns({});
});
afterEach(function () {
@ -28,23 +30,32 @@ describe('Unit: canary/utils/serializers/output/utils/url', function () {
it('meta & models & relations', function () {
const post = pageModel(testUtils.DataGenerator.forKnex.createPost({
id: 'id1',
feature_image: 'value'
mobiledoc: '{}',
html: 'html',
custom_excerpt: 'customExcerpt',
codeinjection_head: 'codeinjectionHead',
codeinjection_foot: 'codeinjectionFoot',
feature_image: 'featureImage',
og_image: 'ogImage',
twitter_image: 'twitterImage',
canonical_url: 'canonicalUrl'
}));
urlUtil.forPost(post.id, post, {options: {}});
post.hasOwnProperty('url').should.be.true();
urlUtils.urlFor.callCount.should.eql(2);
urlUtils.urlFor.getCall(0).args.should.eql(['image', {image: 'value'}, true]);
urlUtils.urlFor.getCall(1).args.should.eql(['home', true]);
// feature_image, og_image, twitter_image, canonical_url
urlUtils.relativeToAbsolute.callCount.should.eql(4);
urlUtils.htmlRelativeToAbsolute.callCount.should.eql(1);
// mobiledoc
urlUtils.mobiledocRelativeToAbsolute.callCount.should.eql(1);
// html, codeinjection_head, codeinjection_foot
urlUtils.htmlRelativeToAbsolute.callCount.should.eql(3);
urlUtils.htmlRelativeToAbsolute.getCall(0).args.should.eql([
'## markdown',
'urlFor',
'getUrlByResourceId',
{assetsOnly: true}
'html',
'getUrlByResourceId'
]);
urlService.getUrlByResourceId.callCount.should.eql(1);

View File

@ -13,45 +13,5 @@ describe('Unit: v2/utils/serializers/input/utils/url', function () {
afterEach(function () {
sinon.restore();
});
it('should transform canonical_url when protocol and domain match', function () {
const attrs = {
canonical_url: 'https://blogurl.com/hello-world'
};
url.forPost(attrs, {});
should.equal(attrs.canonical_url, '/hello-world');
});
it('should transform canonical_url when protocol and domain match with backslash in the end', function () {
const attrs = {
canonical_url: 'https://blogurl.com/hello-world/'
};
url.forPost(attrs, {});
should.equal(attrs.canonical_url, '/hello-world/');
});
it('should not transform canonical_url when different domains', function () {
const attrs = {
canonical_url: 'http://ghost.org/no-transform'
};
url.forPost(attrs, {});
should.equal(attrs.canonical_url, 'http://ghost.org/no-transform');
});
it('should not transform canonical_url when different protocols', function () {
const attrs = {
canonical_url: 'http://blogurl.com/no-transform'
};
url.forPost(attrs, {});
should.equal(attrs.canonical_url, 'http://blogurl.com/no-transform');
});
});
});

View File

@ -7,7 +7,7 @@ const {UnauthorizedError} = require('../../../../server/lib/common/errors');
const sessionController = require('../../../../server/api/v2/session');
const sessionServiceMiddleware = require('../../../../server/services/auth/session/middleware');
describe('Session controller', function () {
describe('v2 Session controller', function () {
before(function () {
models.init();
});

View File

@ -221,174 +221,6 @@ describe('Unit: v2/utils/serializers/input/posts', function () {
});
describe('edit', function () {
describe('Ensure relative urls are returned for standard image urls', function () {
describe('no subdir', function () {
let sandbox;
after(function () {
sandbox.restore();
});
before(function () {
sandbox = sinon.createSandbox();
urlUtils.stubUrlUtils({url: 'https://mysite.com'}, sandbox);
});
it('when mobiledoc contains an absolute URL to image', function () {
const apiConfig = {};
const frame = {
options: {
context: {
user: 0,
api_key: {
id: 1,
type: 'content'
}
}
},
data: {
posts: [
{
id: 'id1',
mobiledoc: '{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"https://mysite.com/content/images/2019/02/image.jpg"}]]}'
}
]
}
};
serializers.input.posts.edit(apiConfig, frame);
let postData = frame.data.posts[0];
postData.mobiledoc.should.equal('{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"/content/images/2019/02/image.jpg"}]]}');
});
it('when mobiledoc contains multiple absolute URLs to images with different protocols', function () {
const apiConfig = {};
const frame = {
options: {
context: {
user: 0,
api_key: {
id: 1,
type: 'content'
}
}
},
data: {
posts: [
{
id: 'id1',
mobiledoc: '{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"https://mysite.com/content/images/2019/02/image.jpg"}],["image",{"src":"http://mysite.com/content/images/2019/02/image.png"}]]'
}
]
}
};
serializers.input.posts.edit(apiConfig, frame);
let postData = frame.data.posts[0];
postData.mobiledoc.should.equal('{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"/content/images/2019/02/image.jpg"}],["image",{"src":"/content/images/2019/02/image.png"}]]');
});
it('when blog url is without subdir', function () {
const apiConfig = {};
const frame = {
options: {
context: {
user: 0,
api_key: {
id: 1,
type: 'content'
}
},
withRelated: ['tags', 'authors']
},
data: {
posts: [
{
id: 'id1',
feature_image: 'https://mysite.com/content/images/image.jpg',
og_image: 'https://mysite.com/mycustomstorage/images/image.jpg',
twitter_image: 'https://mysite.com/blog/content/images/image.jpg',
tags: [{
id: 'id3',
feature_image: 'http://mysite.com/content/images/image.jpg'
}],
authors: [{
id: 'id4',
name: 'Ghosty',
profile_image: 'https://somestorage.com/blog/images/image.jpg'
}]
}
]
}
};
serializers.input.posts.edit(apiConfig, frame);
let postData = frame.data.posts[0];
postData.feature_image.should.eql('/content/images/image.jpg');
postData.og_image.should.eql('https://mysite.com/mycustomstorage/images/image.jpg');
postData.twitter_image.should.eql('https://mysite.com/blog/content/images/image.jpg');
postData.tags[0].feature_image.should.eql('/content/images/image.jpg');
postData.authors[0].profile_image.should.eql('https://somestorage.com/blog/images/image.jpg');
});
});
describe('with subdir', function () {
let sandbox;
after(function () {
sandbox.restore();
});
before(function () {
sandbox = sinon.createSandbox();
urlUtils.stubUrlUtils({url: 'https://mysite.com/blog'}, sandbox);
});
it('when blog url is with subdir', function () {
const apiConfig = {};
const frame = {
options: {
context: {
user: 0,
api_key: {
id: 1,
type: 'content'
}
},
withRelated: ['tags', 'authors']
},
data: {
posts: [
{
id: 'id1',
feature_image: 'https://mysite.com/blog/content/images/image.jpg',
og_image: 'https://mysite.com/content/images/image.jpg',
twitter_image: 'https://mysite.com/mycustomstorage/images/image.jpg',
tags: [{
id: 'id3',
feature_image: 'http://mysite.com/blog/mycustomstorage/content/images/image.jpg'
}],
authors: [{
id: 'id4',
name: 'Ghosty',
profile_image: 'https://somestorage.com/blog/content/images/image.jpg'
}]
}
]
}
};
serializers.input.posts.edit(apiConfig, frame);
let postData = frame.data.posts[0];
postData.feature_image.should.eql('/blog/content/images/image.jpg');
postData.og_image.should.eql('https://mysite.com/content/images/image.jpg');
postData.twitter_image.should.eql('https://mysite.com/mycustomstorage/images/image.jpg');
postData.tags[0].feature_image.should.eql('http://mysite.com/blog/mycustomstorage/content/images/image.jpg');
postData.authors[0].profile_image.should.eql('https://somestorage.com/blog/content/images/image.jpg');
});
});
});
describe('Ensure html to mobiledoc conversion', function () {
it('no transformation when no html source option provided', function () {
const apiConfig = {};

View File

@ -0,0 +1,136 @@
const should = require('should');
const sinon = require('sinon');
const labs = require('../../../../../../../../server/services/labs');
const members = require('../../../../../../../../server/api/v2/utils/serializers/output/utils/members');
describe('Unit: v2/utils/serializers/output/utils/members', function () {
describe('for post', function () {
it('does not modify attributes when members is disabled', function () {
const attrs = {
plaintext: 'no touching',
html: '<p>I am here to stay</p>'
};
const frame = {
options: {}
};
members.forPost(attrs, frame);
attrs.plaintext.should.eql('no touching');
});
describe('labs.members enabled', function () {
before(function () {
sinon.stub(labs, 'isSet').returns(true);
});
it('should NOT hide content attributes when visibility is public', function () {
const attrs = {
visibility: 'public',
plaintext: 'no touching',
html: '<p>I am here to stay</p>'
};
const frame = {
original: {
context: {}
}
};
members.forPost(attrs, frame);
attrs.plaintext.should.eql('no touching');
});
it('should hide content attributes when visibility is "members"', function () {
const attrs = {
visibility: 'members',
plaintext: 'no touching. secret stuff',
html: '<p>I am here to stay</p>'
};
const frame = {
original: {
context: {}
}
};
members.forPost(attrs, frame);
attrs.plaintext.should.eql('');
attrs.html.should.eql('');
});
it('should NOT hide content attributes when visibility is "members" and member is present', function () {
const attrs = {
visibility: 'members',
plaintext: 'I see dead people',
html: '<p>What\'s the matter?</p>'
};
const frame = {
original: {
context: {
member: {}
}
}
};
members.forPost(attrs, frame);
attrs.plaintext.should.eql('I see dead people');
attrs.html.should.eql('<p>What\'s the matter?</p>');
});
it('should hide content attributes when visibility is "paid" and member has no subscription', function () {
const attrs = {
visibility: 'paid',
plaintext: 'I see dead people',
html: '<p>What\'s the matter?</p>'
};
const frame = {
original: {
context: {
member: {
stripe: {
subscriptions: []
}
}
}
}
};
members.forPost(attrs, frame);
attrs.plaintext.should.eql('');
attrs.html.should.eql('');
});
it('should NOT hide content attributes when visibility is "paid" and member has a subscription', function () {
const attrs = {
visibility: 'paid',
plaintext: 'Secret paid content',
html: '<p>Can read this</p>'
};
const frame = {
original: {
context: {
member: {
stripe: {
subscriptions: ['I pay money dollaz']
}
}
}
}
};
members.forPost(attrs, frame);
attrs.plaintext.should.eql('Secret paid content');
attrs.html.should.eql('<p>Can read this</p>');
});
});
});
});

View File

@ -9,7 +9,9 @@ describe('Unit: v2/utils/serializers/output/utils/url', function () {
beforeEach(function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('getUrlByResourceId');
sinon.stub(urlUtils, 'urlFor').returns('urlFor');
sinon.stub(urlUtils, 'relativeToAbsolute').returns('relativeToAbsolute');
sinon.stub(urlUtils, 'htmlRelativeToAbsolute').returns({html: sinon.stub()});
sinon.stub(urlUtils, 'mobiledocRelativeToAbsolute').returns({});
});
afterEach(function () {
@ -28,21 +30,31 @@ describe('Unit: v2/utils/serializers/output/utils/url', function () {
it('meta & models & relations', function () {
const post = pageModel(testUtils.DataGenerator.forKnex.createPost({
id: 'id1',
feature_image: 'value'
mobiledoc: '{}',
html: 'html',
custom_excerpt: 'customExcerpt',
codeinjection_head: 'codeinjectionHead',
codeinjection_foot: 'codeinjectionFoot',
feature_image: 'featureImage',
og_image: 'ogImage',
twitter_image: 'twitterImage',
canonical_url: 'canonicalUrl'
}));
urlUtil.forPost(post.id, post, {options: {}});
post.hasOwnProperty('url').should.be.true();
urlUtils.urlFor.callCount.should.eql(2);
urlUtils.urlFor.getCall(0).args.should.eql(['image', {image: 'value'}, true]);
urlUtils.urlFor.getCall(1).args.should.eql(['home', true]);
// feature_image, og_image, twitter_image, canonical_url
urlUtils.relativeToAbsolute.callCount.should.eql(4);
urlUtils.htmlRelativeToAbsolute.callCount.should.eql(1);
// mobiledoc
urlUtils.mobiledocRelativeToAbsolute.callCount.should.eql(1);
// html, codeinjection_head, codeinjection_foot
urlUtils.htmlRelativeToAbsolute.callCount.should.eql(3);
urlUtils.htmlRelativeToAbsolute.getCall(0).args.should.eql([
'## markdown',
'urlFor',
'html',
'getUrlByResourceId',
{assetsOnly: true}
]);

View File

@ -7,7 +7,7 @@ const {UnauthorizedError} = require('../../../../server/lib/common/errors');
const sessionController = require('../../../../server/api/canary/session');
const sessionServiceMiddleware = require('../../../../server/services/auth/session/middleware');
describe('Session controller', function () {
describe('v3 Session controller', function () {
before(function () {
models.init();
});

View File

@ -1,7 +1,7 @@
const should = require('should');
const serializers = require('../../../../../../../server/api/v2/utils/serializers');
describe('Unit: v2/utils/serializers/input/pages', function () {
describe('Unit: v3/utils/serializers/input/pages', function () {
describe('browse', function () {
it('default', function () {
const apiConfig = {};

View File

@ -3,7 +3,7 @@ const sinon = require('sinon');
const serializers = require('../../../../../../../server/api/v2/utils/serializers');
const urlUtils = require('../../../../../../utils/urlUtils');
describe('Unit: v2/utils/serializers/input/posts', function () {
describe('Unit: v3/utils/serializers/input/posts', function () {
describe('browse', function () {
it('default', function () {
const apiConfig = {};
@ -221,174 +221,6 @@ describe('Unit: v2/utils/serializers/input/posts', function () {
});
describe('edit', function () {
describe('Ensure relative urls are returned for standard image urls', function () {
describe('no subdir', function () {
let sandbox;
after(function () {
sandbox.restore();
});
before(function () {
sandbox = sinon.createSandbox();
urlUtils.stubUrlUtils({url: 'https://mysite.com'}, sandbox);
});
it('when mobiledoc contains an absolute URL to image', function () {
const apiConfig = {};
const frame = {
options: {
context: {
user: 0,
api_key: {
id: 1,
type: 'content'
}
}
},
data: {
posts: [
{
id: 'id1',
mobiledoc: '{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"https://mysite.com/content/images/2019/02/image.jpg"}]]}'
}
]
}
};
serializers.input.posts.edit(apiConfig, frame);
let postData = frame.data.posts[0];
postData.mobiledoc.should.equal('{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"/content/images/2019/02/image.jpg"}]]}');
});
it('when mobiledoc contains multiple absolute URLs to images with different protocols', function () {
const apiConfig = {};
const frame = {
options: {
context: {
user: 0,
api_key: {
id: 1,
type: 'content'
}
}
},
data: {
posts: [
{
id: 'id1',
mobiledoc: '{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"https://mysite.com/content/images/2019/02/image.jpg"}],["image",{"src":"http://mysite.com/content/images/2019/02/image.png"}]]'
}
]
}
};
serializers.input.posts.edit(apiConfig, frame);
let postData = frame.data.posts[0];
postData.mobiledoc.should.equal('{"version":"0.3.1","atoms":[],"cards":[["image",{"src":"/content/images/2019/02/image.jpg"}],["image",{"src":"/content/images/2019/02/image.png"}]]');
});
it('when blog url is without subdir', function () {
const apiConfig = {};
const frame = {
options: {
context: {
user: 0,
api_key: {
id: 1,
type: 'content'
}
},
withRelated: ['tags', 'authors']
},
data: {
posts: [
{
id: 'id1',
feature_image: 'https://mysite.com/content/images/image.jpg',
og_image: 'https://mysite.com/mycustomstorage/images/image.jpg',
twitter_image: 'https://mysite.com/blog/content/images/image.jpg',
tags: [{
id: 'id3',
feature_image: 'http://mysite.com/content/images/image.jpg'
}],
authors: [{
id: 'id4',
name: 'Ghosty',
profile_image: 'https://somestorage.com/blog/images/image.jpg'
}]
}
]
}
};
serializers.input.posts.edit(apiConfig, frame);
let postData = frame.data.posts[0];
postData.feature_image.should.eql('/content/images/image.jpg');
postData.og_image.should.eql('https://mysite.com/mycustomstorage/images/image.jpg');
postData.twitter_image.should.eql('https://mysite.com/blog/content/images/image.jpg');
postData.tags[0].feature_image.should.eql('/content/images/image.jpg');
postData.authors[0].profile_image.should.eql('https://somestorage.com/blog/images/image.jpg');
});
});
describe('with subdir', function () {
let sandbox;
after(function () {
sandbox.restore();
});
before(function () {
sandbox = sinon.createSandbox();
urlUtils.stubUrlUtils({url: 'https://mysite.com/blog'}, sandbox);
});
it('when blog url is with subdir', function () {
const apiConfig = {};
const frame = {
options: {
context: {
user: 0,
api_key: {
id: 1,
type: 'content'
}
},
withRelated: ['tags', 'authors']
},
data: {
posts: [
{
id: 'id1',
feature_image: 'https://mysite.com/blog/content/images/image.jpg',
og_image: 'https://mysite.com/content/images/image.jpg',
twitter_image: 'https://mysite.com/mycustomstorage/images/image.jpg',
tags: [{
id: 'id3',
feature_image: 'http://mysite.com/blog/mycustomstorage/content/images/image.jpg'
}],
authors: [{
id: 'id4',
name: 'Ghosty',
profile_image: 'https://somestorage.com/blog/content/images/image.jpg'
}]
}
]
}
};
serializers.input.posts.edit(apiConfig, frame);
let postData = frame.data.posts[0];
postData.feature_image.should.eql('/blog/content/images/image.jpg');
postData.og_image.should.eql('https://mysite.com/content/images/image.jpg');
postData.twitter_image.should.eql('https://mysite.com/mycustomstorage/images/image.jpg');
postData.tags[0].feature_image.should.eql('http://mysite.com/blog/mycustomstorage/content/images/image.jpg');
postData.authors[0].profile_image.should.eql('https://somestorage.com/blog/content/images/image.jpg');
});
});
});
describe('Ensure html to mobiledoc conversion', function () {
it('no transformation when no html source option provided', function () {
const apiConfig = {};

View File

@ -3,13 +3,15 @@ const sinon = require('sinon');
const testUtils = require('../../../../../../../utils');
const urlService = require('../../../../../../../../frontend/services/url');
const urlUtils = require('../../../../../../../../server/lib/url-utils');
const urlUtil = require('../../../../../../../../server/api/canary/utils/serializers/output/utils/url');
const urlUtil = require('../../../../../../../../server/api/v2/utils/serializers/output/utils/url');
describe('Unit: v3/utils/serializers/output/utils/url', function () {
beforeEach(function () {
sinon.stub(urlService, 'getUrlByResourceId').returns('getUrlByResourceId');
sinon.stub(urlUtils, 'urlFor').returns('urlFor');
sinon.stub(urlUtils, 'relativeToAbsolute').returns('relativeToAbsolute');
sinon.stub(urlUtils, 'htmlRelativeToAbsolute').returns({html: sinon.stub()});
sinon.stub(urlUtils, 'mobiledocRelativeToAbsolute').returns({});
});
afterEach(function () {
@ -28,21 +30,31 @@ describe('Unit: v3/utils/serializers/output/utils/url', function () {
it('meta & models & relations', function () {
const post = pageModel(testUtils.DataGenerator.forKnex.createPost({
id: 'id1',
feature_image: 'value'
mobiledoc: '{}',
html: 'html',
custom_excerpt: 'customExcerpt',
codeinjection_head: 'codeinjectionHead',
codeinjection_foot: 'codeinjectionFoot',
feature_image: 'featureImage',
og_image: 'ogImage',
twitter_image: 'twitterImage',
canonical_url: 'canonicalUrl'
}));
urlUtil.forPost(post.id, post, {options: {}});
post.hasOwnProperty('url').should.be.true();
urlUtils.urlFor.callCount.should.eql(2);
urlUtils.urlFor.getCall(0).args.should.eql(['image', {image: 'value'}, true]);
urlUtils.urlFor.getCall(1).args.should.eql(['home', true]);
// feature_image, og_image, twitter_image, canonical_url
urlUtils.relativeToAbsolute.callCount.should.eql(4);
urlUtils.htmlRelativeToAbsolute.callCount.should.eql(1);
// mobiledoc
urlUtils.mobiledocRelativeToAbsolute.callCount.should.eql(1);
// html, codeinjection_head, codeinjection_foot
urlUtils.htmlRelativeToAbsolute.callCount.should.eql(3);
urlUtils.htmlRelativeToAbsolute.getCall(0).args.should.eql([
'## markdown',
'urlFor',
'html',
'getUrlByResourceId',
{assetsOnly: true}
]);

View File

@ -19,7 +19,7 @@ var should = require('should'),
*/
describe('DB version integrity', function () {
// Only these variables should need updating
const currentSchemaHash = '6d99c4ed8317240d392eb2046df10368';
const currentSchemaHash = '4b68ed24fc3aee70a25de6238663d0b8';
const currentFixturesHash = 'a7152a9f4a59d30e0ccf7785fd6a8cc9';
// If this test is failing, then it is likely a change has been made that requires a DB version bump,

View File

@ -0,0 +1,238 @@
const should = require('should');
const card = require('../../../../../server/lib/mobiledoc/cards/bookmark');
const SimpleDom = require('simple-dom');
const serializer = new SimpleDom.HTMLSerializer(SimpleDom.voidMap);
describe('Bookmark card', function () {
it('renders', function () {
let opts = {
env: {dom: new SimpleDom.Document()},
payload: {
metadata: {
url: 'http://example.com',
title: 'Title',
description: 'Description',
icon: 'http://example.com/icon.png',
thumbnail: 'http://exampple.com/thumbnail.png',
author: 'Author',
publisher: 'Publisher'
},
caption: 'Caption'
}
};
serializer.serialize(card.render(opts))
.should.equal('<figure class="kg-card kg-bookmark-card kg-card-hascaption"><a class="kg-bookmark-container" href="http://example.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">Title</div><div class="kg-bookmark-description">Description</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="http://example.com/icon.png"><span class="kg-bookmark-author">Author</span><span class="kg-bookmark-publisher">Publisher</span></div></div><div class="kg-bookmark-thumbnail"><img src="http://exampple.com/thumbnail.png"></div></a><figcaption>Caption</figcaption></figure>');
});
it('skips icon when missing', function () {
let opts = {
env: {dom: new SimpleDom.Document()},
payload: {
metadata: {
url: 'http://example.com',
title: 'Title',
description: 'Description',
icon: null,
thumbnail: 'http://exampple.com/thumbnail.png',
author: 'Author',
publisher: 'Publisher'
},
caption: 'Caption'
}
};
serializer.serialize(card.render(opts))
.should.equal('<figure class="kg-card kg-bookmark-card kg-card-hascaption"><a class="kg-bookmark-container" href="http://example.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">Title</div><div class="kg-bookmark-description">Description</div><div class="kg-bookmark-metadata"><span class="kg-bookmark-author">Author</span><span class="kg-bookmark-publisher">Publisher</span></div></div><div class="kg-bookmark-thumbnail"><img src="http://exampple.com/thumbnail.png"></div></a><figcaption>Caption</figcaption></figure>');
});
it('skips thumbnail when missing', function () {
let opts = {
env: {dom: new SimpleDom.Document()},
payload: {
metadata: {
url: 'http://example.com',
title: 'Title',
description: 'Description',
icon: 'http://example.com/icon.png',
thumbnail: null,
author: 'Author',
publisher: 'Publisher'
},
caption: 'Caption'
}
};
serializer.serialize(card.render(opts))
.should.equal('<figure class="kg-card kg-bookmark-card kg-card-hascaption"><a class="kg-bookmark-container" href="http://example.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">Title</div><div class="kg-bookmark-description">Description</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="http://example.com/icon.png"><span class="kg-bookmark-author">Author</span><span class="kg-bookmark-publisher">Publisher</span></div></div></a><figcaption>Caption</figcaption></figure>');
});
it('skips author when missing', function () {
let opts = {
env: {dom: new SimpleDom.Document()},
payload: {
metadata: {
url: 'http://example.com',
title: 'Title',
description: 'Description',
icon: 'http://example.com/icon.png',
thumbnail: 'http://exampple.com/thumbnail.png',
author: null,
publisher: 'Publisher'
},
caption: 'Caption'
}
};
serializer.serialize(card.render(opts))
.should.equal('<figure class="kg-card kg-bookmark-card kg-card-hascaption"><a class="kg-bookmark-container" href="http://example.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">Title</div><div class="kg-bookmark-description">Description</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="http://example.com/icon.png"><span class="kg-bookmark-publisher">Publisher</span></div></div><div class="kg-bookmark-thumbnail"><img src="http://exampple.com/thumbnail.png"></div></a><figcaption>Caption</figcaption></figure>');
});
it('skips publisher when missing', function () {
let opts = {
env: {dom: new SimpleDom.Document()},
payload: {
metadata: {
url: 'http://example.com',
title: 'Title',
description: 'Description',
icon: 'http://example.com/icon.png',
thumbnail: 'http://exampple.com/thumbnail.png',
author: 'Author',
publisher: null
},
caption: 'Caption'
}
};
serializer.serialize(card.render(opts))
.should.equal('<figure class="kg-card kg-bookmark-card kg-card-hascaption"><a class="kg-bookmark-container" href="http://example.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">Title</div><div class="kg-bookmark-description">Description</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="http://example.com/icon.png"><span class="kg-bookmark-author">Author</span></div></div><div class="kg-bookmark-thumbnail"><img src="http://exampple.com/thumbnail.png"></div></a><figcaption>Caption</figcaption></figure>');
});
it('skips caption when missing', function () {
let opts = {
env: {dom: new SimpleDom.Document()},
payload: {
metadata: {
url: 'http://example.com',
title: 'Title',
description: 'Description',
icon: 'http://example.com/icon.png',
thumbnail: 'http://exampple.com/thumbnail.png',
author: 'Author',
publisher: 'Publisher'
},
caption: ''
}
};
serializer.serialize(card.render(opts))
.should.equal('<figure class="kg-card kg-bookmark-card"><a class="kg-bookmark-container" href="http://example.com"><div class="kg-bookmark-content"><div class="kg-bookmark-title">Title</div><div class="kg-bookmark-description">Description</div><div class="kg-bookmark-metadata"><img class="kg-bookmark-icon" src="http://example.com/icon.png"><span class="kg-bookmark-author">Author</span><span class="kg-bookmark-publisher">Publisher</span></div></div><div class="kg-bookmark-thumbnail"><img src="http://exampple.com/thumbnail.png"></div></a></figure>');
});
it('renders nothing when payload is undefined', function () {
let opts = {
env: {dom: new SimpleDom.Document()},
payload: {
metadata: undefined
}
};
serializer.serialize(card.render(opts))
.should.equal('');
});
it('renders nothing when payload metadata is empty', function () {
let opts = {
env: {dom: new SimpleDom.Document()},
payload: {
metadata: {}
}
};
serializer.serialize(card.render(opts))
.should.equal('');
});
it('renders nothing when url is missing', function () {
let opts = {
env: {dom: new SimpleDom.Document()},
payload: {
metadata: {
url: null,
title: 'Test bookmark',
description: 'This is just a test'
}
}
};
serializer.serialize(card.render(opts))
.should.equal('');
});
it('renders nothing when title is missing', function () {
let opts = {
env: {dom: new SimpleDom.Document()},
payload: {
metadata: {
url: 'http://example.com',
title: null,
description: 'This is just a test'
}
}
};
serializer.serialize(card.render(opts))
.should.equal('');
});
it('renders nothing when description is missing', function () {
let opts = {
env: {dom: new SimpleDom.Document()},
payload: {
metadata: {
url: 'http://example.com',
title: 'Test bookmark',
description: null
}
}
};
serializer.serialize(card.render(opts))
.should.equal('');
});
it('transforms urls absolute to relative', function () {
let payload = {
metadata: {
url: 'http://127.0.0.1:2369/post'
},
caption: 'A link to <a href="http://127.0.0.1:2369/post">an internal post</a>'
};
const transformed = card.absoluteToRelative(payload, {});
transformed.metadata.url
.should.equal('/post');
transformed.caption
.should.equal('A link to <a href="/post">an internal post</a>');
});
it('transforms urls relative to absolute', function () {
let payload = {
metadata: {
url: '/post'
},
caption: 'A link to <a href="/post">an internal post</a>'
};
const transformed = card.relativeToAbsolute(payload, {});
transformed.metadata.url
.should.equal('http://127.0.0.1:2369/post');
transformed.caption
.should.equal('A link to <a href="http://127.0.0.1:2369/post">an internal post</a>');
});
});

View File

@ -58,4 +58,26 @@ describe('Code card', function () {
serializer.serialize(card.render(opts)).should.match('<figure class="kg-card kg-code-card"><pre><code class="language-html">&lt;p&gt;Test&lt;/p&gt;</code></pre><figcaption>Some <strong>HTML</strong></figcaption></figure>');
});
it('transforms urls absolute to relative', function () {
let payload = {
caption: 'A link to <a href="http://127.0.0.1:2369/post">an internal post</a>'
};
const transformed = card.absoluteToRelative(payload, {});
transformed.caption
.should.equal('A link to <a href="/post">an internal post</a>');
});
it('transforms urls relative to absolute', function () {
let payload = {
caption: 'A link to <a href="/post">an internal post</a>'
};
const transformed = card.relativeToAbsolute(payload, {});
transformed.caption
.should.equal('A link to <a href="http://127.0.0.1:2369/post">an internal post</a>');
});
});

View File

@ -69,4 +69,26 @@ describe('Embed card', function () {
serializer.serialize(card.render(opts)).should.match('<figure class="kg-card kg-embed-card kg-card-hascaption">Testing<figcaption><strong>Caption</strong></figcaption></figure>');
});
it('transforms urls absolute to relative', function () {
let payload = {
caption: 'A link to <a href="http://127.0.0.1:2369/post">an internal post</a>'
};
const transformed = card.absoluteToRelative(payload, {});
transformed.caption
.should.equal('A link to <a href="/post">an internal post</a>');
});
it('transforms urls relative to absolute', function () {
let payload = {
caption: 'A link to <a href="/post">an internal post</a>'
};
const transformed = card.relativeToAbsolute(payload, {});
transformed.caption
.should.equal('A link to <a href="http://127.0.0.1:2369/post">an internal post</a>');
});
});

View File

@ -138,4 +138,74 @@ describe('Gallery card', function () {
serializer.serialize(card.render(opts)).should.eql('<figure class="kg-card kg-gallery-card kg-width-wide kg-card-hascaption"><div class="kg-gallery-container"><div class="kg-gallery-row"><div class="kg-gallery-image"><img src="/content/images/2018/08/NatGeo01-9.jpg" width="3200" height="1600"></div><div class="kg-gallery-image"><img src="/content/images/2018/08/NatGeo03-6.jpg" width="3200" height="1600"></div></div></div><figcaption>Test caption</figcaption></figure>');
});
it('transforms urls absolute to relative', function () {
let payload = {
images: [
{
row: 0,
fileName: 'NatGeo01.jpg',
src: 'http://127.0.0.1:2369/content/images/2018/08/NatGeo01-9.jpg',
width: 3200,
height: 1600,
caption: 'A link to <a href="http://127.0.0.1:2369/post">an internal post</a>'
},
{
row: 0,
fileName: 'NatGeo02.jpg',
src: 'http://127.0.0.1:2369/content/images/2018/08/NatGeo02-10.jpg',
caption: 'A link to <a href="http://127.0.0.1:2369/post">an internal post</a>'
}
],
caption: 'A link to <a href="http://127.0.0.1:2369/post">an internal post</a>'
};
const transformed = card.absoluteToRelative(payload, {});
transformed.images[0].src
.should.equal('/content/images/2018/08/NatGeo01-9.jpg');
transformed.images[0].caption
.should.equal('A link to <a href="/post">an internal post</a>');
transformed.images[1].src
.should.equal('/content/images/2018/08/NatGeo02-10.jpg');
transformed.images[1].caption
.should.equal('A link to <a href="/post">an internal post</a>');
transformed.caption
.should.equal('A link to <a href="/post">an internal post</a>');
});
it('transforms urls relative to absolute', function () {
let payload = {
images: [
{
row: 0,
fileName: 'NatGeo01.jpg',
src: '/content/images/2018/08/NatGeo01-9.jpg',
width: 3200,
height: 1600
},
{
row: 0,
fileName: 'NatGeo02.jpg',
src: '/content/images/2018/08/NatGeo02-10.jpg'
}
],
caption: 'A link to <a href="/post">an internal post</a>'
};
const transformed = card.relativeToAbsolute(payload, {});
transformed.images[0].src
.should.equal('http://127.0.0.1:2369/content/images/2018/08/NatGeo01-9.jpg');
transformed.images[1].src
.should.equal('http://127.0.0.1:2369/content/images/2018/08/NatGeo02-10.jpg');
transformed.caption
.should.equal('A link to <a href="http://127.0.0.1:2369/post">an internal post</a>');
});
});

View File

@ -55,4 +55,26 @@ describe('HTML card', function () {
serializer.serialize(card.render(opts)).should.eql('');
});
it('transforms urls absolute to relative', function () {
let payload = {
html: 'A link to <a href="http://127.0.0.1:2369/post">an internal post</a>'
};
const transformed = card.absoluteToRelative(payload, {});
transformed.html
.should.equal('A link to <a href="/post">an internal post</a>');
});
it('transforms urls relative to absolute', function () {
let payload = {
html: 'A link to <a href="/post">an internal post</a>'
};
const transformed = card.relativeToAbsolute(payload, {});
transformed.html
.should.equal('A link to <a href="http://127.0.0.1:2369/post">an internal post</a>');
});
});

View File

@ -116,4 +116,34 @@ describe('Image card', function () {
serializer.serialize(card.render(opts)).should.eql('<figure class="kg-card kg-image-card kg-width-full"><img src="https://www.ghost.org/image.png" class="kg-image"></figure>');
});
});
it('transforms urls absolute to relative', function () {
let payload = {
src: 'http://127.0.0.1:2369/content/images/2018/08/NatGeo01-9.jpg',
caption: 'A link to <a href="http://127.0.0.1:2369/post">an internal post</a>'
};
const transformed = card.absoluteToRelative(payload, {});
transformed.src
.should.equal('/content/images/2018/08/NatGeo01-9.jpg');
transformed.caption
.should.equal('A link to <a href="/post">an internal post</a>');
});
it('transforms urls relative to absolute', function () {
let payload = {
src: '/content/images/2018/08/NatGeo01-9.jpg',
caption: 'A link to <a href="/post">an internal post</a>'
};
const transformed = card.relativeToAbsolute(payload, {});
transformed.src
.should.equal('http://127.0.0.1:2369/content/images/2018/08/NatGeo01-9.jpg');
transformed.caption
.should.equal('A link to <a href="http://127.0.0.1:2369/post">an internal post</a>');
});
});

View File

@ -4,44 +4,64 @@ const SimpleDom = require('simple-dom');
const serializer = new SimpleDom.HTMLSerializer(SimpleDom.voidMap);
describe('Markdown card', function () {
describe('default', function () {
it('Markdown Card renders', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: '#HEADING\r\n- list\r\n- items'
}
};
it('renders', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: '#HEADING\r\n- list\r\n- items'
}
};
serializer.serialize(card.render(opts)).should.eql('<!--kg-card-begin: markdown--><h1 id="heading">HEADING</h1>\n<ul>\n<li>list</li>\n<li>items</li>\n</ul>\n<!--kg-card-end: markdown-->');
});
serializer.serialize(card.render(opts)).should.eql('<!--kg-card-begin: markdown--><h1 id="heading">HEADING</h1>\n<ul>\n<li>list</li>\n<li>items</li>\n</ul>\n<!--kg-card-end: markdown-->');
});
it('Accepts invalid HTML in markdown', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: '#HEADING\r\n<h2>Heading 2>'
}
};
it('Accepts invalid HTML in markdown', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: '#HEADING\r\n<h2>Heading 2>'
}
};
serializer.serialize(card.render(opts)).should.eql('<!--kg-card-begin: markdown--><h1 id="heading">HEADING</h1>\n<h2>Heading 2><!--kg-card-end: markdown-->');
});
serializer.serialize(card.render(opts)).should.eql('<!--kg-card-begin: markdown--><h1 id="heading">HEADING</h1>\n<h2>Heading 2><!--kg-card-end: markdown-->');
});
it('Renders nothing when payload is undefined', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: undefined
}
};
it('Renders nothing when payload is undefined', function () {
let opts = {
env: {
dom: new SimpleDom.Document()
},
payload: {
markdown: undefined
}
};
serializer.serialize(card.render(opts)).should.eql('');
});
serializer.serialize(card.render(opts)).should.eql('');
});
it('transforms urls absolute to relative', function () {
let payload = {
markdown: 'A link to [an internal post](http://127.0.0.1:2369/post)'
};
const transformed = card.absoluteToRelative(payload, {});
transformed.markdown
.should.equal('A link to [an internal post](/post)');
});
it('transforms urls relative to absolute', function () {
let payload = {
markdown: 'A link to [an internal post](/post)'
};
const transformed = card.relativeToAbsolute(payload, {});
transformed.markdown
.should.equal('A link to [an internal post](http://127.0.0.1:2369/post)');
});
});

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