Merge branch 'master' of https://github.com/Human-Connection/Human-Connection into 1017-send-out-notifications-on-create-omment

# Conflicts:
#	backend/src/schema/resolvers/notifications.spec.js
This commit is contained in:
Wolfgang Huß 2019-08-05 10:15:20 +02:00
commit 22b9bf77fa
108 changed files with 20690 additions and 1763 deletions

View File

@ -26,8 +26,8 @@ script:
# Backend # Backend
- docker-compose exec backend yarn run lint - docker-compose exec backend yarn run lint
- docker-compose exec backend yarn run test:jest --ci --verbose=false --coverage - docker-compose exec backend yarn run test:jest --ci --verbose=false --coverage
- docker-compose exec backend yarn run db:reset
- docker-compose exec backend yarn run db:seed - docker-compose exec backend yarn run db:seed
- docker-compose exec backend yarn run db:reset
# ActivityPub cucumber testing temporarily disabled because it's too buggy # ActivityPub cucumber testing temporarily disabled because it's too buggy
# - docker-compose exec backend yarn run test:cucumber --tags "not @wip" # - docker-compose exec backend yarn run test:cucumber --tags "not @wip"
# - docker-compose exec backend yarn run db:reset # - docker-compose exec backend yarn run db:reset

View File

@ -28,6 +28,7 @@
* [HTTPS](deployment/digital-ocean/https/README.md) * [HTTPS](deployment/digital-ocean/https/README.md)
* [Human Connection](deployment/human-connection/README.md) * [Human Connection](deployment/human-connection/README.md)
* [Mailserver](deployment/human-connection/mailserver/README.md) * [Mailserver](deployment/human-connection/mailserver/README.md)
* [Maintenance](deployment/human-connection/maintenance/README.md)
* [Volumes](deployment/volumes/README.md) * [Volumes](deployment/volumes/README.md)
* [Neo4J Offline-Backups](deployment/volumes/neo4j-offline-backup/README.md) * [Neo4J Offline-Backups](deployment/volumes/neo4j-offline-backup/README.md)
* [Volume Snapshots](deployment/volumes/volume-snapshots/README.md) * [Volume Snapshots](deployment/volumes/volume-snapshots/README.md)

View File

@ -1,4 +1,4 @@
FROM node:12.6-alpine as base FROM node:12.7-alpine as base
LABEL Description="Backend of the Social Network Human-Connection.org" Vendor="Human Connection gGmbH" Version="0.0.1" Maintainer="Human Connection gGmbH (developer@human-connection.org)" LABEL Description="Backend of the Social Network Human-Connection.org" Vendor="Human Connection gGmbH" Version="0.0.1" Maintainer="Human Connection gGmbH (developer@human-connection.org)"
EXPOSE 4000 EXPOSE 4000

View File

@ -9,6 +9,7 @@
"dev": "nodemon --exec babel-node src/ -e js,gql", "dev": "nodemon --exec babel-node src/ -e js,gql",
"dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,gql", "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,gql",
"lint": "eslint src --config .eslintrc.js", "lint": "eslint src --config .eslintrc.js",
"jest": "jest --forceExit --detectOpenHandles --runInBand",
"test": "run-s test:jest test:cucumber", "test": "run-s test:jest test:cucumber",
"test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 yarn run dev 2> /dev/null", "test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 yarn run dev 2> /dev/null",
"test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions,activityPub yarn run dev 2> /dev/null", "test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions,activityPub yarn run dev 2> /dev/null",
@ -47,13 +48,14 @@
"apollo-client": "~2.6.3", "apollo-client": "~2.6.3",
"apollo-link-context": "~1.0.18", "apollo-link-context": "~1.0.18",
"apollo-link-http": "~1.5.15", "apollo-link-http": "~1.5.15",
"apollo-server": "~2.6.9", "apollo-server": "~2.8.1",
"apollo-server-express": "^2.6.9", "apollo-server-express": "^2.8.1",
"babel-plugin-transform-runtime": "^6.23.0",
"bcryptjs": "~2.4.3", "bcryptjs": "~2.4.3",
"cheerio": "~1.0.0-rc.3", "cheerio": "~1.0.0-rc.3",
"cors": "~2.8.5", "cors": "~2.8.5",
"cross-env": "~5.2.0", "cross-env": "~5.2.0",
"date-fns": "2.0.0-beta.1", "date-fns": "2.0.0-beta.3",
"debug": "~4.1.1", "debug": "~4.1.1",
"dotenv": "~8.0.0", "dotenv": "~8.0.0",
"express": "^4.17.1", "express": "^4.17.1",
@ -61,17 +63,33 @@
"graphql": "~14.4.2", "graphql": "~14.4.2",
"graphql-custom-directives": "~0.2.14", "graphql-custom-directives": "~0.2.14",
"graphql-iso-date": "~3.6.1", "graphql-iso-date": "~3.6.1",
"graphql-middleware": "~3.0.2", "graphql-middleware": "~3.0.3",
"graphql-shield": "~6.0.3", "graphql-shield": "~6.0.4",
"graphql-tag": "~2.10.1", "graphql-tag": "~2.10.1",
"helmet": "~3.18.0", "helmet": "~3.20.0",
"jsonwebtoken": "~8.5.1", "jsonwebtoken": "~8.5.1",
"linkifyjs": "~2.1.8", "linkifyjs": "~2.1.8",
"lodash": "~4.17.14", "lodash": "~4.17.14",
"merge-graphql-schemas": "^1.5.8", "merge-graphql-schemas": "^1.7.0",
"neo4j-driver": "~1.7.4", "metascraper": "^4.10.3",
"metascraper-audio": "^5.6.5",
"metascraper-author": "^5.6.5",
"metascraper-clearbit-logo": "^5.3.0",
"metascraper-date": "^5.6.5",
"metascraper-description": "^5.6.5",
"metascraper-image": "^5.6.5",
"metascraper-lang": "^5.6.3",
"metascraper-lang-detector": "^4.8.5",
"metascraper-logo": "^5.6.5",
"metascraper-publisher": "^5.6.5",
"metascraper-soundcloud": "^5.6.5",
"metascraper-title": "^5.6.5",
"metascraper-url": "^5.6.5",
"metascraper-video": "^5.6.5",
"metascraper-youtube": "^5.6.3",
"neo4j-driver": "~1.7.5",
"neo4j-graphql-js": "^2.6.3", "neo4j-graphql-js": "^2.6.3",
"neode": "^0.2.16", "neode": "^0.3.0",
"node-fetch": "~2.6.0", "node-fetch": "~2.6.0",
"nodemailer": "^6.3.0", "nodemailer": "^6.3.0",
"npm-run-all": "~4.1.5", "npm-run-all": "~4.1.5",
@ -83,23 +101,23 @@
"wait-on": "~3.3.0" "wait-on": "~3.3.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "~7.5.0", "@babel/cli": "~7.5.5",
"@babel/core": "~7.5.4", "@babel/core": "~7.5.5",
"@babel/node": "~7.5.0", "@babel/node": "~7.5.5",
"@babel/plugin-proposal-throw-expressions": "^7.2.0", "@babel/plugin-proposal-throw-expressions": "^7.2.0",
"@babel/preset-env": "~7.5.4", "@babel/preset-env": "~7.5.5",
"@babel/register": "~7.4.4", "@babel/register": "~7.5.5",
"apollo-server-testing": "~2.6.9", "apollo-server-testing": "~2.8.1",
"babel-core": "~7.0.0-0", "babel-core": "~7.0.0-0",
"babel-eslint": "~10.0.2", "babel-eslint": "~10.0.2",
"babel-jest": "~24.8.0", "babel-jest": "~24.8.0",
"chai": "~4.2.0", "chai": "~4.2.0",
"cucumber": "~5.1.0", "cucumber": "~5.1.0",
"eslint": "~6.0.1", "eslint": "~6.1.0",
"eslint-config-prettier": "~6.0.0", "eslint-config-prettier": "~6.0.0",
"eslint-config-standard": "~12.0.0", "eslint-config-standard": "~13.0.1",
"eslint-plugin-import": "~2.18.0", "eslint-plugin-import": "~2.18.2",
"eslint-plugin-jest": "~22.8.0", "eslint-plugin-jest": "~22.14.1",
"eslint-plugin-node": "~9.1.0", "eslint-plugin-node": "~9.1.0",
"eslint-plugin-prettier": "~3.1.0", "eslint-plugin-prettier": "~3.1.0",
"eslint-plugin-promise": "~4.2.1", "eslint-plugin-promise": "~4.2.1",

View File

@ -35,8 +35,8 @@ export default class ActivityPub {
handleFollowActivity(activity) { handleFollowActivity(activity) {
debug(`inside FOLLOW ${activity.actor}`) debug(`inside FOLLOW ${activity.actor}`)
let toActorName = extractNameFromId(activity.object) const toActorName = extractNameFromId(activity.object)
let fromDomain = extractDomainFromUrl(activity.actor) const fromDomain = extractDomainFromUrl(activity.actor)
const dataSource = this.dataSource const dataSource = this.dataSource
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -53,7 +53,7 @@ export default class ActivityPub {
toActorObject = JSON.parse(toActorObject) toActorObject = JSON.parse(toActorObject)
await this.dataSource.addSharedInboxEndpoint(toActorObject.endpoints.sharedInbox) await this.dataSource.addSharedInboxEndpoint(toActorObject.endpoints.sharedInbox)
let followersCollectionPage = await this.dataSource.getFollowersCollectionPage( const followersCollectionPage = await this.dataSource.getFollowersCollectionPage(
activity.object, activity.object,
) )
@ -222,6 +222,7 @@ export default class ActivityPub {
}) })
} }
} }
async trySend(activity, fromName, host, url, tries = 5) { async trySend(activity, fromName, host, url, tries = 5) {
try { try {
return await signAndSend(activity, fromName, host, url) return await signAndSend(activity, fromName, host, url)

View File

@ -2,6 +2,7 @@ export default class Collections {
constructor(dataSource) { constructor(dataSource) {
this.dataSource = dataSource this.dataSource = dataSource
} }
getFollowersCollection(actorId) { getFollowersCollection(actorId) {
return this.dataSource.getFollowersCollection(actorId) return this.dataSource.getFollowersCollection(actorId)
} }

View File

@ -303,6 +303,7 @@ export default class NitroDataSource {
}), }),
) )
} }
async saveFollowingCollectionPage(followingCollection, onlyNewestItem = true) { async saveFollowingCollectionPage(followingCollection, onlyNewestItem = true) {
debug('inside saveFollowers') debug('inside saveFollowers')
let orderedItems = followingCollection.orderedItems let orderedItems = followingCollection.orderedItems
@ -470,6 +471,7 @@ export default class NitroDataSource {
throwErrorIfApolloErrorOccurred(result) throwErrorIfApolloErrorOccurred(result)
return result.data.SharedInboxEnpoint return result.data.SharedInboxEnpoint
} }
async addSharedInboxEndpoint(uri) { async addSharedInboxEndpoint(uri) {
try { try {
const result = await this.client.mutate({ const result = await this.client.mutate({

View File

@ -97,7 +97,7 @@ export function verifySignature(url, headers) {
// private: signing // private: signing
function constructSigningString(url, headers) { function constructSigningString(url, headers) {
const urlObj = new URL(url) const urlObj = new URL(url)
let signingString = `(request-target): post ${urlObj.pathname}${ const signingString = `(request-target): post ${urlObj.pathname}${
urlObj.search !== '' ? urlObj.search : '' urlObj.search !== '' ? urlObj.search : ''
}` }`
return Object.keys(headers).reduce((result, key) => { return Object.keys(headers).reduce((result, key) => {

View File

@ -40,70 +40,72 @@ export function signAndSend(activity, fromName, targetDomain, url) {
// fix for development: replace with http // fix for development: replace with http
url = url.indexOf('localhost') > -1 ? url.replace('https', 'http') : url url = url.indexOf('localhost') > -1 ? url.replace('https', 'http') : url
debug(`passhprase = ${CONFIG.PRIVATE_KEY_PASSPHRASE}`) debug(`passhprase = ${CONFIG.PRIVATE_KEY_PASSPHRASE}`)
return new Promise(async (resolve, reject) => { return new Promise((resolve, reject) => {
debug('inside signAndSend') debug('inside signAndSend')
// get the private key // get the private key
const result = await activityPub.dataSource.client.query({ activityPub.dataSource.client
query: gql` .query({
query: gql`
query { query {
User(slug: "${fromName}") { User(slug: "${fromName}") {
privateKey privateKey
} }
} }
`, `,
}) })
.then(result => {
if (result.error) {
reject(result.error)
} else {
// add security context
const parsedActivity = JSON.parse(activity)
if (Array.isArray(parsedActivity['@context'])) {
parsedActivity['@context'].push('https://w3id.org/security/v1')
} else {
const context = [parsedActivity['@context']]
context.push('https://w3id.org/security/v1')
parsedActivity['@context'] = context
}
if (result.error) { // deduplicate context strings
reject(result.error) parsedActivity['@context'] = [...new Set(parsedActivity['@context'])]
} else { const privateKey = result.data.User[0].privateKey
// add security context const date = new Date().toUTCString()
const parsedActivity = JSON.parse(activity)
if (Array.isArray(parsedActivity['@context'])) {
parsedActivity['@context'].push('https://w3id.org/security/v1')
} else {
const context = [parsedActivity['@context']]
context.push('https://w3id.org/security/v1')
parsedActivity['@context'] = context
}
// deduplicate context strings debug(`url = ${url}`)
parsedActivity['@context'] = [...new Set(parsedActivity['@context'])] request(
const privateKey = result.data.User[0].privateKey {
const date = new Date().toUTCString() url: url,
debug(`url = ${url}`)
request(
{
url: url,
headers: {
Host: targetDomain,
Date: date,
Signature: createSignature({
privateKey,
keyId: `${activityPub.endpoint}/activitypub/users/${fromName}#main-key`,
url,
headers: { headers: {
Host: targetDomain, Host: targetDomain,
Date: date, Date: date,
Signature: createSignature({
privateKey,
keyId: `${activityPub.endpoint}/activitypub/users/${fromName}#main-key`,
url,
headers: {
Host: targetDomain,
Date: date,
'Content-Type': 'application/activity+json',
},
}),
'Content-Type': 'application/activity+json', 'Content-Type': 'application/activity+json',
}, },
}), method: 'POST',
'Content-Type': 'application/activity+json', body: JSON.stringify(parsedActivity),
}, },
method: 'POST', (error, response) => {
body: JSON.stringify(parsedActivity), if (error) {
}, debug(`Error = ${JSON.stringify(error, null, 2)}`)
(error, response) => { reject(error)
if (error) { } else {
debug(`Error = ${JSON.stringify(error, null, 2)}`) debug('Response Headers:', JSON.stringify(response.headers, null, 2))
reject(error) debug('Response Body:', JSON.stringify(response.body, null, 2))
} else { resolve()
debug('Response Headers:', JSON.stringify(response.headers, null, 2)) }
debug('Response Body:', JSON.stringify(response.body, null, 2)) },
resolve() )
} }
}, })
)
}
}) })
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -52,10 +52,12 @@ export default schema => {
if (CONFIG.DISABLED_MIDDLEWARES) { if (CONFIG.DISABLED_MIDDLEWARES) {
const disabledMiddlewares = CONFIG.DISABLED_MIDDLEWARES.split(',') const disabledMiddlewares = CONFIG.DISABLED_MIDDLEWARES.split(',')
order = order.filter(key => { order = order.filter(key => {
if (disabledMiddlewares.includes(key)) {
/* eslint-disable-next-line no-console */
console.log(`Warning: Disabled "${disabledMiddlewares}" middleware.`)
}
return !disabledMiddlewares.includes(key) return !disabledMiddlewares.includes(key)
}) })
/* eslint-disable-next-line no-console */
console.log(`Warning: "${disabledMiddlewares}" middlewares have been disabled.`)
} }
const appliedMiddlewares = order.map(key => middlewares[key]) const appliedMiddlewares = order.map(key => middlewares[key])

View File

@ -1,4 +1,7 @@
import { rule, shield, deny, allow, and, or, not } from 'graphql-shield' import { rule, shield, deny, allow, and, or, not } from 'graphql-shield'
import { neode } from '../bootstrap/neo4j'
const instance = neode()
/* /*
* TODO: implement * TODO: implement
@ -7,7 +10,7 @@ import { rule, shield, deny, allow, and, or, not } from 'graphql-shield'
const isAuthenticated = rule({ const isAuthenticated = rule({
cache: 'contextual', cache: 'contextual',
})(async (_parent, _args, ctx, _info) => { })(async (_parent, _args, ctx, _info) => {
return ctx.user !== null return ctx.user != null
}) })
const isModerator = rule()(async (parent, args, { user }, info) => { const isModerator = rule()(async (parent, args, { user }, info) => {
@ -30,6 +33,14 @@ const isMyOwn = rule({
return context.user.id === parent.id return context.user.id === parent.id
}) })
const isMySocialMedia = rule({
cache: 'no_cache',
})(async (_, args, { user }) => {
let socialMedia = await instance.find('SocialMedia', args.id)
socialMedia = await socialMedia.toJson()
return socialMedia.ownedBy.node.id === user.id
})
const belongsToMe = rule({ const belongsToMe = rule({
cache: 'no_cache', cache: 'no_cache',
})(async (_, args, context) => { })(async (_, args, context) => {
@ -86,8 +97,6 @@ const invitationLimitReached = rule({
return record.get('limitReached') return record.get('limitReached')
}) })
return limitReached return limitReached
} catch (e) {
throw e
} finally { } finally {
session.close() session.close()
} }
@ -136,6 +145,7 @@ const permissions = shield(
Query: { Query: {
'*': deny, '*': deny,
findPosts: allow, findPosts: allow,
embed: allow,
Category: allow, Category: allow,
Tag: allow, Tag: allow,
Report: isModerator, Report: isModerator,
@ -162,7 +172,8 @@ const permissions = shield(
DeletePost: isAuthor, DeletePost: isAuthor,
report: isAuthenticated, report: isAuthenticated,
CreateSocialMedia: isAuthenticated, CreateSocialMedia: isAuthenticated,
DeleteSocialMedia: isAuthenticated, UpdateSocialMedia: isMySocialMedia,
DeleteSocialMedia: isMySocialMedia,
// AddBadgeRewarded: isAdmin, // AddBadgeRewarded: isAdmin,
// RemoveBadgeRewarded: isAdmin, // RemoveBadgeRewarded: isAdmin,
reward: isAdmin, reward: isAdmin,
@ -176,6 +187,7 @@ const permissions = shield(
enable: isModerator, enable: isModerator,
disable: isModerator, disable: isModerator,
CreateComment: isAuthenticated, CreateComment: isAuthenticated,
UpdateComment: isAuthor,
DeleteComment: isAuthor, DeleteComment: isAuthor,
DeleteUser: isDeletingOwnAccount, DeleteUser: isDeletingOwnAccount,
requestPasswordReset: allow, requestPasswordReset: allow,

View File

@ -1,6 +1,6 @@
import slugify from 'slug' import slugify from 'slug'
export default async function uniqueSlug(string, isUnique) { export default async function uniqueSlug(string, isUnique) {
let slug = slugify(string || 'anonymous', { const slug = slugify(string || 'anonymous', {
lower: true, lower: true,
}) })
if (await isUnique(slug)) return slug if (await isUnique(slug)) return slug

View File

@ -1,18 +0,0 @@
import { UserInputError } from 'apollo-server'
const validateUrl = async (resolve, root, args, context, info) => {
const { url } = args
const isValid = url.match(/^(?:https?:\/\/)(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g)
if (isValid) {
/* eslint-disable-next-line no-return-await */
return await resolve(root, args, context, info)
} else {
throw new UserInputError('Input is not a URL')
}
}
export default {
Mutation: {
CreateSocialMedia: validateUrl,
},
}

View File

@ -1,23 +1,8 @@
import { UserInputError } from 'apollo-server' import { UserInputError } from 'apollo-server'
import Joi from '@hapi/joi'
const COMMENT_MIN_LENGTH = 1 const COMMENT_MIN_LENGTH = 1
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!' const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
const validate = schema => {
return async (resolve, root, args, context, info) => {
const validation = schema.validate(args)
if (validation.error) throw new UserInputError(validation.error)
return resolve(root, args, context, info)
}
}
const socialMediaSchema = Joi.object().keys({
url: Joi.string()
.uri()
.required(),
})
const validateCommentCreation = async (resolve, root, args, context, info) => { const validateCommentCreation = async (resolve, root, args, context, info) => {
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim() const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
const { postId } = args const { postId } = args
@ -45,9 +30,19 @@ const validateCommentCreation = async (resolve, root, args, context, info) => {
} }
} }
const validateUpdateComment = async (resolve, root, args, context, info) => {
const COMMENT_MIN_LENGTH = 1
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
if (!args.content || content.length < COMMENT_MIN_LENGTH) {
throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`)
}
return resolve(root, args, context, info)
}
export default { export default {
Mutation: { Mutation: {
CreateSocialMedia: validate(socialMediaSchema),
CreateComment: validateCommentCreation, CreateComment: validateCommentCreation,
UpdateComment: validateUpdateComment,
}, },
} }

View File

@ -8,8 +8,8 @@ import linkifyHtml from 'linkifyjs/html'
const embedToAnchor = content => { const embedToAnchor = content => {
const $ = cheerio.load(content) const $ = cheerio.load(content)
$('div[data-url-embed]').each((i, el) => { $('div[data-url-embed]').each((i, el) => {
let url = el.attribs['data-url-embed'] const url = el.attribs['data-url-embed']
let aTag = $(`<a href="${url}" target="_blank" data-url-embed="">${url}</a>`) const aTag = $(`<a href="${url}" target="_blank" data-url-embed="">${url}</a>`)
$(el).replaceWith(aTag) $(el).replaceWith(aTag)
}) })
return $('body').html() return $('body').html()
@ -87,7 +87,7 @@ function clean(dirty) {
b: 'strong', b: 'strong',
s: 'strike', s: 'strike',
img: function(tagName, attribs) { img: function(tagName, attribs) {
let src = attribs.src const src = attribs.src
if (!src) { if (!src) {
// remove broken images // remove broken images

View File

@ -8,5 +8,6 @@ module.exports = {
relationship: 'BELONGS_TO', relationship: 'BELONGS_TO',
target: 'User', target: 'User',
direction: 'out', direction: 'out',
eager: true,
}, },
} }

View File

@ -0,0 +1,15 @@
import uuid from 'uuid/v4'
module.exports = {
id: { type: 'string', primary: true, default: uuid },
url: { type: 'string', uri: true, required: true },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
ownedBy: {
type: 'relationship',
relationship: 'OWNED_BY',
target: 'User',
direction: 'in',
eager: true,
cascade: 'detach',
},
}

View File

@ -3,8 +3,7 @@ import uuid from 'uuid/v4'
module.exports = { module.exports = {
id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests id: { type: 'string', primary: true, default: uuid }, // TODO: should be type: 'uuid' but simplified for our tests
actorId: { type: 'string', allow: [null] }, actorId: { type: 'string', allow: [null] },
name: { type: 'string', min: 3 }, name: { type: 'string', disallow: [null], min: 3 },
email: { type: 'string', lowercase: true, email: true },
slug: 'string', slug: 'string',
encryptedPassword: 'string', encryptedPassword: 'string',
avatar: { type: 'string', allow: [null] }, avatar: { type: 'string', allow: [null] },

View File

@ -5,4 +5,5 @@ export default {
User: require('./User.js'), User: require('./User.js'),
InvitationCode: require('./InvitationCode.js'), InvitationCode: require('./InvitationCode.js'),
EmailAddress: require('./EmailAddress.js'), EmailAddress: require('./EmailAddress.js'),
SocialMedia: require('./SocialMedia.js'),
} }

View File

@ -0,0 +1,9 @@
export const undefinedToNull = list => {
const resolvers = {}
list.forEach(key => {
resolvers[key] = async (parent, params, context, resolveInfo) => {
return typeof parent[key] === 'undefined' ? null : parent[key]
}
})
return resolvers
}

View File

@ -19,6 +19,7 @@ export default applyScalars(
'Notfication', 'Notfication',
'Statistics', 'Statistics',
'LoggedInUser', 'LoggedInUser',
'SocialMedia',
], ],
// add 'User' here as soon as possible // add 'User' here as soon as possible
}, },
@ -30,6 +31,7 @@ export default applyScalars(
'Notfication', 'Notfication',
'Statistics', 'Statistics',
'LoggedInUser', 'LoggedInUser',
'SocialMedia',
], ],
// add 'User' here as soon as possible // add 'User' here as soon as possible
}, },

View File

@ -18,7 +18,7 @@ export default {
false, false,
) )
let transactionRes = await session.run( const transactionRes = await session.run(
` `
MATCH (post:Post {id: $postId}), (comment:Comment {id: $commentId}), (author:User {id: $userId}) MATCH (post:Post {id: $postId}), (comment:Comment {id: $commentId}), (author:User {id: $userId})
MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author) MERGE (post)<-[:COMMENTS]-(comment)<-[:WROTE]-(author)

View File

@ -5,11 +5,31 @@ import { host, login, gql } from '../../jest/helpers'
const factory = Factory() const factory = Factory()
let client let client
let createCommentVariables let createCommentVariables
let createPostVariables
let createCommentVariablesSansPostId let createCommentVariablesSansPostId
let createCommentVariablesWithNonExistentPost let createCommentVariablesWithNonExistentPost
let userParams let userParams
let authorParams let headers
const createPostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!) {
CreatePost(id: $id, title: $title, content: $content) {
id
}
}
`
const createCommentMutation = gql`
mutation($id: ID, $postId: ID!, $content: String!) {
CreateComment(id: $id, postId: $postId, content: $content) {
id
content
}
}
`
const createPostVariables = {
id: 'p1',
title: 'post to comment on',
content: 'please comment on me',
}
beforeEach(async () => { beforeEach(async () => {
userParams = { userParams = {
@ -25,21 +45,6 @@ afterEach(async () => {
}) })
describe('CreateComment', () => { describe('CreateComment', () => {
const createCommentMutation = gql`
mutation($postId: ID!, $content: String!) {
CreateComment(postId: $postId, content: $content) {
id
content
}
}
`
const createPostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!) {
CreatePost(id: $id, title: $title, content: $content) {
id
}
}
`
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
createCommentVariables = { createCommentVariables = {
@ -54,7 +59,6 @@ describe('CreateComment', () => {
}) })
describe('authenticated', () => { describe('authenticated', () => {
let headers
beforeEach(async () => { beforeEach(async () => {
headers = await login(userParams) headers = await login(userParams)
client = new GraphQLClient(host, { client = new GraphQLClient(host, {
@ -64,11 +68,6 @@ describe('CreateComment', () => {
postId: 'p1', postId: 'p1',
content: "I'm authorised to comment", content: "I'm authorised to comment",
} }
createPostVariables = {
id: 'p1',
title: 'post to comment on',
content: 'please comment on me',
}
await client.request(createPostMutation, createPostVariables) await client.request(createPostMutation, createPostVariables)
}) })
@ -187,19 +186,8 @@ describe('CreateComment', () => {
}) })
}) })
describe('DeleteComment', () => { describe('ManageComments', () => {
const deleteCommentMutation = gql` let authorParams
mutation($id: ID!) {
DeleteComment(id: $id) {
id
}
}
`
let deleteCommentVariables = {
id: 'c1',
}
beforeEach(async () => { beforeEach(async () => {
authorParams = { authorParams = {
email: 'author@example.org', email: 'author@example.org',
@ -213,51 +201,178 @@ describe('DeleteComment', () => {
content: 'Post to be commented', content: 'Post to be commented',
}) })
await asAuthor.create('Comment', { await asAuthor.create('Comment', {
id: 'c1', id: 'c456',
postId: 'p1', postId: 'p1',
content: 'Comment to be deleted', content: 'Comment to be deleted',
}) })
}) })
describe('unauthenticated', () => { describe('UpdateComment', () => {
it('throws authorization error', async () => { const updateCommentMutation = gql`
client = new GraphQLClient(host) mutation($content: String!, $id: ID!) {
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow( UpdateComment(content: $content, id: $id) {
'Not Authorised', id
) content
}) }
})
describe('authenticated but not the author', () => {
beforeEach(async () => {
let headers
headers = await login(userParams)
client = new GraphQLClient(host, { headers })
})
it('throws authorization error', async () => {
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated as author', () => {
beforeEach(async () => {
let headers
headers = await login(authorParams)
client = new GraphQLClient(host, { headers })
})
it('deletes the comment', async () => {
const expected = {
DeleteComment: {
id: 'c1',
},
} }
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).resolves.toEqual( `
expected,
) let updateCommentVariables = {
id: 'c456',
content: 'The comment is updated',
}
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated but not the author', () => {
beforeEach(async () => {
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
})
it('throws authorization error', async () => {
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated as author', () => {
beforeEach(async () => {
headers = await login(authorParams)
client = new GraphQLClient(host, {
headers,
})
})
it('updates the comment', async () => {
const expected = {
UpdateComment: {
id: 'c456',
content: 'The comment is updated',
},
}
await expect(
client.request(updateCommentMutation, updateCommentVariables),
).resolves.toEqual(expected)
})
it('throw an error if an empty string is sent from the editor as content', async () => {
updateCommentVariables = {
id: 'c456',
content: '<p></p>',
}
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Comment must be at least 1 character long!',
)
})
it('throws an error if a comment sent from the editor does not contain a single letter character', async () => {
updateCommentVariables = {
id: 'c456',
content: '<p> </p>',
}
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Comment must be at least 1 character long!',
)
})
it('throws an error if commentId is sent as an empty string', async () => {
updateCommentVariables = {
id: '',
content: '<p>Hello</p>',
}
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Not Authorised!',
)
})
it('throws an error if the comment does not exist in the database', async () => {
updateCommentVariables = {
id: 'c1000',
content: '<p>Hello</p>',
}
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Not Authorised!',
)
})
})
})
describe('DeleteComment', () => {
const deleteCommentMutation = gql`
mutation($id: ID!) {
DeleteComment(id: $id) {
id
}
}
`
const deleteCommentVariables = {
id: 'c456',
}
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated but not the author', () => {
beforeEach(async () => {
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
})
it('throws authorization error', async () => {
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated as author', () => {
beforeEach(async () => {
headers = await login(authorParams)
client = new GraphQLClient(host, {
headers,
})
})
it('deletes the comment', async () => {
const expected = {
DeleteComment: {
id: 'c456',
},
}
await expect(
client.request(deleteCommentMutation, deleteCommentVariables),
).resolves.toEqual(expected)
})
}) })
}) })
}) })

View File

@ -0,0 +1,29 @@
import scrape from './embeds/scraper.js'
import { undefinedToNullResolver } from './helpers/Resolver'
export default {
Query: {
embed: async (object, { url }, context, resolveInfo) => {
return scrape(url)
},
},
Embed: {
...undefinedToNullResolver([
'type',
'title',
'author',
'publisher',
'date',
'description',
'url',
'image',
'audio',
'video',
'lang',
'html',
]),
sources: async (parent, params, context, resolveInfo) => {
return typeof parent.sources === 'undefined' ? [] : parent.sources
},
},
}

View File

@ -0,0 +1,216 @@
import fetch from 'node-fetch'
import fs from 'fs'
import path from 'path'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../../server'
import { gql } from '../../jest/helpers'
jest.mock('node-fetch')
const { Response } = jest.requireActual('node-fetch')
afterEach(() => {
fetch.mockRestore()
})
let variables = {}
const HumanConnectionOrg = fs.readFileSync(
path.join(__dirname, '../../jest/snapshots/embeds/HumanConnectionOrg.html'),
'utf8',
)
const pr960 = fs.readFileSync(
path.join(__dirname, '../../jest/snapshots/embeds/pr960.html'),
'utf8',
)
const babyLovesCat = fs.readFileSync(
path.join(__dirname, '../../jest/snapshots/embeds/babyLovesCat.html'),
'utf8',
)
const babyLovesCatEmbedResponse = new Response(
JSON.stringify({
height: 270,
provider_name: 'YouTube',
title: 'Baby Loves Cat',
type: 'video',
width: 480,
thumbnail_height: 360,
provider_url: 'https://www.youtube.com/',
thumbnail_width: 480,
html:
'<iframe width="480" height="270" src="https://www.youtube.com/embed/qkdXAtO40Fo?start=18&feature=oembed" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>',
thumbnail_url: 'https://i.ytimg.com/vi/qkdXAtO40Fo/hqdefault.jpg',
version: '1.0',
author_name: 'Merkley Family',
author_url: 'https://www.youtube.com/channel/UC5P8yei950tif7UmdPpkJLQ',
}),
)
describe('Query', () => {
describe('embed', () => {
let embedAction
beforeEach(() => {
embedAction = async variables => {
const { server } = createServer({
context: () => {},
})
const { query } = createTestClient(server)
const embed = gql`
query($url: String!) {
embed(url: $url) {
type
title
author
publisher
date
description
url
image
audio
video
lang
sources
html
}
}
`
return query({ query: embed, variables })
}
})
describe('given a video link', () => {
beforeEach(() => {
fetch
.mockReturnValueOnce(Promise.resolve(new Response('')))
.mockReturnValueOnce(Promise.resolve(JSON.stringify({})))
variables = { url: 'https://www.w3schools.com/html/mov_bbb.mp4' }
})
it('shows some default data', async () => {
const expected = expect.objectContaining({
data: {
embed: {
audio: null,
author: null,
date: null,
description: null,
html: null,
image: null,
lang: null,
publisher: 'YouTube',
sources: ['resource'],
title: null,
type: 'link',
url: 'https://www.w3schools.com/html/mov_bbb.mp4',
video: null,
},
},
})
await expect(embedAction(variables)).resolves.toEqual(expected)
})
})
describe('given a Facebook link', () => {
beforeEach(() => {
fetch
.mockReturnValueOnce(Promise.resolve(new Response(HumanConnectionOrg)))
.mockReturnValueOnce(Promise.resolve('invalid json'))
variables = { url: 'https://www.facebook.com/HumanConnectionOrg/' }
})
it('does not crash if embed provider returns invalid JSON', async () => {
const expected = expect.objectContaining({
data: {
embed: {
audio: null,
author: null,
date: expect.any(String),
description:
'Human Connection, Weilheim an der Teck. Gefällt 24.407 Mal. An upcoming non-profit social network focused on local and global positive change. Twitter accounts : @hc_world (EN), @hc_deutschland (GE),...',
html: null,
image:
'https://scontent.ftxl3-1.fna.fbcdn.net/v/t1.0-1/c5.0.200.200a/p200x200/12108307_997373093648222_70057205881020137_n.jpg?_nc_cat=110&_nc_oc=AQnPPYQlR0dU556gOfl4xkXr7IPZdRIAUfQeXl3fpUv4DAsFN8T4PfgOjPwuq85GPKGZ5S5E5mWQ8IVV1UiRBAIZ&_nc_ht=scontent.ftxl3-1.fna&oh=90309adddaab38839782f16e7d4b7bcf&oe=5DEEDFE5',
lang: 'de',
publisher: 'Facebook',
sources: ['resource'],
title: 'Human Connection',
type: 'link',
url: 'https://www.facebook.com/HumanConnectionOrg/',
video: null,
},
},
})
await expect(embedAction(variables)).resolves.toEqual(expected)
})
})
describe('given a Github link', () => {
beforeEach(() => {
fetch
.mockReturnValueOnce(Promise.resolve(new Response(pr960)))
.mockReturnValueOnce(Promise.resolve(JSON.stringify({})))
variables = { url: 'https://github.com/Human-Connection/Human-Connection/pull/960' }
})
it('returns meta data even if no embed html can be retrieved', async () => {
const expected = expect.objectContaining({
data: {
embed: {
type: 'link',
title:
'Editor embeds merge in nitro embed by mattwr18 · Pull Request #960 · Human-Connection/Human-Connection',
author: 'Human-Connection',
publisher: 'GitHub',
date: expect.any(String),
description: '🍰 Pullrequest Issues fixes #256',
url: 'https://github.com/Human-Connection/Human-Connection/pull/960',
image:
'https://repository-images.githubusercontent.com/112590397/52c9a000-7e11-11e9-899d-aaa55f3a3d72',
audio: null,
video: null,
lang: 'en',
sources: ['resource'],
html: null,
},
},
})
await expect(embedAction(variables)).resolves.toEqual(expected)
})
})
describe('given a youtube link', () => {
beforeEach(() => {
fetch
.mockReturnValueOnce(Promise.resolve(new Response(babyLovesCat)))
.mockReturnValueOnce(Promise.resolve(babyLovesCatEmbedResponse))
variables = { url: 'https://www.youtube.com/watch?v=qkdXAtO40Fo&t=18s' }
})
it('returns meta data plus youtube iframe html', async () => {
const expected = expect.objectContaining({
data: {
embed: {
type: 'video',
title: 'Baby Loves Cat',
author: 'Merkley Family',
publisher: 'YouTube',
date: expect.any(String),
description:
'Shes incapable of controlling her limbs when her kitty is around. The obsession grows every day. Ps. Thats a sleep sack shes in. Not a starfish outfit. Al...',
url: 'https://www.youtube.com/watch?v=qkdXAtO40Fo',
image: 'https://i.ytimg.com/vi/qkdXAtO40Fo/maxresdefault.jpg',
audio: null,
video: null,
lang: 'de',
sources: ['resource', 'oembed'],
html:
'<iframe width="480" height="270" src="https://www.youtube.com/embed/qkdXAtO40Fo?start=18&feature=oembed" frameborder="0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>',
},
},
})
await expect(embedAction(variables)).resolves.toEqual(expected)
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,102 @@
import Metascraper from 'metascraper'
import fetch from 'node-fetch'
import fs from 'fs'
import path from 'path'
import { ApolloError } from 'apollo-server'
import isEmpty from 'lodash/isEmpty'
import isArray from 'lodash/isArray'
import mergeWith from 'lodash/mergeWith'
const error = require('debug')('embed:error')
const metascraper = Metascraper([
require('metascraper-author')(),
require('metascraper-date')(),
require('metascraper-description')(),
require('metascraper-image')(),
require('metascraper-lang')(),
require('metascraper-lang-detector')(),
require('metascraper-logo')(),
// require('metascraper-clearbit-logo')(),
require('metascraper-publisher')(),
require('metascraper-title')(),
require('metascraper-url')(),
require('metascraper-audio')(),
require('metascraper-soundcloud')(),
require('metascraper-video')(),
require('metascraper-youtube')(),
// require('./rules/metascraper-embed')()
])
let oEmbedProvidersFile = fs.readFileSync(path.join(__dirname, './providers.json'), 'utf8')
// some providers allow a format parameter
// we need JSON
oEmbedProvidersFile = oEmbedProvidersFile.replace('{format}', 'json')
const oEmbedProviders = JSON.parse(oEmbedProvidersFile)
const fetchEmbed = async url => {
const provider = oEmbedProviders.find(provider => {
return provider.provider_url.includes(url.hostname)
})
if (!provider) return {}
const {
endpoints: [endpoint],
} = provider
const endpointUrl = new URL(endpoint.url)
endpointUrl.searchParams.append('url', url.href)
endpointUrl.searchParams.append('format', 'json')
let json
try {
const response = await fetch(endpointUrl)
json = await response.json()
} catch (err) {
error(`Error fetching embed data: ${err.message}`)
return {}
}
return {
type: json.type,
html: json.html,
author: json.author_name,
date: json.upload_date,
sources: ['oembed'],
}
}
const fetchResource = async url => {
const response = await fetch(url)
const html = await response.text()
const resource = await metascraper({ html, url: url.href })
return {
sources: ['resource'],
...resource,
}
}
export default async function scrape(url) {
url = new URL(url)
if (url.hostname === 'youtu.be') {
// replace youtu.be to get proper results
url.hostname = 'youtube.com'
}
const [meta, embed] = await Promise.all([fetchResource(url), fetchEmbed(url)])
const output = mergeWith(meta, embed, (objValue, srcValue) => {
if (isArray(objValue)) {
return objValue.concat(srcValue)
}
})
if (isEmpty(output)) {
throw new ApolloError('Not found', 'NOT_FOUND')
}
return {
type: 'link',
...output,
}
}

View File

@ -4,7 +4,7 @@ export default {
const { id, type } = params const { id, type } = params
const session = context.driver.session() const session = context.driver.session()
let transactionRes = await session.run( const transactionRes = await session.run(
`MATCH (node {id: $id}), (user:User {id: $userId}) `MATCH (node {id: $id}), (user:User {id: $userId})
WHERE $type IN labels(node) AND NOT $id = $userId WHERE $type IN labels(node) AND NOT $id = $userId
MERGE (user)-[relation:FOLLOWS]->(node) MERGE (user)-[relation:FOLLOWS]->(node)
@ -29,7 +29,7 @@ export default {
const { id, type } = params const { id, type } = params
const session = context.driver.session() const session = context.driver.session()
let transactionRes = await session.run( const transactionRes = await session.run(
`MATCH (user:User {id: $userId})-[relation:FOLLOWS]->(node {id: $id}) `MATCH (user:User {id: $userId})-[relation:FOLLOWS]->(node {id: $id})
WHERE $type IN labels(node) WHERE $type IN labels(node)
DELETE relation DELETE relation

View File

@ -41,8 +41,7 @@ describe('follow', () => {
describe('follow user', () => { describe('follow user', () => {
describe('unauthenticated follow', () => { describe('unauthenticated follow', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
let client const client = new GraphQLClient(host)
client = new GraphQLClient(host)
await expect(client.request(mutationFollowUser('u2'))).rejects.toThrow('Not Authorised') await expect(client.request(mutationFollowUser('u2'))).rejects.toThrow('Not Authorised')
}) })
}) })
@ -93,8 +92,7 @@ describe('follow', () => {
// follow // follow
await clientUser1.request(mutationFollowUser('u2')) await clientUser1.request(mutationFollowUser('u2'))
// unfollow // unfollow
let client const client = new GraphQLClient(host)
client = new GraphQLClient(host)
await expect(client.request(mutationUnfollowUser('u2'))).rejects.toThrow('Not Authorised') await expect(client.request(mutationUnfollowUser('u2'))).rejects.toThrow('Not Authorised')
}) })
}) })

View File

@ -0,0 +1,75 @@
import { neode } from '../../../bootstrap/neo4j'
export const undefinedToNullResolver = list => {
const resolvers = {}
list.forEach(key => {
resolvers[key] = async (parent, params, context, resolveInfo) => {
return typeof parent[key] === 'undefined' ? null : parent[key]
}
})
return resolvers
}
export default function Resolver(type, options = {}) {
const instance = neode()
const {
idAttribute = 'id',
undefinedToNull = [],
count = {},
hasOne = {},
hasMany = {},
} = options
const _hasResolver = (resolvers, { key, connection }, { returnType }) => {
return async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const id = parent[idAttribute]
const statement = `MATCH(:${type} {${idAttribute}: {id}})${connection} RETURN related`
const result = await instance.cypher(statement, { id })
let response = result.records.map(r => r.get('related').properties)
if (returnType === 'object') response = response[0] || null
return response
}
}
const countResolver = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const id = parent[idAttribute]
const statement = `
MATCH(u:${type} {${idAttribute}: {id}})${connection}
WHERE NOT related.deleted = true AND NOT related.disabled = true
RETURN COUNT(DISTINCT(related)) as count
`
const result = await instance.cypher(statement, { id })
const [response] = result.records.map(r => r.get('count').toNumber())
return response
}
}
return resolvers
}
const hasManyResolver = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _hasResolver(resolvers, { key, connection }, { returnType: 'iterable' })
}
return resolvers
}
const hasOneResolver = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _hasResolver(resolvers, { key, connection }, { returnType: 'object' })
}
return resolvers
}
const result = {
...undefinedToNullResolver(undefinedToNull),
...countResolver(count),
...hasOneResolver(hasOne),
...hasManyResolver(hasMany),
}
return result
}

View File

@ -9,7 +9,7 @@ import {
const factory = Factory() const factory = Factory()
let client let client
let userParams = { const userParams = {
id: 'you', id: 'you',
email: 'test@example.org', email: 'test@example.org',
password: '1234', password: '1234',
@ -39,7 +39,7 @@ describe('Notification', () => {
}) })
describe('currentUser { notifications }', () => { describe('currentUser { notifications }', () => {
let variables = {} const variables = {}
describe('authenticated', () => { describe('authenticated', () => {
let headers let headers
@ -116,9 +116,7 @@ describe('currentUser { notifications }', () => {
} }
} }
}` }`
let variables = { const variables = { read: false }
read: false
}
it('returns only unread notifications of current user', async () => { it('returns only unread notifications of current user', async () => {
const expected = { const expected = {
currentUser: { currentUser: {

View File

@ -5,7 +5,7 @@ export async function createPasswordReset(options) {
const { driver, code, email, issuedAt = new Date() } = options const { driver, code, email, issuedAt = new Date() } = options
const session = driver.session() const session = driver.session()
const cypher = ` const cypher = `
MATCH (u:User) WHERE u.email = $email MATCH (u:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email:$email})
CREATE(pr:PasswordReset {code: $code, issuedAt: datetime($issuedAt), usedAt: NULL}) CREATE(pr:PasswordReset {code: $code, issuedAt: datetime($issuedAt), usedAt: NULL})
MERGE (u)-[:REQUESTED]->(pr) MERGE (u)-[:REQUESTED]->(pr)
RETURN u RETURN u
@ -35,13 +35,13 @@ export default {
const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10) const encryptedNewPassword = await bcrypt.hashSync(newPassword, 10)
const cypher = ` const cypher = `
MATCH (pr:PasswordReset {code: $code}) MATCH (pr:PasswordReset {code: $code})
MATCH (u:User {email: $email})-[:REQUESTED]->(pr) MATCH (e:EmailAddress {email: $email})<-[:PRIMARY_EMAIL]-(u:User)-[:REQUESTED]->(pr)
WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL WHERE duration.between(pr.issuedAt, datetime()).days <= 0 AND pr.usedAt IS NULL
SET pr.usedAt = datetime() SET pr.usedAt = datetime()
SET u.encryptedPassword = $encryptedNewPassword SET u.encryptedPassword = $encryptedNewPassword
RETURN pr RETURN pr
` `
let transactionRes = await session.run(cypher, { const transactionRes = await session.run(cypher, {
stillValid, stillValid,
email, email,
code, code,

View File

@ -10,7 +10,7 @@ const driver = getDriver()
const getAllPasswordResets = async () => { const getAllPasswordResets = async () => {
const session = driver.session() const session = driver.session()
let transactionRes = await session.run('MATCH (r:PasswordReset) RETURN r') const transactionRes = await session.run('MATCH (r:PasswordReset) RETURN r')
const resets = transactionRes.records.map(record => record.get('r')) const resets = transactionRes.records.map(record => record.get('r'))
session.close() session.close()
return resets return resets
@ -84,9 +84,9 @@ describe('passwordReset', () => {
} }
const mutation = `mutation($code: String!, $email: String!, $newPassword: String!) { resetPassword(code: $code, email: $email, newPassword: $newPassword) }` const mutation = `mutation($code: String!, $email: String!, $newPassword: String!) { resetPassword(code: $code, email: $email, newPassword: $newPassword) }`
let email = 'user@example.org' const email = 'user@example.org'
let code = 'abcdef' const code = 'abcdef'
let newPassword = 'supersecret' const newPassword = 'supersecret'
let variables let variables
describe('invalid email', () => { describe('invalid email', () => {

View File

@ -337,7 +337,7 @@ describe('DeletePost', () => {
} }
` `
let variables = { const variables = {
id: 'p1', id: 'p1',
} }

View File

@ -12,8 +12,8 @@ const instance = neode()
*/ */
const checkEmailDoesNotExist = async ({ email }) => { const checkEmailDoesNotExist = async ({ email }) => {
email = email.toLowerCase() email = email.toLowerCase()
const users = await instance.all('User', { email }) const emails = await instance.all('EmailAddress', { email })
if (users.length > 0) throw new UserInputError('User account with this email already exists.') if (emails.length > 0) throw new UserInputError('User account with this email already exists.')
} }
export default { export default {

View File

@ -166,11 +166,12 @@ describe('SignupByInvitation', () => {
await expect(action()).rejects.toThrow('"email" must be a valid email') await expect(action()).rejects.toThrow('"email" must be a valid email')
}) })
it('creates no EmailAddress node', async done => { it('creates no additional EmailAddress node', async done => {
try { try {
await action() await action()
} catch (e) { } catch (e) {
const emailAddresses = await instance.all('EmailAddress') let emailAddresses = await instance.all('EmailAddress')
emailAddresses = await emailAddresses.toJson
expect(emailAddresses).toHaveLength(0) expect(emailAddresses).toHaveLength(0)
done() done()
} }
@ -191,16 +192,16 @@ describe('SignupByInvitation', () => {
describe('creates a EmailAddress node', () => { describe('creates a EmailAddress node', () => {
it('with a `createdAt` attribute', async () => { it('with a `createdAt` attribute', async () => {
await action() await action()
const emailAddresses = await instance.all('EmailAddress') let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
const emailAddress = await emailAddresses.first().toJson() emailAddress = await emailAddress.toJson()
expect(emailAddress.createdAt).toBeTruthy() expect(emailAddress.createdAt).toBeTruthy()
expect(Date.parse(emailAddress.createdAt)).toEqual(expect.any(Number)) expect(Date.parse(emailAddress.createdAt)).toEqual(expect.any(Number))
}) })
it('with a cryptographic `nonce`', async () => { it('with a cryptographic `nonce`', async () => {
await action() await action()
const emailAddresses = await instance.all('EmailAddress') let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
const emailAddress = await emailAddresses.first().toJson() emailAddress = await emailAddress.toJson()
expect(emailAddress.nonce).toEqual(expect.any(String)) expect(emailAddress.nonce).toEqual(expect.any(String))
}) })
@ -220,6 +221,7 @@ describe('SignupByInvitation', () => {
it('rejects because codes can be used only once', async done => { it('rejects because codes can be used only once', async done => {
await action() await action()
try { try {
variables.email = 'yetanotheremail@example.org'
await action() await action()
} catch (e) { } catch (e) {
expect(e.message).toMatch(/Invitation code already used/) expect(e.message).toMatch(/Invitation code already used/)
@ -282,8 +284,8 @@ describe('Signup', () => {
it('creates a Signup with a cryptographic `nonce`', async () => { it('creates a Signup with a cryptographic `nonce`', async () => {
await action() await action()
const emailAddresses = await instance.all('EmailAddress') let emailAddress = await instance.first('EmailAddress', { email: 'someuser@example.org' })
const emailAddress = await emailAddresses.first().toJson() emailAddress = await emailAddress.toJson()
expect(emailAddress.nonce).toEqual(expect.any(String)) expect(emailAddress.nonce).toEqual(expect.any(String))
}) })
}) })
@ -298,7 +300,7 @@ describe('SignupVerification', () => {
} }
` `
describe('given valid password and email', () => { describe('given valid password and email', () => {
let variables = { const variables = {
nonce: '123456', nonce: '123456',
name: 'John Doe', name: 'John Doe',
password: '123', password: '123',

View File

@ -60,7 +60,7 @@ export default {
if (!dbResponse) return null if (!dbResponse) return null
const { report, submitter, resource, type } = dbResponse const { report, submitter, resource, type } = dbResponse
let response = { const response = {
...report.properties, ...report.properties,
post: null, post: null,
comment: null, comment: null,

View File

@ -4,7 +4,7 @@ import { UserInputError } from 'apollo-server'
const instance = neode() const instance = neode()
const getUserAndBadge = async ({ badgeKey, userId }) => { const getUserAndBadge = async ({ badgeKey, userId }) => {
let user = await instance.first('User', 'id', userId) const user = await instance.first('User', 'id', userId)
const badge = await instance.first('Badge', 'id', badgeKey) const badge = await instance.first('Badge', 'id', badgeKey)
if (!user) throw new UserInputError("Couldn't find a user with that id") if (!user) throw new UserInputError("Couldn't find a user with that id")
if (!badge) throw new UserInputError("Couldn't find a badge with that id") if (!badge) throw new UserInputError("Couldn't find a badge with that id")
@ -36,8 +36,6 @@ export default {
userId, userId,
}, },
) )
} catch (err) {
throw err
} finally { } finally {
session.close() session.close()
} }

View File

@ -4,7 +4,7 @@ export default {
const { id, type } = params const { id, type } = params
const session = context.driver.session() const session = context.driver.session()
let transactionRes = await session.run( const transactionRes = await session.run(
`MATCH (node {id: $id})<-[:WROTE]-(userWritten:User), (user:User {id: $userId}) `MATCH (node {id: $id})<-[:WROTE]-(userWritten:User), (user:User {id: $userId})
WHERE $type IN labels(node) AND NOT userWritten.id = $userId WHERE $type IN labels(node) AND NOT userWritten.id = $userId
MERGE (user)-[relation:SHOUTED]->(node) MERGE (user)-[relation:SHOUTED]->(node)
@ -29,7 +29,7 @@ export default {
const { id, type } = params const { id, type } = params
const session = context.driver.session() const session = context.driver.session()
let transactionRes = await session.run( const transactionRes = await session.run(
`MATCH (user:User {id: $userId})-[relation:SHOUTED]->(node {id: $id}) `MATCH (user:User {id: $userId})-[relation:SHOUTED]->(node {id: $id})
WHERE $type IN labels(node) WHERE $type IN labels(node)
DELETE relation DELETE relation

View File

@ -60,8 +60,7 @@ describe('shout', () => {
describe('shout foreign post', () => { describe('shout foreign post', () => {
describe('unauthenticated shout', () => { describe('unauthenticated shout', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
let client const client = new GraphQLClient(host)
client = new GraphQLClient(host)
await expect(client.request(mutationShoutPost('p1'))).rejects.toThrow('Not Authorised') await expect(client.request(mutationShoutPost('p1'))).rejects.toThrow('Not Authorised')
}) })
}) })
@ -109,8 +108,7 @@ describe('shout', () => {
// shout // shout
await clientUser1.request(mutationShoutPost('p2')) await clientUser1.request(mutationShoutPost('p2'))
// unshout // unshout
let client const client = new GraphQLClient(host)
client = new GraphQLClient(host)
await expect(client.request(mutationUnshoutPost('p2'))).rejects.toThrow('Not Authorised') await expect(client.request(mutationUnshoutPost('p2'))).rejects.toThrow('Not Authorised')
}) })
}) })

View File

@ -1,30 +1,38 @@
import { neo4jgraphql } from 'neo4j-graphql-js' import { neode } from '../../bootstrap/neo4j'
import Resolver from './helpers/Resolver'
const instance = neode()
export default { export default {
Mutation: { Mutation: {
CreateSocialMedia: async (object, params, context, resolveInfo) => { CreateSocialMedia: async (object, params, context, resolveInfo) => {
/** const [user, socialMedia] = await Promise.all([
* TODO?: Creates double Nodes! instance.find('User', context.user.id),
*/ instance.create('SocialMedia', params),
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false) ])
const session = context.driver.session() await socialMedia.relateTo(user, 'ownedBy')
await session.run( const response = await socialMedia.toJson()
`MATCH (owner:User {id: $userId}), (socialMedia:SocialMedia {id: $socialMediaId})
MERGE (socialMedia)<-[:OWNED]-(owner)
RETURN owner`,
{
userId: context.user.id,
socialMediaId: socialMedia.id,
},
)
session.close()
return socialMedia return response
}, },
DeleteSocialMedia: async (object, params, context, resolveInfo) => { UpdateSocialMedia: async (object, params, context, resolveInfo) => {
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false) const socialMedia = await instance.find('SocialMedia', params.id)
await socialMedia.update({ url: params.url })
const response = await socialMedia.toJson()
return socialMedia return response
},
DeleteSocialMedia: async (object, { id }, context, resolveInfo) => {
const socialMedia = await instance.find('SocialMedia', id)
if (!socialMedia) return null
await socialMedia.delete()
return socialMedia.toJson()
}, },
}, },
SocialMedia: Resolver('SocialMedia', {
idAttribute: 'url',
hasOne: {
ownedBy: '<-[:OWNED_BY]-(related:User)',
},
}),
} }

View File

@ -1,115 +1,274 @@
import { GraphQLClient } from 'graphql-request' import { createTestClient } from 'apollo-server-testing'
import createServer from '../../server'
import Factory from '../../seed/factories' import Factory from '../../seed/factories'
import { host, login, gql } from '../../jest/helpers' import { gql } from '../../jest/helpers'
import { neode, getDriver } from '../../bootstrap/neo4j'
const driver = getDriver()
const factory = Factory() const factory = Factory()
const instance = neode()
describe('SocialMedia', () => { describe('SocialMedia', () => {
let client let socialMediaAction, someUser, ownerNode, owner
let headers
const mutationC = gql` const ownerParams = {
mutation($url: String!) { email: 'pippi@example.com',
CreateSocialMedia(url: $url) { password: '1234',
id name: 'Pippi Langstrumpf',
url }
}
} const userParams = {
` email: 'kalle@example.com',
const mutationD = gql` password: 'abcd',
mutation($id: ID!) { name: 'Kalle Blomqvist',
DeleteSocialMedia(id: $id) { }
id
url const url = 'https://twitter.com/pippi-langstrumpf'
} const newUrl = 'https://twitter.com/bullerby'
}
` const setUpSocialMedia = async () => {
const socialMediaNode = await instance.create('SocialMedia', { url })
await socialMediaNode.relateTo(ownerNode, 'ownedBy')
return socialMediaNode.toJson()
}
beforeEach(async () => { beforeEach(async () => {
await factory.create('User', { const someUserNode = await instance.create('User', userParams)
avatar: 'https://s3.amazonaws.com/uifaces/faces/twitter/jimmuirhead/128.jpg', someUser = await someUserNode.toJson()
id: 'acb2d923-f3af-479e-9f00-61b12e864666', ownerNode = await instance.create('User', ownerParams)
name: 'Matilde Hermiston', owner = await ownerNode.toJson()
slug: 'matilde-hermiston',
role: 'user', socialMediaAction = async (user, mutation, variables) => {
email: 'test@example.org', const { server } = createServer({
password: '1234', context: () => {
}) return {
user,
driver,
}
},
})
const { mutate } = createTestClient(server)
return mutate({
mutation,
variables,
})
}
}) })
afterEach(async () => { afterEach(async () => {
await factory.cleanDatabase() await factory.cleanDatabase()
}) })
describe('unauthenticated', () => { describe('create social media', () => {
it('throws authorization error', async () => { let mutation, variables
client = new GraphQLClient(host)
const variables = { beforeEach(() => {
url: 'http://nsosp.org', mutation = gql`
} mutation($url: String!) {
await expect(client.request(mutationC, variables)).rejects.toThrow('Not Authorised') CreateSocialMedia(url: $url) {
id
url
}
}
`
variables = { url }
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const user = null
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('authenticated', () => {
let user
beforeEach(() => {
user = owner
})
it('creates social media with the given url', async () => {
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining({
data: {
CreateSocialMedia: {
id: expect.any(String),
url,
},
},
}),
)
})
it('rejects an empty string as url', async () => {
variables = { url: '' }
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0].message).toEqual(
expect.stringContaining('"url" is not allowed to be empty'),
)
})
it('rejects invalid urls', async () => {
variables = { url: 'not-a-url' }
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0].message).toEqual(
expect.stringContaining('"url" must be a valid uri'),
)
})
})
describe('ownedBy', () => {
beforeEach(() => {
mutation = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
url
ownedBy {
name
}
}
}
`
})
it('resolves', async () => {
const user = someUser
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining({
data: {
CreateSocialMedia: { url, ownedBy: { name: 'Kalle Blomqvist' } },
},
}),
)
})
}) })
}) })
describe('authenticated', () => { describe('update social media', () => {
let mutation, variables
beforeEach(async () => { beforeEach(async () => {
headers = await login({ const socialMedia = await setUpSocialMedia()
email: 'test@example.org',
password: '1234', mutation = gql`
}) mutation($id: ID!, $url: String!) {
client = new GraphQLClient(host, { UpdateSocialMedia(id: $id, url: $url) {
headers, id
url
}
}
`
variables = { url: newUrl, id: socialMedia.id }
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const user = null
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
}) })
}) })
it('creates social media with correct URL', async () => { describe('authenticated as other user', () => {
const variables = { it('throws authorization error', async () => {
url: 'http://nsosp.org', const user = someUser
} const result = await socialMediaAction(user, mutation, variables)
await expect(client.request(mutationC, variables)).resolves.toEqual(
expect.objectContaining({ expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
CreateSocialMedia: { })
id: expect.any(String), })
url: 'http://nsosp.org',
describe('authenticated as owner', () => {
let user
beforeEach(() => {
user = owner
})
it('updates social media with the given id', async () => {
const expected = {
data: {
UpdateSocialMedia: { ...variables },
}, },
}), }
)
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining(expected),
)
})
it('does not update if the the given id does not exist', async () => {
variables.id = 'some-id'
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
})
describe('delete social media', () => {
let mutation, variables
beforeEach(async () => {
const socialMedia = await setUpSocialMedia()
mutation = gql`
mutation($id: ID!) {
DeleteSocialMedia(id: $id) {
id
url
}
}
`
variables = { url: newUrl, id: socialMedia.id }
}) })
it('deletes social media', async () => { describe('unauthenticated', () => {
const creationVariables = { it('throws authorization error', async () => {
url: 'http://nsosp.org', const user = null
} const result = await socialMediaAction(user, mutation, variables)
const { CreateSocialMedia } = await client.request(mutationC, creationVariables)
const { id } = CreateSocialMedia
const deletionVariables = { expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
id, })
}
const expected = {
DeleteSocialMedia: {
id: id,
url: 'http://nsosp.org',
},
}
await expect(client.request(mutationD, deletionVariables)).resolves.toEqual(expected)
}) })
it('rejects empty string', async () => { describe('authenticated as other user', () => {
const variables = { it('throws authorization error', async () => {
url: '', const user = someUser
} const result = await socialMediaAction(user, mutation, variables)
await expect(client.request(mutationC, variables)).rejects.toThrow(
'"url" is not allowed to be empty', expect(result.errors[0]).toHaveProperty('message', 'Not Authorised!')
) })
}) })
it('validates URLs', async () => { describe('authenticated as owner', () => {
const variables = { let user
url: 'not-a-url',
}
await expect(client.request(mutationC, variables)).rejects.toThrow( beforeEach(async () => {
'"url" must be a valid uri', user = owner
) })
it('deletes social media with the given id', async () => {
const expected = {
data: {
DeleteSocialMedia: {
id: variables.id,
url,
},
},
}
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining(expected),
)
})
}) })
}) })
}) })

View File

@ -1,9 +1,9 @@
export const query = (cypher, session) => { export const query = (cypher, session) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let data = [] const data = []
session.run(cypher).subscribe({ session.run(cypher).subscribe({
onNext: function(record) { onNext: function(record) {
let item = {} const item = {}
record.keys.forEach(key => { record.keys.forEach(key => {
item[key] = record.get(key) item[key] = record.get(key)
}) })
@ -34,7 +34,7 @@ const queryOne = (cypher, session) => {
export default { export default {
Query: { Query: {
statistics: async (parent, args, { driver, user }) => { statistics: async (parent, args, { driver, user }) => {
return new Promise(async resolve => { return new Promise(resolve => {
const session = driver.session() const session = driver.session()
const queries = { const queries = {
countUsers: countUsers:
@ -54,18 +54,24 @@ export default {
countFollows: 'MATCH (:User)-[r:FOLLOWS]->(:User) RETURN COUNT(r) AS countFollows', countFollows: 'MATCH (:User)-[r:FOLLOWS]->(:User) RETURN COUNT(r) AS countFollows',
countShouts: 'MATCH (:User)-[r:SHOUTED]->(:Post) RETURN COUNT(r) AS countShouts', countShouts: 'MATCH (:User)-[r:SHOUTED]->(:Post) RETURN COUNT(r) AS countShouts',
} }
let data = { const data = {
countUsers: (await queryOne(queries.countUsers, session)).countUsers.low, countUsers: queryOne(queries.countUsers, session).then(res => res.countUsers.low),
countPosts: (await queryOne(queries.countPosts, session)).countPosts.low, countPosts: queryOne(queries.countPosts, session).then(res => res.countPosts.low),
countComments: (await queryOne(queries.countComments, session)).countComments.low, countComments: queryOne(queries.countComments, session).then(
countNotifications: (await queryOne(queries.countNotifications, session)) res => res.countComments.low,
.countNotifications.low, ),
countOrganizations: (await queryOne(queries.countOrganizations, session)) countNotifications: queryOne(queries.countNotifications, session).then(
.countOrganizations.low, res => res.countNotifications.low,
countProjects: (await queryOne(queries.countProjects, session)).countProjects.low, ),
countInvites: (await queryOne(queries.countInvites, session)).countInvites.low, countOrganizations: queryOne(queries.countOrganizations, session).then(
countFollows: (await queryOne(queries.countFollows, session)).countFollows.low, res => res.countOrganizations.low,
countShouts: (await queryOne(queries.countShouts, session)).countShouts.low, ),
countProjects: queryOne(queries.countProjects, session).then(
res => res.countProjects.low,
),
countInvites: queryOne(queries.countInvites, session).then(res => res.countInvites.low),
countFollows: queryOne(queries.countFollows, session).then(res => res.countFollows.low),
countShouts: queryOne(queries.countShouts, session).then(res => res.countShouts.low),
} }
resolve(data) resolve(data)
}) })

View File

@ -2,6 +2,9 @@ import encode from '../../jwt/encode'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { AuthenticationError } from 'apollo-server' import { AuthenticationError } from 'apollo-server'
import { neo4jgraphql } from 'neo4j-graphql-js' import { neo4jgraphql } from 'neo4j-graphql-js'
import { neode } from '../../bootstrap/neo4j'
const instance = neode()
export default { export default {
Query: { Query: {
@ -21,8 +24,8 @@ export default {
// } // }
const session = driver.session() const session = driver.session()
const result = await session.run( const result = await session.run(
'MATCH (user:User {email: $userEmail}) ' + 'MATCH (user:User)-[:PRIMARY_EMAIL]->(e:EmailAddress {email: $userEmail})' +
'RETURN user {.id, .slug, .name, .avatar, .email, .encryptedPassword, .role, .disabled} as user LIMIT 1', 'RETURN user {.id, .slug, .name, .avatar, .encryptedPassword, .role, .disabled, email:e.email} as user LIMIT 1',
{ {
userEmail: email, userEmail: email,
}, },
@ -46,41 +49,24 @@ export default {
} }
}, },
changePassword: async (_, { oldPassword, newPassword }, { driver, user }) => { changePassword: async (_, { oldPassword, newPassword }, { driver, user }) => {
const session = driver.session() const currentUser = await instance.find('User', user.id)
let result = await session.run(
`MATCH (user:User {email: $userEmail})
RETURN user {.id, .email, .encryptedPassword}`,
{
userEmail: user.email,
},
)
const [currentUser] = result.records.map(function(record) { const encryptedPassword = currentUser.get('encryptedPassword')
return record.get('user') if (!(await bcrypt.compareSync(oldPassword, encryptedPassword))) {
})
if (!(await bcrypt.compareSync(oldPassword, currentUser.encryptedPassword))) {
throw new AuthenticationError('Old password is not correct') throw new AuthenticationError('Old password is not correct')
} }
if (await bcrypt.compareSync(newPassword, currentUser.encryptedPassword)) { if (await bcrypt.compareSync(newPassword, encryptedPassword)) {
throw new AuthenticationError('Old password and new password should be different') throw new AuthenticationError('Old password and new password should be different')
} else {
const newEncryptedPassword = await bcrypt.hashSync(newPassword, 10)
session.run(
`MATCH (user:User {email: $userEmail})
SET user.encryptedPassword = $newEncryptedPassword
RETURN user
`,
{
userEmail: user.email,
newEncryptedPassword,
},
)
session.close()
return encode(currentUser)
} }
const newEncryptedPassword = await bcrypt.hashSync(newPassword, 10)
await currentUser.update({
encryptedPassword: newEncryptedPassword,
updatedAt: new Date().toISOString(),
})
return encode(await currentUser.toJson())
}, },
}, },
} }

View File

@ -296,7 +296,7 @@ describe('change password', () => {
describe('correct password', () => { describe('correct password', () => {
it('changes the password if given correct credentials "', async () => { it('changes the password if given correct credentials "', async () => {
let response = await client.request( const response = await client.request(
mutation({ mutation({
oldPassword: '1234', oldPassword: '1234',
newPassword: '12345', newPassword: '12345',

View File

@ -2,69 +2,20 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
import fileUpload from './fileUpload' import fileUpload from './fileUpload'
import { neode } from '../../bootstrap/neo4j' import { neode } from '../../bootstrap/neo4j'
import { UserInputError } from 'apollo-server' import { UserInputError } from 'apollo-server'
import Resolver from './helpers/Resolver'
const instance = neode() const instance = neode()
const _has = (resolvers, { key, connection }, { returnType }) => {
return async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const { id } = parent
const statement = `MATCH(u:User {id: {id}})${connection} RETURN related`
const result = await instance.cypher(statement, { id })
let response = result.records.map(r => r.get('related').properties)
if (returnType === 'object') response = response[0] || null
return response
}
}
const count = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = async (parent, params, context, resolveInfo) => {
if (typeof parent[key] !== 'undefined') return parent[key]
const { id } = parent
const statement = `
MATCH(u:User {id: {id}})${connection}
WHERE NOT related.deleted = true AND NOT related.disabled = true
RETURN COUNT(DISTINCT(related)) as count
`
const result = await instance.cypher(statement, { id })
const [response] = result.records.map(r => r.get('count').toNumber())
return response
}
}
return resolvers
}
const undefinedToNull = list => {
const resolvers = {}
list.forEach(key => {
resolvers[key] = async (parent, params, context, resolveInfo) => {
return typeof parent[key] === 'undefined' ? null : parent[key]
}
})
return resolvers
}
export const hasMany = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _has(resolvers, { key, connection }, { returnType: 'iterable' })
}
return resolvers
}
export const hasOne = obj => {
const resolvers = {}
for (const [key, connection] of Object.entries(obj)) {
resolvers[key] = _has(resolvers, { key, connection }, { returnType: 'object' })
}
return resolvers
}
export default { export default {
Query: { Query: {
User: async (object, args, context, resolveInfo) => { User: async (object, args, context, resolveInfo) => {
const { email } = args
if (email) {
const e = await instance.first('EmailAddress', { email })
let user = e.get('belongsTo')
user = await user.toJson()
return [user.node]
}
return neo4jgraphql(object, args, context, resolveInfo, false) return neo4jgraphql(object, args, context, resolveInfo, false)
}, },
}, },
@ -72,7 +23,7 @@ export default {
UpdateUser: async (object, args, context, resolveInfo) => { UpdateUser: async (object, args, context, resolveInfo) => {
args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' }) args = await fileUpload(args, { file: 'avatarUpload', url: 'avatar' })
try { try {
let user = await instance.find('User', args.id) const user = await instance.find('User', args.id)
if (!user) return null if (!user) return null
await user.update(args) await user.update(args)
return user.toJson() return user.toJson()
@ -104,42 +55,52 @@ export default {
}, },
}, },
User: { User: {
...undefinedToNull([ email: async (parent, params, context, resolveInfo) => {
'actorId', if (typeof parent.email !== 'undefined') return parent.email
'avatar', const { id } = parent
'coverImg', const statement = `MATCH(u:User {id: {id}})-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e`
'deleted', const result = await instance.cypher(statement, { id })
'disabled', const [{ email }] = result.records.map(r => r.get('e').properties)
'locationName', return email
'about', },
]), ...Resolver('User', {
...count({ undefinedToNull: [
contributionsCount: '-[:WROTE]->(related:Post)', 'actorId',
friendsCount: '<-[:FRIENDS]->(related:User)', 'avatar',
followingCount: '-[:FOLLOWS]->(related:User)', 'coverImg',
followedByCount: '<-[:FOLLOWS]-(related:User)', 'deleted',
commentsCount: '-[:WROTE]->(r:Comment)', 'disabled',
commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)', 'locationName',
shoutedCount: '-[:SHOUTED]->(related:Post)', 'about',
badgesCount: '<-[:REWARDED]-(related:Badge)', ],
}), count: {
...hasOne({ contributionsCount: '-[:WROTE]->(related:Post)',
invitedBy: '<-[:INVITED]-(related:User)', friendsCount: '<-[:FRIENDS]->(related:User)',
disabledBy: '<-[:DISABLED]-(related:User)', followingCount: '-[:FOLLOWS]->(related:User)',
}), followedByCount: '<-[:FOLLOWS]-(related:User)',
...hasMany({ commentsCount: '-[:WROTE]->(r:Comment)',
followedBy: '<-[:FOLLOWS]-(related:User)', commentedCount: '-[:WROTE]->(:Comment)-[:COMMENTS]->(related:Post)',
following: '-[:FOLLOWS]->(related:User)', shoutedCount: '-[:SHOUTED]->(related:Post)',
friends: '-[:FRIENDS]-(related:User)', badgesCount: '<-[:REWARDED]-(related:Badge)',
blacklisted: '-[:BLACKLISTED]->(related:User)', },
socialMedia: '-[:OWNED]->(related:SocialMedia)', hasOne: {
contributions: '-[:WROTE]->(related:Post)', invitedBy: '<-[:INVITED]-(related:User)',
comments: '-[:WROTE]->(related:Comment)', disabledBy: '<-[:DISABLED]-(related:User)',
shouted: '-[:SHOUTED]->(related:Post)', },
organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)', hasMany: {
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)', followedBy: '<-[:FOLLOWS]-(related:User)',
categories: '-[:CATEGORIZED]->(related:Category)', following: '-[:FOLLOWS]->(related:User)',
badges: '<-[:REWARDED]-(related:Badge)', friends: '-[:FRIENDS]-(related:User)',
blacklisted: '-[:BLACKLISTED]->(related:User)',
socialMedia: '-[:OWNED_BY]->(related:SocialMedia',
contributions: '-[:WROTE]->(related:Post)',
comments: '-[:WROTE]->(related:Comment)',
shouted: '-[:SHOUTED]->(related:Post)',
organizationsCreated: '-[:CREATED_ORGA]->(related:Organization)',
organizationsOwned: '-[:OWNING_ORGA]->(related:Organization)',
categories: '-[:CATEGORIZED]->(related:Category)',
badges: '<-[:REWARDED]-(related:Badge)',
},
}), }),
}, },
} }

View File

@ -0,0 +1,19 @@
type Embed {
type: String
title: String
author: String
publisher: String
date: String
description: String
url: String
image: String
audio: String
video: String
lang: String
html: String
sources: [String]
}
type Query {
embed(url: String!): Embed
}

View File

@ -21,7 +21,7 @@ const findGqlFiles = dir => {
return results return results
} }
let typeDefs = [] const typeDefs = []
findGqlFiles(__dirname).forEach(file => { findGqlFiles(__dirname).forEach(file => {
typeDefs.push(fs.readFileSync(file).toString('utf-8')) typeDefs.push(fs.readFileSync(file).toString('utf-8'))

View File

@ -132,8 +132,3 @@ type SharedInboxEndpoint {
uri: String uri: String
} }
type SocialMedia {
id: ID!
url: String
ownedBy: [User]! @relation(name: "OWNED", direction: "IN")
}

View File

@ -24,7 +24,7 @@ type Mutation {
): Comment ): Comment
UpdateComment( UpdateComment(
id: ID! id: ID!
content: String content: String!
contentExcerpt: String contentExcerpt: String
deleted: Boolean deleted: Boolean
disabled: Boolean disabled: Boolean

View File

@ -0,0 +1,11 @@
type SocialMedia {
id: ID!
url: String
ownedBy: User! @relation(name: "OWNED_BY", direction: "IN")
}
type Mutation {
CreateSocialMedia(id: ID, url: String!): SocialMedia
UpdateSocialMedia(id: ID!, url: String!): SocialMedia
DeleteSocialMedia(id: ID!): SocialMedia
}

View File

@ -2,7 +2,7 @@ type User {
id: ID! id: ID!
actorId: String actorId: String
name: String name: String
email: String! email: String! @cypher(statement: "MATCH (this)-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e.email")
slug: String! slug: String!
avatar: String avatar: String
coverImg: String coverImg: String
@ -17,7 +17,7 @@ type User {
location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
locationName: String locationName: String
about: String about: String
socialMedia: [SocialMedia]! @relation(name: "OWNED", direction: "OUT") socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "OUT")
#createdAt: DateTime #createdAt: DateTime
#updatedAt: DateTime #updatedAt: DateTime

View File

@ -40,15 +40,13 @@ export const cleanDatabase = async (options = {}) => {
const cypher = 'MATCH (n) DETACH DELETE n' const cypher = 'MATCH (n) DETACH DELETE n'
try { try {
return await session.run(cypher) return await session.run(cypher)
} catch (error) {
throw error
} finally { } finally {
session.close() session.close()
} }
} }
export default function Factory(options = {}) { export default function Factory(options = {}) {
let { const {
seedServerHost = 'http://127.0.0.1:4001', seedServerHost = 'http://127.0.0.1:4001',
neo4jDriver = getDriver(), neo4jDriver = getDriver(),
neodeInstance = neode(), neodeInstance = neode(),

View File

@ -21,7 +21,11 @@ export default function create() {
...args, ...args,
} }
args = await encryptPassword(args) args = await encryptPassword(args)
return neodeInstance.create('User', args) const user = await neodeInstance.create('User', args)
const email = await neodeInstance.create('EmailAddress', { email: args.email })
await user.relateTo(email, 'primaryEmail')
await email.relateTo(user, 'belongsTo')
return user
}, },
} }
} }

View File

@ -37,17 +37,17 @@ const difficulties = ['easy', 'medium', 'hard']
export default { export default {
randomItem: (items, filter) => { randomItem: (items, filter) => {
let ids = filter const ids = filter
? Object.keys(items).filter(id => { ? Object.keys(items).filter(id => {
return filter(items[id]) return filter(items[id])
}) })
: _.keys(items) : _.keys(items)
let randomIds = _.shuffle(ids) const randomIds = _.shuffle(ids)
return items[randomIds.pop()] return items[randomIds.pop()]
}, },
randomItems: (items, key = 'id', min = 1, max = 1) => { randomItems: (items, key = 'id', min = 1, max = 1) => {
let randomIds = _.shuffle(_.keys(items)) const randomIds = _.shuffle(_.keys(items))
let res = [] const res = []
const count = _.random(min, max) const count = _.random(min, max)
@ -86,8 +86,8 @@ export default {
if (allowEmpty === false && count === 0) { if (allowEmpty === false && count === 0) {
count = 1 count = 1
} }
let categorieIds = _.shuffle(_.keys(seederstore.categories)) const categorieIds = _.shuffle(_.keys(seederstore.categories))
let ids = [] const ids = []
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
ids.push(categorieIds.pop()) ids.push(categorieIds.pop())
} }
@ -95,7 +95,7 @@ export default {
}, },
randomAddresses: () => { randomAddresses: () => {
const count = Math.round(Math.random() * 3) const count = Math.round(Math.random() * 3)
let addresses = [] const addresses = []
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
addresses.push({ addresses.push({
city: faker.address.city(), city: faker.address.city(),
@ -116,7 +116,7 @@ export default {
* @param key the field key that is represented in the values (slug, name, etc.) * @param key the field key that is represented in the values (slug, name, etc.)
*/ */
mapIdsByKey: (items, values, key) => { mapIdsByKey: (items, values, key) => {
let res = [] const res = []
values.forEach(value => { values.forEach(value => {
res.push(_.find(items, [key, value]).id.toString()) res.push(_.find(items, [key, value]).id.toString())
}) })

File diff suppressed because it is too large Load Diff

View File

@ -1,73 +1,70 @@
import { When, Then } from 'cypress-cucumber-preprocessor/steps' import { When, Then } from "cypress-cucumber-preprocessor/steps";
When('I search for {string}', value => { When("I search for {string}", value => {
cy.get('#nav-search') cy.get("#nav-search")
.focus() .focus()
.type(value) .type(value);
}) });
Then('I should have one post in the select dropdown', () => { Then("I should have one post in the select dropdown", () => {
cy.get('.ds-select-dropdown').should($li => { cy.get(".input .ds-select-dropdown").should($li => {
expect($li).to.have.length(1) expect($li).to.have.length(1);
}) });
}) });
Then('I should see the following posts in the select dropdown:', table => { Then("I should see the following posts in the select dropdown:", table => {
table.hashes().forEach(({ title }) => { table.hashes().forEach(({ title }) => {
cy.get('.ds-select-dropdown').should('contain', title) cy.get(".ds-select-dropdown").should("contain", title);
}) });
}) });
When('I type {string} and press Enter', value => { When("I type {string} and press Enter", value => {
cy.get('#nav-search') cy.get("#nav-search")
.focus() .focus()
.type(value) .type(value)
.type('{enter}', { force: true }) .type("{enter}", { force: true });
}) });
When('I type {string} and press escape', value => { When("I type {string} and press escape", value => {
cy.get('#nav-search') cy.get("#nav-search")
.focus() .focus()
.type(value) .type(value)
.type('{esc}') .type("{esc}");
}) });
Then('the search field should clear', () => { Then("the search field should clear", () => {
cy.get('#nav-search').should('have.text', '') cy.get("#nav-search").should("have.text", "");
}) });
When('I select an entry', () => { When("I select an entry", () => {
cy.get('.ds-select-dropdown ul li') cy.get(".input .ds-select-dropdown ul li")
.first() .first()
.trigger('click') .trigger("click");
}) });
Then("I should be on the post's page", () => { Then("I should be on the post's page", () => {
cy.location('pathname').should( cy.location("pathname").should("contain", "/post/");
'contain', cy.location("pathname").should(
'/post/' "eq",
) "/post/p1/101-essays-that-will-change-the-way-you-think"
cy.location('pathname').should( );
'eq', });
'/post/p1/101-essays-that-will-change-the-way-you-think'
)
})
Then( Then(
'I should see posts with the searched-for term in the select dropdown', "I should see posts with the searched-for term in the select dropdown",
() => { () => {
cy.get('.ds-select-dropdown').should( cy.get(".ds-select-dropdown").should(
'contain', "contain",
'101 Essays that will change the way you think' "101 Essays that will change the way you think"
) );
} }
) );
Then( Then(
'I should not see posts without the searched-for term in the select dropdown', "I should not see posts without the searched-for term in the select dropdown",
() => { () => {
cy.get('.ds-select-dropdown').should( cy.get(".ds-select-dropdown").should(
'not.contain', "not.contain",
'No searched for content' "No searched for content"
) );
} }
) );

View File

@ -79,7 +79,7 @@ Then('I should be on the {string} page', page => {
}) })
When('I add a social media link', () => { When('I add a social media link', () => {
cy.get("input[name='social-media']") cy.get('input#addSocialMedia')
.type('https://freeradical.zone/peter-pan') .type('https://freeradical.zone/peter-pan')
.get('button') .get('button')
.contains('Add link') .contains('Add link')
@ -98,7 +98,7 @@ Then('the new social media link shows up on the page', () => {
Given('I have added a social media link', () => { Given('I have added a social media link', () => {
cy.openPage('/settings/my-social-media') cy.openPage('/settings/my-social-media')
.get("input[name='social-media']") .get('input#addSocialMedia')
.type('https://freeradical.zone/peter-pan') .type('https://freeradical.zone/peter-pan')
.get('button') .get('button')
.contains('Add link') .contains('Add link')
@ -121,3 +121,34 @@ Then('it gets deleted successfully', () => {
cy.get('.iziToast-message') cy.get('.iziToast-message')
.should('contain', 'Deleted social media') .should('contain', 'Deleted social media')
}) })
When('I start editing a social media link', () => {
cy.get("a[name='edit']")
.click()
})
Then('I can cancel editing', () => {
cy.get('button#cancel')
.click()
.get('input#editSocialMedia')
.should('have.length', 0)
})
When('I edit and save the link', () => {
cy.get('input#editSocialMedia')
.clear()
.type('https://freeradical.zone/tinkerbell')
.get('button')
.contains('Save')
.click()
})
Then('the new url is displayed', () => {
cy.get("a[href='https://freeradical.zone/tinkerbell']")
.should('have.length', 1)
})
Then('the old url is not displayed', () => {
cy.get("a[href='https://freeradical.zone/peter-pan']")
.should('have.length', 0)
})

View File

@ -15,7 +15,7 @@ Feature: List Social Media Accounts
Then it gets saved successfully Then it gets saved successfully
And the new social media link shows up on the page And the new social media link shows up on the page
Scenario: Other user's viewing my Social Media Scenario: Other users viewing my Social Media
Given I have added a social media link Given I have added a social media link
When people visit my profile page When people visit my profile page
Then they should be able to see my social media links Then they should be able to see my social media links
@ -27,3 +27,16 @@ Feature: List Social Media Accounts
Given I have added a social media link Given I have added a social media link
When I delete a social media link When I delete a social media link
Then it gets deleted successfully Then it gets deleted successfully
Scenario: Editing Social Media
Given I am on the "settings" page
And I click on the "Social media" link
Then I should be on the "/settings/my-social-media" page
Given I have added a social media link
When I start editing a social media link
Then I can cancel editing
When I start editing a social media link
And I edit and save the link
Then it gets saved successfully
And the new url is displayed
But the old url is not displayed

View File

@ -0,0 +1,3 @@
FROM nginx:alpine
COPY ./onourjourney.svg /usr/share/nginx/html/
COPY ./maintenance.html /usr/share/nginx/html/index.html

View File

@ -0,0 +1,43 @@
# Maintenance mode
> Despite our best efforts, systems sometimes require downtime for a variety of reasons.
Quote from [here](https://www.nrmitchi.com/2017/11/easy-maintenance-mode-in-kubernetes/)
We use our maintenance mode for manual database backup and restore. Also we
bring the database into maintenance mode for manual database migrations.
## Deploy the service
We prepared sample configuration, so you can simply run:
```sh
# in folder deployment/
kubectl apply -f human-connection/maintenance
```
This will fire up a maintenance service.
## Bring application into maintenance mode
Now if you want to have a controlled downtime and you want to bring your
application into maintenance mode, you can edit your global ingress server.
E.g. in file `deployment/digital-ocean/https/ingress.yaml` change the following:
```yaml
...
- host: nitro-staging.human-connection.org
http:
paths:
- path: /
backend:
# serviceName: nitro-web
serviceName: maintenance
# servicePort: 3000
servicePort: 80
```
Then run ` kubectl apply -f deployment/digital-ocean/https/ingress.yaml`. If you
want to deactivate the maintenance server, just undo the edit and apply the
configuration again.

View File

@ -0,0 +1,27 @@
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: maintenance
namespace: human-connection
spec:
selector:
matchLabels:
human-connection.org/selector: deployment-human-connection-maintenance
template:
metadata:
labels:
human-connection.org/commit: COMMIT
human-connection.org/selector: deployment-human-connection-maintenance
name: maintenance
spec:
containers:
- name: web
env:
- name: HOST
value: 0.0.0.0
image: humanconnection/maintenance:latest
ports:
- containerPort: 80
imagePullPolicy: Always
restartPolicy: Always
terminationGracePeriodSeconds: 30

View File

@ -0,0 +1,61 @@
<head>
<style>
.body {
background-color: #f5f4f6;
}
.left-header,
.right-header {
font-family: "LatoWeb", sans-serif;
line-height: 1.3;
}
@media only screen and (min-width: 960px) {
.outer-div {
margin: 3em;
vertical-align: middle;
text-align: center;
}
.left-header {
float: left;
width: 25%;
margin-right: 1em;
}
.image {
width: 35em;
}
.right-header {
float: right;
width: 25%;
margin: 12em 1em 5em 2em;
}
.email-link {
margin-left: -5em;
}
}
@media only screen and (max-width: 960px) {
.outer-div {
margin: 3em;
vertical-align: middle;
text-align: center;
}
.image {
margin: 2em 0em;
}
}
</style>
</head>
<body class="body">
<div class="outer-div">
<h1 class="left-header">
At the moment we are doing some scheduled maintenance, please try again
later.
</h1>
<img src="./onourjourney.svg" alt="Maintenance mode image" class="image" />
<h1 class="right-header">
Any Questions or concerns, send an email to <br />
<a href="mailto:info@human-connection.org" class="email-link"
>info@human-connection.org</a
>
</h1>
</div>
</body>

View File

@ -0,0 +1,360 @@
<svg xmlns="http://www.w3.org/2000/svg" id="Back" viewBox="0 0 600 570">
<defs>
<style>
.cls-1,.cls-12{fill:#fff;}.cls-2,.cls-4{fill:#dfeff4;}.cls-3{fill:#fddb00;}.cls-12,.cls-13,.cls-14,.cls-15,.cls-16,.cls-18,.cls-19,.cls-20,.cls-21,.cls-23,.cls-24,.cls-26,.cls-27,.cls-28,.cls-29,.cls-30,.cls-32,.cls-34,.cls-35,.cls-36,.cls-37,.cls-38,.cls-39,.cls-4,.cls-40,.cls-41,.cls-42,.cls-44,.cls-45,.cls-46,.cls-47,.cls-48,.cls-50,.cls-51,.cls-52,.cls-53,.cls-55,.cls-56,.cls-57,.cls-58,.cls-59,.cls-64,.cls-67,.cls-68,.cls-9,.cls-96,.cls-97{fill-rule:evenodd;}.cls-5{mask:url(#mask);}.cls-6,.cls-7{fill:#9dca00;}.cls-6{opacity:0.5;}.cls-14,.cls-8{fill:#471e04;}.cls-9{fill:#70a30a;}.cls-10,.cls-16{fill:#f5b800;}.cls-11,.cls-21{fill:#ff9;}.cls-13{fill:#5b8408;}.cls-15{fill:#fc0;}.cls-17,.cls-48{fill:#f1502d;}.cls-18{fill:#f6aa32;}.cls-19{fill:#cc3;}.cls-20,.cls-63{fill:#f5e68a;}.cls-22,.cls-58{fill:#9cc;}.cls-23,.cls-62{fill:#a44624;}.cls-24,.cls-43{fill:#333;}.cls-25,.cls-45{fill:#d4a15e;}.cls-26{fill:#c4d621;}.cls-27{fill:#4e2111;}.cls-28{fill:#78331a;}.cls-30{fill:#058e7f;}.cls-31{fill:#fcda9a;}.cls-32{fill:#07b09d;}.cls-33,.cls-40{fill:#d63d3d;}.cls-34{fill:#3b696a;}.cls-35{fill:#a6d0d1;}.cls-36{fill:#d48439;}.cls-37{fill:#9e5a29;}.cls-38{fill:#e44b4b;}.cls-39{fill:#b0642e;}.cls-41{fill:#f66;}.cls-42,.cls-61{fill:#f2d291;}.cls-44{fill:#fcc;}.cls-46{fill:#edba68;}.cls-47{fill:#366;}.cls-49,.cls-50{fill:#1f0f05;}.cls-51{fill:#804921;}.cls-52,.cls-65{fill:#ff6;}.cls-53{fill:#f69883;}.cls-54,.cls-55{fill:#ff1f1f;}.cls-56{fill:#aac905;}.cls-57{fill:#f5e97d;}.cls-59,.cls-60{fill:#bf3611;}.cls-64{fill:#eccf7c;}.cls-66{fill:#ff441f;}.cls-67{fill:#88b02c;}.cls-68{fill:#500d00;}.cls-69{fill:#216194;}.cls-70{fill:#017f9f;}.cls-71{fill:#76b629;}.cls-72{fill:#a53589;}.cls-73{fill:#024f9d;}.cls-74{fill:#50568c;}.cls-75{fill:#c6d200;}.cls-76{fill:#0369b2;}.cls-77{fill:#007d75;}.cls-78{fill:#008d6d;}.cls-79{fill:#008051;}.cls-80{fill:#46942b;}.cls-81{fill:#d5146e;}.cls-82{fill:#15a338;}.cls-83{fill:#0080c7;}.cls-84{fill:#0089d0;}.cls-85{fill:#e9bb00;}.cls-86{fill:#007632;}.cls-87{fill:#c51553;}.cls-88{fill:#593872;}.cls-89{fill:#f19000;}.cls-90{fill:#f9b800;}.cls-91{fill:#e43311;}.cls-92{fill:#b7c200;}.cls-93{fill:#e64e10;}.cls-94{fill:#079de1;}.cls-95{fill:#93bf1f;}.cls-96{fill:#8fdbf3;}.cls-97{fill:#c20212;}.cls-98{fill:#152e56;}
</style>
<mask id="mask" width="1087.71" height="713.35" x="-161.4" y="91.4" maskUnits="userSpaceOnUse">
<circle cx="301.72" cy="330.39" r="238.99" class="cls-1"/>
</mask>
</defs>
<circle cx="300" cy="332.01" r="238.99" class="cls-2"/>
<circle cx="438.86" cy="295.31" r="80.84" class="cls-3"/>
<path d="M538.13 168.86a24.22 24.22 0 0 0-19.13 9.39 19.58 19.58 0 0 0-19 16.1 11.59 11.59 0 1 0-1.77 23c28.22 0 62.52-1.53 93.54-1.53a8.94 8.94 0 1 0-1.8-17.7 18.13 18.13 0 0 0-29.2-13.77 24.27 24.27 0 0 0-22.64-15.49zM82.63 147.25a30.27 30.27 0 0 1 24 11.73 24.46 24.46 0 0 1 23.7 20.12 14.48 14.48 0 1 1 2.21 28.79c-35.26 0-78.12-1.92-116.88-1.92a11.17 11.17 0 1 1 2.25-22.11 22.66 22.66 0 0 1 36.48-17.21 30.33 30.33 0 0 1 28.24-19.4zM89.91 50.71a10.35 10.35 0 0 0-8.19 4 8.36 8.36 0 0 0-8.1 6.88 5 5 0 0 0-.75-.06 5 5 0 1 0 0 9.9c12.06 0 26.71-.65 40-.65a3.82 3.82 0 1 0 0-7.64 3.87 3.87 0 0 0-.77.08 7.75 7.75 0 0 0-12.47-5.88 10.37 10.37 0 0 0-9.72-6.63z" class="cls-4"/>
<g class="cls-5">
<ellipse cx="189.6" cy="640.52" class="cls-6" rx="350.99" ry="164.22"/>
<ellipse cx="254.61" cy="625.53" class="cls-7" rx="349.38" ry="127.57"/>
<ellipse cx="575.32" cy="640.52" class="cls-6" rx="350.99" ry="164.22"/>
<ellipse cx="370.48" cy="648.66" class="cls-7" rx="272.66" ry="127.57"/>
</g>
<path d="M152.94 477.67h4.13v25.81h-4.13z" class="cls-8"/>
<path d="M135.69 472a5.9 5.9 0 0 1 .8-2.44 6.45 6.45 0 0 1-2.81-5.92 5.8 5.8 0 0 1 3-4.56 6.22 6.22 0 0 1-.37-2.81 5.81 5.81 0 0 1 5.14-5.24 6.2 6.2 0 0 1-.18-2.22 5.85 5.85 0 0 1 6.74-5.22 6.39 6.39 0 0 1 2.22.75c2.55-3.23 9.66-4.25 12.46.19a6 6 0 0 1 1.52-.51 6.48 6.48 0 0 1 7.41 4.92 6.14 6.14 0 0 1 0 2.6 6.55 6.55 0 0 1 4.54 4.78 6 6 0 0 1-1.33 5.35 6.45 6.45 0 0 1 1.26 2.58 5.89 5.89 0 0 1-3.1 6.69 6.43 6.43 0 0 1 .89 2.08 5.86 5.86 0 0 1-4.6 7.19 6.15 6.15 0 0 1-2.31 0c-3.64 7-11.92 3.56-12.59.39a5.87 5.87 0 0 1-6.2 3.26 6.54 6.54 0 0 1-5.38-4.86 6.18 6.18 0 0 1-1.63 0 6.49 6.49 0 0 1-5.48-7z" class="cls-9"/>
<circle cx="152.62" cy="452.42" r="2.69" class="cls-10"/>
<circle cx="163.65" cy="456.62" r="2.69" class="cls-10"/>
<circle cx="147.37" cy="471.06" r="2.69" class="cls-10"/>
<circle cx="159.98" cy="472.64" r="2.69" class="cls-10"/>
<circle cx="154.46" cy="463.45" r="2.69" class="cls-10"/>
<circle cx="167.46" cy="466.07" r="2.69" class="cls-10"/>
<circle cx="144.88" cy="460.16" r="2.69" class="cls-10"/>
<path d="M396.3 510.23h3.08v19.27h-3.08z" class="cls-8"/>
<path d="M383.42 506a4.4 4.4 0 0 1 .6-1.82 4.82 4.82 0 0 1-2.1-4.42 4.33 4.33 0 0 1 2.24-3.41 4.64 4.64 0 0 1-.28-2.1 4.34 4.34 0 0 1 3.84-3.91 4.63 4.63 0 0 1-.14-1.66 4.37 4.37 0 0 1 5-3.89 4.77 4.77 0 0 1 1.65.56c1.9-2.41 7.21-3.17 9.31.14a4.44 4.44 0 0 1 1.14-.38 4.84 4.84 0 0 1 5.53 3.67 4.58 4.58 0 0 1 0 1.94 4.89 4.89 0 0 1 3.39 3.57 4.45 4.45 0 0 1-1 4 4.82 4.82 0 0 1 .94 1.93 4.4 4.4 0 0 1-2.31 5 4.8 4.8 0 0 1 .66 1.55 4.38 4.38 0 0 1-3.43 5.37 4.59 4.59 0 0 1-1.72 0c-2.72 5.24-8.9 2.66-9.4.29a4.38 4.38 0 0 1-4.63 2.43 4.88 4.88 0 0 1-4-3.63 4.62 4.62 0 0 1-1.22 0 4.85 4.85 0 0 1-4.07-5.23z" class="cls-9"/>
<circle cx="396.06" cy="491.37" r="2.01" class="cls-11"/>
<circle cx="404.3" cy="494.51" r="2.01" class="cls-11"/>
<circle cx="392.14" cy="505.3" r="2.01" class="cls-11"/>
<circle cx="401.56" cy="506.47" r="2.01" class="cls-11"/>
<circle cx="397.44" cy="499.61" r="2.01" class="cls-11"/>
<circle cx="407.15" cy="501.57" r="2.01" class="cls-11"/>
<circle cx="390.28" cy="497.16" r="2.01" class="cls-11"/>
<path d="M446.95 488.05h1.66v10.4h-1.66z" class="cls-8"/>
<path d="M440 485.76a2.38 2.38 0 0 1 .32-1 2.6 2.6 0 0 1-1.13-2.38 2.34 2.34 0 0 1 1.21-1.84 2.51 2.51 0 0 1-.15-1.13 2.34 2.34 0 0 1 2.07-2.11 2.5 2.5 0 0 1-.07-.9 2.36 2.36 0 0 1 2.72-2.1 2.58 2.58 0 0 1 .89.3c1-1.3 3.89-1.71 5 .08a2.4 2.4 0 0 1 .61-.2 2.61 2.61 0 0 1 3 2 2.47 2.47 0 0 1 0 1 2.64 2.64 0 0 1 1.83 1.93 2.4 2.4 0 0 1-.53 2.16 2.6 2.6 0 0 1 .51 1 2.37 2.37 0 0 1-1.25 2.7 2.59 2.59 0 0 1 .36.84 2.36 2.36 0 0 1-1.85 2.9 2.48 2.48 0 0 1-.93 0c-1.47 2.83-4.8 1.43-5.08.16a2.37 2.37 0 0 1-2.5 1.31 2.64 2.64 0 0 1-2.17-2 2.49 2.49 0 0 1-.66 0 2.62 2.62 0 0 1-2.2-2.72z" class="cls-9"/>
<circle cx="446.83" cy="477.87" r="1.09" class="cls-11"/>
<circle cx="451.27" cy="479.56" r="1.09" class="cls-11"/>
<circle cx="444.71" cy="485.39" r="1.09" class="cls-11"/>
<circle cx="449.79" cy="486.02" r="1.09" class="cls-11"/>
<circle cx="447.57" cy="482.32" r="1.09" class="cls-11"/>
<circle cx="452.81" cy="483.38" r="1.09" class="cls-11"/>
<circle cx="443.7" cy="480.99" r="1.09" class="cls-11"/>
<path d="M193.42 486.85h2.94v18.37h-2.94z" class="cls-8"/>
<path d="M181.15 482.81a4.2 4.2 0 0 1 .57-1.74 4.59 4.59 0 0 1-2-4.21 4.13 4.13 0 0 1 2.14-3.25 4.43 4.43 0 0 1-.27-2 4.14 4.14 0 0 1 3.66-3.73 4.41 4.41 0 0 1-.13-1.58 4.16 4.16 0 0 1 4.8-3.71 4.55 4.55 0 0 1 1.58.53c1.81-2.3 6.88-3 8.87.13a4.23 4.23 0 0 1 1.08-.36 4.61 4.61 0 0 1 5.27 3.5 4.37 4.37 0 0 1 0 1.85 4.66 4.66 0 0 1 3.23 3.4 4.24 4.24 0 0 1-.94 3.81 4.59 4.59 0 0 1 .9 1.84 4.19 4.19 0 0 1-2.21 4.77 4.58 4.58 0 0 1 .63 1.48 4.17 4.17 0 0 1-3.27 5.12 4.38 4.38 0 0 1-1.64 0c-2.59 5-8.48 2.53-9 .28a4.18 4.18 0 0 1-4.41 2.32 4.66 4.66 0 0 1-3.83-3.46 4.4 4.4 0 0 1-1.16 0 4.62 4.62 0 0 1-3.87-4.99z" class="cls-9"/>
<circle cx="193.2" cy="468.87" r="1.92" class="cls-10"/>
<circle cx="201.05" cy="471.86" r="1.92" class="cls-10"/>
<circle cx="189.46" cy="482.14" r="1.92" class="cls-10"/>
<circle cx="198.43" cy="483.27" r="1.92" class="cls-10"/>
<circle cx="194.51" cy="476.72" r="1.92" class="cls-10"/>
<circle cx="203.76" cy="478.59" r="1.92" class="cls-10"/>
<circle cx="187.68" cy="474.38" r="1.92" class="cls-10"/>
<path d="M532.92 91.83a17.85 17.85 0 0 0-14.13 6.92 14.43 14.43 0 0 0-14 11.87 8.67 8.67 0 0 0-1.3-.1 8.54 8.54 0 1 0 0 17.08c20.8 0 46.08-1.13 68.95-1.13a6.59 6.59 0 1 0 0-13.18 6.68 6.68 0 0 0-1.32.13 13.36 13.36 0 0 0-21.52-10.15 17.89 17.89 0 0 0-16.68-11.44z" class="cls-4"/>
<path d="M449.81 370.21a17.85 17.85 0 0 0-14.13 6.92 14.43 14.43 0 0 0-14 11.87 8.67 8.67 0 0 0-1.3-.1 8.54 8.54 0 1 0 0 17.08c20.8 0 46.08-1.13 68.95-1.13a6.59 6.59 0 1 0 0-13.18 6.68 6.68 0 0 0-1.32.13 13.36 13.36 0 0 0-21.52-10.15 17.89 17.89 0 0 0-16.68-11.44zM153 369.27a15.23 15.23 0 0 0-12.05 5.9A12.31 12.31 0 0 0 129 385.3a7.4 7.4 0 0 0-1.11-.08 7.29 7.29 0 1 0 0 14.57c17.74 0 39.31-1 58.82-1a5.62 5.62 0 1 0 0-11.24 5.7 5.7 0 0 0-1.13.11 11.4 11.4 0 0 0-18.34-8.66 15.26 15.26 0 0 0-14.24-9.73zM215.67 220.63a23.91 23.91 0 0 0-18.92 9.26A19.32 19.32 0 0 0 178 245.78a11.61 11.61 0 0 0-1.74-.13 11.44 11.44 0 1 0 0 22.87c27.85 0 61.7-1.51 92.32-1.51a8.82 8.82 0 1 0 0-17.65 8.94 8.94 0 0 0-1.77.18A17.9 17.9 0 0 0 238 235.95a24 24 0 0 0-22.33-15.32z" class="cls-12"/>
<path d="M196.6 523.67h8.37l.04-8.41-8.41 8.41z" class="cls-13"/>
<path d="M199.32 529.07H205a.81.81 0 1 1 0 1.61h-5.65v2.37h-1.61v-12.59a.81.81 0 1 1 1.61 0v5.38H205a.81.81 0 0 1 0 1.61h-5.65v1.61z" class="cls-14"/>
<path d="M204.97 533.1v-19.36l19.36-19.36 25.81 25.82v12.9h-45.17z" class="cls-15"/>
<path d="M250.14 520.2v12.9h35.85v-19.36h-23.64l6.04 6.46h-18.25z" class="cls-16"/>
<path d="M270 494.38h-45.67l25.81 25.82h18.25l-6.04-6.46h27.01L270 494.38z" class="cls-13"/>
<path d="M250.34 485.34h10.67v17.28h-10.67z" class="cls-10"/>
<path d="M247.38 480.93h16.6v4.71h-16.6z" class="cls-10"/>
<path d="M210.84 514h5.77v13.22h-5.77zM221.14 514h5.77v13.22h-5.77z" class="cls-1"/>
<path d="M233.13 517.09h5.77v16.01h-5.77z" class="cls-17"/>
<path d="M256.24 523.79h5.77v5.17h-5.77zM272.49 518.03h9.61v10.14h-9.61z" class="cls-1"/>
<path d="M272.49 522.58a.81.81 0 0 1 0-1.61h9.61a.81.81 0 0 1 0 1.61h-9.61z" class="cls-18"/>
<path d="M223.61 493.63a1.07 1.07 0 0 1 1.52 0L251 519.54a1.07 1.07 0 0 1-1.52 1.52l-25.1-25.16-28.28 28.28a1.07 1.07 0 1 1-1.52-1.52z" class="cls-19"/>
<path d="M250.14 521.27a1.07 1.07 0 0 1 0-2.15h18.25a1.07 1.07 0 0 1 0 2.15h-18.25zM262.35 514.82a1.07 1.07 0 0 1 0-2.15h27a1.07 1.07 0 0 1 0 2.15h-27z" class="cls-19"/>
<path d="M356.8 421.12c-4.28 4.22-9.41 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
<path d="M356.8 421.12c-4.28 4.22-9.41 9.69-10.45 15a9.75 9.75 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1z" class="cls-21"/>
<path d="M239.15 407.15c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
<path d="M239.15 407.15c-4.28 4.22-9.4 9.69-10.45 15a9.76 9.76 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1z" class="cls-21"/>
<path d="M332.887 408.086l44.848-153.565 3.388.99-44.847 153.565zM269.147 242.175l3.526-.168 7.648 159.797-3.525.168zM356.93 408.03l59.335-148.57 3.278 1.31-59.334 148.57zM217.413 236.783l3.494-.505 22.9 158.333-3.493.506z" class="cls-22"/>
<path d="M314.19 383.78a12 12 0 0 0 4.1 3c2.34-5.06 5.21-14.71 8-23.87l-5.25-.62a150.41 150.41 0 0 1-6.85 21.49z" class="cls-23"/>
<path d="M322.53 385.92l22.27 2.64 3.2-27.26a5 5 0 0 0-4.35-5.53l-12.4-1.47a5 5 0 0 0-5.53 4.35z" class="cls-16"/>
<path d="M320 411.45l8.78 1a19 19 0 0 1-.15 6.56l-15.36-1.82c.42-3.49 6.52-3.58 6.73-5.74z" class="cls-24"/>
<path d="M313.073 417.213l.152-1.28 15.78 1.873-.153 1.28z" class="cls-25"/>
<path d="M341.25 414l-8.78-1a19 19 0 0 0-1.39 6.42l15.36 1.82c.47-3.6-5.44-5.11-5.19-7.24z" class="cls-24"/>
<path d="M330.913 419.318l.152-1.28 15.78 1.872-.153 1.28z" class="cls-25"/>
<path d="M345.19 385.57l-22.33-2.65-3.38 28.51 10.07 1.19 2.12-24.41 3.72.44-3.65 24.23 10.07 1.19z" class="cls-13"/>
<path d="M326.46 383.6l1.13 1.25-4.54 4.37-1.13-1.25 4.54-4.37zM341.52 385.39l-1.39.95 3.39 5.31 1.39-.95-3.39-5.31z" class="cls-26"/>
<path d="M343.56 355.76v.34l-.08.2a6.28 6.28 0 0 1-12-1.43v-.55l2.88.34a3.42 3.42 0 0 0 1 1.72 3.42 3.42 0 0 0 4.09.49 3.42 3.42 0 0 0 1.34-1.45z" class="cls-12"/>
<path d="M333.27 351.79l9.07 1.08c0 .11 0 .23.06.34l-.3 2.56a4.85 4.85 0 0 1-9.28-1.1l.3-2.56z" class="cls-23"/>
<path d="M333.31 352.53l9 .34c0 .11 0 .23.06.34l-.3 2.56a4.85 4.85 0 0 1-3.91 3z" class="cls-27"/>
<path d="M320.14 363.94l5.38 1.68 3.44-11c-4.05-1.2-6.42 2.49-7.05 4.29z" class="cls-16"/>
<path d="M341.24 323.41c-3-.29-12.18-1.09-13.6 9.52s.39 14.77 1.64 16.53a11 11 0 0 0 5.42 4.54 4.44 4.44 0 0 0 2.73 1.57z" class="cls-23"/>
<path d="M341.24 323.41c3 .43 12.1 1.8 11 12.44s-3.84 14.27-5.46 15.69a11 11 0 0 1-6.32 3.09 4.44 4.44 0 0 1-3 .89z" class="cls-28"/>
<path d="M328.65 338.55a1.89 1.89 0 0 0-1.32-1.56c-.86-.29-2.5 0-2.79 2.46s1.53 4.87 2.88 4.73 1.27-4.95 1.23-5.63z" class="cls-23"/>
<path d="M328 339.3a1.47 1.47 0 0 0-.92-1c-.52-.18-1.32 0-1.5 1.51s.73 2.95 1.55 2.86a1 1 0 0 0 .57-.84 1.15 1.15 0 0 1-.53-1.4 1.26 1.26 0 0 1 .82-.88c.01-.15.01-.25.01-.25zM349.74 341.07a2.42 2.42 0 0 1 1.85-1.2c.9-.08 2.44.56 2.14 3s-2.63 4.38-3.9 3.93-.09-5.73-.09-5.73z" class="cls-28"/>
<path d="M350.42 342a1.47 1.47 0 0 1 1.13-.73c.55 0 1.29.31 1.11 1.82s-1.4 2.7-2.18 2.42a1 1 0 0 1-.36-1 1.15 1.15 0 0 0 .84-1.24 1.26 1.26 0 0 0-.59-1z" class="cls-27"/>
<path d="M343.81 338.4l1.85.22a.66.66 0 0 0 .73-.58.66.66 0 0 0-.58-.73l-1.85-.22a.66.66 0 0 0-.73.58.66.66 0 0 0 .58.73z" class="cls-29"/>
<path d="M343.89 346.63c-.24 2-2.83 3.42-5.77 3.07s-5.14-2.29-4.89-4.33z" class="cls-12"/>
<path d="M333.23 337.15l1.85.22a.66.66 0 0 0 .73-.58.66.66 0 0 0-.58-.73l-1.85-.22a.66.66 0 0 0-.73.58.66.66 0 0 0 .58.73z" class="cls-29"/>
<path d="M327.54 335.9c-1.74-11.29 6.39-15.55 14-14.65s14.47 6.94 10.14 17.51c.22-2.3.42-5.11-.49-6.73-1.51-2.75-5.69-4.92-10.3-5.29-4.56-.72-9.14.41-11.25 2.73-1.3 1.37-1.77 4.14-2.1 6.43z" class="cls-29"/>
<circle cx="344.9" cy="341.69" r="1.03" transform="rotate(-83.23 344.905 341.69)"/>
<path d="M346.31 341.73a.32.32 0 1 0 .45-.45 2.48 2.48 0 0 0-3.65-.37.32.32 0 0 0 .35.53 1.93 1.93 0 0 1 2.85.29z" class="cls-29"/>
<circle cx="333.4" cy="340.32" r="1.03" transform="rotate(-83.23 333.4 340.322)"/>
<path d="M332 340a.32.32 0 1 1-.33-.54 2.48 2.48 0 0 1 3.63.5.32.32 0 1 1-.47.43 1.93 1.93 0 0 0-2.84-.39z" class="cls-29"/>
<path d="M353.64 388.46a12 12 0 0 1-4.69 1.95c-1.09-5.47-1.62-15.52-2.21-25.09l5.25.62a150.41 150.41 0 0 0 1.65 22.52z" class="cls-23"/>
<path d="M352.5 367.78l-5.62.37-.76-11.53c4.22-.22 5.66 3.93 5.85 5.83z" class="cls-16"/>
<path d="M306.22 407.49l-5.71-.68a12.06 12.06 0 0 0-1.61 5.37l11.87 1.41c.39-2.97-3.84-4.91-4.55-6.1z" class="cls-30"/>
<path d="M298.71 412.18l.087-.724 12.214 1.45-.085.725z" class="cls-31"/>
<path d="M308.6 409.89a.43.43 0 0 0 .29-.81s-1.68-.64-4.53 1.61a.43.43 0 1 0 .53.68c2.47-1.95 3.7-1.48 3.71-1.48zM307.54 408.74a.43.43 0 0 0 .29-.81s-1.68-.64-4.53 1.61a.43.43 0 1 0 .53.68c2.48-1.95 3.7-1.48 3.71-1.48z" class="cls-32"/>
<path d="M290.69 405.65l5.71.68a12.06 12.06 0 0 1 .31 5.6l-11.87-1.41c.31-2.99 4.88-3.88 5.85-4.87z" class="cls-30"/>
<path d="M284.68 410.527l.086-.725 12.214 1.45-.086.725z" class="cls-8"/>
<path d="M287.81 407.43a.43.43 0 1 1-.1-.85s1.78-.22 4 2.63a.43.43 0 0 1-.68.53c-1.91-2.48-3.22-2.31-3.22-2.31zM289.11 406.55a.43.43 0 0 1-.09-.85s1.78-.23 4 2.63a.43.43 0 1 1-.68.53c-1.92-2.48-3.23-2.31-3.23-2.31z" class="cls-32"/>
<path d="M299.47 407.756l3.483-29.324 9.602 1.14-3.48 29.324zM287.693 406.35l3.48-29.325 9.603 1.14-3.48 29.324z" class="cls-33"/>
<path d="M312.26 399l-25.38-3 6.77-44a5.06 5.06 0 0 1 5.54-4.36l12.43 1.48a5.06 5.06 0 0 1 4.36 5.54z" class="cls-34"/>
<path d="M321.51 381.33a12 12 0 0 1-4.69 1.95c-1.41-7.09-1.88-21.87-2.77-33.33 4.19-.22 5.75 3.89 5.81 5.78a156.51 156.51 0 0 0 1.65 25.6zM282.27 376.68a12 12 0 0 0 4.1 3c3-6.56 6.95-20.82 10.5-31.75-4-1.19-6.5 2.44-7 4.26a156.47 156.47 0 0 1-7.6 24.49z" class="cls-35"/>
<path d="M300.47 342.12l11.05 1.31-1.18 10c-.87 7.31-11.92 6-11.05-1.31z" class="cls-36"/>
<path d="M309.58 355.74l-9.34-11.68.23-1.94 11.05 1.31-1.18 10a5.94 5.94 0 0 1-.76 2.31z" class="cls-37"/>
<path d="M320.12 323.59c-1.53 2.93-15.8 14-22.78 20-1.36 1.17-4.8 2.13-5.57 5.17-1.29 5.07 7.07 8.61 11.35 9.08 7.38.8 14.37 1.86 17.42-4.44.54-1.12.26-4.56-2.47-5.31 2.93-6.73 2.76-19.86 2.05-24.5z" class="cls-38"/>
<path d="M309.28 315.14c3 .42 12 1.79 10.93 12.37s-3.82 14.19-5.43 15.6a11 11 0 0 1-6.29 3.08 4 4 0 0 1-5.71-.68 11 11 0 0 1-5.39-4.46c-1.24-1.75-3-5.89-1.63-16.44s10.5-9.76 13.52-9.47z" class="cls-36"/>
<path d="M309.28 315.14c3 .42 12 1.79 10.93 12.37s-3.82 14.19-5.43 15.6a11 11 0 0 1-6.29 3.08 4.41 4.41 0 0 1-3 .88z" class="cls-39"/>
<path d="M317.4 339.38a11.66 11.66 0 0 1-8.91 6.8 4 4 0 0 1-5.71-.68 11.66 11.66 0 0 1-7.07-8.7c0 3.54 4.74 11.7 6.88 16.24 9.82 1.22 13.25-8.25 14.82-13.66z" class="cls-40"/>
<path d="M296.76 320.82c-2.3 15.88 1.36 32.08 21.32 27.3-4.51 8.89-12.34 7.84-14 7.48a12.33 12.33 0 0 1-9.61-11c-1.05-8.32-.78-16.28 2.29-23.78z" class="cls-41"/>
<path d="M296.52 332.37c1.95-7.11 6.09-8.85 11.72-8.44l.84-7.1c-9.08-1.03-13.46 8.84-12.56 15.54z" class="cls-12"/>
<path d="M295.7 336.81c1-12.27 5.88-16.18 12.86-15.67l.71-6c-3-.29-12.11-1.08-13.52 9.47-.05.39-1.92 7.39-.05 12.2z" class="cls-41"/>
<path d="M317.65 334.87c-.23-7.37-3.85-10-9.42-11l.84-7.1c8.92 1.12 11 12.06 8.76 18.42-.04.17-.14-.04-.18-.32z" class="cls-12"/>
<path d="M317.4 339.38c1.86-12.17-1.93-17.11-8.84-18.25l.71-6c3 .42 12 1.79 10.93 12.37-.04.44.14 7.66-2.8 11.88z" class="cls-38"/>
<path d="M309.72 339.69a3.41 3.41 0 0 1-6.62-.79z" class="cls-12"/>
<path d="M314.19 334.39a.32.32 0 0 0 .45-.45 2.49 2.49 0 0 0-3.67-.37.32.32 0 1 0 .36.53 2 2 0 0 1 2.86.29z" class="cls-29"/>
<circle cx="312.68" cy="334.19" r=".96" transform="rotate(-83.23 312.683 334.193)"/>
<path d="M314.38 333.24a.19.19 0 1 0-.32-.23l-.42.58a.19.19 0 1 0 .32.23z" class="cls-29"/>
<path d="M314.81 333.46a.19.19 0 1 0-.25-.29l-.65.56a.19.19 0 1 0 .26.29zM312.1 330.14a.46.46 0 1 0-.07.91 8.33 8.33 0 0 1 2.07.72.46.46 0 0 0 .41-.82 6.64 6.64 0 0 0-2.41-.81zM300 332.7a.32.32 0 0 1-.33-.55 2.49 2.49 0 0 1 3.65.5.32.32 0 0 1-.47.43 2 2 0 0 0-2.85-.39z" class="cls-29"/>
<circle cx="301.51" cy="332.86" r=".96" transform="rotate(-83.23 301.517 332.864)"/>
<path d="M300.08 331.54a.19.19 0 1 1 .36-.15l.27.66a.19.19 0 1 1-.36.15z" class="cls-29"/>
<path d="M299.61 331.66a.19.19 0 1 1 .32-.23l.5.7a.19.19 0 1 1-.32.23zM303 329.07a.46.46 0 1 1-.14.9 8.34 8.34 0 0 0-2.19.22.46.46 0 0 1-.21-.89 6.65 6.65 0 0 1 2.54-.23z" class="cls-29"/>
<path d="M258.49 354.506l3.575-30.118 23.892 2.836-3.575 30.118z"/>
<path d="M269.64 377.64l5.58.66-3.97 30.02-5.08-.79 1.44-12.76 2.03-17.13zM265.25 377.12l-5.58-.67-3.17 30.12 5.12.42 1.59-12.74 2.04-17.13z" class="cls-42"/>
<path d="M256.45 405.19l5.61.67a11.85 11.85 0 0 1 .31 5.51L250.69 410c.31-3 4.81-3.83 5.76-4.81z" class="cls-34"/>
<path d="M250.546 409.986l.085-.715 12.007 1.426-.085.715z" class="cls-43"/>
<path d="M271.72 407l-5.61-.67a11.85 11.85 0 0 0-1.59 5.28l11.67 1.39c.39-2.92-3.77-4.83-4.47-6z" class="cls-34"/>
<path d="M264.33 411.615l.086-.715 12.005 1.426-.084.715z" class="cls-43"/>
<path d="M256.39 376.72l22 2.61 2.84-23.91a4.92 4.92 0 0 0-4.29-5.45l-12.22-1.45a4.92 4.92 0 0 0-5.45 4.29z" class="cls-44"/>
<path d="M259 357.59s-3.76 14.65-5.65 21.58h-1.67l-2.27-2.6c2-7.5 5.22-20 5.23-20z" class="cls-42"/>
<path d="M253.89 357.56l5.25 1.64 3.36-10.77c-4-1.17-6.27 2.43-6.88 4.19z" class="cls-44"/>
<path d="M252.55 382.3l-.69-2.88a.92.92 0 0 1 .68-1.1.92.92 0 0 1 1.1.68l.69 2.88a.92.92 0 0 1-.68 1.1.92.92 0 0 1-1.1-.68z" class="cls-42"/>
<path d="M250.61 379.13l-.46 5.69a.91.91 0 0 0 .84 1 .93.93 0 0 0 1-.85l.46-5.69a.91.91 0 0 0-.84-1 .93.93 0 0 0-1 .85z" class="cls-42"/>
<path d="M249.65 377.65l-.46 5.69a.91.91 0 0 0 .84 1 .93.93 0 0 0 1-.85l.46-5.69a.91.91 0 0 0-.84-1 .93.93 0 0 0-1 .85z" class="cls-42"/>
<path d="M249.34 376.33l-.47 5.67a.91.91 0 0 0 .84 1 .93.93 0 0 0 1-.85l.46-5.69a.91.91 0 0 0-.84-1 .93.93 0 0 0-.99.87z" class="cls-42"/>
<path d="M279.06 350.4v.64a6.47 6.47 0 0 1-3.12 4.69 9.62 9.62 0 0 1-11.61-1.38 6.47 6.47 0 0 1-1.93-5.29c0-.15 0-.33.09-.54v-.08a12.09 12.09 0 0 1 3.36 0l2.52.3-.13 1.1-1.1-.13-1.42-.17c-.35 0-.7-.07-1-.09h-.09a4.32 4.32 0 0 0 1.33 3.39 7.44 7.44 0 0 0 8.83 1 4.32 4.32 0 0 0 2.08-3h-.09c-.34-.07-.69-.12-1-.16l-1.42-.17-1.1-.13.13-1.1 2.52.3a12.06 12.06 0 0 1 3.15.82z" class="cls-12"/>
<path d="M277.94 350a4.82 4.82 0 0 1 0 .89c-.38 3.23-3.93 5.47-7.92 5s-6.92-3.48-6.53-6.71a4.82 4.82 0 0 1 .19-.87 13.87 13.87 0 0 1 2.18.1l1.42.17.47-3.94.11-.27 7 .83v.29l-.47 3.94 1.42.17a13.87 13.87 0 0 1 2.13.4z" class="cls-42"/>
<path d="M275.3 354.8l-7.64-9.73v-.22h1.59l.11-.27 5.41.64v.29l-.47 3.94 1.42.17a13.87 13.87 0 0 1 2.14.42 4.82 4.82 0 0 1 0 .89 5.39 5.39 0 0 1-2.56 3.87z" class="cls-45"/>
<path d="M274.5 317.74c-2.89-.28-11.62-1-13 9.08s.37 14.09 1.56 15.77a10.54 10.54 0 0 0 5.17 4.28 3.81 3.81 0 0 0 5.48.65 10.54 10.54 0 0 0 6-3c1.55-1.35 4.16-4.81 5.21-15s-7.54-11.38-10.42-11.78z" class="cls-42"/>
<path d="M274.5 317.74c2.88.41 11.54 1.71 10.49 11.87s-3.66 13.61-5.21 15a10.54 10.54 0 0 1-6 2.95 4.23 4.23 0 0 1-2.88.85z" class="cls-46"/>
<path d="M262.49 332.17a1.8 1.8 0 0 0-1.26-1.49c-.82-.28-2.39 0-2.67 2.34s1.46 4.65 2.74 4.51 1.24-4.71 1.19-5.36z" class="cls-42"/>
<path d="M280.12 324.23c.87 2.49.33 6.23 4.55 9.19 4.3-10.68-2.83-16.71-10.27-17.43-7.87-.76-14.2 3.76-13.24 14.87 2.84-3.13 17.39-5.26 18.96-6.63z" class="cls-29"/>
<path d="M275.74 341.17a4 4 0 0 1-7.92-.94z" class="cls-12"/>
<path d="M276.66 379l-18.57-2.2-1.39 10.84 18.79 2.23z" class="cls-47"/>
<path d="M272.74 331.5a49.29 49.29 0 0 1-11.06-2.89l1.08-6.1c3.38-7.4 11.74-5.82 11.74-5.82s8.49.42 10.05 8.41l-.37 6.18c-.75.12-2.1.15-2.92.22-.48 0 .23-2.51-1.1-5.63.38 3.13-.2 5.79-.78 5.79-1.98.04-4.75.01-6.64-.16z" class="cls-29"/>
<path d="M266.7 361.4a8 8 0 0 0-2.17-2.13c-.62-.31-.7 3-.55 3.87-.08 1-.72 2.26.56 4.56 1.2 2.15 2.74 3.16 3.71 2.76.85.62 2.59 0 4.26-1.81s1.45-3.29 1.61-4.3c.35-.83 1.05-4.05.37-3.89a8 8 0 0 0-2.6 1.56 5.79 5.79 0 0 0-5.19-.62z" class="cls-12"/>
<path d="M267.47 367.78a.28.28 0 1 1-.53.16 2.34 2.34 0 0 0-1.87-1.46.28.28 0 1 1 .11-.54 2.91 2.91 0 0 1 2.29 1.84zM269.9 368.3a.28.28 0 0 1-.48-.28 2.91 2.91 0 0 1 2.66-1.26.28.28 0 1 1 0 .55 2.35 2.35 0 0 0-2.18.99zM268.3 370.14a2.81 2.81 0 0 1-.66-.72c0-.51 1.56-.33 1.46.17a2.8 2.8 0 0 1-.8.55z" class="cls-44"/>
<path d="M249.73 374.84l4.29 1.33a.89.89 0 0 1 .59 1.12.89.89 0 0 1-1.12.59l-4.29-1.33a.89.89 0 0 1-.59-1.12.89.89 0 0 1 1.12-.59z" class="cls-48"/>
<path d="M261.86 332.85a1.41 1.41 0 0 0-.88-.93c-.5-.17-1.26 0-1.43 1.44s.7 2.81 1.48 2.73a1 1 0 0 0 .54-.81 1.09 1.09 0 0 1-.5-1.34 1.19 1.19 0 0 1 .78-.84c.01-.15.01-.25.01-.25z" class="cls-46"/>
<path d="M278.11 334.62a1 1 0 0 1 .26.07l.23-.33a.19.19 0 0 1 .31.21l-.23.33.34-.2a.19.19 0 0 1 .19.32l-.32.19h.41a.19.19 0 0 1 0 .37h-.38a.92.92 0 0 1 0 .1 1 1 0 1 1-.81-1.06zM277.76 332.55a.5.5 0 1 1 .08-1 5.43 5.43 0 0 1 1.83.53.5.5 0 0 1-.45.89 4.31 4.31 0 0 0-1.46-.42zM267.06 333.31a1 1 0 0 0-.27 0l-.15-.38a.19.19 0 0 0-.35.13l.15.38-.28-.27a.19.19 0 0 0-.26.27l.27.26-.38-.14a.19.19 0 0 0-.13.35l.36.13a.92.92 0 0 0 0 .1 1 1 0 1 0 1.04-.83zM268 330.39a.5.5 0 1 1-.16 1 4.31 4.31 0 0 0-1.52.08.5.5 0 0 1-.23-1 5.43 5.43 0 0 1 1.91-.08z" class="cls-29"/>
<path d="M279.44 335.78a.32.32 0 1 0 .34-.55 2.52 2.52 0 0 0-3.7.46.32.32 0 0 0 .47.44 2 2 0 0 1 2.89-.36zM265.48 334.12a.32.32 0 1 1-.21-.61 2.52 2.52 0 0 1 3.49 1.32.32.32 0 0 1-.56.32 2 2 0 0 0-2.72-1z" class="cls-29"/>
<path d="M280 360.16a165.74 165.74 0 0 0 4.56 22.84 1.49 1.49 0 0 0 .49.87l1-1.19 2.43-2.49c-1.66-3.06-3.83-19.46-3.66-20.09z" class="cls-42"/>
<path d="M285.33 361.29l-5.48.36-.75-11.25c4.12-.21 5.53 3.83 5.71 5.69z" class="cls-44"/>
<path d="M281.7 326.71l4.24.5-1.7 30.15c-.13 2.35-4.42 5.09-6.75 4.81z" class="cls-29"/>
<path d="M336.33 406.29l2.11-17.75a5 5 0 0 1 5.48-4.31l12.29 1.46a4.94 4.94 0 0 1 4.31 5.48l-2.11 17.75z" class="cls-48"/>
<circle cx="340.18" cy="361.87" r="6.71" class="cls-49" transform="rotate(-83.23 340.185 361.876)"/>
<circle cx="365.07" cy="364.83" r="6.71" class="cls-49" transform="rotate(-83.23 365.073 364.83)"/>
<path d="M355.19 380.64l-9.11-1.08-.74 6.23 3.93 5.85 5.18-4.77.74-6.23z" class="cls-36"/>
<path d="M352.36 389.23l-6.47-8.08.19-1.59 9.11 1.08-.74 6.23-2.09 2.36z" class="cls-37"/>
<path d="M353.91 352.55c-2.94-.28-11.82-1.05-13.2 9.24s.38 14.34 1.59 16a10.73 10.73 0 0 0 5.26 4.35 3.88 3.88 0 0 0 5.58.66 10.72 10.72 0 0 0 6.14-3c1.58-1.38 4.23-4.9 5.3-15.22s-7.75-11.58-10.67-12.03z" class="cls-37"/>
<path d="M353.91 352.55c-2.94-.28-11.82-1.05-13.2 9.24s.38 14.34 1.59 16a10.73 10.73 0 0 0 5.26 4.35 4.31 4.31 0 0 0 2.65 1.53z" class="cls-36"/>
<path d="M341.88 367.27a2.35 2.35 0 0 0-1.47-1.55c-.83-.28-2.43 0-2.71 2.38s1.48 4.73 2.79 4.59 1.39-5.42 1.39-5.42z" class="cls-36"/>
<path d="M341 368a1.43 1.43 0 0 0-.9-.95c-.51-.17-1.28 0-1.46 1.47s.71 2.86 1.51 2.78a1 1 0 0 0 .55-.82 1.11 1.11 0 0 1-.51-1.36 1.21 1.21 0 0 1 .8-.85c.01-.21.01-.27.01-.27z" class="cls-37"/>
<path d="M347.34 364.89a.51.51 0 1 0 .16-1 5.48 5.48 0 0 0-1.94.09.51.51 0 1 0 .24 1 4.39 4.39 0 0 1 1.54-.09zM357.48 365.07a.51.51 0 1 0-.08 1 4.39 4.39 0 0 1 1.48.44.51.51 0 1 0 .46-.91 5.48 5.48 0 0 0-1.86-.53z" class="cls-50"/>
<path d="M346.81 357.63c-1.45 2.25-1.81 6.08-6.69 8-1.71-11.59 6.78-15.85 14.31-14.79 8 1.12 14.53 7.37 10.93 18.12-5.22-1.76-7.74-4.79-9.94-8.09.55 2.65 2.07 4.13 1.64 4-5.01-1.87-6.82-2.37-10.25-7.24z" class="cls-50"/>
<path d="M362.35 369.68a1.84 1.84 0 0 1 1.6-1.17c.87-.08 2.37.54 2.08 3s-2.55 4.25-3.79 3.81-.09-5.01.11-5.64z" class="cls-37"/>
<path d="M362.82 370.55a1.43 1.43 0 0 1 1.09-.71c.53 0 1.25.3 1.07 1.77s-1.36 2.62-2.11 2.35a1 1 0 0 1-.35-.93 1.11 1.11 0 0 0 .82-1.2 1.22 1.22 0 0 0-.57-1z" class="cls-51"/>
<path d="M353.63 375.59a2.45 2.45 0 1 1-4.86-.58z" class="cls-12"/>
<path d="M349.27 391.64l5.21-.88.56-5.21-.43-.05-5.34 6.14zM349.27 391.64l-4.87-2.08.68-5.19.42.05 3.77 7.22z" class="cls-52"/>
<path d="M359.11 370.24a.32.32 0 0 0 .46-.46 2.53 2.53 0 0 0-3.72-.37.32.32 0 1 0 .36.54 2 2 0 0 1 2.91.29z" class="cls-29"/>
<circle cx="357.58" cy="370.03" r=".97" transform="rotate(-83.23 357.584 370.03)"/>
<path d="M359.3 369.07a.2.2 0 1 0-.32-.23l-.43.59a.2.2 0 1 0 .32.23z" class="cls-29"/>
<path d="M359.74 369.3a.2.2 0 0 0-.26-.3l-.66.57a.2.2 0 1 0 .26.3zM344.71 368.53a.32.32 0 1 1-.34-.55 2.53 2.53 0 0 1 3.71.51.32.32 0 1 1-.48.44 2 2 0 0 0-2.89-.39z" class="cls-29"/>
<circle cx="346.24" cy="368.69" r=".97" transform="rotate(-83.23 346.247 368.69)"/>
<path d="M344.79 367.35a.2.2 0 1 1 .37-.15l.27.67a.2.2 0 1 1-.37.15z" class="cls-29"/>
<path d="M344.32 367.47a.2.2 0 0 1 .32-.23l.51.71a.2.2 0 0 1-.32.23z" class="cls-29"/>
<path d="M335.15 406.15c1.38-4.24 2.81-9 4.24-13.62l-5.33-.63c-1.23 5-2.48 9.35-3.94 13.65z" class="cls-36"/>
<path d="M333.12 393.59l5.46 1.7 3.49-11.2c-4.11-1.22-6.52 2.53-7.16 4.36z" class="cls-48"/>
<path d="M333.3 392.9l5.67 1.83-.61 1.81-5.67-1.83.61-1.81z" class="cls-52"/>
<path d="M271.3 398.57l1.54-13a5.08 5.08 0 0 1 5.63-4.44l12.63 1.5a5.08 5.08 0 0 1 4.44 5.63l-1.54 13z" class="cls-53"/>
<path d="M278.19 381.16l.61 1.06 3.89 7.27 1 1.84 1.38-1.55 5.48-6.16.77-.9h-.2l-12.63-1.5z" class="cls-12"/>
<path d="M290.21 376.23l-9.36-1.11-.76 6.41 3.88 7.27 5.48-6.16.76-6.41z" class="cls-23"/>
<path d="M287.3 385.06l-6.65-8.3.2-1.64 9.36 1.11-.76 6.41-2.15 2.42z" class="cls-27"/>
<path d="M289.11 349.37c3 .42 12 1.78 10.91 12.35s-3.81 14.16-5.42 15.57a11 11 0 0 1-6.28 3.07 4 4 0 0 1-5.7-.68 11 11 0 0 1-5.38-4.45c-1.24-1.75-3-5.88-1.63-16.41s10.49-9.75 13.5-9.45z" class="cls-23"/>
<path d="M289.11 349.37c3 .42 12 1.78 10.91 12.35s-3.81 14.16-5.42 15.57a11 11 0 0 1-6.28 3.07 4.4 4.4 0 0 1-3 .88z" class="cls-28"/>
<path d="M289.86 373.27a3.57 3.57 0 0 1-7.08-.84z" class="cls-12"/>
<path d="M292.56 364.16a.52.52 0 1 1 .08-1 5.66 5.66 0 0 1 1.91.56.52.52 0 1 1-.47.93 4.49 4.49 0 0 0-1.52-.49zM282.44 361.91a.52.52 0 1 1-.16 1 4.49 4.49 0 0 0-1.58.08.52.52 0 1 1-.24-1 5.66 5.66 0 0 1 1.98-.08z" class="cls-29"/>
<path d="M283.12 396a3 3 0 0 1 3.12-1.58 2.76 2.76 0 0 1 2.58 2.9 5.66 5.66 0 0 1-1.76 3.13l-8.81-1a5.66 5.66 0 0 1-1-3.46 2.76 2.76 0 0 1 3.19-2.22 3 3 0 0 1 2.68 2.23z" class="cls-12"/>
<path d="M294.3 367.68a.33.33 0 0 0 .46-.46c-.65-.65-1.74-1.73-3.76-.38a.33.33 0 1 0 .36.54 2 2 0 0 1 2.93.3z" class="cls-29"/>
<circle cx="292.75" cy="367.48" r=".98" transform="rotate(-83.23 292.76 367.48)"/>
<path d="M294.49 366.51a.2.2 0 1 0-.32-.23l-.43.6a.2.2 0 0 0 .32.23z" class="cls-29"/>
<path d="M294.93 366.74a.2.2 0 0 0-.26-.3l-.67.56a.2.2 0 1 0 .26.3zM279.77 366a.33.33 0 1 1-.34-.56c.78-.48 2.09-1.28 3.74.51a.33.33 0 1 1-.48.44 2 2 0 0 0-2.92-.4z" class="cls-29"/>
<circle cx="281.32" cy="366.12" r=".98" transform="rotate(-83.23 281.326 366.125)"/>
<path d="M279.85 364.77a.2.2 0 1 1 .37-.15l.28.68a.2.2 0 0 1-.37.15z" class="cls-29"/>
<path d="M279.37 364.89a.2.2 0 0 1 .32-.23l.51.72a.2.2 0 1 1-.32.23zM303.18 353.32a.93.93 0 1 1 .42 1.82 6.3 6.3 0 0 0-3.8 2.71.93.93 0 0 1-1.62-.93 8.27 8.27 0 0 1 5-3.6zM304.89 364.63a.93.93 0 1 1-1.13 1.49 4.08 4.08 0 0 0-3.92-.46.93.93 0 1 1-.72-1.72 6 6 0 0 1 5.77.69z" class="cls-29"/>
<ellipse cx="303.53" cy="364.93" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-83.23 303.537 364.93)"/>
<path d="M304.82 358.93a.93.93 0 0 1-.57 1.78 4.05 4.05 0 0 0-3.81 1 .94.94 0 1 1-1.29-1.35 6 6 0 0 1 5.67-1.43z" class="cls-29"/>
<ellipse cx="303.65" cy="359.69" class="cls-54" rx="1.18" ry="1.21" transform="rotate(-14.05 303.772 359.798)"/>
<ellipse cx="302.49" cy="354.41" class="cls-54" rx="1.18" ry="1.21" transform="rotate(-30.52 302.444 354.37)"/>
<path d="M299.34 348.84a.94.94 0 0 1 1.33 1.32c-.14.15-.34.32-.56.52a12 12 0 0 0-2.67 3.31.93.93 0 0 1-1.62-.93 13.69 13.69 0 0 1 3-3.77c.18-.15.35-.29.52-.45z" class="cls-29"/>
<ellipse cx="299.35" cy="349.99" class="cls-54" rx="1.18" ry="1.21" transform="rotate(-30.53 299.335 349.985)"/>
<path d="M294.61 345.85a.93.93 0 0 1 1.66.86c-.11.21-.24.43-.38.66a12 12 0 0 0-1.58 3.95.93.93 0 0 1-1.82-.41 13.69 13.69 0 0 1 1.8-4.49c.12-.2.23-.42.32-.57z" class="cls-29"/>
<ellipse cx="295.01" cy="346.94" class="cls-54" rx="1.18" ry="1.21" transform="rotate(-30.52 295.067 346.982)"/>
<path d="M273.66 351.58a.93.93 0 0 1 .84-1.67 8.27 8.27 0 0 1 4 4.66.93.93 0 0 1-1.79.52 6.3 6.3 0 0 0-3.05-3.51z" class="cls-29"/>
<path d="M299.58 366.46c4.24-10.54-2.63-16.24-10-17.2-8.66-1.12-16.21 3.3-14.43 14.61 0 0 1.3.58 1.44-.12.47-3.49.78-5.06 2-6.39 1.66-1.87 8.74-.84 14.9-2.11a5 5 0 0 1 3.2 2.37c1.63 3.07 2.57 4.51 2.13 8.76a4.36 4.36 0 0 0 .76.08z" class="cls-29"/>
<path d="M297.1 352.84a.79.79 0 0 1 0 1.11l-2.45 2.36a.79.79 0 0 1-1.11 0 .79.79 0 0 1 0-1.11l2.45-2.36a.79.79 0 0 1 1.11 0zM299 354.27a.79.79 0 0 1 0 1.11l-2.45 2.36a.79.79 0 0 1-1.11 0 .79.79 0 0 1 0-1.11l2.45-2.35a.79.79 0 0 1 1.11-.01z" class="cls-55"/>
<path d="M270.94 362.22a.93.93 0 0 1-.75-1.71 6 6 0 0 1 5.78.68.93.93 0 0 1-1.11 1.5 4.08 4.08 0 0 0-3.92-.47z" class="cls-29"/>
<ellipse cx="271.44" cy="361.12" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-83.23 271.442 361.12)"/>
<path d="M271.72 356.85a.93.93 0 0 1-.13-1.86 6 6 0 0 1 5.18 2.69.94.94 0 1 1-1.57 1 4.05 4.05 0 0 0-3.48-1.83z" class="cls-29"/>
<ellipse cx="272.55" cy="356" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-62.4 272.587 356.01)"/>
<ellipse cx="274.91" cy="351.14" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-45.95 274.927 351.15)"/>
<path d="M277.68 347.43a.94.94 0 0 1 1.6-1c.12.2.24.37.36.55a13.67 13.67 0 0 1 2.07 4.38.93.93 0 0 1-1.79.52 12 12 0 0 0-1.82-3.84c-.17-.22-.32-.44-.42-.61z" class="cls-29"/>
<ellipse cx="279" cy="347.58" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-45.93 279.006 347.586)"/>
<path d="M282.76 345.1a.93.93 0 1 1 1.81-.45c.05.2.11.4.18.63a13.69 13.69 0 0 1 .7 4.79.93.93 0 0 1-1.87 0 12 12 0 0 0-.61-4.21c-.08-.28-.15-.53-.21-.76z" class="cls-29"/>
<ellipse cx="283.93" cy="345.62" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-45.94 283.966 345.642)"/>
<path d="M288.19 344.47a.94.94 0 1 1 1.87 0v1.13c0 .86.12 2 .09 4a.93.93 0 0 1-1.87 0c0-1.83 0-3-.09-3.84.02-.52 0-.9 0-1.29z" class="cls-29"/>
<ellipse cx="289.12" cy="345.25" class="cls-54" rx="1.21" ry="1.18" transform="rotate(-45.93 289.126 345.25)"/>
<path d="M276.61 364.38a1.88 1.88 0 0 0-1.31-1.55c-.85-.29-2.48 0-2.77 2.44s1.52 4.84 2.85 4.7 1.28-4.91 1.23-5.59z" class="cls-23"/>
<path d="M275.93 365.13a1.46 1.46 0 0 0-.92-1c-.52-.17-1.31 0-1.49 1.5s.73 2.93 1.54 2.84a1 1 0 0 0 .57-.84 1.14 1.14 0 0 1-.52-1.39 1.24 1.24 0 0 1 .81-.87c.01-.14.01-.24.01-.24zM297.54 366.89a2.4 2.4 0 0 1 1.84-1.19c.89-.08 2.42.55 2.12 3s-2.61 4.35-3.87 3.9-.09-5.71-.09-5.71z" class="cls-28"/>
<path d="M298.22 367.78a1.46 1.46 0 0 1 1.12-.73c.54 0 1.28.31 1.1 1.81s-1.39 2.67-2.16 2.4a1 1 0 0 1-.35-.95 1.14 1.14 0 0 0 .83-1.23 1.24 1.24 0 0 0-.59-1z" class="cls-27"/>
<path d="M272.4 391.4c-.69 2.17-1.53 4.56-2.49 7l-5.16-.61c1.6-3.87 2.82-7.11 2.82-7.39z" class="cls-23"/>
<path d="M267.15 391l5.59 1.74 3.58-11.48c-4.21-1.25-6.68 2.59-7.34 4.47z" class="cls-53"/>
<path d="M294.55 394c.16 2.27.42 4.79.78 7.39l5.16.61c-.65-4.14-1.08-7.57-1-7.85z" class="cls-23"/>
<path d="M299.74 394.87l-5.84.39-.79-12c4.39-.23 5.89 4.08 6.09 6.06z" class="cls-53"/>
<path d="M247 378.52c-.32 0-4 .31-4.35.3-9.81-.32-14.89-7.43-17.46-15.91-.06-.27-.12-.56-.18-.87v-.16A4.43 4.43 0 0 0 226 360l.66-2.19a.94.94 0 0 0-.66-1.16 1 1 0 0 0-1.07.46l-.14-.08-.06-4.14a.94.94 0 0 0-1-.93.94.94 0 0 0-.93 1v1.36l-.5-2.32a1 1 0 0 0-1.13-.7.94.94 0 0 0-.7 1.13l1.07 4.54-1.44-4.28a.94.94 0 0 0-1.19-.59.94.94 0 0 0-.59 1.19l1.45 4.3-1.28-2.17a.94.94 0 0 0-1.29-.33.94.94 0 0 0-.33 1.29l1.22 2.07a.44.44 0 0 0 0 .54l1.41 3a2 2 0 0 0 1 1.26c2.26 10.76 8.25 20.26 22.25 21.83z" class="cls-42"/>
<path d="M245.23 377.81c-.32 0-3.94-.27-4.24-.28a17.89 17.89 0 0 1-5-.84l-1.47 7a28.71 28.71 0 0 0 6.36 1.42z" class="cls-56"/>
<path d="M259.86 355c1.06.46 4-5.77 5.06-3.27 5.59 5.39-1.21 19.27 5.27 19.09-2.74 7.69-15.85 10.28-20.88 4.8 0 0 6.36-18.54 10.55-20.62z" class="cls-57"/>
<path d="M251.22 354c-1.14.2-3.12-6.77-4.73-4.58-6.69 3.94-2.76 19.24-9 17.54.87 8.12 13 13.71 19.18 9.55-.03-.02-1.87-19.51-5.45-22.51z" class="cls-57"/>
<path d="M239.4 393.69l1.36-11.45a5.07 5.07 0 0 1 5.62-4.43l12.6 1.5a5.07 5.07 0 0 1 4.43 5.62L262 396.38z" class="cls-56"/>
<path d="M246.1 377.79l.61 1.05 3.88 7.25 1 1.83 1.38-1.55 5.47-6.14.77-.89h-.2l-12.6-1.5z" class="cls-12"/>
<path d="M258.09 372.88l-9.34-1.11-.76 6.39 3.88 7.26 5.46-6.15.76-6.39z" class="cls-42"/>
<path d="M255.18 381.69l-6.62-8.29.19-1.63 9.34 1.11-.76 6.39-2.15 2.42z" class="cls-45"/>
<path d="M256.68 344.85c3 .42 12 1.78 10.88 12.32s-3.8 14.13-5.41 15.53a10.94 10.94 0 0 1-6.26 3.06 4 4 0 0 1-5.69-.68 10.94 10.94 0 0 1-5.37-4.44c-1.23-1.74-3-5.87-1.62-16.37s10.47-9.71 13.47-9.42z" class="cls-42"/>
<path d="M256.68 344.85c3 .42 12 1.78 10.88 12.32s-3.8 14.13-5.41 15.53a10.94 10.94 0 0 1-6.26 3.06 4.39 4.39 0 0 1-3 .88z" class="cls-46"/>
<path d="M244.22 359.82a1.87 1.87 0 0 0-1.31-1.55c-.85-.29-2.48 0-2.77 2.43s1.51 4.82 2.85 4.68 1.27-4.88 1.23-5.56z" class="cls-42"/>
<path d="M243.54 360.57a1.45 1.45 0 0 0-.91-1c-.52-.17-1.31 0-1.49 1.5s.72 2.92 1.53 2.83a1 1 0 0 0 .57-.84 1.13 1.13 0 0 1-.52-1.39 1.24 1.24 0 0 1 .81-.87c.01-.13.01-.23.01-.23zM265.1 362.32a2.4 2.4 0 0 1 1.83-1.19c.89-.08 2.42.55 2.12 3s-2.6 4.34-3.86 3.89-.09-5.7-.09-5.7z" class="cls-46"/>
<path d="M265.77 363.21a1.46 1.46 0 0 1 1.11-.72c.54 0 1.27.3 1.09 1.8s-1.39 2.67-2.16 2.4a1 1 0 0 1-.35-.95 1.13 1.13 0 0 0 .83-1.23 1.24 1.24 0 0 0-.59-1z" class="cls-45"/>
<path d="M262.51 351.59c.9 2.58.35 6.47 4.72 9.54 4.46-11.09-2.93-17.34-10.66-18.09-8.17-.79-16.17 3.84-15.17 15.37 4.71.68 7.15-4.46 7.41-4.49 5.25.61 9.13 1.67 13.7-2.33z" class="cls-21"/>
<path d="M257.43 368.69c-.41 1.22-2 2-3.79 1.79s-3.16-1.35-3.27-2.63z" class="cls-12"/>
<path d="M260.13 359.6a.52.52 0 1 1 .08-1 5.64 5.64 0 0 1 1.9.55.52.52 0 0 1-.47.93 4.45 4.45 0 0 0-1.51-.48zM250 357.36a.52.52 0 0 1-.16 1 4.46 4.46 0 0 0-1.58.08.52.52 0 0 1-.24-1 5.64 5.64 0 0 1 1.98-.08z" class="cls-45"/>
<path d="M262 362.82a.33.33 0 0 0 .47-.47c-.65-.65-1.75-1.75-3.8-.38a.33.33 0 1 0 .37.55 2 2 0 0 1 3 .3z" class="cls-14"/>
<circle cx="260.43" cy="362.61" r=".99" class="cls-8" transform="rotate(-83.23 260.438 362.615)"/>
<path d="M262.19 361.63a.2.2 0 1 0-.33-.23l-.43.6a.2.2 0 0 0 .33.23z" class="cls-14"/>
<path d="M262.63 361.86a.2.2 0 1 0-.26-.3l-.67.58a.2.2 0 0 0 .26.3zM247.32 361.08a.33.33 0 0 1-.34-.56c.79-.48 2.12-1.29 3.78.52a.33.33 0 1 1-.49.45 2 2 0 0 0-2.95-.4z" class="cls-14"/>
<circle cx="248.88" cy="361.24" r=".99" class="cls-8" transform="rotate(-83.23 248.886 361.245)"/>
<path d="M247.4 359.87a.2.2 0 0 1 .37-.15l.28.69a.2.2 0 1 1-.37.15z" class="cls-14"/>
<path d="M246.91 360a.2.2 0 1 1 .33-.23l.52.73a.2.2 0 0 1-.33.23z" class="cls-14"/>
<path d="M246.78 349.88a.78.78 0 0 1 1.05.34l1.77 3.45a.78.78 0 0 1-.34 1.05.79.79 0 0 1-1.05-.34l-1.77-3.45a.79.79 0 0 1 .34-1.05z" class="cls-48"/>
<path d="M263.59 390.63c.13 1.86.33 3.89.59 6l5.12.61a63.72 63.72 0 0 1-.79-6.44z" class="cls-42"/>
<path d="M268.78 391.47l-5.84.39-.79-12c4.39-.23 5.89 4.08 6.09 6.06z" class="cls-56"/>
<path d="M326.77 401l1.5-12.61a5 5 0 0 0-4.4-5.58l-12.52-1.49a5 5 0 0 0-5.58 4.4l-1.5 12.61z" class="cls-58"/>
<path d="M323.75 382.77v.35l-.08.2a6.35 6.35 0 0 1-12.16-1.44v-.56l2.9.34a3.45 3.45 0 0 0 1 1.74 3.45 3.45 0 0 0 4.13.49 3.45 3.45 0 0 0 1.36-1.47z" class="cls-12"/>
<path d="M313.36 378.76l9.16 1.09c0 .11 0 .23.06.34l-.31 2.58a4.9 4.9 0 0 1-9.37-1.11l.31-2.58c.05-.08.09-.21.15-.32z" class="cls-42"/>
<path d="M313.39 379.51l9.13.34c0 .11 0 .23.06.34l-.31 2.58a4.9 4.9 0 0 1-4 3z" class="cls-45"/>
<path d="M321.38 350.33c-3-.29-12.16-1.08-13.58 9.51s.39 14.75 1.64 16.51a11 11 0 0 0 5.41 4.48 4.43 4.43 0 0 0 2.72 1.57z" class="cls-42"/>
<path d="M321.38 350.33c3 .42 12.08 1.79 11 12.42s-3.83 14.25-5.45 15.66a11 11 0 0 1-6.31 3.09 4.43 4.43 0 0 1-3 .89z" class="cls-46"/>
<path d="M328.92 356.45c-.42-1-1-1.93-4-1.13s-8 3-13.34 1.88c-2.24 3.14-2.41 9.39-2.41 9.39l-2.83-2.36s.34-7.09 1.74-9.22c-.89-.26-1.54-1.26-1.2-3.44 1.12.29 1.69 1.27 3.35.24s5.47-4.72 11.5-3.85 8.3 4.44 9 5.82c3.78 4.08 2.91 8.4 1.92 13.55l-3.32 1.67c.45-2.15.73-9.93-.41-12.55z" class="cls-59"/>
<path d="M308.81 365.43a1.89 1.89 0 0 0-1.32-1.56c-.85-.29-2.5 0-2.79 2.45s1.53 4.86 2.87 4.72 1.28-4.93 1.24-5.61z" class="cls-42"/>
<path d="M308.13 366.19a1.47 1.47 0 0 0-.92-1c-.52-.18-1.32 0-1.5 1.51s.73 2.94 1.55 2.86a1 1 0 0 0 .57-.84 1.14 1.14 0 0 1-.53-1.4 1.25 1.25 0 0 1 .82-.88c.01-.15.01-.25.01-.25zM329.86 368a2.41 2.41 0 0 1 1.85-1.2c.9-.08 2.44.56 2.14 3s-2.62 4.37-3.9 3.92-.09-5.72-.09-5.72z" class="cls-46"/>
<path d="M330.55 368.85a1.47 1.47 0 0 1 1.12-.73c.55 0 1.28.31 1.1 1.82s-1.4 2.69-2.17 2.42a1 1 0 0 1-.36-1 1.14 1.14 0 0 0 .84-1.24 1.25 1.25 0 0 0-.59-1z" class="cls-45"/>
<rect width="1.32" height="4.18" x="313.49" y="360.4" class="cls-60" rx=".66" ry=".66" transform="rotate(-83.23 314.154 362.488)"/>
<rect width="1.32" height="4.18" x="324.9" y="361.75" class="cls-60" rx=".66" ry=".66" transform="rotate(-83.23 325.56 363.84)"/>
<path d="M321.2 373.17a2.52 2.52 0 1 1-5-.59z" class="cls-12"/>
<circle cx="325.58" cy="367.79" r="1.11" class="cls-8" transform="rotate(-83.23 325.58 367.785)"/>
<path d="M327.09 367.83a.34.34 0 0 0 .48-.48c-.68-.67-1.81-1.81-3.92-.39a.34.34 0 1 0 .38.57 2.08 2.08 0 0 1 3.06.31z" class="cls-14"/>
<circle cx="313.21" cy="366.32" r="1.11" class="cls-8" transform="rotate(-83.23 313.208 366.32)"/>
<path d="M311.72 366a.34.34 0 0 1-.36-.58c.81-.5 2.19-1.33 3.91.54a.34.34 0 1 1-.5.46 2.08 2.08 0 0 0-3-.42z" class="cls-14"/>
<path d="M303.59 398.22c.8-2.59 1.61-5.25 2.42-7.88l-5.43-.64c-.69 2.79-1.39 5.4-2.12 7.91z" class="cls-46"/>
<path d="M299.62 391.42l5.56 1.73 3.56-11.41c-4.19-1.24-6.64 2.58-7.29 4.44z" class="cls-58"/>
<path d="M299.582 391.507l.487-1.386 5.64 1.984-.487 1.387z" class="cls-1"/>
<path d="M327.5 401.06c-.17-2.71-.34-5.49-.51-8.23l5.43.64c0 2.87.08 5.57.21 8.19z" class="cls-46"/>
<path d="M332.95 395.38l-5.81.38-.79-11.93c4.36-.23 5.86 4.06 6.05 6z" class="cls-58"/>
<path d="M326.872 394.63l5.95-.607.148 1.463-5.95.607z" class="cls-1"/>
<path d="M240.28 390.81l122.07 14.49-13.55 56.4c-1.35 5.67-5.85 14.57-13.9 13.62l-84.33-10c-8-1-10.34-10.66-10.33-16.49z" class="cls-15"/>
<path d="M349.57 458.48l-.77 3.22c-1.35 5.67-5.85 14.57-13.9 13.62l-84.33-10c-8-1-10.34-10.66-10.33-16.49v-3.31c.9 5.48 3.76 11.74 10.27 12.48l86.16 10.23c6.52.77 10.75-4.63 12.9-9.75zM240.28 390.81l122.07 14.49-3.54 14.7-118.54-14.07.01-15.12z" class="cls-16"/>
<path d="M241.83 387.66l119.75 14.21a6.63 6.63 0 0 1 5.78 7.34A6.63 6.63 0 0 1 360 415l-119.73-14.22a6.63 6.63 0 0 1-5.78-7.34 6.63 6.63 0 0 1 7.34-5.78z" class="cls-20"/>
<path d="M241.83 387.66l119.75 14.21a6.63 6.63 0 0 1 5.78 7.34 6.56 6.56 0 0 1-.27 1.25 6.88 6.88 0 0 1-4.94 1.32L239 397.15a6.88 6.88 0 0 1-4.49-2.44 6.59 6.59 0 0 1 0-1.28 6.63 6.63 0 0 1 7.32-5.77z" class="cls-21"/>
<rect width="4.95" height="2.12" x="303.99" y="394.68" class="cls-61" rx="1.06" ry="1.06" transform="rotate(-83.23 306.474 395.745)"/>
<rect width="4.95" height="2.12" x="306.1" y="394.93" class="cls-61" rx="1.06" ry="1.06" transform="rotate(-83.23 308.58 395.994)"/>
<path d="M311 393.79a1.06 1.06 0 0 1 .93 1.18l-.33 2.81a1.06 1.06 0 0 1-1.18.93 1.06 1.06 0 0 1-.93-1.18l.33-2.81a1.06 1.06 0 0 1 1.18-.93z" class="cls-42"/>
<path d="M313.08 394a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18zM321.85 395.08a1.06 1.06 0 0 1 .93 1.18l-.33 2.81a1.06 1.06 0 0 1-1.18.93 1.06 1.06 0 0 1-.93-1.18l.33-2.81a1.06 1.06 0 0 1 1.18-.93zM324 395.33a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18z" class="cls-42"/>
<rect width="4.95" height="2.12" x="323.29" y="396.97" class="cls-61" rx="1.06" ry="1.06" transform="rotate(-83.23 325.77 398.033)"/>
<path d="M319.74 394.83a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18z" class="cls-42"/>
<path d="M272.38 389.21a1.06 1.06 0 0 1 .93 1.18l-.31 2.8a1.06 1.06 0 0 1-1.18.93 1.06 1.06 0 0 1-.93-1.18l.33-2.81a1.06 1.06 0 0 1 1.16-.92z" class="cls-23"/>
<rect width="4.95" height="2.12" x="271.72" y="390.85" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 274.19 391.91)"/>
<rect width="4.95" height="2.12" x="273.82" y="391.1" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 276.302 392.165)"/>
<rect width="4.95" height="2.12" x="275.93" y="391.35" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 278.407 392.413)"/>
<rect width="4.95" height="2.12" x="281.54" y="392.02" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 284.02 393.08)"/>
<path d="M286.41 390.87a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18z" class="cls-23"/>
<rect width="4.95" height="2.12" x="285.75" y="392.52" class="cls-62" rx="1.06" ry="1.06" transform="rotate(-83.23 288.23 393.576)"/>
<path d="M290.62 391.37a1.06 1.06 0 0 0-1.18.93l-.33 2.81a1.06 1.06 0 0 0 .93 1.18 1.06 1.06 0 0 0 1.18-.93l.33-2.81a1.06 1.06 0 0 0-.93-1.18z" class="cls-23"/>
<path d="M266.86 409.48s14.79 12.28 30.84 3.68l.83 1.55c-17.09 9.16-32.74-3.83-32.78-3.87zM301.23 413.56s14.79 12.28 30.84 3.68l.83 1.55c-17.09 9.16-32.74-3.83-32.79-3.87zM335.76 417.65s16.1 12.57 23.06 2.3l1.46 1c-8 11.84-25.55-1.85-25.6-1.89z" class="cls-21"/>
<path d="M264.27 412.88c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-16"/>
<path d="M263.6 410.06c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
<path d="M263.6 410.06c-4.28 4.22-9.4 9.69-10.45 15a9.76 9.76 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1z" class="cls-21"/>
<path d="M298.68 417c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17zM333.34 421.07c-4.28 4.22-9.41 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-16"/>
<path d="M332.35 418.21c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
<path d="M332.35 418.21c-4.28 4.22-9.4 9.69-10.45 15a9.75 9.75 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1z" class="cls-21"/>
<path d="M298 414.13c-4.28 4.22-9.4 9.69-10.45 15-1 4.89 1.62 10.37 8.73 11.47 7.17.6 11-4.13 11.17-9.11.24-5.45-3.46-12-6.63-17.07l-1.41-.17z" class="cls-20"/>
<path d="M298 414.13c-4.28 4.22-9.4 9.69-10.45 15a9.75 9.75 0 0 0 .73 6.08 11.44 11.44 0 0 0 5.61 2.35c7.17.6 11-4.13 11.17-9.11.2-4.44-2.23-9.59-4.85-14.11l-.81-.1zM263.57 409.08c-.05 0-18.86 8.36-23.22-3.25l-1.39.7c5 13.39 25.27 4.18 25.33 4.15z" class="cls-21"/>
<circle cx="299.56" cy="412.87" r="2.18" class="cls-63" transform="rotate(-83.23 299.563 412.878)"/>
<circle cx="265.19" cy="408.79" r="2.18" class="cls-63" transform="rotate(-83.23 265.19 408.8)"/>
<circle cx="333.93" cy="416.95" r="2.18" class="cls-63" transform="rotate(-83.23 333.935 416.956)"/>
<path d="M299.81 410.71a2.18 2.18 0 0 1 1.91 2.42v.09l-4.33-.51v-.09a2.18 2.18 0 0 1 2.42-1.91zM265.44 406.63a2.18 2.18 0 0 1 1.91 2.42v.09l-4.33-.51v-.09a2.18 2.18 0 0 1 2.42-1.91zM334.18 414.79a2.18 2.18 0 0 1 1.91 2.42v.09l-4.33-.51v-.09a2.18 2.18 0 0 1 2.42-1.91z" class="cls-64"/>
<path d="M359.32 417.89a2.18 2.18 0 0 1-1 4.15zM240.27 408a2.18 2.18 0 0 1 0-4.27V408z" class="cls-20"/>
<path d="M369.24 397.41l2.07 1.24c.59.35.93-.12 1.83-.68.69-.43 1.27-.29 2.63-.31a4.64 4.64 0 0 0 1.76-.67.89.89 0 0 0 .69-.8.78.78 0 0 0 .54-.87 1 1 0 0 1-.32-1.2.78.78 0 0 0-1-.45l-.14.06a.73.73 0 0 0-.8-.32 10.73 10.73 0 0 1-2.6.51.77.77 0 0 0-.13-.71.77.77 0 0 0-1.09-.11l-1.07.94a2.06 2.06 0 0 0-1.33.5 4.51 4.51 0 0 0-1.04 2.87z" class="cls-36"/>
<path d="M364 395.85c.1 1.88.85 6.82.86 6.82s1.51-2.23 4.88-6.84l2.36 2.78c-5.41 9-7.48 9.71-9.78 8-.88-.94-1.29-3.33-1.77-5.64a25.48 25.48 0 0 0-.91-3.54z" class="cls-36"/>
<path d="M364.57 397.44l-5.52.37-.75-11.32c4.14-.21 5.56 3.85 5.74 5.72z" class="cls-48"/>
<path d="M359.32 397.504l5.62-.49.165 1.903-5.62.49z" class="cls-65"/>
<circle cx="375.66" cy="391.02" r="4.54" class="cls-66" transform="rotate(-83.23 375.666 391.018)"/>
<path d="M373.7 386a2.64 2.64 0 1 0-2.32-.68 2.63 2.63 0 0 0 2.32.68z" class="cls-67"/>
<path d="M374.06 387.82a.23.23 0 1 1-.46.09 7.62 7.62 0 0 1 .67-3.77.23.23 0 1 1 .44.16 7.18 7.18 0 0 0-.65 3.52z" class="cls-68"/>
<circle cx="332.39" cy="150.75" r="145.02" class="cls-2" transform="rotate(-83.23 332.39 150.748)"/>
<path d="M405.16 72.93A203.65 203.65 0 0 0 377.3 62.8c.41 4.76.62 9.81.83 14.87 12.07-1.81 18.57-.56 27-4.75" class="cls-69"/>
<path d="M425.64 127.47a30.49 30.49 0 0 1 3.59 1.24c-.18-2.62-.17-5.38-.53-7.86a11.19 11.19 0 0 1-1 2 24 24 0 0 0-2 4.63" class="cls-70"/>
<path d="M310 254.66a25.25 25.25 0 0 1-.23-4.9 1.45 1.45 0 0 0-.07-.82 143.69 143.69 0 0 1-14.1-3c3.31 28.15 10.57 46.38 20.65 47.58a8 8 0 0 0 3.94-.34c-1.13-4.19-2.64-9.24-3.82-13-3.8-13.11-6.09-21.18-6.38-25.6" class="cls-71"/>
<path d="M268.12 55.36a200.3 200.3 0 0 1 45.05-6.18c7.06-19.78 15.52-35 24.35-42.4l-.52-.06c-26.12 2.58-50.68 20.77-68.92 48.64" class="cls-72"/>
<path d="M371 55.56C367.72 27.25 360.45 9.18 350.21 8S328.66 22.61 319 49.39a219.09 219.09 0 0 1 52 6.18" class="cls-73"/>
<path d="M339.25 92.05c7.6-6.57 10.54-5.4 20.9-9.21a129.19 129.19 0 0 1 12.65-4c-.24-6.2-.67-12.09-1.15-17.67a224 224 0 0 0-54.59-6.48c-2.22 6.39-4.16 13.14-6.13 20.21 10.73 2.57 20.94 6.87 28.32 17.16" class="cls-74"/>
<path d="M293.63 209.69c.17 10.9.55 21.33 1.23 30.67 4.73 1.21 9.49 2.1 14.24 3.15a31.34 31.34 0 0 0-2.33-7.74c-1.7-3.45-7.57-15.51-13.14-26.07" class="cls-75"/>
<path d="M363.1 10.14C369 18 373.47 32.15 376 52.25a24.12 24.12 0 0 1 .41 4.76 199.77 199.77 0 0 1 33.89 12.3 34.31 34.31 0 0 0 4.65-5c-11.25-27-29.36-46.67-51.85-54.21" class="cls-76"/>
<path d="M434.69 130.49c19.78 7.06 35 15.52 42.4 24.35l.06-.48c-2-21.5-14.52-41.82-34.6-58.65-2.79 4.38-5.66 9.39-9 15.65.54 6.4 1.08 12.79 1.14 19.13" class="cls-70"/>
<path d="M475.89 167.69c1.2-10.09-14.65-21.54-41.43-31.22a219.09 219.09 0 0 1-6.18 52c28.31-3.3 46.38-10.57 47.6-20.81" class="cls-77"/>
<path d="M394.74 175.75l-9.68 9.08c-.83 1.52-1.86 3.35-2.91 5.34a368.06 368.06 0 0 0 40.74-1.33 224 224 0 0 0 6.48-54.59c-1.7-.69-3.75-1.26-5.77-2-.06.48-.26.78-.31 1.26-4.09 11.2-8.67 23.81-28.55 42.23" class="cls-78"/>
<path d="M427.13 194.2a202.69 202.69 0 0 1-16.55 42.35c31.54-11.35 55-31 63.26-55.64v-.16c-7.86 6-22 10.53-42.15 13.18a26.83 26.83 0 0 0-4.58.27" class="cls-79"/>
<path d="M373 275.12A123.43 123.43 0 0 0 398.26 246c-5.87 1.58-11.84 2.65-18.15 3.85a54.87 54.87 0 0 1-3.77 14c-1.06 3.45-2.28 6.87-3.29 11.3" class="cls-80"/>
<path d="M214.35 81.61C227.3 71 243.51 63 260.2 57.83c12.37-20.77 29.47-39 50.13-48.92A145 145 0 0 0 206 83.38a69.68 69.68 0 0 1 8.33-1.77" class="cls-81"/>
<path d="M363.77 224.67l-.22.46c8.12 4 16.35 11.36 16.87 17.91-.06.48.07.82 0 1.3a168 168 0 0 0 22.2-5.16A191.78 191.78 0 0 0 421 194.94a410.77 410.77 0 0 1-42.18 1.16l-2.94 5.66c-5.18 9.45-10.62 19.68-12.14 22.91" class="cls-82"/>
<path d="M445.38 91c12.56 10.42 23.28 22.73 29.74 36.65a142.73 142.73 0 0 0-19.3-52.13c-1.21 2-2.75 4.06-4.3 6.15-1.64 2.89-3.8 6-6.14 9.34" class="cls-83"/>
<path d="M388.94 18.08c12.56 10.42 22.59 24.43 29.94 40.41A77.24 77.24 0 0 0 425 40a138.12 138.12 0 0 0-36-22" class="cls-84"/>
<path d="M240.31 224c-20.77-12.37-39-29.47-48.92-50.13a144.52 144.52 0 0 0 86.34 109.6c-17.79-14.29-30.1-36.37-37.42-59.48" class="cls-85"/>
<path d="M406.48 243.7c-9.28 15.29-21.21 29.63-34.93 39.85-.41 2.06-.64 4-1.22 6.19a144.16 144.16 0 0 0 95.76-83.28c-14.61 17.75-36.37 30.1-59.62 37.24" class="cls-86"/>
<path d="M255.76 65.09a127 127 0 0 0-29.68 14.82A212.18 212.18 0 0 0 251 75.08c1.54-3.39 3.24-6.76 4.76-10" class="cls-87"/>
<path d="M273.64 69c17.82 3.09 31.71 5.06 31.71 5.06 1.76-6.61 3.68-13.2 5.84-19.11a189.68 189.68 0 0 0-47.32 7.53c-1.86 3.35-3.72 6.7-5.6 10.21 8.48-3.05 7-4.36 15.37-3.69" class="cls-88"/>
<path d="M269.73 169c-6.85-5.2-12.47-11.22-17.8-16.89a132.84 132.84 0 0 0-13.08-12.92 263.93 263.93 0 0 0-1.67 27.72 379.55 379.55 0 0 0 40.74 11 15.6 15.6 0 0 0-2.62-3.88 34.76 34.76 0 0 0-5.57-5" class="cls-89"/>
<path d="M237.84 216.06a200.3 200.3 0 0 1-6.17-45.06c-19.16-6.82-33.8-14.73-41.54-23.44l-.88.55c3.28 25.71 21.19 49.92 48.59 67.94" class="cls-90"/>
<path d="M281.36 184.53A426.21 426.21 0 0 1 237.44 173a189.68 189.68 0 0 0 7.56 47.31 191.78 191.78 0 0 0 44.25 18.4 327.53 327.53 0 0 1-.95-39.89c-2-3.65-3.85-8.57-6.91-14.29" class="cls-3"/>
<path d="M211.14 134.64l-.18.14-15.78 9.82c6.32 6.92 19.28 14 36.7 20.43a201 201 0 0 1 2-29.47 28.08 28.08 0 0 0-10.9-3.89 15.51 15.51 0 0 0-11.88 3" class="cls-91"/>
<path d="M289.81 244.63a202.69 202.69 0 0 1-42.35-16.55c11.35 31.54 31 55 55.64 63.26h.16c-6-7.86-10.53-22-13.18-42.15a16.72 16.72 0 0 0-.27-4.58" class="cls-92"/>
<path d="M223.71 125.9c13.45 1.6 24 12.75 32.75 22.23 5.35 5.51 10.65 11.49 17 16.47a28.42 28.42 0 0 1 6.4 6.28 26.83 26.83 0 0 1 4.38 6.85c1.3 2.75 2.37 4.66 3.17 4.76 1.6.19 5.33-1.15 6.14-3.82 1.93-5.29-2.75-11-6.45-15.38-5-6.11-12.6-16.92-14.47-21.69-15-38.14 30.95-62.4 53.95-38.73 2.85-2.1 5.79-5 8-6.85-11.84-16.5-31.79-15.3-51.47-19.1-2.86-.5-10.79-2.09-13.11-1.72a28.57 28.57 0 0 0-6.6 2.3c-19.45 7.92-44.85 7.18-61 13.7a144.06 144.06 0 0 0-11.87 42.59c-.29 2.4-.57 4.8-.71 7.38L208 130a21 21 0 0 1 15.75-4.14" class="cls-93"/>
<path d="M374.24 84.51c-8 2-14.58 5.25-19.24 6.16-8.85 2-10.25 4.3-17.31 10.45a93 93 0 0 1-7.78 6.71 3.59 3.59 0 0 0 .55 2.18c1.86 3.47 5.2 2.73 8.4.35 33.68-25.22 54.89 2.62 56.14 18 .62 7.05 0 17.86-10.39 34.16 3.83 1.92 6.43 4.66 6.39 9 25.39-23.77 23.77-36.14 31.61-51.61C437.2 90.2 446 80.69 452.87 70.31a142.92 142.92 0 0 0-22.42-25.88C419.61 88 393.11 80.1 374.24 84.51" class="cls-94"/>
<path d="M367.08 274.74c2.36-13 8.36-18.49 7.46-31.42-.26-3.28-7.33-10.77-16-14.08l-2.82-.82 2.86-6.32c1.79-4.17 9.68-18.66 16.52-31.16.81-1.36 9.38-16.09 9.38-18.85-.2-2.46-2.73-4.38-4.65-4.61s-4.26 3.07-6.76 6.34c-3.43 4.3-5.73 8.57-8.66 12.77-3.13 4.5-6.62 9.28-10.31 14.36q-5.56 7.86-12.85 18a9.13 9.13 0 0 1-9.11 4.28c-3.2-.38-6.77-1.78-7.64-2.69s-21.65-31-26.91-37.29a12.8 12.8 0 0 1-8.51 4.67c7 13.81 20.46 39.6 23.07 44.95 3.91 8.09 3.18 12.88 3.81 21.23.26 6 7.51 27 10.81 40.24A143.35 143.35 0 0 0 364 291.1c1-5.56 2.13-11.11 3.08-16.36" class="cls-95"/>
<path d="M163 44.29a25.89 25.89 0 0 0-20.48 10 20.92 20.92 0 0 0-20.27 17.21 12.57 12.57 0 0 0-1.89-.14 12.38 12.38 0 1 0 0 24.77c30.16 0 66.81-1.64 100-1.64a9.55 9.55 0 1 0 0-19.11 9.68 9.68 0 0 0-1.92.19 19.38 19.38 0 0 0-31.2-14.72A25.94 25.94 0 0 0 163 44.29z" class="cls-4"/>
<path d="M167.72 295.05c2.43.86 4.29 2.11 7 1.43s5.42-1.17 6.39-.5 8.28 8 12.53 20.36-1.93 2.83-4 1.35-7.11-10.44-9.28-11.65-8.44.11-14.24-2c-1.7-.63-4.15.45-5.16 3.15s-1.93 6.53-1.93 6.53-5.38 1-10.92-5.66c1.81-1.85 6.19-4.06 7.18-5.23.41-1.12-.83-2.46-2-3.37a64.77 64.77 0 0 1-7.81-6.77c-2.3-2.63-4.87-1.72-7.8-1.58s-8.72 1.25-11.19 1.08-6.23.22-.57-2.3 19.5-7.48 24.5-6.46c3.25.67 2.67 1.71 4.19 3.69s4.68 6.38 6.9 4.7a17.2 17.2 0 0 1 4.86-3c1.49-.52 2.92-.15 2.4 1.56s-1.05 4.67-1.05 4.67z" class="cls-96"/>
<path d="M171.42 288.28a4 4 0 0 0-3.2.9c-.66.75.57.91.82.78a26 26 0 0 1 2.38-1.68z" class="cls-97"/>
<ellipse cx="166.11" cy="289.09" class="cls-98" rx=".48" ry=".38" transform="rotate(-34.42 166.098 289.075)"/>
<path d="M118.86 326c1.88.3 3.4 1 5.29.11s3.78-1.57 4.57-1.21 7.07 4.68 11.8 13.12-1 2.31-2.69 1.5-6.55-6.63-8.28-7.23-6.11 1.2-10.61.42c-1.31-.23-3 .87-3.33 3s-.54 5-.54 5-3.78 1.42-8.68-2.66c1.07-1.58 4-3.77 4.52-4.75.15-.87-.93-1.67-1.87-2.19a47.81 47.81 0 0 1-6.57-3.88c-2-1.61-3.77-.61-5.87-.11s-6.17 2.06-8 2.27-4.5 1-.72-1.6 13.17-8 16.93-7.94c2.45.05 2.17.89 3.54 2.12s4.25 4 5.64 2.5a12.69 12.69 0 0 1 3.13-2.81c1-.57 2.1-.49 1.95.81s-.21 3.53-.21 3.53z" class="cls-96"/>
<path d="M120.65 320.64a2.92 2.92 0 0 0-2.21 1.08c-.38.63.53.59.7.46a19.22 19.22 0 0 1 1.51-1.54z" class="cls-97"/>
<ellipse cx="116.9" cy="321.94" class="cls-98" rx=".35" ry=".28" transform="rotate(-44.77 116.913 321.942)"/>
<path d="M117.91 237.8c1.29-.47 2.69-1.4 3.91-4.27s2-2.28 4.38-3.45a25.09 25.09 0 0 1 8.88-1.92c3-.18-2.8 3.39-4 4s-3.8 1.4-4.09 2.34a18.45 18.45 0 0 1-2.63 4.5c-.76.76-3.16 2.63-2.34 3.68s3.91 2.1 4.27 2.22-1 3.8-4.5 4.33a23.51 23.51 0 0 1-1.76-4c-.29-.65-1.87-1-3.39.47s-4.32 2.1-5.49 2.4-3 3.27-4.55 4.38-3.45 3-4.33 3.27.53-4 1.11-5a63.46 63.46 0 0 1 5.37-7c.88-.7 2.87-.64 4.62-1.75s1.75-1.23 1.69-1.81-1.87-2.16-2.1-2.75.24-.93 1-.87a11.35 11.35 0 0 1 3.95 1.23z" class="cls-96"/>
<path d="M111.55 236.06a15.21 15.21 0 0 1 1.52 1.25c.22.25.6-.38.55-.58a2.87 2.87 0 0 0-2.07-.67z" class="cls-97"/>
<ellipse cx="114.62" cy="236.83" class="cls-98" rx=".27" ry=".21"/>
</svg>

After

Width:  |  Height:  |  Size: 55 KiB

View File

@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: maintenance
namespace: human-connection
labels:
human-connection.org/selector: deployment-human-connection-maintenance
spec:
ports:
- name: web
port: 80
targetPort: 80
selector:
human-connection.org/selector: deployment-human-connection-maintenance

View File

@ -1 +1,2 @@
.ssh/ .ssh/
ssh/

View File

@ -111,6 +111,13 @@ u.createdAt = user.createdAt.`$date`,
u.updatedAt = user.updatedAt.`$date`, u.updatedAt = user.updatedAt.`$date`,
u.deleted = user.deletedAt IS NOT NULL, u.deleted = user.deletedAt IS NOT NULL,
u.disabled = false u.disabled = false
MERGE (e:EmailAddress {
email: user.email,
createdAt: toString(datetime()),
verifiedAt: toString(datetime())
})
MERGE (e)-[:BELONGS_TO]->(u)
MERGE (u)<-[:PRIMARY_EMAIL]-(e)
WITH u, user, user.badgeIds AS badgeIds WITH u, user, user.badgeIds AS badgeIds
UNWIND badgeIds AS badgeId UNWIND badgeIds AS badgeId
MATCH (b:Badge {id: badgeId}) MATCH (b:Badge {id: badgeId})

View File

@ -9,4 +9,4 @@
- ReadWriteOnce - ReadWriteOnce
resources: resources:
requests: requests:
storage: 1Gi storage: 5Gi

View File

@ -23,11 +23,7 @@ So, all we have to do is edit the kubernetes deployment of our Neo4J database
and set a custom `command` every time we have to carry out tasks like backup, and set a custom `command` every time we have to carry out tasks like backup,
restore, seed etc. restore, seed etc.
{% hint style="info" %} First bring the application into [maintenance mode](https://github.com/Human-Connection/Human-Connection/blob/master/deployment/human-connection/maintenance/README.md) to ensure there are no
TODO: implement maintenance mode
{% endhint %}
First bring the application into maintenance mode to ensure there are no
database connections left and nobody can access the application. database connections left and nobody can access the application.
Run the following: Run the following:

View File

@ -42,6 +42,14 @@ services:
context: neo4j context: neo4j
networks: networks:
- hc-network - hc-network
maintenance:
image: humanconnection/maintenance:latest
build:
context: deployment/human-connection/maintenance
networks:
- hc-network
ports:
- 80:80
networks: networks:
hc-network: hc-network:

View File

@ -1,11 +1,11 @@
FROM neo4j:3.5.5 FROM neo4j:3.5.8
LABEL Description="Neo4J database of the Social Network Human-Connection.org with preinstalled database constraints and indices" Vendor="Human Connection gGmbH" Version="0.0.1" Maintainer="Human Connection gGmbH (developer@human-connection.org)" LABEL Description="Neo4J database of the Social Network Human-Connection.org with preinstalled database constraints and indices" Vendor="Human Connection gGmbH" Version="0.0.1" Maintainer="Human Connection gGmbH (developer@human-connection.org)"
ARG BUILD_COMMIT ARG BUILD_COMMIT
ENV BUILD_COMMIT=$BUILD_COMMIT ENV BUILD_COMMIT=$BUILD_COMMIT
RUN wget https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/3.5.0.1/apoc-3.5.0.1-all.jar -P plugins/
RUN apk add --no-cache --quiet procps
COPY db_setup.sh /usr/local/bin/db_setup COPY db_setup.sh /usr/local/bin/db_setup
COPY entrypoint.sh /docker-entrypoint-wrapper.sh COPY entrypoint.sh /docker-entrypoint-wrapper.sh
RUN apt-get update && apt-get -y install procps wget
RUN wget https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/3.5.0.4/apoc-3.5.0.4-all.jar -P plugins/
ENTRYPOINT ["/docker-entrypoint-wrapper.sh"] ENTRYPOINT ["/docker-entrypoint-wrapper.sh"]

View File

@ -34,6 +34,8 @@ CREATE CONSTRAINT ON (p:Post) ASSERT p.slug IS UNIQUE;
CREATE CONSTRAINT ON (c:Category) ASSERT c.slug IS UNIQUE; CREATE CONSTRAINT ON (c:Category) ASSERT c.slug IS UNIQUE;
CREATE CONSTRAINT ON (u:User) ASSERT u.slug IS UNIQUE; CREATE CONSTRAINT ON (u:User) ASSERT u.slug IS UNIQUE;
CREATE CONSTRAINT ON (o:Organization) ASSERT o.slug IS UNIQUE; CREATE CONSTRAINT ON (o:Organization) ASSERT o.slug IS UNIQUE;
CREATE CONSTRAINT ON (e:EmailAddress) ASSERT e.email IS UNIQUE;
' | cypher-shell ' | cypher-shell
echo ' echo '

View File

@ -22,15 +22,15 @@
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"codecov": "^3.5.0", "codecov": "^3.5.0",
"cross-env": "^5.2.0", "cross-env": "^5.2.0",
"cypress": "^3.4.0", "cypress": "^3.4.1",
"cypress-cucumber-preprocessor": "^1.12.0", "cypress-cucumber-preprocessor": "^1.13.0",
"cypress-file-upload": "^3.3.1", "cypress-file-upload": "^3.3.3",
"cypress-plugin-retries": "^1.2.2", "cypress-plugin-retries": "^1.2.2",
"dotenv": "^8.0.0", "dotenv": "^8.0.0",
"faker": "Marak/faker.js#master", "faker": "Marak/faker.js#master",
"graphql-request": "^1.8.2", "graphql-request": "^1.8.2",
"neo4j-driver": "^1.7.5", "neo4j-driver": "^1.7.5",
"neode": "^0.2.16", "neode": "^0.3.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"slug": "^1.1.0" "slug": "^1.1.0"
} }

View File

@ -4,7 +4,9 @@ docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT --target production -t huma
docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT --target production -t humanconnection/nitro-web:latest $TRAVIS_BUILD_DIR/webapp docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT --target production -t humanconnection/nitro-web:latest $TRAVIS_BUILD_DIR/webapp
docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT -t humanconnection/neo4j:latest $TRAVIS_BUILD_DIR/neo4j docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT -t humanconnection/neo4j:latest $TRAVIS_BUILD_DIR/neo4j
docker build -t humanconnection/maintenance-worker:latest $TRAVIS_BUILD_DIR/deployment/legacy-migration/maintenance-worker docker build -t humanconnection/maintenance-worker:latest $TRAVIS_BUILD_DIR/deployment/legacy-migration/maintenance-worker
docker build -t humanconnection/maintenance:latest $TRAVIS_BUILD_DIR/deployment/human-connection/maintenance
docker push humanconnection/nitro-backend:latest docker push humanconnection/nitro-backend:latest
docker push humanconnection/nitro-web:latest docker push humanconnection/nitro-web:latest
docker push humanconnection/neo4j:latest docker push humanconnection/neo4j:latest
docker push humanconnection/maintenance-worker:latest docker push humanconnection/maintenance-worker:latest
docker push humanconnection/maintenance:latest

View File

@ -1,4 +1,4 @@
FROM node:12.6-alpine as base FROM node:12.7-alpine as base
LABEL Description="Web Frontend of the Social Network Human-Connection.org" Vendor="Human-Connection gGmbH" Version="0.0.1" Maintainer="Human-Connection gGmbH (developer@human-connection.org)" LABEL Description="Web Frontend of the Social Network Human-Connection.org" Vendor="Human-Connection gGmbH" Version="0.0.1" Maintainer="Human-Connection gGmbH (developer@human-connection.org)"
EXPOSE 3000 EXPOSE 3000

View File

@ -23,49 +23,61 @@
:modalsData="menuModalsData" :modalsData="menuModalsData"
style="float-right" style="float-right"
:is-owner="isAuthor(author.id)" :is-owner="isAuthor(author.id)"
@showEditCommentMenu="editCommentMenu"
/> />
</no-ssr> </no-ssr>
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<div v-if="isCollapsed" v-html="comment.contentExcerpt" style="padding-left: 40px;" /> <ds-space margin-bottom="small" />
<div <div v-if="openEditCommentMenu">
v-show="comment.content !== comment.contentExcerpt" <hc-edit-comment-form
style="text-align: right; margin-right: 20px; margin-top: -12px;" :comment="comment"
> :post="post"
<a v-if="isCollapsed" style="padding-left: 40px;" @click="isCollapsed = !isCollapsed"> @showEditCommentMenu="editCommentMenu"
{{ $t('comment.show.more') }} />
</a>
</div> </div>
<div v-if="!isCollapsed" v-html="comment.content" style="padding-left: 40px;" /> <div v-show="!openEditCommentMenu">
<div style="text-align: right; margin-right: 20px; margin-top: -12px;"> <div v-if="isCollapsed" v-html="comment.contentExcerpt" style="padding-left: 40px;" />
<a v-if="!isCollapsed" @click="isCollapsed = !isCollapsed" style="padding-left: 40px; "> <div
{{ $t('comment.show.less') }} v-show="comment.content !== comment.contentExcerpt"
</a> style="text-align: right; margin-right: 20px; margin-top: -12px;"
>
<a v-if="isCollapsed" style="padding-left: 40px;" @click="isCollapsed = !isCollapsed">
{{ $t('comment.show.more') }}
</a>
</div>
<div v-if="!isCollapsed" v-html="comment.content" style="padding-left: 40px;" />
<div style="text-align: right; margin-right: 20px; margin-top: -12px;">
<a v-if="!isCollapsed" @click="isCollapsed = !isCollapsed" style="padding-left: 40px; ">
{{ $t('comment.show.less') }}
</a>
</div>
</div> </div>
<ds-space margin-bottom="small" /> <ds-space margin-bottom="small" />
</ds-card> </ds-card>
</div> </div>
</template> </template>
<!-- eslint-enable vue/no-v-html -->
<script> <script>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { mapGetters } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
import HcUser from '~/components/User' import HcUser from '~/components/User'
import ContentMenu from '~/components/ContentMenu' import ContentMenu from '~/components/ContentMenu'
import HcEditCommentForm from '~/components/comments/EditCommentForm/EditCommentForm'
export default { export default {
data: function() { data: function() {
return { return {
isCollapsed: true, isCollapsed: true,
openEditCommentMenu: false,
} }
}, },
components: { components: {
HcUser, HcUser,
ContentMenu, ContentMenu,
HcEditCommentForm,
}, },
props: { props: {
post: { type: Object, default: () => {} },
comment: { comment: {
type: Object, type: Object,
default() { default() {
@ -112,9 +124,16 @@ export default {
}, },
}, },
methods: { methods: {
...mapMutations({
setEditPending: 'editor/SET_EDIT_PENDING',
}),
isAuthor(id) { isAuthor(id) {
return this.user.id === id return this.user.id === id
}, },
editCommentMenu(showMenu) {
this.openEditCommentMenu = showMenu
this.setEditPending(showMenu)
},
async deleteCommentCallback() { async deleteCommentCallback() {
try { try {
var gqlMutation = gql` var gqlMutation = gql`

View File

@ -76,14 +76,13 @@ export default {
} }
if (this.isOwner && this.resourceType === 'comment') { if (this.isOwner && this.resourceType === 'comment') {
// routes.push({ routes.push({
// name: this.$t(`comment.menu.edit`), name: this.$t(`comment.menu.edit`),
// callback: () => { callback: () => {
// /* eslint-disable-next-line no-console */ this.$emit('showEditCommentMenu', true)
// console.log('EDIT COMMENT') },
// }, icon: 'edit',
// icon: 'edit' })
// })
routes.push({ routes.push({
name: this.$t(`comment.menu.delete`), name: this.$t(`comment.menu.delete`),
callback: () => { callback: () => {

View File

@ -6,6 +6,9 @@ import PostMutations from '~/graphql/PostMutations.js'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect' import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import Filters from '~/plugins/vue-filters' import Filters from '~/plugins/vue-filters'
import TeaserImage from '~/components/TeaserImage/TeaserImage' import TeaserImage from '~/components/TeaserImage/TeaserImage'
import MutationObserver from 'mutation-observer'
global.MutationObserver = MutationObserver
const localVue = createLocalVue() const localVue = createLocalVue()
@ -14,6 +17,8 @@ localVue.use(Styleguide)
localVue.use(Filters) localVue.use(Filters)
config.stubs['no-ssr'] = '<span><slot /></span>' config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
config.stubs['v-popover'] = '<span><slot /></span>'
describe('ContributionForm.vue', () => { describe('ContributionForm.vue', () => {
let wrapper let wrapper
@ -24,7 +29,17 @@ describe('ContributionForm.vue', () => {
let mocks let mocks
let propsData let propsData
const postTitle = 'this is a title for a post' const postTitle = 'this is a title for a post'
const postTitleTooShort = 'xx'
let postTitleTooLong = ''
for (let i = 0; i < 65; i++) {
postTitleTooLong += 'x'
}
const postContent = 'this is a post' const postContent = 'this is a post'
const postContentTooShort = 'xx'
let postContentTooLong = ''
for (let i = 0; i < 2001; i++) {
postContentTooLong += 'x'
}
const imageUpload = { const imageUpload = {
file: { file: {
filename: 'avataar.svg', filename: 'avataar.svg',
@ -37,22 +52,17 @@ describe('ContributionForm.vue', () => {
mocks = { mocks = {
$t: jest.fn(), $t: jest.fn(),
$apollo: { $apollo: {
mutate: jest mutate: jest.fn().mockResolvedValueOnce({
.fn() data: {
.mockResolvedValueOnce({ CreatePost: {
data: { title: postTitle,
CreatePost: { slug: 'this-is-a-title-for-a-post',
title: postTitle, content: postContent,
slug: 'this-is-a-title-for-a-post', contentExcerpt: postContent,
content: postContent, language: 'en',
contentExcerpt: postContent,
language: 'en',
},
}, },
}) },
.mockRejectedValue({ }),
message: 'Not Authorised!',
}),
}, },
$toast: { $toast: {
error: jest.fn(), error: jest.fn(),
@ -74,6 +84,13 @@ describe('ContributionForm.vue', () => {
'editor/placeholder': () => { 'editor/placeholder': () => {
return 'some cool placeholder' return 'some cool placeholder'
}, },
'auth/user': () => {
return {
id: '4711',
name: 'You yourself',
slug: 'you-yourself',
}
},
} }
const store = new Vuex.Store({ const store = new Vuex.Store({
getters, getters,
@ -109,16 +126,53 @@ describe('ContributionForm.vue', () => {
}) })
describe('invalid form submission', () => { describe('invalid form submission', () => {
it('title required for form submission', async () => { it('title and content should not be empty ', async () => {
postTitleInput = wrapper.find('.ds-input') wrapper.find('.submit-button-for-test').trigger('click')
postTitleInput.setValue(postTitle)
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled() expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
}) })
it('content required for form submission', async () => { it('title should not be empty', async () => {
wrapper.vm.updateEditorContent(postContent) await wrapper.vm.updateEditorContent(postContent)
await wrapper.find('form').trigger('submit') wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('title should not be too long', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitleTooLong)
await wrapper.vm.updateEditorContent(postContent)
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('title should not be too short', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitleTooShort)
await wrapper.vm.updateEditorContent(postContent)
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('content should not be empty', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
await wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('content should not be too short', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
await wrapper.vm.updateEditorContent(postContentTooShort)
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
it('content should not be too long', async () => {
postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle)
await wrapper.vm.updateEditorContent(postContentTooLong)
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled() expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
}) })
}) })
@ -139,15 +193,16 @@ describe('ContributionForm.vue', () => {
} }
postTitleInput = wrapper.find('.ds-input') postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle) postTitleInput.setValue(postTitle)
wrapper.vm.updateEditorContent(postContent) await wrapper.vm.updateEditorContent(postContent)
await wrapper.find('form').trigger('submit')
}) })
it('with title and content', () => { it('with title and content', () => {
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
}) })
it("sends a fallback language based on a user's locale", () => { it("sends a fallback language based on a user's locale", () => {
wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
}) })
@ -155,7 +210,7 @@ describe('ContributionForm.vue', () => {
expectedParams.variables.language = 'de' expectedParams.variables.language = 'de'
deutschOption = wrapper.findAll('li').at(0) deutschOption = wrapper.findAll('li').at(0)
deutschOption.trigger('click') deutschOption.trigger('click')
await wrapper.find('form').trigger('submit') wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
}) })
@ -163,22 +218,26 @@ describe('ContributionForm.vue', () => {
const categoryIds = ['cat12', 'cat15', 'cat37'] const categoryIds = ['cat12', 'cat15', 'cat37']
expectedParams.variables.categoryIds = categoryIds expectedParams.variables.categoryIds = categoryIds
wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds) wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds)
await wrapper.find('form').trigger('submit') await wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
}) })
it('supports adding a teaser image', async () => { it('supports adding a teaser image', async () => {
expectedParams.variables.imageUpload = imageUpload expectedParams.variables.imageUpload = imageUpload
wrapper.find(TeaserImage).vm.$emit('addTeaserImage', imageUpload) wrapper.find(TeaserImage).vm.$emit('addTeaserImage', imageUpload)
await wrapper.find('form').trigger('submit') await wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
}) })
it("pushes the user to the post's page", async () => { it("pushes the user to the post's page", async () => {
wrapper.find('.submit-button-for-test').trigger('click')
await mocks.$apollo.mutate
expect(mocks.$router.push).toHaveBeenCalledTimes(1) expect(mocks.$router.push).toHaveBeenCalledTimes(1)
}) })
it('shows a success toaster', () => { it('shows a success toaster', async () => {
wrapper.find('.submit-button-for-test').trigger('click')
await mocks.$apollo.mutate
expect(mocks.$toast.success).toHaveBeenCalledTimes(1) expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
}) })
}) })
@ -194,18 +253,19 @@ describe('ContributionForm.vue', () => {
describe('handles errors', () => { describe('handles errors', () => {
beforeEach(async () => { beforeEach(async () => {
jest.useFakeTimers() jest.useFakeTimers()
mocks.$apollo.mutate = jest.fn().mockRejectedValueOnce({
message: 'Not Authorised!',
})
wrapper = Wrapper() wrapper = Wrapper()
postTitleInput = wrapper.find('.ds-input') postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle) postTitleInput.setValue(postTitle)
wrapper.vm.updateEditorContent(postContent) await wrapper.vm.updateEditorContent(postContent)
// second submission causes mutation to reject
await wrapper.find('form').trigger('submit')
}) })
it('shows an error toaster when apollo mutation rejects', async () => { it('shows an error toaster when apollo mutation rejects', async () => {
await wrapper.find('form').trigger('submit') await wrapper.find('.submit-button-for-test').trigger('click')
await mocks.$apollo.mutate await mocks.$apollo.mutate
expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!') await expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!')
}) })
}) })
}) })
@ -263,7 +323,7 @@ describe('ContributionForm.vue', () => {
postTitleInput = wrapper.find('.ds-input') postTitleInput = wrapper.find('.ds-input')
postTitleInput.setValue(postTitle) postTitleInput.setValue(postTitle)
wrapper.vm.updateEditorContent(postContent) wrapper.vm.updateEditorContent(postContent)
await wrapper.find('form').trigger('submit') await wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
}) })
@ -274,7 +334,7 @@ describe('ContributionForm.vue', () => {
wrapper.vm.updateEditorContent(postContent) wrapper.vm.updateEditorContent(postContent)
expectedParams.variables.categoryIds = categoryIds expectedParams.variables.categoryIds = categoryIds
wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds) wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds)
await wrapper.find('form').trigger('submit') await wrapper.find('.submit-button-for-test').trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
}) })
}) })

View File

@ -1,5 +1,5 @@
<template> <template>
<ds-form ref="contributionForm" v-model="form" :schema="formSchema" @submit="submit"> <ds-form ref="contributionForm" v-model="form" :schema="formSchema">
<template slot-scope="{ errors }"> <template slot-scope="{ errors }">
<ds-card> <ds-card>
<hc-teaser-image :contribution="contribution" @addTeaserImage="addTeaserImage"> <hc-teaser-image :contribution="contribution" @addTeaserImage="addTeaserImage">
@ -9,7 +9,11 @@
:src="contribution.image | proxyApiUrl" :src="contribution.image | proxyApiUrl"
/> />
</hc-teaser-image> </hc-teaser-image>
<ds-space />
<hc-user :user="currentUser" :trunc="35" />
<ds-space />
<ds-input model="title" class="post-title" placeholder="Title" name="title" autofocus /> <ds-input model="title" class="post-title" placeholder="Title" name="title" autofocus />
<small class="smallTag">{{ form.title.length }}/{{ formSchema.title.max }}</small>
<no-ssr> <no-ssr>
<hc-editor <hc-editor
:users="users" :users="users"
@ -17,6 +21,7 @@
:value="form.content" :value="form.content"
@input="updateEditorContent" @input="updateEditorContent"
/> />
<small class="smallTag">{{ form.contentLength }}/{{ contentMax }}</small>
</no-ssr> </no-ssr>
<ds-space margin-bottom="xxx-large" /> <ds-space margin-bottom="xxx-large" />
<hc-categories-select <hc-categories-select
@ -41,18 +46,20 @@
<div slot="footer" style="text-align: right"> <div slot="footer" style="text-align: right">
<ds-button <ds-button
class="cancel-button" class="cancel-button"
:disabled="loading || disabled" :disabled="loading"
ghost ghost
@click.prevent="$router.back()" @click.prevent="$router.back()"
> >
{{ $t('actions.cancel') }} {{ $t('actions.cancel') }}
</ds-button> </ds-button>
<ds-button <ds-button
class="submit-button-for-test"
type="submit" type="submit"
icon="check" icon="check"
:loading="loading" :loading="loading"
:disabled="disabled || errors" :disabled="disabledByContent || errors"
primary primary
@click.prevent="submit"
> >
{{ $t('actions.save') }} {{ $t('actions.save') }}
</ds-button> </ds-button>
@ -65,18 +72,21 @@
<script> <script>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import HcEditor from '~/components/Editor/Editor'
import orderBy from 'lodash/orderBy' import orderBy from 'lodash/orderBy'
import { mapGetters } from 'vuex'
import HcEditor from '~/components/Editor/Editor'
import locales from '~/locales' import locales from '~/locales'
import PostMutations from '~/graphql/PostMutations.js' import PostMutations from '~/graphql/PostMutations.js'
import HcCategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect' import HcCategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import HcTeaserImage from '~/components/TeaserImage/TeaserImage' import HcTeaserImage from '~/components/TeaserImage/TeaserImage'
import HcUser from '~/components/User'
export default { export default {
components: { components: {
HcEditor, HcEditor,
HcCategoriesSelect, HcCategoriesSelect,
HcTeaserImage, HcTeaserImage,
HcUser,
}, },
props: { props: {
contribution: { type: Object, default: () => {} }, contribution: { type: Object, default: () => {} },
@ -86,6 +96,7 @@ export default {
form: { form: {
title: '', title: '',
content: '', content: '',
contentLength: 0,
teaserImage: null, teaserImage: null,
image: null, image: null,
language: null, language: null,
@ -94,13 +105,16 @@ export default {
}, },
formSchema: { formSchema: {
title: { required: true, min: 3, max: 64 }, title: { required: true, min: 3, max: 64 },
content: { required: true, min: 3 }, content: [{ required: true }],
}, },
id: null, id: null,
loading: false, loading: false,
disabled: false, disabledByContent: true,
slug: null, slug: null,
users: [], users: [],
contentMin: 3,
contentMax: 2000,
hashtags: [], hashtags: [],
} }
}, },
@ -113,8 +127,9 @@ export default {
} }
this.id = contribution.id this.id = contribution.id
this.slug = contribution.slug this.slug = contribution.slug
this.form.content = contribution.content
this.form.title = contribution.title this.form.title = contribution.title
this.form.content = contribution.content
this.manageContent(this.form.content)
this.form.image = contribution.image this.form.image = contribution.image
this.form.categoryIds = this.categoryIds(contribution.categories) this.form.categoryIds = this.categoryIds(contribution.categories)
}, },
@ -128,6 +143,9 @@ export default {
: locales.find(loc => this.$i18n.locale() === loc.code) : locales.find(loc => this.$i18n.locale() === loc.code)
return locale.name return locale.name
}, },
...mapGetters({
currentUser: 'auth/user',
}),
}, },
mounted() { mounted() {
this.availableLocales() this.availableLocales()
@ -160,7 +178,7 @@ export default {
.then(res => { .then(res => {
this.loading = false this.loading = false
this.$toast.success(this.$t('contribution.success')) this.$toast.success(this.$t('contribution.success'))
this.disabled = true this.disabledByContent = true
const result = res.data[this.id ? 'UpdatePost' : 'CreatePost'] const result = res.data[this.id ? 'UpdatePost' : 'CreatePost']
this.$router.push({ this.$router.push({
@ -171,12 +189,21 @@ export default {
.catch(err => { .catch(err => {
this.$toast.error(err.message) this.$toast.error(err.message)
this.loading = false this.loading = false
this.disabled = false this.disabledByContent = false
}) })
}, },
updateEditorContent(value) { updateEditorContent(value) {
// this.form.content = value // TODO: Do smth????? what is happening
this.$refs.contributionForm.update('content', value) this.$refs.contributionForm.update('content', value)
this.manageContent(value)
},
manageContent(content) {
// filter HTML out of content value
const str = content.replace(/<\/?[^>]+(>|$)/gm, '')
// Set counter length of text
this.form.contentLength = str.length
// Enable save button if requirements are met
this.disabledByContent = !(this.contentMin <= str.length && str.length <= this.contentMax)
}, },
availableLocales() { availableLocales() {
orderBy(locales, 'name').map(locale => { orderBy(locales, 'name').map(locale => {
@ -233,6 +260,11 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
.smallTag {
width: 100%;
position: relative;
left: 90%;
}
.post-title { .post-title {
margin-top: $space-x-small; margin-top: $space-x-small;
margin-bottom: $space-xx-small; margin-bottom: $space-xx-small;

View File

@ -1,8 +1,10 @@
import { mount, createLocalVue } from '@vue/test-utils' import { mount, createLocalVue } from '@vue/test-utils'
import Editor from './Editor' import Editor from './Editor'
import Vuex from 'vuex' import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide' import Styleguide from '@human-connection/styleguide'
import MutationObserver from 'mutation-observer'
global.MutationObserver = MutationObserver
const localVue = createLocalVue() const localVue = createLocalVue()
localVue.use(Vuex) localVue.use(Vuex)

View File

@ -2,6 +2,9 @@ import { mount, createLocalVue } from '@vue/test-utils'
import CommentForm from './CommentForm.vue' import CommentForm from './CommentForm.vue'
import Styleguide from '@human-connection/styleguide' import Styleguide from '@human-connection/styleguide'
import Vuex from 'vuex' import Vuex from 'vuex'
import MutationObserver from 'mutation-observer'
global.MutationObserver = MutationObserver
const localVue = createLocalVue() const localVue = createLocalVue()
localVue.use(Vuex) localVue.use(Vuex)
@ -48,6 +51,7 @@ describe('CommentForm.vue', () => {
'editor/placeholder': () => { 'editor/placeholder': () => {
return 'some cool placeholder' return 'some cool placeholder'
}, },
'editor/editPending': () => false,
} }
const store = new Vuex.Store({ const store = new Vuex.Store({
getters, getters,

View File

@ -1,5 +1,5 @@
<template> <template>
<ds-form v-model="form" @submit="handleSubmit"> <ds-form v-show="!editPending" v-model="form" @submit="handleSubmit">
<template slot-scope="{ errors }"> <template slot-scope="{ errors }">
<ds-card> <ds-card>
<hc-editor <hc-editor
@ -33,6 +33,7 @@ import gql from 'graphql-tag'
import HcEditor from '~/components/Editor/Editor' import HcEditor from '~/components/Editor/Editor'
import PostCommentsQuery from '~/graphql/PostCommentsQuery.js' import PostCommentsQuery from '~/graphql/PostCommentsQuery.js'
import CommentMutations from '~/graphql/CommentMutations.js' import CommentMutations from '~/graphql/CommentMutations.js'
import { mapGetters } from 'vuex'
export default { export default {
components: { components: {
@ -52,6 +53,11 @@ export default {
users: [], users: [],
} }
}, },
computed: {
...mapGetters({
editPending: 'editor/editPending',
}),
},
methods: { methods: {
updateEditorContent(value) { updateEditorContent(value) {
const content = value.replace(/<(?:.|\n)*?>/gm, '').trim() const content = value.replace(/<(?:.|\n)*?>/gm, '').trim()

View File

@ -16,11 +16,12 @@
</span> </span>
</h3> </h3>
<ds-space margin-bottom="large" /> <ds-space margin-bottom="large" />
<div v-if="comments && comments.length" class="comments"> <div v-if="comments && comments.length" id="comments" class="comments">
<comment <comment
v-for="(comment, index) in comments" v-for="(comment, index) in comments"
:key="comment.id" :key="comment.id"
:comment="comment" :comment="comment"
:post="post"
@deleteComment="comments.splice(index, 1)" @deleteComment="comments.splice(index, 1)"
/> />
</div> </div>

View File

@ -0,0 +1,108 @@
<template>
<ds-form v-model="form" @submit="handleSubmit">
<template slot-scope="{ errors }">
<ds-card>
<!-- with no-ssr the content is not shown -->
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
<ds-space />
<ds-flex :gutter="{ base: 'small', md: 'small', sm: 'x-large', xs: 'x-large' }">
<ds-flex-item :width="{ base: '0%', md: '50%', sm: '0%', xs: '0%' }" />
<ds-flex-item :width="{ base: '40%', md: '20%', sm: '30%', xs: '30%' }">
<ds-button ghost class="cancelBtn" @click.prevent="closeEditWindow">
{{ $t('actions.cancel') }}
</ds-button>
</ds-flex-item>
<ds-flex-item :width="{ base: '40%', md: '20%', sm: '40%', xs: '40%' }">
<ds-button type="submit" :loading="loading" :disabled="disabled || errors" primary>
{{ $t('post.comment.submit') }}
</ds-button>
</ds-flex-item>
</ds-flex>
</ds-card>
</template>
</ds-form>
</template>
<script>
import gql from 'graphql-tag'
import HcEditor from '~/components/Editor/Editor'
import { mapMutations } from 'vuex'
import CommentMutations from '~/graphql/CommentMutations.js'
export default {
components: {
HcEditor,
},
props: {
comment: {
type: Object,
default() {
return {}
},
},
},
data() {
return {
disabled: true,
loading: false,
form: {
content: this.comment.content,
},
users: [],
}
},
methods: {
...mapMutations({
setEditPending: 'editor/SET_EDIT_PENDING',
}),
updateEditorContent(value) {
const sanitizedContent = value.replace(/<(?:.|\n)*?>/gm, '').trim()
this.disabled = value === this.comment.content || sanitizedContent.length < 1
this.form.content = value
},
closeEditWindow() {
this.$emit('showEditCommentMenu', false)
},
handleSubmit() {
this.loading = true
this.disabled = true
this.$apollo
.mutate({
mutation: CommentMutations().UpdateComment,
variables: {
content: this.form.content,
id: this.comment.id,
},
})
.then(() => {
this.loading = false
this.$toast.success(this.$t('post.comment.updated'))
this.disabled = false
this.$emit('showEditCommentMenu', false)
this.setEditPending(false)
})
.catch(err => {
this.$toast.error(err.message)
})
},
},
apollo: {
User: {
query() {
return gql`
{
User(orderBy: slug_asc) {
id
slug
}
}
`
},
result({ data: { User } }) {
this.users = User
},
},
},
}
</script>

View File

@ -20,5 +20,14 @@ export default () => {
} }
} }
`, `,
UpdateComment: gql`
mutation($content: String!, $id: ID!) {
UpdateComment(content: $content, id: $id) {
id
content
contentExcerpt
}
}
`,
} }
} }

View File

@ -2,7 +2,7 @@ import gql from 'graphql-tag'
export default app => { export default app => {
const lang = app.$i18n.locale().toUpperCase() const lang = app.$i18n.locale().toUpperCase()
return gql(` return gql`
query Comment($postId: ID) { query Comment($postId: ID) {
Comment(postId: $postId) { Comment(postId: $postId) {
id id
@ -30,5 +30,5 @@ export default app => {
} }
} }
} }
`) `
} }

View File

@ -2,7 +2,7 @@ import gql from 'graphql-tag'
export default i18n => { export default i18n => {
const lang = i18n.locale().toUpperCase() const lang = i18n.locale().toUpperCase()
return gql(` return gql`
query Post($slug: String!) { query Post($slug: String!) {
Post(slug: $slug) { Post(slug: $slug) {
id id
@ -73,14 +73,14 @@ export default i18n => {
shoutedByCurrentUser shoutedByCurrentUser
} }
} }
`) `
} }
export const filterPosts = i18n => { export const filterPosts = i18n => {
const lang = i18n.locale().toUpperCase() const lang = i18n.locale().toUpperCase()
return gql(` return gql`
query Post($filter: _PostFilter, $first: Int, $offset: Int) { query Post($filter: _PostFilter, $first: Int, $offset: Int, $orderBy: [_PostOrdering]) {
Post(filter: $filter, first: $first, offset: $offset) { Post(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) {
id id
title title
contentExcerpt contentExcerpt
@ -118,5 +118,5 @@ export const filterPosts = i18n => {
shoutedCount shoutedCount
} }
} }
`) `
} }

View File

@ -23,6 +23,12 @@
"bank": "Bankverbindung", "bank": "Bankverbindung",
"germany": "Deutschland" "germany": "Deutschland"
}, },
"sorting": {
"newest": "Neuste",
"oldest": "Älteste",
"popular": "Beliebt",
"commented": "meist Kommentiert"
},
"login": { "login": {
"copy": "Wenn Du bereits ein Konto bei Human Connection hast, melde Dich bitte hier an.", "copy": "Wenn Du bereits ein Konto bei Human Connection hast, melde Dich bitte hier an.",
"login": "Einloggen", "login": "Einloggen",
@ -168,7 +174,8 @@
}, },
"social-media": { "social-media": {
"name": "Soziale Medien", "name": "Soziale Medien",
"placeholder": "Füge eine Social-Media URL hinzu", "placeholder": "Deine Social-Media URL",
"requireUnique": "Dieser Link existiert bereits",
"submit": "Link hinzufügen", "submit": "Link hinzufügen",
"successAdd": "Social-Media hinzugefügt. Profil aktualisiert!", "successAdd": "Social-Media hinzugefügt. Profil aktualisiert!",
"successDelete": "Social-Media gelöscht. Profil aktualisiert!" "successDelete": "Social-Media gelöscht. Profil aktualisiert!"
@ -286,6 +293,7 @@
"reportContent": "Melden", "reportContent": "Melden",
"validations": { "validations": {
"email": "muss eine gültige E-Mail Adresse sein", "email": "muss eine gültige E-Mail Adresse sein",
"url": "muss eine gültige URL sein",
"verification-code": "muss genau 6 Buchstaben lang sein" "verification-code": "muss genau 6 Buchstaben lang sein"
} }
}, },

View File

@ -18,11 +18,17 @@
"tribunal": "Registry court", "tribunal": "Registry court",
"register": "Registry number", "register": "Registry number",
"director": "Managing Director", "director": "Managing Director",
"taxident": "Value added tax identification number according to § 27 a Value Added Tax Act (Germany)", "taxident": "USt-ID. according to §27a of the German Sales Tax Law:",
"responsible": "Responsible according to § 55 Abs. 2 RStV (Germany) ", "responsible": "responsible for contents of this page (§ 55 Abs. 2 RStV)",
"bank": "bank account", "bank": "bank account",
"germany": "Germany" "germany": "Germany"
}, },
"sorting": {
"newest": "Newest",
"oldest": "Oldest",
"popular": "Popular",
"commented": "most Commented"
},
"login": { "login": {
"copy": "If you already have a human-connection account, login here.", "copy": "If you already have a human-connection account, login here.",
"login": "Login", "login": "Login",
@ -169,7 +175,8 @@
}, },
"social-media": { "social-media": {
"name": "Social media", "name": "Social media",
"placeholder": "Add social media url", "placeholder": "Your social media url",
"requireUnique": "You added this url already",
"submit": "Add link", "submit": "Add link",
"successAdd": "Added social media. Updated user profile!", "successAdd": "Added social media. Updated user profile!",
"successDelete": "Deleted social media. Updated user profile!" "successDelete": "Deleted social media. Updated user profile!"
@ -246,7 +253,8 @@
}, },
"comment": { "comment": {
"submit": "Comment", "submit": "Comment",
"submitted": "Comment Submitted" "submitted": "Comment Submitted",
"updated": "Changes Saved"
} }
}, },
"comment": { "comment": {
@ -287,6 +295,7 @@
"reportContent": "Report", "reportContent": "Report",
"validations": { "validations": {
"email": "must be a valid email address", "email": "must be a valid email address",
"url": "must be a valid URL",
"verification-code": "must be 6 characters long" "verification-code": "must be 6 characters long"
} }
}, },

View File

@ -305,15 +305,6 @@ module.exports = {
tokenName: 'human-connection-token', // optional, default: apollo-token tokenName: 'human-connection-token', // optional, default: apollo-token
tokenExpires: 3, // optional, default: 7 (days) tokenExpires: 3, // optional, default: 7 (days)
// includeNodeModules: true, // optional, default: false (this includes graphql-tag for node_modules folder) // includeNodeModules: true, // optional, default: false (this includes graphql-tag for node_modules folder)
// optional
errorHandler(error) {
/* eslint-disable-next-line no-console */
console.log(
'%cError',
'background: red; color: white; padding: 2px 4px; border-radius: 3px; font-weight: bold;',
error.message,
)
},
// Watch loading state for all queries // Watch loading state for all queries
// See 'Smart Query > options > watchLoading' for detail // See 'Smart Query > options > watchLoading' for detail

View File

@ -50,16 +50,16 @@
}, },
"dependencies": { "dependencies": {
"@human-connection/styleguide": "0.5.17", "@human-connection/styleguide": "0.5.17",
"@nuxtjs/apollo": "4.0.0-rc4.2", "@nuxtjs/apollo": "^4.0.0-rc10",
"@nuxtjs/axios": "~5.5.4", "@nuxtjs/axios": "~5.5.4",
"@nuxtjs/dotenv": "~1.3.0", "@nuxtjs/dotenv": "~1.3.0",
"@nuxtjs/style-resources": "~0.1.2", "@nuxtjs/style-resources": "~0.1.2",
"accounting": "~0.4.1", "accounting": "~0.4.1",
"apollo-cache-inmemory": "~1.5.1", "apollo-cache-inmemory": "~1.6.2",
"apollo-client": "~2.6.3", "apollo-client": "~2.6.3",
"cookie-universal-nuxt": "~2.0.16", "cookie-universal-nuxt": "~2.0.17",
"cross-env": "~5.2.0", "cross-env": "~5.2.0",
"date-fns": "2.0.0-beta.2", "date-fns": "2.0.0-beta.3",
"express": "~4.17.1", "express": "~4.17.1",
"graphql": "~14.4.2", "graphql": "~14.4.2",
"isemail": "^3.2.0", "isemail": "^3.2.0",
@ -70,21 +70,21 @@
"nuxt-env": "~0.1.0", "nuxt-env": "~0.1.0",
"stack-utils": "^1.0.2", "stack-utils": "^1.0.2",
"string-hash": "^1.1.3", "string-hash": "^1.1.3",
"tiptap": "1.21.0", "tiptap": "~1.24.0",
"tiptap-extensions": "1.22.2", "tiptap-extensions": "~1.26.0",
"v-tooltip": "~2.0.2", "v-tooltip": "~2.0.2",
"vue-count-to": "~1.0.13", "vue-count-to": "~1.0.13",
"vue-izitoast": "1.1.2", "vue-izitoast": "1.1.2",
"vue-sweetalert-icons": "~3.2.0", "vue-sweetalert-icons": "~3.2.0",
"vuex-i18n": "~1.11.0", "vuex-i18n": "~1.13.0",
"zxcvbn": "^4.4.2" "zxcvbn": "^4.4.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "~7.5.4", "@babel/core": "~7.5.5",
"@babel/plugin-syntax-dynamic-import": "^7.2.0", "@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/preset-env": "~7.5.4", "@babel/preset-env": "~7.5.5",
"@vue/cli-shared-utils": "~3.9.0", "@vue/cli-shared-utils": "~3.10.0",
"@vue/eslint-config-prettier": "~4.0.1", "@vue/eslint-config-prettier": "~5.0.0",
"@vue/server-test-utils": "~1.0.0-beta.29", "@vue/server-test-utils": "~1.0.0-beta.29",
"@vue/test-utils": "~1.0.0-beta.29", "@vue/test-utils": "~1.0.0-beta.29",
"babel-core": "~7.0.0-bridge.0", "babel-core": "~7.0.0-bridge.0",
@ -94,20 +94,22 @@
"eslint-config-prettier": "~6.0.0", "eslint-config-prettier": "~6.0.0",
"eslint-config-standard": "~12.0.0", "eslint-config-standard": "~12.0.0",
"eslint-loader": "~2.2.1", "eslint-loader": "~2.2.1",
"eslint-plugin-import": "~2.18.0", "eslint-plugin-import": "~2.18.2",
"eslint-plugin-jest": "~22.8.0", "eslint-plugin-jest": "~22.14.1",
"eslint-plugin-node": "~9.1.0", "eslint-plugin-node": "~9.1.0",
"eslint-plugin-prettier": "~3.1.0", "eslint-plugin-prettier": "~3.1.0",
"eslint-plugin-promise": "~4.2.1", "eslint-plugin-promise": "~4.2.1",
"eslint-plugin-standard": "~4.0.0", "eslint-plugin-standard": "~4.0.0",
"eslint-plugin-vue": "~5.2.3", "eslint-plugin-vue": "~5.2.3",
"flush-promises": "^1.0.2",
"fuse.js": "^3.4.5", "fuse.js": "^3.4.5",
"jest": "~24.8.0", "jest": "~24.8.0",
"mutation-observer": "^1.0.3",
"node-sass": "~4.12.0", "node-sass": "~4.12.0",
"nodemon": "~1.19.1", "nodemon": "~1.19.1",
"prettier": "~1.18.2", "prettier": "~1.18.2",
"sass-loader": "~7.1.0", "sass-loader": "~7.1.0",
"tippy.js": "^4.3.4", "tippy.js": "^4.3.5",
"vue-jest": "~3.0.4", "vue-jest": "~3.0.4",
"vue-svg-loader": "~0.12.0" "vue-svg-loader": "~0.12.0"
} }

140
webapp/pages/index.spec.js Normal file
View File

@ -0,0 +1,140 @@
import { config, shallowMount, mount, createLocalVue } from '@vue/test-utils'
import PostIndex from './index.vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters'
import VTooltip from 'v-tooltip'
import FilterMenu from '~/components/FilterMenu/FilterMenu'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(Filters)
localVue.use(VTooltip)
config.stubs['no-ssr'] = '<span><slot /></span>'
config.stubs['router-link'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
describe('PostIndex', () => {
let wrapper
let Wrapper
let store
let mocks
beforeEach(() => {
store = new Vuex.Store({
getters: {
'posts/posts': () => {
return [
{
id: 'p23',
name: 'It is a post',
author: {
id: 'u1',
},
},
]
},
'auth/user': () => {
return { id: 'u23' }
},
},
})
mocks = {
$t: key => key,
$filters: {
truncate: a => a,
removeLinks: jest.fn(),
},
// If you are mocking router, than don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
$router: {
history: {
push: jest.fn(),
},
push: jest.fn(),
},
$toast: {
success: jest.fn(),
error: jest.fn(),
},
$apollo: {
mutate: jest.fn().mockResolvedValue(),
queries: {
Post: {
refetch: jest.fn(),
fetchMore: jest.fn().mockResolvedValue([
{
id: 'p23',
name: 'It is a post',
author: {
id: 'u1',
},
},
]),
},
},
},
$route: {
query: {},
},
}
})
describe('shallowMount', () => {
Wrapper = () => {
return shallowMount(PostIndex, {
store,
mocks,
localVue,
})
}
beforeEach(() => {
wrapper = Wrapper()
})
it('refetches Posts when changeFilterBubble is emitted', () => {
wrapper.find(FilterMenu).vm.$emit('changeFilterBubble')
expect(mocks.$apollo.queries.Post.refetch).toHaveBeenCalledTimes(1)
})
it('clears the search when the filter menu emits clearSearch', () => {
wrapper.find(FilterMenu).vm.$emit('clearSearch')
expect(wrapper.vm.hashtag).toBeNull()
})
it('calls the changeFilterBubble if there are hasthags in the route query', () => {
mocks.$route.query.hashtag = { id: 'hashtag' }
wrapper = Wrapper()
expect(mocks.$apollo.queries.Post.refetch).toHaveBeenCalledTimes(1)
})
describe('mount', () => {
beforeEach(() => {
wrapper = mount(PostIndex, {
store,
mocks,
localVue,
})
})
it('sets the post in the store when there are posts', () => {
wrapper
.findAll('li')
.at(0)
.trigger('click')
expect(wrapper.vm.sorting).toEqual('createdAt_desc')
})
it('loads more posts when a user clicks on the load more button', () => {
wrapper
.findAll('button')
.at(2)
.trigger('click')
expect(mocks.$apollo.queries.Post.fetchMore).toHaveBeenCalledTimes(1)
})
})
})
})

View File

@ -9,6 +9,17 @@
@clearSearch="clearSearch" @clearSearch="clearSearch"
/> />
</ds-flex-item> </ds-flex-item>
<ds-flex-item>
<div class="sorting-dropdown">
<ds-select
v-model="selected"
:options="sortingOptions"
size="large"
v-bind:icon-right="sortingIcon"
@input="toggleOnlySorting"
></ds-select>
</div>
</ds-flex-item>
<hc-post-card <hc-post-card
v-for="(post, index) in posts" v-for="(post, index) in posts"
:key="post.id" :key="post.id"
@ -53,6 +64,36 @@ export default {
pageSize: 12, pageSize: 12,
filter: {}, filter: {},
hashtag, hashtag,
placeholder: this.$t('sorting.newest'),
selected: this.$t('sorting.newest'),
sortingIcon: 'sort-amount-desc',
sorting: 'createdAt_desc',
sortingOptions: [
{
label: this.$t('sorting.newest'),
value: 'Newest',
icons: 'sort-amount-desc',
order: 'createdAt_desc',
},
{
label: this.$t('sorting.oldest'),
value: 'Oldest',
icons: 'sort-amount-asc',
order: 'createdAt_asc',
},
{
label: this.$t('sorting.popular'),
value: 'Popular',
icons: 'fire',
order: 'shoutedCount_desc',
},
{
label: this.$t('sorting.commented'),
value: 'Commented',
icons: 'comment',
order: 'commentsCount_desc',
},
],
} }
}, },
mounted() { mounted() {
@ -89,7 +130,12 @@ export default {
} }
} }
this.filter = filter this.filter = filter
this.$apollo.queries.Post.refresh() this.$apollo.queries.Post.refetch()
},
toggleOnlySorting(x) {
this.sortingIcon = x.icons
this.sorting = x.order
this.$apollo.queries.Post.refetch()
}, },
clearSearch() { clearSearch() {
this.$router.push({ path: '/' }) this.$router.push({ path: '/' })
@ -144,6 +190,7 @@ export default {
filter: this.filter, filter: this.filter,
first: this.pageSize, first: this.pageSize,
offset: 0, offset: 0,
orderBy: this.sorting,
} }
}, },
fetchPolicy: 'cache-and-network', fetchPolicy: 'cache-and-network',
@ -161,4 +208,11 @@ export default {
transform: translate(-120%, -120%); transform: translate(-120%, -120%);
box-shadow: $box-shadow-x-large; box-shadow: $box-shadow-x-large;
} }
.sorting-dropdown {
width: 250px;
position: relative;
float: right;
padding: 0 18px;
}
</style> </style>

View File

@ -134,9 +134,9 @@
</ds-text> </ds-text>
<template> <template>
<ds-space v-for="link in socialMediaLinks" :key="link.username" margin="x-small"> <ds-space v-for="link in socialMediaLinks" :key="link.username" margin="x-small">
<a :href="link.url"> <a :href="link.url" target="_blank">
<ds-avatar :image="link.favicon" /> <ds-avatar :image="link.favicon" />
{{ 'link.username' }} {{ link.username }}
</a> </a>
</ds-space> </ds-space>
</template> </template>

View File

@ -1,4 +1,5 @@
import { mount, createLocalVue } from '@vue/test-utils' import { mount, createLocalVue } from '@vue/test-utils'
import flushPromises from 'flush-promises'
import MySocialMedia from './my-social-media.vue' import MySocialMedia from './my-social-media.vue'
import Vuex from 'vuex' import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide' import Styleguide from '@human-connection/styleguide'
@ -12,23 +13,17 @@ localVue.use(Filters)
describe('my-social-media.vue', () => { describe('my-social-media.vue', () => {
let wrapper let wrapper
let store
let mocks let mocks
let getters let getters
let input
let submitBtn
const socialMediaUrl = 'https://freeradical.zone/@mattwr18' const socialMediaUrl = 'https://freeradical.zone/@mattwr18'
const newSocialMediaUrl = 'https://twitter.com/mattwr18'
const faviconUrl = 'https://freeradical.zone/favicon.ico'
beforeEach(() => { beforeEach(() => {
mocks = { mocks = {
$t: jest.fn(), $t: jest.fn(),
$apollo: { $apollo: {
mutate: jest mutate: jest.fn(),
.fn()
.mockRejectedValue({ message: 'Ouch!' })
.mockResolvedValueOnce({
data: { CreateSocialMeda: { id: 's1', url: socialMediaUrl } },
}),
}, },
$toast: { $toast: {
error: jest.fn(), error: jest.fn(),
@ -43,79 +38,161 @@ describe('my-social-media.vue', () => {
}) })
describe('mount', () => { describe('mount', () => {
let form, input, submitButton
const Wrapper = () => { const Wrapper = () => {
store = new Vuex.Store({ const store = new Vuex.Store({
getters, getters,
}) })
return mount(MySocialMedia, { store, mocks, localVue }) return mount(MySocialMedia, { store, mocks, localVue })
} }
it('renders', () => { describe('adding social media link', () => {
wrapper = Wrapper()
expect(wrapper.contains('div')).toBe(true)
})
describe('given currentUser has a social media account linked', () => {
beforeEach(() => { beforeEach(() => {
getters = {
'auth/user': () => {
return {
socialMedia: [{ id: 's1', url: socialMediaUrl }],
}
},
}
})
it("displays a link to the currentUser's social media", () => {
wrapper = Wrapper() wrapper = Wrapper()
const socialMediaLink = wrapper.find('a').attributes().href form = wrapper.find('form')
expect(socialMediaLink).toBe(socialMediaUrl) input = wrapper.find('input#addSocialMedia')
submitButton = wrapper.find('button')
}) })
beforeEach(() => { it('requires the link to be a valid url', () => {
mocks = { input.setValue('some value')
$t: jest.fn(), form.trigger('submit')
$apollo: {
mutate: jest expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
.fn()
.mockRejectedValue({ message: 'Ouch!' })
.mockResolvedValueOnce({
data: { DeleteSocialMeda: { id: 's1', url: socialMediaUrl } },
}),
},
$toast: {
error: jest.fn(),
success: jest.fn(),
},
}
getters = {
'auth/user': () => {
return {
socialMedia: [{ id: 's1', url: socialMediaUrl }],
}
},
}
}) })
it('displays a trash sympol after a social media and allows the user to delete it', () => { it('displays an error message when not saved successfully', async () => {
wrapper = Wrapper() mocks.$apollo.mutate.mockRejectedValue({ message: 'Ouch!' })
const deleteSelector = wrapper.find({ name: 'delete' }) input.setValue(newSocialMediaUrl)
expect(deleteSelector).toEqual({ selector: 'Component' }) form.trigger('submit')
const icon = wrapper.find({ name: 'trash' })
icon.trigger('click') await flushPromises()
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
expect(mocks.$toast.error).toHaveBeenCalledTimes(1)
})
describe('success', () => {
beforeEach(() => {
mocks.$apollo.mutate.mockResolvedValue({
data: { CreateSocialMedia: { id: 's2', url: newSocialMediaUrl } },
})
input.setValue(newSocialMediaUrl)
form.trigger('submit')
})
it('sends the new url to the backend', () => {
const expected = expect.objectContaining({
variables: { url: newSocialMediaUrl },
})
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
})
it('displays a success message', async () => {
await flushPromises()
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
it('clears the form', async () => {
await flushPromises()
expect(input.value).toBe(undefined)
expect(submitButton.vm.$attrs.disabled).toBe(true)
})
}) })
}) })
describe('currentUser does not have a social media account linked', () => { describe('given existing social media links', () => {
it('allows a user to add a social media link', () => { beforeEach(() => {
getters = {
'auth/user': () => ({
socialMedia: [{ id: 's1', url: socialMediaUrl }],
}),
}
wrapper = Wrapper() wrapper = Wrapper()
input = wrapper.find({ name: 'social-media' }) form = wrapper.find('form')
input.element.value = socialMediaUrl })
input.trigger('input')
submitBtn = wrapper.find('.ds-button') describe('for each link it', () => {
submitBtn.trigger('click') it('displays the favicon', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) expect(wrapper.find(`img[src="${faviconUrl}"]`).exists()).toBe(true)
})
it('displays the url', () => {
expect(wrapper.find(`a[href="${socialMediaUrl}"]`).exists()).toBe(true)
})
it('displays the edit button', () => {
expect(wrapper.find('a[name="edit"]').exists()).toBe(true)
})
it('displays the delete button', () => {
expect(wrapper.find('a[name="delete"]').exists()).toBe(true)
})
})
it('does not accept a duplicate url', () => {
input = wrapper.find('input#addSocialMedia')
input.setValue(socialMediaUrl)
form.trigger('submit')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
describe('editing social media link', () => {
beforeEach(() => {
const editButton = wrapper.find('a[name="edit"]')
editButton.trigger('click')
input = wrapper.find('input#editSocialMedia')
})
it('disables adding new links while editing', () => {
const addInput = wrapper.find('input#addSocialMedia')
expect(addInput.exists()).toBe(false)
})
it('sends the new url to the backend', () => {
const expected = expect.objectContaining({
variables: { id: 's1', url: newSocialMediaUrl },
})
input.setValue(newSocialMediaUrl)
form.trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
})
it('allows the user to cancel editing', () => {
const cancelButton = wrapper.find('button#cancel')
cancelButton.trigger('click')
expect(wrapper.find('input#editSocialMedia').exists()).toBe(false)
})
})
describe('deleting social media link', () => {
beforeEach(() => {
const deleteButton = wrapper.find('a[name="delete"]')
deleteButton.trigger('click')
})
it('sends the link id to the backend', () => {
const expected = expect.objectContaining({
variables: { id: 's1' },
})
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
})
it('displays a success message', async () => {
await flushPromises()
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
}) })
}) })
}) })

View File

@ -1,49 +1,90 @@
<template> <template>
<ds-card :header="$t('settings.social-media.name')"> <ds-form
<ds-space v-if="socialMediaLinks" margin-top="base" margin="x-small"> v-model="formData"
<ds-list> :schema="formSchema"
<ds-list-item v-for="link in socialMediaLinks" :key="link.id"> @input="handleInput"
<a :href="link.url" target="_blank"> @input-valid="handleInputValid"
<img :src="link.favicon | proxyApiUrl" alt="Social Media link" width="16" height="16" /> @submit="handleSubmitSocialMedia"
{{ link.url }} >
</a> <ds-card :header="$t('settings.social-media.name')">
&nbsp;&nbsp; <ds-space v-if="socialMediaLinks" margin-top="base" margin="x-small">
<span class="layout-leave-active">|</span> <ds-list>
&nbsp;&nbsp; <ds-list-item v-for="link in socialMediaLinks" :key="link.id" class="list-item--high">
<ds-icon name="edit" class="layout-leave-active" /> <ds-input
<a name="delete" @click="handleDeleteSocialMedia(link)"> v-if="editingLink.id === link.id"
<ds-icon name="trash" /> id="editSocialMedia"
</a> model="socialMediaUrl"
</ds-list-item> type="text"
</ds-list> :placeholder="$t('settings.social-media.placeholder')"
</ds-space> />
<ds-space margin-top="base">
<div> <template v-else>
<ds-input <a :href="link.url" target="_blank">
v-model="value" <img :src="link.favicon" alt="Link:" height="16" width="16" />
:placeholder="$t('settings.social-media.placeholder')" {{ link.url }}
name="social-media" </a>
:schema="{ type: 'url' }" <span class="divider">|</span>
/> <a name="edit" @click="handleEditSocialMedia(link)">
</div> <ds-icon
<ds-space margin-top="base"> :aria-label="$t('actions.edit')"
<div> class="icon-button"
<ds-button primary @click="handleAddSocialMedia"> name="edit"
{{ $t('settings.social-media.submit') }} :title="$t('actions.edit')"
</ds-button> />
</div> </a>
<a name="delete" @click="handleDeleteSocialMedia(link)">
<ds-icon
:aria-label="$t('actions.delete')"
class="icon-button"
name="trash"
:title="$t('actions.delete')"
/>
</a>
</template>
</ds-list-item>
</ds-list>
</ds-space> </ds-space>
</ds-space>
</ds-card> <ds-space margin-top="base">
<ds-input
v-if="!editingLink.id"
id="addSocialMedia"
model="socialMediaUrl"
type="text"
:placeholder="$t('settings.social-media.placeholder')"
/>
<ds-space margin-top="base">
<ds-button primary :disabled="disabled">
{{ editingLink.id ? $t('actions.save') : $t('settings.social-media.submit') }}
</ds-button>
<ds-button v-if="editingLink.id" id="cancel" ghost @click="handleCancel()">
{{ $t('actions.cancel') }}
</ds-button>
</ds-space>
</ds-space>
</ds-card>
</ds-form>
</template> </template>
<script> <script>
import unionBy from 'lodash/unionBy'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import { mapGetters, mapMutations } from 'vuex' import { mapGetters, mapMutations } from 'vuex'
export default { export default {
data() { data() {
return { return {
value: '', formData: {
socialMediaUrl: '',
},
formSchema: {
socialMediaUrl: {
type: 'url',
message: this.$t('common.validations.url'),
},
},
disabled: true,
editingLink: {},
} }
}, },
computed: { computed: {
@ -51,11 +92,10 @@ export default {
currentUser: 'auth/user', currentUser: 'auth/user',
}), }),
socialMediaLinks() { socialMediaLinks() {
const domainRegex = /^(?:https?:\/\/)?(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g
const { socialMedia = [] } = this.currentUser const { socialMedia = [] } = this.currentUser
return socialMedia.map(socialMedia => { return socialMedia.map(({ id, url }) => {
const { id, url } = socialMedia const [domain] = url.match(domainRegex) || []
const matches = url.match(/^(?:https?:\/\/)?(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g)
const [domain] = matches || []
const favicon = domain ? `${domain}/favicon.ico` : null const favicon = domain ? `${domain}/favicon.ico` : null
return { id, url, favicon } return { id, url, favicon }
}) })
@ -65,39 +105,28 @@ export default {
...mapMutations({ ...mapMutations({
setCurrentUser: 'auth/SET_USER', setCurrentUser: 'auth/SET_USER',
}), }),
handleAddSocialMedia() { handleCancel() {
this.$apollo this.editingLink = {}
.mutate({ this.formData.socialMediaUrl = ''
mutation: gql` this.disabled = true
mutation($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`,
variables: {
url: this.value,
},
update: (store, { data }) => {
const socialMedia = [...this.currentUser.socialMedia, data.CreateSocialMedia]
this.setCurrentUser({
...this.currentUser,
socialMedia,
})
},
})
.then(() => {
this.$toast.success(this.$t('settings.social-media.successAdd'))
this.value = ''
})
.catch(error => {
this.$toast.error(error.message)
})
}, },
handleDeleteSocialMedia(link) { handleEditSocialMedia(link) {
this.$apollo this.editingLink = link
.mutate({ this.formData.socialMediaUrl = link.url
},
handleInput(data) {
this.disabled = true
},
handleInputValid(data) {
if (data.socialMediaUrl.length < 1) {
this.disabled = true
} else {
this.disabled = false
}
},
async handleDeleteSocialMedia(link) {
try {
await this.$apollo.mutate({
mutation: gql` mutation: gql`
mutation($id: ID!) { mutation($id: ID!) {
DeleteSocialMedia(id: $id) { DeleteSocialMedia(id: $id) {
@ -119,19 +148,83 @@ export default {
}) })
}, },
}) })
.then(() => {
this.$toast.success(this.$t('settings.social-media.successDelete')) this.$toast.success(this.$t('settings.social-media.successDelete'))
}) } catch (err) {
.catch(error => { this.$toast.error(err.message)
this.$toast.error(error.message) }
},
async handleSubmitSocialMedia() {
const isEditing = !!this.editingLink.id
const url = this.formData.socialMediaUrl
const duplicateUrl = this.socialMediaLinks.find(link => link.url === url)
if (duplicateUrl && duplicateUrl.id !== this.editingLink.id) {
return this.$toast.error(this.$t('settings.social-media.requireUnique'))
}
let mutation = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`
const variables = { url }
let successMessage = this.$t('settings.social-media.successAdd')
if (isEditing) {
mutation = gql`
mutation($id: ID!, $url: String!) {
UpdateSocialMedia(id: $id, url: $url) {
id
url
}
}
`
variables.id = this.editingLink.id
successMessage = this.$t('settings.data.success')
}
try {
await this.$apollo.mutate({
mutation,
variables,
update: (store, { data }) => {
const newSocialMedia = isEditing ? data.UpdateSocialMedia : data.CreateSocialMedia
this.setCurrentUser({
...this.currentUser,
socialMedia: unionBy([newSocialMedia], this.currentUser.socialMedia, 'id'),
})
},
}) })
this.$toast.success(successMessage)
this.formData.socialMediaUrl = ''
this.disabled = true
this.editingLink = {}
} catch (err) {
this.$toast.error(err.message)
}
}, },
}, },
} }
</script> </script>
<style lang="scss"> <style lang="scss">
.layout-leave-active { .divider {
opacity: 0.4; opacity: 0.4;
padding: 0 $space-small;
}
.icon-button {
cursor: pointer;
}
.list-item--high {
.ds-list-item-prefix {
align-self: center;
}
} }
</style> </style>

View File

@ -1,6 +1,7 @@
export const state = () => { export const state = () => {
return { return {
placeholder: null, placeholder: null,
editPending: false,
} }
} }
@ -8,10 +9,16 @@ export const getters = {
placeholder(state) { placeholder(state) {
return state.placeholder return state.placeholder
}, },
editPending(state) {
return state.editPending
},
} }
export const mutations = { export const mutations = {
SET_PLACEHOLDER_TEXT(state, text) { SET_PLACEHOLDER_TEXT(state, text) {
state.placeholder = text state.placeholder = text
}, },
SET_EDIT_PENDING(state, boolean) {
state.editPending = boolean
},
} }

Some files were not shown because too many files have changed in this diff Show More