diff --git a/.github/ISSUE_TEMPLATE/devops_ticket.md b/.github/ISSUE_TEMPLATE/devops_ticket.md index 3507e25bb..6f8ea55cb 100644 --- a/.github/ISSUE_TEMPLATE/devops_ticket.md +++ b/.github/ISSUE_TEMPLATE/devops_ticket.md @@ -1,8 +1,8 @@ --- -name: :boom: DevOps ticket +name: 💥 DevOps ticket about: Help us manage our deployed App. labels: devops -title: :boom: [DevOps] +title: 💥 [DevOps] --- ## :fire: DevOps ticket diff --git a/backend/package.json b/backend/package.json index 4beb85b54..1822a52ca 100644 --- a/backend/package.json +++ b/backend/package.json @@ -60,9 +60,11 @@ "graphql-iso-date": "~3.6.1", "graphql-middleware": "~4.0.2", "graphql-middleware-sentry": "^3.2.1", + "graphql-redis-subscriptions": "^2.1.2", "graphql-shield": "~7.0.11", "graphql-tag": "~2.10.3", "helmet": "~3.21.2", + "ioredis": "^4.14.1", "jsonwebtoken": "~8.5.1", "linkifyjs": "~2.1.8", "lodash": "~4.17.14", @@ -73,7 +75,7 @@ "metascraper-clearbit-logo": "^5.3.0", "metascraper-date": "^5.10.7", "metascraper-description": "^5.11.0", - "metascraper-image": "^5.10.7", + "metascraper-image": "^5.11.1", "metascraper-lang": "^5.10.7", "metascraper-lang-detector": "^4.10.2", "metascraper-logo": "^5.10.7", @@ -96,6 +98,7 @@ "request": "~2.88.2", "sanitize-html": "~1.21.1", "slug": "~2.1.1", + "subscriptions-transport-ws": "^0.9.16", "trunc-html": "~1.1.2", "uuid": "~3.4.0", "validator": "^12.2.0", @@ -119,7 +122,7 @@ "eslint-config-prettier": "~6.10.0", "eslint-config-standard": "~14.1.0", "eslint-plugin-import": "~2.20.1", - "eslint-plugin-jest": "~23.6.0", + "eslint-plugin-jest": "~23.7.0", "eslint-plugin-node": "~11.0.0", "eslint-plugin-prettier": "~3.1.2", "eslint-plugin-promise": "~4.2.1", diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 058dc00ab..398bc6ff2 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -23,6 +23,9 @@ const { NEO4J_PASSWORD = 'neo4j', CLIENT_URI = 'http://localhost:3000', GRAPHQL_URI = 'http://localhost:4000', + REDIS_DOMAIN, + REDIS_PORT, + REDIS_PASSWORD, } = env export const requiredConfigs = { @@ -61,7 +64,7 @@ export const developmentConfigs = { } export const sentryConfigs = { SENTRY_DSN_BACKEND, COMMIT } - +export const redisConfiig = { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD } export default { ...requiredConfigs, ...smtpConfigs, @@ -69,4 +72,5 @@ export default { ...serverConfigs, ...developmentConfigs, ...sentryConfigs, + ...redisConfiig, } diff --git a/backend/src/index.js b/backend/src/index.js index 98354dc1f..59718dad1 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,9 +1,11 @@ import createServer from './server' import CONFIG from './config' -const { app } = createServer() +const { server, httpServer } = createServer() const url = new URL(CONFIG.GRAPHQL_URI) -app.listen({ port: url.port }, () => { +httpServer.listen({ port: url.port }, () => { /* eslint-disable-next-line no-console */ - console.log(`GraphQLServer ready at ${CONFIG.GRAPHQL_URI} 🚀`) + console.log(`🚀 Server ready at http://localhost:${url.port}${server.graphqlPath}`) + /* eslint-disable-next-line no-console */ + console.log(`🚀 Subscriptions ready at ws://localhost:${url.port}${server.subscriptionsPath}`) }) diff --git a/backend/src/middleware/notifications/notificationsMiddleware.js b/backend/src/middleware/notifications/notificationsMiddleware.js index e0b831b59..4636b8e9f 100644 --- a/backend/src/middleware/notifications/notificationsMiddleware.js +++ b/backend/src/middleware/notifications/notificationsMiddleware.js @@ -1,5 +1,6 @@ import extractMentionedUsers from './mentions/extractMentionedUsers' import { validateNotifyUsers } from '../validation/validationMiddleware' +import { pubsub, NOTIFICATION_ADDED } from '../../server' const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { const idsOfUsers = extractMentionedUsers(args.content) @@ -52,34 +53,48 @@ const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => { WHERE user.id in $idsOfUsers AND NOT (user)-[:BLOCKED]-(author) MERGE (post)-[notification:NOTIFIED {reason: $reason}]->(user) + WITH post AS resource, notification, user ` break } case 'mentioned_in_comment': { mentionedCypher = ` - MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User) + MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(commenter: User) MATCH (user: User) WHERE user.id in $idsOfUsers - AND NOT (user)-[:BLOCKED]-(author) + AND NOT (user)-[:BLOCKED]-(commenter) AND NOT (user)-[:BLOCKED]-(postAuthor) MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user) + WITH comment AS resource, notification, user ` break } } mentionedCypher += ` + WITH notification, user, resource, + [(resource)<-[:WROTE]-(author:User) | author {.*}] AS authors, + [(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] AS posts + WITH resource, user, notification, authors, posts, + resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0]} AS finalResource SET notification.read = FALSE - SET ( - CASE - WHEN notification.createdAt IS NULL - THEN notification END ).createdAt = toString(datetime()) + SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime())) SET notification.updatedAt = toString(datetime()) + RETURN notification {.*, from: finalResource, to: properties(user)} ` const session = context.driver.session() - try { - await session.writeTransaction(transaction => { - return transaction.run(mentionedCypher, { id, idsOfUsers, reason }) + const writeTxResultPromise = session.writeTransaction(async transaction => { + const notificationTransactionResponse = await transaction.run(mentionedCypher, { + id, + idsOfUsers, + reason, }) + return notificationTransactionResponse.records.map(record => record.get('notification')) + }) + try { + const [notification] = await writeTxResultPromise + return pubsub.publish(NOTIFICATION_ADDED, { notificationAdded: notification }) + } catch (error) { + throw new Error(error) } finally { session.close() } @@ -88,24 +103,26 @@ const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => { const notifyUsersOfComment = async (label, commentId, postAuthorId, reason, context) => { await validateNotifyUsers(label, reason) const session = context.driver.session() - + const writeTxResultPromise = await session.writeTransaction(async transaction => { + const notificationTransactionResponse = await transaction.run( + ` + MATCH (postAuthor:User {id: $postAuthorId})-[:WROTE]->(post:Post)<-[:COMMENTS]-(comment:Comment { id: $commentId })<-[:WROTE]-(commenter:User) + WHERE NOT (postAuthor)-[:BLOCKED]-(commenter) + MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(postAuthor) + SET notification.read = FALSE + SET notification.createdAt = COALESCE(notification.createdAt, toString(datetime())) + SET notification.updatedAt = toString(datetime()) + WITH notification, postAuthor, post, + comment {.*, __typename: labels(comment)[0], author: properties(commenter), post: post {.*, author: properties(postAuthor) } } AS finalResource + RETURN notification {.*, from: finalResource, to: properties(postAuthor)} + `, + { commentId, postAuthorId, reason }, + ) + return notificationTransactionResponse.records.map(record => record.get('notification')) + }) try { - await session.writeTransaction(async transaction => { - await transaction.run( - ` - MATCH (postAuthor:User {id: $postAuthorId})-[:WROTE]->(post:Post)<-[:COMMENTS]-(comment:Comment { id: $commentId })<-[:WROTE]-(commenter:User) - WHERE NOT (postAuthor)-[:BLOCKED]-(commenter) - MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(postAuthor) - SET notification.read = FALSE - SET ( - CASE - WHEN notification.createdAt IS NULL - THEN notification END ).createdAt = toString(datetime()) - SET notification.updatedAt = toString(datetime()) - `, - { commentId, postAuthorId, reason }, - ) - }) + const [notification] = await writeTxResultPromise + return pubsub.publish(NOTIFICATION_ADDED, { notificationAdded: notification }) } finally { session.close() } diff --git a/backend/src/schema/resolvers/notifications.js b/backend/src/schema/resolvers/notifications.js index 31369a8c7..cf35fa8a1 100644 --- a/backend/src/schema/resolvers/notifications.js +++ b/backend/src/schema/resolvers/notifications.js @@ -1,21 +1,18 @@ import log from './helpers/databaseLogger' - -const resourceTypes = ['Post', 'Comment'] - -const transformReturnType = record => { - return { - ...record.get('notification').properties, - from: { - __typename: record.get('resource').labels.find(l => resourceTypes.includes(l)), - ...record.get('resource').properties, - }, - to: { - ...record.get('user').properties, - }, - } -} +import { withFilter } from 'graphql-subscriptions' +import { pubsub, NOTIFICATION_ADDED } from '../../server' export default { + Subscription: { + notificationAdded: { + subscribe: withFilter( + () => pubsub.asyncIterator(NOTIFICATION_ADDED), + (payload, variables) => { + return payload.notificationAdded.to.id === variables.userId + }, + ), + }, + }, Query: { notifications: async (_parent, args, context, _resolveInfo) => { const { user: currentUser } = context @@ -51,10 +48,10 @@ export default { MATCH (resource {deleted: false, disabled: false})-[notification:NOTIFIED]->(user:User {id:$id}) ${whereClause} WITH user, notification, resource, - [(resource)<-[:WROTE]-(author:User) | author {.*}] as authors, - [(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] as posts + [(resource)<-[:WROTE]-(author:User) | author {.*}] AS authors, + [(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] AS posts WITH resource, user, notification, authors, posts, - resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0]} as finalResource + resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0]} AS finalResource RETURN notification {.*, from: finalResource, to: properties(user)} ${orderByClause} ${offset} ${limit} @@ -81,12 +78,19 @@ export default { ` MATCH (resource {id: $resourceId})-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id}) SET notification.read = TRUE - RETURN resource, notification, user + WITH user, notification, resource, + [(resource)<-[:WROTE]-(author:User) | author {.*}] AS authors, + [(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] AS posts + WITH resource, user, notification, authors, posts, + resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0]} AS finalResource + RETURN notification {.*, from: finalResource, to: properties(user)} `, { resourceId: args.id, id: currentUser.id }, ) log(markNotificationAsReadTransactionResponse) - return markNotificationAsReadTransactionResponse.records.map(transformReturnType) + return markNotificationAsReadTransactionResponse.records.map(record => + record.get('notification'), + ) }) try { const [notifications] = await writeTxResultPromise diff --git a/backend/src/schema/types/type/NOTIFIED.gql b/backend/src/schema/types/type/NOTIFIED.gql index af91460f7..88ecd3882 100644 --- a/backend/src/schema/types/type/NOTIFIED.gql +++ b/backend/src/schema/types/type/NOTIFIED.gql @@ -30,3 +30,7 @@ type Query { type Mutation { markAsRead(id: ID!): NOTIFIED } + +type Subscription { + notificationAdded(userId: ID!): NOTIFIED +} diff --git a/backend/src/server.js b/backend/src/server.js index 02da4c6aa..4df73559d 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,4 +1,5 @@ import express from 'express' +import http from 'http' import helmet from 'helmet' import { ApolloServer } from 'apollo-server-express' import CONFIG from './config' @@ -7,12 +8,35 @@ import { getNeode, getDriver } from './db/neo4j' import decode from './jwt/decode' import schema from './schema' import webfinger from './activitypub/routes/webfinger' +import { RedisPubSub } from 'graphql-redis-subscriptions' +import { PubSub } from 'graphql-subscriptions' +import Redis from 'ioredis' import bodyParser from 'body-parser' +export const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED' +const { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD } = CONFIG +let prodPubsub, devPubsub +const options = { + host: REDIS_DOMAIN, + port: REDIS_PORT, + password: REDIS_PASSWORD, + retryStrategy: times => { + return Math.min(times * 50, 2000) + }, +} +if (options.host && options.port && options.password) { + prodPubsub = new RedisPubSub({ + publisher: new Redis(options), + subscriber: new Redis(options), + }) +} else { + devPubsub = new PubSub() +} +export const pubsub = prodPubsub || devPubsub const driver = getDriver() const neode = getNeode() -export const context = async ({ req }) => { +const getContext = async req => { const user = await decode(driver, req.headers.authorization) return { driver, @@ -24,11 +48,24 @@ export const context = async ({ req }) => { }, } } +export const context = async options => { + const { connection, req } = options + if (connection) { + return connection.context + } else { + return getContext(req) + } +} const createServer = options => { const defaults = { context, schema: middleware(schema), + subscriptions: { + onConnect: (connectionParams, webSocket) => { + return getContext(connectionParams) + }, + }, debug: !!CONFIG.DEBUG, tracing: !!CONFIG.DEBUG, formatError: error => { @@ -49,8 +86,10 @@ const createServer = options => { app.use(bodyParser.json({ limit: '10mb' })) app.use(bodyParser.urlencoded({ limit: '10mb', extended: true })) server.applyMiddleware({ app, path: '/' }) + const httpServer = http.createServer(app) + server.installSubscriptionHandlers(httpServer) - return { server, app } + return { server, httpServer, app } } export default createServer diff --git a/backend/yarn.lock b/backend/yarn.lock index 33b76df73..8bc2e7c00 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1175,13 +1175,13 @@ url-regex "~4.1.1" video-extensions "~1.1.0" -"@metascraper/helpers@^5.10.7": - version "5.10.7" - resolved "https://registry.yarnpkg.com/@metascraper/helpers/-/helpers-5.10.7.tgz#4d330204372ce5c1afedfc3ac891fb373f72c085" - integrity sha512-YkL4vTF4grgNTFhe9t4qsD0c5aEjxWoC0cpvMICs6JriRtedwjVfiwWhaGiTbU3pGFhmLgE9fV42wXOXGUGjMQ== +"@metascraper/helpers@^5.10.7", "@metascraper/helpers@^5.11.1": + version "5.11.1" + resolved "https://registry.yarnpkg.com/@metascraper/helpers/-/helpers-5.11.1.tgz#227fdd0caf1d33f4b24a85298a355ce7ebb451df" + integrity sha512-oES/e6bwKBlT7WGa2ni3xbJMDx2rbFxSzbUhRX8D+Kylb8H2ThP07c7f+VXMPXWx5CPrNMai/Oyp5IczCf3v8g== dependencies: audio-extensions "0.0.0" - chrono-node "~1.4.2" + chrono-node "~1.4.3" condense-whitespace "~2.0.0" entities "~2.0.0" file-extension "~4.0.5" @@ -2691,10 +2691,10 @@ chrono-node@~1.3.11: dependencies: moment "2.21.0" -chrono-node@~1.4.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/chrono-node/-/chrono-node-1.4.2.tgz#0c7fc1f264e60a660c2b2dab753a3f285dbfd8c9" - integrity sha512-fsb82wPDHVZl3xtche8k4ZZtNwf81/ZMueil2ANpSfogUAEa3BuzZAar7ObLXi1ptMjBzdzA6ys/bFq1oBjO8w== +chrono-node@~1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/chrono-node/-/chrono-node-1.4.3.tgz#4c8e24643ec5e576f6f8fe0429370c3b554491b4" + integrity sha512-ZyKcnTcr8i7Mt9p4+ixMHEuR6+eMTrjYCL9Rm9TZHviLleCtcZoVzmr2uSc+Vg8MX1YbNCnPbEd4rfV8WvzLcw== dependencies: dayjs "^1.8.19" @@ -2774,6 +2774,11 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" +cluster-key-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz#30474b2a981fb12172695833052bc0d01336d10d" + integrity sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -3254,6 +3259,11 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= +denque@^1.1.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/denque/-/denque-1.4.1.tgz#6744ff7641c148c3f8a69c307e51235c1f4a37cf" + integrity sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ== + depd@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -3643,13 +3653,12 @@ eslint-plugin-import@~2.20.1: read-pkg-up "^2.0.0" resolve "^1.12.0" -eslint-plugin-jest@~23.6.0: - version "23.6.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-23.6.0.tgz#508b32f80d44058c8c01257c0ee718cfbd521e9d" - integrity sha512-GH8AhcFXspOLqak7fqnddLXEJsrFyvgO8Bm60SexvKSn1+3rWYESnCiWUOCUcBTprNSDSE4CtAZdM4EyV6gPPw== +eslint-plugin-jest@~23.7.0: + version "23.7.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-23.7.0.tgz#84d5603b6e745b59898cb6750df6a44782a39b04" + integrity sha512-zkiyGlvJeHNjAEz8FaIxTXNblJJ/zj3waNbYbgflK7K6uy0cpE5zJBt/JpJtOBGM/UGkC6BqsQ4n0y7kQ2HA8w== dependencies: "@typescript-eslint/experimental-utils" "^2.5.0" - micromatch "^4.0.2" eslint-plugin-node@~11.0.0: version "11.0.0" @@ -4476,6 +4485,15 @@ graphql-middleware@~4.0.2: dependencies: graphql-tools "^4.0.5" +graphql-redis-subscriptions@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/graphql-redis-subscriptions/-/graphql-redis-subscriptions-2.1.2.tgz#9c1b744bace0c6ba99dd0ebafe0148cad1df3301" + integrity sha512-l69KbGxyYfVHxvE+Dzv9/hXg/q+Xnjfx1JsrJD6ikePuSsNaCSNxr+MubSTNF3Gt3C/+JZs4FaWImFeK/+X2og== + dependencies: + iterall "^1.2.2" + optionalDependencies: + ioredis "^4.6.3" + graphql-shield@~7.0.11: version "7.0.11" resolved "https://registry.yarnpkg.com/graphql-shield/-/graphql-shield-7.0.11.tgz#78d49f346326be71090d35d8f5843da9ee8136e2" @@ -4920,6 +4938,21 @@ invariant@^2.2.2, invariant@^2.2.4: dependencies: loose-envify "^1.0.0" +ioredis@^4.14.1, ioredis@^4.6.3: + version "4.14.1" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.14.1.tgz#b73ded95fcf220f106d33125a92ef6213aa31318" + integrity sha512-94W+X//GHM+1GJvDk6JPc+8qlM7Dul+9K+lg3/aHixPN7ZGkW6qlvX0DG6At9hWtH2v3B32myfZqWoANUJYGJA== + dependencies: + cluster-key-slot "^1.1.0" + debug "^4.1.1" + denque "^1.1.0" + lodash.defaults "^4.2.0" + lodash.flatten "^4.4.0" + redis-commands "1.5.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.0.1" + ip-regex@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-1.0.3.tgz#dc589076f659f419c222039a33316f1c7387effd" @@ -5960,11 +5993,21 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= + lodash.escaperegexp@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" integrity sha1-ZHYsSGGAglGKw99Mz11YhtriA0c= +lodash.flatten@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" + integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= + lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" @@ -6190,12 +6233,12 @@ metascraper-description@^5.11.0: dependencies: "@metascraper/helpers" "^5.10.7" -metascraper-image@^5.10.7: - version "5.10.7" - resolved "https://registry.yarnpkg.com/metascraper-image/-/metascraper-image-5.10.7.tgz#9b1da02f2e748fd388dea6394d29b6e43c367924" - integrity sha512-xrR4Rl8UNwwyzMfrKH3RtaC775aHDXVT0TQEzn5p5uYfd4evLI2O+jr1CIBIl1d3CXqVxWCQWBY3gA7RtcjgWQ== +metascraper-image@^5.11.1: + version "5.11.1" + resolved "https://registry.yarnpkg.com/metascraper-image/-/metascraper-image-5.11.1.tgz#e63e9ff045441783f9aa8c684e04927cac289e44" + integrity sha512-Nz2ZTecj2V0KgK2QE390dOSedppaG2PtUBrTz/oaFLMZEReBtMVrcygYm9VuuTpa6XwkubvuBaouCRah12zdfg== dependencies: - "@metascraper/helpers" "^5.10.7" + "@metascraper/helpers" "^5.11.1" metascraper-lang-detector@^4.10.2: version "4.10.2" @@ -7489,6 +7532,23 @@ realpath-native@^1.1.0: dependencies: util.promisify "^1.0.0" +redis-commands@1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.5.0.tgz#80d2e20698fe688f227127ff9e5164a7dd17e785" + integrity sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg== + +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60= + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ= + dependencies: + redis-errors "^1.0.0" + referrer-policy@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/referrer-policy/-/referrer-policy-1.2.0.tgz#b99cfb8b57090dc454895ef897a4cc35ef67a98e" @@ -8196,6 +8256,11 @@ stacktrace-js@^2.0.0: stack-generator "^2.0.1" stacktrace-gps "^3.0.1" +standard-as-callback@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.0.1.tgz#ed8bb25648e15831759b6023bdb87e6b60b38126" + integrity sha512-NQOxSeB8gOI5WjSaxjBgog2QFw55FV8TkS6Y07BiB3VJ8xNTvUYm0wl0s8ObgQ5NhdpnNfigMIKjgPESzgr4tg== + static-extend@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" diff --git a/cypress/integration/administration/TagsAndCategories.feature b/cypress/integration/administration/TagsAndCategories.feature index f93cdb59c..516966c6b 100644 --- a/cypress/integration/administration/TagsAndCategories.feature +++ b/cypress/integration/administration/TagsAndCategories.feature @@ -14,9 +14,8 @@ Feature: Tags and Categories looking at the popularity of a tag. Background: - Given my user account has the role "admin" + Given I am logged in with a "admin" role And we have a selection of tags and categories as well as posts - And I am logged in Scenario: See an overview of categories When I navigate to the administration dashboard diff --git a/cypress/integration/common/report.js b/cypress/integration/common/report.js index 664d71d1b..cc424ef3e 100644 --- a/cypress/integration/common/report.js +++ b/cypress/integration/common/report.js @@ -32,15 +32,20 @@ Given("I see David Irving's post on the post page", page => { Given('I am logged in with a {string} role', role => { cy.factory().build('user', { termsAndConditionsAgreedVersion: VERSION, - role + role, + name: `${role} is my name` }, { email: `${role}@example.org`, password: '1234', }) - cy.login({ - email: `${role}@example.org`, - password: '1234' - }) + cy.neode() + .first("User", { + name: `${role} is my name`, + }) + .then(async user => { + const userJson = await user.toJson() + cy.login(userJson) + }) }) When('I click on "Report Post" from the content menu of the post', () => { diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js index f96ec78e6..eb533a5a2 100644 --- a/cypress/integration/common/steps.js +++ b/cypress/integration/common/steps.js @@ -35,13 +35,38 @@ const annoyingParams = { }; Given("I am logged in", () => { - cy.login(loginCredentials); + cy.neode() + .first("User", { + name: narratorParams.name + }) + .then(async user => { + const userJson = await user.toJson() + cy.login(userJson) + }) }); +Given("I log in as {string}", name => { + cy.neode() + .first("User", { + name + }) + .then(async user => { + const userJson = await user.toJson() + cy.login(userJson) + }) +}) + Given("the {string} user searches for {string}", (_, postTitle) => { cy.logout() - .login({ email: annoyingParams.email, password: '1234' }) - .get(".searchable-input .ds-select-search") + cy.neode() + .first("User", { + id: "annoying-user" + }) + .then(async user => { + const userJson = await user.toJson() + cy.login(userJson) + }) + cy.get(".searchable-input .ds-select-search") .focus() .type(postTitle); }); @@ -113,8 +138,15 @@ When("I visit the {string} page", page => { When("a blocked user visits the post page of one of my authored posts", () => { cy.logout() - .login({ email: annoyingParams.email, password: annoyingParams.password }) - .openPage('/post/previously-created-post') + cy.neode() + .first("User", { + name: 'Harassing User' + }) + .then(async user => { + const userJson = await user.toJson() + cy.login(userJson) + }) + cy.openPage('post/previously-created-post') }) Given("I am on the {string} page", page => { @@ -122,7 +154,7 @@ Given("I am on the {string} page", page => { }); When("I fill in my email and password combination and click submit", () => { - cy.login(loginCredentials); + cy.manualLogin(loginCredentials); }); When(/(?:when )?I refresh the page/, () => { @@ -306,12 +338,21 @@ Then( } ); -Given("my user account has the following login credentials:", table => { +Given("I am logged in with these credentials:", table => { loginCredentials = table.hashes()[0]; cy.debug(); cy.factory().build("user", { ...termsAndConditionsAgreedVersion, + name: loginCredentials.email, }, loginCredentials); + cy.neode() + .first("User", { + name: loginCredentials.email, + }) + .then(async user => { + const userJson = await user.toJson() + cy.login(userJson) + }) }); When("I fill the password form with:", table => { @@ -330,45 +371,16 @@ When("submit the form", () => { Then("I cannot login anymore with password {string}", password => { cy.reload(); - const { - email - } = loginCredentials; - cy.visit(`/login`); - cy.get("input[name=email]") - .trigger("focus") - .type(email); - cy.get("input[name=password]") - .trigger("focus") - .type(password); - cy.get("button[name=submit]") - .as("submitButton") - .click(); - cy.get(".iziToast-wrapper").should( - "contain", - "Incorrect email address or password." - ); + const { email } = loginCredentials + cy.manualLogin({ email, password }) + .get(".iziToast-wrapper").should("contain", "Incorrect email address or password."); }); Then("I can login successfully with password {string}", password => { cy.reload(); - cy.login({ - ...loginCredentials, - ...{ - password - } - }); - cy.get(".iziToast-wrapper").should("contain", "You are logged in!"); -}); - -When("I log in with the following credentials:", table => { - const { - email, - password - } = table.hashes()[0]; - cy.login({ - email, - password - }); + const { email } = loginCredentials + cy.manualLogin({ email, password }) + .get(".iziToast-wrapper").should("contain", "You are logged in!"); }); When("open the notification menu and click on the first item", () => { @@ -440,15 +452,15 @@ Given("there is an annoying user who has muted me", () => { }); Given("I am on the profile page of the annoying user", name => { - cy.openPage("/profile/annoying-user/spammy-spammer"); + cy.openPage("profile/annoying-user/spammy-spammer"); }); When("I visit the profile page of the annoying user", name => { - cy.openPage("/profile/annoying-user"); + cy.openPage("profile/annoying-user"); }); When("I ", name => { - cy.openPage("/profile/annoying-user"); + cy.openPage("profile/annoying-user"); }); When( @@ -559,18 +571,6 @@ When("a user has blocked me", () => { }); }); -When("I log in with:", table => { - const [firstRow] = table.hashes(); - const { - Email, - Password - } = firstRow; - cy.login({ - email: Email, - password: Password - }); -}); - Then("I see only one post with the title {string}", title => { cy.get(".main-container") .find(".post-link") diff --git a/cypress/integration/moderation/ReportContent.feature b/cypress/integration/moderation/ReportContent.feature index 105bad5e6..be1a07786 100644 --- a/cypress/integration/moderation/ReportContent.feature +++ b/cypress/integration/moderation/ReportContent.feature @@ -62,9 +62,8 @@ Feature: Report and Moderate Given somebody reported the following posts: | submitterEmail | resourceId | reasonCategory | reasonDescription | | p2.submitter@example.org | p2 | other | Offensive content | - And my user account has the role "moderator" + And I am logged in with a "moderator" role And there is an annoying user who has muted me - And I am logged in When I click on the avatar menu in the top right corner And I click on "Moderation" Then I see all the reported posts including from the user who muted me diff --git a/cypress/integration/notifications/Mentions.feature b/cypress/integration/notifications/Mentions.feature index ef2694abc..02dc0abd2 100644 --- a/cypress/integration/notifications/Mentions.feature +++ b/cypress/integration/notifications/Mentions.feature @@ -11,9 +11,7 @@ Feature: Notification for a mention | Matt Rider | matt-rider | matt@example.org | 4321 | Scenario: Mention another user, re-login as this user and see notifications - Given I log in with the following credentials: - | email | password | - | wolle@example.org | 1234 | + Given I log in as "Wolle aus Hamburg" And I start to write a new post with the title "Hey Matt" beginning with: """ Big shout to our fellow contributor @@ -23,9 +21,7 @@ Feature: Notification for a mention And I choose "en" as the language for the post And I click on "Save" When I log out - And I log in with the following credentials: - | email | password | - | matt@example.org | 4321 | + And I log in as "Matt Rider" And see 1 unread notifications in the top menu And open the notification menu and click on the first item Then I get to the post page of ".../hey-matt" diff --git a/cypress/integration/user_account/ChangePassword.feature b/cypress/integration/user_account/ChangePassword.feature index 44e4e5483..dbdf724f7 100644 --- a/cypress/integration/user_account/ChangePassword.feature +++ b/cypress/integration/user_account/ChangePassword.feature @@ -9,10 +9,9 @@ Feature: Change password password or just out of an good habit, you want to change your password. Background: - Given my user account has the following login credentials: + Given I am logged in with these credentials: | email | password | | user@example.org | exposed | - And I am logged in Scenario: Change my password Given I am on the "settings" page diff --git a/cypress/integration/user_account/Login.feature b/cypress/integration/user_account/Login.feature index 3837f7042..6e8f60a56 100644 --- a/cypress/integration/user_account/Login.feature +++ b/cypress/integration/user_account/Login.feature @@ -7,7 +7,7 @@ Feature: Authentication Given I have a user account Scenario: Log in - When I visit the "/login" page + When I visit the "login" page And I fill in my email and password combination and click submit Then I can click on my profile picture in the top right corner And I can see my name "Peter Lustig" in the dropdown menu diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js index 893b99f4f..cc6ac0e91 100644 --- a/cypress/plugins/index.js +++ b/cypress/plugins/index.js @@ -23,7 +23,7 @@ module.exports = (on, config) => { config.env.NEO4J_URI = parsed.NEO4J_URI config.env.NEO4J_USERNAME = parsed.NEO4J_USERNAME config.env.NEO4J_PASSWORD = parsed.NEO4J_PASSWORD - + config.env.JWT_SECRET = parsed.JWT_SECRET on('file:preprocessor', cucumber()) return config } diff --git a/cypress/support/commands.js b/cypress/support/commands.js index a8ef25e7d..f3035dcdd 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -18,6 +18,7 @@ import helpers from "./helpers"; import { GraphQLClient, request } from 'graphql-request' import { gql } from '../../backend/src/helpers/jest' import config from '../../backend/src/config' +import encode from '../../backend/src/jwt/encode' const switchLang = name => { cy.get(".locale-menu").click(); @@ -47,7 +48,13 @@ Cypress.Commands.add("switchLanguage", (name, force) => { } }); -Cypress.Commands.add("login", ({ email, password }) => { +Cypress.Commands.add("login", user => { + const token = encode(user) + cy.setCookie('human-connection-token', token) + .visit("/") +}); + +Cypress.Commands.add("manualLogin", ({ email, password }) => { cy.visit(`/login`); cy.get("input[name=email]") .trigger("focus") @@ -58,11 +65,9 @@ Cypress.Commands.add("login", ({ email, password }) => { cy.get("button[name=submit]") .as("submitButton") .click(); - cy.get(".iziToast-message").should("contain", "You are logged in!"); - cy.location("pathname").should("eq", "/"); }); -Cypress.Commands.add("logout", (email, password) => { +Cypress.Commands.add("logout", () => { cy.visit(`/logout`); cy.location("pathname").should("contain", "/login"); // we're out }); diff --git a/package.json b/package.json index ec54cdb7e..79798391c 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "expect": "^25.1.0", "faker": "Marak/faker.js#master", "graphql-request": "^1.8.2", + "jsonwebtoken": "^8.5.1", "neo4j-driver": "^4.0.1", "neode": "^0.3.7", "npm-run-all": "^4.1.5", diff --git a/webapp/components/NotificationMenu/NotificationMenu.vue b/webapp/components/NotificationMenu/NotificationMenu.vue index a3b085db9..d00ab2837 100644 --- a/webapp/components/NotificationMenu/NotificationMenu.vue +++ b/webapp/components/NotificationMenu/NotificationMenu.vue @@ -22,10 +22,9 @@