Initial commit
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
coverage/
|
||||||
|
dist/
|
||||||
|
scratch/
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
14
README.md
Normal 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.
|
||||||
BIN
design/Deighton AR System.docx
Normal file
84
design/Deighton AR System.svg
Normal 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 |
BIN
design/models/clipboard/cardboard2.jpg
Executable file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
design/models/clipboard/clipboard.fbx
Executable file
36
design/models/number_and_symbol/hdri_small.hdr
Executable file
BIN
design/models/number_and_symbol/number and symbol.FBX
Executable file
BIN
design/models/outdoor_trashcan/outdoortrashcan.fbx
Executable file
BIN
design/models/outdoor_trashcan/outdoortrashcan_AO.tga
Executable file
|
After Width: | Height: | Size: 4.0 MiB |
BIN
design/models/outdoor_trashcan/outdoortrashcan_BaseColor.tga
Executable file
|
After Width: | Height: | Size: 4.0 MiB |
BIN
design/models/outdoor_trashcan/outdoortrashcan_Metallic.tga
Executable file
BIN
design/models/outdoor_trashcan/outdoortrashcan_Normal.tga
Executable file
|
After Width: | Height: | Size: 4.0 MiB |
BIN
design/models/outdoor_trashcan/outdoortrashcan_Roughness.tga
Executable file
BIN
design/models/outdoor_trashcan/outdoortrashcan_marmoset.tbscene
Executable file
BIN
design/models/outdoor_trashcan/outdoortrashcan_wireframe_marmoset.tbscene
Executable file
BIN
design/models/trash_bin/trashbin.fbx
Executable file
BIN
design/models/trash_bin/trashbin_ao.tga
Executable file
|
After Width: | Height: | Size: 12 MiB |
BIN
design/models/trash_bin/trashbin_diffuse_black_noscratches.tga
Executable file
|
After Width: | Height: | Size: 12 MiB |
BIN
design/models/trash_bin/trashbin_diffuse_black_scratches.tga
Executable file
|
After Width: | Height: | Size: 12 MiB |
BIN
design/models/trash_bin/trashbin_diffuse_blue_noscratches.tga
Executable file
|
After Width: | Height: | Size: 12 MiB |
BIN
design/models/trash_bin/trashbin_diffuse_blue_scratches.tga
Executable file
|
After Width: | Height: | Size: 12 MiB |
BIN
design/models/trash_bin/trashbin_diffuse_green_noscratches.tga
Executable file
|
After Width: | Height: | Size: 12 MiB |
BIN
design/models/trash_bin/trashbin_diffuse_green_scratches.tga
Executable file
|
After Width: | Height: | Size: 12 MiB |
BIN
design/models/trash_bin/trashbin_gloss.tga
Executable file
|
After Width: | Height: | Size: 12 MiB |
BIN
design/models/trash_bin/trashbin_metalness.tga
Executable file
|
After Width: | Height: | Size: 12 MiB |
BIN
design/models/trash_bin/trashbin_normal.tga
Executable file
|
After Width: | Height: | Size: 12 MiB |
BIN
design/models/trash_bin/trashbin_specular.tga
Executable file
|
After Width: | Height: | Size: 12 MiB |
BIN
design/models/worker_helmet/TS_HDRI_Studio_Product.exr
Normal file
BIN
design/models/worker_helmet/WorkerHelmet.fbx
Normal file
8
mobile/.babelrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"presets": ["babel-preset-expo"],
|
||||||
|
"env": {
|
||||||
|
"development": {
|
||||||
|
"plugins": ["transform-react-jsx-source"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
mobile/.expo/packager-info.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"expoServerPort": 19000,
|
||||||
|
"packagerPort": 19001,
|
||||||
|
"packagerPid": 7732
|
||||||
|
}
|
||||||
7
mobile/.expo/settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"hostType": "tunnel",
|
||||||
|
"lanType": "ip",
|
||||||
|
"dev": true,
|
||||||
|
"minify": false,
|
||||||
|
"urlRandomness": null
|
||||||
|
}
|
||||||
75
mobile/.flowconfig
Normal 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
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
23
mobile/App.js
Normal 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
@@ -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
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"sdkVersion": "25.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
58
mobile/containers/LoginPage.js
Normal 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
@@ -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
23
package.json
Normal 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
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
[ "env", {
|
||||||
|
"targets": {
|
||||||
|
"node": 8
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"transform-class-properties",
|
||||||
|
"transform-object-rest-spread"
|
||||||
|
]
|
||||||
|
}
|
||||||
7
server/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
coverage/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
21
server/config/default.json5
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
7
server/config/local-development.json5
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
awsConfig: {
|
||||||
|
accessKeyId: 'AKIAJUP6XRVYDAXNTUNA',
|
||||||
|
secretAccessKey: 'hbZpkr9QLMivVK5oIGlnSa18ivqAYBPTdoUFYDqt',
|
||||||
|
region: 'us-west-2'
|
||||||
|
}
|
||||||
|
}
|
||||||
10
server/config/production.json5
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
logDir: '/var/log/deighton-ar',
|
||||||
|
api: {
|
||||||
|
port: '3001',
|
||||||
|
loginKey: '*',
|
||||||
|
uploadTimout: 120,
|
||||||
|
maxEmailTokenAgeInHours: 36,
|
||||||
|
sendEmailDelayInSeconds: 3
|
||||||
|
},
|
||||||
|
}
|
||||||
27
server/config/templates/README.md5
Normal 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
|
||||||
9
server/config/templates/accountDeleted.txt
Normal 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}}
|
||||||
13
server/config/templates/changeEmailNew.txt
Normal 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
|
||||||
11
server/config/templates/changeEmailOld.txt
Normal 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
|
||||||
11
server/config/templates/forgotPassword.txt
Normal 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
|
||||||
22
server/config/templates/templates.json5
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
15
server/config/templates/welcome.txt
Normal 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}}
|
||||||
15
server/deighton-ar.service
Normal 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
77
server/package.json
Normal 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
@@ -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
@@ -0,0 +1,7 @@
|
|||||||
|
.DS_STORE
|
||||||
|
*.log
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
**/local*.json*
|
||||||
|
.idea/
|
||||||
101
server/src/api/MQ.js
Normal 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
@@ -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
@@ -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
@@ -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)
|
||||||
|
})
|
||||||
199
server/src/api/routes/AssetRoutes.js
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
396
server/src/api/routes/AuthRoutes.js
Normal 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}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
493
server/src/api/routes/ProjectRoutes.js
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
339
server/src/api/routes/UserRoutes.js
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
3
server/src/api/routes/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { AuthRoutes } from './AuthRoutes'
|
||||||
|
export { AssetRoutes } from './AssetRoutes'
|
||||||
|
export { UserRoutes } from './UserRoutes'
|
||||||
37
server/src/api/routes/loginToken.js
Normal 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
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
1
server/src/database/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { DB } from './DB'
|
||||||
3
server/src/database/schemas/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { workItemSchema } from './workItem'
|
||||||
|
export { userSchema } from './user'
|
||||||
|
export { teamSchema } from './team'
|
||||||
6
server/src/database/schemas/team.js
Normal 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 })
|
||||||
73
server/src/database/schemas/user.js
Normal 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
|
||||||
|
}
|
||||||
17
server/src/database/schemas/workItem.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
88
server/src/email/EmailHandlers.js
Normal 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
@@ -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)
|
||||||
|
})
|
||||||
298
server/src/image/ImageHandlers.js
Normal 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
@@ -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)
|
||||||
|
})
|
||||||
114
server/src/message-service/MS.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
1
server/src/message-service/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { MS } from './MS'
|
||||||
9
server/src/server.js
Normal 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
@@ -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
@@ -0,0 +1,5 @@
|
|||||||
|
blueprints/**/files/**
|
||||||
|
coverage/**
|
||||||
|
node_modules/**
|
||||||
|
dist/**
|
||||||
|
src/index.html
|
||||||
27
website/.eslintrc
Normal 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
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
coverage/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
17
website/build/asset-manifest.json
Normal 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
|
After Width: | Height: | Size: 24 KiB |
BIN
website/build/favicon.png
Normal file
|
After Width: | Height: | Size: 910 B |
1
website/build/index.html
Normal 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>
|
||||||
1
website/build/service-worker.js
Normal 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)}))}});
|
||||||
355
website/build/static/css/main.b7f40099.css
Normal file
1
website/build/static/css/main.b7f40099.css.map
Normal file
2
website/build/static/js/main.0feff0e8.js
Normal file
1
website/build/static/js/main.0feff0e8.js.map
Normal file
BIN
website/build/static/media/extensive-tracking.30e779c7.jpg
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
website/build/static/media/flags.9c74e172.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
website/build/static/media/icons.674f50d2.eot
Normal file
2671
website/build/static/media/icons.912ec66d.svg
Normal file
|
After Width: | Height: | Size: 434 KiB |