From d3b893e02ec5c2a22b8b7cf7a57f8a0fa78746e7 Mon Sep 17 00:00:00 2001 From: huxzhi Date: Mon, 3 Aug 2026 17:30:04 +0900 Subject: [PATCH 1/2] feat: persist WebDAV user identity --- apps/cent/src/api/endpoints/web-dav/index.ts | 77 ++++++++++++++----- apps/cent/src/components/modal/index.tsx | 6 ++ .../src/components/modal/web-dav-user.tsx | 73 ++++++++++++++++++ apps/cent/src/components/modal/web-dav.tsx | 2 + apps/cent/src/locale/lang/en.json | 2 + apps/cent/src/locale/lang/zh.json | 2 + apps/cent/src/tidal/web-dav.ts | 43 ++++++++++- 7 files changed, 183 insertions(+), 22 deletions(-) create mode 100644 apps/cent/src/components/modal/web-dav-user.tsx diff --git a/apps/cent/src/api/endpoints/web-dav/index.ts b/apps/cent/src/api/endpoints/web-dav/index.ts index 1af3bdf6..c391931e 100644 --- a/apps/cent/src/api/endpoints/web-dav/index.ts +++ b/apps/cent/src/api/endpoints/web-dav/index.ts @@ -1,9 +1,13 @@ import type { WebDAVEdit } from "@/components/modal/web-dav"; import { Scheduler } from "@/database/scheduler"; import { BillIndexedDBStorage } from "@/database/storage"; -import type { Bill } from "@/ledger/type"; +import type { Bill, GlobalMeta } from "@/ledger/type"; import { createTidal } from "@/tidal"; -import { checkWebDAVConfig, createWebDAVSyncer } from "@/tidal/web-dav"; +import { + checkWebDAVConfig, + createWebDAVSyncer, + fetchWebDAVUserIds, +} from "@/tidal/web-dav"; import type { ZenPost } from "@/zen/types"; import type { SyncEndpointFactory } from "../type"; @@ -29,6 +33,12 @@ type WebDAVPrivateMeta = { _webDAVUserAliases?: string[]; }; +type WebDAVMeta = WebDAVPrivateMeta & GlobalMeta; + +const restoreUserIdType = (userId: string) => { + return /^\d+$/.test(userId) ? Number(userId) : userId; +}; + export const WebDAVEndpoint: SyncEndpointFactory = { type: "webdav", name: "webdav", @@ -47,6 +57,24 @@ export const WebDAVEndpoint: SyncEndpointFactory = { ); return Promise.reject(error); }); + + const userIds = await fetchWebDAVUserIds({ + remoteUrl: remote, + username: data.username, + password: data.password, + proxy: data.proxy, + }); + if (userIds.length === 0) { + data.userId = crypto.randomUUID(); + } else if (userIds.length === 1) { + data.userId = restoreUserIdType(userIds[0]); + } else { + const selection = await modal.webDavUser({ userIds }); + data.userId = + selection.type === "new" + ? crypto.randomUUID() + : restoreUserIdType(selection.userId); + } }, }); if (!auth) { @@ -69,6 +97,8 @@ export const WebDAVEndpoint: SyncEndpointFactory = { password: auth.password, remoteUrl: remote, proxy: auth.proxy, + userId: auth.userId, + displayName: auth.customUserName, }; const repo = createTidal({ storageFactory: (name) => new BillIndexedDBStorage(`book-${name}`), @@ -118,19 +148,12 @@ export const WebDAVEndpoint: SyncEndpointFactory = { await repo.detach(); await zenRepo.detach(); }, - getUserInfo: async () => { - const Me = { - id: auth.customUserName || auth.username, - name: auth.customUserName || auth.username, - avatar_url: "/icon.png", - }; - return Me; - }, + getUserInfo: repo.getUserInfo, getCollaborators: async (id) => { const aliases = await getUserAliases(id); const Me = { - id: auth.username, - name: auth.username, + id: (auth.userId ?? auth.username) as unknown as string, + name: auth.customUserName || auth.username, avatar_url: "/icon.png", }; const users = [ @@ -153,17 +176,35 @@ export const WebDAVEndpoint: SyncEndpointFactory = { createBook: repo.create, initBook: async (name) => { await Promise.all([repo.init(name), zenRepo.init(name)]); - repo.getMeta(name).then((meta?: WebDAVPrivateMeta) => { + repo.getMeta(name).then((meta?: WebDAVMeta) => { const customUserName = auth.customUserName; - if (!customUserName) { - return; + const userId = auth.userId; + const newMeta = meta ?? ({} as WebDAVMeta); + let needsUpdate = false; + + if ( + userId !== undefined && + !newMeta.personal?.[userId] + ) { + newMeta.personal = { + ...newMeta.personal, + [userId]: {}, + }; + needsUpdate = true; } - if (!meta?._webDAVUserAliases?.includes(customUserName)) { - const newMeta = meta ?? {}; + + if ( + customUserName && + !newMeta._webDAVUserAliases?.includes(customUserName) + ) { newMeta._webDAVUserAliases = [ - ...(meta?._webDAVUserAliases ?? []), + ...(newMeta._webDAVUserAliases ?? []), customUserName, ]; + needsUpdate = true; + } + + if (needsUpdate) { repo.batch(name, [ { type: "meta", diff --git a/apps/cent/src/components/modal/index.tsx b/apps/cent/src/components/modal/index.tsx index 1026adb2..5c07c19a 100644 --- a/apps/cent/src/components/modal/index.tsx +++ b/apps/cent/src/components/modal/index.tsx @@ -3,6 +3,10 @@ import { LoadingProvider, loading } from "./loading"; import { PromptProvider, prompt } from "./prompt"; import { S3AuthProvider, showS3Auth } from "./s3"; import { showWebDAVAuth, WebDAVAuthProvider } from "./web-dav"; +import { + showWebDAVUserSelect, + WebDAVUserSelectProvider, +} from "./web-dav-user"; export function ModalProvider() { return ( @@ -10,6 +14,7 @@ export function ModalProvider() { + ); @@ -19,6 +24,7 @@ const modal = { loading, prompt, webDavAuth: showWebDAVAuth, + webDavUser: showWebDAVUserSelect, s3Auth: showS3Auth, toast, }; diff --git a/apps/cent/src/components/modal/web-dav-user.tsx b/apps/cent/src/components/modal/web-dav-user.tsx new file mode 100644 index 00000000..bb07fc10 --- /dev/null +++ b/apps/cent/src/components/modal/web-dav-user.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { useIntl } from "@/locale"; +import createConfirmProvider from "../confirm"; +import { Button } from "../ui/button"; + +export type WebDAVUserSelection = + | { type: "existing"; userId: string } + | { type: "new" }; + +export type WebDAVUserSelectOptions = { + userIds: string[]; +}; + +const NEW_USER = "__new_webdav_user__"; + +const WebDAVUserSelectForm = ({ + edit, + onCancel, + onConfirm, +}: { + edit?: WebDAVUserSelectOptions; + onCancel?: () => void; + onConfirm?: (selection: WebDAVUserSelection) => void; +}) => { + const t = useIntl(); + const [selected, setSelected] = useState(edit?.userIds[0] ?? NEW_USER); + + return ( +
+
{t("web-dav-select-user")}
+ + +
+ + +
+
+ ); +}; + +export const [WebDAVUserSelectProvider, showWebDAVUserSelect] = + createConfirmProvider( + WebDAVUserSelectForm, + { + dialogTitle: "web-dav-select-user", + dialogModalClose: false, + contentClassName: "w-[360px] h-fit", + fade: true, + }, + ); diff --git a/apps/cent/src/components/modal/web-dav.tsx b/apps/cent/src/components/modal/web-dav.tsx index 651fcf31..48aee8e0 100644 --- a/apps/cent/src/components/modal/web-dav.tsx +++ b/apps/cent/src/components/modal/web-dav.tsx @@ -22,6 +22,7 @@ export type WebDAVEdit = { password: string; proxy?: string; customUserName?: string; + userId?: string | number; }; type LoadingState = Partial & { @@ -35,6 +36,7 @@ export const createFormSchema = (t: any) => password: z.string(), proxy: z.optional(z.string()), customUserName: z.optional(z.string()), + userId: z.optional(z.union([z.string(), z.number()])), }); const LoadingForm = ({ diff --git a/apps/cent/src/locale/lang/en.json b/apps/cent/src/locale/lang/en.json index 5066e2e1..8a8166ca 100644 --- a/apps/cent/src/locale/lang/en.json +++ b/apps/cent/src/locale/lang/en.json @@ -367,6 +367,8 @@ "custom-user-name-tooltip": "When multiple people share the same account, you can set different nicknames to distinguish different users. This way you can see who created each record.", "web-dav-proxy-url": "https://glink25.github.io/post/%E9%80%9A%E8%BF%87Web-DAV%E8%BF%9B%E8%A1%8CCent%E6%95%B0%E6%8D%AE%E5%90%8C%E6%AD%A5/#%E4%BD%BF%E7%94%A8%E4%BB%A3%E7%90%86", "web-dav-custom-user": "https://glink25.github.io/post/%E9%80%9A%E8%BF%87Web-DAV%E8%BF%9B%E8%A1%8CCent%E6%95%B0%E6%8D%AE%E5%90%8C%E6%AD%A5/#%E8%87%AA%E5%AE%9A%E4%B9%89%E7%94%A8%E6%88%B7", + "web-dav-select-user": "Select the user ID for WebDAV", + "web-dav-create-user": "Create a new user", "sync-with-s3": "Sync with S3", "s3-endpoint": "S3 Endpoint", "s3-endpoint-required": "S3 Endpoint is required", diff --git a/apps/cent/src/locale/lang/zh.json b/apps/cent/src/locale/lang/zh.json index 2258d4b3..281e842d 100644 --- a/apps/cent/src/locale/lang/zh.json +++ b/apps/cent/src/locale/lang/zh.json @@ -369,6 +369,8 @@ "custom-user-name-tooltip": "当多人共享同一个账号时,可以设置不同的昵称来区分不同用户。这样可以在记录中看到是谁创建的每条记录。", "web-dav-proxy-url": "https://glink25.github.io/post/%E9%80%9A%E8%BF%87Web-DAV%E8%BF%9B%E8%A1%8CCent%E6%95%B0%E6%8D%AE%E5%90%8C%E6%AD%A5/#%E4%BD%BF%E7%94%A8%E4%BB%A3%E7%90%86", "web-dav-custom-user": "https://glink25.github.io/post/%E9%80%9A%E8%BF%87Web-DAV%E8%BF%9B%E8%A1%8CCent%E6%95%B0%E6%8D%AE%E5%90%8C%E6%AD%A5/#%E8%87%AA%E5%AE%9A%E4%B9%89%E7%94%A8%E6%88%B7", + "web-dav-select-user": "选择 WebDAV 使用的用户 ID", + "web-dav-create-user": "创建新用户", "sync-with-s3": "通过 S3 进行数据同步", "s3-endpoint": "S3 Endpoint", "s3-endpoint-required": "S3 Endpoint 不能为空", diff --git a/apps/cent/src/tidal/web-dav.ts b/apps/cent/src/tidal/web-dav.ts index 5da53235..b702dbdd 100644 --- a/apps/cent/src/tidal/web-dav.ts +++ b/apps/cent/src/tidal/web-dav.ts @@ -5,7 +5,7 @@ import { shortId } from "@/database/id"; import { registerProxy } from "@/utils/fetch-proxy"; import type { AssetKey, FileLike, StoreStructure, Syncer } from "."; -type WebDAVConfig = { +export type WebDAVConfig = { remoteUrl: string; username?: string; password?: string; @@ -13,6 +13,8 @@ type WebDAVConfig = { repoPrefix?: string; entryName?: string; baseDir?: string; + userId?: string | number; + displayName?: string; }; const createClient = async ( @@ -286,7 +288,11 @@ export const createWebDAVSyncer = (cfg: WebDAVConfig): Syncer => { await client.createDirectory(storePath, { recursive: true }); await client.putFileContents( `${storePath}/meta.json`, - JSON.stringify({}), + JSON.stringify( + config.userId === undefined + ? {} + : { personal: { [config.userId]: {} } }, + ), ); await client.createDirectory(`${storePath}/assets`); // return id and name similar to github.createStore @@ -297,8 +303,12 @@ export const createWebDAVSyncer = (cfg: WebDAVConfig): Syncer => { // WebDAV servers do not have standardized user API; return simple info based on username return { avatar_url: undefined, - name: config.username ?? "webdav-user", - id: config.username ?? "webdav-user", + name: + config.displayName ?? config.username ?? "webdav-user", + id: + (config.userId as unknown as string | undefined) ?? + config.username ?? + "webdav-user", } as unknown as UserInfo; }; @@ -340,6 +350,31 @@ export const createWebDAVSyncer = (cfg: WebDAVConfig): Syncer => { }; }; +export const fetchWebDAVUserIds = async ( + config: Pick< + WebDAVConfig, + "username" | "password" | "remoteUrl" | "proxy" + >, +) => { + const syncer = createWebDAVSyncer(config); + const storeNames = await syncer.fetchAllStore(); + const userIds = new Set(); + + for (const storeName of storeNames) { + const structure = await syncer.fetchStructure(storeName); + if (!structure.meta.path) continue; + + const [metaFile] = await syncer.fetchContent(storeName, [ + structure.meta, + ]); + Object.keys(metaFile?.content?.personal ?? {}).forEach((userId) => { + userIds.add(userId); + }); + } + + return Array.from(userIds); +}; + export const checkWebDAVConfig = async ( config: Pick, ) => { From eeec8e03b30673522fcabc9c41c3fd5dc545a1d9 Mon Sep 17 00:00:00 2001 From: huxzhi Date: Mon, 3 Aug 2026 17:36:08 +0900 Subject: [PATCH 2/2] chore: enforce consistent line endings --- .gitattributes | 4 ++++ apps/cent/src/api/endpoints/web-dav/index.ts | 5 +---- apps/cent/src/components/modal/index.tsx | 5 +---- apps/cent/src/tidal/web-dav.ts | 8 ++------ 4 files changed, 8 insertions(+), 14 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a2237bc6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf + +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/apps/cent/src/api/endpoints/web-dav/index.ts b/apps/cent/src/api/endpoints/web-dav/index.ts index c391931e..626d9556 100644 --- a/apps/cent/src/api/endpoints/web-dav/index.ts +++ b/apps/cent/src/api/endpoints/web-dav/index.ts @@ -182,10 +182,7 @@ export const WebDAVEndpoint: SyncEndpointFactory = { const newMeta = meta ?? ({} as WebDAVMeta); let needsUpdate = false; - if ( - userId !== undefined && - !newMeta.personal?.[userId] - ) { + if (userId !== undefined && !newMeta.personal?.[userId]) { newMeta.personal = { ...newMeta.personal, [userId]: {}, diff --git a/apps/cent/src/components/modal/index.tsx b/apps/cent/src/components/modal/index.tsx index 5c07c19a..9bab6642 100644 --- a/apps/cent/src/components/modal/index.tsx +++ b/apps/cent/src/components/modal/index.tsx @@ -3,10 +3,7 @@ import { LoadingProvider, loading } from "./loading"; import { PromptProvider, prompt } from "./prompt"; import { S3AuthProvider, showS3Auth } from "./s3"; import { showWebDAVAuth, WebDAVAuthProvider } from "./web-dav"; -import { - showWebDAVUserSelect, - WebDAVUserSelectProvider, -} from "./web-dav-user"; +import { showWebDAVUserSelect, WebDAVUserSelectProvider } from "./web-dav-user"; export function ModalProvider() { return ( diff --git a/apps/cent/src/tidal/web-dav.ts b/apps/cent/src/tidal/web-dav.ts index b702dbdd..7352866a 100644 --- a/apps/cent/src/tidal/web-dav.ts +++ b/apps/cent/src/tidal/web-dav.ts @@ -303,8 +303,7 @@ export const createWebDAVSyncer = (cfg: WebDAVConfig): Syncer => { // WebDAV servers do not have standardized user API; return simple info based on username return { avatar_url: undefined, - name: - config.displayName ?? config.username ?? "webdav-user", + name: config.displayName ?? config.username ?? "webdav-user", id: (config.userId as unknown as string | undefined) ?? config.username ?? @@ -351,10 +350,7 @@ export const createWebDAVSyncer = (cfg: WebDAVConfig): Syncer => { }; export const fetchWebDAVUserIds = async ( - config: Pick< - WebDAVConfig, - "username" | "password" | "remoteUrl" | "proxy" - >, + config: Pick, ) => { const syncer = createWebDAVSyncer(config); const storeNames = await syncer.fetchAllStore();