diff --git a/Human-Connection/.github/ISSUE_TEMPLATE.md b/Human-Connection/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..35457d215 --- /dev/null +++ b/Human-Connection/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,11 @@ + + +## Issue + + + diff --git a/Human-Connection/.github/ISSUE_TEMPLATE/bug_report.md b/Human-Connection/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..8b0d890e2 --- /dev/null +++ b/Human-Connection/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: 🐛 Bug report +about: Create a report to help us improve + +--- + +## 🐛 Bugreport + + + +### Steps to reproduce the behavior +1. +2. +3. +4. ... +5. Profit + + +### Expected behavior + + + +### Version & Environment + Type: [] + - OS: [] + - Browser: [] + - Version [] + - Device: [] + +### Additional context + diff --git a/Human-Connection/.github/ISSUE_TEMPLATE/feature_request.md b/Human-Connection/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..4b0493ac8 --- /dev/null +++ b/Human-Connection/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,26 @@ +--- +name: 🚀 Feature request +about: Suggest an idea for this project + +--- + +## 🚀 Feature + + + +### Is your feature request related to a problem? Please describe. + + + +### Describe the prefered solution and alternatives you've considered + + + +### Design & Layout + + + +### Additional context + diff --git a/Human-Connection/.github/ISSUE_TEMPLATE/question.md b/Human-Connection/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 000000000..0999221f4 --- /dev/null +++ b/Human-Connection/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,10 @@ +--- +name: 💬 Question +about: If you need help understanding HumanConnection. +--- + + + +## 💬 Question + diff --git a/Human-Connection/.github/PULL_REQUEST_TEMPLATE.md b/Human-Connection/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..e67351c41 --- /dev/null +++ b/Human-Connection/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ +## Pullrequest + + +### Issues + +- [X] None + +### Checklist + +- [X] None + +### How2Test + + +- [X] None + +### Todo + +- [X] None diff --git a/Human-Connection/.gitignore b/Human-Connection/.gitignore new file mode 100644 index 000000000..07094a43b --- /dev/null +++ b/Human-Connection/.gitignore @@ -0,0 +1,18 @@ +.env +.idea +*.iml +.vscode +.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.yarn-integrity +.eslintcache +kubeconfig.yaml + +node_modules/ +cypress/videos +cypress/screenshots/ +cypress.env.json + +!.gitkeep diff --git a/Human-Connection/.travis.yml b/Human-Connection/.travis.yml new file mode 100644 index 000000000..309beb07d --- /dev/null +++ b/Human-Connection/.travis.yml @@ -0,0 +1,61 @@ +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 --verbose=false + - 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 --verbose=false + - 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 + +before_deploy: + - ./scripts/setup_kubernetes.sh + +deploy: + - provider: script + script: scripts/docker_push.sh + on: + branch: master + - provider: script + script: scripts/deploy.sh + on: + branch: master diff --git a/Human-Connection/CODE_OF_CONDUCT.md b/Human-Connection/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..19f3854c1 --- /dev/null +++ b/Human-Connection/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at developer@human-connection.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/Human-Connection/CONTRIBUTING.md b/Human-Connection/CONTRIBUTING.md new file mode 100644 index 000000000..0da2958ea --- /dev/null +++ b/Human-Connection/CONTRIBUTING.md @@ -0,0 +1,72 @@ +Thanks so much for thinking of contributing to the Human Connection project, we really appreciate it! :-) + +### Getting Set Up + +Instructions for how to install all the necessary software can be found in our [documentation](https://docs.human-connection.org/nitro/) + +We recommend that new folks should ideally work together with an existing developer. Please join our discord instance to chat with developers or just ask them in tickets in [Zenhub](https://app.zenhub.com/workspaces/human-connection-nitro-5c0154ecc699f60fc92cf11f/boards?repos=152252353): + + + +Here are some general notes on our development flow: + +### Development + +* Currently operating in two week sprints +* We are using ZenHub to coordinate + - estimating time per issue is the crucial feature of [Zenhub](https://app.zenhub.com/workspaces/human-connection-nitro-5c0154ecc699f60fc92cf11f) that Github does not have + - "up-for-grabs" links to [Github project](https://github.com/orgs/Human-Connection/projects/10?card_filter_query=label%3A%22good+first+issue) + - ordering on ZenHub not necessarily reflected on github projects + +* AgileVentures run open pairing sessions at 10:30am UTC each week on Tuesdays and Thursdays + +* Core team + - all the people who are hired by HC non-profit corporation + - you can Meet-the-team [every two weeks in German](https://human-connection.org/veranstaltungen/) and [every month in English](https://human-connection.org/en/events/). + - 9 people + - 2 core developers (Robert [@roschaefer](https://github.com/roschaefer) and Greg [@appinteractive](https://github.com/appinteractive)) + - 3 marketeers Jasi, Dennis and Sensi + - Hardy doing business development + - 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: + +* anyone can start working on anyone elses code +* we avoid blocking because someone else isn't working on something +* however it's sometimes good to leave something in order to create successful education experience +* everyone should always push their code to branches so others can see it + +Everyone feel free to request merges or answers to issues from the project managers + +But what do we do when waiting for merge into master (wanting to keep PRs small) + --> Robert recommends creating a pull request for each step + - programming is also about thinking about other people - empathy for your co-workers + - but what about when you are waiting for merge? + - 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 + +Robert shares: [Zenhub board](https://app.zenhub.com/workspaces/nitro-embed-5c0154ecc699f60fc92cf11f/boards?repos=112590397,152252353,152252578,157710732,163305928) +Robert says the order of tickets are preserved in ZenHub and reflect their priority (most important at the top) and so check out the current milestones + +Matt - question about who can work on [ticket 100](https://app.zenhub.com/workspaces/nitro-embed-5c0154ecc699f60fc92cf11f/issues/human-connection/human-connection/100) --> Robert - in rare occasions it might be exclusive to someone with admin permissions +Robert: notes greg just pushed this today: https://github.com/Human-Connection/Nitro-Deployment + +Matt makes point that new stories will have to be taken off the "New Issues" and Robert says that's fine, if you don't like the first one, then you can take the next one. Volunteeers have no commitment except their own self development and their awesomeness by contributing to free and open-source software projects. + +Robert notes that everyone is invited to join the kickoff meetings + +Robert - difference between "important" (creates a lot of value) and "beginner friendly" (easy to implement) + + diff --git a/Human-Connection/LICENSE.md b/Human-Connection/LICENSE.md new file mode 100644 index 000000000..9d4508b38 --- /dev/null +++ b/Human-Connection/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Human-Connection gGmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Human-Connection/README.md b/Human-Connection/README.md new file mode 100644 index 000000000..77ac8dd4b --- /dev/null +++ b/Human-Connection/README.md @@ -0,0 +1,39 @@ +
+ +# Human-Connection + + +[](https://travis-ci.com/Human-Connection/Human-Connection) + +Human Connection is a free and open-source social network for active citizenship. + +**Technology Stack** +- [VueJS](https://vuejs.org/) +- [NuxtJS](https://nuxtjs.org/) +- [GraphQL](https://graphql.org/) +- [NodeJS](https://nodejs.org/en/) +- [Neo4J](https://neo4j.com/) + +## Live demo + +Try out our deployed [staging environment](https://nitro-staging.human-connection.org/). + +Logins: + +| email | password | role | +| --- | --- | --- | +| `user@example.org` | 1234 | user | +| `moderator@example.org` | 1234 | moderator | +| `admin@example.org` | 1234 | admin | + + +## Documentation +Learn how to set up a local development environment in our [Docs](https://docs.human-connection.org/nitro). + +## Translations +Contributre translations on [lokalise.co](https://lokalise.co/public/556252725c18dd752dd546.13222042/). + +## Developer Chat +Join the open-source community on [Discord](https://discord.gg/6ub73U3). diff --git a/Human-Connection/backend/.babelrc b/Human-Connection/backend/.babelrc new file mode 100644 index 000000000..f36dbeadb --- /dev/null +++ b/Human-Connection/backend/.babelrc @@ -0,0 +1,15 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "10" + } + } + ] + ], + "plugins": [ + "@babel/plugin-proposal-throw-expressions" + ] +} diff --git a/Human-Connection/backend/.codecov.yml b/Human-Connection/backend/.codecov.yml new file mode 100644 index 000000000..97bec0084 --- /dev/null +++ b/Human-Connection/backend/.codecov.yml @@ -0,0 +1,2 @@ +coverage: + range: "60...100" diff --git a/Human-Connection/backend/.dockerignore b/Human-Connection/backend/.dockerignore new file mode 100644 index 000000000..31f5b28f3 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/.env.template b/Human-Connection/backend/.env.template new file mode 100644 index 000000000..abc62b2dc --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/.eslintrc.js b/Human-Connection/backend/.eslintrc.js new file mode 100644 index 000000000..0fdbfd52d --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/.gitignore b/Human-Connection/backend/.gitignore new file mode 100644 index 000000000..81a29c8e6 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/.graphqlconfig b/Human-Connection/backend/.graphqlconfig new file mode 100644 index 000000000..ca328bc83 --- /dev/null +++ b/Human-Connection/backend/.graphqlconfig @@ -0,0 +1,3 @@ +{ + "schemaPath": "./src/schema.graphql" +} diff --git a/Human-Connection/backend/Dockerfile b/Human-Connection/backend/Dockerfile new file mode 100644 index 000000000..750d284dc --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/README.md b/Human-Connection/backend/README.md new file mode 100644 index 000000000..dd4c040e7 --- /dev/null +++ b/Human-Connection/backend/README.md @@ -0,0 +1,196 @@ + + +# 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 + +### Requirements + +Node >= `v10.12.0` +``` + node --version +``` + +### 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: + +Download [Neo4j Community Edition](https://neo4j.com/download-center/#releases) and unpack the files. + +Download [Neo4j Apoc](https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases) and drop the file into the `plugins` folder of the just extracted Neo4j-Server + +Start Neo4j +``` +neo4j\bin\neo4j start +``` +and confirm it's running [here](http://localhost:7474) + + +```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/Human-Connection/backend/db-migration-worker/.dockerignore b/Human-Connection/backend/db-migration-worker/.dockerignore new file mode 100644 index 000000000..59ba63a8b --- /dev/null +++ b/Human-Connection/backend/db-migration-worker/.dockerignore @@ -0,0 +1 @@ +.ssh/ diff --git a/Human-Connection/backend/db-migration-worker/.gitignore b/Human-Connection/backend/db-migration-worker/.gitignore new file mode 100644 index 000000000..59ba63a8b --- /dev/null +++ b/Human-Connection/backend/db-migration-worker/.gitignore @@ -0,0 +1 @@ +.ssh/ diff --git a/Human-Connection/backend/db-migration-worker/.ssh/known_hosts b/Human-Connection/backend/db-migration-worker/.ssh/known_hosts new file mode 100644 index 000000000..947840cb2 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/db-migration-worker/Dockerfile b/Human-Connection/backend/db-migration-worker/Dockerfile new file mode 100644 index 000000000..865a4c330 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/db-migration-worker/migrate.sh b/Human-Connection/backend/db-migration-worker/migrate.sh new file mode 100755 index 000000000..214da53d8 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/db-migration-worker/migration/mongo/import.sh b/Human-Connection/backend/db-migration-worker/migration/mongo/import.sh new file mode 100755 index 000000000..7cf3e91e4 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/db-migration-worker/migration/neo4j/badges.cql b/Human-Connection/backend/db-migration-worker/migration/neo4j/badges.cql new file mode 100644 index 000000000..90e4755b4 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/db-migration-worker/migration/neo4j/categories.cql b/Human-Connection/backend/db-migration-worker/migration/neo4j/categories.cql new file mode 100644 index 000000000..a2bf6a352 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/db-migration-worker/migration/neo4j/comments.cql b/Human-Connection/backend/db-migration-worker/migration/neo4j/comments.cql new file mode 100644 index 000000000..6709acbc8 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/db-migration-worker/migration/neo4j/contributions.cql b/Human-Connection/backend/db-migration-worker/migration/neo4j/contributions.cql new file mode 100644 index 000000000..0c7b18959 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/db-migration-worker/migration/neo4j/follows.cql b/Human-Connection/backend/db-migration-worker/migration/neo4j/follows.cql new file mode 100644 index 000000000..0dad6a435 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/db-migration-worker/migration/neo4j/import.sh b/Human-Connection/backend/db-migration-worker/migration/neo4j/import.sh new file mode 100755 index 000000000..6f539c501 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/db-migration-worker/migration/neo4j/shouts.cql b/Human-Connection/backend/db-migration-worker/migration/neo4j/shouts.cql new file mode 100644 index 000000000..60aca50c9 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/db-migration-worker/migration/neo4j/users.cql b/Human-Connection/backend/db-migration-worker/migration/neo4j/users.cql new file mode 100644 index 000000000..5f87bb273 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/db-migration-worker/sync_uploads.sh b/Human-Connection/backend/db-migration-worker/sync_uploads.sh new file mode 100755 index 000000000..d24936e3b --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/docker-compose.cypress.yml b/Human-Connection/backend/docker-compose.cypress.yml new file mode 100644 index 000000000..3d577e638 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/docker-compose.db-migration.yml b/Human-Connection/backend/docker-compose.db-migration.yml new file mode 100644 index 000000000..02f054d1b --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/docker-compose.override.yml b/Human-Connection/backend/docker-compose.override.yml new file mode 100644 index 000000000..b972c31f6 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/docker-compose.prod.yml b/Human-Connection/backend/docker-compose.prod.yml new file mode 100644 index 000000000..c4f5dc4f5 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/docker-compose.travis.yml b/Human-Connection/backend/docker-compose.travis.yml new file mode 100644 index 000000000..e1998f6dd --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/docker-compose.yml b/Human-Connection/backend/docker-compose.yml new file mode 100644 index 000000000..30d102f96 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/graphql-playground.png b/Human-Connection/backend/graphql-playground.png new file mode 100644 index 000000000..32396a577 Binary files /dev/null and b/Human-Connection/backend/graphql-playground.png differ diff --git a/Human-Connection/backend/humanconnection.png b/Human-Connection/backend/humanconnection.png new file mode 100644 index 000000000..f0576413f Binary files /dev/null and b/Human-Connection/backend/humanconnection.png differ diff --git a/Human-Connection/backend/neo4j/Dockerfile b/Human-Connection/backend/neo4j/Dockerfile new file mode 100644 index 000000000..f6e71811b --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/neo4j/migrate.sh b/Human-Connection/backend/neo4j/migrate.sh new file mode 100755 index 000000000..1ec5212ad --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/package.json b/Human-Connection/backend/package.json new file mode 100644 index 000000000..dcdadd472 --- /dev/null +++ b/Human-Connection/backend/package.json @@ -0,0 +1,100 @@ +{ + "name": "human-connection-backend", + "version": "0.0.1", + "description": "GraphQL Backend for Human Connection", + "main": "src/index.js", + "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 CLIENT_URI=http://localhost:4123 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 DISABLED_MIDDLEWARES=permissions,activityPub 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": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions 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.14", + "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", + "debug": "~4.1.1", + "dotenv": "~7.0.0", + "express": "~4.16.4", + "faker": "~4.1.0", + "graphql": "~14.2.1", + "graphql-custom-directives": "~0.2.14", + "graphql-iso-date": "~3.6.1", + "graphql-middleware": "~3.0.2", + "graphql-shield": "~5.3.1", + "graphql-tag": "~2.10.1", + "graphql-yoga": "~1.17.4", + "helmet": "~3.16.0", + "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.1.0", + "trunc-html": "~1.1.2", + "uuid": "~3.3.2", + "wait-on": "~3.2.0" + }, + "devDependencies": { + "@babel/cli": "~7.2.3", + "@babel/core": "~7.4.0", + "@babel/node": "~7.2.2", + "@babel/plugin-proposal-throw-expressions": "^7.2.0", + "@babel/preset-env": "~7.4.3", + "@babel/register": "~7.4.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", + "eslint": "~5.16.0", + "eslint-config-standard": "~12.0.0", + "eslint-plugin-import": "~2.16.0", + "eslint-plugin-jest": "~22.4.1", + "eslint-plugin-node": "~8.0.1", + "eslint-plugin-promise": "~4.1.1", + "eslint-plugin-standard": "~4.0.0", + "graphql-request": "~1.8.2", + "jest": "~24.7.0", + "nodemon": "~1.18.10", + "nyc": "~13.3.0", + "supertest": "~4.0.2" + } +} \ No newline at end of file diff --git a/Human-Connection/backend/public/.gitkeep b/Human-Connection/backend/public/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/Human-Connection/backend/public/img/badges/fundraisingbox_de_airship.svg b/Human-Connection/backend/public/img/badges/fundraisingbox_de_airship.svg new file mode 100644 index 000000000..078dcf4f9 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/fundraisingbox_de_airship.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/fundraisingbox_de_alienship.svg b/Human-Connection/backend/public/img/badges/fundraisingbox_de_alienship.svg new file mode 100644 index 000000000..e891c5fa9 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/fundraisingbox_de_alienship.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/fundraisingbox_de_balloon.svg b/Human-Connection/backend/public/img/badges/fundraisingbox_de_balloon.svg new file mode 100644 index 000000000..6fc436d86 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/fundraisingbox_de_balloon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/fundraisingbox_de_bigballoon.svg b/Human-Connection/backend/public/img/badges/fundraisingbox_de_bigballoon.svg new file mode 100644 index 000000000..e2650963a --- /dev/null +++ b/Human-Connection/backend/public/img/badges/fundraisingbox_de_bigballoon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/fundraisingbox_de_crane.svg b/Human-Connection/backend/public/img/badges/fundraisingbox_de_crane.svg new file mode 100644 index 000000000..4904c5ec5 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/fundraisingbox_de_crane.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/fundraisingbox_de_glider.svg b/Human-Connection/backend/public/img/badges/fundraisingbox_de_glider.svg new file mode 100644 index 000000000..0c15955de --- /dev/null +++ b/Human-Connection/backend/public/img/badges/fundraisingbox_de_glider.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/fundraisingbox_de_helicopter.svg b/Human-Connection/backend/public/img/badges/fundraisingbox_de_helicopter.svg new file mode 100644 index 000000000..3a84e4466 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/fundraisingbox_de_helicopter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/fundraisingbox_de_starter.svg b/Human-Connection/backend/public/img/badges/fundraisingbox_de_starter.svg new file mode 100644 index 000000000..99980560e --- /dev/null +++ b/Human-Connection/backend/public/img/badges/fundraisingbox_de_starter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/indiegogo_en_bear.svg b/Human-Connection/backend/public/img/badges/indiegogo_en_bear.svg new file mode 100644 index 000000000..43465a0e6 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/indiegogo_en_bear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/indiegogo_en_panda.svg b/Human-Connection/backend/public/img/badges/indiegogo_en_panda.svg new file mode 100644 index 000000000..a2f211e85 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/indiegogo_en_panda.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/indiegogo_en_rabbit.svg b/Human-Connection/backend/public/img/badges/indiegogo_en_rabbit.svg new file mode 100644 index 000000000..c8c0c9727 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/indiegogo_en_rabbit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/indiegogo_en_racoon.svg b/Human-Connection/backend/public/img/badges/indiegogo_en_racoon.svg new file mode 100644 index 000000000..619cb75f1 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/indiegogo_en_racoon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/indiegogo_en_rhino.svg b/Human-Connection/backend/public/img/badges/indiegogo_en_rhino.svg new file mode 100644 index 000000000..71c0eb1ad --- /dev/null +++ b/Human-Connection/backend/public/img/badges/indiegogo_en_rhino.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/indiegogo_en_tiger.svg b/Human-Connection/backend/public/img/badges/indiegogo_en_tiger.svg new file mode 100644 index 000000000..88583a472 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/indiegogo_en_tiger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/indiegogo_en_turtle.svg b/Human-Connection/backend/public/img/badges/indiegogo_en_turtle.svg new file mode 100644 index 000000000..6b5431c2e --- /dev/null +++ b/Human-Connection/backend/public/img/badges/indiegogo_en_turtle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/indiegogo_en_whale.svg b/Human-Connection/backend/public/img/badges/indiegogo_en_whale.svg new file mode 100644 index 000000000..458e03b6d --- /dev/null +++ b/Human-Connection/backend/public/img/badges/indiegogo_en_whale.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/indiegogo_en_wolf.svg b/Human-Connection/backend/public/img/badges/indiegogo_en_wolf.svg new file mode 100644 index 000000000..e4952d86f --- /dev/null +++ b/Human-Connection/backend/public/img/badges/indiegogo_en_wolf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/user_role_admin.svg b/Human-Connection/backend/public/img/badges/user_role_admin.svg new file mode 100644 index 000000000..101e7458d --- /dev/null +++ b/Human-Connection/backend/public/img/badges/user_role_admin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/user_role_developer.svg b/Human-Connection/backend/public/img/badges/user_role_developer.svg new file mode 100644 index 000000000..55d363c9a --- /dev/null +++ b/Human-Connection/backend/public/img/badges/user_role_developer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/user_role_moderator.svg b/Human-Connection/backend/public/img/badges/user_role_moderator.svg new file mode 100644 index 000000000..bb2e5fde6 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/user_role_moderator.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/wooold_de_bee.svg b/Human-Connection/backend/public/img/badges/wooold_de_bee.svg new file mode 100644 index 000000000..e716c6116 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/wooold_de_bee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/wooold_de_butterfly.svg b/Human-Connection/backend/public/img/badges/wooold_de_butterfly.svg new file mode 100644 index 000000000..6d2b83e31 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/wooold_de_butterfly.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/wooold_de_double_rainbow.svg b/Human-Connection/backend/public/img/badges/wooold_de_double_rainbow.svg new file mode 100644 index 000000000..406001188 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/wooold_de_double_rainbow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/wooold_de_end_of_rainbow.svg b/Human-Connection/backend/public/img/badges/wooold_de_end_of_rainbow.svg new file mode 100644 index 000000000..2ae24cb7b --- /dev/null +++ b/Human-Connection/backend/public/img/badges/wooold_de_end_of_rainbow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/wooold_de_flower.svg b/Human-Connection/backend/public/img/badges/wooold_de_flower.svg new file mode 100644 index 000000000..ffc4b3da4 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/wooold_de_flower.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/wooold_de_lifetree.svg b/Human-Connection/backend/public/img/badges/wooold_de_lifetree.svg new file mode 100644 index 000000000..5a89fa5f9 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/wooold_de_lifetree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/wooold_de_magic_rainbow.svg b/Human-Connection/backend/public/img/badges/wooold_de_magic_rainbow.svg new file mode 100644 index 000000000..74df95190 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/wooold_de_magic_rainbow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/public/img/badges/wooold_de_super_founder.svg b/Human-Connection/backend/public/img/badges/wooold_de_super_founder.svg new file mode 100644 index 000000000..b437f6383 --- /dev/null +++ b/Human-Connection/backend/public/img/badges/wooold_de_super_founder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Human-Connection/backend/src/activitypub/ActivityPub.js b/Human-Connection/backend/src/activitypub/ActivityPub.js new file mode 100644 index 000000000..3ce27e109 --- /dev/null +++ b/Human-Connection/backend/src/activitypub/ActivityPub.js @@ -0,0 +1,227 @@ +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 (activityPubEndpointUri, internalGraphQlUri) { + this.endpoint = activityPubEndpointUri + this.dataSource = new NitroDataSource(internalGraphQlUri) + this.collections = new Collections(this.dataSource) + } + + static init (server) { + if (!activityPub) { + dotenv.config() + activityPub = new ActivityPub(process.env.CLIENT_URI || 'http://localhost:3000', process.env.GRAPHQL_URI || 'http://localhost:4000') + + // integrate into running graphql express server + server.express.set('ap', activityPub) + 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) + // 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 `https://${this.host}/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/Human-Connection/backend/src/activitypub/Collections.js b/Human-Connection/backend/src/activitypub/Collections.js new file mode 100644 index 000000000..227e1717b --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/src/activitypub/NitroDataSource.js b/Human-Connection/backend/src/activitypub/NitroDataSource.js new file mode 100644 index 000000000..4225e02ea --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/src/activitypub/routes/inbox.js b/Human-Connection/backend/src/activitypub/routes/inbox.js new file mode 100644 index 000000000..f9cfb3794 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/src/activitypub/routes/index.js b/Human-Connection/backend/src/activitypub/routes/index.js new file mode 100644 index 000000000..24898e766 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/src/activitypub/routes/serveUser.js b/Human-Connection/backend/src/activitypub/routes/serveUser.js new file mode 100644 index 000000000..f65876741 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/src/activitypub/routes/user.js b/Human-Connection/backend/src/activitypub/routes/user.js new file mode 100644 index 000000000..017891e61 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/src/activitypub/routes/verify.js b/Human-Connection/backend/src/activitypub/routes/verify.js new file mode 100644 index 000000000..bb5850b3e --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/src/activitypub/routes/webFinger.js b/Human-Connection/backend/src/activitypub/routes/webFinger.js new file mode 100644 index 000000000..8def32328 --- /dev/null +++ b/Human-Connection/backend/src/activitypub/routes/webFinger.js @@ -0,0 +1,39 @@ +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] + + let result + try { + result = await req.app.get('ap').dataSource.client.query({ + query: gql` + query { + User(slug: "${name}") { + slug + } + } + ` + }) + } catch (error) { + return res.status(500).json({ error }) + } + + 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/Human-Connection/backend/src/activitypub/security/httpSignature.spec.js b/Human-Connection/backend/src/activitypub/security/httpSignature.spec.js new file mode 100644 index 000000000..d40c38242 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/src/activitypub/security/index.js b/Human-Connection/backend/src/activitypub/security/index.js new file mode 100644 index 000000000..fdb1e27c6 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/src/activitypub/utils/activity.js b/Human-Connection/backend/src/activitypub/utils/activity.js new file mode 100644 index 000000000..57b6dfb83 --- /dev/null +++ b/Human-Connection/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': `${activityPub.endpoint}/activitypub/users/${name}/status/${createUuid}`, + 'type': 'Create', + 'actor': `${activityPub.endpoint}/activitypub/users/${name}`, + 'object': { + 'id': `${activityPub.endpoint}/activitypub/users/${name}/status/${id}`, + 'type': 'Note', + 'published': published, + 'attributedTo': `${activityPub.endpoint}/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(`${activityPub.endpoint}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) + .actor(`${activityPub.endpoint}/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(`${activityPub.endpoint}/activitypub/users/${name}/status/` + crypto.randomBytes(16).toString('hex')) + .actor(`${activityPub.endpoint}/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/Human-Connection/backend/src/activitypub/utils/actor.js b/Human-Connection/backend/src/activitypub/utils/actor.js new file mode 100644 index 000000000..27612517b --- /dev/null +++ b/Human-Connection/backend/src/activitypub/utils/actor.js @@ -0,0 +1,41 @@ +import { activityPub } from '../ActivityPub' + +export function createActor (name, pubkey) { + return { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1' + ], + 'id': `${activityPub.endpoint}/activitypub/users/${name}`, + 'type': 'Person', + 'preferredUsername': `${name}`, + 'name': `${name}`, + 'following': `${activityPub.endpoint}/activitypub/users/${name}/following`, + 'followers': `${activityPub.endpoint}/activitypub/users/${name}/followers`, + 'inbox': `${activityPub.endpoint}/activitypub/users/${name}/inbox`, + 'outbox': `${activityPub.endpoint}/activitypub/users/${name}/outbox`, + 'url': `${activityPub.endpoint}/activitypub/@${name}`, + 'endpoints': { + 'sharedInbox': `${activityPub.endpoint}/activitypub/inbox` + }, + 'publicKey': { + 'id': `${activityPub.endpoint}/activitypub/users/${name}#main-key`, + 'owner': `${activityPub.endpoint}/activitypub/users/${name}`, + 'publicKeyPem': pubkey + } + } +} + +export function createWebFinger (name) { + const { host } = new URL(activityPub.endpoint) + return { + 'subject': `acct:${name}@${host}`, + 'links': [ + { + 'rel': 'self', + 'type': 'application/activity+json', + 'href': `${activityPub.endpoint}/activitypub/users/${name}` + } + ] + } +} diff --git a/Human-Connection/backend/src/activitypub/utils/collection.js b/Human-Connection/backend/src/activitypub/utils/collection.js new file mode 100644 index 000000000..e3a63c74d --- /dev/null +++ b/Human-Connection/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': `${activityPub.endpoint}/activitypub/users/${name}/${collectionName}`, + 'summary': `${name}s ${collectionName} collection`, + 'type': 'OrderedCollection', + 'first': `${activityPub.endpoint}/activitypub/users/${name}/${collectionName}?page=true`, + 'totalItems': 0 + } +} + +export function createOrderedCollectionPage (name, collectionName) { + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': `${activityPub.endpoint}/activitypub/users/${name}/${collectionName}?page=true`, + 'summary': `${name}s ${collectionName} collection`, + 'type': 'OrderedCollectionPage', + 'totalItems': 0, + 'partOf': `${activityPub.endpoint}/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/Human-Connection/backend/src/activitypub/utils/index.js b/Human-Connection/backend/src/activitypub/utils/index.js new file mode 100644 index 000000000..a83dcc829 --- /dev/null +++ b/Human-Connection/backend/src/activitypub/utils/index.js @@ -0,0 +1,102 @@ +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.endpoint) { + return `${fromDomain}/activitypub/users/${name}` +} + +export function extractDomainFromUrl (url) { + return new URL(url).host +} + +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: `${activityPub.endpoint}/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/Human-Connection/backend/src/bootstrap/directives.js b/Human-Connection/backend/src/bootstrap/directives.js new file mode 100644 index 000000000..8c392ed46 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/src/bootstrap/neo4j.js b/Human-Connection/backend/src/bootstrap/neo4j.js new file mode 100644 index 000000000..935449a0a --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/src/bootstrap/scalars.js b/Human-Connection/backend/src/bootstrap/scalars.js new file mode 100644 index 000000000..813bd5051 --- /dev/null +++ b/Human-Connection/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/Human-Connection/backend/src/graphql-schema.js b/Human-Connection/backend/src/graphql-schema.js new file mode 100644 index 000000000..57b2ffb6c --- /dev/null +++ b/Human-Connection/backend/src/graphql-schema.js @@ -0,0 +1,29 @@ +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' +import rewards from './resolvers/rewards.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, + ...posts.Mutation, + ...moderation.Mutation, + ...rewards.Mutation + } +} diff --git a/Human-Connection/backend/src/helpers/asyncForEach.js b/Human-Connection/backend/src/helpers/asyncForEach.js new file mode 100644 index 000000000..1f05ea915 --- /dev/null +++ b/Human-Connection/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