Various improvements for usability and consistency and inclusion of PWA handling.
This commit is contained in:
46
README.md
46
README.md
@@ -10,8 +10,9 @@ It provides basic "infrastructure" and "Framework" for specific developments.
|
|||||||
- Eases updates by providing OTA update mechanism using update-server URL
|
- Eases updates by providing OTA update mechanism using update-server URL
|
||||||
- Role based User Management
|
- Role based User Management
|
||||||
- Standard Roles
|
- Standard Roles
|
||||||
- Sysadmin (Can administer the system, update, change settings)
|
- SystemAdmin (Can administer the system, update, change settings)
|
||||||
- UserAdmin (Can administer user accounts)
|
- AccessAdmin (Can administer user accounts, roles, and API access)
|
||||||
|
- NetworkAdmin (Can administer network settings)
|
||||||
- WebUIConnect (Allows logging in to the Web UI)
|
- WebUIConnect (Allows logging in to the Web UI)
|
||||||
- Debugger (Allowed to use API Test UI in the Web UI)
|
- Debugger (Allowed to use API Test UI in the Web UI)
|
||||||
- Secure, Role based Rest API for all functions
|
- Secure, Role based Rest API for all functions
|
||||||
@@ -33,7 +34,7 @@ It provides basic "infrastructure" and "Framework" for specific developments.
|
|||||||
- Users can be assigned zero or more roles
|
- Users can be assigned zero or more roles
|
||||||
- Allows to interactively calling of the Rest APIs via the web UI
|
- Allows to interactively calling of the Rest APIs via the web UI
|
||||||
- The corresponding user token is preset with the current user's token by decfault, but it can be overwritten
|
- The corresponding user token is preset with the current user's token by decfault, but it can be overwritten
|
||||||
- This functionality is only accessible when the user has the role Debugger
|
- This functionality is only accessible when the user has the role AccessAdmin
|
||||||
- Enables secrity configuration of the exposed API functions
|
- Enables secrity configuration of the exposed API functions
|
||||||
- Configuration of logging
|
- Configuration of logging
|
||||||
- log level
|
- log level
|
||||||
@@ -116,7 +117,7 @@ The firmware in `src/main.cpp` implements the boilerplate as a compact Arduino E
|
|||||||
- Configurable API access control where each route can be `PUBLIC` or require one role.
|
- Configurable API access control where each route can be `PUBLIC` or require one role.
|
||||||
- Public boilerplate APIs: ping, add, and LED brightness.
|
- Public boilerplate APIs: ping, add, and LED brightness.
|
||||||
- LED brightness is persisted in non-volatile memory, applied on boot, and loaded into the Admin UI slider.
|
- LED brightness is persisted in non-volatile memory, applied on boot, and loaded into the Admin UI slider.
|
||||||
- User management with the standard roles `Sysadmin`, `UserAdmin`, `WebUIConnect`, and `Debugger`.
|
- User management with the standard roles `SystemAdmin`, `AccessAdmin`, `NetworkAdmin`, `WebUIConnect`, and `Debugger`.
|
||||||
- Custom role management. System roles are protected and cannot be deleted.
|
- Custom role management. System roles are protected and cannot be deleted.
|
||||||
- Active/inactive user accounts with role checkboxes in the Admin UI.
|
- Active/inactive user accounts with role checkboxes in the Admin UI.
|
||||||
- API security management through a list/detail UI with a role dropdown per API.
|
- API security management through a list/detail UI with a role dropdown per API.
|
||||||
@@ -224,7 +225,7 @@ The Admin UI stores HTTPS certificate material so applications built on this boi
|
|||||||
|
|
||||||
To configure the stored certificate material:
|
To configure the stored certificate material:
|
||||||
|
|
||||||
1. Log in as a user with the `Sysadmin` role.
|
1. Log in as a user with the `NetworkAdmin` role.
|
||||||
2. Open `Networking` -> `HTTPS Certificate`.
|
2. Open `Networking` -> `HTTPS Certificate`.
|
||||||
3. Select a certificate file and click `Save certificate`.
|
3. Select a certificate file and click `Save certificate`.
|
||||||
4. Use `Load current` to verify what is currently stored.
|
4. Use `Load current` to verify what is currently stored.
|
||||||
@@ -305,23 +306,26 @@ Protected APIs and their default roles:
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `POST /api/logout` | `WebUIConnect` |
|
| `POST /api/logout` | `WebUIConnect` |
|
||||||
| `GET /api/me` | `WebUIConnect` |
|
| `GET /api/me` | `WebUIConnect` |
|
||||||
| `GET /api/apis` | `Sysadmin` |
|
| `GET /api/apis` | `AccessAdmin` |
|
||||||
| `POST /api/apis` | `Sysadmin` |
|
| `POST /api/apis` | `AccessAdmin` |
|
||||||
| `GET /api/users` | `UserAdmin` |
|
| `GET /api/users` | `AccessAdmin` |
|
||||||
| `POST /api/users` | `UserAdmin` |
|
| `POST /api/users` | `AccessAdmin` |
|
||||||
| `POST /api/password` | `WebUIConnect` |
|
| `POST /api/password` | `WebUIConnect` |
|
||||||
| `GET /api/settings` | `Sysadmin` |
|
| `GET /api/settings` | `SystemAdmin` |
|
||||||
| `POST /api/settings` | `Sysadmin` |
|
| `POST /api/settings` | `SystemAdmin` |
|
||||||
| `GET /api/logs` | `Debugger` |
|
| `GET /api/network` | `NetworkAdmin` |
|
||||||
| `POST /api/logs/clear` | `Debugger` |
|
| `POST /api/network` | `NetworkAdmin` |
|
||||||
| `GET /api/files` | `Debugger` |
|
| `GET /api/logs` | `SystemAdmin` |
|
||||||
| `GET /api/files/download` | `Debugger` |
|
| `POST /api/logs/clear` | `SystemAdmin` |
|
||||||
| `POST /api/factory-reset` | `Sysadmin` |
|
| `GET /api/files` | `SystemAdmin` |
|
||||||
| `POST /api/ota/check` | `Sysadmin` |
|
| `GET /api/files/download` | `SystemAdmin` |
|
||||||
| `POST /api/ota/run` | `Sysadmin` |
|
| `POST /api/restart` | `SystemAdmin` |
|
||||||
| `POST /api/update` | `Sysadmin` |
|
| `POST /api/factory-reset` | `SystemAdmin` |
|
||||||
| `GET /api/cert` | `Sysadmin` |
|
| `POST /api/ota/check` | `SystemAdmin` |
|
||||||
| `POST /api/cert` | `Sysadmin` |
|
| `POST /api/ota/run` | `SystemAdmin` |
|
||||||
|
| `POST /api/update` | `SystemAdmin` |
|
||||||
|
| `GET /api/cert` | `NetworkAdmin` |
|
||||||
|
| `POST /api/cert` | `NetworkAdmin` |
|
||||||
|
|
||||||
Change API security:
|
Change API security:
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,17 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>ESP32-C3 Admin</title>
|
<meta name="theme-color" content="#1261a6">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="TSL-Embedded">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<title></title>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml">
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png">
|
||||||
<link rel="stylesheet" href="/css/app.css">
|
<link rel="stylesheet" href="/css/app.css">
|
||||||
|
<script defer src="/js/pwa.js"></script>
|
||||||
<script defer src="/js/app.js"></script>
|
<script defer src="/js/app.js"></script>
|
||||||
<script type="module" src="/components/status-card.js"></script>
|
<script type="module" src="/components/status-card.js"></script>
|
||||||
<script type="module" src="/components/uptime-card.js"></script>
|
<script type="module" src="/components/uptime-card.js"></script>
|
||||||
@@ -11,8 +20,14 @@
|
|||||||
<script type="module" src="/components/add-card.js"></script>
|
<script type="module" src="/components/add-card.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="appTitle" class="top">ESP32-C3 Admin</div>
|
<div id="appTitle" class="top"></div>
|
||||||
<div id="busyChip" class="busyChip hidden"><span class="spinner"></span>Working...</div>
|
<div class="activityDock" aria-live="polite">
|
||||||
|
<div class="activityChips">
|
||||||
|
<div id="uiBusyChip" class="busyChip hidden"><span class="spinner"></span>UI request...</div>
|
||||||
|
<div id="serverBusyChip" class="busyChip serverBusy hidden"><span class="spinner"></span>Server event...</div>
|
||||||
|
</div>
|
||||||
|
<button id="logoutBtn" class="logoutBtn hidden">Logout</button>
|
||||||
|
</div>
|
||||||
<main>
|
<main>
|
||||||
<div id="loginPanel" class="box">
|
<div id="loginPanel" class="box">
|
||||||
<h2>Login</h2>
|
<h2>Login</h2>
|
||||||
@@ -43,7 +58,7 @@
|
|||||||
|
|
||||||
<div id="users" class="subpanel active">
|
<div id="users" class="subpanel active">
|
||||||
<section id="usersOverview">
|
<section id="usersOverview">
|
||||||
<div class="toolbar"><h3>User Overview</h3><button id="usersRefresh">Refresh</button><button id="userNew" class="secondary">New</button></div>
|
<div class="toolbar"><h3>User Overview</h3><button id="userNew" class="secondary">New</button></div>
|
||||||
<div id="userList" class="list"></div>
|
<div id="userList" class="list"></div>
|
||||||
</section>
|
</section>
|
||||||
<section id="userDetail" class="hidden">
|
<section id="userDetail" class="hidden">
|
||||||
@@ -59,7 +74,7 @@
|
|||||||
|
|
||||||
<div id="roles" class="subpanel">
|
<div id="roles" class="subpanel">
|
||||||
<section id="rolesOverview">
|
<section id="rolesOverview">
|
||||||
<div class="toolbar"><h3>Role Overview</h3><button id="rolesRefresh">Refresh</button><button id="roleNew" class="secondary">New</button></div>
|
<div class="toolbar"><h3>Role Overview</h3><button id="roleNew" class="secondary">New</button></div>
|
||||||
<div id="rolesOut" class="list"></div>
|
<div id="rolesOut" class="list"></div>
|
||||||
</section>
|
</section>
|
||||||
<section id="roleDetail" class="hidden">
|
<section id="roleDetail" class="hidden">
|
||||||
@@ -98,7 +113,7 @@
|
|||||||
<div id="ops" class="panel"><div class="grid">
|
<div id="ops" class="panel"><div class="grid">
|
||||||
<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 id="settingsSave">Save</button><pre id="settingsOut"></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 id="settingsSave">Save</button><pre id="settingsOut"></pre></section>
|
||||||
<section><h3>Updates</h3><label>Update package</label><input id="pkg" type="file"><button id="uploadPkgBtn">Upload package</button><button id="otaCheck" class="secondary">Check URL</button><button id="otaRun" class="danger">Install from URL</button><pre id="fwOut"></pre></section>
|
<section><h3>Updates</h3><label>Update package</label><input id="pkg" type="file"><button id="uploadPkgBtn">Upload package</button><button id="otaCheck" class="secondary">Check URL</button><button id="otaRun" class="danger">Install from URL</button><pre id="fwOut"></pre></section>
|
||||||
<section><h3>Maintenance</h3><button id="configDownload">Download config</button><label>Restore configuration</label><input id="configFile" type="file" accept=".json,application/json"><button id="restoreConfigBtn" class="secondary">Restore config</button><button id="factoryReset" class="danger">Factory reset</button><pre id="maintOut"></pre></section>
|
<section><h3>Maintenance</h3><button id="configDownload">Download config</button><label>Restore configuration</label><input id="configFile" type="file" accept=".json,application/json"><button id="restoreConfigBtn" class="secondary">Restore config</button><button id="restartDevice" class="secondary">Restart</button><button id="factoryReset" class="danger">Factory reset</button><pre id="maintOut"></pre></section>
|
||||||
<section class="fullWidth"><div class="toolbar"><h3>Files</h3><span id="filesPath" class="filePathChip">/</span></div><div id="fileList" class="list"></div><pre id="filesOut"></pre></section>
|
<section class="fullWidth"><div class="toolbar"><h3>Files</h3><span id="filesPath" class="filePathChip">/</span></div><div id="fileList" class="list"></div><pre id="filesOut"></pre></section>
|
||||||
<section class="fullWidth"><h3>Logs</h3><div class="row"><button id="logsLoad">Load</button><button id="logsClear" class="danger">Clear</button></div><pre id="logsOut"></pre></section>
|
<section class="fullWidth"><h3>Logs</h3><div class="row"><button id="logsLoad">Load</button><button id="logsClear" class="danger">Clear</button></div><pre id="logsOut"></pre></section>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class UptimeCard extends HTMLElement {
|
class UptimeCard extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.source = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
@@ -11,11 +11,16 @@ class UptimeCard extends HTMLElement {
|
|||||||
</style>
|
</style>
|
||||||
<section>
|
<section>
|
||||||
<h3>Live uptime</h3>
|
<h3>Live uptime</h3>
|
||||||
<div class="muted">Server-Sent Events</div>
|
<div class="muted">Auto refresh</div>
|
||||||
<div class="liveValue"><span id="seconds">--</span>s</div>
|
<div class="liveValue"><span id="seconds">--</span>s</div>
|
||||||
<div class="muted state"><span id="dot" class="dot"></span><span id="state">Disconnected</span></div>
|
<div class="muted state"><span id="dot" class="dot"></span><span id="state">Disconnected</span></div>
|
||||||
</section>`;
|
</section>`;
|
||||||
window.addEventListener('app:login', event => this.start(event.detail.token));
|
window.addEventListener('app:login', event => this.start(event.detail.token));
|
||||||
|
window.addEventListener('app:logout', () => {
|
||||||
|
this.stop();
|
||||||
|
this.setState('Disconnected', false);
|
||||||
|
this.querySelector('#seconds').textContent = '--';
|
||||||
|
});
|
||||||
window.addEventListener('beforeunload', () => this.stop());
|
window.addEventListener('beforeunload', () => this.stop());
|
||||||
if (window.App && window.App.token()) this.start(window.App.token());
|
if (window.App && window.App.token()) this.start(window.App.token());
|
||||||
}
|
}
|
||||||
@@ -31,28 +36,32 @@ class UptimeCard extends HTMLElement {
|
|||||||
|
|
||||||
start(token) {
|
start(token) {
|
||||||
this.stop();
|
this.stop();
|
||||||
if (!token || !window.EventSource) {
|
if (!token) {
|
||||||
this.setState('Unavailable', false);
|
this.setState('Unavailable', false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.setState('Connecting', false);
|
this.setState('Loading', false);
|
||||||
this.source = new EventSource('/api/uptime/events?token=' + encodeURIComponent(token));
|
const load = async () => {
|
||||||
this.source.addEventListener('open', () => this.setState('Connected', true));
|
|
||||||
this.source.addEventListener('uptime', event => {
|
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(event.data);
|
const text = await window.App.serverReq('GET', '/api/uptime');
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
if (!json.success) {
|
||||||
|
this.setState('Unavailable', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.querySelector('#seconds').textContent = json.uptimeSeconds;
|
this.querySelector('#seconds').textContent = json.uptimeSeconds;
|
||||||
this.setState('Connected', true);
|
this.setState('Connected', true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState('Invalid event', false);
|
this.setState('Retrying', false);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
this.source.addEventListener('error', () => this.setState('Reconnecting', false));
|
load();
|
||||||
|
this.timer = setInterval(load, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
if (this.source) this.source.close();
|
if (this.timer) clearInterval(this.timer);
|
||||||
this.source = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ pre{white-space:pre-wrap;background:#111;color:#d7ffd7;padding:10px;border-radiu
|
|||||||
.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}
|
.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}
|
.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}
|
.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}
|
.fullWidth{grid-column:1/-1}.activityDock{position:fixed;right:14px;top:10px;z-index:10;display:flex;gap:8px;align-items:flex-start}.activityChips{display:flex;flex-direction:row;gap:6px;align-items:flex-start}.busyChip{background:#1261a6;color:white;border-radius:6px;padding:7px 10px;box-shadow:0 2px 10px rgba(0,0,0,.22);font-size:13px;white-space:nowrap}.serverBusy{background:#607d8b}.logoutBtn{margin:0;background:#607d8b;box-shadow:0 2px 10px rgba(0,0,0,.22);padding:7px 10px;font-size:13px}
|
||||||
.fileList{gap:3px}.fileRow{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:8px;align-items:center;background:#eef3f6;border-radius:6px;color:#263238;min-height:30px;padding:3px 8px;box-sizing:border-box}.fileRow[data-path]{cursor:pointer}.fileRow:hover{background:#dbe7ed}.filePath{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}.filePathChip{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px;color:#263238;background:#eef3f6;border-radius:6px;padding:6px 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}.fileSize{color:#607d8b;font-size:12px;white-space:nowrap}.iconBtn,.folderIcon{width:24px;height:24px;display:inline-grid;place-items:center}.iconBtn{border:0;border-radius:5px;background:transparent;color:#1261a6;margin:0;padding:0}.iconBtn:hover{background:#c9dbe5}.iconBtn svg,.folderIcon svg{width:16px;height:16px}.folderIcon{color:#607d8b}
|
.fileList{gap:3px}.fileRow{display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:8px;align-items:center;background:#eef3f6;border-radius:6px;color:#263238;min-height:30px;padding:3px 8px;box-sizing:border-box}.fileRow[data-path]{cursor:pointer}.fileRow:hover{background:#dbe7ed}.filePath{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}.filePathChip{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px;color:#263238;background:#eef3f6;border-radius:6px;padding:6px 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}.fileSize{color:#607d8b;font-size:12px;white-space:nowrap}.iconBtn,.folderIcon{width:24px;height:24px;display:inline-grid;place-items:center}.iconBtn{border:0;border-radius:5px;background:transparent;color:#1261a6;margin:0;padding:0}.iconBtn:hover{background:#c9dbe5}.iconBtn svg,.folderIcon svg{width:16px;height:16px}.folderIcon{color:#607d8b}
|
||||||
.apiRow{display:grid;grid-template-columns:minmax(180px,1fr) minmax(220px,auto);gap:10px;align-items:center;background:#eef3f6;border-radius:6px;padding:7px 9px}.apiPath{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}.apiVerbs{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.verbBadge{display:inline-flex;gap:6px;align-items:center;background:white;color:#263238;border:1px solid #cfd8dc;border-radius:6px;margin:0;padding:5px 7px;font-size:12px}.verbBadge:hover{background:#dbe7ed}.verbBadge span{color:#607d8b}
|
.apiRow{display:grid;grid-template-columns:minmax(180px,1fr) minmax(220px,auto);gap:10px;align-items:center;background:#eef3f6;border-radius:6px;padding:7px 9px}.apiPath{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}.apiVerbs{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.verbBadge{display:inline-flex;gap:6px;align-items:center;background:white;color:#263238;border:1px solid #cfd8dc;border-radius:6px;margin:0;padding:5px 7px;font-size:12px}.verbBadge:hover{background:#dbe7ed}.verbBadge span{color:#607d8b}
|
||||||
.usageGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:12px 0}.usageGrid h3{font-size:14px;margin:0 0 6px}.usageList{display:flex;flex-direction:column;gap:4px}.usageItem{display:flex;gap:6px;align-items:center;background:#eef3f6;border-radius:6px;padding:6px 8px;min-height:28px}.usageItem code{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
|
.usageGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:12px 0}.usageGrid h3{font-size:14px;margin:0 0 6px}.usageList{display:flex;flex-direction:column;gap:4px}.usageItem{display:flex;gap:6px;align-items:center;background:#eef3f6;border-radius:6px;padding:6px 8px;min-height:28px}.usageItem code{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
|
||||||
|
@media (max-width:600px){main{padding:14px}.apiRow{grid-template-columns:1fr;gap:7px;align-items:start}.apiVerbs{justify-content:flex-start}.verbBadge{min-height:30px;box-sizing:border-box;flex:1 1 96px;justify-content:space-between}.activityDock{right:8px;top:8px;gap:6px}.busyChip,.logoutBtn{font-size:12px;padding:6px 8px}}
|
||||||
|
|||||||
BIN
data/www/icons/icon-192.png
Normal file
BIN
data/www/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
data/www/icons/icon-512.png
Normal file
BIN
data/www/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
21
data/www/icons/icon.svg
Normal file
21
data/www/icons/icon.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="TSL Embedded">
|
||||||
|
<rect width="512" height="512" rx="112" fill="#1261a6"/>
|
||||||
|
<rect x="116" y="116" width="280" height="280" rx="44" fill="#f5f7f8"/>
|
||||||
|
<rect x="164" y="164" width="184" height="184" rx="24" fill="#263238"/>
|
||||||
|
<path fill="#58c4b8" d="M210 233h92v46h-92z"/>
|
||||||
|
<path fill="#f5f7f8" d="M233 210h46v92h-46z"/>
|
||||||
|
<g fill="#58c4b8">
|
||||||
|
<rect x="152" y="68" width="24" height="64" rx="12"/>
|
||||||
|
<rect x="244" y="68" width="24" height="64" rx="12"/>
|
||||||
|
<rect x="336" y="68" width="24" height="64" rx="12"/>
|
||||||
|
<rect x="152" y="380" width="24" height="64" rx="12"/>
|
||||||
|
<rect x="244" y="380" width="24" height="64" rx="12"/>
|
||||||
|
<rect x="336" y="380" width="24" height="64" rx="12"/>
|
||||||
|
<rect x="68" y="152" width="64" height="24" rx="12"/>
|
||||||
|
<rect x="68" y="244" width="64" height="24" rx="12"/>
|
||||||
|
<rect x="68" y="336" width="64" height="24" rx="12"/>
|
||||||
|
<rect x="380" y="152" width="64" height="24" rx="12"/>
|
||||||
|
<rect x="380" y="244" width="64" height="24" rx="12"/>
|
||||||
|
<rect x="380" y="336" width="64" height="24" rx="12"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -7,47 +7,103 @@ let selectedRole = '';
|
|||||||
let selectedApi = -1;
|
let selectedApi = -1;
|
||||||
let currentFilePath = '/';
|
let currentFilePath = '/';
|
||||||
let filesLoaded = false;
|
let filesLoaded = false;
|
||||||
let busyCount = 0;
|
let uiBusyCount = 0;
|
||||||
|
let serverBusyCount = 0;
|
||||||
|
let currentUserRoles = [];
|
||||||
|
|
||||||
const el = id => document.getElementById(id);
|
const el = id => document.getElementById(id);
|
||||||
|
const hasCurrentRole = role => currentUserRoles.includes(role);
|
||||||
|
|
||||||
function setGlobalBusy(on) {
|
function setProjectName(projectName) {
|
||||||
busyCount += on ? 1 : -1;
|
if (!projectName) return;
|
||||||
if (busyCount < 0) busyCount = 0;
|
document.title = projectName;
|
||||||
el('busyChip').classList.toggle('hidden', busyCount == 0);
|
el('appTitle').textContent = projectName;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tracked(fn) {
|
async function loadAppInfo() {
|
||||||
setGlobalBusy(true);
|
try {
|
||||||
|
const response = await fetch('/api/app-info');
|
||||||
|
const json = await response.json();
|
||||||
|
setProjectName(json.projectName || '');
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActivityBusy(kind, on) {
|
||||||
|
if (kind == 'server') {
|
||||||
|
serverBusyCount += on ? 1 : -1;
|
||||||
|
if (serverBusyCount < 0) serverBusyCount = 0;
|
||||||
|
el('serverBusyChip').classList.toggle('hidden', serverBusyCount == 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uiBusyCount += on ? 1 : -1;
|
||||||
|
if (uiBusyCount < 0) uiBusyCount = 0;
|
||||||
|
el('uiBusyChip').classList.toggle('hidden', uiBusyCount == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tracked(fn, kind = 'ui') {
|
||||||
|
setActivityBusy(kind, true);
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
setGlobalBusy(false);
|
setActivityBusy(kind, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiFetch(url, options) {
|
async function apiFetch(url, options, activity = 'ui') {
|
||||||
return await tracked(() => fetch(url, options));
|
return await tracked(() => fetch(url, options), activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function req(method, url, body) {
|
async function req(method, url, body, activity = 'ui') {
|
||||||
const options = {method, headers: {'Content-Type': 'application/json'}};
|
const options = {method, headers: {'Content-Type': 'application/json'}};
|
||||||
if (token) options.headers.Authorization = 'Bearer ' + token;
|
if (token) options.headers.Authorization = 'Bearer ' + token;
|
||||||
if (body !== undefined && method != 'GET') options.body = JSON.stringify(body);
|
if (body !== undefined && method != 'GET') options.body = JSON.stringify(body);
|
||||||
const response = await apiFetch(url, options);
|
const response = await apiFetch(url, options, activity);
|
||||||
return await response.text();
|
return await response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function serverReq(method, url, body) {
|
||||||
|
return await req(method, url, body, 'server');
|
||||||
|
}
|
||||||
|
|
||||||
window.App = {
|
window.App = {
|
||||||
apiFetch,
|
apiFetch,
|
||||||
req,
|
req,
|
||||||
token: () => token
|
serverReq,
|
||||||
|
setActivityBusy,
|
||||||
|
token: () => token,
|
||||||
|
roles: () => [...currentUserRoles]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const topLevelTabs = [
|
||||||
|
{id: 'dashboard', role: 'WebUIConnect'},
|
||||||
|
{id: 'access', role: 'AccessAdmin'},
|
||||||
|
{id: 'network', role: 'NetworkAdmin'},
|
||||||
|
{id: 'ops', role: 'SystemAdmin'}
|
||||||
|
];
|
||||||
|
|
||||||
|
function visibleTopLevelTabs() {
|
||||||
|
return topLevelTabs.filter(tab => hasCurrentRole(tab.role)).map(tab => tab.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRoleVisibility() {
|
||||||
|
const visible = visibleTopLevelTabs();
|
||||||
|
topLevelTabs.forEach(tab => {
|
||||||
|
const allowed = visible.includes(tab.id);
|
||||||
|
document.querySelectorAll('[data-tab="' + tab.id + '"]').forEach(button => button.classList.toggle('hidden', !allowed));
|
||||||
|
const panel = el(tab.id);
|
||||||
|
if (panel) panel.classList.toggle('hidden', !allowed);
|
||||||
|
});
|
||||||
|
const topTabs = document.querySelector('#app > .tabs');
|
||||||
|
if (topTabs) topTabs.classList.toggle('hidden', visible.length <= 1);
|
||||||
|
if (visible.length) showTab(visible[0]);
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
function showTab(id) {
|
function showTab(id) {
|
||||||
|
if (!visibleTopLevelTabs().includes(id)) return;
|
||||||
document.querySelectorAll('.tab[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab == id));
|
document.querySelectorAll('.tab[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab == id));
|
||||||
document.querySelectorAll('.panel').forEach(panel => panel.classList.toggle('active', panel.id == id));
|
document.querySelectorAll('.panel').forEach(panel => panel.classList.toggle('active', panel.id == id));
|
||||||
if (id == 'network') loadSettings();
|
if (id == 'network') loadNetworkSettings();
|
||||||
if (id == 'ops' && !filesLoaded) loadFiles('/');
|
if (id == 'ops' && !filesLoaded) loadFiles('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,12 +121,40 @@ async function doLogin() {
|
|||||||
const json = JSON.parse(text);
|
const json = JSON.parse(text);
|
||||||
if (!json.success) return;
|
if (!json.success) return;
|
||||||
token = json.token;
|
token = json.token;
|
||||||
|
currentUserRoles = (json.roles || '').split('|').filter(Boolean);
|
||||||
el('loginPanel').classList.add('hidden');
|
el('loginPanel').classList.add('hidden');
|
||||||
el('app').classList.remove('hidden');
|
el('app').classList.remove('hidden');
|
||||||
window.dispatchEvent(new CustomEvent('app:login', {detail: {token}}));
|
el('logoutBtn').classList.remove('hidden');
|
||||||
loadSettings();
|
window.dispatchEvent(new CustomEvent('app:login', {detail: {token, roles: currentUserRoles}}));
|
||||||
loadAccess();
|
const visible = applyRoleVisibility();
|
||||||
loadFiles('/');
|
if (hasCurrentRole('SystemAdmin')) loadSettings();
|
||||||
|
if (hasCurrentRole('AccessAdmin')) loadAccess();
|
||||||
|
if (hasCurrentRole('NetworkAdmin')) loadNetworkSettings();
|
||||||
|
if (visible.includes('ops')) loadFiles('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
await req('POST', '/api/logout');
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
token = '';
|
||||||
|
currentUserRoles = [];
|
||||||
|
roles = [];
|
||||||
|
users = [];
|
||||||
|
apis = [];
|
||||||
|
selectedUser = '';
|
||||||
|
selectedRole = '';
|
||||||
|
selectedApi = -1;
|
||||||
|
filesLoaded = false;
|
||||||
|
currentFilePath = '/';
|
||||||
|
el('app').classList.add('hidden');
|
||||||
|
el('loginPanel').classList.remove('hidden');
|
||||||
|
el('logoutBtn').classList.add('hidden');
|
||||||
|
el('pass').value = '';
|
||||||
|
el('loginOut').textContent = '';
|
||||||
|
window.dispatchEvent(new CustomEvent('app:logout'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(value) {
|
function esc(value) {
|
||||||
@@ -443,6 +527,16 @@ async function factoryResetDevice() {
|
|||||||
el('maintOut').textContent = await req('POST', '/api/factory-reset');
|
el('maintOut').textContent = await req('POST', '/api/factory-reset');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restartDevice() {
|
||||||
|
if (!confirm('Restart this device now?')) return;
|
||||||
|
setBusy('restartDevice', true, 'Restarting');
|
||||||
|
try {
|
||||||
|
el('maintOut').textContent = await req('POST', '/api/restart');
|
||||||
|
} finally {
|
||||||
|
setBusy('restartDevice', false, 'Restart');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadLogs() {
|
async function loadLogs() {
|
||||||
const text = await req('GET', '/api/logs');
|
const text = await req('GET', '/api/logs');
|
||||||
try {
|
try {
|
||||||
@@ -472,12 +566,20 @@ async function loadSettings() {
|
|||||||
const json = JSON.parse(text);
|
const json = JSON.parse(text);
|
||||||
if (!json.success) return;
|
if (!json.success) return;
|
||||||
const settings = json.settings;
|
const settings = json.settings;
|
||||||
const projectName = settings.projectName || 'ESP32-C3';
|
setProjectName(settings.projectName);
|
||||||
el('appTitle').textContent = projectName + ' Admin';
|
|
||||||
document.title = projectName + ' Admin';
|
|
||||||
el('logLevel').value = settings.logLevel;
|
el('logLevel').value = settings.logLevel;
|
||||||
el('logMax').value = settings.maxLogBytes;
|
el('logMax').value = settings.maxLogBytes;
|
||||||
el('upd').value = settings.updateUrl;
|
el('upd').value = settings.updateUrl;
|
||||||
|
window.dispatchEvent(new CustomEvent('app:settings-loaded', {detail: {settings}}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNetworkSettings() {
|
||||||
|
const text = await req('GET', '/api/network');
|
||||||
|
el('networkOut').textContent = text;
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
if (!json.success) return;
|
||||||
|
const settings = json.settings;
|
||||||
|
setProjectName(settings.projectName);
|
||||||
el('netHost').value = settings.hostname || '';
|
el('netHost').value = settings.hostname || '';
|
||||||
el('netHost').placeholder = settings.defaultHostname || 'Default host name';
|
el('netHost').placeholder = settings.defaultHostname || 'Default host name';
|
||||||
el('netDhcp').value = String(settings.dhcp !== false);
|
el('netDhcp').value = String(settings.dhcp !== false);
|
||||||
@@ -488,7 +590,6 @@ async function loadSettings() {
|
|||||||
el('netDns2').value = settings.dns2 || '';
|
el('netDns2').value = settings.dns2 || '';
|
||||||
el('netCurrent').textContent = 'Current IP: ' + (settings.currentIp || 'not connected');
|
el('netCurrent').textContent = 'Current IP: ' + (settings.currentIp || 'not connected');
|
||||||
syncNetworkMode();
|
syncNetworkMode();
|
||||||
window.dispatchEvent(new CustomEvent('app:settings-loaded', {detail: {settings}}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
@@ -499,9 +600,9 @@ async function saveNetwork() {
|
|||||||
setBusy('networkSaveBtn', true, 'Saving');
|
setBusy('networkSaveBtn', true, 'Saving');
|
||||||
try {
|
try {
|
||||||
const 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};
|
const 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};
|
||||||
const text = await req('POST', '/api/settings', body);
|
const text = await req('POST', '/api/network', body);
|
||||||
el('networkOut').textContent = text + '\nNetwork changes apply on the next WiFi reconnect or reboot.';
|
el('networkOut').textContent = text + '\nNetwork changes apply on the next WiFi reconnect or reboot.';
|
||||||
if (JSON.parse(text).success) await loadSettings();
|
if (JSON.parse(text).success) await loadNetworkSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
el('networkOut').textContent = 'Network save failed: ' + error.message;
|
el('networkOut').textContent = 'Network save failed: ' + error.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -545,13 +646,12 @@ async function runOta() {
|
|||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
el('loginBtn').addEventListener('click', doLogin);
|
el('loginBtn').addEventListener('click', doLogin);
|
||||||
|
el('logoutBtn').addEventListener('click', doLogout);
|
||||||
document.querySelectorAll('.tab[data-tab]').forEach(button => button.addEventListener('click', () => showTab(button.dataset.tab)));
|
document.querySelectorAll('.tab[data-tab]').forEach(button => button.addEventListener('click', () => showTab(button.dataset.tab)));
|
||||||
document.querySelectorAll('.subtab').forEach(button => button.addEventListener('click', () => showAccessTab(button.dataset.subtab)));
|
document.querySelectorAll('.subtab').forEach(button => button.addEventListener('click', () => showAccessTab(button.dataset.subtab)));
|
||||||
el('usersRefresh').addEventListener('click', loadUsers);
|
|
||||||
el('userNew').addEventListener('click', newUserRecord);
|
el('userNew').addEventListener('click', newUserRecord);
|
||||||
el('userBack').addEventListener('click', showUserOverview);
|
el('userBack').addEventListener('click', showUserOverview);
|
||||||
el('userSave').addEventListener('click', saveSelectedUser);
|
el('userSave').addEventListener('click', saveSelectedUser);
|
||||||
el('rolesRefresh').addEventListener('click', loadRoles);
|
|
||||||
el('roleNew').addEventListener('click', newRoleRecord);
|
el('roleNew').addEventListener('click', newRoleRecord);
|
||||||
el('roleBack').addEventListener('click', showRoleOverview);
|
el('roleBack').addEventListener('click', showRoleOverview);
|
||||||
el('roleSave').addEventListener('click', addRole);
|
el('roleSave').addEventListener('click', addRole);
|
||||||
@@ -582,6 +682,7 @@ function bindEvents() {
|
|||||||
el('otaRun').addEventListener('click', runOta);
|
el('otaRun').addEventListener('click', runOta);
|
||||||
el('configDownload').addEventListener('click', downloadConfig);
|
el('configDownload').addEventListener('click', downloadConfig);
|
||||||
el('restoreConfigBtn').addEventListener('click', restoreConfig);
|
el('restoreConfigBtn').addEventListener('click', restoreConfig);
|
||||||
|
el('restartDevice').addEventListener('click', restartDevice);
|
||||||
el('factoryReset').addEventListener('click', factoryResetDevice);
|
el('factoryReset').addEventListener('click', factoryResetDevice);
|
||||||
el('fileList').addEventListener('click', event => {
|
el('fileList').addEventListener('click', event => {
|
||||||
const button = event.target.closest('[data-download]');
|
const button = event.target.closest('[data-download]');
|
||||||
@@ -596,4 +697,5 @@ function bindEvents() {
|
|||||||
el('logsClear').addEventListener('click', clearLogs);
|
el('logsClear').addEventListener('click', clearLogs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadAppInfo();
|
||||||
bindEvents();
|
bindEvents();
|
||||||
|
|||||||
9
data/www/js/pwa.js
Normal file
9
data/www/js/pwa.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
(function () {
|
||||||
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
|
||||||
|
window.addEventListener('load', function () {
|
||||||
|
navigator.serviceWorker.register('/service-worker.js').catch(function (error) {
|
||||||
|
console.warn('PWA service worker registration failed:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -4,6 +4,16 @@ const admin = document.getElementById('admin');
|
|||||||
const adminPass = document.getElementById('adminPass');
|
const adminPass = document.getElementById('adminPass');
|
||||||
const out = document.getElementById('out');
|
const out = document.getElementById('out');
|
||||||
|
|
||||||
|
async function loadAppInfo() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/app-info');
|
||||||
|
const json = await response.json();
|
||||||
|
const projectName = json.projectName || '';
|
||||||
|
document.title = projectName;
|
||||||
|
document.getElementById('setupTitle').textContent = projectName;
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
|
||||||
async function scan() {
|
async function scan() {
|
||||||
const response = await fetch('/api/wifi/scan');
|
const response = await fetch('/api/wifi/scan');
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
@@ -17,4 +27,5 @@ async function save() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('saveSetup').addEventListener('click', save);
|
document.getElementById('saveSetup').addEventListener('click', save);
|
||||||
|
loadAppInfo();
|
||||||
scan();
|
scan();
|
||||||
|
|||||||
32
data/www/manifest.webmanifest
Normal file
32
data/www/manifest.webmanifest
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "TSL-Embedded Admin",
|
||||||
|
"short_name": "TSL Admin",
|
||||||
|
"description": "Installable administration app for the embedded ESP32-C3 device.",
|
||||||
|
"id": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#f5f7f8",
|
||||||
|
"theme_color": "#1261a6",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
55
data/www/service-worker.js
Normal file
55
data/www/service-worker.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
const CACHE_NAME = 'tsl-embedded-pwa-v12';
|
||||||
|
|
||||||
|
const APP_SHELL = [
|
||||||
|
'/',
|
||||||
|
'/admin.html',
|
||||||
|
'/setup.html',
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/css/app.css',
|
||||||
|
'/css/setup.css',
|
||||||
|
'/js/app.js',
|
||||||
|
'/js/setup.js',
|
||||||
|
'/js/pwa.js',
|
||||||
|
'/components/status-card.js',
|
||||||
|
'/components/uptime-card.js',
|
||||||
|
'/components/led-card.js',
|
||||||
|
'/components/add-card.js',
|
||||||
|
'/icons/icon.svg',
|
||||||
|
'/icons/icon-192.png',
|
||||||
|
'/icons/icon-512.png'
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', event => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME)
|
||||||
|
.then(cache => cache.addAll(APP_SHELL))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', event => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then(names => Promise.all(names.filter(name => name !== CACHE_NAME).map(name => caches.delete(name))))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
const requestUrl = new URL(event.request.url);
|
||||||
|
if (requestUrl.origin !== location.origin || requestUrl.pathname.startsWith('/api/')) return;
|
||||||
|
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
event.respondWith(fetch(event.request).catch(() => caches.match('/admin.html')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then(cached => cached || fetch(event.request).then(response => {
|
||||||
|
if (!response || response.status !== 200 || event.request.method !== 'GET') return response;
|
||||||
|
const copy = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then(cache => cache.put(event.request, copy));
|
||||||
|
return response;
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>ESP32-C3 first-run setup</title>
|
<title></title>
|
||||||
<link rel="stylesheet" href="/css/setup.css">
|
<link rel="stylesheet" href="/css/setup.css">
|
||||||
<script defer src="/js/setup.js"></script>
|
<script defer src="/js/setup.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="top">ESP32-C3 first-run setup</div>
|
<div id="setupTitle" class="top"></div>
|
||||||
<main>
|
<main>
|
||||||
<section class="box">
|
<section class="box">
|
||||||
<h2>Network and admin setup</h2>
|
<h2>Network and admin setup</h2>
|
||||||
|
|||||||
@@ -158,10 +158,13 @@ void handleApisGet();
|
|||||||
void handleApisPost();
|
void handleApisPost();
|
||||||
void handleSettingsGet();
|
void handleSettingsGet();
|
||||||
void handleSettingsPost();
|
void handleSettingsPost();
|
||||||
|
void handleNetworkSettingsGet();
|
||||||
|
void handleNetworkSettingsPost();
|
||||||
void handleLogsGet();
|
void handleLogsGet();
|
||||||
void handleLogsClear();
|
void handleLogsClear();
|
||||||
void handleFilesList();
|
void handleFilesList();
|
||||||
void handleFileDownload();
|
void handleFileDownload();
|
||||||
|
void handleRestart();
|
||||||
void handleFactoryReset();
|
void handleFactoryReset();
|
||||||
void handleConfigExport();
|
void handleConfigExport();
|
||||||
void handleConfigImport();
|
void handleConfigImport();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
void handleAppInfo();
|
||||||
void handlePing();
|
void handlePing();
|
||||||
void handleAdd();
|
void handleAdd();
|
||||||
void handleLed();
|
void handleLed();
|
||||||
|
|||||||
@@ -1,33 +1,36 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
|
||||||
ApiDef apiDefs[] = {
|
ApiDef apiDefs[] = {
|
||||||
{"/api/wifi/scan", "GET", "Sysadmin", false, handleWifiScan, nullptr},
|
{"/api/wifi/scan", "GET", "SystemAdmin", false, handleWifiScan, nullptr},
|
||||||
{"/api/setup", "POST", "Sysadmin", false, handleSetupSubmit, nullptr},
|
{"/api/setup", "POST", "SystemAdmin", false, handleSetupSubmit, nullptr},
|
||||||
{"/api/login", "POST", "", true, handleLogin, nullptr},
|
{"/api/login", "POST", "", true, handleLogin, nullptr},
|
||||||
{"/api/logout", "POST", "WebUIConnect", false, handleLogout, nullptr},
|
{"/api/logout", "POST", "WebUIConnect", false, handleLogout, nullptr},
|
||||||
{"/api/me", "GET", "WebUIConnect", false, handleMe, nullptr},
|
{"/api/me", "GET", "WebUIConnect", false, handleMe, nullptr},
|
||||||
{"/api/apis", "GET", "Sysadmin", false, handleApisGet, nullptr},
|
{"/api/apis", "GET", "AccessAdmin", false, handleApisGet, nullptr},
|
||||||
{"/api/apis", "POST", "Sysadmin", false, handleApisPost, nullptr},
|
{"/api/apis", "POST", "AccessAdmin", false, handleApisPost, nullptr},
|
||||||
{"/api/users", "GET", "UserAdmin", false, handleUsersGet, nullptr},
|
{"/api/users", "GET", "AccessAdmin", false, handleUsersGet, nullptr},
|
||||||
{"/api/users", "POST", "UserAdmin", false, handleUsersPost, nullptr},
|
{"/api/users", "POST", "AccessAdmin", false, handleUsersPost, nullptr},
|
||||||
{"/api/roles", "GET", "UserAdmin", false, handleRolesGet, nullptr},
|
{"/api/roles", "GET", "AccessAdmin", false, handleRolesGet, nullptr},
|
||||||
{"/api/roles", "POST", "UserAdmin", false, handleRolesPost, nullptr},
|
{"/api/roles", "POST", "AccessAdmin", false, handleRolesPost, nullptr},
|
||||||
{"/api/roles", "DELETE", "UserAdmin", false, handleRolesDelete, nullptr},
|
{"/api/roles", "DELETE", "AccessAdmin", false, handleRolesDelete, nullptr},
|
||||||
{"/api/password", "POST", "WebUIConnect", false, handlePassword, nullptr},
|
{"/api/password", "POST", "WebUIConnect", false, handlePassword, nullptr},
|
||||||
{"/api/settings", "GET", "Sysadmin", false, handleSettingsGet, nullptr},
|
{"/api/settings", "GET", "SystemAdmin", false, handleSettingsGet, nullptr},
|
||||||
{"/api/settings", "POST", "Sysadmin", false, handleSettingsPost, nullptr},
|
{"/api/settings", "POST", "SystemAdmin", false, handleSettingsPost, nullptr},
|
||||||
{"/api/logs", "GET", "Debugger", false, handleLogsGet, nullptr},
|
{"/api/network", "GET", "NetworkAdmin", false, handleNetworkSettingsGet, nullptr},
|
||||||
{"/api/logs/clear", "POST", "Debugger", false, handleLogsClear, nullptr},
|
{"/api/network", "POST", "NetworkAdmin", false, handleNetworkSettingsPost, nullptr},
|
||||||
{"/api/files", "GET", "Debugger", false, handleFilesList, nullptr},
|
{"/api/logs", "GET", "SystemAdmin", false, handleLogsGet, nullptr},
|
||||||
{"/api/files/download", "GET", "Debugger", false, handleFileDownload, nullptr},
|
{"/api/logs/clear", "POST", "SystemAdmin", false, handleLogsClear, nullptr},
|
||||||
{"/api/factory-reset", "POST", "Sysadmin", false, handleFactoryReset, nullptr},
|
{"/api/files", "GET", "SystemAdmin", false, handleFilesList, nullptr},
|
||||||
{"/api/config/export", "GET", "Sysadmin", false, handleConfigExport, nullptr},
|
{"/api/files/download", "GET", "SystemAdmin", false, handleFileDownload, nullptr},
|
||||||
{"/api/config/import", "POST", "Sysadmin", false, handleConfigImport, nullptr},
|
{"/api/restart", "POST", "SystemAdmin", false, handleRestart, nullptr},
|
||||||
{"/api/ota/check", "POST", "Sysadmin", false, handleOtaCheck, nullptr},
|
{"/api/factory-reset", "POST", "SystemAdmin", false, handleFactoryReset, nullptr},
|
||||||
{"/api/ota/run", "POST", "Sysadmin", false, handleOtaRun, nullptr},
|
{"/api/config/export", "GET", "SystemAdmin", false, handleConfigExport, nullptr},
|
||||||
{"/api/update", "POST", "Sysadmin", false, handleUpdateUploadDone, handleUpdateUploadChunk},
|
{"/api/config/import", "POST", "SystemAdmin", false, handleConfigImport, nullptr},
|
||||||
{"/api/cert", "GET", "Sysadmin", false, handleCertGet, nullptr},
|
{"/api/ota/check", "POST", "SystemAdmin", false, handleOtaCheck, nullptr},
|
||||||
{"/api/cert", "POST", "Sysadmin", false, handleCertPost, nullptr},
|
{"/api/ota/run", "POST", "SystemAdmin", false, handleOtaRun, nullptr},
|
||||||
|
{"/api/update", "POST", "SystemAdmin", false, handleUpdateUploadDone, handleUpdateUploadChunk},
|
||||||
|
{"/api/cert", "GET", "NetworkAdmin", false, handleCertGet, nullptr},
|
||||||
|
{"/api/cert", "POST", "NetworkAdmin", false, handleCertPost, nullptr},
|
||||||
};
|
};
|
||||||
|
|
||||||
const size_t API_DEF_COUNT = sizeof(apiDefs) / sizeof(apiDefs[0]);
|
const size_t API_DEF_COUNT = sizeof(apiDefs) / sizeof(apiDefs[0]);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
#include "custom_api.h"
|
#include "custom_api.h"
|
||||||
|
|
||||||
ApiDef customApiDefs[] = {
|
ApiDef customApiDefs[] = {
|
||||||
|
{"/api/app-info", "GET", "", true, handleAppInfo, nullptr},
|
||||||
{"/api/ping", "GET", "", true, handlePing, nullptr},
|
{"/api/ping", "GET", "", true, handlePing, nullptr},
|
||||||
{"/api/add", "POST", "", true, handleAdd, nullptr},
|
{"/api/add", "POST", "", true, handleAdd, nullptr},
|
||||||
{"/api/led", "POST", "", true, handleLed, nullptr},
|
{"/api/led", "POST", "", true, handleLed, nullptr},
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
|
||||||
|
static String canonicalRoleName(const String &role) {
|
||||||
|
if (role == "Sysadmin") return "SystemAdmin";
|
||||||
|
if (role == "UserAdmin") return "AccessAdmin";
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
bool hasRole(const String &roles, const String &role) {
|
bool hasRole(const String &roles, const String &role) {
|
||||||
if (role.length() == 0) return true;
|
String required = canonicalRoleName(role);
|
||||||
|
if (required.length() == 0) return true;
|
||||||
int start = 0;
|
int start = 0;
|
||||||
while (start <= (int)roles.length()) {
|
while (start <= (int)roles.length()) {
|
||||||
int end = roles.indexOf('|', start);
|
int end = roles.indexOf('|', start);
|
||||||
if (end < 0) end = roles.length();
|
if (end < 0) end = roles.length();
|
||||||
if (roles.substring(start, end) == role) return true;
|
if (canonicalRoleName(roles.substring(start, end)) == required) return true;
|
||||||
start = end + 1;
|
start = end + 1;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -39,15 +46,16 @@ String cleanRoles(const String &roles) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool isKnownRole(const String &role) {
|
bool isKnownRole(const String &role) {
|
||||||
return role == "PUBLIC" || hasRole(allRoles(), role);
|
return role == "PUBLIC" || hasRole(allRoles(), canonicalRoleName(role));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isSystemRole(const String &role) {
|
bool isSystemRole(const String &role) {
|
||||||
return role == "Sysadmin" || role == "UserAdmin" || role == "WebUIConnect" || role == "Debugger";
|
String canonical = canonicalRoleName(role);
|
||||||
|
return canonical == "SystemAdmin" || canonical == "AccessAdmin" || canonical == "NetworkAdmin" || canonical == "WebUIConnect" || canonical == "Debugger";
|
||||||
}
|
}
|
||||||
|
|
||||||
String allRoles() {
|
String allRoles() {
|
||||||
String roles = "Sysadmin|UserAdmin|WebUIConnect|Debugger";
|
String roles = "SystemAdmin|AccessAdmin|NetworkAdmin|WebUIConnect|Debugger";
|
||||||
String custom = prefString("roles", "");
|
String custom = prefString("roles", "");
|
||||||
int start = 0;
|
int start = 0;
|
||||||
while (start <= (int)custom.length()) {
|
while (start <= (int)custom.length()) {
|
||||||
@@ -129,7 +137,7 @@ bool deleteCustomRole(const String &role) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String defaultUsers() {
|
static String defaultUsers() {
|
||||||
return String(DEFAULT_ADMIN) + "\t" + passwordHash("") + "\tSysadmin|UserAdmin|WebUIConnect|Debugger\t1\n";
|
return String(DEFAULT_ADMIN) + "\t" + passwordHash("") + "\tSystemAdmin|AccessAdmin|NetworkAdmin|WebUIConnect|Debugger\t1\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
static String usersText() {
|
static String usersText() {
|
||||||
@@ -247,7 +255,7 @@ ApiDef *findApi(const String &path, const String &method) {
|
|||||||
String configuredRole(ApiDef *api) {
|
String configuredRole(ApiDef *api) {
|
||||||
if (!api) return "";
|
if (!api) return "";
|
||||||
String key = apiKey(api->path, api->method);
|
String key = apiKey(api->path, api->method);
|
||||||
return prefString(key.c_str(), api->publicByDefault ? "PUBLIC" : api->defaultRole);
|
return canonicalRoleName(prefString(key.c_str(), api->publicByDefault ? "PUBLIC" : api->defaultRole));
|
||||||
}
|
}
|
||||||
|
|
||||||
static String requestMethodName() {
|
static String requestMethodName() {
|
||||||
|
|||||||
@@ -26,11 +26,35 @@ static bool validIpValue(const String &value) {
|
|||||||
|
|
||||||
void handleSettingsGet() {
|
void handleSettingsGet() {
|
||||||
if (!authorize()) return;
|
if (!authorize()) return;
|
||||||
String out = "\"settings\":{\"projectName\":\"" + jsonEscape(PROJECT_NAME_VALUE) + "\",\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(true) + "}";
|
String out = "\"settings\":{\"projectName\":\"" + jsonEscape(PROJECT_NAME_VALUE) + "\",\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "}";
|
||||||
sendJson(200, jsonOk(out));
|
sendJson(200, jsonOk(out));
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleSettingsPost() {
|
void handleSettingsPost() {
|
||||||
|
if (!authorize()) return;
|
||||||
|
String body = requestBody();
|
||||||
|
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);
|
||||||
|
prefs.putUChar("logLevel", currentLogLevel);
|
||||||
|
prefs.putUInt("logMax", maxLogBytes);
|
||||||
|
prefs.putBool("ledInv", ledInverted);
|
||||||
|
prefs.putUChar("ledBright", ledBrightness);
|
||||||
|
prefs.putString("updateUrl", updateUrl);
|
||||||
|
applyLed();
|
||||||
|
appendLog(LOG_SECURITY_AUDIT, "system settings updated");
|
||||||
|
sendJson(200, jsonOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleNetworkSettingsGet() {
|
||||||
|
if (!authorize()) return;
|
||||||
|
String out = "\"settings\":{\"projectName\":\"" + jsonEscape(PROJECT_NAME_VALUE) + "\"," + networkSettingsJson(true) + "}";
|
||||||
|
sendJson(200, jsonOk(out));
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleNetworkSettingsPost() {
|
||||||
if (!authorize()) return;
|
if (!authorize()) return;
|
||||||
String body = requestBody();
|
String body = requestBody();
|
||||||
String nextHostname = jsonStringValue(body, "hostname", networkHostname);
|
String nextHostname = jsonStringValue(body, "hostname", networkHostname);
|
||||||
@@ -52,11 +76,6 @@ void handleSettingsPost() {
|
|||||||
if (nextDns1.length() && !validIpValue(nextDns1)) return sendJson(400, jsonError("DNS 1 must be a valid IPv4 address"));
|
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"));
|
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;
|
networkHostname = nextHostname;
|
||||||
networkDhcp = nextDhcp;
|
networkDhcp = nextDhcp;
|
||||||
networkIp = nextIp;
|
networkIp = nextIp;
|
||||||
@@ -64,11 +83,6 @@ void handleSettingsPost() {
|
|||||||
networkSubnet = nextSubnet;
|
networkSubnet = nextSubnet;
|
||||||
networkDns1 = nextDns1;
|
networkDns1 = nextDns1;
|
||||||
networkDns2 = nextDns2;
|
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.putString("netHost", networkHostname);
|
||||||
prefs.putBool("netDhcp", networkDhcp);
|
prefs.putBool("netDhcp", networkDhcp);
|
||||||
prefs.putString("netIp", networkIp);
|
prefs.putString("netIp", networkIp);
|
||||||
@@ -76,8 +90,7 @@ void handleSettingsPost() {
|
|||||||
prefs.putString("netMask", networkSubnet);
|
prefs.putString("netMask", networkSubnet);
|
||||||
prefs.putString("netDns1", networkDns1);
|
prefs.putString("netDns1", networkDns1);
|
||||||
prefs.putString("netDns2", networkDns2);
|
prefs.putString("netDns2", networkDns2);
|
||||||
applyLed();
|
appendLog(LOG_SECURITY_AUDIT, "network settings updated");
|
||||||
appendLog(LOG_SECURITY_AUDIT, "settings updated");
|
|
||||||
sendJson(200, jsonOk());
|
sendJson(200, jsonOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +176,14 @@ void handleFileDownload() {
|
|||||||
file.close();
|
file.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void handleRestart() {
|
||||||
|
if (!authorize()) return;
|
||||||
|
appendLog(LOG_SECURITY_AUDIT, "restart requested");
|
||||||
|
sendJson(200, jsonOk("\"restart\":true"));
|
||||||
|
delay(500);
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
|
||||||
void handleFactoryReset() {
|
void handleFactoryReset() {
|
||||||
if (!authorize()) return;
|
if (!authorize()) return;
|
||||||
factoryReset();
|
factoryReset();
|
||||||
@@ -353,11 +374,11 @@ void handleConfigImport() {
|
|||||||
userCount++;
|
userCount++;
|
||||||
}
|
}
|
||||||
if (!userCount) return sendJson(400, jsonError("Backup must contain at least one user"));
|
if (!userCount) return sendJson(400, jsonError("Backup must contain at least one user"));
|
||||||
bool hasActiveSysadmin = false;
|
bool hasActiveSystemAdmin = false;
|
||||||
for (size_t i = 0; i < userCount; i++) {
|
for (size_t i = 0; i < userCount; i++) {
|
||||||
if (users[i].active && hasRole(users[i].roles, "Sysadmin")) hasActiveSysadmin = true;
|
if (users[i].active && hasRole(users[i].roles, "SystemAdmin")) hasActiveSystemAdmin = true;
|
||||||
}
|
}
|
||||||
if (!hasActiveSysadmin) return sendJson(400, jsonError("Backup must contain an active Sysadmin user"));
|
if (!hasActiveSystemAdmin) return sendJson(400, jsonError("Backup must contain an active SystemAdmin user"));
|
||||||
saveUsers(users, userCount);
|
saveUsers(users, userCount);
|
||||||
|
|
||||||
currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 4);
|
currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 4);
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ static String uptimeJsonFields() {
|
|||||||
return "\"uptimeSeconds\":" + String(millis() / 1000);
|
return "\"uptimeSeconds\":" + String(millis() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void handleAppInfo() {
|
||||||
|
sendJson(200, jsonOk("\"projectName\":\"" + jsonEscape(PROJECT_NAME_VALUE) + "\",\"version\":\"" + APP_VERSION + "\""));
|
||||||
|
}
|
||||||
|
|
||||||
void handlePing() {
|
void handlePing() {
|
||||||
if (!authorize()) return;
|
if (!authorize()) return;
|
||||||
sendJson(200, jsonOk("\"uptimeMs\":" + String(millis()) + ",\"version\":\"" + APP_VERSION + "\",\"name\":\"" + jsonEscape(deviceName()) + "\",\"ip\":\"" + WiFi.localIP().toString() + "\""));
|
sendJson(200, jsonOk("\"uptimeMs\":" + String(millis()) + ",\"version\":\"" + APP_VERSION + "\",\"name\":\"" + jsonEscape(deviceName()) + "\",\"ip\":\"" + WiFi.localIP().toString() + "\""));
|
||||||
@@ -34,7 +38,7 @@ void handleUptime() {
|
|||||||
|
|
||||||
void handleUptimeEvents() {
|
void handleUptimeEvents() {
|
||||||
if (!authorize()) return;
|
if (!authorize()) return;
|
||||||
String payload = "retry: 1000\n";
|
String payload = "retry: 3000\n";
|
||||||
payload += "event: uptime\n";
|
payload += "event: uptime\n";
|
||||||
payload += "data: {" + uptimeJsonFields() + "}\n\n";
|
payload += "data: {" + uptimeJsonFields() + "}\n\n";
|
||||||
server.sendHeader("Cache-Control", "no-store");
|
server.sendHeader("Cache-Control", "no-store");
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ void handleSetupSubmit() {
|
|||||||
if (!validName(admin)) return sendJson(400, jsonError("Admin username is invalid"));
|
if (!validName(admin)) return sendJson(400, jsonError("Admin username is invalid"));
|
||||||
prefs.putString("wifiSsid", ssid);
|
prefs.putString("wifiSsid", ssid);
|
||||||
prefs.putString("wifiPass", wifiPass);
|
prefs.putString("wifiPass", wifiPass);
|
||||||
User u{admin, passwordHash(adminPass), "Sysadmin|UserAdmin|WebUIConnect|Debugger", true};
|
User u{admin, passwordHash(adminPass), "SystemAdmin|AccessAdmin|NetworkAdmin|WebUIConnect|Debugger", true};
|
||||||
saveUsers(&u, 1);
|
saveUsers(&u, 1);
|
||||||
prefs.putBool("configured", true);
|
prefs.putBool("configured", true);
|
||||||
appendLog(LOG_SECURITY_AUDIT, "initial setup saved");
|
appendLog(LOG_SECURITY_AUDIT, "initial setup saved");
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "app.h"
|
#include "app.h"
|
||||||
|
|
||||||
|
#include <uri/UriGlob.h>
|
||||||
|
|
||||||
static HTTPMethod httpMethod(const char *method) {
|
static HTTPMethod httpMethod(const char *method) {
|
||||||
if (strcmp(method, "GET") == 0) return HTTP_GET;
|
if (strcmp(method, "GET") == 0) return HTTP_GET;
|
||||||
if (strcmp(method, "POST") == 0) return HTTP_POST;
|
if (strcmp(method, "POST") == 0) return HTTP_POST;
|
||||||
@@ -42,6 +44,22 @@ static String requestMethodName() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void handleFallbackRequest() {
|
||||||
|
if (setupMode) {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void registerRoutes() {
|
void registerRoutes() {
|
||||||
static const char *headers[] = {"Authorization", "X-Auth-Token"};
|
static const char *headers[] = {"Authorization", "X-Auth-Token"};
|
||||||
server.collectHeaders(headers, 2);
|
server.collectHeaders(headers, 2);
|
||||||
@@ -58,19 +76,6 @@ void registerRoutes() {
|
|||||||
|
|
||||||
registerApiRoutes();
|
registerApiRoutes();
|
||||||
|
|
||||||
server.onNotFound([]() {
|
server.on(UriGlob("*"), HTTP_ANY, handleFallbackRequest);
|
||||||
if (setupMode) {
|
server.onNotFound(handleFallbackRequest);
|
||||||
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"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ static String contentTypeFor(const String &path) {
|
|||||||
if (path.endsWith(".html")) return "text/html";
|
if (path.endsWith(".html")) return "text/html";
|
||||||
if (path.endsWith(".css")) return "text/css";
|
if (path.endsWith(".css")) return "text/css";
|
||||||
if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript";
|
if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript";
|
||||||
|
if (path.endsWith(".webmanifest")) return "application/manifest+json";
|
||||||
if (path.endsWith(".json")) return "application/json";
|
if (path.endsWith(".json")) return "application/json";
|
||||||
if (path.endsWith(".svg")) return "image/svg+xml";
|
if (path.endsWith(".svg")) return "image/svg+xml";
|
||||||
if (path.endsWith(".png")) return "image/png";
|
if (path.endsWith(".png")) return "image/png";
|
||||||
@@ -26,7 +27,7 @@ static bool serveWwwFile(const String &path) {
|
|||||||
if (file) file.close();
|
if (file) file.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
server.sendHeader("Cache-Control", path.endsWith(".html") ? "no-store" : "max-age=3600");
|
server.sendHeader("Cache-Control", (path.endsWith(".html") || path == "/service-worker.js") ? "no-store" : "max-age=3600");
|
||||||
server.streamFile(file, contentTypeFor(path));
|
server.streamFile(file, contentTypeFor(path));
|
||||||
file.close();
|
file.close();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user