fix: isolate admin-mode state, harden deepMerge, disable stale defaults cache

- InterfaceSettings now reads from a reactive activeSettings alias
  (initialSettings in admin mode, $settings otherwise) instead of
  reading $settings directly. toggleTitleAutoGenerate spreads
  activeSettings.title and the chat-bubble template branch uses the
  local chatBubble state, so the admin defaults modal no longer mixes
  the current admin's personal settings into the payload or the render
  logic.
- deepMerge skips __proto__, constructor, and prototype keys before
  copying. Inputs come from API payloads and localStorage, both
  untrusted channels, and a crafted payload could otherwise mutate the
  object prototype chain or reassign the constructor during app init.
- GET /configs/interface/defaults swaps Cache-Control: private,
  max-age=600 for no-store. The endpoint is fetched on every
  authenticated app load and merged into runtime settings, so a
  10-minute freshness window made admin "save defaults" and "reset all
  users" appear to have no effect for up to ten minutes. The read is
  cheap (in-memory config), so skipping the cache is fine.
This commit is contained in:
DrMelone 2026-04-13 00:58:29 +02:00
parent 6cd3c7238b
commit 5835cd93cf
3 changed files with 42 additions and 23 deletions

View file

@ -713,16 +713,21 @@ async def get_interface_defaults(user=Depends(get_verified_user)):
"""
Get global interface defaults for all users.
Returns empty dict if no defaults are configured.
Cached for 10 minutes to reduce redundant fetches.
Served with a no-store cache policy: this endpoint is fetched on
authenticated app load and merged into runtime settings, so caching
here would leave users seeing stale defaults for up to the cache TTL
after an admin 'save defaults' or 'reset all users' action. The call
is lightweight (in-memory config read), so the extra hit is cheap.
"""
from fastapi.responses import JSONResponse
config = await asyncio.to_thread(get_config)
defaults = config.get("ui", {}).get("interface_defaults", {})
return JSONResponse(
content=defaults,
headers={"Cache-Control": "private, max-age=600"}
headers={"Cache-Control": "no-store"},
)

View file

@ -24,6 +24,13 @@
// In admin mode, we don't apply CSS side effects like text scale
$: isAdminMode = initialSettings !== null;
// Reading $settings directly mixes the current admin's personal settings
// into composition (e.g. ...$settings.title) and render branches (e.g.
// {#if !$settings.chatBubble}) when this component is reused by the
// defaults modal. activeSettings resolves to initialSettings in admin
// mode and $settings in the normal user mode, so neither path leaks.
$: activeSettings = isAdminMode ? (initialSettings ?? {}) : $settings;
let backgroundImageUrl = null;
let inputFiles = null;
let filesInputElement;
@ -135,7 +142,7 @@
const toggleTitleAutoGenerate = async () => {
saveSettings({
title: {
...$settings.title,
...(activeSettings?.title ?? {}),
auto: titleAutoGenerate
}
});
@ -716,7 +723,7 @@
</div>
</div>
{#if !$settings.chatBubble}
{#if !chatBubble}
<div>
<div class=" py-0.5 flex w-full justify-between">
<div id="chat-bubble-username-label" class=" self-center text-xs">

View file

@ -1883,24 +1883,31 @@ export const deepMerge = (target: any, source: any): any => {
const result = { ...target };
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const sourceValue = source[key];
const targetValue = result[key];
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
// Recursively merge if both are non-null objects
if (
sourceValue !== null &&
targetValue !== null &&
typeof sourceValue === 'object' &&
typeof targetValue === 'object' &&
!Array.isArray(sourceValue) &&
!Array.isArray(targetValue)
) {
result[key] = deepMerge(targetValue, sourceValue);
} else {
// Source value takes precedence (including explicit null)
result[key] = sourceValue;
}
// Sources for this merge include API payloads and localStorage, so
// never copy keys that would let a crafted value mutate the
// prototype chain or reassign the object constructor.
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
const sourceValue = source[key];
const targetValue = result[key];
// Recursively merge if both are non-null objects
if (
sourceValue !== null &&
targetValue !== null &&
typeof sourceValue === 'object' &&
typeof targetValue === 'object' &&
!Array.isArray(sourceValue) &&
!Array.isArray(targetValue)
) {
result[key] = deepMerge(targetValue, sourceValue);
} else {
// Source value takes precedence (including explicit null)
result[key] = sourceValue;
}
}