mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(sync): support self-managed GitLab snippets
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "ラベル",
|
||||
|
||||
@@ -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: "액세스 토큰",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "代码片段同步目标可访问。",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user