@glance-apps/sync 0.3.0 → 0.5.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 +1 -1
- package/src/autoBackup.js +272 -1
- package/src/index.js +11 -0
- package/src/providers.js +317 -1
package/package.json
CHANGED
package/src/autoBackup.js
CHANGED
|
@@ -1 +1,272 @@
|
|
|
1
|
-
|
|
1
|
+
import { encryptData, decryptData, isEncryptedEnvelope, hasEncryptionReady } from './crypto.js';
|
|
2
|
+
import { webdavFetch } from './providers.js';
|
|
3
|
+
|
|
4
|
+
// btoa() throws InvalidCharacterError for codepoints > 255 (CJK, emoji, etc.).
|
|
5
|
+
const toBase64 = (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str)));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Creates an IndexedDB-backed auto-backup store.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} config
|
|
11
|
+
* @param {string} config.autoBackupDBName - IDB database name (e.g. 'dayglance-auto-backups')
|
|
12
|
+
* @returns {object} autoBackupDB instance
|
|
13
|
+
*/
|
|
14
|
+
export function createAutoBackupDB(config) {
|
|
15
|
+
return {
|
|
16
|
+
_db: null,
|
|
17
|
+
async open() {
|
|
18
|
+
if (this._db) return this._db;
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const req = indexedDB.open(config.autoBackupDBName, 1);
|
|
21
|
+
req.onupgradeneeded = (e) => {
|
|
22
|
+
const db = e.target.result;
|
|
23
|
+
if (!db.objectStoreNames.contains('backups')) {
|
|
24
|
+
const store = db.createObjectStore('backups', { keyPath: 'id' });
|
|
25
|
+
store.createIndex('timestamp', 'timestamp', { unique: false });
|
|
26
|
+
store.createIndex('frequency', 'frequency', { unique: false });
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
req.onsuccess = () => { this._db = req.result; resolve(req.result); };
|
|
30
|
+
req.onerror = () => reject(req.error);
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
async saveBackup(frequency, data) {
|
|
34
|
+
const db = await this.open();
|
|
35
|
+
const timestamp = new Date().toISOString();
|
|
36
|
+
const id = `auto-${frequency}-${timestamp}`;
|
|
37
|
+
const record = { id, timestamp, frequency, data };
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const tx = db.transaction('backups', 'readwrite');
|
|
40
|
+
tx.objectStore('backups').put(record);
|
|
41
|
+
tx.oncomplete = () => resolve(record);
|
|
42
|
+
tx.onerror = () => reject(tx.error);
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
async listBackups(frequency) {
|
|
46
|
+
const db = await this.open();
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const tx = db.transaction('backups', 'readonly');
|
|
49
|
+
const store = tx.objectStore('backups');
|
|
50
|
+
const req = frequency
|
|
51
|
+
? store.index('frequency').getAll(frequency)
|
|
52
|
+
: store.getAll();
|
|
53
|
+
req.onsuccess = () => resolve(req.result.sort((a, b) => b.timestamp.localeCompare(a.timestamp)));
|
|
54
|
+
req.onerror = () => reject(req.error);
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
async getBackup(id) {
|
|
58
|
+
const db = await this.open();
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const tx = db.transaction('backups', 'readonly');
|
|
61
|
+
const req = tx.objectStore('backups').get(id);
|
|
62
|
+
req.onsuccess = () => resolve(req.result);
|
|
63
|
+
req.onerror = () => reject(req.error);
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
async deleteBackup(id) {
|
|
67
|
+
const db = await this.open();
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const tx = db.transaction('backups', 'readwrite');
|
|
70
|
+
tx.objectStore('backups').delete(id);
|
|
71
|
+
tx.oncomplete = () => resolve();
|
|
72
|
+
tx.onerror = () => reject(tx.error);
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
async pruneBackups(frequency, maxCount) {
|
|
76
|
+
const all = await this.listBackups(frequency);
|
|
77
|
+
if (all.length <= maxCount) return;
|
|
78
|
+
const toDelete = all.slice(maxCount);
|
|
79
|
+
const db = await this.open();
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const tx = db.transaction('backups', 'readwrite');
|
|
82
|
+
const store = tx.objectStore('backups');
|
|
83
|
+
toDelete.forEach(b => store.delete(b.id));
|
|
84
|
+
tx.oncomplete = () => resolve(toDelete.length);
|
|
85
|
+
tx.onerror = () => reject(tx.error);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Creates the auto-backup remote provider objects.
|
|
93
|
+
* These are separate from cloudSyncProviders (real-time sync); they handle
|
|
94
|
+
* periodic snapshot uploads.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} config
|
|
97
|
+
* @param {string} config.backupFilenamePrefix - e.g. 'dayglance-backup-'
|
|
98
|
+
* @param {string} config.appFolderName - e.g. 'dayglance' (used in Nextcloud path)
|
|
99
|
+
* @param {Function} config.webdavFetch - bound webdavFetch(method, url, auth, body, extra)
|
|
100
|
+
* @returns {object} provider map keyed by provider id
|
|
101
|
+
*/
|
|
102
|
+
export function createAutoBackupProviders(config) {
|
|
103
|
+
const wdFetch = config.webdavFetch;
|
|
104
|
+
const prefix = config.backupFilenamePrefix;
|
|
105
|
+
const appFolder = config.appFolderName;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
nextcloud: {
|
|
109
|
+
name: 'Nextcloud / WebDAV',
|
|
110
|
+
configFields: [
|
|
111
|
+
{ key: 'nextcloudUrl', label: 'Nextcloud URL', type: 'url', placeholder: 'https://cloud.example.com' },
|
|
112
|
+
{ key: 'username', label: 'Username', type: 'text', placeholder: 'your-username' },
|
|
113
|
+
{ key: 'appPassword', label: 'App Password', type: 'password', placeholder: 'xxxxx-xxxxx-xxxxx-xxxxx-xxxxx' }
|
|
114
|
+
],
|
|
115
|
+
_getBackupDirUrl(providerConfig) {
|
|
116
|
+
if (!providerConfig.nextcloudUrl) throw new Error('Nextcloud URL is not configured');
|
|
117
|
+
return `${providerConfig.nextcloudUrl.replace(/\/+$/, '')}/remote.php/dav/files/${encodeURIComponent(providerConfig.username)}/${appFolder}/backups/`;
|
|
118
|
+
},
|
|
119
|
+
_getAuthHeaders(providerConfig) {
|
|
120
|
+
return { 'X-WebDAV-Auth': 'Basic ' + toBase64(providerConfig.username + ':' + providerConfig.appPassword) };
|
|
121
|
+
},
|
|
122
|
+
async uploadBackup(providerConfig, data) {
|
|
123
|
+
const dirUrl = this._getBackupDirUrl(providerConfig);
|
|
124
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
125
|
+
const now = new Date();
|
|
126
|
+
const timestamp = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}T${String(now.getHours()).padStart(2,'0')}-${String(now.getMinutes()).padStart(2,'0')}-${String(now.getSeconds()).padStart(2,'0')}`;
|
|
127
|
+
const filename = `${prefix}${timestamp}.json`;
|
|
128
|
+
const fileUrl = dirUrl + filename;
|
|
129
|
+
const payload = hasEncryptionReady() ? await encryptData(data) : data;
|
|
130
|
+
const body = JSON.stringify(payload);
|
|
131
|
+
|
|
132
|
+
const doUpload = () =>
|
|
133
|
+
wdFetch('PUT', fileUrl, authHeaders, body, { 'Content-Type': 'application/json' });
|
|
134
|
+
|
|
135
|
+
let res = await doUpload();
|
|
136
|
+
if (res.status === 404 || res.status === 409) {
|
|
137
|
+
const parentDir = `${providerConfig.nextcloudUrl.replace(/\/+$/, '')}/remote.php/dav/files/${encodeURIComponent(providerConfig.username)}/${appFolder}/`;
|
|
138
|
+
await wdFetch('MKCOL', parentDir, authHeaders);
|
|
139
|
+
await wdFetch('MKCOL', dirUrl, authHeaders);
|
|
140
|
+
res = await doUpload();
|
|
141
|
+
}
|
|
142
|
+
if (!res.ok) throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
|
|
143
|
+
return filename;
|
|
144
|
+
},
|
|
145
|
+
async listBackups(providerConfig) {
|
|
146
|
+
const dirUrl = this._getBackupDirUrl(providerConfig);
|
|
147
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
148
|
+
const res = await wdFetch('PROPFIND', dirUrl, authHeaders, undefined, { 'Depth': '1' });
|
|
149
|
+
if (res.status === 404) return [];
|
|
150
|
+
if (!res.ok) throw new Error(`List failed: ${res.status}`);
|
|
151
|
+
const xml = await res.text();
|
|
152
|
+
const parser = new DOMParser();
|
|
153
|
+
const doc = parser.parseFromString(xml, 'application/xml');
|
|
154
|
+
const responses = doc.querySelectorAll('response');
|
|
155
|
+
const files = [];
|
|
156
|
+
responses.forEach(r => {
|
|
157
|
+
const href = r.querySelector('href')?.textContent || '';
|
|
158
|
+
const filename = decodeURIComponent(href.split('/').filter(Boolean).pop());
|
|
159
|
+
if (filename.startsWith(prefix) && filename.endsWith('.json')) {
|
|
160
|
+
const lastModified = r.querySelector('getlastmodified')?.textContent;
|
|
161
|
+
files.push({ filename, lastModified: lastModified ? new Date(lastModified).toISOString() : null });
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
return files.sort((a, b) => (b.lastModified || '').localeCompare(a.lastModified || ''));
|
|
165
|
+
},
|
|
166
|
+
async downloadBackup(providerConfig, filename) {
|
|
167
|
+
const fileUrl = this._getBackupDirUrl(providerConfig) + filename;
|
|
168
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
169
|
+
const res = await wdFetch('GET', fileUrl, authHeaders);
|
|
170
|
+
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
|
171
|
+
const parsed = await res.json();
|
|
172
|
+
if (isEncryptedEnvelope(parsed)) return decryptData(parsed);
|
|
173
|
+
return parsed;
|
|
174
|
+
},
|
|
175
|
+
async deleteBackup(providerConfig, filename) {
|
|
176
|
+
const fileUrl = this._getBackupDirUrl(providerConfig) + filename;
|
|
177
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
178
|
+
const res = await wdFetch('DELETE', fileUrl, authHeaders);
|
|
179
|
+
if (!res.ok && res.status !== 404) throw new Error(`Delete failed: ${res.status}`);
|
|
180
|
+
},
|
|
181
|
+
async testConnection(providerConfig) {
|
|
182
|
+
const dirUrl = this._getBackupDirUrl(providerConfig);
|
|
183
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
184
|
+
const res = await wdFetch('PROPFIND', dirUrl, authHeaders, undefined, { 'Depth': '0' });
|
|
185
|
+
if (res.status === 200 || res.status === 207 || res.status === 404) return { success: true };
|
|
186
|
+
if (res.status === 401) return { success: false, error: 'Invalid credentials.' };
|
|
187
|
+
return { success: false, error: `Unexpected response: ${res.status}` };
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
webdav: {
|
|
191
|
+
name: 'Generic WebDAV',
|
|
192
|
+
configFields: [
|
|
193
|
+
{ key: 'webdavUrl', label: 'WebDAV URL', type: 'url', placeholder: 'https://app.koofr.net/dav/Koofr/dayGLANCE/' },
|
|
194
|
+
{ key: 'username', label: 'Username', type: 'text', placeholder: 'your-username' },
|
|
195
|
+
{ key: 'appPassword', label: 'Password / App Password', type: 'password', placeholder: 'your-password' }
|
|
196
|
+
],
|
|
197
|
+
_getBackupDirUrl(providerConfig) {
|
|
198
|
+
return `${providerConfig.webdavUrl.replace(/\/+$/, '')}/`;
|
|
199
|
+
},
|
|
200
|
+
_getAuthHeaders(providerConfig) {
|
|
201
|
+
return { 'X-WebDAV-Auth': 'Basic ' + toBase64(providerConfig.username + ':' + providerConfig.appPassword) };
|
|
202
|
+
},
|
|
203
|
+
async uploadBackup(providerConfig, data) {
|
|
204
|
+
const dirUrl = this._getBackupDirUrl(providerConfig);
|
|
205
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
206
|
+
const now = new Date();
|
|
207
|
+
const timestamp = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}T${String(now.getHours()).padStart(2,'0')}-${String(now.getMinutes()).padStart(2,'0')}-${String(now.getSeconds()).padStart(2,'0')}`;
|
|
208
|
+
const filename = `${prefix}${timestamp}.json`;
|
|
209
|
+
const fileUrl = dirUrl + filename;
|
|
210
|
+
const payload = hasEncryptionReady() ? await encryptData(data) : data;
|
|
211
|
+
const body = JSON.stringify(payload);
|
|
212
|
+
const doUpload = () =>
|
|
213
|
+
wdFetch('PUT', fileUrl, authHeaders, body, { 'Content-Type': 'application/json' });
|
|
214
|
+
let res = await doUpload();
|
|
215
|
+
if (res.status === 404 || res.status === 409) {
|
|
216
|
+
await wdFetch('MKCOL', dirUrl, authHeaders);
|
|
217
|
+
res = await doUpload();
|
|
218
|
+
}
|
|
219
|
+
if (!res.ok) throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
|
|
220
|
+
return filename;
|
|
221
|
+
},
|
|
222
|
+
async listBackups(providerConfig) {
|
|
223
|
+
const dirUrl = this._getBackupDirUrl(providerConfig);
|
|
224
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
225
|
+
const res = await wdFetch('PROPFIND', dirUrl, authHeaders, undefined, { 'Depth': '1' });
|
|
226
|
+
if (res.status === 404) return [];
|
|
227
|
+
if (!res.ok) throw new Error(`List failed: ${res.status}`);
|
|
228
|
+
const xml = await res.text();
|
|
229
|
+
const parser = new DOMParser();
|
|
230
|
+
const doc = parser.parseFromString(xml, 'application/xml');
|
|
231
|
+
const responses = doc.querySelectorAll('response');
|
|
232
|
+
const files = [];
|
|
233
|
+
responses.forEach(r => {
|
|
234
|
+
const href = r.querySelector('href')?.textContent || '';
|
|
235
|
+
const filename = decodeURIComponent(href.split('/').filter(Boolean).pop());
|
|
236
|
+
if (filename.startsWith(prefix) && filename.endsWith('.json')) {
|
|
237
|
+
const lastModified = r.querySelector('getlastmodified')?.textContent;
|
|
238
|
+
files.push({ filename, lastModified: lastModified ? new Date(lastModified).toISOString() : null });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
return files.sort((a, b) => (b.lastModified || '').localeCompare(a.lastModified || ''));
|
|
242
|
+
},
|
|
243
|
+
async downloadBackup(providerConfig, filename) {
|
|
244
|
+
const fileUrl = this._getBackupDirUrl(providerConfig) + filename;
|
|
245
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
246
|
+
const res = await wdFetch('GET', fileUrl, authHeaders);
|
|
247
|
+
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
|
248
|
+
const parsed = await res.json();
|
|
249
|
+
if (isEncryptedEnvelope(parsed)) return decryptData(parsed);
|
|
250
|
+
return parsed;
|
|
251
|
+
},
|
|
252
|
+
async deleteBackup(providerConfig, filename) {
|
|
253
|
+
const fileUrl = this._getBackupDirUrl(providerConfig) + filename;
|
|
254
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
255
|
+
const res = await wdFetch('DELETE', fileUrl, authHeaders);
|
|
256
|
+
if (!res.ok && res.status !== 404) throw new Error(`Delete failed: ${res.status}`);
|
|
257
|
+
},
|
|
258
|
+
async testConnection(providerConfig) {
|
|
259
|
+
const dirUrl = this._getBackupDirUrl(providerConfig);
|
|
260
|
+
const authHeaders = this._getAuthHeaders(providerConfig);
|
|
261
|
+
const res = await wdFetch('PROPFIND', dirUrl, authHeaders, undefined, { 'Depth': '0' });
|
|
262
|
+
if (res.status === 200 || res.status === 207 || res.status === 404) return { success: true };
|
|
263
|
+
if (res.status === 401) return { success: false, error: 'Invalid credentials.' };
|
|
264
|
+
return { success: false, error: `Unexpected response: ${res.status}` };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Auto-backup retention limits (app-agnostic constants)
|
|
271
|
+
export const AUTO_BACKUP_RETENTION = { hourly: 24, daily: 30, weekly: 12 };
|
|
272
|
+
export const AUTO_BACKUP_INTERVALS = { hourly: 3600, daily: 86400, weekly: 604800 };
|
package/src/index.js
CHANGED
|
@@ -19,3 +19,14 @@ export {
|
|
|
19
19
|
isEncryptedEnvelope,
|
|
20
20
|
initSessionKey,
|
|
21
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';
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
createAutoBackupDB,
|
|
29
|
+
createAutoBackupProviders,
|
|
30
|
+
AUTO_BACKUP_RETENTION,
|
|
31
|
+
AUTO_BACKUP_INTERVALS,
|
|
32
|
+
} from './autoBackup.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
|
+
}
|