diff --git a/README.md b/README.md index 095fab3..556913e 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,24 @@ Cohere 用于通过 OSS / S3 兼容对象存储同步当前 vault 文件。 同步端点填写服务商提供的 **S3 API Endpoint**。 +地址风格默认选择 `自动`。如果服务商明确要求虚拟主机风格地址,请选择 `Virtual Hosted Style`。 + ### Cloudflare R2 ```text Endpoint: https://.r2.cloudflarestorage.com Region: auto Bucket: <桶名称> +地址风格: 自动 +``` + +### 阿里云 OSS + +```text +Endpoint: https://oss-.aliyuncs.com +Region: oss- +Bucket: <桶名称> +地址风格: Virtual Hosted Style ``` ### 七牛云 @@ -42,6 +54,7 @@ Bucket: <桶名称> Endpoint: https://s3..qiniucs.com Region: Bucket: <桶名称> +地址风格: 自动 ``` ## 本地开发 diff --git a/src/main.test.ts b/src/main.test.ts index 072ccd7..70770e3 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -154,6 +154,7 @@ describe("connection config import and export", () => { schemaVersion: 1, endpoint: "https://example.com", bucket: "vault", + addressingStyle: "auto", region: "auto", rootPrefix: "obsync/v2", accountKey: "team", @@ -175,6 +176,7 @@ describe("connection config import and export", () => { schemaVersion: 1, endpoint: "https://r2.example.com", bucket: "shared-vault", + addressingStyle: "virtual-hosted", region: "auto", rootPrefix: "obsync/v2", accountKey: "team", @@ -185,6 +187,7 @@ describe("connection config import and export", () => { expect(plugin.settings).toMatchObject({ endpoint: "https://r2.example.com", bucket: "shared-vault", + addressingStyle: "virtual-hosted", region: "auto", rootPrefix: "obsync/v2", accountKey: "team", @@ -263,6 +266,7 @@ function createPlugin(settings: Partial = {}): TestPlugin { plugin.settings = { endpoint: "https://example.com", bucket: "vault", + addressingStyle: "auto", region: "auto", accessKeyId: "key", secretAccessKey: "secret", @@ -300,6 +304,7 @@ type TestPlugin = { interface TestSettings { endpoint: string; bucket: string; + addressingStyle: "auto" | "path" | "virtual-hosted"; region: string; accessKeyId: string; secretAccessKey: string; diff --git a/src/main.ts b/src/main.ts index 7db15ca..3d4293b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,12 +4,13 @@ import { createRandomId, createVaultId, normalizeKey } from "./core/ids"; import SettingsApp from "./settings/SettingsApp.vue"; import "./styles.scss"; import { releaseDeletedContent, syncOnce, type LocalSyncState } from "./sync/engine"; -import { S3ObjectStore } from "./store/s3"; +import { S3ObjectStore, type S3AddressingStyle } from "./store/s3"; import { ObsidianVaultIO } from "./vault-io"; interface ObsyncSettings { endpoint: string; bucket: string; + addressingStyle: S3AddressingStyle; region: string; accessKeyId: string; secretAccessKey: string; @@ -29,6 +30,7 @@ interface ConnectionConfig { schemaVersion: 1; endpoint: string; bucket: string; + addressingStyle?: S3AddressingStyle; region: string; rootPrefix: string; accountKey: string; @@ -41,6 +43,7 @@ interface ConnectionConfig { const DEFAULT_SETTINGS: ObsyncSettings = { endpoint: "", bucket: "", + addressingStyle: "auto", region: "", accessKeyId: "", secretAccessKey: "", @@ -255,7 +258,7 @@ export default class ObsyncPlugin extends Plugin { this.pruneLocalDeletedState(); await this.saveSettings(); - this.showNotice(`Obsync 已释放:清理删除记录 ${result.deletedTombstones},删除 Blob ${result.deletedBlobs}。`); + this.showNotice(`Obsync 已释放:清理文件删除记录 ${result.deletedTombstones},目录删除记录 ${result.deletedDirectoryTombstones},删除 Blob ${result.deletedBlobs}。`); }); } @@ -325,6 +328,7 @@ export default class ObsyncPlugin extends Plugin { return new S3ObjectStore({ endpoint: this.settings.endpoint, bucket: this.settings.bucket, + addressingStyle: this.settings.addressingStyle, region: this.settings.region || "auto", accessKeyId: this.settings.accessKeyId, secretAccessKey: this.settings.secretAccessKey, @@ -358,6 +362,12 @@ export default class ObsyncPlugin extends Plugin { delete this.settings.syncState.files[path]; } } + + for (const [path, directoryState] of Object.entries(this.settings.syncState.directories ?? {})) { + if (directoryState.deleted) { + delete this.settings.syncState.directories?.[path]; + } + } } async loadSettings(): Promise { @@ -408,6 +418,7 @@ export default class ObsyncPlugin extends Plugin { schemaVersion: 1, endpoint: this.settings.endpoint, bucket: this.settings.bucket, + addressingStyle: this.settings.addressingStyle, region: this.settings.region, rootPrefix: this.settings.rootPrefix, accountKey: this.settings.accountKey, @@ -429,6 +440,7 @@ export default class ObsyncPlugin extends Plugin { await this.updateSettings({ endpoint: config.endpoint, bucket: config.bucket, + addressingStyle: config.addressingStyle || "auto", region: config.region, rootPrefix: config.rootPrefix, accountKey: config.accountKey, @@ -487,6 +499,7 @@ function parseConnectionConfig(configText: string): ConnectionConfig { schemaVersion: 1 as const, endpoint: readRequiredString(parsed, "endpoint"), bucket: readRequiredString(parsed, "bucket"), + addressingStyle: readAddressingStyle(parsed, "addressingStyle", "auto"), region: readOptionalString(parsed, "region", "auto"), rootPrefix: readOptionalString(parsed, "rootPrefix", "obsync/v1"), accountKey: readOptionalString(parsed, "accountKey", "default"), @@ -503,6 +516,20 @@ function parseConnectionConfig(configText: string): ConnectionConfig { return config; } +function readAddressingStyle(record: Record, key: string, fallback: S3AddressingStyle): S3AddressingStyle { + const value = record[key]; + + if (value === undefined || value === null || value === "") { + return fallback; + } + + if (value === "auto" || value === "path" || value === "virtual-hosted") { + return value; + } + + throw new Error(`连接配置字段 ${key} 格式不正确。`); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/settings/SettingsApp.vue b/src/settings/SettingsApp.vue index 4da3b47..dda7fb4 100644 --- a/src/settings/SettingsApp.vue +++ b/src/settings/SettingsApp.vue @@ -5,6 +5,7 @@ import { computed, ref, watchEffect } from "vue"; type ObsyncSettings = { endpoint: string; bucket: string; + addressingStyle: "auto" | "path" | "virtual-hosted"; region: string; accessKeyId: string; secretAccessKey: string; @@ -97,6 +98,23 @@ watchEffect(() => { +
+
+

地址风格

+

自动适配常见服务,也可手动指定请求地址格式。

+
+
+ +
+
+

Region

diff --git a/src/store/s3.test.ts b/src/store/s3.test.ts index 9a55494..d24dfa1 100644 --- a/src/store/s3.test.ts +++ b/src/store/s3.test.ts @@ -111,12 +111,76 @@ describe("S3ObjectStore", () => { expect(requests).toHaveLength(2); expect(requests[1]?.url).toContain("continuation-token=page+2"); }); + + test("uses virtual-hosted style automatically for Aliyun OSS endpoints", async () => { + const requests: HttpRequest[] = []; + const store = createStore(async (request) => { + requests.push(request); + return { + status: 200, + text: "", + arrayBuffer: new ArrayBuffer(0), + }; + }, { + endpoint: "https://oss-cn-beijing.aliyuncs.com", + bucket: "cohere-test", + region: "oss-cn-beijing", + }); + + await store.writeObject("files/notes/today.md", new TextEncoder().encode("hello")); + + expect(requests[0]?.url).toBe("https://cohere-test.oss-cn-beijing.aliyuncs.com/obsync/v1/vaults/vlt_TEST/files/notes/today.md"); + }); + + test("can force path style addressing", async () => { + const requests: HttpRequest[] = []; + const store = createStore(async (request) => { + requests.push(request); + return { + status: 200, + text: "", + arrayBuffer: new ArrayBuffer(0), + }; + }, { + addressingStyle: "path", + endpoint: "https://oss-cn-beijing.aliyuncs.com", + bucket: "cohere-test", + }); + + await store.writeObject("files/notes/today.md", new TextEncoder().encode("hello")); + + expect(requests[0]?.url).toBe("https://oss-cn-beijing.aliyuncs.com/cohere-test/obsync/v1/vaults/vlt_TEST/files/notes/today.md"); + }); + + test("can force virtual-hosted style addressing", async () => { + const requests: HttpRequest[] = []; + const store = createStore(async (request) => { + requests.push(request); + return { + status: 200, + text: "", + arrayBuffer: new ArrayBuffer(0), + }; + }, { + addressingStyle: "virtual-hosted", + endpoint: "https://s3.example.com", + bucket: "cohere-test", + }); + + await store.writeObject("files/notes/today.md", new TextEncoder().encode("hello")); + + expect(requests[0]?.url).toBe("https://cohere-test.s3.example.com/obsync/v1/vaults/vlt_TEST/files/notes/today.md"); + }); }); -function createStore(request: (request: HttpRequest) => Promise<{ status: number; text: string; arrayBuffer: ArrayBuffer }>) { +function createStore( + request: (request: HttpRequest) => Promise<{ status: number; text: string; arrayBuffer: ArrayBuffer }>, + options: Partial[0]> = {}, +) { return new S3ObjectStore({ endpoint: "https://s3.example.com", bucket: "my-bucket", + addressingStyle: "auto", region: "auto", accessKeyId: "AKIA_TEST", secretAccessKey: "SECRET_TEST", @@ -125,5 +189,6 @@ function createStore(request: (request: HttpRequest) => Promise<{ status: number deviceId: "dev_TEST", now: () => 1000, request, + ...options, }); } diff --git a/src/store/s3.ts b/src/store/s3.ts index 400cdb2..8ca64c2 100644 --- a/src/store/s3.ts +++ b/src/store/s3.ts @@ -17,6 +17,7 @@ interface HttpResponse { interface S3ObjectStoreOptions { endpoint: string; bucket: string; + addressingStyle: S3AddressingStyle; region: string; accessKeyId: string; secretAccessKey: string; @@ -27,6 +28,8 @@ interface S3ObjectStoreOptions { request(request: HttpRequest): Promise; } +export type S3AddressingStyle = "auto" | "path" | "virtual-hosted"; + export class S3ObjectStore implements ObjectStore { private options: S3ObjectStoreOptions; private layout: ReturnType; @@ -127,7 +130,7 @@ export class S3ObjectStore implements ObjectStore { } private async request(method: string, key: string, body?: Uint8Array, query?: Record): Promise { - const url = createObjectUrl(this.options.endpoint, this.options.bucket, key, query); + const url = createObjectUrl(this.options.endpoint, this.options.bucket, key, this.options.addressingStyle, query); const bodyBytes = body ?? new Uint8Array(); const headers = await createSignedHeaders({ method, @@ -148,13 +151,43 @@ export class S3ObjectStore implements ObjectStore { } } -function createObjectUrl(endpoint: string, bucket: string, key: string, query?: Record): string { +function createObjectUrl(endpoint: string, bucket: string, key: string, addressingStyle: S3AddressingStyle, query?: Record): string { const base = endpoint.replace(/\/+$/g, ""); - const path = key ? `${encodePathPart(bucket)}/${encodeKey(key)}` : encodePathPart(bucket); const search = query ? `?${new URLSearchParams(query).toString()}` : ""; + + if (resolveAddressingStyle(endpoint, addressingStyle) === "virtual-hosted") { + const url = new URL(base); + url.hostname = `${bucket}.${url.hostname}`; + const path = key ? encodeKey(key) : ""; + return `${url.origin}${url.pathname.replace(/\/+$/g, "")}/${path}${search}`; + } + + const path = key ? `${encodePathPart(bucket)}/${encodeKey(key)}` : encodePathPart(bucket); return `${base}/${path}${search}`; } +function resolveAddressingStyle(endpoint: string, addressingStyle: S3AddressingStyle): Exclude { + if (addressingStyle !== "auto") { + return addressingStyle; + } + + const hostname = safeHostname(endpoint); + + if (hostname.endsWith(".aliyuncs.com") || hostname.endsWith(".myqcloud.com")) { + return "virtual-hosted"; + } + + return "path"; +} + +function safeHostname(endpoint: string): string { + try { + return new URL(endpoint).hostname.toLowerCase(); + } catch { + return ""; + } +} + function encodeKey(key: string): string { return key.split("/").map(encodePathPart).join("/"); } diff --git a/src/styles.scss b/src/styles.scss index e40aa82..2e2cdf2 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -125,14 +125,16 @@ @apply w-80 shrink-0; } -.obsync-control input { +.obsync-control input, +.obsync-control select { @apply h-9 w-full min-w-0 rounded-md px-3 py-2 text-sm outline-none transition; background: var(--background-primary); border: 1px solid var(--background-modifier-border); color: var(--text-normal); } -.obsync-control input:focus { +.obsync-control input:focus, +.obsync-control select:focus { border-color: var(--interactive-accent); box-shadow: 0 0 0 2px var(--background-modifier-border-focus); } diff --git a/src/sync/engine.test.ts b/src/sync/engine.test.ts index 4dd818b..f1c6959 100644 --- a/src/sync/engine.test.ts +++ b/src/sync/engine.test.ts @@ -644,6 +644,12 @@ describe("sync engine", () => { previousVersion: "ver_deleted", deletedBy: "dev_mac", }; + store.manifest.deletedDirectories["notes/empty"] = { + deletedAt: 1000, + deletedRevision: 3, + previousVersion: "ver_empty", + deletedBy: "dev_mac", + }; await store.putText(blobObjectKey(keptHash), "kept"); await store.putText(blobObjectKey(deletedHash), "deleted"); await store.putText(blobObjectKey(orphanHash), "orphan"); @@ -651,8 +657,10 @@ describe("sync engine", () => { const result = await releaseDeletedContent({ store, now: () => 5000 }); expect(result.deletedTombstones).toBe(1); + expect(result.deletedDirectoryTombstones).toBe(1); expect(result.deletedBlobs).toBe(2); expect(store.manifest.deleted["notes/deleted.md"]).toBeUndefined(); + expect(store.manifest.deletedDirectories["notes/empty"]).toBeUndefined(); expect(store.objects[blobObjectKey(keptHash)]).toBeDefined(); expect(store.objects[blobObjectKey(deletedHash)]).toBeUndefined(); expect(store.objects[blobObjectKey(orphanHash)]).toBeUndefined(); @@ -669,14 +677,22 @@ describe("sync engine", () => { previousVersion: "ver_deleted", deletedBy: "dev_mac", }; + store.manifest.deletedDirectories["notes/empty"] = { + deletedAt: 1000, + deletedRevision: 3, + previousVersion: "ver_empty", + deletedBy: "dev_mac", + }; await store.putText(blobObjectKey(deletedHash), "deleted"); const result = await releaseDeletedContent({ store, now: () => 5000 }); expect(result.locked).toBe(true); expect(result.deletedTombstones).toBe(0); + expect(result.deletedDirectoryTombstones).toBe(0); expect(result.deletedBlobs).toBe(0); expect(store.manifest.deleted["notes/deleted.md"]).toBeDefined(); + expect(store.manifest.deletedDirectories["notes/empty"]).toBeDefined(); expect(store.objects[blobObjectKey(deletedHash)]).toBeDefined(); }); diff --git a/src/sync/engine.ts b/src/sync/engine.ts index 49ee33b..56fbc76 100644 --- a/src/sync/engine.ts +++ b/src/sync/engine.ts @@ -256,6 +256,7 @@ async function applyConflict(item: PlannedFile, input: SyncOnceInput, result: Sy export interface ReleaseDeletedContentResult { deletedTombstones: number; + deletedDirectoryTombstones: number; deletedBlobs: number; locked: boolean; } @@ -264,14 +265,16 @@ export async function releaseDeletedContent(input: { store: ObjectStore; now(): const locked = await input.store.acquireLock(); if (!locked) { - return { deletedTombstones: 0, deletedBlobs: 0, locked: true }; + return { deletedTombstones: 0, deletedDirectoryTombstones: 0, deletedBlobs: 0, locked: true }; } try { const manifest = normalizeManifest(await input.store.readManifest()); const deletedTombstones = Object.keys(manifest.deleted).length; + const deletedDirectoryTombstones = Object.keys(manifest.deletedDirectories).length; manifest.deleted = {}; + manifest.deletedDirectories = {}; const referencedHashes = new Set(Object.values(manifest.paths).map((entry) => entry.contentHash)); const knownBlobKeys = new Set(Object.values(manifest.blobs).map((entry) => entry.key)); const listedBlobKeys = input.store.listObjectKeys ? await input.store.listObjectKeys("blobs/sha256/") : []; @@ -291,7 +294,7 @@ export async function releaseDeletedContent(input: { store: ObjectStore; now(): manifest.updatedAt = input.now(); await input.store.writeManifest(manifest); - return { deletedTombstones, deletedBlobs, locked: false }; + return { deletedTombstones, deletedDirectoryTombstones, deletedBlobs, locked: false }; } finally { await input.store.releaseLock(); }