@brftech/filex-core 0.36.0 → 0.37.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/dist/index.d.ts CHANGED
@@ -443,6 +443,95 @@ export declare type AuthConfig = {
443
443
  csrf: string;
444
444
  };
445
445
 
446
+ /**
447
+ * Injection seam. The defaults touch the DOM and the network; tests replace
448
+ * them so the classification logic can be asserted without either.
449
+ */
450
+ export declare interface BrowserProbeDeps {
451
+ loadScript(url: string, timeoutMs: number): Promise<'load' | 'error' | 'timeout'>;
452
+ loadFramed(url: string, timeoutMs: number): Promise<'ready' | 'timeout'>;
453
+ reachable(url: string): Promise<boolean>;
454
+ hasDocsAPI(): boolean;
455
+ pageProtocol(): string;
456
+ now(): number;
457
+ }
458
+
459
+ export declare interface BrowserProbeResult {
460
+ service: string;
461
+ state: BrowserProbeState;
462
+ /** The exact URL attempted — the same one the real viewer loads. */
463
+ url: string;
464
+ /** Milliseconds elapsed. Worth showing: a 6 s "unreachable" is a DNS story. */
465
+ ms: number;
466
+ mechanism: 'script' | 'iframe' | 'none';
467
+ }
468
+
469
+ /**
470
+ * externalReach — "can THIS browser reach the external service?"
471
+ *
472
+ * # Why this exists
473
+ *
474
+ * Three machines must reach three addresses before the Office editor works,
475
+ * and until now only one of them was ever checked:
476
+ *
477
+ * | address | who must reach it | who checked it |
478
+ * | ---------------------- | ------------------------------------- | ---------------- |
479
+ * | the Document Server URL| the **browser** (loads the editor JS) | nobody |
480
+ * | the Document Server URL| the filex process | the Test button |
481
+ * | `FILEX_PUBLIC_URL` | the **document server** (fetch + save)| nobody |
482
+ *
483
+ * So an operator on podman types `http://onlyoffice`, filex reaches it, Test
484
+ * goes green, and the browser cannot resolve that name at all. The editor then
485
+ * fails with the same message as a missing configuration — issue #17, twice.
486
+ *
487
+ * The admin page runs **in the browser that will actually open the editor**,
488
+ * so it can answer the first row directly instead of disclaiming it. That is
489
+ * what this module does.
490
+ *
491
+ * # Mechanism, and why not `fetch`
492
+ *
493
+ * ⚠ A plain `fetch()` is the obvious choice and the wrong one: a document
494
+ * server that works perfectly will usually answer without CORS headers, the
495
+ * promise rejects, and a naive `catch` reports failure for a healthy service.
496
+ *
497
+ * Instead each service is probed **the same way the real viewer loads it**:
498
+ *
499
+ * - **onlyoffice** — a `<script>` pointing at
500
+ * `<base>/web-apps/apps/api/documents/api.js`. Script `load`/`error` is not
501
+ * subject to CORS for load detection, and a successful load leaves
502
+ * `window.DocsAPI.DocEditor` defined, which proves the thing that answered
503
+ * really is a Document Server. (Verified against a live OnlyOffice Docs
504
+ * instance: `load` + `DocsAPI` in ~0.5 s.)
505
+ * - **drawio** — a hidden `<iframe>` at `<base>/?embed=1&proto=json`, which
506
+ * posts `{"event":"init"}` to its parent when the editor is ready. Same
507
+ * handshake `DrawioViewer.vue` uses. (Verified against a live drawio.)
508
+ *
509
+ * Both distinguish **"could not reach"** from **"reached, wrong thing"** with
510
+ * a second signal: a `no-cors` `fetch`, whose promise resolves for any HTTP
511
+ * status and rejects only on a network-level failure. Reached + no editor =
512
+ * `wrong-content`; not reached at all = `unreachable`.
513
+ *
514
+ * Every probe is bounded by a timeout and reports the timeout as its own
515
+ * state, so a black-holed address stops the spinner instead of hanging.
516
+ */
517
+ /** The result of one probe. Each value is a different sentence to the operator. */
518
+ export declare type BrowserProbeState =
519
+ /** Loaded, and it really is the service it claims to be. */
520
+ 'ok'
521
+ /** Something answered at that address, but it is not this service. */
522
+ | 'wrong-content'
523
+ /** Nothing answered: DNS failure, connection refused, blocked. */
524
+ | 'unreachable'
525
+ /** Nothing answered and nothing refused either — a black hole. */
526
+ | 'timeout'
527
+ /** The admin page is HTTPS and the service URL is HTTP; the browser will refuse. */
528
+ | 'blocked-mixed-content'
529
+ /** No URL configured, or a service with no browser-side entry point to probe. */
530
+ | 'skipped';
531
+
532
+ /** The URL each service is probed at — the same one its viewer loads. */
533
+ export declare function browserProbeURL(service: string, base: string): string;
534
+
446
535
  /**
447
536
  * Every command here was RUN against the endpoint on 2026-08-16 with curl
448
537
  * 8.5, lftp 4.9 and rclone 1.73 — including the settings each of them needs to
@@ -659,6 +748,14 @@ export declare function declineEscrowSlot(marker: E2eMarker, when: string): E2eM
659
748
  */
660
749
  export declare function decryptFile(fmk: CryptoKey, data: ArrayBuffer): Promise<ArrayBuffer>;
661
750
 
751
+ /**
752
+ * Default timeout. ⚠ Measured, not guessed: a browser takes ~5 s to give up on
753
+ * a DNS name that does not exist (`http://onlyoffice`), and that case must
754
+ * report `unreachable`, not `timeout`. 10 s leaves room and still bounds a
755
+ * black hole.
756
+ */
757
+ export declare const DEFAULT_PROBE_TIMEOUT_MS = 10000;
758
+
662
759
  export declare const DEFAULT_THEME_ID = "default";
663
760
 
664
761
  /** The default backing store, or null where the platform has none. Reading
@@ -2395,6 +2492,19 @@ monacoEl: HTMLDivElement;
2395
2492
  officeEl: HTMLDivElement;
2396
2493
  }, any>;
2397
2494
 
2495
+ /**
2496
+ * Probe one external service from the browser running this page.
2497
+ *
2498
+ * Returns `skipped` — never a failure — for a service with no URL and for a
2499
+ * service filex does not load in the browser at all (the converter), because
2500
+ * reporting "unreachable" for something the browser never fetches would be a
2501
+ * new lie in place of the old one.
2502
+ */
2503
+ export declare function probeExternalFromBrowser(service: string, base: string | null | undefined, opts?: {
2504
+ timeoutMs?: number;
2505
+ deps?: Partial<BrowserProbeDeps>;
2506
+ }): Promise<BrowserProbeResult>;
2507
+
2398
2508
  export declare interface ProtocolGuide {
2399
2509
  id: string;
2400
2510
  /** Protocol name — a wire protocol is not translated either. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brftech/filex-core",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "filex core — Vue 3 source of truth for the filex file manager (FileExplorer + ConnectionsPanel SFCs, composables, types)",
5
5
  "type": "module",
6
6
  "main": "./dist/filex-core.umd.cjs",
package/src/index.ts CHANGED
@@ -59,6 +59,21 @@ export { useFileApi, resolveEndpoints } from './composables/useFileApi';
59
59
  export type { FileApi, ManagerResponse, PendingOpDto } from './composables/useFileApi';
60
60
  /* bul:s3 — global-search contract types + snippet helpers */
61
61
  export type { GlobalSearchHit, GlobalSearchScope } from './composables/useFileApi';
62
+ /* Browser-side reachability probe for external services (OnlyOffice, drawio).
63
+ Lives here, not in the admin app, because the admin page and every embedder
64
+ need the same answer to "can THIS browser reach the document server?" — see
65
+ lib/externalReach.ts for why a plain fetch() is the wrong mechanism. */
66
+ export {
67
+ probeExternalFromBrowser,
68
+ browserProbeURL,
69
+ DEFAULT_PROBE_TIMEOUT_MS,
70
+ } from './lib/externalReach';
71
+ export type {
72
+ BrowserProbeState,
73
+ BrowserProbeResult,
74
+ BrowserProbeDeps,
75
+ } from './lib/externalReach';
76
+
62
77
  export { snippetSegments, matchedInContent } from './lib/snippet';
63
78
  export type { SnippetSegment, SearchMatched } from './lib/snippet';
64
79
 
@@ -0,0 +1,270 @@
1
+ /**
2
+ * externalReach — "can THIS browser reach the external service?"
3
+ *
4
+ * # Why this exists
5
+ *
6
+ * Three machines must reach three addresses before the Office editor works,
7
+ * and until now only one of them was ever checked:
8
+ *
9
+ * | address | who must reach it | who checked it |
10
+ * | ---------------------- | ------------------------------------- | ---------------- |
11
+ * | the Document Server URL| the **browser** (loads the editor JS) | nobody |
12
+ * | the Document Server URL| the filex process | the Test button |
13
+ * | `FILEX_PUBLIC_URL` | the **document server** (fetch + save)| nobody |
14
+ *
15
+ * So an operator on podman types `http://onlyoffice`, filex reaches it, Test
16
+ * goes green, and the browser cannot resolve that name at all. The editor then
17
+ * fails with the same message as a missing configuration — issue #17, twice.
18
+ *
19
+ * The admin page runs **in the browser that will actually open the editor**,
20
+ * so it can answer the first row directly instead of disclaiming it. That is
21
+ * what this module does.
22
+ *
23
+ * # Mechanism, and why not `fetch`
24
+ *
25
+ * ⚠ A plain `fetch()` is the obvious choice and the wrong one: a document
26
+ * server that works perfectly will usually answer without CORS headers, the
27
+ * promise rejects, and a naive `catch` reports failure for a healthy service.
28
+ *
29
+ * Instead each service is probed **the same way the real viewer loads it**:
30
+ *
31
+ * - **onlyoffice** — a `<script>` pointing at
32
+ * `<base>/web-apps/apps/api/documents/api.js`. Script `load`/`error` is not
33
+ * subject to CORS for load detection, and a successful load leaves
34
+ * `window.DocsAPI.DocEditor` defined, which proves the thing that answered
35
+ * really is a Document Server. (Verified against a live OnlyOffice Docs
36
+ * instance: `load` + `DocsAPI` in ~0.5 s.)
37
+ * - **drawio** — a hidden `<iframe>` at `<base>/?embed=1&proto=json`, which
38
+ * posts `{"event":"init"}` to its parent when the editor is ready. Same
39
+ * handshake `DrawioViewer.vue` uses. (Verified against a live drawio.)
40
+ *
41
+ * Both distinguish **"could not reach"** from **"reached, wrong thing"** with
42
+ * a second signal: a `no-cors` `fetch`, whose promise resolves for any HTTP
43
+ * status and rejects only on a network-level failure. Reached + no editor =
44
+ * `wrong-content`; not reached at all = `unreachable`.
45
+ *
46
+ * Every probe is bounded by a timeout and reports the timeout as its own
47
+ * state, so a black-holed address stops the spinner instead of hanging.
48
+ */
49
+
50
+ /** The result of one probe. Each value is a different sentence to the operator. */
51
+ export type BrowserProbeState =
52
+ /** Loaded, and it really is the service it claims to be. */
53
+ | 'ok'
54
+ /** Something answered at that address, but it is not this service. */
55
+ | 'wrong-content'
56
+ /** Nothing answered: DNS failure, connection refused, blocked. */
57
+ | 'unreachable'
58
+ /** Nothing answered and nothing refused either — a black hole. */
59
+ | 'timeout'
60
+ /** The admin page is HTTPS and the service URL is HTTP; the browser will refuse. */
61
+ | 'blocked-mixed-content'
62
+ /** No URL configured, or a service with no browser-side entry point to probe. */
63
+ | 'skipped';
64
+
65
+ export interface BrowserProbeResult {
66
+ service: string;
67
+ state: BrowserProbeState;
68
+ /** The exact URL attempted — the same one the real viewer loads. */
69
+ url: string;
70
+ /** Milliseconds elapsed. Worth showing: a 6 s "unreachable" is a DNS story. */
71
+ ms: number;
72
+ mechanism: 'script' | 'iframe' | 'none';
73
+ }
74
+
75
+ /**
76
+ * Injection seam. The defaults touch the DOM and the network; tests replace
77
+ * them so the classification logic can be asserted without either.
78
+ */
79
+ export interface BrowserProbeDeps {
80
+ loadScript(url: string, timeoutMs: number): Promise<'load' | 'error' | 'timeout'>;
81
+ loadFramed(url: string, timeoutMs: number): Promise<'ready' | 'timeout'>;
82
+ reachable(url: string): Promise<boolean>;
83
+ hasDocsAPI(): boolean;
84
+ pageProtocol(): string;
85
+ now(): number;
86
+ }
87
+
88
+ const ONLYOFFICE_API_PATH = '/web-apps/apps/api/documents/api.js';
89
+ const DRAWIO_EMBED_QUERY = '/?embed=1&proto=json';
90
+
91
+ /**
92
+ * Default timeout. ⚠ Measured, not guessed: a browser takes ~5 s to give up on
93
+ * a DNS name that does not exist (`http://onlyoffice`), and that case must
94
+ * report `unreachable`, not `timeout`. 10 s leaves room and still bounds a
95
+ * black hole.
96
+ */
97
+ export const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
98
+
99
+ function trimBase(base: string): string {
100
+ return base.trim().replace(/\/+$/, '');
101
+ }
102
+
103
+ function domLoadScript(url: string, timeoutMs: number): Promise<'load' | 'error' | 'timeout'> {
104
+ return new Promise((resolve) => {
105
+ const el = document.createElement('script');
106
+ let done = false;
107
+ const finish = (r: 'load' | 'error' | 'timeout') => {
108
+ if (done) return;
109
+ done = true;
110
+ clearTimeout(tid);
111
+ el.remove();
112
+ resolve(r);
113
+ };
114
+ const tid = setTimeout(() => finish('timeout'), timeoutMs);
115
+ el.async = true;
116
+ el.src = url;
117
+ el.onload = () => finish('load');
118
+ el.onerror = () => finish('error');
119
+ document.head.appendChild(el);
120
+ });
121
+ }
122
+
123
+ function domLoadFramed(url: string, timeoutMs: number): Promise<'ready' | 'timeout'> {
124
+ return new Promise((resolve) => {
125
+ let origin: string | null = null;
126
+ try {
127
+ origin = new URL(url, window.location.href).origin;
128
+ } catch {
129
+ origin = null;
130
+ }
131
+ const frame = document.createElement('iframe');
132
+ frame.setAttribute('aria-hidden', 'true');
133
+ frame.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;border:0';
134
+ let done = false;
135
+ const finish = (r: 'ready' | 'timeout') => {
136
+ if (done) return;
137
+ done = true;
138
+ clearTimeout(tid);
139
+ window.removeEventListener('message', onMessage);
140
+ frame.remove();
141
+ resolve(r);
142
+ };
143
+ const onMessage = (e: MessageEvent) => {
144
+ // ⚠ Origin check first: any page may postMessage to us, and a probe that
145
+ // accepts an unfiltered message would report a healthy drawio because
146
+ // something else on the page said so.
147
+ if (origin && e.origin !== origin) return;
148
+ let data: unknown = e.data;
149
+ if (typeof data === 'string') {
150
+ try {
151
+ data = JSON.parse(data);
152
+ } catch {
153
+ return;
154
+ }
155
+ }
156
+ if (data && typeof data === 'object' && (data as { event?: string }).event === 'init') {
157
+ finish('ready');
158
+ }
159
+ };
160
+ window.addEventListener('message', onMessage);
161
+ const tid = setTimeout(() => finish('timeout'), timeoutMs);
162
+ frame.src = url;
163
+ document.body.appendChild(frame);
164
+ });
165
+ }
166
+
167
+ async function domReachable(url: string): Promise<boolean> {
168
+ try {
169
+ // `no-cors` gives an opaque response we cannot read — which is fine, the
170
+ // only question is whether the request completed at all. It resolves for
171
+ // any HTTP status (including 404) and rejects on a network failure, which
172
+ // is exactly the "reached, wrong thing" vs "could not reach" split.
173
+ await fetch(url, { mode: 'no-cors', cache: 'no-store' });
174
+ return true;
175
+ } catch {
176
+ return false;
177
+ }
178
+ }
179
+
180
+ const domDeps: BrowserProbeDeps = {
181
+ loadScript: domLoadScript,
182
+ loadFramed: domLoadFramed,
183
+ reachable: domReachable,
184
+ hasDocsAPI: () =>
185
+ !!(window as unknown as { DocsAPI?: { DocEditor?: unknown } }).DocsAPI?.DocEditor,
186
+ pageProtocol: () => window.location.protocol,
187
+ now: () => (typeof performance !== 'undefined' ? performance.now() : Date.now()),
188
+ };
189
+
190
+ /** The URL each service is probed at — the same one its viewer loads. */
191
+ export function browserProbeURL(service: string, base: string): string {
192
+ const b = trimBase(base);
193
+ if (!b) return '';
194
+ if (service === 'onlyoffice') return b + ONLYOFFICE_API_PATH;
195
+ if (service === 'drawio') return b + DRAWIO_EMBED_QUERY;
196
+ return '';
197
+ }
198
+
199
+ /**
200
+ * Probe one external service from the browser running this page.
201
+ *
202
+ * Returns `skipped` — never a failure — for a service with no URL and for a
203
+ * service filex does not load in the browser at all (the converter), because
204
+ * reporting "unreachable" for something the browser never fetches would be a
205
+ * new lie in place of the old one.
206
+ */
207
+ export async function probeExternalFromBrowser(
208
+ service: string,
209
+ base: string | null | undefined,
210
+ opts: { timeoutMs?: number; deps?: Partial<BrowserProbeDeps> } = {},
211
+ ): Promise<BrowserProbeResult> {
212
+ const deps: BrowserProbeDeps = { ...domDeps, ...(opts.deps ?? {}) };
213
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
214
+ const url = browserProbeURL(service, base ?? '');
215
+ const mechanism: BrowserProbeResult['mechanism'] =
216
+ service === 'onlyoffice' ? 'script' : service === 'drawio' ? 'iframe' : 'none';
217
+ if (!url) {
218
+ return { service, state: 'skipped', url: '', ms: 0, mechanism };
219
+ }
220
+
221
+ // ⚠ Pre-empt mixed content rather than reporting it as "unreachable". The
222
+ // browser blocks an http:// subresource on an https:// page before a packet
223
+ // leaves, and "unreachable" would send the operator to check firewalls.
224
+ if (deps.pageProtocol() === 'https:' && url.startsWith('http://')) {
225
+ return { service, state: 'blocked-mixed-content', url, ms: 0, mechanism };
226
+ }
227
+
228
+ const t0 = deps.now();
229
+ const elapsed = () => Math.round(deps.now() - t0);
230
+
231
+ if (mechanism === 'script') {
232
+ const ev = await deps.loadScript(url, timeoutMs);
233
+ if (ev === 'timeout') return { service, state: 'timeout', url, ms: elapsed(), mechanism };
234
+ if (ev === 'load') {
235
+ // Loaded AND it defined the global only a Document Server defines.
236
+ return {
237
+ service,
238
+ state: deps.hasDocsAPI() ? 'ok' : 'wrong-content',
239
+ url,
240
+ ms: elapsed(),
241
+ mechanism,
242
+ };
243
+ }
244
+ const reached = await deps.reachable(url);
245
+ return {
246
+ service,
247
+ state: reached ? 'wrong-content' : 'unreachable',
248
+ url,
249
+ ms: elapsed(),
250
+ mechanism,
251
+ };
252
+ }
253
+
254
+ if (mechanism === 'iframe') {
255
+ const ev = await deps.loadFramed(url, timeoutMs);
256
+ if (ev === 'ready') return { service, state: 'ok', url, ms: elapsed(), mechanism };
257
+ // No handshake. An iframe fires `load` for error pages too, so the frame
258
+ // itself cannot tell us why — ask the network instead.
259
+ const reached = await deps.reachable(url);
260
+ return {
261
+ service,
262
+ state: reached ? 'wrong-content' : 'unreachable',
263
+ url,
264
+ ms: elapsed(),
265
+ mechanism,
266
+ };
267
+ }
268
+
269
+ return { service, state: 'skipped', url: '', ms: 0, mechanism };
270
+ }
@@ -27,6 +27,7 @@ import type { LocaleCode } from '../types/ExplorerConfig';
27
27
  import Modal from './Modal.vue';
28
28
  import { ensureMonaco, getMonaco, ensureHighlight } from '../composables/useMonacoLoader';
29
29
  import { useLocale } from '../composables/useLocale';
30
+ import { browserProbeURL } from '../lib/externalReach';
30
31
 
31
32
  const props = defineProps<{
32
33
  open: boolean;
@@ -895,7 +896,10 @@ function loadOnlyOfficeScript(base: string): Promise<void> {
895
896
  }
896
897
  const script = document.createElement('script');
897
898
  script.id = ONLYOFFICE_SCRIPT_ID;
898
- script.src = `${base.replace(/\/$/, '')}/web-apps/apps/api/documents/api.js`;
899
+ // Same URL the admin page's browser probe attempts, from the same
900
+ // helper — a probe that tested a different address than the editor loads
901
+ // would be the old lie wearing a new badge.
902
+ script.src = browserProbeURL('onlyoffice', base);
899
903
  script.async = true;
900
904
  script.onload = () => resolve();
901
905
  script.onerror = () => reject(new Error('OnlyOffice api.js load failed'));