feat(sync): support self-managed GitLab snippets

This commit is contained in:
ccq
2026-09-20 17:30:23 +08:00
committed by GitHub
parent f909f85075
commit b9cd24f718
18 changed files with 734 additions and 98 deletions
@@ -3845,9 +3845,13 @@ const webdavError = ref(false);
const syncMethodTab = ref<"webdav" | "snippet">("webdav");
const snippetProvider = ref<SnippetProvider>((localStorage.getItem("dbx-snippet-provider") as SnippetProvider) || "github");
const snippetInstanceUrl = ref(localStorage.getItem("dbx-gitlab-instance-url") || "https://gitlab.com");
const activeSnippetInstanceUrl = ref(snippetInstanceUrl.value);
const snippetInstanceError = ref("");
const snippetPreferenceKey = () => `dbx-snippet-remember-token-${snippetProvider.value}${snippetProvider.value === "gitlab" ? `-${activeSnippetInstanceUrl.value}` : ""}`;
const snippetId = ref("");
const snippetToken = ref("");
const snippetRememberToken = ref(localStorage.getItem(`dbx-snippet-remember-token-${snippetProvider.value}`) === "true");
const snippetRememberToken = ref(localStorage.getItem(snippetPreferenceKey()) === "true");
const snippetHasSavedToken = ref(false);
const snippetPassphrase = ref("");
const snippetSecretsPassphrase = ref("");
@@ -3861,7 +3865,7 @@ const pendingLegacyCleanupId = ref("");
const snippetSyncSettingsLoading = ref(true);
const webdavReady = computed(() => !!webdavEndpoint.value.trim() && !webdavBusy.value && (!webdavSyncSecrets.value || !!webdavSecretsPassphrase.value.trim() || webdavHasSavedSecretsPassphrase.value));
const snippetReady = computed(() => !snippetSyncSettingsLoading.value && !snippetBusy.value && (!!snippetToken.value.trim() || snippetHasSavedToken.value));
const snippetReady = computed(() => !snippetSyncSettingsLoading.value && !snippetBusy.value && (snippetProvider.value !== "gitlab" || (!snippetInstanceError.value && snippetInstanceUrl.value === activeSnippetInstanceUrl.value)) && (!!snippetToken.value.trim() || snippetHasSavedToken.value));
const snippetUploadReady = computed(() => snippetReady.value && !!snippetPassphrase.value.trim() && (!snippetIncludeSecrets.value || !!snippetSecretsPassphrase.value.trim()));
// Legacy plaintext snippets have no outer encryption password. Let the
// backend require one only after it detects an encrypted envelope so those
@@ -3871,6 +3875,7 @@ const snippetDownloadReady = computed(() => snippetReady.value && (!snippetResto
function currentSnippetConfig(replaceLegacySnippet = false): SnippetSyncConfig {
return {
provider: snippetProvider.value,
instanceUrl: snippetProvider.value === "gitlab" ? activeSnippetInstanceUrl.value : undefined,
token: snippetToken.value.trim() || undefined,
snippetId: snippetId.value.trim() || undefined,
replaceLegacySnippet: replaceLegacySnippet || undefined,
@@ -3882,43 +3887,70 @@ function currentSnippetAccountConfig(): SnippetSyncConfig {
}
async function refreshSnippetTokenStatus() {
const account = `${snippetProvider.value}:${activeSnippetInstanceUrl.value}`;
try {
const status = await snippetTokenStatus(currentSnippetAccountConfig());
if (account !== `${snippetProvider.value}:${activeSnippetInstanceUrl.value}`) return;
snippetHasSavedToken.value = status.hasSavedToken;
if (status.hasSavedToken) snippetRememberToken.value = true;
} catch {
snippetHasSavedToken.value = false;
if (account === `${snippetProvider.value}:${activeSnippetInstanceUrl.value}`) snippetHasSavedToken.value = false;
}
}
async function refreshSnippetSyncSettings(provider = snippetProvider.value) {
async function refreshSnippetSyncSettings(provider = snippetProvider.value, instanceUrl = activeSnippetInstanceUrl.value) {
const isCurrent = () => provider === snippetProvider.value && instanceUrl === activeSnippetInstanceUrl.value;
try {
const settings = await snippetSyncSettings(provider);
if (provider !== snippetProvider.value) return;
const settings = await snippetSyncSettings(provider, provider === "gitlab" ? instanceUrl : undefined);
if (!isCurrent()) return;
pendingLegacyCleanupId.value = settings.legacyCleanupRequiredId || "";
if (settings.snippetId) {
snippetId.value = settings.snippetId;
return;
}
const legacyId = localStorage.getItem(`dbx-snippet-id-${provider}`)?.trim();
const legacyId = provider === "gitlab" ? undefined : localStorage.getItem(`dbx-snippet-id-${provider}`)?.trim();
if (legacyId) {
await saveSnippetSyncId(provider, legacyId);
localStorage.removeItem(`dbx-snippet-id-${provider}`);
}
if (provider !== snippetProvider.value) return;
if (!isCurrent()) return;
snippetId.value = legacyId || "";
} catch {
if (provider === snippetProvider.value) {
if (isCurrent()) {
snippetId.value = "";
pendingLegacyCleanupId.value = "";
}
} finally {
if (provider === snippetProvider.value) snippetSyncSettingsLoading.value = false;
if (isCurrent()) snippetSyncSettingsLoading.value = false;
}
}
async function persistSnippetSyncId() {
await saveSnippetSyncId(snippetProvider.value, snippetId.value.trim() || undefined);
await saveSnippetSyncId(snippetProvider.value, snippetId.value.trim() || undefined, snippetProvider.value === "gitlab" ? activeSnippetInstanceUrl.value : undefined);
}
function commitSnippetInstance() {
try {
const url = new URL(snippetInstanceUrl.value.trim());
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error();
snippetInstanceUrl.value = url.href.replace(/\/$/, "");
snippetInstanceError.value = "";
} catch {
snippetInstanceError.value = t("settings.syncGitLabInstanceInvalid");
return;
}
if (snippetInstanceUrl.value === activeSnippetInstanceUrl.value) return;
activeSnippetInstanceUrl.value = snippetInstanceUrl.value;
localStorage.setItem("dbx-gitlab-instance-url", activeSnippetInstanceUrl.value);
snippetRememberToken.value = localStorage.getItem(snippetPreferenceKey()) === "true";
snippetToken.value = "";
snippetHasSavedToken.value = false;
snippetId.value = "";
legacySnippetId.value = "";
pendingLegacyCleanupId.value = "";
snippetSyncSettingsLoading.value = true;
void refreshSnippetTokenStatus();
void refreshSnippetSyncSettings();
}
async function applySnippetTokenPreference() {
@@ -3940,7 +3972,7 @@ async function runSnippetAction(kind: "test" | "upload" | "download" | "migrate"
snippetError.value = false;
try {
localStorage.setItem("dbx-snippet-provider", snippetProvider.value);
localStorage.setItem(`dbx-snippet-remember-token-${snippetProvider.value}`, String(snippetRememberToken.value));
localStorage.setItem(snippetPreferenceKey(), String(snippetRememberToken.value));
if (persistCurrentSnippetId) await persistSnippetSyncId();
await applySnippetTokenPreference();
snippetMessage.value = await action();
@@ -4325,8 +4357,9 @@ watch([webdavAutoUploadEnabled, webdavAutoUploadIntervalMinutes], () => {
watch(snippetProvider, (provider) => {
localStorage.setItem("dbx-snippet-provider", provider);
snippetId.value = "";
snippetRememberToken.value = localStorage.getItem(`dbx-snippet-remember-token-${provider}`) === "true";
snippetRememberToken.value = localStorage.getItem(snippetPreferenceKey()) === "true";
snippetToken.value = "";
snippetHasSavedToken.value = false;
legacySnippetId.value = "";
pendingLegacyCleanupId.value = "";
snippetSyncSettingsLoading.value = true;
@@ -8456,7 +8489,7 @@ LIMIT 100;</pre
<Tabs v-model="syncMethodTab" class="w-full">
<TabsList v-if="!isWeb" class="grid w-full grid-cols-2">
<TabsTrigger value="webdav">WebDAV</TabsTrigger>
<TabsTrigger value="snippet">GitHub / Gitee</TabsTrigger>
<TabsTrigger value="snippet">{{ t("settings.syncSnippetTitle") }}</TabsTrigger>
</TabsList>
<TabsContent value="webdav" data-settings-search-id="sync-webdav" :class="['mt-5 space-y-5', settingsSearchTargetClass('sync-webdav')]">
@@ -8583,9 +8616,16 @@ LIMIT 100;</pre
<SelectContent>
<SelectItem value="github">GitHub Gist</SelectItem>
<SelectItem value="gitee">{{ t("settings.syncSnippetProviderGitee") }}</SelectItem>
<SelectItem value="gitlab">GitLab Snippets</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="snippetProvider === 'gitlab'" class="space-y-2 md:col-span-2">
<Label for="gitlab-instance-url">{{ t("settings.syncGitLabInstance") }}</Label>
<Input id="gitlab-instance-url" v-model="snippetInstanceUrl" type="url" autocomplete="url" :disabled="!!snippetBusy" placeholder="https://gitlab.example.com" @blur="commitSnippetInstance" @keydown.enter="commitSnippetInstance" />
<p v-if="snippetInstanceError" class="text-xs text-destructive">{{ snippetInstanceError }}</p>
<p v-if="activeSnippetInstanceUrl.startsWith('http://')" class="text-xs text-destructive">{{ t("settings.syncGitLabHttpWarning") }}</p>
</div>
<div class="space-y-2">
<Label for="snippet-sync-id">{{ t("settings.syncSnippetId") }}</Label>
<Input id="snippet-sync-id" v-model="snippetId" autocomplete="off" :disabled="snippetSyncSettingsLoading || !!snippetBusy" :placeholder="t('settings.syncSnippetIdPlaceholder')" @blur="persistSnippetSyncId" />
+7 -4
View File
@@ -7637,7 +7637,7 @@ export default {
syncSecretNotice: "By default, DBX syncs connection details and settings only. Database passwords, SSH passwords, proxy passwords, HTTP tunnel tokens, connection strings, and AI API keys stay local.",
syncSecrets: "Sync encrypted secrets",
syncSecretsDescription: "When enabled, DBX encrypts database passwords, SSH passwords, proxy passwords, HTTP tunnel tokens, and AI API keys before uploading them to remote storage.",
syncSecretsSharedDescription: "For WebDAV, this password encrypts included credentials. GitHub Gist and Gitee snippets use independent encryption and credential controls.",
syncSecretsSharedDescription: "For WebDAV, this password encrypts included credentials. GitHub, Gitee, and GitLab snippets use independent encryption and credential controls.",
syncSecretsPassphrase: "Sync password",
syncSecretsPassphraseDescription: "This password is only used to encrypt and restore sensitive data. It is encrypted in the current DBX instance; enter it again in other instances.",
syncTest: "Test",
@@ -7654,17 +7654,20 @@ export default {
syncSecretsApplied: "Encrypted secrets were restored.",
syncSecretsSkipped: "Encrypted secrets were present but were not restored.",
syncDownloadConfirm: "Download and apply the remote DBX snapshot? Local metadata and saved SQL will be replaced.",
syncSnippetTitle: "GitHub / Gitee Snippet Sync",
syncSnippetTitle: "Snippet Sync",
syncSnippetDescription: "Store the same DBX snapshot in a private code snippet. The first upload creates a snippet and saves its ID.",
syncSnippetProvider: "Provider",
syncSnippetProviderGitee: "Gitee Snippets",
syncGitLabInstance: "GitLab instance URL",
syncGitLabInstanceInvalid: "Enter an HTTP or HTTPS URL without credentials, query, or fragment.",
syncGitLabHttpWarning: "HTTP sends the access token in cleartext. Use it only on a trusted internal network; prefer HTTPS.",
syncSnippetId: "Snippet ID",
syncSnippetIdPlaceholder: "Leave empty for the first upload",
syncSnippetToken: "Access token",
syncSnippetRememberToken: "Store encrypted on this device",
syncSnippetTokenDescription: "GitHub tokens need Gists write permission. Tokens are never included in the sync snapshot.",
syncSnippetTokenDescription: "GitHub tokens need Gists write permission; GitLab personal access tokens need api scope. Tokens are never included in the sync snapshot.",
syncSnippetPassphrase: "Snippet encryption password",
syncSnippetPassphraseDescription: "Encrypts the complete GitHub/Gitee snapshot. It is not saved and is separate from the password for synced credentials.",
syncSnippetPassphraseDescription: "Encrypts the complete snippet snapshot. It is not saved and is separate from the password for synced credentials.",
syncSnippetIncludeSecrets: "Include encrypted credentials when uploading",
syncSnippetRestoreSecrets: "Restore encrypted credentials when downloading",
syncSnippetTestSuccess: "The snippet sync target is accessible.",
+3
View File
@@ -7113,6 +7113,9 @@ export default withEnglishFallback({
sidebarShowTooltipsDescription: "Muestra sugerencias con detalles al pasar el cursor sobre conexiones y objetos de la base de datos en la barra lateral. Desactívalo para ocultarlas.",
snippetsDescription: "Personaliza plantillas SQL activadas en el editor.",
syncSnippetProviderGitee: "Fragmentos de Gitee",
syncGitLabInstance: "URL de la instancia de GitLab",
syncGitLabInstanceInvalid: "Introduce una URL HTTP o HTTPS sin credenciales, parámetros de consulta ni fragmentos.",
syncGitLabHttpWarning: "HTTP envía el token de acceso en texto sin cifrar. Úsalo solo en una red interna de confianza; se recomienda HTTPS.",
snippetsPlaceholderHint: "Usa {'${número:texto predeterminado}'} para crear un marcador editable. Los marcadores con el mismo número permanecen vinculados. Por ejemplo:",
snippetsAdd: "Agregar fragmento",
snippetsLabel: "Etiqueta",
+3
View File
@@ -7112,6 +7112,9 @@ export default withEnglishFallback({
sidebarShowTooltipsDescription: "Mostra tooltip con dettagli al passaggio del mouse su connessioni e oggetti del database nella barra laterale. Disattiva per nasconderli.",
snippetsDescription: "Personalizza i modelli di snippet SQL attivati nell'editor.",
syncSnippetProviderGitee: "Snippet Gitee",
syncGitLabInstance: "URL dell'istanza GitLab",
syncGitLabInstanceInvalid: "Inserisci un URL HTTP o HTTPS senza credenziali, parametri di query o frammenti.",
syncGitLabHttpWarning: "HTTP trasmette il token di accesso in chiaro. Usalo solo su una rete interna attendibile; preferisci HTTPS.",
snippetsPlaceholderHint: "Usa {'${numero:testo predefinito}'} per creare un segnaposto modificabile. I segnaposto con lo stesso numero restano collegati. Ad esempio:",
snippetsAdd: "Aggiungi Snippet",
snippetsLabel: "Etichetta",
+3
View File
@@ -7135,6 +7135,9 @@ export default withEnglishFallback({
sidebarShowTooltipsDescription: "サイドバーで接続やデータベースオブジェクトにホバーしたときに詳細ツールチップを表示します。オフにすると表示されません。",
snippetsDescription: "エディタでトリガーされるSQLスニペットテンプレートをカスタマイズします。",
syncSnippetProviderGitee: "Gitee コードスニペット",
syncGitLabInstance: "GitLab インスタンス URL",
syncGitLabInstanceInvalid: "認証情報、クエリ、フラグメントを含まない HTTP または HTTPS の URL を入力してください。",
syncGitLabHttpWarning: "HTTP ではアクセストークンが平文で送信されます。信頼できる内部ネットワークでのみ使用し、HTTPS を優先してください。",
snippetsPlaceholderHint: "{'${番号:デフォルトテキスト}'} で編集可能なプレースホルダーを作成できます。同じ番号のプレースホルダーは連動します。例:",
snippetsAdd: "スニペットを追加",
snippetsLabel: "ラベル",
+3
View File
@@ -6954,6 +6954,9 @@ export default withEnglishFallback({
syncSnippetDescription: "동일한 DBX 스냅샷을 비공개 코드 스니펫에 저장합니다. 첫 업로드가 스니펫을 만들고 ID를 저장합니다.",
syncSnippetProvider: "공급자",
syncSnippetProviderGitee: "Gitee 스니펫",
syncGitLabInstance: "GitLab 인스턴스 URL",
syncGitLabInstanceInvalid: "자격 증명, 쿼리 또는 프래그먼트가 없는 HTTP 또는 HTTPS URL을 입력하세요.",
syncGitLabHttpWarning: "HTTP는 액세스 토큰을 평문으로 전송합니다. 신뢰할 수 있는 내부 네트워크에서만 사용하고 HTTPS를 우선 사용하세요.",
syncSnippetId: "스니펫 ID",
syncSnippetIdPlaceholder: "첫 업로드의 경우 비워 두세요",
syncSnippetToken: "액세스 토큰",
+3
View File
@@ -7114,6 +7114,9 @@ export default withEnglishFallback({
sidebarShowTooltipsDescription: "Mostra dicas com detalhes ao passar o mouse sobre conexões e objetos do banco de dados na barra lateral. Desative para ocultá-las.",
snippetsDescription: "Personalize os modelos de snippets SQL acionados no editor.",
syncSnippetProviderGitee: "Snippets do Gitee",
syncGitLabInstance: "URL da instância do GitLab",
syncGitLabInstanceInvalid: "Insira uma URL HTTP ou HTTPS sem credenciais, parâmetros de consulta ou fragmentos.",
syncGitLabHttpWarning: "O HTTP envia o token de acesso em texto simples. Use-o somente em uma rede interna confiável; prefira HTTPS.",
snippetsPlaceholderHint: "Use {'${número:texto padrão}'} para criar um espaço reservado editável. Espaços reservados com o mesmo número permanecem vinculados. Por exemplo:",
snippetsAdd: "Adicionar snippet",
snippetsLabel: "Rótulo",
+7 -4
View File
@@ -7608,7 +7608,7 @@ export default withEnglishFallback({
syncSecretNotice: "默认只同步连接信息和设置,不会同步数据库密码、SSH 密码、代理密码、HTTP 隧道 Token、连接串或 AI API Key。",
syncSecrets: "同步加密后的敏感信息",
syncSecretsDescription: "开启后,DBX 会先加密数据库密码、SSH 密码、代理密码、HTTP 隧道 Token 和 AI API Key,再上传到远端存储。",
syncSecretsSharedDescription: "WebDAV 使用此密码加密包含的敏感信息。GitHub Gist 和 Gitee 代码片段使用独立的加密和敏感信息选项。",
syncSecretsSharedDescription: "WebDAV 使用此密码加密包含的敏感信息。GitHub、Gitee 和 GitLab 代码片段使用独立的加密和敏感信息选项。",
syncSecretsPassphrase: "同步密码",
syncSecretsPassphraseDescription: "这个密码只用于加密和恢复敏感信息,并会加密保存在当前 DBX 实例中;在其他实例恢复时需要再次输入。",
syncTest: "测试",
@@ -7625,17 +7625,20 @@ export default withEnglishFallback({
syncSecretsApplied: "已恢复加密敏感信息。",
syncSecretsSkipped: "远端包含加密敏感信息,但本次未恢复。",
syncDownloadConfirm: "要下载并应用远端 DBX 快照吗?本地元信息和保存的 SQL 会被替换。",
syncSnippetTitle: "GitHub / Gitee 代码片段同步",
syncSnippetTitle: "代码片段同步",
syncSnippetDescription: "将同一份 DBX 快照保存到私有代码片段。首次上传会自动创建代码片段并保存其 ID。",
syncSnippetProvider: "平台",
syncSnippetProviderGitee: "Gitee 代码片段",
syncGitLabInstance: "GitLab 实例地址",
syncGitLabInstanceInvalid: "请输入不含账号、查询参数或片段的 HTTP 或 HTTPS 地址。",
syncGitLabHttpWarning: "HTTP 会明文传输访问令牌。仅在可信内网使用,建议优先配置 HTTPS。",
syncSnippetId: "代码片段 ID",
syncSnippetIdPlaceholder: "首次上传可留空",
syncSnippetToken: "访问令牌",
syncSnippetRememberToken: "加密保存在本机",
syncSnippetTokenDescription: "GitHub 令牌需要 Gists 写权限;令牌不会写入同步快照。",
syncSnippetTokenDescription: "GitHub 需要 Gists 写权限;GitLab 个人访问令牌需要 api 权限。令牌不会写入同步快照。",
syncSnippetPassphrase: "代码片段加密密码",
syncSnippetPassphraseDescription: "用于加密完整的 GitHub/Gitee 快照,不会保存,且与敏感信息同步密码相互独立。",
syncSnippetPassphraseDescription: "用于加密完整的代码片段快照,不会保存,且与敏感信息同步密码相互独立。",
syncSnippetIncludeSecrets: "上传时包含加密敏感信息",
syncSnippetRestoreSecrets: "下载时恢复加密敏感信息",
syncSnippetTestSuccess: "代码片段同步目标可访问。",
+3
View File
@@ -6435,6 +6435,9 @@ export default withEnglishFallback({
sidebarShowTooltipsDescription: "在側邊欄的連線與資料庫物件上懸浮時顯示詳細提示;關閉後懸浮不再彈出。",
snippetsDescription: "自訂編輯器中觸發的 SQL 程式碼片段範本。",
syncSnippetProviderGitee: "Gitee 程式碼片段",
syncGitLabInstance: "GitLab 執行個體網址",
syncGitLabInstanceInvalid: "請輸入不含認證資訊、查詢參數或片段的 HTTP 或 HTTPS 網址。",
syncGitLabHttpWarning: "HTTP 會以明文傳輸存取權杖。僅限在受信任的內部網路使用,並優先使用 HTTPS。",
snippetsPlaceholderHint: "使用 {'${編號:預設文字}'} 建立可編輯的佔位符;編號相同的佔位符會同步修改,例如:",
snippetsAdd: "新增片段",
snippetsLabel: "顯示名",
@@ -74,3 +74,27 @@ describe("WebDAV sync HTTP API", () => {
});
});
});
describe("GitLab snippet sync HTTP API", () => {
it("passes the selected instance to settings, ID, and transfer requests", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({}) });
vi.stubGlobal("fetch", fetchMock);
const { snippetSyncSettings, saveSnippetSyncId, snippetSyncTest } = await import("@/lib/backend/http");
const instanceUrl = "https://gitlab.example.com";
await snippetSyncSettings("gitlab", instanceUrl);
let [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
expect(url).toBe("/api/cloud-sync/snippet/settings");
expect(JSON.parse(String(init.body))).toEqual({ provider: "gitlab", instanceUrl });
await saveSnippetSyncId("gitlab", "42", instanceUrl);
[url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
expect(url).toBe("/api/cloud-sync/snippet/save-id");
expect(JSON.parse(String(init.body))).toEqual({ provider: "gitlab", snippetId: "42", instanceUrl });
await snippetSyncTest({ provider: "gitlab", instanceUrl, token: "test-token" });
[url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
expect(url).toBe("/api/cloud-sync/snippet/test");
expect(JSON.parse(String(init.body))).toEqual({ config: { provider: "gitlab", instanceUrl, token: "test-token" } });
vi.unstubAllGlobals();
});
});
+6 -5
View File
@@ -2268,10 +2268,11 @@ export interface WebDavSyncSecretsStatus {
hasSavedPassphrase: boolean;
}
export type SnippetProvider = "github" | "gitee";
export type SnippetProvider = "github" | "gitee" | "gitlab";
export interface SnippetSyncConfig {
provider: SnippetProvider;
instanceUrl?: string;
token?: string;
snippetId?: string;
replaceLegacySnippet?: boolean;
@@ -2361,12 +2362,12 @@ export async function forgetSnippetSavedToken(config: SnippetSyncConfig): Promis
await post("/api/cloud-sync/snippet/forget-token", { config });
}
export async function snippetSyncSettings(provider: SnippetProvider): Promise<SnippetSyncSettings> {
return post("/api/cloud-sync/snippet/settings", { provider });
export async function snippetSyncSettings(provider: SnippetProvider, instanceUrl?: string): Promise<SnippetSyncSettings> {
return post("/api/cloud-sync/snippet/settings", { provider, instanceUrl });
}
export async function saveSnippetSyncId(provider: SnippetProvider, snippetId?: string): Promise<void> {
await post("/api/cloud-sync/snippet/save-id", { provider, snippetId });
export async function saveSnippetSyncId(provider: SnippetProvider, snippetId?: string, instanceUrl?: string): Promise<void> {
await post("/api/cloud-sync/snippet/save-id", { provider, snippetId, instanceUrl });
}
export async function retrySnippetLegacyCleanup(config: SnippetSyncConfig): Promise<SnippetSyncSettings> {
+6 -5
View File
@@ -372,10 +372,11 @@ export interface WebDavSyncSecretsStatus {
hasSavedPassphrase: boolean;
}
export type SnippetProvider = "github" | "gitee";
export type SnippetProvider = "github" | "gitee" | "gitlab";
export interface SnippetSyncConfig {
provider: SnippetProvider;
instanceUrl?: string;
token?: string;
snippetId?: string;
replaceLegacySnippet?: boolean;
@@ -983,12 +984,12 @@ export async function forgetSnippetSavedToken(config: SnippetSyncConfig): Promis
return invoke("forget_snippet_saved_token", { config });
}
export async function snippetSyncSettings(provider: SnippetProvider): Promise<SnippetSyncSettings> {
return invoke("snippet_sync_settings", { provider });
export async function snippetSyncSettings(provider: SnippetProvider, instanceUrl?: string): Promise<SnippetSyncSettings> {
return invoke("snippet_sync_settings", { provider, instanceUrl });
}
export async function saveSnippetSyncId(provider: SnippetProvider, snippetId?: string): Promise<void> {
return invoke("save_snippet_sync_id", { provider, snippetId });
export async function saveSnippetSyncId(provider: SnippetProvider, snippetId?: string, instanceUrl?: string): Promise<void> {
return invoke("save_snippet_sync_id", { provider, snippetId, instanceUrl });
}
export async function retrySnippetLegacyCleanup(config: SnippetSyncConfig): Promise<SnippetSyncSettings> {
+505 -37
View File
@@ -28,6 +28,7 @@ const DEFAULT_REMOTE_PATH: &str = "DBX/sync/snapshot.json";
const DEFAULT_SNIPPET_FILE_NAME: &str = "dbx-sync.json";
const GITHUB_API_BASE: &str = "https://api.github.com";
const GITEE_API_BASE: &str = "https://gitee.com/api/v5";
const GITLAB_DEFAULT_INSTANCE: &str = "https://gitlab.com";
const SECRET_KEYS: &[&str] = &[
"password",
"ssh_password",
@@ -64,12 +65,16 @@ pub enum SnippetProvider {
GitHub,
#[serde(rename = "gitee")]
Gitee,
#[serde(rename = "gitlab")]
GitLab,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SnippetSyncConfig {
pub provider: SnippetProvider,
#[serde(default)]
pub instance_url: Option<String>,
pub token: Option<String>,
pub snippet_id: Option<String>,
/// Explicitly requested one-time migration for a legacy plaintext snippet.
@@ -395,7 +400,7 @@ pub async fn snippet_saved_token_status(
storage: &Storage,
config: &SnippetSyncConfig,
) -> Result<SnippetTokenStatus, String> {
let account = snippet_token_account(config.provider);
let account = snippet_token_account(config)?;
Ok(SnippetTokenStatus { has_saved_token: storage.load_webdav_password_blob(&account).await?.is_some() })
}
@@ -403,18 +408,27 @@ pub async fn save_snippet_token(storage: &Storage, config: &SnippetSyncConfig, t
let secret = storage.load_or_create_local_device_secret().await?;
let blob = encrypt_text_with_secret(token, &secret)?;
let value = serde_json::to_value(blob).map_err(|e| e.to_string())?;
storage.save_webdav_password_blob(&snippet_token_account(config.provider), &value).await
storage.save_webdav_password_blob(&snippet_token_account(config)?, &value).await
}
pub async fn forget_snippet_token(storage: &Storage, config: &SnippetSyncConfig) -> Result<(), String> {
storage.delete_webdav_password_blob(&snippet_token_account(config.provider)).await
storage.delete_webdav_password_blob(&snippet_token_account(config)?).await
}
pub async fn snippet_sync_settings(
storage: &Storage,
provider: SnippetProvider,
) -> Result<SnippetSyncSettings, String> {
let state = storage.load_snippet_sync_state(snippet_provider_storage_key(provider)).await?;
snippet_sync_settings_for_instance(storage, provider, None).await
}
pub async fn snippet_sync_settings_for_instance(
storage: &Storage,
provider: SnippetProvider,
instance_url: Option<&str>,
) -> Result<SnippetSyncSettings, String> {
let key = snippet_provider_storage_key(provider, instance_url)?;
let state = storage.load_snippet_sync_state(&key).await?;
Ok(SnippetSyncSettings {
snippet_id: state.snippet_id,
legacy_cleanup_required_id: state.pending_cleanup.map(|cleanup| cleanup.snippet_id),
@@ -426,7 +440,20 @@ pub async fn save_snippet_sync_id(
provider: SnippetProvider,
snippet_id: Option<&str>,
) -> Result<(), String> {
storage.save_snippet_sync_id(snippet_provider_storage_key(provider), snippet_id).await
save_snippet_sync_id_for_instance(storage, provider, None, snippet_id).await
}
pub async fn save_snippet_sync_id_for_instance(
storage: &Storage,
provider: SnippetProvider,
instance_url: Option<&str>,
snippet_id: Option<&str>,
) -> Result<(), String> {
if let Some(id) = normalized_snippet_id(snippet_id) {
validate_snippet_id(provider, id)?;
}
let key = snippet_provider_storage_key(provider, instance_url)?;
storage.save_snippet_sync_id(&key, snippet_id).await
}
pub async fn finalize_snippet_migration(
@@ -437,17 +464,17 @@ pub async fn finalize_snippet_migration(
let Some(pending_cleanup) = snippet_pending_cleanup(summary)? else {
return Ok(());
};
let provider_key = snippet_provider_storage_key(summary.provider);
let provider_key = client.storage_key()?;
storage
.save_snippet_migration_state(
provider_key,
&provider_key,
&summary.snippet_id,
&pending_cleanup.snippet_id,
&pending_cleanup.expected_content_hash,
)
.await?;
if client.delete_legacy_snippet_if_unchanged(&pending_cleanup).await.unwrap_or(false)
&& storage.clear_snippet_pending_cleanup_if_matches(provider_key, &pending_cleanup).await?
&& storage.clear_snippet_pending_cleanup_if_matches(&provider_key, &pending_cleanup).await?
{
summary.legacy_cleanup_required_id = None;
summary.legacy_cleanup_expected_content_hash = None;
@@ -460,21 +487,24 @@ pub async fn retry_pending_snippet_cleanup(
provider: SnippetProvider,
client: &SnippetSyncClient,
) -> Result<SnippetSyncSettings, String> {
let provider_key = snippet_provider_storage_key(provider);
let state = storage.load_snippet_sync_state(provider_key).await?;
if provider != client.config.provider {
return Err("Snippet provider mismatch".to_string());
}
let provider_key = client.storage_key()?;
let state = storage.load_snippet_sync_state(&provider_key).await?;
if let Some(pending_cleanup) = state.pending_cleanup {
if client.delete_legacy_snippet_if_unchanged(&pending_cleanup).await? {
storage.clear_snippet_pending_cleanup_if_matches(provider_key, &pending_cleanup).await?;
storage.clear_snippet_pending_cleanup_if_matches(&provider_key, &pending_cleanup).await?;
}
}
snippet_sync_settings(storage, provider).await
snippet_sync_settings_for_instance(storage, provider, client.config.instance_url.as_deref()).await
}
pub async fn resolve_snippet_token(storage: &Storage, config: &mut SnippetSyncConfig) -> Result<(), String> {
if config.token.as_deref().is_some_and(|token| !token.trim().is_empty()) {
return Ok(());
}
let Some(value) = storage.load_webdav_password_blob(&snippet_token_account(config.provider)).await? else {
let Some(value) = storage.load_webdav_password_blob(&snippet_token_account(config)?).await? else {
return Ok(());
};
let blob: EncryptedSecretsBlob = serde_json::from_value(value).map_err(|e| e.to_string())?;
@@ -623,12 +653,23 @@ impl WebDavClient {
}
impl SnippetSyncClient {
pub fn new(config: SnippetSyncConfig) -> Self {
pub fn new(config: SnippetSyncConfig) -> Result<Self, String> {
let api_base = match config.provider {
SnippetProvider::GitHub => GITHUB_API_BASE,
SnippetProvider::Gitee => GITEE_API_BASE,
SnippetProvider::GitLab => {
let instance = gitlab_instance_url(config.instance_url.as_deref())?;
return Ok(Self {
http: Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| e.to_string())?,
config,
api_base: format!("{instance}/api/v4"),
});
}
};
Self { http: Client::new(), config, api_base: api_base.to_string() }
Ok(Self { http: Client::new(), config, api_base: api_base.to_string() })
}
#[cfg(test)]
@@ -636,10 +677,24 @@ impl SnippetSyncClient {
Self { http: Client::new(), config, api_base }
}
fn storage_key(&self) -> Result<String, String> {
snippet_provider_storage_key(self.config.provider, self.config.instance_url.as_deref())
}
fn snippet_url(&self, id: Option<&str>) -> Result<String, String> {
let path = if self.config.provider == SnippetProvider::GitLab { "snippets" } else { "gists" };
if let Some(id) = id {
validate_snippet_id(self.config.provider, id)?;
Ok(format!("{}/{path}/{id}", self.api_base))
} else {
Ok(format!("{}/{path}", self.api_base))
}
}
pub async fn test(&self) -> Result<(), String> {
self.require_token()?;
let url = match (self.config.provider, normalized_snippet_id(self.config.snippet_id.as_deref())) {
(_, Some(id)) => format!("{}/gists/{id}", self.api_base),
(_, Some(id)) => self.snippet_url(Some(id))?,
(_, None) => format!("{}/user", self.api_base),
};
let response = self.request(Method::GET, &url)?.send().await.map_err(|e| e.to_string())?;
@@ -703,8 +758,9 @@ impl SnippetSyncClient {
// snippet would retain its plaintext revision history on the provider.
let update_id = if legacy_snapshot.is_some() { None } else { existing_id };
let (method, url) = match (self.config.provider, update_id) {
(_, Some(id)) => (Method::PATCH, format!("{}/gists/{id}", self.api_base)),
(_, None) => (Method::POST, format!("{}/gists", self.api_base)),
(SnippetProvider::GitLab, Some(id)) => (Method::PUT, self.snippet_url(Some(id))?),
(_, Some(id)) => (Method::PATCH, self.snippet_url(Some(id))?),
(_, None) => (Method::POST, self.snippet_url(None)?),
};
let response = match self.config.provider {
@@ -721,6 +777,14 @@ impl SnippetSyncClient {
// Gitee validates `files` as a nested object; form encoding turns it into a string and is rejected.
self.request(method, &url)?.json(&payload).send().await
}
SnippetProvider::GitLab => {
let payload = if update_id.is_some() {
serde_json::json!({"files": [{"action": "update", "file_path": DEFAULT_SNIPPET_FILE_NAME, "content": content}]})
} else {
serde_json::json!({"title": "DBX encrypted configuration sync", "visibility": "private", "files": [{"file_path": DEFAULT_SNIPPET_FILE_NAME, "content": content}]})
};
self.request(method, &url)?.json(&payload).send().await
}
}
.map_err(|e| e.to_string())?;
let status = response.status();
@@ -770,11 +834,41 @@ impl SnippetSyncClient {
}
async fn load_snippet_content(&self, snippet_id: &str) -> Result<String, String> {
let url = format!("{}/gists/{snippet_id}", self.api_base);
let url = self.snippet_url(Some(snippet_id))?;
let response = self.request(Method::GET, &url)?.send().await.map_err(|e| e.to_string())?;
let status = response.status();
let response_body = response.text().await.map_err(|e| e.to_string())?;
ensure_snippet_response_success(status, "download", &response_body)?;
if self.config.provider == SnippetProvider::GitLab {
let value: serde_json::Value = serde_json::from_str(&response_body).map_err(|e| e.to_string())?;
let files = value
.get("files")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "Snippet response did not include files".to_string())?;
let file = files
.iter()
.find(|file| file.get("path").and_then(serde_json::Value::as_str) == Some(DEFAULT_SNIPPET_FILE_NAME))
.ok_or_else(|| format!("Snippet does not contain {DEFAULT_SNIPPET_FILE_NAME}"))?;
// Snippet repositories default to `main` on current instances but
// `master` on older ones; the per-file `raw_url` always carries the
// snippet's actual default branch, so prefer it over guessing.
let constructed_main = format!("{url}/files/main/{DEFAULT_SNIPPET_FILE_NAME}/raw");
let raw_url = file
.get("raw_url")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| constructed_main.clone());
let response = self.request(Method::GET, &raw_url)?.send().await.map_err(|e| e.to_string())?;
if response.status() == StatusCode::NOT_FOUND && raw_url == constructed_main {
let master_url = format!("{url}/files/master/{DEFAULT_SNIPPET_FILE_NAME}/raw");
let response = self.request(Method::GET, &master_url)?.send().await.map_err(|e| e.to_string())?;
ensure_snippet_success(response.status(), "raw download")?;
return response.text().await.map_err(|e| e.to_string());
}
ensure_snippet_success(response.status(), "raw download")?;
return response.text().await.map_err(|e| e.to_string());
}
let value: serde_json::Value = serde_json::from_str(&response_body).map_err(|e| e.to_string())?;
let (content, raw_url) = snippet_file_content(&value, DEFAULT_SNIPPET_FILE_NAME)?;
let content = match content {
@@ -801,7 +895,7 @@ impl SnippetSyncClient {
{
return Ok(false);
}
let url = format!("{}/gists/{}", self.api_base, pending_cleanup.snippet_id);
let url = self.snippet_url(Some(&pending_cleanup.snippet_id))?;
let response = self.request(Method::DELETE, &url)?.send().await.map_err(|e| e.to_string())?;
// If the provider reports that the old snippet is already absent, the
// cleanup goal is satisfied and the newly created encrypted snippet is
@@ -833,6 +927,7 @@ impl SnippetSyncClient {
.bearer_auth(token),
// Gitee API v5 documents access_token as a request parameter rather than an Authorization header.
SnippetProvider::Gitee => request.query(&[("access_token", token)]),
SnippetProvider::GitLab => request.header("PRIVATE-TOKEN", token),
})
}
}
@@ -1486,26 +1581,49 @@ fn snippet_pending_cleanup(summary: &SnippetSyncSummary) -> Result<Option<Snippe
fn required_snippet_passphrase(passphrase: Option<&str>) -> Result<&str, String> {
normalized_passphrase(passphrase)
.ok_or_else(|| "A snippet encryption password is required for GitHub and Gitee sync.".to_string())
.ok_or_else(|| "A snippet encryption password is required for snippet sync.".to_string())
}
fn required_sync_passphrase(passphrase: Option<&str>) -> Result<&str, String> {
normalized_passphrase(passphrase)
.ok_or_else(|| "A sync password is required for GitHub and Gitee snippet sync.".to_string())
normalized_passphrase(passphrase).ok_or_else(|| "A sync password is required for snippet sync.".to_string())
}
fn snippet_provider_storage_key(provider: SnippetProvider) -> &'static str {
match provider {
SnippetProvider::GitHub => "github",
SnippetProvider::Gitee => "gitee",
fn gitlab_instance_url(value: Option<&str>) -> Result<String, String> {
let value = value.unwrap_or(GITLAB_DEFAULT_INSTANCE).trim();
let url = Url::parse(value).map_err(|_| "Enter a valid GitLab HTTPS instance URL".to_string())?;
if !matches!(url.scheme(), "https" | "http")
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err("GitLab instance must be an HTTP or HTTPS URL without credentials, query, or fragment".to_string());
}
Ok(url.as_str().trim_end_matches('/').to_string())
}
fn snippet_token_account(provider: SnippetProvider) -> String {
match provider {
SnippetProvider::GitHub => "snippet-token:github".to_string(),
SnippetProvider::Gitee => "snippet-token:gitee".to_string(),
fn snippet_provider_storage_key(provider: SnippetProvider, instance_url: Option<&str>) -> Result<String, String> {
Ok(match provider {
SnippetProvider::GitHub => "github".to_string(),
SnippetProvider::Gitee => "gitee".to_string(),
SnippetProvider::GitLab => {
format!("gitlab:{:x}", Sha256::digest(gitlab_instance_url(instance_url)?.as_bytes()))
}
})
}
fn snippet_token_account(config: &SnippetSyncConfig) -> Result<String, String> {
Ok(format!("snippet-token:{}", snippet_provider_storage_key(config.provider, config.instance_url.as_deref())?))
}
fn validate_snippet_id(provider: SnippetProvider, id: &str) -> Result<(), String> {
if provider == SnippetProvider::GitLab
&& (id.is_empty() || id.bytes().all(|byte| byte == b'0') || !id.bytes().all(|byte| byte.is_ascii_digit()))
{
return Err("GitLab snippet ID must be a positive numeric ID".to_string());
}
Ok(())
}
fn ensure_snippet_success(status: StatusCode, operation: &str) -> Result<(), String> {
@@ -1541,6 +1659,9 @@ fn snippet_response_id(value: &serde_json::Value) -> Option<String> {
if let Some(id) = value.get("id").and_then(serde_json::Value::as_str) {
return Some(id.to_string());
}
if let Some(id) = value.get("id").and_then(serde_json::Value::as_u64) {
return Some(id.to_string());
}
// Some Gitee endpoints historically document an array response despite returning one created snippet.
value.as_array()?.first()?.get("id")?.as_str().map(str::to_string)
}
@@ -1652,13 +1773,16 @@ mod tests {
apply_sensitive_payload, apply_sync_snapshot, build_sensitive_payload, build_sync_snapshot,
build_sync_snapshot_with_saved_secrets, decrypt_sensitive_payload, encrypt_sensitive_payload,
encrypt_snippet_snapshot, finalize_snippet_migration, forget_webdav_sync_secrets_passphrase,
gitee_snippet_payload, is_legacy_dbx_snapshot, normalized_remote_path, parent_collection_paths,
parse_legacy_dbx_snapshot, parse_snippet_snapshot, prepare_legacy_snippet_snapshot,
resolve_webdav_sync_secrets_passphrase, retry_pending_snippet_cleanup, save_snippet_sync_id,
gitee_snippet_payload, gitlab_instance_url, is_legacy_dbx_snapshot, normalized_remote_path,
parent_collection_paths, parse_legacy_dbx_snapshot, parse_snippet_snapshot, prepare_legacy_snippet_snapshot,
resolve_snippet_token, resolve_webdav_sync_secrets_passphrase, retry_pending_snippet_cleanup,
save_snippet_sync_id, save_snippet_sync_id_for_instance, save_snippet_token,
save_webdav_sync_secrets_preference, scrub_connection_secrets, snapshot_for_snippet_upload,
snippet_file_content, snippet_response_id, snippet_sync_settings, webdav_endpoint_uses_direct_connection,
webdav_sync_secrets_status, ApplySnapshotOptions, ConnectionSecretSnapshot, SensitiveSyncPayload,
SnippetProvider, SnippetSyncClient, SnippetSyncConfig, WebDavClient, WebDavConfig, DEFAULT_SNIPPET_FILE_NAME,
snippet_file_content, snippet_provider_storage_key, snippet_response_id, snippet_saved_token_status,
snippet_sync_settings, snippet_sync_settings_for_instance, validate_snippet_id,
webdav_endpoint_uses_direct_connection, webdav_sync_secrets_status, ApplySnapshotOptions,
ConnectionSecretSnapshot, SensitiveSyncPayload, SnippetProvider, SnippetSyncClient, SnippetSyncConfig,
WebDavClient, WebDavConfig, DEFAULT_SNIPPET_FILE_NAME,
};
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiConfigItem};
use crate::connection_secrets::{
@@ -1746,6 +1870,66 @@ mod tests {
(format!("http://{address}"), server)
}
async fn spawn_gitlab_server(responses: Vec<String>) -> (String, tokio::task::JoinHandle<Vec<String>>) {
spawn_gitlab_server_with_status(responses.into_iter().map(|body| (200, body)).collect()).await
}
#[allow(clippy::type_complexity)]
async fn spawn_gitlab_server_with_status(
responses: Vec<(u16, String)>,
) -> (String, tokio::task::JoinHandle<Vec<String>>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let mut requests = Vec::new();
for response in responses {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let header_end = loop {
let mut chunk = [0_u8; 4096];
let size = socket.read(&mut chunk).await.unwrap();
assert!(size > 0, "request ended before headers were complete");
request.extend_from_slice(&chunk[..size]);
if let Some(pos) = request.windows(4).position(|window| window == b"\r\n\r\n") {
break pos + 4;
}
};
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length: ")
.and_then(|value| value.trim().parse::<usize>().ok())
})
.unwrap_or(0);
while request.len() - header_end < content_length {
let mut chunk = [0_u8; 4096];
let size = socket.read(&mut chunk).await.unwrap();
assert!(size > 0, "request ended before body was complete");
request.extend_from_slice(&chunk[..size]);
}
requests.push(String::from_utf8(request).unwrap());
let (status, body) = response;
let body = body.replace("{SERVER_BASE}", &format!("http://{address}"));
let reason = match status {
200 => "OK",
404 => "Not Found",
_ => "Status",
};
let response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
}
requests
});
(format!("http://{address}/api/v4"), server)
}
async fn spawn_webdav_server(
responses: Vec<u16>,
reject_non_collection_mkcol: bool,
@@ -2385,6 +2569,7 @@ mod tests {
let client = SnippetSyncClient::with_api_base(
SnippetSyncConfig {
provider: SnippetProvider::GitHub,
instance_url: None,
token: Some("test-token".to_string()),
snippet_id: Some("existing-id".to_string()),
replace_legacy_snippet: false,
@@ -2409,6 +2594,7 @@ mod tests {
let client = SnippetSyncClient::with_api_base(
SnippetSyncConfig {
provider: SnippetProvider::GitHub,
instance_url: None,
token: Some("test-token".to_string()),
snippet_id: Some("existing-id".to_string()),
replace_legacy_snippet: false,
@@ -2436,6 +2622,7 @@ mod tests {
let client = SnippetSyncClient::with_api_base(
SnippetSyncConfig {
provider: SnippetProvider::GitHub,
instance_url: None,
token: Some("test-token".to_string()),
snippet_id: Some("legacy-id".to_string()),
replace_legacy_snippet: true,
@@ -2471,6 +2658,7 @@ mod tests {
let client = SnippetSyncClient::with_api_base(
SnippetSyncConfig {
provider: SnippetProvider::GitHub,
instance_url: None,
token: Some("test-token".to_string()),
snippet_id: Some("legacy-id".to_string()),
replace_legacy_snippet: true,
@@ -2494,6 +2682,7 @@ mod tests {
let retry_client = SnippetSyncClient::with_api_base(
SnippetSyncConfig {
provider: SnippetProvider::GitHub,
instance_url: None,
token: Some("test-token".to_string()),
snippet_id: Some("new-id".to_string()),
replace_legacy_snippet: false,
@@ -2584,6 +2773,285 @@ mod tests {
);
}
#[test]
fn gitlab_instance_validation_and_state_key() {
assert_eq!(gitlab_instance_url(None).unwrap(), "https://gitlab.com");
assert_eq!(
gitlab_instance_url(Some(" HTTPS://GITLAB.EXAMPLE.COM:443/ ")).unwrap(),
"https://gitlab.example.com"
);
assert_eq!(
snippet_provider_storage_key(SnippetProvider::GitLab, Some("https://gitlab.example.com/")).unwrap(),
snippet_provider_storage_key(SnippetProvider::GitLab, Some("https://gitlab.example.com")).unwrap()
);
for url in [
"ftp://gitlab.example.com",
"https://user:password@gitlab.example.com",
"https://gitlab.example.com/?token=x",
"https://gitlab.example.com/#fragment",
] {
assert!(gitlab_instance_url(Some(url)).is_err(), "accepted {url}");
}
assert_eq!(gitlab_instance_url(Some("http://gitlab.internal:8080/")).unwrap(), "http://gitlab.internal:8080");
assert_ne!(
snippet_provider_storage_key(SnippetProvider::GitLab, Some("http://gitlab.example.com")).unwrap(),
snippet_provider_storage_key(SnippetProvider::GitLab, Some("https://gitlab.example.com")).unwrap()
);
assert!(validate_snippet_id(SnippetProvider::GitLab, "12/evil").is_err());
assert_eq!(snippet_response_id(&serde_json::json!({"id": 42})).as_deref(), Some("42"));
}
#[tokio::test]
async fn gitlab_instances_keep_saved_tokens_ids_and_cleanup_separate() {
let storage = Storage::open(&temp_db_path("gitlab-instance-isolation")).await.unwrap();
let first = "https://gitlab.example.com";
let second = "https://gitlab.other.com";
let config = |instance: &str| SnippetSyncConfig {
provider: SnippetProvider::GitLab,
instance_url: Some(instance.to_string()),
token: None,
snippet_id: None,
replace_legacy_snippet: false,
};
save_snippet_token(&storage, &config(first), "first-secret").await.unwrap();
save_snippet_sync_id_for_instance(&storage, SnippetProvider::GitLab, Some(first), Some("42")).await.unwrap();
let first_key = snippet_provider_storage_key(SnippetProvider::GitLab, Some(first)).unwrap();
storage.save_snippet_migration_state(&first_key, "43", "42", "hash").await.unwrap();
assert!(!snippet_saved_token_status(&storage, &config(second)).await.unwrap().has_saved_token);
assert!(snippet_sync_settings_for_instance(&storage, SnippetProvider::GitLab, Some(second))
.await
.unwrap()
.snippet_id
.is_none());
assert!(snippet_sync_settings_for_instance(&storage, SnippetProvider::GitLab, Some(second))
.await
.unwrap()
.legacy_cleanup_required_id
.is_none());
let mut first_config = config("https://gitlab.example.com/");
resolve_snippet_token(&storage, &mut first_config).await.unwrap();
assert_eq!(first_config.token.as_deref(), Some("first-secret"));
assert_eq!(
snippet_sync_settings_for_instance(&storage, SnippetProvider::GitLab, Some(first))
.await
.unwrap()
.snippet_id
.as_deref(),
Some("43")
);
}
#[tokio::test]
async fn gitlab_create_and_download_use_private_personal_snippet_and_raw_file() {
let storage = Storage::open(&temp_db_path("gitlab-create-download")).await.unwrap();
let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap();
let encrypted = encrypt_snippet_snapshot(&snapshot, "password").unwrap();
let (base, server) = spawn_gitlab_server(vec![
serde_json::json!({"id": 42}).to_string(),
serde_json::json!({"files": [{"path": "dbx-sync.json"}]}).to_string(),
serde_json::to_string(&encrypted).unwrap(),
])
.await;
let config = SnippetSyncConfig {
provider: SnippetProvider::GitLab,
instance_url: None,
token: Some("test-token".to_string()),
snippet_id: None,
replace_legacy_snippet: false,
};
let client = SnippetSyncClient::with_api_base(config.clone(), base.clone());
let summary = client.put_snapshot(&snapshot, Some("password"), None).await.unwrap();
assert_eq!(summary.snippet_id, "42");
let download =
SnippetSyncClient::with_api_base(SnippetSyncConfig { snippet_id: Some("42".to_string()), ..config }, base);
let (restored, _) = download.get_snapshot(Some("password")).await.unwrap();
assert_eq!(restored.app_version, snapshot.app_version);
let requests = server.await.unwrap();
assert!(requests[0].starts_with("POST /api/v4/snippets HTTP/1.1"));
assert!(requests[0].to_ascii_lowercase().contains("private-token: test-token"));
let body: serde_json::Value = serde_json::from_str(requests[0].split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(body["visibility"], "private");
assert_eq!(body["files"][0]["file_path"], "dbx-sync.json");
assert!(body["files"][0]["content"].as_str().unwrap().contains("dbx-encrypted-sync-snapshot"));
assert!(requests[1].starts_with("GET /api/v4/snippets/42 HTTP/1.1"));
assert!(requests[2].starts_with("GET /api/v4/snippets/42/files/main/dbx-sync.json/raw HTTP/1.1"));
}
#[tokio::test]
async fn gitlab_download_uses_snippet_raw_url_branch() {
let storage = Storage::open(&temp_db_path("gitlab-raw-url-branch")).await.unwrap();
let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap();
let encrypted = serde_json::to_string(&encrypt_snippet_snapshot(&snapshot, "password").unwrap()).unwrap();
let (base, server) = spawn_gitlab_server_with_status(vec![
(
200,
serde_json::json!({"files": [{"path": "dbx-sync.json", "raw_url": "{SERVER_BASE}/api/v4/snippets/42/files/master/dbx-sync.json/raw"}]}).to_string(),
),
(200, encrypted),
])
.await;
let client = SnippetSyncClient::with_api_base(
SnippetSyncConfig {
provider: SnippetProvider::GitLab,
instance_url: None,
token: Some("test-token".to_string()),
snippet_id: Some("42".to_string()),
replace_legacy_snippet: false,
},
base,
);
let (restored, _) = client.get_snapshot(Some("password")).await.unwrap();
assert_eq!(restored.app_version, snapshot.app_version);
let requests = server.await.unwrap();
assert_eq!(requests.len(), 2);
assert!(requests[0].starts_with("GET /api/v4/snippets/42 HTTP/1.1"));
assert!(requests[1].starts_with("GET /api/v4/snippets/42/files/master/dbx-sync.json/raw HTTP/1.1"));
}
#[tokio::test]
async fn gitlab_download_falls_back_to_master_when_main_raw_file_is_missing() {
let storage = Storage::open(&temp_db_path("gitlab-master-fallback")).await.unwrap();
let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap();
let encrypted = serde_json::to_string(&encrypt_snippet_snapshot(&snapshot, "password").unwrap()).unwrap();
let (base, server) = spawn_gitlab_server_with_status(vec![
(200, serde_json::json!({"files": [{"path": "dbx-sync.json"}]}).to_string()),
(404, String::new()),
(200, encrypted),
])
.await;
let client = SnippetSyncClient::with_api_base(
SnippetSyncConfig {
provider: SnippetProvider::GitLab,
instance_url: None,
token: Some("test-token".to_string()),
snippet_id: Some("42".to_string()),
replace_legacy_snippet: false,
},
base,
);
let (restored, _) = client.get_snapshot(Some("password")).await.unwrap();
assert_eq!(restored.app_version, snapshot.app_version);
let requests = server.await.unwrap();
assert_eq!(requests.len(), 3);
assert!(requests[1].starts_with("GET /api/v4/snippets/42/files/main/dbx-sync.json/raw HTTP/1.1"));
assert!(requests[2].starts_with("GET /api/v4/snippets/42/files/master/dbx-sync.json/raw HTTP/1.1"));
}
#[tokio::test]
async fn gitlab_http_instance_uses_configured_api_base() {
let (base, server) = spawn_gitlab_server(vec![serde_json::json!({"id": 42}).to_string()]).await;
let instance = base.strip_suffix("/api/v4").unwrap();
let client = SnippetSyncClient::new(SnippetSyncConfig {
provider: SnippetProvider::GitLab,
instance_url: Some(instance.to_string()),
token: Some("test-token".to_string()),
snippet_id: Some("42".to_string()),
replace_legacy_snippet: false,
})
.unwrap();
client.test().await.unwrap();
let requests = server.await.unwrap();
assert!(requests[0].starts_with("GET /api/v4/snippets/42 HTTP/1.1"));
assert!(requests[0].to_ascii_lowercase().contains("private-token: test-token"));
}
#[tokio::test]
async fn gitlab_update_checks_existing_password_before_put() {
let storage = Storage::open(&temp_db_path("gitlab-password-guard")).await.unwrap();
let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap();
let encrypted = serde_json::to_string(&encrypt_snippet_snapshot(&snapshot, "password").unwrap()).unwrap();
let (base, server) = spawn_gitlab_server(vec![
serde_json::json!({"files": [{"path": "dbx-sync.json"}]}).to_string(),
encrypted,
serde_json::json!({"id": 42}).to_string(),
])
.await;
let client = SnippetSyncClient::with_api_base(
SnippetSyncConfig {
provider: SnippetProvider::GitLab,
instance_url: None,
token: Some("test-token".to_string()),
snippet_id: Some("42".to_string()),
replace_legacy_snippet: false,
},
base,
);
client.put_snapshot(&snapshot, Some("password"), None).await.unwrap();
let requests = server.await.unwrap();
assert_eq!(requests.len(), 3);
assert!(requests[2].starts_with("PUT /api/v4/snippets/42 HTTP/1.1"));
let body: serde_json::Value = serde_json::from_str(requests[2].split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(body["files"][0]["action"], "update");
}
#[tokio::test]
async fn gitlab_wrong_password_never_updates_existing_snippet() {
let storage = Storage::open(&temp_db_path("gitlab-wrong-password")).await.unwrap();
let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap();
let encrypted = serde_json::to_string(&encrypt_snippet_snapshot(&snapshot, "correct").unwrap()).unwrap();
let (base, server) =
spawn_gitlab_server(vec![serde_json::json!({"files": [{"path": "dbx-sync.json"}]}).to_string(), encrypted])
.await;
let client = SnippetSyncClient::with_api_base(
SnippetSyncConfig {
provider: SnippetProvider::GitLab,
instance_url: None,
token: Some("test-token".to_string()),
snippet_id: Some("42".to_string()),
replace_legacy_snippet: false,
},
base,
);
assert!(client.put_snapshot(&snapshot, Some("wrong"), None).await.is_err());
assert_eq!(server.await.unwrap().len(), 2);
}
#[tokio::test]
async fn gitlab_legacy_migration_creates_before_deleting_and_tracks_instance() {
let storage = Storage::open(&temp_db_path("gitlab-legacy-migration")).await.unwrap();
let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap();
let legacy_content = serde_json::to_string(&snapshot).unwrap();
let metadata = serde_json::json!({"files": [{"path": "dbx-sync.json"}]}).to_string();
let (base, server) = spawn_gitlab_server(vec![
metadata.clone(),
legacy_content.clone(),
serde_json::json!({"id": 43}).to_string(),
metadata,
legacy_content,
String::new(),
])
.await;
let client = SnippetSyncClient::with_api_base(
SnippetSyncConfig {
provider: SnippetProvider::GitLab,
instance_url: Some("https://gitlab.example.com".to_string()),
token: Some("test-token".to_string()),
snippet_id: Some("42".to_string()),
replace_legacy_snippet: true,
},
base,
);
let mut summary = client.put_snapshot(&snapshot, Some("password"), None).await.unwrap();
assert_eq!(summary.snippet_id, "43");
finalize_snippet_migration(&storage, &client, &mut summary).await.unwrap();
assert!(summary.legacy_cleanup_required_id.is_none());
assert_eq!(
snippet_sync_settings_for_instance(
&storage,
SnippetProvider::GitLab,
client.config.instance_url.as_deref()
)
.await
.unwrap()
.snippet_id
.as_deref(),
Some("43")
);
let requests = server.await.unwrap();
assert!(requests[2].starts_with("POST /api/v4/snippets HTTP/1.1"));
assert!(requests[5].starts_with("DELETE /api/v4/snippets/42 HTTP/1.1"));
}
#[test]
fn snippet_response_id_supports_github_object_and_gitee_array() {
assert_eq!(snippet_response_id(&serde_json::json!({ "id": "github-id" })).as_deref(), Some("github-id"));
+14 -8
View File
@@ -7,9 +7,9 @@ use dbx_core::cloud_sync::{
forget_snippet_token, forget_webdav_password,
forget_webdav_sync_secrets_passphrase as core_forget_webdav_sync_secrets_passphrase, resolve_snippet_token,
resolve_webdav_password, resolve_webdav_sync_secrets_passphrase, retry_pending_snippet_cleanup,
save_snippet_sync_id as core_save_snippet_sync_id, save_snippet_token, save_webdav_password,
save_snippet_sync_id_for_instance as core_save_snippet_sync_id, save_snippet_token, save_webdav_password,
save_webdav_sync_secrets_preference as core_save_webdav_sync_secrets_preference, snippet_saved_token_status,
snippet_sync_settings as core_snippet_sync_settings, webdav_saved_password_status,
snippet_sync_settings_for_instance as core_snippet_sync_settings, webdav_saved_password_status,
webdav_sync_secrets_status as core_webdav_sync_secrets_status, ApplySnapshotOptions, ApplySnapshotSummary,
SnippetProvider, SnippetSyncClient, SnippetSyncConfig, SnippetSyncSettings, SnippetSyncSummary, SnippetTokenStatus,
WebDavClient, WebDavConfig, WebDavPasswordStatus, WebDavSyncSecretsStatus, WebDavSyncSummary,
@@ -111,12 +111,14 @@ pub struct SnippetDownloadRequest {
#[serde(rename_all = "camelCase")]
pub struct SnippetSyncSettingsRequest {
pub provider: SnippetProvider,
pub instance_url: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveSnippetSyncIdRequest {
pub provider: SnippetProvider,
pub instance_url: Option<String>,
pub snippet_id: Option<String>,
}
@@ -224,7 +226,7 @@ pub async fn snippet_sync_test(
Json(mut req): Json<SnippetConfigRequest>,
) -> Result<Json<()>, AppError> {
resolve_snippet_token(&state.app.storage, &mut req.config).await.map_err(AppError::from)?;
SnippetSyncClient::new(req.config).test().await.map_err(AppError::from)?;
SnippetSyncClient::new(req.config).map_err(AppError::from)?.test().await.map_err(AppError::from)?;
Ok(Json(()))
}
@@ -255,14 +257,17 @@ pub async fn snippet_sync_settings(
State(state): State<Arc<WebState>>,
Json(req): Json<SnippetSyncSettingsRequest>,
) -> Result<Json<SnippetSyncSettings>, AppError> {
core_snippet_sync_settings(&state.app.storage, req.provider).await.map(Json).map_err(AppError::from)
core_snippet_sync_settings(&state.app.storage, req.provider, req.instance_url.as_deref())
.await
.map(Json)
.map_err(AppError::from)
}
pub async fn save_snippet_sync_id(
State(state): State<Arc<WebState>>,
Json(req): Json<SaveSnippetSyncIdRequest>,
) -> Result<Json<()>, AppError> {
core_save_snippet_sync_id(&state.app.storage, req.provider, req.snippet_id.as_deref())
core_save_snippet_sync_id(&state.app.storage, req.provider, req.instance_url.as_deref(), req.snippet_id.as_deref())
.await
.map_err(AppError::from)?;
Ok(Json(()))
@@ -274,7 +279,7 @@ pub async fn retry_snippet_legacy_cleanup(
) -> Result<Json<SnippetSyncSettings>, AppError> {
resolve_snippet_token(&state.app.storage, &mut req.config).await.map_err(AppError::from)?;
let provider = req.config.provider;
let client = SnippetSyncClient::new(req.config);
let client = SnippetSyncClient::new(req.config).map_err(AppError::from)?;
retry_pending_snippet_cleanup(&state.app.storage, provider, &client).await.map(Json).map_err(AppError::from)
}
@@ -298,7 +303,7 @@ pub async fn snippet_sync_upload(
build_sync_snapshot(&state.app.storage, env!("CARGO_PKG_VERSION"), req.editor_settings, secrets_passphrase)
.await
.map_err(AppError::from)?;
let client = SnippetSyncClient::new(req.config);
let client = SnippetSyncClient::new(req.config).map_err(AppError::from)?;
let mut summary = client
.put_snapshot(&snapshot, req.snippet_passphrase.as_deref(), secrets_passphrase)
.await
@@ -313,6 +318,7 @@ pub async fn snippet_sync_download(
) -> Result<Json<SnippetDownloadResult>, AppError> {
resolve_snippet_token(&state.app.storage, &mut req.config).await.map_err(AppError::from)?;
let (snapshot, summary) = SnippetSyncClient::new(req.config)
.map_err(AppError::from)?
.get_snapshot(req.snippet_passphrase.as_deref())
.await
.map_err(AppError::from)?;
@@ -360,7 +366,7 @@ mod tests {
let state = Arc::new(WebState::for_tests(app, dir.clone()));
let Json(settings) = super::snippet_sync_settings(
State(state),
Json(super::SnippetSyncSettingsRequest { provider: SnippetProvider::GitHub }),
Json(super::SnippetSyncSettingsRequest { provider: SnippetProvider::GitHub, instance_url: None }),
)
.await
.unwrap();
+16 -6
View File
@@ -1,6 +1,6 @@
---
title: DBX 配置云同步
description: 使用 WebDAV、GitHub Gist 或 Gitee 代码片段同步 DBX 连接、设置和保存的 SQL。
description: 使用 WebDAV、GitHub Gist、Gitee 或 GitLab Snippets 同步 DBX 连接、设置和保存的 SQL。
---
<Callout type="info">
@@ -12,7 +12,7 @@ DBX 可以把连接配置、界面设置和保存的 SQL 组成一个快照。
在 **设置 → 同步** 中选择同步方式:
- **WebDAV**:适合已有 NAS、Nextcloud、坚果云或其他 WebDAV 服务的用户,支持定时自动上传。
- **GitHub / Gitee 代码片段**:不需要自己维护服务器,通过私有 Gist 或私有代码片段保存快照。目前仅桌面端显示,且需要手动上传和下载。
- **GitHub / Gitee / GitLab 代码片段**:通过私有代码片段保存快照,GitLab 支持企业自托管实例。目前仅桌面端显示,且需要手动上传和下载,不自动合并。
## 同步哪些内容
@@ -68,7 +68,7 @@ DBX 可以把连接配置、界面设置和保存的 SQL 组成一个快照。
<Step>
### 选择平台
打开 **设置 → 同步 → GitHub / Gitee**,平台选择 **GitHub Gist**。
打开 **设置 → 同步 → 代码片段**,平台选择 **GitHub Gist**。
</Step>
<Step>
### 填写令牌
@@ -126,15 +126,25 @@ DBX 会在 Gist 中保存一个经过**代码片段加密密码**加密的 `dbx-
### 上传与恢复
1. 在 **设置 → 同步 → GitHub / Gitee** 中选择 **Gitee 代码片段**。
1. 在 **设置 → 同步 → 代码片段** 中选择 **Gitee 代码片段**。
2. 输入私人令牌。首次上传时将代码片段 ID 留空,DBX 会创建私有代码片段并保存 ID。
3. 在其他设备恢复时,填写同一个代码片段 ID,然后点击 **下载**。
Gitee 代码片段中同样使用 `dbx-sync.json` 文件。
## GitLab Snippets(包括私有化实例)
1. 在 GitLab **User Settings → Access Tokens** 创建个人访问令牌,授予 `api` 权限;不要把令牌放在实例地址里。
2. 在桌面端 **设置 → 同步 → 代码片段** 选择 **GitLab Snippets**,输入公司的 GitLab 根地址(例如 `https://gitlab.example.com` 或内网 `http://gitlab.internal`,默认 `https://gitlab.com`)。不接受含账号密码、查询参数或片段的地址。修改地址后,失焦或按 Enter 才会切换实例。
3. 输入令牌和代码片段加密密码;首次上传时留空 ID,DBX 创建仅自己可见的个人 Snippet。后续上传更新同一 ID 对应的 `dbx-sync.json`;在另一台设备输入相同实例、令牌、ID 和加密密码后手动下载。
GitLab Snippets 不是 GitLab 仓库文件同步。不同实例的本地令牌、ID 和迁移待清理记录互相隔离;上传和下载不会自动合并。旧明文 DBX 快照须使用 **迁移旧版代码片段**,DBX 先创建加密的新 Snippet,再核对并删除旧 Snippet。如果发现旧内容已变化或清理失败,旧 Snippet 仍须手动处理。
<Callout type="warn">HTTP 会明文传输 GitLab 访问令牌,即使快照内容已加密。只在可信内网使用 HTTP,优先为私有化 GitLab 配置 HTTPS。</Callout>
## 同步密码与敏感信息
GitHub Gist 和 Gitee 代码片段始终使用**代码片段加密密码**加密整个快照。该密码不会上传或保存,也无法找回;它与可选敏感信息的同步密码相互独立。
GitHub Gist、Gitee 和 GitLab 代码片段始终使用**代码片段加密密码**加密整个快照。该密码不会上传或保存,也无法找回;它与可选敏感信息的同步密码相互独立。
默认快照不会包含数据库密码等密钥。如果需要在新设备直接恢复密码:
@@ -146,7 +156,7 @@ GitHub Gist 和 Gitee 代码片段始终使用**代码片段加密密码**加密
同步密码使用 **Argon2id** 派生密钥,并使用 **AES-256-GCM** 加密。访问令牌只会使用设备密钥加密保存在本机,永远不会写入 `dbx-sync.json`。
<Callout type="warn">
两种密码都无法找回。GitHub/Gitee 的加密代码片段没有代码片段加密密码就无法恢复;WebDAV 中不含加密敏感信息的快照仍可不使用同步密码恢复。
两种密码都无法找回。加密代码片段没有代码片段加密密码就无法恢复;WebDAV 中不含加密敏感信息的快照仍可不使用同步密码恢复。
</Callout>
## WebDAV
+16 -6
View File
@@ -1,6 +1,6 @@
---
title: DBX Configuration Cloud Sync
description: Sync DBX connections, settings, and saved SQL with WebDAV, GitHub Gist, or Gitee snippets.
description: Sync DBX connections, settings, and saved SQL with WebDAV, GitHub Gist, Gitee, or GitLab Snippets.
---
<Callout type="info">
@@ -12,7 +12,7 @@ DBX can package connections, application settings, and saved SQL into a snapshot
Choose a provider under **Settings → Sync**:
- **WebDAV**: Best when you already use a NAS, Nextcloud, or another WebDAV service. Supports scheduled uploads.
- **GitHub / Gitee snippets**: Stores the snapshot in a private Gist or code snippet without requiring your own server. It is currently exposed in Desktop only, and uploads and downloads are manual.
- **GitHub / Gitee / GitLab snippets**: Stores the snapshot in a private snippet, including on self-managed GitLab. It is currently exposed in Desktop only; uploads and downloads are manual and never merge automatically.
## What Is Synchronized
@@ -68,7 +68,7 @@ A classic token also works, but grant only the `gist` scope.
<Step>
### Select GitHub
Open **Settings → Sync → GitHub / Gitee** and select **GitHub Gist**.
Open **Settings → Sync → Snippets** and select **GitHub Gist**.
</Step>
<Step>
### Enter the token
@@ -126,15 +126,25 @@ Plain-text snippets created by older versions cannot be safely migrated in place
### Upload and Restore
1. Select **Gitee Snippet** under **Settings → Sync → GitHub / Gitee**.
1. Select **Gitee Snippet** under **Settings → Sync → Snippets**.
2. Enter the personal access token. Leave the snippet ID empty on the first upload; DBX creates a private snippet and stores its ID.
3. To restore elsewhere, enter the same snippet ID and select **Download**.
Gitee snippets also use a `dbx-sync.json` file.
## GitLab Snippets (including self-managed instances)
1. Create a personal access token in GitLab under **User Settings → Access Tokens** with `api` scope. Do not put the token in the instance URL.
2. In Desktop, open **Settings → Sync → Snippets**, select **GitLab Snippets**, and enter the root URL of your GitLab instance (for example, `https://gitlab.example.com` or `http://gitlab.internal` on an internal network; the default is `https://gitlab.com`). URLs with credentials, queries, or fragments are rejected. Leaving the field or pressing Enter applies a changed instance.
3. Enter the token and a snippet encryption password. Leave the ID empty for the first upload: DBX creates a private personal Snippet. Later uploads update `dbx-sync.json` in that Snippet. To restore on another device, provide the same instance, token, ID, and encryption password, then manually download.
GitLab Snippets are **not** GitLab repository file sync. Each instance keeps its own local token, ID, and pending migration cleanup. Uploads and downloads do not merge automatically. A legacy plaintext DBX snapshot must use **Migrate legacy snippet**: DBX first creates an encrypted replacement, then rereads and deletes the old Snippet only if unchanged. If the old content has changed or cleanup fails, delete the old Snippet manually.
<Callout type="warn">HTTP transmits the GitLab access token in cleartext even though the snapshot is encrypted. Use HTTP only on a trusted internal network; prefer HTTPS for self-managed GitLab.</Callout>
## Sync Password and Secrets
GitHub Gist and Gitee snippets always encrypt the entire snapshot with a **snippet encryption password**. The password is never uploaded or saved and cannot be recovered. This password is independent from the password that encrypts optional synced credentials.
GitHub Gist, Gitee, and GitLab snippets always encrypt the entire snapshot with a **snippet encryption password**. The password is never uploaded or saved and cannot be recovered. This password is independent from the password that encrypts optional synced credentials.
Snapshots exclude credentials by default. To restore passwords on another device:
@@ -146,7 +156,7 @@ Snapshots exclude credentials by default. To restore passwords on another device
DBX derives the encryption key with **Argon2id** and uses **AES-256-GCM** encryption. Provider access tokens are encrypted with a device-local key and are never written to `dbx-sync.json`.
<Callout type="warn">
Neither password can be recovered. An encrypted GitHub/Gitee snippet cannot be restored without its snippet encryption password. For WebDAV, a snapshot without encrypted secrets can still be restored without a passphrase.
Neither password can be recovered. An encrypted snippet cannot be restored without its snippet encryption password. For WebDAV, a snapshot without encrypted secrets can still be restored without a passphrase.
</Callout>
## WebDAV
+50
View File
@@ -0,0 +1,50 @@
# GitLab Snippets Implementation Plan
> For agentic workers: implement and verify each task in this worktree.
**Goal:** Support encrypted DBX configuration snapshots in private personal GitLab Snippets on self-managed HTTP or HTTPS instances.
**Architecture:** Extend the existing snippet sync client with GitLab-specific REST paths and payloads, keeping snapshot encryption and legacy migration shared. Scope locally saved token, snippet ID, and cleanup state by canonical instance URL; expose the URL in Desktop settings and command/API contracts.
**Tech Stack:** Rust (dbx-core, Tauri, Axum), Vue/TypeScript, Vitest.
**Spec:** https://github.com/t8y2/dbx/issues/9699
## Global Constraints
- Personal Snippets only, not Repository Files API or automatic merging.
- Accept HTTP for trusted internal instances with a clear token transport warning; reject URLs with credentials, query, or fragment.
- Keep existing GitHub/Gitee state keys and snapshot formats unchanged.
- Preserve migration protections and never persist a token in the snapshot.
## Review Focus
- URL variants of one instance map to one local account; different instances never share secrets or cleanup state.
- Malformed or cross-origin IDs/redirects must not leak the token.
- New GitLab snippet must be private and only its `dbx-sync.json` content restored.
- An encrypted existing snippet must decrypt before update; plaintext migration creates a new snippet.
- Invalid token/permissions and missing snippet ID should have actionable errors.
### Task 1: Core provider and state
**Files:** `crates/dbx-core/src/persistence/cloud_sync.rs`
- [x] Add tests for canonical URL, scoped credentials/state, GitLab create/update/read/migration HTTP contract and invalid IDs.
- [x] Add GitLab provider, validated instance URL, request builder and payload/read paths.
- [x] Run focused Rust tests; preserve existing provider tests.
### Task 2: Desktop and API contracts
**Files:** `src-tauri/src/commands/cloud_sync.rs`, `crates/dbx-web/src/routes/cloud_sync.rs`, `apps/desktop/src/lib/backend/{tauri,http}.ts`, `apps/desktop/src/components/editor/EditorSettingsDialog.vue`, `apps/desktop/src/i18n/locales/{en,zh-CN}.ts`
- [x] Propagate instance URL to settings/ID operations without changing older provider behavior.
- [x] Add GitLab option and instance field; refresh saved state on instance changes.
- [x] Run typecheck and targeted frontend checks.
### Task 3: Documentation and delivery
**Files:** `docs/content/docs/cloud-sync.{cn.mdx,mdx}`
- [x] Document token scope, HTTPS URL, private snippets, encryption, manual sync and migration.
- [x] Run format, focused tests and diff review.
- Push only the feature branch to the fork and open a PR with `Fixes #9699`.
+11 -9
View File
@@ -5,9 +5,9 @@ use dbx_core::cloud_sync::{
forget_snippet_token, forget_webdav_password,
forget_webdav_sync_secrets_passphrase as core_forget_webdav_sync_secrets_passphrase, resolve_snippet_token,
resolve_webdav_password, resolve_webdav_sync_secrets_passphrase, retry_pending_snippet_cleanup,
save_snippet_sync_id as core_save_snippet_sync_id, save_snippet_token, save_webdav_password,
save_snippet_sync_id_for_instance as core_save_snippet_sync_id, save_snippet_token, save_webdav_password,
save_webdav_sync_secrets_preference as core_save_webdav_sync_secrets_preference, snippet_saved_token_status,
snippet_sync_settings as core_snippet_sync_settings, webdav_saved_password_status,
snippet_sync_settings_for_instance as core_snippet_sync_settings, webdav_saved_password_status,
webdav_sync_secrets_status as core_webdav_sync_secrets_status, ApplySnapshotOptions, ApplySnapshotSummary,
SnippetProvider, SnippetSyncClient, SnippetSyncConfig, SnippetSyncSettings, SnippetSyncSummary, SnippetTokenStatus,
WebDavClient, WebDavConfig, WebDavPasswordStatus, WebDavSyncSecretsStatus, WebDavSyncSummary,
@@ -135,7 +135,7 @@ pub async fn webdav_sync_download(
#[tauri::command]
pub async fn snippet_sync_test(state: State<'_, Arc<AppState>>, mut config: SnippetSyncConfig) -> Result<(), String> {
resolve_snippet_token(&state.storage, &mut config).await?;
SnippetSyncClient::new(config).test().await
SnippetSyncClient::new(config)?.test().await
}
#[tauri::command]
@@ -167,17 +167,19 @@ pub async fn forget_snippet_saved_token(
pub async fn snippet_sync_settings(
state: State<'_, Arc<AppState>>,
provider: SnippetProvider,
instance_url: Option<String>,
) -> Result<SnippetSyncSettings, String> {
core_snippet_sync_settings(&state.storage, provider).await
core_snippet_sync_settings(&state.storage, provider, instance_url.as_deref()).await
}
#[tauri::command]
pub async fn save_snippet_sync_id(
state: State<'_, Arc<AppState>>,
provider: SnippetProvider,
instance_url: Option<String>,
snippet_id: Option<String>,
) -> Result<(), String> {
core_save_snippet_sync_id(&state.storage, provider, snippet_id.as_deref()).await
core_save_snippet_sync_id(&state.storage, provider, instance_url.as_deref(), snippet_id.as_deref()).await
}
#[tauri::command]
@@ -187,7 +189,7 @@ pub async fn retry_snippet_legacy_cleanup(
) -> Result<SnippetSyncSettings, String> {
resolve_snippet_token(&state.storage, &mut config).await?;
let provider = config.provider;
let client = SnippetSyncClient::new(config);
let client = SnippetSyncClient::new(config)?;
retry_pending_snippet_cleanup(&state.storage, provider, &client).await
}
@@ -214,7 +216,7 @@ pub async fn snippet_sync_upload(
};
let snapshot =
build_sync_snapshot(&state.storage, env!("CARGO_PKG_VERSION"), editor_settings, secrets_passphrase).await?;
let client = SnippetSyncClient::new(config);
let client = SnippetSyncClient::new(config)?;
let mut summary = client.put_snapshot(&snapshot, snippet_passphrase.as_deref(), secrets_passphrase).await?;
finalize_snippet_migration(&state.storage, &client, &mut summary).await?;
Ok(summary)
@@ -229,7 +231,7 @@ pub async fn snippet_sync_download(
secrets_passphrase: Option<String>,
) -> Result<SnippetDownloadResult, String> {
resolve_snippet_token(&state.storage, &mut config).await?;
let (snapshot, summary) = SnippetSyncClient::new(config).get_snapshot(snippet_passphrase.as_deref()).await?;
let (snapshot, summary) = SnippetSyncClient::new(config)?.get_snapshot(snippet_passphrase.as_deref()).await?;
let apply_summary = apply_sync_snapshot(
&state.storage,
&snapshot,
@@ -268,7 +270,7 @@ mod tests {
let app = tauri::test::mock_app();
app.manage(app_state);
let state: tauri::State<'_, Arc<AppState>> = app.state();
let settings = super::snippet_sync_settings(state, SnippetProvider::GitHub).await.unwrap();
let settings = super::snippet_sync_settings(state, SnippetProvider::GitHub, None).await.unwrap();
assert_eq!(settings.snippet_id.as_deref(), Some("replacement-id"));
assert_eq!(settings.legacy_cleanup_required_id.as_deref(), Some("legacy-id"));