fix: address review findings in the v2 stack fix PR

Board scaling: no sideways scrolling
- Revert the pannable narrow canvas. The canvas fits the viewport again and
  `.viewport` goes back to `overflow: clip`, so a board can never widen the
  document or scroll horizontally.
- Fix the unreadable-narrow-board regression at the real cause instead: cap the
  inverse UI compensation at 2x (50% canvas scale). Down to 50% text and
  controls keep their physical size; below it the board zooms out as a whole, so
  a 200x200 logical tile hosts at most 28px logical text rather than 96px and
  content stops overlapping its neighbours.

Touch editing
- Add `touch-action: none` to `.board-grid-entry`, the dnd-kit draggable and its
  own drag handle. The 200ms touch activation delay is useless if the browser
  has already handed the gesture to page panning and emitted `pointercancel`.
  Only edit mode renders this element.

Custom widgets
- Memoize the merged `data`/`status`/`options` passed to `CustomJsxRenderer`.
  They were rebuilt every render, so the compiled-template memo never hit and
  the new reference-identity boundary key changed on every render, resetting the
  error boundary and forcing an extra reconciliation of the widget subtree.
- Keep the cache key when the shared Redis cache is unavailable so the
  process-local cache still absorbs polling instead of sending every widget
  refresh upstream.
- Reclaim the shared cache's LRU/size/byte bookkeeping for TTL-expired entries
  and touch the LRU in the same script, so expired ghosts stop consuming the
  64 MiB budget and a read costs one round trip instead of two.

Forwarded IP
- Restore `ipAddressFromHeaders` to the full forwarding chain and add
  `trustedIpAddressFromHeaders` for the last hop. Repointing the shared helper
  had collapsed MCP failed-auth rate limiting into a single bucket for every
  client behind a reverse proxy and replaced the client IP with the proxy IP in
  API-key auth logs.

Runtime and restore
- Make the new /appdata ownership migration best-effort. Under `set -e` a mount
  that rejects chown (CIFS/SMB, fixed-uid NFS) turned into a container that
  refuses to start, and /appdata was never chowned before.
- Report malformed metadata.json as such instead of blaming the archive.
This commit is contained in:
ajnart committed 2026-08-21 01:40:46 +02:00
1 parent 496fa60b5f
commit 1b544f599c
10 files changed
+153 -69

No files matched your search

@@ -213,9 +213,11 @@ otherwise the collision chain reflows neighboring tiles automatically.
Every `1 × 1` tile has a logical content size of `200 × 200` pixels.
Larger tiles use multiples of that fixed unit, with a consistent gap between cells.
Homarr scales the complete board uniformly, like zooming a page.
Below 100%, Homarr preserves the widget's readable typography and control density instead of shrinking each widget
independently. Scaling stops at 50%; if the selected layout is still wider than the viewport, the board scrolls
horizontally instead of making tiles and controls unusably small.
The complete canvas always fits the available width without horizontal board scrolling.
Down to 50%, Homarr preserves the widget's readable typography and control density instead of shrinking each widget
independently. Below 50% it stops compensating and scales text and controls together with the board, like zooming out a
page, so tile content keeps its designed proportions instead of overlapping neighboring tiles. Add a narrow responsive
layout — Homarr already creates and reflows the protected Mobile layout — when a board has to stay readable on small screens.
It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes.
On a narrow screen, Homarr uses the **Mobile layout** for the selected board and stacks the board content vertically while
@@ -541,7 +541,14 @@ export async function POST(req: Request) {
const entries = await inspectArchiveAsync(zip);
await extractEntryToFileAsync(zip, entries.database, tempPath);
const metadataBuffer = await readBoundedEntryAsync(zip, entries.metadata, MAX_METADATA_BYTES);
const parsedMetadata: unknown = JSON.parse(metadataBuffer.toString());
let parsedMetadata: unknown;
try {
parsedMetadata = JSON.parse(metadataBuffer.toString());
} catch {
// Without this the archive itself gets blamed for a readable ZIP that
// simply carries malformed metadata.
throw new Error("Invalid backup: metadata.json is not valid JSON");
}
if (typeof parsedMetadata !== "object" || parsedMetadata === null || Array.isArray(parsedMetadata)) {
throw new Error("Invalid backup: metadata.json must contain an object");
}
@@ -2,24 +2,12 @@
width: 100%;
max-width: 100%;
min-width: 0;
overflow: visible;
}
.horizontalScroller {
width: 100%;
max-width: 100%;
min-width: 0;
/*
* Horizontal scrolling computes the other overflow axis to auto. Keep the
* board chrome inside that scrollport without changing the sizer's height.
* The canvas always scales to fit, so board content must never widen the
* document or scroll sideways. `clip` guarantees that without creating a
* scroll container.
*/
padding-block: var(--board-canvas-overflow-top) var(--board-canvas-overflow-bottom);
margin-block: calc(0px - var(--board-canvas-overflow-top)) calc(0px - var(--board-canvas-overflow-bottom));
overflow-x: auto;
}
.viewport[data-board-hydrated="false"] .horizontalScroller {
overflow: clip;
}
@@ -33,8 +21,10 @@
/*
* Keep dashboard UI at its normal readable density while the fixed canvas
* is fitted below 100%. Above 100%, the canvas still behaves like browser
* zoom and enlarges everything together.
* is fitted below 100%, until the compensation is capped (see
* MAX_BOARD_UI_COMPENSATION) and the board zooms out as a whole instead.
* Above 100%, the canvas behaves like browser zoom and enlarges everything
* together.
*/
--mantine-scale: var(--board-canvas-ui-scale, 1);
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
@@ -8,9 +8,18 @@ import { useElementSize } from "@mantine/hooks";
import classes from "./scaled-board-canvas.module.css";
const BoardCanvasScaleContext = createContext(1);
const MIN_READABLE_CANVAS_SCALE = 0.5;
const CANVAS_OVERFLOW_TOP = 48;
const CANVAS_OVERFLOW_BOTTOM = 8;
/**
* Below 100% the board keeps dashboard UI at its normal physical size by
* inverse-scaling it against the canvas. That compensation cannot continue
* forever: at 0.14 a `200 x 200` logical tile still has to host controls sized
* for a desktop, so labels, tables and buttons overlap their neighbours.
*
* Stop compensating at 50% and let the remaining reduction behave like zooming
* out a page. Tile content then keeps the proportions it was designed with
* instead of overflowing its tile, and the canvas can still always fit the
* viewport - the board never scrolls sideways.
*/
const MAX_BOARD_UI_COMPENSATION = 2;
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
@@ -18,13 +27,14 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth:
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
return Math.max(MIN_READABLE_CANVAS_SCALE, availableWidth / logicalWidth);
return availableWidth / logicalWidth;
};
export const calculateBoardUiScale = (canvasScale: number) => {
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
if (canvasScale >= 1) return 1;
return canvasScale < 1 ? 1 / canvasScale : 1;
return Math.min(1 / canvasScale, MAX_BOARD_UI_COMPENSATION);
};
interface ScaledBoardCanvasProps {
@@ -36,8 +46,8 @@ interface ScaledBoardCanvasProps {
/**
* Scales the complete board as one surface while keeping widget layout in
* fixed logical pixels. Narrow viewports stop scaling at a readable threshold
* and scroll the remaining logical surface horizontally.
* fixed logical pixels. The complete canvas always fits its viewport so board
* content can never widen the document or require horizontal scrolling.
*/
export const ScaledBoardCanvas = ({
logicalWidth,
@@ -63,7 +73,6 @@ export const ScaledBoardCanvas = ({
const uiScale = calculateBoardUiScale(scale);
const inverseScale = scale > 0 ? 1 / scale : 1;
const visualWidth = logicalWidth * scale;
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
return (
<Box
@@ -73,31 +82,22 @@ export const ScaledBoardCanvas = ({
data-testid="board-canvas"
data-board-hydrated={isHydrated ? "true" : "false"}
data-canvas-scale={scale}
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
data-canvas-initial-height={initialLogicalHeight}
data-canvas-initial-width={initialAvailableWidth}
aria-label={label}
>
<Box
className={classes.horizontalScroller}
style={{
"--board-canvas-overflow-top": `${CANVAS_OVERFLOW_TOP * Math.max(1, scale)}px`,
"--board-canvas-overflow-bottom": `${CANVAS_OVERFLOW_BOTTOM * Math.max(1, scale)}px`,
}}
>
<Box className={classes.sizer} style={{ width: visualWidth, height: resolvedLogicalHeight * scale }}>
<Box
ref={canvasRef}
className={classes.canvas}
style={{
"--board-canvas-inverse-scale": inverseScale,
"--board-canvas-ui-scale": uiScale,
width: logicalWidth,
zoom: scale,
}}
>
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
</Box>
<Box className={classes.sizer} style={{ width: visualWidth, height: resolvedLogicalHeight * scale }}>
<Box
ref={canvasRef}
className={classes.canvas}
style={{
"--board-canvas-inverse-scale": inverseScale,
"--board-canvas-ui-scale": uiScale,
width: logicalWidth,
zoom: scale,
}}
>
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
</Box>
</Box>
</Box>
@@ -8,6 +8,17 @@
.board-grid-entry {
z-index: 1;
overflow: visible;
/*
* This element is the dnd-kit draggable and its own drag handle. The touch
* activation constraint only fires after a 200 ms hold, but the browser
* decides who owns a touch gesture when it begins: without this rule the
* board's horizontal scrollport (or the page) claims the pan, emits
* `pointercancel`, and cancels the grid transaction instead of moving the
* widget. Only edit mode renders this element, so ordinary view-mode
* scrolling over widgets is unaffected.
*/
touch-action: none;
}
.board-grid-entry:has([data-board-widget-header]):hover,
@@ -25,6 +25,32 @@ end
return #KEYS
`;
// Reading has to touch the LRU on a hit and reclaim the byte/LRU/size
// bookkeeping on a miss: entry keys carry their own PX expiry, so a TTL-expired
// response would otherwise keep occupying a slot and its bytes forever and
// shrink the usable cache to nothing over a long uptime.
const READ_SCRIPT = `
local value = redis.call('GET', KEYS[1])
if value then
redis.call('ZADD', KEYS[2], ARGV[1], KEYS[1])
return value
end
local size = tonumber(redis.call('HGET', KEYS[3], KEYS[1]) or '0')
if size > 0 then redis.call('DECRBY', KEYS[4], size) end
redis.call('ZREM', KEYS[2], KEYS[1])
redis.call('HDEL', KEYS[3], KEYS[1])
return false
`;
const FORGET_SCRIPT = `
local size = tonumber(redis.call('HGET', KEYS[3], KEYS[1]) or '0')
if size > 0 then redis.call('DECRBY', KEYS[4], size) end
redis.call('DEL', KEYS[1])
redis.call('ZREM', KEYS[2], KEYS[1])
redis.call('HDEL', KEYS[3], KEYS[1])
return 1
`;
const STORE_SCRIPT = `
local previousSize = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
if previousSize > 0 then redis.call('DECRBY', KEYS[3], previousSize) end
@@ -80,13 +106,14 @@ const getEntryKey = (generation: string, cacheKey: string) => {
};
const readCachedResponse = async (client: RedisClient, entryKey: string) => {
const serialized = await client.get(entryKey);
const serialized = (await client.eval(READ_SCRIPT, 4, entryKey, LRU_KEY, SIZE_KEY, TOTAL_BYTES_KEY, Date.now())) as
| string
| null;
if (serialized === null) return undefined;
await client.zadd(LRU_KEY, Date.now(), entryKey);
try {
return JSON.parse(serialized) as CustomWidgetHttpResponse;
} catch {
await client.del(entryKey);
await client.eval(FORGET_SCRIPT, 4, entryKey, LRU_KEY, SIZE_KEY, TOTAL_BYTES_KEY);
return undefined;
}
};
@@ -136,7 +163,10 @@ export async function executeWithCustomWidgetResponseCache(
logger.error("Shared custom widget response cache is unavailable", {
errorName: error instanceof Error ? error.name : "UnknownError",
});
return execute({ ...input, cacheKey: undefined });
// Keep the cache key so the caller's process-local cache still absorbs
// polling while Redis is down. Dropping it would send every refresh of
// every placed widget straight to the upstream API.
return execute(input);
}
const pendingKey = `${generation}:${cacheKey}`;
+3 -2
View File
@@ -13,7 +13,7 @@ import { ZodError } from "zod/v4";
import type { Session } from "@homarr/auth";
import { extractBaseUrlFromHeaders, FlattenError } from "@homarr/common";
import { ipAddressFromHeaders, userAgent } from "@homarr/common/server";
import { trustedIpAddressFromHeaders, userAgent } from "@homarr/common/server";
import type { DeviceType } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
@@ -43,6 +43,7 @@ interface ApiContext {
deviceType: DeviceType;
baseUrl?: `${string}://${string}`;
onboardingClaimToken?: string;
/** Trusted transport peer, safe to key rate limits and quotas on. */
ipAddress?: string;
db: typeof db;
}
@@ -61,7 +62,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
session,
deviceType: userAgent(opts.headers).device.type,
baseUrl: extractBaseUrlFromHeaders(opts.headers),
ipAddress: ipAddressFromHeaders(opts.headers) ?? undefined,
ipAddress: trustedIpAddressFromHeaders(opts.headers) ?? undefined,
onboardingClaimToken: getOnboardingClaimTokenFromCookieHeader(opts.headers.get("cookie")),
db,
};
+19 -3
View File
@@ -10,12 +10,28 @@ export const userAgent = (headers: Headers) => {
export type DeviceType = "console" | "mobile" | "tablet" | "smarttv" | "wearable" | "embedded" | undefined;
/**
* The client-reported forwarding chain, verbatim. Behind an additional reverse
* proxy this still contains the originating client, so it is the useful value
* for audit logging and diagnostics. A caller can put anything in front of the
* chain, so never key a limit or quota on it.
*/
export const ipAddressFromHeaders = (headers: Headers): string | null => {
return headers.get("x-forwarded-for");
};
/**
* The last hop of the forwarding chain. The bundled nginx proxy appends its own
* transport peer, so this is the only entry a caller cannot choose, which makes
* it the right identity for rate limits and concurrency budgets.
*
* Behind an additional reverse proxy the last hop is that proxy, so every client
* behind it shares one identity. Distinguishing them needs an explicit
* trusted-proxy configuration, which Homarr does not have yet.
*/
export const trustedIpAddressFromHeaders = (headers: Headers): string | null => {
const forwardedFor = headers.get("x-forwarded-for");
if (!forwardedFor) return null;
// The bundled nginx proxy appends the transport peer to this chain. Using
// the last hop prevents a caller-controlled prefix from creating arbitrary
// rate-limit identities.
return forwardedFor.split(",").at(-1)?.trim() || null;
};
@@ -11,6 +11,8 @@ import { InactiveWidgetDefinitionProvider, WidgetDefinitionProvider } from "./wi
export { CUSTOM_JSX_METHOD_COLORS } from "@homarr/custom-widgets/runtime";
const EMPTY_RECORD: Record<string, unknown> = {};
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
const t = useScopedI18n("widget.customApi.customJsx");
const capabilities = useMemo(() => parseRequestCapabilities(data.requestCapabilities), [data.requestCapabilities]);
@@ -33,17 +35,33 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
return { ...current, [requestId]: value };
});
}, []);
const baseData = isRecord(data.data) ? data.data : {};
const baseStatus = isRecord(data.status) ? data.status : {};
const baseData = isRecord(data.data) ? data.data : EMPTY_RECORD;
const baseStatus = isRecord(data.status) ? data.status : EMPTY_RECORD;
const options = isRecord(data.options) ? data.options : EMPTY_RECORD;
// The renderer memoizes the interpreted template and derives its error-boundary
// reset key from these prop identities. Merging into a fresh object on every
// render would re-run the interpreter and reset the boundary even when the
// response is byte-identical, so keep the merged records referentially stable.
const mergedData = useMemo(
() =>
Object.keys(queryState).length === 0
? baseData
: { ...baseData, ...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.data])) },
[baseData, queryState],
);
const mergedStatus = useMemo(
() =>
Object.keys(queryState).length === 0
? baseStatus
: { ...baseStatus, ...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.status])) },
[baseStatus, queryState],
);
const renderer = (
<CustomJsxRenderer
template={String(data.template ?? "")}
data={{ ...baseData, ...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.data])) }}
status={{
...baseStatus,
...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.status])),
}}
options={isRecord(data.options) ? data.options : {}}
data={mergedData}
status={mergedStatus}
options={options}
components={components}
createBindings={SAFE_BINDINGS}
messages={{
+11 -2
View File
@@ -20,9 +20,18 @@ if [ "${PUID}" != "0" ] || [ "${PGID}" != "0" ]; then
# existing content, so a persistent volume with stale or root-owned
# entries stays writable after a PUID/PGID change. These trees are small
# (a single sqlite db, a redis dump, a few certs), unlike the /app tree.
#
# /appdata is user-supplied storage and was never chowned before, so it can
# be a CIFS/SMB or NFS mount that rejects chown outright. Those mounts
# already assign ownership themselves, so a failure here is informational:
# `set -e` must not turn it into a container that refuses to start.
mkdir -p /appdata/db /appdata/redis /appdata/trusted-certificates
chown "${PUID}:${PGID}" /appdata
chown -R "${PUID}:${PGID}" /appdata/db /appdata/redis /appdata/trusted-certificates
appdata_chown_failed=0
chown "${PUID}:${PGID}" /appdata || appdata_chown_failed=1
chown -R "${PUID}:${PGID}" /appdata/db /appdata/redis /appdata/trusted-certificates || appdata_chown_failed=1
if [ "${appdata_chown_failed}" = "1" ]; then
echo "Warning: could not change ownership of /appdata. This is expected on mounts that assign ownership themselves (CIFS/SMB, NFS with a fixed uid). Continuing."
fi
# Next.js runtime cache (image optimization, fetch cache, ...)
mkdir -p /app/apps/nextjs/.next/cache
chown -R "${PUID}:${PGID}" /app/apps/nextjs/.next/cache