Make data-hashtag-id authoritive for #links

This adds consistency: The mention links with `@` was implemented
that way already. Instead of parsing the URL, we add some redundancy and
add another attribute: data-hashtag-id

So, what characters are valid for html attributes?
Read: https://stackoverflow.com/questions/925994/what-characters-are-allowed-in-an-html-attribute-name

Thanks to @Tirokk, who added some validations on the hahstag ids, I
think we are all set. If you try to write a hashtag with a `"` double
quotation mark for example, it gets automatically replaced with a valid
hashtag. If someone wants to send us invalid hashtag ids to the backend
directly, the regex there would filter it out.
This commit is contained in:
roschaefer 2019-09-16 22:57:57 +02:00
parent 15816dd97b
commit 431de3319f
8 changed files with 125 additions and 53 deletions

View File

@ -6,21 +6,21 @@ import { exec, build } from 'xregexp/xregexp-all.js'
// 0. Search for whole string.
// 1. Hashtag has only all unicode characters and '0-9'.
// 2. If it starts with a digit '0-9' than a unicode character has to follow.
const regX = build('^/search/hashtag/((\\pL+[\\pL0-9]*)|([0-9]+\\pL+[\\pL0-9]*))$')
const regX = build('^((\\pL+[\\pL0-9]*)|([0-9]+\\pL+[\\pL0-9]*))$')
export default function(content) {
if (!content) return []
const $ = cheerio.load(content)
// We can not search for class '.hashtag', because the classes are removed at the 'xss' middleware.
// But we have to know, which Hashtags are removed from the content as well, so we search for the 'a' html-tag.
const urls = $('a')
const ids = $('a[data-hashtag-id]')
.map((_, el) => {
return $(el).attr('href')
return $(el).attr('data-hashtag-id')
})
.get()
const hashtags = []
urls.forEach(url => {
const match = exec(decodeURI(url), regX)
ids.forEach(id => {
const match = exec(id, regX)
if (match != null) {
hashtags.push(match[1])
}

View File

@ -8,9 +8,24 @@ describe('extractHashtags', () => {
})
describe('searches through links', () => {
it('finds links with and without ".hashtag" class and extracts Hashtag names', () => {
const content =
'<p><a class="hashtag" href="/search/hashtag/Elections">#Elections</a><a href="/search/hashtag/Democracy">#Democracy</a></p>'
it('not `class="hashtag"` but `data-hashtag-id="something"` makes a link a hashtag link', () => {
const content = `
<p>
<a
class="hashtag"
data-hashtag-id="Elections"
href="/?hashtag=Elections"
>
#Elections
</a>
<a
data-hashtag-id="Democracy"
href="/?hashtag=Democracy"
>
#Democracy
</a>
</p>
`
expect(extractHashtags(content)).toEqual(['Elections', 'Democracy'])
})
@ -20,23 +35,57 @@ describe('extractHashtags', () => {
expect(extractHashtags(content)).toEqual([])
})
describe('handles links', () => {
it('ignores links with domains', () => {
const content =
'<p><a class="hashtag" href="http://localhost:3000/search/hashtag/Elections">#Elections</a><a href="/search/hashtag/Democracy">#Democracy</a></p>'
expect(extractHashtags(content)).toEqual(['Democracy'])
})
it('ignores Hashtag links with not allowed character combinations and handles `encodeURI` URLs', () => {
// Allowed are all unicode letters '\pL' and all digits '0-9'. There haveto be at least one letter in it.
const content =
'<p>Something inspirational about <a href="/search/hashtag/AbcDefXyz0123456789!*(),2" class="hashtag" target="_blank">#AbcDefXyz0123456789!*(),2</a>, <a href="/search/hashtag/0123456789" class="hashtag" target="_blank">#0123456789</a>, <a href="/search/hashtag/0123456789a" class="hashtag" target="_blank">#0123456789a</a>, <a href="/search/hashtag/AbcDefXyz0123456789" target="_blank">#AbcDefXyz0123456789</a>, and <a href="/search/hashtag/%C4%A7%CF%80%CE%B1%CE%BB" target="_blank">#ħπαλ</a>.</p>'
expect(extractHashtags(content).sort()).toEqual([
'0123456789a',
'AbcDefXyz0123456789',
'ħπαλ',
])
})
it('ignores hashtag links with not allowed character combinations', () => {
// Allowed are all unicode letters '\pL' and all digits '0-9'. There haveto be at least one letter in it.
const content = `
<p>
Something inspirational about
<a
href="/?hashtag=AbcDefXyz0123456789!*(),2"
data-hashtag-id="AbcDefXyz0123456789!*(),2"
class="hashtag"
target="_blank"
>
#AbcDefXyz0123456789!*(),2
</a>,
<a
href="/?hashtag=0123456789"
data-hashtag-id="0123456789"
class="hashtag"
target="_blank"
>
#0123456789
</a>,
<a href="?hashtag=0123456789a"
data-hashtag-id="0123456789a"
class="hashtag"
target="_blank"
>
#0123456789a
</a>,
<a
href="/?hashtag=AbcDefXyz0123456789"
data-hashtag-id="AbcDefXyz0123456789"
class="hashtag"
target="_blank"
>
#AbcDefXyz0123456789
</a>, and
<a
href="/?hashtag=%C4%A7%CF%80%CE%B1%CE%BB"
data-hashtag-id="ħπαλ"
class="hashtag"
target="_blank"
>
#ħπαλ
</a>.
</p>
`
expect(extractHashtags(content).sort()).toEqual([
'0123456789a',
'AbcDefXyz0123456789',
'ħπαλ',
])
})
describe('does not crash if', () => {

View File

@ -69,8 +69,27 @@ afterEach(async () => {
describe('hashtags', () => {
const id = 'p135'
const title = 'Two Hashtags'
const postContent =
'<p>Hey Dude, <a class="hashtag" href="/search/hashtag/Democracy">#Democracy</a> should work equal for everybody!? That seems to be the only way to have equal <a class="hashtag" href="/search/hashtag/Liberty">#Liberty</a> for everyone.</p>'
const postContent = `
<p>
Hey Dude,
<a
class="hashtag"
data-hashtag-id="Democracy"
href="/?hashtag=Democracy">
#Democracy
</a>
should work equal for everybody!? That seems to be the only way to have
equal
<a
class="hashtag"
data-hashtag-id="Liberty"
href="/?hashtag=Liberty"
>
#Liberty
</a>
for everyone.
</p>
`
const postWithHastagsQuery = gql`
query($id: ID) {
Post(id: $id) {
@ -131,8 +150,27 @@ describe('hashtags', () => {
describe('updates the Post by removing, keeping and adding one hashtag respectively', () => {
// The already existing hashtag has no class at this point.
const postContent =
'<p>Hey Dude, <a class="hashtag" href="/search/hashtag/Elections">#Elections</a> should work equal for everybody!? That seems to be the only way to have equal <a href="/search/hashtag/Liberty">#Liberty</a> for everyone.</p>'
const postContent = `
<p>
Hey Dude,
<a
class="hashtag"
data-hashtag-id="Elections"
href="?hashtag=Elections"
>
#Elections
</a>
should work equal for everybody!? That seems to be the only way to
have equal
<a
data-hashtag-id="Liberty"
href="?hashtag=Liberty"
>
#Liberty
</a>
for everyone.
</p>
`
it('only one previous Hashtag and the new Hashtag exists', async () => {
await mutate({

View File

@ -413,9 +413,9 @@ import { gql } from '../jest/helpers'
const mention2 =
'Hey <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a>, here is another notification for you!'
const hashtag1 =
'See <a class="hashtag" href="/search/hashtag/NaturphilosophieYoga">#NaturphilosophieYoga</a> can really help you!'
'See <a class="hashtag" data-hashtag-id="NaturphilosophieYoga" href="/?hashtag=NaturphilosophieYoga">#NaturphilosophieYoga</a> can really help you!'
const hashtagAndMention1 =
'The new physics of <a class="hashtag" href="/search/hashtag/QuantenFlussTheorie">#QuantenFlussTheorie</a> can explain <a class="hashtag" href="/search/hashtag/QuantumGravity">#QuantumGravity</a>! <a class="mention" data-mention-id="u1" href="/profile/u1">@peter-lustig</a> got that already. ;-)'
'The new physics of <a class="hashtag" data-hashtag-id="QuantenFlussTheorie" href="/?hashtag=QuantenFlussTheorie">#QuantenFlussTheorie</a> can explain <a class="hashtag" data-hashtag-id="QuantumGravity" href="/?hashtag=QuantumGravity">#QuantumGravity</a>! <a class="mention" data-mention-id="u1" href="/profile/u1">@peter-lustig</a> got that already. ;-)'
const createPostMutation = gql`
mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]) {
CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {

View File

@ -21,11 +21,15 @@ export default class Hashtag extends TipTapMention {
return {
...super.schema,
toDOM: node => {
// use a dummy domain because URL cannot handle relative urls
const url = new URL('/', 'http://example.org')
url.searchParams.append('hashtag', node.attrs.id)
return [
'a',
{
class: this.options.mentionClass,
href: `/search/hashtag/${encodeURI(node.attrs.id)}`,
href: `/${url.search}`,
'data-hashtag-id': node.attrs.id,
target: '_blank',
},

View File

@ -1,16 +0,0 @@
import { exec, build } from 'xregexp/xregexp-all.js'
export default async ({ store, env, route, redirect }) => {
let publicPages = env.publicPages
// only affect non public pages
if (publicPages.indexOf(route.name) >= 0) {
return true
}
const regX = build('^/search/hashtag/((\\pL+[\\pL0-9]*)|([0-9]+\\pL+[\\pL0-9]*))$')
const matchHashtag = route.fullPath ? exec(decodeURI(route.fullPath), regX) : null
if (!matchHashtag) return true
return redirect(`/?hashtag=${encodeURI(matchHashtag[1])}`)
}

View File

@ -123,7 +123,7 @@ export default {
],
router: {
middleware: ['authenticated', 'termsAndConditions', 'searchHashtag'],
middleware: ['authenticated', 'termsAndConditions'],
linkActiveClass: 'router-link-active',
linkExactActiveClass: 'router-link-exact-active',
scrollBehavior: (to, _from, savedPosition) => {

View File

@ -75,10 +75,7 @@ export default {
MasonryGridItem,
},
data() {
let { hashtag = null } = this.$route.query
if (hashtag) {
hashtag = decodeURI(hashtag)
}
const { hashtag = null } = this.$route.query
return {
posts: [],
hasMore: true,