Merge branch 'master' of github.com:Human-Connection/Human-Connection into helm

This commit is contained in:
mattwr18 2020-01-28 21:07:17 +01:00
commit aa799e6f6b
629 changed files with 45480 additions and 21816 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
backend/snapshots/* linguist-generated=true

View File

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 490.1 105.6" style="enable-background:new 0 0 490.1 105.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#F4B960;}
.st1{fill:#E66F32;}
.st2{fill:#E43C41;}
.st3{fill:#BDD041;}
.st4{fill:#6DB54C;}
.st5{fill:#AEDAE6;}
.st6{fill:#56B8DE;}
.st7{fill:#00B1D5;}
.st8{fill:url(#SVGID_1_);}
.st9{fill:#221F1F;}
.st10{fill:#FFFFFF;}
.st11{fill:#000111;}
</style>
<title>Browserstack-logo-white</title>
<circle class="st0" cx="52.8" cy="52.8" r="52.8"/>
<circle class="st1" cx="47.5" cy="47.5" r="47.5"/>
<circle class="st2" cx="53.8" cy="41.1" r="41.1"/>
<circle class="st3" cx="57.1" cy="44.4" r="37.8"/>
<circle class="st4" cx="54.3" cy="47.2" r="35.1"/>
<circle class="st5" cx="48.8" cy="41.7" r="29.5"/>
<circle class="st6" cx="53.6" cy="36.8" r="24.7"/>
<circle class="st7" cx="56.6" cy="39.9" r="21.7"/>
<radialGradient id="SVGID_1_" cx="53.45" cy="63.02" r="18.57" gradientTransform="matrix(1 0 0 -1 0 106)" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#797979"/>
<stop offset="1" style="stop-color:#4C4C4C"/>
</radialGradient>
<circle class="st8" cx="53.5" cy="43" r="18.6"/>
<circle class="st9" cx="53.5" cy="43" r="18.6"/>
<ellipse transform="matrix(0.4094 -0.9123 0.9123 0.4094 2.8913 76.9251)" class="st10" cx="60.9" cy="36.2" rx="5.7" ry="3.7"/>
<path class="st11" d="M122.5,32.6c0-0.3,0.3-0.6,0.6-0.6c0,0,0,0,0.1,0h16.6c9.5,0,13.9,4.4,13.9,11c0.2,3.7-1.8,7.2-5.2,8.8v0.1
c3.7,1.5,6.1,5.2,6,9.3c0,8.2-5.6,12.2-15.4,12.2h-16c-0.3,0-0.6-0.2-0.7-0.5c0,0,0,0,0-0.1L122.5,32.6L122.5,32.6z M139.6,49.1
c3.9,0,6.4-2.2,6.4-5.4s-2.4-5.5-6.4-5.5h-8.9c-0.2,0-0.4,0.1-0.4,0.3c0,0,0,0,0,0.1v10.2c0,0.2,0.1,0.3,0.3,0.4c0,0,0,0,0.1,0
H139.6L139.6,49.1z M130.6,66.9h9.3c4.3,0,6.8-2.3,6.8-5.8s-2.4-5.7-6.7-5.7h-9.3c-0.2,0-0.4,0.1-0.4,0.3c0,0,0,0,0,0.1v10.7
C130.3,66.8,130.4,66.9,130.6,66.9C130.6,66.9,130.6,66.9,130.6,66.9L130.6,66.9z"/>
<path class="st11" d="M159.9,73.3c-0.3,0-0.6-0.2-0.7-0.5c0,0,0,0,0-0.1V44.6c0-0.3,0.3-0.6,0.6-0.6c0,0,0,0,0.1,0h6
c0.3,0,0.6,0.2,0.7,0.5c0,0,0,0,0,0.1v2.5h0.1c1.5-2.2,4.2-3.8,8.2-3.8c2.4,0,4.8,0.8,6.6,2.4c0.3,0.3,0.4,0.5,0.1,0.8l-3.5,4.1
c-0.2,0.3-0.6,0.4-0.9,0.2c0,0,0,0-0.1,0c-1.4-0.9-3-1.4-4.7-1.4c-4.1,0-6,2.7-6,7.4v15.9c0,0.3-0.3,0.6-0.6,0.6c0,0,0,0-0.1,0
H159.9L159.9,73.3z"/>
<path class="st11" d="M182.9,65.8c-0.8-2.3-1.1-4.8-1.1-7.2c-0.1-2.5,0.3-4.9,1.1-7.2c1.8-5.1,6.6-8.1,13.1-8.1s11.2,3,13,8.1
c0.8,2.3,1.1,4.8,1.1,7.2c0.1,2.5-0.3,4.9-1.1,7.2c-1.8,5.1-6.6,8.1-13,8.1S184.7,71,182.9,65.8z M201.9,64c0.5-1.7,0.8-3.6,0.7-5.4
c0.1-1.8-0.1-3.7-0.7-5.4c-0.9-2.5-3.3-4-5.9-3.8c-2.6-0.2-5.1,1.4-6,3.8c-0.5,1.8-0.8,3.6-0.7,5.4c-0.1,1.8,0.1,3.7,0.7,5.4
c0.9,2.5,3.4,4,6,3.8C198.6,68,201,66.5,201.9,64L201.9,64z"/>
<path class="st11" d="M241.9,73.3c-0.4,0-0.7-0.3-0.8-0.6L235,53.9h-0.1l-6.2,18.7c-0.1,0.4-0.4,0.6-0.8,0.6h-5.4
c-0.4,0-0.7-0.3-0.8-0.6l-10-28.1c-0.1-0.2,0-0.5,0.2-0.6c0.1,0,0.2-0.1,0.3,0h6.3c0.4,0,0.8,0.2,0.9,0.6l6.1,19.3h0.1l6-19.3
c0.1-0.4,0.5-0.6,0.9-0.6h4.7c0.4,0,0.7,0.2,0.9,0.6l6.4,19.3h0.1l5.8-19.3c0.1-0.4,0.5-0.7,0.9-0.6h6.3c0.2-0.1,0.5,0.1,0.5,0.3
c0,0.1,0,0.2,0,0.3l-10,28.1c-0.1,0.4-0.4,0.6-0.8,0.6L241.9,73.3L241.9,73.3z"/>
<path class="st11" d="M259.3,69.3c-0.2-0.2-0.3-0.6-0.1-0.8c0,0,0,0,0.1-0.1l3.7-3.6c0.3-0.2,0.7-0.2,0.9,0c2.6,2.1,5.9,3.3,9.3,3.3
c3.9,0,5.9-1.5,5.9-3.5c0-1.8-1.1-2.9-5.2-3.2l-3.4-0.3c-6.4-0.6-9.7-3.6-9.7-8.6c0-5.7,4.4-9.2,12.3-9.2c4.2-0.1,8.4,1.2,11.9,3.6
c0.3,0.2,0.3,0.5,0.2,0.8c0,0,0,0,0,0.1l-3.2,3.6c-0.2,0.3-0.6,0.3-0.9,0.1c-2.5-1.5-5.4-2.4-8.3-2.4c-3.1,0-4.8,1.3-4.8,3
s1.1,2.7,5.2,3.1l3.4,0.3c6.6,0.6,9.8,3.8,9.8,8.6c0,5.8-4.6,9.9-13.3,9.9C268,74,263.2,72.4,259.3,69.3z"/>
<path class="st11" d="M291.2,65.8c-0.8-2.3-1.2-4.7-1.1-7.2c-0.1-2.5,0.3-4.9,1-7.2c1.8-5.1,6.6-8.1,12.9-8.1c6.5,0,11.2,3.1,13,8.1
c0.7,2.1,1,4.1,1,8.8c0,0.3-0.3,0.6-0.6,0.6c0,0-0.1,0-0.1,0h-19.5c-0.2,0-0.4,0.1-0.4,0.3c0,0,0,0,0,0.1c0,0.8,0.2,1.5,0.5,2.2
c1,2.9,3.5,4.4,7.1,4.4c2.7,0.1,5.4-0.9,7.4-2.8c0.2-0.3,0.7-0.4,1-0.1c0,0,0,0,0,0l3.9,3.2c0.2,0.1,0.3,0.5,0.2,0.7
c0,0.1-0.1,0.1-0.1,0.1c-2.7,2.9-7.2,5-13,5C297.8,73.9,293,70.9,291.2,65.8z M310.4,52.8c-0.9-2.4-3.2-3.8-6.2-3.8
s-5.4,1.4-6.2,3.8c-0.3,0.8-0.4,1.6-0.4,2.5c0,0.2,0.1,0.3,0.3,0.4c0,0,0,0,0.1,0h12.4c0.2,0,0.4-0.1,0.4-0.3c0,0,0,0,0-0.1
C310.8,54.5,310.6,53.6,310.4,52.8L310.4,52.8z"/>
<path class="st11" d="M323.6,73.3c-0.3,0-0.6-0.2-0.7-0.5c0,0,0,0,0-0.1V44.6c0-0.3,0.3-0.6,0.6-0.6c0,0,0,0,0.1,0h6
c0.3,0,0.6,0.2,0.7,0.5c0,0,0,0,0,0.1v2.5h0.1c1.5-2.2,4.2-3.8,8.2-3.8c2.4,0,4.8,0.8,6.6,2.4c0.3,0.3,0.4,0.5,0.1,0.8l-3.5,4.1
c-0.2,0.3-0.6,0.4-0.9,0.2c0,0,0,0-0.1,0c-1.4-0.9-3-1.4-4.7-1.4c-4.1,0-6,2.7-6,7.4v15.9c0,0.3-0.3,0.6-0.6,0.6c0,0,0,0-0.1,0
H323.6L323.6,73.3z"/>
<path class="st11" d="M346.5,68.5c-0.3-0.2-0.4-0.6-0.2-0.9c0,0,0,0,0,0l4.1-4.4c0.2-0.3,0.6-0.3,0.9-0.1c0,0,0,0,0,0
c3.5,2.7,7.7,4.2,12.1,4.4c5.3,0,8.4-2.5,8.4-6c0-3-2-4.9-8.1-5.7l-2.4-0.3c-8.6-1.1-13.5-4.9-13.5-11.8c0-7.5,5.9-12.4,15.1-12.4
c5.1-0.1,10.1,1.4,14.5,4.2c0.3,0.1,0.4,0.4,0.2,0.7c0,0.1-0.1,0.1-0.1,0.2l-3.1,4.5c-0.2,0.3-0.6,0.4-0.9,0.2
c-3.2-2.1-6.9-3.2-10.7-3.2c-4.5,0-7,2.3-7,5.5c0,2.9,2.2,4.8,8.2,5.6l2.4,0.3c8.6,1.1,13.3,4.9,13.3,12c0,7.3-5.7,12.8-16.8,12.8
C356.3,73.9,350,71.5,346.5,68.5z"/>
<path class="st11" d="M393.3,73.8c-6.4,0-8.8-2.9-8.8-8.6V49.8c0-0.2-0.1-0.3-0.3-0.4c0,0,0,0-0.1,0H382c-0.3,0-0.6-0.2-0.7-0.5
c0,0,0,0,0-0.1v-4.1c0-0.3,0.3-0.6,0.6-0.6c0,0,0,0,0.1,0h2.1c0.2,0,0.4-0.1,0.4-0.3c0,0,0,0,0-0.1v-8c0-0.3,0.3-0.6,0.6-0.6
c0,0,0,0,0.1,0h6c0.3,0,0.6,0.2,0.7,0.5c0,0,0,0,0,0.1v8c0,0.2,0.1,0.3,0.3,0.4c0,0,0,0,0.1,0h4.2c0.3,0,0.6,0.2,0.7,0.5
c0,0,0,0,0,0.1v4.1c0,0.3-0.3,0.6-0.6,0.6c0,0,0,0-0.1,0h-4.2c-0.2,0-0.4,0.1-0.4,0.3c0,0,0,0,0,0.1V65c0,2.1,0.9,2.7,3,2.7h1.6
c0.3,0,0.6,0.2,0.7,0.5c0,0,0,0,0,0.1v4.9c0,0.3-0.3,0.6-0.6,0.6c0,0,0,0-0.1,0L393.3,73.8L393.3,73.8z"/>
<path class="st11" d="M421.2,73.3c-0.3,0-0.6-0.2-0.7-0.5c0,0,0,0,0-0.1v-2.1h0c-1.5,2-4.5,3.4-8.9,3.4c-5.8,0-10.6-2.8-10.6-8.9
c0-6.4,4.9-9.3,12.7-9.3h6.4c0.2,0,0.4-0.1,0.4-0.3c0,0,0,0,0-0.1v-1.4c0-3.3-1.7-4.9-7-4.9c-2.6-0.1-5.1,0.6-7.2,2
c-0.3,0.2-0.7,0.2-0.9-0.1c0,0,0,0,0-0.1l-2.4-4c-0.2-0.2-0.1-0.6,0.1-0.8c0,0,0,0,0,0c2.6-1.7,6-2.9,11.2-2.9
c9.6,0,13.2,3,13.2,10.2v19.1c0,0.3-0.3,0.6-0.6,0.6c0,0,0,0-0.1,0H421.2L421.2,73.3z M420.4,63.4v-2.2c0-0.2-0.1-0.3-0.3-0.4
c0,0,0,0-0.1,0h-5.2c-4.7,0-6.8,1.2-6.8,3.9c0,2.4,1.9,3.6,5.5,3.6C417.9,68.4,420.4,66.8,420.4,63.4L420.4,63.4z"/>
<path class="st11" d="M433.1,65.8c-0.7-2.3-1.1-4.8-1-7.2c-0.1-2.4,0.3-4.9,1-7.2c1.8-5.2,6.7-8.1,13.1-8.1c4.2-0.2,8.2,1.5,11,4.6
c0.2,0.2,0.2,0.6,0,0.8c0,0,0,0-0.1,0.1l-4.1,3.3c-0.3,0.2-0.7,0.2-0.9-0.1c0,0,0,0,0-0.1c-1.5-1.7-3.6-2.6-5.9-2.5
c-2.8,0-5,1.3-5.9,3.8c-0.5,1.8-0.8,3.6-0.7,5.4c-0.1,1.8,0.1,3.7,0.7,5.5c0.9,2.5,3.1,3.8,5.9,3.8c2.2,0.1,4.4-0.9,5.9-2.6
c0.2-0.3,0.6-0.3,0.9-0.1c0,0,0,0,0,0l4.1,3.3c0.3,0.2,0.3,0.5,0.1,0.8c0,0,0,0-0.1,0.1c-2.9,3-6.9,4.6-11,4.5
C439.8,73.9,435,71.1,433.1,65.8z"/>
<path class="st11" d="M482.8,73.3c-0.4,0-0.8-0.2-1-0.6l-8-12.3l-4.3,4.6v7.7c0,0.3-0.3,0.6-0.6,0.6c0,0,0,0-0.1,0h-6
c-0.3,0-0.6-0.2-0.7-0.5c0,0,0,0,0-0.1V32.6c0-0.3,0.3-0.6,0.6-0.6c0,0,0,0,0.1,0h6c0.3,0,0.6,0.2,0.7,0.5c0,0,0,0,0,0.1v23.8
l10.8-11.8c0.3-0.4,0.8-0.6,1.2-0.6h6.7c0.2,0,0.4,0.1,0.4,0.3c0,0.1,0,0.3-0.1,0.3l-10.1,10.7L490,72.7c0.1,0.2,0.1,0.4,0,0.5
c-0.1,0.1-0.2,0.1-0.3,0.1H482.8L482.8,73.3z"/>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -6,11 +6,19 @@ title: 🚀 [Feature]
--- ---
## :rocket: Feature ## :rocket: Feature
<!-- Describe the Feature. Use Screenshots if possible. --> <!-- Give a short summary of the Feature. Use Screenshots if you want. -->
### User Problem
<!-- Which problem is this solving? Why do you think this is important? Who will benefit from it and how? -->
### Implementation
<!-- How do you think this feature should be implemented? How will it be used? Where in the network should it be located? Which steps and screens are involved? -->
### Design & Layout ### Design & Layout
<!-- Attach Screenshots and Drawings. --> <!-- Attach Screenshots and Sketches to illustrate your idea. -->
### Validation
<!-- How can we make sure that this feature indeed solves the above problem? How do we know if it has been accepted by the users of the network, once released? -->
### Additional context ### Additional context
<!-- Add any other context or screenshots about the feature request here.--> <!-- Add other context or background about the feature request here.-->

18
.github/stale.yml vendored Normal file
View File

@ -0,0 +1,18 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 60
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 30
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
- bounty
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

1
.gitignore vendored
View File

@ -18,3 +18,4 @@ cypress.env.json
**/coverage **/coverage
release/ release/
*~

View File

@ -6,20 +6,19 @@ addons:
- libgconf-2-4 - libgconf-2-4
snaps: snaps:
- docker - docker
- chromium
before_install: install:
- yarn global add wait-on - yarn global add wait-on
# Install Codecov # Install Codecov
- yarn install - yarn install
- cp cypress.env.template.json cypress.env.json - cp backend/.env.template backend/.env
install: before_script:
- docker-compose -f docker-compose.yml build --parallel - docker-compose -f docker-compose.yml build --parallel
- docker-compose -f docker-compose.yml -f docker-compose.build-and-test.yml build # just tagging, just be quite fast - docker-compose -f docker-compose.yml -f docker-compose.build-and-test.yml build # just tagging, just be quite fast
- docker-compose -f docker-compose.yml -f docker-compose.build-and-test.yml up -d - docker-compose -f docker-compose.yml -f docker-compose.build-and-test.yml up -d
- wait-on http://localhost:7474 - wait-on http://localhost:7474
- docker-compose -f docker-compose.yml -f docker-compose.build-and-test.yml exec neo4j db_setup - docker-compose -f docker-compose.yml -f docker-compose.build-and-test.yml exec backend yarn run db:migrate init
script: script:
- export CYPRESS_RETRIES=1 - export CYPRESS_RETRIES=1
@ -27,22 +26,18 @@ script:
- echo "TRAVIS_BRANCH=$TRAVIS_BRANCH, PR=$PR, BRANCH=$BRANCH" - echo "TRAVIS_BRANCH=$TRAVIS_BRANCH, PR=$PR, BRANCH=$BRANCH"
# Backend # Backend
- docker-compose exec backend yarn run lint - docker-compose exec backend yarn run lint
- docker-compose exec backend yarn run test:jest --ci --verbose=false --coverage - docker-compose exec backend yarn run test --ci --verbose=false --coverage
- docker-compose exec backend yarn run db:seed - docker-compose exec backend yarn run db:seed
- docker-compose exec backend yarn run db:reset - docker-compose exec backend yarn run db:reset
# ActivityPub cucumber testing temporarily disabled because it's too buggy
# - docker-compose exec backend yarn run test:cucumber --tags "not @wip"
# - docker-compose exec backend yarn run db:reset
# - docker-compose exec backend yarn run db:seed
# Frontend # Frontend
- docker-compose exec webapp yarn run lint - docker-compose exec webapp yarn run lint
- docker-compose exec webapp yarn run test --ci --verbose=false --coverage - docker-compose exec webapp yarn run test --ci --verbose=false --coverage
- docker-compose exec -d backend yarn run test:before:seeder
# Fullstack # Fullstack
- docker-compose down - docker-compose down
- docker-compose -f docker-compose.yml up -d - docker-compose -f docker-compose.yml up -d
- wait-on http://localhost:7474 - wait-on http://localhost:7474
- yarn run cypress:run - yarn run cypress:run --record
- yarn run cucumber
# Coverage # Coverage
- yarn run codecov - yarn run codecov
@ -67,14 +62,14 @@ before_deploy:
deploy: deploy:
- provider: script - provider: script
script: scripts/docker_push.sh script: bash scripts/docker_push.sh
on: on:
branch: master branch: master
- provider: script - provider: script
script: scripts/deploy.sh script: bash scripts/deploy.sh
on: on:
branch: master branch: master
- provider: script - provider: script
script: scripts/github_release.sh script: bash scripts/github_release.sh
on: on:
branch: master branch: master

7
.versionrc.json Normal file
View File

@ -0,0 +1,7 @@
{
"bumpFiles": [
"package.json",
"backend/package.json",
"webapp/package.json"
]
}

View File

@ -7,6 +7,5 @@
"autoFix": true "autoFix": true
} }
], ],
"editor.formatOnSave": true, "editor.formatOnSave": false,
"eslint.autoFixOnSave": true
} }

2068
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,79 +1,101 @@
# CONTRIBUTING # CONTRIBUTING
Thanks so much for thinking of contributing to the Human Connection project, we really appreciate it! :-\) Thank you so much for thinking of contributing to the Human Connection project! It's awesome you're here, we really appreciate it. :-\)
## Getting Set Up ## Getting Set Up
Instructions for how to install all the necessary software can be found in our [documentation](https://docs.human-connection.org/human-connection/). Instructions for how to install all the necessary software and some code guidelines can be found in our [documentation](https://docs.human-connection.org/human-connection/).
We recommend that new folks should ideally work together with an existing developer. Please join our [discord](https://discord.gg/6ub73U3) 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): To get you started we recommend that you join forces with a regular contributor. Please join [our discord instance](https://human-connection.org/discord) to chat with developers or just get in touch directly on an issue on either [Github](https://github.com/Human-Connection/Human-Connection/issues) or [Zenhub](https://app.zenhub.com/workspaces/human-connection-nitro-5c0154ecc699f60fc92cf11f/boards?repos=152252353):
![](https://dl.dropbox.com/s/vbmcihkduy9dhko/Screenshot%202019-01-03%2015.50.11.png?dl=0) ![](https://dl.dropbox.com/s/vbmcihkduy9dhko/Screenshot%202019-01-03%2015.50.11.png?dl=0)
Here are some general notes on our development flow: We also have regular pair programming sessions that you are very welcome to join! We feel this is often the best way to get to know both the project and the team. Most developers are also available for spontaneous sessions if the times listed below don't work for you just ping us on discord.
## Development ## Development Flow
* Currently operating in two week sprints We operate in two week sprints that are planned, estimated and prioritised on [Zenhub](https://app.zenhub.com/workspaces/human-connection-nitro-5c0154ecc699f60fc92cf11f). All issues are also linked to and synced with [Github](https://github.com/Human-Connection/Human-Connection/issues). Look for the `good first issue` label if you're not sure where to start!
* 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 We try to discuss all questions directly related to a feature or bug in the respective issue, in order to preserve it for the future and for other developers. We use discord for real-time communication.
* "up-for-grabs" links to [Github project](https://github.com/Human-Connection/Human-Connection/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)
* ordering on ZenHub not necessarily reflected on github projects This is how we solve bugs and implement features, step by step:
* AgileVentures run open pairing sessions at 10:30am UTC each week on Tuesdays and Thursdays 1. We find an issue we want to work on, usually during the sprint planning but as an open source contributor this can happen at any time.
* Core team 2. We communicate with the team to see if the issue is still available. (When you comment on an issue but don't get an answer there within 1-2 days try to mention @Human-Connection/hc-dev-team to make sure we check in.)
* all the people who are hired by HC non-profit corporation 3. We make sure we understand the issue in detail what problem is it solving and how should it be implemented?
* 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/). 4. We assign ourselves to the issue and move it to `In Progress` on [Zenhub](https://app.zenhub.com/workspaces/human-connection-nitro-5c0154ecc699f60fc92cf11f).
* 9 people 5. We start working on it in a `new branch` and open a `pull request` prefixed with `[WIP]` (work in progress) to which we regularly push our changes.
* 2 core developers \(Robert [@roschaefer](https://github.com/roschaefer) and Greg [@appinteractive](https://github.com/appinteractive)\) 6. When questions come up we clarify them with the team (directly in the issue on Github).
* 3 marketeers Jasi, Dennis and Sensi 7. When we are happy with our work and our PR is passing all tests we remove the `[WIP]` from the PR description and ask for reviews (if you're not sure who to ask there is @Human-Connection/hc-dev-team which pings all core developers).
* Hardy doing business development 8. We then incorporate the suggestions from the reviews into our work and once it has been approved it can be merged into master!
* Martin head of IT and previously data protection officer
* Victor doing accounting and controlling Every pull request needs to:
* Nicolas is the community manager \(reviews content in the network\) reflects community opinion back to the core team * fix an issue (if there is something you want to work on but there is no issue for it, create one first and discuss it with the team)
* when can folks pair with Robert * include tests for the code that is added or changed
* 10am UTC until 5pm UTC every working day * pass all tests (linter, backend, frontend, end-to-end)
* be approved by at least 1 developer who is not the owner of the PR (when more than 10 files were changed it needs 2 approvals)
## The Team
There are many volunteers all around the world helping us build this network and without their contributions we wouldn't be where we are today. Big thank you to all of you!
You can see the core team behind Human Connection [on our website](https://human-connection.org/en/the-team/). On Github you will mostly run into our developers:
* Robert (@roschaefer)
* Matt (@mattwr18)
* Wolle (@Tirokk)
* Alex (@ogerly)
* Alina (@alina-beck)
* Martin (@datenbrei), our head of IT
* and sometimes Dennis (@DennisHack), the founder of Human Connection
## Meetings and Pair Programming Sessions
Times below refer to **German Time** that's CET (GMT+1) in winter and CEST (GMT+2) in summer because most Human Connection core team members are living in Germany.
Daily standup
* every MondayFriday 11:30
* in the discord `Conference Room`
* all contributors welcome!
* everybody shares what they are working on and asks for help if they are blocked
Regular pair programming sessions
* every Monday, Wednesday and Thursday 15:00
* the link will be posted in the [discord chat](https://discord.gg/6ub73U3) and on the [Agile Ventures website](https://www.agileventures.org/events?utf8=%E2%9C%93&project_id=220&commit=Filter+by+Project)
* all contributors welcome!
* we team up and work on an issue together (often using Visual Studio live sharing sessions)
Open-Source Community Meeting
* every Thursday 13:00
* the link will be posted in the [discord chat](https://discord.gg/6ub73U3) and on the [Agile Ventures website](https://www.agileventures.org/events?utf8=%E2%9C%93&project_id=220&commit=Filter+by+Project)
* all contributors welcome!
Meet the team
* every Monday 21:00 (at the moment only in German)
* details here https://human-connection.org/veranstaltungen/
* via this [zoom link](https://zoom.us/j/936943532)
* all contributors and users of the network welcome!
* users of the network chat with the Human Connection team and discuss current questions and issues
Sprint planning
* bi-weekly on Tuesday 13:00
* via this [zoom link](https://zoom.us/j/7743582385)
* all contributors welcome (recommended for those who want to work on an issue in this sprint)
* we select and prioritise the issues we will work on in the following two weeks
Sprint retrospective
* bi-weekly on Monday 13:00
* via this [zoom link](https://zoom.us/j/7743582385)
* all contributors welcome (most interesting for those who participated in the sprint)
* we review the past sprint and talk about what went well and what we could improve
## Philosophy ## Philosophy
We practise [collective code ownership](http://www.extremeprogramming.org/rules/collective.html) rather than strong code ownership, which means that: We practise [collective code ownership](http://www.extremeprogramming.org/rules/collective.html) rather than strong code ownership, which means that:
* developers can make contributions to other people's PRs (after checking in with them)
* anyone can start working on anyone elses code * we avoid blocking because someone else isn't working, so we sometimes take over PRs from other developers
* 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 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 We believe in open source contributions as a learning experience everyone is welcome to join our team of volunteers and to contribute to the project, no matter their background or level of experience.
But what do we do when waiting for merge into master \(wanting to keep PRs small\) --&gt; Robert recommends creating a pull request for each step We use pair programming sessions as a tool for knowledge sharing. We can learn a lot from each other and only by sharing what we know and overcoming challenges together can we grow as a team and truly own this project collectively.
* programming is also about thinking about other people - empathy for your co-workers As a volunteeer you have no commitment except your own self development and your awesomeness by contributing to this free and open-source software project. Cheers to you!
* 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
### Code Review
* Github setting in place - at least one review is required to merge
- in principle anyone (who is not the PR owner) can review
- but often it will be the core developers (Robert, Ulf, Greg, Wolfgang?)
- once there is a review, and presuming no requested changes, PR opener can merge
* CI/tests
- the CI needs to pass
- linting <-- autofix?
- tests (unit, feature) (backend, frontend)
- codecoverage
## Notes
question: when you want to pick a task - \(find out priority\) - is it in discord? is it in AV slack? --&gt; 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) --&gt; 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](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\)

View File

@ -4,6 +4,7 @@
[![Codecov Coverage](https://img.shields.io/codecov/c/github/Human-Connection/Human-Connection/master.svg?style=flat-square)](https://codecov.io/gh/Human-Connection/Human-Connection/) [![Codecov Coverage](https://img.shields.io/codecov/c/github/Human-Connection/Human-Connection/master.svg?style=flat-square)](https://codecov.io/gh/Human-Connection/Human-Connection/)
[![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/Human-Connection/Nitro-Backend/blob/backend/LICENSE.md) [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/Human-Connection/Nitro-Backend/blob/backend/LICENSE.md)
[![Discord Channel](https://img.shields.io/discord/489522408076738561.svg)](https://discordapp.com/invite/DFSjPaX) [![Discord Channel](https://img.shields.io/discord/489522408076738561.svg)](https://discordapp.com/invite/DFSjPaX)
[![Open Source Helpers](https://www.codetriage.com/human-connection/human-connection/badges/users.svg)](https://www.codetriage.com/human-connection/human-connection)
Human Connection is a nonprofit social, action and knowledge network that connects information to action and promotes positive local and global change in all areas of life. Human Connection is a nonprofit social, action and knowledge network that connects information to action and promotes positive local and global change in all areas of life.
@ -24,7 +25,7 @@ Human Connection is a nonprofit social, action and knowledge network that connec
## Live demo ## Live demo
Try out our deployed [staging environment](https://nitro-staging.human-connection.org/). Try out our deployed [development environment](https://develop.human-connection.org/).
Logins: Logins:
@ -49,10 +50,16 @@ Join our friendly open-source community on [Discord](https://discordapp.com/invi
Just introduce yourself at `#introduce-yourself` and mention `@@Mentor` to get you onboard :neckbeard: Just introduce yourself at `#introduce-yourself` and mention `@@Mentor` to get you onboard :neckbeard:
Check out the [contribution guideline](./CONTRIBUTING.md), too! Check out the [contribution guideline](./CONTRIBUTING.md), too!
[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/0)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/0)[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/1)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/1)[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/2)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/2)[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/3)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/3)[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/4)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/4)[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/5)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/5)[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/6)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/6)[![](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/images/7)](https://sourcerer.io/fame/roschaefer/Human-Connection/Human-Connection/links/7)
## Attributions ## Attributions
Locale Icons made by [Freepik](http://www.freepik.com/) from [www.flaticon.com](https://www.flaticon.com/) is licensed by [CC 3.0 BY](http://creativecommons.org/licenses/by/3.0/) Locale Icons made by [Freepik](http://www.freepik.com/) from [www.flaticon.com](https://www.flaticon.com/) is licensed by [CC 3.0 BY](http://creativecommons.org/licenses/by/3.0/).
Browser compatibility testing with [BrowserStack](https://www.browserstack.com/).
<img alt="BrowserStack Logo" src=".gitbook/assets/browserstack-logo.svg" width="256">
## License ## License
See the [LICENSE](LICENSE.md) file for license rights and limitations (MIT). See the [LICENSE](LICENSE.md) file for license rights and limitations (MIT).

View File

@ -6,16 +6,12 @@
* [Neo4J](neo4j/README.md) * [Neo4J](neo4j/README.md)
* [Backend](backend/README.md) * [Backend](backend/README.md)
* [GraphQL](backend/graphql.md) * [GraphQL](backend/graphql.md)
* [neo4j-graphql-js](backend/neo4j-graphql-js.md)
* [Webapp](webapp/README.md) * [Webapp](webapp/README.md)
* [COMPONENTS](webapp/components.md) * [Components](webapp/components.md)
* [PLUGINS](webapp/plugins.md) * [HTML](webapp/html.md)
* [STORE](webapp/store.md) * [SCSS](webapp/scss.md)
* [PAGES](webapp/pages.md) * [Vue](webapp/vue.md)
* [ASSETS](webapp/assets.md)
* [LAYOUTS](webapp/layouts.md)
* [Styleguide](webapp/styleguide.md)
* [STATIC](webapp/static.md)
* [MIDDLEWARE](webapp/middleware.md)
* [Testing Guide](testing.md) * [Testing Guide](testing.md)
* [End-to-end tests](cypress/README.md) * [End-to-end tests](cypress/README.md)
* [Frontend tests](webapp/testing.md) * [Frontend tests](webapp/testing.md)
@ -32,9 +28,11 @@
* [Maintenance](deployment/human-connection/maintenance/README.md) * [Maintenance](deployment/human-connection/maintenance/README.md)
* [Volumes](deployment/volumes/README.md) * [Volumes](deployment/volumes/README.md)
* [Neo4J Offline-Backups](deployment/volumes/neo4j-offline-backup/README.md) * [Neo4J Offline-Backups](deployment/volumes/neo4j-offline-backup/README.md)
* [Neo4J Online-Backups](deployment/volumes/neo4j-online-backup/README.md)
* [Volume Snapshots](deployment/volumes/volume-snapshots/README.md) * [Volume Snapshots](deployment/volumes/volume-snapshots/README.md)
* [Reclaim Policy](deployment/volumes/reclaim-policy/README.md) * [Reclaim Policy](deployment/volumes/reclaim-policy/README.md)
* [Velero](deployment/volumes/velero/README.md) * [Velero](deployment/volumes/velero/README.md)
* [Metrics](deployment/monitoring/README.md)
* [Legacy Migration](deployment/legacy-migration/README.md) * [Legacy Migration](deployment/legacy-migration/README.md)
* [Feature Specification](cypress/features.md) * [Feature Specification](cypress/features.md)
* [Code of conduct](CODE_OF_CONDUCT.md) * [Code of conduct](CODE_OF_CONDUCT.md)

View File

@ -1 +0,0 @@
0.1.0

12
babel.config.json Normal file
View File

@ -0,0 +1,12 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "10"
}
}
]
]
}

View File

@ -1,7 +1,6 @@
NEO4J_URI=bolt://localhost:7687 NEO4J_URI=bolt://localhost:7687
NEO4J_USERNAME=neo4j NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=letmein NEO4J_PASSWORD=letmein
GRAPHQL_PORT=4000
GRAPHQL_URI=http://localhost:4000 GRAPHQL_URI=http://localhost:4000
CLIENT_URI=http://localhost:3000 CLIENT_URI=http://localhost:3000
SMTP_HOST= SMTP_HOST=
@ -17,3 +16,4 @@ PRIVATE_KEY_PASSPHRASE="a7dsf78sadg87ad87sfagsadg78"
SENTRY_DSN_BACKEND= SENTRY_DSN_BACKEND=
COMMIT= COMMIT=
PUBLIC_REGISTRATION=false

View File

@ -1,4 +1,4 @@
FROM node:12.10.0-alpine as base FROM node:lts-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)" 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 EXPOSE 4000
@ -24,4 +24,5 @@ FROM base as production
ENV NODE_ENV=production ENV NODE_ENV=production
COPY --from=build-and-test /nitro-backend/dist ./dist COPY --from=build-and-test /nitro-backend/dist ./dist
COPY ./public/img/ ./public/img/ COPY ./public/img/ ./public/img/
COPY ./public/providers.json ./public/providers.json
RUN yarn install --production=true --frozen-lockfile --non-interactive --no-cache RUN yarn install --production=true --frozen-lockfile --non-interactive --no-cache

View File

@ -53,6 +53,27 @@ can issue GraphQL requests or access GraphQL Playground in the browser.
![GraphQL Playground](../.gitbook/assets/graphql-playground.png) ![GraphQL Playground](../.gitbook/assets/graphql-playground.png)
### Database Indices and Constraints
Database indices and constraints need to be created when the database and the
backend is running:
{% tabs %}
{% tab title="Docker" %}
```bash
docker-compose exec backend yarn run db:migrate init
```
{% endtab %}
{% tab title="Without Docker" %}
```bash
# in folder backend/
# make sure your database is running on http://localhost:7474/browser/
yarn run db:migrate init
```
{% endtab %}
{% endtabs %}
#### Seed Database #### Seed Database
@ -72,6 +93,8 @@ To reset the database run:
$ docker-compose exec backend yarn run db:reset $ docker-compose exec backend yarn run db:reset
# you could also wipe out your neo4j database and delete all volumes with: # you could also wipe out your neo4j database and delete all volumes with:
$ docker-compose down -v $ docker-compose down -v
# if container is not running, run this command to set up your database indeces and contstraints
$ docker-compose run backend yarn run db:migrate init
``` ```
{% endtab %} {% endtab %}
@ -88,6 +111,37 @@ $ yarn run db:reset
{% endtab %} {% endtab %}
{% endtabs %} {% endtabs %}
### Data migrations
Although Neo4J is schema-less,you might find yourself in a situation in which
you have to migrate your data e.g. because your data modeling has changed.
{% tabs %}
{% tab title="Docker" %}
Generate a data migration file:
```bash
$ docker-compose exec backend yarn run db:migrate:create your_data_migration
# Edit the file in ./src/db/migrations/
```
To run the migration:
```bash
$ docker-compose exec backend yarn run db:migrate up
```
{% endtab %}
{% tab title="Without Docker" %}
Generate a data migration file:
```bash
$ yarn run db:migrate:create your_data_migration
# Edit the file in ./src/db/migrations/
```
To run the migration:
```bash
$ yarn run db:migrate up
```
{% endtab %}
{% endtabs %}
# Testing # Testing

View File

@ -0,0 +1,16 @@
# neo4j-graphql.js
We use an npm package called `neo4j-graphql-js` as a cypher query builder. This
library also generates resolvers for graphql queries, unless we implement them
ourselves.
## Debugging
As you can see in their [documentation](https://github.com/neo4j-graphql/neo4j-graphql-js)
it is possible to log out the generated cypher statements. To do so, run the
backend like this:
```sh
DEBUG=neo4j-graphql-js yarn run dev
```

View File

@ -1,26 +1,22 @@
{ {
"name": "human-connection-backend", "name": "human-connection-backend",
"version": "0.0.1", "version": "0.2.2",
"description": "GraphQL Backend for Human Connection", "description": "GraphQL Backend for Human Connection",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"build": "babel src/ -d dist/ --copy-files", "__migrate": "migrate --compiler 'js:@babel/register' --migrations-dir ./src/db/migrations",
"prod:migrate": "migrate --migrations-dir ./dist/db/migrations --store ./dist/db/migrate/store.js",
"start": "node dist/", "start": "node dist/",
"build": "babel src/ -d dist/ --copy-files",
"dev": "nodemon --exec babel-node src/ -e js,gql", "dev": "nodemon --exec babel-node src/ -e js,gql",
"dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/index.js -e js,gql", "dev:debug": "nodemon --exec babel-node --inspect=0.0.0.0:9229 src/ -e js,gql",
"lint": "eslint src --config .eslintrc.js", "lint": "eslint src --config .eslintrc.js",
"jest": "jest --forceExit --detectOpenHandles --runInBand", "test": "jest --forceExit --detectOpenHandles --runInBand",
"test": "run-s test:jest test:cucumber", "db:clean": "babel-node src/db/clean.js",
"test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 yarn run dev 2> /dev/null", "db:reset": "yarn run db:clean",
"test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions,activityPub yarn run dev 2> /dev/null", "db:seed": "babel-node src/db/seed.js",
"test:jest:cmd": "wait-on tcp:4001 tcp:4123 && jest --forceExit --detectOpenHandles --runInBand", "db:migrate": "yarn run __migrate --store ./src/db/migrate/store.js",
"test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/", "db:migrate:create": "yarn run __migrate --template-file ./src/db/migrate/template.js --date-format 'yyyymmddHHmmss' create"
"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": " cross-env CLIENT_URI=http://localhost:4123 run-p --race test:before:* 'test:cucumber:cmd {@}' --",
"test:jest:debug": "run-p --race test:before:* 'test:jest:cmd:debug {@}' --",
"db:reset": "babel-node src/seed/reset-db.js",
"db:seed": "babel-node src/seed/seed-db.js"
}, },
"author": "Human Connection gGmbH", "author": "Human Connection gGmbH",
"license": "MIT", "license": "MIT",
@ -41,94 +37,96 @@
] ]
}, },
"dependencies": { "dependencies": {
"@hapi/joi": "^16.0.1", "@hapi/joi": "^17.1.0",
"@sentry/node": "^5.6.2", "@sentry/node": "^5.11.1",
"activitystrea.ms": "~2.1.3", "apollo-cache-inmemory": "~1.6.5",
"apollo-cache-inmemory": "~1.6.3", "apollo-client": "~2.6.8",
"apollo-client": "~2.6.4",
"apollo-link-context": "~1.0.19", "apollo-link-context": "~1.0.19",
"apollo-link-http": "~1.5.16", "apollo-link-http": "~1.5.16",
"apollo-server": "~2.9.3", "apollo-server": "~2.9.16",
"apollo-server-express": "^2.9.0", "apollo-server-express": "^2.9.16",
"babel-plugin-transform-runtime": "^6.23.0", "babel-plugin-transform-runtime": "^6.23.0",
"bcryptjs": "~2.4.3", "bcryptjs": "~2.4.3",
"cheerio": "~1.0.0-rc.3", "cheerio": "~1.0.0-rc.3",
"cors": "~2.8.5", "cors": "~2.8.5",
"cross-env": "~5.2.1", "cross-env": "~7.0.0",
"date-fns": "2.1.0", "date-fns": "2.9.0",
"debug": "~4.1.1", "debug": "~4.1.1",
"dotenv": "~8.1.0", "dotenv": "~8.2.0",
"express": "^4.17.1", "express": "^4.17.1",
"faker": "Marak/faker.js#master", "faker": "Marak/faker.js#master",
"graphql": "^14.5.4", "graphql": "^14.6.0",
"graphql-custom-directives": "~0.2.14", "graphql-custom-directives": "~0.2.14",
"graphql-iso-date": "~3.6.1", "graphql-iso-date": "~3.6.1",
"graphql-middleware": "~3.0.5", "graphql-middleware": "~4.0.2",
"graphql-middleware-sentry": "^3.2.0", "graphql-middleware-sentry": "^3.2.1",
"graphql-shield": "~6.1.0", "graphql-shield": "~7.0.8",
"graphql-tag": "~2.10.1", "graphql-tag": "~2.10.1",
"helmet": "~3.21.0", "helmet": "~3.21.2",
"jsonwebtoken": "~8.5.1", "jsonwebtoken": "~8.5.1",
"linkifyjs": "~2.1.8", "linkifyjs": "~2.1.8",
"lodash": "~4.17.14", "lodash": "~4.17.14",
"merge-graphql-schemas": "^1.7.0", "merge-graphql-schemas": "^1.7.6",
"metascraper": "^4.10.3", "metascraper": "^5.10.6",
"metascraper-audio": "^5.6.5", "metascraper-audio": "^5.10.6",
"metascraper-author": "^5.6.5", "metascraper-author": "^5.10.6",
"metascraper-clearbit-logo": "^5.3.0", "metascraper-clearbit-logo": "^5.3.0",
"metascraper-date": "^5.7.0", "metascraper-date": "^5.10.6",
"metascraper-description": "^5.7.4", "metascraper-description": "^5.10.6",
"metascraper-image": "^5.6.5", "metascraper-image": "^5.10.6",
"metascraper-lang": "^5.6.5", "metascraper-lang": "^5.10.6",
"metascraper-lang-detector": "^4.8.5", "metascraper-lang-detector": "^4.10.2",
"metascraper-logo": "^5.7.4", "metascraper-logo": "^5.10.6",
"metascraper-publisher": "^5.6.5", "metascraper-publisher": "^5.10.6",
"metascraper-soundcloud": "^5.6.7", "metascraper-soundcloud": "^5.10.6",
"metascraper-title": "^5.7.0", "metascraper-title": "^5.10.6",
"metascraper-url": "^5.7.4", "metascraper-url": "^5.10.6",
"metascraper-video": "^5.6.5", "metascraper-video": "^5.10.6",
"metascraper-youtube": "^5.7.4", "metascraper-youtube": "^5.10.6",
"migrate": "^1.6.2",
"minimatch": "^3.0.4", "minimatch": "^3.0.4",
"neo4j-driver": "~1.7.6", "mustache": "^4.0.0",
"neo4j-graphql-js": "^2.7.2", "neo4j-driver": "^4.0.1",
"neode": "^0.3.3", "neo4j-graphql-js": "^2.11.5",
"neode": "^0.3.7",
"node-fetch": "~2.6.0", "node-fetch": "~2.6.0",
"nodemailer": "^6.3.0", "nodemailer": "^6.4.2",
"nodemailer-html-to-text": "^3.1.0",
"npm-run-all": "~4.1.5", "npm-run-all": "~4.1.5",
"request": "~2.88.0", "request": "~2.88.0",
"sanitize-html": "~1.20.1", "sanitize-html": "~1.21.1",
"slug": "~1.1.0", "slug": "~2.1.1",
"trunc-html": "~1.1.2", "trunc-html": "~1.1.2",
"uuid": "~3.3.3", "uuid": "~3.4.0",
"xregexp": "^4.2.4", "validator": "^12.2.0",
"wait-on": "~3.3.0" "wait-on": "~4.0.0",
"xregexp": "^4.2.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "~7.6.0", "@babel/cli": "~7.8.3",
"@babel/core": "~7.6.0", "@babel/core": "~7.8.3",
"@babel/node": "~7.6.1", "@babel/node": "~7.8.3",
"@babel/plugin-proposal-throw-expressions": "^7.2.0", "@babel/plugin-proposal-throw-expressions": "^7.8.3",
"@babel/preset-env": "~7.6.0", "@babel/preset-env": "~7.8.3",
"@babel/register": "~7.6.0", "@babel/register": "^7.8.3",
"apollo-server-testing": "~2.9.3", "apollo-server-testing": "~2.9.16",
"babel-core": "~7.0.0-0", "babel-core": "~7.0.0-0",
"babel-eslint": "~10.0.3", "babel-eslint": "~10.0.3",
"babel-jest": "~24.9.0", "babel-jest": "~25.1.0",
"chai": "~4.2.0", "chai": "~4.2.0",
"cucumber": "~5.1.0", "cucumber": "~6.0.5",
"eslint": "~6.3.0", "eslint": "~6.8.0",
"eslint-config-prettier": "~6.3.0", "eslint-config-prettier": "~6.9.0",
"eslint-config-standard": "~14.1.0", "eslint-config-standard": "~14.1.0",
"eslint-plugin-import": "~2.18.2", "eslint-plugin-import": "~2.20.0",
"eslint-plugin-jest": "~22.17.0", "eslint-plugin-jest": "~23.6.0",
"eslint-plugin-node": "~10.0.0", "eslint-plugin-node": "~11.0.0",
"eslint-plugin-prettier": "~3.1.0", "eslint-plugin-prettier": "~3.1.2",
"eslint-plugin-promise": "~4.2.1", "eslint-plugin-promise": "~4.2.1",
"eslint-plugin-standard": "~4.0.1", "eslint-plugin-standard": "~4.0.1",
"graphql-request": "~1.8.2", "jest": "~25.1.0",
"jest": "~24.9.0", "nodemon": "~2.0.2",
"nodemon": "~1.19.2", "prettier": "~1.19.1",
"prettier": "~1.18.2",
"supertest": "~4.0.2" "supertest": "~4.0.2"
} }
} }

View File

@ -0,0 +1,257 @@
[
{
"provider_name": "Codepen",
"provider_url": "https:\/\/codepen.io",
"endpoints": [
{
"schemes": [
"http:\/\/codepen.io\/*",
"https:\/\/codepen.io\/*"
],
"url": "http:\/\/codepen.io\/api\/oembed"
}
]
},
{
"provider_name": "DTube",
"provider_url": "https:\/\/d.tube\/",
"endpoints": [
{
"schemes": [
"https:\/\/d.tube\/v\/*"
],
"url": "https:\/\/api.d.tube\/oembed",
"discovery": true
}
]
},
{
"provider_name": "Facebook (Post)",
"provider_url": "https:\/\/www.facebook.com\/",
"endpoints": [
{
"schemes": [
"https:\/\/www.facebook.com\/*\/posts\/*",
"https:\/\/www.facebook.com\/photos\/*",
"https:\/\/www.facebook.com\/*\/photos\/*",
"https:\/\/www.facebook.com\/photo.php*",
"https:\/\/www.facebook.com\/photo.php",
"https:\/\/www.facebook.com\/*\/activity\/*",
"https:\/\/www.facebook.com\/permalink.php",
"https:\/\/www.facebook.com\/media\/set?set=*",
"https:\/\/www.facebook.com\/questions\/*",
"https:\/\/www.facebook.com\/notes\/*\/*\/*"
],
"url": "https:\/\/www.facebook.com\/plugins\/post\/oembed.json",
"discovery": true
}
]
},
{
"provider_name": "Facebook (Video)",
"provider_url": "https:\/\/www.facebook.com\/",
"endpoints": [
{
"schemes": [
"https:\/\/www.facebook.com\/*\/videos\/*",
"https:\/\/www.facebook.com\/video.php"
],
"url": "https:\/\/www.facebook.com\/plugins\/video\/oembed.json",
"discovery": true
}
]
},
{
"provider_name": "Flickr",
"provider_url": "https:\/\/www.flickr.com\/",
"endpoints": [
{
"schemes": [
"http:\/\/*.flickr.com\/photos\/*",
"http:\/\/flic.kr\/p\/*",
"https:\/\/*.flickr.com\/photos\/*",
"https:\/\/flic.kr\/p\/*"
],
"url": "https:\/\/www.flickr.com\/services\/oembed\/",
"discovery": true
}
]
},
{
"provider_name": "GIPHY",
"provider_url": "https:\/\/giphy.com",
"endpoints": [
{
"schemes": [
"https:\/\/giphy.com\/gifs\/*",
"http:\/\/gph.is\/*",
"https:\/\/media.giphy.com\/media\/*\/giphy.gif"
],
"url": "https:\/\/giphy.com\/services\/oembed",
"discovery": true
}
]
},
{
"provider_name": "Instagram",
"provider_url": "https:\/\/instagram.com",
"endpoints": [
{
"schemes": [
"http:\/\/instagram.com\/p\/*",
"http:\/\/instagr.am\/p\/*",
"http:\/\/www.instagram.com\/p\/*",
"http:\/\/www.instagr.am\/p\/*",
"https:\/\/instagram.com\/p\/*",
"https:\/\/instagr.am\/p\/*",
"https:\/\/www.instagram.com\/p\/*",
"https:\/\/www.instagr.am\/p\/*"
],
"url": "https:\/\/api.instagram.com\/oembed",
"formats": [
"json"
]
}
]
},
{
"provider_name": "Meetup",
"provider_url": "http:\/\/www.meetup.com",
"endpoints": [
{
"schemes": [
"http:\/\/meetup.com\/*",
"https:\/\/www.meetup.com\/*",
"https:\/\/meetup.com\/*",
"http:\/\/meetu.ps\/*"
],
"url": "https:\/\/api.meetup.com\/oembed",
"formats": [
"json"
]
}
]
},
{
"provider_name": "MixCloud",
"provider_url": "https:\/\/mixcloud.com\/",
"endpoints": [
{
"schemes": [
"http:\/\/www.mixcloud.com\/*\/*\/",
"https:\/\/www.mixcloud.com\/*\/*\/"
],
"url": "https:\/\/www.mixcloud.com\/oembed\/"
}
]
},
{
"provider_name": "Reddit",
"provider_url": "https:\/\/reddit.com\/",
"endpoints": [
{
"schemes": [
"https:\/\/reddit.com\/r\/*\/comments\/*\/*",
"https:\/\/www.reddit.com\/r\/*\/comments\/*\/*"
],
"url": "https:\/\/www.reddit.com\/oembed"
}
]
},
{
"provider_name": "SlideShare",
"provider_url": "http:\/\/www.slideshare.net\/",
"endpoints": [
{
"schemes": [
"http:\/\/www.slideshare.net\/*\/*",
"http:\/\/fr.slideshare.net\/*\/*",
"http:\/\/de.slideshare.net\/*\/*",
"http:\/\/es.slideshare.net\/*\/*",
"http:\/\/pt.slideshare.net\/*\/*"
],
"url": "http:\/\/www.slideshare.net\/api\/oembed\/2",
"discovery": true
}
]
},
{
"provider_name": "SoundCloud",
"provider_url": "http:\/\/soundcloud.com\/",
"endpoints": [
{
"schemes": [
"http:\/\/soundcloud.com\/*",
"https:\/\/soundcloud.com\/*"
],
"url": "https:\/\/soundcloud.com\/oembed"
}
]
},
{
"provider_name": "Twitch",
"provider_url": "https:\/\/www.twitch.tv",
"endpoints": [
{
"schemes": [
"http:\/\/clips.twitch.tv\/*",
"https:\/\/clips.twitch.tv\/*",
"http:\/\/www.twitch.tv\/*",
"https:\/\/www.twitch.tv\/*",
"http:\/\/twitch.tv\/*",
"https:\/\/twitch.tv\/*"
],
"url": "https:\/\/api.twitch.tv\/v4\/oembed",
"formats": [
"json"
]
}
]
},
{
"provider_name": "Twitter",
"provider_url": "http:\/\/www.twitter.com\/",
"endpoints": [
{
"schemes": [
"https:\/\/twitter.com\/*\/status\/*",
"https:\/\/*.twitter.com\/*\/status\/*"
],
"url": "https:\/\/publish.twitter.com\/oembed"
}
]
},
{
"provider_name": "Vimeo",
"provider_url": "https:\/\/vimeo.com\/",
"endpoints": [
{
"schemes": [
"https:\/\/vimeo.com\/*",
"https:\/\/vimeo.com\/album\/*\/video\/*",
"https:\/\/vimeo.com\/channels\/*\/*",
"https:\/\/vimeo.com\/groups\/*\/videos\/*",
"https:\/\/vimeo.com\/ondemand\/*\/*",
"https:\/\/player.vimeo.com\/video\/*"
],
"url": "https:\/\/vimeo.com\/api\/oembed.{format}",
"discovery": true
}
]
},
{
"provider_name": "YouTube",
"provider_url": "https:\/\/www.youtube.com\/",
"endpoints": [
{
"schemes": [
"https:\/\/*.youtube.com\/watch*",
"https:\/\/*.youtube.com\/v\/*",
"https:\/\/youtu.be\/*"
],
"url": "https:\/\/www.youtube.com\/oembed",
"discovery": true
}
]
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,9 @@
import { extractNameFromId, extractDomainFromUrl, signAndSend } from './utils' // import { extractDomainFromUrl, signAndSend } from './utils'
import { isPublicAddressed, sendAcceptActivity, sendRejectActivity } from './utils/activity' import { extractNameFromId, signAndSend } from './utils'
import { isPublicAddressed } from './utils/activity'
// import { isPublicAddressed, sendAcceptActivity, sendRejectActivity } from './utils/activity'
import request from 'request' import request from 'request'
import as from 'activitystrea.ms' // import as from 'activitystrea.ms'
import NitroDataSource from './NitroDataSource' import NitroDataSource from './NitroDataSource'
import router from './routes' import router from './routes'
import Collections from './Collections' import Collections from './Collections'
@ -33,71 +35,71 @@ export default class ActivityPub {
} }
} }
handleFollowActivity(activity) { // handleFollowActivity(activity) {
debug(`inside FOLLOW ${activity.actor}`) // debug(`inside FOLLOW ${activity.actor}`)
const toActorName = extractNameFromId(activity.object) // const toActorName = extractNameFromId(activity.object)
const fromDomain = extractDomainFromUrl(activity.actor) // const fromDomain = extractDomainFromUrl(activity.actor)
const dataSource = this.dataSource // const dataSource = this.dataSource
return new Promise((resolve, reject) => { // return new Promise((resolve, reject) => {
request( // request(
{ // {
url: activity.actor, // url: activity.actor,
headers: { // headers: {
Accept: 'application/activity+json', // Accept: 'application/activity+json',
}, // },
}, // },
async (err, response, toActorObject) => { // async (err, response, toActorObject) => {
if (err) return reject(err) // if (err) return reject(err)
// save shared inbox // // save shared inbox
toActorObject = JSON.parse(toActorObject) // toActorObject = JSON.parse(toActorObject)
await this.dataSource.addSharedInboxEndpoint(toActorObject.endpoints.sharedInbox) // await this.dataSource.addSharedInboxEndpoint(toActorObject.endpoints.sharedInbox)
const followersCollectionPage = await this.dataSource.getFollowersCollectionPage( // const followersCollectionPage = await this.dataSource.getFollowersCollectionPage(
activity.object, // activity.object,
) // )
const followActivity = as // const followActivity = as
.follow() // .follow()
.id(activity.id) // .id(activity.id)
.actor(activity.actor) // .actor(activity.actor)
.object(activity.object) // .object(activity.object)
// add follower if not already in collection // // add follower if not already in collection
if (followersCollectionPage.orderedItems.includes(activity.actor)) { // if (followersCollectionPage.orderedItems.includes(activity.actor)) {
debug('follower already in collection!') // debug('follower already in collection!')
debug(`inbox = ${toActorObject.inbox}`) // debug(`inbox = ${toActorObject.inbox}`)
resolve( // resolve(
sendRejectActivity(followActivity, toActorName, fromDomain, toActorObject.inbox), // sendRejectActivity(followActivity, toActorName, fromDomain, toActorObject.inbox),
) // )
} else { // } else {
followersCollectionPage.orderedItems.push(activity.actor) // followersCollectionPage.orderedItems.push(activity.actor)
} // }
debug(`toActorObject = ${toActorObject}`) // debug(`toActorObject = ${toActorObject}`)
toActorObject = // toActorObject =
typeof toActorObject !== 'object' ? JSON.parse(toActorObject) : toActorObject // typeof toActorObject !== 'object' ? JSON.parse(toActorObject) : toActorObject
debug(`followers = ${JSON.stringify(followersCollectionPage.orderedItems, null, 2)}`) // debug(`followers = ${JSON.stringify(followersCollectionPage.orderedItems, null, 2)}`)
debug(`inbox = ${toActorObject.inbox}`) // debug(`inbox = ${toActorObject.inbox}`)
debug(`outbox = ${toActorObject.outbox}`) // debug(`outbox = ${toActorObject.outbox}`)
debug(`followers = ${toActorObject.followers}`) // debug(`followers = ${toActorObject.followers}`)
debug(`following = ${toActorObject.following}`) // debug(`following = ${toActorObject.following}`)
try { // try {
await dataSource.saveFollowersCollectionPage(followersCollectionPage) // await dataSource.saveFollowersCollectionPage(followersCollectionPage)
debug('follow activity saved') // debug('follow activity saved')
resolve( // resolve(
sendAcceptActivity(followActivity, toActorName, fromDomain, toActorObject.inbox), // sendAcceptActivity(followActivity, toActorName, fromDomain, toActorObject.inbox),
) // )
} catch (e) { // } catch (e) {
debug('followers update error!', e) // debug('followers update error!', e)
resolve( // resolve(
sendRejectActivity(followActivity, toActorName, fromDomain, toActorObject.inbox), // sendRejectActivity(followActivity, toActorName, fromDomain, toActorObject.inbox),
) // )
} // }
}, // },
) // )
}) // })
} // }
handleUndoActivity(activity) { handleUndoActivity(activity) {
debug('inside UNDO') debug('inside UNDO')

View File

@ -18,9 +18,9 @@ router.post('/', async function(req, res, next) {
case 'Undo': case 'Undo':
await activityPub.handleUndoActivity(req.body).catch(next) await activityPub.handleUndoActivity(req.body).catch(next)
break break
case 'Follow': // case 'Follow':
await activityPub.handleFollowActivity(req.body).catch(next) // await activityPub.handleFollowActivity(req.body).catch(next)
break // break
case 'Delete': case 'Delete':
await activityPub.handleDeleteActivity(req.body).catch(next) await activityPub.handleDeleteActivity(req.body).catch(next)
break break

View File

@ -1,27 +1,29 @@
import user from './user' import user from './user'
import inbox from './inbox' import inbox from './inbox'
import webFinger from './webFinger'
import express from 'express' import express from 'express'
import cors from 'cors' import cors from 'cors'
import verify from './verify' import verify from './verify'
const router = express.Router() export default function() {
const router = express.Router()
router.use('/.well-known/webFinger', cors(), express.urlencoded({ extended: true }), webFinger) router.use(
router.use( '/activitypub/users',
'/activitypub/users', cors(),
cors(), express.json({
express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }), type: ['application/activity+json', 'application/ld+json', 'application/json'],
express.urlencoded({ extended: true }), }),
user, express.urlencoded({ extended: true }),
) user,
router.use( )
'/activitypub/inbox', router.use(
cors(), '/activitypub/inbox',
express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'] }), cors(),
express.urlencoded({ extended: true }), express.json({
verify, type: ['application/activity+json', 'application/ld+json', 'application/json'],
inbox, }),
) express.urlencoded({ extended: true }),
verify,
export default router inbox,
)
return router
}

View File

@ -56,9 +56,9 @@ router.post('/:name/inbox', verify, async function(req, res, next) {
case 'Undo': case 'Undo':
await activityPub.handleUndoActivity(req.body).catch(next) await activityPub.handleUndoActivity(req.body).catch(next)
break break
case 'Follow': // case 'Follow':
await activityPub.handleFollowActivity(req.body).catch(next) // await activityPub.handleFollowActivity(req.body).catch(next)
break // break
case 'Delete': case 'Delete':
await activityPub.handleDeleteActivity(req.body).catch(next) await activityPub.handleDeleteActivity(req.body).catch(next)
break break

View File

@ -1,43 +0,0 @@
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

View File

@ -0,0 +1,59 @@
import express from 'express'
import CONFIG from '../../config/'
import cors from 'cors'
const debug = require('debug')('ea:webfinger')
const regex = /acct:([a-z0-9_-]*)@([a-z0-9_-]*)/
const createWebFinger = name => {
const { host } = new URL(CONFIG.CLIENT_URI)
return {
subject: `acct:${name}@${host}`,
links: [
{
rel: 'self',
type: 'application/activity+json',
href: `${CONFIG.CLIENT_URI}/activitypub/users/${name}`,
},
],
}
}
export async function handler(req, res) {
const { resource = '' } = req.query
// eslint-disable-next-line no-unused-vars
const [_, name, domain] = resource.match(regex) || []
if (!(name && domain))
return res.status(400).json({
error: 'Query parameter "?resource=acct:<USER>@<DOMAIN>" is missing.',
})
const session = req.app.get('driver').session()
try {
const [slug] = await session.readTransaction(async t => {
const result = await t.run('MATCH (u:User {slug: $slug}) RETURN u.slug AS slug', {
slug: name,
})
return result.records.map(record => record.get('slug'))
})
if (!slug)
return res.status(404).json({
error: `No record found for "${name}@${domain}".`,
})
const webFinger = createWebFinger(name)
return res.contentType('application/jrd+json').json(webFinger)
} catch (error) {
debug(error)
return res.status(500).json({
error: 'Something went terribly wrong. Please contact support@human-connection.org',
})
} finally {
session.close()
}
}
export default function() {
const router = express.Router()
router.use('/webfinger', cors(), express.urlencoded({ extended: true }), handler)
return router
}

View File

@ -0,0 +1,113 @@
import { handler } from './webfinger'
import Factory from '../../factories'
import { getDriver } from '../../db/neo4j'
let resource, res, json, status, contentType
const factory = Factory()
const driver = getDriver()
const request = () => {
json = jest.fn()
status = jest.fn(() => ({ json }))
contentType = jest.fn(() => ({ json }))
res = { status, contentType }
const req = {
app: {
get: key => {
return {
driver,
}[key]
},
},
query: {
resource,
},
}
return handler(req, res)
}
afterEach(async () => {
await factory.cleanDatabase()
})
describe('webfinger', () => {
describe('no ressource', () => {
beforeEach(() => {
resource = undefined
})
it('sends HTTP 400', async () => {
await request()
expect(status).toHaveBeenCalledWith(400)
expect(json).toHaveBeenCalledWith({
error: 'Query parameter "?resource=acct:<USER>@<DOMAIN>" is missing.',
})
})
})
describe('?resource query param', () => {
describe('is missing acct:', () => {
beforeEach(() => {
resource = 'some-user@domain'
})
it('sends HTTP 400', async () => {
await request()
expect(status).toHaveBeenCalledWith(400)
expect(json).toHaveBeenCalledWith({
error: 'Query parameter "?resource=acct:<USER>@<DOMAIN>" is missing.',
})
})
})
describe('has no domain', () => {
beforeEach(() => {
resource = 'acct:some-user@'
})
it('sends HTTP 400', async () => {
await request()
expect(status).toHaveBeenCalledWith(400)
expect(json).toHaveBeenCalledWith({
error: 'Query parameter "?resource=acct:<USER>@<DOMAIN>" is missing.',
})
})
})
describe('with acct:', () => {
beforeEach(() => {
resource = 'acct:some-user@domain'
})
it('returns error as json', async () => {
await request()
expect(status).toHaveBeenCalledWith(404)
expect(json).toHaveBeenCalledWith({
error: 'No record found for "some-user@domain".',
})
})
describe('given a user for acct', () => {
beforeEach(async () => {
await factory.create('User', { slug: 'some-user' })
})
it('returns user object', async () => {
await request()
expect(contentType).toHaveBeenCalledWith('application/jrd+json')
expect(json).toHaveBeenCalledWith({
links: [
{
href: 'http://localhost:3000/activitypub/users/some-user',
rel: 'self',
type: 'application/activity+json',
},
],
subject: 'acct:some-user@localhost:3000',
})
})
})
})
})
})

View File

@ -1,10 +1,11 @@
import { activityPub } from '../ActivityPub' import { activityPub } from '../ActivityPub'
import { signAndSend, throwErrorIfApolloErrorOccurred } from './index' import { throwErrorIfApolloErrorOccurred } from './index'
// import { signAndSend, throwErrorIfApolloErrorOccurred } from './index'
import crypto from 'crypto' import crypto from 'crypto'
import as from 'activitystrea.ms' // import as from 'activitystrea.ms'
import gql from 'graphql-tag' import gql from 'graphql-tag'
const debug = require('debug')('ea:utils:activity') // const debug = require('debug')('ea:utils:activity')
export function createNoteObject(text, name, id, published) { export function createNoteObject(text, name, id, published) {
const createUuid = crypto.randomBytes(16).toString('hex') const createUuid = crypto.randomBytes(16).toString('hex')
@ -62,41 +63,41 @@ export async function getActorId(name) {
} }
} }
export function sendAcceptActivity(theBody, name, targetDomain, url) { // export function sendAcceptActivity(theBody, name, targetDomain, url) {
as.accept() // as.accept()
.id( // .id(
`${activityPub.endpoint}/activitypub/users/${name}/status/` + // `${activityPub.endpoint}/activitypub/users/${name}/status/` +
crypto.randomBytes(16).toString('hex'), // crypto.randomBytes(16).toString('hex'),
) // )
.actor(`${activityPub.endpoint}/activitypub/users/${name}`) // .actor(`${activityPub.endpoint}/activitypub/users/${name}`)
.object(theBody) // .object(theBody)
.prettyWrite((err, doc) => { // .prettyWrite((err, doc) => {
if (!err) { // if (!err) {
return signAndSend(doc, name, targetDomain, url) // return signAndSend(doc, name, targetDomain, url)
} else { // } else {
debug(`error serializing Accept object: ${err}`) // debug(`error serializing Accept object: ${err}`)
throw new Error('error serializing Accept object') // throw new Error('error serializing Accept object')
} // }
}) // })
} // }
export function sendRejectActivity(theBody, name, targetDomain, url) { // export function sendRejectActivity(theBody, name, targetDomain, url) {
as.reject() // as.reject()
.id( // .id(
`${activityPub.endpoint}/activitypub/users/${name}/status/` + // `${activityPub.endpoint}/activitypub/users/${name}/status/` +
crypto.randomBytes(16).toString('hex'), // crypto.randomBytes(16).toString('hex'),
) // )
.actor(`${activityPub.endpoint}/activitypub/users/${name}`) // .actor(`${activityPub.endpoint}/activitypub/users/${name}`)
.object(theBody) // .object(theBody)
.prettyWrite((err, doc) => { // .prettyWrite((err, doc) => {
if (!err) { // if (!err) {
return signAndSend(doc, name, targetDomain, url) // return signAndSend(doc, name, targetDomain, url)
} else { // } else {
debug(`error serializing Accept object: ${err}`) // debug(`error serializing Accept object: ${err}`)
throw new Error('error serializing Accept object') // throw new Error('error serializing Accept object')
} // }
}) // })
} // }
export function isPublicAddressed(postObject) { export function isPublicAddressed(postObject) {
if (typeof postObject.to === 'string') { if (typeof postObject.to === 'string') {

View File

@ -22,17 +22,3 @@ export function createActor(name, 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}`,
},
],
}
}

View File

@ -1,12 +0,0 @@
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
}

View File

@ -1,26 +0,0 @@
import { v1 as neo4j } from 'neo4j-driver'
import CONFIG from './../config'
import setupNeode from './neode'
let driver
export function getDriver(options = {}) {
const {
uri = CONFIG.NEO4J_URI,
username = CONFIG.NEO4J_USERNAME,
password = CONFIG.NEO4J_PASSWORD,
} = options
if (!driver) {
driver = neo4j.driver(uri, neo4j.auth.basic(username, password))
}
return driver
}
let neodeInstance
export function neode() {
if (!neodeInstance) {
const { NEO4J_URI: uri, NEO4J_USERNAME: username, NEO4J_PASSWORD: password } = CONFIG
neodeInstance = setupNeode({ uri, username, password })
}
return neodeInstance
}

View File

@ -1,9 +0,0 @@
import Neode from 'neode'
import models from '../models'
export default function setupNeode(options) {
const { uri, username, password } = options
const neodeInstance = new Neode(uri, username, password)
neodeInstance.with(models)
return neodeInstance
}

View File

@ -1,9 +0,0 @@
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
}

View File

@ -1,6 +1,8 @@
import dotenv from 'dotenv' import dotenv from 'dotenv'
if (require.resolve) {
dotenv.config() // are we in a nodejs environment?
dotenv.config({ path: require.resolve('../../.env') })
}
const { const {
MAPBOX_TOKEN, MAPBOX_TOKEN,
@ -16,12 +18,25 @@ const {
NEO4J_URI = 'bolt://localhost:7687', NEO4J_URI = 'bolt://localhost:7687',
NEO4J_USERNAME = 'neo4j', NEO4J_USERNAME = 'neo4j',
NEO4J_PASSWORD = 'neo4j', NEO4J_PASSWORD = 'neo4j',
GRAPHQL_PORT = 4000,
CLIENT_URI = 'http://localhost:3000', CLIENT_URI = 'http://localhost:3000',
GRAPHQL_URI = 'http://localhost:4000', GRAPHQL_URI = 'http://localhost:4000',
} = process.env } = process.env
export const requiredConfigs = { MAPBOX_TOKEN, JWT_SECRET, PRIVATE_KEY_PASSPHRASE } export const requiredConfigs = {
MAPBOX_TOKEN,
JWT_SECRET,
PRIVATE_KEY_PASSPHRASE,
}
if (require.resolve) {
// are we in a nodejs environment?
Object.entries(requiredConfigs).map(entry => {
if (!entry[1]) {
throw new Error(`ERROR: "${entry[0]}" env variable is missing.`)
}
})
}
export const smtpConfigs = { export const smtpConfigs = {
SMTP_HOST, SMTP_HOST,
SMTP_PORT, SMTP_PORT,
@ -30,7 +45,11 @@ export const smtpConfigs = {
SMTP_PASSWORD, SMTP_PASSWORD,
} }
export const neo4jConfigs = { NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD } export const neo4jConfigs = { NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD }
export const serverConfigs = { GRAPHQL_PORT, CLIENT_URI, GRAPHQL_URI } export const serverConfigs = {
CLIENT_URI,
GRAPHQL_URI,
PUBLIC_REGISTRATION: process.env.PUBLIC_REGISTRATION === 'true',
}
export const developmentConfigs = { export const developmentConfigs = {
DEBUG: process.env.NODE_ENV !== 'production' && process.env.DEBUG, DEBUG: process.env.NODE_ENV !== 'production' && process.env.DEBUG,

View File

@ -1,4 +1,4 @@
import { cleanDatabase } from './factories' import { cleanDatabase } from '../factories'
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
throw new Error(`You cannot clean the database in production environment!`) throw new Error(`You cannot clean the database in production environment!`)

View File

@ -0,0 +1,97 @@
import { getDriver, getNeode } from '../../db/neo4j'
class Store {
async init(next) {
const neode = getNeode()
const { driver } = neode
const session = driver.session()
// eslint-disable-next-line no-console
const writeTxResultPromise = session.writeTransaction(async txc => {
await txc.run('CALL apoc.schema.assert({},{},true)') // drop all indices
return Promise.all(
[
'CALL db.index.fulltext.createNodeIndex("post_fulltext_search",["Post"],["title", "content"])',
'CALL db.index.fulltext.createNodeIndex("user_fulltext_search",["User"],["name", "slug"])',
].map(statement => txc.run(statement)),
)
})
try {
await writeTxResultPromise
await getNeode().schema.install()
// eslint-disable-next-line no-console
console.log('Successfully created database indices and constraints!')
next()
} catch (error) {
console.log(error) // eslint-disable-line no-console
next(error, null)
} finally {
session.close()
driver.close()
}
}
async load(next) {
const driver = getDriver()
const session = driver.session()
const readTxResultPromise = session.readTransaction(async txc => {
const result = await txc.run(
'MATCH (migration:Migration) RETURN migration {.*} ORDER BY migration.timestamp DESC',
)
return result.records.map(r => r.get('migration'))
})
try {
const migrations = await readTxResultPromise
if (migrations.length <= 0) {
// eslint-disable-next-line no-console
console.log(
"No migrations found in database. If it's the first time you run migrations, then this is normal.",
)
return next(null, {})
}
const [{ title: lastRun }] = migrations
next(null, { lastRun, migrations })
} catch (error) {
console.log(error) // eslint-disable-line no-console
next(error)
} finally {
session.close()
}
}
async save(set, next) {
const driver = getDriver()
const session = driver.session()
const { migrations } = set
const writeTxResultPromise = session.writeTransaction(txc => {
return Promise.all(
migrations.map(async migration => {
const { title, description, timestamp } = migration
const properties = { title, description, timestamp }
const migrationResult = await txc.run(
`
MERGE (migration:Migration { title: $properties.title })
ON MATCH SET
migration += $properties
ON CREATE SET
migration += $properties,
migration.migratedAt = toString(datetime())
`,
{ properties },
)
return migrationResult
}),
)
})
try {
await writeTxResultPromise
next()
} catch (error) {
console.log(error) // eslint-disable-line no-console
next(error)
} finally {
session.close()
}
}
}
module.exports = Store

View File

@ -0,0 +1,31 @@
import { getDriver } from '../../db/neo4j'
export const description = ''
export function up(next) {
const driver = getDriver()
const session = driver.session()
try {
// Implement your migration here.
next()
} catch (err) {
next(err)
} finally {
session.close()
driver.close()
}
}
export function down(next) {
const driver = getDriver()
const session = driver.session()
try {
// Rollback your migration here.
next()
} catch (err) {
next(err)
} finally {
session.close()
driver.close()
}
}

View File

@ -0,0 +1,84 @@
import { throwError, concat } from 'rxjs'
import { flatMap, mergeMap, map, catchError, filter } from 'rxjs/operators'
import { getDriver } from '../neo4j'
import normalizeEmail from '../../schema/resolvers//helpers/normalizeEmail'
export const description = `
This migration merges duplicate :User and :EmailAddress nodes. It became
necessary after we implemented the email normalization but forgot to migrate
the existing data. Some (40) users decided to just register with a new account
but the same email address. On signup our backend would normalize the email,
which is good, but would also keep the existing unnormalized email address.
This led to about 40 duplicate user and email address nodes in our database.
`
export function up(next) {
const driver = getDriver()
const rxSession = driver.rxSession()
rxSession
.beginTransaction()
.pipe(
flatMap(txc =>
concat(
txc
.run('MATCH (email:EmailAddress) RETURN email {.email}')
.records()
.pipe(
map(record => {
const { email } = record.get('email')
const normalizedEmail = normalizeEmail(email)
return { email, normalizedEmail }
}),
filter(({ email, normalizedEmail }) => email !== normalizedEmail),
mergeMap(({ email, normalizedEmail }) => {
return txc
.run(
`
MATCH (oldUser:User)-[:PRIMARY_EMAIL]->(oldEmail:EmailAddress {email: $email}), (oldUser)-[previousRelationship]-(oldEmail)
MATCH (user:User)-[:PRIMARY_EMAIL]->(email:EmailAddress {email: $normalizedEmail})
DELETE previousRelationship
WITH oldUser, oldEmail, user, email
CALL apoc.refactor.mergeNodes([user, oldUser], { properties: 'discard', mergeRels: true }) YIELD node as mergedUser
CALL apoc.refactor.mergeNodes([email, oldEmail], { properties: 'discard', mergeRels: true }) YIELD node as mergedEmail
RETURN user {.*}, email {.*}
`,
{ email, normalizedEmail },
)
.records()
.pipe(
map(r => ({
oldEmail: email,
email: r.get('email'),
user: r.get('user'),
})),
)
}),
),
txc.commit(),
).pipe(catchError(err => txc.rollback().pipe(throwError(err)))),
),
)
.subscribe({
next: ({ user, email, oldUser, oldEmail }) =>
// eslint-disable-next-line no-console
console.log(`
Merged:
=============================
userId: ${user.id}
email: ${oldEmail} => ${email.email}
=============================
`),
complete: () => {
// eslint-disable-next-line no-console
console.log('Merging of duplicate users completed')
next()
},
error: error => {
next(new Error(error), null)
},
})
}
export function down(next) {
next(new Error('Irreversible migration'))
}

View File

@ -0,0 +1,77 @@
import { throwError, concat } from 'rxjs'
import { flatMap, mergeMap, map, catchError } from 'rxjs/operators'
import { getDriver } from '../neo4j'
export const description = `
This migration merges duplicate :Location nodes. It became
necessary after we realized that we had not set up constraints for Location.id in production.
`
export function up(next) {
const driver = getDriver()
const rxSession = driver.rxSession()
rxSession
.beginTransaction()
.pipe(
flatMap(transaction =>
concat(
transaction
.run(
`
MATCH (location:Location)
RETURN location {.id}
`,
)
.records()
.pipe(
map(record => {
const { id: locationId } = record.get('location')
return { locationId }
}),
mergeMap(({ locationId }) => {
return transaction
.run(
`
MATCH(location:Location {id: $locationId}), (location2:Location {id: $locationId})
WHERE location.id = location2.id AND id(location) < id(location2)
CALL apoc.refactor.mergeNodes([location, location2], { properties: 'combine', mergeRels: true }) YIELD node as updatedLocation
RETURN location {.*},updatedLocation {.*}
`,
{ locationId },
)
.records()
.pipe(
map(record => ({
location: record.get('location'),
updatedLocation: record.get('updatedLocation'),
})),
)
}),
),
transaction.commit(),
).pipe(catchError(error => transaction.rollback().pipe(throwError(error)))),
),
)
.subscribe({
next: ({ updatedLocation, location }) =>
// eslint-disable-next-line no-console
console.log(`
Merged:
=============================
locationId: ${location.id}
updatedLocation: ${location.id} => ${updatedLocation.id}
=============================
`),
complete: () => {
// eslint-disable-next-line no-console
console.log('Merging of duplicate locations completed')
next()
},
error: error => {
next(new Error(error), null)
},
})
}
export function down(next) {
next(new Error('Irreversible migration'))
}

29
backend/src/db/neo4j.js Normal file
View File

@ -0,0 +1,29 @@
import neo4j from 'neo4j-driver'
import CONFIG from './../config'
import Neode from 'neode'
import models from '../models'
let driver
const defaultOptions = {
uri: CONFIG.NEO4J_URI,
username: CONFIG.NEO4J_USERNAME,
password: CONFIG.NEO4J_PASSWORD,
}
export function getDriver(options = {}) {
const { uri, username, password } = { ...defaultOptions, ...options }
if (!driver) {
driver = neo4j.driver(uri, neo4j.auth.basic(username, password))
}
return driver
}
let neodeInstance
export function getNeode(options = {}) {
if (!neodeInstance) {
const { uri, username, password } = { ...defaultOptions, ...options }
neodeInstance = new Neode(uri, username, password).with(models)
return neodeInstance
}
return neodeInstance
}

View File

@ -1,9 +1,12 @@
import faker from 'faker' import faker from 'faker'
import sample from 'lodash/sample'
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
import createServer from '../server' import createServer from '../server'
import Factory from './factories' import Factory from '../factories'
import { neode as getNeode, getDriver } from '../bootstrap/neo4j' import { getNeode, getDriver } from '../db/neo4j'
import { gql } from '../jest/helpers' import { gql } from '../helpers/jest'
const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
/* eslint-disable no-multi-spaces */ /* eslint-disable no-multi-spaces */
;(async function() { ;(async function() {
@ -24,10 +27,8 @@ import { gql } from '../jest/helpers'
}) })
const { mutate } = createTestClient(server) const { mutate } = createTestClient(server)
const f = Factory()
const [Hamburg, Berlin, Germany, Paris, France] = await Promise.all([ const [Hamburg, Berlin, Germany, Paris, France] = await Promise.all([
f.create('Location', { factory.create('Location', {
id: 'region.5127278006398860', id: 'region.5127278006398860',
name: 'Hamburg', name: 'Hamburg',
type: 'region', type: 'region',
@ -41,8 +42,9 @@ import { gql } from '../jest/helpers'
nameDE: 'Hamburg', nameDE: 'Hamburg',
nameNL: 'Hamburg', nameNL: 'Hamburg',
namePL: 'Hamburg', namePL: 'Hamburg',
nameRU: 'Гамбург',
}), }),
f.create('Location', { factory.create('Location', {
id: 'region.14880313158564380', id: 'region.14880313158564380',
type: 'region', type: 'region',
name: 'Berlin', name: 'Berlin',
@ -56,8 +58,9 @@ import { gql } from '../jest/helpers'
nameDE: 'Berlin', nameDE: 'Berlin',
nameNL: 'Berlijn', nameNL: 'Berlijn',
namePL: 'Berlin', namePL: 'Berlin',
nameRU: 'Берлин',
}), }),
f.create('Location', { factory.create('Location', {
id: 'country.10743216036480410', id: 'country.10743216036480410',
name: 'Germany', name: 'Germany',
type: 'country', type: 'country',
@ -69,8 +72,9 @@ import { gql } from '../jest/helpers'
nameFR: 'Allemagne', nameFR: 'Allemagne',
nameIT: 'Germania', nameIT: 'Germania',
nameEN: 'Germany', nameEN: 'Germany',
nameRU: 'Германия',
}), }),
f.create('Location', { factory.create('Location', {
id: 'region.9397217726497330', id: 'region.9397217726497330',
name: 'Paris', name: 'Paris',
type: 'region', type: 'region',
@ -84,8 +88,9 @@ import { gql } from '../jest/helpers'
nameDE: 'Paris', nameDE: 'Paris',
nameNL: 'Parijs', nameNL: 'Parijs',
namePL: 'Paryż', namePL: 'Paryż',
nameRU: 'Париж',
}), }),
f.create('Location', { factory.create('Location', {
id: 'country.9759535382641660', id: 'country.9759535382641660',
name: 'France', name: 'France',
type: 'country', type: 'country',
@ -97,6 +102,7 @@ import { gql } from '../jest/helpers'
nameFR: 'France', nameFR: 'France',
nameIT: 'Francia', nameIT: 'Francia',
nameEN: 'France', nameEN: 'France',
nameRU: 'Франция',
}), }),
]) ])
await Promise.all([ await Promise.all([
@ -106,27 +112,27 @@ import { gql } from '../jest/helpers'
]) ])
const [racoon, rabbit, wolf, bear, turtle, rhino] = await Promise.all([ const [racoon, rabbit, wolf, bear, turtle, rhino] = await Promise.all([
f.create('Badge', { factory.create('Badge', {
id: 'indiegogo_en_racoon', id: 'indiegogo_en_racoon',
icon: '/img/badges/indiegogo_en_racoon.svg', icon: '/img/badges/indiegogo_en_racoon.svg',
}), }),
f.create('Badge', { factory.create('Badge', {
id: 'indiegogo_en_rabbit', id: 'indiegogo_en_rabbit',
icon: '/img/badges/indiegogo_en_rabbit.svg', icon: '/img/badges/indiegogo_en_rabbit.svg',
}), }),
f.create('Badge', { factory.create('Badge', {
id: 'indiegogo_en_wolf', id: 'indiegogo_en_wolf',
icon: '/img/badges/indiegogo_en_wolf.svg', icon: '/img/badges/indiegogo_en_wolf.svg',
}), }),
f.create('Badge', { factory.create('Badge', {
id: 'indiegogo_en_bear', id: 'indiegogo_en_bear',
icon: '/img/badges/indiegogo_en_bear.svg', icon: '/img/badges/indiegogo_en_bear.svg',
}), }),
f.create('Badge', { factory.create('Badge', {
id: 'indiegogo_en_turtle', id: 'indiegogo_en_turtle',
icon: '/img/badges/indiegogo_en_turtle.svg', icon: '/img/badges/indiegogo_en_turtle.svg',
}), }),
f.create('Badge', { factory.create('Badge', {
id: 'indiegogo_en_rhino', id: 'indiegogo_en_rhino',
icon: '/img/badges/indiegogo_en_rhino.svg', icon: '/img/badges/indiegogo_en_rhino.svg',
}), }),
@ -141,49 +147,49 @@ import { gql } from '../jest/helpers'
louie, louie,
dagobert, dagobert,
] = await Promise.all([ ] = await Promise.all([
f.create('User', { factory.create('User', {
id: 'u1', id: 'u1',
name: 'Peter Lustig', name: 'Peter Lustig',
slug: 'peter-lustig', slug: 'peter-lustig',
role: 'admin', role: 'admin',
email: 'admin@example.org', email: 'admin@example.org',
}), }),
f.create('User', { factory.create('User', {
id: 'u2', id: 'u2',
name: 'Bob der Baumeister', name: 'Bob der Baumeister',
slug: 'bob-der-baumeister', slug: 'bob-der-baumeister',
role: 'moderator', role: 'moderator',
email: 'moderator@example.org', email: 'moderator@example.org',
}), }),
f.create('User', { factory.create('User', {
id: 'u3', id: 'u3',
name: 'Jenny Rostock', name: 'Jenny Rostock',
slug: 'jenny-rostock', slug: 'jenny-rostock',
role: 'user', role: 'user',
email: 'user@example.org', email: 'user@example.org',
}), }),
f.create('User', { factory.create('User', {
id: 'u4', id: 'u4',
name: 'Huey', name: 'Huey',
slug: 'huey', slug: 'huey',
role: 'user', role: 'user',
email: 'huey@example.org', email: 'huey@example.org',
}), }),
f.create('User', { factory.create('User', {
id: 'u5', id: 'u5',
name: 'Dewey', name: 'Dewey',
slug: 'dewey', slug: 'dewey',
role: 'user', role: 'user',
email: 'dewey@example.org', email: 'dewey@example.org',
}), }),
f.create('User', { factory.create('User', {
id: 'u6', id: 'u6',
name: 'Louie', name: 'Louie',
slug: 'louie', slug: 'louie',
role: 'user', role: 'user',
email: 'louie@example.org', email: 'louie@example.org',
}), }),
f.create('User', { factory.create('User', {
id: 'u7', id: 'u7',
name: 'Dagobert', name: 'Dagobert',
slug: 'dagobert', slug: 'dagobert',
@ -220,103 +226,107 @@ import { gql } from '../jest/helpers'
dewey.relateTo(huey, 'following'), dewey.relateTo(huey, 'following'),
louie.relateTo(jennyRostock, 'following'), louie.relateTo(jennyRostock, 'following'),
huey.relateTo(dagobert, 'muted'),
dewey.relateTo(dagobert, 'muted'),
louie.relateTo(dagobert, 'muted'),
dagobert.relateTo(huey, 'blocked'), dagobert.relateTo(huey, 'blocked'),
dagobert.relateTo(dewey, 'blocked'), dagobert.relateTo(dewey, 'blocked'),
dagobert.relateTo(louie, 'blocked'), dagobert.relateTo(louie, 'blocked'),
]) ])
await Promise.all([ await Promise.all([
f.create('Category', { factory.create('Category', {
id: 'cat1', id: 'cat1',
name: 'Just For Fun', name: 'Just For Fun',
slug: 'just-for-fun', slug: 'just-for-fun',
icon: 'smile', icon: 'smile',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat2', id: 'cat2',
name: 'Happiness & Values', name: 'Happiness & Values',
slug: 'happiness-values', slug: 'happiness-values',
icon: 'heart-o', icon: 'heart-o',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat3', id: 'cat3',
name: 'Health & Wellbeing', name: 'Health & Wellbeing',
slug: 'health-wellbeing', slug: 'health-wellbeing',
icon: 'medkit', icon: 'medkit',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat4', id: 'cat4',
name: 'Environment & Nature', name: 'Environment & Nature',
slug: 'environment-nature', slug: 'environment-nature',
icon: 'tree', icon: 'tree',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat5', id: 'cat5',
name: 'Animal Protection', name: 'Animal Protection',
slug: 'animal-protection', slug: 'animal-protection',
icon: 'paw', icon: 'paw',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat6', id: 'cat6',
name: 'Human Rights & Justice', name: 'Human Rights & Justice',
slug: 'human-rights-justice', slug: 'human-rights-justice',
icon: 'balance-scale', icon: 'balance-scale',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat7', id: 'cat7',
name: 'Education & Sciences', name: 'Education & Sciences',
slug: 'education-sciences', slug: 'education-sciences',
icon: 'graduation-cap', icon: 'graduation-cap',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat8', id: 'cat8',
name: 'Cooperation & Development', name: 'Cooperation & Development',
slug: 'cooperation-development', slug: 'cooperation-development',
icon: 'users', icon: 'users',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat9', id: 'cat9',
name: 'Democracy & Politics', name: 'Democracy & Politics',
slug: 'democracy-politics', slug: 'democracy-politics',
icon: 'university', icon: 'university',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat10', id: 'cat10',
name: 'Economy & Finances', name: 'Economy & Finances',
slug: 'economy-finances', slug: 'economy-finances',
icon: 'money', icon: 'money',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat11', id: 'cat11',
name: 'Energy & Technology', name: 'Energy & Technology',
slug: 'energy-technology', slug: 'energy-technology',
icon: 'flash', icon: 'flash',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat12', id: 'cat12',
name: 'IT, Internet & Data Privacy', name: 'IT, Internet & Data Privacy',
slug: 'it-internet-data-privacy', slug: 'it-internet-data-privacy',
icon: 'mouse-pointer', icon: 'mouse-pointer',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat13', id: 'cat13',
name: 'Art, Culture & Sport', name: 'Art, Culture & Sport',
slug: 'art-culture-sport', slug: 'art-culture-sport',
icon: 'paint-brush', icon: 'paint-brush',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat14', id: 'cat14',
name: 'Freedom of Speech', name: 'Freedom of Speech',
slug: 'freedom-of-speech', slug: 'freedom-of-speech',
icon: 'bullhorn', icon: 'bullhorn',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat15', id: 'cat15',
name: 'Consumption & Sustainability', name: 'Consumption & Sustainability',
slug: 'consumption-sustainability', slug: 'consumption-sustainability',
icon: 'shopping-cart', icon: 'shopping-cart',
}), }),
f.create('Category', { factory.create('Category', {
id: 'cat16', id: 'cat16',
name: 'Global Peace & Nonviolence', name: 'Global Peace & Nonviolence',
slug: 'global-peace-nonviolence', slug: 'global-peace-nonviolence',
@ -325,16 +335,16 @@ import { gql } from '../jest/helpers'
]) ])
const [environment, nature, democracy, freedom] = await Promise.all([ const [environment, nature, democracy, freedom] = await Promise.all([
f.create('Tag', { factory.create('Tag', {
id: 'Environment', id: 'Environment',
}), }),
f.create('Tag', { factory.create('Tag', {
id: 'Nature', id: 'Nature',
}), }),
f.create('Tag', { factory.create('Tag', {
id: 'Democracy', id: 'Democracy',
}), }),
f.create('Tag', { factory.create('Tag', {
id: 'Freedom', id: 'Freedom',
}), }),
]) ])
@ -343,66 +353,84 @@ import { gql } from '../jest/helpers'
factory.create('Post', { factory.create('Post', {
author: peterLustig, author: peterLustig,
id: 'p0', id: 'p0',
image: faker.image.unsplash.food(), language: sample(languages),
image: faker.image.unsplash.food(300, 169),
categoryIds: ['cat16'], categoryIds: ['cat16'],
imageBlurred: true,
imageAspectRatio: 300 / 169,
}), }),
factory.create('Post', { factory.create('Post', {
author: bobDerBaumeister, author: bobDerBaumeister,
id: 'p1', id: 'p1',
image: faker.image.unsplash.technology(), language: sample(languages),
image: faker.image.unsplash.technology(300, 1500),
categoryIds: ['cat1'], categoryIds: ['cat1'],
imageAspectRatio: 300 / 1500,
}), }),
factory.create('Post', { factory.create('Post', {
author: huey, author: huey,
id: 'p3', id: 'p3',
language: sample(languages),
categoryIds: ['cat3'], categoryIds: ['cat3'],
}), }),
factory.create('Post', { factory.create('Post', {
author: dewey, author: dewey,
id: 'p4', id: 'p4',
language: sample(languages),
categoryIds: ['cat4'], categoryIds: ['cat4'],
}), }),
factory.create('Post', { factory.create('Post', {
author: louie, author: louie,
id: 'p5', id: 'p5',
language: sample(languages),
categoryIds: ['cat5'], categoryIds: ['cat5'],
}), }),
factory.create('Post', { factory.create('Post', {
authorId: 'u1', authorId: 'u1',
id: 'p6', id: 'p6',
image: faker.image.unsplash.buildings(), language: sample(languages),
image: faker.image.unsplash.buildings(300, 857),
categoryIds: ['cat6'], categoryIds: ['cat6'],
imageAspectRatio: 300 / 857,
}), }),
factory.create('Post', { factory.create('Post', {
author: huey, author: huey,
id: 'p9', id: 'p9',
language: sample(languages),
categoryIds: ['cat9'], categoryIds: ['cat9'],
}), }),
factory.create('Post', { factory.create('Post', {
author: dewey, author: dewey,
id: 'p10', id: 'p10',
categoryIds: ['cat10'], categoryIds: ['cat10'],
imageBlurred: true,
}), }),
factory.create('Post', { factory.create('Post', {
author: louie, author: louie,
id: 'p11', id: 'p11',
image: faker.image.unsplash.people(), language: sample(languages),
image: faker.image.unsplash.people(300, 901),
categoryIds: ['cat11'], categoryIds: ['cat11'],
imageAspectRatio: 300 / 901,
}), }),
factory.create('Post', { factory.create('Post', {
author: bobDerBaumeister, author: bobDerBaumeister,
id: 'p13', id: 'p13',
language: sample(languages),
categoryIds: ['cat13'], categoryIds: ['cat13'],
}), }),
factory.create('Post', { factory.create('Post', {
author: jennyRostock, author: jennyRostock,
id: 'p14', id: 'p14',
image: faker.image.unsplash.objects(), language: sample(languages),
image: faker.image.unsplash.objects(300, 200),
categoryIds: ['cat14'], categoryIds: ['cat14'],
imageAspectRatio: 300 / 450,
}), }),
factory.create('Post', { factory.create('Post', {
author: huey, author: huey,
id: 'p15', id: 'p15',
language: sample(languages),
categoryIds: ['cat15'], categoryIds: ['cat15'],
}), }),
]) ])
@ -413,12 +441,26 @@ import { gql } from '../jest/helpers'
const mention2 = const mention2 =
'Hey <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a>, here is another notification for you!' 'Hey <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a>, here is another notification for you!'
const hashtag1 = const hashtag1 =
'See <a class="hashtag" href="/search/hashtag/NaturphilosophieYoga">#NaturphilosophieYoga</a> can really help you!' 'See <a class="hashtag" data-hashtag-id="NaturphilosophieYoga" href="/?hashtag=NaturphilosophieYoga">#NaturphilosophieYoga</a>, it can really help you!'
const hashtagAndMention1 = const hashtagAndMention1 =
'The new physics of <a class="hashtag" href="/search/hashtag/QuantenFlussTheorie">#QuantenFlussTheorie</a> can explain <a class="hashtag" href="/search/hashtag/QuantumGravity">#QuantumGravity</a>! <a class="mention" data-mention-id="u1" href="/profile/u1">@peter-lustig</a> got that already. ;-)' 'The new physics of <a class="hashtag" data-hashtag-id="QuantenFlussTheorie" href="/?hashtag=QuantenFlussTheorie">#QuantenFlussTheorie</a> can explain <a class="hashtag" data-hashtag-id="QuantumGravity" href="/?hashtag=QuantumGravity">#QuantumGravity</a>! <a class="mention" data-mention-id="u1" href="/profile/u1">@peter-lustig</a> got that already. ;-)'
const createPostMutation = gql` const createPostMutation = gql`
mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]) { mutation(
CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { $id: ID
$title: String!
$content: String!
$categoryIds: [ID]
$imageBlurred: Boolean
$imageAspectRatio: Float
) {
CreatePost(
id: $id
title: $title
content: $content
categoryIds: $categoryIds
imageBlurred: $imageBlurred
imageAspectRatio: $imageAspectRatio
) {
id id
} }
} }
@ -432,6 +474,7 @@ import { gql } from '../jest/helpers'
title: `Nature Philosophy Yoga`, title: `Nature Philosophy Yoga`,
content: hashtag1, content: hashtag1,
categoryIds: ['cat2'], categoryIds: ['cat2'],
imageAspectRatio: 300 / 200,
}, },
}), }),
mutate({ mutate({
@ -441,6 +484,7 @@ import { gql } from '../jest/helpers'
title: 'This is post #7', title: 'This is post #7',
content: `${mention1} ${faker.lorem.paragraph()}`, content: `${mention1} ${faker.lorem.paragraph()}`,
categoryIds: ['cat7'], categoryIds: ['cat7'],
imageAspectRatio: 300 / 180,
}, },
}), }),
mutate({ mutate({
@ -451,6 +495,7 @@ import { gql } from '../jest/helpers'
title: `Quantum Flow Theory explains Quantum Gravity`, title: `Quantum Flow Theory explains Quantum Gravity`,
content: hashtagAndMention1, content: hashtagAndMention1,
categoryIds: ['cat8'], categoryIds: ['cat8'],
imageAspectRatio: 300 / 900,
}, },
}), }),
mutate({ mutate({
@ -460,6 +505,7 @@ import { gql } from '../jest/helpers'
title: 'This is post #12', title: 'This is post #12',
content: `${mention2} ${faker.lorem.paragraph()}`, content: `${mention2} ${faker.lorem.paragraph()}`,
categoryIds: ['cat12'], categoryIds: ['cat12'],
imageAspectRatio: 300 / 200,
}, },
}), }),
]) ])
@ -470,9 +516,9 @@ import { gql } from '../jest/helpers'
authenticatedUser = await dewey.toJson() authenticatedUser = await dewey.toJson()
const mentionInComment1 = const mentionInComment1 =
'I heard <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a>, practice it since 3 years now.' 'I heard <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a> has practiced it for 3 years now.'
const mentionInComment2 = const mentionInComment2 =
'Did <a class="mention" data-mention-id="u1" href="/profile/u1">@peter-lustig</a> told you?' 'Did <a class="mention" data-mention-id="u1" href="/profile/u1">@peter-lustig</a> tell you?'
const createCommentMutation = gql` const createCommentMutation = gql`
mutation($id: ID, $postId: ID!, $content: String!) { mutation($id: ID, $postId: ID!, $content: String!) {
CreateComment(id: $id, postId: $postId, content: $content) { CreateComment(id: $id, postId: $postId, content: $content) {
@ -507,7 +553,7 @@ import { gql } from '../jest/helpers'
]) ])
authenticatedUser = null authenticatedUser = null
await Promise.all([ const comments = await Promise.all([
factory.create('Comment', { factory.create('Comment', {
author: jennyRostock, author: jennyRostock,
id: 'c1', id: 'c1',
@ -524,7 +570,7 @@ import { gql } from '../jest/helpers'
postId: 'p3', postId: 'p3',
}), }),
factory.create('Comment', { factory.create('Comment', {
author: bobDerBaumeister, author: jennyRostock,
id: 'c5', id: 'c5',
postId: 'p3', postId: 'p3',
}), }),
@ -564,6 +610,7 @@ import { gql } from '../jest/helpers'
postId: 'p15', postId: 'p15',
}), }),
]) ])
const trollingComment = comments[0]
await Promise.all([ await Promise.all([
democracy.relateTo(p3, 'post'), democracy.relateTo(p3, 'post'),
@ -627,67 +674,339 @@ import { gql } from '../jest/helpers'
louie.relateTo(p10, 'shouted'), louie.relateTo(p10, 'shouted'),
]) ])
const disableMutation = gql` const reports = await Promise.all([
mutation($id: ID!) { factory.create('Report'),
disable(id: $id) factory.create('Report'),
} factory.create('Report'),
` factory.create('Report'),
authenticatedUser = await bobDerBaumeister.toJson()
await Promise.all([
mutate({
mutation: disableMutation,
variables: {
id: 'p11',
},
}),
mutate({
mutation: disableMutation,
variables: {
id: 'c5',
},
}),
]) ])
authenticatedUser = null const reportAgainstDagobert = reports[0]
const reportAgainstTrollingPost = reports[1]
const reportAgainstTrollingComment = reports[2]
const reportAgainstDewey = reports[3]
const reportMutation = gql` // report resource first time
mutation($id: ID!, $description: String!) {
report(description: $description, id: $id) {
id
}
}
`
authenticatedUser = await huey.toJson()
await Promise.all([ await Promise.all([
mutate({ reportAgainstDagobert.relateTo(jennyRostock, 'filed', {
mutation: reportMutation, resourceId: 'u7',
variables: { reasonCategory: 'discrimination_etc',
description: "I don't like this comment", reasonDescription: 'This user is harassing me with bigoted remarks!',
id: 'c1',
},
}), }),
mutate({ reportAgainstDagobert.relateTo(dagobert, 'belongsTo'),
mutation: reportMutation, reportAgainstTrollingPost.relateTo(jennyRostock, 'filed', {
variables: { resourceId: 'p2',
description: "I don't like this post", reasonCategory: 'doxing',
id: 'p1', reasonDescription: "This shouldn't be shown to anybody else! It's my private thing!",
},
}), }),
mutate({ reportAgainstTrollingPost.relateTo(p2, 'belongsTo'),
mutation: reportMutation, reportAgainstTrollingComment.relateTo(huey, 'filed', {
variables: { resourceId: 'c1',
description: "I don't like this user", reasonCategory: 'other',
id: 'u1', reasonDescription: 'This comment is bigoted',
},
}), }),
reportAgainstTrollingComment.relateTo(trollingComment, 'belongsTo'),
reportAgainstDewey.relateTo(dagobert, 'filed', {
resourceId: 'u5',
reasonCategory: 'discrimination_etc',
reasonDescription: 'This user is harassing me!',
}),
reportAgainstDewey.relateTo(dewey, 'belongsTo'),
])
// report resource a second time
await Promise.all([
reportAgainstDagobert.relateTo(louie, 'filed', {
resourceId: 'u7',
reasonCategory: 'discrimination_etc',
reasonDescription: 'this user is attacking me for who I am!',
}),
reportAgainstDagobert.relateTo(dagobert, 'belongsTo'),
reportAgainstTrollingPost.relateTo(peterLustig, 'filed', {
resourceId: 'p2',
reasonCategory: 'discrimination_etc',
reasonDescription: 'This post is bigoted',
}),
reportAgainstTrollingPost.relateTo(p2, 'belongsTo'),
reportAgainstTrollingComment.relateTo(bobDerBaumeister, 'filed', {
resourceId: 'c1',
reasonCategory: 'pornographic_content_links',
reasonDescription: 'This comment is porno!!!',
}),
reportAgainstTrollingComment.relateTo(trollingComment, 'belongsTo'),
])
const disableVariables = {
resourceId: 'undefined-resource',
disable: true,
closed: false,
}
// review resource first time
await Promise.all([
reportAgainstDagobert.relateTo(bobDerBaumeister, 'reviewed', {
...disableVariables,
resourceId: 'u7',
}),
dagobert.update({ disabled: true, updatedAt: new Date().toISOString() }),
reportAgainstTrollingPost.relateTo(peterLustig, 'reviewed', {
...disableVariables,
resourceId: 'p2',
}),
p2.update({ disabled: true, updatedAt: new Date().toISOString() }),
reportAgainstTrollingComment.relateTo(bobDerBaumeister, 'reviewed', {
...disableVariables,
resourceId: 'c1',
}),
trollingComment.update({ disabled: true, updatedAt: new Date().toISOString() }),
])
// second review of resource and close report
await Promise.all([
reportAgainstDagobert.relateTo(peterLustig, 'reviewed', {
resourceId: 'u7',
disable: false,
closed: true,
}),
dagobert.update({ disabled: false, updatedAt: new Date().toISOString(), closed: true }),
reportAgainstTrollingPost.relateTo(bobDerBaumeister, 'reviewed', {
resourceId: 'p2',
disable: true,
closed: true,
}),
p2.update({ disabled: true, updatedAt: new Date().toISOString(), closed: true }),
reportAgainstTrollingComment.relateTo(peterLustig, 'reviewed', {
...disableVariables,
resourceId: 'c1',
disable: true,
closed: true,
}),
trollingComment.update({ disabled: true, updatedAt: new Date().toISOString(), closed: true }),
]) ])
authenticatedUser = null
await Promise.all( await Promise.all(
[...Array(30).keys()].map(i => { [...Array(30).keys()].map(i => {
return f.create('User') return factory.create('User')
}), }),
) )
await Promise.all(
[...Array(30).keys()].map(() => {
return factory.create('Post', {
author: jennyRostock,
image: faker.image.unsplash.objects(),
})
}),
)
await Promise.all(
[...Array(6).keys()].map(() => {
return factory.create('Comment', {
author: jennyRostock,
postId: 'p2',
})
}),
)
await Promise.all(
[...Array(4).keys()].map(() => {
return factory.create('Comment', {
author: jennyRostock,
postId: 'p15',
})
}),
)
await Promise.all(
[...Array(2).keys()].map(() => {
return factory.create('Comment', {
author: jennyRostock,
postId: 'p4',
})
}),
)
await Promise.all(
[...Array(21).keys()].map(() => {
return factory.create('Post', {
author: peterLustig,
image: faker.image.unsplash.buildings(),
})
}),
)
await Promise.all(
[...Array(3).keys()].map(() => {
return factory.create('Comment', {
author: peterLustig,
postId: 'p4',
})
}),
)
await Promise.all(
[...Array(5).keys()].map(() => {
return factory.create('Comment', {
author: peterLustig,
postId: 'p14',
})
}),
)
await Promise.all(
[...Array(6).keys()].map(() => {
return factory.create('Comment', {
author: peterLustig,
postId: 'p0',
})
}),
)
await Promise.all(
[...Array(11).keys()].map(() => {
return factory.create('Post', {
author: dewey,
image: faker.image.unsplash.food(),
})
}),
)
await Promise.all(
[...Array(7).keys()].map(() => {
return factory.create('Comment', {
author: dewey,
postId: 'p2',
})
}),
)
await Promise.all(
[...Array(5).keys()].map(() => {
return factory.create('Comment', {
author: dewey,
postId: 'p6',
})
}),
)
await Promise.all(
[...Array(2).keys()].map(() => {
return factory.create('Comment', {
author: dewey,
postId: 'p9',
})
}),
)
await Promise.all(
[...Array(16).keys()].map(() => {
return factory.create('Post', {
author: louie,
image: faker.image.unsplash.technology(),
})
}),
)
await Promise.all(
[...Array(4).keys()].map(() => {
return factory.create('Comment', {
author: louie,
postId: 'p1',
})
}),
)
await Promise.all(
[...Array(8).keys()].map(() => {
return factory.create('Comment', {
author: louie,
postId: 'p10',
})
}),
)
await Promise.all(
[...Array(5).keys()].map(() => {
return factory.create('Comment', {
author: louie,
postId: 'p13',
})
}),
)
await Promise.all(
[...Array(45).keys()].map(() => {
return factory.create('Post', {
author: bobDerBaumeister,
image: faker.image.unsplash.people(),
})
}),
)
await Promise.all(
[...Array(2).keys()].map(() => {
return factory.create('Comment', {
author: bobDerBaumeister,
postId: 'p2',
})
}),
)
await Promise.all(
[...Array(3).keys()].map(() => {
return factory.create('Comment', {
author: bobDerBaumeister,
postId: 'p12',
})
}),
)
await Promise.all(
[...Array(7).keys()].map(() => {
return factory.create('Comment', {
author: bobDerBaumeister,
postId: 'p13',
})
}),
)
await Promise.all(
[...Array(8).keys()].map(() => {
return factory.create('Post', {
author: huey,
image: faker.image.unsplash.nature(),
})
}),
)
await Promise.all(
[...Array(6).keys()].map(() => {
return factory.create('Comment', {
author: huey,
postId: 'p0',
})
}),
)
await Promise.all(
[...Array(8).keys()].map(() => {
return factory.create('Comment', {
author: huey,
postId: 'p13',
})
}),
)
await Promise.all(
[...Array(9).keys()].map(() => {
return factory.create('Comment', {
author: huey,
postId: 'p15',
})
}),
)
await factory.create('Donations')
/* eslint-disable-next-line no-console */ /* eslint-disable-next-line no-console */
console.log('Seeded Data...') console.log('Seeded Data...')
process.exit(0) process.exit(0)

View File

@ -1,17 +1,18 @@
import faker from 'faker' import uuid from 'uuid/v4'
export default function create() { export default function create() {
return { return {
factory: async ({ args, neodeInstance }) => { factory: async ({ args, neodeInstance }) => {
const defaults = { const defaults = {
email: faker.internet.email(), id: uuid(),
verifiedAt: new Date().toISOString(), goal: 15000,
progress: 0,
} }
args = { args = {
...defaults, ...defaults,
...args, ...args,
} }
return neodeInstance.create('EmailAddress', args) return neodeInstance.create('Donations', args)
}, },
} }
} }

View File

@ -0,0 +1,22 @@
import faker from 'faker'
export function defaults({ args }) {
const defaults = {
email: faker.internet.email(),
verifiedAt: new Date().toISOString(),
}
args = {
...defaults,
...args,
}
return args
}
export default function create() {
return {
factory: async ({ args, neodeInstance }) => {
args = defaults({ args })
return neodeInstance.create('EmailAddress', args)
},
}
}

View File

@ -0,0 +1,63 @@
import { getDriver, getNeode } from '../db/neo4j'
const factories = {
Badge: require('./badges.js').default,
User: require('./users.js').default,
Post: require('./posts.js').default,
Comment: require('./comments.js').default,
Category: require('./categories.js').default,
Tag: require('./tags.js').default,
SocialMedia: require('./socialMedia.js').default,
Location: require('./locations.js').default,
EmailAddress: require('./emailAddresses.js').default,
UnverifiedEmailAddress: require('./unverifiedEmailAddresses.js').default,
Donations: require('./donations.js').default,
Report: require('./reports.js').default,
}
export const cleanDatabase = async (options = {}) => {
const { driver = getDriver() } = options
const session = driver.session()
try {
await session.writeTransaction(transaction => {
return transaction.run(
`
MATCH (everything)
DETACH DELETE everything
`,
)
})
} finally {
session.close()
}
}
export default function Factory(options = {}) {
const { neo4jDriver = getDriver(), neodeInstance = getNeode() } = options
const result = {
neo4jDriver,
factories,
lastResponse: null,
neodeInstance,
async create(node, args = {}) {
const { factory } = this.factories[node](args)
this.lastResponse = await factory({
args,
neodeInstance,
factoryInstance: this,
})
return this.lastResponse
},
async cleanDatabase() {
this.lastResponse = await cleanDatabase({
driver: this.neo4jDriver,
})
return this
},
}
result.create.bind(result)
result.cleanDatabase.bind(result)
return result
}

View File

@ -19,11 +19,17 @@ export default function create() {
visibility: 'public', visibility: 'public',
deleted: false, deleted: false,
categoryIds: [], categoryIds: [],
imageBlurred: false,
imageAspectRatio: 1.333,
pinned: null,
} }
args = { args = {
...defaults, ...defaults,
...args, ...args,
} }
// Convert false to null
args.pinned = args.pinned || null
args.slug = args.slug || slugify(args.title, { lower: true }) args.slug = args.slug || slugify(args.title, { lower: true })
args.contentExcerpt = args.contentExcerpt || args.content args.contentExcerpt = args.contentExcerpt || args.content
@ -34,7 +40,6 @@ export default function create() {
if (categoryIds) if (categoryIds)
categories = await Promise.all(categoryIds.map(id => neodeInstance.find('Category', id))) categories = await Promise.all(categoryIds.map(id => neodeInstance.find('Category', id)))
categories = categories || (await Promise.all([factoryInstance.create('Category')])) categories = categories || (await Promise.all([factoryInstance.create('Category')]))
const { tagIds = [] } = args const { tagIds = [] } = args
delete args.tags delete args.tags
const tags = await Promise.all( const tags = await Promise.all(
@ -49,9 +54,21 @@ export default function create() {
if (author && authorId) throw new Error('You provided both author and authorId') if (author && authorId) throw new Error('You provided both author and authorId')
if (authorId) author = await neodeInstance.find('User', authorId) if (authorId) author = await neodeInstance.find('User', authorId)
author = author || (await factoryInstance.create('User')) author = author || (await factoryInstance.create('User'))
const post = await neodeInstance.create('Post', args) const post = await neodeInstance.create('Post', args)
await post.relateTo(author, 'author') await post.relateTo(author, 'author')
if (args.pinned) {
args.pinnedAt = args.pinnedAt || new Date().toISOString()
if (!args.pinnedBy) {
const admin = await factoryInstance.create('User', {
role: 'admin',
updatedAt: new Date().toISOString(),
})
await admin.relateTo(post, 'pinned')
args.pinnedBy = admin
}
}
await Promise.all(categories.map(c => c.relateTo(post, 'post'))) await Promise.all(categories.map(c => c.relateTo(post, 'post')))
await Promise.all(tags.map(t => t.relateTo(post, 'post'))) await Promise.all(tags.map(t => t.relateTo(post, 'post')))
return post return post

View File

@ -0,0 +1,7 @@
export default function create() {
return {
factory: async ({ args, neodeInstance }) => {
return neodeInstance.create('Report', args)
},
}
}

View File

@ -0,0 +1,10 @@
import { defaults } from './emailAddresses.js'
export default function create() {
return {
factory: async ({ args, neodeInstance }) => {
args = defaults({ args })
return neodeInstance.create('UnverifiedEmailAddress', args)
},
}
}

View File

@ -1,6 +1,6 @@
import faker from 'faker' import faker from 'faker'
import uuid from 'uuid/v4' import uuid from 'uuid/v4'
import encryptPassword from '../../helpers/encryptPassword' import encryptPassword from '../helpers/encryptPassword'
import slugify from 'slug' import slugify from 'slug'
export default function create() { export default function create() {
@ -16,6 +16,9 @@ export default function create() {
about: faker.lorem.paragraph(), about: faker.lorem.paragraph(),
termsAndConditionsAgreedVersion: '0.0.1', termsAndConditionsAgreedVersion: '0.0.1',
termsAndConditionsAgreedAt: '2019-08-01T10:47:19.212Z', termsAndConditionsAgreedAt: '2019-08-01T10:47:19.212Z',
allowEmbedIframes: false,
showShoutsPublicly: false,
locale: 'en',
} }
defaults.slug = slugify(defaults.name, { lower: true }) defaults.slug = slugify(defaults.name, { lower: true })
args = { args = {
@ -24,7 +27,15 @@ export default function create() {
} }
args = await encryptPassword(args) args = await encryptPassword(args)
const user = await neodeInstance.create('User', args) const user = await neodeInstance.create('User', args)
const email = await factoryInstance.create('EmailAddress', { email: args.email })
let email
if (typeof args.email === 'object') {
// probably a neode node
email = args.email
} else {
email = await factoryInstance.create('EmailAddress', { email: args.email })
}
await user.relateTo(email, 'primaryEmail') await user.relateTo(email, 'primaryEmail')
await email.relateTo(user, 'belongsTo') await email.relateTo(user, 'belongsTo')
return user return user

View File

@ -0,0 +1,5 @@
//* This is a fake ES2015 template string, just to benefit of syntax
// highlighting of `gql` template strings in certain editors.
export function gql(strings) {
return strings.join('')
}

View File

@ -2,7 +2,8 @@ import createServer from './server'
import CONFIG from './config' import CONFIG from './config'
const { app } = createServer() const { app } = createServer()
app.listen({ port: CONFIG.GRAPHQL_PORT }, () => { const url = new URL(CONFIG.GRAPHQL_URI)
app.listen({ port: url.port }, () => {
/* eslint-disable-next-line no-console */ /* eslint-disable-next-line no-console */
console.log(`GraphQLServer ready at ${CONFIG.GRAPHQL_URI} 🚀`) console.log(`GraphQLServer ready at ${CONFIG.GRAPHQL_URI} 🚀`)
}) })

View File

@ -1,23 +0,0 @@
import { request } from 'graphql-request'
// this is the to-be-tested server host
// not to be confused with the seeder host
export const host = 'http://127.0.0.1:4123'
export async function login(variables) {
const mutation = `
mutation($email: String!, $password: String!) {
login(email: $email, password: $password)
}
`
const response = await request(host, mutation, variables)
return {
authorization: `Bearer ${response.login}`,
}
}
//* This is a fake ES2015 template string, just to benefit of syntax
// highlighting of `gql` template strings in certain editors.
export function gql(strings) {
return strings.join('')
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -12,19 +12,27 @@ export default async (driver, authorizationHeader) => {
return null return null
} }
const session = driver.session() const session = driver.session()
const query = `
MATCH (user:User {id: $id, deleted: false, disabled: false }) const writeTxResultPromise = session.writeTransaction(async transaction => {
RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId} const updateUserLastActiveTransactionResponse = await transaction.run(
LIMIT 1 `
` MATCH (user:User {id: $id, deleted: false, disabled: false })
const result = await session.run(query, { id }) SET user.lastActiveAt = toString(datetime())
session.close() RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId}
const [currentUser] = await result.records.map(record => { LIMIT 1
return record.get('user') `,
{ id },
)
return updateUserLastActiveTransactionResponse.records.map(record => record.get('user'))
}) })
if (!currentUser) return null try {
return { const [currentUser] = await writeTxResultPromise
token, if (!currentUser) return null
...currentUser, return {
token,
...currentUser,
}
} finally {
session.close()
} }
} }

View File

@ -1,9 +1,10 @@
import Factory from '../seed/factories/index' import Factory from '../factories/index'
import { getDriver } from '../bootstrap/neo4j' import { getDriver, getNeode } from '../db/neo4j'
import decode from './decode' import decode from './decode'
const factory = Factory() const factory = Factory()
const driver = getDriver() const driver = getDriver()
const neode = getNeode()
// here is the decoded JWT token: // here is the decoded JWT token:
// { // {
@ -85,6 +86,33 @@ describe('decode', () => {
}) })
}) })
it('sets `lastActiveAt`', async () => {
let user = await neode.first('User', { id: 'u3' })
await expect(user.toJson()).resolves.not.toHaveProperty('lastActiveAt')
await decode(driver, authorizationHeader)
user = await neode.first('User', { id: 'u3' })
await expect(user.toJson()).resolves.toMatchObject({
lastActiveAt: expect.any(String),
})
})
it('updates `lastActiveAt` for every authenticated request', async () => {
let user = await neode.first('User', { id: 'u3' })
await user.update({
updatedAt: new Date().toISOString(),
lastActiveAt: '2019-10-03T23:33:08.598Z',
})
await expect(user.toJson()).resolves.toMatchObject({
lastActiveAt: '2019-10-03T23:33:08.598Z',
})
await decode(driver, authorizationHeader)
user = await neode.first('User', { id: 'u3' })
await expect(user.toJson()).resolves.toMatchObject({
// should be a different time by now ;)
lastActiveAt: expect.not.stringContaining('2019-10-03T23:33'),
})
})
describe('but user is deleted', () => { describe('but user is deleted', () => {
beforeEach(async () => { beforeEach(async () => {
await user.update({ updatedAt: new Date().toISOString(), deleted: true }) await user.update({ updatedAt: new Date().toISOString(), deleted: true })
@ -92,6 +120,7 @@ describe('decode', () => {
it('returns null', returnsNull) it('returns null', returnsNull)
}) })
describe('but user is disabled', () => { describe('but user is disabled', () => {
beforeEach(async () => { beforeEach(async () => {
await user.update({ updatedAt: new Date().toISOString(), disabled: true }) await user.update({ updatedAt: new Date().toISOString(), disabled: true })

View File

@ -1,51 +1,51 @@
import { generateRsaKeyPair } from '../activitypub/security' import { generateRsaKeyPair } from '../activitypub/security'
import { activityPub } from '../activitypub/ActivityPub' import { activityPub } from '../activitypub/ActivityPub'
import as from 'activitystrea.ms' // import as from 'activitystrea.ms'
const debug = require('debug')('backend:schema') // const debug = require('debug')('backend:schema')
export default { export default {
Mutation: { Mutation: {
CreatePost: async (resolve, root, args, context, info) => { // CreatePost: async (resolve, root, args, context, info) => {
args.activityId = activityPub.generateStatusId(context.user.slug) // args.activityId = activityPub.generateStatusId(context.user.slug)
args.objectId = activityPub.generateStatusId(context.user.slug) // args.objectId = activityPub.generateStatusId(context.user.slug)
const post = await resolve(root, args, context, info) // const post = await resolve(root, args, context, info)
const { user: author } = context // const { user: author } = context
const actorId = author.actorId // const actorId = author.actorId
debug(`actorId = ${actorId}`) // debug(`actorId = ${actorId}`)
const createActivity = await new Promise((resolve, reject) => { // const createActivity = await new Promise((resolve, reject) => {
as.create() // as.create()
.id(`${actorId}/status/${args.activityId}`) // .id(`${actorId}/status/${args.activityId}`)
.actor(`${actorId}`) // .actor(`${actorId}`)
.object( // .object(
as // as
.article() // .article()
.id(`${actorId}/status/${post.id}`) // .id(`${actorId}/status/${post.id}`)
.content(post.content) // .content(post.content)
.to('https://www.w3.org/ns/activitystreams#Public') // .to('https://www.w3.org/ns/activitystreams#Public')
.publishedNow() // .publishedNow()
.attributedTo(`${actorId}`), // .attributedTo(`${actorId}`),
) // )
.prettyWrite((err, doc) => { // .prettyWrite((err, doc) => {
if (err) { // if (err) {
reject(err) // reject(err)
} else { // } else {
debug(doc) // debug(doc)
const parsedDoc = JSON.parse(doc) // const parsedDoc = JSON.parse(doc)
parsedDoc.send = true // parsedDoc.send = true
resolve(JSON.stringify(parsedDoc)) // resolve(JSON.stringify(parsedDoc))
} // }
}) // })
}) // })
try { // try {
await activityPub.sendActivity(createActivity) // await activityPub.sendActivity(createActivity)
} catch (e) { // } catch (e) {
debug(`error sending post activity\n${e}`) // debug(`error sending post activity\n${e}`)
} // }
return post // return post
}, // },
SignupVerification: async (resolve, root, args, context, info) => { SignupVerification: async (resolve, root, args, context, info) => {
const keys = generateRsaKeyPair() const keys = generateRsaKeyPair()
Object.assign(args, keys) Object.assign(args, keys)

View File

@ -1,36 +1,45 @@
import CONFIG from '../../config' import CONFIG from '../../config'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { resetPasswordMail, wrongAccountMail } from './templates/passwordReset' import { htmlToText } from 'nodemailer-html-to-text'
import { signupTemplate } from './templates/signup' import {
signupTemplate,
resetPasswordTemplate,
wrongAccountTemplate,
emailVerificationTemplate,
} from './templateBuilder'
let sendMail const hasEmailConfig = CONFIG.SMTP_HOST && CONFIG.SMTP_PORT
if (CONFIG.SMTP_HOST && CONFIG.SMTP_PORT) { const hasAuthData = CONFIG.SMTP_USERNAME && CONFIG.SMTP_PASSWORD
sendMail = async templateArgs => {
await transporter().sendMail({ let sendMail = () => {}
from: '"Human Connection" <info@human-connection.org>', if (!hasEmailConfig) {
...templateArgs,
})
}
} else {
sendMail = () => {}
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('Warning: Email middleware will not try to send mails.') console.log('Warning: Email middleware will not try to send mails.')
} }
} } else {
sendMail = async templateArgs => {
const transporter = nodemailer.createTransport({
host: CONFIG.SMTP_HOST,
port: CONFIG.SMTP_PORT,
ignoreTLS: CONFIG.SMTP_IGNORE_TLS === 'true',
secure: false, // true for 465, false for other ports
auth: hasAuthData && {
user: CONFIG.SMTP_USERNAME,
pass: CONFIG.SMTP_PASSWORD,
},
})
const transporter = () => { transporter.use(
const configs = { 'compile',
host: CONFIG.SMTP_HOST, htmlToText({
port: CONFIG.SMTP_PORT, ignoreImage: true,
ignoreTLS: CONFIG.SMTP_IGNORE_TLS, wordwrap: false,
secure: false, // true for 465, false for other ports }),
)
await transporter.sendMail(templateArgs)
} }
const { SMTP_USERNAME: user, SMTP_PASSWORD: pass } = CONFIG
if (user && pass) {
configs.auth = { user, pass }
}
return nodemailer.createTransport(configs)
} }
const sendSignupMail = async (resolve, root, args, context, resolveInfo) => { const sendSignupMail = async (resolve, root, args, context, resolveInfo) => {
@ -41,15 +50,26 @@ const sendSignupMail = async (resolve, root, args, context, resolveInfo) => {
return response return response
} }
const sendPasswordResetMail = async (resolve, root, args, context, resolveInfo) => {
const { email } = args
const { email: userFound, nonce, name } = await resolve(root, args, context, resolveInfo)
const template = userFound ? resetPasswordTemplate : wrongAccountTemplate
await sendMail(template({ email, nonce, name }))
return true
}
const sendEmailVerificationMail = async (resolve, root, args, context, resolveInfo) => {
const response = await resolve(root, args, context, resolveInfo)
const { email, nonce, name } = response
await sendMail(emailVerificationTemplate({ email, nonce, name }))
delete response.nonce
return response
}
export default { export default {
Mutation: { Mutation: {
requestPasswordReset: async (resolve, root, args, context, resolveInfo) => { AddEmailAddress: sendEmailVerificationMail,
const { email } = args requestPasswordReset: sendPasswordResetMail,
const { email: emailFound, nonce, name } = await resolve(root, args, context, resolveInfo)
const mailTemplate = emailFound ? resetPasswordMail : wrongAccountMail
await sendMail(mailTemplate({ email, nonce, name }))
return true
},
Signup: sendSignupMail, Signup: sendSignupMail,
SignupByInvitation: sendSignupMail, SignupByInvitation: sendSignupMail,
}, },

View File

@ -0,0 +1,77 @@
import mustache from 'mustache'
import CONFIG from '../../config'
import * as templates from './templates'
const from = '"Human Connection" <info@human-connection.org>'
const supportUrl = 'https://human-connection.org/en/contact'
export const signupTemplate = ({ email, nonce }) => {
const subject = 'Willkommen, Bienvenue, Welcome to Human Connection!'
const actionUrl = new URL('/registration/create-user-account', CONFIG.CLIENT_URI)
actionUrl.searchParams.set('nonce', nonce)
actionUrl.searchParams.set('email', email)
return {
from,
to: email,
subject,
html: mustache.render(
templates.layout,
{ actionUrl, nonce, supportUrl, subject },
{ content: templates.signup },
),
}
}
export const emailVerificationTemplate = ({ email, nonce, name }) => {
const subject = 'Neue E-Mail Adresse | New E-Mail Address'
const actionUrl = new URL('/settings/my-email-address/verify', CONFIG.CLIENT_URI)
actionUrl.searchParams.set('nonce', nonce)
actionUrl.searchParams.set('email', email)
return {
from,
to: email,
subject,
html: mustache.render(
templates.layout,
{ actionUrl, name, nonce, supportUrl, subject },
{ content: templates.emailVerification },
),
}
}
export const resetPasswordTemplate = ({ email, nonce, name }) => {
const subject = 'Neues Passwort | Reset Password'
const actionUrl = new URL('/password-reset/change-password', CONFIG.CLIENT_URI)
actionUrl.searchParams.set('nonce', nonce)
actionUrl.searchParams.set('email', email)
return {
from,
to: email,
subject,
html: mustache.render(
templates.layout,
{ actionUrl, name, nonce, supportUrl, subject },
{ content: templates.passwordReset },
),
}
}
export const wrongAccountTemplate = ({ email }) => {
const subject = 'Falsche Mailadresse? | Wrong E-mail?'
const actionUrl = new URL('/password-reset/request', CONFIG.CLIENT_URI)
return {
from,
to: email,
subject,
html: mustache.render(
templates.layout,
{ actionUrl, supportUrl },
{ content: templates.wrongAccount },
),
}
}

View File

@ -0,0 +1,186 @@
<!-- Email Body German : BEGIN -->
<table class="email-german" align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"
style="margin: auto;">
<!-- Hero Image, Flush : BEGIN -->
<tr>
<td style="background-color: #ffffff;">
<img
src="https://firebasestorage.googleapis.com/v0/b/gitbook-28427.appspot.com/o/assets%2F-LcGvGRsW6DrZn7FWRzF%2F-LcGv6EiVcsjYLfQ_2YE%2F-LcGv8UtmAWc61fxGveg%2Flets_get_together.png?generation=1555078880410873&alt=media"
width="600" height="" alt="Human Connection community logo" border="0"
style="width: 100%; max-width: 600px; height: auto; background: #ffffff; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; color: #555555; margin: auto; display: block;"
class="g-img">
</td>
</tr>
<!-- Hero Image, Flush : END -->
<!-- 1 Column Text + Button : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<h1
style="margin: 0 0 10px 0; font-family: Lato, sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;">
Hallo {{ name }}!</h1>
<p style="margin: 0;">Du möchtest also deine E-Mail ändern? Kein Problem! Mit Klick auf diesen Button
kannst Du Deine neue E-Mail Adresse bestätigen:</p>
</td>
</tr>
<tr>
<td style="padding: 0 20px;">
<!-- Button : BEGIN -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
<tr>
<td class="button-td button-td-primary" style="border-radius: 4px; background: #17b53e;">
<a class="button-a button-a-primary" href="{{{ actionUrl }}}"
style="background: #17b53e; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;">E-Mail
Adresse
bestätigen</a>
</td>
</tr>
</table>
<!-- Button : END -->
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text + Button : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-bottom: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">Falls Du deine E-Mail Adresse doch nicht ändern möchtest, kannst du diese Nachricht
einfach ignorieren. Melde Dich gerne <a href="{{{ supportUrl }}}" style="color: #17b53e;">bei
unserem Support Team</a>, wenn du noch Fragen hast!</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in
Dein Browserfenster kopieren: <span style="color: #17b53e;">{{{ nonce }}}</span></p>
<p style="margin: 0; margin-top: 10px;">Bis bald bei <a href="https://human-connection.org"
style="color: #17b53e;">Human Connection</a>!</p>
<p style="margin: 0; margin-bottom: 10px;"> Dein Human Connection Team</p>
</td>
</tr>
<tr>
<td style="display: none;">
<p></p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
</table>
<!-- Email Body German : END -->
<!-- Email Body English : BEGIN -->
<table class="email-english" align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"
style="margin: auto;">
<tr>
<td style="padding: 20px 0; text-align: center">
</td>
</tr>
<!-- Hero Image, Flush : BEGIN -->
<tr>
<td style="background-color: #ffffff;">
<img
src="https://firebasestorage.googleapis.com/v0/b/gitbook-28427.appspot.com/o/assets%2F-LcGvGRsW6DrZn7FWRzF%2F-LcGv6EiVcsjYLfQ_2YE%2F-LcGv8UtmAWc61fxGveg%2Flets_get_together.png?generation=1555078880410873&alt=media"
width="600" height="" alt="Human Connection community logo" border="0"
style="width: 100%; max-width: 600px; height: auto; background: #ffffff; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; color: #555555; margin: auto; display: block;"
class="g-img">
</td>
</tr>
<!-- Hero Image, Flush : END -->
<!-- 1 Column Text + Button : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<h1
style="margin: 0 0 10px 0; font-family: Lato, sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;">
Hello {{ name }}!</h1>
<p style="margin: 0;">So, you want to change your e-mail? No problem! Just click the button below to verify
your new address:</p>
</td>
</tr>
<tr>
<td style="padding: 0 20px;">
<!-- Button : BEGIN -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
<tr>
<td class="button-td button-td-primary" style="border-radius: 4px; background: #17b53e;">
<a class="button-a button-a-primary" href="{{{ actionUrl }}}"
style="background: #17b53e; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;">Verify
e-mail address</a>
</td>
</tr>
</table>
<!-- Button : END -->
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text + Button : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-bottom: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">If you don't want to change your e-mail address feel free to ignore this message. You
can
also <a href="{{{ supportUrl }}}" style="color: #17b53e;">contact our
support team</a> if you have any questions!</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">If the above button doesn't work you can also copy the following code into your
browser window: <span style="color: #17b53e;">{{{ nonce }}}</span></p>
<p style="margin: 0; margin-top: 10px;">See you soon on <a href="https://human-connection.org"
style="color: #17b53e;">Human Connection</a>!</p>
<p style="margin: 0; margin-bottom: 10px;"> The Human Connection Team</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
</table>
<!-- Email Body English : END -->

View File

@ -0,0 +1,11 @@
import fs from 'fs'
import path from 'path'
const readFile = fileName => fs.readFileSync(path.join(__dirname, fileName), 'utf-8')
export const signup = readFile('./signup.html')
export const passwordReset = readFile('./resetPassword.html')
export const wrongAccount = readFile('./wrongAccount.html')
export const emailVerification = readFile('./emailVerification.html')
export const layout = readFile('./layout.html')

View File

@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
<title>{{ subject }}</title>
<!--[if mso]>
<style>
* {
font-family: sans-serif !important;
}
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href='https://fonts.googleapis.com/css?family=Lato:400,700' rel='stylesheet' type='text/css'>
<!--<![endif]-->
<!-- CSS RESETS -->
<style>
html,
body {
margin: 0 !important;
padding: 0 !important;
height: 100% !important;
width: 100% !important;
}
* {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
div[style*="margin: 16px 0"] {
margin: 0 !important;
}
table,
td {
mso-table-lspace: 0pt !important;
mso-table-rspace: 0pt !important;
}
table {
border-spacing: 0 !important;
border-collapse: collapse !important;
table-layout: fixed !important;
margin: 0 auto !important;
}
img {
-ms-interpolation-mode: bicubic;
}
a {
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a[x-apple-data-detectors],
.unstyle-auto-detected-links a,
.aBn {
border-bottom: 0 !important;
cursor: default !important;
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
.a6S {
display: none !important;
opacity: 0.01 !important;
}
.im {
color: inherit !important;
}
img.g-img+div {
display: none !important;
}
/* iPhone 4, 4S, 5, 5S, 5C, and 5SE */
@media only screen and (min-device-width: 320px) and (max-device-width: 374px) {
u~div .email-container {
min-width: 320px !important;
}
}
/* iPhone 6, 6S, 7, 8, and X */
@media only screen and (min-device-width: 375px) and (max-device-width: 413px) {
u~div .email-container {
min-width: 375px !important;
}
}
/* iPhone 6+, 7+, and 8+ */
@media only screen and (min-device-width: 414px) {
u~div .email-container {
min-width: 414px !important;
}
}
</style>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!-- PROGRESSIVE ENHANCEMENTS -->
<style>
.button-td,
.button-a {
transition: all 100ms ease-in;
}
.button-td-primary:hover,
.button-a-primary:hover {
background: #19c243 !important;
border-color: #555555 !important;
}
@media screen and (max-width: 600px) {
.email-container p {
font-size: 17px !important;
}
}
</style>
</head>
<body width="100%" style="margin: 0; padding: 0 !important; mso-line-height-rule: exactly; background-color: #f5f4f6;">
<center style="width: 100%; background-color: #f5f4f6;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f5f4f6;">
<tr>
<td>
<![endif]-->
<div style="max-width: 600px; margin: 0 auto;" class="email-container">
<!--[if mso]>
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="600">
<tr>
<td>
<![endif]-->
<p style="color:#19c243; font-style: italic; font-family: Lato, sans-serif; font-size: 16px; padding-top: 20px;">English version below!</p>
{{> content}}
<!-- Email Footer : BEGIN -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"
style="margin: auto;">
<tr>
<td
style="padding: 20px; font-family: Lato, sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;">
<br><br>
Human Connection gGmbH<br><span class="unstyle-auto-detected-links">Bahnhofstraße 11, 73235 Weilheim /
Teck<br>Germany</span>
<br><br>
</td>
</tr>
</table>
<!-- Email Footer : END -->
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</center>
</body>
</html>

View File

@ -1,83 +0,0 @@
import CONFIG from '../../../config'
export const resetPasswordMail = options => {
const {
name,
email,
nonce,
subject = 'Use this link to reset your password. The link is only valid for 24 hours.',
supportUrl = 'https://human-connection.org/en/contact/',
} = options
const actionUrl = new URL('/password-reset/change-password', CONFIG.CLIENT_URI)
actionUrl.searchParams.set('nonce', nonce)
actionUrl.searchParams.set('email', email)
return {
to: email,
subject,
text: `
Hi ${name}!
You recently requested to reset your password for your Human Connection account.
Use the link below to reset it. This password reset is only valid for the next
24 hours.
${actionUrl}
If you did not request a password reset, please ignore this email or contact
support if you have questions:
${supportUrl}
Thanks,
The Human Connection Team
If you're having trouble with the link above, you can manually copy and
paste the following code into your browser window:
${nonce}
Human Connection gemeinnützige GmbH
Bahnhofstr. 11
73235 Weilheim / Teck
Deutschland
`,
}
}
export const wrongAccountMail = options => {
const {
email,
subject = `We received a request to reset your password with this email address (${email})`,
supportUrl = 'https://human-connection.org/en/contact/',
} = options
const actionUrl = new URL('/password-reset/request', CONFIG.CLIENT_URI)
return {
to: email,
subject,
text: `
We received a request to reset the password to access Human Connection with your
email address, but we were unable to find an account associated with this
address.
If you use Human Connection and were expecting this email, consider trying to
request a password reset using the email address associated with your account.
Try a different email:
${actionUrl}
If you do not use Human Connection or did not request a password reset, please
ignore this email. Feel free to contact support if you have further questions:
${supportUrl}
Thanks,
The Human Connection Team
Human Connection gemeinnützige GmbH
Bahnhofstr. 11
73235 Weilheim / Teck
Deutschland
`,
}
}

View File

@ -0,0 +1,185 @@
<!-- Email Body German : BEGIN -->
<table class="email-german" align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"
style="margin: auto;">
<!-- Hero Image, Flush : BEGIN -->
<tr>
<td style="background-color: #ffffff;">
<img
src="https://firebasestorage.googleapis.com/v0/b/gitbook-28427.appspot.com/o/assets%2F-LcGvGRsW6DrZn7FWRzF%2F-LcGv6EiVcsjYLfQ_2YE%2F-LcGv8UtmAWc61fxGveg%2Flets_get_together.png?generation=1555078880410873&alt=media"
width="600" height="" alt="Human Connection community logo" border="0"
style="width: 100%; max-width: 600px; height: auto; background: #ffffff; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; color: #555555; margin: auto; display: block;"
class="g-img">
</td>
</tr>
<!-- Hero Image, Flush : END -->
<!-- 1 Column Text + Button : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<h1
style="margin: 0 0 10px 0; font-family: Lato, sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;">
Hallo {{ name }}!</h1>
<p style="margin: 0;">Du hast also dein Passwort vergessen? Kein Problem! Mit Klick auf diesen Button
kannst Du innerhalb der nächsten 24 Stunden Dein Passwort zurücksetzen:</p>
</td>
</tr>
<tr>
<td style="padding: 0 20px;">
<!-- Button : BEGIN -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
<tr>
<td class="button-td button-td-primary" style="border-radius: 4px; background: #17b53e;">
<a class="button-a button-a-primary" href="{{{ actionUrl }}}"
style="background: #17b53e; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;">Passwort
zurücksetzen</a>
</td>
</tr>
</table>
<!-- Button : END -->
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text + Button : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-bottom: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">Falls Du kein neues Passwort angefordert hast, kannst Du diese E-Mail einfach
ignorieren. Wenn Du noch Fragen hast, melde Dich gerne <a href="{{{ supportUrl }}}"
style="color: #17b53e;">bei
unserem Support Team</a>!</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in
Dein Browserfenster kopieren: <span style="color: #17b53e;">{{{ nonce }}}</span></p>
<p style="margin: 0; margin-top: 10px;">Bis bald bei <a href="https://human-connection.org"
style="color: #17b53e;">Human Connection</a>!</p>
<p style="margin: 0; margin-bottom: 10px;"> Dein Human Connection Team</p>
</td>
</tr>
<tr>
<td style="display: none;">
<p></p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
</table>
<!-- Email Body German : END -->
<!-- Email Body English : BEGIN -->
<table class="email-english" align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"
style="margin: auto;">
<tr>
<td style="padding: 20px 0; text-align: center">
</td>
</tr>
<!-- Hero Image, Flush : BEGIN -->
<tr>
<td style="background-color: #ffffff;">
<img
src="https://firebasestorage.googleapis.com/v0/b/gitbook-28427.appspot.com/o/assets%2F-LcGvGRsW6DrZn7FWRzF%2F-LcGv6EiVcsjYLfQ_2YE%2F-LcGv8UtmAWc61fxGveg%2Flets_get_together.png?generation=1555078880410873&alt=media"
width="600" height="" alt="Human Connection community logo" border="0"
style="width: 100%; max-width: 600px; height: auto; background: #ffffff; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; color: #555555; margin: auto; display: block;"
class="g-img">
</td>
</tr>
<!-- Hero Image, Flush : END -->
<!-- 1 Column Text + Button : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<h1
style="margin: 0 0 10px 0; font-family: Lato, sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;">
Hello {{ name }}!</h1>
<p style="margin: 0;">So, you forgot your password? No problem! Just click the button below to reset
it within the next 24 hours:</p>
</td>
</tr>
<tr>
<td style="padding: 0 20px;">
<!-- Button : BEGIN -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
<tr>
<td class="button-td button-td-primary" style="border-radius: 4px; background: #17b53e;">
<a class="button-a button-a-primary" href="{{{ actionUrl }}}"
style="background: #17b53e; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;">Reset
password</a>
</td>
</tr>
</table>
<!-- Button : END -->
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text + Button : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-bottom: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">If you didn't request a new password feel free to ignore this e-mail. You can
also <a href="{{{ supportUrl }}}" style="color: #17b53e;">contact our
support team</a> if you have any questions!</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">If the above button doesn't work you can also copy the following code into your
browser window: <span style="color: #17b53e;">{{{ nonce }}}</span></p>
<p style="margin: 0; margin-top: 10px;">See you soon on <a href="https://human-connection.org"
style="color: #17b53e;">Human Connection</a>!</p>
<p style="margin: 0; margin-bottom: 10px;"> The Human Connection Team</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
</table>
<!-- Email Body English : END -->

View File

@ -0,0 +1,214 @@
<!-- Email Body German : BEGIN -->
<table class="email-german" align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"
style="margin: auto;">
<!-- Hero Image, Flush : BEGIN -->
<tr>
<td style="background-color: #ffffff;">
<img
src="https://firebasestorage.googleapis.com/v0/b/gitbook-28427.appspot.com/o/assets%2F-LcGvGRsW6DrZn7FWRzF%2F-LcGv6EiVcsjYLfQ_2YE%2F-LcGv8UtmAWc61fxGveg%2Flets_get_together.png?generation=1555078880410873&alt=media"
width="600" height="" alt="Human Connection community logo" border="0"
style="width: 100%; max-width: 600px; height: auto; background: #ffffff; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; color: #555555; margin: auto; display: block;"
class="g-img">
</td>
</tr>
<!-- Hero Image, Flush : END -->
<!-- 1 Column Text + Button : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<h1
style="margin: 0 0 10px 0; font-family: Lato, sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;">
Willkommen bei Human Connection!</h1>
<p style="margin: 0;">Danke, dass Du dich angemeldet hast wir freuen uns, Dich dabei zu haben. Jetzt
fehlt nur noch eine Kleinigkeit, bevor wir gemeinsam die Welt verbessern können ... Bitte bestätige
Deine E-Mail Adresse:</p>
</td>
</tr>
<tr>
<td style="padding: 0 20px;">
<!-- Button : BEGIN -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
<tr>
<td class="button-td button-td-primary" style="border-radius: 4px; background: #17b53e;">
<a class="button-a button-a-primary" href="{{{ actionUrl }}}"
style="background: #17b53e; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;">Bestätige
Deine E-Mail Adresse</a>
</td>
</tr>
</table>
<!-- Button : END -->
</td>
</tr>
<tr>
<td style="text-align: center; color :#17b53e">
<p></p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text + Button : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">Sollte der Button für Dich nicht funktionieren, kannst Du auch folgenden Code in Dein Browserfenster kopieren: <span style="color: #17b53e;">{{{ nonce }}}</span></p>
<p style="margin: 0;">Das funktioniert allerdings nur, wenn du Dich über unsere Website registriert hast.</p>
<p style="margin: 0; margin-top: 10px;">Falls Du Dich nicht selbst bei <a href="https://human-connection.org"
style="color: #17b53e;">Human Connection</a> angemeldet hast, schau doch mal vorbei!
Wir sind ein gemeinnütziges Aktionsnetzwerk von Menschen für Menschen.</p>
<p style="margin: 0; margin-top: 10px;">PS: Wenn Du keinen Account bei uns möchtest, kannst Du diese
E-Mail einfach ignorieren. ;)</p>
</td>
</tr>
<tr>
<td style="text-align: center; color :#17b53e">
<p></p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">Melde Dich gerne <a href="{{{ supportUrl }}}" style="color: #17b53e;">bei
unserem Support Team</a>, wenn Du Fragen hast.</p>
<p style="margin: 0; margin-top: 10px;">Bis bald bei <a href="https://human-connection.org"
style="color: #17b53e;">Human Connection</a>!</p>
<p style="margin: 0; margin-bottom: 10px;"> Dein Human Connection Team</p>
</td>
</tr>
<tr>
<td style="display: none;">
<p></p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
</table>
<!-- Email Body German : END -->
<!-- Email Body English : BEGIN -->
<table class="email-english" align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"
style="margin: auto;">
<tr>
<td style="padding: 20px 0; text-align: center">
</td>
</tr>
<!-- Hero Image, Flush : BEGIN -->
<tr>
<td style="background-color: #ffffff;">
<img
src="https://firebasestorage.googleapis.com/v0/b/gitbook-28427.appspot.com/o/assets%2F-LcGvGRsW6DrZn7FWRzF%2F-LcGv6EiVcsjYLfQ_2YE%2F-LcGv8UtmAWc61fxGveg%2Flets_get_together.png?generation=1555078880410873&alt=media"
width="600" height="" alt="Human Connection community logo" border="0"
style="width: 100%; max-width: 600px; height: auto; background: #ffffff; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; color: #555555; margin: auto; display: block;"
class="g-img">
</td>
</tr>
<!-- Hero Image, Flush : END -->
<!-- 1 Column Text + Button : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<h1
style="margin: 0 0 10px 0; font-family: Lato, sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;">
Welcome to Human Connection!</h1>
<p style="margin: 0;">Thank you for joining our cause it's awesome to have you on board. There's
just one tiny step missing before we can start shaping the world together ... Please confirm your
e-mail address by clicking the button below:</p>
</td>
</tr>
<tr>
<td style="padding: 0 20px;">
<!-- Button : BEGIN -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
<tr>
<td class="button-td button-td-primary" style="border-radius: 4px; background: #17b53e;">
<a class="button-a button-a-primary" href="{{{ actionUrl }}}"
style="background: #17b53e; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;">Confirm
your e-mail address</a>
</td>
</tr>
</table>
<!-- Button : END -->
</td>
</tr>
<tr>
<td style="text-align: center; color :#17b53e">
<p></p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text + Button : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">If the above button doesn't work, you can also copy the following code into your browser window: <span style="color: #17b53e;">{{{ nonce }}}</span></p>
<p style="margin: 0;">However, this only works if you have registered through our website.</p>
<p style="margin: 0; margin-top: 10px;">If you didn't sign up for <a href="https://human-connection.org"
style="color: #17b53e;">Human Connection</a> we recommend you to check it out!
It's a social network from people for people who want to connect and change the world together.</p>
<p style="margin: 0; margin-top: 10px;">PS: If you ignore this e-mail we will not create an account
for
you. ;)</p>
</td>
</tr>
<tr>
<td style="text-align: center; color :#17b53e">
<p></p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">Feel free to <a href="{{{ supportUrl }}}" style="color: #17b53e;">contact our
support team</a> with any
questions you have.</p>
<p style="margin: 0; margin-top: 10px;">See you soon on <a href="https://human-connection.org"
style="color: #17b53e;">Human Connection</a>!</p>
<p style="margin: 0; margin-bottom: 10px;"> The Human Connection Team</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
</table>
<!-- Email Body English : END -->

View File

@ -1,62 +0,0 @@
import CONFIG from '../../../config'
export const signupTemplate = options => {
const {
email,
nonce,
subject = 'Welcome to Human Connection! Here is your signup link.',
supportUrl = 'https://human-connection.org/en/contact/',
} = options
const actionUrl = new URL('/registration/create-user-account', CONFIG.CLIENT_URI)
actionUrl.searchParams.set('nonce', nonce)
actionUrl.searchParams.set('email', email)
return {
to: email,
subject,
text: `
Willkommen bei Human Connection! Klick auf diesen Link, um den
Registrierungsprozess abzuschließen und um ein Benutzerkonto zu erstellen!
${actionUrl}
Alternativ kannst du diesen Code auch kopieren und im Browserfenster einfügen:
${nonce}
Bitte ignoriere diese Mail, falls du dich nicht bei Human Connection angemeldet
hast. Bei Fragen kontaktiere gerne unseren Support:
${supportUrl}
Danke,
Das Human Connection Team
English Version
===============
Welcome to Human Connection! Use this link to complete the registration process
and create a user account:
${actionUrl}
You can also copy+paste this verification nonce in your browser window:
${nonce}
If you did not signed up for Human Connection, please ignore this email or
contact support if you have questions:
${supportUrl}
Thanks,
The Human Connection Team
Human Connection gemeinnützige GmbH
Bahnhofstr. 11
73235 Weilheim / Teck
Deutschland
`,
}
}

View File

@ -0,0 +1,185 @@
<!-- Email Body German : BEGIN -->
<table class="email-german" align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"
style="margin: auto;">
<!-- Hero Image, Flush : BEGIN -->
<tr>
<td style="background-color: #ffffff;">
<img
src="https://firebasestorage.googleapis.com/v0/b/gitbook-28427.appspot.com/o/assets%2F-LcGvGRsW6DrZn7FWRzF%2F-LcGv6EiVcsjYLfQ_2YE%2F-LcGv8UtmAWc61fxGveg%2Flets_get_together.png?generation=1555078880410873&alt=media"
width="600" height="" alt="Human Connection community logo" border="0"
style="width: 100%; max-width: 600px; height: auto; background: #ffffff; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; color: #555555; margin: auto; display: block;"
class="g-img">
</td>
</tr>
<!-- Hero Image, Flush : END -->
<!-- 1 Column Text + Button : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<h1
style="margin: 0 0 10px 0; font-family: Lato, sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;">
Hallo!</h1>
<p style="margin: 0;">Du hast bei uns ein neues Passwort angefordert leider haben wir aber keinen
Account mit Deiner E-Mailadresse gefunden. Kann es sein, dass Du mit einer anderen Adresse bei uns
angemeldet bist?</p>
</td>
</tr>
<tr>
<td style="padding: 0 20px;">
<!-- Button : BEGIN -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
<tr>
<td class="button-td button-td-primary" style="border-radius: 4px; background: #17b53e;">
<a class="button-a button-a-primary" href="{{{ actionUrl }}}"
style="background: #17b53e; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;">Versuch'
es mit einer anderen E-Mail</a>
</td>
</tr>
</table>
<!-- Button : END -->
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text + Button : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">Wenn Du noch keinen Account bei <a href="https://human-connection.org"
style="color: #17b53e;">Human Connection</a> hast oder Dein Password gar nicht ändern willst,
kannst Du diese E-Mail einfach ignorieren!</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">Ansonsten hilft Dir <a href="{{{ supportUrl }}}" style="color: #17b53e;">unser
Support Team</a> gerne weiter.</p>
<p style="margin: 0; margin-top: 10px;">Bis bald bei <a href="https://human-connection.org"
style="color: #17b53e;">Human Connection</a>!</p>
<p style="margin: 0; margin-bottom: 10px;"> Dein Human Connection Team</p>
</td>
</tr>
<tr>
<td style="display: none;">
<p></p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
</table>
<!-- Email Body German : END -->
<!-- Email Body English : BEGIN -->
<table class="email-english" align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"
style="margin: auto;">
<tr>
<td style="padding: 20px 0; text-align: center">
</td>
</tr>
<!-- Hero Image, Flush : BEGIN -->
<tr>
<td style="background-color: #ffffff;">
<img
src="https://firebasestorage.googleapis.com/v0/b/gitbook-28427.appspot.com/o/assets%2F-LcGvGRsW6DrZn7FWRzF%2F-LcGv6EiVcsjYLfQ_2YE%2F-LcGv8UtmAWc61fxGveg%2Flets_get_together.png?generation=1555078880410873&alt=media"
width="600" height="" alt="Human Connection community logo" border="0"
style="width: 100%; max-width: 600px; height: auto; background: #ffffff; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; color: #555555; margin: auto; display: block;"
class="g-img">
</td>
</tr>
<!-- Hero Image, Flush : END -->
<!-- 1 Column Text + Button : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<h1
style="margin: 0 0 10px 0; font-family: Lato, sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;">
Hello!</h1>
<p style="margin: 0;">You requested a password reset but unfortunately we couldn't find an account
associated with your e-mail address. Did you maybe use another one when you signed up?</p>
</td>
</tr>
<tr>
<td style="padding: 0 20px;">
<!-- Button : BEGIN -->
<table align="center" role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: auto;">
<tr>
<td class="button-td button-td-primary" style="border-radius: 4px; background: #17b53e;">
<a class="button-a button-a-primary" href="{{{ actionUrl }}}"
style="background: #17b53e; font-family: Lato, sans-serif; font-size: 16px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;">Try
a different e-mail</a>
</td>
</tr>
</table>
<!-- Button : END -->
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text + Button : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">If you don't have an account at <a href="https://human-connection.org"
style="color: #17b53e;">Human Connection</a> yet or if you didn't want to reset your password,
please ignore this e-mail.</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
<!-- 1 Column Text : BEGIN -->
<tr>
<td style="background-color: #ffffff; padding: 0 20px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td
style="padding: 20px; padding-top: 0; font-family: Lato, sans-serif; font-size: 16px; line-height: 22px; color: #555555;">
<p style="margin: 0;">Otherwise <a href="{{{ supportUrl }}}" style="color: #17b53e;">our
support team</a> will be happy to help you out.</p>
<p style="margin: 0; margin-top: 10px;">See you soon on <a href="https://human-connection.org"
style="color: #17b53e;">Human Connection</a>!</p>
<p style="margin: 0; margin-bottom: 10px;"> The Human Connection Team</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- 1 Column Text : END -->
</table>
<!-- Email Body English : END -->

View File

@ -4,23 +4,23 @@ import { exec, build } from 'xregexp/xregexp-all.js'
// https://en.wikipedia.org/w/index.php?title=Hashtag&oldid=905141980#Style // https://en.wikipedia.org/w/index.php?title=Hashtag&oldid=905141980#Style
// here: // here:
// 0. Search for whole string. // 0. Search for whole string.
// 1. Hashtag has only all unicode characters and '0-9'. // 1. Hashtag has only all unicode letters and '0-9'.
// 2. If it starts with a digit '0-9' than a unicode character has to follow. // 2. If it starts with a digit '0-9' than a unicode letter has to follow.
const regX = build('^/search/hashtag/((\\pL+[\\pL0-9]*)|([0-9]+\\pL+[\\pL0-9]*))$') const regX = build('^((\\pL+[\\pL0-9]*)|([0-9]+\\pL+[\\pL0-9]*))$')
export default function(content) { export default function(content) {
if (!content) return [] if (!content) return []
const $ = cheerio.load(content) const $ = cheerio.load(content)
// We can not search for class '.hashtag', because the classes are removed at the 'xss' middleware. // We can not search for class '.hashtag', because the classes are removed at the 'xss' middleware.
// But we have to know, which Hashtags are removed from the content as well, so we search for the 'a' html-tag. // But we have to know, which Hashtags are removed from the content as well, so we search for the 'a' html-tag.
const urls = $('a') const ids = $('a[data-hashtag-id]')
.map((_, el) => { .map((_, el) => {
return $(el).attr('href') return $(el).attr('data-hashtag-id')
}) })
.get() .get()
const hashtags = [] const hashtags = []
urls.forEach(url => { ids.forEach(id => {
const match = exec(url, regX) const match = exec(id, regX)
if (match != null) { if (match != null) {
hashtags.push(match[1]) hashtags.push(match[1])
} }

View File

@ -8,9 +8,24 @@ describe('extractHashtags', () => {
}) })
describe('searches through links', () => { describe('searches through links', () => {
it('finds links with and without ".hashtag" class and extracts Hashtag names', () => { it('without `class="hashtag"` but `data-hashtag-id="something"`, and extracts the Hashtag to make a Hashtag link', () => {
const content = const content = `
'<p><a class="hashtag" href="/search/hashtag/Elections">#Elections</a><a href="/search/hashtag/Democracy">#Democracy</a></p>' <p>
<a
class="hashtag"
data-hashtag-id="Elections"
href="/?hashtag=Elections"
>
#Elections
</a>
<a
data-hashtag-id="Democracy"
href="/?hashtag=Democracy"
>
#Democracy
</a>
</p>
`
expect(extractHashtags(content)).toEqual(['Elections', 'Democracy']) expect(extractHashtags(content)).toEqual(['Elections', 'Democracy'])
}) })
@ -20,23 +35,57 @@ describe('extractHashtags', () => {
expect(extractHashtags(content)).toEqual([]) expect(extractHashtags(content)).toEqual([])
}) })
describe('handles links', () => { it('ignores hashtag links with unsupported character combinations', () => {
it('ignores links with domains', () => { // Allowed are all unicode letters '\pL' and all digits '0-9'. There haveto be at least one letter in it.
const content = const content = `
'<p><a class="hashtag" href="http://localhost:3000/search/hashtag/Elections">#Elections</a><a href="/search/hashtag/Democracy">#Democracy</a></p>' <p>
expect(extractHashtags(content)).toEqual(['Democracy']) Something inspirational about
}) <a
href="/?hashtag=AbcDefXyz0123456789!*(),2"
it('ignores Hashtag links with not allowed character combinations', () => { data-hashtag-id="AbcDefXyz0123456789!*(),2"
// Allowed are all unicode letters '\pL' and all digits '0-9'. There haveto be at least one letter in it. class="hashtag"
const content = target="_blank"
'<p>Something inspirational about <a href="/search/hashtag/AbcDefXyz0123456789!*(),2" class="hashtag" target="_blank">#AbcDefXyz0123456789!*(),2</a>, <a href="/search/hashtag/0123456789" class="hashtag" target="_blank">#0123456789</a>, <a href="/search/hashtag/0123456789a" class="hashtag" target="_blank">#0123456789a</a>, <a href="/search/hashtag/AbcDefXyz0123456789" target="_blank">#AbcDefXyz0123456789</a>, and <a href="/search/hashtag/λαπ" target="_blank">#λαπ</a>.</p>' >
expect(extractHashtags(content).sort()).toEqual([ #AbcDefXyz0123456789!*(),2
'0123456789a', </a>,
'AbcDefXyz0123456789', <a
'λαπ', href="/?hashtag=0123456789"
]) data-hashtag-id="0123456789"
}) class="hashtag"
target="_blank"
>
#0123456789
</a>,
<a href="?hashtag=0123456789a"
data-hashtag-id="0123456789a"
class="hashtag"
target="_blank"
>
#0123456789a
</a>,
<a
href="/?hashtag=AbcDefXyz0123456789"
data-hashtag-id="AbcDefXyz0123456789"
class="hashtag"
target="_blank"
>
#AbcDefXyz0123456789
</a>, and
<a
href="/?hashtag=%C4%A7%CF%80%CE%B1%CE%BB"
data-hashtag-id="ħπαλ"
class="hashtag"
target="_blank"
>
#ħπαλ
</a>.
</p>
`
expect(extractHashtags(content).sort()).toEqual([
'0123456789a',
'AbcDefXyz0123456789',
'ħπαλ',
])
}) })
describe('does not crash if', () => { describe('does not crash if', () => {

View File

@ -2,31 +2,27 @@ import extractHashtags from '../hashtags/extractHashtags'
const updateHashtagsOfPost = async (postId, hashtags, context) => { const updateHashtagsOfPost = async (postId, hashtags, context) => {
if (!hashtags.length) return if (!hashtags.length) return
const session = context.driver.session() const session = context.driver.session()
// We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement
// functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted try {
// and no new Hashtags and relations will be created. await session.writeTransaction(txc => {
const cypherDeletePreviousRelations = ` return txc.run(
MATCH (p: Post { id: $postId })-[previousRelations: TAGGED]->(t: Tag) `
DELETE previousRelations MATCH (post:Post { id: $postId})
RETURN p, t OPTIONAL MATCH (post)-[previousRelations:TAGGED]->(tag:Tag)
` DELETE previousRelations
const cypherCreateNewTagsAndRelations = ` WITH post
MATCH (p: Post { id: $postId}) UNWIND $hashtags AS tagName
UNWIND $hashtags AS tagName MERGE (tag:Tag {id: tagName, disabled: false, deleted: false })
MERGE (t: Tag { id: tagName, disabled: false, deleted: false }) MERGE (post)-[:TAGGED]->(tag)
MERGE (p)-[:TAGGED]->(t) RETURN post, tag
RETURN p, t `,
` { postId, hashtags },
await session.run(cypherDeletePreviousRelations, { )
postId, })
}) } finally {
await session.run(cypherCreateNewTagsAndRelations, { session.close()
postId, }
hashtags,
})
session.close()
} }
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {

View File

@ -1,7 +1,7 @@
import { gql } from '../../jest/helpers' import { gql } from '../../helpers/jest'
import Factory from '../../seed/factories' import Factory from '../../factories'
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
import { neode, getDriver } from '../../bootstrap/neo4j' import { getNeode, getDriver } from '../../db/neo4j'
import createServer from '../../server' import createServer from '../../server'
let server let server
@ -11,7 +11,7 @@ let hashtagingUser
let authenticatedUser let authenticatedUser
const factory = Factory() const factory = Factory()
const driver = getDriver() const driver = getDriver()
const instance = neode() const neode = getNeode()
const categoryIds = ['cat9'] const categoryIds = ['cat9']
const createPostMutation = gql` const createPostMutation = gql`
mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) { mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) {
@ -36,7 +36,7 @@ beforeAll(() => {
context: () => { context: () => {
return { return {
user: authenticatedUser, user: authenticatedUser,
neode: instance, neode,
driver, driver,
} }
}, },
@ -48,14 +48,14 @@ beforeAll(() => {
}) })
beforeEach(async () => { beforeEach(async () => {
hashtagingUser = await instance.create('User', { hashtagingUser = await neode.create('User', {
id: 'you', id: 'you',
name: 'Al Capone', name: 'Al Capone',
slug: 'al-capone', slug: 'al-capone',
email: 'test@example.org', email: 'test@example.org',
password: '1234', password: '1234',
}) })
await instance.create('Category', { await neode.create('Category', {
id: 'cat9', id: 'cat9',
name: 'Democracy & Politics', name: 'Democracy & Politics',
icon: 'university', icon: 'university',
@ -69,8 +69,27 @@ afterEach(async () => {
describe('hashtags', () => { describe('hashtags', () => {
const id = 'p135' const id = 'p135'
const title = 'Two Hashtags' const title = 'Two Hashtags'
const postContent = const postContent = `
'<p>Hey Dude, <a class="hashtag" href="/search/hashtag/Democracy">#Democracy</a> should work equal for everybody!? That seems to be the only way to have equal <a class="hashtag" href="/search/hashtag/Liberty">#Liberty</a> for everyone.</p>' <p>
Hey Dude,
<a
class="hashtag"
data-hashtag-id="Democracy"
href="/?hashtag=Democracy">
#Democracy
</a>
should work equal for everybody!? That seems to be the only way to have
equal
<a
class="hashtag"
data-hashtag-id="Liberty"
href="/?hashtag=Liberty"
>
#Liberty
</a>
for everyone.
</p>
`
const postWithHastagsQuery = gql` const postWithHastagsQuery = gql`
query($id: ID) { query($id: ID) {
Post(id: $id) { Post(id: $id) {
@ -129,10 +148,29 @@ describe('hashtags', () => {
) )
}) })
describe('afterwards update the Post by removing a Hashtag, leaving a Hashtag and add a Hashtag', () => { describe('updates the Post by removing, keeping and adding one hashtag respectively', () => {
// The already existing Hashtag has no class at this point. // The already existing hashtag has no class at this point.
const postContent = const postContent = `
'<p>Hey Dude, <a class="hashtag" href="/search/hashtag/Elections">#Elections</a> should work equal for everybody!? That seems to be the only way to have equal <a href="/search/hashtag/Liberty">#Liberty</a> for everyone.</p>' <p>
Hey Dude,
<a
class="hashtag"
data-hashtag-id="Elections"
href="?hashtag=Elections"
>
#Elections
</a>
should work equal for everybody!? That seems to be the only way to
have equal
<a
data-hashtag-id="Liberty"
href="?hashtag=Liberty"
>
#Liberty
</a>
for everyone.
</p>
`
it('only one previous Hashtag and the new Hashtag exists', async () => { it('only one previous Hashtag and the new Hashtag exists', async () => {
await mutate({ await mutate({

View File

@ -7,7 +7,6 @@ import sluggify from './sluggifyMiddleware'
import excerpt from './excerptMiddleware' import excerpt from './excerptMiddleware'
import xss from './xssMiddleware' import xss from './xssMiddleware'
import permissions from './permissionsMiddleware' import permissions from './permissionsMiddleware'
import user from './userMiddleware'
import includedFields from './includedFieldsMiddleware' import includedFields from './includedFieldsMiddleware'
import orderBy from './orderByMiddleware' import orderBy from './orderByMiddleware'
import validation from './validation/validationMiddleware' import validation from './validation/validationMiddleware'
@ -18,25 +17,25 @@ import sentry from './sentryMiddleware'
export default schema => { export default schema => {
const middlewares = { const middlewares = {
permissions,
sentry, sentry,
permissions,
xss,
activityPub, activityPub,
validation, validation,
sluggify, sluggify,
excerpt, excerpt,
email,
notifications, notifications,
hashtags, hashtags,
xss,
softDelete, softDelete,
user,
includedFields, includedFields,
orderBy, orderBy,
email,
} }
let order = [ let order = [
'sentry', 'sentry',
'permissions', 'permissions',
'xss',
// 'activityPub', disabled temporarily // 'activityPub', disabled temporarily
'validation', 'validation',
'sluggify', 'sluggify',
@ -44,9 +43,7 @@ export default schema => {
'email', 'email',
'notifications', 'notifications',
'hashtags', 'hashtags',
'xss',
'softDelete', 'softDelete',
'user',
'includedFields', 'includedFields',
'orderBy', 'orderBy',
] ]

View File

@ -1,135 +1,121 @@
import extractMentionedUsers from './mentions/extractMentionedUsers' import extractMentionedUsers from './mentions/extractMentionedUsers'
import { validateNotifyUsers } from '../validation/validationMiddleware'
const notifyUsers = async (label, id, idsOfUsers, reason, context) => { const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
if (!idsOfUsers.length) return const idsOfUsers = extractMentionedUsers(args.content)
const post = await resolve(root, args, context, resolveInfo)
if (post && idsOfUsers && idsOfUsers.length)
await notifyUsersOfMention('Post', post.id, idsOfUsers, 'mentioned_in_post', context)
return post
}
// Checked here, because it does not go through GraphQL checks at all in this file. const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => {
const reasonsAllowed = ['mentioned_in_post', 'mentioned_in_comment', 'commented_on_post'] const { content } = args
if (!reasonsAllowed.includes(reason)) { let idsOfUsers = extractMentionedUsers(content)
throw new Error('Notification reason is not allowed!') const comment = await resolve(root, args, context, resolveInfo)
} const [postAuthor] = await postAuthorOfComment(comment.id, { context })
if ( idsOfUsers = idsOfUsers.filter(id => id !== postAuthor.id)
(label === 'Post' && reason !== 'mentioned_in_post') || if (idsOfUsers && idsOfUsers.length)
(label === 'Comment' && !['mentioned_in_comment', 'commented_on_post'].includes(reason)) await notifyUsersOfMention('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context)
) { if (context.user.id !== postAuthor.id)
throw new Error('Notification does not fit the reason!') await notifyUsersOfComment('Comment', comment.id, postAuthor.id, 'commented_on_post', context)
} return comment
}
const postAuthorOfComment = async (commentId, { context }) => {
const session = context.driver.session() const session = context.driver.session()
let cypher let postAuthorId
try {
postAuthorId = await session.readTransaction(transaction => {
return transaction.run(
`
MATCH (author:User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId })
RETURN author { .id } as authorId
`,
{ commentId },
)
})
return postAuthorId.records.map(record => record.get('authorId'))
} finally {
session.close()
}
}
const notifyUsersOfMention = async (label, id, idsOfUsers, reason, context) => {
await validateNotifyUsers(label, reason)
let mentionedCypher
switch (reason) { switch (reason) {
case 'mentioned_in_post': { case 'mentioned_in_post': {
cypher = ` mentionedCypher = `
MATCH (post: Post { id: $id })<-[:WROTE]-(author: User) MATCH (post: Post { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User) MATCH (user: User)
WHERE user.id in $idsOfUsers WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author) AND NOT (user)<-[:BLOCKED]-(author)
MERGE (post)-[notification:NOTIFIED {reason: $reason}]->(user) MERGE (post)-[notification:NOTIFIED {reason: $reason}]->(user)
SET notification.read = FALSE
SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
` `
break break
} }
case 'mentioned_in_comment': { case 'mentioned_in_comment': {
cypher = ` mentionedCypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User) MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User) MATCH (user: User)
WHERE user.id in $idsOfUsers WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author) AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (user)<-[:BLOCKED]-(postAuthor) AND NOT (user)<-[:BLOCKED]-(postAuthor)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user) MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
SET notification.read = FALSE
SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
`
break
}
case 'commented_on_post': {
cypher = `
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
AND NOT (author)<-[:BLOCKED]-(user)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
SET notification.read = FALSE
SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
` `
break break
} }
} }
await session.run(cypher, { mentionedCypher += `
id, SET notification.read = FALSE
idsOfUsers, SET (
reason, CASE
}) WHEN notification.createdAt IS NULL
session.close() THEN notification END ).createdAt = toString(datetime())
} SET notification.updatedAt = toString(datetime())
`
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => { const session = context.driver.session()
const idsOfUsers = extractMentionedUsers(args.content) try {
await session.writeTransaction(transaction => {
const post = await resolve(root, args, context, resolveInfo) return transaction.run(mentionedCypher, { id, idsOfUsers, reason })
if (post) {
await notifyUsers('Post', post.id, idsOfUsers, 'mentioned_in_post', context)
}
return post
}
const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => {
const idsOfUsers = extractMentionedUsers(args.content)
const comment = await resolve(root, args, context, resolveInfo)
if (comment) {
await notifyUsers('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context)
}
return comment
}
const handleCreateComment = async (resolve, root, args, context, resolveInfo) => {
const comment = await handleContentDataOfComment(resolve, root, args, context, resolveInfo)
if (comment) {
const session = context.driver.session()
const cypherFindUser = `
MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId })
RETURN user { .id }
`
const result = await session.run(cypherFindUser, {
commentId: comment.id,
}) })
} finally {
session.close() session.close()
const [postAuthor] = await result.records.map(record => {
return record.get('user')
})
if (context.user.id !== postAuthor.id) {
await notifyUsers('Comment', comment.id, [postAuthor.id], 'commented_on_post', context)
}
} }
}
return comment const notifyUsersOfComment = async (label, commentId, postAuthorId, reason, context) => {
await validateNotifyUsers(label, reason)
const session = context.driver.session()
try {
await session.writeTransaction(async transaction => {
await transaction.run(
`
MATCH (postAuthor:User {id: $postAuthorId})-[:WROTE]->(post:Post)<-[:COMMENTS]-(comment:Comment { id: $commentId })<-[:WROTE]-(commenter:User)
WHERE NOT (postAuthor)-[:BLOCKED]-(commenter)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(postAuthor)
SET notification.read = FALSE
SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
`,
{ commentId, postAuthorId, reason },
)
})
} finally {
session.close()
}
} }
export default { export default {
Mutation: { Mutation: {
CreatePost: handleContentDataOfPost, CreatePost: handleContentDataOfPost,
UpdatePost: handleContentDataOfPost, UpdatePost: handleContentDataOfPost,
CreateComment: handleCreateComment, CreateComment: handleContentDataOfComment,
UpdateComment: handleContentDataOfComment, UpdateComment: handleContentDataOfComment,
}, },
} }

View File

@ -1,17 +1,13 @@
import { gql } from '../../jest/helpers' import { gql } from '../../helpers/jest'
import Factory from '../../seed/factories' import Factory from '../../factories'
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
import { neode, getDriver } from '../../bootstrap/neo4j' import { getNeode, getDriver } from '../../db/neo4j'
import createServer from '../../server' import createServer from '../../server'
let server let server, query, mutate, notifiedUser, authenticatedUser
let query
let mutate
let notifiedUser
let authenticatedUser
const factory = Factory() const factory = Factory()
const driver = getDriver() const driver = getDriver()
const instance = neode() const neode = getNeode()
const categoryIds = ['cat9'] const categoryIds = ['cat9']
const createPostMutation = gql` const createPostMutation = gql`
mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) { mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) {
@ -39,12 +35,13 @@ const createCommentMutation = gql`
} }
` `
beforeAll(() => { beforeAll(async () => {
await factory.cleanDatabase()
const createServerResult = createServer({ const createServerResult = createServer({
context: () => { context: () => {
return { return {
user: authenticatedUser, user: authenticatedUser,
neode: instance, neode: neode,
driver, driver,
} }
}, },
@ -56,14 +53,14 @@ beforeAll(() => {
}) })
beforeEach(async () => { beforeEach(async () => {
notifiedUser = await instance.create('User', { notifiedUser = await neode.create('User', {
id: 'you', id: 'you',
name: 'Al Capone', name: 'Al Capone',
slug: 'al-capone', slug: 'al-capone',
email: 'test@example.org', email: 'test@example.org',
password: '1234', password: '1234',
}) })
await instance.create('Category', { await neode.create('Category', {
id: 'cat9', id: 'cat9',
name: 'Democracy & Politics', name: 'Democracy & Politics',
icon: 'university', icon: 'university',
@ -105,6 +102,7 @@ describe('notifications', () => {
let title let title
let postContent let postContent
let postAuthor let postAuthor
const createPostAction = async () => { const createPostAction = async () => {
authenticatedUser = await postAuthor.toJson() authenticatedUser = await postAuthor.toJson()
await mutate({ await mutate({
@ -145,7 +143,7 @@ describe('notifications', () => {
describe('commenter is not me', () => { describe('commenter is not me', () => {
beforeEach(async () => { beforeEach(async () => {
commentContent = 'Commenters comment.' commentContent = 'Commenters comment.'
commentAuthor = await instance.create('User', { commentAuthor = await neode.create('User', {
id: 'commentAuthor', id: 'commentAuthor',
name: 'Mrs Comment', name: 'Mrs Comment',
slug: 'mrs-comment', slug: 'mrs-comment',
@ -172,7 +170,6 @@ describe('notifications', () => {
], ],
}, },
}) })
const { query } = createTestClient(server)
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,
@ -189,7 +186,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({ const expected = expect.objectContaining({
data: { notifications: [] }, data: { notifications: [] },
}) })
const { query } = createTestClient(server)
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,
@ -213,7 +210,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({ const expected = expect.objectContaining({
data: { notifications: [] }, data: { notifications: [] },
}) })
const { query } = createTestClient(server)
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,
@ -227,7 +224,7 @@ describe('notifications', () => {
}) })
beforeEach(async () => { beforeEach(async () => {
postAuthor = await instance.create('User', { postAuthor = await neode.create('User', {
id: 'postAuthor', id: 'postAuthor',
name: 'Mrs Post', name: 'Mrs Post',
slug: 'mrs-post', slug: 'mrs-post',
@ -239,6 +236,7 @@ describe('notifications', () => {
describe('mentions me in a post', () => { describe('mentions me in a post', () => {
beforeEach(async () => { beforeEach(async () => {
title = 'Mentioning Al Capone' title = 'Mentioning Al Capone'
postContent = postContent =
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone">@al-capone</a> how do you do?' 'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone">@al-capone</a> how do you do?'
}) })
@ -263,7 +261,7 @@ describe('notifications', () => {
], ],
}, },
}) })
const { query } = createTestClient(server)
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,
@ -369,7 +367,7 @@ describe('notifications', () => {
expect(readAfter).toEqual(false) expect(readAfter).toEqual(false)
}) })
it('updates the `createdAt` attribute', async () => { it('does not update the `createdAt` attribute', async () => {
await createPostAction() await createPostAction()
await markAsReadAction() await markAsReadAction()
const { const {
@ -407,7 +405,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({ const expected = expect.objectContaining({
data: { notifications: [] }, data: { notifications: [] },
}) })
const { query } = createTestClient(server)
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,
@ -430,7 +428,7 @@ describe('notifications', () => {
beforeEach(async () => { beforeEach(async () => {
commentContent = commentContent =
'One mention about me with <a data-mention-id="you" class="mention" href="/profile/you" target="_blank">@al-capone</a>.' 'One mention about me with <a data-mention-id="you" class="mention" href="/profile/you" target="_blank">@al-capone</a>.'
commentAuthor = await instance.create('User', { commentAuthor = await neode.create('User', {
id: 'commentAuthor', id: 'commentAuthor',
name: 'Mrs Comment', name: 'Mrs Comment',
slug: 'mrs-comment', slug: 'mrs-comment',
@ -439,7 +437,15 @@ describe('notifications', () => {
}) })
}) })
it('sends a notification', async () => { it('sends only one notification with reason mentioned_in_comment', async () => {
postAuthor = await neode.create('User', {
id: 'MrPostAuthor',
name: 'Mr Author',
slug: 'mr-author',
email: 'post-author@example.org',
password: '1234',
})
await createCommentOnPostAction() await createCommentOnPostAction()
const expected = expect.objectContaining({ const expected = expect.objectContaining({
data: { data: {
@ -457,7 +463,41 @@ describe('notifications', () => {
], ],
}, },
}) })
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
beforeEach(async () => {
title = "Post where I'm the author and I get mentioned in a comment"
postContent = 'Content of post where I get mentioned in a comment.'
postAuthor = notifiedUser
})
it('sends only one notification with reason commented_on_post, no notification with reason mentioned_in_comment', async () => {
await createCommentOnPostAction()
const expected = expect.objectContaining({
data: {
notifications: [
{
read: false,
createdAt: expect.any(String),
reason: 'commented_on_post',
from: {
__typename: 'Comment',
id: 'c47',
content: commentContent,
},
},
],
},
})
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,
@ -474,7 +514,7 @@ describe('notifications', () => {
await postAuthor.relateTo(notifiedUser, 'blocked') await postAuthor.relateTo(notifiedUser, 'blocked')
commentContent = commentContent =
'One mention about me with <a data-mention-id="you" class="mention" href="/profile/you" target="_blank">@al-capone</a>.' 'One mention about me with <a data-mention-id="you" class="mention" href="/profile/you" target="_blank">@al-capone</a>.'
commentAuthor = await instance.create('User', { commentAuthor = await neode.create('User', {
id: 'commentAuthor', id: 'commentAuthor',
name: 'Mrs Comment', name: 'Mrs Comment',
slug: 'mrs-comment', slug: 'mrs-comment',
@ -488,7 +528,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({ const expected = expect.objectContaining({
data: { notifications: [] }, data: { notifications: [] },
}) })
const { query } = createTestClient(server)
await expect( await expect(
query({ query({
query: notificationQuery, query: notificationQuery,

View File

@ -1,6 +1,6 @@
import Factory from '../seed/factories' import { gql } from '../helpers/jest'
import { gql } from '../jest/helpers' import Factory from '../factories'
import { neode as getNeode, getDriver } from '../bootstrap/neo4j' import { getNeode, getDriver } from '../db/neo4j'
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
import createServer from '../server' import createServer from '../server'

View File

@ -1,11 +1,11 @@
import { rule, shield, deny, allow, and, or, not } from 'graphql-shield' import { rule, shield, deny, allow, or } from 'graphql-shield'
import { neode } from '../bootstrap/neo4j' import { getNeode } from '../db/neo4j'
import CONFIG from '../config' import CONFIG from '../config'
const debug = !!CONFIG.DEBUG const debug = !!CONFIG.DEBUG
const allowExternalErrors = true const allowExternalErrors = true
const instance = neode() const neode = getNeode()
const isAuthenticated = rule({ const isAuthenticated = rule({
cache: 'contextual', cache: 'contextual',
@ -36,67 +36,33 @@ const isMyOwn = rule({
const isMySocialMedia = rule({ const isMySocialMedia = rule({
cache: 'no_cache', cache: 'no_cache',
})(async (_, args, { user }) => { })(async (_, args, { user }) => {
let socialMedia = await instance.find('SocialMedia', args.id) let socialMedia = await neode.find('SocialMedia', args.id)
socialMedia = await socialMedia.toJson() socialMedia = await socialMedia.toJson()
return socialMedia.ownedBy.node.id === user.id return socialMedia.ownedBy.node.id === user.id
}) })
/* TODO: decide if we want to remove this check: the check
* `onlyEnabledContent` throws authorization errors only if you have
* arguments for `disabled` or `deleted` assuming these are filter
* parameters. Soft-delete middleware obfuscates data on its way out
* anyways. Furthermore, `neo4j-graphql-js` offers many ways to filter for
* data so I believe, this is not a good check anyways.
*/
const onlyEnabledContent = rule({
cache: 'strict',
})(async (parent, args, ctx, info) => {
const { disabled, deleted } = args
return !(disabled || deleted)
})
const invitationLimitReached = rule({
cache: 'no_cache',
})(async (parent, args, { user, driver }) => {
const session = driver.session()
try {
const result = await session.run(
`
MATCH (user:User {id:$id})-[:GENERATED]->(i:InvitationCode)
RETURN COUNT(i) >= 3 as limitReached
`,
{ id: user.id },
)
const [limitReached] = result.records.map(record => {
return record.get('limitReached')
})
return limitReached
} finally {
session.close()
}
})
const isAuthor = rule({ const isAuthor = rule({
cache: 'no_cache', cache: 'no_cache',
})(async (_parent, args, { user, driver }) => { })(async (_parent, args, { user, driver }) => {
if (!user) return false if (!user) return false
const session = driver.session()
const { id: resourceId } = args const { id: resourceId } = args
const result = await session.run( const session = driver.session()
` const authorReadTxPromise = session.readTransaction(async transaction => {
MATCH (resource {id: $resourceId})<-[:WROTE]-(author) const authorTransactionResponse = await transaction.run(
RETURN author `
`, MATCH (resource {id: $resourceId})<-[:WROTE]-(author {id: $userId})
{ RETURN author
resourceId, `,
}, { resourceId, userId: user.id },
) )
session.close() return authorTransactionResponse.records.map(record => record.get('author'))
const [author] = result.records.map(record => {
return record.get('author')
}) })
const authorId = author && author.properties && author.properties.id try {
return authorId === user.id const [author] = await authorReadTxPromise
return !!author
} finally {
session.close()
}
}) })
const isDeletingOwnAccount = rule({ const isDeletingOwnAccount = rule({
@ -111,40 +77,45 @@ const noEmailFilter = rule({
return !('email' in args) return !('email' in args)
}) })
const publicRegistration = rule()(() => !!CONFIG.PUBLIC_REGISTRATION)
// Permissions // Permissions
const permissions = shield( export default shield(
{ {
Query: { Query: {
'*': deny, '*': deny,
findPosts: allow, findPosts: allow,
findUsers: allow,
findResources: allow,
embed: allow, embed: allow,
Category: allow, Category: allow,
Tag: allow, Tag: allow,
Report: isModerator, reports: isModerator,
statistics: allow, statistics: allow,
currentUser: allow, currentUser: allow,
Post: or(onlyEnabledContent, isModerator), Post: allow,
profilePagePosts: allow,
Comment: allow, Comment: allow,
User: or(noEmailFilter, isAdmin), User: or(noEmailFilter, isAdmin),
isLoggedIn: allow, isLoggedIn: allow,
Badge: allow, Badge: allow,
PostsEmotionsCountByEmotion: allow, PostsEmotionsCountByEmotion: allow,
PostsEmotionsByCurrentUser: isAuthenticated, PostsEmotionsByCurrentUser: isAuthenticated,
blockedUsers: isAuthenticated, mutedUsers: isAuthenticated,
notifications: isAuthenticated, notifications: isAuthenticated,
Donations: isAuthenticated,
}, },
Mutation: { Mutation: {
'*': deny, '*': deny,
login: allow, login: allow,
SignupByInvitation: allow, SignupByInvitation: allow,
Signup: isAdmin, Signup: or(publicRegistration, isAdmin),
SignupVerification: allow, SignupVerification: allow,
CreateInvitationCode: and(isAuthenticated, or(not(invitationLimitReached), isAdmin)),
UpdateUser: onlyYourself, UpdateUser: onlyYourself,
CreatePost: isAuthenticated, CreatePost: isAuthenticated,
UpdatePost: isAuthor, UpdatePost: isAuthor,
DeletePost: isAuthor, DeletePost: isAuthor,
report: isAuthenticated, fileReport: isAuthenticated,
CreateSocialMedia: isAuthenticated, CreateSocialMedia: isAuthenticated,
UpdateSocialMedia: isMySocialMedia, UpdateSocialMedia: isMySocialMedia,
DeleteSocialMedia: isMySocialMedia, DeleteSocialMedia: isMySocialMedia,
@ -152,13 +123,12 @@ const permissions = shield(
// RemoveBadgeRewarded: isAdmin, // RemoveBadgeRewarded: isAdmin,
reward: isAdmin, reward: isAdmin,
unreward: isAdmin, unreward: isAdmin,
follow: isAuthenticated, followUser: isAuthenticated,
unfollow: isAuthenticated, unfollowUser: isAuthenticated,
shout: isAuthenticated, shout: isAuthenticated,
unshout: isAuthenticated, unshout: isAuthenticated,
changePassword: isAuthenticated, changePassword: isAuthenticated,
enable: isModerator, review: isModerator,
disable: isModerator,
CreateComment: isAuthenticated, CreateComment: isAuthenticated,
UpdateComment: isAuthor, UpdateComment: isAuthor,
DeleteComment: isAuthor, DeleteComment: isAuthor,
@ -167,12 +137,17 @@ const permissions = shield(
resetPassword: allow, resetPassword: allow,
AddPostEmotions: isAuthenticated, AddPostEmotions: isAuthenticated,
RemovePostEmotions: isAuthenticated, RemovePostEmotions: isAuthenticated,
block: isAuthenticated, muteUser: isAuthenticated,
unblock: isAuthenticated, unmuteUser: isAuthenticated,
markAsRead: isAuthenticated, markAsRead: isAuthenticated,
AddEmailAddress: isAuthenticated,
VerifyEmailAddress: isAuthenticated,
pinPost: isAdmin,
unpinPost: isAdmin,
UpdateDonations: isAdmin,
}, },
User: { User: {
email: isMyOwn, email: or(isMyOwn, isAdmin),
}, },
}, },
{ {
@ -181,5 +156,3 @@ const permissions = shield(
fallbackRule: allow, fallbackRule: allow,
}, },
) )
export default permissions

View File

@ -1,22 +1,63 @@
import { GraphQLClient } from 'graphql-request' import { createTestClient } from 'apollo-server-testing'
import Factory from '../seed/factories' import createServer from '../server'
import { host, login } from '../jest/helpers' import Factory from '../factories'
import { gql } from '../helpers/jest'
import { getDriver, getNeode } from '../db/neo4j'
const factory = Factory() const factory = Factory()
const instance = getNeode()
const driver = getDriver()
let query, authenticatedUser, owner, anotherRegularUser, administrator, variables, moderator
const userQuery = gql`
query($name: String) {
User(name: $name) {
email
}
}
`
describe('authorization', () => { describe('authorization', () => {
beforeAll(async () => {
await factory.cleanDatabase()
const { server } = createServer({
context: () => ({
driver,
instance,
user: authenticatedUser,
}),
})
query = createTestClient(server).query
})
describe('given two existing users', () => { describe('given two existing users', () => {
beforeEach(async () => { beforeEach(async () => {
await factory.create('User', { ;[owner, anotherRegularUser, administrator, moderator] = await Promise.all([
email: 'owner@example.org', factory.create('User', {
name: 'Owner', email: 'owner@example.org',
password: 'iamtheowner', name: 'Owner',
}) password: 'iamtheowner',
await factory.create('User', { }),
email: 'someone@example.org', factory.create('User', {
name: 'Someone else', email: 'another.regular.user@example.org',
password: 'else', name: 'Another Regular User',
}) password: 'else',
}),
factory.create('User', {
email: 'admin@example.org',
name: 'Admin',
password: 'admin',
role: 'admin',
}),
factory.create('User', {
email: 'moderator@example.org',
name: 'Moderator',
password: 'moderator',
role: 'moderator',
}),
])
variables = {}
}) })
afterEach(async () => { afterEach(async () => {
@ -24,66 +65,77 @@ describe('authorization', () => {
}) })
describe('access email address', () => { describe('access email address', () => {
let headers = {} describe('unauthenticated', () => {
let loginCredentials = null
const action = async () => {
if (loginCredentials) {
headers = await login(loginCredentials)
}
const graphQLClient = new GraphQLClient(host, { headers })
return graphQLClient.request('{User(name: "Owner") { email } }')
}
describe('not logged in', () => {
it('rejects', async () => {
await expect(action()).rejects.toThrow('Not Authorised!')
})
it("does not expose the owner's email address", async () => {
let response = {}
try {
await action()
} catch (error) {
response = error.response.data
} finally {
expect(response).toEqual({ User: [null] })
}
})
})
describe('as owner', () => {
beforeEach(() => { beforeEach(() => {
loginCredentials = { authenticatedUser = null
email: 'owner@example.org',
password: 'iamtheowner',
}
}) })
it("throws an error and does not expose the owner's email address", async () => {
it("exposes the owner's email address", async () => { await expect(
await expect(action()).resolves.toEqual({ User: [{ email: 'owner@example.org' }] }) query({ query: userQuery, variables: { name: 'Owner' } }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }],
data: { User: [null] },
})
}) })
}) })
describe('authenticated as another user', () => { describe('authenticated', () => {
beforeEach(async () => { describe('as the owner', () => {
loginCredentials = { beforeEach(async () => {
email: 'someone@example.org', authenticatedUser = await owner.toJson()
password: 'else', })
}
it("exposes the owner's email address", async () => {
variables = { name: 'Owner' }
await expect(query({ query: userQuery, variables })).resolves.toMatchObject({
data: { User: [{ email: 'owner@example.org' }] },
errors: undefined,
})
})
}) })
it('rejects', async () => { describe('as another regular user', () => {
await expect(action()).rejects.toThrow('Not Authorised!') beforeEach(async () => {
authenticatedUser = await anotherRegularUser.toJson()
})
it("throws an error and does not expose the owner's email address", async () => {
await expect(
query({ query: userQuery, variables: { name: 'Owner' } }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }],
data: { User: [null] },
})
})
}) })
it("does not expose the owner's email address", async () => { describe('as a moderator', () => {
let response beforeEach(async () => {
try { authenticatedUser = await moderator.toJson()
await action() })
} catch (error) {
response = error.response.data it("throws an error and does not expose the owner's email address", async () => {
} await expect(
expect(response).toEqual({ User: [null] }) query({ query: userQuery, variables: { name: 'Owner' } }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorised!' }],
data: { User: [null] },
})
})
})
describe('administrator', () => {
beforeEach(async () => {
authenticatedUser = await administrator.toJson()
})
it("exposes the owner's email address", async () => {
variables = { name: 'Owner' }
await expect(query({ query: userQuery, variables })).resolves.toMatchObject({
data: { User: [{ email: 'owner@example.org' }] },
errors: undefined,
})
})
}) })
}) })
}) })

View File

@ -3,11 +3,20 @@ import uniqueSlug from './slugify/uniqueSlug'
const isUniqueFor = (context, type) => { const isUniqueFor = (context, type) => {
return async slug => { return async slug => {
const session = context.driver.session() const session = context.driver.session()
const response = await session.run(`MATCH(p:${type} {slug: $slug }) return p.slug`, { try {
slug, const existingSlug = await session.readTransaction(transaction => {
}) return transaction.run(
session.close() `
return response.records.length === 0 MATCH(p:${type} {slug: $slug })
RETURN p.slug
`,
{ slug },
)
})
return existingSlug.records.length === 0
} finally {
session.close()
}
} }
} }
@ -25,9 +34,5 @@ export default {
args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post'))) args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post')))
return resolve(root, args, context, info) return resolve(root, args, context, info)
}, },
CreateCategory: async (resolve, root, args, context, info) => {
args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'Category')))
return resolve(root, args, context, info)
},
}, },
} }

View File

@ -1,6 +1,6 @@
import Factory from '../seed/factories' import Factory from '../factories'
import { gql } from '../jest/helpers' import { gql } from '../helpers/jest'
import { neode as getNeode, getDriver } from '../bootstrap/neo4j' import { getNeode, getDriver } from '../db/neo4j'
import createServer from '../server' import createServer from '../server'
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'

View File

@ -3,9 +3,7 @@ const isModerator = ({ user }) => {
} }
const setDefaultFilters = (resolve, root, args, context, info) => { const setDefaultFilters = (resolve, root, args, context, info) => {
if (typeof args.deleted !== 'boolean') { args.deleted = false
args.deleted = false
}
if (!isModerator(context)) { if (!isModerator(context)) {
args.disabled = false args.disabled = false
@ -32,6 +30,7 @@ export default {
Post: setDefaultFilters, Post: setDefaultFilters,
Comment: setDefaultFilters, Comment: setDefaultFilters,
User: setDefaultFilters, User: setDefaultFilters,
profilePagePosts: setDefaultFilters,
}, },
Mutation: async (resolve, root, args, context, info) => { Mutation: async (resolve, root, args, context, info) => {
args.disabled = false args.disabled = false

View File

@ -1,6 +1,6 @@
import Factory from '../../seed/factories' import Factory from '../../factories'
import { gql } from '../../jest/helpers' import { gql } from '../../helpers/jest'
import { neode as getNeode, getDriver } from '../../bootstrap/neo4j' import { getNeode, getDriver } from '../../db/neo4j'
import createServer from '../../server' import createServer from '../../server'
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
@ -8,14 +8,8 @@ const factory = Factory()
const neode = getNeode() const neode = getNeode()
const driver = getDriver() const driver = getDriver()
let query
let mutate
let graphqlQuery
const categoryIds = ['cat9'] const categoryIds = ['cat9']
let authenticatedUser let query, graphqlQuery, authenticatedUser, user, moderator, troll
let user
let moderator
let troll
const action = () => { const action = () => {
return query({ query: graphqlQuery }) return query({ query: graphqlQuery })
@ -38,18 +32,17 @@ beforeAll(async () => {
avatar: '/some/offensive/avatar.jpg', avatar: '/some/offensive/avatar.jpg',
about: 'This self description is very offensive', about: 'This self description is very offensive',
}), }),
neode.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
}),
]) ])
user = users[0] user = users[0]
moderator = users[1] moderator = users[1]
troll = users[2] troll = users[2]
await neode.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
})
await Promise.all([ await Promise.all([
user.relateTo(troll, 'following'), user.relateTo(troll, 'following'),
factory.create('Post', { factory.create('Post', {
@ -70,33 +63,32 @@ beforeAll(async () => {
}), }),
]) ])
await Promise.all([ const resources = await Promise.all([
factory.create('Comment', { factory.create('Comment', {
author: user, author: user,
id: 'c2', id: 'c2',
postId: 'p3', postId: 'p3',
content: 'Enabled comment on public post', content: 'Enabled comment on public post',
}), }),
factory.create('Post', {
id: 'p2',
author: troll,
title: 'Disabled post',
content: 'This is an offensive post content',
contentExcerpt: 'This is an offensive post content',
image: '/some/offensive/image.jpg',
deleted: false,
categoryIds,
}),
factory.create('Comment', {
id: 'c1',
author: troll,
postId: 'p3',
content: 'Disabled comment',
contentExcerpt: 'Disabled comment',
}),
]) ])
await factory.create('Post', {
id: 'p2',
author: troll,
title: 'Disabled post',
content: 'This is an offensive post content',
contentExcerpt: 'This is an offensive post content',
image: '/some/offensive/image.jpg',
deleted: false,
categoryIds,
})
await factory.create('Comment', {
id: 'c1',
author: troll,
postId: 'p3',
content: 'Disabled comment',
contentExcerpt: 'Disabled comment',
})
const { server } = createServer({ const { server } = createServer({
context: () => { context: () => {
return { return {
@ -108,20 +100,57 @@ beforeAll(async () => {
}) })
const client = createTestClient(server) const client = createTestClient(server)
query = client.query query = client.query
mutate = client.mutate
authenticatedUser = await moderator.toJson() const trollingPost = resources[1]
const disableMutation = gql` const trollingComment = resources[2]
mutation($id: ID!) {
disable(id: $id) const reports = await Promise.all([
} factory.create('Report'),
` factory.create('Report'),
await Promise.all([ factory.create('Report'),
mutate({ mutation: disableMutation, variables: { id: 'c1' } }), ])
mutate({ mutation: disableMutation, variables: { id: 'u2' } }), const reportAgainstTroll = reports[0]
mutate({ mutation: disableMutation, variables: { id: 'p2' } }), const reportAgainstTrollingPost = reports[1]
const reportAgainstTrollingComment = reports[2]
const reportVariables = {
resourceId: 'undefined-resource',
reasonCategory: 'discrimination_etc',
reasonDescription: 'I am what I am !!!',
}
await Promise.all([
reportAgainstTroll.relateTo(user, 'filed', { ...reportVariables, resourceId: 'u2' }),
reportAgainstTroll.relateTo(troll, 'belongsTo'),
reportAgainstTrollingPost.relateTo(user, 'filed', { ...reportVariables, resourceId: 'p2' }),
reportAgainstTrollingPost.relateTo(trollingPost, 'belongsTo'),
reportAgainstTrollingComment.relateTo(moderator, 'filed', {
...reportVariables,
resourceId: 'c1',
}),
reportAgainstTrollingComment.relateTo(trollingComment, 'belongsTo'),
])
const disableVariables = {
resourceId: 'undefined-resource',
disable: true,
closed: false,
}
await Promise.all([
reportAgainstTroll.relateTo(moderator, 'reviewed', { ...disableVariables, resourceId: 'u2' }),
troll.update({ disabled: true, updatedAt: new Date().toISOString() }),
reportAgainstTrollingPost.relateTo(moderator, 'reviewed', {
...disableVariables,
resourceId: 'p2',
}),
trollingPost.update({ disabled: true, updatedAt: new Date().toISOString() }),
reportAgainstTrollingComment.relateTo(moderator, 'reviewed', {
...disableVariables,
resourceId: 'c1',
}),
trollingComment.update({ disabled: true, updatedAt: new Date().toISOString() }),
]) ])
authenticatedUser = null
}) })
afterAll(async () => { afterAll(async () => {
@ -341,76 +370,6 @@ describe('softDeleteMiddleware', () => {
}) })
}) })
}) })
describe('filter (deleted: true)', () => {
beforeEach(() => {
graphqlQuery = gql`
{
Post(deleted: true) {
title
}
}
`
})
describe('as user', () => {
beforeEach(async () => {
authenticatedUser = await user.toJson()
})
it('throws authorisation error', async () => {
const { data, errors } = await action()
expect(data).toEqual({ Post: null })
expect(errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('as moderator', () => {
beforeEach(async () => {
authenticatedUser = await moderator.toJson()
})
it('does not show deleted posts', async () => {
const expected = { data: { Post: [{ title: 'UNAVAILABLE' }] } }
await expect(action()).resolves.toMatchObject(expected)
})
})
})
describe('filter (disabled: true)', () => {
beforeEach(() => {
graphqlQuery = gql`
{
Post(disabled: true) {
title
}
}
`
})
describe('as user', () => {
beforeEach(async () => {
authenticatedUser = await user.toJson()
})
it('throws authorisation error', async () => {
const { data, errors } = await action()
expect(data).toEqual({ Post: null })
expect(errors[0]).toHaveProperty('message', 'Not Authorised!')
})
})
describe('as moderator', () => {
beforeEach(async () => {
authenticatedUser = await moderator.toJson()
})
it('shows disabled posts', async () => {
const expected = { data: { Post: [{ title: 'Disabled post' }] } }
await expect(action()).resolves.toMatchObject(expected)
})
})
})
}) })
}) })
}) })

View File

@ -1,16 +0,0 @@
import createOrUpdateLocations from './nodes/locations'
export default {
Mutation: {
SignupVerification: async (resolve, root, args, context, info) => {
const result = await resolve(root, args, context, info)
await createOrUpdateLocations(args.id, args.locationName, context.driver)
return result
},
UpdateUser: async (resolve, root, args, context, info) => {
const result = await resolve(root, args, context, info)
await createOrUpdateLocations(args.id, args.locationName, context.driver)
return result
},
},
}

View File

@ -4,8 +4,8 @@ const COMMENT_MIN_LENGTH = 1
const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!' const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!'
const NO_CATEGORIES_ERR_MESSAGE = const NO_CATEGORIES_ERR_MESSAGE =
'You cannot save a post without at least one category or more than three' 'You cannot save a post without at least one category or more than three'
const USERNAME_MIN_LENGTH = 3
const validateCommentCreation = async (resolve, root, args, context, info) => { const validateCreateComment = async (resolve, root, args, context, info) => {
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim() const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
const { postId } = args const { postId } = args
@ -13,28 +13,31 @@ const validateCommentCreation = async (resolve, root, args, context, info) => {
throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`) throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`)
} }
const session = context.driver.session() const session = context.driver.session()
const postQueryRes = await session.run( try {
` const postQueryRes = await session.readTransaction(transaction => {
MATCH (post:Post {id: $postId}) return transaction.run(
RETURN post`, `
{ MATCH (post:Post {id: $postId})
postId, RETURN post
}, `,
) { postId },
session.close() )
const [post] = postQueryRes.records.map(record => { })
return record.get('post') const [post] = postQueryRes.records.map(record => {
}) return record.get('post')
})
if (!post) { if (!post) {
throw new UserInputError(NO_POST_ERR_MESSAGE) throw new UserInputError(NO_POST_ERR_MESSAGE)
} else { } else {
return resolve(root, args, context, info) return resolve(root, args, context, info)
}
} finally {
session.close()
} }
} }
const validateUpdateComment = async (resolve, root, args, context, info) => { const validateUpdateComment = async (resolve, root, args, context, info) => {
const COMMENT_MIN_LENGTH = 1
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim() const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
if (!args.content || content.length < COMMENT_MIN_LENGTH) { if (!args.content || content.length < COMMENT_MIN_LENGTH) {
throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`) throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`)
@ -57,11 +60,88 @@ const validateUpdatePost = async (resolve, root, args, context, info) => {
return validatePost(resolve, root, args, context, info) return validatePost(resolve, root, args, context, info)
} }
const validateReport = async (resolve, root, args, context, info) => {
const { resourceId } = args
const { user } = context
if (resourceId === user.id) throw new Error('You cannot report yourself!')
return resolve(root, args, context, info)
}
const validateReview = async (resolve, root, args, context, info) => {
const { resourceId } = args
let existingReportedResource
const { user, driver } = context
if (resourceId === user.id) throw new Error('You cannot review yourself!')
const session = driver.session()
const reportReadTxPromise = session.readTransaction(async transaction => {
const validateReviewTransactionResponse = await transaction.run(
`
MATCH (resource {id: $resourceId})
WHERE resource:User OR resource:Post OR resource:Comment
OPTIONAL MATCH (:User)-[filed:FILED]->(:Report {closed: false})-[:BELONGS_TO]->(resource)
OPTIONAL MATCH (resource)<-[:WROTE]-(author:User)
RETURN labels(resource)[0] AS label, author, filed
`,
{
resourceId,
submitterId: user.id,
},
)
return validateReviewTransactionResponse.records.map(record => ({
label: record.get('label'),
author: record.get('author'),
filed: record.get('filed'),
}))
})
try {
const txResult = await reportReadTxPromise
existingReportedResource = txResult
if (!existingReportedResource || !existingReportedResource.length)
throw new Error(`Resource not found or is not a Post|Comment|User!`)
existingReportedResource = existingReportedResource[0]
if (!existingReportedResource.filed)
throw new Error(
`Before starting the review process, please report the ${existingReportedResource.label}!`,
)
const authorId =
existingReportedResource.label !== 'User' && existingReportedResource.author
? existingReportedResource.author.properties.id
: null
if (authorId && authorId === user.id)
throw new Error(`You cannot review your own ${existingReportedResource.label}!`)
} finally {
session.close()
}
return resolve(root, args, context, info)
}
export const validateNotifyUsers = async (label, reason) => {
const reasonsAllowed = ['mentioned_in_post', 'mentioned_in_comment', 'commented_on_post']
if (!reasonsAllowed.includes(reason)) throw new Error('Notification reason is not allowed!')
if (
(label === 'Post' && reason !== 'mentioned_in_post') ||
(label === 'Comment' && !['mentioned_in_comment', 'commented_on_post'].includes(reason))
) {
throw new Error('Notification does not fit the reason!')
}
}
const validateUpdateUser = async (resolve, root, params, context, info) => {
const { name } = params
if (typeof name === 'string' && name.trim().length < USERNAME_MIN_LENGTH)
throw new UserInputError(`Username must be at least ${USERNAME_MIN_LENGTH} character long!`)
return resolve(root, params, context, info)
}
export default { export default {
Mutation: { Mutation: {
CreateComment: validateCommentCreation, CreateComment: validateCreateComment,
UpdateComment: validateUpdateComment, UpdateComment: validateUpdateComment,
CreatePost: validatePost, CreatePost: validatePost,
UpdatePost: validateUpdatePost, UpdatePost: validateUpdatePost,
UpdateUser: validateUpdateUser,
fileReport: validateReport,
review: validateReview,
}, },
} }

View File

@ -0,0 +1,437 @@
import { gql } from '../../helpers/jest'
import Factory from '../../factories'
import { getNeode, getDriver } from '../../db/neo4j'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../../server'
const factory = Factory()
const neode = getNeode()
const driver = getDriver()
let authenticatedUser,
mutate,
users,
offensivePost,
reportVariables,
disableVariables,
reportingUser,
moderatingUser,
commentingUser
const createCommentMutation = gql`
mutation($id: ID, $postId: ID!, $content: String!) {
CreateComment(id: $id, postId: $postId, content: $content) {
id
}
}
`
const updateCommentMutation = gql`
mutation($content: String!, $id: ID!) {
UpdateComment(content: $content, id: $id) {
id
}
}
`
const createPostMutation = gql`
mutation($id: ID, $title: String!, $content: String!, $language: String, $categoryIds: [ID]) {
CreatePost(
id: $id
title: $title
content: $content
language: $language
categoryIds: $categoryIds
) {
id
}
}
`
const updatePostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) {
UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
id
}
}
`
const reportMutation = gql`
mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) {
fileReport(
resourceId: $resourceId
reasonCategory: $reasonCategory
reasonDescription: $reasonDescription
) {
id
}
}
`
const reviewMutation = gql`
mutation($resourceId: ID!, $disable: Boolean, $closed: Boolean) {
review(resourceId: $resourceId, disable: $disable, closed: $closed) {
createdAt
updatedAt
}
}
`
const updateUserMutation = gql`
mutation($id: ID!, $name: String) {
UpdateUser(id: $id, name: $name) {
name
}
}
`
beforeAll(() => {
const { server } = createServer({
context: () => {
return {
user: authenticatedUser,
neode,
driver,
}
},
})
mutate = createTestClient(server).mutate
})
beforeEach(async () => {
users = await Promise.all([
factory.create('User', {
id: 'reporting-user',
}),
factory.create('User', {
id: 'moderating-user',
role: 'moderator',
}),
factory.create('User', {
id: 'commenting-user',
}),
])
reportVariables = {
resourceId: 'whatever',
reasonCategory: 'other',
reasonDescription: 'Violates code of conduct !!!',
}
disableVariables = {
resourceId: 'undefined-resource',
disable: true,
closed: false,
}
reportingUser = users[0]
moderatingUser = users[1]
commentingUser = users[2]
const posts = await Promise.all([
factory.create('Post', {
id: 'offensive-post',
authorId: 'moderating-user',
}),
factory.create('Post', {
id: 'post-4-commenting',
authorId: 'commenting-user',
}),
])
offensivePost = posts[0]
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('validateCreateComment', () => {
let createCommentVariables
beforeEach(async () => {
createCommentVariables = {
postId: 'whatever',
content: '',
}
authenticatedUser = await commentingUser.toJson()
})
it('throws an error if content is empty', async () => {
createCommentVariables = { ...createCommentVariables, postId: 'post-4-commenting' }
await expect(
mutate({ mutation: createCommentMutation, variables: createCommentVariables }),
).resolves.toMatchObject({
data: { CreateComment: null },
errors: [{ message: 'Comment must be at least 1 character long!' }],
})
})
it('sanitizes content and throws an error if not longer than 1 character', async () => {
createCommentVariables = { postId: 'post-4-commenting', content: '<a></a>' }
await expect(
mutate({ mutation: createCommentMutation, variables: createCommentVariables }),
).resolves.toMatchObject({
data: { CreateComment: null },
errors: [{ message: 'Comment must be at least 1 character long!' }],
})
})
it('throws an error if there is no post with given id in the database', async () => {
createCommentVariables = {
...createCommentVariables,
postId: 'non-existent-post',
content: 'valid content',
}
await expect(
mutate({ mutation: createCommentMutation, variables: createCommentVariables }),
).resolves.toMatchObject({
data: { CreateComment: null },
errors: [{ message: 'Comment cannot be created without a post!' }],
})
})
describe('validateUpdateComment', () => {
let updateCommentVariables
beforeEach(async () => {
await factory.create('Comment', {
id: 'comment-id',
authorId: 'commenting-user',
})
updateCommentVariables = {
id: 'whatever',
content: '',
}
authenticatedUser = await commentingUser.toJson()
})
it('throws an error if content is empty', async () => {
updateCommentVariables = { ...updateCommentVariables, id: 'comment-id' }
await expect(
mutate({ mutation: updateCommentMutation, variables: updateCommentVariables }),
).resolves.toMatchObject({
data: { UpdateComment: null },
errors: [{ message: 'Comment must be at least 1 character long!' }],
})
})
it('sanitizes content and throws an error if not longer than 1 character', async () => {
updateCommentVariables = { id: 'comment-id', content: '<a></a>' }
await expect(
mutate({ mutation: updateCommentMutation, variables: updateCommentVariables }),
).resolves.toMatchObject({
data: { UpdateComment: null },
errors: [{ message: 'Comment must be at least 1 character long!' }],
})
})
})
describe('validatePost', () => {
let createPostVariables
beforeEach(async () => {
createPostVariables = {
title: 'I am a title',
content: 'Some content',
}
authenticatedUser = await commentingUser.toJson()
})
describe('categories', () => {
describe('null', () => {
it('throws UserInputError', async () => {
createPostVariables = { ...createPostVariables, categoryIds: null }
await expect(
mutate({ mutation: createPostMutation, variables: createPostVariables }),
).resolves.toMatchObject({
data: { CreatePost: null },
errors: [
{
message: 'You cannot save a post without at least one category or more than three',
},
],
})
})
})
describe('empty', () => {
it('throws UserInputError', async () => {
createPostVariables = { ...createPostVariables, categoryIds: [] }
await expect(
mutate({ mutation: createPostMutation, variables: createPostVariables }),
).resolves.toMatchObject({
data: { CreatePost: null },
errors: [
{
message: 'You cannot save a post without at least one category or more than three',
},
],
})
})
})
describe('more than 3 categoryIds', () => {
it('throws UserInputError', async () => {
createPostVariables = {
...createPostVariables,
categoryIds: ['cat9', 'cat27', 'cat15', 'cat4'],
}
await expect(
mutate({ mutation: createPostMutation, variables: createPostVariables }),
).resolves.toMatchObject({
data: { CreatePost: null },
errors: [
{
message: 'You cannot save a post without at least one category or more than three',
},
],
})
})
})
})
})
describe('validateUpdatePost', () => {
describe('post created without categories somehow', () => {
let owner, updatePostVariables
beforeEach(async () => {
const postSomehowCreated = await neode.create('Post', {
id: 'how-was-this-created',
})
owner = await neode.create('User', {
id: 'author-of-post-without-category',
slug: 'hacker',
})
await postSomehowCreated.relateTo(owner, 'author')
authenticatedUser = await owner.toJson()
updatePostVariables = {
id: 'how-was-this-created',
title: 'I am a title',
content: 'Some content',
categoryIds: [],
}
})
it('requires at least one category for successful update', async () => {
await expect(
mutate({ mutation: updatePostMutation, variables: updatePostVariables }),
).resolves.toMatchObject({
data: { UpdatePost: null },
errors: [
{ message: 'You cannot save a post without at least one category or more than three' },
],
})
})
})
})
})
describe('validateReport', () => {
it('throws an error if a user tries to report themself', async () => {
authenticatedUser = await reportingUser.toJson()
reportVariables = { ...reportVariables, resourceId: 'reporting-user' }
await expect(
mutate({ mutation: reportMutation, variables: reportVariables }),
).resolves.toMatchObject({
data: { fileReport: null },
errors: [{ message: 'You cannot report yourself!' }],
})
})
})
describe('validateReview', () => {
beforeEach(async () => {
const reportAgainstModerator = await factory.create('Report')
await Promise.all([
reportAgainstModerator.relateTo(reportingUser, 'filed', {
...reportVariables,
resourceId: 'moderating-user',
}),
reportAgainstModerator.relateTo(moderatingUser, 'belongsTo'),
])
authenticatedUser = await moderatingUser.toJson()
})
it('throws an error if a user tries to review a report against them', async () => {
disableVariables = { ...disableVariables, resourceId: 'moderating-user' }
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
data: { review: null },
errors: [{ message: 'You cannot review yourself!' }],
})
})
it('throws an error for invaild resource', async () => {
disableVariables = { ...disableVariables, resourceId: 'non-existent-resource' }
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
data: { review: null },
errors: [{ message: 'Resource not found or is not a Post|Comment|User!' }],
})
})
it('throws an error if no report exists', async () => {
disableVariables = { ...disableVariables, resourceId: 'offensive-post' }
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
data: { review: null },
errors: [{ message: 'Before starting the review process, please report the Post!' }],
})
})
it('throws an error if a moderator tries to review their own resource(Post|Comment)', async () => {
const reportAgainstOffensivePost = await factory.create('Report')
await Promise.all([
reportAgainstOffensivePost.relateTo(reportingUser, 'filed', {
...reportVariables,
resourceId: 'offensive-post',
}),
reportAgainstOffensivePost.relateTo(offensivePost, 'belongsTo'),
])
disableVariables = { ...disableVariables, resourceId: 'offensive-post' }
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
data: { review: null },
errors: [{ message: 'You cannot review your own Post!' }],
})
})
describe('moderate a resource that is not a (Comment|Post|User) ', () => {
beforeEach(async () => {
await Promise.all([factory.create('Tag', { id: 'tag-id' })])
})
it('returns null', async () => {
disableVariables = {
...disableVariables,
resourceId: 'tag-id',
}
await expect(
mutate({ mutation: reviewMutation, variables: disableVariables }),
).resolves.toMatchObject({
data: { review: null },
errors: [{ message: 'Resource not found or is not a Post|Comment|User!' }],
})
})
})
describe('validateUpdateUser', () => {
let userParams, variables, updatingUser
beforeEach(async () => {
userParams = {
id: 'updating-user',
name: 'John Doe',
}
variables = {
id: 'updating-user',
name: 'John Doughnut',
}
updatingUser = await factory.create('User', userParams)
authenticatedUser = await updatingUser.toJson()
})
it('with name too short', async () => {
variables = {
...variables,
name: ' ',
}
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: { UpdateUser: null },
errors: [{ message: 'Username must be at least 3 character long!' }],
})
})
})
})

View File

@ -85,7 +85,7 @@ function clean(dirty) {
return dirty return dirty
} }
const fields = ['content', 'contentExcerpt'] const fields = ['content', 'contentExcerpt', 'reasonDescription']
export default { export default {
Mutation: async (resolve, root, args, context, info) => { Mutation: async (resolve, root, args, context, info) => {

View File

@ -1,4 +1,4 @@
module.exports = { export default {
id: { type: 'string', primary: true, lowercase: true }, id: { type: 'string', primary: true, lowercase: true },
status: { type: 'string', valid: ['permanent', 'temporary'] }, status: { type: 'string', valid: ['permanent', 'temporary'] },
type: { type: 'string', valid: ['role', 'crowdfunding'] }, type: { type: 'string', valid: ['role', 'crowdfunding'] },

View File

@ -1,9 +1,9 @@
import uuid from 'uuid/v4' import uuid from 'uuid/v4'
module.exports = { export default {
id: { type: 'string', primary: true, default: uuid }, id: { type: 'string', primary: true, default: uuid },
name: { type: 'string', required: true, default: false }, name: { type: 'string', required: true, default: false },
slug: { type: 'string' }, slug: { type: 'string', unique: 'true' },
icon: { type: 'string', required: true, default: false }, icon: { type: 'string', required: true, default: false },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
updatedAt: { updatedAt: {

View File

@ -1,6 +1,6 @@
import uuid from 'uuid/v4' import uuid from 'uuid/v4'
module.exports = { export default {
id: { type: 'string', primary: true, default: uuid }, id: { type: 'string', primary: true, default: uuid },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
updatedAt: { updatedAt: {
@ -25,12 +25,6 @@ module.exports = {
target: 'User', target: 'User',
direction: 'in', direction: 'in',
}, },
disabledBy: {
type: 'relationship',
relationship: 'DISABLED',
target: 'User',
direction: 'in',
},
notified: { notified: {
type: 'relationship', type: 'relationship',
relationship: 'NOTIFIED', relationship: 'NOTIFIED',

View File

@ -0,0 +1,14 @@
import uuid from 'uuid/v4'
export default {
id: { type: 'string', primary: true, default: uuid },
goal: { type: 'number' },
progress: { type: 'number' },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
updatedAt: {
type: 'string',
isoDate: true,
required: true,
default: () => new Date().toISOString(),
},
}

View File

@ -1,4 +1,4 @@
module.exports = { export default {
email: { type: 'string', primary: true, lowercase: true, email: true }, email: { type: 'string', primary: true, lowercase: true, email: true },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
verifiedAt: { type: 'string', isoDate: true }, verifiedAt: { type: 'string', isoDate: true },

Some files were not shown because too many files have changed in this diff Show More