Skip to content

Commit 72d099a

Browse files
author
Paul C
committed
feat: server-side user preferences — settings sync across browsers
User preferences (theme, icon theme, bookmarks, sidebar state, DC layout, container view mode) are now saved per-user on the server at /etc/wolfstack/user-prefs/<username>.json. On first login, existing localStorage values are uploaded to the server so existing users keep their bookmarks and settings. On subsequent logins (including from other browsers/devices), server preferences are applied. API: GET/POST/PATCH /api/user/preferences with 64KB size limit. Frontend: savePref()/removePref() wrappers sync to both localStorage and server. loadUserPreferences() called on page load.
1 parent f8b6148 commit 72d099a

2 files changed

Lines changed: 196 additions & 16 deletions

File tree

src/api/mod.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9696,6 +9696,99 @@ pub async fn wolfflow_all_containers_exec(req: HttpRequest, state: web::Data<App
96969696
}
96979697
}
96989698

9699+
// ─── User Preferences API ───
9700+
9701+
fn user_prefs_path(username: &str) -> String {
9702+
// Sanitize username for safe filesystem path
9703+
let safe: String = username.chars()
9704+
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
9705+
.collect();
9706+
format!("{}/user-prefs/{}.json", crate::paths::get().config_dir, safe)
9707+
}
9708+
9709+
/// GET /api/user/preferences — load the current user's preferences
9710+
pub async fn user_prefs_get(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
9711+
let username = match require_auth(&req, &state) {
9712+
Ok(u) => u,
9713+
Err(resp) => return resp,
9714+
};
9715+
9716+
let path = user_prefs_path(&username);
9717+
match std::fs::read_to_string(&path) {
9718+
Ok(data) => {
9719+
match serde_json::from_str::<serde_json::Value>(&data) {
9720+
Ok(prefs) => HttpResponse::Ok().json(prefs),
9721+
Err(_) => HttpResponse::Ok().json(serde_json::json!({})),
9722+
}
9723+
}
9724+
Err(_) => HttpResponse::Ok().json(serde_json::json!({})),
9725+
}
9726+
}
9727+
9728+
/// POST /api/user/preferences — save the current user's preferences (full replace)
9729+
pub async fn user_prefs_save(req: HttpRequest, state: web::Data<AppState>, body: web::Json<serde_json::Value>) -> HttpResponse {
9730+
let username = match require_auth(&req, &state) {
9731+
Ok(u) => u,
9732+
Err(resp) => return resp,
9733+
};
9734+
9735+
let path = user_prefs_path(&username);
9736+
let dir = std::path::Path::new(&path).parent().unwrap();
9737+
if let Err(e) = std::fs::create_dir_all(dir) {
9738+
return HttpResponse::InternalServerError().json(serde_json::json!({ "error": format!("Failed to create dir: {}", e) }));
9739+
}
9740+
9741+
// Limit size to prevent abuse (64 KB should be plenty for preferences)
9742+
let json = serde_json::to_string_pretty(&body.into_inner()).unwrap_or_default();
9743+
if json.len() > 65536 {
9744+
return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Preferences too large (max 64 KB)" }));
9745+
}
9746+
9747+
match std::fs::write(&path, &json) {
9748+
Ok(_) => HttpResponse::Ok().json(serde_json::json!({ "ok": true })),
9749+
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": format!("Failed to save: {}", e) })),
9750+
}
9751+
}
9752+
9753+
/// PATCH /api/user/preferences — merge specific keys into existing preferences
9754+
pub async fn user_prefs_patch(req: HttpRequest, state: web::Data<AppState>, body: web::Json<serde_json::Value>) -> HttpResponse {
9755+
let username = match require_auth(&req, &state) {
9756+
Ok(u) => u,
9757+
Err(resp) => return resp,
9758+
};
9759+
9760+
let path = user_prefs_path(&username);
9761+
let dir = std::path::Path::new(&path).parent().unwrap();
9762+
let _ = std::fs::create_dir_all(dir);
9763+
9764+
// Load existing
9765+
let mut prefs: serde_json::Map<String, serde_json::Value> = std::fs::read_to_string(&path)
9766+
.ok()
9767+
.and_then(|d| serde_json::from_str(&d).ok())
9768+
.unwrap_or_default();
9769+
9770+
// Merge incoming keys
9771+
if let Some(obj) = body.as_object() {
9772+
for (k, v) in obj {
9773+
if v.is_null() {
9774+
prefs.remove(k);
9775+
} else {
9776+
prefs.insert(k.clone(), v.clone());
9777+
}
9778+
}
9779+
}
9780+
9781+
let json = serde_json::to_string_pretty(&prefs).unwrap_or_default();
9782+
if json.len() > 65536 {
9783+
return HttpResponse::BadRequest().json(serde_json::json!({ "error": "Preferences too large" }));
9784+
}
9785+
9786+
match std::fs::write(&path, &json) {
9787+
Ok(_) => HttpResponse::Ok().json(serde_json::json!({ "ok": true })),
9788+
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ "error": format!("Failed to save: {}", e) })),
9789+
}
9790+
}
9791+
96999792
// ─── WolfUSB API ───
97009793

97019794
/// GET /api/wolfusb/status — installation status, service status, config
@@ -16202,6 +16295,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
1620216295
.route("/api/wolfflow/container-exec", web::post().to(wolfflow_container_exec))
1620316296
.route("/api/wolfflow/all-containers-exec", web::post().to(wolfflow_all_containers_exec))
1620416297
.route("/api/wolfflow/infrastructure", web::get().to(wolfflow_infrastructure))
16298+
// User Preferences
16299+
.route("/api/user/preferences", web::get().to(user_prefs_get))
16300+
.route("/api/user/preferences", web::post().to(user_prefs_save))
16301+
.route("/api/user/preferences", web::patch().to(user_prefs_patch))
1620516302
// WolfUSB — USB over IP
1620616303
.route("/api/wolfusb/status", web::get().to(wolfusb_status))
1620716304
.route("/api/wolfusb/install", web::post().to(wolfusb_install))

web/js/app.js

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,88 @@
44

55
// WolfStack Dashboard — app.js
66

7+
// ─── User Preferences (synced to server) ───
8+
9+
// Keys that should be persisted server-side per user
10+
const _syncedPrefKeys = [
11+
'wolfstack-theme', 'wolfstack-icon-theme', 'wolfstack-mono-icons', 'wolfstack-mono-preset',
12+
'wolfstack_sidebar_collapsed', 'wolfstack_map_collapsed',
13+
'wolfstack_dc_layout', 'wolfstack_dc_compact', 'wolfstack_dc_bg_image',
14+
'wolfstack_dc_bg_brightness', 'wolfstack_dc_bg_blur',
15+
'wolfstack_bookmarks', 'wolfstack_view_docker', 'wolfstack_view_lxc', 'wolfstack_view_vm',
16+
];
17+
let _prefsLoaded = false;
18+
19+
// Load user preferences from server and apply to localStorage.
20+
// If the server has no saved prefs yet, upload current localStorage values
21+
// so existing users don't lose their bookmarks or theme on first sync.
22+
async function loadUserPreferences() {
23+
try {
24+
var resp = await fetch('/api/user/preferences');
25+
if (!resp.ok) return;
26+
var prefs = await resp.json();
27+
if (!prefs || typeof prefs !== 'object') return;
28+
29+
var serverHasData = Object.keys(prefs).length > 0;
30+
31+
if (serverHasData) {
32+
// Server has prefs — apply to localStorage
33+
for (var key of _syncedPrefKeys) {
34+
if (prefs[key] !== undefined && prefs[key] !== null) {
35+
localStorage.setItem(key, typeof prefs[key] === 'string' ? prefs[key] : JSON.stringify(prefs[key]));
36+
}
37+
}
38+
} else {
39+
// Server has nothing — upload current localStorage so existing users keep their settings
40+
var upload = {};
41+
var hasAnything = false;
42+
for (var key of _syncedPrefKeys) {
43+
var val = localStorage.getItem(key);
44+
if (val !== null) {
45+
upload[key] = val;
46+
hasAnything = true;
47+
}
48+
}
49+
if (hasAnything) {
50+
fetch('/api/user/preferences', {
51+
method: 'POST',
52+
headers: { 'Content-Type': 'application/json' },
53+
body: JSON.stringify(upload),
54+
}).catch(function() {});
55+
}
56+
}
57+
_prefsLoaded = true;
58+
} catch (e) { /* silent — localStorage fallback works */ }
59+
}
60+
61+
// Save a single preference to both localStorage and server
62+
function savePref(key, value) {
63+
localStorage.setItem(key, value);
64+
if (_syncedPrefKeys.includes(key)) {
65+
var patch = {};
66+
patch[key] = value;
67+
fetch('/api/user/preferences', {
68+
method: 'PATCH',
69+
headers: { 'Content-Type': 'application/json' },
70+
body: JSON.stringify(patch),
71+
}).catch(function() {});
72+
}
73+
}
74+
75+
// Remove a preference from both localStorage and server
76+
function removePref(key) {
77+
localStorage.removeItem(key);
78+
if (_syncedPrefKeys.includes(key)) {
79+
var patch = {};
80+
patch[key] = null;
81+
fetch('/api/user/preferences', {
82+
method: 'PATCH',
83+
headers: { 'Content-Type': 'application/json' },
84+
body: JSON.stringify(patch),
85+
}).catch(function() {});
86+
}
87+
}
88+
789
// ─── State ───
890
let currentPage = 'datacenter';
991
let currentComponent = null;
@@ -33,7 +115,7 @@ function toggleSidebar() {
33115
// Desktop: toggle collapsed sidebar
34116
document.body.classList.toggle('sidebar-collapsed');
35117
const collapsed = document.body.classList.contains('sidebar-collapsed');
36-
localStorage.setItem('wolfstack_sidebar_collapsed', collapsed ? '1' : '0');
118+
savePref('wolfstack_sidebar_collapsed', collapsed ? '1' : '0');
37119
}
38120
// Invalidate map size + 3D topology after sidebar transition
39121
setTimeout(() => {
@@ -67,7 +149,7 @@ let mapCollapsed = localStorage.getItem('wolfstack_map_collapsed') === '1'; // d
67149

68150
function toggleMapCollapse() {
69151
mapCollapsed = !mapCollapsed;
70-
localStorage.setItem('wolfstack_map_collapsed', mapCollapsed ? '1' : '0');
152+
savePref('wolfstack_map_collapsed', mapCollapsed ? '1' : '0');
71153
applyMapCollapse();
72154
}
73155

@@ -530,7 +612,7 @@ function loadBookmarks() {
530612
catch { return []; }
531613
}
532614
function saveBookmarks(bookmarks) {
533-
localStorage.setItem('wolfstack_bookmarks', JSON.stringify(bookmarks));
615+
savePref('wolfstack_bookmarks', JSON.stringify(bookmarks));
534616
}
535617
function renderBookmarks() {
536618
const el = document.getElementById('bookmarks-list');
@@ -705,7 +787,7 @@ function toggleBgSettingsPanel() {
705787

706788
function setDcLayout(layout) {
707789
dcLayout = layout;
708-
localStorage.setItem('wolfstack_dc_layout', layout);
790+
savePref('wolfstack_dc_layout', layout);
709791
const icon = document.getElementById('dc-layout-icon');
710792
const label = document.getElementById('dc-layout-label');
711793
if (icon) icon.textContent = layout === 'grid' ? '⊞' : '▥';
@@ -718,7 +800,7 @@ function setDcLayout(layout) {
718800

719801
function toggleDcCompact(checked) {
720802
dcCompact = checked;
721-
localStorage.setItem('wolfstack_dc_compact', checked ? '1' : '0');
803+
savePref('wolfstack_dc_compact', checked ? '1' : '0');
722804
renderDatacenterOverview();
723805
}
724806

@@ -732,29 +814,29 @@ function handleBgUpload(input) {
732814
const reader = new FileReader();
733815
reader.onload = (e) => {
734816
dcBgImage = e.target.result;
735-
localStorage.setItem('wolfstack_dc_bg_image', dcBgImage);
817+
savePref('wolfstack_dc_bg_image', dcBgImage);
736818
applyDcBackground();
737819
};
738820
reader.readAsDataURL(file);
739821
}
740822

741823
function clearBgImage() {
742824
dcBgImage = '';
743-
localStorage.removeItem('wolfstack_dc_bg_image');
825+
removePref('wolfstack_dc_bg_image');
744826
applyDcBackground();
745827
}
746828

747829
function updateBgBrightness(val) {
748830
dcBgBrightness = parseInt(val, 10);
749-
localStorage.setItem('wolfstack_dc_bg_brightness', val);
831+
savePref('wolfstack_dc_bg_brightness', val);
750832
const el = document.getElementById('dc-bg-bright-val');
751833
if (el) el.textContent = val + '%';
752834
applyDcBackground();
753835
}
754836

755837
function updateBgBlur(val) {
756838
dcBgBlur = parseInt(val, 10);
757-
localStorage.setItem('wolfstack_dc_bg_blur', val);
839+
savePref('wolfstack_dc_bg_blur', val);
758840
const el = document.getElementById('dc-bg-blur-val');
759841
if (el) el.textContent = val + 'px';
760842
applyDcBackground();
@@ -9836,6 +9918,7 @@ function tomlToggleRaw(component, displayName) {
98369918
const POLL_FAST = isMobileView() ? 30000 : 15000;
98379919
const POLL_SLOW = isMobileView() ? 120000 : 60000;
98389920
let _currentPollSpeed = 'fast';
9921+
loadUserPreferences(); // Load user preferences from server (async, non-blocking)
98399922
fetchNodes();
98409923
fetchMetricsHistory(); // Initial history load
98419924
loadTaskLog(); // Restore task log from localStorage
@@ -11481,7 +11564,7 @@ function getContainerView(type) {
1148111564
}
1148211565

1148311566
function setContainerView(type, view) {
11484-
localStorage.setItem(`wolfstack_view_${type}`, view);
11567+
savePref(`wolfstack_view_${type}`, view);
1148511568
applyContainerView(type);
1148611569
// Re-render the current data
1148711570
if (type === 'docker') loadDockerContainers();
@@ -22270,7 +22353,7 @@ function applyTheme(themeId) {
2227022353
}
2227122354

2227222355
// Save preference
22273-
localStorage.setItem('wolfstack-theme', themeId);
22356+
savePref('wolfstack-theme', themeId);
2227422357

2227522358
// Swap logo for light/dark backgrounds
2227622359
const lightThemes = ['light', 'arctic'];
@@ -22321,7 +22404,7 @@ function applyIconTheme(themeName) {
2232122404
document.body.appendChild(modal);
2232222405
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
2232322406
document.getElementById('icon-theme-confirm-btn').onclick = function() {
22324-
localStorage.setItem('wolfstack-icon-theme', themeName);
22407+
savePref('wolfstack-icon-theme', themeName);
2232522408
location.reload();
2232622409
};
2232722410
}
@@ -22331,7 +22414,7 @@ async function initIconTheme() {
2233122414
// Migrate old "candy" key to "candy_emoji"
2233222415
if (currentIconTheme === 'candy') {
2233322416
currentIconTheme = 'candy_emoji';
22334-
localStorage.setItem('wolfstack-icon-theme', 'candy_emoji');
22417+
savePref('wolfstack-icon-theme', 'candy_emoji');
2233522418
}
2233622419
// Update icon theme selector UI
2233722420
document.querySelectorAll('.icon-theme-card').forEach(card => {
@@ -22532,12 +22615,12 @@ const MONO_PRESETS = [
2253222615
function toggleMonoIcons(enabled) {
2253322616
if (enabled) {
2253422617
document.documentElement.setAttribute('data-mono-icons', '');
22535-
localStorage.setItem('wolfstack-mono-icons', '1');
22618+
savePref('wolfstack-mono-icons', '1');
2253622619
const saved = localStorage.getItem('wolfstack-mono-preset') || 'Gray';
2253722620
applyMonoPreset(saved);
2253822621
} else {
2253922622
document.documentElement.removeAttribute('data-mono-icons');
22540-
localStorage.removeItem('wolfstack-mono-icons');
22623+
removePref('wolfstack-mono-icons');
2254122624
}
2254222625
const opts = document.getElementById('mono-icons-options');
2254322626
if (opts) opts.style.display = enabled ? '' : 'none';
@@ -22550,7 +22633,7 @@ function applyMonoPreset(name) {
2255022633
root.style.setProperty('--mono-hue', preset.hue + 'deg');
2255122634
root.style.setProperty('--mono-sat', preset.sat);
2255222635
root.style.setProperty('--mono-brightness', preset.brightness);
22553-
localStorage.setItem('wolfstack-mono-preset', preset.name);
22636+
savePref('wolfstack-mono-preset', preset.name);
2255422637
// Update active state on preset buttons
2255522638
document.querySelectorAll('.mono-preset-btn').forEach(btn => {
2255622639
btn.style.outline = btn.dataset.preset === preset.name ? '2px solid var(--accent)' : 'none';

0 commit comments

Comments
 (0)