Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Storybook #952

Merged
merged 20 commits into from Aug 6, 2019
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/package.json
Expand Up @@ -87,6 +87,7 @@
"metascraper-url": "^5.6.5",
"metascraper-video": "^5.6.3",
"metascraper-youtube": "^5.6.3",
"minimatch": "^3.0.4",
"neo4j-driver": "~1.7.5",
"neo4j-graphql-js": "^2.6.3",
"neode": "^0.2.16",
Expand Down
28 changes: 18 additions & 10 deletions backend/src/middleware/handleHtmlContent/handleContentData.spec.js
Expand Up @@ -64,7 +64,7 @@ describe('currentUser { notifications }', () => {
let post
const title = 'Mentioning Al Capone'
const content =
'Hey <a class="mention" href="/profile/you/al-capone">@al-capone</a> how do you do?'
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone">@al-capone</a> how do you do?'

beforeEach(async () => {
const createPostMutation = gql`
Expand All @@ -88,7 +88,7 @@ describe('currentUser { notifications }', () => {

it('sends you a notification', async () => {
const expectedContent =
'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
const expected = {
currentUser: {
notifications: [
Expand All @@ -108,14 +108,22 @@ describe('currentUser { notifications }', () => {
).resolves.toEqual(expected)
})

describe('who mentions me again', () => {
describe('who mentions me many times', () => {
beforeEach(async () => {
const updatedContent = `${post.content} One more mention to <a href="/profile/you" class="mention">@al-capone</a>`
// The response `post.content` contains a link but the XSSmiddleware
// should have the `mention` CSS class removed. I discovered this
// during development and thought: A feature not a bug! This way we
// can encode a re-mentioning of users when you edit your post or
// comment.
const updatedContent = `
One more mention to
<a data-mention-id="you" class="mention" href="/profile/you">
@al-capone
</a>
and again:
<a data-mention-id="you" class="mention" href="/profile/you">
@al-capone
</a>
and again
<a data-mention-id="you" class="mention" href="/profile/you">
@al-capone
</a>
`
const updatePostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!) {
UpdatePost(id: $id, content: $content, title: $title) {
Expand All @@ -136,7 +144,7 @@ describe('currentUser { notifications }', () => {

it('creates exactly one more notification', async () => {
const expectedContent =
'Hey <a href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do? One more mention to <a href="/profile/you" target="_blank">@al-capone</a>'
'<br>One more mention to<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again:<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>'
const expected = {
currentUser: {
notifications: [
Expand Down
@@ -1,20 +1,13 @@
import cheerio from 'cheerio'
const ID_REGEX = /\/profile\/([\w\-.!~*'"(),]+)/g

export default function(content) {
if (!content) return []
const $ = cheerio.load(content)
const urls = $('.mention')
let userIds = $('a.mention[data-mention-id]')
.map((_, el) => {
return $(el).attr('href')
return $(el).attr('data-mention-id')
})
.get()
const ids = []
urls.forEach(url => {
let match
while ((match = ID_REGEX.exec(url)) != null) {
ids.push(match[1])
}
})
return ids
userIds = userIds.map(id => id.trim()).filter(id => !!id)
return userIds
}
@@ -1,59 +1,30 @@
import extractMentionedUsers from './extractMentionedUsers'

const contentWithMentions =
'<p>Something inspirational about <a href="/profile/u2" class="not-a-mention" data-mention-id="bobs-id" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock" class="mention" data-mention-id="u3" target="_blank">@jenny-rostock</a>.</p>'
const contentEmptyMentions =
'<p>Something inspirational about <a href="/profile/u2" data-mention-id="" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock" class="mention" data-mention-id target="_blank">@jenny-rostock</a>.</p>'
const contentWithPlainLinks =
'<p>Something inspirational about <a class="mention" href="/profile/u2" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3" target="_blank">@jenny-rostock</a>.</p>'

describe('extractMentionedUsers', () => {
describe('content undefined', () => {
it('returns empty array', () => {
expect(extractMentionedUsers()).toEqual([])
})
})

describe('searches through links', () => {
it('ignores links without .mention class', () => {
const content =
'<p>Something inspirational about <a href="/profile/u2" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3" target="_blank">@jenny-rostock</a>.</p>'
expect(extractMentionedUsers(content)).toEqual([])
})

describe('given a link with .mention class', () => {
it('extracts ids', () => {
const content =
'<p>Something inspirational about <a href="/profile/u2" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractMentionedUsers(content)).toEqual(['u2', 'u3'])
})

describe('handles links', () => {
it('with slug and id', () => {
const content =
'<p>Something inspirational about <a href="/profile/u2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/u3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractMentionedUsers(content)).toEqual(['u2', 'u3'])
})

it('with domains', () => {
const content =
'<p>Something inspirational about <a href="http://localhost:3000/profile/u2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="http://localhost:3000//profile/u3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractMentionedUsers(content)).toEqual(['u2', 'u3'])
})

it('special characters', () => {
const content =
'<p>Something inspirational about <a href="http://localhost:3000/profile/u!*(),2/bob-der-baumeister" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="http://localhost:3000//profile/u.~-3/jenny-rostock/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractMentionedUsers(content)).toEqual(['u!*(),2', 'u.~-3'])
})
})
it('ignores links without .mention class', () => {
expect(extractMentionedUsers(contentWithPlainLinks)).toEqual([])
})

describe('does not crash if', () => {
it('`href` contains no user id', () => {
const content =
'<p>Something inspirational about <a href="/profile" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="/profile/" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractMentionedUsers(content)).toEqual([])
})
describe('given a link with .mention class and `data-mention-id` attribute ', () => {
it('extracts ids', () => {
expect(extractMentionedUsers(contentWithMentions)).toEqual(['u3'])
})

it('`href` is empty or invalid', () => {
const content =
'<p>Something inspirational about <a href="" class="mention" target="_blank">@bob-der-baumeister</a> and <a href="not-a-url" class="mention" target="_blank">@jenny-rostock</a>.</p>'
expect(extractMentionedUsers(content)).toEqual([])
})
})
it('ignores empty `data-mention-id` attributes', () => {
expect(extractMentionedUsers(contentEmptyMentions)).toEqual([])
})
})
})
63 changes: 4 additions & 59 deletions backend/src/middleware/xssMiddleware.js
Expand Up @@ -2,30 +2,16 @@ import walkRecursive from '../helpers/walkRecursive'
// import { getByDot, setByDot, getItems, replaceItems } from 'feathers-hooks-common'
import sanitizeHtml from 'sanitize-html'
// import { isEmpty, intersection } from 'lodash'
import cheerio from 'cheerio'
import linkifyHtml from 'linkifyjs/html'

const embedToAnchor = content => {
const $ = cheerio.load(content)
$('div[data-url-embed]').each((i, el) => {
const url = el.attribs['data-url-embed']
const aTag = $(`<a href="${url}" target="_blank" data-url-embed="">${url}</a>`)
$(el).replaceWith(aTag)
})
return $('body').html()
}

function clean(dirty) {
if (!dirty) {
return dirty
}

// Convert embeds to a-tags
dirty = embedToAnchor(dirty)
dirty = linkifyHtml(dirty)
dirty = sanitizeHtml(dirty, {
allowedTags: [
'iframe',
'img',
'p',
'h3',
Expand All @@ -50,78 +36,38 @@ function clean(dirty) {
a: ['href', 'class', 'target', 'data-*', 'contenteditable'],
span: ['contenteditable', 'class', 'data-*'],
img: ['src'],
iframe: ['src', 'class', 'frameborder', 'allowfullscreen'],
},
allowedIframeHostnames: ['www.youtube.com', 'player.vimeo.com'],
parser: {
lowerCaseTags: true,
},
transformTags: {
iframe: function(tagName, attribs) {
return {
tagName: 'a',
text: attribs.src,
attribs: {
href: attribs.src,
target: '_blank',
'data-url-embed': '',
},
}
},
h1: 'h3',
h2: 'h3',
h3: 'h3',
h4: 'h4',
h5: 'strong',
i: 'em',
a: function(tagName, attribs) {
a: (tagName, attribs) => {
return {
tagName: 'a',
attribs: {
href: attribs.href,
...attribs,
href: attribs.href || '',
target: '_blank',
rel: 'noopener noreferrer nofollow',
},
}
},
b: 'strong',
s: 'strike',
img: function(tagName, attribs) {
const src = attribs.src

if (!src) {
// remove broken images
return {}
}

// if (isEmpty(hook.result)) {
// const config = hook.app.get('thumbor')
// if (config && src.indexOf(config < 0)) {
// // download image
// // const ThumborUrlHelper = require('../helper/thumbor-helper')
// // const Thumbor = new ThumborUrlHelper(config.key || null, config.url || null)
// // src = Thumbor
// // .setImagePath(src)
// // .buildUrl('740x0')
// }
// }
return {
tagName: 'img',
attribs: {
// TODO: use environment variables
src: `http://localhost:3050/images?url=${src}`,
},
}
},
},
})

// remove empty html tags and duplicated linebreaks and returns
dirty = dirty
// remove all tags with "space only"
.replace(/<[a-z-]+>[\s]+<\/[a-z-]+>/gim, '')
// remove all iframes
.replace(/(<iframe(?!.*?src=(['"]).*?\2)[^>]*)(>)[^>]*\/*>/gim, '')
.replace(/[\n]{3,}/gim, '\n\n')
.replace(/(\r\n|\n\r|\r|\n)/g, '<br>$1')

Expand All @@ -144,8 +90,7 @@ const fields = ['content', 'contentExcerpt']
export default {
Mutation: async (resolve, root, args, context, info) => {
args = walkRecursive(args, fields, clean)
const result = await resolve(root, args, context, info)
return result
return resolve(root, args, context, info)
},
Query: async (resolve, root, args, context, info) => {
const result = await resolve(root, args, context, info)
Expand Down
26 changes: 26 additions & 0 deletions backend/src/schema/resolvers/embeds/findProvider.js
@@ -0,0 +1,26 @@
import fs from 'fs'
import path from 'path'
import minimatch from 'minimatch'

let oEmbedProvidersFile = fs.readFileSync(path.join(__dirname, './providers.json'), 'utf8')
// some providers allow a format parameter
// we need JSON
oEmbedProvidersFile = oEmbedProvidersFile.replace(/\{format\}/g, 'json')
const oEmbedProviders = JSON.parse(oEmbedProvidersFile)

export default function(embedUrl) {
for (const provider of oEmbedProviders) {
for (const endpoint of provider.endpoints) {
const { schemes = [], url } = endpoint
if (schemes.some(scheme => minimatch(embedUrl, scheme))) return url
}
const { hostname } = new URL(embedUrl)
if (provider.provider_url.includes(hostname)) {
const {
endpoints: [{ url }],
} = provider
return url
}
}
return null
}
35 changes: 35 additions & 0 deletions backend/src/schema/resolvers/embeds/findProvider.spec.js
@@ -0,0 +1,35 @@
import findProvider from './findProvider'

describe('Vimeo', () => {
it('matches `https://vimeo.com/showcase/2098620/video/4082288`', () => {
expect(findProvider('https://vimeo.com/showcase/2098620/video/4082288')).toEqual(
'https://vimeo.com/api/oembed.json',
)
})
})

describe('RiffReporter', () => {
it('matches `https://www.riffreporter.de/flugbegleiter-koralle/`', () => {
expect(findProvider('https://www.riffreporter.de/flugbegleiter-koralle/')).toEqual(
'https://www.riffreporter.de/service/oembed',
)
})
})

describe('Youtube', () => {
it('matches `https://www.youtube.com/watch?v=qkdXAtO40Fo`', () => {
expect(findProvider('https://www.youtube.com/watch?v=qkdXAtO40Fo')).toEqual(
'https://www.youtube.com/oembed',
)
})

it('matches `https://youtu.be/qkdXAtO40Fo`', () => {
expect(findProvider(`https://youtu.be/qkdXAtO40Fo`)).toEqual('https://www.youtube.com/oembed')
})

it('matches `https://youtu.be/qkdXAtO40Fo?t=41`', () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this test just to give the edge case of non-alphanumeric characters in the URL?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I wanted to see if youtube shareable links with a time position ?t=41 cause troubles or not.

expect(findProvider(`https://youtu.be/qkdXAtO40Fo?t=41`)).toEqual(
'https://www.youtube.com/oembed',
)
})
})