├── .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 | /(]*)(>)[^>]*\/*>/gim, 105 | '' 106 | ) 107 | .replace(/[\n]{3,}/gim, '\n\n') 108 | .replace(/(\r\n|\n\r|\r|\n)/g, '
$1') 109 | 110 | // replace all p tags with line breaks (and spaces) only by single linebreaks 111 | // limit linebreaks to max 2 (equivalent to html "br" linebreak) 112 | .replace(/(
\s*){2,}/gim, '
') 113 | // remove additional linebreaks after p tags 114 | .replace( 115 | /<\/(p|div|th|tr)>\s*(
\s*)+\s*<(p|div|th|tr)>/gim, 116 | '

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