@glance-apps/sync 0.3.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 +1 -1
- package/src/index.js +4 -0
- package/src/providers.js +317 -1
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -19,3 +19,7 @@ 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';
|
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
|
+
}
|