Initial commit

This commit is contained in:
John Lyon-Smith
2018-02-22 17:57:27 -08:00
commit e80f5490d5
196 changed files with 38982 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
coverage/
dist/
scratch/
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*

0
LICENSE Normal file
View File

14
README.md Normal file
View File

@@ -0,0 +1,14 @@
# Deighton AR Training System
This is the repository for the Deighton AR Training System.
## Development
Ensure the [snap-tools]() are installed. Then clone the repository, and from the root do:
```
snap install
snap start
```
This will start the website, server and mobile app.

Binary file not shown.

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1418px" height="932px" viewBox="0 0 1418 932" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 48.2 (47327) - http://www.bohemiancoding.com/sketch -->
<title>Deighton AR System</title>
<desc>Created with Sketch.</desc>
<defs>
<linearGradient x1="5.65288774%" y1="49.7500002%" x2="96.8479147%" y2="49.7500002%" id="linearGradient-1">
<stop stop-color="#D9D9D9" offset="0%"></stop>
<stop stop-color="#FFFFFF" offset="74.1230867%"></stop>
<stop stop-color="#D9D9D9" offset="100%"></stop>
</linearGradient>
<rect id="path-2" x="0" y="0" width="101" height="101" rx="15"></rect>
<rect id="path-3" x="347" y="600" width="732" height="305" rx="20"></rect>
<mask id="mask-4" maskContentUnits="userSpaceOnUse" maskUnits="objectBoundingBox" x="0" y="0" width="732" height="305" fill="white">
<use xlink:href="#path-3"></use>
</mask>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Deighton-AR-System">
<g id="Deighton-AR-Cloud" transform="translate(166.000000, 112.000000)">
<path d="M61.9666767,263.630536 C61.9666767,263.630536 13.8823758,266.642564 2.14585145,223.105034 C-9.59067294,179.567505 30.6421304,149.777478 30.6421304,149.777478 C30.6421304,149.777478 3.86713774,88.9148862 48.0221034,59.4026571 C92.177069,29.890428 132.137392,65.0961481 132.137392,65.0961481 C132.137392,65.0961481 146.882195,9.30099158 213.387402,0.942799034 C279.892608,-7.41539351 307.464966,42.5490795 307.464966,42.5490795 C307.464966,42.5490795 379.94436,-8.27020865 439.077164,28.4129697 C498.209969,65.0961481 476.357577,123.281621 476.357577,123.281621 C476.357577,123.281621 536.316599,151.787877 529.452639,208.995308 C522.588679,266.202739 463.005692,277.459669 463.005692,277.459669 C463.005692,277.459669 469.848467,311.731424 421.287008,332.695502 C372.72555,353.65958 324.990309,332.695502 324.990309,332.695502 C324.990309,332.695502 306.72398,353 265,353 C223.27602,353 199.633,332.695502 199.633,332.695502 C199.633,332.695502 111.211847,365.183754 73.76725,332.695502 C36.3226528,300.20725 61.9666767,263.630536 61.9666767,263.630536 Z" id="Line" stroke="#979797" stroke-width="4" stroke-linecap="square"></path>
<text id="Text" font-family="AvenirNext-Medium, Avenir Next" font-size="24" font-weight="400" fill="#888888">
<tspan x="202.816" y="89">Deighton AR Cloud</tspan>
</text>
<g id="Database" transform="translate(102.000000, 115.000000)" stroke="#979797" stroke-width="3">
<path d="M88,14 L88,84.738255 C88,92.6147997 68.3005302,99 44,99 C19.6994698,99 1.66095379e-06,91.8691275 0,84.738255 L0,14" id="Sides" fill="url(#linearGradient-1)"></path>
<ellipse id="Top" fill="#D9D9D9" cx="44" cy="14" rx="44" ry="14"></ellipse>
</g>
<g id="Group" transform="translate(259.000000, 202.000000)">
<g id="Rectangle">
<use fill="#D8D8D8" fill-rule="evenodd" xlink:href="#path-2"></use>
<rect stroke="#979797" stroke-width="3" x="1.5" y="1.5" width="98" height="98" rx="15"></rect>
</g>
<text id="api-+-static" font-family="AvenirNext-Medium, Avenir Next" font-size="20" font-weight="400" fill="#888888">
<tspan x="36.65" y="30">api</tspan>
<tspan x="44.34" y="57">+</tspan>
<tspan x="27.13" y="84">static</tspan>
</text>
</g>
</g>
<g id="Google-Cloud" transform="translate(729.000000, 117.000000)">
<path d="M61.9666767,263.630536 C61.9666767,263.630536 13.8823758,266.642564 2.14585145,223.105034 C-9.59067294,179.567505 30.6421304,149.777478 30.6421304,149.777478 C30.6421304,149.777478 3.86713774,88.9148862 48.0221034,59.4026571 C92.177069,29.890428 132.137392,65.0961481 132.137392,65.0961481 C132.137392,65.0961481 146.882195,9.30099158 213.387402,0.942799034 C279.892608,-7.41539351 307.464966,42.5490795 307.464966,42.5490795 C307.464966,42.5490795 379.94436,-8.27020865 439.077164,28.4129697 C498.209969,65.0961481 476.357577,123.281621 476.357577,123.281621 C476.357577,123.281621 536.316599,151.787877 529.452639,208.995308 C522.588679,266.202739 463.005692,277.459669 463.005692,277.459669 C463.005692,277.459669 469.848467,311.731424 421.287008,332.695502 C372.72555,353.65958 324.990309,332.695502 324.990309,332.695502 C324.990309,332.695502 306.72398,353 265,353 C223.27602,353 199.633,332.695502 199.633,332.695502 C199.633,332.695502 111.211847,365.183754 73.76725,332.695502 C36.3226528,300.20725 61.9666767,263.630536 61.9666767,263.630536 Z" id="Line" stroke="#979797" stroke-width="4" stroke-linecap="square"></path>
<text id="Text" font-family="AvenirNext-Medium, Avenir Next" font-size="24" font-weight="400" fill="#888888">
<tspan x="174.904" y="112">Google Cloud</tspan>
</text>
<g id="google" transform="translate(202.000000, 177.000000)" fill-rule="nonzero">
<path d="M91,47.9368519 L91,92 L77.3570726,89.4368331 L71.3423929,82.1135571 L46,53.5172716 L67.5161024,38 C78.2938994,52.4396524 78.6337659,48.919524 78.6337659,48.919524 C78.6337659,48.919524 83.9575433,40.8520571 90.7875834,47.7844102 L91,47.9368519 Z" id="Shape" fill="#CCCCCC"></path>
<path d="M38.6152093,45 L84,102.960048 C83.7357573,102.985746 83.4686821,103 83.1987742,103 L9,103 L38.6152093,45 Z" id="Shape" fill="#518EF8"></path>
<path d="M67,38.24173 L0,95 L0,20.2375192 C0,15.6869933 3.66589034,12 8.18408913,12 L57.0430129,12 C54.9449765,15.6295862 62.724199,17.9592242 62.724199,22.4610349 C62.724199,26.9917513 64.8764586,34.5949621 67,38.24173 Z" id="Shape" fill="#28B446"></path>
<path d="M91,91.9033423 L91,94.7589459 C91,99.0598279 87.7649535,102.596208 83.6252806,103 L46,65.3573245 L55.107601,56 L76.1280869,77.0280699 L77.2888871,78.1904704 L91,91.9033423 Z" id="Shape" fill="#F2F2F2"></path>
<path d="M64.7672582,46.4135458 L54.7595809,56.5364496 L45.62246,65.7789787 L8.82602129,103 L8.19517463,103 C3.67085585,103 0,99.3359873 0,94.8201028 L57.1631127,37 C58.2335612,38.8291962 65.5951816,36.9771175 67.0909538,38.45866 L69.8399732,41.985779 C71.1815027,43.4135276 63.6141582,44.8978802 64.7672582,46.4135458 Z" id="Shape" fill="#FFD837"></path>
<path d="M21,46 C13.8316728,46 8,40.1683272 8,33 C8,25.8316728 13.8316728,20 21,20 C24.4700565,20 27.7337193,21.3528696 30.190625,23.8095751 L27.1763963,26.8232033 C25.5252468,25.1716534 23.3315881,24.2621999 21,24.2621999 C16.1818784,24.2621999 12.2624001,28.1818784 12.2624001,32.9997998 C12.2624001,37.8177212 16.1820786,41.7373997 21,41.7373997 C25.0830318,41.7373997 28.5212584,38.9223579 29.4747532,35.1307997 L21,35.1307997 L21,30.8685998 L34,30.8685998 L34,32.9997998 C33.9997998,40.1683272 28.1683272,46 21,46 Z" id="Shape" fill="#FFFFFF"></path>
<path d="M57.3049966,12.2066616 C61.5467744,4.90899518 69.4507532,0 78.5,0 C92.0311063,0 103,10.9722665 103,24.5075334 C103,27.9021199 102.308734,31.1336503 101.063247,34.073894 C99.8149437,37.0141377 98.0096021,39.6600753 95.7816525,41.8772356 C93.9249943,43.8516236 92.2597171,45.9375339 90.7628795,48.0691401 C80.9565605,62.0444572 78.5,78 78.5,78 C78.5,78 75.8321355,60.6757924 64.957423,46.3175955 C63.8035016,44.7975503 62.5580152,43.3087072 61.2155301,41.8772356 L61.2183475,41.8772356 C59.7215099,40.3914121 58.419072,38.7113303 57.3478611,36.8768485 C55.2197275,33.2451261 54,29.0191548 54,24.5073321 C54,20.0242958 55.2026219,15.8210719 57.3049966,12.2066616 Z" id="Shape" fill="#F14336"></path>
<path d="M78.0017055,11 C85.1811559,11 91,16.8222551 91,24.0017055 C91,31.1811559 85.1811559,37 78.0017055,37 C70.8222551,37 65,31.1811559 65,24.0017055 C65,16.8222551 70.8224558,11 78.0017055,11 Z" id="Shape" fill="#7E2D25"></path>
</g>
</g>
<g id="iphone" transform="translate(747.000000, 646.000000)">
<path d="M78.9280215,0 L6.07351579,0 C2.72377728,0 0,2.7369505 0,6.1009901 L0,149.900554 C0,153.264594 2.72377728,156 6.07351579,156 L78.9280215,156 C82.2777601,156 84.9999993,153.264594 84.9999993,149.900554 L84.9999993,6.1009901 C85.0015373,2.7369505 82.2777601,0 78.9280215,0 Z M36.54414,142.871287 C36.54414,139.565941 39.2079357,136.886139 42.5007687,136.886139 C45.7982156,136.886139 48.4573974,139.565941 48.4573974,142.871287 C48.4573974,146.178178 45.7982156,148.856436 42.5007687,148.856436 C39.2079357,148.856436 36.54414,146.178178 36.54414,142.871287 Z M4.61550289,132.613901 L4.61550289,18.9686733 L80.3891104,18.9686733 L80.3891104,132.613901 L4.61550289,132.613901 Z" id="Shape" fill="#000000" fill-rule="nonzero"></path>
<text id="iOS" font-family="AvenirNext-Medium, Avenir Next" font-size="24" font-weight="400" fill="#888888">
<tspan x="22.376" y="228">iOS</tspan>
</text>
</g>
<g id="samsung-galaxy-2" transform="translate(925.000000, 645.000000)">
<path d="M75.7096136,157 L11.2871365,157 C5.06344907,157 0,151.977612 0,145.802413 L0,11.199199 C0,5.024 5.06344907,0 11.2871365,0 L75.7112385,0 C81.9365509,0 87,5.024 87,11.199199 L87,145.799189 C87,151.974388 81.9349259,157 75.7096136,157 Z M11.2871365,1.61232349 C5.96044005,1.61232349 1.62498366,5.91400257 1.62498366,11.199199 L1.62498366,145.799189 C1.62498366,151.084385 5.96044005,155.386064 11.2871365,155.386064 L75.7112385,155.386064 C81.0395599,155.386064 85.3750163,151.084385 85.3750163,145.799189 L85.3750163,11.199199 C85.3750163,5.91400257 81.0395599,1.61232349 75.7112385,1.61232349 L11.2871365,1.61232349 Z M50.8132389,151.72609 L36.188386,151.72609 C33.4259138,151.72609 31.1769364,149.496246 31.1769364,146.753684 C31.1769364,144.014347 33.4259138,141.784503 36.188386,141.784503 L50.8132389,141.784503 C53.5740862,141.784503 55.8246885,144.014347 55.8246885,146.753684 C55.8230636,149.496246 53.5740862,151.72609 50.8132389,151.72609 Z M36.1867611,143.396827 C34.3196548,143.396827 32.8002951,144.904349 32.8002951,146.753684 C32.8002951,148.604632 34.3196548,150.113766 36.1867611,150.113766 L50.811614,150.113766 C52.6770952,150.113766 54.1980799,148.604632 54.1980799,146.753684 C54.1980799,144.904349 52.6770952,143.396827 50.811614,143.396827 L36.1867611,143.396827 Z M82.5637946,140.107687 L4.43620538,140.107687 L4.43620538,16.8890886 L82.5637946,16.8890886 L82.5637946,140.107687 Z M6.06118904,138.495363 L80.938811,138.495363 L80.938811,18.5014121 L6.06118904,18.5014121 L6.06118904,138.495363 Z M54.1639552,10.4462439 L32.8360448,10.4462439 C32.1080521,10.4462439 31.514933,9.85935815 31.514933,9.1354249 C31.514933,8.41149166 32.1064271,7.82460591 32.8360448,7.82460591 L54.1639552,7.82460591 C54.8919479,7.82460591 55.483442,8.41149166 55.483442,9.1354249 C55.483442,9.85935815 54.8919479,10.4462439 54.1639552,10.4462439 Z M32.8360448,8.63237997 C32.5565476,8.63237997 32.3274249,8.85971759 32.3274249,9.13703723 C32.3274249,9.41435687 32.5565476,9.64169448 32.8360448,9.64169448 L54.1639552,9.64169448 C54.4434524,9.64169448 54.6709501,9.41435687 54.6709501,9.13703723 C54.6709501,8.85971759 54.4418275,8.63237997 54.1639552,8.63237997 L32.8360448,8.63237997 Z" id="Shape" fill="#000000" fill-rule="nonzero"></path>
<text id="Android" font-family="AvenirNext-Medium, Avenir Next" font-size="24" font-weight="400" fill="#888888">
<tspan x="0.188" y="227">Android</tspan>
</text>
</g>
<g id="monitor-2" transform="translate(377.000000, 624.000000)">
<path d="M268.813647,0 L8.19642939,0 C3.67730335,0 0,3.65748115 0,8.15533006 L0,151.888319 C0,151.888319 0.00504085449,151.893336 0.00504085449,151.898353 L0.00504085449,159.080362 C0.00504085449,161.736927 2.165047,163.886764 4.8291386,163.886764 L111.49362,163.886764 C111.060106,173.339019 108.963111,192.308754 99.1031992,196.467947 C99.1031992,196.467947 95.2771906,200 105.983966,200 L124.309992,200 L149.567193,200 L167.898261,200 C178.605036,200 174.779027,196.467947 174.779027,196.467947 C164.919116,192.308754 162.817079,173.336511 162.388607,163.886764 L272.264112,163.886764 C274.938285,163.886764 276.979831,161.736927 276.979831,159.080362 L276.979831,152.114089 C276.979831,152.036324 276.999995,151.963576 276.999995,151.888319 L276.999995,8.15533006 C277.005036,3.65748115 273.332773,0 268.813647,0 Z M133.252468,162.301353 C133.252468,160.056192 135.079778,158.239994 137.33304,158.239994 C139.588822,158.239994 141.413611,160.0587 141.413611,162.301353 C141.413611,164.544006 139.586302,166.362713 137.33304,166.362713 C135.079778,166.362713 133.252468,164.546515 133.252468,162.301353 Z M268.813647,152.781366 L8.19642939,152.781366 L8.19642939,8.79752154 C8.19642939,8.16034718 8.71311698,7.64107517 9.3533055,7.64107517 L267.65173,7.64107517 C268.291919,7.64107517 268.813647,8.16034718 268.813647,8.79752154 L268.813647,152.781366 Z" id="Shape" fill="#000000" fill-rule="nonzero"></path>
<text id="Text-Copy" font-family="AvenirNext-Medium, Avenir Next" font-size="24" font-weight="400" fill="#888888">
<tspan x="44.488" y="250">Desktop Browser</tspan>
</text>
</g>
<path d="M474.296875,590 L474.296875,437.820312" id="Path-2-Copy" stroke="#979797" stroke-width="6"></path>
<path id="Path-2-Copy-decoration-1" d="M477.296875,448.620313 L474.296875,437.820312 L471.296875,448.620313" stroke="#979797" stroke-width="6"></path>
<path d="M476,292.453125 C476,268.151042 467.590008,256 450.770024,256 C433.950041,256 408.026699,261.6875 373,273.0625" id="Path-2-Copy-2" stroke="#979797" stroke-width="6"></path>
<path id="Path-2-Copy-2-decoration-1" d="M382.345297,266.873362 L373,273.0625 L384.198535,272.579982" stroke="#979797" stroke-width="6"></path>
<path d="M979,590 L979,415" id="Path-2-Copy-4" stroke="#979797" stroke-width="6"></path>
<path id="Path-2-Copy-4-decoration-1" d="M982,425.8 L979,415 L976,425.8" stroke="#979797" stroke-width="6"></path>
<use id="Rectangle-2" stroke="#979797" mask="url(#mask-4)" stroke-width="6" stroke-dasharray="9" xlink:href="#path-3"></use>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

Binary file not shown.

8
mobile/.babelrc Normal file
View File

@@ -0,0 +1,8 @@
{
"presets": ["babel-preset-expo"],
"env": {
"development": {
"plugins": ["transform-react-jsx-source"]
}
}
}

View File

@@ -0,0 +1,5 @@
{
"expoServerPort": 19000,
"packagerPort": 19001,
"packagerPid": 7732
}

View File

@@ -0,0 +1,7 @@
{
"hostType": "tunnel",
"lanType": "ip",
"dev": true,
"minify": false,
"urlRandomness": null
}

75
mobile/.flowconfig Normal file
View File

@@ -0,0 +1,75 @@
[ignore]
; We fork some components by platform
.*/*[.]android.js
; Ignore templates for 'react-native init'
<PROJECT_ROOT>/node_modules/react-native/local-cli/templates/.*
; Ignore RN jest
<PROJECT_ROOT>/node_modules/react-native/jest/.*
; Ignore RNTester
<PROJECT_ROOT>/node_modules/react-native/RNTester/.*
; Ignore the website subdir
<PROJECT_ROOT>/node_modules/react-native/website/.*
; Ignore the Dangerfile
<PROJECT_ROOT>/node_modules/react-native/danger/dangerfile.js
; Ignore Fbemitter
<PROJECT_ROOT>/node_modules/fbemitter/.*
; Ignore "BUCK" generated dirs
<PROJECT_ROOT>/node_modules/react-native/\.buckd/
; Ignore unexpected extra "@providesModule"
.*/node_modules/.*/node_modules/fbjs/.*
; Ignore polyfills
<PROJECT_ROOT>/node_modules/react-native/Libraries/polyfills/.*
; Ignore various node_modules
<PROJECT_ROOT>/node_modules/react-native-gesture-handler/.*
<PROJECT_ROOT>/node_modules/expo/.*
<PROJECT_ROOT>/node_modules/react-navigation/.*
<PROJECT_ROOT>/node_modules/xdl/.*
<PROJECT_ROOT>/node_modules/reqwest/.*
<PROJECT_ROOT>/node_modules/metro-bundler/.*
[include]
[libs]
node_modules/react-native/Libraries/react-native/react-native-interface.js
node_modules/react-native/flow/
node_modules/expo/flow/
[options]
emoji=true
module.system=haste
module.file_ext=.js
module.file_ext=.jsx
module.file_ext=.json
module.file_ext=.ios.js
munge_underscores=true
module.name_mapper='^[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> 'RelativeImageStub'
suppress_type=$FlowIssue
suppress_type=$FlowFixMe
suppress_type=$FlowFixMeProps
suppress_type=$FlowFixMeState
suppress_type=$FixMe
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe\\($\\|[^(]\\|(\\(>=0\\.\\(5[0-6]\\|[1-4][0-9]\\|[0-9]\\).[0-9]\\)? *\\(site=[a-z,_]*react_native_oss[a-z,_]*\\)?)\\)
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(>=0\\.\\(5[0-6]\\|[1-4][0-9]\\|[0-9]\\).[0-9]\\)? *\\(site=[a-z,_]*react_native_oss[a-z,_]*\\)?)\\)?:? #[0-9]+
suppress_comment=\\(.\\|\n\\)*\\$FlowFixedInNextDeploy
suppress_comment=\\(.\\|\n\\)*\\$FlowExpectedError
unsafe.enable_getters_and_setters=true
[version]
^0.56.0

1
mobile/.watchmanconfig Normal file
View File

@@ -0,0 +1 @@
{}

23
mobile/App.js Normal file
View File

@@ -0,0 +1,23 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
export default class App extends React.Component {
render() {
return (
<View style={styles.container}>
<Text>Did this change?</Text>
<Text>Changes you make will automatically reload.</Text>
<Text>Shake your phone to open the developer menu.</Text>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});

9
mobile/App.test.js Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react';
import App from './App';
import renderer from 'react-test-renderer';
it('renders without crashing', () => {
const rendered = renderer.create(<App />).toJSON();
expect(rendered).toBeTruthy();
});

5
mobile/app.json Normal file
View File

@@ -0,0 +1,5 @@
{
"expo": {
"sdkVersion": "25.0.0"
}
}

View File

@@ -0,0 +1,58 @@
import React, { Component } from 'react';
import { AppRegistry, StyleSheet, Text, View } from 'react-native';
let styles = {
}
export class LoginPage extends Component {
constructor(props) {
super(props)
this.state = {
userName: 'john@lyon-smith.org',
password: 'test123!'
}
}
render() {
return (
<KeyboardAvoidingView className="view" behavior="padding">
<ScrollView>
<View className="logoBox">
<Image className="image" source={acmLogo}/>
</View>
<Text className="username">User Name:</Text>
<View className="fieldBlock">
<IconInput width={250} rounded={true} icon={iconEmail} onChange={value => this.setState({email: value})}
value={this.state.email} iconStyle={{width: 20, height: 24, top: 12}}/>
</View>
<View className="fieldBlock">
<IconInput
password={true}
rounded={true}
width={250}
icon={iconPassword}
iconStyle={{left: 12, top: 8}}
onChange={value => this.setState({password: value})}
value={this.state.password}
/>
</View>
<View className="nextBlock">
<NextButton busy={auth.authInProgress} width={250} title="Log In" onPress={() => this.login()}/>
</View>
{errorMessage}
<TouchableOpacity onPress={() => this.toggleLoginData()}>
<View className="linksBlock">
<Text className="linksText">Sign Up</Text>
<Text className="linksTextRight">Forgot password?</Text>
</View>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
)
}
}

26
mobile/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "DeightonAR",
"version": "0.1.0",
"private": true,
"devDependencies": {
"react-native-scripts": "1.11.1",
"jest-expo": "25.0.0",
"react-test-renderer": "16.2.0"
},
"main": "./node_modules/react-native-scripts/build/bin/crna-entry.js",
"scripts": {
"start": "react-native-scripts start",
"eject": "react-native-scripts eject",
"android": "react-native-scripts android",
"ios": "react-native-scripts ios",
"test": "node node_modules/jest/bin/jest.js"
},
"jest": {
"preset": "jest-expo"
},
"dependencies": {
"expo": "^25.0.0",
"react": "16.2.0",
"react-native": "0.52.0"
}
}

6515
mobile/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "deighton-ar",
"version": "1.0.0",
"description": "Deighton AR Training System",
"main": "index.js",
"repository": {
"type": "git",
"url": "git+https://github.com/KingstonSoftware/deighton-ar.git"
},
"keywords": [
"deighton",
"training",
"ar",
"vr",
"maps"
],
"author": "Kingston Software Solutions",
"license": "ISC",
"bugs": {
"url": "https://github.com/KingstonSoftware/deighton-ar/issues"
},
"homepage": "https://github.com/KingstonSoftware/deighton-ar#readme"
}

13
server/.babelrc Normal file
View File

@@ -0,0 +1,13 @@
{
"presets": [
[ "env", {
"targets": {
"node": 8
}
}]
],
"plugins": [
"transform-class-properties",
"transform-object-rest-spread"
]
}

7
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
coverage/
dist/
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -0,0 +1,21 @@
{
logDir: '',
uri: {
mongo: 'mongodb://localhost/dar-v1',
amqp: 'amqp://localhost',
redis: 'redis://localhost',
},
api: {
port: '3001',
loginKey: '6508b427b3cc486498671cfd7967218bd6b95fbb42f5e17a112f4c9e9900057c',
uploadTimout: 3600,
},
email: {
senderEmail: 'support@kingstonsoftware.solutions',
maxEmailTokenAgeInHours: 36,
maxPasswordTokenAgeInHours: 3,
sendEmailDelayInSeconds: 3,
supportEmail: 'support@kingstonsoftware.solutions',
sendEmail: true
}
}

View File

@@ -0,0 +1,7 @@
{
awsConfig: {
accessKeyId: 'AKIAJUP6XRVYDAXNTUNA',
secretAccessKey: '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',
region: 'us-west-2'
}
}

View File

@@ -0,0 +1,10 @@
{
logDir: '/var/log/deighton-ar',
api: {
port: '3001',
loginKey: '*',
uploadTimout: 120,
maxEmailTokenAgeInHours: 36,
sendEmailDelayInSeconds: 3
},
}

View File

@@ -0,0 +1,27 @@
# Email Templates
This directory contains the email templates used for various communications with the user. The list of templates is as follows:
| File Name | Description
|:------------ |:-----------
`welcome.txt/.html` | Sent to users when they are first added to the system. Should include welcome message, link to email confirmation page and notification of link expiration and support email.
`forgotPassword.txt/.html` | Sent to users when they click on the forgot password link. Should include the link to the reset and support email. Rate limited in production.
`changeEmailNew.txt/.html` | Sent to the old email of existing users when they are changing their email. Should include support email and the new email.
`changeEmailOld.txt/.html` | Sent to existing users when they are changing their email. Should include the link to email confirmation page and support email.
`accountDeleted.txt/.html` | Notification that the users account has been deleted from the system. Must include a support email.
Each template must have a text (`.txt`) version and can also have an HTML (`.html`) version. Both will be sent and it is up to the users email reader to decide which to use.
## List of Supported Data Fields
This is the the definitive list of supported data fields in emails.
Name | Value
---- | -----
`recipientFullName` | Full name of the user receiving the email
`confirmEmailLink` | URL that will take the user to confirming their email, and if necessary setting their password (for first time account setup.)
`confirmEmailLinkExpirationHours` | The number of hours before the given link expires
`senderFullName` | The full name of the user that initiated the action
`supportEmail` | The support email for the system
`recipientNewEmail` | The new email that is being set for the user
`resetPasswordLink` | A link that allows the user to reset their password

View File

@@ -0,0 +1,9 @@
Hello {{recipientFullName}}.
This email is for your records to indicated that your account for the Deighton AR system has been deleted.
Please contact {{supportEmail}} if you have any questions.
Regards,
{{senderFullName}}

View File

@@ -0,0 +1,13 @@
Hello {{recipientFullName}},
This message allows you to complete the process of changing your email. If you did not make this request please do not worry. Just ignore this email and your account will remain unchanged.
If you did make this request, please click on the following link to confirm your new email:
{{confirmEmailLink}}
If you have any questions, please contact us at {{supportEmail}}.
Regards,
Deighton

View File

@@ -0,0 +1,11 @@
Hello {{recipientFullName}},
This message is to inform you that a request was made to change your email to {{recipientNewEmail}}. If you did not make this request please do not worry. Just ignore this email and your account will remain unchanged.
If you did make this request, please see the message sent to your new email account for further instructions.
If you have any questions, please contact us at {{supportEmail}}.
Regards,
Deighton

View File

@@ -0,0 +1,11 @@
Hello {{recipientFullName}},
The following link will allow you to reset your password. Please paste it into your browser and you will be redirected to the Deighton AR site to set your new password:
{{resetPasswordLink}}
Please contact {{supportEmail}} if you have any questions or problems.
Regards,
Deighton

View File

@@ -0,0 +1,22 @@
{
accountDeleted: {
heading: 'Deighton AR Account Deleted Notification',
text: 'accountDeleted.txt'
},
changeEmailNew: {
heading: 'Deighton AR Change of Email Confirmation',
text: 'changeEmailNew.txt'
},
changeEmailOld: {
heading: 'Deighton AR Change of Email Request',
text: 'changeEmailOld.txt'
},
forgotPassword: {
heading: 'Deighton AR Password Reset Request',
text: 'forgotPassword.txt'
},
welcome: {
heading: 'Welcome to the Deighton AR System!',
text: 'welcome.txt'
}
}

View File

@@ -0,0 +1,15 @@
Hello {{recipientFullName}},
Thank you for joining the Deighton AR system!
Please paste this link into your browser to go to the Deighton AR system and set your login password:
{{confirmEmailLink}}
This invitation expires in {{confirmEmailLinkExpirationHours}} hours. If it has expired or you have any problems creating a password or logging into our system, please contact {{supportEmail}}.
Thank you and we look forward to working with you!
Sincerely,
{{senderFullName}}

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Deighton Service
After=rabbitmq-server.service mongod.service redis-server.service
[Service]
Type=simple
User=ubuntu
Group=ubuntu
WorkingDirectory=/home/ubuntu/deighton-ar/server
Environment='NODE_ENV=production'
ExecStart=/usr/bin/node server/index.js
Restart=on-abort
[Install]
WantedBy=multi-user.target

6622
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

77
server/package.json Normal file
View File

@@ -0,0 +1,77 @@
{
"name": "dar-server",
"version": "1.0.0",
"description": "Deighton AR Server",
"main": "src/server.js",
"scripts": {
"start": "babel-node src/index.js",
"start:prod": "NODE_ENV=production npm start",
"build": "babel src -d dist -s",
"serve": "NODE_ENV=production node dist/server.js",
"test": "jest",
"actor:api": "monzilla 'src/api/**/*.js:src/database/**/*.js' -- babel-node src/api/index.js",
"actor:api:debug": "babel-node --inspect-brk src/api/index.js",
"actor:image": "monzilla 'src/image/**/*.js:src/(message-service|database)/**/*.js' -- babel-node src/image/index.js",
"actor:image:debug": "babel-node --inspect-brk src/image/index.js",
"actor:email": "monzilla 'src/email/**/*.js:src/(message-service|database)/**/*.js' -- babel-node src/email/index.js",
"actor:email:debug": "babel-node --inspect-brk src/email/index.js"
},
"author": "John Lyon-Smith",
"license": "ISC",
"dependencies": {
"amqplib": "^0.5.1",
"app-root-path": "^2.0.1",
"auto-bind2": "^1.0.3",
"aws-sdk": "^2.98.0",
"body-parser": "^1.17.1",
"canvas": "^1.6.7",
"config": "^1.25.1",
"cors": "^2.8.3",
"credential": "^2.0.0",
"eventemitter3": "^2.0.3",
"express": "^4.15.2",
"gridfs-stream": "^1.1.1",
"handlebars": "^4.0.10",
"http-errors": "^1.6.1",
"json5": "^0.5.1",
"jsonwebtoken": "^7.4.0",
"mongodb": "^2.2.31",
"mongoose": "^4.11.7",
"mongoose-merge-plugin": "0.0.5",
"nodemailer": "^4.0.1",
"passport": "^0.3.2",
"passport-http-bearer": "^1.0.1",
"pino": "^4.10.1",
"pino-pretty-express": "^1.0.4",
"redis": "^2.7.1",
"redis-rstream": "^0.1.3",
"regexp-pattern": "^1.0.4",
"replace-ext": "^1.0.0",
"socket.io": "^2.0.3",
"urlsafe-base64": "^1.0.0",
"uuid": "^3.1.0"
},
"devDependencies": {
"babel-cli": "^6.24.1",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-object-rest-spread": "^6.23.0",
"babel-preset-env": "^1.5.2",
"istanbul": "^0.4.5",
"jest": "^21.1.0",
"monzilla": "^1.1.0"
},
"private": true,
"keywords": {
"0": "rest",
"1": "api",
"2": "deighton"
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/KingstonSoftware/deighton-ar.git"
},
"bugs": {
"url": "https://github.com/KingstonSoftware/deighton-ar/issues"
},
"homepage": "https://github.com/KingstonSoftware/deighton-ar#readme"
}

13
server/src/api/.babelrc Normal file
View File

@@ -0,0 +1,13 @@
{
"presets": [
[ "env", {
"targets": {
"node": 8
}
}]
],
"plugins": [
"transform-class-properties",
"transform-object-rest-spread"
]
}

7
server/src/api/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.DS_STORE
*.log
node_modules
dist
coverage
**/local*.json*
.idea/

101
server/src/api/MQ.js Normal file
View File

@@ -0,0 +1,101 @@
import amqp from 'amqplib'
import uuidv4 from 'uuid/v4'
import autoBind from 'auto-bind2'
export class MQ {
constructor(container) {
autoBind(this)
this.container = container
this.log = container.log
this.replyQueueName = `reply-${uuidv4()}`
this.appId = 'dar-api'
}
connect(amqpUri) {
return amqp.connect(amqpUri).then((conn) => {
this.connection = conn
this.connection.on('error', () => {
this.log.error(`RabbitMQ has gone, shutting down service`)
process.exit(-1)
})
return conn.createChannel()
}).then((ch) => {
this.replyChannel = ch
return ch.assertQueue(this.replyQueueName, {exclusive: true})
}).then((q) => {
if (!q) {
return Promise.reject(new Error(`Could not create reply queue ${replyQueueName}`))
}
this.replyChannel.consume(q.queue, this.handleReply, {noAck: true})
return Promise.resolve(this)
})
}
handleReply(rawMsg) {
const { type, correlationId } = rawMsg.properties
const content = JSON.parse(rawMsg.content.toString())
const { error, data } = content
let { passback } = content
if (passback && passback.routerName && passback.funcName) {
const router = this.container[passback.routerName]
if (router) {
const func = router[passback.funcName]
if (func) {
// TODO: Try setting these to unknown instead as deleting fields break optimizations
delete passback.routerName
delete passback.funcName
passback.correlationId = correlationId
passback.type = type
func(passback, error, data)
} else {
this.log.error(`Router method '${passback.funcName}' not found`)
}
} else {
this.log.error(`Router '${passback.routerName}' not found`)
}
}
}
close() {
return this.replyChannel.close().then(() => {
this.connection.close()
})
}
request(exchangeName, msgType, msgs) {
if (!Array.isArray(msgs)) {
msgs = [msgs]
}
let channel = null
const correlationId = uuidv4()
return this.connection.createChannel().then((ch) => {
channel = ch
return channel.checkExchange(exchangeName)
}).then((arr) => {
return Promise.all(msgs.map((msg) => (
channel.publish(exchangeName, '', new Buffer(JSON.stringify(msg)), {
type: msgType,
contentType: 'application/json',
timestamp: Date.now(),
correlationId,
appId: this.appId,
replyTo: this.replyQueueName
})
)))
}).then(() => {
return channel.close()
}).then(() => {
return Promise.resolve(correlationId)
})
}
}

19
server/src/api/RS.js Normal file
View File

@@ -0,0 +1,19 @@
import redis from 'redis'
import util from 'util'
export class RS {
connect(redisUri) {
let client = redis.createClient(redisUri, { detect_buffers: true })
this.setAsync = util.promisify(client.set.bind(client))
this.getAsync = util.promisify(client.get.bind(client))
this.setrangeAsync = util.promisify(client.setrange.bind(client))
this.incrAsync = util.promisify(client.incr.bind(client))
this.expireAsync = util.promisify(client.expire.bind(client))
this.del = client.del.bind(client)
this.client = client
// The createClient call doesn't return a promise so we fake it
return Promise.resolve(this)
}
}

74
server/src/api/WS.js Normal file
View File

@@ -0,0 +1,74 @@
import IOServer from 'socket.io'
import autoBind from 'auto-bind2'
export class WS {
constructor(container) {
this.log = container.log
this.io = new IOServer(container.server, { path: '/socketio' })
this.socketMap = {}
this.db = container.db
autoBind(this)
}
listen() {
// Starting listing handling connections
this.io.on('connection', this.handleConnection)
return Promise.resolve(this)
}
handleConnection(socket) {
this.db.lookupToken(socket.handshake.query.auth_token, (err, user) => {
if (err || !user) {
this.log.warn(`socket.io client failed authentication and was disconnected`)
socket.disconnect()
} else {
this.socketMap[user._id] = socket.id
this.log.info(`socket.io client '${socket.id}' is connected`)
}
})
}
notify(roomNames, eventName, eventData) {
if (roomNames.length <= 0) {
return
}
let namespace = this.io.sockets
roomNames.forEach((roomName) => {
if (/[a-f0-9]{24}/.test(roomName)) {
const socketId = this.socketMap[roomName]
if (!socketId) {
return
} else {
roomName = socketId
}
}
namespace = namespace.in(roomName)
})
namespace.emit('notify', { eventName, eventData })
}
enterRoom(userId, newRoom) {
const socketId = this.socketMap[userId]
if (socketId) {
const socket = this.io.sockets.connected[socketId]
if (socket) {
const rooms = Object.keys(socket.rooms)
// We want to leave any rooms we are still in except the socket id room
rooms.filter((room) => (room !== socket.id)).forEach((room) => (socket.leave(room)))
if (newRoom) {
socket.join(newRoom)
}
}
}
}
}

105
server/src/api/index.js Normal file
View File

@@ -0,0 +1,105 @@
import config from 'config'
import express from 'express'
import pino from 'pino'
import * as pinoExpress from 'pino-pretty-express'
import http from 'http'
import { DB } from '../database'
import { MQ } from './MQ'
import { RS } from './RS'
import { WS } from './WS'
import bodyParser from 'body-parser'
import cors from 'cors'
import passport from 'passport'
import { Strategy as BearerStrategy } from 'passport-http-bearer'
import path from 'path'
import fs from 'fs'
import createError from 'http-errors'
import * as Routes from './routes'
let app = express()
let server = http.createServer(app)
let container = { app, server }
const serviceName = 'dar-api'
const isProduction = (process.env.NODE_ENV == 'production')
let log = null
if (isProduction) {
log = pino( { name: serviceName },
fs.createWriteStream(path.join(config.get('logDir'), serviceName + '.log'))
)
} else {
const pretty = pinoExpress.pretty({})
pretty.pipe(process.stdout)
log = pino({ name: serviceName }, pretty)
}
container.log = log
app.use(pinoExpress.config({ log }))
app.set('etag', false) // Not wanted for _all_ routes. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag.
app.options('*', cors()) // Enable all pre-flight CORS requests
app.use(cors())
app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())
app.use(bodyParser.raw({ type: 'application/octet-stream'})) // TODO: Support gzip, etc.. here
app.use(passport.initialize())
const rs = new RS(container)
container.rs = rs
const db = new DB(container)
container.db = db
const ws = new WS(container)
container.ws = ws
const mq = new MQ(container)
container.mq = mq
passport.use(new BearerStrategy(db.lookupToken))
let mongoUri = config.get('uri.mongo')
let amqpUri = config.get('uri.amqp')
let redisUri = config.get('uri.redis')
Promise.all([
db.connect(mongoUri, isProduction),
mq.connect(amqpUri),
rs.connect(redisUri),
ws.listen()
]).then(() => {
log.info(`Connected to MongoDB at ${mongoUri}`)
log.info(`Connected to RabbitMQ at ${amqpUri}`)
log.info(`Connected to Redis at ${redisUri}`)
try {
container.authRoutes = new Routes.AuthRoutes(container)
container.userRoutes = new Routes.UserRoutes(container)
container.assetRoutes = new Routes.AssetRoutes(container)
app.use(function(req, res, next) {
res.status(404).json({
message: 'Not found'
})
})
app.use(function(err, req, res, next) {
if (!isProduction) {
log.error(err)
}
if (!err.status) {
err = createError.InternalServerError(err.message)
}
res.status(err.status).json({
message: err.message
})
})
} catch(error) {
console.error(error)
process.exit(-1)
}
let port = config.get('api.port')
server.listen(port)
log.info(`Deight AR API started on port ${port}`)
}).catch((err) => {
log.error(err.message)
process.exit(1)
})

View File

@@ -0,0 +1,199 @@
import passport from 'passport'
import redis from 'redis'
import redisReadStream from 'redis-rstream'
import createError from 'http-errors'
import path from 'path'
import util from 'util'
import config from 'config'
import autoBind from 'auto-bind2'
function pipeToGridFS(readable, gfsWriteable) {
const promise = new Promise((resolve, reject) => {
readable.on('error', (error) => {
reject(error)
})
gfsWriteable.on('error', (error) => {
reject(error)
})
gfsWriteable.on('close', (file) => {
resolve(file)
})
})
readable.pipe(gfsWriteable)
return promise
}
export class AssetRoutes {
static rangeRegex = /^byte (\d+)/
constructor(container) {
const app = container.app
this.db = container.db
this.rs = container.rs
this.uploadTimeout = config.get('api.uploadTimout')
autoBind(this)
app.route('/assets/:_id')
.get(passport.authenticate('bearer', { session: false }), this.getAsset)
.delete(passport.authenticate('bearer', { session: false }), this.deleteAsset)
app.route('/assets/upload')
.post(passport.authenticate('bearer', { session: false }), this.beginAssetUpload)
app.route('/assets/upload/:_id')
.post(passport.authenticate('bearer', { session: false }), this.continueAssetUpload)
}
getAsset(req, res, next) {
const assetId = req.params._id
this.db.gridfs.findOneAsync({ _id: assetId }).then((file) => {
if (!file) {
return next(createError.NotFound(`Asset ${assetId} was not found`))
}
const ifNoneMatch = req.get('If-None-Match')
if (ifNoneMatch && ifNoneMatch === file.md5) {
res.status(304).set({
'ETag': file.md5,
'Cache-Control': 'private,max-age=86400'
}).end()
return
}
res.status(200).set({
'Content-Type': file.contentType,
'Content-Length': file.length,
'ETag': file.md5})
this.db.gridfs.createReadStream({ _id: file._id }).pipe(res)
}).catch((err) => {
next(createError.BadRequest(`Error returning asset '${assetId}'. ${err.message}`))
})
}
deleteAsset(req, res, next) {
const assetId = req.params._id
this.db.gridfs.removeAsync({ _id: assetId }).then(() => {
res.json({})
}).catch((err) => {
next(createError.BadRequest(`Unable to delete asset '${assetId}'. ${err.message}`))
})
}
beginAssetUpload(req, res, next) {
const uploadId = this.db.newObjectId()
let { fileName, fileSize, numberOfChunks, contentType } = req.body
if (!fileName || !fileSize || !numberOfChunks || !contentType) {
return next(createError.BadRequest('Must specify fileName, fileSize, numberOfChunks and Content-Type header'))
}
fileName = uploadId + '-' + path.basename(fileName)
this.rs.setAsync(
uploadId, JSON.stringify({
fileName, fileSize, numberOfChunks, contentType
}), 'EX', this.uploadTimeout).then(() => {
res.json({ uploadId })
}).catch((error) => {
next(createError.InternalServerError(error.message))
})
}
continueAssetUpload(req, res, next) {
if (!(req.body instanceof Buffer)) {
return next(createError.BadRequest('Body must be of type application/octet-stream'))
}
const range = req.get('Range')
const contentLength = req.get('Content-Length')
let match = range.match(AssetRoutes.rangeRegex)
let offset = null
if (!match || match.length < 2 || (offset = parseInt(match[1])) === NaN) {
return next(createError.BadRequest('Range header must be supplied and of form \'byte <offset>\''))
}
if (parseInt(contentLength, 10) !== req.body.length) {
return next(createError.BadRequest('Must supply Content-Length header matching length of request body'))
}
const uploadId = req.params._id
const uploadCountId = uploadId + '$#'
const uploadDataId = uploadId + '$@'
this.rs.getAsync(uploadId).then((content) => {
let uploadData = null
try {
uploadData = JSON.parse(content)
} catch (error){
return Promise.reject(new Error('Could not parse upload data'))
}
if (offset < 0 || offset + req.body.length > uploadData.fileSize) {
return Promise.reject(new Error(`Illegal range offset ${offset} given`))
}
Promise.all([
this.rs.setrangeAsync(uploadDataId, offset, req.body),
this.rs.incrAsync(uploadCountId)
]).then((arr) => {
const uploadedChunks = arr[1]
let chunkInfo = {
numberOfChunks: uploadData.numberOfChunks,
uploadedChunks
}
if (uploadedChunks >= uploadData.numberOfChunks) {
let readable = redisReadStream(this.rs.client, Buffer(uploadDataId))
let writeable = this.db.gridfs.createWriteStream({
_id: uploadId,
filename: uploadData.fileName,
content_type: uploadData.contentType
})
let promise = pipeToGridFS(readable, writeable).then((file) => {
return Promise.all([
Promise.resolve(file),
this.rs.del(uploadId),
this.rs.del(uploadCountId),
this.rs.del(uploadDataId)
])
}).then((arr) => {
const [file] = arr
res.json({
assetId: file._id,
fileName: file.filename,
contentType: file.contentType,
uploadDate: file.uploadDate,
md5: file.md5,
...chunkInfo
})
}) // TODO: Test that this will be caught...
return promise
} else {
return Promise.all([
this.rs.expireAsync(uploadId, this.uploadTimeout),
this.rs.expireAsync(uploadCountId, this.uploadTimeout),
this.rs.expireAsync(uploadDataId, this.uploadTimeout)
]).then(() => {
res.json(chunkInfo)
})
}
}).catch((error) => {
this.rs.del(uploadId)
this.rs.del(uploadCountId)
this.rs.del(uploadDataId)
console.error(error) // TODO: This should go into log file
next(createError.BadRequest('Unable to upload data chunk'))
})
}).catch((error) => {
console.error(error) // TODO: This should go into log file
next(createError.BadRequest(error.message))
})
}
}

View File

@@ -0,0 +1,396 @@
import passport from 'passport'
import credential from 'credential'
import createError from 'http-errors'
import config from 'config'
import crypto from 'crypto'
import urlSafeBase64 from 'urlsafe-base64'
import util from 'util'
import * as loginToken from './loginToken'
import autoBind from 'auto-bind2'
import url from 'url'
export class AuthRoutes {
constructor(container) {
const app = container.app
this.mq = container.mq
this.db = container.db
this.maxEmailTokenAgeInHours = config.get('email.maxEmailTokenAgeInHours')
this.maxPasswordTokenAgeInHours = config.get('email.maxPasswordTokenAgeInHours')
this.sendEmailDelayInSeconds = config.get('email.sendEmailDelayInSeconds')
this.supportEmail = config.get('email.supportEmail')
this.sendEmail = config.get('email.sendEmail')
autoBind(this)
app.route('/auth/login')
// Used to login. Email must be confirmed.
.post(this.login)
// Used to logout
.delete(passport.authenticate('bearer', { session: false }), this.logout)
// Send change email confirmation email
app.route('/auth/email/send')
.post(passport.authenticate('bearer', { session: false }), this.sendChangeEmailEmail)
// Confirm email address
app.route('/auth/email/confirm')
.post(this.confirmEmail)
// Change the logged in users password, leaving user still logged in
app.route('/auth/password/change')
.post(passport.authenticate('bearer', { session: false }), this.changePassword)
// Send a password reset email
app.route('/auth/password/send')
.post(this.sendPasswordResetEmail)
// Finish a password reset
app.route('/auth/password/reset')
.post(this.resetPassword)
// Indicate who the currently logged in user is
app.route('/auth/who')
.get(passport.authenticate('bearer', { session: false }), this.whoAmI)
}
login(req, res, next) {
const email = req.body.email
const password = req.body.password
if (!email || !password) {
return next(new createError.BadRequest('Must supply user name and password'))
}
let User = this.db.User
// Lookup the user
User.findOne({ email }).then((user) => {
if (!user) {
// NOTE: Don't return NotFound as that gives too much information away to hackers
return Promise.reject(createError.BadRequest("Email or password incorrect"))
} else if (user.emailToken || !user.passwordHash) {
return Promise.reject(createError.Forbidden("Must confirm email and set password"))
} else {
let cr = credential()
return Promise.all([
Promise.resolve(user),
cr.verify(JSON.stringify(user.passwordHash), req.body.password)
])
}
}).then((arr) => {
const [user, isValid] = arr
if (isValid) {
user.loginToken = loginToken.pack(user._id.toString(), user.email)
} else {
user.loginToken = null // A bad login removes existing token for this user...
}
return user.save()
}).then((savedUser) => {
if (savedUser.loginToken) {
res.set('Authorization', `Bearer ${savedUser.loginToken}`)
res.json(savedUser.toClient())
} else {
return Promise.reject(createError.BadRequest('Email or password incorrect'))
}
}).catch((err) => {
if (err instanceof createError.HttpError) {
next(err)
} else {
next(createError.InternalServerError(`Unable to login. ${err ? err.message : ''}`))
}
})
}
logout(req, res, next) {
let User = this.db.User
User.findById({ _id: req.user._id }).then((user) => {
if (!user) {
return next(createError.BadRequest())
}
user.loginToken = null
user.save().then((savedUser) => {
res.json({})
})
}).catch((err) => {
next(createError.InternalServerError(`Unable to login. ${err ? err.message : ''}`))
})
}
whoAmI(req, res, next) {
res.json(req.user.toClient())
}
sendChangeEmailEmail(req, res, next) {
let existingEmail = req.body.existingEmail
const newEmail = req.body.newEmail
let User = this.db.User
const role = req.user.role
const isAdminOrExec = (role === 'executive' || role === 'administrator')
if (existingEmail) {
if (!isAdminOrExec) {
return next(createError.Forbidden('Only admins can resend change email to any user'))
}
} else {
existingEmail = req.user.email
}
let promiseArray = [User.findOne({ email: existingEmail })]
if (newEmail) {
promiseArray.push(User.findOne({ email: newEmail }))
}
Promise.all(promiseArray).then((arr) => {
const [user, conflictingUser] = arr
if (!user) {
return Promise.reject(createError.NotFound(`User with email '${existingEmail}' was not found`))
} else if (conflictingUser) {
return Promise.reject(createError.BadRequest(`A user with '${newEmail}' already exists`))
} else if (!isAdminOrExec && user.emailToken && (new Date() - user.emailToken.created) < this.sendEmailDelayInSeconds) {
return Promise.reject(createError.BadRequest('Cannot request email confirmation again so soon'))
}
return Promise.all([Promise.resolve(user), util.promisify(crypto.randomBytes)(32)])
}).then((arr) => {
let [ user, buf ] = arr
user.emailToken = {
value: urlSafeBase64.encode(buf),
created: new Date()
}
if (newEmail) {
user.newEmail = newEmail
}
return user.save()
}).then((savedUser) => {
const userFullName = `${savedUser.firstName} ${savedUser.lastName}`
const siteUrl = url.parse(req.headers.referer)
let msgs = []
if (savedUser.newEmail) {
msgs.push({
toEmail: savedUser.email,
templateName: 'changeEmailOld',
templateData: {
recipientFullName: userFullName,
recipientNewEmail: savedUser.newEmail,
supportEmail: this.supportEmail
}
})
}
msgs.push({
toEmail: savedUser.newEmail || savedUser.email,
templateName: 'changeEmailNew',
templateData: {
recipientFullName: userFullName,
confirmEmailLink: `${siteUrl.protocol}//${siteUrl.host}/confirm-email?email-token%3D${savedUser.emailToken.value}`,
supportEmail: this.supportEmail
}
})
return this.sendEmail ? this.mq.request('dar-email', 'sendEmail', msgs) : Promise.resolve()
}).then(() => {
res.json({})
}).catch((err) => {
if (err instanceof createError.HttpError) {
next(err)
} else {
next(createError.InternalServerError(`Unable to send change email email. ${err.message}`))
}
})
}
confirmEmail(req, res, next) {
const token = req.body.emailToken
let User = this.db.User
if (!token) {
return next(createError.BadRequest('Invalid request parameters'))
}
User.findOne({ 'emailToken.value': token }).then((user) => {
if (!user) {
return Promise.reject(createError.BadRequest(`The token was not found`))
}
// Token must not be too old
const ageInHours = (new Date() - user.emailToken.created) / 3600000
if (ageInHours > this.maxEmailTokenAgeInHours) {
return Promise.reject(createError.BadRequest(`Token has expired`))
}
// Remove the email token & any login token as it will become invalid
user.emailToken = undefined
user.loginToken = undefined
// Switch in any new email now
if (user.newEmail) {
user.email = user.newEmail
user.newEmail = undefined
}
let promiseArray = [ Promise.resolve(user) ]
if (!user.passwordHash) {
// User has no password, create reset token for them
promiseArray.push(util.promisify(crypto.randomBytes)(32))
}
return Promise.all(promiseArray)
}).then((arr) => {
let [ user, buf ] = arr
if (buf) {
user.passwordToken = {
value: urlSafeBase64.encode(buf),
created: new Date()
}
}
return user.save()
}).then((savedUser) => {
let obj = {}
// Only because the user has sent us a valid email reset token can we respond with an password reset token
if (savedUser.passwordToken) {
obj.passwordToken = savedUser.passwordToken.value
}
res.json(obj)
}).catch((err) => {
if (err instanceof createError.HttpError) {
next(err)
} else {
next(createError.InternalServerError(`Unable to confirm set email token. ${err.message}`))
}
})
}
resetPassword(req, res, next) {
const token = req.body.passwordToken
const newPassword = req.body.newPassword
let User = this.db.User
let cr = credential()
if (!token || !newPassword) {
return next(createError.BadRequest('Invalid request parameters'))
}
User.findOne({ 'passwordToken.value': token }).then((user) => {
if (!user) {
return Promise.reject(createError.BadRequest(`The token was not found`))
}
// Token must not be too old
const ageInHours = (new Date() - user.passwordToken.created) / (3600 * 1000)
if (ageInHours > this.maxPasswordTokenAgeInHours) {
return Promise.reject(createError.BadRequest(`Token has expired`))
}
// Remove the password token & any login token
user.passwordToken = undefined
user.loginToken = undefined
return Promise.all([
Promise.resolve(user),
cr.hash(newPassword)
])
}).then((arr) => {
const [user, json] = arr
user.passwordHash = JSON.parse(json)
return user.save()
}).then((savedUser) => {
res.json({})
}).catch((err) => {
if (err instanceof createError.HttpError) {
next(err)
} else {
next(createError.InternalServerError(`Unable to confirm password reset token. ${err.message}`))
}
})
}
changePassword(req, res, next) {
let User = this.db.User
let cr = credential()
User.findById({ _id: req.user._id }).then((user) => {
if (!user) {
return next(createError.NotFound(`User ${req.user._id} not found`))
}
return Promise.all([
Promise.resolve(user),
cr.verify(JSON.stringify(user.passwordHash), req.body.oldPassword)
])
}).then((arr) => {
const [user, ok] = arr
return Promise.all([Promise.resolve(user), cr.hash(req.body.newPassword)])
}).then((arr) => {
const [user, obj] = arr
user.passwordHash = JSON.parse(obj)
return user.save()
}).then((savedUser) => {
res.json({})
}).catch((err) => {
return next(createError.InternalServerError(err.message))
})
}
sendPasswordResetEmail(req, res, next){
const email = req.body.email
let User = this.db.User
if (!email) {
return next(createError.BadRequest('Invalid request parameters'))
}
User.findOne({ email }).then((user) => {
// User must exist their email must be confirmed
if (!user || user.emailToken) {
// Don't give away any information about why we rejected the request
return Promise.reject(createError.BadRequest('Not a valid request'))
} else if (user.passwordToken && (new Date() - user.emailToken.created) < this.sendEmailDelayInSeconds) {
return Promise.reject(createError.BadRequest('Cannot request password reset so soon'))
}
return Promise.all([Promise.resolve(user), util.promisify(crypto.randomBytes)(32)])
}).then((arr) => {
let [ user, buf ] = arr
user.passwordToken = {
value: urlSafeBase64.encode(buf),
created: new Date()
}
return user.save()
}).then((savedUser) => {
const userFullName = `${savedUser.firstName} ${savedUser.lastName}`
const siteUrl = url.parse(req.headers.referer)
const msg = {
toEmail: savedUser.email,
templateName: 'forgotPassword',
templateData: {
recipientFullName: userFullName,
resetPasswordLink: `${siteUrl.protocol}//${siteUrl.host}/reset-password?password-token%3D${savedUser.passwordToken.value}`,
supportEmail: this.supportEmail
}
}
return this.sendEmail ? this.mq.request('dar-email', 'sendEmail', msg) : Promise.resolve()
}).then(() => {
res.json({})
}).catch((err) => {
if (err instanceof createError.HttpError) {
next(err)
} else {
next(createError.InternalServerError(`Unable to send password reset email. ${err.message}`))
}
})
}
}

View File

@@ -0,0 +1,493 @@
import passport from 'passport'
import createError from 'http-errors'
import { makeFingerprint } from '../makeFingerprint'
import autoBind from 'auto-bind2'
export class ProjectRoutes {
constructor(container) {
const app = container.app
this.log = container.log
this.db = container.db
this.mq = container.mq
this.ws = container.ws
autoBind(this)
app.route('/projects')
.get(passport.authenticate('bearer', { session: false }), this.listProjects)
.post(passport.authenticate('bearer', { session: false }), this.createProject)
.put(passport.authenticate('bearer', { session: false }), this.updateProject)
app.route('/projects/:_id([a-f0-9]{24})/broker/:brokerId([a-f0-9]{24})')
.get(passport.authenticate('bearer', { session: false }), this.getProjectBrokerClientData)
app.route('/projects/:_id([a-f0-9]{24})/broker/:brokerId([a-f0-9]{24})/sign-off')
.post(passport.authenticate('bearer', { session: false }), this.signOffProjectBrokerClientData)
app.route('/projects/:projectId([a-f0-9]{24})/create-packages')
.post(passport.authenticate('bearer', { session: false }), this.createProjectPackages)
app.route('/projects/:projectId([a-f0-9]{24})/reset-packages')
.post(passport.authenticate('bearer', { session: false }), this.resetProjectPackages)
app.route('/projects/:projectId([a-f0-9]{24})/build-pdfs')
.post(passport.authenticate('bearer', { session: false }), this.buildProjectPDFs)
app.route('/projects/:_id([a-f0-9]{24})')
.get(passport.authenticate('bearer', { session: false }), this.getProject)
.delete(passport.authenticate('bearer', { session: false }), this.deleteProject)
app.route('/projects/import-client-data')
.post(passport.authenticate('bearer', { session: false }), this.importProjectClientData)
app.route('/projects/:_id([a-f0-9]{24})/populated')
.get(passport.authenticate('bearer', { session: false }), this.getPopulatedProject)
app.route('/projects/dashboard')
.get(passport.authenticate('bearer', { session: false }), this.listDashboardProjects)
app.route('/projects/broker/:_id([a-f0-9]{24})')
.get(passport.authenticate('bearer', { session: false }), this.listBrokerProjects)
}
listProjects(req, res, next) {
const Project = this.db.Project
let limit = req.params.limit || 20
let skip = req.params.skip || 0
let partial = !!req.params.partial
let branch = req.params.branch
let query = {}
if (branch) {
query.branch = branch
}
Project.count({}).then((total) => {
let projects = []
let cursor = Project.find(query).limit(limit).skip(skip).cursor().map((doc) => {
return doc.toClient(partial)
})
cursor.on('data', (doc) => {
projects.push(doc)
})
cursor.on('end', () => {
res.json({
total: total,
offset: skip,
count: projects.length,
items: projects
})
})
cursor.on('error', (err) => {
next(createError.InternalServerError(err.message))
})
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
listDashboardProjects(req, res, next) {
const Project = this.db.Project
let projects = []
let cursor = Project.find({}).select('_id name fingerprint branch').populate({
path: 'branch', select: '_id name fingerprint corporation', populate: {
path: 'corporation', select: '_id name imageId fingerprint'
}
}).cursor()
cursor.on('data', (project) => {
projects.push(project)
})
cursor.on('end', () => {
res.json({
total: projects.length,
offset: 0,
count: projects.length,
items: projects
})
})
cursor.on('error', (err) => {
next(createError.InternalServerError(err.message))
})
}
listBrokerProjects(req, res, next) {
const brokerId = req.params._id
const Project = this.db.Project
let projects = []
let cursor = Project.find({ brokers: brokerId }).select('_id name clientData').cursor()
cursor.on('data', (project) => {
projects.push(project)
})
cursor.on('end', () => {
res.json({
total: projects.length,
offset: 0,
count: projects.length,
items: projects
})
})
cursor.on('error', (err) => {
next(createError.InternalServerError(err.message))
})
}
createProject(req, res, next) {
const role = req.user.role
// If user's role is not Executive or Administrator, return an error
if (role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
// Create a new Project template then assign it to a value in the req.body
const Project = this.db.Project
let project = new Project(req.body)
project.fingerprint = makeFingerprint(project.name)
// Save the project (with promise) - If it doesnt, catch and throw error
project.save().then((newProject) => {
res.json(newProject.toClient())
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
updateProject(req, res, next) {
const role = req.user.role
// If user's role is not Executive or Administrator, return an error
if (role !== 'executive' && role !== 'administrator') {
return new createError.Forbidden()
}
// Do this here because Mongoose will add it automatically otherwise
if (!req.body._id) {
return next(createError.BadRequest('No _id given in body'))
}
let Project = this.db.Project
let projectUpdates = null
try {
projectUpdates = new Project(req.body)
} catch (err) {
return next(createError.BadRequest('Invalid data'))
}
if (projectUpdates.name) {
projectUpdates.fingerprint = makeFingerprint(projectUpdates.name)
}
Project.findById(projectUpdates._id).then((foundProject) => {
if (!foundProject) {
return next(createError.NotFound(`Project with _id ${_id} was not found`))
}
foundProject.merge(projectUpdates)
return foundProject.save()
}).then((savedProject) => {
res.json(savedProject.toClient())
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
importProjectClientData(req, res, next) {
const role = req.user.role
if (role !== 'broker' && role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
const { _id, brokerId, assetId } = req.body
if (!_id || !brokerId || !assetId) {
return next(createError.BadRequest('Must specify _id, brokerId and assetId'))
}
this.mq.request('dar-import', 'importClientData', {
projectId: _id,
brokerId: brokerId,
assetId: assetId,
passback: {
rooms: [ req.user._id ],
projectId: _id,
brokerId: brokerId,
routerName: 'projectRoutes',
funcName: 'completeImportClientData'
}
}).then((correlationId) => {
res.json({ correlationId })
}).catch((err) => {
// TODO: Should delete the asset
next(createError.InternalServerError('Unable to import uploaded file to project'))
})
}
completeImportClientData(passback, error, data) {
if (!passback.rooms) {
return
}
let obj = {
brokerId: passback.brokerId,
projectId: passback.projectId,
}
if (error) {
obj.problems = error.problems
obj.error = error.message
}
this.ws.notify(passback.rooms, 'clientDataImportComplete', obj)
}
getProject(req, res, next) {
const Project = this.db.Project
const _id = req.params._id
Project.findById(_id).then((project) => {
if (!project) {
return next(createError.NotFound(`Project with _id ${_id} not found`))
}
res.json(project.toClient())
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
getPopulatedProject(req, res, next) {
const Project = this.db.Project
const _id = req.params._id
Project.findById(_id).then((project) => {
if (!project) {
return next(createError.NotFound(`Project with _id ${_id} not found`))
}
return project.populate({ path: 'brokers', select: '_id firstName lastName email thumbnailImageId t12 aum numHouseholds homePhone cellPhone' }).execPopulate()
}).then((project) => {
res.json(project.toClient())
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
getProjectBrokerClientData(req, res, next) {
const Project = this.db.Project
const _id = req.params._id
const brokerId = req.params.brokerId
Project.findById(_id).select('clientData').then((project) => {
if (!project) {
return next(createError.NotFound(`Project with _id ${_id} not found`))
}
if (!project.clientData) {
return res.json({})
}
const clientData = project.clientData.find(data => data.brokerId.toString() === brokerId)
if (clientData) {
res.json(clientData)
} else {
res.json({})
}
}).catch((error) => {
next(createError.InternalServerError(err.message))
})
}
signOffProjectBrokerClientData(req, res, next) {
const Project = this.db.Project
const _id = req.params._id
const brokerId = req.params.brokerId
Project.findById(_id).then((project) => {
if (!project) {
return next(createError.NotFound(`Project with _id ${_id} not found`))
}
for (let clientData of project.clientData) {
if (clientData.brokerId && clientData.brokerId.toString() === brokerId) {
if (clientData.submitted || clientData.numProblems !== 0 || clientData.problems.length !== 0 || clientData.error) {
return next(createError.BadRequest(`Project ${_id}, broker ${brokerId} cannot be signed off at this time`))
}
clientData.submitted = new Date()
return project.save(project)
}
}
return next(createError.NotFound(`Client data for broker ${brokerId} was not found`))
}).then((savedProject) => {
res.json({})
}).catch((error) => {
next(createError.InternalServerError(error.message))
})
}
createProjectPackages(req, res, next) {
const role = req.user.role
if (role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
const { projectId } = req.params
this.mq.request('dar-import', 'createPackages', {
projectId,
passback: {
rooms: [ req.user._id ], // TODO: Add a room for the specific project
projectId,
routerName: 'projectRoutes',
funcName: 'completeCreatePackageData'
}
}).then((correlationId) => {
res.json({ correlationId })
}).catch((err) => {
next(createError.InternalServerError('Unable to create package data'))
})
}
resetProjectPackages(req, res, next) {
const role = req.user.role
if (role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
const { projectId } = req.params
const Package = this.db.Package
const cursor = Package.find({ project: projectId }).cursor()
let totalPDFs = 0
let totalPackages = 0
new Promise((resolve, reject) => {
cursor.on('data', (pkg) => {
pkg.remove().then((pkg) => {
if (pkg.assetId) {
totalPDFs += 1
return this.db.gridfs.remove({ _id: pkg.assetId })
}
}).catch((error) => {
reject(error)
})
totalPackages += 1
})
cursor.on('end', () => {
resolve()
})
cursor.on('error', (error) => {
reject(error)
})
}).then(() => {
res.json({ totalPackages, totalPDFs })
}).catch((error) => {
next(createError.InternalServerError('Unable to delete all project packages'))
})
}
completeCreatePackageData(passback, error, data) {
if (!passback.rooms) {
return
}
let obj = {
projectId: passback.projectId,
}
if (error) {
obj.problems = error.problems
obj.error = error.message
}
this.ws.notify(passback.rooms, 'packageGenerationComplete', obj)
}
buildProjectPDFs(req, res, next) {
const role = req.user.role
if (role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
const projectId = req.params.projectId
const Package = this.db.Package
let total = 0
let cursor = Package.find({ project: projectId }).cursor()
cursor.on('data', (pkg) => {
const packageId = pkg._id
this.mq.request('dar-pdf', 'createPackagePDF', {
packageId,
passback: {
rooms: [ req.user._id ], // TODO: Add a room for the specific project
packageId,
routerName: 'projectRoutes',
funcName: 'completeCreatePackagePDF'
}
}).then((correlationId) => {
res.json({ correlationId })
}).catch((err) => {
// TODO: Collect errors and return to user
})
total += 1
})
cursor.on('end', () => {
res.json({ total })
})
cursor.on('error', (err) => {
next(createError.InternalServerError(err.message))
})
}
completeCreatePackagePDF(passback, error, data) {
if (!passback.rooms) {
return
}
let obj = {
packageId: passback.packageId,
}
if (error) {
obj.problems = error.problems
obj.error = error.message
} else {
obj.assetId = data.assetId
}
this.ws.notify(passback.rooms, 'createPackagePDFComplete', obj)
}
deleteProject(req, res, next) {
const role = req.user.role
// If user's role is not Executive or Administrator, return an error
if (role !== 'executive' && role !== 'administrator') {
return new createError.Forbidden()
}
const Project = this.db.Project
const _id = req.params._id
Project.remove({ _id }).then((project) => {
if (!project) {
return next(createError.NotFound(`Project with _id ${_id} not found`))
}
res.json({})
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
}

View File

@@ -0,0 +1,339 @@
import passport from 'passport'
import createError from 'http-errors'
import crypto from 'crypto'
import urlSafeBase64 from 'urlsafe-base64'
import url from 'url'
import util from 'util'
import autoBind from 'auto-bind2'
import config from 'config'
export class UserRoutes {
constructor(container) {
const app = container.app
this.log = container.log
this.db = container.db
this.mq = container.mq
this.ws = container.ws
this.maxEmailTokenAgeInHours = config.get('email.maxEmailTokenAgeInHours')
this.sendEmail = config.get('email.sendEmail')
autoBind(this)
app.route('/users')
.get(passport.authenticate('bearer', { session: false }), this.listUsers)
// Add a new user, send email confirmation email
.post(passport.authenticate('bearer', { session: false }), this.createUser)
.put(passport.authenticate('bearer', { session: false }), this.updateUser)
app.route('/users/brokers')
.get(passport.authenticate('bearer', { session: false }), this.listBrokerUsers)
app.route('/users/:_id([a-f0-9]{24})')
.get(passport.authenticate('bearer', { session: false }), this.getUser)
.delete(passport.authenticate('bearer', { session: false }), this.deleteUser)
app.route('/users/set-image')
.put(passport.authenticate('bearer', { session: false }), this.setImage)
app.route('/users/enter-room/:roomName')
.put(passport.authenticate('bearer', { session: false }), this.enterRoom)
app.route('/users/leave-room')
.put(passport.authenticate('bearer', { session: false }), this.leaveRoom)
}
listUsers(req, res, next) {
const User = this.db.User
const limit = req.params.limit || 20
const skip = req.params.skip || 0
const role = req.user.role
if (role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
User.count({}).then((total) => {
let users = []
let cursor = User.find({}).limit(limit).skip(skip).cursor().map((doc) => {
return doc.toClient(req.user)
})
cursor.on('data', (doc) => {
users.push(doc)
})
cursor.on('end', () => {
res.json({
total: total,
offset: skip,
count: users.length,
items: users
})
})
cursor.on('error', (err) => {
next(createError.InternalServerError(err.message))
})
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
listBrokerUsers(req, res, next) {
let User = this.db.User
const role = req.user.role
if (role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
let users = []
let cursor = User.find({ role: 'broker' })
.select('_id firstName lastName thumbnailImageId t12 aum numHouseholds cellPhone').cursor()
cursor.on('data', (doc) => {
users.push(doc)
})
cursor.on('end', () => {
res.json({
total: users.length,
offset: 0,
count: users.length,
items: users
})
})
cursor.on('error', (err) => {
next(createError.InternalServerError(err.message))
})
}
getUser(req, res, next) {
let User = this.db.User
const _id = req.params._id
const isSelf = (_id === req.user._id)
// User can see themselves, otherwise must be super user
if (!isSelf && role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
User.findById(_id).then((user) => {
if (!user) {
return Promise.reject(createError.NotFound(`User with _id ${_id} was not found`))
}
res.json(user.toClient(req.user))
}).catch((err) => {
if (err instanceof createError.HttpError) {
next(err)
} else {
next(createError.InternalServerError(err.message))
}
})
}
createUser(req, res, next) {
const role = req.user.role
if (role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
let User = this.db.User
let user = new User(req.body)
// Add email confirmation required token
util.promisify(crypto.randomBytes)(32).then((buf) => {
user.emailToken = {
value: urlSafeBase64.encode(buf),
created: new Date()
}
return user.save()
}).then((savedUser) => {
const userFullName = `${savedUser.firstName} ${savedUser.lastName}`
const senderFullName = `${req.user.firstName} ${req.user.lastName}`
const siteUrl = url.parse(req.headers.referer)
const msg = {
toEmail: savedUser.email,
templateName: 'welcome',
templateData: {
recipientFullName: userFullName,
senderFullName: senderFullName,
confirmEmailLink: `${siteUrl.protocol}//${siteUrl.host}/confirm-email?email-token%3D${savedUser.emailToken.value}`,
confirmEmailLinkExpirationHours: this.maxEmailTokenAgeInHours,
supportEmail: this.supportEmail
}
}
res.json(savedUser.toClient())
return this.sendEmail ? this.mq.request('dar-email', 'sendEmail', msg) : Promise.resolve()
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
updateUser(req, res, next) {
const role = req.user.role
// Do this here because Mongoose will add it automatically otherwise
if (!req.body._id) {
return next(createError.BadRequest('No user _id given in body'))
}
const isSelf = (req.body._id === req.user._id.toString())
// User can change themselves, otherwise must be super user
if (!isSelf && role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
const User = this.db.User
let userUpdates = null
try {
userUpdates = new User(req.body)
} catch (err) {
return next(createError.BadRequest('Invalid data'))
}
if (isSelf && userUpdates.role && userUpdates.role !== req.user.role) {
return next(createError.BadRequest('Cannot modify own role'))
}
User.findById(userUpdates._id).then((foundUser) => {
if (!foundUser) {
return Promise.reject(createError.NotFound(`User with _id ${user._id} was not found`))
}
// We don't allow direct updates to the email field so remove it if present
delete userUpdates.email
foundUser.merge(userUpdates)
return foundUser.save()
}).then((savedUser) => {
res.json(savedUser.toClient(req.user))
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
setImage(req, res, next) {
const role = req.user.role
const { _id, imageId } = req.body
if (!_id || !imageId) {
return next(createError.BadRequest('Must specify _id and imageId'))
}
const isSelf = (_id === req.user._id.toString())
// User can change themselves, otherwise must be super user
if (!isSelf && role !== 'executive' && role !== 'administrator') {
return next(new createError.Forbidden())
}
const { bigSize = {}, smallSize = {} } = req.body
Promise.all([
this.mq.request('dar-image', 'scaleImage', {
newWidth: bigSize.width || 200,
newHeight: bigSize.height || 200,
scaleMode: 'aspectFill',
inputAssetId: imageId,
passback: {
userId: _id,
rooms: [ req.user._id ],
routerName: 'userRoutes',
funcName: 'completeSetBigImage'
}
}),
this.mq.request('dar-image', 'scaleImage', {
newWidth: smallSize.width || 25,
newHeight: smallSize.height || 25,
scaleMode: 'aspectFill',
inputAssetId: imageId,
passback: {
userId: _id,
rooms: [ req.user._id, 'users' ],
routerName: 'userRoutes',
funcName: 'completeSetSmallImage'
}
})
]).then((correlationIds) => {
res.json({ correlationIds })
}).catch((err) => {
next(createError.InternalServerError('Unable to scale user images'))
})
}
completeSetBigImage(passback, error, data) {
if (error || !passback.userId || !passback.rooms) {
return
}
const User = this.db.User
User.findByIdAndUpdate(passback.userId, { imageId: data.outputAssetId }).then((foundUser) => {
if (foundUser) {
this.ws.notify(passback.rooms, 'newProfileImage', { imageId: data.outputAssetId })
}
}).catch((err) => {
this.log.error(`Unable to notify [${passback.rooms.join(', ')}] of new image'`)
})
}
completeSetSmallImage(passback, error, data) {
if (error || !passback.userId || !passback.rooms) {
return
}
const User = this.db.User
User.findByIdAndUpdate(passback.userId, { thumbnailImageId: data.outputAssetId }).then((foundUser) => {
if (foundUser) {
this.ws.notify(passback.rooms, 'newThumbnailImage', { imageId: data.outputAssetId })
}
}).catch((err) => {
this.log.error(`Unable to notify [${passback.rooms.join(', ')}] of new thumbnail image'`)
})
}
enterRoom(req, res, next) {
this.ws.enterRoom(req.user._id, req.params.roomName)
res.json({})
}
leaveRoom(req, res, next) {
this.ws.enterRoom(req.user._id)
res.json({})
}
deleteUser(req, res, next) {
const role = req.user.role
if (role !== 'executive' && role !== 'administrator') {
return new createError.Forbidden()
}
let User = this.db.User
const _id = req.params._id
User.remove({ _id }).then((deletedUser) => {
if (!deletedUser) {
return next(createError.NotFound(`User with _id ${_id} was not found`))
}
const userFullName = `${deletedUser.firstName} ${deletedUser.lastName}`
const senderFullName = `${req.user.firstName} ${req.user.lastName}`
const msg = {
toEmail: deletedUser.newEmail,
templateName: 'accountDeleted',
templateData: {
recipientFullName: userFullName,
senderFullName: senderFullName,
supportEmail: this.supportEmail
}
}
return this.sendEmail ? this.mq.request('dar-email', 'sendEmail', msg) : Promise.resolve()
}).then(() => {
res.json({})
}).catch((err) => {
next(createError.InternalServerError(err.message))
})
}
}

View File

@@ -0,0 +1,3 @@
export { AuthRoutes } from './AuthRoutes'
export { AssetRoutes } from './AssetRoutes'
export { UserRoutes } from './UserRoutes'

View File

@@ -0,0 +1,37 @@
import jwt from 'jsonwebtoken'
import config from 'config'
import crypto from 'crypto'
let key = config.get('api.loginKey')
if (key == null || key == '*') {
key = crypto.randomBytes(32).toString('hex')
}
export function pack(id, email) {
let payload = {
prn: email,
jti: id.toString()
}
if (process.env.NODE_ENV == 'production') {
payload.exp = Math.floor(Date.now() / 1000) + (60 * 60)
}
// TODO: For performance this should return a promise and sign async
return jwt.sign(payload, key)
}
export function unpack(token) {
// TODO: For performance return promise and verify async
try {
let decoded = jwt.verify(token, key)
} catch (err) {
return null
}
return {
id: decoded.prn,
email: decoded.jti
}
}

50
server/src/database/DB.js Normal file
View File

@@ -0,0 +1,50 @@
import mongoose from 'mongoose'
import mongodb from 'mongodb'
import Grid from 'gridfs-stream'
import merge from 'mongoose-merge-plugin'
import autoBind from 'auto-bind2'
import * as Schemas from './schemas'
import util from 'util'
Grid.mongo = mongoose.mongo
export class DB {
constructor() {
mongoose.Promise = Promise
mongoose.plugin(merge)
autoBind(this)
}
connect(mongoUri, isProduction) {
return mongoose.connect(mongoUri, { useMongoClient: true, config: { autoIndex: !isProduction } }).then((connection) => {
this.connection = connection
this.gridfs = Grid(connection.db)
this.gridfs.findOneAsync = util.promisify(this.gridfs.findOne)
this.gridfs.removeAsync = util.promisify(this.gridfs.remove)
this.User = connection.model('User', Schemas.userSchema)
this.WorkItem = connection.model('WorkItem', Schemas.workItemSchema)
return Promise.resolve(this)
})
}
newObjectId(s) {
// If s is undefined, then a new ObjectID is created, else s is assumed to be a parsable ObjectID
return new mongodb.ObjectID(s).toString()
}
lookupToken(token, done) {
this.User.findOne({ 'loginToken': token }).then((user) => {
if (!user) {
done(null, false)
} else {
done(null, user)
}
}).catch((err) => {
done(err)
})
}
}

View File

@@ -0,0 +1 @@
export { DB } from './DB'

View File

@@ -0,0 +1,3 @@
export { workItemSchema } from './workItem'
export { userSchema } from './user'
export { teamSchema } from './team'

View File

@@ -0,0 +1,6 @@
import { Schema } from 'mongoose'
export let team = new Schema({
name: { type: String },
members: { type: Schema.Types.ObjectId, required: true },
}, { timestamps: true, id: false })

View File

@@ -0,0 +1,73 @@
import { Schema } from 'mongoose'
import { regExpPattern } from 'regexp-pattern'
export let userSchema = new Schema({
_id: { type: Schema.Types.ObjectId, required: true, auto: true },
loginToken: { type: String, index: true, unique: true, sparse: true },
passwordHash: {
type: {
hash: String,
salt: String,
keyLength: Number,
hashMethod: String,
iterations: Number
}
},
email: { type: String, match: regExpPattern.email, required: true, index: true, unique: true },
newEmail: { type: String, match: regExpPattern.email },
imageId: { type: Schema.Types.ObjectId },
thumbnailImageId: { type: Schema.Types.ObjectId },
emailToken: {
type: {
value: { type: String, index: true, unique: true, sparse: true },
created: Date
}
},
passwordToken: {
type: {
value: { type: String, index: true, unique: true, sparse: true },
created: Date
}
},
firstName: { type: String, required: true },
lastName: { type: String, required: true },
role: { type: String, required: true, enum: {
values: [ 'administrator', 'normal'],
message: 'enum validator failed for path `{PATH}` with value `{VALUE}`'
}},
}, { timestamps: true, id: false })
userSchema.methods.toClient = function(authUser) {
if (authUser === undefined) {
authUser = this
}
let user = {
_id: this._id,
email: this.email,
emailValidated: (!!this.emailToken !== true),
imageId: this.imageId,
thumbnailImageId: this.thumbnailImageId,
firstName: this.firstName,
lastName: this.lastName,
role: this.role
}
if ((authUser.role === 'administrator' || authUser.role === 'executive') || authUser._id.equals(this._id)) {
user.zip = this.zip
user.state = this.state
user.city = this.city
user.address1 = this.address1
user.address2 = this.address2
user.homePhone = this.homePhone
user.cellPhone = this.cellPhone
user.ssn = this.ssn
user.dateOfBirth = this.dateOfBirth
user.dateOfHire = this.dateOfHire
user.numHouseholds = this.numHouseholds
user.t12 = this.t12
user.aum = this.aum
}
return user
}

View File

@@ -0,0 +1,17 @@
import { Schema } from 'mongoose'
import { regExpPattern } from 'regexp-pattern'
export let workItemSchema = new Schema({
_id: { type: Schema.Types.ObjectId, required: true, auto: true },
workItemType: { type: String, required: true, enum: {
values: [ 'order', 'inspection', 'complaint'],
message: 'enum validator failed for path `{PATH}` with value `{VALUE}`'
}},
}, { timestamps: true, id: false })
workItemSchema.methods.toClient = function() {
return {
_id: this._id,
workItemType: this.workItemType
}
}

View File

@@ -0,0 +1,88 @@
import fs from 'fs'
import util from 'util'
import path from 'path'
import createError from 'http-errors'
import nodemailer from 'nodemailer'
import handlebars from 'handlebars'
import appRoot from 'app-root-path'
import JSON5 from 'json5'
import aws from 'aws-sdk'
import config from 'config'
import autoBind from 'auto-bind2'
export class EmailHandlers {
constructor(container) {
this.log = container.log
const templatesDir = path.join(appRoot.toString(), '/config/templates')
const templatesFile = path.join(templatesDir, 'templates.json5')
const defs = JSON5.parse(fs.readFileSync(templatesFile))
this.templates = {}
for (let name in defs) {
const def = defs[name]
const textFilename = path.join(templatesDir, def.text)
if (!fs.existsSync(textFilename)) {
this.log.error(`File '${textFilename}' specified in '${templatesFile}' does not exist`)
process.exit(-1)
}
this.templates[name] = {
heading: def.heading,
text: handlebars.compile(fs.readFileSync(textFilename).toString()),
html: def.html ? handlebars.compile(fs.readFileSync(def.html).toString()) : null
}
}
autoBind(this)
}
sendEmail(options) {
const {
toEmail,
templateName,
templateData
} = options
if (!toEmail || !templateName || !templateData) {
return Promise.reject(createError.BadRequest(`Must specify toEmail, templateName and templateData`))
}
// configure AWS SDK
aws.config = new aws.Config(config.get('awsConfig'));
// create Nodemailer SES transporter
let transporter = nodemailer.createTransport({
SES: new aws.SES({
apiVersion: '2010-12-01'
})
});
const senderEmail = config.get('senderEmail')
const template = this.templates[templateName]
if (!template) {
return Promise.reject(createError.BadRequest(`Template '${templateName}' was not found`))
}
let mailObj = {
from: senderEmail,
to: toEmail,
subject: template.heading,
text: template.text(templateData)
}
if (template.html) {
mailObj.html = template.html(templateData)
}
return transporter.sendMail(mailObj).then((info) => {
return Promise.resolve({
envelope: info.envelope,
messageId: info.messageId
})
})
}
}

40
server/src/email/index.js Normal file
View File

@@ -0,0 +1,40 @@
import config from 'config'
import pino from 'pino'
import * as pinoExpress from 'pino-pretty-express'
import createError from 'http-errors'
import { MS } from '../message-service'
import { EmailHandlers } from './EmailHandlers'
import path from 'path'
import fs from 'fs'
const serviceName = 'dar-email'
const isProduction = (process.env.NODE_ENV == 'production')
let log = null
if (isProduction) {
log = pino( { name: serviceName },
fs.createWriteStream(path.join(config.get('logDir'), serviceName + '.log'))
)
} else {
const pretty = pinoExpress.pretty({})
pretty.pipe(process.stdout)
log = pino({ name: serviceName }, pretty)
}
const ms = new MS(serviceName, { durable: true }, log)
let container = { ms, log }
const amqpUri = config.get('uri.amqp')
ms.connect(amqpUri).then(() => {
log.info(`Connected to RabbitMQ at ${amqpUri}`)
container = {
...container,
handlers: new EmailHandlers(container)
}
ms.listen(container.handlers)
}).catch((err) => {
log.error(isProduction ? err.message : err)
})

View File

@@ -0,0 +1,298 @@
import Canvas from 'canvas'
import fs from 'fs'
import util from 'util'
import createError from 'http-errors'
import autoBind from 'auto-bind2'
import stream from 'stream'
function streamToBuffer(readable) {
return new Promise((resolve, reject) => {
var chunks = []
var writeable = new stream.Writable()
writeable._write = function (chunk, enc, done) {
chunks.push(chunk)
done()
}
readable.on('end', function () {
resolve(Buffer.concat(chunks))
})
readable.on('error', (err) => {
reject(err)
})
readable.pipe(writeable);
})
}
function pipeToGridFS(readable, gfsWriteable) {
const promise = new Promise((resolve, reject) => {
readable.on('error', (error) => {
reject(error)
})
gfsWriteable.on('error', (error) => {
reject(error)
})
gfsWriteable.on('close', (file) => {
resolve(file)
})
})
readable.pipe(gfsWriteable)
return promise
}
function loadImage(buf) {
return new Promise((resolve, reject) => {
const image = new Canvas.Image()
function cleanup () {
image.onload = null
image.onerror = null
}
image.onload = () => {
cleanup();
resolve(image)
}
image.onerror = () => {
cleanup();
reject(new Error(`Failed to load the image "${buf}"`))
}
image.src = buf
})
}
// Derived from https://stackoverflow.com/questions/20600800/js-client-side-exif-orientation-rotate-and-mirror-jpeg-images
function getExifOrientation(buf) {
if (buf.length < 2 || buf.readUInt16BE(0) != 0xFFD8) {
return -2
}
let length = buf.byteLength
let offset = 2
while (offset < length) {
let marker = buf.readUInt16BE(offset)
offset += 2
if (marker == 0xFFE1) {
if (buf.readUInt32BE(offset += 2) != 0x45786966) {
return -1
}
let little = (buf.readUInt16BE(offset += 6) == 0x4949)
offset += (little ? buf.readUInt32LE(offset + 4) : buf.readUInt32BE(offset + 4))
const numTags = (little ? buf.readUInt16LE(offset) : buf.readUInt16BE(offset))
offset += 2
for (let i = 0; i < numTags; i++) {
let val = (little ? buf.readUInt16LE(offset + (i * 12)) : buf.readUInt16BE(offset + (i * 12)))
if (val === 0x0112) {
return (little ? buf.readUInt16LE(offset + (i * 12) + 8) : buf.readUInt16BE(offset + (i * 12) + 8))
}
}
} else if ((marker & 0xFF00) != 0xFF00) {
break
} else {
offset += buf.readUInt16BE(offset)
}
}
}
function normalizeOrientation(image, orientation) {
let width = image.width
let height = image.height
let canvas = new Canvas(width, height)
let ctx = canvas.getContext("2d")
if (4 < orientation && orientation < 9) {
canvas.width = height
canvas.height = width
} else {
canvas.width = width
canvas.height = height
}
switch (orientation) {
case 2: ctx.transform(-1, 0, 0, 1, width, 0); break
case 3: ctx.transform(-1, 0, 0, -1, width, height ); break
case 4: ctx.transform(1, 0, 0, -1, 0, height ); break
case 5: ctx.transform(0, 1, 1, 0, 0, 0); break
case 6: ctx.transform(0, 1, -1, 0, height , 0); break
case 7: ctx.transform(0, -1, -1, 0, height , width); break
case 8: ctx.transform(0, -1, 1, 0, 0, width); break
default: return Promise.resolve(image)
}
ctx.drawImage(image, 0, 0)
return loadImage(canvas.toBuffer())
}
export class ImageHandlers {
constructor(container) {
this.db = container.db
this.log = container.log
autoBind(this)
}
scaleImage(options) {
const {
newWidth = 400,
newHeight = 100,
scaleMode = 'scaleToFill',
inputFile,
inputAssetId,
outputFile
} = options
if (!inputFile && !inputAssetId) {
return Promise.reject(createError.BadRequest(`No inputAssetId or inputFile given`))
}
let canvas = new Canvas(newWidth, newHeight)
let ctx = canvas.getContext("2d")
ctx.imageSmoothingEnabled = true
let loadPromise = null
if (inputFile) {
loadPromise = util.promisify(fs.readFile)(inputFile)
} else {
loadPromise = streamToBuffer(this.db.gridfs.createReadStream({ _id: inputAssetId }))
}
let orientation
return loadPromise.then((buf) => {
orientation = getExifOrientation(buf)
return loadImage(buf)
}).then((img) => {
return normalizeOrientation(img, orientation)
}).then((img) => {
let x = 0
let y = 0
let scale = 1
switch (scaleMode) {
case 'aspectFill':
if (img.width - newWidth > img.height - newHeight) {
scale = newHeight / img.height
x = -(img.width * scale - newWidth) / 2
} else {
scale = newWidth / img.width
y = -(img.height * scale - newHeight) / 2
}
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
break
case 'aspectFit':
if (img.width - newWidth > img.height - newHeight) {
scale = newWidth / img.width
y = (newHeight - img.height * scale) / 2
} else {
scale = newHeight / img.height
x = (newWidth - img.width * scale) / 2
}
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
break
case 'scaleToFill':
default:
ctx.drawImage(img, 0, 0, newWidth, newHeight)
break
}
let savePromise = null
let readable = canvas.createPNGStream()
let writeable = null
if (outputFile) {
writeable = fs.createWriteStream(outputFile)
} else {
const _id = this.db.newObjectId()
writeable = this.db.gridfs.createWriteStream({
_id,
filename: _id + '.png',
content_type: 'image/png',
metadata: {
scaledFrom: this.db.newObjectId(inputAssetId),
width: newWidth,
height: newHeight
}
})
}
return pipeToGridFS(readable, writeable).then((file) => {
let res = {}
if (outputFile) {
res.outputFile = outputFile
} else if (file) {
res.outputAssetId = file._id
}
return Promise.resolve(res)
})
})
}
createPlaceholder(options) {
const {
width = 400,
height = 100,
fontSize = 36,
fontName = 'helvetica neue, arial black, sans serif',
fontWeight = 'bold',
background = '#1B1B1B',
foreground = '#333333',
outputFile
} = options
let canvas = new Canvas(width, height)
let ctx = canvas.getContext("2d")
const text = `${width}x${height}`
ctx.fillStyle = background
ctx.fillRect(0, 0, width, height)
ctx.font = `${fontWeight} ${fontSize}px ${fontName}`
ctx.fillStyle = foreground
const tm = ctx.measureText(text)
if (tm.width <= width / 2 && fontSize <= height / 2) {
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(text, width / 2, height / 2, width)
}
let promise = null
let readable = canvas.createPNGStream()
let writeable = null
if (outputFile) {
writeable = fs.createWriteStream(outputFile)
} else {
const _id = this.db.newObjectId()
writeable = this.db.gridfs.createWriteStream({
_id,
filename: `${width}x${height}.png`,
content_type: 'image/png',
metadata: {
width: width,
height: height,
}
})
}
return pipeToGridFS(readable, writeable).then((file) => {
let res = {}
if (outputFile) {
res.outputFile = outputFile
} else if (file) {
res.outputAssetId = file._id
}
return Promise.resolve(res)
})
}
}

45
server/src/image/index.js Normal file
View File

@@ -0,0 +1,45 @@
import config from 'config'
import pino from 'pino'
import * as pinoExpress from 'pino-pretty-express'
import { DB } from '../database'
import { MS } from '../message-service'
import { ImageHandlers } from './ImageHandlers'
import path from 'path'
import fs from 'fs'
const serviceName = 'dar-image'
const isProduction = (process.env.NODE_ENV == 'production')
let log = null
if (isProduction) {
log = pino( { name: serviceName },
fs.createWriteStream(path.join(config.get('logDir'), serviceName + '.log'))
)
} else {
const pretty = pinoExpress.pretty({})
pretty.pipe(process.stdout)
log = pino({ name: serviceName }, pretty)
}
const ms = new MS(serviceName, { durable: false }, log)
const db = new DB()
let container = { db, ms, log }
const mongoUri = config.get('uri.mongo')
const amqpUri = config.get('uri.amqp')
Promise.all([
db.connect(mongoUri),
ms.connect(amqpUri)
]).then(() => {
log.info(`Connected to MongoDB at ${mongoUri}`)
log.info(`Connected to RabbitMQ at ${amqpUri}`)
container = {
...container,
handlers: new ImageHandlers(container)
}
ms.listen(container.handlers)
}).catch((err) => {
log.error(isProduction ? err.message : err)
})

View File

@@ -0,0 +1,114 @@
import amqp from 'amqplib'
import autoBind from 'auto-bind2'
import createError from 'http-errors'
export class MS {
constructor(exchangeName, options, log) {
this.exchangeName = exchangeName
this.options = options || {}
this.isProduction = (process.env.NODE_ENV === 'production')
this.log = log
autoBind(this)
}
async connect(amqpUri) {
this.connection = await amqp.connect(amqpUri)
this.connection.on('error', () => {
this.log.error(`RabbitMQ has gone, shutting down service`)
process.exit(-1)
})
this.channel = await this.connection.createChannel()
this.channel.prefetch(1) // Only process one message at a time
return this
}
async listen(obj) {
let handlers = {}
let typeNames = ''
for (const key of Object.getOwnPropertyNames(obj.constructor.prototype)) {
const val = obj[key]
if (key !== 'constructor' && typeof val === 'function') {
handlers[key] = val
if (!typeNames) {
typeNames = `'${key}'`
} else {
typeNames += ', ' + `'${key}'`
}
}
}
this.handlers = handlers
let ok = await this.channel.assertExchange(this.exchangeName, 'fanout', { durable: !!this.options.durable })
const q = await this.channel.assertQueue('', {exclusive: true})
this.log.info(`Waiting for '${this.exchangeName}' exchange ${typeNames} messages in queue '${q.queue}'`)
await this.channel.bindQueue(q.queue, this.exchangeName, '')
this.channel.consume(q.queue, this.consumeMessage)
}
consumeMessage(msg) {
const { type, appId, replyTo, correlationId } = msg.properties
const s = msg.content.toString()
const content = JSON.parse(s)
this.log.info(`Received '${type}' from '${appId}', ${s}`)
const sendReply = (replyContent) => {
if (content.passback) {
replyContent = { ...replyContent, passback: content.passback }
}
this.channel.sendToQueue(replyTo, new Buffer(JSON.stringify(replyContent)), {
correlationId,
appId,
contentType: 'application/json',
timestamp: Date.now(),
type: `replyTo.${type}`
})
}
this.dispatchMessage(type, content).then((res) => {
sendReply({ data: res })
this.channel.ack(msg)
this.log.info(`Processed '${type}' (correlation id '${correlationId}')`)
}).catch((err) => {
this.log.error(`Failed to process '${type}' (correlation id '${correlationId}')`)
if (!this.isProduction) {
// So we can see what happened
console.error(err)
}
sendReply({ error: { name: err.name, message: err.message, problems: err.problems } })
this.channel.ack(msg)
})
}
dispatchMessage(type, content) {
const handler = this.handlers[type]
if (handler) {
return handler(content)
} else {
return Promise.reject(createError.BadRequest(`Unknown message type '${type}'`))
}
}
// Used for intra-service requests, such as when generating packages
async request(exchangeName, msgType, msg, correlationId) {
const channel = await this.connection.createChannel()
await channel.checkExchange(exchangeName)
await channel.publish(exchangeName, '', new Buffer(JSON.stringify(msg)), {
type: msgType,
contentType: 'application/json',
timestamp: Date.now(),
correlationId,
appId: this.appId,
replyTo: this.replyQueueName
})
await channel.close()
return correlationId
}
}

View File

@@ -0,0 +1 @@
export { MS } from './MS'

9
server/src/server.js Normal file
View File

@@ -0,0 +1,9 @@
import childProcess from 'child_process'
const actors = [ 'api', 'email', 'image' ]
// TODO: spawn index.js in each of these sub-directories
// TODO: If any child exits, wait for a back-off period and then restart
// TODO: Gradually increase back-off period if process fails again too quickly

30
website/.editorconfig Normal file
View File

@@ -0,0 +1,30 @@
# http://editorconfig.org
# A special property that should be specified at the top of the file outside of
# any sections. Set to true to stop .editor config file search on current file
root = true
[*]
# Indentation style
# Possible values - tab, space
indent_style = space
# Indentation size in single-spaced characters
# Possible values - an integer, tab
indent_size = 2
# Line ending file format
# Possible values - lf, crlf, cr
end_of_line = lf
# File character encoding
# Possible values - latin1, utf-8, utf-16be, utf-16le
charset = utf-8
# Denotes whether to trim whitespace at the end of lines
# Possible values - true, false
trim_trailing_whitespace = true
# Denotes whether file should end with a newline
# Possible values - true, false
insert_final_newline = true

5
website/.eslintignore Normal file
View File

@@ -0,0 +1,5 @@
blueprints/**/files/**
coverage/**
node_modules/**
dist/**
src/index.html

27
website/.eslintrc Normal file
View File

@@ -0,0 +1,27 @@
{
"parser": "babel-eslint",
"extends": [
"standard",
"standard-react"
],
"plugins": [
"babel",
"react",
"promise"
],
"env": {
"browser" : true
},
"globals": {
"__DEV__" : false,
"__TEST__" : false,
"__PROD__" : false,
"__COVERAGE__" : false
},
"rules": {
"key-spacing" : 0,
"jsx-quotes" : [2, "prefer-single"],
"object-curly-spacing" : [2, "always"],
"space-before-function-paren": ["error", "never"]
}
}

7
website/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
coverage/
dist/
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -0,0 +1,17 @@
{
"main.css": "static/css/main.b7f40099.css",
"main.css.map": "static/css/main.b7f40099.css.map",
"main.js": "static/js/main.0feff0e8.js",
"main.js.map": "static/js/main.0feff0e8.js.map",
"static/media/extensive-tracking.jpg": "static/media/extensive-tracking.30e779c7.jpg",
"static/media/flags.png": "static/media/flags.9c74e172.png",
"static/media/icons.eot": "static/media/icons.674f50d2.eot",
"static/media/icons.svg": "static/media/icons.912ec66d.svg",
"static/media/icons.ttf": "static/media/icons.b06871f2.ttf",
"static/media/icons.woff": "static/media/icons.fee66e71.woff",
"static/media/icons.woff2": "static/media/icons.af7ae505.woff2",
"static/media/masthead-main.png": "static/media/masthead-main.d693e54f.png",
"static/media/on-site-support.jpg": "static/media/on-site-support.33bd1985.jpg",
"static/media/package-building.jpg": "static/media/package-building.a6760708.jpg",
"static/media/pre-transitioning.jpg": "static/media/pre-transitioning.3c463a20.jpg"
}

BIN
website/build/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
website/build/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 B

1
website/build/index.html Normal file
View File

@@ -0,0 +1 @@
<!DOCTYPE html><html lang="en"><head><title>Transition Management Resources</title><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="shortcut icon" href="/favicon.png"><link href="/static/css/main.b7f40099.css" rel="stylesheet"></head><body><div id="root"></div><script type="text/javascript" src="/static/js/main.0feff0e8.js"></script></body></html>

View File

@@ -0,0 +1 @@
"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}var precacheConfig=[["index.html","126facc11926a6f959086e2959a7770d"],["static/css/main.b7f40099.css","4fd28be2ca811a2f8586205d31a8d4ce"],["static/js/main.0feff0e8.js","85f38f3b5f4c9732f4b87d6226f21b2a"],["static/media/extensive-tracking.30e779c7.jpg","30e779c7aeb3f0930f0be1bba2793e46"],["static/media/flags.9c74e172.png","9c74e172f87984c48ddf5c8108cabe67"],["static/media/icons.674f50d2.eot","674f50d287a8c48dc19ba404d20fe713"],["static/media/icons.912ec66d.svg","912ec66d7572ff821749319396470bde"],["static/media/icons.af7ae505.woff2","af7ae505a9eed503f8b8e6982036873e"],["static/media/icons.b06871f2.ttf","b06871f281fee6b241d60582ae9369b9"],["static/media/icons.fee66e71.woff","fee66e712a8a08eef5805a46892932ad"],["static/media/masthead-main.d693e54f.png","d693e54ff591fe3586a5892ff490b8f9"],["static/media/on-site-support.33bd1985.jpg","33bd198571ce8fc479bceb92ea9ac445"],["static/media/package-building.a6760708.jpg","a676070868179299328607a1f91bed11"],["static/media/pre-transitioning.3c463a20.jpg","3c463a203156bbfbfb1331d8f2f8164b"]],cacheName="sw-precache-v3-sw-precache-webpack-plugin-"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var n=new URL(e);return"/"===n.pathname.slice(-1)&&(n.pathname+=t),n.toString()},cleanResponse=function(e){return e.redirected?("body"in e?Promise.resolve(e.body):e.blob()).then(function(t){return new Response(t,{headers:e.headers,status:e.status,statusText:e.statusText})}):Promise.resolve(e)},createCacheKey=function(e,t,n,a){var r=new URL(e);return a&&r.pathname.match(a)||(r.search+=(r.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(n)),r.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var n=new URL(t).pathname;return e.some(function(e){return n.match(e)})},stripIgnoredUrlParameters=function(e,t){var n=new URL(e);return n.hash="",n.search=n.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),n.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],n=e[1],a=new URL(t,self.location),r=createCacheKey(a,hashParamName,n,/\.\w{8}\./);return[a.toString(),r]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(n){if(!t.has(n)){var a=new Request(n,{credentials:"same-origin"});return fetch(a).then(function(t){if(!t.ok)throw new Error("Request for "+n+" returned a response with status "+t.status);return cleanResponse(t).then(function(t){return e.put(n,t)})})}}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(n){return Promise.all(n.map(function(n){if(!t.has(n.url))return e.delete(n)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,n=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(n);t||(n=addDirectoryIndex(n,"index.html"),t=urlsToCacheKeys.has(n));!t&&"navigate"===e.request.mode&&isPathWhitelisted(["^(?!\\/__).*"],e.request.url)&&(n=new URL("/index.html",self.location).toString(),t=urlsToCacheKeys.has(n)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(n)).then(function(e){if(e)return e;throw Error("The cached response that was expected is missing.")})}).catch(function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 434 KiB

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