@glance-apps/sync 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -20,6 +20,7 @@
20
20
  "url": "git+https://github.com/glance-apps/glance-sync.git"
21
21
  },
22
22
  "devDependencies": {
23
+ "fake-indexeddb": "^6.2.5",
23
24
  "vitest": "^3.1.4"
24
25
  }
25
26
  }
package/src/crypto.js CHANGED
@@ -1 +1,362 @@
1
- // Placeholder — encryption module is extracted in Step 3.
1
+ // Client-side encryption for cloud sync and remote backup files.
2
+ // Uses Web Crypto API exclusively — no external dependencies.
3
+ // Architecture: passphrase → PBKDF2 → AES-256-GCM key → encrypts JSON blob.
4
+ //
5
+ // Parameterizations vs. dayGLANCE original:
6
+ // - KEY_DB_NAME is now passed as `cryptoDBName` (no hardcoded app name)
7
+ // - Android Keystore detection uses injected `nativeGetSyncKey` function
8
+ // instead of probing window globals by name
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Session state (in-memory only, never persisted)
12
+ // ---------------------------------------------------------------------------
13
+ let _sessionPassphrase = null; // string | null
14
+ let _sessionKey = null; // CryptoKey | null
15
+ let _sessionSalt = null; // Uint8Array | null — the salt used to derive _sessionKey
16
+
17
+ export function setSyncPassphrase(p) { _sessionPassphrase = p; }
18
+ export function getSyncPassphrase() { return _sessionPassphrase; }
19
+ export function hasEncryptionReady() { return _sessionKey !== null; }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // IndexedDB key store
23
+ // ---------------------------------------------------------------------------
24
+ const KEY_DB_VERSION = 1;
25
+ const KEY_STORE = 'keys';
26
+ const SYNC_KEY_ID = 'sync-key';
27
+
28
+ function openKeyDB(cryptoDBName) {
29
+ return new Promise((resolve, reject) => {
30
+ const req = indexedDB.open(cryptoDBName, KEY_DB_VERSION);
31
+ req.onupgradeneeded = (e) => {
32
+ const db = e.target.result;
33
+ if (!db.objectStoreNames.contains(KEY_STORE)) {
34
+ db.createObjectStore(KEY_STORE, { keyPath: 'id' });
35
+ }
36
+ };
37
+ req.onsuccess = () => resolve(req.result);
38
+ req.onerror = () => reject(req.error);
39
+ });
40
+ }
41
+
42
+ async function saveKeyToIndexedDB(cryptoDBName, cryptoKey, salt) {
43
+ const db = await openKeyDB(cryptoDBName);
44
+ const rawKey = await crypto.subtle.exportKey('raw', cryptoKey);
45
+ return new Promise((resolve, reject) => {
46
+ const tx = db.transaction(KEY_STORE, 'readwrite');
47
+ tx.objectStore(KEY_STORE).put({ id: SYNC_KEY_ID, rawKey, salt: Array.from(salt) });
48
+ tx.oncomplete = () => resolve();
49
+ tx.onerror = () => reject(tx.error);
50
+ });
51
+ }
52
+
53
+ async function loadKeyFromIndexedDB(cryptoDBName) {
54
+ try {
55
+ const db = await openKeyDB(cryptoDBName);
56
+ const record = await new Promise((resolve, reject) => {
57
+ const tx = db.transaction(KEY_STORE, 'readonly');
58
+ const req = tx.objectStore(KEY_STORE).get(SYNC_KEY_ID);
59
+ req.onsuccess = () => resolve(req.result);
60
+ req.onerror = () => reject(req.error);
61
+ });
62
+ if (!record) return null;
63
+ const key = await crypto.subtle.importKey(
64
+ 'raw', record.rawKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
65
+ );
66
+ return { key, salt: new Uint8Array(record.salt) };
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ async function clearKeyFromIndexedDB(cryptoDBName) {
73
+ try {
74
+ const db = await openKeyDB(cryptoDBName);
75
+ await new Promise((resolve, reject) => {
76
+ const tx = db.transaction(KEY_STORE, 'readwrite');
77
+ tx.objectStore(KEY_STORE).delete(SYNC_KEY_ID);
78
+ tx.oncomplete = () => resolve();
79
+ tx.onerror = () => reject(tx.error);
80
+ });
81
+ } catch { /* ignore */ }
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Android Keystore bridge
86
+ // ---------------------------------------------------------------------------
87
+ // The native bridge is injected as a function reference rather than probing
88
+ // window globals by name — no app-specific global detection here.
89
+
90
+ async function loadKeyFromNative(nativeGetSyncKey) {
91
+ try {
92
+ const b64 = await nativeGetSyncKey();
93
+ if (!b64) return null;
94
+ const record = JSON.parse(atob(b64));
95
+ const key = await crypto.subtle.importKey(
96
+ 'raw', new Uint8Array(record.rawKey), { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
97
+ );
98
+ return { key, salt: new Uint8Array(record.salt) };
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ async function saveKeyToNative(nativeStoreSyncKey, cryptoKey, salt) {
105
+ if (!nativeStoreSyncKey) return;
106
+ const rawKey = await crypto.subtle.exportKey('raw', cryptoKey);
107
+ const record = { rawKey: Array.from(new Uint8Array(rawKey)), salt: Array.from(salt) };
108
+ nativeStoreSyncKey(btoa(JSON.stringify(record)));
109
+ }
110
+
111
+ async function clearKeyFromNative(nativeStoreSyncKey) {
112
+ try {
113
+ if (nativeStoreSyncKey) {
114
+ nativeStoreSyncKey(null);
115
+ }
116
+ } catch { /* ignore */ }
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // PBKDF2 key derivation
121
+ // ---------------------------------------------------------------------------
122
+ async function deriveKey(passphrase, salt) {
123
+ const enc = new TextEncoder();
124
+ const keyMaterial = await crypto.subtle.importKey(
125
+ 'raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']
126
+ );
127
+ return crypto.subtle.deriveKey(
128
+ { name: 'PBKDF2', salt, iterations: 310_000, hash: 'SHA-256' },
129
+ keyMaterial,
130
+ { name: 'AES-GCM', length: 256 },
131
+ true,
132
+ ['encrypt', 'decrypt']
133
+ );
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Public lifecycle API
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Called on app load. Attempts to restore the session key from device storage
142
+ * (IndexedDB on browser, native bridge on mobile) without requiring the user
143
+ * to re-enter their passphrase.
144
+ *
145
+ * @param {object} config
146
+ * @param {string} config.cryptoDBName - IndexedDB database name for key storage
147
+ * @param {Function|null} [config.nativeGetSyncKey] - native bridge getter (Android Keystore)
148
+ * @returns {Promise<boolean>} true if key was restored, false if passphrase required
149
+ */
150
+ export async function initSessionKey({ cryptoDBName, nativeGetSyncKey = null } = {}) {
151
+ try {
152
+ let result;
153
+ if (nativeGetSyncKey != null) {
154
+ result = await loadKeyFromNative(nativeGetSyncKey);
155
+ } else {
156
+ result = await loadKeyFromIndexedDB(cryptoDBName);
157
+ }
158
+ if (result) {
159
+ _sessionKey = result.key;
160
+ _sessionSalt = result.salt;
161
+ return true;
162
+ }
163
+ } catch { /* storage unavailable */ }
164
+ return false;
165
+ }
166
+
167
+ /**
168
+ * First-time setup on a fresh device: derives a key from the passphrase with a
169
+ * freshly generated salt and caches it in device storage.
170
+ * Call this when the user configures encryption for the first time.
171
+ *
172
+ * @param {string} passphrase
173
+ * @param {object} config
174
+ * @param {string} config.cryptoDBName
175
+ * @param {Function|null} [config.nativeGetSyncKey]
176
+ * @param {Function|null} [config.nativeStoreSyncKey]
177
+ */
178
+ export async function setupEncryptionKey(passphrase, { cryptoDBName, nativeGetSyncKey = null, nativeStoreSyncKey = null } = {}) {
179
+ const salt = crypto.getRandomValues(new Uint8Array(16));
180
+ const key = await deriveKey(passphrase, salt);
181
+ _sessionPassphrase = passphrase;
182
+ _sessionSalt = salt;
183
+ if (nativeGetSyncKey != null) {
184
+ await saveKeyToNative(nativeStoreSyncKey, key, salt);
185
+ } else {
186
+ await saveKeyToIndexedDB(cryptoDBName, key, salt);
187
+ }
188
+ // Re-import as non-extractable so the live in-memory key cannot be exported via Web Crypto API.
189
+ const rawKey = await crypto.subtle.exportKey('raw', key);
190
+ _sessionKey = await crypto.subtle.importKey(
191
+ 'raw', rawKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
192
+ );
193
+ }
194
+
195
+ /**
196
+ * Clears the cached key from device storage and session memory.
197
+ * Call this when the user disables encryption.
198
+ *
199
+ * @param {object} config
200
+ * @param {string} config.cryptoDBName
201
+ * @param {Function|null} [config.nativeGetSyncKey]
202
+ * @param {Function|null} [config.nativeStoreSyncKey]
203
+ */
204
+ export async function clearEncryptionKey({ cryptoDBName, nativeGetSyncKey = null, nativeStoreSyncKey = null } = {}) {
205
+ _sessionPassphrase = null;
206
+ _sessionKey = null;
207
+ _sessionSalt = null;
208
+ if (nativeGetSyncKey != null) {
209
+ await clearKeyFromNative(nativeStoreSyncKey);
210
+ } else {
211
+ await clearKeyFromIndexedDB(cryptoDBName);
212
+ }
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Helpers
217
+ // ---------------------------------------------------------------------------
218
+ function packBytes(...arrays) {
219
+ const total = arrays.reduce((n, a) => n + a.length, 0);
220
+ const out = new Uint8Array(total);
221
+ let offset = 0;
222
+ for (const a of arrays) { out.set(a, offset); offset += a.length; }
223
+ return out;
224
+ }
225
+
226
+ function toBase64(bytes) {
227
+ // Chunk to avoid "Maximum call stack size exceeded" for large payloads —
228
+ // spreading a large Uint8Array as function arguments hits the engine's
229
+ // argument-count limit (~65 K in V8).
230
+ let binary = '';
231
+ const chunk = 8192;
232
+ for (let i = 0; i < bytes.length; i += chunk) {
233
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
234
+ }
235
+ return btoa(binary);
236
+ }
237
+
238
+ function fromBase64(b64) {
239
+ return Uint8Array.from(atob(b64), c => c.charCodeAt(0));
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Encrypt / decrypt
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /**
247
+ * Encrypts a plain JS object and returns an envelope object ready for JSON.stringify.
248
+ *
249
+ * Envelope format:
250
+ * { v: 1, enc: "AES-GCM-256", data: "<base64>" }
251
+ *
252
+ * The base64 blob encodes:
253
+ * [ 16 bytes: PBKDF2 salt ] [ 12 bytes: AES-GCM IV ] [ N bytes: ciphertext ]
254
+ *
255
+ * @param {object} data - plain JS object to encrypt
256
+ * @param {object} config
257
+ * @param {string} config.cryptoDBName
258
+ * @param {Function|null} [config.nativeGetSyncKey]
259
+ * @param {Function|null} [config.nativeStoreSyncKey]
260
+ */
261
+ export async function encryptData(data, config = {}) {
262
+ // First use: lazily set up the key so the caller doesn't need to distinguish
263
+ // between "first device" and "subsequent upload" scenarios.
264
+ if (!_sessionKey) {
265
+ if (_sessionPassphrase) {
266
+ await setupEncryptionKey(_sessionPassphrase, config);
267
+ } else {
268
+ throw new Error('Encryption key not available. Please enter your sync passphrase.');
269
+ }
270
+ }
271
+
272
+ const plaintext = new TextEncoder().encode(JSON.stringify(data));
273
+ const iv = crypto.getRandomValues(new Uint8Array(12));
274
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, _sessionKey, plaintext);
275
+
276
+ const packed = packBytes(_sessionSalt, iv, new Uint8Array(ciphertext));
277
+ return { v: 1, enc: 'AES-GCM-256', data: toBase64(packed) };
278
+ }
279
+
280
+ /**
281
+ * Decrypts an envelope object produced by encryptData.
282
+ * Throws on wrong passphrase, tampered data, or missing key.
283
+ *
284
+ * Cross-device recovery path: if no cached key but _sessionPassphrase is set,
285
+ * derives the key from the passphrase + salt embedded in the ciphertext, then
286
+ * caches it in device storage for future sessions.
287
+ *
288
+ * @param {object} envelope - encrypted envelope from encryptData
289
+ * @param {object} config
290
+ * @param {string} config.cryptoDBName
291
+ * @param {Function|null} [config.nativeGetSyncKey]
292
+ * @param {Function|null} [config.nativeStoreSyncKey]
293
+ */
294
+ export async function decryptData(envelope, config = {}) {
295
+ if (!envelope || !envelope.data) throw new Error('Invalid encrypted envelope');
296
+ if (envelope.v !== 1) throw new Error(`Unknown encryption version: ${envelope.v}`);
297
+
298
+ const packed = fromBase64(envelope.data);
299
+ const salt = packed.slice(0, 16);
300
+ const iv = packed.slice(16, 28);
301
+ const ciphertext = packed.slice(28);
302
+
303
+ let key = _sessionKey;
304
+
305
+ // If the cached key was derived from a different salt (e.g. user re-configured
306
+ // encryption, generating a new salt), it cannot decrypt this file. Force
307
+ // re-derivation so the file's embedded salt always dictates which key is used.
308
+ if (key && _sessionSalt) {
309
+ const saltMatch = salt.length === _sessionSalt.length &&
310
+ salt.every((b, i) => b === _sessionSalt[i]);
311
+ if (!saltMatch) key = null;
312
+ }
313
+
314
+ let keyWasDerived = false;
315
+ if (!key) {
316
+ if (_sessionPassphrase) {
317
+ // Cross-device recovery or salt mismatch: derive key from passphrase + salt from file.
318
+ // Do NOT cache yet — only persist after we confirm the key actually decrypts the file.
319
+ key = await deriveKey(_sessionPassphrase, salt);
320
+ keyWasDerived = true;
321
+ } else {
322
+ const err = new Error('Encryption key not available. Please enter your sync passphrase.');
323
+ err.code = 'PASSPHRASE_REQUIRED';
324
+ throw err;
325
+ }
326
+ }
327
+
328
+ try {
329
+ const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
330
+ // Decryption succeeded — safe to promote and cache the derived key.
331
+ if (keyWasDerived) {
332
+ _sessionKey = key;
333
+ _sessionSalt = salt;
334
+ const { nativeGetSyncKey = null, nativeStoreSyncKey = null, cryptoDBName } = config;
335
+ if (nativeGetSyncKey != null) {
336
+ await saveKeyToNative(nativeStoreSyncKey, key, salt);
337
+ } else {
338
+ await saveKeyToIndexedDB(cryptoDBName, key, salt);
339
+ }
340
+ }
341
+ return JSON.parse(new TextDecoder().decode(plaintext));
342
+ } catch (err) {
343
+ if (err.name === 'OperationError') {
344
+ throw new Error('Decryption failed — wrong passphrase or corrupted data.');
345
+ }
346
+ throw err;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Returns true if the value looks like an encrypted envelope produced by
352
+ * encryptData. Used to decide whether to call decryptData on downloaded files.
353
+ */
354
+ export function isEncryptedEnvelope(value) {
355
+ return (
356
+ value !== null &&
357
+ typeof value === 'object' &&
358
+ value.v === 1 &&
359
+ value.enc === 'AES-GCM-256' &&
360
+ typeof value.data === 'string'
361
+ );
362
+ }
package/src/index.js CHANGED
@@ -7,3 +7,19 @@ export {
7
7
  mergeSyncData,
8
8
  pruneTombstones,
9
9
  } from './merge.js';
10
+
11
+ export {
12
+ encryptData,
13
+ decryptData,
14
+ setupEncryptionKey,
15
+ setSyncPassphrase,
16
+ getSyncPassphrase,
17
+ clearEncryptionKey,
18
+ hasEncryptionReady,
19
+ isEncryptedEnvelope,
20
+ initSessionKey,
21
+ } from './crypto.js';
22
+
23
+ // webdavFetch and createProviders are internal to the engine (Step 6) but
24
+ // exported here so shims can wire them before the engine exists.
25
+ export { webdavFetch, createProviders } from './providers.js';
package/src/providers.js CHANGED
@@ -1 +1,317 @@
1
- // Placeholder — WebDAV providers are extracted in Step 4.
1
+ // WebDAV provider objects and transport routing for cloud sync.
2
+ // No hardcoded app names, paths, or platform globals — all injected via config.
3
+
4
+ import { encryptData, decryptData, isEncryptedEnvelope } from './crypto.js';
5
+
6
+ // btoa() throws InvalidCharacterError for codepoints > 255 (CJK, emoji, etc.).
7
+ const toBase64 = (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str)));
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Transport routing
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Creates the transport-selecting WebDAV fetcher for a given engine config.
15
+ * Returns an async function with the same signature as the original webdavFetch.
16
+ *
17
+ * Transport priority:
18
+ * 1. nativeHttpRequest != null → Android HTTP bridge (direct, no CORS)
19
+ * 2. electronProxyFetch != null → Electron main-process net.fetch
20
+ * 3. fallback → server-side CORS proxy at proxyUrl
21
+ *
22
+ * iOS note: pass nativeHttpRequest = null on iOS (the iOS DayGlanceNative Proxy
23
+ * makes every property truthy, so callers must apply the !DayGlanceIOS guard
24
+ * before deciding whether to pass a function here).
25
+ *
26
+ * @param {object} config
27
+ * @param {Function|null} config.nativeHttpRequest - Android bridge fn, or null
28
+ * @param {Function|null} config.electronProxyFetch - Electron proxy fn, or null
29
+ * @param {string} [config.proxyUrl=''] - base URL for CORS proxy
30
+ */
31
+ export const webdavFetch = (config) => async (method, targetUrl, authHeaders, body, extraHeaders = {}) => {
32
+ if (config.nativeHttpRequest != null) {
33
+ // Android: call the target URL directly via the native HTTP bridge.
34
+ const headers = { ...extraHeaders };
35
+ if (authHeaders['X-WebDAV-Auth']) {
36
+ headers['Authorization'] = authHeaders['X-WebDAV-Auth'];
37
+ } else {
38
+ Object.assign(headers, authHeaders);
39
+ }
40
+ if (body !== undefined && body !== null) {
41
+ headers['Content-Type'] = extraHeaders['Content-Type'] || 'application/octet-stream';
42
+ }
43
+ const result = config.nativeHttpRequest(method, targetUrl, headers, body ?? '');
44
+ if (!result) throw new Error('Native HTTP bridge unavailable');
45
+ return {
46
+ status: result.status,
47
+ ok: result.ok,
48
+ statusText: result.error || '',
49
+ headers: { get: (h) => (h.toLowerCase() === 'etag' ? result.headers?.etag ?? null : null) },
50
+ json: async () => {
51
+ try { return JSON.parse(result.body); }
52
+ catch { throw new Error(`Server returned non-JSON response (status ${result.status})`); }
53
+ },
54
+ text: async () => result.body,
55
+ };
56
+ }
57
+
58
+ if (config.electronProxyFetch != null) {
59
+ // Electron: route through the main process (net.fetch has no CORS restrictions).
60
+ const headers = { ...extraHeaders };
61
+ if (authHeaders['X-WebDAV-Auth']) {
62
+ headers['Authorization'] = authHeaders['X-WebDAV-Auth'];
63
+ } else {
64
+ Object.assign(headers, authHeaders);
65
+ }
66
+ if (body !== undefined && body !== null) {
67
+ headers['Content-Type'] = extraHeaders['Content-Type'] || 'application/octet-stream';
68
+ }
69
+ const result = await config.electronProxyFetch(method, targetUrl, headers, body ?? null);
70
+ if (!result) throw new Error('Electron network bridge unavailable');
71
+ return {
72
+ status: result.status,
73
+ ok: result.ok,
74
+ statusText: result.statusText,
75
+ headers: { get: (h) => (h.toLowerCase() === 'etag' ? result.headers?.etag ?? null : null) },
76
+ json: async () => {
77
+ try { return JSON.parse(result.body); }
78
+ catch { throw new Error(`Server returned non-JSON response (status ${result.status})`); }
79
+ },
80
+ text: async () => result.body,
81
+ };
82
+ }
83
+
84
+ // Web / iOS: route through the server-side CORS proxy.
85
+ return fetch(`${config.proxyUrl ?? ''}/api/webdav-proxy/?url=${encodeURIComponent(targetUrl)}`, {
86
+ method,
87
+ headers: { ...authHeaders, ...extraHeaders },
88
+ ...(body !== undefined && body !== null ? { body } : {}),
89
+ });
90
+ };
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Internal helpers
94
+ // ---------------------------------------------------------------------------
95
+
96
+ async function parseDownloadResponse(res, engineConfig) {
97
+ const parsed = await res.json();
98
+ const payload = isEncryptedEnvelope(parsed) ? await decryptData(parsed, engineConfig) : parsed;
99
+ const etag = res.headers.get('etag') || null;
100
+ return { payload, etag };
101
+ }
102
+
103
+ async function mkcolWithParents(dirUrl, authHeaders, wf) {
104
+ const res = await wf('MKCOL', dirUrl, authHeaders);
105
+ if (res.status === 409 || res.status === 404) {
106
+ // Derive parent by stripping the last path segment via URL() to avoid
107
+ // producing malformed strings on root-level inputs.
108
+ let parent;
109
+ try {
110
+ const u = new URL(dirUrl);
111
+ const stripped = u.pathname.replace(/\/+$/, '').replace(/\/[^/]+$/, '/');
112
+ if (!stripped || stripped === u.pathname.replace(/\/+$/, '')) return;
113
+ u.pathname = stripped;
114
+ parent = u.toString();
115
+ } catch { return; }
116
+ if (parent && parent !== dirUrl) {
117
+ await mkcolWithParents(parent, authHeaders, wf);
118
+ await wf('MKCOL', dirUrl, authHeaders);
119
+ }
120
+ }
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Provider factory
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Creates the cloud sync provider objects for the given engine config.
129
+ * Provider methods keep the same per-call signature as dayGLANCE's original
130
+ * cloudSyncProviders — callers pass user credentials via `config`.
131
+ *
132
+ * @param {object} engineConfig
133
+ * @param {string} engineConfig.appFolderName - e.g. 'dayglance'
134
+ * @param {string} engineConfig.syncFilename - e.g. 'dayglance-sync.json'
135
+ * @param {Function|null} engineConfig.nativeHttpRequest - Android bridge, or null
136
+ * @param {Function|null} engineConfig.electronProxyFetch - Electron proxy, or null
137
+ * @param {string} [engineConfig.proxyUrl=''] - CORS proxy base URL
138
+ * @param {string} engineConfig.cryptoDBName - IndexedDB name for key storage
139
+ * @param {Function|null} [engineConfig.nativeGetSyncKey] - Android crypto key bridge
140
+ * @param {Function|null} [engineConfig.nativeStoreSyncKey]
141
+ */
142
+ export function createProviders(engineConfig) {
143
+ const { appFolderName, syncFilename } = engineConfig;
144
+ const wf = webdavFetch(engineConfig);
145
+
146
+ const parseDownload = (res) => parseDownloadResponse(res, engineConfig);
147
+ const mkcol = (dirUrl, authHeaders) => mkcolWithParents(dirUrl, authHeaders, wf);
148
+
149
+ return {
150
+ nextcloud: {
151
+ name: 'Nextcloud / WebDAV',
152
+ getFileUrl: (config) =>
153
+ `${config.nextcloudUrl.replace(/\/+$/, '')}/remote.php/dav/files/${encodeURIComponent(config.username)}/${appFolderName}/${syncFilename}`,
154
+ getDirUrl: (config) =>
155
+ `${config.nextcloudUrl.replace(/\/+$/, '')}/remote.php/dav/files/${encodeURIComponent(config.username)}/${appFolderName}/`,
156
+ getAuthHeaders: (config) => ({
157
+ 'X-WebDAV-Auth': 'Basic ' + toBase64(config.username + ':' + config.appPassword)
158
+ }),
159
+ async upload(config, data, etag = null) {
160
+ const fileUrl = this.getFileUrl(config);
161
+ const dirUrl = this.getDirUrl(config);
162
+ const authHeaders = this.getAuthHeaders(config);
163
+ const payload = config.encryptionEnabled ? await encryptData(data, engineConfig) : data;
164
+ const body = JSON.stringify(payload);
165
+ const extraHeaders = { 'Content-Type': 'application/json' };
166
+ if (etag) extraHeaders['If-Match'] = etag;
167
+ const doUpload = () => wf('PUT', fileUrl, authHeaders, body, extraHeaders);
168
+ let res = await doUpload();
169
+ if (res.status === 404 || res.status === 409) {
170
+ await mkcol(dirUrl, authHeaders);
171
+ res = await doUpload();
172
+ }
173
+ if (res.status === 403) throw new Error('FORBIDDEN');
174
+ if (res.status === 412) throw new Error('PRECONDITION_FAILED');
175
+ if (!res.ok) throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
176
+ return true;
177
+ },
178
+ async download(config) {
179
+ const fileUrl = this.getFileUrl(config);
180
+ const authHeaders = this.getAuthHeaders(config);
181
+ const res = await wf('GET', fileUrl, authHeaders);
182
+ if (res.status === 404) return null;
183
+ if (res.status === 403) throw new Error('FORBIDDEN');
184
+ if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
185
+ return parseDownload(res);
186
+ },
187
+ async test(config) {
188
+ const dirUrl = this.getDirUrl(config);
189
+ const authHeaders = this.getAuthHeaders(config);
190
+ const res = await wf('PROPFIND', dirUrl, authHeaders, undefined, { 'Depth': '0' });
191
+ if (res.status === 200 || res.status === 207 || res.status === 404) return { success: true };
192
+ if (res.status === 401) return { success: false, error: 'Invalid credentials. Check your username and app password.' };
193
+ if (res.status === 403) return { success: false, error: "Access forbidden (403). If using a self-hosted server, it may be blocking requests from Vercel's IP addresses." };
194
+ return { success: false, error: `Unexpected response: ${res.status}${res.statusText ? ' ' + res.statusText : ''}` };
195
+ },
196
+ configFields: [
197
+ { key: 'nextcloudUrl', label: 'Nextcloud URL', type: 'url', placeholder: 'https://cloud.example.com' },
198
+ { key: 'username', label: 'Username', type: 'text', placeholder: 'your-username' },
199
+ { key: 'appPassword', label: 'App Password', type: 'password', placeholder: 'xxxxx-xxxxx-xxxxx-xxxxx-xxxxx' }
200
+ ],
201
+ helpText: 'Go to Nextcloud Settings → Security → Devices & sessions → Create new app password'
202
+ },
203
+
204
+ koofr: {
205
+ name: 'Koofr',
206
+ getFileUrl: () => `https://app.koofr.net/dav/Koofr/${appFolderName}/${syncFilename}`,
207
+ getDirUrl: () => `https://app.koofr.net/dav/Koofr/${appFolderName}/`,
208
+ getAuthHeaders: (config) => ({
209
+ 'X-WebDAV-Auth': 'Basic ' + toBase64(config.username + ':' + config.appPassword)
210
+ }),
211
+ async upload(config, data, etag = null) {
212
+ const fileUrl = this.getFileUrl();
213
+ const dirUrl = this.getDirUrl();
214
+ const authHeaders = this.getAuthHeaders(config);
215
+ const payload = config.encryptionEnabled ? await encryptData(data, engineConfig) : data;
216
+ const body = JSON.stringify(payload);
217
+ const extraHeaders = { 'Content-Type': 'application/json' };
218
+ if (etag) extraHeaders['If-Match'] = etag;
219
+ const doUpload = () => wf('PUT', fileUrl, authHeaders, body, extraHeaders);
220
+ let res = await doUpload();
221
+ if (res.status === 404 || res.status === 409) {
222
+ await mkcol(dirUrl, authHeaders);
223
+ res = await doUpload();
224
+ }
225
+ if (res.status === 403) throw new Error('FORBIDDEN');
226
+ if (res.status === 412) throw new Error('PRECONDITION_FAILED');
227
+ if (!res.ok) throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
228
+ return true;
229
+ },
230
+ async download(config) {
231
+ const fileUrl = this.getFileUrl();
232
+ const authHeaders = this.getAuthHeaders(config);
233
+ const res = await wf('GET', fileUrl, authHeaders);
234
+ if (res.status === 404) return null;
235
+ if (res.status === 403) throw new Error('FORBIDDEN');
236
+ if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
237
+ return parseDownload(res);
238
+ },
239
+ async test(config) {
240
+ const dirUrl = this.getDirUrl();
241
+ const authHeaders = this.getAuthHeaders(config);
242
+ const res = await wf('PROPFIND', dirUrl, authHeaders, undefined, { 'Depth': '0' });
243
+ if (res.status === 200 || res.status === 207 || res.status === 404) return { success: true };
244
+ if (res.status === 401) return { success: false, error: 'Invalid credentials. Use your Koofr email and an app password, not your account password.' };
245
+ if (res.status === 403) {
246
+ // nativeHttpRequest != null means the Android HTTP bridge is available,
247
+ // which bypasses Koofr's server-side block on Vercel proxy IPs.
248
+ const onAndroid = engineConfig.nativeHttpRequest != null;
249
+ if (!onAndroid) return { success: false, error: "Koofr is blocking requests from the web app's server. Use the Android app — it connects to Koofr directly." };
250
+ return { success: false, error: 'Access forbidden. Check that your app password is correct and has not been revoked.' };
251
+ }
252
+ return { success: false, error: `Unexpected response: ${res.status}${res.statusText ? ' ' + res.statusText : ''}` };
253
+ },
254
+ configFields: [
255
+ { key: 'username', label: 'Email', type: 'text', placeholder: 'you@example.com' },
256
+ { key: 'appPassword', label: 'App Password', type: 'password', placeholder: 'your-app-password' },
257
+ ],
258
+ helpText: "Go to Koofr → Preferences → App passwords → New app password. Use your Koofr email as the username. Note: sync works on the Android app (direct connection); the web app may be blocked by Koofr's server-side restrictions.",
259
+ },
260
+
261
+ webdav: {
262
+ name: 'Generic WebDAV',
263
+ getFileUrl: (config) =>
264
+ `${config.webdavUrl.replace(/\/+$/, '')}/${syncFilename}`,
265
+ getDirUrl: (config) =>
266
+ `${config.webdavUrl.replace(/\/+$/, '')}/`,
267
+ getAuthHeaders: (config) => ({
268
+ 'X-WebDAV-Auth': 'Basic ' + toBase64(config.username + ':' + config.appPassword)
269
+ }),
270
+ async upload(config, data, etag = null) {
271
+ const fileUrl = this.getFileUrl(config);
272
+ const dirUrl = this.getDirUrl(config);
273
+ const authHeaders = this.getAuthHeaders(config);
274
+ const payload = config.encryptionEnabled ? await encryptData(data, engineConfig) : data;
275
+ const body = JSON.stringify(payload);
276
+ const extraHeaders = { 'Content-Type': 'application/json' };
277
+ if (etag) extraHeaders['If-Match'] = etag;
278
+ const doUpload = () => wf('PUT', fileUrl, authHeaders, body, extraHeaders);
279
+ let res = await doUpload();
280
+ if (res.status === 404 || res.status === 409) {
281
+ await mkcol(dirUrl, authHeaders);
282
+ res = await doUpload();
283
+ }
284
+ if (res.status === 403) throw new Error('FORBIDDEN');
285
+ if (res.status === 412) throw new Error('PRECONDITION_FAILED');
286
+ if (!res.ok) throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
287
+ return true;
288
+ },
289
+ async download(config) {
290
+ const fileUrl = this.getFileUrl(config);
291
+ const authHeaders = this.getAuthHeaders(config);
292
+ const res = await wf('GET', fileUrl, authHeaders);
293
+ if (res.status === 404) return null;
294
+ if (res.status === 403) throw new Error('FORBIDDEN');
295
+ if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
296
+ return parseDownload(res);
297
+ },
298
+ async test(config) {
299
+ // GET the sync file rather than PROPFIND — Apache mod_dav and other strict
300
+ // servers return 400 for a bodyless PROPFIND (RFC 4918 requires XML body).
301
+ const fileUrl = this.getFileUrl(config);
302
+ const authHeaders = this.getAuthHeaders(config);
303
+ const res = await wf('GET', fileUrl, authHeaders);
304
+ if (res.status === 200 || res.status === 404) return { success: true };
305
+ if (res.status === 401) return { success: false, error: 'Invalid credentials. Check your username and password.' };
306
+ if (res.status === 403) return { success: false, error: 'Access forbidden (403). Check that the WebDAV URL and path are correct.' };
307
+ return { success: false, error: `Unexpected response: ${res.status}${res.statusText ? ' ' + res.statusText : ''}` };
308
+ },
309
+ configFields: [
310
+ { key: 'webdavUrl', label: 'WebDAV URL', type: 'url', placeholder: 'https://dav.example.com/dayGLANCE/' },
311
+ { key: 'username', label: 'Username', type: 'text', placeholder: 'your-username' },
312
+ { key: 'appPassword', label: 'Password / App Password', type: 'password', placeholder: 'your-password' }
313
+ ],
314
+ helpText: 'Enter the full URL of the WebDAV folder where dayGLANCE should store its sync file. pCloud, Seafile, and most self-hosted WebDAV providers are supported.'
315
+ }
316
+ };
317
+ }