Build and push server image / build-and-push (push) Successful in 40s
Admin-configured SMTP (server/port/username/password/from address/ STARTTLS, a singleton server_settings row set from /admin -- not env vars, since it's operator infrastructure a household admin sets up once through the UI) powers two features, both requiring the relevant user to have an email set in their own Settings: - "Forgot password?" on /login emails a one-hour single-use reset link (password_reset_tokens table). The endpoint always returns the same generic "check your email" response regardless of whether the address matched an account, so it can't be used to enumerate registered users. - A frame's Configuration tab can set a battery-alert threshold (Frame.battery_alert_threshold_pct, -1 = disabled); POST /frame/battery emails the owner the first time a report drops to or below it, then stays quiet for the rest of that discharge cycle (battery_alert_sent, reset alongside battery_history whenever the existing recharge-jump detection fires) -- not once per wake. New app/mail.py wraps stdlib smtplib (no new dependency); send_email() never raises, so a broken mail server can't 500 a battery report or a password-reset request. Schema migration v2 adds users.email and the two frame columns via ALTER TABLE (safe against the live, already- populated database) plus the two new tables via the existing create_all-based migration runner. Verified against a real (already-migrated, real user/frame data) database: the v1->v2 migration, admin SMTP config + test-email button, full forgot/reset-password roundtrip (including single-use token invalidation and the no-enumeration response), and the battery alert firing exactly once per crossing against a hand-rolled fake SMTP server -- all via curl end-to-end, plus the standing legacy-device curl suite to confirm the device protocol is untouched.
222 lines
7.9 KiB
JavaScript
222 lines
7.9 KiB
JavaScript
// Configuration tab: frame settings + firmware card + take control.
|
|
// window.FRAME_API is set by the template. Checkboxes are always sent
|
|
// explicitly as "true"/"false" -- the server treats absent fields as
|
|
// "leave unchanged", so a checkbox must never be simply omitted.
|
|
|
|
async function saveConfig() {
|
|
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
|
const body = new URLSearchParams({
|
|
name: document.getElementById('frame_name').value || '',
|
|
order: document.getElementById('order').value,
|
|
orientation: document.getElementById('orientation').value,
|
|
refresh_interval_s: String(minutes * 60),
|
|
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
|
|
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
|
|
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
|
|
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
|
|
timezone: document.getElementById('timezone').value || 'UTC',
|
|
});
|
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body,
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error(await apiError(resp));
|
|
}
|
|
}
|
|
|
|
document.getElementById('config-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
try {
|
|
await saveConfig();
|
|
showStatus(true, 'Saved.');
|
|
} catch (e) {
|
|
showStatus(false, e.message);
|
|
}
|
|
});
|
|
|
|
async function takeControl() {
|
|
try {
|
|
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
|
if (!resp.ok) throw new Error(await apiError(resp));
|
|
showStatus(true, 'You have control now.');
|
|
loadControl();
|
|
} catch (e) {
|
|
showStatus(false, e.message);
|
|
}
|
|
}
|
|
|
|
async function loadControl() {
|
|
const banner = document.getElementById('control-banner');
|
|
try {
|
|
const resp = await fetch(`${window.FRAME_API}/queue`);
|
|
if (!resp.ok) return; // unconfigured frame: control still works via 409s
|
|
const data = await resp.json();
|
|
if (data.control && !data.control.you) {
|
|
banner.style.display = 'flex';
|
|
document.getElementById('control-holder').textContent = data.control.controller
|
|
? `${data.control.controller} currently has control of this frame.`
|
|
: 'Nobody has control of this frame yet.';
|
|
} else {
|
|
banner.style.display = 'none';
|
|
}
|
|
} catch (e) { /* banner is best-effort */ }
|
|
}
|
|
|
|
document.getElementById('take-control').addEventListener('click', takeControl);
|
|
|
|
// ---- Battery alerts card ----
|
|
|
|
document.getElementById('battery-alert-save').addEventListener('click', async () => {
|
|
const raw = document.getElementById('battery_alert_threshold_pct').value.trim();
|
|
const body = new URLSearchParams({
|
|
battery_alert_threshold_pct: raw === '' ? '-1' : raw,
|
|
});
|
|
try {
|
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body,
|
|
});
|
|
if (!resp.ok) throw new Error(await apiError(resp));
|
|
showStatus(true, 'Saved.');
|
|
} catch (e) {
|
|
showStatus(false, e.message);
|
|
}
|
|
});
|
|
|
|
// ---- Firmware card ----
|
|
|
|
document.getElementById('firmware-upload').addEventListener('click', async () => {
|
|
const input = document.getElementById('firmware-file');
|
|
if (!input.files.length) {
|
|
showStatus(false, 'Pick a firmware .bin first.');
|
|
return;
|
|
}
|
|
const form = new FormData();
|
|
form.append('file', input.files[0]);
|
|
try {
|
|
const resp = await fetch(`${window.FRAME_API}/firmware`, { method: 'POST', body: form });
|
|
if (!resp.ok) {
|
|
throw new Error(await apiError(resp));
|
|
}
|
|
const result = await resp.json();
|
|
document.getElementById('firmware-available').textContent =
|
|
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
|
|
showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`);
|
|
} catch (e) {
|
|
showStatus(false, e.message);
|
|
}
|
|
});
|
|
|
|
function showRepoDisplayMode(url) {
|
|
document.getElementById('firmware-repo-text').textContent = url;
|
|
document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none';
|
|
document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block';
|
|
}
|
|
|
|
document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => {
|
|
document.getElementById('firmware-repo-display').style.display = 'none';
|
|
document.getElementById('firmware-repo-edit').style.display = 'block';
|
|
document.getElementById('firmware_update_repo_url').focus();
|
|
});
|
|
|
|
document.getElementById('firmware-settings-save').addEventListener('click', async () => {
|
|
try {
|
|
const body = new URLSearchParams({
|
|
firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '',
|
|
firmware_auto_update: String(document.getElementById('firmware_auto_update').checked),
|
|
});
|
|
const resp = await fetch(`${window.FRAME_API}/config`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body,
|
|
});
|
|
if (!resp.ok) throw new Error(await apiError(resp));
|
|
showStatus(true, 'Saved.');
|
|
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
|
|
loadFirmwareCheck();
|
|
} catch (e) {
|
|
showStatus(false, e.message);
|
|
}
|
|
});
|
|
|
|
async function loadFirmwareCheck(force) {
|
|
const statusEl = document.getElementById('firmware-gitea-status');
|
|
const btn = document.getElementById('firmware-update-btn');
|
|
const boardEl = document.getElementById('firmware-board');
|
|
try {
|
|
const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''));
|
|
if (!resp.ok) {
|
|
if (force) {
|
|
showStatus(false, await apiError(resp));
|
|
}
|
|
return;
|
|
}
|
|
const data = await resp.json();
|
|
if (data.board) {
|
|
boardEl.textContent = `Detected board: ${data.board}`;
|
|
}
|
|
if (!data.enabled) {
|
|
statusEl.style.display = 'none';
|
|
btn.style.display = 'none';
|
|
if (force) {
|
|
showStatus(false, 'No Gitea repo URL configured.');
|
|
}
|
|
return;
|
|
}
|
|
statusEl.style.display = 'block';
|
|
if (!data.board) {
|
|
statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
|
|
btn.style.display = 'none';
|
|
} else if (data.update_available) {
|
|
statusEl.textContent = `Update available: v${data.latest_version}.`;
|
|
btn.style.display = 'inline-block';
|
|
} else if (data.latest_version) {
|
|
statusEl.textContent = `Up to date (v${data.latest_version}).`;
|
|
btn.style.display = 'none';
|
|
} else {
|
|
statusEl.textContent = 'No releases found yet.';
|
|
btn.style.display = 'none';
|
|
}
|
|
if (force) {
|
|
showStatus(true, 'Checked.');
|
|
}
|
|
} catch (e) {
|
|
// A failed passive poll is silent; an explicit "Check now" click
|
|
// still surfaces the error.
|
|
if (force) {
|
|
showStatus(false, e.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
document.getElementById('firmware-check-now').addEventListener('click', () => loadFirmwareCheck(true));
|
|
|
|
document.getElementById('firmware-update-btn').addEventListener('click', async () => {
|
|
const btn = document.getElementById('firmware-update-btn');
|
|
btn.disabled = true;
|
|
try {
|
|
const resp = await fetch(`${window.FRAME_API}/firmware/apply-latest`, { method: 'POST' });
|
|
if (!resp.ok) {
|
|
throw new Error(await apiError(resp));
|
|
}
|
|
const result = await resp.json();
|
|
document.getElementById('firmware-available').textContent =
|
|
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
|
|
showStatus(true, `Firmware v${result.version} staged from Gitea.`);
|
|
loadFirmwareCheck();
|
|
} catch (e) {
|
|
showStatus(false, e.message);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
loadControl();
|
|
loadFirmwareCheck();
|
|
// The server throttles actual Gitea API calls itself, so this poll is
|
|
// cheap either way.
|
|
setInterval(loadFirmwareCheck, 60000);
|