├── .babelrc
├── .codecov.yml
├── .dockerignore
├── .env.template
├── .eslintrc.js
├── .github
└── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── .gitignore
├── .graphqlconfig
├── .travis.yml
├── CODE_OF_CONDUCT.md
├── Dockerfile
├── LICENSE.md
├── README.md
├── db-migration-worker
├── .dockerignore
├── .gitignore
├── .ssh
│ └── known_hosts
├── Dockerfile
├── migrate.sh
├── migration
│ ├── mongo
│ │ └── import.sh
│ └── neo4j
│ │ ├── badges.cql
│ │ ├── categories.cql
│ │ ├── comments.cql
│ │ ├── contributions.cql
│ │ ├── follows.cql
│ │ ├── import.sh
│ │ ├── shouts.cql
│ │ └── users.cql
└── sync_uploads.sh
├── docker-compose.cypress.yml
├── docker-compose.db-migration.yml
├── docker-compose.override.yml
├── docker-compose.prod.yml
├── docker-compose.travis.yml
├── docker-compose.yml
├── graphql-playground.png
├── humanconnection.png
├── neo4j
├── Dockerfile
└── migrate.sh
├── package.json
├── public
├── .gitkeep
└── img
│ └── badges
│ ├── fundraisingbox_de_airship.svg
│ ├── fundraisingbox_de_alienship.svg
│ ├── fundraisingbox_de_balloon.svg
│ ├── fundraisingbox_de_bigballoon.svg
│ ├── fundraisingbox_de_crane.svg
│ ├── fundraisingbox_de_glider.svg
│ ├── fundraisingbox_de_helicopter.svg
│ ├── fundraisingbox_de_starter.svg
│ ├── indiegogo_en_bear.svg
│ ├── indiegogo_en_panda.svg
│ ├── indiegogo_en_rabbit.svg
│ ├── indiegogo_en_racoon.svg
│ ├── indiegogo_en_rhino.svg
│ ├── indiegogo_en_tiger.svg
│ ├── indiegogo_en_turtle.svg
│ ├── indiegogo_en_whale.svg
│ ├── indiegogo_en_wolf.svg
│ ├── user_role_admin.svg
│ ├── user_role_developer.svg
│ ├── user_role_moderator.svg
│ ├── wooold_de_bee.svg
│ ├── wooold_de_butterfly.svg
│ ├── wooold_de_double_rainbow.svg
│ ├── wooold_de_end_of_rainbow.svg
│ ├── wooold_de_flower.svg
│ ├── wooold_de_lifetree.svg
│ ├── wooold_de_magic_rainbow.svg
│ └── wooold_de_super_founder.svg
├── scripts
├── deploy.sh
├── docker_push.sh
└── test.sh
├── src
├── activitypub
│ ├── ActivityPub.js
│ ├── Collections.js
│ ├── NitroDataSource.js
│ ├── routes
│ │ ├── inbox.js
│ │ ├── index.js
│ │ ├── serveUser.js
│ │ ├── user.js
│ │ ├── verify.js
│ │ └── webFinger.js
│ ├── security
│ │ ├── httpSignature.spec.js
│ │ └── index.js
│ └── utils
│ │ ├── activity.js
│ │ ├── actor.js
│ │ ├── collection.js
│ │ └── index.js
├── bootstrap
│ ├── directives.js
│ ├── neo4j.js
│ └── scalars.js
├── graphql-schema.js
├── helpers
│ ├── asyncForEach.js
│ └── walkRecursive.js
├── index.js
├── jest
│ └── helpers.js
├── jwt
│ ├── decode.js
│ └── encode.js
├── middleware
│ ├── activityPubMiddleware.js
│ ├── dateTimeMiddleware.js
│ ├── excerptMiddleware.js
│ ├── fixImageUrlsMiddleware.js
│ ├── fixImageUrlsMiddleware.spec.js
│ ├── includedFieldsMiddleware.js
│ ├── index.js
│ ├── nodes
│ │ └── locations.js
│ ├── passwordMiddleware.js
│ ├── permissionsMiddleware.js
│ ├── permissionsMiddleware.spec.js
│ ├── sluggifyMiddleware.js
│ ├── slugify
│ │ ├── uniqueSlug.js
│ │ └── uniqueSlug.spec.js
│ ├── slugifyMiddleware.spec.js
│ ├── softDeleteMiddleware.js
│ ├── softDeleteMiddleware.spec.js
│ ├── userMiddleware.js
│ └── xssMiddleware.js
├── mocks
│ └── index.js
├── resolvers
│ ├── badges.spec.js
│ ├── follow.spec.js
│ ├── moderation.js
│ ├── moderation.spec.js
│ ├── posts.js
│ ├── posts.spec.js
│ ├── reports.js
│ ├── reports.spec.js
│ ├── shout.spec.js
│ ├── statistics.js
│ ├── user_management.js
│ └── user_management.spec.js
├── schema.graphql
├── seed
│ ├── factories
│ │ ├── badges.js
│ │ ├── categories.js
│ │ ├── comments.js
│ │ ├── index.js
│ │ ├── organizations.js
│ │ ├── posts.js
│ │ ├── reports.js
│ │ ├── tags.js
│ │ └── users.js
│ ├── reset-db.js
│ ├── seed-db.js
│ └── seed-helpers.js
└── server.js
├── test
└── features
│ ├── activity-delete.feature
│ ├── activity-follow.feature
│ ├── activity-like.feature
│ ├── collection.feature
│ ├── object-article.feature
│ ├── support
│ └── steps.js
│ ├── webfinger.feature
│ └── world.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "targets": {
7 | "node": "10"
8 | }
9 | }
10 | ]
11 | ],
12 | "plugins": [
13 | "@babel/plugin-proposal-throw-expressions"
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/.codecov.yml:
--------------------------------------------------------------------------------
1 | coverage:
2 | range: "60...100"
3 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .vscode/
2 | .nyc_output/
3 | .github/
4 | .travis.yml
5 | .graphqlconfig
6 | .env
7 |
8 | Dockerfile
9 | docker-compose*.yml
10 |
11 | ./*.png
12 | ./*.log
13 |
14 | node_modules/
15 | scripts/
16 | dist/
17 |
18 | db-migration-worker/
19 | neo4j/
20 |
21 | public/uploads/*
22 | !.gitkeep
23 |
--------------------------------------------------------------------------------
/.env.template:
--------------------------------------------------------------------------------
1 | NEO4J_URI=bolt://localhost:7687
2 | NEO4J_USER=neo4j
3 | NEO4J_PASSWORD=letmein
4 | GRAPHQL_PORT=4000
5 | GRAPHQL_URI=http://localhost:4000
6 | CLIENT_URI=http://localhost:3000
7 | MOCK=false
8 |
9 | JWT_SECRET="b/&&7b78BF&fv/Vd"
10 | MAPBOX_TOKEN="pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ"
11 |
12 | PRIVATE_KEY_PASSPHRASE="a7dsf78sadg87ad87sfagsadg78"
13 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "extends": "standard",
3 | "parser": "babel-eslint",
4 | "env": {
5 | "es6": true,
6 | "node": true,
7 | "jest/globals": true
8 | },
9 | "rules": {
10 | "indent": [
11 | "error",
12 | 2
13 | ],
14 | "quotes": [
15 | "error",
16 | "single"
17 | ]
18 | },
19 | "plugins": ["jest"]
20 | };
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 |
5 | ---
6 |
7 | **Describe the bug**
8 | A clear and concise description of what the bug is.
9 |
10 | **To Reproduce**
11 | Steps to reproduce the behavior:
12 | 1. Authenticate '...'
13 | 2. Post following data to endpoint '...'
14 | 3. See error
15 |
16 | **Expected behavior**
17 | A clear and concise description of what you expected to happen
18 |
19 | **Additional context**
20 | Add any other context about the problem here.
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 |
5 | ---
6 |
7 | **Is your feature request related to a problem? Please describe.**
8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
9 |
10 | **Describe the solution you'd like**
11 | A clear and concise description of what you want to happen.
12 |
13 | **Describe alternatives you've considered**
14 | A clear and concise description of any alternative solutions or features you've considered.
15 |
16 | **Additional context**
17 | Add any other context or screenshots about the feature request here.
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .env
3 | .vscode
4 | .idea
5 | yarn-error.log
6 | dist/*
7 | coverage.lcov
8 | .nyc_output/
9 | public/uploads/*
10 | !.gitkeep
11 |
12 | # Apple macOS folder attribute file
13 | .DS_Store
--------------------------------------------------------------------------------
/.graphqlconfig:
--------------------------------------------------------------------------------
1 | {
2 | "schemaPath": "./src/schema.graphql"
3 | }
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "10"
4 | cache:
5 | yarn: true
6 | directories:
7 | - node_modules
8 | services:
9 | - docker
10 |
11 | env:
12 | - DOCKER_COMPOSE_VERSION=1.23.2
13 |
14 | before_install:
15 | - sudo rm /usr/local/bin/docker-compose
16 | - curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose
17 | - chmod +x docker-compose
18 | - sudo mv docker-compose /usr/local/bin
19 | - yarn global add wait-on
20 |
21 | install:
22 | - docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT --target production -t humanconnection/nitro-backend:latest .
23 | - docker-compose -f docker-compose.yml -f docker-compose.travis.yml up -d
24 | - wait-on http://localhost:7474 && docker-compose exec neo4j migrate
25 |
26 | script:
27 | - docker-compose exec backend yarn run lint
28 | - docker-compose exec backend yarn run test --ci
29 | - docker-compose exec backend yarn run db:reset
30 | - docker-compose exec backend yarn run db:seed
31 | - docker-compose exec backend yarn run test:cucumber
32 | - docker-compose exec backend yarn run test:coverage
33 | - docker-compose exec backend yarn run db:reset
34 | - docker-compose exec backend yarn run db:seed
35 |
36 | after_success:
37 | - wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh
38 | - chmod +x send.sh
39 | - ./send.sh success $WEBHOOK_URL
40 |
41 | after_failure:
42 | - wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh
43 | - chmod +x send.sh
44 | - ./send.sh failure $WEBHOOK_URL
45 |
46 | deploy:
47 | - provider: script
48 | script: scripts/docker_push.sh
49 | on:
50 | branch: master
51 | - provider: script
52 | script: scripts/deploy.sh nitro.human-connection.org
53 | on:
54 | branch: master
55 | tags: true
56 | - provider: script
57 | script: scripts/deploy.sh nitro-staging.human-connection.org
58 | on:
59 | branch: master
60 | - provider: script
61 | script: scripts/deploy.sh "nitro-$TRAVIS_COMMIT.human-connection.org"
62 | on:
63 | tags: true
64 | all_branches: true
65 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | ## Our Standards
8 |
9 | Examples of behavior that contributes to creating a positive environment include:
10 |
11 | * Using welcoming and inclusive language
12 | * Being respectful of differing viewpoints and experiences
13 | * Gracefully accepting constructive criticism
14 | * Focusing on what is best for the community
15 | * Showing empathy towards other community members
16 |
17 | Examples of unacceptable behavior by participants include:
18 |
19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances
20 | * Trolling, insulting/derogatory comments, and personal or political attacks
21 | * Public or private harassment
22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission
23 | * Other conduct which could reasonably be considered inappropriate in a professional setting
24 |
25 | ## Our Responsibilities
26 |
27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
28 |
29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
30 |
31 | ## Scope
32 |
33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
34 |
35 | ## Enforcement
36 |
37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at developer@human-connection.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
38 |
39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
40 |
41 | ## Attribution
42 |
43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
44 |
45 | [homepage]: http://contributor-covenant.org
46 | [version]: http://contributor-covenant.org/version/1/4/
47 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:10-alpine as base
2 | 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)"
3 |
4 | EXPOSE 4000
5 | ARG BUILD_COMMIT
6 | ENV BUILD_COMMIT=$BUILD_COMMIT
7 | ARG WORKDIR=/nitro-backend
8 | RUN mkdir -p $WORKDIR
9 | WORKDIR $WORKDIR
10 | COPY package.json yarn.lock ./
11 | COPY .env.template .env
12 | CMD ["yarn", "run", "start"]
13 |
14 | FROM base as builder
15 | RUN yarn install --frozen-lockfile --non-interactive
16 | COPY . .
17 | RUN cp .env.template .env
18 | RUN yarn run build
19 |
20 | # reduce image size with a multistage build
21 | FROM base as production
22 | ENV NODE_ENV=production
23 | COPY --from=builder /nitro-backend/dist ./dist
24 | RUN yarn install --frozen-lockfile --non-interactive
25 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Human-Connection gGmbH
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/db-migration-worker/.dockerignore:
--------------------------------------------------------------------------------
1 | .ssh/
2 |
--------------------------------------------------------------------------------
/db-migration-worker/.gitignore:
--------------------------------------------------------------------------------
1 | .ssh/
2 |
--------------------------------------------------------------------------------
/db-migration-worker/.ssh/known_hosts:
--------------------------------------------------------------------------------
1 | |1|GuOYlVEhTowidPs18zj9p5F2j3o=|sDHJYLz9Ftv11oXeGEjs7SpVyg0= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBM5N29bI5CeKu1/RBPyM2fwyf7fuajOO+tyhKe1+CC2sZ1XNB5Ff6t6MtCLNRv2mUuvzTbW/HkisDiA5tuXUHOk=
2 | |1|2KP9NV+Q5g2MrtjAeFSVcs8YeOI=|nf3h4wWVwC4xbBS1kzgzE2tBldk= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNhRK6BeIEUxXlS0z/pOfkUkSPfn33g4J1U3L+MyUQYHm+7agT08799ANJhnvELKE1tt4Vx80I9UR81oxzZcy3E=
3 | |1|HonYIRNhKyroUHPKU1HSZw0+Qzs=|5T1btfwFBz2vNSldhqAIfTbfIgQ= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNhRK6BeIEUxXlS0z/pOfkUkSPfn33g4J1U3L+MyUQYHm+7agT08799ANJhnvELKE1tt4Vx80I9UR81oxzZcy3E=
4 |
--------------------------------------------------------------------------------
/db-migration-worker/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mongo:4
2 |
3 | RUN apt-get update && apt-get -y install --no-install-recommends wget apt-transport-https \
4 | && apt-get clean \
5 | && rm -rf /var/lib/apt/lists/*
6 | RUN wget -O - https://debian.neo4j.org/neotechnology.gpg.key | apt-key add -
7 | RUN echo 'deb https://debian.neo4j.org/repo stable/' | tee /etc/apt/sources.list.d/neo4j.list
8 | RUN apt-get update && apt-get -y install --no-install-recommends openjdk-8-jre openssh-client neo4j rsync \
9 | && apt-get clean \
10 | && rm -rf /var/lib/apt/lists/*
11 | COPY migration ./migration
12 | COPY migrate.sh /usr/local/bin/migrate
13 | COPY sync_uploads.sh /usr/local/bin/sync_uploads
14 |
--------------------------------------------------------------------------------
/db-migration-worker/migrate.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -e
3 | for var in "SSH_USERNAME" "SSH_HOST" "MONGODB_USERNAME" "MONGODB_PASSWORD" "MONGODB_DATABASE" "MONGODB_AUTH_DB" "NEO4J_URI"
4 | do
5 | if [[ -z "${!var}" ]]; then
6 | echo "${var} is undefined"
7 | exit 1
8 | fi
9 | done
10 |
11 | /migration/mongo/import.sh
12 | /migration/neo4j/import.sh
13 |
--------------------------------------------------------------------------------
/db-migration-worker/migration/mongo/import.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -e
3 |
4 | echo "SSH_USERNAME ${SSH_USERNAME}"
5 | echo "SSH_HOST ${SSH_HOST}"
6 | echo "MONGODB_USERNAME ${MONGODB_USERNAME}"
7 | echo "MONGODB_PASSWORD ${MONGODB_PASSWORD}"
8 | echo "MONGODB_DATABASE ${MONGODB_DATABASE}"
9 | echo "MONGODB_AUTH_DB ${MONGODB_AUTH_DB}"
10 | echo "-------------------------------------------------"
11 |
12 | mongo ${MONGODB_DATABASE} --eval "db.dropDatabase();"
13 | rm -rf /mongo-export/*
14 |
15 | ssh -4 -M -S my-ctrl-socket -fnNT -L 27018:localhost:27017 -l ${SSH_USERNAME} ${SSH_HOST}
16 | mongodump --host localhost -d ${MONGODB_DATABASE} --port 27018 --username ${MONGODB_USERNAME} --password ${MONGODB_PASSWORD} --authenticationDatabase ${MONGODB_AUTH_DB} --gzip --archive=/tmp/mongodump.archive
17 | mongorestore --gzip --archive=/tmp/mongodump.archive
18 | ssh -S my-ctrl-socket -O check -l ${SSH_USERNAME} ${SSH_HOST}
19 | ssh -S my-ctrl-socket -O exit -l ${SSH_USERNAME} ${SSH_HOST}
20 |
21 | for collection in "categories" "badges" "users" "contributions" "comments" "follows" "shouts"
22 | do
23 | mongoexport --db ${MONGODB_DATABASE} --collection $collection --out "/mongo-export/$collection.json"
24 | done
25 |
--------------------------------------------------------------------------------
/db-migration-worker/migration/neo4j/badges.cql:
--------------------------------------------------------------------------------
1 | CALL apoc.load.json('file:/mongo-export/badges.json') YIELD value as badge
2 | MERGE(b:Badge {id: badge._id["$oid"]})
3 | ON CREATE SET
4 | b.key = badge.key,
5 | b.type = badge.type,
6 | b.icon = badge.image.path,
7 | b.status = badge.status,
8 | b.createdAt = badge.createdAt.`$date`,
9 | b.updatedAt = badge.updatedAt.`$date`
10 | ;
11 |
--------------------------------------------------------------------------------
/db-migration-worker/migration/neo4j/categories.cql:
--------------------------------------------------------------------------------
1 | CALL apoc.load.json('file:/mongo-export/categories.json') YIELD value as category
2 | MERGE(c:Category {id: category._id["$oid"]})
3 | ON CREATE SET
4 | c.name = category.title,
5 | c.slug = category.slug,
6 | c.icon = category.icon,
7 | c.createdAt = category.createdAt.`$date`,
8 | c.updatedAt = category.updatedAt.`$date`
9 | ;
10 |
11 | MATCH (c:Category)
12 | WHERE (c.icon = "categories-justforfun")
13 | SET c.icon = 'smile'
14 | ;
15 |
16 | MATCH (c:Category)
17 | WHERE (c.icon = "categories-luck")
18 | SET c.icon = 'heart-o'
19 | ;
20 |
21 | MATCH (c:Category)
22 | WHERE (c.icon = "categories-health")
23 | SET c.icon = 'medkit'
24 | ;
25 |
26 | MATCH (c:Category)
27 | WHERE (c.icon = "categories-environment")
28 | SET c.icon = 'tree'
29 | ;
30 |
31 | MATCH (c:Category)
32 | WHERE (c.icon = "categories-animal-justice")
33 | SET c.icon = 'paw'
34 | ;
35 |
36 | MATCH (c:Category)
37 | WHERE (c.icon = "categories-human-rights")
38 | SET c.icon = 'balance-scale'
39 | ;
40 |
41 | MATCH (c:Category)
42 | WHERE (c.icon = "categories-education")
43 | SET c.icon = 'graduation-cap'
44 | ;
45 |
46 | MATCH (c:Category)
47 | WHERE (c.icon = "categories-cooperation")
48 | SET c.icon = 'users'
49 | ;
50 |
51 | MATCH (c:Category)
52 | WHERE (c.icon = "categories-politics")
53 | SET c.icon = 'university'
54 | ;
55 |
56 | MATCH (c:Category)
57 | WHERE (c.icon = "categories-economy")
58 | SET c.icon = 'money'
59 | ;
60 |
61 | MATCH (c:Category)
62 | WHERE (c.icon = "categories-technology")
63 | SET c.icon = 'flash'
64 | ;
65 |
66 | MATCH (c:Category)
67 | WHERE (c.icon = "categories-internet")
68 | SET c.icon = 'mouse-pointer'
69 | ;
70 |
71 | MATCH (c:Category)
72 | WHERE (c.icon = "categories-art")
73 | SET c.icon = 'paint-brush'
74 | ;
75 |
76 | MATCH (c:Category)
77 | WHERE (c.icon = "categories-freedom-of-speech")
78 | SET c.icon = 'bullhorn'
79 | ;
80 |
81 | MATCH (c:Category)
82 | WHERE (c.icon = "categories-sustainability")
83 | SET c.icon = 'shopping-cart'
84 | ;
85 |
86 | MATCH (c:Category)
87 | WHERE (c.icon = "categories-peace")
88 | SET c.icon = 'angellist'
89 | ;
90 |
--------------------------------------------------------------------------------
/db-migration-worker/migration/neo4j/comments.cql:
--------------------------------------------------------------------------------
1 | CALL apoc.load.json('file:/mongo-export/comments.json') YIELD value as json
2 | MERGE (comment:Comment {id: json._id["$oid"]})
3 | ON CREATE SET
4 | comment.content = json.content,
5 | comment.contentExcerpt = json.contentExcerpt,
6 | comment.deleted = json.deleted,
7 | comment.disabled = false
8 | WITH comment, json, json.contributionId as postId
9 | MATCH (post:Post {id: postId})
10 | WITH comment, post, json.userId as userId
11 | MATCH (author:User {id: userId})
12 | MERGE (comment)-[:COMMENTS]->(post)
13 | MERGE (author)-[:WROTE]->(comment)
14 | ;
15 |
--------------------------------------------------------------------------------
/db-migration-worker/migration/neo4j/contributions.cql:
--------------------------------------------------------------------------------
1 | CALL apoc.load.json('file:/mongo-export/contributions.json') YIELD value as post
2 | MERGE (p:Post {id: post._id["$oid"]})
3 | ON CREATE SET
4 | p.title = post.title,
5 | p.slug = post.slug,
6 | p.image = post.teaserImg,
7 | p.content = post.content,
8 | p.contentExcerpt = post.contentExcerpt,
9 | p.visibility = toLower(post.visibility),
10 | p.createdAt = post.createdAt.`$date`,
11 | p.updatedAt = post.updatedAt.`$date`,
12 | p.deleted = post.deleted,
13 | p.disabled = NOT post.isEnabled
14 | WITH p, post
15 | MATCH (u:User {id: post.userId})
16 | MERGE (u)-[:WROTE]->(p)
17 | WITH p, post, post.categoryIds as categoryIds
18 | UNWIND categoryIds AS categoryId
19 | MATCH (c:Category {id: categoryId})
20 | MERGE (p)-[:CATEGORIZED]->(c)
21 | WITH p, post.tags AS tags
22 | UNWIND tags AS tag
23 | MERGE (t:Tag {id: apoc.create.uuid(), name: tag})
24 | MERGE (p)-[:TAGGED]->(t)
25 | ;
26 |
--------------------------------------------------------------------------------
/db-migration-worker/migration/neo4j/follows.cql:
--------------------------------------------------------------------------------
1 | CALL apoc.load.json('file:/mongo-export/follows.json') YIELD value as follow
2 | MATCH (u1:User {id: follow.userId}), (u2:User {id: follow.foreignId})
3 | MERGE (u1)-[:FOLLOWS]->(u2)
4 | ;
5 |
--------------------------------------------------------------------------------
/db-migration-worker/migration/neo4j/import.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -e
3 |
4 | SCRIPT_DIRECTORY="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
5 | echo "MATCH (n) OPTIONAL MATCH (n)-[r]-() DELETE n,r;" | cypher-shell -a $NEO4J_URI
6 | for collection in "badges" "categories" "users" "follows" "contributions" "shouts" "comments"
7 | do
8 | echo "Import ${collection}..." && cypher-shell -a $NEO4J_URI < $SCRIPT_DIRECTORY/$collection.cql
9 | done
10 |
--------------------------------------------------------------------------------
/db-migration-worker/migration/neo4j/shouts.cql:
--------------------------------------------------------------------------------
1 | CALL apoc.load.json('file:/mongo-export/shouts.json') YIELD value as shout
2 | MATCH (u:User {id: shout.userId}), (p:Post {id: shout.foreignId})
3 | MERGE (u)-[:SHOUTED]->(p)
4 | ;
5 |
--------------------------------------------------------------------------------
/db-migration-worker/migration/neo4j/users.cql:
--------------------------------------------------------------------------------
1 | CALL apoc.load.json('file:/mongo-export/users.json') YIELD value as user
2 | MERGE(u:User {id: user._id["$oid"]})
3 | ON CREATE SET
4 | u.name = user.name,
5 | u.slug = user.slug,
6 | u.email = user.email,
7 | u.password = user.password,
8 | u.avatar = user.avatar,
9 | u.coverImg = user.coverImg,
10 | u.wasInvited = user.wasInvited,
11 | u.role = toLower(user.role),
12 | u.createdAt = user.createdAt.`$date`,
13 | u.updatedAt = user.updatedAt.`$date`,
14 | u.deleted = user.deletedAt IS NOT NULL,
15 | u.disabled = false
16 | WITH u, user, user.badgeIds AS badgeIds
17 | UNWIND badgeIds AS badgeId
18 | MATCH (b:Badge {id: badgeId})
19 | MERGE (b)-[:REWARDED]->(u)
20 | ;
21 |
--------------------------------------------------------------------------------
/db-migration-worker/sync_uploads.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -e
3 |
4 | for var in "SSH_USERNAME" "SSH_HOST" "UPLOADS_DIRECTORY"
5 | do
6 | if [[ -z "${!var}" ]]; then
7 | echo "${var} is undefined"
8 | exit 1
9 | fi
10 | done
11 |
12 | rsync --archive --update --verbose ${SSH_USERNAME}@${SSH_HOST}:${UPLOADS_DIRECTORY}/* /uploads/
13 |
--------------------------------------------------------------------------------
/docker-compose.cypress.yml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 |
3 | services:
4 | neo4j:
5 | environment:
6 | - NEO4J_AUTH=none
7 | ports:
8 | - 7687:7687
9 | - 7474:7474
10 | backend:
11 | ports:
12 | - 4001:4001
13 | - 4123:4123
14 | image: humanconnection/nitro-backend:builder
15 | build:
16 | context: .
17 | target: builder
18 | command: yarn run test:cypress
19 |
--------------------------------------------------------------------------------
/docker-compose.db-migration.yml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 |
3 | services:
4 | backend:
5 | volumes:
6 | - uploads:/nitro-backend/public/uploads
7 | neo4j:
8 | volumes:
9 | - mongo-export:/mongo-export
10 | environment:
11 | - NEO4J_apoc_import_file_enabled=true
12 | db-migration-worker:
13 | build:
14 | context: db-migration-worker
15 | volumes:
16 | - mongo-export:/mongo-export
17 | - uploads:/uploads
18 | - ./db-migration-worker/migration/:/migration
19 | - ./db-migration-worker/.ssh/:/root/.ssh/
20 | networks:
21 | - hc-network
22 | depends_on:
23 | - backend
24 | environment:
25 | - NEO4J_URI=bolt://neo4j:7687
26 | - "SSH_USERNAME=${SSH_USERNAME}"
27 | - "SSH_HOST=${SSH_HOST}"
28 | - "MONGODB_USERNAME=${MONGODB_USERNAME}"
29 | - "MONGODB_PASSWORD=${MONGODB_PASSWORD}"
30 | - "MONGODB_AUTH_DB=${MONGODB_AUTH_DB}"
31 | - "MONGODB_DATABASE=${MONGODB_DATABASE}"
32 | - "UPLOADS_DIRECTORY=${UPLOADS_DIRECTORY}"
33 |
34 | volumes:
35 | mongo-export:
36 | uploads:
37 |
--------------------------------------------------------------------------------
/docker-compose.override.yml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 |
3 | services:
4 | backend:
5 | image: humanconnection/nitro-backend:builder
6 | build:
7 | context: .
8 | target: builder
9 | volumes:
10 | - .:/nitro-backend
11 | - /nitro-backend/node_modules
12 | command: yarn run dev
13 | neo4j:
14 | environment:
15 | - NEO4J_AUTH=none
16 | ports:
17 | - 7687:7687
18 | - 7474:7474
19 | volumes:
20 | - neo4j-data:/data
21 |
22 | volumes:
23 | neo4j-data:
24 |
--------------------------------------------------------------------------------
/docker-compose.prod.yml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 |
3 | services:
4 | neo4j:
5 | environment:
6 | - NEO4J_PASSWORD=letmein
7 | backend:
8 | environment:
9 | - NEO4J_PASSWORD=letmein
10 |
--------------------------------------------------------------------------------
/docker-compose.travis.yml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 |
3 | services:
4 | neo4j:
5 | environment:
6 | - NEO4J_AUTH=none
7 | ports:
8 | - 7687:7687
9 | - 7474:7474
10 | backend:
11 | image: humanconnection/nitro-backend:builder
12 | build:
13 | context: .
14 | target: builder
15 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.7"
2 |
3 | services:
4 | backend:
5 | image: humanconnection/nitro-backend:latest
6 | build:
7 | context: .
8 | target: production
9 | networks:
10 | - hc-network
11 | depends_on:
12 | - neo4j
13 | ports:
14 | - 4000:4000
15 | environment:
16 | - NEO4J_URI=bolt://neo4j:7687
17 | - GRAPHQL_PORT=4000
18 | - GRAPHQL_URI=http://localhost:4000
19 | - CLIENT_URI=http://localhost:3000
20 | - JWT_SECRET=b/&&7b78BF&fv/Vd
21 | - MOCK=false
22 | - MAPBOX_TOKEN=pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ
23 | - PRIVATE_KEY_PASSPHRASE=a7dsf78sadg87ad87sfagsadg78
24 |
25 | neo4j:
26 | image: humanconnection/neo4j:latest
27 | build:
28 | context: neo4j
29 | networks:
30 | - hc-network
31 |
32 | networks:
33 | hc-network:
34 | name: hc-network
35 |
--------------------------------------------------------------------------------
/graphql-playground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Human-Connection/Nitro-Backend/7d26b03f8898def591a932c7b198855ac19a3186/graphql-playground.png
--------------------------------------------------------------------------------
/humanconnection.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Human-Connection/Nitro-Backend/7d26b03f8898def591a932c7b198855ac19a3186/humanconnection.png
--------------------------------------------------------------------------------
/neo4j/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM neo4j:3.5.0
2 | 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/
3 | COPY migrate.sh /usr/local/bin/migrate
4 |
--------------------------------------------------------------------------------
/neo4j/migrate.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | # If the user has the password `neo4j` this is a strong indicator, that we are
4 | # the initial default user. Before we can create constraints, we have to change
5 | # the default password. This is a security feature of neo4j.
6 | if echo ":exit" | cypher-shell --password neo4j 2> /dev/null ; then
7 | echo "CALL dbms.security.changePassword('${NEO4J_PASSWORD}');" | cypher-shell --password neo4j
8 | fi
9 |
10 | set -e
11 |
12 | echo '
13 | CALL db.index.fulltext.createNodeIndex("full_text_search",["Post"],["title", "content"]);
14 | CREATE CONSTRAINT ON (p:Post) ASSERT p.slug IS UNIQUE;
15 | CREATE CONSTRAINT ON (c:Category) ASSERT c.slug IS UNIQUE;
16 | CREATE CONSTRAINT ON (u:User) ASSERT u.slug IS UNIQUE;
17 | CREATE CONSTRAINT ON (o:Organization) ASSERT o.slug IS UNIQUE;
18 | ' | cypher-shell
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "human-connection-backend",
3 | "version": "0.0.1",
4 | "description": "GraphQL Backend for Human Connection",
5 | "main": "src/index.js",
6 | "config": {
7 | "no_auth": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 PERMISSIONS=disabled"
8 | },
9 | "scripts": {
10 | "build": "babel src/ -d dist/ --copy-files",
11 | "start": "node dist/",
12 | "dev": "nodemon --exec babel-node src/ -e js,graphql",
13 | "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,graphql",
14 | "lint": "eslint src --config .eslintrc.js",
15 | "test": "nyc --reporter=text-lcov yarn test:jest",
16 | "test:cypress": "run-p --race test:before:*",
17 | "test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 babel-node src/ 2> /dev/null",
18 | "test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 PERMISSIONS=disabled babel-node src/ 2> /dev/null",
19 | "test:jest:cmd": "wait-on tcp:4001 tcp:4123 && jest --forceExit --detectOpenHandles --runInBand",
20 | "test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/",
21 | "test:jest:cmd:debug": "wait-on tcp:4001 tcp:4123 && node --inspect-brk ./node_modules/.bin/jest -i --forceExit --detectOpenHandles --runInBand",
22 | "test:jest": "run-p --race test:before:* 'test:jest:cmd {@}' --",
23 | "test:cucumber": "run-p --race test:before:* 'test:cucumber:cmd {@}' --",
24 | "test:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --",
25 | "test:coverage": "nyc report --reporter=text-lcov > coverage.lcov",
26 | "db:script:seed": "wait-on tcp:4001 && babel-node src/seed/seed-db.js",
27 | "db:reset": "babel-node src/seed/reset-db.js",
28 | "db:seed": "$npm_package_config_no_auth run-p --race dev db:script:seed"
29 | },
30 | "author": "Human Connection gGmbH",
31 | "license": "MIT",
32 | "jest": {
33 | "verbose": true,
34 | "testMatch": [
35 | "**/src/**/?(*.)+(spec|test).js?(x)"
36 | ]
37 | },
38 | "dependencies": {
39 | "activitystrea.ms": "~2.1.3",
40 | "apollo-cache-inmemory": "~1.5.1",
41 | "apollo-client": "~2.5.1",
42 | "apollo-link-context": "~1.0.14",
43 | "apollo-link-http": "~1.5.13",
44 | "apollo-server": "~2.4.8",
45 | "bcryptjs": "~2.4.3",
46 | "cheerio": "~1.0.0-rc.2",
47 | "cors": "~2.8.5",
48 | "cross-env": "~5.2.0",
49 | "date-fns": "2.0.0-alpha.27",
50 | "dotenv": "~7.0.0",
51 | "express": "~4.16.4",
52 | "faker": "~4.1.0",
53 | "graphql": "~14.1.1",
54 | "graphql-custom-directives": "~0.2.14",
55 | "graphql-iso-date": "~3.6.1",
56 | "graphql-middleware": "~3.0.2",
57 | "graphql-shield": "~5.3.0",
58 | "graphql-tag": "~2.10.1",
59 | "graphql-yoga": "~1.17.4",
60 | "helmet": "~3.15.1",
61 | "jsonwebtoken": "~8.5.1",
62 | "linkifyjs": "~2.1.8",
63 | "lodash": "~4.17.11",
64 | "ms": "~2.1.1",
65 | "neo4j-driver": "~1.7.3",
66 | "neo4j-graphql-js": "~2.4.2",
67 | "node-fetch": "~2.3.0",
68 | "npm-run-all": "~4.1.5",
69 | "request": "~2.88.0",
70 | "sanitize-html": "~1.20.0",
71 | "slug": "~1.0.0",
72 | "trunc-html": "~1.1.2",
73 | "uuid": "~3.3.2",
74 | "wait-on": "~3.2.0"
75 | },
76 | "devDependencies": {
77 | "@babel/cli": "~7.2.3",
78 | "@babel/core": "~7.3.4",
79 | "@babel/node": "~7.2.2",
80 | "@babel/plugin-proposal-throw-expressions": "^7.2.0",
81 | "@babel/preset-env": "~7.3.4",
82 | "@babel/register": "~7.0.0",
83 | "apollo-server-testing": "~2.4.8",
84 | "babel-core": "~7.0.0-0",
85 | "babel-eslint": "~10.0.1",
86 | "babel-jest": "~24.5.0",
87 | "chai": "~4.2.0",
88 | "cucumber": "~5.1.0",
89 | "debug": "~4.1.1",
90 | "eslint": "~5.15.1",
91 | "eslint-config-standard": "~12.0.0",
92 | "eslint-plugin-import": "~2.16.0",
93 | "eslint-plugin-jest": "~22.3.2",
94 | "eslint-plugin-node": "~8.0.1",
95 | "eslint-plugin-promise": "~4.0.1",
96 | "eslint-plugin-standard": "~4.0.0",
97 | "graphql-request": "~1.8.2",
98 | "jest": "~24.5.0",
99 | "nodemon": "~1.18.10",
100 | "nyc": "~13.3.0",
101 | "supertest": "~4.0.0"
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/public/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Human-Connection/Nitro-Backend/7d26b03f8898def591a932c7b198855ac19a3186/public/.gitkeep
--------------------------------------------------------------------------------
/public/img/badges/fundraisingbox_de_airship.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/fundraisingbox_de_alienship.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/fundraisingbox_de_balloon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/fundraisingbox_de_bigballoon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/fundraisingbox_de_crane.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/fundraisingbox_de_glider.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/fundraisingbox_de_helicopter.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/fundraisingbox_de_starter.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/indiegogo_en_bear.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/indiegogo_en_panda.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/indiegogo_en_rabbit.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/indiegogo_en_racoon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/indiegogo_en_rhino.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/indiegogo_en_tiger.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/indiegogo_en_turtle.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/indiegogo_en_whale.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/indiegogo_en_wolf.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/user_role_admin.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/user_role_developer.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/user_role_moderator.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/wooold_de_bee.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/wooold_de_butterfly.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/wooold_de_double_rainbow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/wooold_de_flower.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/img/badges/wooold_de_magic_rainbow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/scripts/deploy.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | echo "See me deployed at $1 :)"
4 |
5 |
--------------------------------------------------------------------------------
/scripts/docker_push.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
3 | docker push humanconnection/nitro-backend:latest
4 |
--------------------------------------------------------------------------------
/scripts/test.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
--------------------------------------------------------------------------------
/src/activitypub/Collections.js:
--------------------------------------------------------------------------------
1 | export default class Collections {
2 | constructor (dataSource) {
3 | this.dataSource = dataSource
4 | }
5 | getFollowersCollection (actorId) {
6 | return this.dataSource.getFollowersCollection(actorId)
7 | }
8 |
9 | getFollowersCollectionPage (actorId) {
10 | return this.dataSource.getFollowersCollectionPage(actorId)
11 | }
12 |
13 | getFollowingCollection (actorId) {
14 | return this.dataSource.getFollowingCollection(actorId)
15 | }
16 |
17 | getFollowingCollectionPage (actorId) {
18 | return this.dataSource.getFollowingCollectionPage(actorId)
19 | }
20 |
21 | getOutboxCollection (actorId) {
22 | return this.dataSource.getOutboxCollection(actorId)
23 | }
24 |
25 | getOutboxCollectionPage (actorId) {
26 | return this.dataSource.getOutboxCollectionPage(actorId)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/activitypub/routes/inbox.js:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import { activityPub } from '../ActivityPub'
3 |
4 | const debug = require('debug')('ea:inbox')
5 |
6 | const router = express.Router()
7 |
8 | // Shared Inbox endpoint (federated Server)
9 | // For now its only able to handle Note Activities!!
10 | router.post('/', async function (req, res, next) {
11 | debug(`Content-Type = ${req.get('Content-Type')}`)
12 | debug(`body = ${JSON.stringify(req.body, null, 2)}`)
13 | debug(`Request headers = ${JSON.stringify(req.headers, null, 2)}`)
14 | switch (req.body.type) {
15 | case 'Create':
16 | await activityPub.handleCreateActivity(req.body).catch(next)
17 | break
18 | case 'Undo':
19 | await activityPub.handleUndoActivity(req.body).catch(next)
20 | break
21 | case 'Follow':
22 | await activityPub.handleFollowActivity(req.body).catch(next)
23 | break
24 | case 'Delete':
25 | await activityPub.handleDeleteActivity(req.body).catch(next)
26 | break
27 | /* eslint-disable */
28 | case 'Update':
29 | await activityPub.handleUpdateActivity(req.body).catch(next)
30 | break
31 | case 'Accept':
32 | await activityPub.handleAcceptActivity(req.body).catch(next)
33 | case 'Reject':
34 | // Do nothing
35 | break
36 | case 'Add':
37 | break
38 | case 'Remove':
39 | break
40 | case 'Like':
41 | await activityPub.handleLikeActivity(req.body).catch(next)
42 | break
43 | case 'Dislike':
44 | await activityPub.handleDislikeActivity(req.body).catch(next)
45 | break
46 | case 'Announce':
47 | debug('else!!')
48 | debug(JSON.stringify(req.body, null, 2))
49 | }
50 | /* eslint-enable */
51 | res.status(200).end()
52 | })
53 |
54 | export default router
55 |
--------------------------------------------------------------------------------
/src/activitypub/routes/index.js:
--------------------------------------------------------------------------------
1 | import user from './user'
2 | import inbox from './inbox'
3 | import webFinger from './webFinger'
4 | import express from 'express'
5 | import cors from 'cors'
6 | import verify from './verify'
7 |
8 | const router = express.Router()
9 |
10 | router.use('/.well-known/webFinger',
11 | cors(),
12 | express.urlencoded({ extended: true }),
13 | webFinger
14 | )
15 | router.use('/activitypub/users',
16 | cors(),
17 | express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }),
18 | express.urlencoded({ extended: true }),
19 | user
20 | )
21 | router.use('/activitypub/inbox',
22 | cors(),
23 | express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }),
24 | express.urlencoded({ extended: true }),
25 | verify,
26 | inbox
27 | )
28 |
29 | export default router
30 |
--------------------------------------------------------------------------------
/src/activitypub/routes/serveUser.js:
--------------------------------------------------------------------------------
1 | import { createActor } from '../utils/actor'
2 | const gql = require('graphql-tag')
3 | const debug = require('debug')('ea:serveUser')
4 |
5 | export async function serveUser (req, res, next) {
6 | let name = req.params.name
7 |
8 | if (name.startsWith('@')) {
9 | name = name.slice(1)
10 | }
11 |
12 | debug(`name = ${name}`)
13 | const result = await req.app.get('ap').dataSource.client.query({
14 | query: gql`
15 | query {
16 | User(slug: "${name}") {
17 | publicKey
18 | }
19 | }
20 | `
21 | }).catch(reason => { debug(`serveUser User fetch error: ${reason}`) })
22 |
23 | if (result.data && Array.isArray(result.data.User) && result.data.User.length > 0) {
24 | const publicKey = result.data.User[0].publicKey
25 | const actor = createActor(name, publicKey)
26 | debug(`actor = ${JSON.stringify(actor, null, 2)}`)
27 | debug(`accepts json = ${req.accepts(['application/activity+json', 'application/ld+json', 'application/json'])}`)
28 | if (req.accepts(['application/activity+json', 'application/ld+json', 'application/json'])) {
29 | return res.json(actor)
30 | } else if (req.accepts('text/html')) {
31 | // TODO show user's profile page instead of the actor object
32 | /* const outbox = JSON.parse(result.outbox)
33 | const posts = outbox.orderedItems.filter((el) => { return el.object.type === 'Note'})
34 | const actor = result.actor
35 | debug(posts) */
36 | // res.render('user', { user: actor, posts: JSON.stringify(posts)})
37 | return res.json(actor)
38 | }
39 | } else {
40 | debug(`error getting publicKey for actor ${name}`)
41 | next()
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/activitypub/routes/user.js:
--------------------------------------------------------------------------------
1 | import { sendCollection } from '../utils/collection'
2 | import express from 'express'
3 | import { serveUser } from './serveUser'
4 | import { activityPub } from '../ActivityPub'
5 | import verify from './verify'
6 |
7 | const router = express.Router()
8 | const debug = require('debug')('ea:user')
9 |
10 | router.get('/:name', async function (req, res, next) {
11 | debug('inside user.js -> serveUser')
12 | await serveUser(req, res, next)
13 | })
14 |
15 | router.get('/:name/following', (req, res) => {
16 | debug('inside user.js -> serveFollowingCollection')
17 | const name = req.params.name
18 | if (!name) {
19 | res.status(400).send('Bad request! Please specify a name.')
20 | } else {
21 | const collectionName = req.query.page ? 'followingPage' : 'following'
22 | sendCollection(collectionName, req, res)
23 | }
24 | })
25 |
26 | router.get('/:name/followers', (req, res) => {
27 | debug('inside user.js -> serveFollowersCollection')
28 | const name = req.params.name
29 | if (!name) {
30 | return res.status(400).send('Bad request! Please specify a name.')
31 | } else {
32 | const collectionName = req.query.page ? 'followersPage' : 'followers'
33 | sendCollection(collectionName, req, res)
34 | }
35 | })
36 |
37 | router.get('/:name/outbox', (req, res) => {
38 | debug('inside user.js -> serveOutboxCollection')
39 | const name = req.params.name
40 | if (!name) {
41 | return res.status(400).send('Bad request! Please specify a name.')
42 | } else {
43 | const collectionName = req.query.page ? 'outboxPage' : 'outbox'
44 | sendCollection(collectionName, req, res)
45 | }
46 | })
47 |
48 | router.post('/:name/inbox', verify, async function (req, res, next) {
49 | debug(`body = ${JSON.stringify(req.body, null, 2)}`)
50 | debug(`actorId = ${req.body.actor}`)
51 | // const result = await saveActorId(req.body.actor)
52 | switch (req.body.type) {
53 | case 'Create':
54 | await activityPub.handleCreateActivity(req.body).catch(next)
55 | break
56 | case 'Undo':
57 | await activityPub.handleUndoActivity(req.body).catch(next)
58 | break
59 | case 'Follow':
60 | await activityPub.handleFollowActivity(req.body).catch(next)
61 | break
62 | case 'Delete':
63 | await activityPub.handleDeleteActivity(req.body).catch(next)
64 | break
65 | /* eslint-disable */
66 | case 'Update':
67 | await activityPub.handleUpdateActivity(req.body).catch(next)
68 | break
69 | case 'Accept':
70 | await activityPub.handleAcceptActivity(req.body).catch(next)
71 | case 'Reject':
72 | // Do nothing
73 | break
74 | case 'Add':
75 | break
76 | case 'Remove':
77 | break
78 | case 'Like':
79 | await activityPub.handleLikeActivity(req.body).catch(next)
80 | break
81 | case 'Dislike':
82 | await activityPub.handleDislikeActivity(req.body).catch(next)
83 | break
84 | case 'Announce':
85 | debug('else!!')
86 | debug(JSON.stringify(req.body, null, 2))
87 | }
88 | /* eslint-enable */
89 | res.status(200).end()
90 | })
91 |
92 | export default router
93 |
--------------------------------------------------------------------------------
/src/activitypub/routes/verify.js:
--------------------------------------------------------------------------------
1 | import { verifySignature } from '../security'
2 | const debug = require('debug')('ea:verify')
3 |
4 | export default async (req, res, next) => {
5 | debug(`actorId = ${req.body.actor}`)
6 | // TODO stop if signature validation fails
7 | if (await verifySignature(`${req.protocol}://${req.hostname}:${req.app.get('port')}${req.originalUrl}`, req.headers)) {
8 | debug('verify = true')
9 | next()
10 | } else {
11 | // throw Error('Signature validation failed!')
12 | debug('verify = false')
13 | next()
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/activitypub/routes/webFinger.js:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import { createWebFinger } from '../utils/actor'
3 | import gql from 'graphql-tag'
4 |
5 | const router = express.Router()
6 |
7 | router.get('/', async function (req, res) {
8 | const resource = req.query.resource
9 | if (!resource || !resource.includes('acct:')) {
10 | return res.status(400).send('Bad request. Please make sure "acct:USER@DOMAIN" is what you are sending as the "resource" query parameter.')
11 | } else {
12 | const nameAndDomain = resource.replace('acct:', '')
13 | const name = nameAndDomain.split('@')[0]
14 |
15 | const result = await req.app.get('ap').dataSource.client.query({
16 | query: gql`
17 | query {
18 | User(slug: "${name}") {
19 | slug
20 | }
21 | }
22 | `
23 | })
24 |
25 | if (result.data && result.data.User.length > 0) {
26 | const webFinger = createWebFinger(name)
27 | return res.contentType('application/jrd+json').json(webFinger)
28 | } else {
29 | return res.status(404).json({ error: `No record found for ${nameAndDomain}.` })
30 | }
31 | }
32 | })
33 |
34 | export default router
35 |
--------------------------------------------------------------------------------
/src/activitypub/security/httpSignature.spec.js:
--------------------------------------------------------------------------------
1 | import { generateRsaKeyPair, createSignature, verifySignature } from '.'
2 | import crypto from 'crypto'
3 | import request from 'request'
4 | jest.mock('request')
5 |
6 | let privateKey
7 | let publicKey
8 | let headers
9 | const passphrase = 'a7dsf78sadg87ad87sfagsadg78'
10 |
11 | describe('activityPub/security', () => {
12 | beforeEach(() => {
13 | const pair = generateRsaKeyPair({ passphrase })
14 | privateKey = pair.privateKey
15 | publicKey = pair.publicKey
16 | headers = {
17 | 'Date': '2019-03-08T14:35:45.759Z',
18 | 'Host': 'democracy-app.de',
19 | 'Content-Type': 'application/json'
20 | }
21 | })
22 |
23 | describe('createSignature', () => {
24 | describe('returned http signature', () => {
25 | let signatureB64
26 | let httpSignature
27 |
28 | beforeEach(() => {
29 | const signer = crypto.createSign('rsa-sha256')
30 | signer.update('(request-target): post /activitypub/users/max/inbox\ndate: 2019-03-08T14:35:45.759Z\nhost: democracy-app.de\ncontent-type: application/json')
31 | signatureB64 = signer.sign({ key: privateKey, passphrase }, 'base64')
32 | httpSignature = createSignature({ privateKey, keyId: 'https://human-connection.org/activitypub/users/lea#main-key', url: 'https://democracy-app.de/activitypub/users/max/inbox', headers, passphrase })
33 | })
34 |
35 | it('contains keyId', () => {
36 | expect(httpSignature).toContain('keyId="https://human-connection.org/activitypub/users/lea#main-key"')
37 | })
38 |
39 | it('contains default algorithm "rsa-sha256"', () => {
40 | expect(httpSignature).toContain('algorithm="rsa-sha256"')
41 | })
42 |
43 | it('contains headers', () => {
44 | expect(httpSignature).toContain('headers="(request-target) date host content-type"')
45 | })
46 |
47 | it('contains signature', () => {
48 | expect(httpSignature).toContain('signature="' + signatureB64 + '"')
49 | })
50 | })
51 | })
52 |
53 | describe('verifySignature', () => {
54 | let httpSignature
55 |
56 | beforeEach(() => {
57 | httpSignature = createSignature({ privateKey, keyId: 'http://localhost:4001/activitypub/users/test-user#main-key', url: 'https://democracy-app.de/activitypub/users/max/inbox', headers, passphrase })
58 | const body = {
59 | 'publicKey': {
60 | 'id': 'https://localhost:4001/activitypub/users/test-user#main-key',
61 | 'owner': 'https://localhost:4001/activitypub/users/test-user',
62 | 'publicKeyPem': publicKey
63 | }
64 | }
65 |
66 | const mockedRequest = jest.fn((_, callback) => callback(null, null, JSON.stringify(body)))
67 | request.mockImplementation(mockedRequest)
68 | })
69 |
70 | it('resolves false', async () => {
71 | await expect(verifySignature('https://democracy-app.de/activitypub/users/max/inbox', headers)).resolves.toEqual(false)
72 | })
73 |
74 | describe('valid signature', () => {
75 | beforeEach(() => {
76 | headers.Signature = httpSignature
77 | })
78 |
79 | it('resolves true', async () => {
80 | await expect(verifySignature('https://democracy-app.de/activitypub/users/max/inbox', headers)).resolves.toEqual(true)
81 | })
82 | })
83 | })
84 | })
85 |
--------------------------------------------------------------------------------
/src/activitypub/utils/activity.js:
--------------------------------------------------------------------------------
1 | import { activityPub } from '../ActivityPub'
2 | import { signAndSend, throwErrorIfApolloErrorOccurred } from './index'
3 |
4 | import crypto from 'crypto'
5 | import as from 'activitystrea.ms'
6 | import gql from 'graphql-tag'
7 | const debug = require('debug')('ea:utils:activity')
8 |
9 | export function createNoteObject (text, name, id, published) {
10 | const createUuid = crypto.randomBytes(16).toString('hex')
11 |
12 | return {
13 | '@context': 'https://www.w3.org/ns/activitystreams',
14 | 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${createUuid}`,
15 | 'type': 'Create',
16 | 'actor': `https://${activityPub.domain}/activitypub/users/${name}`,
17 | 'object': {
18 | 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${id}`,
19 | 'type': 'Note',
20 | 'published': published,
21 | 'attributedTo': `https://${activityPub.domain}/activitypub/users/${name}`,
22 | 'content': text,
23 | 'to': 'https://www.w3.org/ns/activitystreams#Public'
24 | }
25 | }
26 | }
27 |
28 | export async function createArticleObject (activityId, objectId, text, name, id, published) {
29 | const actorId = await getActorId(name)
30 |
31 | return {
32 | '@context': 'https://www.w3.org/ns/activitystreams',
33 | 'id': `${activityId}`,
34 | 'type': 'Create',
35 | 'actor': `${actorId}`,
36 | 'object': {
37 | 'id': `${objectId}`,
38 | 'type': 'Article',
39 | 'published': published,
40 | 'attributedTo': `${actorId}`,
41 | 'content': text,
42 | 'to': 'https://www.w3.org/ns/activitystreams#Public'
43 | }
44 | }
45 | }
46 |
47 | export async function getActorId (name) {
48 | const result = await activityPub.dataSource.client.query({
49 | query: gql`
50 | query {
51 | User(slug: "${name}") {
52 | actorId
53 | }
54 | }
55 | `
56 | })
57 | throwErrorIfApolloErrorOccurred(result)
58 | if (Array.isArray(result.data.User) && result.data.User[0]) {
59 | return result.data.User[0].actorId
60 | } else {
61 | throw Error(`No user with name: ${name}`)
62 | }
63 | }
64 |
65 | export function sendAcceptActivity (theBody, name, targetDomain, url) {
66 | as.accept()
67 | .id(`https://${activityPub.domain}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex'))
68 | .actor(`https://${activityPub.domain}/activitypub/users/${name}`)
69 | .object(theBody)
70 | .prettyWrite((err, doc) => {
71 | if (!err) {
72 | return signAndSend(doc, name, targetDomain, url)
73 | } else {
74 | debug(`error serializing Accept object: ${err}`)
75 | throw new Error('error serializing Accept object')
76 | }
77 | })
78 | }
79 |
80 | export function sendRejectActivity (theBody, name, targetDomain, url) {
81 | as.reject()
82 | .id(`https://${activityPub.domain}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex'))
83 | .actor(`https://${activityPub.domain}/activitypub/users/${name}`)
84 | .object(theBody)
85 | .prettyWrite((err, doc) => {
86 | if (!err) {
87 | return signAndSend(doc, name, targetDomain, url)
88 | } else {
89 | debug(`error serializing Accept object: ${err}`)
90 | throw new Error('error serializing Accept object')
91 | }
92 | })
93 | }
94 |
95 | export function isPublicAddressed (postObject) {
96 | if (typeof postObject.to === 'string') {
97 | postObject.to = [postObject.to]
98 | }
99 | if (typeof postObject === 'string') {
100 | postObject.to = [postObject]
101 | }
102 | if (Array.isArray(postObject)) {
103 | postObject.to = postObject
104 | }
105 | return postObject.to.includes('Public') ||
106 | postObject.to.includes('as:Public') ||
107 | postObject.to.includes('https://www.w3.org/ns/activitystreams#Public')
108 | }
109 |
--------------------------------------------------------------------------------
/src/activitypub/utils/actor.js:
--------------------------------------------------------------------------------
1 | import { activityPub } from '../ActivityPub'
2 |
3 | export function createActor (name, pubkey) {
4 | return {
5 | '@context': [
6 | 'https://www.w3.org/ns/activitystreams',
7 | 'https://w3id.org/security/v1'
8 | ],
9 | 'id': `https://${activityPub.domain}/activitypub/users/${name}`,
10 | 'type': 'Person',
11 | 'preferredUsername': `${name}`,
12 | 'name': `${name}`,
13 | 'following': `https://${activityPub.domain}/activitypub/users/${name}/following`,
14 | 'followers': `https://${activityPub.domain}/activitypub/users/${name}/followers`,
15 | 'inbox': `https://${activityPub.domain}/activitypub/users/${name}/inbox`,
16 | 'outbox': `https://${activityPub.domain}/activitypub/users/${name}/outbox`,
17 | 'url': `https://${activityPub.domain}/activitypub/@${name}`,
18 | 'endpoints': {
19 | 'sharedInbox': `https://${activityPub.domain}/activitypub/inbox`
20 | },
21 | 'publicKey': {
22 | 'id': `https://${activityPub.domain}/activitypub/users/${name}#main-key`,
23 | 'owner': `https://${activityPub.domain}/activitypub/users/${name}`,
24 | 'publicKeyPem': pubkey
25 | }
26 | }
27 | }
28 |
29 | export function createWebFinger (name) {
30 | return {
31 | 'subject': `acct:${name}@${activityPub.domain}`,
32 | 'links': [
33 | {
34 | 'rel': 'self',
35 | 'type': 'application/activity+json',
36 | 'href': `https://${activityPub.domain}/users/${name}`
37 | }
38 | ]
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/activitypub/utils/collection.js:
--------------------------------------------------------------------------------
1 | import { activityPub } from '../ActivityPub'
2 | import { constructIdFromName } from './index'
3 | const debug = require('debug')('ea:utils:collections')
4 |
5 | export function createOrderedCollection (name, collectionName) {
6 | return {
7 | '@context': 'https://www.w3.org/ns/activitystreams',
8 | 'id': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}`,
9 | 'summary': `${name}s ${collectionName} collection`,
10 | 'type': 'OrderedCollection',
11 | 'first': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}?page=true`,
12 | 'totalItems': 0
13 | }
14 | }
15 |
16 | export function createOrderedCollectionPage (name, collectionName) {
17 | return {
18 | '@context': 'https://www.w3.org/ns/activitystreams',
19 | 'id': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}?page=true`,
20 | 'summary': `${name}s ${collectionName} collection`,
21 | 'type': 'OrderedCollectionPage',
22 | 'totalItems': 0,
23 | 'partOf': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}`,
24 | 'orderedItems': []
25 | }
26 | }
27 | export function sendCollection (collectionName, req, res) {
28 | const name = req.params.name
29 | const id = constructIdFromName(name)
30 |
31 | switch (collectionName) {
32 | case 'followers':
33 | attachThenCatch(activityPub.collections.getFollowersCollection(id), res)
34 | break
35 |
36 | case 'followersPage':
37 | attachThenCatch(activityPub.collections.getFollowersCollectionPage(id), res)
38 | break
39 |
40 | case 'following':
41 | attachThenCatch(activityPub.collections.getFollowingCollection(id), res)
42 | break
43 |
44 | case 'followingPage':
45 | attachThenCatch(activityPub.collections.getFollowingCollectionPage(id), res)
46 | break
47 |
48 | case 'outbox':
49 | attachThenCatch(activityPub.collections.getOutboxCollection(id), res)
50 | break
51 |
52 | case 'outboxPage':
53 | attachThenCatch(activityPub.collections.getOutboxCollectionPage(id), res)
54 | break
55 |
56 | default:
57 | res.status(500).end()
58 | }
59 | }
60 |
61 | function attachThenCatch (promise, res) {
62 | return promise
63 | .then((collection) => {
64 | res.status(200).contentType('application/activity+json').send(collection)
65 | })
66 | .catch((err) => {
67 | debug(`error getting a Collection: = ${err}`)
68 | res.status(500).end()
69 | })
70 | }
71 |
--------------------------------------------------------------------------------
/src/activitypub/utils/index.js:
--------------------------------------------------------------------------------
1 | import { activityPub } from '../ActivityPub'
2 | import gql from 'graphql-tag'
3 | import { createSignature } from '../security'
4 | import request from 'request'
5 | const debug = require('debug')('ea:utils')
6 |
7 | export function extractNameFromId (uri) {
8 | const urlObject = new URL(uri)
9 | const pathname = urlObject.pathname
10 | const splitted = pathname.split('/')
11 |
12 | return splitted[splitted.indexOf('users') + 1]
13 | }
14 |
15 | export function extractIdFromActivityId (uri) {
16 | const urlObject = new URL(uri)
17 | const pathname = urlObject.pathname
18 | const splitted = pathname.split('/')
19 |
20 | return splitted[splitted.indexOf('status') + 1]
21 | }
22 |
23 | export function constructIdFromName (name, fromDomain = activityPub.domain) {
24 | return `http://${fromDomain}/activitypub/users/${name}`
25 | }
26 |
27 | export function extractDomainFromUrl (url) {
28 | return new URL(url).hostname
29 | }
30 |
31 | export function throwErrorIfApolloErrorOccurred (result) {
32 | if (result.error && (result.error.message || result.error.errors)) {
33 | throw new Error(`${result.error.message ? result.error.message : result.error.errors[0].message}`)
34 | }
35 | }
36 |
37 | export function signAndSend (activity, fromName, targetDomain, url) {
38 | // fix for development: replace with http
39 | url = url.indexOf('localhost') > -1 ? url.replace('https', 'http') : url
40 | debug(`passhprase = ${process.env.PRIVATE_KEY_PASSPHRASE}`)
41 | return new Promise(async (resolve, reject) => {
42 | debug('inside signAndSend')
43 | // get the private key
44 | const result = await activityPub.dataSource.client.query({
45 | query: gql`
46 | query {
47 | User(slug: "${fromName}") {
48 | privateKey
49 | }
50 | }
51 | `
52 | })
53 |
54 | if (result.error) {
55 | reject(result.error)
56 | } else {
57 | // add security context
58 | const parsedActivity = JSON.parse(activity)
59 | if (Array.isArray(parsedActivity['@context'])) {
60 | parsedActivity['@context'].push('https://w3id.org/security/v1')
61 | } else {
62 | const context = [parsedActivity['@context']]
63 | context.push('https://w3id.org/security/v1')
64 | parsedActivity['@context'] = context
65 | }
66 |
67 | // deduplicate context strings
68 | parsedActivity['@context'] = [...new Set(parsedActivity['@context'])]
69 | const privateKey = result.data.User[0].privateKey
70 | const date = new Date().toUTCString()
71 |
72 | debug(`url = ${url}`)
73 | request({
74 | url: url,
75 | headers: {
76 | 'Host': targetDomain,
77 | 'Date': date,
78 | 'Signature': createSignature({ privateKey,
79 | keyId: `http://${activityPub.domain}/activitypub/users/${fromName}#main-key`,
80 | url,
81 | headers: {
82 | 'Host': targetDomain,
83 | 'Date': date,
84 | 'Content-Type': 'application/activity+json'
85 | }
86 | }),
87 | 'Content-Type': 'application/activity+json'
88 | },
89 | method: 'POST',
90 | body: JSON.stringify(parsedActivity)
91 | }, (error, response) => {
92 | if (error) {
93 | debug(`Error = ${JSON.stringify(error, null, 2)}`)
94 | reject(error)
95 | } else {
96 | debug('Response Headers:', JSON.stringify(response.headers, null, 2))
97 | debug('Response Body:', JSON.stringify(response.body, null, 2))
98 | resolve()
99 | }
100 | })
101 | }
102 | })
103 | }
104 |
--------------------------------------------------------------------------------
/src/bootstrap/directives.js:
--------------------------------------------------------------------------------
1 | import {
2 | GraphQLLowerCaseDirective,
3 | GraphQLTrimDirective,
4 | GraphQLDefaultToDirective
5 | } from 'graphql-custom-directives'
6 |
7 | export default function applyDirectives (augmentedSchema) {
8 | const directives = [
9 | GraphQLLowerCaseDirective,
10 | GraphQLTrimDirective,
11 | GraphQLDefaultToDirective
12 | ]
13 | augmentedSchema._directives.push.apply(augmentedSchema._directives, directives)
14 |
15 | return augmentedSchema
16 | }
17 |
--------------------------------------------------------------------------------
/src/bootstrap/neo4j.js:
--------------------------------------------------------------------------------
1 | import { v1 as neo4j } from 'neo4j-driver'
2 | import dotenv from 'dotenv'
3 |
4 | dotenv.config()
5 |
6 | let driver
7 |
8 | export function getDriver (options = {}) {
9 | const {
10 | uri = process.env.NEO4J_URI || 'bolt://localhost:7687',
11 | username = process.env.NEO4J_USERNAME || 'neo4j',
12 | password = process.env.NEO4J_PASSWORD || 'neo4j'
13 | } = options
14 | if (!driver) {
15 | driver = neo4j.driver(uri, neo4j.auth.basic(username, password))
16 | }
17 | return driver
18 | }
19 |
--------------------------------------------------------------------------------
/src/bootstrap/scalars.js:
--------------------------------------------------------------------------------
1 | import {
2 | GraphQLDate,
3 | GraphQLTime,
4 | GraphQLDateTime
5 | } from 'graphql-iso-date'
6 |
7 | export default function applyScalars (augmentedSchema) {
8 | augmentedSchema._typeMap.Date = GraphQLDate
9 | augmentedSchema._typeMap.Time = GraphQLTime
10 | augmentedSchema._typeMap.DateTime = GraphQLDateTime
11 |
12 | return augmentedSchema
13 | }
14 |
--------------------------------------------------------------------------------
/src/graphql-schema.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import path from 'path'
3 |
4 | import userManagement from './resolvers/user_management.js'
5 | import statistics from './resolvers/statistics.js'
6 | import reports from './resolvers/reports.js'
7 | import posts from './resolvers/posts.js'
8 | import moderation from './resolvers/moderation.js'
9 |
10 | export const typeDefs = fs
11 | .readFileSync(
12 | process.env.GRAPHQL_SCHEMA || path.join(__dirname, 'schema.graphql')
13 | )
14 | .toString('utf-8')
15 |
16 | export const resolvers = {
17 | Query: {
18 | ...statistics.Query,
19 | ...userManagement.Query
20 | },
21 | Mutation: {
22 | ...userManagement.Mutation,
23 | ...reports.Mutation,
24 | ...moderation.Mutation,
25 | ...posts.Mutation
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/helpers/asyncForEach.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Provide a way to iterate for each element in an array while waiting for async functions to finish
3 | *
4 | * @param array
5 | * @param callback
6 | * @returns {Promise}
7 | */
8 | async function asyncForEach (array, callback) {
9 | for (let index = 0; index < array.length; index++) {
10 | await callback(array[index], index, array)
11 | }
12 | }
13 |
14 | export default asyncForEach
15 |
--------------------------------------------------------------------------------
/src/helpers/walkRecursive.js:
--------------------------------------------------------------------------------
1 | /**
2 | * iterate through all fields and replace it with the callback result
3 | * @property data Array
4 | * @property fields Array
5 | * @property callback Function
6 | */
7 | function walkRecursive (data, fields, callback, _key) {
8 | if (!Array.isArray(fields)) {
9 | throw new Error('please provide an fields array for the walkRecursive helper')
10 | }
11 | if (data && typeof data === 'string' && fields.includes(_key)) {
12 | // well we found what we searched for, lets replace the value with our callback result
13 | data = callback(data, _key)
14 | } else if (data && Array.isArray(data)) {
15 | // go into the rabbit hole and dig through that array
16 | data.forEach((res, index) => {
17 | data[index] = walkRecursive(data[index], fields, callback, index)
18 | })
19 | } else if (data && typeof data === 'object') {
20 | // lets get some keys and stir them
21 | Object.keys(data).forEach(k => {
22 | data[k] = walkRecursive(data[k], fields, callback, k)
23 | })
24 | }
25 | return data
26 | }
27 |
28 | export default walkRecursive
29 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import createServer from './server'
2 | import ActivityPub from './activitypub/ActivityPub'
3 |
4 | const serverConfig = {
5 | port: process.env.GRAPHQL_PORT || 4000
6 | // cors: {
7 | // credentials: true,
8 | // origin: [process.env.CLIENT_URI] // your frontend url.
9 | // }
10 | }
11 |
12 | const server = createServer()
13 | server.start(serverConfig, options => {
14 | /* eslint-disable-next-line no-console */
15 | console.log(`Server ready at ${process.env.GRAPHQL_URI} 🚀`)
16 | ActivityPub.init(server)
17 | })
18 |
--------------------------------------------------------------------------------
/src/jest/helpers.js:
--------------------------------------------------------------------------------
1 | import { request } from 'graphql-request'
2 |
3 | // this is the to-be-tested server host
4 | // not to be confused with the seeder host
5 | export const host = 'http://127.0.0.1:4123'
6 |
7 | export async function login ({ email, password }) {
8 | const mutation = `
9 | mutation {
10 | login(email:"${email}", password:"${password}")
11 | }`
12 | const response = await request(host, mutation)
13 | return {
14 | authorization: `Bearer ${response.login}`
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/jwt/decode.js:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken'
2 |
3 | export default async (driver, authorizationHeader) => {
4 | if (!authorizationHeader) return null
5 | const token = authorizationHeader.replace('Bearer ', '')
6 | let id = null
7 | try {
8 | const decoded = await jwt.verify(token, process.env.JWT_SECRET)
9 | id = decoded.sub
10 | } catch {
11 | return null
12 | }
13 | const session = driver.session()
14 | const query = `
15 | MATCH (user:User {id: {id} })
16 | RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId}
17 | LIMIT 1
18 | `
19 | const result = await session.run(query, { id })
20 | session.close()
21 | const [currentUser] = await result.records.map((record) => {
22 | return record.get('user')
23 | })
24 | if (!currentUser) return null
25 | if (currentUser.disabled) return null
26 | return {
27 | token,
28 | ...currentUser
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/jwt/encode.js:
--------------------------------------------------------------------------------
1 |
2 | import jwt from 'jsonwebtoken'
3 | import ms from 'ms'
4 |
5 | // Generate an Access Token for the given User ID
6 | export default function encode (user) {
7 | const token = jwt.sign(user, process.env.JWT_SECRET, {
8 | expiresIn: ms('1d'),
9 | issuer: process.env.GRAPHQL_URI,
10 | audience: process.env.CLIENT_URI,
11 | subject: user.id.toString()
12 | })
13 | // jwt.verifySignature(token, process.env.JWT_SECRET, (err, data) => {
14 | // console.log('token verification:', err, data)
15 | // })
16 | return token
17 | }
18 |
--------------------------------------------------------------------------------
/src/middleware/activityPubMiddleware.js:
--------------------------------------------------------------------------------
1 | import { generateRsaKeyPair } from '../activitypub/security'
2 | import { activityPub } from '../activitypub/ActivityPub'
3 | import as from 'activitystrea.ms'
4 | import dotenv from 'dotenv'
5 |
6 | const debug = require('debug')('backend:schema')
7 | dotenv.config()
8 |
9 | export default {
10 | Mutation: {
11 | CreatePost: async (resolve, root, args, context, info) => {
12 | args.activityId = activityPub.generateStatusId(context.user.slug)
13 | args.objectId = activityPub.generateStatusId(context.user.slug)
14 |
15 | const post = await resolve(root, args, context, info)
16 |
17 | const { user: author } = context
18 | const actorId = author.actorId
19 | debug(`actorId = ${actorId}`)
20 | const createActivity = await new Promise((resolve, reject) => {
21 | as.create()
22 | .id(`${actorId}/status/${args.activityId}`)
23 | .actor(`${actorId}`)
24 | .object(
25 | as.article()
26 | .id(`${actorId}/status/${post.id}`)
27 | .content(post.content)
28 | .to('https://www.w3.org/ns/activitystreams#Public')
29 | .publishedNow()
30 | .attributedTo(`${actorId}`)
31 | ).prettyWrite((err, doc) => {
32 | if (err) {
33 | reject(err)
34 | } else {
35 | debug(doc)
36 | const parsedDoc = JSON.parse(doc)
37 | parsedDoc.send = true
38 | resolve(JSON.stringify(parsedDoc))
39 | }
40 | })
41 | })
42 | try {
43 | await activityPub.sendActivity(createActivity)
44 | } catch (e) {
45 | debug(`error sending post activity\n${e}`)
46 | }
47 | return post
48 | },
49 | CreateUser: async (resolve, root, args, context, info) => {
50 | const keys = generateRsaKeyPair()
51 | Object.assign(args, keys)
52 | args.actorId = `${process.env.GRAPHQL_URI}/activitypub/users/${args.slug}`
53 | return resolve(root, args, context, info)
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/middleware/dateTimeMiddleware.js:
--------------------------------------------------------------------------------
1 | export default {
2 | Mutation: {
3 | CreateUser: async (resolve, root, args, context, info) => {
4 | args.createdAt = (new Date()).toISOString()
5 | const result = await resolve(root, args, context, info)
6 | return result
7 | },
8 | CreatePost: async (resolve, root, args, context, info) => {
9 | args.createdAt = (new Date()).toISOString()
10 | const result = await resolve(root, args, context, info)
11 | return result
12 | },
13 | CreateComment: async (resolve, root, args, context, info) => {
14 | args.createdAt = (new Date()).toISOString()
15 | const result = await resolve(root, args, context, info)
16 | return result
17 | },
18 | CreateOrganization: async (resolve, root, args, context, info) => {
19 | args.createdAt = (new Date()).toISOString()
20 | const result = await resolve(root, args, context, info)
21 | return result
22 | },
23 | UpdateUser: async (resolve, root, args, context, info) => {
24 | args.updatedAt = (new Date()).toISOString()
25 | const result = await resolve(root, args, context, info)
26 | return result
27 | },
28 | UpdatePost: async (resolve, root, args, context, info) => {
29 | args.updatedAt = (new Date()).toISOString()
30 | const result = await resolve(root, args, context, info)
31 | return result
32 | },
33 | UpdateComment: async (resolve, root, args, context, info) => {
34 | args.updatedAt = (new Date()).toISOString()
35 | const result = await resolve(root, args, context, info)
36 | return result
37 | },
38 | UpdateOrganization: async (resolve, root, args, context, info) => {
39 | args.updatedAt = (new Date()).toISOString()
40 | const result = await resolve(root, args, context, info)
41 | return result
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/middleware/excerptMiddleware.js:
--------------------------------------------------------------------------------
1 | import trunc from 'trunc-html'
2 |
3 | export default {
4 | Mutation: {
5 | CreatePost: async (resolve, root, args, context, info) => {
6 | args.contentExcerpt = trunc(args.content, 120).html
7 | const result = await resolve(root, args, context, info)
8 | return result
9 | },
10 | UpdatePost: async (resolve, root, args, context, info) => {
11 | args.contentExcerpt = trunc(args.content, 120).html
12 | const result = await resolve(root, args, context, info)
13 | return result
14 | },
15 | CreateComment: async (resolve, root, args, context, info) => {
16 | args.contentExcerpt = trunc(args.content, 180).html
17 | const result = await resolve(root, args, context, info)
18 | return result
19 | },
20 | UpdateComment: async (resolve, root, args, context, info) => {
21 | args.contentExcerpt = trunc(args.content, 180).html
22 | const result = await resolve(root, args, context, info)
23 | return result
24 | },
25 | CreateOrganization: async (resolve, root, args, context, info) => {
26 | args.descriptionExcerpt = trunc(args.description, 120).html
27 | const result = await resolve(root, args, context, info)
28 | return result
29 | },
30 | UpdateOrganization: async (resolve, root, args, context, info) => {
31 | args.descriptionExcerpt = trunc(args.description, 120).html
32 | const result = await resolve(root, args, context, info)
33 | return result
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/middleware/fixImageUrlsMiddleware.js:
--------------------------------------------------------------------------------
1 |
2 | const legacyUrls = [
3 | 'https://api-alpha.human-connection.org',
4 | 'https://staging-api.human-connection.org',
5 | 'http://localhost:3000'
6 | ]
7 |
8 | export const fixUrl = (url) => {
9 | legacyUrls.forEach((legacyUrl) => {
10 | url = url.replace(legacyUrl, '/api')
11 | })
12 | return url
13 | }
14 |
15 | const checkUrl = (thing) => {
16 | return thing && typeof thing === 'string' && legacyUrls.find((legacyUrl) => {
17 | return thing.indexOf(legacyUrl) === 0
18 | })
19 | }
20 |
21 | export const fixImageURLs = (result, recursive) => {
22 | if (checkUrl(result)) {
23 | result = fixUrl(result)
24 | } else if (result && Array.isArray(result)) {
25 | result.forEach((res, index) => {
26 | result[index] = fixImageURLs(result[index], true)
27 | })
28 | } else if (result && typeof result === 'object') {
29 | Object.keys(result).forEach(key => {
30 | result[key] = fixImageURLs(result[key], true)
31 | })
32 | }
33 | return result
34 | }
35 |
36 | export default {
37 | Mutation: async (resolve, root, args, context, info) => {
38 | const result = await resolve(root, args, context, info)
39 | return fixImageURLs(result)
40 | },
41 | Query: async (resolve, root, args, context, info) => {
42 | let result = await resolve(root, args, context, info)
43 | return fixImageURLs(result)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/middleware/fixImageUrlsMiddleware.spec.js:
--------------------------------------------------------------------------------
1 | import { fixImageURLs } from './fixImageUrlsMiddleware'
2 |
3 | describe('fixImageURLs', () => {
4 | describe('image url of legacy alpha', () => {
5 | it('removes domain', () => {
6 | const url = 'https://api-alpha.human-connection.org/uploads/4bfaf9172c4ba03d7645108bbbd16f0a696a37d01eacd025fb131e5da61b15d9.png'
7 | expect(fixImageURLs(url)).toEqual('/api/uploads/4bfaf9172c4ba03d7645108bbbd16f0a696a37d01eacd025fb131e5da61b15d9.png')
8 | })
9 | })
10 |
11 | describe('image url of legacy staging', () => {
12 | it('removes domain', () => {
13 | const url = 'https://staging-api.human-connection.org/uploads/1b3c39a24f27e2fb62b69074b2f71363b63b263f0c4574047d279967124c026e.jpeg'
14 | expect(fixImageURLs(url)).toEqual('/api/uploads/1b3c39a24f27e2fb62b69074b2f71363b63b263f0c4574047d279967124c026e.jpeg')
15 | })
16 | })
17 |
18 | describe('object', () => {
19 | it('returns untouched', () => {
20 | const object = { some: 'thing' }
21 | expect(fixImageURLs(object)).toEqual(object)
22 | })
23 | })
24 |
25 | describe('some string', () => {
26 | it('returns untouched', () => {})
27 | const string = 'Yeah I\'m a String'
28 | expect(fixImageURLs(string)).toEqual(string)
29 | })
30 | })
31 |
--------------------------------------------------------------------------------
/src/middleware/includedFieldsMiddleware.js:
--------------------------------------------------------------------------------
1 | import cloneDeep from 'lodash/cloneDeep'
2 |
3 | const _includeFieldsRecursively = (selectionSet, includedFields) => {
4 | if (!selectionSet) return
5 | includedFields.forEach((includedField) => {
6 | selectionSet.selections.unshift({
7 | kind: 'Field',
8 | name: { kind: 'Name', value: includedField }
9 | })
10 | })
11 | selectionSet.selections.forEach((selection) => {
12 | _includeFieldsRecursively(selection.selectionSet, includedFields)
13 | })
14 | }
15 |
16 | const includeFieldsRecursively = (includedFields) => {
17 | return (resolve, root, args, context, resolveInfo) => {
18 | const copy = cloneDeep(resolveInfo)
19 | copy.fieldNodes.forEach((fieldNode) => {
20 | _includeFieldsRecursively(fieldNode.selectionSet, includedFields)
21 | })
22 | return resolve(root, args, context, copy)
23 | }
24 | }
25 |
26 | export default {
27 | Query: includeFieldsRecursively(['id', 'disabled', 'deleted']),
28 | Mutation: includeFieldsRecursively(['id', 'disabled', 'deleted'])
29 | }
30 |
--------------------------------------------------------------------------------
/src/middleware/index.js:
--------------------------------------------------------------------------------
1 | import activityPubMiddleware from './activityPubMiddleware'
2 | import passwordMiddleware from './passwordMiddleware'
3 | import softDeleteMiddleware from './softDeleteMiddleware'
4 | import sluggifyMiddleware from './sluggifyMiddleware'
5 | import fixImageUrlsMiddleware from './fixImageUrlsMiddleware'
6 | import excerptMiddleware from './excerptMiddleware'
7 | import dateTimeMiddleware from './dateTimeMiddleware'
8 | import xssMiddleware from './xssMiddleware'
9 | import permissionsMiddleware from './permissionsMiddleware'
10 | import userMiddleware from './userMiddleware'
11 | import includedFieldsMiddleware from './includedFieldsMiddleware'
12 |
13 | export default schema => {
14 | let middleware = [
15 | passwordMiddleware,
16 | dateTimeMiddleware,
17 | sluggifyMiddleware,
18 | excerptMiddleware,
19 | xssMiddleware,
20 | fixImageUrlsMiddleware,
21 | softDeleteMiddleware,
22 | userMiddleware,
23 | includedFieldsMiddleware
24 | ]
25 |
26 | // add permisions middleware at the first position (unless we're seeding)
27 | // NOTE: DO NOT SET THE PERMISSION FLAT YOUR SELF
28 | if (process.env.PERMISSIONS !== 'disabled' && process.env.NODE_ENV !== 'production') {
29 | middleware.unshift(activityPubMiddleware)
30 | middleware.unshift(permissionsMiddleware.generate(schema))
31 | }
32 | return middleware
33 | }
34 |
--------------------------------------------------------------------------------
/src/middleware/nodes/locations.js:
--------------------------------------------------------------------------------
1 |
2 | import request from 'request'
3 | import { UserInputError } from 'apollo-server'
4 | import isEmpty from 'lodash/isEmpty'
5 | import asyncForEach from '../../helpers/asyncForEach'
6 |
7 | const fetch = url => {
8 | return new Promise((resolve, reject) => {
9 | request(url, function (error, response, body) {
10 | if (error) {
11 | reject(error)
12 | } else {
13 | resolve(JSON.parse(body))
14 | }
15 | })
16 | })
17 | }
18 |
19 | const locales = [
20 | 'en',
21 | 'de',
22 | 'fr',
23 | 'nl',
24 | 'it',
25 | 'es',
26 | 'pt',
27 | 'pl'
28 | ]
29 |
30 | const createLocation = async (session, mapboxData) => {
31 | const data = {
32 | id: mapboxData.id,
33 | nameEN: mapboxData.text_en,
34 | nameDE: mapboxData.text_de,
35 | nameFR: mapboxData.text_fr,
36 | nameNL: mapboxData.text_nl,
37 | nameIT: mapboxData.text_it,
38 | nameES: mapboxData.text_es,
39 | namePT: mapboxData.text_pt,
40 | namePL: mapboxData.text_pl,
41 | type: mapboxData.id.split('.')[0].toLowerCase(),
42 | lat: (mapboxData.center && mapboxData.center.length) ? mapboxData.center[0] : null,
43 | lng: (mapboxData.center && mapboxData.center.length) ? mapboxData.center[1] : null
44 | }
45 |
46 | let query = 'MERGE (l:Location {id: $id}) ' +
47 | 'SET l.name = $nameEN, ' +
48 | 'l.nameEN = $nameEN, ' +
49 | 'l.nameDE = $nameDE, ' +
50 | 'l.nameFR = $nameFR, ' +
51 | 'l.nameNL = $nameNL, ' +
52 | 'l.nameIT = $nameIT, ' +
53 | 'l.nameES = $nameES, ' +
54 | 'l.namePT = $namePT, ' +
55 | 'l.namePL = $namePL, ' +
56 | 'l.type = $type'
57 |
58 | if (data.lat && data.lng) {
59 | query += ', l.lat = $lat, l.lng = $lng'
60 | }
61 | query += ' RETURN l.id'
62 |
63 | await session.run(query, data)
64 | }
65 |
66 | const createOrUpdateLocations = async (userId, locationName, driver) => {
67 | if (isEmpty(locationName)) {
68 | return
69 | }
70 | const mapboxToken = process.env.MAPBOX_TOKEN
71 | const res = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(locationName)}.json?access_token=${mapboxToken}&types=region,place,country&language=${locales.join(',')}`)
72 |
73 | if (!res || !res.features || !res.features[0]) {
74 | throw new UserInputError('locationName is invalid')
75 | }
76 |
77 | let data
78 |
79 | res.features.forEach(item => {
80 | if (item.matching_place_name === locationName) {
81 | data = item
82 | }
83 | })
84 | if (!data) {
85 | data = res.features[0]
86 | }
87 |
88 | if (!data || !data.place_type || !data.place_type.length) {
89 | throw new UserInputError('locationName is invalid')
90 | }
91 |
92 | const session = driver.session()
93 | await createLocation(session, data)
94 |
95 | let parent = data
96 |
97 | if (data.context) {
98 | await asyncForEach(data.context, async ctx => {
99 | await createLocation(session, ctx)
100 |
101 | await session.run(
102 | 'MATCH (parent:Location {id: $parentId}), (child:Location {id: $childId}) ' +
103 | 'MERGE (child)<-[:IS_IN]-(parent) ' +
104 | 'RETURN child.id, parent.id', {
105 | parentId: parent.id,
106 | childId: ctx.id
107 | })
108 |
109 | parent = ctx
110 | })
111 | }
112 | // delete all current locations from user
113 | await session.run('MATCH (u:User {id: $userId})-[r:IS_IN]->(l:Location) DETACH DELETE r', {
114 | userId: userId
115 | })
116 | // connect user with location
117 | await session.run('MATCH (u:User {id: $userId}), (l:Location {id: $locationId}) MERGE (u)-[:IS_IN]->(l) RETURN l.id, u.id', {
118 | userId: userId,
119 | locationId: data.id
120 | })
121 | session.close()
122 | }
123 |
124 | export default createOrUpdateLocations
125 |
--------------------------------------------------------------------------------
/src/middleware/passwordMiddleware.js:
--------------------------------------------------------------------------------
1 | import bcrypt from 'bcryptjs'
2 | import walkRecursive from '../helpers/walkRecursive'
3 |
4 | export default {
5 | Mutation: {
6 | CreateUser: async (resolve, root, args, context, info) => {
7 | args.password = await bcrypt.hashSync(args.password, 10)
8 | const result = await resolve(root, args, context, info)
9 | result.password = '*****'
10 | return result
11 | }
12 | },
13 | Query: async (resolve, root, args, context, info) => {
14 | const result = await resolve(root, args, context, info)
15 | return walkRecursive(result, ['password'], () => {
16 | // replace password with asterisk
17 | return '*****'
18 | })
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/middleware/permissionsMiddleware.js:
--------------------------------------------------------------------------------
1 | import { rule, shield, allow, or } from 'graphql-shield'
2 |
3 | /*
4 | * TODO: implement
5 | * See: https://github.com/Human-Connection/Nitro-Backend/pull/40#pullrequestreview-180898363
6 | */
7 | const isAuthenticated = rule()(async (parent, args, ctx, info) => {
8 | return ctx.user !== null
9 | })
10 |
11 | const isModerator = rule()(async (parent, args, { user }, info) => {
12 | return user && (user.role === 'moderator' || user.role === 'admin')
13 | })
14 |
15 | const isAdmin = rule()(async (parent, args, { user }, info) => {
16 | return user && (user.role === 'admin')
17 | })
18 |
19 | const isMyOwn = rule({ cache: 'no_cache' })(async (parent, args, context, info) => {
20 | return context.user.id === parent.id
21 | })
22 |
23 | const onlyEnabledContent = rule({ cache: 'strict' })(async (parent, args, ctx, info) => {
24 | const { disabled, deleted } = args
25 | return !(disabled || deleted)
26 | })
27 |
28 | const isAuthor = rule({ cache: 'no_cache' })(async (parent, args, { user, driver }) => {
29 | if (!user) return false
30 | const session = driver.session()
31 | const { id: postId } = args
32 | const result = await session.run(`
33 | MATCH (post:Post {id: $postId})<-[:WROTE]-(author)
34 | RETURN author
35 | `, { postId })
36 | const [author] = result.records.map((record) => {
37 | return record.get('author')
38 | })
39 | const { properties: { id: authorId } } = author
40 | session.close()
41 | return authorId === user.id
42 | })
43 |
44 | // Permissions
45 | const permissions = shield({
46 | Query: {
47 | statistics: allow,
48 | currentUser: allow,
49 | Post: or(onlyEnabledContent, isModerator)
50 | },
51 | Mutation: {
52 | CreatePost: isAuthenticated,
53 | UpdatePost: isAuthor,
54 | DeletePost: isAuthor,
55 | report: isAuthenticated,
56 | CreateBadge: isAdmin,
57 | UpdateBadge: isAdmin,
58 | DeleteBadge: isAdmin,
59 | // addFruitToBasket: isAuthenticated
60 | follow: isAuthenticated,
61 | unfollow: isAuthenticated,
62 | shout: isAuthenticated,
63 | unshout: isAuthenticated,
64 | changePassword: isAuthenticated,
65 | enable: isModerator,
66 | disable: isModerator
67 | // CreateUser: allow,
68 | },
69 | User: {
70 | email: isMyOwn,
71 | password: isMyOwn
72 | }
73 | })
74 |
75 | export default permissions
76 |
--------------------------------------------------------------------------------
/src/middleware/permissionsMiddleware.spec.js:
--------------------------------------------------------------------------------
1 | import Factory from '../seed/factories'
2 | import { host, login } from '../jest/helpers'
3 | import { GraphQLClient } from 'graphql-request'
4 |
5 | const factory = Factory()
6 |
7 | describe('authorization', () => {
8 | describe('given two existing users', () => {
9 | beforeEach(async () => {
10 | await factory.create('User', {
11 | email: 'owner@example.org',
12 | name: 'Owner',
13 | password: 'iamtheowner'
14 | })
15 | await factory.create('User', {
16 | email: 'someone@example.org',
17 | name: 'Someone else',
18 | password: 'else'
19 | })
20 | })
21 |
22 | afterEach(async () => {
23 | await factory.cleanDatabase()
24 | })
25 |
26 | describe('access email address', () => {
27 | let headers = {}
28 | let loginCredentials = null
29 | const action = async () => {
30 | if (loginCredentials) {
31 | headers = await login(loginCredentials)
32 | }
33 | const graphQLClient = new GraphQLClient(host, { headers })
34 | return graphQLClient.request('{User(name: "Owner") { email } }')
35 | }
36 |
37 | describe('not logged in', () => {
38 | it('rejects', async () => {
39 | await expect(action()).rejects.toThrow('Not Authorised!')
40 | })
41 |
42 | it('does not expose the owner\'s email address', async () => {
43 | try {
44 | await action()
45 | } catch (error) {
46 | expect(error.response.data).toEqual({ User: [ { email: null } ] })
47 | }
48 | })
49 | })
50 |
51 | describe('as owner', () => {
52 | beforeEach(() => {
53 | loginCredentials = {
54 | email: 'owner@example.org',
55 | password: 'iamtheowner'
56 | }
57 | })
58 |
59 | it('exposes the owner\'s email address', async () => {
60 | await expect(action()).resolves.toEqual({ User: [ { email: 'owner@example.org' } ] })
61 | })
62 | })
63 |
64 | describe('authenticated as another user', () => {
65 | beforeEach(async () => {
66 | loginCredentials = {
67 | email: 'someone@example.org',
68 | password: 'else'
69 | }
70 | })
71 |
72 | it('rejects', async () => {
73 | await expect(action()).rejects.toThrow('Not Authorised!')
74 | })
75 |
76 | it('does not expose the owner\'s email address', async () => {
77 | try {
78 | await action()
79 | } catch (error) {
80 | expect(error.response.data).toEqual({ User: [ { email: null } ] })
81 | }
82 | })
83 | })
84 | })
85 | })
86 | })
87 |
--------------------------------------------------------------------------------
/src/middleware/sluggifyMiddleware.js:
--------------------------------------------------------------------------------
1 | import uniqueSlug from './slugify/uniqueSlug'
2 |
3 | const isUniqueFor = (context, type) => {
4 | return async slug => {
5 | const session = context.driver.session()
6 | const response = await session.run(
7 | `MATCH(p:${type} {slug: $slug }) return p.slug`,
8 | {
9 | slug
10 | }
11 | )
12 | session.close()
13 | return response.records.length === 0
14 | }
15 | }
16 |
17 | export default {
18 | Mutation: {
19 | CreatePost: async (resolve, root, args, context, info) => {
20 | args.slug =
21 | args.slug ||
22 | (await uniqueSlug(args.title, isUniqueFor(context, 'Post')))
23 | return resolve(root, args, context, info)
24 | },
25 | CreateUser: async (resolve, root, args, context, info) => {
26 | args.slug =
27 | args.slug ||
28 | (await uniqueSlug(args.name, isUniqueFor(context, 'User')))
29 | return resolve(root, args, context, info)
30 | },
31 | CreateOrganization: async (resolve, root, args, context, info) => {
32 | args.slug =
33 | args.slug ||
34 | (await uniqueSlug(args.name, isUniqueFor(context, 'Organization')))
35 | return resolve(root, args, context, info)
36 | },
37 | CreateCategory: async (resolve, root, args, context, info) => {
38 | args.slug =
39 | args.slug ||
40 | (await uniqueSlug(args.name, isUniqueFor(context, 'Category')))
41 | return resolve(root, args, context, info)
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/middleware/slugify/uniqueSlug.js:
--------------------------------------------------------------------------------
1 | import slugify from 'slug'
2 | export default async function uniqueSlug (string, isUnique) {
3 | let slug = slugify(string, {
4 | lower: true
5 | })
6 | if (await isUnique(slug)) return slug
7 |
8 | let count = 0
9 | let uniqueSlug
10 | do {
11 | count += 1
12 | uniqueSlug = `${slug}-${count}`
13 | } while (!await isUnique(uniqueSlug))
14 | return uniqueSlug
15 | }
16 |
--------------------------------------------------------------------------------
/src/middleware/slugify/uniqueSlug.spec.js:
--------------------------------------------------------------------------------
1 | import uniqueSlug from './uniqueSlug'
2 |
3 | describe('uniqueSlug', () => {
4 | it('slugifies given string', () => {
5 | const string = 'Hello World'
6 | const isUnique = jest.fn()
7 | .mockResolvedValue(true)
8 | expect(uniqueSlug(string, isUnique)).resolves.toEqual('hello-world')
9 | })
10 |
11 | it('increments slugified string until unique', () => {
12 | const string = 'Hello World'
13 | const isUnique = jest.fn()
14 | .mockResolvedValueOnce(false)
15 | .mockResolvedValueOnce(true)
16 | expect(uniqueSlug(string, isUnique)).resolves.toEqual('hello-world-1')
17 | })
18 | })
19 |
--------------------------------------------------------------------------------
/src/middleware/slugifyMiddleware.spec.js:
--------------------------------------------------------------------------------
1 | import Factory from '../seed/factories'
2 | import { host, login } from '../jest/helpers'
3 | import { GraphQLClient } from 'graphql-request'
4 |
5 | let authenticatedClient
6 | let headers
7 | const factory = Factory()
8 |
9 | beforeEach(async () => {
10 | await factory.create('User', { email: 'user@example.org', password: '1234' })
11 | await factory.create('User', {
12 | email: 'someone@example.org',
13 | password: '1234'
14 | })
15 | headers = await login({ email: 'user@example.org', password: '1234' })
16 | authenticatedClient = new GraphQLClient(host, { headers })
17 | })
18 |
19 | afterEach(async () => {
20 | await factory.cleanDatabase()
21 | })
22 |
23 | describe('slugify', () => {
24 | describe('CreatePost', () => {
25 | it('generates a slug based on title', async () => {
26 | const response = await authenticatedClient.request(`mutation {
27 | CreatePost(
28 | title: "I am a brand new post",
29 | content: "Some content"
30 | ) { slug }
31 | }`)
32 | expect(response).toEqual({
33 | CreatePost: { slug: 'i-am-a-brand-new-post' }
34 | })
35 | })
36 |
37 | describe('if slug exists', () => {
38 | beforeEach(async () => {
39 | const asSomeoneElse = await Factory().authenticateAs({
40 | email: 'someone@example.org',
41 | password: '1234'
42 | })
43 | await asSomeoneElse.create('Post', {
44 | title: 'Pre-existing post',
45 | slug: 'pre-existing-post'
46 | })
47 | })
48 |
49 | it('chooses another slug', async () => {
50 | const response = await authenticatedClient.request(`mutation {
51 | CreatePost(
52 | title: "Pre-existing post",
53 | content: "Some content"
54 | ) { slug }
55 | }`)
56 | expect(response).toEqual({
57 | CreatePost: { slug: 'pre-existing-post-1' }
58 | })
59 | })
60 |
61 | describe('but if the client specifies a slug', () => {
62 | it('rejects CreatePost', async () => {
63 | await expect(
64 | authenticatedClient.request(`mutation {
65 | CreatePost(
66 | title: "Pre-existing post",
67 | content: "Some content",
68 | slug: "pre-existing-post"
69 | ) { slug }
70 | }`)
71 | ).rejects.toThrow('already exists')
72 | })
73 | })
74 | })
75 | })
76 |
77 | describe('CreateUser', () => {
78 | const action = async (mutation, params) => {
79 | return authenticatedClient.request(`mutation {
80 | ${mutation}(password: "yo", ${params}) { slug }
81 | }`)
82 | }
83 | it('generates a slug based on name', async () => {
84 | await expect(
85 | action('CreateUser', 'name: "I am a user"')
86 | ).resolves.toEqual({ CreateUser: { slug: 'i-am-a-user' } })
87 | })
88 |
89 | describe('if slug exists', () => {
90 | beforeEach(async () => {
91 | await action(
92 | 'CreateUser',
93 | 'name: "Pre-existing user", slug: "pre-existing-user"'
94 | )
95 | })
96 |
97 | it('chooses another slug', async () => {
98 | await expect(
99 | action('CreateUser', 'name: "pre-existing-user"')
100 | ).resolves.toEqual({ CreateUser: { slug: 'pre-existing-user-1' } })
101 | })
102 |
103 | describe('but if the client specifies a slug', () => {
104 | it('rejects CreateUser', async () => {
105 | await expect(
106 | action(
107 | 'CreateUser',
108 | 'name: "Pre-existing user", slug: "pre-existing-user"'
109 | )
110 | ).rejects.toThrow('already exists')
111 | })
112 | })
113 | })
114 | })
115 | })
116 |
--------------------------------------------------------------------------------
/src/middleware/softDeleteMiddleware.js:
--------------------------------------------------------------------------------
1 | const isModerator = ({ user }) => {
2 | return user && (user.role === 'moderator' || user.role === 'admin')
3 | }
4 |
5 | const setDefaultFilters = (resolve, root, args, context, info) => {
6 | if (typeof args.deleted !== 'boolean') {
7 | args.deleted = false
8 | }
9 |
10 | if (!isModerator(context)) {
11 | args.disabled = false
12 | }
13 | return resolve(root, args, context, info)
14 | }
15 |
16 | const obfuscateDisabled = async (resolve, root, args, context, info) => {
17 | if (!isModerator(context) && root.disabled) {
18 | root.content = 'UNAVAILABLE'
19 | root.contentExcerpt = 'UNAVAILABLE'
20 | root.title = 'UNAVAILABLE'
21 | root.image = 'UNAVAILABLE'
22 | root.avatar = 'UNAVAILABLE'
23 | root.about = 'UNAVAILABLE'
24 | root.name = 'UNAVAILABLE'
25 | }
26 | return resolve(root, args, context, info)
27 | }
28 |
29 | export default {
30 | Query: {
31 | Post: setDefaultFilters,
32 | Comment: setDefaultFilters,
33 | User: setDefaultFilters
34 | },
35 | Mutation: async (resolve, root, args, context, info) => {
36 | args.disabled = false
37 | // TODO: remove as soon as our factories don't need this anymore
38 | if (typeof args.deleted !== 'boolean') {
39 | args.deleted = false
40 | }
41 | return resolve(root, args, context, info)
42 | },
43 | Post: obfuscateDisabled,
44 | User: obfuscateDisabled,
45 | Comment: obfuscateDisabled
46 | }
47 |
--------------------------------------------------------------------------------
/src/middleware/userMiddleware.js:
--------------------------------------------------------------------------------
1 | import createOrUpdateLocations from './nodes/locations'
2 | import dotenv from 'dotenv'
3 |
4 | dotenv.config()
5 |
6 | export default {
7 | Mutation: {
8 | CreateUser: async (resolve, root, args, context, info) => {
9 | const result = await resolve(root, args, context, info)
10 | await createOrUpdateLocations(args.id, args.locationName, context.driver)
11 | return result
12 | },
13 | UpdateUser: async (resolve, root, args, context, info) => {
14 | const result = await resolve(root, args, context, info)
15 | await createOrUpdateLocations(args.id, args.locationName, context.driver)
16 | return result
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/middleware/xssMiddleware.js:
--------------------------------------------------------------------------------
1 | import walkRecursive from '../helpers/walkRecursive'
2 | // import { getByDot, setByDot, getItems, replaceItems } from 'feathers-hooks-common'
3 | import sanitizeHtml from 'sanitize-html'
4 | // import { isEmpty, intersection } from 'lodash'
5 | import cheerio from 'cheerio'
6 | import linkifyHtml from 'linkifyjs/html'
7 |
8 | const embedToAnchor = (content) => {
9 | const $ = cheerio.load(content)
10 | $('div[data-url-embed]').each((i, el) => {
11 | let url = el.attribs['data-url-embed']
12 | let aTag = $(`${url}`)
13 | $(el).replaceWith(aTag)
14 | })
15 | return $('body').html()
16 | }
17 |
18 | function clean (dirty) {
19 | if (!dirty) {
20 | return dirty
21 | }
22 |
23 | // Convert embeds to a-tags
24 | dirty = embedToAnchor(dirty)
25 | dirty = linkifyHtml(dirty)
26 | dirty = sanitizeHtml(dirty, {
27 | allowedTags: ['iframe', 'img', 'p', 'h3', 'h4', 'br', 'hr', 'b', 'i', 'em', 'strong', 'a', 'pre', 'ul', 'li', 'ol', 's', 'strike', 'span', 'blockquote'],
28 | allowedAttributes: {
29 | a: ['href', 'class', 'target', 'data-*', 'contenteditable'],
30 | span: ['contenteditable', 'class', 'data-*'],
31 | img: ['src'],
32 | iframe: ['src', 'class', 'frameborder', 'allowfullscreen']
33 | },
34 | allowedIframeHostnames: ['www.youtube.com', 'player.vimeo.com'],
35 | parser: {
36 | lowerCaseTags: true
37 | },
38 | transformTags: {
39 | iframe: function (tagName, attribs) {
40 | return {
41 | tagName: 'a',
42 | text: attribs.src,
43 | attribs: {
44 | href: attribs.src,
45 | target: '_blank',
46 | 'data-url-embed': ''
47 | }
48 | }
49 | },
50 | h1: 'h3',
51 | h2: 'h3',
52 | h3: 'h3',
53 | h4: 'h4',
54 | h5: 'strong',
55 | i: 'em',
56 | a: function (tagName, attribs) {
57 | return {
58 | tagName: 'a',
59 | attribs: {
60 | href: attribs.href,
61 | target: '_blank',
62 | rel: 'noopener noreferrer nofollow'
63 | }
64 | }
65 | },
66 | b: 'strong',
67 | s: 'strike',
68 | img: function (tagName, attribs) {
69 | let src = attribs.src
70 |
71 | if (!src) {
72 | // remove broken images
73 | return {}
74 | }
75 |
76 | // if (isEmpty(hook.result)) {
77 | // const config = hook.app.get('thumbor')
78 | // if (config && src.indexOf(config < 0)) {
79 | // // download image
80 | // // const ThumborUrlHelper = require('../helper/thumbor-helper')
81 | // // const Thumbor = new ThumborUrlHelper(config.key || null, config.url || null)
82 | // // src = Thumbor
83 | // // .setImagePath(src)
84 | // // .buildUrl('740x0')
85 | // }
86 | // }
87 | return {
88 | tagName: 'img',
89 | attribs: {
90 | // TODO: use environment variables
91 | src: `http://localhost:3050/images?url=${src}`
92 | }
93 | }
94 | }
95 | }
96 | })
97 |
98 | // remove empty html tags and duplicated linebreaks and returns
99 | dirty = dirty
100 | // remove all tags with "space only"
101 | .replace(/<[a-z-]+>[\s]+<\/[a-z-]+>/gim, '')
102 | // remove all iframes
103 | .replace(
104 | /(
'
117 | )
118 | // remove additional linebreaks inside p tags
119 | .replace(
120 | /<[a-z-]+>(<[a-z-]+>)*\s*(
\s*)+\s*(<\/[a-z-]+>)*<\/[a-z-]+>/gim,
121 | ''
122 | )
123 | // remove additional linebreaks when first child inside p tags
124 | .replace(/
(\s*
\s*)+/gim, '
')
125 | // remove additional linebreaks when last child inside p tags
126 | .replace(/(\s*
\s*)+<\/p+>/gim, '
')
127 | return dirty
128 | }
129 |
130 | const fields = ['content', 'contentExcerpt']
131 |
132 | export default {
133 | Mutation: async (resolve, root, args, context, info) => {
134 | args = walkRecursive(args, fields, clean)
135 | const result = await resolve(root, args, context, info)
136 | return result
137 | },
138 | Query: async (resolve, root, args, context, info) => {
139 | const result = await resolve(root, args, context, info)
140 | return walkRecursive(result, fields, clean)
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/src/mocks/index.js:
--------------------------------------------------------------------------------
1 |
2 | import faker from 'faker'
3 |
4 | export default {
5 | User: () => ({
6 | name: () => `${faker.name.firstName()} ${faker.name.lastName()}`,
7 | email: () => `${faker.internet.email()}`
8 | }),
9 | Post: () => ({
10 | title: () => faker.lorem.lines(1),
11 | slug: () => faker.lorem.slug(3),
12 | content: () => faker.lorem.paragraphs(5),
13 | contentExcerpt: () => faker.lorem.paragraphs(1)
14 | })
15 | }
16 |
--------------------------------------------------------------------------------
/src/resolvers/follow.spec.js:
--------------------------------------------------------------------------------
1 | import Factory from '../seed/factories'
2 | import { GraphQLClient } from 'graphql-request'
3 | import { host, login } from '../jest/helpers'
4 |
5 | const factory = Factory()
6 | let clientUser1
7 | let headersUser1
8 |
9 | const mutationFollowUser = (id) => `
10 | mutation {
11 | follow(id: "${id}", type: User)
12 | }
13 | `
14 | const mutationUnfollowUser = (id) => `
15 | mutation {
16 | unfollow(id: "${id}", type: User)
17 | }
18 | `
19 |
20 | beforeEach(async () => {
21 | await factory.create('User', {
22 | id: 'u1',
23 | email: 'test@example.org',
24 | password: '1234'
25 | })
26 | await factory.create('User', {
27 | id: 'u2',
28 | email: 'test2@example.org',
29 | password: '1234'
30 | })
31 |
32 | headersUser1 = await login({ email: 'test@example.org', password: '1234' })
33 | clientUser1 = new GraphQLClient(host, { headers: headersUser1 })
34 | })
35 |
36 | afterEach(async () => {
37 | await factory.cleanDatabase()
38 | })
39 |
40 | describe('follow', () => {
41 | describe('follow user', () => {
42 | describe('unauthenticated follow', () => {
43 | it('throws authorization error', async () => {
44 | let client
45 | client = new GraphQLClient(host)
46 | await expect(
47 | client.request(mutationFollowUser('u2'))
48 | ).rejects.toThrow('Not Authorised')
49 | })
50 | })
51 |
52 | it('I can follow another user', async () => {
53 | const res = await clientUser1.request(
54 | mutationFollowUser('u2')
55 | )
56 | const expected = {
57 | follow: true
58 | }
59 | expect(res).toMatchObject(expected)
60 |
61 | const { User } = await clientUser1.request(`{
62 | User(id: "u2") {
63 | followedBy { id }
64 | followedByCurrentUser
65 | }
66 | }`)
67 | const expected2 = {
68 | followedBy: [
69 | { id: 'u1' }
70 | ],
71 | followedByCurrentUser: true
72 | }
73 | expect(User[0]).toMatchObject(expected2)
74 | })
75 |
76 | it('I can`t follow myself', async () => {
77 | const res = await clientUser1.request(
78 | mutationFollowUser('u1')
79 | )
80 | const expected = {
81 | follow: false
82 | }
83 | expect(res).toMatchObject(expected)
84 |
85 | const { User } = await clientUser1.request(`{
86 | User(id: "u1") {
87 | followedBy { id }
88 | followedByCurrentUser
89 | }
90 | }`)
91 | const expected2 = {
92 | followedBy: [],
93 | followedByCurrentUser: false
94 | }
95 | expect(User[0]).toMatchObject(expected2)
96 | })
97 | })
98 | describe('unfollow user', () => {
99 | describe('unauthenticated follow', () => {
100 | it('throws authorization error', async () => {
101 | // follow
102 | await clientUser1.request(
103 | mutationFollowUser('u2')
104 | )
105 | // unfollow
106 | let client
107 | client = new GraphQLClient(host)
108 | await expect(
109 | client.request(mutationUnfollowUser('u2'))
110 | ).rejects.toThrow('Not Authorised')
111 | })
112 | })
113 |
114 | it('I can unfollow a user', async () => {
115 | // follow
116 | await clientUser1.request(
117 | mutationFollowUser('u2')
118 | )
119 | // unfollow
120 | const expected = {
121 | unfollow: true
122 | }
123 | const res = await clientUser1.request(mutationUnfollowUser('u2'))
124 | expect(res).toMatchObject(expected)
125 |
126 | const { User } = await clientUser1.request(`{
127 | User(id: "u2") {
128 | followedBy { id }
129 | followedByCurrentUser
130 | }
131 | }`)
132 | const expected2 = {
133 | followedBy: [],
134 | followedByCurrentUser: false
135 | }
136 | expect(User[0]).toMatchObject(expected2)
137 | })
138 | })
139 | })
140 |
--------------------------------------------------------------------------------
/src/resolvers/moderation.js:
--------------------------------------------------------------------------------
1 | export default {
2 | Mutation: {
3 | disable: async (object, params, { user, driver }) => {
4 | const { id } = params
5 | const { id: userId } = user
6 | const cypher = `
7 | MATCH (u:User {id: $userId})
8 | MATCH (resource {id: $id})
9 | WHERE resource:User OR resource:Comment OR resource:Post
10 | SET resource.disabled = true
11 | MERGE (resource)<-[:DISABLED]-(u)
12 | RETURN resource {.id}
13 | `
14 | const session = driver.session()
15 | const res = await session.run(cypher, { id, userId })
16 | session.close()
17 | const [resource] = res.records.map((record) => {
18 | return record.get('resource')
19 | })
20 | if (!resource) return null
21 | return resource.id
22 | },
23 | enable: async (object, params, { user, driver }) => {
24 | const { id } = params
25 | const cypher = `
26 | MATCH (resource {id: $id})<-[d:DISABLED]-()
27 | SET resource.disabled = false
28 | DELETE d
29 | RETURN resource {.id}
30 | `
31 | const session = driver.session()
32 | const res = await session.run(cypher, { id })
33 | session.close()
34 | const [resource] = res.records.map((record) => {
35 | return record.get('resource')
36 | })
37 | if (!resource) return null
38 | return resource.id
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/resolvers/posts.js:
--------------------------------------------------------------------------------
1 | import { neo4jgraphql } from 'neo4j-graphql-js'
2 |
3 | export default {
4 | Mutation: {
5 | CreatePost: async (object, params, context, resolveInfo) => {
6 | const result = await neo4jgraphql(object, params, context, resolveInfo, false)
7 |
8 | const session = context.driver.session()
9 | await session.run(
10 | 'MATCH (author:User {id: $userId}), (post:Post {id: $postId}) ' +
11 | 'MERGE (post)<-[:WROTE]-(author) ' +
12 | 'RETURN author', {
13 | userId: context.user.id,
14 | postId: result.id
15 | }
16 | )
17 | session.close()
18 |
19 | return result
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/resolvers/reports.js:
--------------------------------------------------------------------------------
1 | import uuid from 'uuid/v4'
2 |
3 | export default {
4 | Mutation: {
5 | report: async (parent, { id, description }, { driver, req, user }, resolveInfo) => {
6 | const reportId = uuid()
7 | const session = driver.session()
8 | const reportData = {
9 | id: reportId,
10 | createdAt: (new Date()).toISOString(),
11 | description: description
12 | }
13 |
14 | const res = await session.run(`
15 | MATCH (submitter:User {id: $userId})
16 | MATCH (resource {id: $resourceId})
17 | WHERE resource:User OR resource:Comment OR resource:Post
18 | CREATE (report:Report $reportData)
19 | MERGE (resource)<-[:REPORTED]-(report)
20 | MERGE (report)<-[:REPORTED]-(submitter)
21 | RETURN report, submitter, resource, labels(resource)[0] as type
22 | `, {
23 | resourceId: id,
24 | userId: user.id,
25 | reportData
26 | }
27 | )
28 | session.close()
29 |
30 | const [dbResponse] = res.records.map(r => {
31 | return {
32 | report: r.get('report'),
33 | submitter: r.get('submitter'),
34 | resource: r.get('resource'),
35 | type: r.get('type')
36 | }
37 | })
38 | if (!dbResponse) return null
39 | const { report, submitter, resource, type } = dbResponse
40 |
41 | let response = {
42 | ...report.properties,
43 | post: null,
44 | comment: null,
45 | user: null,
46 | submitter: submitter.properties,
47 | type
48 | }
49 | switch (type) {
50 | case 'Post':
51 | response.post = resource.properties
52 | break
53 | case 'Comment':
54 | response.comment = resource.properties
55 | break
56 | case 'User':
57 | response.user = resource.properties
58 | break
59 | }
60 | return response
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/resolvers/reports.spec.js:
--------------------------------------------------------------------------------
1 | import Factory from '../seed/factories'
2 | import { GraphQLClient } from 'graphql-request'
3 | import { host, login } from '../jest/helpers'
4 |
5 | const factory = Factory()
6 |
7 | describe('report', () => {
8 | let mutation
9 | let headers
10 | let returnedObject
11 | let variables
12 |
13 | beforeEach(async () => {
14 | returnedObject = '{ description }'
15 | variables = { id: 'whatever' }
16 | headers = {}
17 | await factory.create('User', {
18 | id: 'u1',
19 | email: 'test@example.org',
20 | password: '1234'
21 | })
22 | await factory.create('User', {
23 | id: 'u2',
24 | name: 'abusive-user',
25 | role: 'user',
26 | email: 'abusive-user@example.org'
27 | })
28 | })
29 |
30 | afterEach(async () => {
31 | await factory.cleanDatabase()
32 | })
33 |
34 | let client
35 | const action = () => {
36 | mutation = `
37 | mutation($id: ID!) {
38 | report(
39 | id: $id,
40 | description: "Violates code of conduct"
41 | ) ${returnedObject}
42 | }
43 | `
44 | client = new GraphQLClient(host, { headers })
45 | return client.request(mutation, variables)
46 | }
47 |
48 | describe('unauthenticated', () => {
49 | it('throws authorization error', async () => {
50 | await expect(action()).rejects.toThrow('Not Authorised')
51 | })
52 |
53 | describe('authenticated', () => {
54 | beforeEach(async () => {
55 | headers = await login({ email: 'test@example.org', password: '1234' })
56 | })
57 |
58 | describe('invalid resource id', () => {
59 | it('returns null', async () => {
60 | await expect(action()).resolves.toEqual({
61 | report: null
62 | })
63 | })
64 | })
65 |
66 | describe('valid resource id', () => {
67 | beforeEach(async () => {
68 | variables = { id: 'u2' }
69 | })
70 |
71 | it('creates a report', async () => {
72 | await expect(action()).resolves.toEqual({
73 | report: { description: 'Violates code of conduct' }
74 | })
75 | })
76 |
77 | it('returns the submitter', async () => {
78 | returnedObject = '{ submitter { email } }'
79 | await expect(action()).resolves.toEqual({
80 | report: { submitter: { email: 'test@example.org' } }
81 | })
82 | })
83 |
84 | describe('reported resource is a user', () => {
85 | it('returns type "User"', async () => {
86 | returnedObject = '{ type }'
87 | await expect(action()).resolves.toEqual({
88 | report: { type: 'User' }
89 | })
90 | })
91 |
92 | it('returns resource in user attribute', async () => {
93 | returnedObject = '{ user { name } }'
94 | await expect(action()).resolves.toEqual({
95 | report: { user: { name: 'abusive-user' } }
96 | })
97 | })
98 | })
99 |
100 | describe('reported resource is a post', () => {
101 | beforeEach(async () => {
102 | await factory.authenticateAs({ email: 'test@example.org', password: '1234' })
103 | await factory.create('Post', { id: 'p23', title: 'Matt and Robert having a pair-programming' })
104 | variables = { id: 'p23' }
105 | })
106 |
107 | it('returns type "Post"', async () => {
108 | returnedObject = '{ type }'
109 | await expect(action()).resolves.toEqual({
110 | report: { type: 'Post' }
111 | })
112 | })
113 |
114 | it('returns resource in post attribute', async () => {
115 | returnedObject = '{ post { title } }'
116 | await expect(action()).resolves.toEqual({
117 | report: { post: { title: 'Matt and Robert having a pair-programming' } }
118 | })
119 | })
120 |
121 | it('returns null in user attribute', async () => {
122 | returnedObject = '{ user { name } }'
123 | await expect(action()).resolves.toEqual({
124 | report: { user: null }
125 | })
126 | })
127 | })
128 |
129 | describe('reported resource is a comment', () => {
130 | beforeEach(async () => {
131 | await factory.authenticateAs({ email: 'test@example.org', password: '1234' })
132 | await factory.create('Comment', { id: 'c34', content: 'Robert getting tired.' })
133 | variables = { id: 'c34' }
134 | })
135 |
136 | it('returns type "Comment"', async () => {
137 | returnedObject = '{ type }'
138 | await expect(action()).resolves.toEqual({
139 | report: { type: 'Comment' }
140 | })
141 | })
142 |
143 | it('returns resource in comment attribute', async () => {
144 | returnedObject = '{ comment { content } }'
145 | await expect(action()).resolves.toEqual({
146 | report: { comment: { content: 'Robert getting tired.' } }
147 | })
148 | })
149 | })
150 |
151 | describe('reported resource is a tag', () => {
152 | beforeEach(async () => {
153 | await factory.create('Tag', { id: 't23' })
154 | variables = { id: 't23' }
155 | })
156 |
157 | it('returns null', async () => {
158 | await expect(action()).resolves.toEqual({ report: null })
159 | })
160 | })
161 | })
162 | })
163 | })
164 | })
165 |
--------------------------------------------------------------------------------
/src/resolvers/shout.spec.js:
--------------------------------------------------------------------------------
1 | import Factory from '../seed/factories'
2 | import { GraphQLClient } from 'graphql-request'
3 | import { host, login } from '../jest/helpers'
4 |
5 | const factory = Factory()
6 | let clientUser1, clientUser2
7 | let headersUser1, headersUser2
8 |
9 | const mutationShoutPost = (id) => `
10 | mutation {
11 | shout(id: "${id}", type: Post)
12 | }
13 | `
14 | const mutationUnshoutPost = (id) => `
15 | mutation {
16 | unshout(id: "${id}", type: Post)
17 | }
18 | `
19 |
20 | beforeEach(async () => {
21 | await factory.create('User', {
22 | id: 'u1',
23 | email: 'test@example.org',
24 | password: '1234'
25 | })
26 | await factory.create('User', {
27 | id: 'u2',
28 | email: 'test2@example.org',
29 | password: '1234'
30 | })
31 |
32 | headersUser1 = await login({ email: 'test@example.org', password: '1234' })
33 | headersUser2 = await login({ email: 'test2@example.org', password: '1234' })
34 | clientUser1 = new GraphQLClient(host, { headers: headersUser1 })
35 | clientUser2 = new GraphQLClient(host, { headers: headersUser2 })
36 |
37 | await clientUser1.request(`
38 | mutation {
39 | CreatePost(id: "p1", title: "Post Title 1", content: "Some Post Content 1") {
40 | id
41 | title
42 | }
43 | }
44 | `)
45 | await clientUser2.request(`
46 | mutation {
47 | CreatePost(id: "p2", title: "Post Title 2", content: "Some Post Content 2") {
48 | id
49 | title
50 | }
51 | }
52 | `)
53 | })
54 |
55 | afterEach(async () => {
56 | await factory.cleanDatabase()
57 | })
58 |
59 | describe('shout', () => {
60 | describe('shout foreign post', () => {
61 | describe('unauthenticated shout', () => {
62 | it('throws authorization error', async () => {
63 | let client
64 | client = new GraphQLClient(host)
65 | await expect(
66 | client.request(mutationShoutPost('p1'))
67 | ).rejects.toThrow('Not Authorised')
68 | })
69 | })
70 |
71 | it('I shout a post of another user', async () => {
72 | const res = await clientUser1.request(
73 | mutationShoutPost('p2')
74 | )
75 | const expected = {
76 | shout: true
77 | }
78 | expect(res).toMatchObject(expected)
79 |
80 | const { Post } = await clientUser1.request(`{
81 | Post(id: "p2") {
82 | shoutedByCurrentUser
83 | }
84 | }`)
85 | const expected2 = {
86 | shoutedByCurrentUser: true
87 | }
88 | expect(Post[0]).toMatchObject(expected2)
89 | })
90 |
91 | it('I can`t shout my own post', async () => {
92 | const res = await clientUser1.request(
93 | mutationShoutPost('p1')
94 | )
95 | const expected = {
96 | shout: false
97 | }
98 | expect(res).toMatchObject(expected)
99 |
100 | const { Post } = await clientUser1.request(`{
101 | Post(id: "p1") {
102 | shoutedByCurrentUser
103 | }
104 | }`)
105 | const expected2 = {
106 | shoutedByCurrentUser: false
107 | }
108 | expect(Post[0]).toMatchObject(expected2)
109 | })
110 | })
111 |
112 | describe('unshout foreign post', () => {
113 | describe('unauthenticated shout', () => {
114 | it('throws authorization error', async () => {
115 | // shout
116 | await clientUser1.request(
117 | mutationShoutPost('p2')
118 | )
119 | // unshout
120 | let client
121 | client = new GraphQLClient(host)
122 | await expect(
123 | client.request(mutationUnshoutPost('p2'))
124 | ).rejects.toThrow('Not Authorised')
125 | })
126 | })
127 |
128 | it('I unshout a post of another user', async () => {
129 | // shout
130 | await clientUser1.request(
131 | mutationShoutPost('p2')
132 | )
133 | const expected = {
134 | unshout: true
135 | }
136 | // unshout
137 | const res = await clientUser1.request(mutationUnshoutPost('p2'))
138 | expect(res).toMatchObject(expected)
139 |
140 | const { Post } = await clientUser1.request(`{
141 | Post(id: "p2") {
142 | shoutedByCurrentUser
143 | }
144 | }`)
145 | const expected2 = {
146 | shoutedByCurrentUser: false
147 | }
148 | expect(Post[0]).toMatchObject(expected2)
149 | })
150 | })
151 | })
152 |
--------------------------------------------------------------------------------
/src/resolvers/statistics.js:
--------------------------------------------------------------------------------
1 | export const query = (cypher, session) => {
2 | return new Promise((resolve, reject) => {
3 | let data = []
4 | session
5 | .run(cypher)
6 | .subscribe({
7 | onNext: function (record) {
8 | let item = {}
9 | record.keys.forEach(key => {
10 | item[key] = record.get(key)
11 | })
12 | data.push(item)
13 | },
14 | onCompleted: function () {
15 | session.close()
16 | resolve(data)
17 | },
18 | onError: function (error) {
19 | reject(error)
20 | }
21 | })
22 | })
23 | }
24 | const queryOne = (cypher, session) => {
25 | return new Promise((resolve, reject) => {
26 | query(cypher, session)
27 | .then(res => {
28 | resolve(res.length ? res.pop() : {})
29 | })
30 | .catch(err => {
31 | reject(err)
32 | })
33 | })
34 | }
35 |
36 | export default {
37 | Query: {
38 | statistics: async (parent, args, { driver, user }) => {
39 | return new Promise(async (resolve) => {
40 | const session = driver.session()
41 | const queries = {
42 | countUsers: 'MATCH (r:User) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countUsers',
43 | countPosts: 'MATCH (r:Post) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countPosts',
44 | countComments: 'MATCH (r:Comment) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countComments',
45 | countNotifications: 'MATCH (r:Notification) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countNotifications',
46 | countOrganizations: 'MATCH (r:Organization) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countOrganizations',
47 | countProjects: 'MATCH (r:Project) WHERE r.deleted <> true OR NOT exists(r.deleted) RETURN COUNT(r) AS countProjects',
48 | countInvites: 'MATCH (r:Invite) WHERE r.wasUsed <> true OR NOT exists(r.wasUsed) RETURN COUNT(r) AS countInvites',
49 | countFollows: 'MATCH (:User)-[r:FOLLOWS]->(:User) RETURN COUNT(r) AS countFollows',
50 | countShouts: 'MATCH (:User)-[r:SHOUTED]->(:Post) RETURN COUNT(r) AS countShouts'
51 | }
52 | let data = {
53 | countUsers: (await queryOne(queries.countUsers, session)).countUsers.low,
54 | countPosts: (await queryOne(queries.countPosts, session)).countPosts.low,
55 | countComments: (await queryOne(queries.countComments, session)).countComments.low,
56 | countNotifications: (await queryOne(queries.countNotifications, session)).countNotifications.low,
57 | countOrganizations: (await queryOne(queries.countOrganizations, session)).countOrganizations.low,
58 | countProjects: (await queryOne(queries.countProjects, session)).countProjects.low,
59 | countInvites: (await queryOne(queries.countInvites, session)).countInvites.low,
60 | countFollows: (await queryOne(queries.countFollows, session)).countFollows.low,
61 | countShouts: (await queryOne(queries.countShouts, session)).countShouts.low
62 | }
63 | resolve(data)
64 | })
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/resolvers/user_management.js:
--------------------------------------------------------------------------------
1 | import encode from '../jwt/encode'
2 | import bcrypt from 'bcryptjs'
3 | import { AuthenticationError } from 'apollo-server'
4 | import { neo4jgraphql } from 'neo4j-graphql-js'
5 |
6 | export default {
7 | Query: {
8 | isLoggedIn: (parent, args, { driver, user }) => {
9 | return Boolean(user && user.id)
10 | },
11 | currentUser: async (object, params, ctx, resolveInfo) => {
12 | const { user } = ctx
13 | if (!user) return null
14 | return neo4jgraphql(object, { id: user.id }, ctx, resolveInfo, false)
15 | }
16 | },
17 | Mutation: {
18 | signup: async (parent, { email, password }, { req }) => {
19 | // if (data[email]) {
20 | // throw new Error('Another User with same email exists.')
21 | // }
22 | // data[email] = {
23 | // password: await bcrypt.hashSync(password, 10),
24 | // }
25 |
26 | return true
27 | },
28 | login: async (parent, { email, password }, { driver, req, user }) => {
29 | // if (user && user.id) {
30 | // throw new Error('Already logged in.')
31 | // }
32 | const session = driver.session()
33 | const result = await session.run(
34 | 'MATCH (user:User {email: $userEmail}) ' +
35 | 'RETURN user {.id, .slug, .name, .avatar, .email, .password, .role, .disabled} as user LIMIT 1',
36 | {
37 | userEmail: email
38 | }
39 | )
40 |
41 | session.close()
42 | const [currentUser] = await result.records.map(function (record) {
43 | return record.get('user')
44 | })
45 |
46 | if (
47 | currentUser &&
48 | (await bcrypt.compareSync(password, currentUser.password)) &&
49 | !currentUser.disabled
50 | ) {
51 | delete currentUser.password
52 | return encode(currentUser)
53 | } else if (currentUser &&
54 | currentUser.disabled
55 | ) {
56 | throw new AuthenticationError('Your account has been disabled.')
57 | } else {
58 | throw new AuthenticationError('Incorrect email address or password.')
59 | }
60 | },
61 | changePassword: async (
62 | _,
63 | { oldPassword, newPassword },
64 | { driver, user }
65 | ) => {
66 | const session = driver.session()
67 | let result = await session.run(
68 | `MATCH (user:User {email: $userEmail})
69 | RETURN user {.id, .email, .password}`,
70 | {
71 | userEmail: user.email
72 | }
73 | )
74 |
75 | const [currentUser] = result.records.map(function (record) {
76 | return record.get('user')
77 | })
78 |
79 | if (!(await bcrypt.compareSync(oldPassword, currentUser.password))) {
80 | throw new AuthenticationError('Old password is not correct')
81 | }
82 |
83 | if (await bcrypt.compareSync(newPassword, currentUser.password)) {
84 | throw new AuthenticationError(
85 | 'Old password and new password should be different'
86 | )
87 | } else {
88 | const newHashedPassword = await bcrypt.hashSync(newPassword, 10)
89 | session.run(
90 | `MATCH (user:User {email: $userEmail})
91 | SET user.password = $newHashedPassword
92 | RETURN user
93 | `,
94 | {
95 | userEmail: user.email,
96 | newHashedPassword
97 | }
98 | )
99 | session.close()
100 |
101 | return encode(currentUser)
102 | }
103 | }
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/src/seed/factories/badges.js:
--------------------------------------------------------------------------------
1 | import uuid from 'uuid/v4'
2 |
3 | export default function (params) {
4 | const {
5 | id = uuid(),
6 | key,
7 | type = 'crowdfunding',
8 | status = 'permanent',
9 | icon
10 | } = params
11 |
12 | return `
13 | mutation {
14 | CreateBadge(
15 | id: "${id}",
16 | key: "${key}",
17 | type: ${type},
18 | status: ${status},
19 | icon: "${icon}"
20 | ) { id }
21 | }
22 | `
23 | }
24 |
--------------------------------------------------------------------------------
/src/seed/factories/categories.js:
--------------------------------------------------------------------------------
1 | import uuid from 'uuid/v4'
2 |
3 | export default function (params) {
4 | const {
5 | id = uuid(),
6 | name,
7 | slug,
8 | icon
9 | } = params
10 |
11 | return `
12 | mutation {
13 | CreateCategory(
14 | id: "${id}",
15 | name: "${name}",
16 | slug: "${slug}",
17 | icon: "${icon}"
18 | ) { id, name }
19 | }
20 | `
21 | }
22 |
--------------------------------------------------------------------------------
/src/seed/factories/comments.js:
--------------------------------------------------------------------------------
1 | import faker from 'faker'
2 | import uuid from 'uuid/v4'
3 |
4 | export default function (params) {
5 | const {
6 | id = uuid(),
7 | content = [
8 | faker.lorem.sentence(),
9 | faker.lorem.sentence()
10 | ].join('. '),
11 | disabled = false,
12 | deleted = false
13 | } = params
14 |
15 | return `
16 | mutation {
17 | CreateComment(
18 | id: "${id}",
19 | content: "${content}",
20 | disabled: ${disabled},
21 | deleted: ${deleted}
22 | ) { id }
23 | }
24 | `
25 | }
26 |
--------------------------------------------------------------------------------
/src/seed/factories/index.js:
--------------------------------------------------------------------------------
1 | import { GraphQLClient, request } from 'graphql-request'
2 | import { getDriver } from '../../bootstrap/neo4j'
3 | import createBadge from './badges.js'
4 | import createUser from './users.js'
5 | import createOrganization from './organizations.js'
6 | import createPost from './posts.js'
7 | import createComment from './comments.js'
8 | import createCategory from './categories.js'
9 | import createTag from './tags.js'
10 | import createReport from './reports.js'
11 |
12 | export const seedServerHost = 'http://127.0.0.1:4001'
13 |
14 | const authenticatedHeaders = async ({ email, password }, host) => {
15 | const mutation = `
16 | mutation {
17 | login(email:"${email}", password:"${password}")
18 | }`
19 | const response = await request(host, mutation)
20 | return {
21 | authorization: `Bearer ${response.login}`
22 | }
23 | }
24 | const factories = {
25 | Badge: createBadge,
26 | User: createUser,
27 | Organization: createOrganization,
28 | Post: createPost,
29 | Comment: createComment,
30 | Category: createCategory,
31 | Tag: createTag,
32 | Report: createReport
33 | }
34 |
35 | export const cleanDatabase = async (options = {}) => {
36 | const { driver = getDriver() } = options
37 | const session = driver.session()
38 | const cypher = 'MATCH (n) DETACH DELETE n'
39 | try {
40 | return await session.run(cypher)
41 | } catch (error) {
42 | throw error
43 | } finally {
44 | session.close()
45 | }
46 | }
47 |
48 | export default function Factory (options = {}) {
49 | const {
50 | neo4jDriver = getDriver(),
51 | seedServerHost = 'http://127.0.0.1:4001'
52 | } = options
53 |
54 | const graphQLClient = new GraphQLClient(seedServerHost)
55 |
56 | const result = {
57 | neo4jDriver,
58 | seedServerHost,
59 | graphQLClient,
60 | factories,
61 | lastResponse: null,
62 | async authenticateAs ({ email, password }) {
63 | const headers = await authenticatedHeaders(
64 | { email, password },
65 | seedServerHost
66 | )
67 | this.lastResponse = headers
68 | this.graphQLClient = new GraphQLClient(seedServerHost, { headers })
69 | return this
70 | },
71 | async create (node, properties) {
72 | const mutation = this.factories[node](properties)
73 | this.lastResponse = await this.graphQLClient.request(mutation)
74 | return this
75 | },
76 | async relate (node, relationship, properties) {
77 | const { from, to } = properties
78 | const mutation = `
79 | mutation {
80 | Add${node}${relationship}(
81 | from: { id: "${from}" },
82 | to: { id: "${to}" }
83 | ) { from { id } }
84 | }
85 | `
86 | this.lastResponse = await this.graphQLClient.request(mutation)
87 | return this
88 | },
89 | async mutate (mutation, variables) {
90 | this.lastResponse = await this.graphQLClient.request(mutation, variables)
91 | return this
92 | },
93 | async shout (properties) {
94 | const { id, type } = properties
95 | const mutation = `
96 | mutation {
97 | shout(
98 | id: "${id}",
99 | type: ${type}
100 | )
101 | }
102 | `
103 | this.lastResponse = await this.graphQLClient.request(mutation)
104 | return this
105 | },
106 | async follow (properties) {
107 | const { id, type } = properties
108 | const mutation = `
109 | mutation {
110 | follow(
111 | id: "${id}",
112 | type: ${type}
113 | )
114 | }
115 | `
116 | this.lastResponse = await this.graphQLClient.request(mutation)
117 | return this
118 | },
119 | async cleanDatabase () {
120 | this.lastResponse = await cleanDatabase({ driver: this.neo4jDriver })
121 | return this
122 | }
123 | }
124 | result.authenticateAs.bind(result)
125 | result.create.bind(result)
126 | result.relate.bind(result)
127 | result.mutate.bind(result)
128 | result.cleanDatabase.bind(result)
129 | return result
130 | }
131 |
--------------------------------------------------------------------------------
/src/seed/factories/organizations.js:
--------------------------------------------------------------------------------
1 | import faker from 'faker'
2 | import uuid from 'uuid/v4'
3 |
4 | export default function create (params) {
5 | const {
6 | id = uuid(),
7 | name = faker.company.companyName(),
8 | description = faker.company.catchPhrase(),
9 | disabled = false,
10 | deleted = false
11 | } = params
12 |
13 | return `
14 | mutation {
15 | CreateOrganization(
16 | id: "${id}",
17 | name: "${name}",
18 | description: "${description}",
19 | disabled: ${disabled},
20 | deleted: ${deleted}
21 | ) { name }
22 | }
23 | `
24 | }
25 |
--------------------------------------------------------------------------------
/src/seed/factories/posts.js:
--------------------------------------------------------------------------------
1 | import faker from 'faker'
2 | import uuid from 'uuid/v4'
3 |
4 | export default function (params) {
5 | const {
6 | id = uuid(),
7 | title = faker.lorem.sentence(),
8 | content = [
9 | faker.lorem.sentence(),
10 | faker.lorem.sentence(),
11 | faker.lorem.sentence(),
12 | faker.lorem.sentence(),
13 | faker.lorem.sentence()
14 | ].join('. '),
15 | image = faker.image.image(),
16 | visibility = 'public',
17 | deleted = false
18 | } = params
19 |
20 | return `
21 | mutation {
22 | CreatePost(
23 | id: "${id}",
24 | title: "${title}",
25 | content: "${content}",
26 | image: "${image}",
27 | visibility: ${visibility},
28 | deleted: ${deleted}
29 | ) { title, content }
30 | }
31 | `
32 | }
33 |
--------------------------------------------------------------------------------
/src/seed/factories/reports.js:
--------------------------------------------------------------------------------
1 | import faker from 'faker'
2 |
3 | export default function create (params) {
4 | const {
5 | description = faker.lorem.sentence(),
6 | id
7 | } = params
8 |
9 | return `
10 | mutation {
11 | report(
12 | description: "${description}",
13 | id: "${id}",
14 | ) {
15 | id,
16 | createdAt
17 | }
18 | }
19 | `
20 | }
21 |
--------------------------------------------------------------------------------
/src/seed/factories/tags.js:
--------------------------------------------------------------------------------
1 | import uuid from 'uuid/v4'
2 |
3 | export default function (params) {
4 | const {
5 | id = uuid(),
6 | name
7 | } = params
8 |
9 | return `
10 | mutation {
11 | CreateTag(
12 | id: "${id}",
13 | name: "${name}",
14 | ) { name }
15 | }
16 | `
17 | }
18 |
--------------------------------------------------------------------------------
/src/seed/factories/users.js:
--------------------------------------------------------------------------------
1 | import faker from 'faker'
2 | import uuid from 'uuid/v4'
3 |
4 | export default function create (params) {
5 | const {
6 | id = uuid(),
7 | name = faker.name.findName(),
8 | email = faker.internet.email(),
9 | password = '1234',
10 | role = 'user',
11 | avatar = faker.internet.avatar(),
12 | about = faker.lorem.paragraph(),
13 | disabled = false,
14 | deleted = false
15 | } = params
16 |
17 | return `
18 | mutation {
19 | CreateUser(
20 | id: "${id}",
21 | name: "${name}",
22 | password: "${password}",
23 | email: "${email}",
24 | avatar: "${avatar}",
25 | about: "${about}",
26 | role: ${role},
27 | disabled: ${disabled},
28 | deleted: ${deleted}
29 | ) {
30 | id
31 | name
32 | email
33 | avatar
34 | role
35 | deleted
36 | disabled
37 | }
38 | }
39 | `
40 | }
41 |
--------------------------------------------------------------------------------
/src/seed/reset-db.js:
--------------------------------------------------------------------------------
1 | import { cleanDatabase } from './factories'
2 | import dotenv from 'dotenv'
3 |
4 | dotenv.config()
5 |
6 | if (process.env.NODE_ENV === 'production') {
7 | throw new Error(`YOU CAN'T CLEAN THE DATABASE WITH NODE_ENV=${process.env.NODE_ENV}`)
8 | }
9 |
10 | (async function () {
11 | try {
12 | await cleanDatabase()
13 | console.log('Successfully deleted all nodes and relations!')
14 | process.exit(0)
15 | } catch (err) {
16 | console.log(`Error occurred deleting the nodes and relations (reset the db)\n\n${err}`)
17 | process.exit(1)
18 | }
19 | })()
20 |
--------------------------------------------------------------------------------
/src/seed/seed-helpers.js:
--------------------------------------------------------------------------------
1 | const _ = require('lodash')
2 | const faker = require('faker')
3 | const unsplashTopics = [
4 | 'love',
5 | 'family',
6 | 'spring',
7 | 'business',
8 | 'nature',
9 | 'travel',
10 | 'happy',
11 | 'landscape',
12 | 'health',
13 | 'friends',
14 | 'computer',
15 | 'autumn',
16 | 'space',
17 | 'animal',
18 | 'smile',
19 | 'face',
20 | 'people',
21 | 'portrait',
22 | 'amazing'
23 | ]
24 | let unsplashTopicsTmp = []
25 |
26 | const ngoLogos = [
27 | 'http://www.fetchlogos.com/wp-content/uploads/2015/11/Girl-Scouts-Of-The-Usa-Logo.jpg',
28 | 'http://logos.textgiraffe.com/logos/logo-name/Ngo-designstyle-friday-m.png',
29 | 'http://seeklogo.com/images/N/ngo-logo-BD53A3E024-seeklogo.com.png',
30 | 'https://dcassetcdn.com/design_img/10133/25833/25833_303600_10133_image.jpg',
31 | 'https://cdn.tutsplus.com/vector/uploads/legacy/articles/08bad_ngologos/20.jpg',
32 | 'https://cdn.tutsplus.com/vector/uploads/legacy/articles/08bad_ngologos/33.jpg',
33 | null
34 | ]
35 |
36 | const difficulties = ['easy', 'medium', 'hard']
37 |
38 | export default {
39 | randomItem: (items, filter) => {
40 | let ids = filter
41 | ? Object.keys(items)
42 | .filter(id => {
43 | return filter(items[id])
44 | })
45 | : _.keys(items)
46 | let randomIds = _.shuffle(ids)
47 | return items[randomIds.pop()]
48 | },
49 | randomItems: (items, key = 'id', min = 1, max = 1) => {
50 | let randomIds = _.shuffle(_.keys(items))
51 | let res = []
52 |
53 | const count = _.random(min, max)
54 |
55 | for (let i = 0; i < count; i++) {
56 | let r = items[randomIds.pop()][key]
57 | if (key === 'id') {
58 | r = r.toString()
59 | }
60 | res.push(r)
61 | }
62 | return res
63 | },
64 | random: (items) => {
65 | return _.shuffle(items).pop()
66 | },
67 | randomDifficulty: () => {
68 | return _.shuffle(difficulties).pop()
69 | },
70 | randomLogo: () => {
71 | return _.shuffle(ngoLogos).pop()
72 | },
73 | randomUnsplashUrl: () => {
74 | if (Math.random() < 0.6) {
75 | // do not attach images in 60 percent of the cases (faster seeding)
76 | return
77 | }
78 | if (unsplashTopicsTmp.length < 2) {
79 | unsplashTopicsTmp = _.shuffle(unsplashTopics)
80 | }
81 | return 'https://source.unsplash.com/daily?' + unsplashTopicsTmp.pop() + ',' + unsplashTopicsTmp.pop()
82 | },
83 | randomCategories: (seederstore, allowEmpty = false) => {
84 | let count = Math.round(Math.random() * 3)
85 | if (allowEmpty === false && count === 0) {
86 | count = 1
87 | }
88 | let categorieIds = _.shuffle(_.keys(seederstore.categories))
89 | let ids = []
90 | for (let i = 0; i < count; i++) {
91 | ids.push(categorieIds.pop())
92 | }
93 | return ids
94 | },
95 | randomAddresses: () => {
96 | const count = Math.round(Math.random() * 3)
97 | let addresses = []
98 | for (let i = 0; i < count; i++) {
99 | addresses.push({
100 | city: faker.address.city(),
101 | zipCode: faker.address.zipCode(),
102 | street: faker.address.streetAddress(),
103 | country: faker.address.countryCode(),
104 | lat: 54.032726 - (Math.random() * 10),
105 | lng: 6.558838 + (Math.random() * 10)
106 | })
107 | }
108 | return addresses
109 | },
110 | /**
111 | * Get array of ids from the given seederstore items after mapping them by the key in the values
112 | *
113 | * @param items items from the seederstore
114 | * @param values values for which you need the ids
115 | * @param key the field key that is represented in the values (slug, name, etc.)
116 | */
117 | mapIdsByKey: (items, values, key) => {
118 | let res = []
119 | values.forEach(value => {
120 | res.push(_.find(items, [key, value]).id.toString())
121 | })
122 | return res
123 | },
124 | genInviteCode: () => {
125 | const chars = '23456789abcdefghkmnpqrstuvwxyzABCDEFGHJKLMNPRSTUVWXYZ'
126 | let code = ''
127 | for (let i = 0; i < 8; i++) {
128 | const n = _.random(0, chars.length - 1)
129 | code += chars.substr(n, 1)
130 | }
131 | return code
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/src/server.js:
--------------------------------------------------------------------------------
1 | import { GraphQLServer } from 'graphql-yoga'
2 | import { makeAugmentedSchema } from 'neo4j-graphql-js'
3 | import { typeDefs, resolvers } from './graphql-schema'
4 | import express from 'express'
5 | import dotenv from 'dotenv'
6 | import mocks from './mocks'
7 | import middleware from './middleware'
8 | import applyDirectives from './bootstrap/directives'
9 | import applyScalars from './bootstrap/scalars'
10 | import { getDriver } from './bootstrap/neo4j'
11 | import helmet from 'helmet'
12 | import decode from './jwt/decode'
13 |
14 | dotenv.config()
15 | // check env and warn
16 | const requiredEnvVars = ['MAPBOX_TOKEN', 'JWT_SECRET']
17 | requiredEnvVars.forEach(env => {
18 | if (!process.env[env]) {
19 | throw new Error(`ERROR: "${env}" env variable is missing.`)
20 | }
21 | })
22 |
23 | const driver = getDriver()
24 | const debug = process.env.NODE_ENV !== 'production' && process.env.DEBUG === 'true'
25 |
26 | let schema = makeAugmentedSchema({
27 | typeDefs,
28 | resolvers,
29 | config: {
30 | query: {
31 | exclude: ['Statistics', 'LoggedInUser']
32 | },
33 | mutation: {
34 | exclude: ['Statistics', 'LoggedInUser']
35 | },
36 | debug: debug
37 | }
38 | })
39 | schema = applyScalars(applyDirectives(schema))
40 |
41 | const createServer = (options) => {
42 | const defaults = {
43 | context: async ({ request }) => {
44 | const authorizationHeader = request.headers.authorization || ''
45 | const user = await decode(driver, authorizationHeader)
46 | return {
47 | driver,
48 | user,
49 | req: request,
50 | cypherParams: {
51 | currentUserId: user ? user.id : null
52 | }
53 | }
54 | },
55 | schema: schema,
56 | debug: debug,
57 | tracing: debug,
58 | middlewares: middleware(schema),
59 | mocks: (process.env.MOCK === 'true') ? mocks : false
60 | }
61 | const server = new GraphQLServer(Object.assign({}, defaults, options))
62 |
63 | server.express.use(helmet())
64 | server.express.use(express.static('public'))
65 | return server
66 | }
67 |
68 | export default createServer
69 |
--------------------------------------------------------------------------------
/test/features/activity-delete.feature:
--------------------------------------------------------------------------------
1 | Feature: Delete an object
2 | I want to delete objects
3 |
4 | Background:
5 | Given our own server runs at "http://localhost:4123"
6 | And we have the following users in our database:
7 | | Slug |
8 | | bernd-das-brot|
9 | And I send a POST request with the following activity to "/activitypub/users/bernd-das-brot/inbox":
10 | """
11 | {
12 | "@context": "https://www.w3.org/ns/activitystreams",
13 | "id": "https://aronda.org/users/bernd-das-brot/status/lka7dfzkjn2398hsfd",
14 | "type": "Create",
15 | "actor": "https://aronda.org/users/bernd-das-brot",
16 | "object": {
17 | "id": "https://aronda.org/users/bernd-das-brot/status/kljsdfg9843jknsdf234",
18 | "type": "Article",
19 | "published": "2019-02-07T19:37:55.002Z",
20 | "attributedTo": "https://aronda.org/users/bernd-das-brot",
21 | "content": "Hi Max, how are you?",
22 | "to": "https://www.w3.org/ns/activitystreams#Public"
23 | }
24 | }
25 | """
26 |
27 | Scenario: Deleting a post (Article Object)
28 | When I send a POST request with the following activity to "/activitypub/users/bernd-das-brot/inbox":
29 | """
30 | {
31 | "@context": "https://www.w3.org/ns/activitystreams",
32 | "id": "https://localhost:4123/activitypub/users/karl-heinz/status/a4DJ2afdg323v32641vna42lkj685kasd2",
33 | "type": "Delete",
34 | "object": {
35 | "id": "https://aronda.org/activitypub/users/bernd-das-brot/status/kljsdfg9843jknsdf234",
36 | "type": "Article",
37 | "published": "2019-02-07T19:37:55.002Z",
38 | "attributedTo": "https://aronda.org/activitypub/users/bernd-das-brot",
39 | "content": "Hi Max, how are you?",
40 | "to": "https://www.w3.org/ns/activitystreams#Public"
41 | }
42 | }
43 | """
44 | Then I expect the status code to be 200
45 | And the object is removed from the outbox collection of "bernd-das-brot"
46 | """
47 | {
48 | "id": "https://aronda.org/activitypub/users/bernd-das-brot/status/kljsdfg9843jknsdf234",
49 | "type": "Article",
50 | "published": "2019-02-07T19:37:55.002Z",
51 | "attributedTo": "https://aronda.org/activitypub/users/bernd-das-brot",
52 | "content": "Hi Max, how are you?",
53 | "to": "https://www.w3.org/ns/activitystreams#Public"
54 | }
55 | """
56 |
--------------------------------------------------------------------------------
/test/features/activity-follow.feature:
--------------------------------------------------------------------------------
1 | Feature: Follow a user
2 | I want to be able to follow a user on another instance.
3 | Also if I do not want to follow a previous followed user anymore,
4 | I want to undo the follow.
5 |
6 | Background:
7 | Given our own server runs at "http://localhost:4123"
8 | And we have the following users in our database:
9 | | Slug |
10 | | stuart-little |
11 | | tero-vota |
12 |
13 | Scenario: Send a follow to a user inbox and make sure it's added to the right followers collection
14 | When I send a POST request with the following activity to "/activitypub/users/tero-vota/inbox":
15 | """
16 | {
17 | "@context": "https://www.w3.org/ns/activitystreams",
18 | "id": "https://localhost:4123/activitypub/users/stuart-little/status/83J23549sda1k72fsa4567na42312455kad83",
19 | "type": "Follow",
20 | "actor": "http://localhost:4123/activitypub/users/stuart-little",
21 | "object": "http://localhost:4123/activitypub/users/tero-vota"
22 | }
23 | """
24 | Then I expect the status code to be 200
25 | And the follower is added to the followers collection of "tero-vota"
26 | """
27 | http://localhost:4123/activitypub/users/stuart-little
28 | """
29 |
30 | Scenario: Send an undo activity to revert the previous follow activity
31 | When I send a POST request with the following activity to "/activitypub/users/stuart-little/inbox":
32 | """
33 | {
34 | "@context": "https://www.w3.org/ns/activitystreams",
35 | "id": "https://localhost:4123/activitypub/users/tero-vota/status/a4DJ2afdg323v32641vna42lkj685kasd2",
36 | "type": "Undo",
37 | "actor": "http://localhost:4123/activitypub/users/tero-vota",
38 | "object": {
39 | "id": "https://localhost:4123/activitypub/users/stuart-little/status/83J23549sda1k72fsa4567na42312455kad83",
40 | "type": "Follow",
41 | "actor": "http://localhost:4123/activitypub/users/stuart-little",
42 | "object": "http://localhost:4123/activitypub/users/tero-vota"
43 | }
44 | }
45 | """
46 | Then I expect the status code to be 200
47 | And the follower is removed from the followers collection of "tero-vota"
48 | """
49 | http://localhost:4123/activitypub/users/stuart-little
50 | """
51 |
--------------------------------------------------------------------------------
/test/features/activity-like.feature:
--------------------------------------------------------------------------------
1 | Feature: Like an object like an article or note
2 | As a user I want to like others posts
3 | Also if I do not want to follow a previous followed user anymore,
4 | I want to undo the follow.
5 |
6 | Background:
7 | Given our own server runs at "http://localhost:4123"
8 | And we have the following users in our database:
9 | | Slug |
10 | | karl-heinz |
11 | | peter-lustiger |
12 | And I send a POST request with the following activity to "/activitypub/users/bernd-das-brot/inbox":
13 | """
14 | {
15 | "@context": "https://www.w3.org/ns/activitystreams",
16 | "id": "https://localhost:4123/activitypub/users/karl-heinz/status/faslkasa7dasfzkjn2398hsfd",
17 | "type": "Create",
18 | "actor": "https://localhost:4123/activitypub/users/karl-heinz",
19 | "object": {
20 | "id": "https://localhost:4123/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf",
21 | "type": "Article",
22 | "published": "2019-02-07T19:37:55.002Z",
23 | "attributedTo": "https://localhost:4123/activitypub/users/karl-heinz",
24 | "content": "Hi Max, how are you?",
25 | "to": "https://www.w3.org/ns/activitystreams#Public"
26 | }
27 | }
28 | """
29 |
30 | Scenario: Send a like of a person to an users inbox and make sure it's added to the likes collection
31 | When I send a POST request with the following activity to "/activitypub/users/karl-heinz/inbox":
32 | """
33 | {
34 | "@context": "https://www.w3.org/ns/activitystreams",
35 | "id": "https://localhost:4123/activitypub/users/peter-lustiger/status/83J23549sda1k72fsa4567na42312455kad83",
36 | "type": "Like",
37 | "actor": "http://localhost:4123/activitypub/users/peter-lustiger",
38 | "object": "http://localhost:4123/activitypub/users/karl-heinz/status/dkasfljsdfaafg9843jknsdf"
39 | }
40 | """
41 | Then I expect the status code to be 200
42 | And the post with id "dkasfljsdfaafg9843jknsdf" has been liked by "peter-lustiger"
43 |
--------------------------------------------------------------------------------
/test/features/collection.feature:
--------------------------------------------------------------------------------
1 | Feature: Receiving collections
2 | As a member of the Fediverse I want to be able of fetching collections
3 |
4 | Background:
5 | Given our own server runs at "http://localhost:4123"
6 | And we have the following users in our database:
7 | | Slug |
8 | | renate-oberdorfer |
9 |
10 | Scenario: Send a request to the outbox URI of peter-lustig and expect a ordered collection
11 | When I send a GET request to "/activitypub/users/renate-oberdorfer/outbox"
12 | Then I expect the status code to be 200
13 | And I receive the following json:
14 | """
15 | {
16 | "@context": "https://www.w3.org/ns/activitystreams",
17 | "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/outbox",
18 | "summary": "renate-oberdorfers outbox collection",
19 | "type": "OrderedCollection",
20 | "first": "https://localhost:4123/activitypub/users/renate-oberdorfer/outbox?page=true",
21 | "totalItems": 0
22 | }
23 | """
24 |
25 | Scenario: Send a request to the following URI of peter-lustig and expect a ordered collection
26 | When I send a GET request to "/activitypub/users/renate-oberdorfer/following"
27 | Then I expect the status code to be 200
28 | And I receive the following json:
29 | """
30 | {
31 | "@context": "https://www.w3.org/ns/activitystreams",
32 | "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/following",
33 | "summary": "renate-oberdorfers following collection",
34 | "type": "OrderedCollection",
35 | "first": "https://localhost:4123/activitypub/users/renate-oberdorfer/following?page=true",
36 | "totalItems": 0
37 | }
38 | """
39 |
40 | Scenario: Send a request to the followers URI of peter-lustig and expect a ordered collection
41 | When I send a GET request to "/activitypub/users/renate-oberdorfer/followers"
42 | Then I expect the status code to be 200
43 | And I receive the following json:
44 | """
45 | {
46 | "@context": "https://www.w3.org/ns/activitystreams",
47 | "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/followers",
48 | "summary": "renate-oberdorfers followers collection",
49 | "type": "OrderedCollection",
50 | "first": "https://localhost:4123/activitypub/users/renate-oberdorfer/followers?page=true",
51 | "totalItems": 0
52 | }
53 | """
54 |
55 | Scenario: Send a request to the outbox URI of peter-lustig and expect a paginated outbox collection
56 | When I send a GET request to "/activitypub/users/renate-oberdorfer/outbox?page=true"
57 | Then I expect the status code to be 200
58 | And I receive the following json:
59 | """
60 | {
61 | "@context": "https://www.w3.org/ns/activitystreams",
62 | "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/outbox?page=true",
63 | "summary": "renate-oberdorfers outbox collection",
64 | "type": "OrderedCollectionPage",
65 | "totalItems": 0,
66 | "partOf": "https://localhost:4123/activitypub/users/renate-oberdorfer/outbox",
67 | "orderedItems": []
68 | }
69 | """
70 |
71 | Scenario: Send a request to the following URI of peter-lustig and expect a paginated following collection
72 | When I send a GET request to "/activitypub/users/renate-oberdorfer/following?page=true"
73 | Then I expect the status code to be 200
74 | And I receive the following json:
75 | """
76 | {
77 | "@context": "https://www.w3.org/ns/activitystreams",
78 | "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/following?page=true",
79 | "summary": "renate-oberdorfers following collection",
80 | "type": "OrderedCollectionPage",
81 | "totalItems": 0,
82 | "partOf": "https://localhost:4123/activitypub/users/renate-oberdorfer/following",
83 | "orderedItems": []
84 | }
85 | """
86 |
87 | Scenario: Send a request to the followers URI of peter-lustig and expect a paginated followers collection
88 | When I send a GET request to "/activitypub/users/renate-oberdorfer/followers?page=true"
89 | Then I expect the status code to be 200
90 | And I receive the following json:
91 | """
92 | {
93 | "@context": "https://www.w3.org/ns/activitystreams",
94 | "id": "https://localhost:4123/activitypub/users/renate-oberdorfer/followers?page=true",
95 | "summary": "renate-oberdorfers followers collection",
96 | "type": "OrderedCollectionPage",
97 | "totalItems": 0,
98 | "partOf": "https://localhost:4123/activitypub/users/renate-oberdorfer/followers",
99 | "orderedItems": []
100 | }
101 | """
102 |
--------------------------------------------------------------------------------
/test/features/object-article.feature:
--------------------------------------------------------------------------------
1 | Feature: Send and receive Articles
2 | I want to send and receive article's via ActivityPub
3 |
4 | Background:
5 | Given our own server runs at "http://localhost:4123"
6 | And we have the following users in our database:
7 | | Slug |
8 | | marvin |
9 | | max |
10 |
11 | Scenario: Send an article to a user inbox and make sure it's added to the inbox
12 | When I send a POST request with the following activity to "/activitypub/users/max/inbox":
13 | """
14 | {
15 | "@context": "https://www.w3.org/ns/activitystreams",
16 | "id": "https://aronda.org/users/marvin/status/lka7dfzkjn2398hsfd",
17 | "type": "Create",
18 | "actor": "https://aronda.org/users/marvin",
19 | "object": {
20 | "id": "https://aronda.org/users/marvin/status/kljsdfg9843jknsdf",
21 | "type": "Article",
22 | "published": "2019-02-07T19:37:55.002Z",
23 | "attributedTo": "https://aronda.org/users/marvin",
24 | "content": "Hi Max, how are you?",
25 | "to": "as:Public"
26 | }
27 | }
28 | """
29 | Then I expect the status code to be 200
30 | And the post with id "kljsdfg9843jknsdf" to be created
31 |
--------------------------------------------------------------------------------
/test/features/webfinger.feature:
--------------------------------------------------------------------------------
1 | Feature: Webfinger discovery
2 | From an external server, e.g. Mastodon
3 | I want to search for an actor alias
4 | In order to follow the actor
5 |
6 | Background:
7 | Given our own server runs at "http://localhost:4100"
8 | And we have the following users in our database:
9 | | Slug |
10 | | peter-lustiger |
11 |
12 | Scenario: Search
13 | When I send a GET request to "/.well-known/webfinger?resource=acct:peter-lustiger@localhost"
14 | Then I receive the following json:
15 | """
16 | {
17 | "subject": "acct:peter-lustiger@localhost:4123",
18 | "links": [
19 | {
20 | "rel": "self",
21 | "type": "application/activity+json",
22 | "href": "https://localhost:4123/users/peter-lustiger"
23 | }
24 | ]
25 | }
26 | """
27 | And I expect the Content-Type to be "application/jrd+json; charset=utf-8"
28 |
29 | Scenario: User does not exist
30 | When I send a GET request to "/.well-known/webfinger?resource=acct:nonexisting@localhost"
31 | Then I receive the following json:
32 | """
33 | {
34 | "error": "No record found for nonexisting@localhost."
35 | }
36 | """
37 |
38 | Scenario: Receiving an actor object
39 | When I send a GET request to "/activitypub/users/peter-lustiger"
40 | Then I receive the following json:
41 | """
42 | {
43 | "@context": [
44 | "https://www.w3.org/ns/activitystreams",
45 | "https://w3id.org/security/v1"
46 | ],
47 | "id": "https://localhost:4123/activitypub/users/peter-lustiger",
48 | "type": "Person",
49 | "preferredUsername": "peter-lustiger",
50 | "name": "peter-lustiger",
51 | "following": "https://localhost:4123/activitypub/users/peter-lustiger/following",
52 | "followers": "https://localhost:4123/activitypub/users/peter-lustiger/followers",
53 | "inbox": "https://localhost:4123/activitypub/users/peter-lustiger/inbox",
54 | "outbox": "https://localhost:4123/activitypub/users/peter-lustiger/outbox",
55 | "url": "https://localhost:4123/activitypub/@peter-lustiger",
56 | "endpoints": {
57 | "sharedInbox": "https://localhost:4123/activitypub/inbox"
58 | },
59 | "publicKey": {
60 | "id": "https://localhost:4123/activitypub/users/peter-lustiger#main-key",
61 | "owner": "https://localhost:4123/activitypub/users/peter-lustiger",
62 | "publicKeyPem": "adglkjlk89235kjn8obn2384f89z5bv9..."
63 | }
64 | }
65 | """
66 |
--------------------------------------------------------------------------------
/test/features/world.js:
--------------------------------------------------------------------------------
1 | // features/support/world.js
2 | import { setWorldConstructor } from 'cucumber'
3 | import request from 'request'
4 | const debug = require('debug')('ea:test:world')
5 |
6 | class CustomWorld {
7 | constructor () {
8 | // webFinger.feature
9 | this.lastResponses = []
10 | this.lastContentType = null
11 | this.lastInboxUrl = null
12 | this.lastActivity = null
13 | // object-article.feature
14 | this.statusCode = null
15 | }
16 | get (pathname) {
17 | return new Promise((resolve, reject) => {
18 | request(`http://localhost:4123/${this.replaceSlashes(pathname)}`, {
19 | headers: {
20 | 'Accept': 'application/activity+json'
21 | }}, function (error, response, body) {
22 | if (!error) {
23 | debug(`get content-type = ${response.headers['content-type']}`)
24 | debug(`get body = ${JSON.stringify(typeof body === 'string' ? JSON.parse(body) : body, null, 2)}`)
25 | resolve({ lastResponse: body, lastContentType: response.headers['content-type'], statusCode: response.statusCode })
26 | } else {
27 | reject(error)
28 | }
29 | })
30 | })
31 | }
32 |
33 | replaceSlashes (pathname) {
34 | return pathname.replace(/^\/+/, '')
35 | }
36 |
37 | post (pathname, activity) {
38 | return new Promise((resolve, reject) => {
39 | request({
40 | url: `http://localhost:4123/${this.replaceSlashes(pathname)}`,
41 | method: 'POST',
42 | headers: {
43 | 'Content-Type': 'application/activity+json'
44 | },
45 | body: activity
46 | }, function (error, response, body) {
47 | if (!error) {
48 | debug(`post response = ${response.headers['content-type']}`)
49 | resolve({ lastResponse: body, lastContentType: response.headers['content-type'], statusCode: response.statusCode })
50 | } else {
51 | reject(error)
52 | }
53 | })
54 | })
55 | }
56 | }
57 |
58 | setWorldConstructor(CustomWorld)
59 |
--------------------------------------------------------------------------------