diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b73537336..fbb83adc2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -9,7 +9,7 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: -1. Go to '...' +1. Go to 'http...' 2. Click on '....' 3. Scroll down to '....' 4. See error diff --git a/.gitignore b/.gitignore index 1d0565198..1ebe41f06 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,18 @@ +.env +.idea +.vscode +.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.yarn-integrity +.eslintcache + /.github + +node_modules/ +cypress/videos +cypress/screenshots/ +cypress.env.json + +!.gitkeep diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 3540ac24e..000000000 --- a/.gitmodules +++ /dev/null @@ -1,12 +0,0 @@ -[submodule "Nitro-Web"] - path = Nitro-Web - url = https://github.com/Human-Connection/Nitro-Web.git -[submodule "Nitro-Backend"] - path = Nitro-Backend - url = https://github.com/Human-Connection/Nitro-Backend.git -[submodule "Nitro-Embed"] - path = Nitro-Embed - url = https://github.com/Human-Connection/Nitro-Embed.git -[submodule "Nitro-Styleguide"] - path = Nitro-Styleguide - url = https://github.com/Human-Connection/Nitro-Styleguide.git diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..747a00143 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,54 @@ +language: generic +services: + - docker +addons: + chrome: stable + apt: + sources: + - google-chrome + packages: + - google-chrome-stable + +before_install: + - yarn global add wait-on + - yarn install + - cp cypress.env.template.json cypress.env.json + +install: + - docker-compose -f docker-compose.yml -f docker-compose.travis.yml up --build -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 + - docker-compose exec webapp yarn run lint + - docker-compose exec webapp yarn run test --ci + - docker-compose exec -d backend yarn run test:cypress + - yarn run cypress:run --record --key $CYPRESS_TOKEN + +after_success: + - wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh + - chmod +x send.sh + - ./send.sh success $WEBHOOK_URL + - if [ $TRAVIS_BRANCH == "master" ] && [ $TRAVIS_EVENT_TYPE == "push" ]; then + wget https://raw.githubusercontent.com/Human-Connection/Discord-Bot/develop/tester.sh && + chmod +x tester.sh && + ./tester.sh staging $WEBHOOK_URL; + fi + +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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 956d3af35..0da2958ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ We recommend that new folks should ideally work together with an existing develo Here are some general notes on our development flow: -### Development +### Development * Currently operating in two week sprints * We are using ZenHub to coordinate @@ -30,10 +30,10 @@ Here are some general notes on our development flow: - Martin head of IT and previously data protection officer - Victor doing accounting and controlling - Nicolas is the community manager (reviews content in the network) reflects community opinion back to the core team - + * when can folks pair with Robert - 10am UTC until 5pm UTC every working day - + ### Philosophy We practise [collective code ownership](http://www.extremeprogramming.org/rules/collective.html) rather than strong code ownership, which means that: @@ -52,7 +52,7 @@ But what do we do when waiting for merge into master (wanting to keep PRs small) - solutions - 1) put 2nd PR into branch that the first PR is hitting - but requires update after merging - 2) prefer to leave exiting PR until it can be reviewed, and instead go and work on some other part of the codebase that is not impacted by the first PR - + ### Notes question: when you want to pick a task - (find out priority) - is it in discord? is it in AV slack? --> Robert says you can always ask in discord - group channels are the best diff --git a/LICENSE b/LICENSE.md similarity index 96% rename from LICENSE rename to LICENSE.md index 45cc9972b..9d4508b38 100644 --- a/LICENSE +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 Human-Connection +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 diff --git a/Nitro-Backend b/Nitro-Backend deleted file mode 160000 index fd8cc3f7e..000000000 --- a/Nitro-Backend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit fd8cc3f7e60f4be10994ea934575fe38a60345f8 diff --git a/Nitro-Embed b/Nitro-Embed deleted file mode 160000 index 296fc5a03..000000000 --- a/Nitro-Embed +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 296fc5a0399425c698be0e8875b8dbd6a4455994 diff --git a/Nitro-Styleguide b/Nitro-Styleguide deleted file mode 160000 index 3f7a00b8b..000000000 --- a/Nitro-Styleguide +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3f7a00b8b94c4bfd2a029d6a50c847f3ab027692 diff --git a/Nitro-Web b/Nitro-Web deleted file mode 160000 index 0c2a228b4..000000000 --- a/Nitro-Web +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0c2a228b47647de5bfe873ade42fb1bf110c8bc0 diff --git a/README.md b/README.md index fb8c94162..0ec1ded21 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ We want to do more than comments and sharing! | [Nitro Backend](https://github.com/Human-Connection/Nitro-Backend) | [](https://travis-ci.com/Human-Connection/Nitro-Styleguide) | [Nitro Backend](https://github.com/Human-Connection/Nitro-Backend) | | [Nitro Styleguide](https://github.com/Human-Connection/Nitro-Styleguide) | [](https://travis-ci.com/Human-Connection/Nitro-Styleguide) | [Nitro Styleguide](https://github.com/Human-Connection/Nitro-Styleguide) | | [Nitro Embed](https://github.com/Human-Connection/Nitro-Embed) |  | - + **Technology Stack** - vuejs - nuxtjs 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/.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/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/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/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