feat(webdav): add WebDAV synchronization configuration and functionality

This commit is contained in:
HouYunFei
2026-06-08 14:59:30 +08:00
parent dd9347e85d
commit 39a1e859b1
10 changed files with 845 additions and 4 deletions
+394
View File
@@ -0,0 +1,394 @@
"use client";
import localforage from "localforage";
import { getMediaBlob, resolveMediaUrl, setMediaBlob } from "@/services/file-storage";
import { getImageBlob, resolveImageUrl, setImageBlob } from "@/services/image-storage";
import { downloadWebdavFile, uploadWebdavFile, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
import type { Asset } from "@/stores/use-asset-store";
import { useAssetStore } from "@/stores/use-asset-store";
import type { WebdavSyncConfig } from "@/stores/use-config-store";
import type { CanvasProject } from "@/app/(user)/canvas/stores/use-canvas-store";
import { useCanvasStore } from "@/app/(user)/canvas/stores/use-canvas-store";
type StoredLog = Record<string, unknown> & { id?: string };
export type AppSyncDomainKey = "canvas" | "assets" | "image-workbench" | "video-workbench";
type DomainKey = AppSyncDomainKey;
type CanvasDomainData = { projects: CanvasProject[] };
type AssetDomainData = { assets: Asset[] };
type LogDomainData = { logs: StoredLog[] };
type AppSyncFile = {
storageKey: string;
path: string;
mimeType: string;
bytes: number;
};
type DomainManifest<T> = {
app: "infinite-canvas";
version: 1;
domain: DomainKey;
exportedAt: string;
data: T;
files: AppSyncFile[];
};
type SyncDomainOptions<T> = {
key: DomainKey;
label: string;
localData: () => Promise<T>;
emptyData: T;
mergeData: (local: T, remote: T) => T;
applyData?: (data: T) => Promise<void>;
};
type SyncDomainResult<T> = {
data: T;
mergedRemote: boolean;
files: number;
manifestBytes: number;
uploadedFiles: number;
uploadedBytes: number;
};
export type AppSyncResult = {
syncedAt: string;
mergedRemote: boolean;
projects: number;
assets: number;
imageLogs: number;
videoLogs: number;
files: number;
manifestBytes: number;
uploadedFiles: number;
uploadedBytes: number;
};
export type AppSyncProgressEvent = {
domain?: AppSyncDomainKey;
label?: string;
stage: string;
current?: number;
total?: number;
status?: "active" | "success" | "exception";
};
export type AppSyncProgress = (event: AppSyncProgressEvent) => void;
const FILE_CONCURRENCY = 3;
const imageLogStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" });
const videoLogStore = localforage.createInstance({ name: "infinite-canvas", storeName: "video_generation_logs" });
type LogStore = typeof imageLogStore;
const storageKeyPattern = /^(image|video|audio|file|video-reference|audio-reference):/;
export async function syncAppDataToWebdav(config: WebdavSyncConfig, onProgress?: AppSyncProgress): Promise<AppSyncResult> {
emitProgress(onProgress, { stage: "等待本地数据加载" });
await Promise.all([waitForHydration(useCanvasStore), waitForHydration(useAssetStore)]);
const [canvas, assets, imageLogs, videoLogs] = await Promise.all([
syncDomain<CanvasDomainData>(config, onProgress, {
key: "canvas",
label: "画布",
emptyData: { projects: [] },
localData: async () => ({ projects: useCanvasStore.getState().projects }),
mergeData: (local, remote) => ({ projects: mergeById(local.projects, remote.projects, "updatedAt") }),
applyData: async (data) => useCanvasStore.getState().replaceProjects(data.projects),
}),
syncDomain<AssetDomainData>(config, onProgress, {
key: "assets",
label: "我的素材",
emptyData: { assets: [] },
localData: async () => ({ assets: useAssetStore.getState().assets }),
mergeData: (local, remote) => ({ assets: mergeById(local.assets, remote.assets, "updatedAt") }),
applyData: async (data) => useAssetStore.getState().replaceAssets(await Promise.all(data.assets.map(hydrateAsset))),
}),
syncDomain<LogDomainData>(config, onProgress, {
key: "image-workbench",
label: "生图工作台",
emptyData: { logs: [] },
localData: async () => ({ logs: await readStoredLogs(imageLogStore) }),
mergeData: (local, remote) => ({ logs: mergeById(local.logs, remote.logs, "createdAt") }),
applyData: async (data) => replaceStoredLogs(imageLogStore, data.logs),
}),
syncDomain<LogDomainData>(config, onProgress, {
key: "video-workbench",
label: "视频创作台",
emptyData: { logs: [] },
localData: async () => ({ logs: await readStoredLogs(videoLogStore) }),
mergeData: (local, remote) => ({ logs: mergeById(local.logs, remote.logs, "createdAt") }),
applyData: async (data) => replaceStoredLogs(videoLogStore, data.logs),
}),
]);
const result = {
syncedAt: new Date().toISOString(),
mergedRemote: [canvas, assets, imageLogs, videoLogs].some((item) => item.mergedRemote),
projects: canvas.data.projects.length,
assets: assets.data.assets.length,
imageLogs: imageLogs.data.logs.length,
videoLogs: videoLogs.data.logs.length,
files: canvas.files + assets.files + imageLogs.files + videoLogs.files,
manifestBytes: canvas.manifestBytes + assets.manifestBytes + imageLogs.manifestBytes + videoLogs.manifestBytes,
uploadedFiles: canvas.uploadedFiles + assets.uploadedFiles + imageLogs.uploadedFiles + videoLogs.uploadedFiles,
uploadedBytes: canvas.uploadedBytes + assets.uploadedBytes + imageLogs.uploadedBytes + videoLogs.uploadedBytes,
};
emitProgress(onProgress, { stage: "同步完成", status: "success" });
return result;
}
async function syncDomain<T>(config: WebdavSyncConfig, onProgress: AppSyncProgress | undefined, options: SyncDomainOptions<T>): Promise<SyncDomainResult<T>> {
try {
emitProgress(onProgress, { domain: options.key, label: options.label, stage: "读取远端清单", status: "active" });
const remoteManifest = await readDomainManifest(config, options.key, options.emptyData);
emitProgress(onProgress, { domain: options.key, label: options.label, stage: "读取本地数据", status: "active" });
const localData = await options.localData();
const mergedData = remoteManifest ? options.mergeData(localData, remoteManifest.data) : localData;
if (remoteManifest) {
emitProgress(onProgress, { domain: options.key, label: options.label, stage: "下载缺失媒体", status: "active" });
await downloadMissingFiles(config, options.key, mergedData, remoteManifest.files, onProgress);
emitProgress(onProgress, { domain: options.key, label: options.label, stage: "写入本地合并结果", status: "active" });
await options.applyData?.(mergedData);
}
emitProgress(onProgress, { domain: options.key, label: options.label, stage: "上传新增媒体", status: "active" });
const uploaded = await uploadChangedFiles(config, options.key, mergedData, remoteManifest?.files || [], onProgress);
const manifest: DomainManifest<T> = { app: "infinite-canvas", version: 1, domain: options.key, exportedAt: new Date().toISOString(), data: mergedData, files: uploaded.files };
const manifestFile = new Blob([JSON.stringify(manifest, null, 2)], { type: "application/json" });
emitProgress(onProgress, { domain: options.key, label: options.label, stage: `上传清单 ${formatBytes(manifestFile.size)}`, status: "active" });
await uploadWebdavFile(config, domainPath(options.key, WEBDAV_MANIFEST_FILE_NAME), manifestFile, "application/json");
emitProgress(onProgress, { domain: options.key, label: options.label, stage: "完成", current: 1, total: 1, status: "success" });
return {
data: mergedData,
mergedRemote: Boolean(remoteManifest),
files: uploaded.files.length,
manifestBytes: manifestFile.size,
uploadedFiles: uploaded.uploadedFiles,
uploadedBytes: uploaded.uploadedBytes,
};
} catch (error) {
emitProgress(onProgress, { domain: options.key, label: options.label, stage: error instanceof Error ? error.message : "同步失败", status: "exception" });
throw error;
}
}
async function readDomainManifest<T>(config: WebdavSyncConfig, domain: DomainKey, emptyData: T): Promise<DomainManifest<T> | null> {
const file = await downloadWebdavFile(config, domainPath(domain, WEBDAV_MANIFEST_FILE_NAME));
if (!file) return null;
const data = JSON.parse(await file.text()) as DomainManifest<T>;
if (data.app !== "infinite-canvas" || data.domain !== domain) throw new Error(`${domain} 同步清单不是当前应用的数据`);
return {
app: "infinite-canvas",
version: 1,
domain,
exportedAt: data.exportedAt || new Date().toISOString(),
data: data.data || emptyData,
files: Array.isArray(data.files) ? data.files : [],
};
}
async function downloadMissingFiles<T>(config: WebdavSyncConfig, domain: DomainKey, data: T, remoteFiles: AppSyncFile[], onProgress?: AppSyncProgress) {
const remoteFileMap = new Map(remoteFiles.map((item) => [item.storageKey, item]));
const tasks: AppSyncFile[] = [];
const storageKeys = collectStorageKeys(data);
let scanned = 0;
for (const storageKey of storageKeys) {
const localBlob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
scanned += 1;
if (localBlob) {
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查缺失媒体", current: scanned, total: storageKeys.length, status: "active" });
continue;
}
const remoteFile = remoteFileMap.get(storageKey);
if (remoteFile) tasks.push(remoteFile);
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查缺失媒体", current: scanned, total: storageKeys.length, status: "active" });
}
if (!tasks.length) {
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "媒体已齐全", current: 1, total: 1, status: "active" });
return;
}
let downloaded = 0;
await runWithConcurrency(tasks, FILE_CONCURRENCY, async (remoteFile) => {
const blob = await downloadWebdavFile(config, remoteFile.path);
if (!blob) return;
const typedBlob = blob.type ? blob : blob.slice(0, blob.size, remoteFile.mimeType);
await (remoteFile.storageKey.startsWith("image:") ? setImageBlob(remoteFile.storageKey, typedBlob) : setMediaBlob(remoteFile.storageKey, typedBlob));
downloaded += 1;
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "下载媒体", current: downloaded, total: tasks.length, status: "active" });
});
}
async function uploadChangedFiles<T>(config: WebdavSyncConfig, domain: DomainKey, data: T, remoteFiles: AppSyncFile[], onProgress?: AppSyncProgress) {
const remoteFileMap = new Map(remoteFiles.map((item) => [item.storageKey, item]));
const files: AppSyncFile[] = [];
const tasks: Array<{ item: AppSyncFile; blob: Blob }> = [];
let uploadedFiles = 0;
let uploadedBytes = 0;
const storageKeys = collectStorageKeys(data);
let scanned = 0;
for (const storageKey of storageKeys) {
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
const remoteFile = remoteFileMap.get(storageKey);
if (!blob) {
if (remoteFile) files.push(remoteFile);
scanned += 1;
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查本地媒体", current: scanned, total: storageKeys.length, status: "active" });
continue;
}
const item: AppSyncFile = {
storageKey,
path: remoteFile?.path || domainPath(domain, `files/${safeFileName(storageKey)}.${fileExtension(blob.type, storageKey)}`),
mimeType: blob.type || remoteFile?.mimeType || "application/octet-stream",
bytes: blob.size,
};
files.push(item);
if (!remoteFile || remoteFile.bytes !== blob.size) tasks.push({ item, blob });
scanned += 1;
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "检查本地媒体", current: scanned, total: storageKeys.length, status: "active" });
}
if (!tasks.length) {
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: "媒体无需上传", current: 1, total: 1, status: "active" });
return { files, uploadedFiles, uploadedBytes };
}
await runWithConcurrency(tasks, FILE_CONCURRENCY, async ({ item, blob }) => {
await uploadWebdavFile(config, item.path, blob, item.mimeType);
uploadedFiles += 1;
uploadedBytes += blob.size;
emitProgress(onProgress, { domain, label: domainLabel(domain), stage: `上传媒体 ${formatBytes(blob.size)}`, current: uploadedFiles, total: tasks.length, status: "active" });
});
return { files, uploadedFiles, uploadedBytes };
}
async function hydrateAsset(asset: Asset): Promise<Asset> {
if (asset.kind === "image" && asset.data.storageKey) {
const dataUrl = await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl);
return { ...asset, coverUrl: asset.coverUrl.startsWith("blob:") ? dataUrl : asset.coverUrl, data: { ...asset.data, dataUrl } };
}
if (asset.kind === "video" && asset.data.storageKey) {
const url = await resolveMediaUrl(asset.data.storageKey, asset.data.url);
return { ...asset, coverUrl: asset.coverUrl.startsWith("blob:") ? url : asset.coverUrl, data: { ...asset.data, url } };
}
return asset;
}
async function readStoredLogs(store: LogStore) {
const logs: StoredLog[] = [];
await store.iterate<StoredLog, void>((value) => {
if (value && typeof value === "object") logs.push(value);
});
return logs;
}
async function replaceStoredLogs(store: LogStore, logs: StoredLog[]) {
await store.clear();
await runWithConcurrency(logs, FILE_CONCURRENCY, async (log) => {
const id = getStringField(log, "id");
if (id) await store.setItem(id, log);
});
}
function mergeById<T extends { id?: string }>(local: T[], remote: T[], timeKey: string) {
const items = new Map<string, T>();
remote.forEach((item) => {
const id = item.id || "";
if (id) items.set(id, item);
});
local.forEach((item) => {
const id = item.id || "";
if (!id) return;
const current = items.get(id);
if (!current || getTime(item as Record<string, unknown>, timeKey) >= getTime(current as Record<string, unknown>, timeKey)) items.set(id, item);
});
return Array.from(items.values()).sort((a, b) => getTime(b as Record<string, unknown>, timeKey) - getTime(a as Record<string, unknown>, timeKey));
}
function collectStorageKeys(value: unknown, keys = new Set<string>()) {
if (typeof value === "string") {
if (storageKeyPattern.test(value)) keys.add(value);
return [...keys];
}
if (!value || typeof value !== "object") return [...keys];
if ("storageKey" in value && typeof value.storageKey === "string" && storageKeyPattern.test(value.storageKey)) keys.add(value.storageKey);
Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectStorageKeys(child, keys)) : collectStorageKeys(item, keys)));
return [...keys];
}
function domainPath(domain: DomainKey, path: string) {
return `${domain}/${path}`;
}
function domainLabel(domain: DomainKey) {
if (domain === "canvas") return "画布";
if (domain === "assets") return "我的素材";
if (domain === "image-workbench") return "生图工作台";
return "视频创作台";
}
function emitProgress(onProgress: AppSyncProgress | undefined, event: AppSyncProgressEvent) {
onProgress?.(event);
}
function getStringField(item: Record<string, unknown>, key: string) {
const value = item[key];
return typeof value === "string" ? value : "";
}
function getTime(item: Record<string, unknown>, key: string) {
const value = item[key];
if (typeof value === "number") return value;
if (typeof value === "string") return Date.parse(value) || 0;
return 0;
}
function safeFileName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, "_");
}
function fileExtension(mimeType: string, storageKey: string) {
if (mimeType.includes("png")) return "png";
if (mimeType.includes("jpeg")) return "jpg";
if (mimeType.includes("webp")) return "webp";
if (mimeType.includes("gif")) return "gif";
if (mimeType.includes("mp4")) return "mp4";
if (mimeType.includes("webm")) return "webm";
if (mimeType.includes("wav")) return "wav";
if (mimeType.includes("mpeg") || mimeType.includes("mp3")) return "mp3";
return storageKey.startsWith("image:") ? "png" : "bin";
}
function waitForHydration<T extends { hydrated: boolean }>(store: { getState: () => T; subscribe: (listener: (state: T) => void) => () => void }) {
if (store.getState().hydrated) return Promise.resolve();
return new Promise<void>((resolve) => {
const unsubscribe = store.subscribe((state) => {
if (!state.hydrated) return;
unsubscribe();
resolve();
});
});
}
async function runWithConcurrency<T, R>(items: T[], limit: number, worker: (item: T, index: number) => Promise<R>) {
const results = new Array<R>(items.length);
let nextIndex = 0;
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (nextIndex < items.length) {
const index = nextIndex++;
results[index] = await worker(items[index], index);
}
}),
);
return results;
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import type { WebdavSyncConfig } from "@/stores/use-config-store";
export const WEBDAV_MANIFEST_FILE_NAME = "manifest.json";
const WEBDAV_REQUEST_TIMEOUT_MS = 120000;
const ensuredDirectories = new Set<string>();
export async function testWebdavConnection(config: WebdavSyncConfig) {
await ensureWebdavDirectory(config);
const response = await webdavFetch(config, "", { method: "PROPFIND", headers: { Depth: "0" } });
if (response.ok || response.status === 207) return;
await throwWebdavError(response, "WebDAV 连接测试失败");
}
export async function downloadWebdavSyncFile(config: WebdavSyncConfig) {
return downloadWebdavFile(config, WEBDAV_MANIFEST_FILE_NAME);
}
export async function downloadWebdavFile(config: WebdavSyncConfig, path: string) {
await ensureWebdavDirectory(config);
const response = await webdavFetch(config, path, { method: "GET" });
if (response.status === 404) return null;
if (!response.ok) await throwWebdavError(response, "读取 WebDAV 同步文件失败");
const file = await withTimeout(response.blob(), "读取 WebDAV 同步文件超时");
return file.size ? file : null;
}
export async function uploadWebdavSyncFile(config: WebdavSyncConfig, file: Blob) {
return uploadWebdavFile(config, WEBDAV_MANIFEST_FILE_NAME, file, "application/json");
}
export async function uploadWebdavFile(config: WebdavSyncConfig, path: string, file: Blob, contentType = "application/octet-stream") {
if (!file.size) throw new Error("上传文件为空,已取消上传");
await ensureWebdavDirectory(config);
await ensureWebdavSubdirectory(config, path);
const response = await webdavFetch(config, path, {
method: "PUT",
headers: { "Content-Type": contentType },
body: file,
});
if (!response.ok) await throwWebdavError(response, "上传 WebDAV 同步文件失败");
}
async function ensureWebdavDirectory(config: WebdavSyncConfig) {
assertWebdavConfig(config);
await ensureWebdavDirectoryPath(config, config.directory);
}
async function ensureWebdavSubdirectory(config: WebdavSyncConfig, path: string) {
const directory = normalizePath(path).split("/").slice(0, -1).join("/");
if (!directory) return;
await ensureWebdavDirectoryPath(config, [config.directory, directory].filter(Boolean).join("/"));
}
async function ensureWebdavDirectoryPath(config: WebdavSyncConfig, directory: string) {
const parts = normalizePath(directory).split("/").filter(Boolean);
const cacheKey = `${config.proxyMode}:${config.url}:${parts.join("/")}`;
if (ensuredDirectories.has(cacheKey)) return;
let path = "";
for (const part of parts) {
path = path ? `${path}/${part}` : part;
const response = await webdavFetch({ ...config, directory: "" }, path, { method: "MKCOL" });
if (response.ok || ((response.status === 405 || response.status === 423) && (await webdavDirectoryExists(config, path)))) continue;
await throwWebdavError(response, "创建 WebDAV 远程目录失败");
}
ensuredDirectories.add(cacheKey);
}
async function webdavDirectoryExists(config: WebdavSyncConfig, path: string) {
const response = await webdavFetch({ ...config, directory: "" }, path, { method: "PROPFIND", headers: { Depth: "0" } });
return response.ok || response.status === 207;
}
async function webdavFetch(config: WebdavSyncConfig, path: string, init: RequestInit) {
const headers = new Headers(init.headers);
if (config.username || config.password) headers.set("Authorization", `Basic ${encodeBasicAuth(`${config.username}:${config.password}`)}`);
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), WEBDAV_REQUEST_TIMEOUT_MS);
try {
const url = buildWebdavUrl(config, path);
if (config.proxyMode === "nextjs") return await fetch("/webdav-proxy", { method: "POST", headers: proxyHeaders(url, init.method || "GET", headers), body: proxyBody(init), signal: controller.signal });
return await fetch(url, { ...init, headers, signal: controller.signal });
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw new Error("WebDAV 请求超时,请检查网络、代理或远端服务状态");
if (error instanceof TypeError) throw new Error("无法连接 WebDAV,请检查地址、HTTPS 证书、CORS 或网络状态");
throw error;
} finally {
window.clearTimeout(timer);
}
}
function proxyHeaders(target: string, method: string, headers: Headers) {
const proxyHeaders = new Headers({
"x-webdav-target": target,
"x-webdav-method": method,
});
copyProxyHeader(headers, proxyHeaders, "Authorization", "x-webdav-authorization");
copyProxyHeader(headers, proxyHeaders, "Depth", "x-webdav-depth");
copyProxyHeader(headers, proxyHeaders, "Destination", "x-webdav-destination");
copyProxyHeader(headers, proxyHeaders, "Overwrite", "x-webdav-overwrite");
copyProxyHeader(headers, proxyHeaders, "Content-Type", "x-webdav-content-type");
const contentType = headers.get("Content-Type");
if (contentType) proxyHeaders.set("Content-Type", contentType);
return proxyHeaders;
}
function copyProxyHeader(from: Headers, to: Headers, source: string, target: string) {
const value = from.get(source);
if (value) to.set(target, value);
}
function proxyBody(init: RequestInit) {
const method = (init.method || "GET").toUpperCase();
if (method === "GET" || method === "HEAD") return undefined;
return init.body || undefined;
}
function buildWebdavUrl(config: WebdavSyncConfig, path: string) {
const baseUrl = config.url.trim().replace(/\/+$/, "");
const remotePath = [normalizePath(config.directory), normalizePath(path)].filter(Boolean).join("/");
if (!remotePath) return baseUrl;
return `${baseUrl}/${remotePath.split("/").map(encodeURIComponent).join("/")}`;
}
function normalizePath(path: string) {
return path.trim().replace(/^\/+|\/+$/g, "");
}
function assertWebdavConfig(config: WebdavSyncConfig) {
if (!config.url.trim()) throw new Error("请先填写 WebDAV 地址");
}
async function throwWebdavError(response: Response, fallback: string): Promise<never> {
const detail = await response.text().catch(() => "");
if (response.status === 401 || response.status === 403) throw new Error("WebDAV 认证失败,请检查用户名、密码或应用密码");
if (response.status === 404) throw new Error("WebDAV 路径不存在,请检查地址和远程目录");
throw new Error(`${fallback}${response.status}${detail ? ` ${detail.slice(0, 120)}` : ""}`);
}
function encodeBasicAuth(value: string) {
const bytes = new TextEncoder().encode(value);
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
function withTimeout<T>(promise: Promise<T>, message: string) {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error(message)), WEBDAV_REQUEST_TIMEOUT_MS);
promise.then(resolve, reject).finally(() => window.clearTimeout(timer));
});
}