fix: persist service media uploads

This commit is contained in:
DCCONSTRUCTIONS 2026-05-15 22:59:42 +03:00
parent 3094464f62
commit a81caed95d
3 changed files with 106 additions and 36 deletions

View File

@ -14,6 +14,7 @@ FROM node:20-alpine AS runner
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=5173 ENV PORT=5173
ENV NODEDC_LAUNCHER_STORAGE_DIR=/app/server/storage ENV NODEDC_LAUNCHER_STORAGE_DIR=/app/server/storage
ENV NODEDC_LAUNCHER_UPLOADS_DIR=/app/server/storage/uploads
WORKDIR /app WORKDIR /app
@ -23,7 +24,7 @@ COPY --from=build /app/server ./server
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY --from=build /app/public ./public 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 && chown -R node:node /app/server/storage /app/dist/storage /app/public/storage
USER node USER node

View File

@ -3,7 +3,7 @@ import { createServer as createHttpServer } from "node:http";
import { randomBytes, randomUUID, createHash, timingSafeEqual } from "node:crypto"; import { randomBytes, randomUUID, createHash, timingSafeEqual } from "node:crypto";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises"; 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 { fileURLToPath } from "node:url";
import { createRemoteJWKSet, jwtVerify } from "jose"; import { createRemoteJWKSet, jwtVerify } from "jose";
import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs"; import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs";
@ -31,6 +31,8 @@ const config = readConfig();
const app = express(); const app = express();
const httpServer = createHttpServer(app); const httpServer = createHttpServer(app);
const controlPlaneStore = createControlPlaneStore({ projectRoot }); const controlPlaneStore = createControlPlaneStore({ projectRoot });
const runtimeStorageRoot = resolveRuntimeStorageRoot(projectRoot);
const runtimeUploadsRoot = resolveRuntimeUploadsRoot(projectRoot, runtimeStorageRoot);
const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken }); const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken });
const pendingLogins = new Map(); const pendingLogins = new Map();
const serviceHandoffs = 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" }); 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 = () => {}; let fixFrontendStacktrace = () => {};
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
@ -2821,10 +2837,9 @@ async function saveUploadedFile(payload) {
const fileBuffer = Buffer.from(match[2], "base64"); const fileBuffer = Buffer.from(match[2], "base64");
await Promise.all( await Promise.all(
getWritableStorageRoots().map(async (storageRoot) => { getWritableUploadRoots().map(async (uploadRoot) => {
const uploadDir = join(storageRoot, "uploads"); await mkdir(uploadRoot, { recursive: true });
await mkdir(uploadDir, { recursive: true }); await writeFile(join(uploadRoot, storedName), fileBuffer);
await writeFile(join(uploadDir, storedName), fileBuffer);
}) })
); );
@ -2841,15 +2856,22 @@ async function saveLauncherData(payload) {
await controlPlaneStore.writeData(payload); await controlPlaneStore.writeData(payload);
} }
function getWritableStorageRoots() { function getWritableUploadRoots() {
const roots = [join(projectRoot, "public", "storage")]; return [runtimeUploadsRoot];
}
function getReadableUploadRoots() {
const roots = [
runtimeUploadsRoot,
join(projectRoot, "public", "storage", "uploads"),
];
const distRoot = join(projectRoot, "dist"); const distRoot = join(projectRoot, "dist");
if (existsSync(distRoot)) { if (existsSync(distRoot)) {
roots.push(join(distRoot, "storage")); roots.push(join(distRoot, "storage", "uploads"));
} }
return roots; return [...new Set(roots)];
} }
function buildStoredFileName(fileName, mimeType) { function buildStoredFileName(fileName, mimeType) {
@ -2876,6 +2898,26 @@ function extensionFromMimeType(mimeType) {
return ""; 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) { function isUploadPayload(payload) {
return Boolean( return Boolean(
payload && payload &&

View File

@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useState, type ReactNode } from "react"; import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { import {
closestCenter, closestCenter,
DndContext, DndContext,
@ -1965,6 +1965,9 @@ function ServiceContentModal({
onDelete: () => void; onDelete: () => void;
}) { }) {
const [draft, setDraft] = useState<Service>(service); const [draft, setDraft] = useState<Service>(service);
const draftRef = useRef<Service>(service);
const serviceIdRef = useRef(service.id);
const hasUnsavedDraftRef = useRef(false);
const [mediaPreviewUrls, setMediaPreviewUrls] = useState<{ cover: string | null; ambient: string | null }>({ const [mediaPreviewUrls, setMediaPreviewUrls] = useState<{ cover: string | null; ambient: string | null }>({
cover: service.coverImageUrl ?? null, cover: service.coverImageUrl ?? null,
ambient: service.ambientVideoUrl ?? null, ambient: service.ambientVideoUrl ?? null,
@ -1976,6 +1979,15 @@ function ServiceContentModal({
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => { useEffect(() => {
const isAnotherService = service.id !== serviceIdRef.current;
if (!isAnotherService && hasUnsavedDraftRef.current) {
return;
}
serviceIdRef.current = service.id;
hasUnsavedDraftRef.current = false;
draftRef.current = service;
setDraft(service); setDraft(service);
setMediaPreviewUrls({ setMediaPreviewUrls({
cover: service.coverImageUrl ?? null, cover: service.coverImageUrl ?? null,
@ -1997,8 +2009,15 @@ function ServiceContentModal({
}; };
}, [mediaPreviewUrls]); }, [mediaPreviewUrls]);
function patchDraft(patch: Partial<Service>, options: { markUnsaved?: boolean } = {}) {
const nextDraft = { ...draftRef.current, ...patch };
hasUnsavedDraftRef.current = options.markUnsaved ?? true;
draftRef.current = nextDraft;
setDraft(nextDraft);
}
function update<K extends keyof Service>(key: K, value: Service[K]) { function update<K extends keyof Service>(key: K, value: Service[K]) {
setDraft((current) => ({ ...current, [key]: value })); patchDraft({ [key]: value } as Partial<Service>);
} }
function updateMediaPreview(slot: "cover" | "ambient", previewUrl: string | null) { function updateMediaPreview(slot: "cover" | "ambient", previewUrl: string | null) {
@ -2026,19 +2045,24 @@ function ServiceContentModal({
const mediaKind = mediaKindFromFile(file); const mediaKind = mediaKindFromFile(file);
if (slot === "cover") { if (slot === "cover") {
update("coverImageUrl", storedFile.url); patchDraft({
update("coverMediaKind", mediaKind); coverImageUrl: storedFile.url,
update("coverMediaSource", "file"); coverMediaKind: mediaKind,
update("coverMediaFileName", storedFile.fileName); coverMediaSource: "file",
coverMediaFileName: storedFile.fileName,
});
} else { } else {
update("ambientVideoUrl", storedFile.url); patchDraft({
update("ambientMediaKind", mediaKind); ambientVideoUrl: storedFile.url,
update("ambientMediaSource", "file"); ambientMediaKind: mediaKind,
update("ambientMediaFileName", storedFile.fileName); ambientMediaSource: "file",
ambientMediaFileName: storedFile.fileName,
});
} }
updateMediaPreview(slot, storedFile.url);
} catch (error) { } catch (error) {
setStorageError(error instanceof Error ? error.message : "Не удалось сохранить файл в storage"); 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 { } finally {
setUploadingSlot(null); setUploadingSlot(null);
} }
@ -2048,26 +2072,29 @@ function ServiceContentModal({
setSaveError(null); setSaveError(null);
setIsSaving(true); setIsSaving(true);
const currentDraft = draftRef.current;
const result = await onSave({ const result = await onSave({
title: draft.title, title: currentDraft.title,
subtitle: draft.subtitle, subtitle: currentDraft.subtitle,
description: draft.description, description: currentDraft.description,
fullDescription: draft.fullDescription, fullDescription: currentDraft.fullDescription,
url: draft.url, url: currentDraft.url,
launchUrl: draft.launchUrl, launchUrl: currentDraft.launchUrl,
coverImageUrl: draft.coverImageUrl, coverImageUrl: currentDraft.coverImageUrl,
coverMediaKind: draft.coverMediaKind, coverMediaKind: currentDraft.coverMediaKind,
coverMediaSource: draft.coverMediaSource, coverMediaSource: currentDraft.coverMediaSource,
coverMediaFileName: draft.coverMediaFileName, coverMediaFileName: currentDraft.coverMediaFileName,
ambientVideoUrl: draft.ambientVideoUrl, ambientVideoUrl: currentDraft.ambientVideoUrl,
ambientMediaKind: draft.ambientMediaKind, ambientMediaKind: currentDraft.ambientMediaKind,
ambientMediaSource: draft.ambientMediaSource, ambientMediaSource: currentDraft.ambientMediaSource,
ambientMediaFileName: draft.ambientMediaFileName, ambientMediaFileName: currentDraft.ambientMediaFileName,
}); });
if (!result.ok) { if (!result.ok) {
setSaveError(result.message); setSaveError(result.message);
setIsSaving(false); setIsSaving(false);
} else {
hasUnsavedDraftRef.current = false;
} }
} }
@ -2111,7 +2138,7 @@ function ServiceContentModal({
</span> </span>
<input <input
value={getServiceLaunchLink(draft)} value={getServiceLaunchLink(draft)}
onChange={(event) => setDraft((current) => ({ ...current, ...createServiceLaunchLinkPatch(event.target.value) }))} onChange={(event) => patchDraft(createServiceLaunchLinkPatch(event.target.value))}
/> />
</label> </label>