diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index e6759e8ff..4f725ef72 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -11,7 +11,7 @@ import userMiddleware from './userMiddleware' import includedFieldsMiddleware from './includedFieldsMiddleware' import orderByMiddleware from './orderByMiddleware' import validUrlMiddleware from './validUrlMiddleware' -import notificationsMiddleware from './notificationsMiddleware' +import notificationsMiddleware from './notifications' export default schema => { let middleware = [ @@ -20,9 +20,9 @@ export default schema => { validUrlMiddleware, sluggifyMiddleware, excerptMiddleware, + notificationsMiddleware, xssMiddleware, fixImageUrlsMiddleware, - notificationsMiddleware, softDeleteMiddleware, userMiddleware, includedFieldsMiddleware, diff --git a/backend/src/middleware/notifications/extractMentions.js b/backend/src/middleware/notifications/extractMentions.js new file mode 100644 index 000000000..f2b28444f --- /dev/null +++ b/backend/src/middleware/notifications/extractMentions.js @@ -0,0 +1,17 @@ +import cheerio from 'cheerio' +const ID_REGEX = /\/profile\/([\w\-.!~*'"(),]+)/g + +export default function (content) { + const $ = cheerio.load(content) + const urls = $('.mention').map((_, el) => { + return $(el).attr('href') + }).get() + const ids = [] + urls.forEach((url) => { + let match + while ((match = ID_REGEX.exec(url)) != null) { + ids.push(match[1]) + } + }) + return ids +} diff --git a/backend/src/middleware/notifications/extractMentions.spec.js b/backend/src/middleware/notifications/extractMentions.spec.js new file mode 100644 index 000000000..625b1d8fe --- /dev/null +++ b/backend/src/middleware/notifications/extractMentions.spec.js @@ -0,0 +1,46 @@ +import extractIds from './extractMentions' + +describe('extract', () => { + describe('searches through links', () => { + it('ignores links without .mention class', () => { + const content = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' + expect(extractIds(content)).toEqual([]) + }) + + describe('given a link with .mention class', () => { + it('extracts ids', () => { + const content = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' + expect(extractIds(content)).toEqual(['u2', 'u3']) + }) + + describe('handles links', () => { + it('with slug and id', () => { + const content = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' + expect(extractIds(content)).toEqual(['u2', 'u3']) + }) + + it('with domains', () => { + const content = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' + expect(extractIds(content)).toEqual(['u2', 'u3']) + }) + + it('special characters', () => { + const content = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' + expect(extractIds(content)).toEqual(['u!*(),2', 'u.~-3']) + }) + }) + + describe('does not crash if', () => { + it('`href` contains no user id', () => { + const content = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' + expect(extractIds(content)).toEqual([]) + }) + + it('`href` is empty or invalid', () => { + const content = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' + expect(extractIds(content)).toEqual([]) + }) + }) + }) + }) +}) diff --git a/backend/src/middleware/notificationsMiddleware.js b/backend/src/middleware/notifications/index.js similarity index 61% rename from backend/src/middleware/notificationsMiddleware.js rename to backend/src/middleware/notifications/index.js index 30205278b..942eb588d 100644 --- a/backend/src/middleware/notificationsMiddleware.js +++ b/backend/src/middleware/notifications/index.js @@ -1,20 +1,22 @@ -import { extractSlugs } from './notifications/mentions' +import extractIds from './extractMentions' const notify = async (resolve, root, args, context, resolveInfo) => { + // extract user ids before xss-middleware removes link classes + const ids = extractIds(args.content) + const post = await resolve(root, args, context, resolveInfo) const session = context.driver.session() - const { content, id: postId } = post - const slugs = extractSlugs(content) + const { id: postId } = post const createdAt = (new Date()).toISOString() const cypher = ` - match(u:User) where u.slug in $slugs + match(u:User) where u.id in $ids match(p:Post) where p.id = $postId create(n:Notification{id: apoc.create.uuid(), read: false, createdAt: $createdAt}) merge (n)-[:NOTIFIED]->(u) merge (p)-[:NOTIFIED]->(n) ` - await session.run(cypher, { slugs, createdAt, postId }) + await session.run(cypher, { ids, createdAt, postId }) session.close() return post @@ -22,6 +24,7 @@ const notify = async (resolve, root, args, context, resolveInfo) => { export default { Mutation: { - CreatePost: notify + CreatePost: notify, + UpdatePost: notify } } diff --git a/backend/src/middleware/notifications/mentions.js b/backend/src/middleware/notifications/mentions.js deleted file mode 100644 index 137c23f1c..000000000 --- a/backend/src/middleware/notifications/mentions.js +++ /dev/null @@ -1,10 +0,0 @@ -const MENTION_REGEX = /\s@([\w_-]+)/g - -export function extractSlugs (content) { - let slugs = [] - let match - while ((match = MENTION_REGEX.exec(content)) != null) { - slugs.push(match[1]) - } - return slugs -} diff --git a/backend/src/middleware/notifications/mentions.spec.js b/backend/src/middleware/notifications/mentions.spec.js deleted file mode 100644 index f12df7f07..000000000 --- a/backend/src/middleware/notifications/mentions.spec.js +++ /dev/null @@ -1,30 +0,0 @@ -import { extractSlugs } from './mentions' - -describe('extract', () => { - describe('finds mentions in the form of', () => { - it('@user', () => { - const content = 'Hello @user' - expect(extractSlugs(content)).toEqual(['user']) - }) - - it('@user-with-dash', () => { - const content = 'Hello @user-with-dash' - expect(extractSlugs(content)).toEqual(['user-with-dash']) - }) - - it('@user.', () => { - const content = 'Hello @user.' - expect(extractSlugs(content)).toEqual(['user']) - }) - - it('@user-With-Capital-LETTERS', () => { - const content = 'Hello @user-With-Capital-LETTERS' - expect(extractSlugs(content)).toEqual(['user-With-Capital-LETTERS']) - }) - }) - - it('ignores email addresses', () => { - const content = 'Hello somebody@example.org' - expect(extractSlugs(content)).toEqual([]) - }) -}) diff --git a/backend/src/middleware/notifications/spec.js b/backend/src/middleware/notifications/spec.js new file mode 100644 index 000000000..786ee7115 --- /dev/null +++ b/backend/src/middleware/notifications/spec.js @@ -0,0 +1,124 @@ +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../../jest/helpers' +import Factory from '../../seed/factories' + +const factory = Factory() +let client + +beforeEach(async () => { + await factory.create('User', { + id: 'you', + name: 'Al Capone', + slug: 'al-capone', + email: 'test@example.org', + password: '1234' + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('currentUser { notifications }', () => { + const query = `query($read: Boolean) { + currentUser { + notifications(read: $read, orderBy: createdAt_desc) { + read + post { + content + } + } + } + }` + + describe('authenticated', () => { + let headers + beforeEach(async () => { + headers = await login({ email: 'test@example.org', password: '1234' }) + client = new GraphQLClient(host, { headers }) + }) + + describe('given another user', () => { + let authorClient + let authorParams + let authorHeaders + + beforeEach(async () => { + authorParams = { + email: 'author@example.org', + password: '1234', + id: 'author' + } + await factory.create('User', authorParams) + authorHeaders = await login(authorParams) + }) + + describe('who mentions me in a post', () => { + let post + const title = 'Mentioning Al Capone' + const content = 'Hey @al-capone how do you do?' + + beforeEach(async () => { + const createPostMutation = ` + mutation($title: String!, $content: String!) { + CreatePost(title: $title, content: $content) { + id + title + content + } + } + ` + authorClient = new GraphQLClient(host, { headers: authorHeaders }) + const { CreatePost } = await authorClient.request(createPostMutation, { title, content }) + post = CreatePost + }) + + it('sends you a notification', async () => { + const expectedContent = 'Hey @al-capone how do you do?' + const expected = { + currentUser: { + notifications: [ + { read: false, post: { content: expectedContent } } + ] + } + } + await expect(client.request(query, { read: false })).resolves.toEqual(expected) + }) + + describe('who mentions me again', () => { + beforeEach(async () => { + const updatedContent = `${post.content} One more mention to @al-capone` + // 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 createPostMutation = ` + mutation($id: ID!, $content: String!) { + UpdatePost(id: $id, content: $content) { + title + content + } + } + ` + authorClient = new GraphQLClient(host, { headers: authorHeaders }) + await authorClient.request(createPostMutation, { id: post.id, content: updatedContent }) + }) + + it('creates exactly one more notification', async () => { + const expectedContent = 'Hey @al-capone how do you do? One more mention to @al-capone' + const expected = { + currentUser: { + notifications: [ + { read: false, post: { content: expectedContent } }, + { read: false, post: { content: expectedContent } } + ] + } + } + await expect(client.request(query, { read: false })).resolves.toEqual(expected) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/middleware/notificationsMiddleware.spec.js b/backend/src/middleware/notificationsMiddleware.spec.js deleted file mode 100644 index e6fc78c52..000000000 --- a/backend/src/middleware/notificationsMiddleware.spec.js +++ /dev/null @@ -1,85 +0,0 @@ -import Factory from '../seed/factories' -import { GraphQLClient } from 'graphql-request' -import { host, login } from '../jest/helpers' - -const factory = Factory() -let client - -beforeEach(async () => { - await factory.create('User', { - id: 'you', - name: 'Al Capone', - slug: 'al-capone', - email: 'test@example.org', - password: '1234' - }) -}) - -afterEach(async () => { - await factory.cleanDatabase() -}) - -describe('currentUser { notifications }', () => { - const query = `query($read: Boolean) { - currentUser { - notifications(read: $read, orderBy: createdAt_desc) { - read - post { - content - } - } - } - }` - - describe('authenticated', () => { - let headers - beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) - }) - - describe('given another user', () => { - let authorClient - let authorParams - let authorHeaders - - beforeEach(async () => { - authorParams = { - email: 'author@example.org', - password: '1234', - id: 'author' - } - await factory.create('User', authorParams) - authorHeaders = await login(authorParams) - }) - - describe('who mentions me in a post', () => { - beforeEach(async () => { - const content = 'Hey @al-capone how do you do?' - const title = 'Mentioning Al Capone' - const createPostMutation = ` - mutation($title: String!, $content: String!) { - CreatePost(title: $title, content: $content) { - title - content - } - } - ` - authorClient = new GraphQLClient(host, { headers: authorHeaders }) - await authorClient.request(createPostMutation, { title, content }) - }) - - it('sends you a notification', async () => { - const expected = { - currentUser: { - notifications: [ - { read: false, post: { content: 'Hey @al-capone how do you do?' } } - ] - } - } - await expect(client.request(query, { read: false })).resolves.toEqual(expected) - }) - }) - }) - }) -}) diff --git a/package.json b/package.json index b22dd158c..703997ee1 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "nonGlobalStepDefinitions": true }, "scripts": { + "db:seed": "cd backend && yarn run db:seed", + "db:reset": "cd backend && yarn run db:reset", "cypress:backend:server": "cd backend && yarn run test:before:server", "cypress:backend:seeder": "cd backend && yarn run test:before:seeder", "cypress:webapp": "cd webapp && cross-env GRAPHQL_URI=http://localhost:4123 yarn run dev", @@ -25,4 +27,4 @@ "neo4j-driver": "^1.7.3", "npm-run-all": "^4.1.5" } -} \ No newline at end of file +} diff --git a/webapp/components/ContributionForm.vue b/webapp/components/ContributionForm/index.vue similarity index 90% rename from webapp/components/ContributionForm.vue rename to webapp/components/ContributionForm/index.vue index 3ef041569..6dc74f104 100644 --- a/webapp/components/ContributionForm.vue +++ b/webapp/components/ContributionForm/index.vue @@ -16,6 +16,7 @@ /> @@ -48,7 +49,7 @@ diff --git a/webapp/components/Editor/Editor.vue b/webapp/components/Editor/index.vue similarity index 55% rename from webapp/components/Editor/Editor.vue rename to webapp/components/Editor/index.vue index b59ca376d..5636c3714 100644 --- a/webapp/components/Editor/Editor.vue +++ b/webapp/components/Editor/index.vue @@ -1,5 +1,29 @@