diff --git a/apps/nextjs/src/app/api/backup/import/route.ts b/apps/nextjs/src/app/api/backup/import/route.ts index eff0b822c..e0ba5a00f 100644 --- a/apps/nextjs/src/app/api/backup/import/route.ts +++ b/apps/nextjs/src/app/api/backup/import/route.ts @@ -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 => { 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); 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); diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css index f50ba9090..e173cee37 100644 --- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css +++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css @@ -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; } diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx index b9dbf4342..f93d22305 100644 --- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx +++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx @@ -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} > - - - {children} + + + + {children} + diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css index 2277b13cd..2cace7d36 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -8,7 +8,6 @@ .board-grid-entry { z-index: 1; overflow: visible; - touch-action: none; } .board-grid-entry:has([data-board-widget-header]):hover, diff --git a/packages/api/src/router/custom-widget/response-cache.ts b/packages/api/src/router/custom-widget/response-cache.ts index eef10c922..92b43e5bb 100644 --- a/packages/api/src/router/custom-widget/response-cache.ts +++ b/packages/api/src/router/custom-widget/response-cache.ts @@ -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>(); +const inFlight = new Map }>(); 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; 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", diff --git a/packages/api/src/router/test/assistant.spec.ts b/packages/api/src/router/test/assistant.spec.ts index a9c699c55..1797676b5 100644 --- a/packages/api/src/router/test/assistant.spec.ts +++ b/packages/api/src/router/test/assistant.spec.ts @@ -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), diff --git a/packages/integrations/src/umami/test/umami-integration.spec.ts b/packages/integrations/src/umami/test/umami-integration.spec.ts index 7507a45b4..c61d6b8ec 100644 --- a/packages/integrations/src/umami/test/umami-integration.spec.ts +++ b/packages/integrations/src/umami/test/umami-integration.spec.ts @@ -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>; }); - 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); }); }); }); diff --git a/packages/onboarding/src/setup-studio.tsx b/packages/onboarding/src/setup-studio.tsx index f8f408a0a..00209c018 100644 --- a/packages/onboarding/src/setup-studio.tsx +++ b/packages/onboarding/src/setup-studio.tsx @@ -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()); const seenAppSourceIds = useRef(new Set()); + const incompleteIntegrationConfirmationIntent = useRef(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, 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 <> setIncompleteIntegrationConfirmationOpened(false)} - title={t("connect.incompleteConfirmation")} + onClose={closeIncompleteIntegrationConfirmation} + title={t("connect.incompleteConfirmationTitle")} centered > {t("connect.incompleteConfirmation")} - - + diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 73e49e7dd..1df0f2594 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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": { diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index fbfe8756d..9788a2c8d 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -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