diff --git a/__pycache__/build_ota_package.cpython-313.pyc b/__pycache__/build_ota_package.cpython-313.pyc new file mode 100644 index 0000000..56c46a6 Binary files /dev/null and b/__pycache__/build_ota_package.cpython-313.pyc differ diff --git a/__pycache__/upload_device.cpython-313.pyc b/__pycache__/upload_device.cpython-313.pyc new file mode 100644 index 0000000..6917f8d Binary files /dev/null and b/__pycache__/upload_device.cpython-313.pyc differ diff --git a/data/admin.html b/data/admin.html deleted file mode 100644 index eb943d4..0000000 --- a/data/admin.html +++ /dev/null @@ -1,181 +0,0 @@ - - - - - ESP32-C3 Admin - - - -
ESP32-C3 Admin
- -
-
-

Login

- - - -

-    
- -
- - - diff --git a/data/setup.html b/data/setup.html deleted file mode 100644 index ab893df..0000000 --- a/data/setup.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - ESP32-C3 first-run setup - - - -
ESP32-C3 first-run setup
-
-
-

Network and admin setup

- - - - - - - - - -

-    
-
- - - diff --git a/data/www/admin.html b/data/www/admin.html new file mode 100644 index 0000000..94eb290 --- /dev/null +++ b/data/www/admin.html @@ -0,0 +1,108 @@ + + + + + ESP32-C3 Admin + + + + + + + + +
ESP32-C3 Admin
+ +
+
+

Login

+ + + +

+    
+ +
+ + diff --git a/data/www/components/add-card.js b/data/www/components/add-card.js new file mode 100644 index 0000000..7fc8af7 --- /dev/null +++ b/data/www/components/add-card.js @@ -0,0 +1,26 @@ +class AddCard extends HTMLElement { + connectedCallback() { + this.innerHTML = ` + +
+

Add API

+
+
+
+
+ +

+      
`; + this.querySelector('#add').addEventListener('click', () => this.addNums()); + } + + async addNums() { + const a = +this.querySelector('#a').value; + const b = +this.querySelector('#b').value; + this.querySelector('#out').textContent = await window.App.req('POST', '/api/add', {a, b}); + } +} + +customElements.define('add-card', AddCard); diff --git a/data/www/components/led-card.js b/data/www/components/led-card.js new file mode 100644 index 0000000..0262b02 --- /dev/null +++ b/data/www/components/led-card.js @@ -0,0 +1,32 @@ +class LedCard extends HTMLElement { + connectedCallback() { + this.innerHTML = ` + +
+

LED

+ +
0
+ +

+      
`; + this.brightness = this.querySelector('#brightness'); + this.value = this.querySelector('#value'); + this.brightness.addEventListener('input', () => this.value.value = this.brightness.value); + this.querySelector('#apply').addEventListener('click', () => this.setLed()); + window.addEventListener('app:settings-loaded', event => this.applySettings(event.detail.settings)); + } + + applySettings(settings) { + if (!settings || settings.ledBrightness === undefined) return; + this.brightness.value = settings.ledBrightness; + this.value.value = settings.ledBrightness; + } + + async setLed() { + this.querySelector('#out').textContent = await window.App.req('POST', '/api/led', {brightness: +this.brightness.value}); + } +} + +customElements.define('led-card', LedCard); diff --git a/data/www/components/status-card.js b/data/www/components/status-card.js new file mode 100644 index 0000000..59ce631 --- /dev/null +++ b/data/www/components/status-card.js @@ -0,0 +1,26 @@ +class StatusCard extends HTMLElement { + connectedCallback() { + this.innerHTML = ` + +
+

Status

+ + +

+      
`; + this.querySelector('#ping').addEventListener('click', () => this.call('GET', '/api/ping')); + this.querySelector('#me').addEventListener('click', () => this.loadMe()); + } + + async call(method, url) { + this.querySelector('#out').textContent = await window.App.req(method, url); + } + + async loadMe() { + await this.call('GET', '/api/me'); + } +} + +customElements.define('status-card', StatusCard); diff --git a/data/www/components/uptime-card.js b/data/www/components/uptime-card.js new file mode 100644 index 0000000..8cda3a8 --- /dev/null +++ b/data/www/components/uptime-card.js @@ -0,0 +1,59 @@ +class UptimeCard extends HTMLElement { + constructor() { + super(); + this.source = null; + } + + connectedCallback() { + this.innerHTML = ` + +
+

Live uptime

+
Server-Sent Events
+
--s
+
Disconnected
+
`; + window.addEventListener('app:login', event => this.start(event.detail.token)); + window.addEventListener('beforeunload', () => this.stop()); + if (window.App && window.App.token()) this.start(window.App.token()); + } + + disconnectedCallback() { + this.stop(); + } + + setState(text, connected) { + this.querySelector('#state').textContent = text; + this.querySelector('#dot').classList.toggle('on', !!connected); + } + + start(token) { + this.stop(); + if (!token || !window.EventSource) { + this.setState('Unavailable', false); + return; + } + this.setState('Connecting', false); + this.source = new EventSource('/api/uptime/events?token=' + encodeURIComponent(token)); + this.source.addEventListener('open', () => this.setState('Connected', true)); + this.source.addEventListener('uptime', event => { + try { + const json = JSON.parse(event.data); + this.querySelector('#seconds').textContent = json.uptimeSeconds; + this.setState('Connected', true); + } catch (error) { + this.setState('Invalid event', false); + } + }); + this.source.addEventListener('error', () => this.setState('Reconnecting', false)); + } + + stop() { + if (this.source) this.source.close(); + this.source = null; + } +} + +customElements.define('uptime-card', UptimeCard); diff --git a/data/www/css/app.css b/data/www/css/app.css new file mode 100644 index 0000000..7ab6940 --- /dev/null +++ b/data/www/css/app.css @@ -0,0 +1,26 @@ +:root{font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#202124;background:#f5f7f8} +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} +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} +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!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;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} +.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} +.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} diff --git a/data/www/css/setup.css b/data/www/css/setup.css new file mode 100644 index 0000000..830e05d --- /dev/null +++ b/data/www/css/setup.css @@ -0,0 +1,7 @@ +:root{font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#202124;background:#f5f7f8} +body{margin:0}.top{background:#263238;color:white;padding:14px 18px;font-weight:650} +main{max-width:520px;margin:0 auto;padding:18px}.box{background:white;border:1px solid #d8dee2;border-radius:8px;padding:14px} +label{display:block;font-size:13px;margin:10px 0 4px}input,button{font:inherit} +input{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} +pre{white-space:pre-wrap;background:#111;color:#d7ffd7;padding:10px;border-radius:6px;max-height:220px;overflow:auto} diff --git a/data/www/js/app.js b/data/www/js/app.js new file mode 100644 index 0000000..4711408 --- /dev/null +++ b/data/www/js/app.js @@ -0,0 +1,599 @@ +let token = ''; +let roles = []; +let users = []; +let apis = []; +let selectedUser = ''; +let selectedRole = ''; +let selectedApi = -1; +let currentFilePath = '/'; +let filesLoaded = false; +let busyCount = 0; + +const el = id => document.getElementById(id); + +function setGlobalBusy(on) { + busyCount += on ? 1 : -1; + if (busyCount < 0) busyCount = 0; + el('busyChip').classList.toggle('hidden', busyCount == 0); +} + +async function tracked(fn) { + setGlobalBusy(true); + try { + return await fn(); + } finally { + setGlobalBusy(false); + } +} + +async function apiFetch(url, options) { + return await tracked(() => fetch(url, options)); +} + +async function req(method, url, body) { + const options = {method, headers: {'Content-Type': 'application/json'}}; + if (token) options.headers.Authorization = 'Bearer ' + token; + if (body !== undefined && method != 'GET') options.body = JSON.stringify(body); + const response = await apiFetch(url, options); + return await response.text(); +} + +window.App = { + apiFetch, + req, + token: () => token +}; + +function showTab(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)); + if (id == 'network') loadSettings(); + if (id == 'ops' && !filesLoaded) loadFiles('/'); +} + +function showAccessTab(id) { + document.querySelectorAll('.subtab').forEach(button => button.classList.toggle('active', button.dataset.subtab == id)); + document.querySelectorAll('.subpanel').forEach(panel => panel.classList.toggle('active', panel.id == id)); + if (id == 'users') showUserOverview(); + if (id == 'roles') showRoleOverview(); + if (id == 'apis') showApiOverview(); +} + +async function doLogin() { + const text = await req('POST', '/api/login', {username: el('user').value, password: el('pass').value}); + el('loginOut').textContent = text; + const json = JSON.parse(text); + if (!json.success) return; + token = json.token; + el('loginPanel').classList.add('hidden'); + el('app').classList.remove('hidden'); + window.dispatchEvent(new CustomEvent('app:login', {detail: {token}})); + loadSettings(); + loadAccess(); + loadFiles('/'); +} + +function esc(value) { + return String(value).replace(/[&<>"']/g, char => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char])); +} + +function roleNames() { + return roles.map(role => role.name); +} + +function userHasRole(user, role) { + return (user.roles || '').split('|').includes(role); +} + +function roleChecks(id, selected) { + el(id).innerHTML = roleNames().map(role => '').join(''); +} + +function selectedRoles() { + return [...el('userRoles').querySelectorAll('input:checked')].map(input => input.value); +} + +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((role, index) => '').join(''); +} + +function renderRoleUsage(role) { + const roleUsers = users.filter(user => userHasRole(user, role.name)).sort((a, b) => a.username.localeCompare(b.username)); + const roleApis = apis.filter(api => api.role == role.name).sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); + const usersHtml = roleUsers.length + ? roleUsers.map(user => '
' + esc(user.username) + '' + (user.active ? 'Active' : 'Inactive') + '
').join('') + : '
No users
'; + const apisHtml = roleApis.length + ? roleApis.map(api => '
' + esc(api.method) + '' + esc(api.path) + '
').join('') + : '
No API endpoints
'; + el('roleUsage').innerHTML = '

Users

' + usersHtml + '

API endpoints

' + apisHtml + '
'; +} + +async function loadRoles() { + const json = JSON.parse(await req('GET', '/api/roles')); + if (!json.success) return; + roles = json.roles; + renderRoles(); + roleOptions(); + if (selectedUser && !el('userDetail').classList.contains('hidden')) 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('roleUsage').innerHTML = ''; + el('roleSave').classList.remove('hidden'); + el('roleDelete').classList.add('hidden'); + el('roleMsg').textContent = ''; +} + +function selectRole(index) { + const role = roles[index]; + if (!role) return; + selectedRole = role.name; + el('rolesOverview').classList.add('hidden'); + el('roleDetail').classList.remove('hidden'); + el('roleTitle').textContent = role.name; + el('roleName').value = role.name; + el('roleName').readOnly = true; + el('roleMeta').innerHTML = '' + (role.system ? 'System role' : 'Custom role') + ''; + renderRoleUsage(role); + el('roleSave').classList.add('hidden'); + el('roleDelete').classList.toggle('hidden', role.system); + el('roleMsg').textContent = ''; +} + +async function addRole() { + const text = await req('POST', '/api/roles', {role: el('roleName').value}); + el('roleMsg').textContent = text; + await loadAccess(); + showRoleOverview(); +} + +async function deleteSelectedRole() { + if (!selectedRole) return; + const text = await req('DELETE', '/api/roles', {role: selectedRole}); + el('roleMsg').textContent = text; + selectedRole = ''; + await loadAccess(); + showRoleOverview(); +} + +function renderUsers() { + el('userList').innerHTML = users.map(user => '').join(''); +} + +async function loadUsers() { + const json = JSON.parse(await req('GET', '/api/users')); + if (!json.success) return; + users = json.users; + renderUsers(); + if (selectedUser && !el('userDetail').classList.contains('hidden')) 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; + const user = users.find(item => item.username == name); + if (!user) 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 = user.active; + roleChecks('userRoles', (user.roles || '').split('|')); + el('usersOut').textContent = ''; +} + +async function saveSelectedUser() { + const text = await req('POST', '/api/users', {username: el('userName').value, password: el('userPass').value, active: el('userActive').checked, roles: selectedRoles()}); + el('usersOut').textContent = text; + selectedUser = el('userName').value; + await loadUsers(); + showUserOverview(); +} + +function roleOptions() { + const options = '' + roleNames().map(role => '').join(''); + el('apiRole').innerHTML = options; +} + +function renderApis() { + const groups = new Map(); + apis.forEach((api, index) => { + if (!groups.has(api.path)) groups.set(api.path, []); + groups.get(api.path).push({...api, index}); + }); + const methodOrder = {GET: 1, POST: 2, PUT: 3, PATCH: 4, DELETE: 5}; + el('apiList').innerHTML = [...groups.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([path, items]) => { + items.sort((a, b) => (methodOrder[a.method] || 99) - (methodOrder[b.method] || 99) || a.method.localeCompare(b.method)); + const verbs = items.map(api => '').join(''); + return '
' + esc(path) + '
' + verbs + '
'; + }) + .join(''); +} + +async function loadApis() { + const json = JSON.parse(await req('GET', '/api/apis')); + if (!json.success) return; + apis = json.apis; + renderApis(); + if (selectedApi >= 0 && !el('apiDetail').classList.contains('hidden')) selectApi(selectedApi); +} + +function selectApi(index) { + selectedApi = index; + const api = apis[index]; + if (!api) return; + el('apisOverview').classList.add('hidden'); + el('apiDetail').classList.remove('hidden'); + el('apiTitle').textContent = api.method + ' ' + api.path; + el('apiPath').value = api.path; + el('apiMethod').value = api.method; + roleOptions(); + el('apiRole').value = api.role; + el('apisOut').textContent = ''; + el('apiTestOut').textContent = ''; +} + +async function saveSelectedApi() { + if (selectedApi < 0) return; + const text = await req('POST', '/api/apis', {path: el('apiPath').value, method: el('apiMethod').value, role: el('apiRole').value}); + el('apisOut').textContent = text; + await loadApis(); +} + +function clearApiTestBody() { + el('apiTestBody').value = ''; +} + +async function testSelectedApi() { + if (selectedApi < 0) return; + const api = apis[selectedApi]; + const body = el('apiTestBody').value.trim(); + let payload; + if (body) { + try { + payload = JSON.parse(body); + } catch (error) { + el('apiTestOut').textContent = 'Invalid JSON body'; + return; + } + } + el('apiTestOut').textContent = await req(api.method, api.path, payload); +} + +async function loadAccess() { + await loadRoles(); + await loadUsers(); + await loadApis(); +} + +function fileName(path) { + const index = String(path).lastIndexOf('/'); + return index >= 0 ? path.substring(index + 1) : path; +} + +function fileIcon(file) { + if (file.directory) return ''; + return ''; +} + +function fileSize(file) { + if (file.directory) return 'Folder'; + return file.size + ' B'; +} + +function parentPath(path) { + if (!path || path == '/') return '/'; + const end = path.lastIndexOf('/'); + return end <= 0 ? '/' : path.substring(0, end); +} + +function renderFiles(files) { + el('fileList').classList.add('fileList'); + files.sort((a, b) => Number(b.directory) - Number(a.directory) || a.path.localeCompare(b.path)); + const rows = currentFilePath == '/' ? [] : [{name: '..', path: parentPath(currentFilePath), directory: true, parent: true}]; + rows.push(...files); + el('fileList').innerHTML = rows.map(file => '
' + fileIcon(file) + '
' + esc(file.name || file.path) + '
' + (file.parent ? '' : fileSize(file)) + '
').join(''); +} + +async function loadFiles(path = currentFilePath) { + const text = await req('GET', '/api/files?path=' + encodeURIComponent(path)); + el('filesOut').textContent = ''; + try { + const json = JSON.parse(text); + if (json.success) { + currentFilePath = json.path || path || '/'; + filesLoaded = true; + el('filesPath').textContent = currentFilePath; + renderFiles(json.files || []); + if (!(json.files || []).length) el('filesOut').textContent = 'No files found'; + } else { + el('filesOut').textContent = text; + } + } catch (error) { + el('filesOut').textContent = text; + } +} + +async function downloadFile(path) { + const response = await apiFetch('/api/files/download?path=' + encodeURIComponent(path), {headers: {Authorization: 'Bearer ' + token}}); + if (!response.ok) { + el('filesOut').textContent = await response.text(); + return; + } + const blob = await response.blob(); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = fileName(path) || 'download'; + document.body.appendChild(link); + link.click(); + URL.revokeObjectURL(link.href); + link.remove(); +} + +async function downloadConfig() { + const response = await apiFetch('/api/config/export', {headers: {Authorization: 'Bearer ' + token}}); + if (!response.ok) { + el('maintOut').textContent = await response.text(); + return; + } + const blob = await response.blob(); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = 'config-backup.json'; + document.body.appendChild(link); + link.click(); + URL.revokeObjectURL(link.href); + link.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 () => { + const body = await el('configFile').files[0].text(); + const response = await fetch('/api/config/import', {method: 'POST', headers: {'Content-Type': 'application/json', Authorization: 'Bearer ' + token}, body}); + el('maintOut').textContent = await response.text(); + if (response.ok) { + loadSettings(); + loadAccess(); + } + }); + } catch (error) { + el('maintOut').textContent = 'Restore failed: ' + error.message; + } finally { + setBusy('restoreConfigBtn', false, 'Restore config'); + } +} + +async function loadCert() { + const text = await req('GET', '/api/cert'); + try { + const json = JSON.parse(text); + el('certOut').textContent = json.success ? (json.certificate || 'No certificate stored') : text; + } catch (error) { + el('certOut').textContent = text; + } +} + +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 () => { + const certificate = await el('certFile').files[0].text(); + el('certOut').textContent = await req('POST', '/api/cert', {certificate}); + }); + } catch (error) { + el('certOut').textContent = 'Certificate save failed: ' + error.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() { + const text = await req('GET', '/api/logs'); + try { + const json = JSON.parse(text); + el('logsOut').textContent = json.success ? (json.logs || '') : text; + } catch (error) { + el('logsOut').textContent = text; + } +} + +async function clearLogs() { + const text = await req('POST', '/api/logs/clear'); + el('logsOut').textContent = text; + try { + if (JSON.parse(text).success) el('logsOut').textContent = 'Logs cleared'; + } catch (error) {} +} + +function syncNetworkMode() { + const staticMode = el('netDhcp').value == 'false'; + ['netIp', 'netGateway', 'netSubnet', 'netDns1', 'netDns2'].forEach(id => el(id).disabled = !staticMode); +} + +async function loadSettings() { + const text = await req('GET', '/api/settings'); + el('settingsOut').textContent = text; + const json = JSON.parse(text); + if (!json.success) return; + const settings = json.settings; + const projectName = settings.projectName || 'ESP32-C3'; + el('appTitle').textContent = projectName + ' Admin'; + document.title = projectName + ' Admin'; + el('logLevel').value = settings.logLevel; + el('logMax').value = settings.maxLogBytes; + el('upd').value = settings.updateUrl; + el('netHost').value = settings.hostname || ''; + el('netHost').placeholder = settings.defaultHostname || 'Default host name'; + el('netDhcp').value = String(settings.dhcp !== false); + el('netIp').value = settings.ip || ''; + el('netGateway').value = settings.gateway || ''; + el('netSubnet').value = settings.subnet || ''; + el('netDns1').value = settings.dns1 || ''; + el('netDns2').value = settings.dns2 || ''; + el('netCurrent').textContent = 'Current IP: ' + (settings.currentIp || 'not connected'); + syncNetworkMode(); + window.dispatchEvent(new CustomEvent('app:settings-loaded', {detail: {settings}})); +} + +async function saveSettings() { + el('settingsOut').textContent = await req('POST', '/api/settings', {logLevel: +el('logLevel').value, maxLogBytes: +el('logMax').value, updateUrl: el('upd').value}); +} + +async function saveNetwork() { + setBusy('networkSaveBtn', true, 'Saving'); + 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 text = await req('POST', '/api/settings', body); + el('networkOut').textContent = text + '\nNetwork changes apply on the next WiFi reconnect or reboot.'; + if (JSON.parse(text).success) await loadSettings(); + } catch (error) { + el('networkOut').textContent = 'Network save failed: ' + error.message; + } finally { + setBusy('networkSaveBtn', false, 'Save network'); + } +} + +function setBusy(id, busy, label) { + const button = el(id); + if (!button) return; + button.disabled = busy; + button.innerHTML = busy ? '' + 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 { + const formData = new FormData(); + formData.append('package', el('pkg').files[0]); + const response = await apiFetch('/api/update', {method: 'POST', headers: {Authorization: 'Bearer ' + token}, body: formData}); + el('fwOut').textContent = await response.text(); + } catch (error) { + el('fwOut').textContent = 'Upload failed: ' + error.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'); +} + +function bindEvents() { + el('loginBtn').addEventListener('click', doLogin); + 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))); + el('usersRefresh').addEventListener('click', loadUsers); + el('userNew').addEventListener('click', newUserRecord); + el('userBack').addEventListener('click', showUserOverview); + el('userSave').addEventListener('click', saveSelectedUser); + el('rolesRefresh').addEventListener('click', loadRoles); + el('roleNew').addEventListener('click', newRoleRecord); + el('roleBack').addEventListener('click', showRoleOverview); + el('roleSave').addEventListener('click', addRole); + el('roleDelete').addEventListener('click', deleteSelectedRole); + el('apiBack').addEventListener('click', showApiOverview); + el('apiSave').addEventListener('click', saveSelectedApi); + el('apiTest').addEventListener('click', testSelectedApi); + el('apiTestClear').addEventListener('click', clearApiTestBody); + el('rolesOut').addEventListener('click', event => { + const button = event.target.closest('[data-role-index]'); + if (button) selectRole(+button.dataset.roleIndex); + }); + el('userList').addEventListener('click', event => { + const button = event.target.closest('[data-user]'); + if (button) selectUser(button.dataset.user); + }); + el('apiList').addEventListener('click', event => { + const button = event.target.closest('[data-api-index]'); + if (button) selectApi(+button.dataset.apiIndex); + }); + el('netDhcp').addEventListener('change', syncNetworkMode); + el('networkSaveBtn').addEventListener('click', saveNetwork); + el('certSaveBtn').addEventListener('click', saveCertFile); + el('certLoad').addEventListener('click', loadCert); + el('settingsSave').addEventListener('click', saveSettings); + el('uploadPkgBtn').addEventListener('click', uploadPkg); + el('otaCheck').addEventListener('click', checkOta); + el('otaRun').addEventListener('click', runOta); + el('configDownload').addEventListener('click', downloadConfig); + el('restoreConfigBtn').addEventListener('click', restoreConfig); + el('factoryReset').addEventListener('click', factoryResetDevice); + el('fileList').addEventListener('click', event => { + const button = event.target.closest('[data-download]'); + if (button) { + downloadFile(button.dataset.download); + return; + } + const directory = event.target.closest('.fileRow[data-path]'); + if (directory) loadFiles(directory.dataset.path); + }); + el('logsLoad').addEventListener('click', loadLogs); + el('logsClear').addEventListener('click', clearLogs); +} + +bindEvents(); diff --git a/data/www/js/setup.js b/data/www/js/setup.js new file mode 100644 index 0000000..2ed17e5 --- /dev/null +++ b/data/www/js/setup.js @@ -0,0 +1,20 @@ +const ssid = document.getElementById('ssid'); +const wifiPass = document.getElementById('wifiPass'); +const admin = document.getElementById('admin'); +const adminPass = document.getElementById('adminPass'); +const out = document.getElementById('out'); + +async function scan() { + const response = await fetch('/api/wifi/scan'); + const json = await response.json(); + ssid.innerHTML = json.networks.map(network => ``).join(''); +} + +async function save() { + const body = {ssid: ssid.value, wifiPass: wifiPass.value, admin: admin.value, adminPass: adminPass.value}; + const response = await fetch('/api/setup', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}); + out.textContent = await response.text(); +} + +document.getElementById('saveSetup').addEventListener('click', save); +scan(); diff --git a/data/www/setup.html b/data/www/setup.html new file mode 100644 index 0000000..f869408 --- /dev/null +++ b/data/www/setup.html @@ -0,0 +1,27 @@ + + + + + ESP32-C3 first-run setup + + + + +
ESP32-C3 first-run setup
+
+
+

Network and admin setup

+ + + + + + + + + +

+    
+
+ + diff --git a/dist/TSL-Embedded_2026-06-28T11-50-01-05-00.tslpkg b/dist/TSL-Embedded_2026-06-28T11-50-01-05-00.tslpkg new file mode 100644 index 0000000..bbd06d4 Binary files /dev/null and b/dist/TSL-Embedded_2026-06-28T11-50-01-05-00.tslpkg differ diff --git a/dist/TSL-Embedded_2026-06-28T11-57-14-05-00.tslpkg b/dist/TSL-Embedded_2026-06-28T11-57-14-05-00.tslpkg new file mode 100644 index 0000000..9abe8bb Binary files /dev/null and b/dist/TSL-Embedded_2026-06-28T11-57-14-05-00.tslpkg differ diff --git a/include/app.h b/include/app.h index 57fbec8..e9945e6 100644 --- a/include/app.h +++ b/include/app.h @@ -34,6 +34,9 @@ extern const byte DNS_PORT; extern const IPAddress SETUP_AP_IP; extern const IPAddress SETUP_AP_GATEWAY; extern const IPAddress SETUP_AP_SUBNET; +extern const char *WWW_ROOT; +extern const char *LOG_DIR; +extern const char *LOG_FILE_PATH; enum LogLevel : uint8_t { LOG_ERROR = 0, diff --git a/include/custom_api.h b/include/custom_api.h index dbd8c34..8dd766e 100644 --- a/include/custom_api.h +++ b/include/custom_api.h @@ -3,3 +3,5 @@ void handlePing(); void handleAdd(); void handleLed(); +void handleUptime(); +void handleUptimeEvents(); diff --git a/scripts/__pycache__/build_ota_package.cpython-313.pyc b/scripts/__pycache__/build_ota_package.cpython-313.pyc new file mode 100644 index 0000000..07a01f7 Binary files /dev/null and b/scripts/__pycache__/build_ota_package.cpython-313.pyc differ diff --git a/scripts/__pycache__/build_upload_device.cpython-313.pyc b/scripts/__pycache__/build_upload_device.cpython-313.pyc new file mode 100644 index 0000000..82a1a95 Binary files /dev/null and b/scripts/__pycache__/build_upload_device.cpython-313.pyc differ diff --git a/scripts/__pycache__/upload_device.cpython-313.pyc b/scripts/__pycache__/upload_device.cpython-313.pyc new file mode 100644 index 0000000..890d419 Binary files /dev/null and b/scripts/__pycache__/upload_device.cpython-313.pyc differ diff --git a/scripts/build_ota_package.py b/scripts/build_ota_package.py new file mode 100644 index 0000000..61992b4 --- /dev/null +++ b/scripts/build_ota_package.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +import argparse +import re +import shutil +import subprocess +import sys +from datetime import datetime +from pathlib import Path + + +DEFAULT_ENV = "seeed_xiao_esp32c3" + + +def find_pio(): + pio = shutil.which("pio") + if pio: + return pio + candidate = Path.home() / ".platformio" / "penv" / "Scripts" / "pio.exe" + if candidate.exists(): + return str(candidate) + candidate = Path.home() / ".platformio" / "penv" / "bin" / "pio" + if candidate.exists(): + return str(candidate) + return "pio" + + +def run(command, cwd): + print("+ " + " ".join(command), flush=True) + subprocess.run(command, check=True, cwd=cwd) + + +def project_name(root): + app_h = root / "include" / "app.h" + if app_h.exists(): + match = re.search(r'^\s*#define\s+PROJECT_NAME\s+"([^"]+)"', app_h.read_text(encoding="utf-8"), re.MULTILINE) + if match: + return match.group(1) + return root.name + + +def safe_filename_part(value): + value = value.strip().replace(" ", "-") + return re.sub(r"[^A-Za-z0-9._+-]+", "-", value).strip("-") or "project" + + +def package_timestamp(): + return datetime.now().astimezone().isoformat(timespec="seconds").replace(":", "-") + + +def main(): + parser = argparse.ArgumentParser(description="Build firmware and LittleFS, then create a timestamped OTA update package.") + parser.add_argument("-e", "--environment", default=DEFAULT_ENV, help="PlatformIO environment to build.") + parser.add_argument("-o", "--output-dir", default="dist", help="Directory for the generated OTA package.") + parser.add_argument("--pio", default=find_pio(), help="Path to the PlatformIO executable.") + args = parser.parse_args() + + root = Path(__file__).resolve().parent.parent + build_dir = root / ".pio" / "build" / args.environment + firmware = build_dir / "firmware.bin" + filesystem = build_dir / "littlefs.bin" + output_dir = (root / args.output_dir).resolve() + output = output_dir / f"{safe_filename_part(project_name(root))}_{package_timestamp()}.tslpkg" + + try: + run([args.pio, "run", "-e", args.environment, "-t", "buildfs"], root) + run([args.pio, "run", "-e", args.environment], root) + run([ + sys.executable, + str(Path(__file__).resolve().parent / "create_update_package.py"), + "--firmware", + str(firmware), + "--filesystem", + str(filesystem), + "--output", + str(output), + ], root) + except subprocess.CalledProcessError as error: + sys.exit(error.returncode) + + print(f"OTA update file: {output.resolve()}") + + +if __name__ == "__main__": + main() diff --git a/scripts/build_upload_device.py b/scripts/build_upload_device.py new file mode 100644 index 0000000..d513685 --- /dev/null +++ b/scripts/build_upload_device.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + + +DEFAULT_ENV = "seeed_xiao_esp32c3" + + +def find_pio(): + pio = shutil.which("pio") + if pio: + return pio + candidate = Path.home() / ".platformio" / "penv" / "Scripts" / "pio.exe" + if candidate.exists(): + return str(candidate) + candidate = Path.home() / ".platformio" / "penv" / "bin" / "pio" + if candidate.exists(): + return str(candidate) + return "pio" + + +def run(command, cwd): + print("+ " + " ".join(command), flush=True) + subprocess.run(command, check=True, cwd=cwd) + + +def pio_command(args, pio, environment, upload_port): + command = [pio, "run", "-e", environment] + args + if upload_port: + command += ["--upload-port", upload_port] + return command + + +def main(): + parser = argparse.ArgumentParser(description="Build firmware and LittleFS, then upload both to a connected device.") + parser.add_argument("-e", "--environment", default=DEFAULT_ENV, help="PlatformIO environment to build and upload.") + parser.add_argument("-p", "--upload-port", help="Upload port, for example COM3.") + parser.add_argument("--pio", default=find_pio(), help="Path to the PlatformIO executable.") + args = parser.parse_args() + + root = Path(__file__).resolve().parent.parent + try: + run(pio_command(["-t", "buildfs"], args.pio, args.environment, None), root) + run(pio_command([], args.pio, args.environment, None), root) + run(pio_command(["-t", "uploadfs"], args.pio, args.environment, args.upload_port), root) + run(pio_command(["-t", "upload"], args.pio, args.environment, args.upload_port), root) + except subprocess.CalledProcessError as error: + sys.exit(error.returncode) + + print(f"Uploaded filesystem and firmware for {args.environment} from {root}") + + +if __name__ == "__main__": + main() diff --git a/src/config/api_definitions_custom.cpp b/src/config/api_definitions_custom.cpp index 2a25f60..b93fe57 100644 --- a/src/config/api_definitions_custom.cpp +++ b/src/config/api_definitions_custom.cpp @@ -5,6 +5,8 @@ ApiDef customApiDefs[] = { {"/api/ping", "GET", "", true, handlePing, nullptr}, {"/api/add", "POST", "", true, handleAdd, nullptr}, {"/api/led", "POST", "", true, handleLed, nullptr}, + {"/api/uptime", "GET", "WebUIConnect", false, handleUptime, nullptr}, + {"/api/uptime/events", "GET", "WebUIConnect", false, handleUptimeEvents, nullptr}, }; const size_t CUSTOM_API_DEF_COUNT = sizeof(customApiDefs) / sizeof(customApiDefs[0]); diff --git a/src/core/device.cpp b/src/core/device.cpp index 6b0e56a..b6855ea 100644 --- a/src/core/device.cpp +++ b/src/core/device.cpp @@ -12,7 +12,7 @@ void applyLed() { void factoryReset() { prefs.clear(); - LittleFS.remove("/logs.txt"); + LittleFS.remove(LOG_FILE_PATH); appendLog(LOG_SECURITY_AUDIT, "factory reset requested"); } diff --git a/src/core/logging.cpp b/src/core/logging.cpp index 86efa44..ecd3a44 100644 --- a/src/core/logging.cpp +++ b/src/core/logging.cpp @@ -16,12 +16,13 @@ void appendLog(LogLevel level, const String &message) { if (level > currentLogLevel) return; const char *names[] = {"ERROR", "WARN", "SecurityAudit", "INFO", "DEBUG"}; String line = timestamp() + " " + names[level] + " " + message + "\n"; - File f = LittleFS.open("/logs.txt", "a"); + if (!LittleFS.exists(LOG_DIR)) LittleFS.mkdir(LOG_DIR); + File f = LittleFS.open(LOG_FILE_PATH, "a"); if (f) { f.print(line); f.close(); } - f = LittleFS.open("/logs.txt", "r"); + f = LittleFS.open(LOG_FILE_PATH, "r"); if (!f) return; size_t size = f.size(); if (size <= maxLogBytes) { @@ -34,7 +35,7 @@ void appendLog(LogLevel level, const String &message) { f.close(); int newline = tail.indexOf('\n'); if (newline >= 0) tail = tail.substring(newline + 1); - f = LittleFS.open("/logs.txt", "w"); + f = LittleFS.open(LOG_FILE_PATH, "w"); if (f) { f.print(tail); f.close(); diff --git a/src/core/state.cpp b/src/core/state.cpp index 2dd3346..aeda008 100644 --- a/src/core/state.cpp +++ b/src/core/state.cpp @@ -14,6 +14,9 @@ const byte DNS_PORT = 53; const IPAddress SETUP_AP_IP(192, 168, 1, 1); const IPAddress SETUP_AP_GATEWAY(192, 168, 1, 1); const IPAddress SETUP_AP_SUBNET(255, 255, 255, 0); +const char *WWW_ROOT = "/www"; +const char *LOG_DIR = "/log"; +const char *LOG_FILE_PATH = "/log/logs.txt"; WebServer server(80); DNSServer dnsServer; diff --git a/src/handlers/handlers_admin.cpp b/src/handlers/handlers_admin.cpp index 375dd2e..c49f223 100644 --- a/src/handlers/handlers_admin.cpp +++ b/src/handlers/handlers_admin.cpp @@ -26,7 +26,7 @@ static bool validIpValue(const String &value) { void handleSettingsGet() { 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) + "}"; + 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) + "}"; sendJson(200, jsonOk(out)); } @@ -83,7 +83,7 @@ void handleSettingsPost() { void handleLogsGet() { if (!authorize()) return; - File f = LittleFS.open("/logs.txt", "r"); + File f = LittleFS.open(LOG_FILE_PATH, "r"); String logs = f ? f.readString() : ""; if (f) f.close(); sendJson(200, jsonOk("\"logs\":\"" + jsonEscape(logs) + "\"")); @@ -91,37 +91,41 @@ void handleLogsGet() { void handleLogsClear() { if (!authorize()) return; - LittleFS.remove("/logs.txt"); + LittleFS.remove(LOG_FILE_PATH); appendLog(LOG_SECURITY_AUDIT, "logs cleared"); sendJson(200, jsonOk()); } static bool validFsPath(const String &path) { - return path.length() && path.startsWith("/") && path.indexOf("..") < 0; + return path.length() && path.startsWith("/") && path.indexOf("..") < 0 && path.indexOf('\\') < 0; } static String fsPathArg(const String &fallback = "/") { String path = server.arg("path"); if (!path.length()) path = fallback; + if (!path.startsWith("/")) path = "/" + path; + while (path.length() > 1 && path.endsWith("/")) path.remove(path.length() - 1); return path; } -static void appendFileJson(String &out, bool &first, File &file) { +static String joinedFsPath(const String &parent, const String &name) { + if (name.startsWith("/")) return name; + if (parent == "/") return "/" + name; + return parent + "/" + name; +} + +static void appendFsEntryJson(String &out, bool &first, const String &parent, File &file) { String path = file.name(); - if (!path.startsWith("/")) path = "/" + path; + path = joinedFsPath(parent, path); if (!first) out += ","; - out += "{\"name\":\"" + jsonEscape(path.substring(path.lastIndexOf('/') + 1)) + "\",\"path\":\"" + jsonEscape(path) + "\",\"size\":" + String(file.size()) + "}"; + out += "{\"name\":\"" + jsonEscape(path.substring(path.lastIndexOf('/') + 1)) + "\",\"path\":\"" + jsonEscape(path) + "\",\"size\":" + String(file.isDirectory() ? 0 : file.size()) + ",\"directory\":" + String(file.isDirectory() ? "true" : "false") + "}"; first = false; } -static void appendFilesJson(String &out, bool &first, File &dir) { +static void appendDirectoryJson(String &out, bool &first, const String &path, File &dir) { File file = dir.openNextFile(); while (file) { - if (file.isDirectory()) { - appendFilesJson(out, first, file); - } else { - appendFileJson(out, first, file); - } + appendFsEntryJson(out, first, path, file); file.close(); file = dir.openNextFile(); } @@ -129,13 +133,20 @@ static void appendFilesJson(String &out, bool &first, File &dir) { void handleFilesList() { if (!authorize()) return; - File root = LittleFS.open("/", "r"); - if (!root) return sendJson(500, jsonError("Filesystem root not available")); + String path = fsPathArg("/"); + if (!validFsPath(path)) return sendJson(400, jsonError("Invalid path")); + File dir = LittleFS.open(path, "r"); + if (!dir && path != "/") dir = LittleFS.open(path + "/", "r"); + if (!dir) return sendJson(404, jsonError("Directory not found")); + if (!dir.isDirectory()) { + dir.close(); + return sendJson(400, jsonError("Path is not a directory")); + } - String out = "\"files\":["; + String out = "\"path\":\"" + jsonEscape(path) + "\",\"files\":["; bool first = true; - appendFilesJson(out, first, root); - root.close(); + appendDirectoryJson(out, first, path, dir); + dir.close(); out += "]"; sendJson(200, jsonOk(out)); } diff --git a/src/handlers/handlers_custom_api.cpp b/src/handlers/handlers_custom_api.cpp index 0cc2893..7c87e7c 100644 --- a/src/handlers/handlers_custom_api.cpp +++ b/src/handlers/handlers_custom_api.cpp @@ -1,6 +1,10 @@ #include "app.h" #include "custom_api.h" +static String uptimeJsonFields() { + return "\"uptimeSeconds\":" + String(millis() / 1000); +} + void handlePing() { if (!authorize()) return; sendJson(200, jsonOk("\"uptimeMs\":" + String(millis()) + ",\"version\":\"" + APP_VERSION + "\",\"name\":\"" + jsonEscape(deviceName()) + "\",\"ip\":\"" + WiFi.localIP().toString() + "\"")); @@ -22,3 +26,18 @@ void handleLed() { appendLog(LOG_INFO, "LED brightness set to " + String(ledBrightness)); sendJson(200, jsonOk("\"brightness\":" + String(ledBrightness))); } + +void handleUptime() { + if (!authorize()) return; + sendJson(200, jsonOk(uptimeJsonFields())); +} + +void handleUptimeEvents() { + if (!authorize()) return; + String payload = "retry: 1000\n"; + payload += "event: uptime\n"; + payload += "data: {" + uptimeJsonFields() + "}\n\n"; + server.sendHeader("Cache-Control", "no-store"); + server.sendHeader("Connection", "close"); + server.send(200, "text/event-stream", payload); +} diff --git a/src/web/ui.cpp b/src/web/ui.cpp index 7eb8ce1..dd95acd 100644 --- a/src/web/ui.cpp +++ b/src/web/ui.cpp @@ -2,25 +2,46 @@ #include -static bool serveHtmlFile(const String &path) { - if (!path.startsWith("/") || path.indexOf("..") >= 0 || !path.endsWith(".html")) return false; - File file = LittleFS.open(path, "r"); - if (!file) return false; - server.streamFile(file, "text/html"); +static bool validPublicPath(const String &path) { + return path.startsWith("/") && path.indexOf("..") < 0 && path.indexOf('\\') < 0; +} + +static String contentTypeFor(const String &path) { + if (path.endsWith(".html")) return "text/html"; + if (path.endsWith(".css")) return "text/css"; + if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript"; + if (path.endsWith(".json")) return "application/json"; + if (path.endsWith(".svg")) return "image/svg+xml"; + if (path.endsWith(".png")) return "image/png"; + if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg"; + if (path.endsWith(".ico")) return "image/x-icon"; + return "application/octet-stream"; +} + +static bool serveWwwFile(const String &path) { + if (!validPublicPath(path)) return false; + String fsPath = String(WWW_ROOT) + path; + File file = LittleFS.open(fsPath, "r"); + if (!file || file.isDirectory()) { + if (file) file.close(); + return false; + } + server.sendHeader("Cache-Control", path.endsWith(".html") ? "no-store" : "max-age=3600"); + server.streamFile(file, contentTypeFor(path)); file.close(); return true; } void handleSetupPage() { - if (!serveHtmlFile("/setup.html")) { - sendJson(500, jsonError("Missing /setup.html in LittleFS")); + if (!serveWwwFile("/setup.html")) { + sendJson(500, jsonError("Missing /www/setup.html in LittleFS")); } } bool handleHtmlFileRequest() { String path = server.uri(); if (path == "/") path = setupMode ? "/setup.html" : "/admin.html"; - return serveHtmlFile(path); + return serveWwwFile(path); } void redirectToSetupPage() { @@ -38,11 +59,11 @@ void handleCaptiveProbe() { } void handleFavicon() { - server.send(204, "image/x-icon", ""); + if (!serveWwwFile("/favicon.ico")) server.send(204, "image/x-icon", ""); } void handleAdminPage() { - if (!serveHtmlFile("/admin.html")) { - sendJson(500, jsonError("Missing /admin.html in LittleFS")); + if (!serveWwwFile("/admin.html")) { + sendJson(500, jsonError("Missing /www/admin.html in LittleFS")); } }