fix: persist service media uploads
This commit is contained in:
parent
3094464f62
commit
a81caed95d
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 &&
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue