diff --git a/backend/src/middleware/hashtags/extractHashtags.js b/backend/src/middleware/hashtags/extractHashtags.js index 13f29475e..ee90102dc 100644 --- a/backend/src/middleware/hashtags/extractHashtags.js +++ b/backend/src/middleware/hashtags/extractHashtags.js @@ -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]) } diff --git a/backend/src/middleware/hashtags/extractHashtags.spec.js b/backend/src/middleware/hashtags/extractHashtags.spec.js index 0881e33a3..0a129682a 100644 --- a/backend/src/middleware/hashtags/extractHashtags.spec.js +++ b/backend/src/middleware/hashtags/extractHashtags.spec.js @@ -8,9 +8,24 @@ describe('extractHashtags', () => { }) describe('searches through links', () => { - it('finds links with and without ".hashtag" class and extracts Hashtag names', () => { - const content = - '

#Elections#Democracy

' + it('not `class="hashtag"` but `data-hashtag-id="something"` makes a link a hashtag link', () => { + const content = ` +

+ + #Elections + + + #Democracy + +

+ ` 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 = - '

#Elections#Democracy

' - 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 = - '

Something inspirational about #AbcDefXyz0123456789!*(),2, #0123456789, #0123456789a, #AbcDefXyz0123456789, and #ħπαλ.

' - 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 = ` +

+ Something inspirational about + + #AbcDefXyz0123456789!*(),2 + , + + #0123456789 + , + + #0123456789a + , + + #AbcDefXyz0123456789 + , and + + #ħπαλ + . +

+ ` + expect(extractHashtags(content).sort()).toEqual([ + '0123456789a', + 'AbcDefXyz0123456789', + 'ħπαλ', + ]) }) describe('does not crash if', () => { diff --git a/backend/src/middleware/hashtags/hashtagsMiddleware.spec.js b/backend/src/middleware/hashtags/hashtagsMiddleware.spec.js index 710382949..89f1bdd86 100644 --- a/backend/src/middleware/hashtags/hashtagsMiddleware.spec.js +++ b/backend/src/middleware/hashtags/hashtagsMiddleware.spec.js @@ -69,8 +69,27 @@ afterEach(async () => { describe('hashtags', () => { const id = 'p135' const title = 'Two Hashtags' - const postContent = - '

Hey Dude, #Democracy should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' + const postContent = ` +

+ Hey Dude, + + #Democracy + + should work equal for everybody!? That seems to be the only way to have + equal + + #Liberty + + for everyone. +

+ ` 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 = - '

Hey Dude, #Elections should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' + const postContent = ` +

+ Hey Dude, + + #Elections + + should work equal for everybody!? That seems to be the only way to + have equal + + #Liberty + + for everyone. +

+ ` it('only one previous Hashtag and the new Hashtag exists', async () => { await mutate({ diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index 48a739529..17a20a87d 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -413,9 +413,9 @@ import { gql } from '../jest/helpers' const mention2 = 'Hey @jenny-rostock, here is another notification for you!' const hashtag1 = - 'See #NaturphilosophieYoga can really help you!' + 'See #NaturphilosophieYoga can really help you!' const hashtagAndMention1 = - 'The new physics of #QuantenFlussTheorie can explain #QuantumGravity! @peter-lustig got that already. ;-)' + 'The new physics of #QuantenFlussTheorie can explain #QuantumGravity! @peter-lustig got that already. ;-)' const createPostMutation = gql` mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]) { CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { diff --git a/webapp/components/Editor/nodes/Hashtag.js b/webapp/components/Editor/nodes/Hashtag.js index bf1530ceb..495a1532f 100644 --- a/webapp/components/Editor/nodes/Hashtag.js +++ b/webapp/components/Editor/nodes/Hashtag.js @@ -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', }, diff --git a/webapp/middleware/searchHashtag.js b/webapp/middleware/searchHashtag.js deleted file mode 100644 index ed2b62de0..000000000 --- a/webapp/middleware/searchHashtag.js +++ /dev/null @@ -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])}`) -} diff --git a/webapp/nuxt.config.js b/webapp/nuxt.config.js index 5942d34e0..858294f37 100644 --- a/webapp/nuxt.config.js +++ b/webapp/nuxt.config.js @@ -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) => { diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index f1d793f1e..31e07f382 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -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,