@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 +2 -1
- package/src/crypto.js +362 -1
- package/src/index.js +16 -0
- package/src/providers.js +317 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glance-apps/sync",
|
|
3
|
-
"version": "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
|
-
//
|
|
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
|
-
//
|
|
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
|
+
}
|