More work on the ESP32C3 Boilerplate
This commit is contained in:
94
README.md
94
README.md
@@ -25,7 +25,7 @@ It provides basic "infrastructure" and "Framework" for specific developments.
|
||||
- Control the on-board LED (set brightness; 0-> off; 100->full brightness)
|
||||
- Ping (that returns the current uptime as JSON)
|
||||
- Add (takes 2 integers and returns the result of adding those integers)
|
||||
- Admin Web-UI
|
||||
- Admin Web-UI
|
||||
- Role based security configuration
|
||||
- Roles are centrally maintained
|
||||
- User management
|
||||
@@ -43,9 +43,12 @@ It provides basic "infrastructure" and "Framework" for specific developments.
|
||||
- search
|
||||
- view
|
||||
- clear logs
|
||||
- HTTPS configuration
|
||||
- By default a self-signed certificate is used
|
||||
- Allows the set up of certificates for HTTPS (file upload)
|
||||
- Networking configuration
|
||||
- Host name
|
||||
- DHCP or static IPv4 parameters
|
||||
- HTTPS certificate configuration
|
||||
- By default a self-signed certificate is used
|
||||
- Allows the set up of certificates for HTTPS (file upload)
|
||||
- Firmware update handling
|
||||
- Upload new firmware via file selector
|
||||
- Check URL for new firmware button (this will reach out to a configurable (in the code) URL to to try and find new firmware. If new firmware is available it offers to install.)
|
||||
@@ -69,8 +72,9 @@ It provides basic "infrastructure" and "Framework" for specific developments.
|
||||
- Once the user entered submitted information, the information is stored in non volatile memory, and the device is restarted.
|
||||
- On subsequent startups, the device looks for configuraiton stored in non volatile memory
|
||||
- The factory reset functionality deletes the information from the non volatile memory, which will lead to the setup screen.
|
||||
- Logging is done based on log level.
|
||||
- The logs are stored in non volatile memory
|
||||
- Logging is done based on log level.
|
||||
- Log levels are `Error`, `Warn`, `SecurityAudit`, `Info`, and `Debug`; `SecurityAudit` records security-relevant events such as login/logout, invalid bearer tokens, authorization failures, unknown API URLs/methods, and security configuration changes.
|
||||
- The logs are stored in non volatile memory
|
||||
- they need to be implemented as a ring buffer that occupies a configurable space in non volatile memory
|
||||
- There is a standard API endpoint that returns the logs (restricted to user role Debugger)
|
||||
|
||||
@@ -81,17 +85,20 @@ The firmware is split by responsibility:
|
||||
| Path | Responsibility |
|
||||
| --- | --- |
|
||||
| `src/main.cpp` | Arduino `setup()`/`loop()` and boot orchestration |
|
||||
| `src/app.h` | Shared constants, state, structs, and function declarations |
|
||||
| `include/app.h` | Framework constants, state, structs, and function declarations |
|
||||
| `include/custom_api.h` | Custom API handler declarations |
|
||||
| `src/config/api_definitions.cpp` | Central API catalog, route handlers, and default role/public access mapping |
|
||||
| `src/config/api_definitions_custom.cpp` | Custom API catalog and default public access mapping |
|
||||
| `src/core/state.cpp` | Global state, project/device identity, hashing, persisted settings |
|
||||
| `src/core/logging.cpp` | LittleFS log ring buffer |
|
||||
| `src/core/auth.cpp` | Users, roles, tokens, and API authorization |
|
||||
| `src/core/device.cpp` | LED control, factory reset, and WiFi connection |
|
||||
| `src/util/json_utils.cpp` | Small JSON response and request parsing helpers |
|
||||
| `src/web/ui.cpp` | LittleFS-backed HTML serving and captive-portal helper pages |
|
||||
| `src/web/routes.cpp` | Page/captive route registration and generic API route registration from `apiDefs` |
|
||||
| `src/web/routes.cpp` | Page/captive route registration and generic API route registration from framework and custom API catalogs |
|
||||
| `src/handlers/handlers_setup.cpp` | Setup and WiFi scan route handlers |
|
||||
| `src/handlers/handlers_api.cpp` | Public/device API handlers and API ACL handlers |
|
||||
| `src/handlers/handlers_custom_api.cpp` | Custom API handlers for ping, add, and LED brightness |
|
||||
| `src/handlers/handlers_api.cpp` | API ACL list/detail management handlers |
|
||||
| `src/handlers/handlers_auth.cpp` | Login, users, roles, and password route handlers |
|
||||
| `src/handlers/handlers_admin.cpp` | Settings, logs, and certificate route handlers |
|
||||
| `src/handlers/handlers_ota.cpp` | Firmware upload and OTA route handlers |
|
||||
@@ -116,6 +123,7 @@ The firmware in `src/main.cpp` implements the boilerplate as a compact Arduino E
|
||||
- Ring-buffer logging in LittleFS with default maximum size of 50 KiB.
|
||||
- Firmware and LittleFS filesystem upload OTA and update-from-URL hooks.
|
||||
- HTTPS certificate storage API. The default Arduino `WebServer` runs HTTP; stored certificate material is available for applications that add TLS termination.
|
||||
- Network settings for hostname plus DHCP/static IPv4 configuration.
|
||||
|
||||
### Web UI files
|
||||
|
||||
@@ -134,6 +142,8 @@ After changing files in `data/`, upload the filesystem image as well as the firm
|
||||
pio run -t uploadfs
|
||||
```
|
||||
|
||||
In the PlatformIO UI, use the `Upload Firmware and Filesystem` project task when you want one action to upload both firmware and the LittleFS image that contains the web UI.
|
||||
|
||||
Uploading the filesystem image replaces the LittleFS contents, including stored log files. WiFi configuration, users, roles, and settings are stored in NVS preferences and are not part of that filesystem image.
|
||||
|
||||
### Provisioning
|
||||
@@ -162,6 +172,24 @@ The default project name is `TSL-Embedded`. The firmware combines the project na
|
||||
|
||||
On normal boot, the device connects to the configured WiFi and serves the Admin UI at the IP printed to serial.
|
||||
|
||||
### Networking
|
||||
|
||||
Open `Networking` in the Admin UI to configure the station-mode host name and IP parameters.
|
||||
|
||||
The host name field is optional. If it is empty, the firmware uses the generated default host name based on `PROJECT_NAME` and the chip suffix, for example `TSL-Embedded-BDF5F0`. A custom host name must be 1-31 characters and may contain only letters, digits, and hyphens. It cannot start or end with a hyphen.
|
||||
|
||||
Address mode defaults to DHCP. To use a static IPv4 address, select `Static IPv4` and provide:
|
||||
|
||||
| Field | Required | Example |
|
||||
| --- | --- | --- |
|
||||
| Static IP | Yes | `192.168.1.50` |
|
||||
| Gateway | Yes | `192.168.1.1` |
|
||||
| Subnet mask | Yes | `255.255.255.0` |
|
||||
| DNS 1 | No | `192.168.1.1` |
|
||||
| DNS 2 | No | `8.8.8.8` |
|
||||
|
||||
Networking changes are stored immediately, but they apply on the next WiFi reconnect or reboot.
|
||||
|
||||
### Factory reset
|
||||
|
||||
The default reset pin is GPIO4. Hold GPIO4 LOW during boot for 10 seconds to clear stored configuration and logs, then the device restarts into setup mode.
|
||||
@@ -190,6 +218,51 @@ The response contains a bearer token. Pass it to protected APIs:
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### HTTPS certificate configuration
|
||||
|
||||
The Admin UI stores HTTPS certificate material so applications built on this boilerplate can use it when adding TLS termination. The default Arduino `WebServer` used by this project serves HTTP only; uploading a certificate stores the material in NVS preferences but does not by itself switch the built-in web server to HTTPS.
|
||||
|
||||
To configure the stored certificate material:
|
||||
|
||||
1. Log in as a user with the `Sysadmin` role.
|
||||
2. Open `Networking` -> `HTTPS Certificate`.
|
||||
3. Select a certificate file and click `Save certificate`.
|
||||
4. Use `Load current` to verify what is currently stored.
|
||||
|
||||
The upload file must be a plain text PEM-style file. Use UTF-8 or ASCII text and preserve the PEM block line breaks exactly. The file may use `.pem`, `.cer`, `.crt`, or `.txt`.
|
||||
|
||||
Valid content is one or more PEM blocks, for example a certificate chain:
|
||||
|
||||
```text
|
||||
-----BEGIN CERTIFICATE-----
|
||||
...base64 certificate data...
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
...base64 intermediate certificate data...
|
||||
-----END CERTIFICATE-----
|
||||
```
|
||||
|
||||
If your TLS integration expects both the certificate and private key from this stored value, put both PEM blocks in the same text file:
|
||||
|
||||
```text
|
||||
-----BEGIN CERTIFICATE-----
|
||||
...base64 certificate data...
|
||||
-----END CERTIFICATE-----
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
...base64 private key data...
|
||||
-----END PRIVATE KEY-----
|
||||
```
|
||||
|
||||
Do not upload binary DER, PKCS#12/PFX, or password-protected keystore files directly. Convert those to PEM text first. The equivalent API is:
|
||||
|
||||
```http
|
||||
POST /api/cert
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{"certificate":"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"}
|
||||
```
|
||||
|
||||
### API response shape
|
||||
|
||||
Every API returns JSON with a `success` field. Errors include an `error` string:
|
||||
@@ -241,6 +314,9 @@ Protected APIs and their default roles:
|
||||
| `POST /api/settings` | `Sysadmin` |
|
||||
| `GET /api/logs` | `Debugger` |
|
||||
| `POST /api/logs/clear` | `Debugger` |
|
||||
| `GET /api/files` | `Debugger` |
|
||||
| `GET /api/files/download` | `Debugger` |
|
||||
| `POST /api/factory-reset` | `Sysadmin` |
|
||||
| `POST /api/ota/check` | `Sysadmin` |
|
||||
| `POST /api/ota/run` | `Sysadmin` |
|
||||
| `POST /api/update` | `Sysadmin` |
|
||||
|
||||
159
data/admin.html
159
data/admin.html
@@ -8,24 +8,30 @@
|
||||
body{margin:0}.top{background:#263238;color:white;padding:14px 18px;font-weight:650}
|
||||
main{max-width:1040px;margin:0 auto;padding:18px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}
|
||||
section,.box{background:white;border:1px solid #d8dee2;border-radius:8px;padding:14px}
|
||||
label{display:block;font-size:13px;margin:10px 0 4px}input,select,textarea,button{font:inherit}
|
||||
h3{margin-top:0}label{display:block;font-size:13px;margin:10px 0 4px}input,select,textarea,button{font:inherit}
|
||||
input,select,textarea{width:100%;box-sizing:border-box;padding:9px;border:1px solid #b9c2c8;border-radius:6px}
|
||||
button{border:0;border-radius:6px;background:#1261a6;color:white;padding:9px 12px;cursor:pointer;margin-top:10px}
|
||||
textarea{min-height:110px;resize:vertical}button{border:0;border-radius:6px;background:#1261a6;color:white;padding:9px 12px;cursor:pointer;margin-top:10px}
|
||||
button:disabled{opacity:.65;cursor:wait}.spinner{display:inline-block;width:1em;height:1em;border:2px solid rgba(255,255,255,.55);border-top-color:#fff;border-radius:50%;animation:spin .8s linear infinite;vertical-align:-2px;margin-right:6px}@keyframes spin{to{transform:rotate(360deg)}}
|
||||
button.secondary{background:#607d8b}button.danger{background:#b3261e}
|
||||
pre{white-space:pre-wrap;background:#111;color:#d7ffd7;padding:10px;border-radius:6px;max-height:300px;overflow:auto}
|
||||
.row{display:flex;gap:8px;align-items:end}.row>*{flex:1}.hidden{display:none}.muted{color:#607d8b;font-size:13px}
|
||||
.row{display:flex;gap:8px;align-items:end}.row>*{flex:1}.hidden{display:none!important}.muted{color:#607d8b;font-size:13px}
|
||||
.tabs{display:flex;gap:6px;border-bottom:1px solid #cfd8dc;margin:0 0 14px;overflow-x:auto}
|
||||
.tab{background:transparent;color:#263238;border-radius:6px 6px 0 0;margin:0;padding:10px 14px;white-space:nowrap}
|
||||
.tab.active{background:#1261a6;color:white}.panel{display:none}.panel.active{display:block}
|
||||
.subtabs{margin-top:4px}.subtab{font-size:14px;padding:8px 12px}.subpanel{display:none}.subpanel.active{display:block}
|
||||
.rangeRow{display:flex;gap:12px;align-items:center}.rangeRow input{flex:1}.rangeRow output{min-width:3ch;text-align:right;font-weight:650}
|
||||
.list{display:flex;flex-direction:column;gap:6px;margin-top:10px}.item{background:#eef3f6;color:#263238;text-align:left;margin:0}.item.active{background:#1261a6;color:white}
|
||||
.list{display:flex;flex-direction:column;gap:6px;margin-top:10px}.item{background:#eef3f6;color:#263238;text-align:left;margin:0;padding:9px 12px;box-sizing:border-box}.item:hover{background:#dbe7ed}
|
||||
.meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}.badge{background:#eef3f6;border-radius:6px;padding:4px 7px;font-size:12px;color:#263238}
|
||||
.checkGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:6px;margin-top:8px}.checkGrid label{display:flex;gap:6px;align-items:center;margin:0}.checkGrid input{width:auto}
|
||||
.pill{display:inline-flex;gap:6px;align-items:center;background:#eef3f6;border-radius:6px;padding:5px 8px;margin:4px 4px 0 0}.pill button{margin:0;padding:3px 7px;background:#b3261e}
|
||||
.detailTitle{margin:0 0 8px}.inlineCheck{display:flex;gap:8px;align-items:center;margin-top:10px}.inlineCheck input{width:auto}
|
||||
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px}.toolbar button{margin-top:0}.detailActions{display:flex;gap:8px;flex-wrap:wrap}.detailActions button{flex:0 1 auto}
|
||||
.fullWidth{grid-column:1/-1}.busyChip{position:fixed;right:14px;top:10px;z-index:10;background:#1261a6;color:white;border-radius:6px;padding:7px 10px;box-shadow:0 2px 10px rgba(0,0,0,.22);font-size:13px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">ESP32-C3 Admin</div>
|
||||
<div id="busyChip" class="busyChip hidden"><span class="spinner"></span>Working...</div>
|
||||
<main>
|
||||
<div id="loginPanel" class="box">
|
||||
<h2>Login</h2>
|
||||
@@ -38,56 +44,137 @@
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="dashboard" onclick="showTab('dashboard')">Dashboard</button>
|
||||
<button class="tab" data-tab="access" onclick="showTab('access')">Access</button>
|
||||
<button class="tab" data-tab="ops" onclick="showTab('ops')">Operations</button>
|
||||
<button class="tab" data-tab="network" onclick="showTab('network')">Networking</button>
|
||||
<button class="tab" data-tab="ops" onclick="showTab('ops')">System</button>
|
||||
</div>
|
||||
<div id="dashboard" class="panel active"><div class="grid">
|
||||
<section><h3>Status</h3><button onclick="call('GET','/api/ping')">Ping</button><button class="secondary" onclick="loadMe()">Me</button><pre id="statusOut"></pre></section>
|
||||
<section><h3>LED</h3><label>Brightness</label><div class="rangeRow"><input id="brightness" type="range" min="0" max="100" value="0" oninput="ledVal.value=value"><output id="ledVal">0</output></div><button onclick="setLed()">Apply</button></section>
|
||||
<section><h3>LED</h3><label>Brightness</label><div class="rangeRow"><input id="brightness" type="range" min="0" max="100" value="0" oninput="ledVal.value=value"><output id="ledVal">0</output></div><button onclick="setLed()">Apply</button><pre id="ledOut"></pre></section>
|
||||
<section><h3>Add API</h3><div class="row"><div><label>A</label><input id="a" type="number" value="1"></div><div><label>B</label><input id="b" type="number" value="2"></div></div><button onclick="addNums()">Add</button><pre id="addOut"></pre></section>
|
||||
</div></div>
|
||||
<div id="access" class="panel"><div class="grid">
|
||||
<section><h3>Roles</h3><div class="row"><div><label>Custom role</label><input id="roleName"></div><div><button onclick="addRole()">Add</button></div></div><div id="rolesOut"></div><pre id="roleMsg"></pre></section>
|
||||
<section><h3>Users</h3><div class="row"><button onclick="loadAccess()">Refresh</button><button class="secondary" onclick="newUserRecord()">New</button></div><div id="userList" class="list"></div></section>
|
||||
<section><h3>User Details</h3><h4 id="userTitle" class="detailTitle">Select a user</h4><label>Name</label><input id="userName"><label>Password</label><input id="userPass" type="password" placeholder="Leave blank to keep unchanged"><label class="inlineCheck"><input id="userActive" type="checkbox" checked> Active</label><label>Roles</label><div id="userRoles" class="checkGrid"></div><button onclick="saveSelectedUser()">Save user</button><pre id="usersOut"></pre></section>
|
||||
<section><h3>API Security</h3><button onclick="loadApis()">Refresh</button><div id="apiList" class="list"></div></section>
|
||||
<section><h3>API Details</h3><h4 id="apiTitle" class="detailTitle">Select an API</h4><label>Path</label><input id="apiPath" readonly><label>Method</label><input id="apiMethod" readonly><label>Required role</label><select id="apiRole"></select><button onclick="saveSelectedApi()">Save ACL</button><pre id="apisOut"></pre></section>
|
||||
<div id="access" class="panel">
|
||||
<div class="tabs subtabs">
|
||||
<button class="tab subtab active" data-subtab="users" onclick="showAccessTab('users')">User Management</button>
|
||||
<button class="tab subtab" data-subtab="roles" onclick="showAccessTab('roles')">Role Management</button>
|
||||
<button class="tab subtab" data-subtab="apis" onclick="showAccessTab('apis')">API Management</button>
|
||||
</div>
|
||||
|
||||
<div id="users" class="subpanel active">
|
||||
<section id="usersOverview">
|
||||
<div class="toolbar"><h3>User Overview</h3><button onclick="loadUsers()">Refresh</button><button class="secondary" onclick="newUserRecord()">New</button></div>
|
||||
<div id="userList" class="list"></div>
|
||||
</section>
|
||||
<section id="userDetail" class="hidden">
|
||||
<div class="toolbar"><button class="secondary" onclick="showUserOverview()">Back</button><h3 id="userTitle" class="detailTitle">User Detail</h3></div>
|
||||
<label>Name</label><input id="userName">
|
||||
<label>Password</label><input id="userPass" type="password" placeholder="Leave blank to keep unchanged">
|
||||
<label class="inlineCheck"><input id="userActive" type="checkbox" checked> Active</label>
|
||||
<label>Roles</label><div id="userRoles" class="checkGrid"></div>
|
||||
<div class="detailActions"><button onclick="saveSelectedUser()">Save user</button></div>
|
||||
<pre id="usersOut"></pre>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="roles" class="subpanel">
|
||||
<section id="rolesOverview">
|
||||
<div class="toolbar"><h3>Role Overview</h3><button onclick="loadRoles()">Refresh</button><button class="secondary" onclick="newRoleRecord()">New</button></div>
|
||||
<div id="rolesOut" class="list"></div>
|
||||
</section>
|
||||
<section id="roleDetail" class="hidden">
|
||||
<div class="toolbar"><button class="secondary" onclick="showRoleOverview()">Back</button><h3 id="roleTitle" class="detailTitle">Role Detail</h3></div>
|
||||
<label>Name</label><input id="roleName">
|
||||
<div id="roleMeta" class="meta"></div>
|
||||
<div class="detailActions"><button id="roleSave" onclick="addRole()">Save role</button><button id="roleDelete" class="danger" onclick="deleteSelectedRole()">Delete role</button></div>
|
||||
<pre id="roleMsg"></pre>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="apis" class="subpanel">
|
||||
<section id="apisOverview">
|
||||
<div class="toolbar"><h3>API Overview</h3><button onclick="loadApis()">Refresh</button></div>
|
||||
<div id="apiList" class="list"></div>
|
||||
</section>
|
||||
<section id="apiDetail" class="hidden">
|
||||
<div class="toolbar"><button class="secondary" onclick="showApiOverview()">Back</button><h3 id="apiTitle" class="detailTitle">API Detail</h3></div>
|
||||
<label>Path</label><input id="apiPath" readonly>
|
||||
<label>Method</label><input id="apiMethod" readonly>
|
||||
<label>Required role</label><select id="apiRole"></select>
|
||||
<button onclick="saveSelectedApi()">Save ACL</button>
|
||||
<pre id="apisOut"></pre>
|
||||
<h3>Test</h3>
|
||||
<label>JSON body</label><textarea id="apiTestBody" placeholder='{"key":"value"}'></textarea>
|
||||
<div class="detailActions"><button onclick="testSelectedApi()">Run test</button><button class="secondary" onclick="clearApiTestBody()">Clear body</button></div>
|
||||
<pre id="apiTestOut"></pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div id="network" class="panel"><div class="grid">
|
||||
<section><h3>Network</h3><label>Host name</label><input id="netHost" placeholder="Default host name"><label>Address mode</label><select id="netDhcp" onchange="syncNetworkMode()"><option value="true">DHCP</option><option value="false">Static IPv4</option></select><div id="netCurrent" class="muted"></div><label>Static IP</label><input id="netIp" inputmode="decimal" placeholder="192.168.1.50"><label>Gateway</label><input id="netGateway" inputmode="decimal" placeholder="192.168.1.1"><label>Subnet mask</label><input id="netSubnet" inputmode="decimal" placeholder="255.255.255.0"><div class="row"><div><label>DNS 1</label><input id="netDns1" inputmode="decimal" placeholder="192.168.1.1"></div><div><label>DNS 2</label><input id="netDns2" inputmode="decimal" placeholder="8.8.8.8"></div></div><button id="networkSaveBtn" onclick="saveNetwork()">Save network</button><pre id="networkOut"></pre></section>
|
||||
<section><h3>HTTPS Certificate</h3><label>Certificate file</label><input id="certFile" type="file" accept=".pem,.cer,.crt,.txt,application/x-pem-file,application/pkix-cert"><button id="certSaveBtn" onclick="saveCertFile()">Save certificate</button><button class="secondary" onclick="loadCert()">Load current</button><pre id="certOut"></pre></section>
|
||||
</div></div>
|
||||
<div id="ops" class="panel"><div class="grid">
|
||||
<section><h3>Logs</h3><div class="row"><button onclick="loadLogs()">Load</button><button class="danger" onclick="clearLogs()">Clear</button></div><pre id="logsOut"></pre></section>
|
||||
<section><h3>Settings</h3><label>Log level</label><select id="logLevel"><option value="0">Error</option><option value="1">Warn</option><option value="2" selected>Info</option><option value="3">Debug</option></select><label>Max log bytes</label><input id="logMax" type="number" value="51200"><label>Update package URL</label><input id="upd"><button onclick="saveSettings()">Save</button><pre id="settingsOut"></pre></section>
|
||||
<section><h3>Updates</h3><label>Update package</label><input id="pkg" type="file"><button onclick="uploadPkg()">Upload package</button><button class="secondary" onclick="checkOta()">Check URL</button><button class="danger" onclick="runOta()">Install from URL</button><pre id="fwOut"></pre></section>
|
||||
<section><h3>Settings</h3><label>Log level</label><select id="logLevel"><option value="0">Error</option><option value="1">Warn</option><option value="2">SecurityAudit</option><option value="3" selected>Info</option><option value="4">Debug</option></select><label>Max log bytes</label><input id="logMax" type="number" value="51200"><label>Update package URL</label><input id="upd"><button onclick="saveSettings()">Save</button><pre id="settingsOut"></pre></section>
|
||||
<section><h3>Updates</h3><label>Update package</label><input id="pkg" type="file"><button id="uploadPkgBtn" onclick="uploadPkg()">Upload package</button><button class="secondary" onclick="checkOta()">Check URL</button><button class="danger" onclick="runOta()">Install from URL</button><pre id="fwOut"></pre></section>
|
||||
<section><h3>Maintenance</h3><button onclick="downloadConfig()">Download config</button><label>Restore configuration</label><input id="configFile" type="file" accept=".json,application/json"><button id="restoreConfigBtn" class="secondary" onclick="restoreConfig()">Restore config</button><button class="danger" onclick="factoryResetDevice()">Factory reset</button><pre id="maintOut"></pre></section>
|
||||
<section class="fullWidth"><h3>Files</h3><button onclick="loadFiles()">Refresh</button><div id="fileList" class="list"></div><pre id="filesOut"></pre></section>
|
||||
<section class="fullWidth"><h3>Logs</h3><div class="row"><button onclick="loadLogs()">Load</button><button class="danger" onclick="clearLogs()">Clear</button></div><pre id="logsOut"></pre></section>
|
||||
</div></div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
let token='';const el=id=>document.getElementById(id);
|
||||
async function req(m,u,b){let o={method:m,headers:{'Content-Type':'application/json'}};if(token)o.headers.Authorization='Bearer '+token;if(b!==undefined)o.body=JSON.stringify(b);let r=await fetch(u,o);return await r.text()}
|
||||
function showTab(id){document.querySelectorAll('.tab').forEach(b=>b.classList.toggle('active',b.dataset.tab==id));document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('active',p.id==id))}
|
||||
let roles=[],users=[],apis=[],selectedUser='',selectedApi=-1;
|
||||
let busyCount=0;function setGlobalBusy(on){busyCount+=on?1:-1;if(busyCount<0)busyCount=0;el('busyChip').classList.toggle('hidden',busyCount==0)}
|
||||
async function tracked(p){setGlobalBusy(true);try{return await p()}finally{setGlobalBusy(false)}}
|
||||
async function apiFetch(u,o){return await tracked(()=>fetch(u,o))}
|
||||
async function req(m,u,b){let o={method:m,headers:{'Content-Type':'application/json'}};if(token)o.headers.Authorization='Bearer '+token;if(b!==undefined&&m!='GET')o.body=JSON.stringify(b);let r=await apiFetch(u,o);return await r.text()}
|
||||
function showTab(id){document.querySelectorAll('.tab[data-tab]').forEach(b=>b.classList.toggle('active',b.dataset.tab==id));document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('active',p.id==id));if(id=='network')loadSettings()}
|
||||
function showAccessTab(id){document.querySelectorAll('.subtab').forEach(b=>b.classList.toggle('active',b.dataset.subtab==id));document.querySelectorAll('.subpanel').forEach(p=>p.classList.toggle('active',p.id==id));if(id=='users')showUserOverview();if(id=='roles')showRoleOverview();if(id=='apis')showApiOverview()}
|
||||
let roles=[],users=[],apis=[],selectedUser='',selectedRole='',selectedApi=-1;
|
||||
async function doLogin(){let t=await req('POST','/api/login',{username:el('user').value,password:el('pass').value});el('loginOut').textContent=t;let j=JSON.parse(t);if(j.success){token=j.token;el('loginPanel').classList.add('hidden');el('app').classList.remove('hidden');loadMe();loadSettings();loadAccess()}}
|
||||
async function call(m,u){el('statusOut').textContent=await req(m,u)}async function loadMe(){el('statusOut').textContent=await req('GET','/api/me')}
|
||||
async function setLed(){el('statusOut').textContent=await req('POST','/api/led',{brightness:+el('brightness').value})}async function addNums(){el('addOut').textContent=await req('POST','/api/add',{a:+el('a').value,b:+el('b').value})}
|
||||
function roleNames(){return roles.map(r=>r.name)}function roleChecks(id,selected){el(id).innerHTML=roleNames().map(r=>'<label><input type=checkbox value="'+r+'" '+(selected.includes(r)?'checked':'')+'> '+r+'</label>').join('')}
|
||||
async function setLed(){el('ledOut').textContent=await req('POST','/api/led',{brightness:+el('brightness').value})}async function addNums(){el('addOut').textContent=await req('POST','/api/add',{a:+el('a').value,b:+el('b').value})}
|
||||
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||
function roleNames(){return roles.map(r=>r.name)}function roleChecks(id,selected){el(id).innerHTML=roleNames().map(r=>'<label><input type=checkbox value="'+esc(r)+'" '+(selected.includes(r)?'checked':'')+'> '+esc(r)+'</label>').join('')}
|
||||
function selectedRoles(){return [...el('userRoles').querySelectorAll('input:checked')].map(x=>x.value)}
|
||||
function renderRoles(){el('rolesOut').innerHTML=roles.map(r=>'<span class=pill>'+r.name+(r.system?'':' <button onclick="deleteRole(\''+r.name+'\')">Delete</button>')+'</span>').join('')}
|
||||
async function loadRoles(){let j=JSON.parse(await req('GET','/api/roles'));if(j.success){roles=j.roles;renderRoles();roleOptions();if(selectedUser)selectUser(selectedUser)}}
|
||||
async function addRole(){let t=await req('POST','/api/roles',{role:el('roleName').value});el('roleMsg').textContent=t;el('roleName').value='';await loadAccess()}
|
||||
async function deleteRole(r){let t=await req('DELETE','/api/roles',{role:r});el('roleMsg').textContent=t;await loadAccess()}
|
||||
function renderUsers(){el('userList').innerHTML=users.map(u=>'<button class="item '+(u.username==selectedUser?'active':'')+'" onclick="selectUser(\''+u.username+'\')">'+u.username+(u.active?'':' (inactive)')+'</button>').join('')}
|
||||
async function loadUsers(){let j=JSON.parse(await req('GET','/api/users'));if(j.success){users=j.users;renderUsers();if(selectedUser)selectUser(selectedUser)}}
|
||||
function newUserRecord(){selectedUser='';renderUsers();el('userTitle').textContent='New user';el('userName').value='';el('userName').readOnly=false;el('userPass').value='';el('userActive').checked=true;roleChecks('userRoles',[])}
|
||||
function selectUser(name){selectedUser=name;let u=users.find(x=>x.username==name);if(!u)return;renderUsers();el('userTitle').textContent=name;el('userName').value=name;el('userName').readOnly=true;el('userPass').value='';el('userActive').checked=u.active;roleChecks('userRoles',(u.roles||'').split('|'))}
|
||||
async function saveSelectedUser(){let t=await req('POST','/api/users',{username:el('userName').value,password:el('userPass').value,active:el('userActive').checked,roles:selectedRoles()});el('usersOut').textContent=t;selectedUser=el('userName').value;await loadUsers()}
|
||||
function roleOptions(){let opts='<option>PUBLIC</option>'+roleNames().map(r=>'<option>'+r+'</option>').join('');el('apiRole').innerHTML=opts}
|
||||
function renderApis(){el('apiList').innerHTML=apis.map((a,i)=>'<button class="item '+(i==selectedApi?'active':'')+'" onclick="selectApi('+i+')">'+a.method+' '+a.path+'</button>').join('')}
|
||||
async function loadApis(){let j=JSON.parse(await req('GET','/api/apis'));if(j.success){apis=j.apis;renderApis();if(selectedApi>=0)selectApi(selectedApi)}}
|
||||
function selectApi(i){selectedApi=i;let a=apis[i];if(!a)return;renderApis();el('apiTitle').textContent=a.method+' '+a.path;el('apiPath').value=a.path;el('apiMethod').value=a.method;roleOptions();el('apiRole').value=a.role}
|
||||
function showUserOverview(){el('usersOverview').classList.remove('hidden');el('userDetail').classList.add('hidden')}
|
||||
function showRoleOverview(){el('rolesOverview').classList.remove('hidden');el('roleDetail').classList.add('hidden')}
|
||||
function showApiOverview(){el('apisOverview').classList.remove('hidden');el('apiDetail').classList.add('hidden')}
|
||||
function renderRoles(){el('rolesOut').innerHTML=roles.map((r,i)=>'<button class=item onclick="selectRole('+i+')"><strong>'+esc(r.name)+'</strong><span class=meta><span class=badge>'+(r.system?'System':'Custom')+'</span></span></button>').join('')}
|
||||
async function loadRoles(){let j=JSON.parse(await req('GET','/api/roles'));if(j.success){roles=j.roles;renderRoles();roleOptions();if(selectedUser&&el('userDetail').classList.contains('hidden')==false)selectUser(selectedUser)}}
|
||||
function newRoleRecord(){selectedRole='';el('rolesOverview').classList.add('hidden');el('roleDetail').classList.remove('hidden');el('roleTitle').textContent='New role';el('roleName').value='';el('roleName').readOnly=false;el('roleMeta').innerHTML='';el('roleSave').classList.remove('hidden');el('roleDelete').classList.add('hidden');el('roleMsg').textContent=''}
|
||||
function selectRole(i){let r=roles[i];if(!r)return;selectedRole=r.name;el('rolesOverview').classList.add('hidden');el('roleDetail').classList.remove('hidden');el('roleTitle').textContent=r.name;el('roleName').value=r.name;el('roleName').readOnly=true;el('roleMeta').innerHTML='<span class=badge>'+(r.system?'System role':'Custom role')+'</span>';el('roleSave').classList.add('hidden');el('roleDelete').classList.toggle('hidden',r.system);el('roleMsg').textContent=''}
|
||||
async function addRole(){let t=await req('POST','/api/roles',{role:el('roleName').value});el('roleMsg').textContent=t;await loadAccess();showRoleOverview()}
|
||||
async function deleteSelectedRole(){if(!selectedRole)return;let t=await req('DELETE','/api/roles',{role:selectedRole});el('roleMsg').textContent=t;selectedRole='';await loadAccess();showRoleOverview()}
|
||||
function jsArg(s){return JSON.stringify(String(s)).replace(/"/g,'"')}
|
||||
function renderUsers(){el('userList').innerHTML=users.map(u=>'<button class=item onclick="selectUser('+jsArg(u.username)+')"><strong>'+esc(u.username)+'</strong><span class=meta><span class=badge>'+(u.active?'Active':'Inactive')+'</span><span class=badge>'+esc(u.roles||'No roles')+'</span></span></button>').join('')}
|
||||
async function loadUsers(){let j=JSON.parse(await req('GET','/api/users'));if(j.success){users=j.users;renderUsers();if(selectedUser&&el('userDetail').classList.contains('hidden')==false)selectUser(selectedUser)}}
|
||||
function newUserRecord(){selectedUser='';el('usersOverview').classList.add('hidden');el('userDetail').classList.remove('hidden');el('userTitle').textContent='New user';el('userName').value='';el('userName').readOnly=false;el('userPass').value='';el('userActive').checked=true;roleChecks('userRoles',[]);el('usersOut').textContent=''}
|
||||
function selectUser(name){selectedUser=name;let u=users.find(x=>x.username==name);if(!u)return;el('usersOverview').classList.add('hidden');el('userDetail').classList.remove('hidden');el('userTitle').textContent=name;el('userName').value=name;el('userName').readOnly=true;el('userPass').value='';el('userActive').checked=u.active;roleChecks('userRoles',(u.roles||'').split('|'));el('usersOut').textContent=''}
|
||||
async function saveSelectedUser(){let t=await req('POST','/api/users',{username:el('userName').value,password:el('userPass').value,active:el('userActive').checked,roles:selectedRoles()});el('usersOut').textContent=t;selectedUser=el('userName').value;await loadUsers();showUserOverview()}
|
||||
function roleOptions(){let opts='<option>PUBLIC</option>'+roleNames().map(r=>'<option>'+esc(r)+'</option>').join('');el('apiRole').innerHTML=opts}
|
||||
function renderApis(){el('apiList').innerHTML=apis.map((a,i)=>'<button class=item onclick="selectApi('+i+')"><strong>'+esc(a.method+' '+a.path)+'</strong><span class=meta><span class=badge>'+esc(a.role)+'</span></span></button>').join('')}
|
||||
async function loadApis(){let j=JSON.parse(await req('GET','/api/apis'));if(j.success){apis=j.apis;renderApis();if(selectedApi>=0&&el('apiDetail').classList.contains('hidden')==false)selectApi(selectedApi)}}
|
||||
function selectApi(i){selectedApi=i;let a=apis[i];if(!a)return;el('apisOverview').classList.add('hidden');el('apiDetail').classList.remove('hidden');el('apiTitle').textContent=a.method+' '+a.path;el('apiPath').value=a.path;el('apiMethod').value=a.method;roleOptions();el('apiRole').value=a.role;el('apisOut').textContent='';el('apiTestOut').textContent=''}
|
||||
async function saveSelectedApi(){if(selectedApi<0)return;let t=await req('POST','/api/apis',{path:el('apiPath').value,method:el('apiMethod').value,role:el('apiRole').value});el('apisOut').textContent=t;await loadApis()}
|
||||
function clearApiTestBody(){el('apiTestBody').value=''}
|
||||
async function testSelectedApi(){if(selectedApi<0)return;let a=apis[selectedApi],body=el('apiTestBody').value.trim(),payload=undefined;if(body){try{payload=JSON.parse(body)}catch(e){el('apiTestOut').textContent='Invalid JSON body';return}}el('apiTestOut').textContent=await req(a.method,a.path,payload)}
|
||||
async function loadAccess(){await loadRoles();await loadUsers();await loadApis()}
|
||||
async function loadLogs(){el('logsOut').textContent=await req('GET','/api/logs')}async function clearLogs(){el('logsOut').textContent=await req('POST','/api/logs/clear')}
|
||||
async function loadSettings(){let t=await req('GET','/api/settings');el('settingsOut').textContent=t;let j=JSON.parse(t);if(j.success){el('logLevel').value=j.settings.logLevel;el('logMax').value=j.settings.maxLogBytes;el('upd').value=j.settings.updateUrl;el('brightness').value=j.settings.ledBrightness;el('ledVal').value=j.settings.ledBrightness}}
|
||||
function fileName(p){let i=String(p).lastIndexOf('/');return i>=0?p.substring(i+1):p}
|
||||
function renderFiles(files){el('fileList').innerHTML=files.map(f=>'<div class=item><div class=detailActions><button onclick="downloadFile('+jsArg(f.path)+')">Download</button><div><strong>'+esc(f.path)+'</strong><span class=meta><span class=badge>'+f.size+' bytes</span></span></div></div></div>').join('')}
|
||||
async function loadFiles(){let t=await req('GET','/api/files');el('filesOut').textContent='';try{let j=JSON.parse(t);if(j.success){renderFiles(j.files||[]);if(!(j.files||[]).length)el('filesOut').textContent='No files found'}else{el('filesOut').textContent=t}}catch(e){el('filesOut').textContent=t}}
|
||||
async function downloadFile(path){let r=await apiFetch('/api/files/download?path='+encodeURIComponent(path),{headers:{Authorization:'Bearer '+token}});if(!r.ok){el('filesOut').textContent=await r.text();return}let b=await r.blob(),a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=fileName(path)||'download';document.body.appendChild(a);a.click();URL.revokeObjectURL(a.href);a.remove()}
|
||||
async function downloadConfig(){let r=await apiFetch('/api/config/export',{headers:{Authorization:'Bearer '+token}});if(!r.ok){el('maintOut').textContent=await r.text();return}let b=await r.blob(),a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='config-backup.json';document.body.appendChild(a);a.click();URL.revokeObjectURL(a.href);a.remove();el('maintOut').textContent='Configuration backup downloaded'}
|
||||
async function restoreConfig(){if(!el('configFile').files.length){el('maintOut').textContent='Select a configuration JSON file first';return}if(!confirm('Restore configuration from this file? WiFi settings will not change.'))return;setBusy('restoreConfigBtn',true,'Restoring');try{await tracked(async()=>{let body=await el('configFile').files[0].text();let r=await fetch('/api/config/import',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body});el('maintOut').textContent=await r.text();if(r.ok){loadSettings();loadAccess()}})}catch(e){el('maintOut').textContent='Restore failed: '+e.message}finally{setBusy('restoreConfigBtn',false,'Restore config')}}
|
||||
async function loadCert(){let t=await req('GET','/api/cert');try{let j=JSON.parse(t);el('certOut').textContent=j.success?(j.certificate||'No certificate stored'):t}catch(e){el('certOut').textContent=t}}
|
||||
async function saveCertFile(){if(!el('certFile').files.length){el('certOut').textContent='Select a PEM/CER/CRT file first';return}setBusy('certSaveBtn',true,'Saving');try{await tracked(async()=>{let certificate=await el('certFile').files[0].text();el('certOut').textContent=await req('POST','/api/cert',{certificate})})}catch(e){el('certOut').textContent='Certificate save failed: '+e.message}finally{setBusy('certSaveBtn',false,'Save certificate')}}
|
||||
async function factoryResetDevice(){if(!confirm('Factory reset this device and restart?'))return;el('maintOut').textContent=await req('POST','/api/factory-reset')}
|
||||
async function loadLogs(){let t=await req('GET','/api/logs');try{let j=JSON.parse(t);el('logsOut').textContent=j.success?(j.logs||''):t}catch(e){el('logsOut').textContent=t}}async function clearLogs(){let t=await req('POST','/api/logs/clear');el('logsOut').textContent=t;try{let j=JSON.parse(t);if(j.success)el('logsOut').textContent='Logs cleared'}catch(e){}}
|
||||
function syncNetworkMode(){let s=el('netDhcp').value=='false';['netIp','netGateway','netSubnet','netDns1','netDns2'].forEach(id=>el(id).disabled=!s)}
|
||||
async function loadSettings(){let t=await req('GET','/api/settings');el('settingsOut').textContent=t;let j=JSON.parse(t);if(j.success){el('logLevel').value=j.settings.logLevel;el('logMax').value=j.settings.maxLogBytes;el('upd').value=j.settings.updateUrl;el('brightness').value=j.settings.ledBrightness;el('ledVal').value=j.settings.ledBrightness;el('netHost').value=j.settings.hostname||'';el('netHost').placeholder=j.settings.defaultHostname||'Default host name';el('netDhcp').value=String(j.settings.dhcp!==false);el('netIp').value=j.settings.ip||'';el('netGateway').value=j.settings.gateway||'';el('netSubnet').value=j.settings.subnet||'';el('netDns1').value=j.settings.dns1||'';el('netDns2').value=j.settings.dns2||'';el('netCurrent').textContent='Current IP: '+(j.settings.currentIp||'not connected');syncNetworkMode()}}
|
||||
async function saveSettings(){el('settingsOut').textContent=await req('POST','/api/settings',{logLevel:+el('logLevel').value,maxLogBytes:+el('logMax').value,updateUrl:el('upd').value,ledBrightness:+el('brightness').value})}
|
||||
async function uploadPkg(){let fd=new FormData();fd.append('package',el('pkg').files[0]);let r=await fetch('/api/update',{method:'POST',headers:{Authorization:'Bearer '+token},body:fd});el('fwOut').textContent=await r.text()}
|
||||
async function saveNetwork(){setBusy('networkSaveBtn',true,'Saving');try{let body={hostname:el('netHost').value,dhcp:el('netDhcp').value=='true',ip:el('netIp').value,gateway:el('netGateway').value,subnet:el('netSubnet').value,dns1:el('netDns1').value,dns2:el('netDns2').value};let t=await req('POST','/api/settings',body);el('networkOut').textContent=t+'\\nNetwork changes apply on the next WiFi reconnect or reboot.';if(JSON.parse(t).success)await loadSettings()}catch(e){el('networkOut').textContent='Network save failed: '+e.message}finally{setBusy('networkSaveBtn',false,'Save network')}}
|
||||
function setBusy(id,busy,label){let b=el(id);if(!b)return;b.disabled=busy;b.innerHTML=busy?'<span class=spinner></span>'+label:label}
|
||||
async function uploadPkg(){if(!el('pkg').files.length){el('fwOut').textContent='Select an update package first';return}setBusy('uploadPkgBtn',true,'Uploading');el('fwOut').textContent='Uploading update package...';try{let fd=new FormData();fd.append('package',el('pkg').files[0]);let r=await apiFetch('/api/update',{method:'POST',headers:{Authorization:'Bearer '+token},body:fd});el('fwOut').textContent=await r.text()}catch(e){el('fwOut').textContent='Upload failed: '+e.message}finally{setBusy('uploadPkgBtn',false,'Upload package')}}
|
||||
async function checkOta(){el('fwOut').textContent=await req('POST','/api/ota/check')}async function runOta(){el('fwOut').textContent=await req('POST','/api/ota/run')}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -38,8 +38,9 @@ extern const IPAddress SETUP_AP_SUBNET;
|
||||
enum LogLevel : uint8_t {
|
||||
LOG_ERROR = 0,
|
||||
LOG_WARN = 1,
|
||||
LOG_INFO = 2,
|
||||
LOG_DEBUG = 3
|
||||
LOG_SECURITY_AUDIT = 2,
|
||||
LOG_INFO = 3,
|
||||
LOG_DEBUG = 4
|
||||
};
|
||||
|
||||
struct User {
|
||||
@@ -73,12 +74,21 @@ extern Preferences prefs;
|
||||
extern Token tokens[];
|
||||
extern ApiDef apiDefs[];
|
||||
extern const size_t API_DEF_COUNT;
|
||||
extern ApiDef customApiDefs[];
|
||||
extern const size_t CUSTOM_API_DEF_COUNT;
|
||||
extern LogLevel currentLogLevel;
|
||||
extern size_t maxLogBytes;
|
||||
extern bool setupMode;
|
||||
extern uint8_t ledBrightness;
|
||||
extern bool ledInverted;
|
||||
extern String updateUrl;
|
||||
extern String networkHostname;
|
||||
extern bool networkDhcp;
|
||||
extern String networkIp;
|
||||
extern String networkGateway;
|
||||
extern String networkSubnet;
|
||||
extern String networkDns1;
|
||||
extern String networkDns2;
|
||||
|
||||
String jsonEscape(const String &s);
|
||||
String jsonOk(const String &payload = "");
|
||||
@@ -92,6 +102,7 @@ String jsonRolesValue(const String &json, const char *key, const String &fallbac
|
||||
|
||||
String chipId();
|
||||
String deviceName();
|
||||
String defaultDeviceName();
|
||||
String sha256(const String &input);
|
||||
String passwordHash(const String &password);
|
||||
String prefString(const char *key, const String &fallback = "");
|
||||
@@ -113,7 +124,7 @@ String createToken(const User &user);
|
||||
Token *currentToken();
|
||||
ApiDef *findApi(const String &path, const String &method);
|
||||
String configuredRole(ApiDef *api);
|
||||
bool authorize(const String &path, const String &method);
|
||||
bool authorize();
|
||||
|
||||
void appendLog(LogLevel level, const String &message);
|
||||
|
||||
@@ -131,9 +142,6 @@ void handleAdminPage();
|
||||
|
||||
void handleWifiScan();
|
||||
void handleSetupSubmit();
|
||||
void handlePing();
|
||||
void handleAdd();
|
||||
void handleLed();
|
||||
void handleLogin();
|
||||
void handleLogout();
|
||||
void handleMe();
|
||||
@@ -149,6 +157,11 @@ void handleSettingsGet();
|
||||
void handleSettingsPost();
|
||||
void handleLogsGet();
|
||||
void handleLogsClear();
|
||||
void handleFilesList();
|
||||
void handleFileDownload();
|
||||
void handleFactoryReset();
|
||||
void handleConfigExport();
|
||||
void handleConfigImport();
|
||||
void handleOtaCheck();
|
||||
void handleOtaRun();
|
||||
void handleUpdateUploadDone();
|
||||
5
include/custom_api.h
Normal file
5
include/custom_api.h
Normal file
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
void handlePing();
|
||||
void handleAdd();
|
||||
void handleLed();
|
||||
@@ -8,8 +8,9 @@
|
||||
; Please visit documentation for the other options and examples
|
||||
; https://docs.platformio.org/page/projectconf.html
|
||||
|
||||
[env:seeed_xiao_esp32c3]
|
||||
platform = espressif32
|
||||
[env:seeed_xiao_esp32c3]
|
||||
platform = espressif32
|
||||
board = seeed_xiao_esp32c3
|
||||
framework = arduino
|
||||
board_build.filesystem = littlefs
|
||||
extra_scripts = scripts/platformio_targets.py
|
||||
|
||||
9
scripts/platformio_targets.py
Normal file
9
scripts/platformio_targets.py
Normal file
@@ -0,0 +1,9 @@
|
||||
Import("env")
|
||||
|
||||
env.AddCustomTarget(
|
||||
"uploadall",
|
||||
["upload", "uploadfs"],
|
||||
[],
|
||||
title="Upload Firmware and Filesystem",
|
||||
description="Upload firmware and the LittleFS image from data/",
|
||||
)
|
||||
@@ -1,11 +1,8 @@
|
||||
#include "../app.h"
|
||||
#include "app.h"
|
||||
|
||||
ApiDef apiDefs[] = {
|
||||
{"/api/wifi/scan", "GET", "Sysadmin", false, handleWifiScan, nullptr},
|
||||
{"/api/setup", "POST", "Sysadmin", false, handleSetupSubmit, nullptr},
|
||||
{"/api/ping", "GET", "", true, handlePing, nullptr},
|
||||
{"/api/add", "POST", "", true, handleAdd, nullptr},
|
||||
{"/api/led", "POST", "", true, handleLed, nullptr},
|
||||
{"/api/login", "POST", "", true, handleLogin, nullptr},
|
||||
{"/api/logout", "POST", "WebUIConnect", false, handleLogout, nullptr},
|
||||
{"/api/me", "GET", "WebUIConnect", false, handleMe, nullptr},
|
||||
@@ -21,6 +18,11 @@ ApiDef apiDefs[] = {
|
||||
{"/api/settings", "POST", "Sysadmin", false, handleSettingsPost, nullptr},
|
||||
{"/api/logs", "GET", "Debugger", false, handleLogsGet, nullptr},
|
||||
{"/api/logs/clear", "POST", "Debugger", false, handleLogsClear, nullptr},
|
||||
{"/api/files", "GET", "Debugger", false, handleFilesList, nullptr},
|
||||
{"/api/files/download", "GET", "Debugger", false, handleFileDownload, nullptr},
|
||||
{"/api/factory-reset", "POST", "Sysadmin", false, handleFactoryReset, nullptr},
|
||||
{"/api/config/export", "GET", "Sysadmin", false, handleConfigExport, nullptr},
|
||||
{"/api/config/import", "POST", "Sysadmin", false, handleConfigImport, nullptr},
|
||||
{"/api/ota/check", "POST", "Sysadmin", false, handleOtaCheck, nullptr},
|
||||
{"/api/ota/run", "POST", "Sysadmin", false, handleOtaRun, nullptr},
|
||||
{"/api/update", "POST", "Sysadmin", false, handleUpdateUploadDone, handleUpdateUploadChunk},
|
||||
|
||||
10
src/config/api_definitions_custom.cpp
Normal file
10
src/config/api_definitions_custom.cpp
Normal file
@@ -0,0 +1,10 @@
|
||||
#include "app.h"
|
||||
#include "custom_api.h"
|
||||
|
||||
ApiDef customApiDefs[] = {
|
||||
{"/api/ping", "GET", "", true, handlePing, nullptr},
|
||||
{"/api/add", "POST", "", true, handleAdd, nullptr},
|
||||
{"/api/led", "POST", "", true, handleLed, nullptr},
|
||||
};
|
||||
|
||||
const size_t CUSTOM_API_DEF_COUNT = sizeof(customApiDefs) / sizeof(customApiDefs[0]);
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "../app.h"
|
||||
#include "app.h"
|
||||
|
||||
bool hasRole(const String &roles, const String &role) {
|
||||
if (role.length() == 0) return true;
|
||||
@@ -112,13 +112,14 @@ bool deleteCustomRole(const String &role) {
|
||||
for (size_t i = 0; i < count; i++) users[i].roles = cleanRoles(users[i].roles);
|
||||
saveUsers(users, count);
|
||||
|
||||
for (size_t i = 0; i < API_DEF_COUNT; i++) {
|
||||
if (configuredRole(&apiDefs[i]) == role) {
|
||||
String fallback = apiDefs[i].publicByDefault ? "PUBLIC" : apiDefs[i].defaultRole;
|
||||
for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) {
|
||||
ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT];
|
||||
if (configuredRole(api) == role) {
|
||||
String fallback = api->publicByDefault ? "PUBLIC" : api->defaultRole;
|
||||
String key = "acl";
|
||||
key += apiDefs[i].method[0];
|
||||
for (size_t j = 0; j < strlen(apiDefs[i].path); j++) {
|
||||
char c = apiDefs[i].path[j];
|
||||
key += api->method[0];
|
||||
for (size_t j = 0; j < strlen(api->path); j++) {
|
||||
char c = api->path[j];
|
||||
if (isalnum(c)) key += c;
|
||||
}
|
||||
prefs.putString(key.substring(0, 15).c_str(), fallback);
|
||||
@@ -208,6 +209,8 @@ static String bearerToken() {
|
||||
return "";
|
||||
}
|
||||
|
||||
static String requestMethodName();
|
||||
|
||||
Token *currentToken() {
|
||||
String value = bearerToken();
|
||||
if (!value.length()) return nullptr;
|
||||
@@ -217,6 +220,7 @@ Token *currentToken() {
|
||||
return &tokens[i];
|
||||
}
|
||||
}
|
||||
appendLog(LOG_SECURITY_AUDIT, "invalid bearer token for " + requestMethodName() + " " + server.uri());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -234,6 +238,9 @@ ApiDef *findApi(const String &path, const String &method) {
|
||||
for (size_t i = 0; i < API_DEF_COUNT; i++) {
|
||||
if (path == apiDefs[i].path && method == apiDefs[i].method) return &apiDefs[i];
|
||||
}
|
||||
for (size_t i = 0; i < CUSTOM_API_DEF_COUNT; i++) {
|
||||
if (path == customApiDefs[i].path && method == customApiDefs[i].method) return &customApiDefs[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -243,22 +250,44 @@ String configuredRole(ApiDef *api) {
|
||||
return prefString(key.c_str(), api->publicByDefault ? "PUBLIC" : api->defaultRole);
|
||||
}
|
||||
|
||||
bool authorize(const String &path, const String &method) {
|
||||
static String requestMethodName() {
|
||||
switch (server.method()) {
|
||||
case HTTP_GET:
|
||||
return "GET";
|
||||
case HTTP_POST:
|
||||
return "POST";
|
||||
case HTTP_DELETE:
|
||||
return "DELETE";
|
||||
case HTTP_PUT:
|
||||
return "PUT";
|
||||
case HTTP_PATCH:
|
||||
return "PATCH";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
bool authorize() {
|
||||
String path = server.uri();
|
||||
String method = requestMethodName();
|
||||
ApiDef *api = findApi(path, method);
|
||||
String required = configuredRole(api);
|
||||
if (required == "PUBLIC" || (api && api->publicByDefault && !required.length())) return true;
|
||||
Token *token = currentToken();
|
||||
if (!token) {
|
||||
if (!bearerToken().length()) appendLog(LOG_SECURITY_AUDIT, "authentication required for " + method + " " + path);
|
||||
sendJson(401, jsonError("Authentication required"));
|
||||
return false;
|
||||
}
|
||||
User user;
|
||||
if (!findUser(token->user, user) || !user.active) {
|
||||
appendLog(LOG_SECURITY_AUDIT, "inactive or unknown user token for " + token->user + " on " + method + " " + path);
|
||||
sendJson(401, jsonError("User is inactive"));
|
||||
return false;
|
||||
}
|
||||
token->roles = user.roles;
|
||||
if (!hasRole(user.roles, required)) {
|
||||
appendLog(LOG_SECURITY_AUDIT, "authorization denied for " + user.name + " on " + method + " " + path + " requires " + required);
|
||||
sendJson(403, jsonError("Missing role " + required));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "../app.h"
|
||||
#include "app.h"
|
||||
|
||||
#include <ESPmDNS.h>
|
||||
#include <LittleFS.h>
|
||||
@@ -11,9 +11,9 @@ void applyLed() {
|
||||
}
|
||||
|
||||
void factoryReset() {
|
||||
appendLog(LOG_WARN, "factory reset requested");
|
||||
prefs.clear();
|
||||
LittleFS.remove("/logs.txt");
|
||||
appendLog(LOG_SECURITY_AUDIT, "factory reset requested");
|
||||
}
|
||||
|
||||
void checkFactoryResetPin() {
|
||||
@@ -37,6 +37,21 @@ bool connectWifi() {
|
||||
String name = deviceName();
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.setHostname(name.c_str());
|
||||
if (!networkDhcp) {
|
||||
IPAddress ip;
|
||||
IPAddress gateway;
|
||||
IPAddress subnet;
|
||||
IPAddress dns1;
|
||||
IPAddress dns2;
|
||||
bool ok = ip.fromString(networkIp) && gateway.fromString(networkGateway) && subnet.fromString(networkSubnet);
|
||||
if (ok) {
|
||||
if (!networkDns1.length() || !dns1.fromString(networkDns1)) dns1 = gateway;
|
||||
if (!networkDns2.length() || !dns2.fromString(networkDns2)) dns2 = IPAddress(0, 0, 0, 0);
|
||||
if (!WiFi.config(ip, gateway, subnet, dns1, dns2)) appendLog(LOG_WARN, "static IP configuration failed; continuing with DHCP");
|
||||
} else {
|
||||
appendLog(LOG_WARN, "static IP configuration is incomplete or invalid; continuing with DHCP");
|
||||
}
|
||||
}
|
||||
WiFi.begin(ssid.c_str(), pass.c_str());
|
||||
appendLog(LOG_INFO, "connecting to WiFi " + ssid);
|
||||
uint32_t start = millis();
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
#include "../app.h"
|
||||
#include "app.h"
|
||||
|
||||
#include <LittleFS.h>
|
||||
#include <time.h>
|
||||
|
||||
static String timestamp() {
|
||||
time_t now = time(nullptr);
|
||||
struct tm tm;
|
||||
localtime_r(&now, &tm);
|
||||
char buffer[20];
|
||||
strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &tm);
|
||||
return String(buffer);
|
||||
}
|
||||
|
||||
void appendLog(LogLevel level, const String &message) {
|
||||
if (level > currentLogLevel) return;
|
||||
const char *names[] = {"ERROR", "WARN", "INFO", "DEBUG"};
|
||||
String line = String(millis()) + " " + names[level] + " " + message + "\n";
|
||||
const char *names[] = {"ERROR", "WARN", "SecurityAudit", "INFO", "DEBUG"};
|
||||
String line = timestamp() + " " + names[level] + " " + message + "\n";
|
||||
File f = LittleFS.open("/logs.txt", "a");
|
||||
if (f) {
|
||||
f.print(line);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "../app.h"
|
||||
#include "app.h"
|
||||
|
||||
#include <mbedtls/md.h>
|
||||
|
||||
@@ -25,6 +25,13 @@ bool setupMode = false;
|
||||
uint8_t ledBrightness = 0;
|
||||
bool ledInverted = DEFAULT_LED_INVERTED;
|
||||
String updateUrl = DEFAULT_UPDATE_URL;
|
||||
String networkHostname;
|
||||
bool networkDhcp = true;
|
||||
String networkIp;
|
||||
String networkGateway;
|
||||
String networkSubnet;
|
||||
String networkDns1;
|
||||
String networkDns2;
|
||||
|
||||
String chipId() {
|
||||
uint64_t mac = ESP.getEfuseMac();
|
||||
@@ -33,7 +40,7 @@ String chipId() {
|
||||
return String(buf);
|
||||
}
|
||||
|
||||
String deviceName() {
|
||||
String defaultDeviceName() {
|
||||
String suffix = chipId().substring(6);
|
||||
String base = PROJECT_NAME_VALUE;
|
||||
base.trim();
|
||||
@@ -52,6 +59,10 @@ String deviceName() {
|
||||
return base + "-" + suffix;
|
||||
}
|
||||
|
||||
String deviceName() {
|
||||
return networkHostname.length() ? networkHostname : defaultDeviceName();
|
||||
}
|
||||
|
||||
String sha256(const String &input) {
|
||||
uint8_t digest[32];
|
||||
mbedtls_md_context_t ctx;
|
||||
@@ -81,9 +92,23 @@ void loadSettings() {
|
||||
prefs.putBool("ledInv", DEFAULT_LED_INVERTED);
|
||||
prefs.putBool("ledPolV2", true);
|
||||
}
|
||||
currentLogLevel = (LogLevel)prefs.getUChar("logLevel", LOG_INFO);
|
||||
uint8_t rawLogLevel = prefs.getUChar("logLevel", LOG_INFO);
|
||||
if (!prefs.getBool("logLevelV2", false)) {
|
||||
if (prefs.isKey("logLevel") && rawLogLevel >= 2) rawLogLevel++;
|
||||
rawLogLevel = constrain(rawLogLevel, 0, 4);
|
||||
prefs.putUChar("logLevel", rawLogLevel);
|
||||
prefs.putBool("logLevelV2", true);
|
||||
}
|
||||
currentLogLevel = (LogLevel)constrain(rawLogLevel, 0, 4);
|
||||
maxLogBytes = prefs.getUInt("logMax", 50 * 1024);
|
||||
ledInverted = prefs.getBool("ledInv", DEFAULT_LED_INVERTED);
|
||||
ledBrightness = constrain(prefs.getUChar("ledBright", 0), 0, 100);
|
||||
updateUrl = prefString("updateUrl", DEFAULT_UPDATE_URL);
|
||||
networkHostname = prefString("netHost", "");
|
||||
networkDhcp = prefs.getBool("netDhcp", true);
|
||||
networkIp = prefString("netIp", "");
|
||||
networkGateway = prefString("netGw", "");
|
||||
networkSubnet = prefString("netMask", "");
|
||||
networkDns1 = prefString("netDns1", "");
|
||||
networkDns2 = prefString("netDns2", "");
|
||||
}
|
||||
|
||||
@@ -1,32 +1,88 @@
|
||||
#include "../app.h"
|
||||
#include "app.h"
|
||||
|
||||
#include <LittleFS.h>
|
||||
|
||||
static String networkSettingsJson(bool includeStatus) {
|
||||
String out = "\"hostname\":\"" + jsonEscape(networkHostname) + "\",\"dhcp\":" + String(networkDhcp ? "true" : "false") + ",\"ip\":\"" + jsonEscape(networkIp) + "\",\"gateway\":\"" + jsonEscape(networkGateway) + "\",\"subnet\":\"" + jsonEscape(networkSubnet) + "\",\"dns1\":\"" + jsonEscape(networkDns1) + "\",\"dns2\":\"" + jsonEscape(networkDns2) + "\"";
|
||||
if (includeStatus) out += ",\"defaultHostname\":\"" + jsonEscape(defaultDeviceName()) + "\",\"currentIp\":\"" + WiFi.localIP().toString() + "\"";
|
||||
return out;
|
||||
}
|
||||
|
||||
static bool validHostnameValue(const String &hostname) {
|
||||
if (!hostname.length()) return true;
|
||||
if (hostname.length() > 31) return false;
|
||||
if (hostname.startsWith("-") || hostname.endsWith("-")) return false;
|
||||
for (size_t i = 0; i < hostname.length(); i++) {
|
||||
char c = hostname[i];
|
||||
if (!(isalnum(c) || c == '-')) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool validIpValue(const String &value) {
|
||||
IPAddress ip;
|
||||
return value.length() && ip.fromString(value);
|
||||
}
|
||||
|
||||
void handleSettingsGet() {
|
||||
if (!authorize("/api/settings", "GET")) return;
|
||||
String out = "\"settings\":{\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "}";
|
||||
if (!authorize()) return;
|
||||
String out = "\"settings\":{\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(true) + "}";
|
||||
sendJson(200, jsonOk(out));
|
||||
}
|
||||
|
||||
void handleSettingsPost() {
|
||||
if (!authorize("/api/settings", "POST")) return;
|
||||
if (!authorize()) return;
|
||||
String body = requestBody();
|
||||
currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 3);
|
||||
String nextHostname = jsonStringValue(body, "hostname", networkHostname);
|
||||
nextHostname.trim();
|
||||
bool nextDhcp = jsonBoolValue(body, "dhcp", networkDhcp);
|
||||
String nextIp = jsonStringValue(body, "ip", networkIp);
|
||||
String nextGateway = jsonStringValue(body, "gateway", networkGateway);
|
||||
String nextSubnet = jsonStringValue(body, "subnet", networkSubnet);
|
||||
String nextDns1 = jsonStringValue(body, "dns1", networkDns1);
|
||||
String nextDns2 = jsonStringValue(body, "dns2", networkDns2);
|
||||
nextIp.trim();
|
||||
nextGateway.trim();
|
||||
nextSubnet.trim();
|
||||
nextDns1.trim();
|
||||
nextDns2.trim();
|
||||
if (!validHostnameValue(nextHostname)) return sendJson(400, jsonError("Hostname must be 1-31 letters, digits, or hyphens, and cannot start or end with a hyphen"));
|
||||
if (!nextDhcp) {
|
||||
if (!validIpValue(nextIp) || !validIpValue(nextGateway) || !validIpValue(nextSubnet)) return sendJson(400, jsonError("Static IP, gateway, and subnet must be valid IPv4 addresses"));
|
||||
if (nextDns1.length() && !validIpValue(nextDns1)) return sendJson(400, jsonError("DNS 1 must be a valid IPv4 address"));
|
||||
if (nextDns2.length() && !validIpValue(nextDns2)) return sendJson(400, jsonError("DNS 2 must be a valid IPv4 address"));
|
||||
}
|
||||
currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 4);
|
||||
maxLogBytes = constrain(jsonIntValue(body, "maxLogBytes", maxLogBytes), 4096, 128 * 1024);
|
||||
ledInverted = jsonBoolValue(body, "ledInverted", ledInverted);
|
||||
ledBrightness = constrain(jsonIntValue(body, "ledBrightness", ledBrightness), 0, 100);
|
||||
updateUrl = jsonStringValue(body, "updateUrl", updateUrl);
|
||||
networkHostname = nextHostname;
|
||||
networkDhcp = nextDhcp;
|
||||
networkIp = nextIp;
|
||||
networkGateway = nextGateway;
|
||||
networkSubnet = nextSubnet;
|
||||
networkDns1 = nextDns1;
|
||||
networkDns2 = nextDns2;
|
||||
prefs.putUChar("logLevel", currentLogLevel);
|
||||
prefs.putUInt("logMax", maxLogBytes);
|
||||
prefs.putBool("ledInv", ledInverted);
|
||||
prefs.putUChar("ledBright", ledBrightness);
|
||||
prefs.putString("updateUrl", updateUrl);
|
||||
prefs.putString("netHost", networkHostname);
|
||||
prefs.putBool("netDhcp", networkDhcp);
|
||||
prefs.putString("netIp", networkIp);
|
||||
prefs.putString("netGw", networkGateway);
|
||||
prefs.putString("netMask", networkSubnet);
|
||||
prefs.putString("netDns1", networkDns1);
|
||||
prefs.putString("netDns2", networkDns2);
|
||||
applyLed();
|
||||
appendLog(LOG_SECURITY_AUDIT, "settings updated");
|
||||
sendJson(200, jsonOk());
|
||||
}
|
||||
|
||||
void handleLogsGet() {
|
||||
if (!authorize("/api/logs", "GET")) return;
|
||||
if (!authorize()) return;
|
||||
File f = LittleFS.open("/logs.txt", "r");
|
||||
String logs = f ? f.readString() : "";
|
||||
if (f) f.close();
|
||||
@@ -34,20 +90,335 @@ void handleLogsGet() {
|
||||
}
|
||||
|
||||
void handleLogsClear() {
|
||||
if (!authorize("/api/logs/clear", "POST")) return;
|
||||
if (!authorize()) return;
|
||||
LittleFS.remove("/logs.txt");
|
||||
appendLog(LOG_SECURITY_AUDIT, "logs cleared");
|
||||
sendJson(200, jsonOk());
|
||||
}
|
||||
|
||||
static bool validFsPath(const String &path) {
|
||||
return path.length() && path.startsWith("/") && path.indexOf("..") < 0;
|
||||
}
|
||||
|
||||
static String fsPathArg(const String &fallback = "/") {
|
||||
String path = server.arg("path");
|
||||
if (!path.length()) path = fallback;
|
||||
return path;
|
||||
}
|
||||
|
||||
static void appendFileJson(String &out, bool &first, File &file) {
|
||||
String path = file.name();
|
||||
if (!path.startsWith("/")) path = "/" + path;
|
||||
if (!first) out += ",";
|
||||
out += "{\"name\":\"" + jsonEscape(path.substring(path.lastIndexOf('/') + 1)) + "\",\"path\":\"" + jsonEscape(path) + "\",\"size\":" + String(file.size()) + "}";
|
||||
first = false;
|
||||
}
|
||||
|
||||
static void appendFilesJson(String &out, bool &first, File &dir) {
|
||||
File file = dir.openNextFile();
|
||||
while (file) {
|
||||
if (file.isDirectory()) {
|
||||
appendFilesJson(out, first, file);
|
||||
} else {
|
||||
appendFileJson(out, first, file);
|
||||
}
|
||||
file.close();
|
||||
file = dir.openNextFile();
|
||||
}
|
||||
}
|
||||
|
||||
void handleFilesList() {
|
||||
if (!authorize()) return;
|
||||
File root = LittleFS.open("/", "r");
|
||||
if (!root) return sendJson(500, jsonError("Filesystem root not available"));
|
||||
|
||||
String out = "\"files\":[";
|
||||
bool first = true;
|
||||
appendFilesJson(out, first, root);
|
||||
root.close();
|
||||
out += "]";
|
||||
sendJson(200, jsonOk(out));
|
||||
}
|
||||
|
||||
void handleFileDownload() {
|
||||
if (!authorize()) return;
|
||||
String path = fsPathArg("");
|
||||
if (!validFsPath(path)) return sendJson(400, jsonError("Invalid path"));
|
||||
File file = LittleFS.open(path, "r");
|
||||
if (!file || file.isDirectory()) return sendJson(404, jsonError("File not found"));
|
||||
String name = path.substring(path.lastIndexOf('/') + 1);
|
||||
server.sendHeader("Content-Disposition", "attachment; filename=\"" + name + "\"");
|
||||
server.streamFile(file, "application/octet-stream");
|
||||
file.close();
|
||||
}
|
||||
|
||||
void handleFactoryReset() {
|
||||
if (!authorize()) return;
|
||||
factoryReset();
|
||||
sendJson(200, jsonOk("\"restart\":true"));
|
||||
delay(500);
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
static String apiPrefKey(const String &path, const String &method) {
|
||||
String key = "acl";
|
||||
key += method[0];
|
||||
for (size_t i = 0; i < path.length(); i++) {
|
||||
char c = path[i];
|
||||
if (isalnum(c)) key += c;
|
||||
}
|
||||
return key.substring(0, 15);
|
||||
}
|
||||
|
||||
static String jsonArrayStringList(const String &items) {
|
||||
String out = "[";
|
||||
int start = 0;
|
||||
bool first = true;
|
||||
while (start <= (int)items.length()) {
|
||||
int end = items.indexOf('|', start);
|
||||
if (end < 0) end = items.length();
|
||||
String item = items.substring(start, end);
|
||||
if (item.length()) {
|
||||
if (!first) out += ",";
|
||||
out += "\"" + jsonEscape(item) + "\"";
|
||||
first = false;
|
||||
}
|
||||
start = end + 1;
|
||||
if (!items.length()) break;
|
||||
}
|
||||
out += "]";
|
||||
return out;
|
||||
}
|
||||
|
||||
static String extractJsonArray(const String &json, const char *key) {
|
||||
String needle = "\"" + String(key) + "\"";
|
||||
int p = json.indexOf(needle);
|
||||
if (p < 0) return "";
|
||||
p = json.indexOf('[', p + needle.length());
|
||||
if (p < 0) return "";
|
||||
int start = p;
|
||||
int depth = 0;
|
||||
bool inString = false;
|
||||
bool esc = false;
|
||||
for (; p < (int)json.length(); p++) {
|
||||
char c = json[p];
|
||||
if (esc) {
|
||||
esc = false;
|
||||
} else if (c == '\\') {
|
||||
esc = inString;
|
||||
} else if (c == '"') {
|
||||
inString = !inString;
|
||||
} else if (!inString && c == '[') {
|
||||
depth++;
|
||||
} else if (!inString && c == ']') {
|
||||
depth--;
|
||||
if (depth == 0) return json.substring(start, p + 1);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
static String objectAt(const String &array, int &pos) {
|
||||
while (pos < (int)array.length() && array[pos] != '{') pos++;
|
||||
if (pos >= (int)array.length()) return "";
|
||||
int start = pos;
|
||||
int depth = 0;
|
||||
bool inString = false;
|
||||
bool esc = false;
|
||||
for (; pos < (int)array.length(); pos++) {
|
||||
char c = array[pos];
|
||||
if (esc) {
|
||||
esc = false;
|
||||
} else if (c == '\\') {
|
||||
esc = inString;
|
||||
} else if (c == '"') {
|
||||
inString = !inString;
|
||||
} else if (!inString && c == '{') {
|
||||
depth++;
|
||||
} else if (!inString && c == '}') {
|
||||
depth--;
|
||||
if (depth == 0) return array.substring(start, ++pos);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
static String stringArrayToRoles(const String &array) {
|
||||
String out;
|
||||
int p = 0;
|
||||
while (p < (int)array.length()) {
|
||||
while (p < (int)array.length() && array[p] != '"') p++;
|
||||
if (p >= (int)array.length()) break;
|
||||
p++;
|
||||
String item;
|
||||
bool esc = false;
|
||||
while (p < (int)array.length()) {
|
||||
char c = array[p++];
|
||||
if (esc) {
|
||||
item += c == 'n' ? '\n' : c == 'r' ? '\r' : c;
|
||||
esc = false;
|
||||
} else if (c == '\\') {
|
||||
esc = true;
|
||||
} else if (c == '"') {
|
||||
break;
|
||||
} else {
|
||||
item += c;
|
||||
}
|
||||
}
|
||||
if (item.length()) {
|
||||
if (out.length()) out += "|";
|
||||
out += item;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static String cleanCustomRolesBackup(const String &roles) {
|
||||
String out;
|
||||
int start = 0;
|
||||
while (start <= (int)roles.length()) {
|
||||
int end = roles.indexOf('|', start);
|
||||
if (end < 0) end = roles.length();
|
||||
String role = roles.substring(start, end);
|
||||
if (validName(role) && !isSystemRole(role) && !hasRole(out, role)) {
|
||||
if (out.length()) out += "|";
|
||||
out += role;
|
||||
}
|
||||
start = end + 1;
|
||||
if (!roles.length()) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
void handleConfigExport() {
|
||||
if (!authorize()) return;
|
||||
User users[MAX_USERS];
|
||||
size_t userCount = parseUsers(users, MAX_USERS);
|
||||
|
||||
String out = "{";
|
||||
out += "\"version\":1";
|
||||
out += ",\"settings\":{\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(false) + "}";
|
||||
out += ",\"customRoles\":" + jsonArrayStringList(prefString("roles", ""));
|
||||
out += ",\"users\":[";
|
||||
for (size_t i = 0; i < userCount; i++) {
|
||||
if (i) out += ",";
|
||||
out += "{\"username\":\"" + jsonEscape(users[i].name) + "\",\"passwordHash\":\"" + jsonEscape(users[i].passwordHash) + "\",\"roles\":\"" + jsonEscape(users[i].roles) + "\",\"active\":" + String(users[i].active ? "true" : "false") + "}";
|
||||
}
|
||||
out += "],\"apiAcls\":[";
|
||||
for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) {
|
||||
ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT];
|
||||
if (i) out += ",";
|
||||
out += "{\"path\":\"" + jsonEscape(api->path) + "\",\"method\":\"" + jsonEscape(api->method) + "\",\"role\":\"" + jsonEscape(configuredRole(api)) + "\"}";
|
||||
}
|
||||
out += "],\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\"}";
|
||||
|
||||
server.sendHeader("Cache-Control", "no-store");
|
||||
server.sendHeader("Content-Disposition", "attachment; filename=\"config-backup.json\"");
|
||||
server.send(200, "application/json", out);
|
||||
}
|
||||
|
||||
void handleConfigImport() {
|
||||
if (!authorize()) return;
|
||||
String body = requestBody();
|
||||
if (!body.length()) return sendJson(400, jsonError("Configuration JSON is required"));
|
||||
|
||||
String customRoles = cleanCustomRolesBackup(stringArrayToRoles(extractJsonArray(body, "customRoles")));
|
||||
prefs.putString("roles", customRoles);
|
||||
|
||||
User users[MAX_USERS];
|
||||
size_t userCount = 0;
|
||||
String usersArray = extractJsonArray(body, "users");
|
||||
int pos = 0;
|
||||
while (userCount < MAX_USERS) {
|
||||
String item = objectAt(usersArray, pos);
|
||||
if (!item.length()) break;
|
||||
String username = jsonStringValue(item, "username");
|
||||
String password = jsonStringValue(item, "passwordHash");
|
||||
if (!validName(username) || !password.length()) return sendJson(400, jsonError("Invalid user in backup"));
|
||||
users[userCount].name = username;
|
||||
users[userCount].passwordHash = password;
|
||||
users[userCount].roles = cleanRoles(jsonStringValue(item, "roles", "WebUIConnect"));
|
||||
users[userCount].active = jsonBoolValue(item, "active", true);
|
||||
userCount++;
|
||||
}
|
||||
if (!userCount) return sendJson(400, jsonError("Backup must contain at least one user"));
|
||||
bool hasActiveSysadmin = false;
|
||||
for (size_t i = 0; i < userCount; i++) {
|
||||
if (users[i].active && hasRole(users[i].roles, "Sysadmin")) hasActiveSysadmin = true;
|
||||
}
|
||||
if (!hasActiveSysadmin) return sendJson(400, jsonError("Backup must contain an active Sysadmin user"));
|
||||
saveUsers(users, userCount);
|
||||
|
||||
currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 4);
|
||||
maxLogBytes = constrain(jsonIntValue(body, "maxLogBytes", maxLogBytes), 4096, 128 * 1024);
|
||||
updateUrl = jsonStringValue(body, "updateUrl", updateUrl);
|
||||
ledInverted = jsonBoolValue(body, "ledInverted", ledInverted);
|
||||
ledBrightness = constrain(jsonIntValue(body, "ledBrightness", ledBrightness), 0, 100);
|
||||
String nextHostname = jsonStringValue(body, "hostname", networkHostname);
|
||||
nextHostname.trim();
|
||||
bool nextDhcp = jsonBoolValue(body, "dhcp", networkDhcp);
|
||||
String nextIp = jsonStringValue(body, "ip", networkIp);
|
||||
String nextGateway = jsonStringValue(body, "gateway", networkGateway);
|
||||
String nextSubnet = jsonStringValue(body, "subnet", networkSubnet);
|
||||
String nextDns1 = jsonStringValue(body, "dns1", networkDns1);
|
||||
String nextDns2 = jsonStringValue(body, "dns2", networkDns2);
|
||||
nextIp.trim();
|
||||
nextGateway.trim();
|
||||
nextSubnet.trim();
|
||||
nextDns1.trim();
|
||||
nextDns2.trim();
|
||||
if (validHostnameValue(nextHostname)) networkHostname = nextHostname;
|
||||
networkDhcp = nextDhcp;
|
||||
if (networkDhcp || (validIpValue(nextIp) && validIpValue(nextGateway) && validIpValue(nextSubnet))) {
|
||||
networkIp = nextIp;
|
||||
networkGateway = nextGateway;
|
||||
networkSubnet = nextSubnet;
|
||||
if (!nextDns1.length() || validIpValue(nextDns1)) networkDns1 = nextDns1;
|
||||
if (!nextDns2.length() || validIpValue(nextDns2)) networkDns2 = nextDns2;
|
||||
}
|
||||
prefs.putUChar("logLevel", currentLogLevel);
|
||||
prefs.putUInt("logMax", maxLogBytes);
|
||||
prefs.putString("updateUrl", updateUrl);
|
||||
prefs.putBool("ledInv", ledInverted);
|
||||
prefs.putUChar("ledBright", ledBrightness);
|
||||
prefs.putString("netHost", networkHostname);
|
||||
prefs.putBool("netDhcp", networkDhcp);
|
||||
prefs.putString("netIp", networkIp);
|
||||
prefs.putString("netGw", networkGateway);
|
||||
prefs.putString("netMask", networkSubnet);
|
||||
prefs.putString("netDns1", networkDns1);
|
||||
prefs.putString("netDns2", networkDns2);
|
||||
prefs.putString("cert", jsonStringValue(body, "certificate", ""));
|
||||
|
||||
String aclsArray = extractJsonArray(body, "apiAcls");
|
||||
pos = 0;
|
||||
while (true) {
|
||||
String item = objectAt(aclsArray, pos);
|
||||
if (!item.length()) break;
|
||||
String path = jsonStringValue(item, "path");
|
||||
String method = jsonStringValue(item, "method", "GET");
|
||||
method.toUpperCase();
|
||||
String role = jsonStringValue(item, "role", "PUBLIC");
|
||||
ApiDef *api = findApi(path, method);
|
||||
if (api && isKnownRole(role)) {
|
||||
prefs.putString(apiPrefKey(path, method).c_str(), role);
|
||||
}
|
||||
}
|
||||
|
||||
applyLed();
|
||||
appendLog(LOG_SECURITY_AUDIT, "configuration restored from backup");
|
||||
sendJson(200, jsonOk());
|
||||
}
|
||||
|
||||
void handleCertGet() {
|
||||
if (!authorize("/api/cert", "GET")) return;
|
||||
if (!authorize()) return;
|
||||
sendJson(200, jsonOk("\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\""));
|
||||
}
|
||||
|
||||
void handleCertPost() {
|
||||
if (!authorize("/api/cert", "POST")) return;
|
||||
if (!authorize()) return;
|
||||
String cert = jsonStringValue(requestBody(), "certificate");
|
||||
prefs.putString("cert", cert);
|
||||
appendLog(LOG_INFO, "HTTPS certificate material saved");
|
||||
appendLog(LOG_SECURITY_AUDIT, "HTTPS certificate material saved");
|
||||
sendJson(200, jsonOk("\"note\":\"certificate is stored for applications that enable TLS termination\""));
|
||||
}
|
||||
|
||||
@@ -1,41 +1,20 @@
|
||||
#include "../app.h"
|
||||
|
||||
void handlePing() {
|
||||
if (!authorize("/api/ping", "GET")) return;
|
||||
sendJson(200, jsonOk("\"uptimeMs\":" + String(millis()) + ",\"version\":\"" + APP_VERSION + "\",\"name\":\"" + jsonEscape(deviceName()) + "\",\"ip\":\"" + WiFi.localIP().toString() + "\""));
|
||||
}
|
||||
|
||||
void handleAdd() {
|
||||
if (!authorize("/api/add", "POST")) return;
|
||||
String body = requestBody();
|
||||
int a = jsonIntValue(body, "a", server.arg("a").toInt());
|
||||
int b = jsonIntValue(body, "b", server.arg("b").toInt());
|
||||
sendJson(200, jsonOk("\"result\":" + String(a + b)));
|
||||
}
|
||||
|
||||
void handleLed() {
|
||||
if (!authorize("/api/led", "POST")) return;
|
||||
ledBrightness = constrain(jsonIntValue(requestBody(), "brightness", server.arg("brightness").toInt()), 0, 100);
|
||||
prefs.putUChar("ledBright", ledBrightness);
|
||||
applyLed();
|
||||
appendLog(LOG_INFO, "LED brightness set to " + String(ledBrightness));
|
||||
sendJson(200, jsonOk("\"brightness\":" + String(ledBrightness)));
|
||||
}
|
||||
#include "app.h"
|
||||
|
||||
void handleApisGet() {
|
||||
if (!authorize("/api/apis", "GET")) return;
|
||||
if (!authorize()) return;
|
||||
String out = "\"apis\":[";
|
||||
for (size_t i = 0; i < API_DEF_COUNT; i++) {
|
||||
for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) {
|
||||
ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT];
|
||||
if (i) out += ",";
|
||||
String role = configuredRole(&apiDefs[i]);
|
||||
out += "{\"path\":\"" + String(apiDefs[i].path) + "\",\"method\":\"" + String(apiDefs[i].method) + "\",\"role\":\"" + jsonEscape(role) + "\"}";
|
||||
String role = configuredRole(api);
|
||||
out += "{\"path\":\"" + String(api->path) + "\",\"method\":\"" + String(api->method) + "\",\"role\":\"" + jsonEscape(role) + "\"}";
|
||||
}
|
||||
out += "]";
|
||||
sendJson(200, jsonOk(out));
|
||||
}
|
||||
|
||||
void handleApisPost() {
|
||||
if (!authorize("/api/apis", "POST")) return;
|
||||
if (!authorize()) return;
|
||||
String body = requestBody();
|
||||
String path = jsonStringValue(body, "path");
|
||||
String method = jsonStringValue(body, "method", "GET");
|
||||
@@ -51,6 +30,6 @@ void handleApisPost() {
|
||||
if (isalnum(c)) key += c;
|
||||
}
|
||||
prefs.putString(key.substring(0, 15).c_str(), role);
|
||||
appendLog(LOG_INFO, "API ACL saved " + method + " " + path + " -> " + role);
|
||||
appendLog(LOG_SECURITY_AUDIT, "API ACL saved " + method + " " + path + " -> " + role);
|
||||
sendJson(200, jsonOk());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "../app.h"
|
||||
#include "app.h"
|
||||
|
||||
void handleLogin() {
|
||||
String body = requestBody();
|
||||
@@ -6,29 +6,32 @@ void handleLogin() {
|
||||
String password = jsonStringValue(body, "password");
|
||||
User user;
|
||||
if (!findUser(username, user) || !user.active || user.passwordHash != passwordHash(password)) {
|
||||
appendLog(LOG_WARN, "failed login for " + username);
|
||||
appendLog(LOG_SECURITY_AUDIT, "failed login for " + username);
|
||||
return sendJson(401, jsonError("Invalid username or password"));
|
||||
}
|
||||
String token = createToken(user);
|
||||
appendLog(LOG_INFO, "login " + username);
|
||||
appendLog(LOG_SECURITY_AUDIT, "login " + username);
|
||||
sendJson(200, jsonOk("\"token\":\"" + token + "\",\"username\":\"" + jsonEscape(user.name) + "\",\"roles\":\"" + jsonEscape(user.roles) + "\""));
|
||||
}
|
||||
|
||||
void handleLogout() {
|
||||
if (!authorize("/api/logout", "POST")) return;
|
||||
if (!authorize()) return;
|
||||
Token *tok = currentToken();
|
||||
if (tok) *tok = Token();
|
||||
if (tok) {
|
||||
appendLog(LOG_SECURITY_AUDIT, "logout " + tok->user);
|
||||
*tok = Token();
|
||||
}
|
||||
sendJson(200, jsonOk());
|
||||
}
|
||||
|
||||
void handleMe() {
|
||||
if (!authorize("/api/me", "GET")) return;
|
||||
if (!authorize()) return;
|
||||
Token *tok = currentToken();
|
||||
sendJson(200, jsonOk("\"username\":\"" + jsonEscape(tok->user) + "\",\"roles\":\"" + jsonEscape(tok->roles) + "\""));
|
||||
}
|
||||
|
||||
void handleUsersGet() {
|
||||
if (!authorize("/api/users", "GET")) return;
|
||||
if (!authorize()) return;
|
||||
User users[8];
|
||||
size_t count = parseUsers(users, MAX_USERS);
|
||||
String out = "\"users\":[";
|
||||
@@ -41,7 +44,7 @@ void handleUsersGet() {
|
||||
}
|
||||
|
||||
void handleUsersPost() {
|
||||
if (!authorize("/api/users", "POST")) return;
|
||||
if (!authorize()) return;
|
||||
String body = requestBody();
|
||||
String username = jsonStringValue(body, "username");
|
||||
String password = jsonStringValue(body, "password");
|
||||
@@ -61,12 +64,12 @@ void handleUsersPost() {
|
||||
users[idx].active = active;
|
||||
if (idx == count) count++;
|
||||
saveUsers(users, count);
|
||||
appendLog(LOG_INFO, "user saved " + username);
|
||||
appendLog(LOG_SECURITY_AUDIT, "user saved " + username);
|
||||
sendJson(200, jsonOk());
|
||||
}
|
||||
|
||||
void handlePassword() {
|
||||
if (!authorize("/api/password", "POST")) return;
|
||||
if (!authorize()) return;
|
||||
Token *tok = currentToken();
|
||||
String password = jsonStringValue(requestBody(), "password");
|
||||
User users[8];
|
||||
@@ -75,7 +78,7 @@ void handlePassword() {
|
||||
if (users[i].name == tok->user) {
|
||||
users[i].passwordHash = passwordHash(password);
|
||||
saveUsers(users, count);
|
||||
appendLog(LOG_INFO, "password changed " + tok->user);
|
||||
appendLog(LOG_SECURITY_AUDIT, "password changed " + tok->user);
|
||||
return sendJson(200, jsonOk());
|
||||
}
|
||||
}
|
||||
@@ -83,22 +86,22 @@ void handlePassword() {
|
||||
}
|
||||
|
||||
void handleRolesGet() {
|
||||
if (!authorize("/api/roles", "GET")) return;
|
||||
if (!authorize()) return;
|
||||
sendJson(200, jsonOk(rolesJson()));
|
||||
}
|
||||
|
||||
void handleRolesPost() {
|
||||
if (!authorize("/api/roles", "POST")) return;
|
||||
if (!authorize()) return;
|
||||
String role = jsonStringValue(requestBody(), "role");
|
||||
if (!addCustomRole(role)) return sendJson(400, jsonError("Role is invalid or already exists"));
|
||||
appendLog(LOG_INFO, "role added " + role);
|
||||
appendLog(LOG_SECURITY_AUDIT, "role added " + role);
|
||||
sendJson(200, jsonOk(rolesJson()));
|
||||
}
|
||||
|
||||
void handleRolesDelete() {
|
||||
if (!authorize("/api/roles", "DELETE")) return;
|
||||
if (!authorize()) return;
|
||||
String role = jsonStringValue(requestBody(), "role", server.arg("role"));
|
||||
if (!deleteCustomRole(role)) return sendJson(400, jsonError("Role cannot be deleted"));
|
||||
appendLog(LOG_INFO, "role deleted " + role);
|
||||
appendLog(LOG_SECURITY_AUDIT, "role deleted " + role);
|
||||
sendJson(200, jsonOk(rolesJson()));
|
||||
}
|
||||
|
||||
24
src/handlers/handlers_custom_api.cpp
Normal file
24
src/handlers/handlers_custom_api.cpp
Normal file
@@ -0,0 +1,24 @@
|
||||
#include "app.h"
|
||||
#include "custom_api.h"
|
||||
|
||||
void handlePing() {
|
||||
if (!authorize()) return;
|
||||
sendJson(200, jsonOk("\"uptimeMs\":" + String(millis()) + ",\"version\":\"" + APP_VERSION + "\",\"name\":\"" + jsonEscape(deviceName()) + "\",\"ip\":\"" + WiFi.localIP().toString() + "\""));
|
||||
}
|
||||
|
||||
void handleAdd() {
|
||||
if (!authorize()) return;
|
||||
String body = requestBody();
|
||||
int a = jsonIntValue(body, "a", server.arg("a").toInt());
|
||||
int b = jsonIntValue(body, "b", server.arg("b").toInt());
|
||||
sendJson(200, jsonOk("\"result\":" + String(a + b)));
|
||||
}
|
||||
|
||||
void handleLed() {
|
||||
if (!authorize()) return;
|
||||
ledBrightness = constrain(jsonIntValue(requestBody(), "brightness", server.arg("brightness").toInt()), 0, 100);
|
||||
prefs.putUChar("ledBright", ledBrightness);
|
||||
applyLed();
|
||||
appendLog(LOG_INFO, "LED brightness set to " + String(ledBrightness));
|
||||
sendJson(200, jsonOk("\"brightness\":" + String(ledBrightness)));
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "../app.h"
|
||||
#include "app.h"
|
||||
|
||||
#include <HTTPClient.h>
|
||||
#include <LittleFS.h>
|
||||
@@ -160,7 +160,7 @@ static bool streamPackageFromUrl(const String &url, String &error) {
|
||||
}
|
||||
|
||||
void handleOtaCheck() {
|
||||
if (!authorize("/api/ota/check", "POST")) return;
|
||||
if (!authorize()) return;
|
||||
HTTPClient http;
|
||||
http.begin(updateUrl);
|
||||
int code = http.GET();
|
||||
@@ -170,8 +170,8 @@ void handleOtaCheck() {
|
||||
}
|
||||
|
||||
void handleOtaRun() {
|
||||
if (!authorize("/api/ota/run", "POST")) return;
|
||||
appendLog(LOG_INFO, "package URL update started");
|
||||
if (!authorize()) return;
|
||||
appendLog(LOG_SECURITY_AUDIT, "package URL update started");
|
||||
String error;
|
||||
if (!streamPackageFromUrl(updateUrl, error)) return sendJson(502, jsonError(error));
|
||||
sendJson(200, jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true"));
|
||||
@@ -180,7 +180,7 @@ void handleOtaRun() {
|
||||
}
|
||||
|
||||
void handleUpdateUploadDone() {
|
||||
if (!authorize("/api/update", "POST")) return;
|
||||
if (!authorize()) return;
|
||||
bool ok = packageState.stage == PKG_DONE;
|
||||
String error = packageState.error.length() ? packageState.error : "Incomplete update package";
|
||||
sendJson(ok ? 200 : 500, ok ? jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true") : jsonError(error));
|
||||
@@ -193,8 +193,8 @@ void handleUpdateUploadDone() {
|
||||
void handleUpdateUploadChunk() {
|
||||
HTTPUpload &upload = server.upload();
|
||||
if (upload.status == UPLOAD_FILE_START) {
|
||||
if (!authorize("/api/update", "POST")) return;
|
||||
appendLog(LOG_INFO, "package upload started");
|
||||
if (!authorize()) return;
|
||||
appendLog(LOG_SECURITY_AUDIT, "package upload started");
|
||||
resetPackageState();
|
||||
} else if (upload.status == UPLOAD_FILE_WRITE) {
|
||||
feedPackageBytes(upload.buf, upload.currentSize);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "../app.h"
|
||||
#include "app.h"
|
||||
|
||||
void handleWifiScan() {
|
||||
if (!setupMode && !authorize("/api/settings", "GET")) return;
|
||||
if (!setupMode && !authorize()) return;
|
||||
int n = WiFi.scanNetworks();
|
||||
String out = "\"networks\":[";
|
||||
for (int i = 0; i < n; i++) {
|
||||
@@ -13,7 +13,7 @@ void handleWifiScan() {
|
||||
}
|
||||
|
||||
void handleSetupSubmit() {
|
||||
if (!setupMode && !authorize("/api/settings", "POST")) return;
|
||||
if (!setupMode && !authorize()) return;
|
||||
String body = requestBody();
|
||||
String ssid = jsonStringValue(body, "ssid");
|
||||
String wifiPass = jsonStringValue(body, "wifiPass");
|
||||
@@ -26,7 +26,7 @@ void handleSetupSubmit() {
|
||||
User u{admin, passwordHash(adminPass), "Sysadmin|UserAdmin|WebUIConnect|Debugger", true};
|
||||
saveUsers(&u, 1);
|
||||
prefs.putBool("configured", true);
|
||||
appendLog(LOG_INFO, "initial setup saved");
|
||||
appendLog(LOG_SECURITY_AUDIT, "initial setup saved");
|
||||
sendJson(200, jsonOk("\"restart\":true"));
|
||||
delay(500);
|
||||
ESP.restart();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "../app.h"
|
||||
#include "app.h"
|
||||
|
||||
static int findJsonKey(const String &json, const char *key) {
|
||||
String needle = "\"" + String(key) + "\"";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "../app.h"
|
||||
#include "app.h"
|
||||
|
||||
static HTTPMethod httpMethod(const char *method) {
|
||||
if (strcmp(method, "GET") == 0) return HTTP_GET;
|
||||
@@ -9,9 +9,9 @@ static HTTPMethod httpMethod(const char *method) {
|
||||
return HTTP_ANY;
|
||||
}
|
||||
|
||||
static void registerApiRoutes() {
|
||||
for (size_t i = 0; i < API_DEF_COUNT; i++) {
|
||||
ApiDef &api = apiDefs[i];
|
||||
static void registerApiDefs(ApiDef *defs, size_t count) {
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
ApiDef &api = defs[i];
|
||||
if (api.uploadHandler) {
|
||||
server.on(api.path, httpMethod(api.method), api.handler, api.uploadHandler);
|
||||
} else {
|
||||
@@ -20,6 +20,28 @@ static void registerApiRoutes() {
|
||||
}
|
||||
}
|
||||
|
||||
static void registerApiRoutes() {
|
||||
registerApiDefs(apiDefs, API_DEF_COUNT);
|
||||
registerApiDefs(customApiDefs, CUSTOM_API_DEF_COUNT);
|
||||
}
|
||||
|
||||
static String requestMethodName() {
|
||||
switch (server.method()) {
|
||||
case HTTP_GET:
|
||||
return "GET";
|
||||
case HTTP_POST:
|
||||
return "POST";
|
||||
case HTTP_DELETE:
|
||||
return "DELETE";
|
||||
case HTTP_PUT:
|
||||
return "PUT";
|
||||
case HTTP_PATCH:
|
||||
return "PATCH";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
void registerRoutes() {
|
||||
static const char *headers[] = {"Authorization", "X-Auth-Token"};
|
||||
server.collectHeaders(headers, 2);
|
||||
@@ -38,10 +60,16 @@ void registerRoutes() {
|
||||
|
||||
server.onNotFound([]() {
|
||||
if (setupMode) {
|
||||
if (!handleHtmlFileRequest()) redirectToSetupPage();
|
||||
if (server.uri().startsWith("/api/")) {
|
||||
appendLog(LOG_SECURITY_AUDIT, "undefined URL or method " + requestMethodName() + " " + server.uri());
|
||||
sendJson(404, jsonError("Not found"));
|
||||
} else if (!handleHtmlFileRequest()) {
|
||||
redirectToSetupPage();
|
||||
}
|
||||
} else if (server.method() == HTTP_GET && !server.uri().startsWith("/api/")) {
|
||||
if (!handleHtmlFileRequest()) handleAdminPage();
|
||||
} else {
|
||||
appendLog(LOG_SECURITY_AUDIT, "undefined URL or method " + requestMethodName() + " " + server.uri());
|
||||
sendJson(404, jsonError("Not found"));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#include "../app.h"
|
||||
#include "app.h"
|
||||
|
||||
#include <LittleFS.h>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user