@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
- // 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
+ }