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
<!-- 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
<!-- 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
<!-- 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
release/
*~

View File

@ -6,20 +6,19 @@ addons:
- libgconf-2-4
snaps:
- docker
- chromium
before_install:
install:
- yarn global add wait-on
# Install Codecov
- 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 -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
- 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:
- export CYPRESS_RETRIES=1
@ -27,22 +26,18 @@ script:
- echo "TRAVIS_BRANCH=$TRAVIS_BRANCH, PR=$PR, BRANCH=$BRANCH"
# Backend
- 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: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
- docker-compose exec webapp yarn run lint
- docker-compose exec webapp yarn run test --ci --verbose=false --coverage
- docker-compose exec -d backend yarn run test:before:seeder
# Fullstack
- docker-compose down
- docker-compose -f docker-compose.yml up -d
- wait-on http://localhost:7474
- yarn run cypress:run
- yarn run cypress:run --record
- yarn run cucumber
# Coverage
- yarn run codecov
@ -67,14 +62,14 @@ before_deploy:
deploy:
- provider: script
script: scripts/docker_push.sh
script: bash scripts/docker_push.sh
on:
branch: master
- provider: script
script: scripts/deploy.sh
script: bash scripts/deploy.sh
on:
branch: master
- provider: script
script: scripts/github_release.sh
script: bash scripts/github_release.sh
on:
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
}
],
"editor.formatOnSave": true,
"eslint.autoFixOnSave": true
"editor.formatOnSave": false,
}

2068
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,79 +1,101 @@
# 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
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)
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 are using ZenHub to coordinate
* estimating time per issue is the crucial feature of [Zenhub](https://app.zenhub.com/workspaces/human-connection-nitro-5c0154ecc699f60fc92cf11f) that Github does not have
* "up-for-grabs" links to [Github project](https://github.com/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
* AgileVentures run open pairing sessions at 10:30am UTC each week on Tuesdays and Thursdays
* Core team
* all the people who are hired by HC non-profit corporation
* you can Meet-the-team [every two weeks in German](https://human-connection.org/veranstaltungen/) and [every month in English](https://human-connection.org/en/events/).
* 9 people
* 2 core developers \(Robert [@roschaefer](https://github.com/roschaefer) and Greg [@appinteractive](https://github.com/appinteractive)\)
* 3 marketeers Jasi, Dennis and Sensi
* Hardy doing business development
* Martin head of IT and previously data protection officer
* Victor doing accounting and controlling
* Nicolas is the community manager \(reviews content in the network\) reflects community opinion back to the core team
* when can folks pair with Robert
* 10am UTC until 5pm UTC every working day
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 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.
This is how we solve bugs and implement features, step by step:
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.
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.)
3. We make sure we understand the issue in detail what problem is it solving and how should it be implemented?
4. We assign ourselves to the issue and move it to `In Progress` on [Zenhub](https://app.zenhub.com/workspaces/human-connection-nitro-5c0154ecc699f60fc92cf11f).
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.
6. When questions come up we clarify them with the team (directly in the issue on Github).
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).
8. We then incorporate the suggestions from the reviews into our work and once it has been approved it can be merged into master!
Every pull request needs to:
* 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)
* include tests for the code that is added or changed
* 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
We practise [collective code ownership](http://www.extremeprogramming.org/rules/collective.html) rather than strong code ownership, which means that:
* anyone can start working on anyone elses code
* we avoid blocking because someone else isn't working on something
* however it's sometimes good to leave something in order to create successful education experience
* developers can make contributions to other people's PRs (after checking in with them)
* we avoid blocking because someone else isn't working, so we sometimes take over PRs from other developers
* 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
* 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\)
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!

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/)
[![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)
[![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.
@ -24,7 +25,7 @@ Human Connection is a nonprofit social, action and knowledge network that connec
## 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:
@ -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:
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
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
See the [LICENSE](LICENSE.md) file for license rights and limitations (MIT).

View File

@ -6,16 +6,12 @@
* [Neo4J](neo4j/README.md)
* [Backend](backend/README.md)
* [GraphQL](backend/graphql.md)
* [neo4j-graphql-js](backend/neo4j-graphql-js.md)
* [Webapp](webapp/README.md)
* [COMPONENTS](webapp/components.md)
* [PLUGINS](webapp/plugins.md)
* [STORE](webapp/store.md)
* [PAGES](webapp/pages.md)
* [ASSETS](webapp/assets.md)
* [LAYOUTS](webapp/layouts.md)
* [Styleguide](webapp/styleguide.md)
* [STATIC](webapp/static.md)
* [MIDDLEWARE](webapp/middleware.md)
* [Components](webapp/components.md)
* [HTML](webapp/html.md)
* [SCSS](webapp/scss.md)
* [Vue](webapp/vue.md)
* [Testing Guide](testing.md)
* [End-to-end tests](cypress/README.md)
* [Frontend tests](webapp/testing.md)
@ -32,9 +28,11 @@
* [Maintenance](deployment/human-connection/maintenance/README.md)
* [Volumes](deployment/volumes/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)
* [Reclaim Policy](deployment/volumes/reclaim-policy/README.md)
* [Velero](deployment/volumes/velero/README.md)
* [Metrics](deployment/monitoring/README.md)
* [Legacy Migration](deployment/legacy-migration/README.md)
* [Feature Specification](cypress/features.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_USERNAME=neo4j
NEO4J_PASSWORD=letmein
GRAPHQL_PORT=4000
GRAPHQL_URI=http://localhost:4000
CLIENT_URI=http://localhost:3000
SMTP_HOST=
@ -17,3 +16,4 @@ PRIVATE_KEY_PASSPHRASE="a7dsf78sadg87ad87sfagsadg78"
SENTRY_DSN_BACKEND=
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)"
EXPOSE 4000
@ -24,4 +24,5 @@ FROM base as production
ENV NODE_ENV=production
COPY --from=build-and-test /nitro-backend/dist ./dist
COPY ./public/img/ ./public/img/
COPY ./public/providers.json ./public/providers.json
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)
### 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
@ -72,6 +93,8 @@ To reset the database run:
$ docker-compose exec backend yarn run db:reset
# you could also wipe out your neo4j database and delete all volumes with:
$ 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 %}
@ -88,6 +111,37 @@ $ yarn run db:reset
{% endtab %}
{% 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

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",
"version": "0.0.1",
"version": "0.2.2",
"description": "GraphQL Backend for Human Connection",
"main": "src/index.js",
"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/",
"build": "babel src/ -d dist/ --copy-files",
"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",
"jest": "jest --forceExit --detectOpenHandles --runInBand",
"test": "run-s test:jest test:cucumber",
"test:before:server": "cross-env GRAPHQL_URI=http://localhost:4123 GRAPHQL_PORT=4123 yarn run dev 2> /dev/null",
"test:before:seeder": "cross-env GRAPHQL_URI=http://localhost:4001 GRAPHQL_PORT=4001 DISABLED_MIDDLEWARES=permissions,activityPub yarn run dev 2> /dev/null",
"test:jest:cmd": "wait-on tcp:4001 tcp:4123 && jest --forceExit --detectOpenHandles --runInBand",
"test:cucumber:cmd": "wait-on tcp:4001 tcp:4123 && cucumber-js --require-module @babel/register --exit test/",
"test:jest:cmd:debug": "wait-on tcp:4001 tcp:4123 && node --inspect-brk ./node_modules/.bin/jest -i --forceExit --detectOpenHandles --runInBand",
"test:jest": "run-p --race test:before:* \"test:jest:cmd {@}\" --",
"test:cucumber": " 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"
"test": "jest --forceExit --detectOpenHandles --runInBand",
"db:clean": "babel-node src/db/clean.js",
"db:reset": "yarn run db:clean",
"db:seed": "babel-node src/db/seed.js",
"db:migrate": "yarn run __migrate --store ./src/db/migrate/store.js",
"db:migrate:create": "yarn run __migrate --template-file ./src/db/migrate/template.js --date-format 'yyyymmddHHmmss' create"
},
"author": "Human Connection gGmbH",
"license": "MIT",
@ -41,94 +37,96 @@
]
},
"dependencies": {
"@hapi/joi": "^16.0.1",
"@sentry/node": "^5.6.2",
"activitystrea.ms": "~2.1.3",
"apollo-cache-inmemory": "~1.6.3",
"apollo-client": "~2.6.4",
"@hapi/joi": "^17.1.0",
"@sentry/node": "^5.11.1",
"apollo-cache-inmemory": "~1.6.5",
"apollo-client": "~2.6.8",
"apollo-link-context": "~1.0.19",
"apollo-link-http": "~1.5.16",
"apollo-server": "~2.9.3",
"apollo-server-express": "^2.9.0",
"apollo-server": "~2.9.16",
"apollo-server-express": "^2.9.16",
"babel-plugin-transform-runtime": "^6.23.0",
"bcryptjs": "~2.4.3",
"cheerio": "~1.0.0-rc.3",
"cors": "~2.8.5",
"cross-env": "~5.2.1",
"date-fns": "2.1.0",
"cross-env": "~7.0.0",
"date-fns": "2.9.0",
"debug": "~4.1.1",
"dotenv": "~8.1.0",
"dotenv": "~8.2.0",
"express": "^4.17.1",
"faker": "Marak/faker.js#master",
"graphql": "^14.5.4",
"graphql": "^14.6.0",
"graphql-custom-directives": "~0.2.14",
"graphql-iso-date": "~3.6.1",
"graphql-middleware": "~3.0.5",
"graphql-middleware-sentry": "^3.2.0",
"graphql-shield": "~6.1.0",
"graphql-middleware": "~4.0.2",
"graphql-middleware-sentry": "^3.2.1",
"graphql-shield": "~7.0.8",
"graphql-tag": "~2.10.1",
"helmet": "~3.21.0",
"helmet": "~3.21.2",
"jsonwebtoken": "~8.5.1",
"linkifyjs": "~2.1.8",
"lodash": "~4.17.14",
"merge-graphql-schemas": "^1.7.0",
"metascraper": "^4.10.3",
"metascraper-audio": "^5.6.5",
"metascraper-author": "^5.6.5",
"merge-graphql-schemas": "^1.7.6",
"metascraper": "^5.10.6",
"metascraper-audio": "^5.10.6",
"metascraper-author": "^5.10.6",
"metascraper-clearbit-logo": "^5.3.0",
"metascraper-date": "^5.7.0",
"metascraper-description": "^5.7.4",
"metascraper-image": "^5.6.5",
"metascraper-lang": "^5.6.5",
"metascraper-lang-detector": "^4.8.5",
"metascraper-logo": "^5.7.4",
"metascraper-publisher": "^5.6.5",
"metascraper-soundcloud": "^5.6.7",
"metascraper-title": "^5.7.0",
"metascraper-url": "^5.7.4",
"metascraper-video": "^5.6.5",
"metascraper-youtube": "^5.7.4",
"metascraper-date": "^5.10.6",
"metascraper-description": "^5.10.6",
"metascraper-image": "^5.10.6",
"metascraper-lang": "^5.10.6",
"metascraper-lang-detector": "^4.10.2",
"metascraper-logo": "^5.10.6",
"metascraper-publisher": "^5.10.6",
"metascraper-soundcloud": "^5.10.6",
"metascraper-title": "^5.10.6",
"metascraper-url": "^5.10.6",
"metascraper-video": "^5.10.6",
"metascraper-youtube": "^5.10.6",
"migrate": "^1.6.2",
"minimatch": "^3.0.4",
"neo4j-driver": "~1.7.6",
"neo4j-graphql-js": "^2.7.2",
"neode": "^0.3.3",
"mustache": "^4.0.0",
"neo4j-driver": "^4.0.1",
"neo4j-graphql-js": "^2.11.5",
"neode": "^0.3.7",
"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",
"request": "~2.88.0",
"sanitize-html": "~1.20.1",
"slug": "~1.1.0",
"sanitize-html": "~1.21.1",
"slug": "~2.1.1",
"trunc-html": "~1.1.2",
"uuid": "~3.3.3",
"xregexp": "^4.2.4",
"wait-on": "~3.3.0"
"uuid": "~3.4.0",
"validator": "^12.2.0",
"wait-on": "~4.0.0",
"xregexp": "^4.2.4"
},
"devDependencies": {
"@babel/cli": "~7.6.0",
"@babel/core": "~7.6.0",
"@babel/node": "~7.6.1",
"@babel/plugin-proposal-throw-expressions": "^7.2.0",
"@babel/preset-env": "~7.6.0",
"@babel/register": "~7.6.0",
"apollo-server-testing": "~2.9.3",
"@babel/cli": "~7.8.3",
"@babel/core": "~7.8.3",
"@babel/node": "~7.8.3",
"@babel/plugin-proposal-throw-expressions": "^7.8.3",
"@babel/preset-env": "~7.8.3",
"@babel/register": "^7.8.3",
"apollo-server-testing": "~2.9.16",
"babel-core": "~7.0.0-0",
"babel-eslint": "~10.0.3",
"babel-jest": "~24.9.0",
"babel-jest": "~25.1.0",
"chai": "~4.2.0",
"cucumber": "~5.1.0",
"eslint": "~6.3.0",
"eslint-config-prettier": "~6.3.0",
"cucumber": "~6.0.5",
"eslint": "~6.8.0",
"eslint-config-prettier": "~6.9.0",
"eslint-config-standard": "~14.1.0",
"eslint-plugin-import": "~2.18.2",
"eslint-plugin-jest": "~22.17.0",
"eslint-plugin-node": "~10.0.0",
"eslint-plugin-prettier": "~3.1.0",
"eslint-plugin-import": "~2.20.0",
"eslint-plugin-jest": "~23.6.0",
"eslint-plugin-node": "~11.0.0",
"eslint-plugin-prettier": "~3.1.2",
"eslint-plugin-promise": "~4.2.1",
"eslint-plugin-standard": "~4.0.1",
"graphql-request": "~1.8.2",
"jest": "~24.9.0",
"nodemon": "~1.19.2",
"prettier": "~1.18.2",
"jest": "~25.1.0",
"nodemon": "~2.0.2",
"prettier": "~1.19.1",
"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 { isPublicAddressed, sendAcceptActivity, sendRejectActivity } from './utils/activity'
// import { extractDomainFromUrl, signAndSend } from './utils'
import { extractNameFromId, signAndSend } from './utils'
import { isPublicAddressed } from './utils/activity'
// import { isPublicAddressed, sendAcceptActivity, sendRejectActivity } from './utils/activity'
import request from 'request'
import as from 'activitystrea.ms'
// import as from 'activitystrea.ms'
import NitroDataSource from './NitroDataSource'
import router from './routes'
import Collections from './Collections'
@ -33,71 +35,71 @@ export default class ActivityPub {
}
}
handleFollowActivity(activity) {
debug(`inside FOLLOW ${activity.actor}`)
const toActorName = extractNameFromId(activity.object)
const fromDomain = extractDomainFromUrl(activity.actor)
const dataSource = this.dataSource
// handleFollowActivity(activity) {
// debug(`inside FOLLOW ${activity.actor}`)
// const toActorName = extractNameFromId(activity.object)
// const fromDomain = extractDomainFromUrl(activity.actor)
// const dataSource = this.dataSource
return new Promise((resolve, reject) => {
request(
{
url: activity.actor,
headers: {
Accept: 'application/activity+json',
},
},
async (err, response, toActorObject) => {
if (err) return reject(err)
// save shared inbox
toActorObject = JSON.parse(toActorObject)
await this.dataSource.addSharedInboxEndpoint(toActorObject.endpoints.sharedInbox)
// return new Promise((resolve, reject) => {
// request(
// {
// url: activity.actor,
// headers: {
// Accept: 'application/activity+json',
// },
// },
// async (err, response, toActorObject) => {
// if (err) return reject(err)
// // save shared inbox
// toActorObject = JSON.parse(toActorObject)
// await this.dataSource.addSharedInboxEndpoint(toActorObject.endpoints.sharedInbox)
const followersCollectionPage = await this.dataSource.getFollowersCollectionPage(
activity.object,
)
// const followersCollectionPage = await this.dataSource.getFollowersCollectionPage(
// activity.object,
// )
const followActivity = as
.follow()
.id(activity.id)
.actor(activity.actor)
.object(activity.object)
// const followActivity = as
// .follow()
// .id(activity.id)
// .actor(activity.actor)
// .object(activity.object)
// add follower if not already in collection
if (followersCollectionPage.orderedItems.includes(activity.actor)) {
debug('follower already in collection!')
debug(`inbox = ${toActorObject.inbox}`)
resolve(
sendRejectActivity(followActivity, toActorName, fromDomain, toActorObject.inbox),
)
} else {
followersCollectionPage.orderedItems.push(activity.actor)
}
debug(`toActorObject = ${toActorObject}`)
toActorObject =
typeof toActorObject !== 'object' ? JSON.parse(toActorObject) : toActorObject
debug(`followers = ${JSON.stringify(followersCollectionPage.orderedItems, null, 2)}`)
debug(`inbox = ${toActorObject.inbox}`)
debug(`outbox = ${toActorObject.outbox}`)
debug(`followers = ${toActorObject.followers}`)
debug(`following = ${toActorObject.following}`)
// // add follower if not already in collection
// if (followersCollectionPage.orderedItems.includes(activity.actor)) {
// debug('follower already in collection!')
// debug(`inbox = ${toActorObject.inbox}`)
// resolve(
// sendRejectActivity(followActivity, toActorName, fromDomain, toActorObject.inbox),
// )
// } else {
// followersCollectionPage.orderedItems.push(activity.actor)
// }
// debug(`toActorObject = ${toActorObject}`)
// toActorObject =
// typeof toActorObject !== 'object' ? JSON.parse(toActorObject) : toActorObject
// debug(`followers = ${JSON.stringify(followersCollectionPage.orderedItems, null, 2)}`)
// debug(`inbox = ${toActorObject.inbox}`)
// debug(`outbox = ${toActorObject.outbox}`)
// debug(`followers = ${toActorObject.followers}`)
// debug(`following = ${toActorObject.following}`)
try {
await dataSource.saveFollowersCollectionPage(followersCollectionPage)
debug('follow activity saved')
resolve(
sendAcceptActivity(followActivity, toActorName, fromDomain, toActorObject.inbox),
)
} catch (e) {
debug('followers update error!', e)
resolve(
sendRejectActivity(followActivity, toActorName, fromDomain, toActorObject.inbox),
)
}
},
)
})
}
// try {
// await dataSource.saveFollowersCollectionPage(followersCollectionPage)
// debug('follow activity saved')
// resolve(
// sendAcceptActivity(followActivity, toActorName, fromDomain, toActorObject.inbox),
// )
// } catch (e) {
// debug('followers update error!', e)
// resolve(
// sendRejectActivity(followActivity, toActorName, fromDomain, toActorObject.inbox),
// )
// }
// },
// )
// })
// }
handleUndoActivity(activity) {
debug('inside UNDO')

View File

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

View File

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

View File

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

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 { signAndSend, throwErrorIfApolloErrorOccurred } from './index'
import { throwErrorIfApolloErrorOccurred } from './index'
// import { signAndSend, throwErrorIfApolloErrorOccurred } from './index'
import crypto from 'crypto'
import as from 'activitystrea.ms'
// import as from 'activitystrea.ms'
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) {
const createUuid = crypto.randomBytes(16).toString('hex')
@ -62,41 +63,41 @@ export async function getActorId(name) {
}
}
export function sendAcceptActivity(theBody, name, targetDomain, url) {
as.accept()
.id(
`${activityPub.endpoint}/activitypub/users/${name}/status/` +
crypto.randomBytes(16).toString('hex'),
)
.actor(`${activityPub.endpoint}/activitypub/users/${name}`)
.object(theBody)
.prettyWrite((err, doc) => {
if (!err) {
return signAndSend(doc, name, targetDomain, url)
} else {
debug(`error serializing Accept object: ${err}`)
throw new Error('error serializing Accept object')
}
})
}
// export function sendAcceptActivity(theBody, name, targetDomain, url) {
// as.accept()
// .id(
// `${activityPub.endpoint}/activitypub/users/${name}/status/` +
// crypto.randomBytes(16).toString('hex'),
// )
// .actor(`${activityPub.endpoint}/activitypub/users/${name}`)
// .object(theBody)
// .prettyWrite((err, doc) => {
// if (!err) {
// return signAndSend(doc, name, targetDomain, url)
// } else {
// debug(`error serializing Accept object: ${err}`)
// throw new Error('error serializing Accept object')
// }
// })
// }
export function sendRejectActivity(theBody, name, targetDomain, url) {
as.reject()
.id(
`${activityPub.endpoint}/activitypub/users/${name}/status/` +
crypto.randomBytes(16).toString('hex'),
)
.actor(`${activityPub.endpoint}/activitypub/users/${name}`)
.object(theBody)
.prettyWrite((err, doc) => {
if (!err) {
return signAndSend(doc, name, targetDomain, url)
} else {
debug(`error serializing Accept object: ${err}`)
throw new Error('error serializing Accept object')
}
})
}
// export function sendRejectActivity(theBody, name, targetDomain, url) {
// as.reject()
// .id(
// `${activityPub.endpoint}/activitypub/users/${name}/status/` +
// crypto.randomBytes(16).toString('hex'),
// )
// .actor(`${activityPub.endpoint}/activitypub/users/${name}`)
// .object(theBody)
// .prettyWrite((err, doc) => {
// if (!err) {
// return signAndSend(doc, name, targetDomain, url)
// } else {
// debug(`error serializing Accept object: ${err}`)
// throw new Error('error serializing Accept object')
// }
// })
// }
export function isPublicAddressed(postObject) {
if (typeof postObject.to === 'string') {

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

View File

@ -1,17 +1,18 @@
import faker from 'faker'
import uuid from 'uuid/v4'
export default function create() {
return {
factory: async ({ args, neodeInstance }) => {
const defaults = {
email: faker.internet.email(),
verifiedAt: new Date().toISOString(),
id: uuid(),
goal: 15000,
progress: 0,
}
args = {
...defaults,
...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',
deleted: false,
categoryIds: [],
imageBlurred: false,
imageAspectRatio: 1.333,
pinned: null,
}
args = {
...defaults,
...args,
}
// Convert false to null
args.pinned = args.pinned || null
args.slug = args.slug || slugify(args.title, { lower: true })
args.contentExcerpt = args.contentExcerpt || args.content
@ -34,7 +40,6 @@ export default function create() {
if (categoryIds)
categories = await Promise.all(categoryIds.map(id => neodeInstance.find('Category', id)))
categories = categories || (await Promise.all([factoryInstance.create('Category')]))
const { tagIds = [] } = args
delete args.tags
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 (authorId) author = await neodeInstance.find('User', authorId)
author = author || (await factoryInstance.create('User'))
const post = await neodeInstance.create('Post', args)
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(tags.map(t => t.relateTo(post, '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 uuid from 'uuid/v4'
import encryptPassword from '../../helpers/encryptPassword'
import encryptPassword from '../helpers/encryptPassword'
import slugify from 'slug'
export default function create() {
@ -16,6 +16,9 @@ export default function create() {
about: faker.lorem.paragraph(),
termsAndConditionsAgreedVersion: '0.0.1',
termsAndConditionsAgreedAt: '2019-08-01T10:47:19.212Z',
allowEmbedIframes: false,
showShoutsPublicly: false,
locale: 'en',
}
defaults.slug = slugify(defaults.name, { lower: true })
args = {
@ -24,7 +27,15 @@ export default function create() {
}
args = await encryptPassword(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 email.relateTo(user, 'belongsTo')
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'
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 */
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
}
const session = driver.session()
const query = `
MATCH (user:User {id: $id, deleted: false, disabled: false })
RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId}
LIMIT 1
`
const result = await session.run(query, { id })
session.close()
const [currentUser] = await result.records.map(record => {
return record.get('user')
const writeTxResultPromise = session.writeTransaction(async transaction => {
const updateUserLastActiveTransactionResponse = await transaction.run(
`
MATCH (user:User {id: $id, deleted: false, disabled: false })
SET user.lastActiveAt = toString(datetime())
RETURN user {.id, .slug, .name, .avatar, .email, .role, .disabled, .actorId}
LIMIT 1
`,
{ id },
)
return updateUserLastActiveTransactionResponse.records.map(record => record.get('user'))
})
if (!currentUser) return null
return {
token,
...currentUser,
try {
const [currentUser] = await writeTxResultPromise
if (!currentUser) return null
return {
token,
...currentUser,
}
} finally {
session.close()
}
}

View File

@ -1,9 +1,10 @@
import Factory from '../seed/factories/index'
import { getDriver } from '../bootstrap/neo4j'
import Factory from '../factories/index'
import { getDriver, getNeode } from '../db/neo4j'
import decode from './decode'
const factory = Factory()
const driver = getDriver()
const neode = getNeode()
// 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', () => {
beforeEach(async () => {
await user.update({ updatedAt: new Date().toISOString(), deleted: true })
@ -92,6 +120,7 @@ describe('decode', () => {
it('returns null', returnsNull)
})
describe('but user is disabled', () => {
beforeEach(async () => {
await user.update({ updatedAt: new Date().toISOString(), disabled: true })

View File

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

View File

@ -1,36 +1,45 @@
import CONFIG from '../../config'
import nodemailer from 'nodemailer'
import { resetPasswordMail, wrongAccountMail } from './templates/passwordReset'
import { signupTemplate } from './templates/signup'
import { htmlToText } from 'nodemailer-html-to-text'
import {
signupTemplate,
resetPasswordTemplate,
wrongAccountTemplate,
emailVerificationTemplate,
} from './templateBuilder'
let sendMail
if (CONFIG.SMTP_HOST && CONFIG.SMTP_PORT) {
sendMail = async templateArgs => {
await transporter().sendMail({
from: '"Human Connection" <info@human-connection.org>',
...templateArgs,
})
}
} else {
sendMail = () => {}
const hasEmailConfig = CONFIG.SMTP_HOST && CONFIG.SMTP_PORT
const hasAuthData = CONFIG.SMTP_USERNAME && CONFIG.SMTP_PASSWORD
let sendMail = () => {}
if (!hasEmailConfig) {
if (process.env.NODE_ENV !== 'test') {
// eslint-disable-next-line no-console
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 = () => {
const configs = {
host: CONFIG.SMTP_HOST,
port: CONFIG.SMTP_PORT,
ignoreTLS: CONFIG.SMTP_IGNORE_TLS,
secure: false, // true for 465, false for other ports
transporter.use(
'compile',
htmlToText({
ignoreImage: true,
wordwrap: false,
}),
)
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) => {
@ -41,15 +50,26 @@ const sendSignupMail = async (resolve, root, args, context, resolveInfo) => {
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 {
Mutation: {
requestPasswordReset: async (resolve, root, args, context, resolveInfo) => {
const { email } = args
const { email: emailFound, nonce, name } = await resolve(root, args, context, resolveInfo)
const mailTemplate = emailFound ? resetPasswordMail : wrongAccountMail
await sendMail(mailTemplate({ email, nonce, name }))
return true
},
AddEmailAddress: sendEmailVerificationMail,
requestPasswordReset: sendPasswordResetMail,
Signup: 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
// here:
// 0. Search for whole string.
// 1. Hashtag has only all unicode characters and '0-9'.
// 2. If it starts with a digit '0-9' than a unicode character has to follow.
const regX = build('^/search/hashtag/((\\pL+[\\pL0-9]*)|([0-9]+\\pL+[\\pL0-9]*))$')
// 1. Hashtag has only all unicode letters and '0-9'.
// 2. If it starts with a digit '0-9' than a unicode letter has to follow.
const regX = build('^((\\pL+[\\pL0-9]*)|([0-9]+\\pL+[\\pL0-9]*))$')
export default function(content) {
if (!content) return []
const $ = cheerio.load(content)
// 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.
const urls = $('a')
const ids = $('a[data-hashtag-id]')
.map((_, el) => {
return $(el).attr('href')
return $(el).attr('data-hashtag-id')
})
.get()
const hashtags = []
urls.forEach(url => {
const match = exec(url, regX)
ids.forEach(id => {
const match = exec(id, regX)
if (match != null) {
hashtags.push(match[1])
}

View File

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

View File

@ -2,31 +2,27 @@ import extractHashtags from '../hashtags/extractHashtags'
const updateHashtagsOfPost = async (postId, hashtags, context) => {
if (!hashtags.length) return
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
// and no new Hashtags and relations will be created.
const cypherDeletePreviousRelations = `
MATCH (p: Post { id: $postId })-[previousRelations: TAGGED]->(t: Tag)
DELETE previousRelations
RETURN p, t
`
const cypherCreateNewTagsAndRelations = `
MATCH (p: Post { id: $postId})
UNWIND $hashtags AS tagName
MERGE (t: Tag { id: tagName, disabled: false, deleted: false })
MERGE (p)-[:TAGGED]->(t)
RETURN p, t
`
await session.run(cypherDeletePreviousRelations, {
postId,
})
await session.run(cypherCreateNewTagsAndRelations, {
postId,
hashtags,
})
session.close()
try {
await session.writeTransaction(txc => {
return txc.run(
`
MATCH (post:Post { id: $postId})
OPTIONAL MATCH (post)-[previousRelations:TAGGED]->(tag:Tag)
DELETE previousRelations
WITH post
UNWIND $hashtags AS tagName
MERGE (tag:Tag {id: tagName, disabled: false, deleted: false })
MERGE (post)-[:TAGGED]->(tag)
RETURN post, tag
`,
{ postId, hashtags },
)
})
} finally {
session.close()
}
}
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {

View File

@ -1,7 +1,7 @@
import { gql } from '../../jest/helpers'
import Factory from '../../seed/factories'
import { gql } from '../../helpers/jest'
import Factory from '../../factories'
import { createTestClient } from 'apollo-server-testing'
import { neode, getDriver } from '../../bootstrap/neo4j'
import { getNeode, getDriver } from '../../db/neo4j'
import createServer from '../../server'
let server
@ -11,7 +11,7 @@ let hashtagingUser
let authenticatedUser
const factory = Factory()
const driver = getDriver()
const instance = neode()
const neode = getNeode()
const categoryIds = ['cat9']
const createPostMutation = gql`
mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) {
@ -36,7 +36,7 @@ beforeAll(() => {
context: () => {
return {
user: authenticatedUser,
neode: instance,
neode,
driver,
}
},
@ -48,14 +48,14 @@ beforeAll(() => {
})
beforeEach(async () => {
hashtagingUser = await instance.create('User', {
hashtagingUser = await neode.create('User', {
id: 'you',
name: 'Al Capone',
slug: 'al-capone',
email: 'test@example.org',
password: '1234',
})
await instance.create('Category', {
await neode.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
@ -69,8 +69,27 @@ afterEach(async () => {
describe('hashtags', () => {
const id = 'p135'
const title = 'Two Hashtags'
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>'
const postContent = `
<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`
query($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', () => {
// The already existing Hashtag has no class at this point.
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>'
describe('updates the Post by removing, keeping and adding one hashtag respectively', () => {
// The already existing hashtag has no class at this point.
const postContent = `
<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 () => {
await mutate({

View File

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

View File

@ -1,135 +1,121 @@
import extractMentionedUsers from './mentions/extractMentionedUsers'
import { validateNotifyUsers } from '../validation/validationMiddleware'
const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
if (!idsOfUsers.length) return
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
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 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 handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => {
const { content } = args
let idsOfUsers = extractMentionedUsers(content)
const comment = await resolve(root, args, context, resolveInfo)
const [postAuthor] = await postAuthorOfComment(comment.id, { context })
idsOfUsers = idsOfUsers.filter(id => id !== postAuthor.id)
if (idsOfUsers && idsOfUsers.length)
await notifyUsersOfMention('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context)
if (context.user.id !== postAuthor.id)
await notifyUsersOfComment('Comment', comment.id, postAuthor.id, 'commented_on_post', context)
return comment
}
const postAuthorOfComment = async (commentId, { context }) => {
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) {
case 'mentioned_in_post': {
cypher = `
mentionedCypher = `
MATCH (post: Post { id: $id })<-[:WROTE]-(author: User)
MATCH (user: User)
WHERE user.id in $idsOfUsers
AND NOT (user)<-[:BLOCKED]-(author)
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
}
case 'mentioned_in_comment': {
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 (user)<-[:BLOCKED]-(postAuthor)
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())
mentionedCypher = `
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 (user)<-[:BLOCKED]-(postAuthor)
MERGE (comment)-[notification:NOTIFIED {reason: $reason}]->(user)
`
break
}
}
await session.run(cypher, {
id,
idsOfUsers,
reason,
})
session.close()
}
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
const idsOfUsers = extractMentionedUsers(args.content)
const post = await resolve(root, args, context, resolveInfo)
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,
mentionedCypher += `
SET notification.read = FALSE
SET (
CASE
WHEN notification.createdAt IS NULL
THEN notification END ).createdAt = toString(datetime())
SET notification.updatedAt = toString(datetime())
`
const session = context.driver.session()
try {
await session.writeTransaction(transaction => {
return transaction.run(mentionedCypher, { id, idsOfUsers, reason })
})
} finally {
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 {
Mutation: {
CreatePost: handleContentDataOfPost,
UpdatePost: handleContentDataOfPost,
CreateComment: handleCreateComment,
CreateComment: handleContentDataOfComment,
UpdateComment: handleContentDataOfComment,
},
}

View File

@ -1,17 +1,13 @@
import { gql } from '../../jest/helpers'
import Factory from '../../seed/factories'
import { gql } from '../../helpers/jest'
import Factory from '../../factories'
import { createTestClient } from 'apollo-server-testing'
import { neode, getDriver } from '../../bootstrap/neo4j'
import { getNeode, getDriver } from '../../db/neo4j'
import createServer from '../../server'
let server
let query
let mutate
let notifiedUser
let authenticatedUser
let server, query, mutate, notifiedUser, authenticatedUser
const factory = Factory()
const driver = getDriver()
const instance = neode()
const neode = getNeode()
const categoryIds = ['cat9']
const createPostMutation = gql`
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({
context: () => {
return {
user: authenticatedUser,
neode: instance,
neode: neode,
driver,
}
},
@ -56,14 +53,14 @@ beforeAll(() => {
})
beforeEach(async () => {
notifiedUser = await instance.create('User', {
notifiedUser = await neode.create('User', {
id: 'you',
name: 'Al Capone',
slug: 'al-capone',
email: 'test@example.org',
password: '1234',
})
await instance.create('Category', {
await neode.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
@ -105,6 +102,7 @@ describe('notifications', () => {
let title
let postContent
let postAuthor
const createPostAction = async () => {
authenticatedUser = await postAuthor.toJson()
await mutate({
@ -145,7 +143,7 @@ describe('notifications', () => {
describe('commenter is not me', () => {
beforeEach(async () => {
commentContent = 'Commenters comment.'
commentAuthor = await instance.create('User', {
commentAuthor = await neode.create('User', {
id: 'commentAuthor',
name: 'Mrs Comment',
slug: 'mrs-comment',
@ -172,7 +170,6 @@ describe('notifications', () => {
],
},
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
@ -189,7 +186,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({
data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
@ -213,7 +210,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({
data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
@ -227,7 +224,7 @@ describe('notifications', () => {
})
beforeEach(async () => {
postAuthor = await instance.create('User', {
postAuthor = await neode.create('User', {
id: 'postAuthor',
name: 'Mrs Post',
slug: 'mrs-post',
@ -239,6 +236,7 @@ describe('notifications', () => {
describe('mentions me in a post', () => {
beforeEach(async () => {
title = 'Mentioning Al Capone'
postContent =
'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(
query({
query: notificationQuery,
@ -369,7 +367,7 @@ describe('notifications', () => {
expect(readAfter).toEqual(false)
})
it('updates the `createdAt` attribute', async () => {
it('does not update the `createdAt` attribute', async () => {
await createPostAction()
await markAsReadAction()
const {
@ -407,7 +405,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({
data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,
@ -430,7 +428,7 @@ describe('notifications', () => {
beforeEach(async () => {
commentContent =
'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',
name: '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()
const expected = expect.objectContaining({
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(
query({
query: notificationQuery,
@ -474,7 +514,7 @@ describe('notifications', () => {
await postAuthor.relateTo(notifiedUser, 'blocked')
commentContent =
'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',
name: 'Mrs Comment',
slug: 'mrs-comment',
@ -488,7 +528,7 @@ describe('notifications', () => {
const expected = expect.objectContaining({
data: { notifications: [] },
})
const { query } = createTestClient(server)
await expect(
query({
query: notificationQuery,

View File

@ -1,6 +1,6 @@
import Factory from '../seed/factories'
import { gql } from '../jest/helpers'
import { neode as getNeode, getDriver } from '../bootstrap/neo4j'
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'

View File

@ -1,11 +1,11 @@
import { rule, shield, deny, allow, and, or, not } from 'graphql-shield'
import { neode } from '../bootstrap/neo4j'
import { rule, shield, deny, allow, or } from 'graphql-shield'
import { getNeode } from '../db/neo4j'
import CONFIG from '../config'
const debug = !!CONFIG.DEBUG
const allowExternalErrors = true
const instance = neode()
const neode = getNeode()
const isAuthenticated = rule({
cache: 'contextual',
@ -36,67 +36,33 @@ const isMyOwn = rule({
const isMySocialMedia = rule({
cache: 'no_cache',
})(async (_, args, { user }) => {
let socialMedia = await instance.find('SocialMedia', args.id)
let socialMedia = await neode.find('SocialMedia', args.id)
socialMedia = await socialMedia.toJson()
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({
cache: 'no_cache',
})(async (_parent, args, { user, driver }) => {
if (!user) return false
const session = driver.session()
const { id: resourceId } = args
const result = await session.run(
`
MATCH (resource {id: $resourceId})<-[:WROTE]-(author)
RETURN author
`,
{
resourceId,
},
)
session.close()
const [author] = result.records.map(record => {
return record.get('author')
const session = driver.session()
const authorReadTxPromise = session.readTransaction(async transaction => {
const authorTransactionResponse = await transaction.run(
`
MATCH (resource {id: $resourceId})<-[:WROTE]-(author {id: $userId})
RETURN author
`,
{ resourceId, userId: user.id },
)
return authorTransactionResponse.records.map(record => record.get('author'))
})
const authorId = author && author.properties && author.properties.id
return authorId === user.id
try {
const [author] = await authorReadTxPromise
return !!author
} finally {
session.close()
}
})
const isDeletingOwnAccount = rule({
@ -111,40 +77,45 @@ const noEmailFilter = rule({
return !('email' in args)
})
const publicRegistration = rule()(() => !!CONFIG.PUBLIC_REGISTRATION)
// Permissions
const permissions = shield(
export default shield(
{
Query: {
'*': deny,
findPosts: allow,
findUsers: allow,
findResources: allow,
embed: allow,
Category: allow,
Tag: allow,
Report: isModerator,
reports: isModerator,
statistics: allow,
currentUser: allow,
Post: or(onlyEnabledContent, isModerator),
Post: allow,
profilePagePosts: allow,
Comment: allow,
User: or(noEmailFilter, isAdmin),
isLoggedIn: allow,
Badge: allow,
PostsEmotionsCountByEmotion: allow,
PostsEmotionsByCurrentUser: isAuthenticated,
blockedUsers: isAuthenticated,
mutedUsers: isAuthenticated,
notifications: isAuthenticated,
Donations: isAuthenticated,
},
Mutation: {
'*': deny,
login: allow,
SignupByInvitation: allow,
Signup: isAdmin,
Signup: or(publicRegistration, isAdmin),
SignupVerification: allow,
CreateInvitationCode: and(isAuthenticated, or(not(invitationLimitReached), isAdmin)),
UpdateUser: onlyYourself,
CreatePost: isAuthenticated,
UpdatePost: isAuthor,
DeletePost: isAuthor,
report: isAuthenticated,
fileReport: isAuthenticated,
CreateSocialMedia: isAuthenticated,
UpdateSocialMedia: isMySocialMedia,
DeleteSocialMedia: isMySocialMedia,
@ -152,13 +123,12 @@ const permissions = shield(
// RemoveBadgeRewarded: isAdmin,
reward: isAdmin,
unreward: isAdmin,
follow: isAuthenticated,
unfollow: isAuthenticated,
followUser: isAuthenticated,
unfollowUser: isAuthenticated,
shout: isAuthenticated,
unshout: isAuthenticated,
changePassword: isAuthenticated,
enable: isModerator,
disable: isModerator,
review: isModerator,
CreateComment: isAuthenticated,
UpdateComment: isAuthor,
DeleteComment: isAuthor,
@ -167,12 +137,17 @@ const permissions = shield(
resetPassword: allow,
AddPostEmotions: isAuthenticated,
RemovePostEmotions: isAuthenticated,
block: isAuthenticated,
unblock: isAuthenticated,
muteUser: isAuthenticated,
unmuteUser: isAuthenticated,
markAsRead: isAuthenticated,
AddEmailAddress: isAuthenticated,
VerifyEmailAddress: isAuthenticated,
pinPost: isAdmin,
unpinPost: isAdmin,
UpdateDonations: isAdmin,
},
User: {
email: isMyOwn,
email: or(isMyOwn, isAdmin),
},
},
{
@ -181,5 +156,3 @@ const permissions = shield(
fallbackRule: allow,
},
)
export default permissions

View File

@ -1,22 +1,63 @@
import { GraphQLClient } from 'graphql-request'
import Factory from '../seed/factories'
import { host, login } from '../jest/helpers'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../server'
import Factory from '../factories'
import { gql } from '../helpers/jest'
import { getDriver, getNeode } from '../db/neo4j'
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', () => {
beforeAll(async () => {
await factory.cleanDatabase()
const { server } = createServer({
context: () => ({
driver,
instance,
user: authenticatedUser,
}),
})
query = createTestClient(server).query
})
describe('given two existing users', () => {
beforeEach(async () => {
await factory.create('User', {
email: 'owner@example.org',
name: 'Owner',
password: 'iamtheowner',
})
await factory.create('User', {
email: 'someone@example.org',
name: 'Someone else',
password: 'else',
})
;[owner, anotherRegularUser, administrator, moderator] = await Promise.all([
factory.create('User', {
email: 'owner@example.org',
name: 'Owner',
password: 'iamtheowner',
}),
factory.create('User', {
email: 'another.regular.user@example.org',
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 () => {
@ -24,66 +65,77 @@ describe('authorization', () => {
})
describe('access email address', () => {
let headers = {}
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', () => {
describe('unauthenticated', () => {
beforeEach(() => {
loginCredentials = {
email: 'owner@example.org',
password: 'iamtheowner',
}
authenticatedUser = null
})
it("exposes the owner's email address", async () => {
await expect(action()).resolves.toEqual({ User: [{ email: 'owner@example.org' }] })
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] },
})
})
})
describe('authenticated as another user', () => {
beforeEach(async () => {
loginCredentials = {
email: 'someone@example.org',
password: 'else',
}
describe('authenticated', () => {
describe('as the owner', () => {
beforeEach(async () => {
authenticatedUser = await owner.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,
})
})
})
it('rejects', async () => {
await expect(action()).rejects.toThrow('Not Authorised!')
describe('as another regular user', () => {
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 () => {
let response
try {
await action()
} catch (error) {
response = error.response.data
}
expect(response).toEqual({ User: [null] })
describe('as a moderator', () => {
beforeEach(async () => {
authenticatedUser = await moderator.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] },
})
})
})
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) => {
return async slug => {
const session = context.driver.session()
const response = await session.run(`MATCH(p:${type} {slug: $slug }) return p.slug`, {
slug,
})
session.close()
return response.records.length === 0
try {
const existingSlug = await session.readTransaction(transaction => {
return transaction.run(
`
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')))
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 { gql } from '../jest/helpers'
import { neode as getNeode, getDriver } from '../bootstrap/neo4j'
import Factory from '../factories'
import { gql } from '../helpers/jest'
import { getNeode, getDriver } from '../db/neo4j'
import createServer from '../server'
import { createTestClient } from 'apollo-server-testing'

View File

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

View File

@ -1,6 +1,6 @@
import Factory from '../../seed/factories'
import { gql } from '../../jest/helpers'
import { neode as getNeode, getDriver } from '../../bootstrap/neo4j'
import Factory from '../../factories'
import { gql } from '../../helpers/jest'
import { getNeode, getDriver } from '../../db/neo4j'
import createServer from '../../server'
import { createTestClient } from 'apollo-server-testing'
@ -8,14 +8,8 @@ const factory = Factory()
const neode = getNeode()
const driver = getDriver()
let query
let mutate
let graphqlQuery
const categoryIds = ['cat9']
let authenticatedUser
let user
let moderator
let troll
let query, graphqlQuery, authenticatedUser, user, moderator, troll
const action = () => {
return query({ query: graphqlQuery })
@ -38,18 +32,17 @@ beforeAll(async () => {
avatar: '/some/offensive/avatar.jpg',
about: 'This self description is very offensive',
}),
neode.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
}),
])
user = users[0]
moderator = users[1]
troll = users[2]
await neode.create('Category', {
id: 'cat9',
name: 'Democracy & Politics',
icon: 'university',
})
await Promise.all([
user.relateTo(troll, 'following'),
factory.create('Post', {
@ -70,33 +63,32 @@ beforeAll(async () => {
}),
])
await Promise.all([
const resources = await Promise.all([
factory.create('Comment', {
author: user,
id: 'c2',
postId: 'p3',
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({
context: () => {
return {
@ -108,20 +100,57 @@ beforeAll(async () => {
})
const client = createTestClient(server)
query = client.query
mutate = client.mutate
authenticatedUser = await moderator.toJson()
const disableMutation = gql`
mutation($id: ID!) {
disable(id: $id)
}
`
await Promise.all([
mutate({ mutation: disableMutation, variables: { id: 'c1' } }),
mutate({ mutation: disableMutation, variables: { id: 'u2' } }),
mutate({ mutation: disableMutation, variables: { id: 'p2' } }),
const trollingPost = resources[1]
const trollingComment = resources[2]
const reports = await Promise.all([
factory.create('Report'),
factory.create('Report'),
factory.create('Report'),
])
const reportAgainstTroll = reports[0]
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 () => {
@ -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_CATEGORIES_ERR_MESSAGE =
'You cannot save a post without at least one category or more than three'
const validateCommentCreation = async (resolve, root, args, context, info) => {
const USERNAME_MIN_LENGTH = 3
const validateCreateComment = async (resolve, root, args, context, info) => {
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
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!`)
}
const session = context.driver.session()
const postQueryRes = await session.run(
`
MATCH (post:Post {id: $postId})
RETURN post`,
{
postId,
},
)
session.close()
const [post] = postQueryRes.records.map(record => {
return record.get('post')
})
try {
const postQueryRes = await session.readTransaction(transaction => {
return transaction.run(
`
MATCH (post:Post {id: $postId})
RETURN post
`,
{ postId },
)
})
const [post] = postQueryRes.records.map(record => {
return record.get('post')
})
if (!post) {
throw new UserInputError(NO_POST_ERR_MESSAGE)
} else {
return resolve(root, args, context, info)
if (!post) {
throw new UserInputError(NO_POST_ERR_MESSAGE)
} else {
return resolve(root, args, context, info)
}
} finally {
session.close()
}
}
const validateUpdateComment = async (resolve, root, args, context, info) => {
const COMMENT_MIN_LENGTH = 1
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
if (!args.content || content.length < COMMENT_MIN_LENGTH) {
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)
}
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 {
Mutation: {
CreateComment: validateCommentCreation,
CreateComment: validateCreateComment,
UpdateComment: validateUpdateComment,
CreatePost: validatePost,
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
}
const fields = ['content', 'contentExcerpt']
const fields = ['content', 'contentExcerpt', 'reasonDescription']
export default {
Mutation: async (resolve, root, args, context, info) => {

View File

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

View File

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

View File

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

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