diff --git a/src/lib/sync.js b/src/lib/sync.js index 6a6cfd6..1c6368c 100644 --- a/src/lib/sync.js +++ b/src/lib/sync.js @@ -1,14 +1,16 @@ /** * Silent Send - Cross-Browser Settings Sync * - * Two sync mechanisms: + * Sync mechanisms: * - * 1. Sync Code — base64-encoded JSON snapshot for manual copy-paste between - * browsers (works across any browser/device combination). - * - * 2. browser.storage.sync — automatic sync within the same browser family - * (Firefox ↔ Firefox via Firefox Sync, Chrome ↔ Chrome via Google account). - * Data is chunked to stay within per-item size limits. + * 1. Sync Code — base64-encoded JSON snapshot for manual copy-paste. + * 2. browser.storage.sync — automatic within the same browser family. + * 3. Folder sync (File System Access API) — any locally-mounted folder + * including Dropbox, OneDrive, Google Drive, iCloud, Nextcloud, etc. + * 4. GitHub Gist — serverless cloud sync using a personal access token; + * works across any browser/device without a local desktop client. + * 5. Custom HTTP endpoint — any URL supporting GET + PUT (WebDAV, + * self-hosted server, cloud function, etc.). * * Conflict resolution: newest `lastModified` timestamp wins. */ @@ -176,6 +178,150 @@ const SilentSendSync = { } }, + // ---------------------------------------------------------------- + // GitHub Gist sync + // Requires a personal access token with the `gist` scope. + // On first push a new secret Gist is created; the Gist ID is stored + // in local storage so all subsequent reads/writes use the same Gist. + // ---------------------------------------------------------------- + + /** + * Push current data to a GitHub Gist (creates one if no gist ID stored). + * token: GitHub PAT with `gist` scope. + * Returns { success, gistId } or { success: false, reason }. + */ + async pushToGist(token) { + if (!token) return { success: false, reason: 'No GitHub token provided.' }; + try { + const data = await this._getAllData(); + const content = JSON.stringify(data, null, 2); + const stored = await api.storage.local.get('ss_gist_id'); + const gistId = stored.ss_gist_id; + + let resp; + if (gistId) { + // Update existing Gist + resp = await fetch(`https://api.github.com/gists/${gistId}`, { + method: 'PATCH', + headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ files: { 'silent-send-sync.json': { content } } }), + }); + } else { + // Create new secret Gist + resp = await fetch('https://api.github.com/gists', { + method: 'POST', + headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + description: 'Silent Send settings sync', + public: false, + files: { 'silent-send-sync.json': { content } }, + }), + }); + } + + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + return { success: false, reason: err.message || `HTTP ${resp.status}` }; + } + + const json = await resp.json(); + await api.storage.local.set({ ss_gist_id: json.id }); + return { success: true, gistId: json.id }; + } catch (e) { + return { success: false, reason: e.message }; + } + }, + + /** + * Pull data from a GitHub Gist and apply if newer. + * token: GitHub PAT with `gist` scope. + * Returns { success, imported?, reason? }. + */ + async pullFromGist(token) { + if (!token) return { success: false, reason: 'No GitHub token provided.' }; + try { + const stored = await api.storage.local.get('ss_gist_id'); + const gistId = stored.ss_gist_id; + if (!gistId) return { success: false, reason: 'No Gist ID stored. Push first.' }; + + const resp = await fetch(`https://api.github.com/gists/${gistId}`, { + headers: { Authorization: `token ${token}` }, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + return { success: false, reason: err.message || `HTTP ${resp.status}` }; + } + + const gist = await resp.json(); + const file = gist.files?.['silent-send-sync.json']; + if (!file) return { success: false, reason: 'Sync file not found in Gist.' }; + + // Fetch raw content (may be truncated in the API response) + const rawResp = await fetch(file.raw_url); + const data = JSON.parse(await rawResp.text()); + + const local = await this._getAllData(); + if (data.lastModified <= (local.lastModified || 0)) { + return { success: true, imported: false }; + } + + await this._applyData(data, 'gist'); + return { success: true, imported: true, time: new Date(data.lastModified).toLocaleString() }; + } catch (e) { + return { success: false, reason: e.message }; + } + }, + + // ---------------------------------------------------------------- + // Custom HTTP endpoint sync + // GET fetches the JSON, PUT/PATCH writes it. + // Works with WebDAV (Nextcloud, ownCloud), any REST endpoint, or a + // simple static file server that supports PUT. + // ---------------------------------------------------------------- + + /** + * Push to a custom URL via HTTP PUT. + * opts: { url, method = 'PUT', headers = {} } + */ + async pushToUrl({ url, method = 'PUT', headers = {} } = {}) { + if (!url) return { success: false, reason: 'No URL provided.' }; + try { + const data = await this._getAllData(); + const resp = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(data, null, 2), + }); + if (!resp.ok) return { success: false, reason: `HTTP ${resp.status}` }; + return { success: true }; + } catch (e) { + return { success: false, reason: e.message }; + } + }, + + /** + * Pull from a custom URL via HTTP GET and apply if newer. + * opts: { url, headers = {} } + */ + async pullFromUrl({ url, headers = {} } = {}) { + if (!url) return { success: false, reason: 'No URL provided.' }; + try { + const resp = await fetch(url, { headers }); + if (!resp.ok) return { success: false, reason: `HTTP ${resp.status}` }; + const data = await resp.json(); + + const local = await this._getAllData(); + if (data.lastModified <= (local.lastModified || 0)) { + return { success: true, imported: false }; + } + + await this._applyData(data, 'url'); + return { success: true, imported: true, time: new Date(data.lastModified).toLocaleString() }; + } catch (e) { + return { success: false, reason: e.message }; + } + }, + // ---------------------------------------------------------------- // File System Access API helpers — folder-based sync // The directory handle is stored in IndexedDB so the user only diff --git a/src/options/options.html b/src/options/options.html index 0634b24..03a2eb4 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -158,6 +158,46 @@
+ +
+

GitHub Gist Sync — no desktop client needed

+

+ Store settings in a private GitHub Gist. Works across any browser or device + with just a GitHub account. Create a + + Personal Access Token + + with the gist scope, paste it below, and click Push. + The Gist ID is remembered — future pushes update the same Gist. +

+
+ + + +
+
+
+ +
+

Custom URL Sync

+

+ Any URL that supports HTTP GET (read) and PUT (write). Works with + Nextcloud/ownCloud WebDAV, a self-hosted server, or a cloud function. + Add custom headers (e.g. Authorization) as JSON if needed. +

+
+ + +
+
+ + +
+
+
diff --git a/src/options/options.js b/src/options/options.js index 20d5646..626fc3c 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -122,6 +122,80 @@ document.addEventListener('DOMContentLoaded', async () => { await checkFileSyncUpdate(); }); + // --- GitHub Gist sync --- + // Restore saved token (session only — never persisted to storage) + { + const stored = await api.storage.local.get('ss_gist_id'); + if (stored.ss_gist_id) { + setGistSyncStatus(`Gist ID: ${stored.ss_gist_id.slice(0, 12)}…`, 'ok'); + } + } + + $('#btnGistPush').addEventListener('click', async () => { + const token = $('#gistToken').value.trim(); + if (!token) { setGistSyncStatus('Enter your GitHub PAT first.', 'warn'); return; } + setGistSyncStatus('Pushing…', 'neutral'); + const r = await SilentSendSync.pushToGist(token); + if (r.success) { + setGistSyncStatus(`Pushed. Gist ID: ${r.gistId.slice(0, 12)}…`, 'ok'); + } else { + setGistSyncStatus('Push failed: ' + r.reason, 'error'); + } + }); + + $('#btnGistPull').addEventListener('click', async () => { + const token = $('#gistToken').value.trim(); + if (!token) { setGistSyncStatus('Enter your GitHub PAT first.', 'warn'); return; } + setGistSyncStatus('Pulling…', 'neutral'); + const r = await SilentSendSync.pullFromGist(token); + if (!r.success) { + setGistSyncStatus('Pull failed: ' + r.reason, 'error'); + } else if (r.imported) { + setGistSyncStatus(`Pulled (${r.time}). Refreshing…`, 'ok'); + mappings = await Storage.getMappings(); + settings = await Storage.getSettings(); + renderMappings(); + renderDomains(); + renderLog(); + } else { + setGistSyncStatus('Already up to date.', 'ok'); + } + }); + + // --- Custom URL sync --- + $('#btnUrlPush').addEventListener('click', async () => { + const url = $('#customSyncUrl').value.trim(); + if (!url) { setUrlSyncStatus('Enter a URL first.', 'warn'); return; } + const headers = parseHeadersField($('#customSyncHeaders').value); + setUrlSyncStatus('Pushing…', 'neutral'); + const r = await SilentSendSync.pushToUrl({ url, headers }); + if (r.success) { + setUrlSyncStatus('Pushed successfully.', 'ok'); + } else { + setUrlSyncStatus('Push failed: ' + r.reason, 'error'); + } + }); + + $('#btnUrlPull').addEventListener('click', async () => { + const url = $('#customSyncUrl').value.trim(); + if (!url) { setUrlSyncStatus('Enter a URL first.', 'warn'); return; } + const headers = parseHeadersField($('#customSyncHeaders').value); + setUrlSyncStatus('Pulling…', 'neutral'); + const r = await SilentSendSync.pullFromUrl({ url, headers }); + if (!r.success) { + setUrlSyncStatus('Pull failed: ' + r.reason, 'error'); + } else if (r.imported) { + setUrlSyncStatus(`Pulled (${r.time}). Refreshing…`, 'ok'); + mappings = await Storage.getMappings(); + settings = await Storage.getSettings(); + renderMappings(); + renderDomains(); + renderLog(); + } else { + setUrlSyncStatus('Already up to date.', 'ok'); + } + }); + // Transfer data $('#btnExportAll').addEventListener('click', exportAllPlain); $('#btnExportEncrypted').addEventListener('click', exportAllEncrypted); @@ -605,6 +679,29 @@ function setFileSyncStatus(msg, type) { el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280'; } +function setGistSyncStatus(msg, type) { + const el = $('#gistSyncStatus'); + if (!el) return; + el.textContent = msg; + el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280'; +} + +function setUrlSyncStatus(msg, type) { + const el = $('#urlSyncStatus'); + if (!el) return; + el.textContent = msg; + el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280'; +} + +function parseHeadersField(val) { + if (!val || !val.trim()) return {}; + try { + return JSON.parse(val.trim()); + } catch { + return {}; + } +} + function setSyncStatus(msg, type) { const el = $('#syncStatus'); if (!el) return;