Skip to content

Commit 3abdfe5

Browse files
fhirschmannclaude
andcommitted
feat: FTP auto-start on boot + configurable session-cookie lifetime
FTP: add an "auto-start on boot" toggle to the FTP tab (NVS "ftpEnable"), mirroring WebDAV - a one-shot start in Ftp_Cyclic fires the server up the first time WiFi is available after boot, so FTP no longer has to be enabled by hand each time. Security: the cookie lifetime is no longer hard-coded to 90 days. The Security tab now has a "stay logged in for N days" field plus an "unlimited" switch (stored as wwwCookieDays, 0 = unlimited -> ~10 years, since browsers cap cookies at ~400 days anyway). The /security handler takes cookieDays independently of the password (so the cookie form never touches the password), and GET /security reports it. Login and password-change responses build the Set-Cookie Max-Age from the setting. Locales (de/en/fr), Swagger (/security) and README updated. Built + clang-format clean; on-device verification still pending. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 96f3581 commit 3abdfe5

8 files changed

Lines changed: 162 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
110110
| **Password protection**: single password (no username), 90-day session cookie, brute-force lockout, logout entry; off in hotspot mode. Scripts/API clients authenticate with the password as an **API key** (`X-API-Key` header / `apikey` query param) | [`e74e712`](../../commit/e74e712) |
111111
| **One shared device password (Security tab)**: the password fields were pulled out of the WiFi/FTP/WebDAV tabs into a single **Security** sub-tab — one password now protects the web interface, FTP *and* WebDAV (set once, applies everywhere; applied live without a reboot). An **empty password is accepted** and disables protection for all three. The **hostname** moved to the top of the **General** tab (saved on its own; `/wificonfig` now does partial updates) and the navbar brand reads just **Leo Industries** | [`c6dfc67`](../../commit/c6dfc67) |
112112
| **FTP/WebDAV accept any username**: with one shared password the username is pointless, so both servers now authenticate by **password only — any username is accepted** (wrong password still rejected; empty device password = open). The username fields are gone from both tabs. FTP uses a **vendored, patched copy** of ESP-FTP-Server-Lib in [`lib/`](lib/ESP-FTP-Server-Lib) (password-only auth); WebDAV's self-contained server decodes the HTTP-Basic header and compares only the password | [`be96850`](../../commit/be96850) |
113+
| **FTP auto-start on boot**: a toggle in the FTP tab starts the FTP server automatically once WiFi is up after boot (same one-shot pattern as WebDAV), so it no longer has to be enabled by hand each time | [`PENDINGHASH4`](../../commit/PENDINGHASH4) |
114+
| **Configurable session-cookie lifetime**: the Security tab lets you set how long the web-interface login is remembered (in days) or choose **unlimited** (≈10 years; browsers cap cookies at ~400 days). Replaces the hard-coded 90-day cookie | [`PENDINGHASH4`](../../commit/PENDINGHASH4) |
113115
| **One-click OTA + version badge**: a Tools-tab button (also bindable command **186** / MQTT `firmware_update`) pulls the rolling `latest` GitHub release and flashes it over OTA; a navbar badge shows the running build and turns amber when an update is available (passive `/version` check) — click it to install | [`8527f5e`](../../commit/8527f5e) · [`b736abc`](../../commit/b736abc) |
114116
| **HTTP file sync**: pull audio files from a web server onto the SD card from a JSON manifest — additive, streamed in chunks straight to SD, background task with live progress + stop, abort-on-button, stall watchdog, auto-pauses playback and keeps the device awake mid-transfer. The manifest is streamed into the parser to halve peak RAM | [`ac24bbc`](../../commit/ac24bbc) · [`42d2c46`](../../commit/42d2c46) |
115117
| **WebDAV server**: mount the SD card as a network drive (`http://<ip>:81/`) to copy audio on/off it straight from Finder/Explorer — no FTP client needed. Self-contained server (OPTIONS/PROPFIND/GET/HEAD+ranges/PUT/DELETE/MKCOL/MOVE/COPY/LOCK) running in its own task pinned to core 0 so transfers never disturb the audio pipeline; optional HTTP-Basic credentials. Configure + auto-start on boot in its own **WebDAV** settings sub-tab; start/stop live from the Control tab, command **188** or MQTT `webdav` (Home Assistant switch included). With auto-start on, the share is announced over **Bonjour/mDNS** (`_webdav._tcp`, `path=/`) so it pops up by itself in the Finder/Explorer network sidebar — no manual "Connect to Server" needed (currently disabled — see `WEBDAV_ENABLE`) | [`9b2bee0`](../../commit/9b2bee0) |

‎REST-API.yaml‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -745,24 +745,29 @@ paths:
745745
/security:
746746
get:
747747
tags: [Fork]
748-
summary: Is web-interface password protection enabled?
748+
summary: Password-protection status and session-cookie lifetime.
749749
responses:
750750
"200":
751-
description: Whether a web password is set.
751+
description: Whether a web password is set, plus the cookie lifetime in days (0 = unlimited).
752752
content:
753753
application/json:
754754
schema:
755755
type: object
756756
properties:
757757
enabled:
758758
type: boolean
759+
cookieDays:
760+
type: integer
761+
description: Session-cookie lifetime in days; 0 means unlimited.
759762
post:
760763
tags: [Fork]
761-
summary: Set or clear the shared device password (web interface, FTP and WebDAV).
764+
summary: Set the shared device password and/or the session-cookie lifetime.
762765
description: >
763766
Sets one shared password that protects the web interface, FTP and WebDAV at once
764767
(applied live, no reboot). An empty password is accepted and disables protection
765-
for all three.
768+
for all three. Both fields are optional: send only "cookieDays" to change the
769+
session-cookie lifetime without touching the password. If "password" is omitted the
770+
password is left unchanged.
766771
requestBody:
767772
required: true
768773
content:
@@ -772,9 +777,12 @@ paths:
772777
properties:
773778
password:
774779
type: string
780+
cookieDays:
781+
type: integer
782+
description: Session-cookie lifetime in days; 0 means unlimited.
775783
responses:
776784
"200":
777-
description: Password updated.
785+
description: Settings updated.
778786
/login:
779787
post:
780788
tags: [Fork]

‎html/locales/de.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@
138138
"title": "Passwort",
139139
"placeholder": "Leer lassen, um den Schutz zu deaktivieren",
140140
"desc": "Ein gemeinsames Passwort für alles: Webinterface, FTP und WebDAV. Bei FTP und WebDAV ist der Benutzername egal – jeder Benutzername funktioniert, es zählt nur dieses Passwort. Nach dem Anmelden im Webinterface bleibt man 90 Tage eingeloggt; im Hotspot-Modus ist der Schutz inaktiv. Leer lassen, um den Schutz überall (Webinterface, FTP, WebDAV) zu deaktivieren."
141+
},
142+
"cookie": {
143+
"title": "Anmeldung merken",
144+
"unlimited": "Unbegrenzt eingeloggt bleiben",
145+
"days": "Eingeloggt bleiben für (Tage)",
146+
"desc": "Wie lange du nach dem Anmelden im Webinterface eingeloggt bleibst. Browser begrenzen Cookies meist auf max. ~400 Tage."
141147
}
142148
},
143149
"wifi": {
@@ -444,6 +450,7 @@
444450
"ftp": {
445451
"title": "FTP-Einstellungen",
446452
"authHint": "FTP akzeptiert jeden Benutzernamen – nur das Passwort zählt. Das Passwort ist das gemeinsame Geräte-Passwort (Tab \"Sicherheit\"). Leeres Passwort = Zugriff ohne Anmeldung.",
453+
"enable": "FTP beim Start automatisch aktivieren",
447454
"user": {
448455
"title": "FTP-Benutzername",
449456
"placeholder": "Benutzername"

‎html/locales/en.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@
138138
"title": "Password",
139139
"placeholder": "Leave empty to disable protection",
140140
"desc": "One shared password for everything: web interface, FTP and WebDAV. For FTP and WebDAV the username does not matter - any username works, only this password is checked. Once logged in to the web interface you stay logged in for 90 days; protection is inactive in hotspot mode. Leave empty to disable protection everywhere (web interface, FTP, WebDAV)."
141+
},
142+
"cookie": {
143+
"title": "Stay logged in",
144+
"unlimited": "Stay logged in indefinitely",
145+
"days": "Stay logged in for (days)",
146+
"desc": "How long you stay logged in to the web interface after signing in. Browsers usually cap cookies at ~400 days."
141147
}
142148
},
143149
"wifi": {
@@ -444,6 +450,7 @@
444450
"ftp": {
445451
"title": "FTP-settings",
446452
"authHint": "FTP accepts any username - only the password matters. The password is the shared device password (\"Security\" tab). An empty password means access without a login.",
453+
"enable": "Start FTP automatically on boot",
447454
"user": {
448455
"title": "FTP-Username",
449456
"placeholder": "username"

‎html/locales/fr.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@
131131
"title": "Mot de passe",
132132
"placeholder": "Laisser vide pour désactiver la protection",
133133
"desc": "Un seul mot de passe partagé pour tout : interface web, FTP et WebDAV. Pour le FTP et le WebDAV, le nom d'utilisateur n'a pas d'importance - n'importe quel nom fonctionne, seul ce mot de passe est vérifié. Une fois connecté à l'interface web, vous restez connecté pendant 90 jours ; la protection est inactive en mode hotspot. Laisser vide pour désactiver la protection partout (interface web, FTP, WebDAV)."
134+
},
135+
"cookie": {
136+
"title": "Rester connecté",
137+
"unlimited": "Rester connecté indéfiniment",
138+
"days": "Rester connecté pendant (jours)",
139+
"desc": "Durée pendant laquelle vous restez connecté à l'interface web après la connexion. Les navigateurs limitent généralement les cookies à ~400 jours."
134140
}
135141
},
136142
"wifi": {
@@ -437,6 +443,7 @@
437443
"ftp": {
438444
"title": "Paramètres FTP",
439445
"authHint": "Le FTP accepte n'importe quel nom d'utilisateur - seul le mot de passe compte. C'est le mot de passe partagé de l'appareil (onglet « Sécurité »). Un mot de passe vide permet l'accès sans connexion.",
446+
"enable": "Démarrer le FTP automatiquement au démarrage",
440447
"user": {
441448
"title": "Nom d'utilisateur FTP",
442449
"placeholder": "nom d'utilisateur"

‎html/management.html‎

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3136,6 +3136,26 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
31363136
</fieldset>
31373137
</div>
31383138
</form>
3139+
<hr>
3140+
<form action="#securityConfig" method="POST" onsubmit="saveCookieTimeout(); return false">
3141+
<div class="col-md-12 mb-4">
3142+
<fieldset>
3143+
<legend class="w-auto" data-i18n="security.cookie.title"></legend>
3144+
<div class="form-check form-switch mb-2">
3145+
<input class="form-check-input" type="checkbox" id="cookieUnlimited" name="cookieUnlimited">
3146+
<label class="form-check-label" for="cookieUnlimited" data-i18n="security.cookie.unlimited"></label>
3147+
</div>
3148+
<div class="mb-2">
3149+
<label for="cookieDays" data-i18n="[prepend]security.cookie.days">:</label>
3150+
<input type="number" class="form-control" id="cookieDays" name="cookieDays" min="1" max="3650" value="90">
3151+
</div>
3152+
<small class="form-text" data-i18n="security.cookie.desc"></small>
3153+
<div class="text-center mt-2">
3154+
<button type="submit" class="btn btn-primary" data-i18n="submit"></button>
3155+
</div>
3156+
</fieldset>
3157+
</div>
3158+
</form>
31393159
</div>
31403160
</div>
31413161
<div class="tab-pane fade" id="nav-wifi" role="tabpanel" aria-labelledby="nav-wifi-tab">
@@ -3239,10 +3259,20 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
32393259
</div>
32403260
<div class="tab-pane fade" id="nav-ftp" role="tabpanel" aria-labelledby="nav-ftp-tab">
32413261
<div class="container" id="ftpConfig">
3242-
<div class="mb-3 col-md-12">
3243-
<legend data-i18n="ftp.title"></legend>
3244-
<div class="mb-2"><small class="text-muted" data-i18n="ftp.authHint"></small></div>
3245-
</div>
3262+
<form action="#ftpConfig" method="POST" onsubmit="ftpSettings('ftpConfig'); return false">
3263+
<div class="mb-3 col-md-12">
3264+
<legend data-i18n="ftp.title"></legend>
3265+
<div class="mb-2"><small class="text-muted" data-i18n="ftp.authHint"></small></div>
3266+
<div class="form-check form-switch mb-3">
3267+
<input class="form-check-input" type="checkbox" id="ftpEnable" name="ftpEnable">
3268+
<label class="form-check-label" for="ftpEnable" data-i18n="ftp.enable"></label>
3269+
</div>
3270+
</div>
3271+
<div class="text-center">
3272+
<button type="reset" class="btn btn-warning" data-i18n="reset"></button>&nbsp;<button
3273+
type="submit" class="btn btn-primary" data-i18n="submit"></button>
3274+
</div>
3275+
</form>
32463276
<hr>
32473277
</div>
32483278
<div class="container" id="ftpStart">
@@ -5346,6 +5376,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
53465376
let ftpSettings = settings.ftp;
53475377
if (ftpSettings) {
53485378
document.getElementById('nav-ftp-tab').setAttribute('data-visible', true);
5379+
document.getElementById("ftpEnable").checked = !!ftpSettings.enable;
53495380
}
53505381
// webdav (the sub-tab stays hidden unless the firmware reports WebDAV support)
53515382
let webdavSettingsData = settings.webdav;
@@ -5957,6 +5988,22 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
59575988
toaster.info(i18next.t("toast.saved"));
59585989
}
59595990

5991+
/* save the FTP auto-start-on-boot flag (no credentials: any username, password via Security tab) */
5992+
async function ftpSettings(clickedId) {
5993+
lastIdclicked = clickedId;
5994+
var myObj = {
5995+
"ftp": {
5996+
enable: document.getElementById('ftpEnable').checked
5997+
}
5998+
};
5999+
await fetch("http://" + host + "/settings", {
6000+
method: "POST",
6001+
headers: { "Content-Type": "application/json" },
6002+
body: JSON.stringify(myObj)
6003+
});
6004+
toaster.info(i18next.t("toast.saved"));
6005+
}
6006+
59606007
function ftpSetStatus(start) {
59616008
var myObj = {
59626009
"ftpStatus": {
@@ -7037,11 +7084,40 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
70377084
try {
70387085
const status = await (await fetch("http://" + host + "/security")).json();
70397086
document.getElementById('menuLogout').setAttribute('data-visible', status.enabled === true);
7087+
if (status.cookieDays !== undefined) {
7088+
const unlimited = (status.cookieDays === 0);
7089+
document.getElementById('cookieUnlimited').checked = unlimited;
7090+
if (!unlimited) document.getElementById('cookieDays').value = status.cookieDays;
7091+
applyCookieUnlimitedState();
7092+
}
70407093
} catch (e) {
70417094
console.log("failed to fetch security status", e);
70427095
}
70437096
}
70447097

7098+
// the "unlimited" switch disables the day input (a blank/ignored value while unlimited)
7099+
function applyCookieUnlimitedState() {
7100+
document.getElementById('cookieDays').disabled = document.getElementById('cookieUnlimited').checked;
7101+
}
7102+
document.getElementById('cookieUnlimited').addEventListener('change', applyCookieUnlimitedState);
7103+
7104+
// save just the cookie lifetime (0 = unlimited); never touches the password
7105+
async function saveCookieTimeout() {
7106+
const unlimited = document.getElementById('cookieUnlimited').checked;
7107+
const days = unlimited ? 0 : parseInt(document.getElementById('cookieDays').value, 10) || 90;
7108+
const response = await fetch("http://" + host + "/security", {
7109+
method: "POST",
7110+
headers: { "Content-Type": "application/json" },
7111+
body: JSON.stringify({ cookieDays: days })
7112+
});
7113+
if (response.ok) {
7114+
flashActionOk();
7115+
refreshSecurityStatus();
7116+
} else {
7117+
toaster.error(response.statusText);
7118+
}
7119+
}
7120+
70457121
async function securityConfig() {
70467122
const response = await fetch("http://" + host + "/security", {
70477123
method: "POST",

‎src/Ftp.cpp‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ String Ftp_Password = "esp32"; // FTP-password (default; can be changed later vi
2424
FTPServer *ftpSrv; // Heap-alloction takes place later (when needed)
2525
bool ftpEnableLastStatus = false;
2626
bool ftpEnableCurrentStatus = false;
27+
static bool ftpAutostart = false; // start automatically on boot (persisted setting "ftpEnable")
2728
#endif
2829

2930
void ftpManager(void);
@@ -49,6 +50,10 @@ void Ftp_Init(void) {
4950
Ftp_Password = nvsFtpPassword;
5051
Log_Printf(LOGLEVEL_INFO, restoredFtpPwdFromNvs, nvsFtpPassword.c_str());
5152
}
53+
54+
#ifdef FTP_ENABLE
55+
ftpAutostart = gPrefsSettings.getBool("ftpEnable", false);
56+
#endif
5257
}
5358

5459
// Re-reads the FTP credentials from NVS into the in-memory globals the running
@@ -83,6 +88,14 @@ void Ftp_Exit(void) {
8388

8489
void Ftp_Cyclic(void) {
8590
#ifdef FTP_ENABLE
91+
// One-shot auto-start: when the persisted setting asks for it, fire the server up the first time
92+
// WiFi is available after boot, then never touch it again (so a manual stop isn't overridden).
93+
static bool autostartHandled = false;
94+
if (ftpAutostart && !autostartHandled && Wlan_IsConnected()) {
95+
autostartHandled = true;
96+
Ftp_EnableServer();
97+
}
98+
8699
ftpManager();
87100

88101
if (WL_CONNECTED == WiFi.status()) {

0 commit comments

Comments
 (0)