fix: address coderabbit review feedback

This commit is contained in:
ajnart committed 2026-08-20 15:20:21 +02:00
1 parent 17aef0588f
commit cf624e2f23
10 files changed
+256 -175

No files matched your search

+29 -1
View File
@@ -40,6 +40,7 @@ const MAX_UNCOMPRESSED_ARCHIVE_BYTES = MAX_UNCOMPRESSED_DATABASE_BYTES + 2 * MEB
const MAX_METADATA_BYTES = MEBIBYTE;
const MAX_ENCRYPTION_KEY_BYTES = 1024;
const MAX_ARCHIVE_ENTRIES = 1024;
const UPLOAD_IDLE_TIMEOUT_MS = 60_000;
const RESTART_DELAY_MS = 500;
let restoreInProgress = false;
@@ -73,6 +74,26 @@ const createRequestSizeLimiter = () => {
});
};
const createUploadIdleTimeout = () => {
let timeout: NodeJS.Timeout | undefined;
const stream = new Transform({
transform(chunk: Buffer, _encoding, callback) {
resetTimeout();
callback(null, chunk);
},
});
const resetTimeout = () => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => stream.destroy(new Error("Backup upload timed out")), UPLOAD_IDLE_TIMEOUT_MS);
timeout.unref();
};
resetTimeout();
stream.on("close", () => {
if (timeout) clearTimeout(timeout);
});
return stream;
};
const streamUploadedBackupAsync = async (request: Request, uploadPath: string): Promise<number | null> => {
assertDeclaredRequestSize(request);
if (!request.body) return null;
@@ -128,7 +149,9 @@ const streamUploadedBackupAsync = async (request: Request, uploadPath: string):
const requestStream = Readable.fromWeb(request.body as NodeReadableStream<Uint8Array>);
try {
await pipeline(requestStream, createRequestSizeLimiter(), parser);
await pipeline(requestStream, createUploadIdleTimeout(), createRequestSizeLimiter(), parser, {
signal: request.signal,
});
await uploadWrite;
} catch (error) {
await uploadWrite.catch(() => undefined);
@@ -453,6 +476,11 @@ export async function POST(req: Request) {
return NextResponse.json({ error: message }, { status: 400 });
} finally {
zip.close();
try {
fs.rmSync(uploadPath, { force: true });
} catch {
// The outer restore-directory cleanup retries this path.
}
}
tempDb = new BetterSqlite3(tempPath);
@@ -2,11 +2,24 @@
width: 100%;
max-width: 100%;
min-width: 0;
overflow-x: auto;
overflow-y: visible;
overflow: visible;
}
.viewport[data-board-hydrated="false"] {
.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.
*/
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;
}
@@ -9,6 +9,8 @@ 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;
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
@@ -76,18 +78,26 @@ export const ScaledBoardCanvas = ({
data-canvas-initial-width={initialAvailableWidth}
aria-label={label}
>
<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
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>
</Box>
</Box>
@@ -8,7 +8,6 @@
.board-grid-entry {
z-index: 1;
overflow: visible;
touch-action: none;
}
.board-grid-entry:has([data-board-widget-header]):hover,
@@ -7,13 +7,24 @@ import type { RedisClient } from "@homarr/core/infrastructure/redis";
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "@homarr/custom-widgets/server";
const ENTRY_PREFIX = "custom-widget:response-cache:v2:entry:";
const GENERATION_KEY = "custom-widget:response-cache:v2:generation";
const GENERATION_PREFIX = "custom-widget:response-cache:v2:generation:";
const LRU_KEY = "custom-widget:response-cache:v2:lru";
const SIZE_KEY = "custom-widget:response-cache:v2:sizes";
const TOTAL_BYTES_KEY = "custom-widget:response-cache:v2:bytes";
// Outlive the one-hour response TTL and 45-second request deadline so a
// generation cannot reset while an entry from the previous one is reachable.
const GENERATION_RETENTION_SECONDS = 2 * 60 * 60;
const MAX_ENTRIES = 1_000;
const MAX_BYTES = 64 * 1024 * 1024;
const INVALIDATE_GENERATIONS_SCRIPT = `
for _, key in ipairs(KEYS) do
redis.call('INCR', key)
redis.call('EXPIRE', key, ARGV[1])
end
return #KEYS
`;
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
@@ -34,7 +45,7 @@ return totalBytes
`;
const logger = createLogger({ module: "custom-widget:response-cache" });
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
const inFlight = new Map<string, { cacheKey: string; request: Promise<CustomWidgetHttpResponse> }>();
let redis: RedisClient | undefined;
const useSharedCache = () => process.env.NODE_ENV !== "test";
@@ -44,9 +55,26 @@ const getRedis = () => {
return redis;
};
const getGeneration = async (client: RedisClient) => Number((await client.get(GENERATION_KEY)) ?? 0);
const getGenerationKey = (prefix: string) => `${GENERATION_PREFIX}${createHash("sha256").update(prefix).digest("hex")}`;
const getEntryKey = (generation: number, cacheKey: string) => {
const getCacheKeyPrefixes = (cacheKey: string) => {
const prefixes: string[] = [];
let separatorIndex = cacheKey.indexOf(":");
while (separatorIndex >= 0) {
prefixes.push(cacheKey.slice(0, separatorIndex + 1));
separatorIndex = cacheKey.indexOf(":", separatorIndex + 1);
}
prefixes.push(cacheKey);
return prefixes;
};
const getGeneration = async (client: RedisClient, cacheKey: string) => {
const keys = getCacheKeyPrefixes(cacheKey).map(getGenerationKey);
const generations = await client.mget(...keys);
return generations.map((generation) => generation ?? "0").join(":");
};
const getEntryKey = (generation: string, cacheKey: string) => {
const digest = createHash("sha256").update(cacheKey).digest("hex");
return `${ENTRY_PREFIX}${generation}:${digest}`;
};
@@ -96,10 +124,10 @@ export async function executeWithCustomWidgetResponseCache(
if (!useSharedCache()) return execute(input);
let client: RedisClient;
let generation: number;
let generation: string;
try {
client = getRedis();
generation = await getGeneration(client);
generation = await getGeneration(client, cacheKey);
if ((input.cacheTtlSeconds ?? 0) > 0) {
const cached = await readCachedResponse(client, getEntryKey(generation, cacheKey));
if (cached) return cached;
@@ -113,7 +141,7 @@ export async function executeWithCustomWidgetResponseCache(
const pendingKey = `${generation}:${cacheKey}`;
const pending = inFlight.get(pendingKey);
if (pending) return pending;
if (pending) return pending.request;
let request: Promise<CustomWidgetHttpResponse>;
request = execute({ ...input, cacheKey: undefined })
@@ -121,7 +149,7 @@ export async function executeWithCustomWidgetResponseCache(
const ttlSeconds = input.cacheTtlSeconds ?? 0;
if (!response.ok || ttlSeconds <= 0) return response;
try {
const currentGeneration = await getGeneration(client);
const currentGeneration = await getGeneration(client, cacheKey);
if (currentGeneration === generation) {
await storeCachedResponse(client, getEntryKey(generation, cacheKey), response, ttlSeconds);
}
@@ -133,19 +161,21 @@ export async function executeWithCustomWidgetResponseCache(
return response;
})
.finally(() => {
if (inFlight.get(pendingKey) === request) inFlight.delete(pendingKey);
if (inFlight.get(pendingKey)?.request === request) inFlight.delete(pendingKey);
});
inFlight.set(pendingKey, request);
inFlight.set(pendingKey, { cacheKey, request });
return request;
}
export async function invalidateSharedCustomWidgetResponseCache(prefixes: readonly string[]) {
if (prefixes.length === 0 || !useSharedCache()) return;
for (const key of inFlight.keys()) {
if (prefixes.some((prefix) => key.includes(prefix))) inFlight.delete(key);
const uniquePrefixes = [...new Set(prefixes)];
for (const [key, pending] of inFlight) {
if (uniquePrefixes.some((prefix) => pending.cacheKey.startsWith(prefix))) inFlight.delete(key);
}
try {
await getRedis().incr(GENERATION_KEY);
const keys = uniquePrefixes.map(getGenerationKey);
await getRedis().eval(INVALIDATE_GENERATIONS_SCRIPT, keys.length, ...keys, GENERATION_RETENTION_SECONDS);
} catch (error) {
logger.error("Failed to invalidate the shared custom widget response cache", {
errorName: error instanceof Error ? error.name : "UnknownError",
+97 -117
View File
@@ -1,6 +1,6 @@
// @vitest-environment node
import { describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { parse } from "superjson";
import type { Session } from "@homarr/auth";
@@ -34,6 +34,14 @@ vi.mock("@homarr/core/infrastructure/logs", () => ({
vi.mock("@homarr/core/infrastructure/db/env", () => ({
dbEnv: { DRIVER: "better-sqlite3" },
}));
const mocks = vi.hoisted(() => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: mocks.fetchWithTrustedCertificatesAsync,
}));
afterEach(() => {
mocks.fetchWithTrustedCertificatesAsync.mockReset();
});
const adminSession = {
user: {
@@ -169,30 +177,23 @@ describe("assistantRouter.updateConfiguration", () => {
.set({ encryptedHeaders: null })
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({
data: [
{
id: "deepseek/deepseek-v4-pro",
name: "DeepSeek: DeepSeek V4 Pro",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
),
mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue(
Response.json({
data: [
{
id: "deepseek/deepseek-v4-pro",
name: "DeepSeek: DeepSeek V4 Pro",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
);
try {
await caller.updateConfiguration({
enabled: true,
modelId: "DeepSeek: DeepSeek V4 Pro (deepseek/deepseek-v4-pro)",
});
} finally {
vi.unstubAllGlobals();
}
await caller.updateConfiguration({
enabled: true,
modelId: "DeepSeek: DeepSeek V4 Pro (deepseek/deepseek-v4-pro)",
});
const [configuration] = await db.select().from(assistantConfigurations);
expect(configuration?.modelId).toBe("deepseek/deepseek-v4-pro");
@@ -221,31 +222,24 @@ describe("assistantRouter.updateConfiguration", () => {
})
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({
data: [
{
id: "homarr/model",
name: "Homarr model",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
),
mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue(
Response.json({
data: [
{
id: "homarr/model",
name: "Homarr model",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
);
try {
await caller.updateConfiguration({
enabled: true,
modelId: "homarr/model",
webSearchEnabled: true,
});
} finally {
vi.unstubAllGlobals();
}
await caller.updateConfiguration({
enabled: true,
modelId: "homarr/model",
webSearchEnabled: true,
});
const [configuration] = await db.select().from(assistantConfigurations);
expect(configuration?.webSearchEnabled).toBe(true);
@@ -277,56 +271,49 @@ describe("assistant conversation features", () => {
})
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({
data: [
{
id: "example/model",
name: "Example Model",
supported_parameters: ["tools"],
architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
},
{
id: "example/no-tools",
name: "No tools",
supported_parameters: [],
architecture: { output_modalities: ["text"] },
},
{
id: "example/unnamed",
name: "",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
),
);
try {
const options = await caller.getRuntimeOptions();
expect(options).toEqual({
provider: "openrouter",
defaultModelId: "example/model",
models: [
expect.objectContaining({
mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue(
Response.json({
data: [
{
id: "example/model",
name: "Example Model",
inputModalities: ["text", "image"],
}),
expect.objectContaining({
supported_parameters: ["tools"],
architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
},
{
id: "example/no-tools",
name: "No tools",
supported_parameters: [],
architecture: { output_modalities: ["text"] },
},
{
id: "example/unnamed",
name: "example/unnamed",
}),
name: "",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
});
expect(JSON.stringify(options)).not.toContain("encrypted");
} finally {
vi.unstubAllGlobals();
}
}),
);
const options = await caller.getRuntimeOptions();
expect(options).toEqual({
provider: "openrouter",
defaultModelId: "example/model",
models: [
expect.objectContaining({
id: "example/model",
name: "Example Model",
inputModalities: ["text", "image"],
}),
expect.objectContaining({
id: "example/unnamed",
name: "example/unnamed",
}),
],
});
expect(JSON.stringify(options)).not.toContain("encrypted");
});
test("returns permission-checked entities for composer mentions", async () => {
@@ -383,33 +370,26 @@ describe("assistant conversation features", () => {
.where(eq(assistantConfigurations.id, "default"));
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const thread = await caller.createThread();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
Response.json({
data: [
{
id: "example/model",
name: "Default",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
{
id: "example/reasoning",
name: "Reasoning",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
),
mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue(
Response.json({
data: [
{
id: "example/model",
name: "Default",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
{
id: "example/reasoning",
name: "Reasoning",
supported_parameters: ["tools"],
architecture: { output_modalities: ["text"] },
},
],
}),
);
try {
await caller.updateThreadModel({ threadId: thread.id, modelId: "example/reasoning" });
} finally {
vi.unstubAllGlobals();
}
await caller.updateThreadModel({ threadId: thread.id, modelId: "example/reasoning" });
const storedThread = await db.query.assistantThreads.findFirst({
where: eq(assistantThreads.id, thread.id),
@@ -172,7 +172,7 @@ describe("UmamiIntegration", () => {
expect(result[0]?.dataPoints.reduce((sum, point) => sum + point.y, 0)).toBe(20 * PAGE_SIZE);
});
test("requests event series one at a time", async () => {
test("requests event series with bounded concurrency", async () => {
let inFlight = 0;
let maxInFlight = 0;
mockFetch.mockImplementation(async () => {
@@ -186,14 +186,11 @@ describe("UmamiIntegration", () => {
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
});
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", [
"signup",
"purchase",
"refund",
]);
const eventNames = ["signup", "purchase", "refund", "login", "logout", "download"];
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", eventNames);
expect(result).toHaveLength(3);
expect(maxInFlight).toBe(1);
expect(result.map(({ eventName }) => eventName)).toEqual(eventNames);
expect(maxInFlight).toBe(4);
});
});
});
+38 -18
View File
@@ -110,6 +110,7 @@ import classes from "./onboarding-studio.module.css";
type StudioSection = "essentials" | "discover" | "connect" | "board" | "extend" | "review";
type LayoutPreset = "balanced" | "wide" | "focused";
type IncompleteIntegrationConfirmationIntent = "navigate" | "apply";
const getLayoutPresetForColumnCount = (columnCount: number): LayoutPreset =>
columnCount <= 8 ? "focused" : columnCount <= 10 ? "balanced" : "wide";
@@ -195,6 +196,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
const focusSectionHeading = useRef(true);
const seenIntegrationSourceIds = useRef(new Set<string>());
const seenAppSourceIds = useRef(new Set<string>());
const incompleteIntegrationConfirmationIntent = useRef<IncompleteIntegrationConfirmationIntent | null>(null);
const docker = clientApi.onboard.discoverDockerServices.useQuery(undefined, {
enabled: environment.dockerConfigured,
@@ -234,16 +236,19 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
() => new Set(discoveredIntegrations.map((integration) => integration.kind)),
[discoveredIntegrations],
);
const incompleteIntegrationSignature = JSON.stringify(
drafts
.filter((draft) => !isIntegrationDraftComplete(draft))
.map((draft) => ({
id: draft.id,
hasUrl: draft.url.trim().length > 0,
secrets: draft.secrets.map((secret) => ({ kind: secret.kind, hasValue: secret.value.trim().length > 0 })),
})),
const incompleteDrafts = useMemo(() => drafts.filter((draft) => !isIntegrationDraftComplete(draft)), [drafts]);
const incompleteIntegrationSignature = useMemo(
() =>
JSON.stringify(
incompleteDrafts.map((draft) => ({
id: draft.id,
hasUrl: draft.url.trim().length > 0,
secrets: draft.secrets.map((secret) => ({ kind: secret.kind, hasValue: secret.value.trim().length > 0 })),
})),
),
[incompleteDrafts],
);
const hasIncompleteIntegrations = incompleteIntegrationSignature !== "[]";
const hasIncompleteIntegrations = incompleteDrafts.length > 0;
const incompleteIntegrationsConfirmed =
hasIncompleteIntegrations && confirmedIncompleteIntegrationSignature === incompleteIntegrationSignature;
@@ -350,16 +355,16 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
hasIncompleteIntegrations &&
!incompleteIntegrationsConfirmed
) {
incompleteIntegrationConfirmationIntent.current = "navigate";
setIncompleteIntegrationConfirmationOpened(true);
return;
}
changeSection(section);
};
const confirmReviewNavigation = () => {
setConfirmedIncompleteIntegrationSignature(incompleteIntegrationSignature);
const closeIncompleteIntegrationConfirmation = () => {
incompleteIntegrationConfirmationIntent.current = null;
setIncompleteIntegrationConfirmationOpened(false);
changeSection("review");
};
const handleSectionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
@@ -397,9 +402,10 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
});
};
const applySetupAsync = async () => {
const applySetupAsync = async (skipIncompleteIntegrationConfirmation = false) => {
if (isApplying) return;
if (hasIncompleteIntegrations && !incompleteIntegrationsConfirmed) {
if (!skipIncompleteIntegrationConfirmation && hasIncompleteIntegrations && !incompleteIntegrationsConfirmed) {
incompleteIntegrationConfirmationIntent.current = "apply";
setIncompleteIntegrationConfirmationOpened(true);
return;
}
@@ -532,6 +538,20 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
}
};
const confirmIncompleteIntegrations = () => {
const intent = incompleteIntegrationConfirmationIntent.current;
if (!intent) return;
incompleteIntegrationConfirmationIntent.current = null;
setConfirmedIncompleteIntegrationSignature(incompleteIntegrationSignature);
setIncompleteIntegrationConfirmationOpened(false);
if (intent === "apply") {
void applySetupAsync(true);
return;
}
changeSection("review");
};
const sectionProps: StudioSectionProps = {
headingRef: sectionHeadingRef,
environment,
@@ -641,17 +661,17 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
<>
<Modal
opened={incompleteIntegrationConfirmationOpened}
onClose={() => setIncompleteIntegrationConfirmationOpened(false)}
title={t("connect.incompleteConfirmation")}
onClose={closeIncompleteIntegrationConfirmation}
title={t("connect.incompleteConfirmationTitle")}
centered
>
<Stack gap="md">
<Text size="sm">{t("connect.incompleteConfirmation")}</Text>
<Group justify="flex-end" gap="xs">
<Button variant="default" onClick={() => setIncompleteIntegrationConfirmationOpened(false)}>
<Button variant="default" onClick={closeIncompleteIntegrationConfirmation}>
{tCommon("cancel")}
</Button>
<Button onClick={confirmReviewNavigation}>{tCommon("confirm")}</Button>
<Button onClick={confirmIncompleteIntegrations}>{tCommon("confirm")}</Button>
</Group>
</Stack>
</Modal>
+1
View File
@@ -128,6 +128,7 @@
"needsConfiguration": "Needs configuration",
"tested": "Connection tested",
"testFailed": "Connection test failed",
"incompleteConfirmationTitle": "Skip unfinished integrations?",
"incompleteConfirmation": "Unfinished integrations will be skipped. Continue without them?"
},
"board": {
+5 -2
View File
@@ -30,8 +30,11 @@ if [ "${PUID}" != "0" ] || [ "${PGID}" != "0" ]; then
chown -R "${PUID}:${PGID}" /var/cache/nginx
chown -R "${PUID}:${PGID}" /var/log/nginx
chown -R "${PUID}:${PGID}" /var/lib/nginx
chown -R "${PUID}:${PGID}" /run/nginx/nginx.pid
chown -R "${PUID}:${PGID}" /etc/nginx
mkdir -p /run/nginx
touch /run/nginx/nginx.pid
chown -R "${PUID}:${PGID}" /run/nginx
touch /etc/nginx/nginx.conf
chown "${PUID}:${PGID}" /etc/nginx/nginx.conf
echo "Changing owner to $PUID:$PGID, done."
fi