diff --git a/backend/.babelrc b/backend/.babelrc new file mode 100644 index 000000000..f36dbeadb --- /dev/null +++ b/backend/.babelrc @@ -0,0 +1,15 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "10" + } + } + ] + ], + "plugins": [ + "@babel/plugin-proposal-throw-expressions" + ] +} diff --git a/backend/.codecov.yml b/backend/.codecov.yml new file mode 100644 index 000000000..97bec0084 --- /dev/null +++ b/backend/.codecov.yml @@ -0,0 +1,2 @@ +coverage: + range: "60...100" diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 000000000..31f5b28f3 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,22 @@ +.vscode/ +.nyc_output/ +.github/ +.travis.yml +.graphqlconfig +.env + +Dockerfile +docker-compose*.yml + +./*.png +./*.log + +node_modules/ +scripts/ +dist/ + +db-migration-worker/ +neo4j/ + +public/uploads/* +!.gitkeep diff --git a/backend/.env.template b/backend/.env.template new file mode 100644 index 000000000..abc62b2dc --- /dev/null +++ b/backend/.env.template @@ -0,0 +1,12 @@ +NEO4J_URI=bolt://localhost:7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=letmein +GRAPHQL_PORT=4000 +GRAPHQL_URI=http://localhost:4000 +CLIENT_URI=http://localhost:3000 +MOCK=false + +JWT_SECRET="b/&&7b78BF&fv/Vd" +MAPBOX_TOKEN="pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ" + +PRIVATE_KEY_PASSPHRASE="a7dsf78sadg87ad87sfagsadg78" diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js new file mode 100644 index 000000000..0fdbfd52d --- /dev/null +++ b/backend/.eslintrc.js @@ -0,0 +1,20 @@ +module.exports = { + "extends": "standard", + "parser": "babel-eslint", + "env": { + "es6": true, + "node": true, + "jest/globals": true + }, + "rules": { + "indent": [ + "error", + 2 + ], + "quotes": [ + "error", + "single" + ] + }, + "plugins": ["jest"] +}; diff --git a/backend/.github/ISSUE_TEMPLATE/bug_report.md b/backend/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..87e0ad52e --- /dev/null +++ b/backend/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,20 @@ +--- +name: Bug report +about: Create a report to help us improve + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Authenticate '...' +2. Post following data to endpoint '...' +3. See error + +**Expected behavior** +A clear and concise description of what you expected to happen + +**Additional context** +Add any other context about the problem here. diff --git a/backend/.github/ISSUE_TEMPLATE/feature_request.md b/backend/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..066b2d920 --- /dev/null +++ b/backend/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 000000000..81a29c8e6 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +.env +.vscode +.idea +yarn-error.log +dist/* +coverage.lcov +.nyc_output/ +public/uploads/* +!.gitkeep + +# Apple macOS folder attribute file +.DS_Store \ No newline at end of file diff --git a/backend/.graphqlconfig b/backend/.graphqlconfig new file mode 100644 index 000000000..ca328bc83 --- /dev/null +++ b/backend/.graphqlconfig @@ -0,0 +1,3 @@ +{ + "schemaPath": "./src/schema.graphql" +} diff --git a/backend/.travis.yml b/backend/.travis.yml new file mode 100644 index 000000000..3110f31af --- /dev/null +++ b/backend/.travis.yml @@ -0,0 +1,64 @@ +language: node_js +node_js: + - "10" +cache: + yarn: true + directories: + - node_modules +services: + - docker + +env: + - DOCKER_COMPOSE_VERSION=1.23.2 + +before_install: + - sudo rm /usr/local/bin/docker-compose + - curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose + - chmod +x docker-compose + - sudo mv docker-compose /usr/local/bin + - yarn global add wait-on + +install: + - docker build --build-arg BUILD_COMMIT=$TRAVIS_COMMIT --target production -t humanconnection/nitro-backend:latest . + - docker-compose -f docker-compose.yml -f docker-compose.travis.yml up -d + - wait-on http://localhost:7474 && docker-compose exec neo4j migrate + +script: + - docker-compose exec backend yarn run lint + - docker-compose exec backend yarn run test --ci + - docker-compose exec backend yarn run db:reset + - docker-compose exec backend yarn run db:seed + - docker-compose exec backend yarn run test:cucumber + - docker-compose exec backend yarn run test:coverage + - docker-compose exec backend yarn run db:reset + - docker-compose exec backend yarn run db:seed + +after_success: + - wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh + - chmod +x send.sh + - ./send.sh success $WEBHOOK_URL + +after_failure: + - wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh + - chmod +x send.sh + - ./send.sh failure $WEBHOOK_URL + +deploy: + - provider: script + script: scripts/docker_push.sh + on: + branch: master + - provider: script + script: scripts/deploy.sh nitro.human-connection.org + on: + branch: master + tags: true + - provider: script + script: scripts/deploy.sh nitro-staging.human-connection.org + on: + branch: master + - provider: script + script: scripts/deploy.sh "nitro-$TRAVIS_COMMIT.human-connection.org" + on: + tags: true + all_branches: true diff --git a/backend/CODE_OF_CONDUCT.md b/backend/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..19f3854c1 --- /dev/null +++ b/backend/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +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. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +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. + +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. + +## Scope + +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. + +## Enforcement + +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. + +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. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 000000000..750d284dc --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,24 @@ +FROM node:10-alpine as base +LABEL Description="Backend of the Social Network Human-Connection.org" Vendor="Human Connection gGmbH" Version="0.0.1" Maintainer="Human Connection gGmbH (developer@human-connection.org)" + +EXPOSE 4000 +ARG BUILD_COMMIT +ENV BUILD_COMMIT=$BUILD_COMMIT +ARG WORKDIR=/nitro-backend +RUN mkdir -p $WORKDIR +WORKDIR $WORKDIR +COPY package.json yarn.lock ./ +COPY .env.template .env +CMD ["yarn", "run", "start"] + +FROM base as builder +RUN yarn install --frozen-lockfile --non-interactive +COPY . . +RUN cp .env.template .env +RUN yarn run build + +# reduce image size with a multistage build +FROM base as production +ENV NODE_ENV=production +COPY --from=builder /nitro-backend/dist ./dist +RUN yarn install --frozen-lockfile --non-interactive diff --git a/backend/LICENSE.md b/backend/LICENSE.md new file mode 100644 index 000000000..9d4508b38 --- /dev/null +++ b/backend/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Human-Connection gGmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 000000000..7883828d6 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,178 @@ +
+ +# NITRO Backend +[](https://travis-ci.com/Human-Connection/Nitro-Backend) +[](https://github.com/Human-Connection/Nitro-Backend/blob/backend/LICENSE.md) +[](https://app.fossa.io/projects/git%2Bgithub.com%2FHuman-Connection%2FNitro-Backend?ref=badge_shield) +[](https://discord.gg/6ub73U3) + +> This Prototype tries to resolve the biggest hurdle of connecting +> our services together. This is not possible in a sane way using +> our current approach. +> +> With this Prototype we can explore using the combination of +> GraphQL and the Neo4j Graph Database for achieving the connected +> nature of a social graph with better development experience as we +> do not need to connect data by our own any more through weird table +> structures etc. + +> +> #### Advantages: +> - easer data structure +> - better connected data +> - easy to achieve "recommendations" based on actions (relations) +> - more performant and better to understand API +> - better API client that uses caching +> +> We still need to evaluate the drawbacks and estimate the development +> cost of such an approach + +## How to get in touch +Connect with other developers over [Discord](https://discord.gg/6ub73U3) + +## Quick Start + +### Forking the repository +Before you start, fork the repository using the fork button above, then clone it to your local machine using `git clone https://github.com/your-username/Nitro-Backend.git` + +### Installation with Docker + +Run: +```sh +docker-compose up + +# create indices etc. +docker-compose exec neo4j migrate + +# if you want seed data +# open another terminal and run +docker-compose exec backend yarn run db:seed +``` + +App is [running on port 4000](http://localhost:4000/) + +To wipe out your neo4j database run: +```sh +docker-compose down -v +``` + + +### Installation without Docker + +Install dependencies: + +```bash +yarn install +# -or- +npm install +``` + +Copy: +``` +cp .env.template .env +``` +Configure the file `.env` according to your needs and your local setup. + +Start the GraphQL service: + +```bash +yarn dev +# -or- +npm dev +``` + +And on the production machine run following: + +```bash +yarn start +# -or- +npm start +``` + +This will start the GraphQL service (by default on localhost:4000) +where you can issue GraphQL requests or access GraphQL Playground in the browser: + + + +## Configure + +Set your Neo4j connection string and credentials in `.env`. +For example: + +_.env_ + +```yaml +NEO4J_URI=bolt://localhost:7687 +NEO4J_USERNAME=neo4j +NEO4J_PASSWORD=letmein +``` + +> You need to install APOC as a plugin for the graph you create in the neo4j desktop app! + +Note that grand-stack-starter does not currently bundle a distribution +of Neo4j. You can download [Neo4j Desktop](https://neo4j.com/download/) +and run locally for development, spin up a [hosted Neo4j Sandbox instance](https://neo4j.com/download/), +run Neo4j in one of the [many cloud options](https://neo4j.com/developer/guide-cloud-deployment/), +[spin up Neo4j in a Docker container](https://neo4j.com/developer/docker/) or on Debian-based systems install [Neo4j from the Debian Repository](http://debian.neo4j.org/). +Just be sure to update the Neo4j connection string and credentials accordingly in `.env`. + +## Mock API Results + +Alternatively you can just mock all responses from the api which let +you build a frontend application without running a neo4j instance. + +Just set `MOCK=true` inside `.env` or pass it on application start. + +## Seed and Reset the Database + +Optionally you can seed the GraphQL service by executing mutations that +will write sample data to the database: + +```bash +yarn run db:seed +# -or- +npm run db:seed +``` + +For a reset you can use the reset script: + +```bash +yarn db:reset +# -or- +npm run db:reset +``` + +## Run Tests + +**Beware**: We have no multiple database setup at the moment. We clean the database after each test, running the tests will wipe out all your data! + +Run the **_jest_** tests: +```bash +yarn run test +# -or- +npm run test +``` +Run the **_cucumber_** features: +```bash +yarn run test:cucumber +# -or- +npm run test:cucumber +``` + +When some tests fail, try `yarn db:reset` and after that `yarn db:seed`. Then run the tests again +## Todo`s + +- [x] add jwt authentication +- [ ] get directives working correctly (@toLower, @auth, @role, etc.) +- [x] check if search is working +- [x] check if sorting is working +- [x] check if pagination is working +- [ ] check if upload is working (using graphql-yoga?) +- [x] evaluate middleware +- [ ] ignore Posts and Comments by blacklisted Users + + +## License +[](https://app.fossa.io/projects/git%2Bgithub.com%2FHuman-Connection%2FNitro-Backend?ref=badge_large) diff --git a/backend/db-migration-worker/.dockerignore b/backend/db-migration-worker/.dockerignore new file mode 100644 index 000000000..59ba63a8b --- /dev/null +++ b/backend/db-migration-worker/.dockerignore @@ -0,0 +1 @@ +.ssh/ diff --git a/backend/db-migration-worker/.gitignore b/backend/db-migration-worker/.gitignore new file mode 100644 index 000000000..59ba63a8b --- /dev/null +++ b/backend/db-migration-worker/.gitignore @@ -0,0 +1 @@ +.ssh/ diff --git a/backend/db-migration-worker/.ssh/known_hosts b/backend/db-migration-worker/.ssh/known_hosts new file mode 100644 index 000000000..947840cb2 --- /dev/null +++ b/backend/db-migration-worker/.ssh/known_hosts @@ -0,0 +1,3 @@ +|1|GuOYlVEhTowidPs18zj9p5F2j3o=|sDHJYLz9Ftv11oXeGEjs7SpVyg0= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBM5N29bI5CeKu1/RBPyM2fwyf7fuajOO+tyhKe1+CC2sZ1XNB5Ff6t6MtCLNRv2mUuvzTbW/HkisDiA5tuXUHOk= +|1|2KP9NV+Q5g2MrtjAeFSVcs8YeOI=|nf3h4wWVwC4xbBS1kzgzE2tBldk= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNhRK6BeIEUxXlS0z/pOfkUkSPfn33g4J1U3L+MyUQYHm+7agT08799ANJhnvELKE1tt4Vx80I9UR81oxzZcy3E= +|1|HonYIRNhKyroUHPKU1HSZw0+Qzs=|5T1btfwFBz2vNSldhqAIfTbfIgQ= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNhRK6BeIEUxXlS0z/pOfkUkSPfn33g4J1U3L+MyUQYHm+7agT08799ANJhnvELKE1tt4Vx80I9UR81oxzZcy3E= diff --git a/backend/db-migration-worker/Dockerfile b/backend/db-migration-worker/Dockerfile new file mode 100644 index 000000000..865a4c330 --- /dev/null +++ b/backend/db-migration-worker/Dockerfile @@ -0,0 +1,13 @@ +FROM mongo:4 + +RUN apt-get update && apt-get -y install --no-install-recommends wget apt-transport-https \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* +RUN wget -O - https://debian.neo4j.org/neotechnology.gpg.key | apt-key add - +RUN echo 'deb https://debian.neo4j.org/repo stable/' | tee /etc/apt/sources.list.d/neo4j.list +RUN apt-get update && apt-get -y install --no-install-recommends openjdk-8-jre openssh-client neo4j rsync \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* +COPY migration ./migration +COPY migrate.sh /usr/local/bin/migrate +COPY sync_uploads.sh /usr/local/bin/sync_uploads diff --git a/backend/db-migration-worker/migrate.sh b/backend/db-migration-worker/migrate.sh new file mode 100755 index 000000000..214da53d8 --- /dev/null +++ b/backend/db-migration-worker/migrate.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -e +for var in "SSH_USERNAME" "SSH_HOST" "MONGODB_USERNAME" "MONGODB_PASSWORD" "MONGODB_DATABASE" "MONGODB_AUTH_DB" "NEO4J_URI" +do + if [[ -z "${!var}" ]]; then + echo "${var} is undefined" + exit 1 + fi +done + +/migration/mongo/import.sh +/migration/neo4j/import.sh diff --git a/backend/db-migration-worker/migration/mongo/import.sh b/backend/db-migration-worker/migration/mongo/import.sh new file mode 100755 index 000000000..7cf3e91e4 --- /dev/null +++ b/backend/db-migration-worker/migration/mongo/import.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -e + +echo "SSH_USERNAME ${SSH_USERNAME}" +echo "SSH_HOST ${SSH_HOST}" +echo "MONGODB_USERNAME ${MONGODB_USERNAME}" +echo "MONGODB_PASSWORD ${MONGODB_PASSWORD}" +echo "MONGODB_DATABASE ${MONGODB_DATABASE}" +echo "MONGODB_AUTH_DB ${MONGODB_AUTH_DB}" +echo "-------------------------------------------------" + +mongo ${MONGODB_DATABASE} --eval "db.dropDatabase();" +rm -rf /mongo-export/* + +ssh -4 -M -S my-ctrl-socket -fnNT -L 27018:localhost:27017 -l ${SSH_USERNAME} ${SSH_HOST} +mongodump --host localhost -d ${MONGODB_DATABASE} --port 27018 --username ${MONGODB_USERNAME} --password ${MONGODB_PASSWORD} --authenticationDatabase ${MONGODB_AUTH_DB} --gzip --archive=/tmp/mongodump.archive +mongorestore --gzip --archive=/tmp/mongodump.archive +ssh -S my-ctrl-socket -O check -l ${SSH_USERNAME} ${SSH_HOST} +ssh -S my-ctrl-socket -O exit -l ${SSH_USERNAME} ${SSH_HOST} + +for collection in "categories" "badges" "users" "contributions" "comments" "follows" "shouts" +do + mongoexport --db ${MONGODB_DATABASE} --collection $collection --out "/mongo-export/$collection.json" +done diff --git a/backend/db-migration-worker/migration/neo4j/badges.cql b/backend/db-migration-worker/migration/neo4j/badges.cql new file mode 100644 index 000000000..90e4755b4 --- /dev/null +++ b/backend/db-migration-worker/migration/neo4j/badges.cql @@ -0,0 +1,10 @@ +CALL apoc.load.json('file:/mongo-export/badges.json') YIELD value as badge +MERGE(b:Badge {id: badge._id["$oid"]}) +ON CREATE SET +b.key = badge.key, +b.type = badge.type, +b.icon = badge.image.path, +b.status = badge.status, +b.createdAt = badge.createdAt.`$date`, +b.updatedAt = badge.updatedAt.`$date` +; diff --git a/backend/db-migration-worker/migration/neo4j/categories.cql b/backend/db-migration-worker/migration/neo4j/categories.cql new file mode 100644 index 000000000..a2bf6a352 --- /dev/null +++ b/backend/db-migration-worker/migration/neo4j/categories.cql @@ -0,0 +1,89 @@ +CALL apoc.load.json('file:/mongo-export/categories.json') YIELD value as category +MERGE(c:Category {id: category._id["$oid"]}) +ON CREATE SET +c.name = category.title, +c.slug = category.slug, +c.icon = category.icon, +c.createdAt = category.createdAt.`$date`, +c.updatedAt = category.updatedAt.`$date` +; + +MATCH (c:Category) +WHERE (c.icon = "categories-justforfun") +SET c.icon = 'smile' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-luck") +SET c.icon = 'heart-o' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-health") +SET c.icon = 'medkit' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-environment") +SET c.icon = 'tree' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-animal-justice") +SET c.icon = 'paw' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-human-rights") +SET c.icon = 'balance-scale' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-education") +SET c.icon = 'graduation-cap' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-cooperation") +SET c.icon = 'users' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-politics") +SET c.icon = 'university' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-economy") +SET c.icon = 'money' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-technology") +SET c.icon = 'flash' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-internet") +SET c.icon = 'mouse-pointer' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-art") +SET c.icon = 'paint-brush' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-freedom-of-speech") +SET c.icon = 'bullhorn' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-sustainability") +SET c.icon = 'shopping-cart' +; + +MATCH (c:Category) +WHERE (c.icon = "categories-peace") +SET c.icon = 'angellist' +; diff --git a/backend/db-migration-worker/migration/neo4j/comments.cql b/backend/db-migration-worker/migration/neo4j/comments.cql new file mode 100644 index 000000000..6709acbc8 --- /dev/null +++ b/backend/db-migration-worker/migration/neo4j/comments.cql @@ -0,0 +1,14 @@ +CALL apoc.load.json('file:/mongo-export/comments.json') YIELD value as json +MERGE (comment:Comment {id: json._id["$oid"]}) +ON CREATE SET +comment.content = json.content, +comment.contentExcerpt = json.contentExcerpt, +comment.deleted = json.deleted, +comment.disabled = false +WITH comment, json, json.contributionId as postId +MATCH (post:Post {id: postId}) +WITH comment, post, json.userId as userId +MATCH (author:User {id: userId}) +MERGE (comment)-[:COMMENTS]->(post) +MERGE (author)-[:WROTE]->(comment) +; diff --git a/backend/db-migration-worker/migration/neo4j/contributions.cql b/backend/db-migration-worker/migration/neo4j/contributions.cql new file mode 100644 index 000000000..0c7b18959 --- /dev/null +++ b/backend/db-migration-worker/migration/neo4j/contributions.cql @@ -0,0 +1,25 @@ +CALL apoc.load.json('file:/mongo-export/contributions.json') YIELD value as post +MERGE (p:Post {id: post._id["$oid"]}) +ON CREATE SET +p.title = post.title, +p.slug = post.slug, +p.image = post.teaserImg, +p.content = post.content, +p.contentExcerpt = post.contentExcerpt, +p.visibility = toLower(post.visibility), +p.createdAt = post.createdAt.`$date`, +p.updatedAt = post.updatedAt.`$date`, +p.deleted = post.deleted, +p.disabled = NOT post.isEnabled +WITH p, post +MATCH (u:User {id: post.userId}) +MERGE (u)-[:WROTE]->(p) +WITH p, post, post.categoryIds as categoryIds +UNWIND categoryIds AS categoryId +MATCH (c:Category {id: categoryId}) +MERGE (p)-[:CATEGORIZED]->(c) +WITH p, post.tags AS tags +UNWIND tags AS tag +MERGE (t:Tag {id: apoc.create.uuid(), name: tag}) +MERGE (p)-[:TAGGED]->(t) +; diff --git a/backend/db-migration-worker/migration/neo4j/follows.cql b/backend/db-migration-worker/migration/neo4j/follows.cql new file mode 100644 index 000000000..0dad6a435 --- /dev/null +++ b/backend/db-migration-worker/migration/neo4j/follows.cql @@ -0,0 +1,4 @@ +CALL apoc.load.json('file:/mongo-export/follows.json') YIELD value as follow +MATCH (u1:User {id: follow.userId}), (u2:User {id: follow.foreignId}) +MERGE (u1)-[:FOLLOWS]->(u2) +; diff --git a/backend/db-migration-worker/migration/neo4j/import.sh b/backend/db-migration-worker/migration/neo4j/import.sh new file mode 100755 index 000000000..6f539c501 --- /dev/null +++ b/backend/db-migration-worker/migration/neo4j/import.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -e + +SCRIPT_DIRECTORY="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +echo "MATCH (n) OPTIONAL MATCH (n)-[r]-() DELETE n,r;" | cypher-shell -a $NEO4J_URI +for collection in "badges" "categories" "users" "follows" "contributions" "shouts" "comments" +do + echo "Import ${collection}..." && cypher-shell -a $NEO4J_URI < $SCRIPT_DIRECTORY/$collection.cql +done diff --git a/backend/db-migration-worker/migration/neo4j/shouts.cql b/backend/db-migration-worker/migration/neo4j/shouts.cql new file mode 100644 index 000000000..60aca50c9 --- /dev/null +++ b/backend/db-migration-worker/migration/neo4j/shouts.cql @@ -0,0 +1,4 @@ +CALL apoc.load.json('file:/mongo-export/shouts.json') YIELD value as shout +MATCH (u:User {id: shout.userId}), (p:Post {id: shout.foreignId}) +MERGE (u)-[:SHOUTED]->(p) +; diff --git a/backend/db-migration-worker/migration/neo4j/users.cql b/backend/db-migration-worker/migration/neo4j/users.cql new file mode 100644 index 000000000..5f87bb273 --- /dev/null +++ b/backend/db-migration-worker/migration/neo4j/users.cql @@ -0,0 +1,20 @@ +CALL apoc.load.json('file:/mongo-export/users.json') YIELD value as user +MERGE(u:User {id: user._id["$oid"]}) +ON CREATE SET +u.name = user.name, +u.slug = user.slug, +u.email = user.email, +u.password = user.password, +u.avatar = user.avatar, +u.coverImg = user.coverImg, +u.wasInvited = user.wasInvited, +u.role = toLower(user.role), +u.createdAt = user.createdAt.`$date`, +u.updatedAt = user.updatedAt.`$date`, +u.deleted = user.deletedAt IS NOT NULL, +u.disabled = false +WITH u, user, user.badgeIds AS badgeIds +UNWIND badgeIds AS badgeId +MATCH (b:Badge {id: badgeId}) +MERGE (b)-[:REWARDED]->(u) +; diff --git a/backend/db-migration-worker/sync_uploads.sh b/backend/db-migration-worker/sync_uploads.sh new file mode 100755 index 000000000..d24936e3b --- /dev/null +++ b/backend/db-migration-worker/sync_uploads.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -e + +for var in "SSH_USERNAME" "SSH_HOST" "UPLOADS_DIRECTORY" +do + if [[ -z "${!var}" ]]; then + echo "${var} is undefined" + exit 1 + fi +done + +rsync --archive --update --verbose ${SSH_USERNAME}@${SSH_HOST}:${UPLOADS_DIRECTORY}/* /uploads/ diff --git a/backend/docker-compose.cypress.yml b/backend/docker-compose.cypress.yml new file mode 100644 index 000000000..3d577e638 --- /dev/null +++ b/backend/docker-compose.cypress.yml @@ -0,0 +1,18 @@ +version: "3.7" + +services: + neo4j: + environment: + - NEO4J_AUTH=none + ports: + - 7687:7687 + - 7474:7474 + backend: + ports: + - 4001:4001 + - 4123:4123 + image: humanconnection/nitro-backend:builder + build: + context: . + target: builder + command: yarn run test:cypress diff --git a/backend/docker-compose.db-migration.yml b/backend/docker-compose.db-migration.yml new file mode 100644 index 000000000..02f054d1b --- /dev/null +++ b/backend/docker-compose.db-migration.yml @@ -0,0 +1,36 @@ +version: "3.7" + +services: + backend: + volumes: + - uploads:/nitro-backend/public/uploads + neo4j: + volumes: + - mongo-export:/mongo-export + environment: + - NEO4J_apoc_import_file_enabled=true + db-migration-worker: + build: + context: db-migration-worker + volumes: + - mongo-export:/mongo-export + - uploads:/uploads + - ./db-migration-worker/migration/:/migration + - ./db-migration-worker/.ssh/:/root/.ssh/ + networks: + - hc-network + depends_on: + - backend + environment: + - NEO4J_URI=bolt://neo4j:7687 + - "SSH_USERNAME=${SSH_USERNAME}" + - "SSH_HOST=${SSH_HOST}" + - "MONGODB_USERNAME=${MONGODB_USERNAME}" + - "MONGODB_PASSWORD=${MONGODB_PASSWORD}" + - "MONGODB_AUTH_DB=${MONGODB_AUTH_DB}" + - "MONGODB_DATABASE=${MONGODB_DATABASE}" + - "UPLOADS_DIRECTORY=${UPLOADS_DIRECTORY}" + +volumes: + mongo-export: + uploads: diff --git a/backend/docker-compose.override.yml b/backend/docker-compose.override.yml new file mode 100644 index 000000000..b972c31f6 --- /dev/null +++ b/backend/docker-compose.override.yml @@ -0,0 +1,23 @@ +version: "3.7" + +services: + backend: + image: humanconnection/nitro-backend:builder + build: + context: . + target: builder + volumes: + - .:/nitro-backend + - /nitro-backend/node_modules + command: yarn run dev + neo4j: + environment: + - NEO4J_AUTH=none + ports: + - 7687:7687 + - 7474:7474 + volumes: + - neo4j-data:/data + +volumes: + neo4j-data: diff --git a/backend/docker-compose.prod.yml b/backend/docker-compose.prod.yml new file mode 100644 index 000000000..c4f5dc4f5 --- /dev/null +++ b/backend/docker-compose.prod.yml @@ -0,0 +1,9 @@ +version: "3.7" + +services: + neo4j: + environment: + - NEO4J_PASSWORD=letmein + backend: + environment: + - NEO4J_PASSWORD=letmein diff --git a/backend/docker-compose.travis.yml b/backend/docker-compose.travis.yml new file mode 100644 index 000000000..e1998f6dd --- /dev/null +++ b/backend/docker-compose.travis.yml @@ -0,0 +1,14 @@ +version: "3.7" + +services: + neo4j: + environment: + - NEO4J_AUTH=none + ports: + - 7687:7687 + - 7474:7474 + backend: + image: humanconnection/nitro-backend:builder + build: + context: . + target: builder diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 000000000..30d102f96 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,34 @@ +version: "3.7" + +services: + backend: + image: humanconnection/nitro-backend:latest + build: + context: . + target: production + networks: + - hc-network + depends_on: + - neo4j + ports: + - 4000:4000 + environment: + - NEO4J_URI=bolt://neo4j:7687 + - GRAPHQL_PORT=4000 + - GRAPHQL_URI=http://localhost:4000 + - CLIENT_URI=http://localhost:3000 + - JWT_SECRET=b/&&7b78BF&fv/Vd + - MOCK=false + - MAPBOX_TOKEN=pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ + - PRIVATE_KEY_PASSPHRASE=a7dsf78sadg87ad87sfagsadg78 + + neo4j: + image: humanconnection/neo4j:latest + build: + context: neo4j + networks: + - hc-network + +networks: + hc-network: + name: hc-network diff --git a/backend/graphql-playground.png b/backend/graphql-playground.png new file mode 100644 index 000000000..32396a577 Binary files /dev/null and b/backend/graphql-playground.png differ diff --git a/backend/humanconnection.png b/backend/humanconnection.png new file mode 100644 index 000000000..f0576413f Binary files /dev/null and b/backend/humanconnection.png differ diff --git a/backend/neo4j/Dockerfile b/backend/neo4j/Dockerfile new file mode 100644 index 000000000..f6e71811b --- /dev/null +++ b/backend/neo4j/Dockerfile @@ -0,0 +1,3 @@ +FROM neo4j:3.5.0 +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/ +COPY migrate.sh /usr/local/bin/migrate diff --git a/backend/neo4j/migrate.sh b/backend/neo4j/migrate.sh new file mode 100755 index 000000000..1ec5212ad --- /dev/null +++ b/backend/neo4j/migrate.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +# If the user has the password `neo4j` this is a strong indicator, that we are +# the initial default user. Before we can create constraints, we have to change +# the default password. This is a security feature of neo4j. +if echo ":exit" | cypher-shell --password neo4j 2> /dev/null ; then + echo "CALL dbms.security.changePassword('${NEO4J_PASSWORD}');" | cypher-shell --password neo4j +fi + +set -e + +echo ' +CALL db.index.fulltext.createNodeIndex("full_text_search",["Post"],["title", "content"]); +CREATE CONSTRAINT ON (p:Post) ASSERT p.slug IS UNIQUE; +CREATE CONSTRAINT ON (c:Category) ASSERT c.slug IS UNIQUE; +CREATE CONSTRAINT ON (u:User) ASSERT u.slug IS UNIQUE; +CREATE CONSTRAINT ON (o:Organization) ASSERT o.slug IS UNIQUE; +' | cypher-shell diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 000000000..5b653ba9d --- /dev/null +++ b/backend/package.json @@ -0,0 +1,103 @@ +{ + "name": "human-connection-backend", + "version": "0.0.1", + "description": "GraphQL Backend for Human Connection", + "main": "src/index.js", + "config": { + "no_auth": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 PERMISSIONS=disabled" + }, + "scripts": { + "build": "babel src/ -d dist/ --copy-files", + "start": "node dist/", + "dev": "nodemon --exec babel-node src/ -e js,graphql", + "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,graphql", + "lint": "eslint src --config .eslintrc.js", + "test": "nyc --reporter=text-lcov yarn test:jest", + "test:cypress": "run-p --race test:before:*", + "test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 babel-node src/ 2> /dev/null", + "test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 PERMISSIONS=disabled babel-node src/ 2> /dev/null", + "test:jest:cmd": "wait-on tcp:4001 tcp:4123 && jest --forceExit --detectOpenHandles --runInBand", + "test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/", + "test:jest:cmd:debug": "wait-on tcp:4001 tcp:4123 && node --inspect-brk ./node_modules/.bin/jest -i --forceExit --detectOpenHandles --runInBand", + "test:jest": "run-p --race test:before:* 'test:jest:cmd {@}' --", + "test:cucumber": "run-p --race test:before:* 'test:cucumber:cmd {@}' --", + "test:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --", + "test:coverage": "nyc report --reporter=text-lcov > coverage.lcov", + "db:script:seed": "wait-on tcp:4001 && babel-node src/seed/seed-db.js", + "db:reset": "babel-node src/seed/reset-db.js", + "db:seed": "$npm_package_config_no_auth run-p --race dev db:script:seed" + }, + "author": "Human Connection gGmbH", + "license": "MIT", + "jest": { + "verbose": true, + "testMatch": [ + "**/src/**/?(*.)+(spec|test).js?(x)" + ] + }, + "dependencies": { + "activitystrea.ms": "~2.1.3", + "apollo-cache-inmemory": "~1.5.1", + "apollo-client": "~2.5.1", + "apollo-link-context": "~1.0.14", + "apollo-link-http": "~1.5.13", + "apollo-server": "~2.4.8", + "bcryptjs": "~2.4.3", + "cheerio": "~1.0.0-rc.2", + "cors": "~2.8.5", + "cross-env": "~5.2.0", + "date-fns": "2.0.0-alpha.27", + "dotenv": "~7.0.0", + "express": "~4.16.4", + "faker": "~4.1.0", + "graphql": "~14.1.1", + "graphql-custom-directives": "~0.2.14", + "graphql-iso-date": "~3.6.1", + "graphql-middleware": "~3.0.2", + "graphql-shield": "~5.3.0", + "graphql-tag": "~2.10.1", + "graphql-yoga": "~1.17.4", + "helmet": "~3.15.1", + "jsonwebtoken": "~8.5.1", + "linkifyjs": "~2.1.8", + "lodash": "~4.17.11", + "ms": "~2.1.1", + "neo4j-driver": "~1.7.3", + "neo4j-graphql-js": "~2.4.2", + "node-fetch": "~2.3.0", + "npm-run-all": "~4.1.5", + "request": "~2.88.0", + "sanitize-html": "~1.20.0", + "slug": "~1.0.0", + "trunc-html": "~1.1.2", + "uuid": "~3.3.2", + "wait-on": "~3.2.0" + }, + "devDependencies": { + "@babel/cli": "~7.2.3", + "@babel/core": "~7.3.4", + "@babel/node": "~7.2.2", + "@babel/plugin-proposal-throw-expressions": "^7.2.0", + "@babel/preset-env": "~7.3.4", + "@babel/register": "~7.0.0", + "apollo-server-testing": "~2.4.8", + "babel-core": "~7.0.0-0", + "babel-eslint": "~10.0.1", + "babel-jest": "~24.5.0", + "chai": "~4.2.0", + "cucumber": "~5.1.0", + "debug": "~4.1.1", + "eslint": "~5.15.1", + "eslint-config-standard": "~12.0.0", + "eslint-plugin-import": "~2.16.0", + "eslint-plugin-jest": "~22.3.2", + "eslint-plugin-node": "~8.0.1", + "eslint-plugin-promise": "~4.0.1", + "eslint-plugin-standard": "~4.0.0", + "graphql-request": "~1.8.2", + "jest": "~24.5.0", + "nodemon": "~1.18.10", + "nyc": "~13.3.0", + "supertest": "~4.0.0" + } +} diff --git a/backend/public/.gitkeep b/backend/public/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/backend/public/img/badges/fundraisingbox_de_airship.svg b/backend/public/img/badges/fundraisingbox_de_airship.svg new file mode 100644 index 000000000..078dcf4f9 --- /dev/null +++ b/backend/public/img/badges/fundraisingbox_de_airship.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/fundraisingbox_de_alienship.svg b/backend/public/img/badges/fundraisingbox_de_alienship.svg new file mode 100644 index 000000000..e891c5fa9 --- /dev/null +++ b/backend/public/img/badges/fundraisingbox_de_alienship.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/fundraisingbox_de_balloon.svg b/backend/public/img/badges/fundraisingbox_de_balloon.svg new file mode 100644 index 000000000..6fc436d86 --- /dev/null +++ b/backend/public/img/badges/fundraisingbox_de_balloon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/fundraisingbox_de_bigballoon.svg b/backend/public/img/badges/fundraisingbox_de_bigballoon.svg new file mode 100644 index 000000000..e2650963a --- /dev/null +++ b/backend/public/img/badges/fundraisingbox_de_bigballoon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/fundraisingbox_de_crane.svg b/backend/public/img/badges/fundraisingbox_de_crane.svg new file mode 100644 index 000000000..4904c5ec5 --- /dev/null +++ b/backend/public/img/badges/fundraisingbox_de_crane.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/fundraisingbox_de_glider.svg b/backend/public/img/badges/fundraisingbox_de_glider.svg new file mode 100644 index 000000000..0c15955de --- /dev/null +++ b/backend/public/img/badges/fundraisingbox_de_glider.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/fundraisingbox_de_helicopter.svg b/backend/public/img/badges/fundraisingbox_de_helicopter.svg new file mode 100644 index 000000000..3a84e4466 --- /dev/null +++ b/backend/public/img/badges/fundraisingbox_de_helicopter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/fundraisingbox_de_starter.svg b/backend/public/img/badges/fundraisingbox_de_starter.svg new file mode 100644 index 000000000..99980560e --- /dev/null +++ b/backend/public/img/badges/fundraisingbox_de_starter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/indiegogo_en_bear.svg b/backend/public/img/badges/indiegogo_en_bear.svg new file mode 100644 index 000000000..43465a0e6 --- /dev/null +++ b/backend/public/img/badges/indiegogo_en_bear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/indiegogo_en_panda.svg b/backend/public/img/badges/indiegogo_en_panda.svg new file mode 100644 index 000000000..a2f211e85 --- /dev/null +++ b/backend/public/img/badges/indiegogo_en_panda.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/indiegogo_en_rabbit.svg b/backend/public/img/badges/indiegogo_en_rabbit.svg new file mode 100644 index 000000000..c8c0c9727 --- /dev/null +++ b/backend/public/img/badges/indiegogo_en_rabbit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/indiegogo_en_racoon.svg b/backend/public/img/badges/indiegogo_en_racoon.svg new file mode 100644 index 000000000..619cb75f1 --- /dev/null +++ b/backend/public/img/badges/indiegogo_en_racoon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/indiegogo_en_rhino.svg b/backend/public/img/badges/indiegogo_en_rhino.svg new file mode 100644 index 000000000..71c0eb1ad --- /dev/null +++ b/backend/public/img/badges/indiegogo_en_rhino.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/indiegogo_en_tiger.svg b/backend/public/img/badges/indiegogo_en_tiger.svg new file mode 100644 index 000000000..88583a472 --- /dev/null +++ b/backend/public/img/badges/indiegogo_en_tiger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/indiegogo_en_turtle.svg b/backend/public/img/badges/indiegogo_en_turtle.svg new file mode 100644 index 000000000..6b5431c2e --- /dev/null +++ b/backend/public/img/badges/indiegogo_en_turtle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/indiegogo_en_whale.svg b/backend/public/img/badges/indiegogo_en_whale.svg new file mode 100644 index 000000000..458e03b6d --- /dev/null +++ b/backend/public/img/badges/indiegogo_en_whale.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/indiegogo_en_wolf.svg b/backend/public/img/badges/indiegogo_en_wolf.svg new file mode 100644 index 000000000..e4952d86f --- /dev/null +++ b/backend/public/img/badges/indiegogo_en_wolf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/user_role_admin.svg b/backend/public/img/badges/user_role_admin.svg new file mode 100644 index 000000000..101e7458d --- /dev/null +++ b/backend/public/img/badges/user_role_admin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/user_role_developer.svg b/backend/public/img/badges/user_role_developer.svg new file mode 100644 index 000000000..55d363c9a --- /dev/null +++ b/backend/public/img/badges/user_role_developer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/user_role_moderator.svg b/backend/public/img/badges/user_role_moderator.svg new file mode 100644 index 000000000..bb2e5fde6 --- /dev/null +++ b/backend/public/img/badges/user_role_moderator.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/wooold_de_bee.svg b/backend/public/img/badges/wooold_de_bee.svg new file mode 100644 index 000000000..e716c6116 --- /dev/null +++ b/backend/public/img/badges/wooold_de_bee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/wooold_de_butterfly.svg b/backend/public/img/badges/wooold_de_butterfly.svg new file mode 100644 index 000000000..6d2b83e31 --- /dev/null +++ b/backend/public/img/badges/wooold_de_butterfly.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/wooold_de_double_rainbow.svg b/backend/public/img/badges/wooold_de_double_rainbow.svg new file mode 100644 index 000000000..406001188 --- /dev/null +++ b/backend/public/img/badges/wooold_de_double_rainbow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/wooold_de_end_of_rainbow.svg b/backend/public/img/badges/wooold_de_end_of_rainbow.svg new file mode 100644 index 000000000..2ae24cb7b --- /dev/null +++ b/backend/public/img/badges/wooold_de_end_of_rainbow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/wooold_de_flower.svg b/backend/public/img/badges/wooold_de_flower.svg new file mode 100644 index 000000000..ffc4b3da4 --- /dev/null +++ b/backend/public/img/badges/wooold_de_flower.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/wooold_de_lifetree.svg b/backend/public/img/badges/wooold_de_lifetree.svg new file mode 100644 index 000000000..5a89fa5f9 --- /dev/null +++ b/backend/public/img/badges/wooold_de_lifetree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/wooold_de_magic_rainbow.svg b/backend/public/img/badges/wooold_de_magic_rainbow.svg new file mode 100644 index 000000000..74df95190 --- /dev/null +++ b/backend/public/img/badges/wooold_de_magic_rainbow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/public/img/badges/wooold_de_super_founder.svg b/backend/public/img/badges/wooold_de_super_founder.svg new file mode 100644 index 000000000..b437f6383 --- /dev/null +++ b/backend/public/img/badges/wooold_de_super_founder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/scripts/deploy.sh b/backend/scripts/deploy.sh new file mode 100755 index 000000000..d4aadf2b3 --- /dev/null +++ b/backend/scripts/deploy.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +echo "See me deployed at $1 :)" + diff --git a/backend/scripts/docker_push.sh b/backend/scripts/docker_push.sh new file mode 100755 index 000000000..a0d46ffd3 --- /dev/null +++ b/backend/scripts/docker_push.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin +docker push humanconnection/nitro-backend:latest diff --git a/backend/scripts/test.sh b/backend/scripts/test.sh new file mode 100755 index 000000000..f1f641af1 --- /dev/null +++ b/backend/scripts/test.sh @@ -0,0 +1 @@ +#!/usr/bin/env bash diff --git a/backend/src/activitypub/ActivityPub.js b/backend/src/activitypub/ActivityPub.js new file mode 100644 index 000000000..bcebf4d49 --- /dev/null +++ b/backend/src/activitypub/ActivityPub.js @@ -0,0 +1,231 @@ +import { + extractNameFromId, + extractDomainFromUrl, + signAndSend +} from './utils' +import { + isPublicAddressed, + sendAcceptActivity, + sendRejectActivity +} from './utils/activity' +import request from 'request' +import as from 'activitystrea.ms' +import NitroDataSource from './NitroDataSource' +import router from './routes' +import dotenv from 'dotenv' +import Collections from './Collections' +import uuid from 'uuid/v4' +const debug = require('debug')('ea') + +let activityPub = null + +export { activityPub } + +export default class ActivityPub { + constructor (domain, port, uri) { + if (domain === 'localhost') { this.domain = `${domain}:${port}` } else { this.domain = domain } + this.port = port + this.dataSource = new NitroDataSource(uri) + this.collections = new Collections(this.dataSource) + } + + static init (server) { + if (!activityPub) { + dotenv.config() + const url = new URL(process.env.GRAPHQL_URI) + activityPub = new ActivityPub(url.hostname || 'localhost', url.port || 4000, url.origin) + + // integrate into running graphql express server + server.express.set('ap', activityPub) + server.express.set('port', url.port) + server.express.use(router) + console.log('-> ActivityPub middleware added to the graphql express server') + } else { + console.log('-> ActivityPub middleware already added to the graphql express server') + } + } + + handleFollowActivity (activity) { + debug(`inside FOLLOW ${activity.actor}`) + let toActorName = extractNameFromId(activity.object) + let fromDomain = extractDomainFromUrl(activity.actor) + const dataSource = this.dataSource + + return new Promise((resolve, reject) => { + request({ + url: activity.actor, + headers: { + 'Accept': 'application/activity+json' + } + }, async (err, response, toActorObject) => { + if (err) return reject(err) + debug(`name = ${toActorName}@${this.domain}`) + // save shared inbox + toActorObject = JSON.parse(toActorObject) + await this.dataSource.addSharedInboxEndpoint(toActorObject.endpoints.sharedInbox) + + let followersCollectionPage = await this.dataSource.getFollowersCollectionPage(activity.object) + + const followActivity = as.follow() + .id(activity.id) + .actor(activity.actor) + .object(activity.object) + + // add follower if not already in collection + if (followersCollectionPage.orderedItems.includes(activity.actor)) { + debug('follower already in collection!') + debug(`inbox = ${toActorObject.inbox}`) + resolve(sendRejectActivity(followActivity, toActorName, fromDomain, toActorObject.inbox)) + } else { + followersCollectionPage.orderedItems.push(activity.actor) + } + debug(`toActorObject = ${toActorObject}`) + toActorObject = typeof toActorObject !== 'object' ? JSON.parse(toActorObject) : toActorObject + debug(`followers = ${JSON.stringify(followersCollectionPage.orderedItems, null, 2)}`) + debug(`inbox = ${toActorObject.inbox}`) + debug(`outbox = ${toActorObject.outbox}`) + debug(`followers = ${toActorObject.followers}`) + debug(`following = ${toActorObject.following}`) + + try { + await dataSource.saveFollowersCollectionPage(followersCollectionPage) + debug('follow activity saved') + resolve(sendAcceptActivity(followActivity, toActorName, fromDomain, toActorObject.inbox)) + } catch (e) { + debug('followers update error!', e) + resolve(sendRejectActivity(followActivity, toActorName, fromDomain, toActorObject.inbox)) + } + }) + }) + } + + handleUndoActivity (activity) { + debug('inside UNDO') + switch (activity.object.type) { + case 'Follow': + const followActivity = activity.object + return this.dataSource.undoFollowActivity(followActivity.actor, followActivity.object) + case 'Like': + return this.dataSource.deleteShouted(activity) + default: + } + } + + handleCreateActivity (activity) { + debug('inside create') + switch (activity.object.type) { + case 'Article': + case 'Note': + const articleObject = activity.object + if (articleObject.inReplyTo) { + return this.dataSource.createComment(activity) + } else { + return this.dataSource.createPost(activity) + } + default: + } + } + + handleDeleteActivity (activity) { + debug('inside delete') + switch (activity.object.type) { + case 'Article': + case 'Note': + return this.dataSource.deletePost(activity) + default: + } + } + + handleUpdateActivity (activity) { + debug('inside update') + switch (activity.object.type) { + case 'Note': + case 'Article': + return this.dataSource.updatePost(activity) + default: + } + } + + handleLikeActivity (activity) { + // TODO differ if activity is an Article/Note/etc. + return this.dataSource.createShouted(activity) + } + + handleDislikeActivity (activity) { + // TODO differ if activity is an Article/Note/etc. + return this.dataSource.deleteShouted(activity) + } + + async handleAcceptActivity (activity) { + debug('inside accept') + switch (activity.object.type) { + case 'Follow': + const followObject = activity.object + const followingCollectionPage = await this.collections.getFollowingCollectionPage(followObject.actor) + followingCollectionPage.orderedItems.push(followObject.object) + await this.dataSource.saveFollowingCollectionPage(followingCollectionPage) + } + } + + getActorObject (url) { + return new Promise((resolve, reject) => { + request({ + url: url, + headers: { + 'Accept': 'application/json' + } + }, (err, response, body) => { + if (err) { + reject(err) + } + resolve(JSON.parse(body)) + }) + }) + } + + generateStatusId (slug) { + return `http://${this.domain}/activitypub/users/${slug}/status/${uuid()}` + } + + async sendActivity (activity) { + delete activity.send + const fromName = extractNameFromId(activity.actor) + if (Array.isArray(activity.to) && isPublicAddressed(activity)) { + debug('is public addressed') + const sharedInboxEndpoints = await this.dataSource.getSharedInboxEndpoints() + // serve shared inbox endpoints + sharedInboxEndpoints.map((sharedInbox) => { + return this.trySend(activity, fromName, new URL(sharedInbox).host, sharedInbox) + }) + activity.to = activity.to.filter((recipient) => { + return !(isPublicAddressed({ to: recipient })) + }) + // serve the rest + activity.to.map(async (recipient) => { + debug('serve rest') + const actorObject = await this.getActorObject(recipient) + return this.trySend(activity, fromName, new URL(recipient).host, actorObject.inbox) + }) + } else if (typeof activity.to === 'string') { + debug('is string') + const actorObject = await this.getActorObject(activity.to) + return this.trySend(activity, fromName, new URL(activity.to).host, actorObject.inbox) + } else if (Array.isArray(activity.to)) { + activity.to.map(async (recipient) => { + const actorObject = await this.getActorObject(recipient) + return this.trySend(activity, fromName, new URL(recipient).host, actorObject.inbox) + }) + } + } + async trySend (activity, fromName, host, url, tries = 5) { + try { + return await signAndSend(activity, fromName, host, url) + } catch (e) { + if (tries > 0) { + setTimeout(function () { + return this.trySend(activity, fromName, host, url, --tries) + }, 20000) + } + } + } +} diff --git a/backend/src/activitypub/Collections.js b/backend/src/activitypub/Collections.js new file mode 100644 index 000000000..227e1717b --- /dev/null +++ b/backend/src/activitypub/Collections.js @@ -0,0 +1,28 @@ +export default class Collections { + constructor (dataSource) { + this.dataSource = dataSource + } + getFollowersCollection (actorId) { + return this.dataSource.getFollowersCollection(actorId) + } + + getFollowersCollectionPage (actorId) { + return this.dataSource.getFollowersCollectionPage(actorId) + } + + getFollowingCollection (actorId) { + return this.dataSource.getFollowingCollection(actorId) + } + + getFollowingCollectionPage (actorId) { + return this.dataSource.getFollowingCollectionPage(actorId) + } + + getOutboxCollection (actorId) { + return this.dataSource.getOutboxCollection(actorId) + } + + getOutboxCollectionPage (actorId) { + return this.dataSource.getOutboxCollectionPage(actorId) + } +} diff --git a/backend/src/activitypub/NitroDataSource.js b/backend/src/activitypub/NitroDataSource.js new file mode 100644 index 000000000..4225e02ea --- /dev/null +++ b/backend/src/activitypub/NitroDataSource.js @@ -0,0 +1,552 @@ +import { + throwErrorIfApolloErrorOccurred, + extractIdFromActivityId, + extractNameFromId, + constructIdFromName +} from './utils' +import { + createOrderedCollection, + createOrderedCollectionPage +} from './utils/collection' +import { + createArticleObject, + isPublicAddressed +} from './utils/activity' +import crypto from 'crypto' +import gql from 'graphql-tag' +import { createHttpLink } from 'apollo-link-http' +import { setContext } from 'apollo-link-context' +import { InMemoryCache } from 'apollo-cache-inmemory' +import fetch from 'node-fetch' +import { ApolloClient } from 'apollo-client' +import trunc from 'trunc-html' +const debug = require('debug')('ea:nitro-datasource') + +export default class NitroDataSource { + constructor (uri) { + this.uri = uri + const defaultOptions = { + query: { + fetchPolicy: 'network-only', + errorPolicy: 'all' + } + } + const link = createHttpLink({ uri: this.uri, fetch: fetch }) // eslint-disable-line + const cache = new InMemoryCache() + const authLink = setContext((_, { headers }) => { + // generate the authentication token (maybe from env? Which user?) + const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJuYW1lIjoiUGV0ZXIgTHVzdGlnIiwiYXZhdGFyIjoiaHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tL3VpZmFjZXMvZmFjZXMvdHdpdHRlci9qb2huY2FmYXp6YS8xMjguanBnIiwiaWQiOiJ1MSIsImVtYWlsIjoiYWRtaW5AZXhhbXBsZS5vcmciLCJzbHVnIjoicGV0ZXItbHVzdGlnIiwiaWF0IjoxNTUyNDIwMTExLCJleHAiOjE2Mzg4MjAxMTEsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6NDAwMCIsInN1YiI6InUxIn0.G7An1yeQUViJs-0Qj-Tc-zm0WrLCMB3M02pfPnm6xzw' + // return the headers to the context so httpLink can read them + return { + headers: { + ...headers, + Authorization: token ? `Bearer ${token}` : '' + } + } + }) + this.client = new ApolloClient({ + link: authLink.concat(link), + cache: cache, + defaultOptions + }) + } + + async getFollowersCollection (actorId) { + const slug = extractNameFromId(actorId) + debug(`slug= ${slug}`) + const result = await this.client.query({ + query: gql` + query { + User(slug: "${slug}") { + followedByCount + } + } + ` + }) + debug('successfully fetched followers') + debug(result.data) + if (result.data) { + const actor = result.data.User[0] + const followersCount = actor.followedByCount + + const followersCollection = createOrderedCollection(slug, 'followers') + followersCollection.totalItems = followersCount + + return followersCollection + } else { + throwErrorIfApolloErrorOccurred(result) + } + } + + async getFollowersCollectionPage (actorId) { + const slug = extractNameFromId(actorId) + debug(`getFollowersPage slug = ${slug}`) + const result = await this.client.query({ + query: gql` + query { + User(slug:"${slug}") { + followedBy { + slug + } + followedByCount + } + } + ` + }) + + debug(result.data) + if (result.data) { + const actor = result.data.User[0] + const followers = actor.followedBy + const followersCount = actor.followedByCount + + const followersCollection = createOrderedCollectionPage(slug, 'followers') + followersCollection.totalItems = followersCount + debug(`followers = ${JSON.stringify(followers, null, 2)}`) + await Promise.all( + followers.map(async (follower) => { + followersCollection.orderedItems.push(constructIdFromName(follower.slug)) + }) + ) + + return followersCollection + } else { + throwErrorIfApolloErrorOccurred(result) + } + } + + async getFollowingCollection (actorId) { + const slug = extractNameFromId(actorId) + const result = await this.client.query({ + query: gql` + query { + User(slug:"${slug}") { + followingCount + } + } + ` + }) + + debug(result.data) + if (result.data) { + const actor = result.data.User[0] + const followingCount = actor.followingCount + + const followingCollection = createOrderedCollection(slug, 'following') + followingCollection.totalItems = followingCount + + return followingCollection + } else { + throwErrorIfApolloErrorOccurred(result) + } + } + + async getFollowingCollectionPage (actorId) { + const slug = extractNameFromId(actorId) + const result = await this.client.query({ + query: gql` + query { + User(slug:"${slug}") { + following { + slug + } + followingCount + } + } + ` + }) + + debug(result.data) + if (result.data) { + const actor = result.data.User[0] + const following = actor.following + const followingCount = actor.followingCount + + const followingCollection = createOrderedCollectionPage(slug, 'following') + followingCollection.totalItems = followingCount + + await Promise.all( + following.map(async (user) => { + followingCollection.orderedItems.push(await constructIdFromName(user.slug)) + }) + ) + + return followingCollection + } else { + throwErrorIfApolloErrorOccurred(result) + } + } + + async getOutboxCollection (actorId) { + const slug = extractNameFromId(actorId) + const result = await this.client.query({ + query: gql` + query { + User(slug:"${slug}") { + contributions { + title + slug + content + contentExcerpt + createdAt + } + } + } + ` + }) + + debug(result.data) + if (result.data) { + const actor = result.data.User[0] + const posts = actor.contributions + + const outboxCollection = createOrderedCollection(slug, 'outbox') + outboxCollection.totalItems = posts.length + + return outboxCollection + } else { + throwErrorIfApolloErrorOccurred(result) + } + } + + async getOutboxCollectionPage (actorId) { + const slug = extractNameFromId(actorId) + debug(`inside getting outbox collection page => ${slug}`) + const result = await this.client.query({ + query: gql` + query { + User(slug:"${slug}") { + actorId + contributions { + id + activityId + objectId + title + slug + content + contentExcerpt + createdAt + author { + name + } + } + } + } + ` + }) + + debug(result.data) + if (result.data) { + const actor = result.data.User[0] + const posts = actor.contributions + + const outboxCollection = createOrderedCollectionPage(slug, 'outbox') + outboxCollection.totalItems = posts.length + await Promise.all( + posts.map(async (post) => { + outboxCollection.orderedItems.push(await createArticleObject(post.activityId, post.objectId, post.content, post.author.name, post.id, post.createdAt)) + }) + ) + + debug('after createNote') + return outboxCollection + } else { + throwErrorIfApolloErrorOccurred(result) + } + } + + async undoFollowActivity (fromActorId, toActorId) { + const fromUserId = await this.ensureUser(fromActorId) + const toUserId = await this.ensureUser(toActorId) + const result = await this.client.mutate({ + mutation: gql` + mutation { + RemoveUserFollowedBy(from: {id: "${fromUserId}"}, to: {id: "${toUserId}"}) { + from { name } + } + } + ` + }) + debug(`undoFollowActivity result = ${JSON.stringify(result, null, 2)}`) + throwErrorIfApolloErrorOccurred(result) + } + + async saveFollowersCollectionPage (followersCollection, onlyNewestItem = true) { + debug('inside saveFollowers') + let orderedItems = followersCollection.orderedItems + const toUserName = extractNameFromId(followersCollection.id) + const toUserId = await this.ensureUser(constructIdFromName(toUserName)) + orderedItems = onlyNewestItem ? [orderedItems.pop()] : orderedItems + + return Promise.all( + orderedItems.map(async (follower) => { + debug(`follower = ${follower}`) + const fromUserId = await this.ensureUser(follower) + debug(`fromUserId = ${fromUserId}`) + debug(`toUserId = ${toUserId}`) + const result = await this.client.mutate({ + mutation: gql` + mutation { + AddUserFollowedBy(from: {id: "${fromUserId}"}, to: {id: "${toUserId}"}) { + from { name } + } + } + ` + }) + debug(`addUserFollowedBy edge = ${JSON.stringify(result, null, 2)}`) + throwErrorIfApolloErrorOccurred(result) + debug('saveFollowers: added follow edge successfully') + }) + ) + } + async saveFollowingCollectionPage (followingCollection, onlyNewestItem = true) { + debug('inside saveFollowers') + let orderedItems = followingCollection.orderedItems + const fromUserName = extractNameFromId(followingCollection.id) + const fromUserId = await this.ensureUser(constructIdFromName(fromUserName)) + orderedItems = onlyNewestItem ? [orderedItems.pop()] : orderedItems + return Promise.all( + orderedItems.map(async (following) => { + debug(`follower = ${following}`) + const toUserId = await this.ensureUser(following) + debug(`fromUserId = ${fromUserId}`) + debug(`toUserId = ${toUserId}`) + const result = await this.client.mutate({ + mutation: gql` + mutation { + AddUserFollowing(from: {id: "${fromUserId}"}, to: {id: "${toUserId}"}) { + from { name } + } + } + ` + }) + debug(`addUserFollowing edge = ${JSON.stringify(result, null, 2)}`) + throwErrorIfApolloErrorOccurred(result) + debug('saveFollowing: added follow edge successfully') + }) + ) + } + + async createPost (activity) { + // TODO how to handle the to field? Now the post is just created, doesn't matter who is the recipient + // createPost + const postObject = activity.object + if (!isPublicAddressed(postObject)) { + return debug('createPost: not send to public (sending to specific persons is not implemented yet)') + } + const title = postObject.summary ? postObject.summary : postObject.content.split(' ').slice(0, 5).join(' ') + const postId = extractIdFromActivityId(postObject.id) + debug('inside create post') + let result = await this.client.mutate({ + mutation: gql` + mutation { + CreatePost(content: "${postObject.content}", contentExcerpt: "${trunc(postObject.content, 120)}", title: "${title}", id: "${postId}", objectId: "${postObject.id}", activityId: "${activity.id}") { + id + } + } + ` + }) + + throwErrorIfApolloErrorOccurred(result) + + // ensure user and add author to post + const userId = await this.ensureUser(postObject.attributedTo) + debug(`userId = ${userId}`) + debug(`postId = ${postId}`) + result = await this.client.mutate({ + mutation: gql` + mutation { + AddPostAuthor(from: {id: "${userId}"}, to: {id: "${postId}"}) { + from { + name + } + } + } + ` + }) + + throwErrorIfApolloErrorOccurred(result) + } + + async deletePost (activity) { + const result = await this.client.mutate({ + mutation: gql` + mutation { + DeletePost(id: "${extractIdFromActivityId(activity.object.id)}") { + title + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result) + } + + async updatePost (activity) { + const postObject = activity.object + const postId = extractIdFromActivityId(postObject.id) + const date = postObject.updated ? postObject.updated : new Date().toISOString() + const result = await this.client.mutate({ + mutation: gql` + mutation { + UpdatePost(content: "${postObject.content}", contentExcerpt: "${trunc(postObject.content, 120).html}", id: "${postId}", updatedAt: "${date}") { + title + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result) + } + + async createShouted (activity) { + const userId = await this.ensureUser(activity.actor) + const postId = extractIdFromActivityId(activity.object) + const result = await this.client.mutate({ + mutation: gql` + mutation { + AddUserShouted(from: {id: "${userId}"}, to: {id: "${postId}"}) { + from { + name + } + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result) + if (!result.data.AddUserShouted) { + debug('something went wrong shouting post') + throw Error('User or Post not exists') + } + } + + async deleteShouted (activity) { + const userId = await this.ensureUser(activity.actor) + const postId = extractIdFromActivityId(activity.object) + const result = await this.client.mutate({ + mutation: gql` + mutation { + RemoveUserShouted(from: {id: "${userId}"}, to: {id: "${postId}"}) { + from { + name + } + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result) + if (!result.data.AddUserShouted) { + debug('something went wrong disliking a post') + throw Error('User or Post not exists') + } + } + + async getSharedInboxEndpoints () { + const result = await this.client.query({ + query: gql` + query { + SharedInboxEndpoint { + uri + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result) + return result.data.SharedInboxEnpoint + } + async addSharedInboxEndpoint (uri) { + try { + const result = await this.client.mutate({ + mutation: gql` + mutation { + CreateSharedInboxEndpoint(uri: "${uri}") + } + ` + }) + throwErrorIfApolloErrorOccurred(result) + return true + } catch (e) { + return false + } + } + + async createComment (activity) { + const postObject = activity.object + let result = await this.client.mutate({ + mutation: gql` + mutation { + CreateComment(content: "${postObject.content}", activityId: "${extractIdFromActivityId(activity.id)}") { + id + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result) + + const toUserId = await this.ensureUser(activity.actor) + const result2 = await this.client.mutate({ + mutation: gql` + mutation { + AddCommentAuthor(from: {id: "${result.data.CreateComment.id}"}, to: {id: "${toUserId}"}) { + id + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result2) + + const postId = extractIdFromActivityId(postObject.inReplyTo) + result = await this.client.mutate({ + mutation: gql` + mutation { + AddCommentPost(from: { id: "${result.data.CreateComment.id}", to: { id: "${postId}" }}) { + id + } + } + ` + }) + + throwErrorIfApolloErrorOccurred(result) + } + + /** + * This function will search for user existence and will create a disabled user with a random 16 bytes password when no user is found. + * + * @param actorId + * @returns {Promise<*>} + */ + async ensureUser (actorId) { + debug(`inside ensureUser = ${actorId}`) + const name = extractNameFromId(actorId) + const queryResult = await this.client.query({ + query: gql` + query { + User(slug: "${name}") { + id + } + } + ` + }) + + if (queryResult.data && Array.isArray(queryResult.data.User) && queryResult.data.User.length > 0) { + debug('ensureUser: user exists.. return id') + // user already exists.. return the id + return queryResult.data.User[0].id + } else { + debug('ensureUser: user not exists.. createUser') + // user does not exist.. create it + const pw = crypto.randomBytes(16).toString('hex') + const slug = name.toLowerCase().split(' ').join('-') + const result = await this.client.mutate({ + mutation: gql` + mutation { + CreateUser(password: "${pw}", slug:"${slug}", actorId: "${actorId}", name: "${name}", email: "${slug}@test.org") { + id + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result) + + return result.data.CreateUser.id + } + } +} diff --git a/backend/src/activitypub/routes/inbox.js b/backend/src/activitypub/routes/inbox.js new file mode 100644 index 000000000..f9cfb3794 --- /dev/null +++ b/backend/src/activitypub/routes/inbox.js @@ -0,0 +1,54 @@ +import express from 'express' +import { activityPub } from '../ActivityPub' + +const debug = require('debug')('ea:inbox') + +const router = express.Router() + +// Shared Inbox endpoint (federated Server) +// For now its only able to handle Note Activities!! +router.post('/', async function (req, res, next) { + debug(`Content-Type = ${req.get('Content-Type')}`) + debug(`body = ${JSON.stringify(req.body, null, 2)}`) + debug(`Request headers = ${JSON.stringify(req.headers, null, 2)}`) + switch (req.body.type) { + case 'Create': + await activityPub.handleCreateActivity(req.body).catch(next) + break + case 'Undo': + await activityPub.handleUndoActivity(req.body).catch(next) + break + case 'Follow': + await activityPub.handleFollowActivity(req.body).catch(next) + break + case 'Delete': + await activityPub.handleDeleteActivity(req.body).catch(next) + break + /* eslint-disable */ + case 'Update': + await activityPub.handleUpdateActivity(req.body).catch(next) + break + case 'Accept': + await activityPub.handleAcceptActivity(req.body).catch(next) + case 'Reject': + // Do nothing + break + case 'Add': + break + case 'Remove': + break + case 'Like': + await activityPub.handleLikeActivity(req.body).catch(next) + break + case 'Dislike': + await activityPub.handleDislikeActivity(req.body).catch(next) + break + case 'Announce': + debug('else!!') + debug(JSON.stringify(req.body, null, 2)) + } + /* eslint-enable */ + res.status(200).end() +}) + +export default router diff --git a/backend/src/activitypub/routes/index.js b/backend/src/activitypub/routes/index.js new file mode 100644 index 000000000..24898e766 --- /dev/null +++ b/backend/src/activitypub/routes/index.js @@ -0,0 +1,29 @@ +import user from './user' +import inbox from './inbox' +import webFinger from './webFinger' +import express from 'express' +import cors from 'cors' +import verify from './verify' + +const router = express.Router() + +router.use('/.well-known/webFinger', + cors(), + express.urlencoded({ extended: true }), + webFinger +) +router.use('/activitypub/users', + cors(), + express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }), + express.urlencoded({ extended: true }), + user +) +router.use('/activitypub/inbox', + cors(), + express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }), + express.urlencoded({ extended: true }), + verify, + inbox +) + +export default router diff --git a/backend/src/activitypub/routes/serveUser.js b/backend/src/activitypub/routes/serveUser.js new file mode 100644 index 000000000..f65876741 --- /dev/null +++ b/backend/src/activitypub/routes/serveUser.js @@ -0,0 +1,43 @@ +import { createActor } from '../utils/actor' +const gql = require('graphql-tag') +const debug = require('debug')('ea:serveUser') + +export async function serveUser (req, res, next) { + let name = req.params.name + + if (name.startsWith('@')) { + name = name.slice(1) + } + + debug(`name = ${name}`) + const result = await req.app.get('ap').dataSource.client.query({ + query: gql` + query { + User(slug: "${name}") { + publicKey + } + } + ` + }).catch(reason => { debug(`serveUser User fetch error: ${reason}`) }) + + if (result.data && Array.isArray(result.data.User) && result.data.User.length > 0) { + const publicKey = result.data.User[0].publicKey + const actor = createActor(name, publicKey) + debug(`actor = ${JSON.stringify(actor, null, 2)}`) + debug(`accepts json = ${req.accepts(['application/activity+json', 'application/ld+json', 'application/json'])}`) + if (req.accepts(['application/activity+json', 'application/ld+json', 'application/json'])) { + return res.json(actor) + } else if (req.accepts('text/html')) { + // TODO show user's profile page instead of the actor object + /* const outbox = JSON.parse(result.outbox) + const posts = outbox.orderedItems.filter((el) => { return el.object.type === 'Note'}) + const actor = result.actor + debug(posts) */ + // res.render('user', { user: actor, posts: JSON.stringify(posts)}) + return res.json(actor) + } + } else { + debug(`error getting publicKey for actor ${name}`) + next() + } +} diff --git a/backend/src/activitypub/routes/user.js b/backend/src/activitypub/routes/user.js new file mode 100644 index 000000000..017891e61 --- /dev/null +++ b/backend/src/activitypub/routes/user.js @@ -0,0 +1,92 @@ +import { sendCollection } from '../utils/collection' +import express from 'express' +import { serveUser } from './serveUser' +import { activityPub } from '../ActivityPub' +import verify from './verify' + +const router = express.Router() +const debug = require('debug')('ea:user') + +router.get('/:name', async function (req, res, next) { + debug('inside user.js -> serveUser') + await serveUser(req, res, next) +}) + +router.get('/:name/following', (req, res) => { + debug('inside user.js -> serveFollowingCollection') + const name = req.params.name + if (!name) { + res.status(400).send('Bad request! Please specify a name.') + } else { + const collectionName = req.query.page ? 'followingPage' : 'following' + sendCollection(collectionName, req, res) + } +}) + +router.get('/:name/followers', (req, res) => { + debug('inside user.js -> serveFollowersCollection') + const name = req.params.name + if (!name) { + return res.status(400).send('Bad request! Please specify a name.') + } else { + const collectionName = req.query.page ? 'followersPage' : 'followers' + sendCollection(collectionName, req, res) + } +}) + +router.get('/:name/outbox', (req, res) => { + debug('inside user.js -> serveOutboxCollection') + const name = req.params.name + if (!name) { + return res.status(400).send('Bad request! Please specify a name.') + } else { + const collectionName = req.query.page ? 'outboxPage' : 'outbox' + sendCollection(collectionName, req, res) + } +}) + +router.post('/:name/inbox', verify, async function (req, res, next) { + debug(`body = ${JSON.stringify(req.body, null, 2)}`) + debug(`actorId = ${req.body.actor}`) + // const result = await saveActorId(req.body.actor) + switch (req.body.type) { + case 'Create': + await activityPub.handleCreateActivity(req.body).catch(next) + break + case 'Undo': + await activityPub.handleUndoActivity(req.body).catch(next) + break + case 'Follow': + await activityPub.handleFollowActivity(req.body).catch(next) + break + case 'Delete': + await activityPub.handleDeleteActivity(req.body).catch(next) + break + /* eslint-disable */ + case 'Update': + await activityPub.handleUpdateActivity(req.body).catch(next) + break + case 'Accept': + await activityPub.handleAcceptActivity(req.body).catch(next) + case 'Reject': + // Do nothing + break + case 'Add': + break + case 'Remove': + break + case 'Like': + await activityPub.handleLikeActivity(req.body).catch(next) + break + case 'Dislike': + await activityPub.handleDislikeActivity(req.body).catch(next) + break + case 'Announce': + debug('else!!') + debug(JSON.stringify(req.body, null, 2)) + } + /* eslint-enable */ + res.status(200).end() +}) + +export default router diff --git a/backend/src/activitypub/routes/verify.js b/backend/src/activitypub/routes/verify.js new file mode 100644 index 000000000..bb5850b3e --- /dev/null +++ b/backend/src/activitypub/routes/verify.js @@ -0,0 +1,15 @@ +import { verifySignature } from '../security' +const debug = require('debug')('ea:verify') + +export default async (req, res, next) => { + debug(`actorId = ${req.body.actor}`) + // TODO stop if signature validation fails + if (await verifySignature(`${req.protocol}://${req.hostname}:${req.app.get('port')}${req.originalUrl}`, req.headers)) { + debug('verify = true') + next() + } else { + // throw Error('Signature validation failed!') + debug('verify = false') + next() + } +} diff --git a/backend/src/activitypub/routes/webFinger.js b/backend/src/activitypub/routes/webFinger.js new file mode 100644 index 000000000..ad1c806ad --- /dev/null +++ b/backend/src/activitypub/routes/webFinger.js @@ -0,0 +1,34 @@ +import express from 'express' +import { createWebFinger } from '../utils/actor' +import gql from 'graphql-tag' + +const router = express.Router() + +router.get('/', async function (req, res) { + const resource = req.query.resource + if (!resource || !resource.includes('acct:')) { + return res.status(400).send('Bad request. Please make sure "acct:USER@DOMAIN" is what you are sending as the "resource" query parameter.') + } else { + const nameAndDomain = resource.replace('acct:', '') + const name = nameAndDomain.split('@')[0] + + const result = await req.app.get('ap').dataSource.client.query({ + query: gql` + query { + User(slug: "${name}") { + slug + } + } + ` + }) + + if (result.data && result.data.User.length > 0) { + const webFinger = createWebFinger(name) + return res.contentType('application/jrd+json').json(webFinger) + } else { + return res.status(404).json({ error: `No record found for ${nameAndDomain}.` }) + } + } +}) + +export default router diff --git a/backend/src/activitypub/security/httpSignature.spec.js b/backend/src/activitypub/security/httpSignature.spec.js new file mode 100644 index 000000000..d40c38242 --- /dev/null +++ b/backend/src/activitypub/security/httpSignature.spec.js @@ -0,0 +1,84 @@ +import { generateRsaKeyPair, createSignature, verifySignature } from '.' +import crypto from 'crypto' +import request from 'request' +jest.mock('request') + +let privateKey +let publicKey +let headers +const passphrase = 'a7dsf78sadg87ad87sfagsadg78' + +describe('activityPub/security', () => { + beforeEach(() => { + const pair = generateRsaKeyPair({ passphrase }) + privateKey = pair.privateKey + publicKey = pair.publicKey + headers = { + 'Date': '2019-03-08T14:35:45.759Z', + 'Host': 'democracy-app.de', + 'Content-Type': 'application/json' + } + }) + + describe('createSignature', () => { + describe('returned http signature', () => { + let signatureB64 + let httpSignature + + beforeEach(() => { + const signer = crypto.createSign('rsa-sha256') + signer.update('(request-target): post /activitypub/users/max/inbox\ndate: 2019-03-08T14:35:45.759Z\nhost: democracy-app.de\ncontent-type: application/json') + signatureB64 = signer.sign({ key: privateKey, passphrase }, 'base64') + httpSignature = createSignature({ privateKey, keyId: 'https://human-connection.org/activitypub/users/lea#main-key', url: 'https://democracy-app.de/activitypub/users/max/inbox', headers, passphrase }) + }) + + it('contains keyId', () => { + expect(httpSignature).toContain('keyId="https://human-connection.org/activitypub/users/lea#main-key"') + }) + + it('contains default algorithm "rsa-sha256"', () => { + expect(httpSignature).toContain('algorithm="rsa-sha256"') + }) + + it('contains headers', () => { + expect(httpSignature).toContain('headers="(request-target) date host content-type"') + }) + + it('contains signature', () => { + expect(httpSignature).toContain('signature="' + signatureB64 + '"') + }) + }) + }) + + describe('verifySignature', () => { + let httpSignature + + beforeEach(() => { + httpSignature = createSignature({ privateKey, keyId: 'http://localhost:4001/activitypub/users/test-user#main-key', url: 'https://democracy-app.de/activitypub/users/max/inbox', headers, passphrase }) + const body = { + 'publicKey': { + 'id': 'https://localhost:4001/activitypub/users/test-user#main-key', + 'owner': 'https://localhost:4001/activitypub/users/test-user', + 'publicKeyPem': publicKey + } + } + + const mockedRequest = jest.fn((_, callback) => callback(null, null, JSON.stringify(body))) + request.mockImplementation(mockedRequest) + }) + + it('resolves false', async () => { + await expect(verifySignature('https://democracy-app.de/activitypub/users/max/inbox', headers)).resolves.toEqual(false) + }) + + describe('valid signature', () => { + beforeEach(() => { + headers.Signature = httpSignature + }) + + it('resolves true', async () => { + await expect(verifySignature('https://democracy-app.de/activitypub/users/max/inbox', headers)).resolves.toEqual(true) + }) + }) + }) +}) diff --git a/backend/src/activitypub/security/index.js b/backend/src/activitypub/security/index.js new file mode 100644 index 000000000..fdb1e27c6 --- /dev/null +++ b/backend/src/activitypub/security/index.js @@ -0,0 +1,154 @@ +import dotenv from 'dotenv' +import { resolve } from 'path' +import crypto from 'crypto' +import request from 'request' +const debug = require('debug')('ea:security') + +dotenv.config({ path: resolve('src', 'activitypub', '.env') }) + +export function generateRsaKeyPair (options = {}) { + const { passphrase = process.env.PRIVATE_KEY_PASSPHRASE } = options + return crypto.generateKeyPairSync('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: 'aes-256-cbc', + passphrase + } + }) +} + +// signing +export function createSignature (options) { + const { + privateKey, keyId, url, + headers = {}, + algorithm = 'rsa-sha256', + passphrase = process.env.PRIVATE_KEY_PASSPHRASE + } = options + if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { throw Error(`SIGNING: Unsupported hashing algorithm = ${algorithm}`) } + const signer = crypto.createSign(algorithm) + const signingString = constructSigningString(url, headers) + signer.update(signingString) + const signatureB64 = signer.sign({ key: privateKey, passphrase }, 'base64') + const headersString = Object.keys(headers).reduce((result, key) => { return result + ' ' + key.toLowerCase() }, '') + return `keyId="${keyId}",algorithm="${algorithm}",headers="(request-target)${headersString}",signature="${signatureB64}"` +} + +// verifying +export function verifySignature (url, headers) { + return new Promise((resolve, reject) => { + const signatureHeader = headers['signature'] ? headers['signature'] : headers['Signature'] + if (!signatureHeader) { + debug('No Signature header present!') + resolve(false) + } + debug(`Signature Header = ${signatureHeader}`) + const signature = extractKeyValueFromSignatureHeader(signatureHeader, 'signature') + const algorithm = extractKeyValueFromSignatureHeader(signatureHeader, 'algorithm') + const headersString = extractKeyValueFromSignatureHeader(signatureHeader, 'headers') + const keyId = extractKeyValueFromSignatureHeader(signatureHeader, 'keyId') + + if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { + debug('Unsupported hash algorithm specified!') + resolve(false) + } + + const usedHeaders = headersString.split(' ') + const verifyHeaders = {} + Object.keys(headers).forEach((key) => { + if (usedHeaders.includes(key.toLowerCase())) { + verifyHeaders[key.toLowerCase()] = headers[key] + } + }) + const signingString = constructSigningString(url, verifyHeaders) + debug(`keyId= ${keyId}`) + request({ + url: keyId, + headers: { + 'Accept': 'application/json' + } + }, (err, response, body) => { + if (err) reject(err) + debug(`body = ${body}`) + const actor = JSON.parse(body) + const publicKeyPem = actor.publicKey.publicKeyPem + resolve(httpVerify(publicKeyPem, signature, signingString, algorithm)) + }) + }) +} + +// private: signing +function constructSigningString (url, headers) { + const urlObj = new URL(url) + let signingString = `(request-target): post ${urlObj.pathname}${urlObj.search !== '' ? urlObj.search : ''}` + return Object.keys(headers).reduce((result, key) => { + return result + `\n${key.toLowerCase()}: ${headers[key]}` + }, signingString) +} + +// private: verifying +function httpVerify (pubKey, signature, signingString, algorithm) { + if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { throw Error(`SIGNING: Unsupported hashing algorithm = ${algorithm}`) } + const verifier = crypto.createVerify(algorithm) + verifier.update(signingString) + return verifier.verify(pubKey, signature, 'base64') +} + +// private: verifying +// This function can be used to extract the signature,headers,algorithm etc. out of the Signature Header. +// Just pass what you want as key +function extractKeyValueFromSignatureHeader (signatureHeader, key) { + const keyString = signatureHeader.split(',').filter((el) => { + return !!el.startsWith(key) + })[0] + + let firstEqualIndex = keyString.search('=') + // When headers are requested add 17 to the index to remove "(request-target) " from the string + if (key === 'headers') { firstEqualIndex += 17 } + return keyString.substring(firstEqualIndex + 2, keyString.length - 1) +} + +// Obtained from invoking crypto.getHashes() +export const SUPPORTED_HASH_ALGORITHMS = [ + 'rsa-md4', + 'rsa-md5', + 'rsa-mdC2', + 'rsa-ripemd160', + 'rsa-sha1', + 'rsa-sha1-2', + 'rsa-sha224', + 'rsa-sha256', + 'rsa-sha384', + 'rsa-sha512', + 'blake2b512', + 'blake2s256', + 'md4', + 'md4WithRSAEncryption', + 'md5', + 'md5-sha1', + 'md5WithRSAEncryption', + 'mdc2', + 'mdc2WithRSA', + 'ripemd', + 'ripemd160', + 'ripemd160WithRSA', + 'rmd160', + 'sha1', + 'sha1WithRSAEncryption', + 'sha224', + 'sha224WithRSAEncryption', + 'sha256', + 'sha256WithRSAEncryption', + 'sha384', + 'sha384WithRSAEncryption', + 'sha512', + 'sha512WithRSAEncryption', + 'ssl3-md5', + 'ssl3-sha1', + 'whirlpool'] diff --git a/backend/src/activitypub/utils/activity.js b/backend/src/activitypub/utils/activity.js new file mode 100644 index 000000000..53bcd37ea --- /dev/null +++ b/backend/src/activitypub/utils/activity.js @@ -0,0 +1,108 @@ +import { activityPub } from '../ActivityPub' +import { signAndSend, throwErrorIfApolloErrorOccurred } from './index' + +import crypto from 'crypto' +import as from 'activitystrea.ms' +import gql from 'graphql-tag' +const debug = require('debug')('ea:utils:activity') + +export function createNoteObject (text, name, id, published) { + const createUuid = crypto.randomBytes(16).toString('hex') + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${createUuid}`, + 'type': 'Create', + 'actor': `https://${activityPub.domain}/activitypub/users/${name}`, + 'object': { + 'id': `https://${activityPub.domain}/activitypub/users/${name}/status/${id}`, + 'type': 'Note', + 'published': published, + 'attributedTo': `https://${activityPub.domain}/activitypub/users/${name}`, + 'content': text, + 'to': 'https://www.w3.org/ns/activitystreams#Public' + } + } +} + +export async function createArticleObject (activityId, objectId, text, name, id, published) { + const actorId = await getActorId(name) + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `${activityId}`, + 'type': 'Create', + 'actor': `${actorId}`, + 'object': { + 'id': `${objectId}`, + 'type': 'Article', + 'published': published, + 'attributedTo': `${actorId}`, + 'content': text, + 'to': 'https://www.w3.org/ns/activitystreams#Public' + } + } +} + +export async function getActorId (name) { + const result = await activityPub.dataSource.client.query({ + query: gql` + query { + User(slug: "${name}") { + actorId + } + } + ` + }) + throwErrorIfApolloErrorOccurred(result) + if (Array.isArray(result.data.User) && result.data.User[0]) { + return result.data.User[0].actorId + } else { + throw Error(`No user with name: ${name}`) + } +} + +export function sendAcceptActivity (theBody, name, targetDomain, url) { + as.accept() + .id(`https://${activityPub.domain}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) + .actor(`https://${activityPub.domain}/activitypub/users/${name}`) + .object(theBody) + .prettyWrite((err, doc) => { + if (!err) { + return signAndSend(doc, name, targetDomain, url) + } else { + debug(`error serializing Accept object: ${err}`) + throw new Error('error serializing Accept object') + } + }) +} + +export function sendRejectActivity (theBody, name, targetDomain, url) { + as.reject() + .id(`https://${activityPub.domain}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) + .actor(`https://${activityPub.domain}/activitypub/users/${name}`) + .object(theBody) + .prettyWrite((err, doc) => { + if (!err) { + return signAndSend(doc, name, targetDomain, url) + } else { + debug(`error serializing Accept object: ${err}`) + throw new Error('error serializing Accept object') + } + }) +} + +export function isPublicAddressed (postObject) { + if (typeof postObject.to === 'string') { + postObject.to = [postObject.to] + } + if (typeof postObject === 'string') { + postObject.to = [postObject] + } + if (Array.isArray(postObject)) { + postObject.to = postObject + } + return postObject.to.includes('Public') || + postObject.to.includes('as:Public') || + postObject.to.includes('https://www.w3.org/ns/activitystreams#Public') +} diff --git a/backend/src/activitypub/utils/actor.js b/backend/src/activitypub/utils/actor.js new file mode 100644 index 000000000..0b8cc7454 --- /dev/null +++ b/backend/src/activitypub/utils/actor.js @@ -0,0 +1,40 @@ +import { activityPub } from '../ActivityPub' + +export function createActor (name, pubkey) { + return { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1' + ], + 'id': `https://${activityPub.domain}/activitypub/users/${name}`, + 'type': 'Person', + 'preferredUsername': `${name}`, + 'name': `${name}`, + 'following': `https://${activityPub.domain}/activitypub/users/${name}/following`, + 'followers': `https://${activityPub.domain}/activitypub/users/${name}/followers`, + 'inbox': `https://${activityPub.domain}/activitypub/users/${name}/inbox`, + 'outbox': `https://${activityPub.domain}/activitypub/users/${name}/outbox`, + 'url': `https://${activityPub.domain}/activitypub/@${name}`, + 'endpoints': { + 'sharedInbox': `https://${activityPub.domain}/activitypub/inbox` + }, + 'publicKey': { + 'id': `https://${activityPub.domain}/activitypub/users/${name}#main-key`, + 'owner': `https://${activityPub.domain}/activitypub/users/${name}`, + 'publicKeyPem': pubkey + } + } +} + +export function createWebFinger (name) { + return { + 'subject': `acct:${name}@${activityPub.domain}`, + 'links': [ + { + 'rel': 'self', + 'type': 'application/activity+json', + 'href': `https://${activityPub.domain}/users/${name}` + } + ] + } +} diff --git a/backend/src/activitypub/utils/collection.js b/backend/src/activitypub/utils/collection.js new file mode 100644 index 000000000..4c46adbde --- /dev/null +++ b/backend/src/activitypub/utils/collection.js @@ -0,0 +1,70 @@ +import { activityPub } from '../ActivityPub' +import { constructIdFromName } from './index' +const debug = require('debug')('ea:utils:collections') + +export function createOrderedCollection (name, collectionName) { + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}`, + 'summary': `${name}s ${collectionName} collection`, + 'type': 'OrderedCollection', + 'first': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}?page=true`, + 'totalItems': 0 + } +} + +export function createOrderedCollectionPage (name, collectionName) { + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}?page=true`, + 'summary': `${name}s ${collectionName} collection`, + 'type': 'OrderedCollectionPage', + 'totalItems': 0, + 'partOf': `https://${activityPub.domain}/activitypub/users/${name}/${collectionName}`, + 'orderedItems': [] + } +} +export function sendCollection (collectionName, req, res) { + const name = req.params.name + const id = constructIdFromName(name) + + switch (collectionName) { + case 'followers': + attachThenCatch(activityPub.collections.getFollowersCollection(id), res) + break + + case 'followersPage': + attachThenCatch(activityPub.collections.getFollowersCollectionPage(id), res) + break + + case 'following': + attachThenCatch(activityPub.collections.getFollowingCollection(id), res) + break + + case 'followingPage': + attachThenCatch(activityPub.collections.getFollowingCollectionPage(id), res) + break + + case 'outbox': + attachThenCatch(activityPub.collections.getOutboxCollection(id), res) + break + + case 'outboxPage': + attachThenCatch(activityPub.collections.getOutboxCollectionPage(id), res) + break + + default: + res.status(500).end() + } +} + +function attachThenCatch (promise, res) { + return promise + .then((collection) => { + res.status(200).contentType('application/activity+json').send(collection) + }) + .catch((err) => { + debug(`error getting a Collection: = ${err}`) + res.status(500).end() + }) +} diff --git a/backend/src/activitypub/utils/index.js b/backend/src/activitypub/utils/index.js new file mode 100644 index 000000000..c48e15e3d --- /dev/null +++ b/backend/src/activitypub/utils/index.js @@ -0,0 +1,103 @@ +import { activityPub } from '../ActivityPub' +import gql from 'graphql-tag' +import { createSignature } from '../security' +import request from 'request' +const debug = require('debug')('ea:utils') + +export function extractNameFromId (uri) { + const urlObject = new URL(uri) + const pathname = urlObject.pathname + const splitted = pathname.split('/') + + return splitted[splitted.indexOf('users') + 1] +} + +export function extractIdFromActivityId (uri) { + const urlObject = new URL(uri) + const pathname = urlObject.pathname + const splitted = pathname.split('/') + + return splitted[splitted.indexOf('status') + 1] +} + +export function constructIdFromName (name, fromDomain = activityPub.domain) { + return `http://${fromDomain}/activitypub/users/${name}` +} + +export function extractDomainFromUrl (url) { + return new URL(url).hostname +} + +export function throwErrorIfApolloErrorOccurred (result) { + if (result.error && (result.error.message || result.error.errors)) { + throw new Error(`${result.error.message ? result.error.message : result.error.errors[0].message}`) + } +} + +export function signAndSend (activity, fromName, targetDomain, url) { + // fix for development: replace with http + url = url.indexOf('localhost') > -1 ? url.replace('https', 'http') : url + debug(`passhprase = ${process.env.PRIVATE_KEY_PASSPHRASE}`) + return new Promise(async (resolve, reject) => { + debug('inside signAndSend') + // get the private key + const result = await activityPub.dataSource.client.query({ + query: gql` + query { + User(slug: "${fromName}") { + privateKey + } + } + ` + }) + + if (result.error) { + reject(result.error) + } else { + // add security context + const parsedActivity = JSON.parse(activity) + if (Array.isArray(parsedActivity['@context'])) { + parsedActivity['@context'].push('https://w3id.org/security/v1') + } else { + const context = [parsedActivity['@context']] + context.push('https://w3id.org/security/v1') + parsedActivity['@context'] = context + } + + // deduplicate context strings + parsedActivity['@context'] = [...new Set(parsedActivity['@context'])] + const privateKey = result.data.User[0].privateKey + const date = new Date().toUTCString() + + debug(`url = ${url}`) + request({ + url: url, + headers: { + 'Host': targetDomain, + 'Date': date, + 'Signature': createSignature({ privateKey, + keyId: `http://${activityPub.domain}/activitypub/users/${fromName}#main-key`, + url, + headers: { + 'Host': targetDomain, + 'Date': date, + 'Content-Type': 'application/activity+json' + } + }), + 'Content-Type': 'application/activity+json' + }, + method: 'POST', + body: JSON.stringify(parsedActivity) + }, (error, response) => { + if (error) { + debug(`Error = ${JSON.stringify(error, null, 2)}`) + reject(error) + } else { + debug('Response Headers:', JSON.stringify(response.headers, null, 2)) + debug('Response Body:', JSON.stringify(response.body, null, 2)) + resolve() + } + }) + } + }) +} diff --git a/backend/src/bootstrap/directives.js b/backend/src/bootstrap/directives.js new file mode 100644 index 000000000..8c392ed46 --- /dev/null +++ b/backend/src/bootstrap/directives.js @@ -0,0 +1,16 @@ +import { + GraphQLLowerCaseDirective, + GraphQLTrimDirective, + GraphQLDefaultToDirective +} from 'graphql-custom-directives' + +export default function applyDirectives (augmentedSchema) { + const directives = [ + GraphQLLowerCaseDirective, + GraphQLTrimDirective, + GraphQLDefaultToDirective + ] + augmentedSchema._directives.push.apply(augmentedSchema._directives, directives) + + return augmentedSchema +} diff --git a/backend/src/bootstrap/neo4j.js b/backend/src/bootstrap/neo4j.js new file mode 100644 index 000000000..935449a0a --- /dev/null +++ b/backend/src/bootstrap/neo4j.js @@ -0,0 +1,18 @@ +import { v1 as neo4j } from 'neo4j-driver' +import dotenv from 'dotenv' + +dotenv.config() + +let driver + +export function getDriver (options = {}) { + const { + uri = process.env.NEO4J_URI || 'bolt://localhost:7687', + username = process.env.NEO4J_USERNAME || 'neo4j', + password = process.env.NEO4J_PASSWORD || 'neo4j' + } = options + if (!driver) { + driver = neo4j.driver(uri, neo4j.auth.basic(username, password)) + } + return driver +} diff --git a/backend/src/bootstrap/scalars.js b/backend/src/bootstrap/scalars.js new file mode 100644 index 000000000..813bd5051 --- /dev/null +++ b/backend/src/bootstrap/scalars.js @@ -0,0 +1,13 @@ +import { + GraphQLDate, + GraphQLTime, + GraphQLDateTime +} from 'graphql-iso-date' + +export default function applyScalars (augmentedSchema) { + augmentedSchema._typeMap.Date = GraphQLDate + augmentedSchema._typeMap.Time = GraphQLTime + augmentedSchema._typeMap.DateTime = GraphQLDateTime + + return augmentedSchema +} diff --git a/backend/src/graphql-schema.js b/backend/src/graphql-schema.js new file mode 100644 index 000000000..b9cdfdb37 --- /dev/null +++ b/backend/src/graphql-schema.js @@ -0,0 +1,27 @@ +import fs from 'fs' +import path from 'path' + +import userManagement from './resolvers/user_management.js' +import statistics from './resolvers/statistics.js' +import reports from './resolvers/reports.js' +import posts from './resolvers/posts.js' +import moderation from './resolvers/moderation.js' + +export const typeDefs = fs + .readFileSync( + process.env.GRAPHQL_SCHEMA || path.join(__dirname, 'schema.graphql') + ) + .toString('utf-8') + +export const resolvers = { + Query: { + ...statistics.Query, + ...userManagement.Query + }, + Mutation: { + ...userManagement.Mutation, + ...reports.Mutation, + ...moderation.Mutation, + ...posts.Mutation + } +} diff --git a/backend/src/helpers/asyncForEach.js b/backend/src/helpers/asyncForEach.js new file mode 100644 index 000000000..1f05ea915 --- /dev/null +++ b/backend/src/helpers/asyncForEach.js @@ -0,0 +1,14 @@ +/** + * Provide a way to iterate for each element in an array while waiting for async functions to finish + * + * @param array + * @param callback + * @returns {Promise