Allow features to be disabled at build time (#143)

* Add framework for built-time feature selection
* Allow MQTT, NTP, OTA features to be disabled at build time
* Allow Project screens to be disabled at build time
* Allow security features to be disabled at build time
* Switch to std::function for StatefulService function aliases for greater flexibility
* Bump various UI lib versions
* Update docs
This commit is contained in:
rjwats 2020-06-09 21:57:44 +01:00 committed by GitHub
parent 88748ac30d
commit 449d3c91ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 800 additions and 193 deletions

View File

@ -2,7 +2,7 @@
[![Build Status](https://travis-ci.org/rjwats/esp8266-react.svg?branch=master)](https://travis-ci.org/rjwats/esp8266-react) [![Build Status](https://travis-ci.org/rjwats/esp8266-react.svg?branch=master)](https://travis-ci.org/rjwats/esp8266-react)
A simple, secure and extensible framework for IoT projects built on ESP8266/ESP32 platforms with responsive React front-end. A simple, secure and extensible framework for IoT projects built on ESP8266/ESP32 platforms with responsive [React](https://reactjs.org/) front-end built with [Material-UI](https://material-ui.com/).
Designed to work with the PlatformIO IDE with [limited setup](#getting-started). Please read below for setup, build and upload instructions. Designed to work with the PlatformIO IDE with [limited setup](#getting-started). Please read below for setup, build and upload instructions.
@ -19,9 +19,7 @@ Provides many of the features required for IoT projects:
* Remote Firmware Updates - Enable secured OTA updates * Remote Firmware Updates - Enable secured OTA updates
* Security - Protected RESTful endpoints and a secured user interface * Security - Protected RESTful endpoints and a secured user interface
The back end is provided by a set of RESTful endpoints and the responsive React based front end is built using [Material-UI](https://material-ui.com/). Features may be [enabled or disabled](#selecting-features) as required at compile time.
The front end has the prerequisite manifest file and icon, so it can be added to the home screen of a mobile device if required.
## Getting Started ## Getting Started
@ -139,7 +137,7 @@ REACT_APP_HTTP_ROOT=http://192.168.0.99
REACT_APP_WEB_SOCKET_ROOT=ws://192.168.0.99 REACT_APP_WEB_SOCKET_ROOT=ws://192.168.0.99
``` ```
The `REACT_APP_HTTP_ROOT` and `REACT_APP_WEB_SOCKET_ROOT` properties can be modified to point a ESP device running the back end firmware. The `REACT_APP_HTTP_ROOT` and `REACT_APP_WEB_SOCKET_ROOT` properties can be modified to point a ESP device running the back end.
> **Tip**: You must restart the development server for changes to the environment file to come into effect. > **Tip**: You must restart the development server for changes to the environment file to come into effect.
@ -152,9 +150,31 @@ You can enable CORS on the back end by uncommenting the -D ENABLE_CORS build fla
-D CORS_ORIGIN=\"http://localhost:3000\" -D CORS_ORIGIN=\"http://localhost:3000\"
``` ```
## Selecting features
Many of the framework's built in features may be enabled or disabled as required at compile time. This can help save sketch space and memory if your project does not require the full suite of features. The access point and WiFi management features are "core features" and are always enabled. Feature selection may be controlled with the build flags defined in [features.ini](features.ini).
Customize the settings as you see fit. A value of 0 will disable the specified feature:
```ini
-D FT_PROJECT=1
-D FT_SECURITY=1
-D FT_MQTT=1
-D FT_NTP=1
-D FT_OTA=1
```
Flag | Description
------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------
FT_PROJECT | Controls whether the "project" section of the UI is enabled. Disable this if you don't intend to have your own screens in the UI.
FT_SECURITY | Controls whether the [security features](#security-features) are enabled. Disabling this means you won't need to authenticate to access the device and all authentication predicates will be bypassed.
FT_MQTT | Controls whether the MQTT features are enabled. Disable this if your project does not require MQTT support.
FT_NTP | Controls whether network time protocol synchronization features are enabled. Disable this if your project does not require accurate time.
FT_OTA | Controls whether OTA update support is enabled. Disable this if you won't be using the remote update feature.
## Factory settings ## Factory settings
The firmware has built-in factory settings which act as default values for the various configurable services where settings are not saved on the file system. These settings can be overridden using the build flags defined in [factory_settings.ini](factory_settings.ini). The framework has built-in factory settings which act as default values for the various configurable services where settings are not saved on the file system. These settings can be overridden using the build flags defined in [factory_settings.ini](factory_settings.ini).
Customize the settings as you see fit, for example you might configure your home WiFi network as the factory default: Customize the settings as you see fit, for example you might configure your home WiFi network as the factory default:
@ -193,7 +213,7 @@ Changing factory time zone setting is a common requirement. This requires a litt
### Device ID factory defaults ### Device ID factory defaults
If not overridden with a build flag, the firmware will use the device ID to generate factory defaults for settings such as the JWT secret and MQTT client ID. If not overridden with a build flag, the framework will use the device ID to generate factory defaults for settings such as the JWT secret and MQTT client ID.
> **Tip**: Random values are generally better defaults for these settings, so it is recommended you leave these flags undefined. > **Tip**: Random values are generally better defaults for these settings, so it is recommended you leave these flags undefined.
@ -481,7 +501,7 @@ class LightStateService : public StatefulService<LightState> {
}; };
``` ```
Endpoint security is provided by authentication predicates which are [documented below](#security-features). The SecurityManager and authentication predicate may be provided if a secure endpoint is required. The demo project shows how endpoints can be secured. Endpoint security is provided by authentication predicates which are [documented below](#security-features). The SecurityManager and authentication predicate may be provided if a secure endpoint is required. The placeholder project shows how endpoints can be secured.
#### Persistence #### Persistence
@ -519,7 +539,7 @@ class LightStateService : public StatefulService<LightState> {
}; };
``` ```
WebSocket security is provided by authentication predicates which are [documented below](#security-features). The SecurityManager and authentication predicate may be provided if a secure WebSocket is required. The demo project shows how WebSockets can be secured. WebSocket security is provided by authentication predicates which are [documented below](#security-features). The SecurityManager and authentication predicate may be provided if a secure WebSocket is required. The placeholder project shows how WebSockets can be secured.
#### MQTT #### MQTT

7
features.ini Normal file
View File

@ -0,0 +1,7 @@
[features]
build_flags =
-D FT_PROJECT=1
-D FT_SECURITY=1
-D FT_MQTT=1
-D FT_NTP=1
-D FT_OTA=1

View File

@ -1,4 +1,4 @@
# Change the IP address to that of your ESP device to enable local development of the UI. # Change the IP address to that of your ESP device to enable local development of the UI.
# Remember to also enable CORS in platformio.ini before uploading the code to the device. # Remember to also enable CORS in platformio.ini before uploading the code to the device.
REACT_APP_HTTP_ROOT=http://192.168.0.99 REACT_APP_HTTP_ROOT=http://192.168.0.88
REACT_APP_WEB_SOCKET_ROOT=ws://192.168.0.99 REACT_APP_WEB_SOCKET_ROOT=ws://192.168.0.88

View File

@ -1403,6 +1403,21 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz",
"integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==" "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw=="
}, },
"@npmcli/move-file": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.0.1.tgz",
"integrity": "sha512-Uv6h1sT+0DrblvIrolFtbvM1FgWm+/sy4B3pvLp67Zys+thcukzS5ekn7HsZFGpWP4Q3fYJCljbWQE/XivMRLw==",
"requires": {
"mkdirp": "^1.0.4"
},
"dependencies": {
"mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="
}
}
},
"@svgr/babel-plugin-add-jsx-attribute": { "@svgr/babel-plugin-add-jsx-attribute": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-4.2.0.tgz", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-4.2.0.tgz",
@ -3324,11 +3339,6 @@
} }
} }
}, },
"classnames": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
},
"clean-css": { "clean-css": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz",
@ -3538,16 +3548,122 @@
} }
}, },
"compression-webpack-plugin": { "compression-webpack-plugin": {
"version": "3.1.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/compression-webpack-plugin/-/compression-webpack-plugin-3.1.0.tgz", "resolved": "https://registry.npmjs.org/compression-webpack-plugin/-/compression-webpack-plugin-4.0.0.tgz",
"integrity": "sha512-iqTHj3rADN4yHwXMBrQa/xrncex/uEQy8QHlaTKxGchT/hC0SdlJlmL/5eRqffmWq2ep0/Romw6Ld39JjTR/ug==", "integrity": "sha512-DRoFQNTkQ8gadlk117Y2wxANU+MDY56b1FIZj/yJXucBOTViTHXjthM7G9ocnitksk4kLzt1N2RLF0gDjxI+hg==",
"requires": { "requires": {
"cacache": "^13.0.1", "cacache": "^15.0.3",
"find-cache-dir": "^3.0.0", "find-cache-dir": "^3.3.1",
"neo-async": "^2.5.0", "schema-utils": "^2.6.6",
"schema-utils": "^2.6.1", "serialize-javascript": "^3.0.0",
"serialize-javascript": "^2.1.2", "webpack-sources": "^1.4.3"
"webpack-sources": "^1.0.1" },
"dependencies": {
"ajv": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.2.tgz",
"integrity": "sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==",
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
}
},
"cacache": {
"version": "15.0.4",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-15.0.4.tgz",
"integrity": "sha512-YlnKQqTbD/6iyoJvEY3KJftjrdBYroCbxxYXzhOzsFLWlp6KX4BOlEf4mTx0cMUfVaTS3ENL2QtDWeRYoGLkkw==",
"requires": {
"@npmcli/move-file": "^1.0.1",
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"glob": "^7.1.4",
"infer-owner": "^1.0.4",
"lru-cache": "^5.1.1",
"minipass": "^3.1.1",
"minipass-collect": "^1.0.2",
"minipass-flush": "^1.0.5",
"minipass-pipeline": "^1.2.2",
"mkdirp": "^1.0.3",
"p-map": "^4.0.0",
"promise-inflight": "^1.0.1",
"rimraf": "^3.0.2",
"ssri": "^8.0.0",
"tar": "^6.0.2",
"unique-filename": "^1.1.1"
}
},
"chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="
},
"find-cache-dir": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.1.tgz",
"integrity": "sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ==",
"requires": {
"commondir": "^1.0.1",
"make-dir": "^3.0.2",
"pkg-dir": "^4.1.0"
}
},
"make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"requires": {
"semver": "^6.0.0"
}
},
"mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="
},
"p-map": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
"integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
"requires": {
"aggregate-error": "^3.0.0"
}
},
"rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"requires": {
"glob": "^7.1.3"
}
},
"schema-utils": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
"integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
"requires": {
"@types/json-schema": "^7.0.4",
"ajv": "^6.12.2",
"ajv-keywords": "^3.4.1"
}
},
"serialize-javascript": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-3.1.0.tgz",
"integrity": "sha512-JIJT1DGiWmIKhzRsG91aS6Ze4sFUrYbltlkg2onR5OrnNM02Kl/hnY/T4FN2omvyeBbQmMJv+K4cPOpGzOTFBg==",
"requires": {
"randombytes": "^2.1.0"
}
},
"ssri": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.0.tgz",
"integrity": "sha512-aq/pz989nxVYwn16Tsbj1TqFpD5LLrQxHf5zaHuieFV+R0Bbr4y8qUsOA45hXT/N4/9UNXTarBjnjVmjSOVaAA==",
"requires": {
"minipass": "^3.1.1"
}
}
} }
}, },
"concat-map": { "concat-map": {
@ -8122,6 +8238,15 @@
"minipass": "^3.0.0" "minipass": "^3.0.0"
} }
}, },
"minizlib": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.0.tgz",
"integrity": "sha512-EzTZN/fjSvifSX0SlqUERCN39o6T40AMarPbv0MrarSFtIITCBh7bi+dU8nxGFHuqs9jdIAeoYoKuQAAASsPPA==",
"requires": {
"minipass": "^3.0.0",
"yallist": "^4.0.0"
}
},
"mississippi": { "mississippi": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz", "resolved": "https://registry.npmjs.org/mississippi/-/mississippi-3.0.0.tgz",
@ -8183,9 +8308,9 @@
} }
}, },
"moment": { "moment": {
"version": "2.24.0", "version": "2.26.0",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.26.0.tgz",
"integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" "integrity": "sha512-oIixUO+OamkUkwjhAVE18rAMfRJNsNe/Stid/gwHSOfHrOtw9EhAY2AHvdKZ/k/MggcYELFCJz/Sn2pL8b8JMw=="
}, },
"move-concurrently": { "move-concurrently": {
"version": "1.0.1", "version": "1.0.1",
@ -8415,14 +8540,12 @@
} }
}, },
"notistack": { "notistack": {
"version": "0.9.7", "version": "0.9.16",
"resolved": "https://registry.npmjs.org/notistack/-/notistack-0.9.7.tgz", "resolved": "https://registry.npmjs.org/notistack/-/notistack-0.9.16.tgz",
"integrity": "sha512-OztbtaIiCMR7QdcDGXTcYu96Uuvu26k41d7cnMGdf4NaKkAX06fsLPAlodGPj4HrMjMBUl8nvUQ3LmQOS6kHWQ==", "integrity": "sha512-+q1KKj2XkU+mKnbp9PbVkRLSLfVYnPJGi+MHT+N9Pm3nZUMVtbjDFodwdv/RoEldvkXKCROnecayUFMwLOiIQA==",
"requires": { "requires": {
"classnames": "^2.2.6", "clsx": "^1.1.0",
"hoist-non-react-statics": "^3.3.0", "hoist-non-react-statics": "^3.3.0"
"prop-types": "^15.7.2",
"react-is": "^16.8.6"
} }
}, },
"npm-run-path": { "npm-run-path": {
@ -10227,9 +10350,9 @@
} }
}, },
"react-app-rewired": { "react-app-rewired": {
"version": "2.1.5", "version": "2.1.6",
"resolved": "https://registry.npmjs.org/react-app-rewired/-/react-app-rewired-2.1.5.tgz", "resolved": "https://registry.npmjs.org/react-app-rewired/-/react-app-rewired-2.1.6.tgz",
"integrity": "sha512-Gr8KfCeL9/PTQs8Vvxc7v8wQ9vCFMnYPhcAkrMlzkLiMFXS+BgSwm11MoERjZm7dpA2WjTi+Pvbu/w7rujAV+A==", "integrity": "sha512-06flj0kK5tf/RN4naRv/sn6j3sQd7rsURoRLKLpffXDzJeNiAaTNic+0I8Basojy5WDwREkTqrMLewSAjcb13w==",
"dev": true, "dev": true,
"requires": { "requires": {
"semver": "^5.6.0" "semver": "^5.6.0"
@ -12099,6 +12222,31 @@
"resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz",
"integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==" "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA=="
}, },
"tar": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.0.2.tgz",
"integrity": "sha512-Glo3jkRtPcvpDlAs/0+hozav78yoXKFr+c4wgw62NNMO3oo4AaJdCo21Uu7lcwr55h39W2XD1LMERc64wtbItg==",
"requires": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^3.0.0",
"minizlib": "^2.1.0",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"dependencies": {
"chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="
},
"mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="
}
}
},
"terser": { "terser": {
"version": "4.6.7", "version": "4.6.7",
"resolved": "https://registry.npmjs.org/terser/-/terser-4.6.7.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-4.6.7.tgz",
@ -12354,9 +12502,9 @@
"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
}, },
"typescript": { "typescript": {
"version": "3.7.5", "version": "3.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.7.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.5.tgz",
"integrity": "sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw==" "integrity": "sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ=="
}, },
"unicode-canonical-property-names-ecmascript": { "unicode-canonical-property-names-ecmascript": {
"version": "1.0.4", "version": "1.0.4",

View File

@ -13,12 +13,12 @@
"@types/react-material-ui-form-validator": "^2.0.5", "@types/react-material-ui-form-validator": "^2.0.5",
"@types/react-router": "^5.1.3", "@types/react-router": "^5.1.3",
"@types/react-router-dom": "^5.1.3", "@types/react-router-dom": "^5.1.3",
"compression-webpack-plugin": "^3.0.1", "compression-webpack-plugin": "^4.0.0",
"jwt-decode": "^2.2.0", "jwt-decode": "^2.2.0",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"mime-types": "^2.1.25", "mime-types": "^2.1.25",
"moment": "^2.24.0", "moment": "^2.26.0",
"notistack": "^0.9.7", "notistack": "^0.9.16",
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-form-validator-core": "^0.6.4", "react-form-validator-core": "^0.6.4",
@ -27,7 +27,7 @@
"react-router-dom": "^5.1.2", "react-router-dom": "^5.1.2",
"react-scripts": "3.4.1", "react-scripts": "3.4.1",
"sockette": "^2.0.6", "sockette": "^2.0.6",
"typescript": "^3.7.5", "typescript": "^3.9.5",
"zlib": "^1.0.5" "zlib": "^1.0.5"
}, },
"scripts": { "scripts": {
@ -51,6 +51,6 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"react-app-rewired": "^2.1.5" "react-app-rewired": "^2.1.6"
} }
} }

View File

@ -8,6 +8,7 @@ import CloseIcon from '@material-ui/icons/Close';
import AppRouting from './AppRouting'; import AppRouting from './AppRouting';
import CustomMuiTheme from './CustomMuiTheme'; import CustomMuiTheme from './CustomMuiTheme';
import { PROJECT_NAME } from './api'; import { PROJECT_NAME } from './api';
import FeaturesWrapper from './features/FeaturesWrapper';
// this redirect forces a call to authenticationContext.refresh() which invalidates the JWT if it is invalid. // this redirect forces a call to authenticationContext.refresh() which invalidates the JWT if it is invalid.
const unauthorizedRedirect = () => <Redirect to="/" />; const unauthorizedRedirect = () => <Redirect to="/" />;
@ -34,10 +35,12 @@ class App extends Component {
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
)}> )}>
<Switch> <FeaturesWrapper>
<Route exact path="/unauthorized" component={unauthorizedRedirect} /> <Switch>
<Route component={AppRouting} /> <Route exact path="/unauthorized" component={unauthorizedRedirect} />
</Switch> <Route component={AppRouting} />
</Switch>
</FeaturesWrapper>
</SnackbarProvider> </SnackbarProvider>
</CustomMuiTheme> </CustomMuiTheme>
); );

View File

@ -16,30 +16,45 @@ import System from './system/System';
import { PROJECT_PATH } from './api'; import { PROJECT_PATH } from './api';
import Mqtt from './mqtt/Mqtt'; import Mqtt from './mqtt/Mqtt';
import { withFeatures, WithFeaturesProps } from './features/FeaturesContext';
import { Features } from './features/types';
class AppRouting extends Component { export const getDefaultRoute = (features: Features) => features.project ? `/${PROJECT_PATH}/` : "/wifi/";
class AppRouting extends Component<WithFeaturesProps> {
componentDidMount() { componentDidMount() {
Authentication.clearLoginRedirect(); Authentication.clearLoginRedirect();
} }
render() { render() {
const { features } = this.props;
return ( return (
<AuthenticationWrapper> <AuthenticationWrapper>
<Switch> <Switch>
<UnauthenticatedRoute exact path="/" component={SignIn} /> {features.security && (
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/*`} component={ProjectRouting} /> <UnauthenticatedRoute exact path="/" component={SignIn} />
)}
{features.project && (
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/*`} component={ProjectRouting} />
)}
<AuthenticatedRoute exact path="/wifi/*" component={WiFiConnection} /> <AuthenticatedRoute exact path="/wifi/*" component={WiFiConnection} />
<AuthenticatedRoute exact path="/ap/*" component={AccessPoint} /> <AuthenticatedRoute exact path="/ap/*" component={AccessPoint} />
{features.ntp && (
<AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} /> <AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} />
<AuthenticatedRoute exact path="/mqtt/*" component={Mqtt} /> )}
<AuthenticatedRoute exact path="/security/*" component={Security} /> {features.mqtt && (
<AuthenticatedRoute exact path="/mqtt/*" component={Mqtt} />
)}
{features.security && (
<AuthenticatedRoute exact path="/security/*" component={Security} />
)}
<AuthenticatedRoute exact path="/system/*" component={System} /> <AuthenticatedRoute exact path="/system/*" component={System} />
<Redirect to="/" /> <Redirect to={getDefaultRoute(features)} />
</Switch> </Switch>
</AuthenticationWrapper> </AuthenticationWrapper>
) )
} }
} }
export default AppRouting; export default withFeatures(AppRouting);

View File

@ -1,5 +1,6 @@
import { ENDPOINT_ROOT } from './Env'; import { ENDPOINT_ROOT } from './Env';
export const FEATURES_ENDPOINT = ENDPOINT_ROOT + "features";
export const NTP_STATUS_ENDPOINT = ENDPOINT_ROOT + "ntpStatus"; export const NTP_STATUS_ENDPOINT = ENDPOINT_ROOT + "ntpStatus";
export const NTP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "ntpSettings"; export const NTP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "ntpSettings";
export const AP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "apSettings"; export const AP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "apSettings";

View File

@ -1,7 +1,8 @@
import * as H from 'history'; import * as H from 'history';
import history from '../history'; import history from '../history';
import { PROJECT_PATH } from '../api'; import { Features } from '../features/types';
import { getDefaultRoute } from '../AppRouting';
export const ACCESS_TOKEN = 'access_token'; export const ACCESS_TOKEN = 'access_token';
export const LOGIN_PATHNAME = 'loginPathname'; export const LOGIN_PATHNAME = 'loginPathname';
@ -26,12 +27,12 @@ export function clearLoginRedirect() {
getStorage().removeItem(LOGIN_SEARCH); getStorage().removeItem(LOGIN_SEARCH);
} }
export function fetchLoginRedirect(): H.LocationDescriptorObject { export function fetchLoginRedirect(features: Features): H.LocationDescriptorObject {
const loginPathname = getStorage().getItem(LOGIN_PATHNAME); const loginPathname = getStorage().getItem(LOGIN_PATHNAME);
const loginSearch = getStorage().getItem(LOGIN_SEARCH); const loginSearch = getStorage().getItem(LOGIN_SEARCH);
clearLoginRedirect(); clearLoginRedirect();
return { return {
pathname: loginPathname || `/${PROJECT_PATH}/`, pathname: loginPathname || getDefaultRoute(features),
search: (loginPathname && loginSearch) || undefined search: (loginPathname && loginSearch) || undefined
}; };
} }

View File

@ -2,37 +2,21 @@ import * as React from 'react';
import { withSnackbar, WithSnackbarProps } from 'notistack'; import { withSnackbar, WithSnackbarProps } from 'notistack';
import jwtDecode from 'jwt-decode'; import jwtDecode from 'jwt-decode';
import CircularProgress from '@material-ui/core/CircularProgress';
import Typography from '@material-ui/core/Typography';
import { withStyles, Theme, createStyles, WithStyles } from '@material-ui/core/styles';
import history from '../history' import history from '../history'
import { VERIFY_AUTHORIZATION_ENDPOINT } from '../api'; import { VERIFY_AUTHORIZATION_ENDPOINT } from '../api';
import { ACCESS_TOKEN, authorizedFetch, getStorage } from './Authentication'; import { ACCESS_TOKEN, authorizedFetch, getStorage } from './Authentication';
import { AuthenticationContext, Me } from './AuthenticationContext'; import { AuthenticationContext, Me } from './AuthenticationContext';
import FullScreenLoading from '../components/FullScreenLoading';
import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext';
export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken); export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken);
const styles = (theme: Theme) => createStyles({
loadingPanel: {
padding: theme.spacing(2),
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
flexDirection: "column"
},
progress: {
margin: theme.spacing(4),
}
});
interface AuthenticationWrapperState { interface AuthenticationWrapperState {
context: AuthenticationContext; context: AuthenticationContext;
initialized: boolean; initialized: boolean;
} }
type AuthenticationWrapperProps = WithSnackbarProps & WithStyles<typeof styles>; type AuthenticationWrapperProps = WithSnackbarProps & WithFeaturesProps;
class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps, AuthenticationWrapperState> { class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps, AuthenticationWrapperState> {
@ -69,18 +53,16 @@ class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps,
} }
renderContentLoading() { renderContentLoading() {
const { classes } = this.props;
return ( return (
<div className={classes.loadingPanel}> <FullScreenLoading />
<CircularProgress className={classes.progress} size={100} />
<Typography variant="h4" >
Loading...
</Typography>
</div>
); );
} }
refresh = () => { refresh = () => {
if (!this.props.features.security) {
this.setState({ initialized: true, context: { ...this.state.context, me: { admin: true, username: "admin" } } });
return;
}
const accessToken = getStorage().getItem(ACCESS_TOKEN) const accessToken = getStorage().getItem(ACCESS_TOKEN)
if (accessToken) { if (accessToken) {
authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT) authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT)
@ -124,4 +106,4 @@ class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps,
} }
export default withStyles(styles)(withSnackbar(AuthenticationWrapper)) export default withFeatures(withSnackbar(AuthenticationWrapper))

View File

@ -3,19 +3,21 @@ import { Redirect, Route, RouteProps, RouteComponentProps } from "react-router-d
import { withAuthenticationContext, AuthenticationContextProps } from './AuthenticationContext'; import { withAuthenticationContext, AuthenticationContextProps } from './AuthenticationContext';
import * as Authentication from './Authentication'; import * as Authentication from './Authentication';
import { WithFeaturesProps, withFeatures } from '../features/FeaturesContext';
interface UnauthenticatedRouteProps extends RouteProps { interface UnauthenticatedRouteProps extends RouteProps, AuthenticationContextProps, WithFeaturesProps {
component: React.ComponentType<RouteComponentProps<any>> | React.ComponentType<any>; component: React.ComponentType<RouteComponentProps<any>> | React.ComponentType<any>;
} }
type RenderComponent = (props: RouteComponentProps<any>) => React.ReactNode; type RenderComponent = (props: RouteComponentProps<any>) => React.ReactNode;
class UnauthenticatedRoute extends Route<UnauthenticatedRouteProps & AuthenticationContextProps> { class UnauthenticatedRoute extends Route<UnauthenticatedRouteProps> {
public render() { public render() {
const { authenticationContext, component:Component, ...rest } = this.props; const { authenticationContext, component: Component, features, ...rest } = this.props;
const renderComponent: RenderComponent = (props) => { const renderComponent: RenderComponent = (props) => {
if (authenticationContext.me) { if (authenticationContext.me) {
return (<Redirect to={Authentication.fetchLoginRedirect()} />); return (<Redirect to={Authentication.fetchLoginRedirect(features)} />);
} }
return (<Component {...props} />); return (<Component {...props} />);
} }
@ -25,4 +27,4 @@ class UnauthenticatedRoute extends Route<UnauthenticatedRouteProps & Authenticat
} }
} }
export default withAuthenticationContext(UnauthenticatedRoute); export default withFeatures(withAuthenticationContext(UnauthenticatedRoute));

View File

@ -0,0 +1,57 @@
import React, { FC } from 'react';
import { makeStyles } from '@material-ui/styles';
import { Paper, Typography, Box, CssBaseline } from "@material-ui/core";
import WarningIcon from "@material-ui/icons/Warning"
const styles = makeStyles(
{
siteErrorPage: {
display: "flex",
height: "100vh",
justifyContent: "center",
flexDirection: "column"
},
siteErrorPagePanel: {
textAlign: "center",
padding: "280px 0 40px 0",
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: "no-repeat",
backgroundPosition: "50% 40px",
backgroundSize: "200px auto",
width: "100%",
}
}
);
interface ApplicationErrorProps {
error?: string;
}
const ApplicationError: FC<ApplicationErrorProps> = ({ error }) => {
const classes = styles();
return (
<div className={classes.siteErrorPage}>
<CssBaseline />
<Paper className={classes.siteErrorPagePanel} elevation={10}>
<Box display="flex" flexDirection="row" justifyContent="center">
<WarningIcon fontSize="large" color="error" />
<Typography variant="h4" gutterBottom>
&nbsp;Application error
</Typography>
</Box>
<Typography variant="subtitle1" gutterBottom>
Failed to configure the application, please refresh to try again.
</Typography>
{error &&
(
<Typography variant="subtitle2" gutterBottom>
Error: {error}
</Typography>
)
}
</Paper>
</div>
);
}
export default ApplicationError;

View File

@ -0,0 +1,32 @@
import React from 'react';
import CircularProgress from '@material-ui/core/CircularProgress';
import { Typography, Theme } from '@material-ui/core';
import { makeStyles, createStyles } from '@material-ui/styles';
const useStyles = makeStyles((theme: Theme) => createStyles({
fullScreenLoading: {
padding: theme.spacing(2),
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
flexDirection: "column"
},
progress: {
margin: theme.spacing(4),
}
}));
const FullScreenLoading = () => {
const classes = useStyles();
return (
<div className={classes.fullScreenLoading}>
<CircularProgress className={classes.progress} size={100} />
<Typography variant="h4">
Loading &hellip;
</Typography>
</div>
)
}
export default FullScreenLoading;

View File

@ -1,4 +1,4 @@
import React, { RefObject } from 'react'; import React, { RefObject, Fragment } from 'react';
import { Link, withRouter, RouteComponentProps } from 'react-router-dom'; import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
import { Drawer, AppBar, Toolbar, Avatar, Divider, Button, Box, IconButton } from '@material-ui/core'; import { Drawer, AppBar, Toolbar, Avatar, Divider, Button, Box, IconButton } from '@material-ui/core';
@ -20,6 +20,7 @@ import MenuIcon from '@material-ui/icons/Menu';
import ProjectMenu from '../project/ProjectMenu'; import ProjectMenu from '../project/ProjectMenu';
import { PROJECT_NAME } from '../api'; import { PROJECT_NAME } from '../api';
import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication'; import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication';
import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext';
const drawerWidth = 290; const drawerWidth = 290;
@ -82,7 +83,7 @@ interface MenuAppBarState {
authMenuOpen: boolean; authMenuOpen: boolean;
} }
interface MenuAppBarProps extends AuthenticatedContextProps, WithTheme, WithStyles<typeof styles>, RouteComponentProps { interface MenuAppBarProps extends WithFeaturesProps, AuthenticatedContextProps, WithTheme, WithStyles<typeof styles>, RouteComponentProps {
sectionTitle: string; sectionTitle: string;
} }
@ -114,7 +115,7 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
}; };
render() { render() {
const { classes, theme, children, sectionTitle, authenticatedContext } = this.props; const { classes, theme, children, sectionTitle, authenticatedContext, features } = this.props;
const { mobileOpen, authMenuOpen } = this.state; const { mobileOpen, authMenuOpen } = this.state;
const path = this.props.match.url; const path = this.props.match.url;
const drawer = ( const drawer = (
@ -128,9 +129,12 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
</Typography> </Typography>
<Divider absolute /> <Divider absolute />
</Toolbar> </Toolbar>
<Divider /> {features.project && (
<ProjectMenu /> <Fragment>
<Divider /> <ProjectMenu />
<Divider />
</Fragment>
)}
<List> <List>
<ListItem to='/wifi/' selected={path.startsWith('/wifi/')} button component={Link}> <ListItem to='/wifi/' selected={path.startsWith('/wifi/')} button component={Link}>
<ListItemIcon> <ListItemIcon>
@ -144,24 +148,30 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Access Point" /> <ListItemText primary="Access Point" />
</ListItem> </ListItem>
{features.ntp && (
<ListItem to='/ntp/' selected={path.startsWith('/ntp/')} button component={Link}> <ListItem to='/ntp/' selected={path.startsWith('/ntp/')} button component={Link}>
<ListItemIcon> <ListItemIcon>
<AccessTimeIcon /> <AccessTimeIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Network Time" /> <ListItemText primary="Network Time" />
</ListItem> </ListItem>
<ListItem to='/mqtt/' selected={path.startsWith('/mqtt/')} button component={Link}> )}
<ListItemIcon> {features.mqtt && (
<DeviceHubIcon /> <ListItem to='/mqtt/' selected={path.startsWith('/mqtt/')} button component={Link}>
</ListItemIcon> <ListItemIcon>
<ListItemText primary="MQTT" /> <DeviceHubIcon />
</ListItem> </ListItemIcon>
<ListItem to='/security/' selected={path.startsWith('/security/')} button component={Link} disabled={!authenticatedContext.me.admin}> <ListItemText primary="MQTT" />
<ListItemIcon> </ListItem>
<LockIcon /> )}
</ListItemIcon> {features.security && (
<ListItemText primary="Security" /> <ListItem to='/security/' selected={path.startsWith('/security/')} button component={Link} disabled={!authenticatedContext.me.admin}>
</ListItem> <ListItemIcon>
<LockIcon />
</ListItemIcon>
<ListItemText primary="Security" />
</ListItem>
)}
<ListItem to='/system/' selected={path.startsWith('/system/')} button component={Link} > <ListItem to='/system/' selected={path.startsWith('/system/')} button component={Link} >
<ListItemIcon> <ListItemIcon>
<SettingsIcon /> <SettingsIcon />
@ -172,6 +182,42 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
</div> </div>
); );
const userMenu = (
<div>
<IconButton
ref={this.anchorRef}
aria-owns={authMenuOpen ? 'menu-list-grow' : undefined}
aria-haspopup="true"
onClick={this.handleToggle}
color="inherit"
>
<AccountCircleIcon />
</IconButton>
<Popper open={authMenuOpen} anchorEl={this.anchorRef.current} transition className={classes.authMenu}>
<ClickAwayListener onClickAway={this.handleClose}>
<Card id="menu-list-grow">
<CardContent>
<List disablePadding>
<ListItem disableGutters>
<ListItemAvatar>
<Avatar>
<AccountCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={"Signed in as: " + authenticatedContext.me.username} secondary={authenticatedContext.me.admin ? "Admin User" : undefined} />
</ListItem>
</List>
</CardContent>
<Divider />
<CardActions className={classes.authMenuActions}>
<Button variant="contained" fullWidth color="primary" onClick={authenticatedContext.signOut}>Sign Out</Button>
</CardActions>
</Card>
</ClickAwayListener>
</Popper>
</div>
);
return ( return (
<div className={classes.root}> <div className={classes.root}>
<AppBar position="fixed" className={classes.appBar} elevation={0}> <AppBar position="fixed" className={classes.appBar} elevation={0}>
@ -188,39 +234,7 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
<Typography variant="h6" color="inherit" noWrap className={classes.title}> <Typography variant="h6" color="inherit" noWrap className={classes.title}>
{sectionTitle} {sectionTitle}
</Typography> </Typography>
<div> {features.security && userMenu}
<IconButton
ref={this.anchorRef}
aria-owns={authMenuOpen ? 'menu-list-grow' : undefined}
aria-haspopup="true"
onClick={this.handleToggle}
color="inherit"
>
<AccountCircleIcon />
</IconButton>
<Popper open={authMenuOpen} anchorEl={this.anchorRef.current} transition className={classes.authMenu}>
<ClickAwayListener onClickAway={this.handleClose}>
<Card id="menu-list-grow">
<CardContent>
<List disablePadding>
<ListItem disableGutters>
<ListItemAvatar>
<Avatar>
<AccountCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={"Signed in as: " + authenticatedContext.me.username} secondary={authenticatedContext.me.admin ? "Admin User" : undefined} />
</ListItem>
</List>
</CardContent>
<Divider />
<CardActions className={classes.authMenuActions}>
<Button variant="contained" fullWidth color="primary" onClick={authenticatedContext.signOut}>Sign Out</Button>
</CardActions>
</Card>
</ClickAwayListener>
</Popper>
</div>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<nav className={classes.drawer}> <nav className={classes.drawer}>
@ -263,8 +277,10 @@ class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
export default withRouter( export default withRouter(
withTheme( withTheme(
withAuthenticatedContext( withFeatures(
withStyles(styles)(MenuAppBar) withAuthenticatedContext(
withStyles(styles)(MenuAppBar)
)
) )
) )
); );

View File

@ -100,8 +100,8 @@ export function restController<D, P extends RestControllerProps<D>>(endpointUrl:
render() { render() {
return <RestController return <RestController
{...this.props as P}
{...this.state} {...this.state}
{...this.props as P}
handleValueChange={this.handleValueChange} handleValueChange={this.handleValueChange}
setData={this.setData} setData={this.setData}
saveData={this.saveData} saveData={this.saveData}

View File

@ -0,0 +1,23 @@
import React from 'react';
import { Features } from './types';
export interface ApplicationContext {
features: Features;
}
const ApplicationContextDefaultValue = {} as ApplicationContext
export const ApplicationContext = React.createContext(
ApplicationContextDefaultValue
);
export function withAuthenticatedContexApplicationContext<T extends ApplicationContext>(Component: React.ComponentType<T>) {
return class extends React.Component<Omit<T, keyof ApplicationContext>> {
render() {
return (
<ApplicationContext.Consumer>
{authenticatedContext => <Component {...this.props as T} features={authenticatedContext} />}
</ApplicationContext.Consumer>
);
}
};
}

View File

@ -0,0 +1,27 @@
import React from 'react';
import { Features } from './types';
export interface FeaturesContext {
features: Features;
}
const FeaturesContextDefaultValue = {} as FeaturesContext
export const FeaturesContext = React.createContext(
FeaturesContextDefaultValue
);
export interface WithFeaturesProps {
features: Features;
}
export function withFeatures<T extends WithFeaturesProps>(Component: React.ComponentType<T>) {
return class extends React.Component<Omit<T, keyof WithFeaturesProps>> {
render() {
return (
<FeaturesContext.Consumer>
{featuresContext => <Component {...this.props as T} features={featuresContext.features} />}
</FeaturesContext.Consumer>
);
}
};
}

View File

@ -0,0 +1,61 @@
import React, { Component } from 'react';
import { Features } from './types';
import { FeaturesContext } from './FeaturesContext';
import FullScreenLoading from '../components/FullScreenLoading';
import ApplicationError from '../components/ApplicationError';
import { FEATURES_ENDPOINT } from '../api';
interface FeaturesWrapperState {
features?: Features;
error?: string;
};
class FeaturesWrapper extends Component<{}, FeaturesWrapperState> {
state: FeaturesWrapperState = {};
componentDidMount() {
this.fetchFeaturesDetails();
}
fetchFeaturesDetails = () => {
fetch(FEATURES_ENDPOINT)
.then(response => {
if (response.status === 200) {
return response.json();
} else {
throw Error("Unexpected status code: " + response.status);
}
}).then(features => {
this.setState({ features });
})
.catch(error => {
this.setState({ error: error.message });
});
}
render() {
const { features, error } = this.state;
if (features) {
return (
<FeaturesContext.Provider value={{
features
}}>
{this.props.children}
</FeaturesContext.Provider>
);
}
if (error) {
return (
<ApplicationError error={error} />
);
}
return (
<FullScreenLoading />
);
}
}
export default FeaturesWrapper;

View File

@ -0,0 +1,7 @@
export interface Features {
project: boolean;
security: boolean;
mqtt: boolean;
ntp: boolean;
ota: boolean;
}

View File

@ -8,8 +8,9 @@ import { MenuAppBar } from '../components';
import SystemStatusController from './SystemStatusController'; import SystemStatusController from './SystemStatusController';
import OTASettingsController from './OTASettingsController'; import OTASettingsController from './OTASettingsController';
import { WithFeaturesProps, withFeatures } from '../features/FeaturesContext';
type SystemProps = AuthenticatedContextProps & RouteComponentProps; type SystemProps = AuthenticatedContextProps & RouteComponentProps & WithFeaturesProps;
class System extends Component<SystemProps> { class System extends Component<SystemProps> {
@ -18,16 +19,20 @@ class System extends Component<SystemProps> {
}; };
render() { render() {
const { authenticatedContext } = this.props; const { authenticatedContext, features } = this.props;
return ( return (
<MenuAppBar sectionTitle="System"> <MenuAppBar sectionTitle="System">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> <Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth">
<Tab value="/system/status" label="System Status" /> <Tab value="/system/status" label="System Status" />
<Tab value="/system/ota" label="OTA Settings" disabled={!authenticatedContext.me.admin} /> {features.ota && (
<Tab value="/system/ota" label="OTA Settings" disabled={!authenticatedContext.me.admin} />
)}
</Tabs> </Tabs>
<Switch> <Switch>
<AuthenticatedRoute exact path="/system/status" component={SystemStatusController} /> <AuthenticatedRoute exact path="/system/status" component={SystemStatusController} />
<AuthenticatedRoute exact path="/system/ota" component={OTASettingsController} /> {features.ota && (
<AuthenticatedRoute exact path="/system/ota" component={OTASettingsController} />
)}
<Redirect to="/system/status" /> <Redirect to="/system/status" />
</Switch> </Switch>
</MenuAppBar> </MenuAppBar>
@ -35,4 +40,4 @@ class System extends Component<SystemProps> {
} }
} }
export default withAuthenticatedContext(System); export default withFeatures(withAuthenticatedContext(System));

View File

@ -1,5 +1,7 @@
#include <AuthenticationService.h> #include <AuthenticationService.h>
#if FT_ENABLED(FT_SECURITY)
AuthenticationService::AuthenticationService(AsyncWebServer* server, SecurityManager* securityManager) : AuthenticationService::AuthenticationService(AsyncWebServer* server, SecurityManager* securityManager) :
_securityManager(securityManager), _securityManager(securityManager),
_signInHandler(SIGN_IN_PATH, _signInHandler(SIGN_IN_PATH,
@ -42,3 +44,5 @@ void AuthenticationService::signIn(AsyncWebServerRequest* request, JsonVariant&
AsyncWebServerResponse* response = request->beginResponse(401); AsyncWebServerResponse* response = request->beginResponse(401);
request->send(response); request->send(response);
} }
#endif // end FT_ENABLED(FT_SECURITY)

View File

@ -1,6 +1,7 @@
#ifndef AuthenticationService_H_ #ifndef AuthenticationService_H_
#define AuthenticationService_H_ #define AuthenticationService_H_
#include <Features.h>
#include <AsyncJson.h> #include <AsyncJson.h>
#include <ESPAsyncWebServer.h> #include <ESPAsyncWebServer.h>
#include <SecurityManager.h> #include <SecurityManager.h>
@ -10,6 +11,8 @@
#define MAX_AUTHENTICATION_SIZE 256 #define MAX_AUTHENTICATION_SIZE 256
#if FT_ENABLED(FT_SECURITY)
class AuthenticationService { class AuthenticationService {
public: public:
AuthenticationService(AsyncWebServer* server, SecurityManager* securityManager); AuthenticationService(AsyncWebServer* server, SecurityManager* securityManager);
@ -23,4 +26,5 @@ class AuthenticationService {
void verifyAuthorization(AsyncWebServerRequest* request); void verifyAuthorization(AsyncWebServerRequest* request);
}; };
#endif // end FT_ENABLED(FT_SECURITY)
#endif // end SecurityManager_h #endif // end SecurityManager_h

View File

@ -1,20 +1,29 @@
#include <ESP8266React.h> #include <ESP8266React.h>
ESP8266React::ESP8266React(AsyncWebServer* server, FS* fs) : ESP8266React::ESP8266React(AsyncWebServer* server, FS* fs) :
_featureService(server),
_securitySettingsService(server, fs), _securitySettingsService(server, fs),
_wifiSettingsService(server, fs, &_securitySettingsService), _wifiSettingsService(server, fs, &_securitySettingsService),
_apSettingsService(server, fs, &_securitySettingsService),
_ntpSettingsService(server, fs, &_securitySettingsService),
_otaSettingsService(server, fs, &_securitySettingsService),
_mqttSettingsService(server, fs, &_securitySettingsService),
_restartService(server, &_securitySettingsService),
_factoryResetService(server, fs, &_securitySettingsService),
_authenticationService(server, &_securitySettingsService),
_wifiScanner(server, &_securitySettingsService), _wifiScanner(server, &_securitySettingsService),
_wifiStatus(server, &_securitySettingsService), _wifiStatus(server, &_securitySettingsService),
_ntpStatus(server, &_securitySettingsService), _apSettingsService(server, fs, &_securitySettingsService),
_apStatus(server, &_securitySettingsService, &_apSettingsService), _apStatus(server, &_securitySettingsService, &_apSettingsService),
#if FT_ENABLED(FT_NTP)
_ntpSettingsService(server, fs, &_securitySettingsService),
_ntpStatus(server, &_securitySettingsService),
#endif
#if FT_ENABLED(FT_OTA)
_otaSettingsService(server, fs, &_securitySettingsService),
#endif
#if FT_ENABLED(FT_MQTT)
_mqttSettingsService(server, fs, &_securitySettingsService),
_mqttStatus(server, &_mqttSettingsService, &_securitySettingsService), _mqttStatus(server, &_mqttSettingsService, &_securitySettingsService),
#endif
#if FT_ENABLED(FT_SECURITY)
_authenticationService(server, &_securitySettingsService),
#endif
_restartService(server, &_securitySettingsService),
_factoryResetService(server, fs, &_securitySettingsService),
_systemStatus(server, &_securitySettingsService) { _systemStatus(server, &_securitySettingsService) {
#ifdef PROGMEM_WWW #ifdef PROGMEM_WWW
// Serve static resources from PROGMEM // Serve static resources from PROGMEM
@ -69,17 +78,29 @@ ESP8266React::ESP8266React(AsyncWebServer* server, FS* fs) :
} }
void ESP8266React::begin() { void ESP8266React::begin() {
_securitySettingsService.begin();
_wifiSettingsService.begin(); _wifiSettingsService.begin();
_apSettingsService.begin(); _apSettingsService.begin();
#if FT_ENABLED(FT_NTP)
_ntpSettingsService.begin(); _ntpSettingsService.begin();
#endif
#if FT_ENABLED(FT_OTA)
_otaSettingsService.begin(); _otaSettingsService.begin();
#endif
#if FT_ENABLED(FT_MQTT)
_mqttSettingsService.begin(); _mqttSettingsService.begin();
#endif
#if FT_ENABLED(FT_SECURITY)
_securitySettingsService.begin();
#endif
} }
void ESP8266React::loop() { void ESP8266React::loop() {
_wifiSettingsService.loop(); _wifiSettingsService.loop();
_apSettingsService.loop(); _apSettingsService.loop();
#if FT_ENABLED(FT_OTA)
_otaSettingsService.loop(); _otaSettingsService.loop();
#endif
#if FT_ENABLED(FT_MQTT)
_mqttSettingsService.loop(); _mqttSettingsService.loop();
#endif
} }

View File

@ -11,6 +11,7 @@
#include <ESPAsyncTCP.h> #include <ESPAsyncTCP.h>
#endif #endif
#include <FeaturesService.h>
#include <APSettingsService.h> #include <APSettingsService.h>
#include <APStatus.h> #include <APStatus.h>
#include <AuthenticationService.h> #include <AuthenticationService.h>
@ -42,9 +43,11 @@ class ESP8266React {
return &_securitySettingsService; return &_securitySettingsService;
} }
#if FT_ENABLED(FT_SECURITY)
StatefulService<SecuritySettings>* getSecuritySettingsService() { StatefulService<SecuritySettings>* getSecuritySettingsService() {
return &_securitySettingsService; return &_securitySettingsService;
} }
#endif
StatefulService<WiFiSettings>* getWiFiSettingsService() { StatefulService<WiFiSettings>* getWiFiSettingsService() {
return &_wifiSettingsService; return &_wifiSettingsService;
@ -54,14 +57,19 @@ class ESP8266React {
return &_apSettingsService; return &_apSettingsService;
} }
#if FT_ENABLED(FT_NTP)
StatefulService<NTPSettings>* getNTPSettingsService() { StatefulService<NTPSettings>* getNTPSettingsService() {
return &_ntpSettingsService; return &_ntpSettingsService;
} }
#endif
#if FT_ENABLED(FT_OTA)
StatefulService<OTASettings>* getOTASettingsService() { StatefulService<OTASettings>* getOTASettingsService() {
return &_otaSettingsService; return &_otaSettingsService;
} }
#endif
#if FT_ENABLED(FT_MQTT)
StatefulService<MqttSettings>* getMqttSettingsService() { StatefulService<MqttSettings>* getMqttSettingsService() {
return &_mqttSettingsService; return &_mqttSettingsService;
} }
@ -69,28 +77,36 @@ class ESP8266React {
AsyncMqttClient* getMqttClient() { AsyncMqttClient* getMqttClient() {
return _mqttSettingsService.getMqttClient(); return _mqttSettingsService.getMqttClient();
} }
#endif
void factoryReset() { void factoryReset() {
_factoryResetService.factoryReset(); _factoryResetService.factoryReset();
} }
private: private:
FeaturesService _featureService;
SecuritySettingsService _securitySettingsService; SecuritySettingsService _securitySettingsService;
WiFiSettingsService _wifiSettingsService; WiFiSettingsService _wifiSettingsService;
APSettingsService _apSettingsService;
NTPSettingsService _ntpSettingsService;
OTASettingsService _otaSettingsService;
MqttSettingsService _mqttSettingsService;
RestartService _restartService;
FactoryResetService _factoryResetService;
AuthenticationService _authenticationService;
WiFiScanner _wifiScanner; WiFiScanner _wifiScanner;
WiFiStatus _wifiStatus; WiFiStatus _wifiStatus;
NTPStatus _ntpStatus; APSettingsService _apSettingsService;
APStatus _apStatus; APStatus _apStatus;
#if FT_ENABLED(FT_NTP)
NTPSettingsService _ntpSettingsService;
NTPStatus _ntpStatus;
#endif
#if FT_ENABLED(FT_OTA)
OTASettingsService _otaSettingsService;
#endif
#if FT_ENABLED(FT_MQTT)
MqttSettingsService _mqttSettingsService;
MqttStatus _mqttStatus; MqttStatus _mqttStatus;
#endif
#if FT_ENABLED(FT_SECURITY)
AuthenticationService _authenticationService;
#endif
RestartService _restartService;
FactoryResetService _factoryResetService;
SystemStatus _systemStatus; SystemStatus _systemStatus;
}; };

31
lib/framework/Features.h Normal file
View File

@ -0,0 +1,31 @@
#ifndef Features_h
#define Features_h
#define FT_ENABLED(feature) feature
// project feature off by default
#ifndef FT_PROJECT
#define FT_PROJECT 0
#endif
// security feature on by default
#ifndef FT_SECURITY
#define FT_SECURITY 1
#endif
// mqtt feature on by default
#ifndef FT_MQTT
#define FT_MQTT 1
#endif
// ntp feature on by default
#ifndef FT_NTP
#define FT_NTP 1
#endif
// mqtt feature on by default
#ifndef FT_OTA
#define FT_OTA 1
#endif
#endif

View File

@ -0,0 +1,37 @@
#include <FeaturesService.h>
FeaturesService::FeaturesService(AsyncWebServer* server) {
server->on(FEATURES_SERVICE_PATH, HTTP_GET, std::bind(&FeaturesService::features, this, std::placeholders::_1));
}
void FeaturesService::features(AsyncWebServerRequest* request) {
AsyncJsonResponse* response = new AsyncJsonResponse(false, MAX_FEATURES_SIZE);
JsonObject root = response->getRoot();
#if FT_ENABLED(FT_PROJECT)
root["project"] = true;
#else
root["project"] = false;
#endif
#if FT_ENABLED(FT_SECURITY)
root["security"] = true;
#else
root["security"] = false;
#endif
#if FT_ENABLED(FT_MQTT)
root["mqtt"] = true;
#else
root["mqtt"] = false;
#endif
#if FT_ENABLED(FT_NTP)
root["ntp"] = true;
#else
root["ntp"] = false;
#endif
#if FT_ENABLED(FT_OTA)
root["ota"] = true;
#else
root["ota"] = false;
#endif
response->setLength();
request->send(response);
}

View File

@ -0,0 +1,29 @@
#ifndef FeaturesService_h
#define FeaturesService_h
#include <Features.h>
#ifdef ESP32
#include <WiFi.h>
#include <AsyncTCP.h>
#elif defined(ESP8266)
#include <ESP8266WiFi.h>
#include <ESPAsyncTCP.h>
#endif
#include <ArduinoJson.h>
#include <AsyncJson.h>
#include <ESPAsyncWebServer.h>
#define MAX_FEATURES_SIZE 256
#define FEATURES_SERVICE_PATH "/rest/features"
class FeaturesService {
public:
FeaturesService(AsyncWebServer* server);
private:
void features(AsyncWebServerRequest* request);
};
#endif

View File

@ -1,6 +1,7 @@
#ifndef SecurityManager_h #ifndef SecurityManager_h
#define SecurityManager_h #define SecurityManager_h
#include <Features.h>
#include <ArduinoJsonJWT.h> #include <ArduinoJsonJWT.h>
#include <ESPAsyncWebServer.h> #include <ESPAsyncWebServer.h>
#include <ESPUtils.h> #include <ESPUtils.h>
@ -62,21 +63,24 @@ class AuthenticationPredicates {
class SecurityManager { class SecurityManager {
public: public:
#if FT_ENABLED(FT_SECURITY)
/* /*
* Authenticate, returning the user if found * Authenticate, returning the user if found
*/ */
virtual Authentication authenticate(const String& username, const String& password) = 0; virtual Authentication authenticate(const String& username, const String& password) = 0;
/*
* Check the request header for the Authorization token
*/
virtual Authentication authenticateRequest(AsyncWebServerRequest* request) = 0;
/* /*
* Generate a JWT for the user provided * Generate a JWT for the user provided
*/ */
virtual String generateJWT(User* user) = 0; virtual String generateJWT(User* user) = 0;
#endif
/*
* Check the request header for the Authorization token
*/
virtual Authentication authenticateRequest(AsyncWebServerRequest* request) = 0;
/** /**
* Filter a request with the provided predicate, only returning true if the predicate matches. * Filter a request with the provided predicate, only returning true if the predicate matches.
*/ */
@ -91,7 +95,7 @@ class SecurityManager {
/** /**
* Wrap the provided json request callback to provide validation against an AuthenticationPredicate. * Wrap the provided json request callback to provide validation against an AuthenticationPredicate.
*/ */
virtual ArJsonRequestHandlerFunction wrapCallback(ArJsonRequestHandlerFunction callback, virtual ArJsonRequestHandlerFunction wrapCallback(ArJsonRequestHandlerFunction onRequest,
AuthenticationPredicate predicate) = 0; AuthenticationPredicate predicate) = 0;
}; };

View File

@ -1,5 +1,7 @@
#include <SecuritySettingsService.h> #include <SecuritySettingsService.h>
#if FT_ENABLED(FT_SECURITY)
SecuritySettingsService::SecuritySettingsService(AsyncWebServer* server, FS* fs) : SecuritySettingsService::SecuritySettingsService(AsyncWebServer* server, FS* fs) :
_httpEndpoint(SecuritySettings::read, SecuritySettings::update, this, server, SECURITY_SETTINGS_PATH, this), _httpEndpoint(SecuritySettings::read, SecuritySettings::update, this, server, SECURITY_SETTINGS_PATH, this),
_fsPersistence(SecuritySettings::read, SecuritySettings::update, this, fs, SECURITY_SETTINGS_FILE), _fsPersistence(SecuritySettings::read, SecuritySettings::update, this, fs, SECURITY_SETTINGS_FILE),
@ -94,14 +96,45 @@ ArRequestHandlerFunction SecuritySettingsService::wrapRequest(ArRequestHandlerFu
}; };
} }
ArJsonRequestHandlerFunction SecuritySettingsService::wrapCallback(ArJsonRequestHandlerFunction callback, ArJsonRequestHandlerFunction SecuritySettingsService::wrapCallback(ArJsonRequestHandlerFunction onRequest,
AuthenticationPredicate predicate) { AuthenticationPredicate predicate) {
return [this, callback, predicate](AsyncWebServerRequest* request, JsonVariant& json) { return [this, onRequest, predicate](AsyncWebServerRequest* request, JsonVariant& json) {
Authentication authentication = authenticateRequest(request); Authentication authentication = authenticateRequest(request);
if (!predicate(authentication)) { if (!predicate(authentication)) {
request->send(401); request->send(401);
return; return;
} }
callback(request, json); onRequest(request, json);
}; };
} }
#else
User ADMIN_USER = User(FACTORY_ADMIN_USERNAME, FACTORY_ADMIN_PASSWORD, true);
SecuritySettingsService::SecuritySettingsService(AsyncWebServer* server, FS* fs) : SecurityManager() {
}
SecuritySettingsService::~SecuritySettingsService() {
}
ArRequestFilterFunction SecuritySettingsService::filterRequest(AuthenticationPredicate predicate) {
return [this, predicate](AsyncWebServerRequest* request) { return true; };
}
// Return the admin user on all request - disabling security features
Authentication SecuritySettingsService::authenticateRequest(AsyncWebServerRequest* request) {
return Authentication(ADMIN_USER);
}
// Return the function unwrapped
ArRequestHandlerFunction SecuritySettingsService::wrapRequest(ArRequestHandlerFunction onRequest,
AuthenticationPredicate predicate) {
return onRequest;
}
ArJsonRequestHandlerFunction SecuritySettingsService::wrapCallback(ArJsonRequestHandlerFunction onRequest,
AuthenticationPredicate predicate) {
return onRequest;
}
#endif

View File

@ -1,6 +1,7 @@
#ifndef SecuritySettingsService_h #ifndef SecuritySettingsService_h
#define SecuritySettingsService_h #define SecuritySettingsService_h
#include <Features.h>
#include <SecurityManager.h> #include <SecurityManager.h>
#include <HttpEndpoint.h> #include <HttpEndpoint.h>
#include <FSPersistence.h> #include <FSPersistence.h>
@ -24,6 +25,8 @@
#define SECURITY_SETTINGS_FILE "/config/securitySettings.json" #define SECURITY_SETTINGS_FILE "/config/securitySettings.json"
#define SECURITY_SETTINGS_PATH "/rest/securitySettings" #define SECURITY_SETTINGS_PATH "/rest/securitySettings"
#if FT_ENABLED(FT_SECURITY)
class SecuritySettings { class SecuritySettings {
public: public:
String jwtSecret; String jwtSecret;
@ -93,4 +96,19 @@ class SecuritySettingsService : public StatefulService<SecuritySettings>, public
boolean validatePayload(JsonObject& parsedPayload, User* user); boolean validatePayload(JsonObject& parsedPayload, User* user);
}; };
#else
class SecuritySettingsService : public SecurityManager {
public:
SecuritySettingsService(AsyncWebServer* server, FS* fs);
~SecuritySettingsService();
// minimal set of functions to support framework with security settings disabled
Authentication authenticateRequest(AsyncWebServerRequest* request);
ArRequestFilterFunction filterRequest(AuthenticationPredicate predicate);
ArRequestHandlerFunction wrapRequest(ArRequestHandlerFunction onRequest, AuthenticationPredicate predicate);
ArJsonRequestHandlerFunction wrapCallback(ArJsonRequestHandlerFunction onRequest, AuthenticationPredicate predicate);
};
#endif // end FT_ENABLED(FT_SECURITY)
#endif // end SecuritySettingsService_h #endif // end SecuritySettingsService_h

View File

@ -21,11 +21,11 @@ enum class StateUpdateResult {
ERROR // There was a problem updating the state, propagation should not take place ERROR // There was a problem updating the state, propagation should not take place
}; };
template <class T> template <typename T>
using JsonStateUpdater = StateUpdateResult (*)(JsonObject& root, T& settings); using JsonStateUpdater = std::function<StateUpdateResult(JsonObject& root, T& settings)>;
template <class T> template <typename T>
using JsonStateReader = void (*)(T& settings, JsonObject& root); using JsonStateReader = std::function<void(T& settings, JsonObject& root)>;
typedef size_t update_handler_id_t; typedef size_t update_handler_id_t;
typedef std::function<void(const String& originId)> StateUpdateCallback; typedef std::function<void(const String& originId)> StateUpdateCallback;

View File

@ -1,11 +1,14 @@
[platformio] [platformio]
extra_configs = factory_settings.ini extra_configs =
factory_settings.ini
features.ini
default_envs = esp12e default_envs = esp12e
;default_envs = node32s ;default_envs = node32s
[env] [env]
build_flags= build_flags=
${factory_settings.build_flags} ${factory_settings.build_flags}
${features.build_flags}
-D NO_GLOBAL_ARDUINOOTA -D NO_GLOBAL_ARDUINOOTA
; Uncomment ENABLE_CORS to enable Cross-Origin Resource Sharing (required for local React development) ; Uncomment ENABLE_CORS to enable Cross-Origin Resource Sharing (required for local React development)
;-D ENABLE_CORS ;-D ENABLE_CORS