From a81caed95ddfe228e61db5d89add3eadd96c28df Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 15 May 2026 22:59:42 +0300 Subject: [PATCH] fix: persist service media uploads --- Dockerfile | 3 +- server/dev-server.mjs | 60 +++++++++++++--- src/widgets/admin-overlay/AdminOverlay.tsx | 79 +++++++++++++++------- 3 files changed, 106 insertions(+), 36 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3b23604..e149706 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ FROM node:20-alpine AS runner ENV NODE_ENV=production ENV PORT=5173 ENV NODEDC_LAUNCHER_STORAGE_DIR=/app/server/storage +ENV NODEDC_LAUNCHER_UPLOADS_DIR=/app/server/storage/uploads WORKDIR /app @@ -23,7 +24,7 @@ COPY --from=build /app/server ./server COPY --from=build /app/dist ./dist COPY --from=build /app/public ./public -RUN mkdir -p /app/server/storage /app/dist/storage/uploads /app/public/storage/uploads \ +RUN mkdir -p /app/server/storage/uploads /app/dist/storage/uploads /app/public/storage/uploads \ && chown -R node:node /app/server/storage /app/dist/storage /app/public/storage USER node diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 102f2ad..80b6433 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -3,7 +3,7 @@ import { createServer as createHttpServer } from "node:http"; import { randomBytes, randomUUID, createHash, timingSafeEqual } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, extname, join, resolve } from "node:path"; +import { dirname, extname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { createRemoteJWKSet, jwtVerify } from "jose"; import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs"; @@ -31,6 +31,8 @@ const config = readConfig(); const app = express(); const httpServer = createHttpServer(app); const controlPlaneStore = createControlPlaneStore({ projectRoot }); +const runtimeStorageRoot = resolveRuntimeStorageRoot(projectRoot); +const runtimeUploadsRoot = resolveRuntimeUploadsRoot(projectRoot, runtimeStorageRoot); const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken }); const pendingLogins = new Map(); const serviceHandoffs = new Map(); @@ -1533,6 +1535,20 @@ app.get("/storage/launcher-data.json", (_req, res) => { res.status(404).json({ error: "not_found" }); }); +for (const uploadRoot of getReadableUploadRoots()) { + app.use( + "/storage/uploads", + express.static(uploadRoot, { + fallthrough: true, + immutable: true, + maxAge: "1y", + setHeaders(res) { + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + }, + }) + ); +} + let fixFrontendStacktrace = () => {}; if (process.env.NODE_ENV === "production") { @@ -2821,10 +2837,9 @@ async function saveUploadedFile(payload) { const fileBuffer = Buffer.from(match[2], "base64"); await Promise.all( - getWritableStorageRoots().map(async (storageRoot) => { - const uploadDir = join(storageRoot, "uploads"); - await mkdir(uploadDir, { recursive: true }); - await writeFile(join(uploadDir, storedName), fileBuffer); + getWritableUploadRoots().map(async (uploadRoot) => { + await mkdir(uploadRoot, { recursive: true }); + await writeFile(join(uploadRoot, storedName), fileBuffer); }) ); @@ -2841,15 +2856,22 @@ async function saveLauncherData(payload) { await controlPlaneStore.writeData(payload); } -function getWritableStorageRoots() { - const roots = [join(projectRoot, "public", "storage")]; +function getWritableUploadRoots() { + return [runtimeUploadsRoot]; +} + +function getReadableUploadRoots() { + const roots = [ + runtimeUploadsRoot, + join(projectRoot, "public", "storage", "uploads"), + ]; const distRoot = join(projectRoot, "dist"); if (existsSync(distRoot)) { - roots.push(join(distRoot, "storage")); + roots.push(join(distRoot, "storage", "uploads")); } - return roots; + return [...new Set(roots)]; } function buildStoredFileName(fileName, mimeType) { @@ -2876,6 +2898,26 @@ function extensionFromMimeType(mimeType) { return ""; } +function resolveRuntimeStorageRoot(root) { + const configuredRoot = process.env.NODEDC_LAUNCHER_STORAGE_DIR; + + if (configuredRoot && configuredRoot.trim()) { + return isAbsolute(configuredRoot) ? configuredRoot : resolve(root, configuredRoot); + } + + return join(root, "server", "storage"); +} + +function resolveRuntimeUploadsRoot(root, storageRoot) { + const configuredRoot = process.env.NODEDC_LAUNCHER_UPLOADS_DIR; + + if (configuredRoot && configuredRoot.trim()) { + return isAbsolute(configuredRoot) ? configuredRoot : resolve(root, configuredRoot); + } + + return join(storageRoot, "uploads"); +} + function isUploadPayload(payload) { return Boolean( payload && diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index 34c24b1..7be9314 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react"; +import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { closestCenter, DndContext, @@ -1965,6 +1965,9 @@ function ServiceContentModal({ onDelete: () => void; }) { const [draft, setDraft] = useState(service); + const draftRef = useRef(service); + const serviceIdRef = useRef(service.id); + const hasUnsavedDraftRef = useRef(false); const [mediaPreviewUrls, setMediaPreviewUrls] = useState<{ cover: string | null; ambient: string | null }>({ cover: service.coverImageUrl ?? null, ambient: service.ambientVideoUrl ?? null, @@ -1976,6 +1979,15 @@ function ServiceContentModal({ const [deleteOpen, setDeleteOpen] = useState(false); useEffect(() => { + const isAnotherService = service.id !== serviceIdRef.current; + + if (!isAnotherService && hasUnsavedDraftRef.current) { + return; + } + + serviceIdRef.current = service.id; + hasUnsavedDraftRef.current = false; + draftRef.current = service; setDraft(service); setMediaPreviewUrls({ cover: service.coverImageUrl ?? null, @@ -1997,8 +2009,15 @@ function ServiceContentModal({ }; }, [mediaPreviewUrls]); + function patchDraft(patch: Partial, options: { markUnsaved?: boolean } = {}) { + const nextDraft = { ...draftRef.current, ...patch }; + hasUnsavedDraftRef.current = options.markUnsaved ?? true; + draftRef.current = nextDraft; + setDraft(nextDraft); + } + function update(key: K, value: Service[K]) { - setDraft((current) => ({ ...current, [key]: value })); + patchDraft({ [key]: value } as Partial); } function updateMediaPreview(slot: "cover" | "ambient", previewUrl: string | null) { @@ -2026,19 +2045,24 @@ function ServiceContentModal({ const mediaKind = mediaKindFromFile(file); if (slot === "cover") { - update("coverImageUrl", storedFile.url); - update("coverMediaKind", mediaKind); - update("coverMediaSource", "file"); - update("coverMediaFileName", storedFile.fileName); + patchDraft({ + coverImageUrl: storedFile.url, + coverMediaKind: mediaKind, + coverMediaSource: "file", + coverMediaFileName: storedFile.fileName, + }); } else { - update("ambientVideoUrl", storedFile.url); - update("ambientMediaKind", mediaKind); - update("ambientMediaSource", "file"); - update("ambientMediaFileName", storedFile.fileName); + patchDraft({ + ambientVideoUrl: storedFile.url, + ambientMediaKind: mediaKind, + ambientMediaSource: "file", + ambientMediaFileName: storedFile.fileName, + }); } + updateMediaPreview(slot, storedFile.url); } catch (error) { setStorageError(error instanceof Error ? error.message : "Не удалось сохранить файл в storage"); - updateMediaPreview(slot, slot === "cover" ? (draft.coverImageUrl ?? null) : (draft.ambientVideoUrl ?? null)); + updateMediaPreview(slot, slot === "cover" ? (draftRef.current.coverImageUrl ?? null) : (draftRef.current.ambientVideoUrl ?? null)); } finally { setUploadingSlot(null); } @@ -2048,26 +2072,29 @@ function ServiceContentModal({ setSaveError(null); setIsSaving(true); + const currentDraft = draftRef.current; const result = await onSave({ - title: draft.title, - subtitle: draft.subtitle, - description: draft.description, - fullDescription: draft.fullDescription, - url: draft.url, - launchUrl: draft.launchUrl, - coverImageUrl: draft.coverImageUrl, - coverMediaKind: draft.coverMediaKind, - coverMediaSource: draft.coverMediaSource, - coverMediaFileName: draft.coverMediaFileName, - ambientVideoUrl: draft.ambientVideoUrl, - ambientMediaKind: draft.ambientMediaKind, - ambientMediaSource: draft.ambientMediaSource, - ambientMediaFileName: draft.ambientMediaFileName, + title: currentDraft.title, + subtitle: currentDraft.subtitle, + description: currentDraft.description, + fullDescription: currentDraft.fullDescription, + url: currentDraft.url, + launchUrl: currentDraft.launchUrl, + coverImageUrl: currentDraft.coverImageUrl, + coverMediaKind: currentDraft.coverMediaKind, + coverMediaSource: currentDraft.coverMediaSource, + coverMediaFileName: currentDraft.coverMediaFileName, + ambientVideoUrl: currentDraft.ambientVideoUrl, + ambientMediaKind: currentDraft.ambientMediaKind, + ambientMediaSource: currentDraft.ambientMediaSource, + ambientMediaFileName: currentDraft.ambientMediaFileName, }); if (!result.ok) { setSaveError(result.message); setIsSaving(false); + } else { + hasUnsavedDraftRef.current = false; } } @@ -2111,7 +2138,7 @@ function ServiceContentModal({ setDraft((current) => ({ ...current, ...createServiceLaunchLinkPatch(event.target.value) }))} + onChange={(event) => patchDraft(createServiceLaunchLinkPatch(event.target.value))} />