@brftech/filex-core 0.19.0 → 0.20.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.
Files changed (39) hide show
  1. package/README.md +34 -4
  2. package/dist/filex-core.js +10626 -6501
  3. package/dist/filex-core.js.map +1 -1
  4. package/dist/filex-core.umd.cjs +94 -63
  5. package/dist/filex-core.umd.cjs.map +1 -1
  6. package/dist/index.d.ts +1026 -7
  7. package/dist/style.css +1 -1
  8. package/package.json +3 -3
  9. package/src/FileExplorer.vue +103 -68
  10. package/src/components/ConnectionGuideView.vue +333 -0
  11. package/src/components/ConnectionsPanel.vue +912 -0
  12. package/src/components/NFSExportsPanel.vue +283 -0
  13. package/src/components/S3KeysPanel.vue +381 -0
  14. package/src/components/SSHKeysPanel.vue +222 -0
  15. package/src/components/StorageFields.vue +362 -0
  16. package/src/components/TokensPanel.vue +191 -0
  17. package/src/components/UploadProgress.vue +5 -1
  18. package/src/composables/useConnections.ts +271 -0
  19. package/src/composables/useFileApi.ts +15 -2
  20. package/src/composables/useNFSExports.ts +148 -0
  21. package/src/composables/useS3Keys.ts +175 -0
  22. package/src/composables/useSSHKeys.ts +119 -0
  23. package/src/composables/useThumbs.ts +1 -1
  24. package/src/composables/useTokens.ts +121 -0
  25. package/src/composables/useUploadChunked.ts +433 -164
  26. package/src/index.ts +77 -2
  27. package/src/lib/connectionGuides.ts +1279 -0
  28. package/src/lib/realtime.ts +1 -1
  29. package/src/lib/uploadResume.ts +157 -0
  30. package/src/locales/en.ts +413 -0
  31. package/src/locales/tr.ts +416 -0
  32. package/src/modals/ConvertModal.vue +1 -1
  33. package/src/styles/base.css +12 -12
  34. package/src/types/Connections.ts +122 -0
  35. package/src/types/ExplorerConfig.ts +23 -2
  36. package/src/types/NFSExports.ts +47 -0
  37. package/src/types/S3Keys.ts +55 -0
  38. package/src/types/SSHKeys.ts +54 -0
  39. package/src/types/Tokens.ts +39 -0
@@ -0,0 +1,1279 @@
1
+ /**
2
+ * connectionGuides — "how do I connect to this thing?", generated from the
3
+ * live deployment instead of written as prose.
4
+ *
5
+ * A published document says `https://fm.example.com/dav/` and leaves the
6
+ * reader to substitute three values, one of which (their own username) they
7
+ * usually get wrong. What ships here is the real host, the real storage
8
+ * name and the caller's own credential, with a copy button — which is the
9
+ * difference between a doc and a working paste. No surveyed product
10
+ * (Backblaze, R2, Storj, MinIO, Garage) does this; it is the cheapest win
11
+ * on the whole surface.
12
+ *
13
+ * Shape of the thing: a protocol is a `GuideBuilder` registered in
14
+ * `GUIDE_BUILDERS`. WebDAV is built here because WebDAV is what filex
15
+ * serves today; S3 and SFTP are a builder each and a registry line when
16
+ * their servers land — no second implementation, no second UI.
17
+ *
18
+ * The commands are not invented. They are the ones verified on 2026-08-14
19
+ * and written down in `docs/WEBDAV.md` and §13.5 of the write-path
20
+ * handover, including the three Windows registry limits that otherwise
21
+ * look exactly like filex bugs.
22
+ */
23
+
24
+ export type Translate = (key: string, vars?: Record<string, string | number>) => string;
25
+
26
+ /** Everything a guide is allowed to know about the deployment. */
27
+ export interface GuideContext {
28
+ /** Absolute origin of the server, e.g. `https://fm.example.com`. */
29
+ origin: string;
30
+ /** The caller's own account e-mail — the username on every protocol. */
31
+ user: string;
32
+ /** Storage names the caller may see. */
33
+ storages: string[];
34
+ /** The storage the page is currently focused on, when any. */
35
+ storage?: string;
36
+ /**
37
+ * The S3 endpoint, as the server computes it.
38
+ *
39
+ * ⚠ NOT derived from `origin` here. With a dedicated host the endpoint is
40
+ * that host's root; without one it lives under `/s3`, and a client pointed
41
+ * at the application root reaches the web app — rclone reported
42
+ * "XML syntax error on line 10" against an HTML redirect page. The server
43
+ * returns this with every key, and the guide repeats it verbatim.
44
+ */
45
+ s3Endpoint?: string;
46
+ /** True when clients must be told to force path-style addressing. */
47
+ s3PathStyle?: boolean;
48
+ /** The access key id to write into the examples, when the caller has one. */
49
+ s3AccessKeyID?: string;
50
+ /** The freshly minted secret, shown only while the user is looking at it. */
51
+ s3Secret?: string;
52
+ /** The SFTP endpoint, as the server reports it. */
53
+ sftpHost?: string;
54
+ sftpPort?: number;
55
+ /** False when the operator has the endpoint switched off. */
56
+ sftpEnabled?: boolean;
57
+ /** The login name a client must use — the username when there is one. */
58
+ sftpLogin?: string;
59
+ /** True when the account has at least one usable key registered. */
60
+ sftpHasKey?: boolean;
61
+ /** The FTPS endpoint, as the server reports it. */
62
+ ftpsHost?: string;
63
+ ftpsPort?: number;
64
+ ftpsEnabled?: boolean;
65
+ /** The passive data-port range, which the client's firewall must allow. */
66
+ ftpsPasvMin?: number;
67
+ ftpsPasvMax?: number;
68
+ /** True when the server is using a self-signed certificate. */
69
+ ftpsSelfSigned?: boolean;
70
+ /** The NFS endpoint, as the server reports it. */
71
+ nfsHost?: string;
72
+ nfsPort?: number;
73
+ nfsEnabled?: boolean;
74
+ /** The freshly minted export path — the credential, shown once. */
75
+ nfsPath?: string;
76
+ /** True when the active export refuses writes. */
77
+ nfsReadOnly?: boolean;
78
+ }
79
+
80
+ export type GuideBlockKind = 'text' | 'steps' | 'code' | 'note' | 'warn';
81
+
82
+ export interface GuideBlock {
83
+ kind: GuideBlockKind;
84
+ /** Prose for `text` / `note` / `warn`. */
85
+ text?: string;
86
+ /** Ordered instructions for `steps`. */
87
+ steps?: string[];
88
+ /** Copyable payload for `code`. */
89
+ code?: string;
90
+ /** Syntax hint / file name shown above a code block. */
91
+ caption?: string;
92
+ }
93
+
94
+ export interface GuideClient {
95
+ id: string;
96
+ /** Product name — never translated ("Cyberduck" is Cyberduck). */
97
+ name: string;
98
+ platform: 'windows' | 'macos' | 'linux' | 'any';
99
+ blocks: GuideBlock[];
100
+ }
101
+
102
+ /** One connection fact, rendered as a copyable row. */
103
+ export interface GuideFact {
104
+ label: string;
105
+ value: string;
106
+ hint?: string;
107
+ /** Rendered as a hint rather than a value — filex never has the
108
+ * plaintext of your password and must not pretend otherwise. */
109
+ placeholderOnly?: boolean;
110
+ }
111
+
112
+ export interface ProtocolGuide {
113
+ id: string;
114
+ /** Protocol name — a wire protocol is not translated either. */
115
+ name: string;
116
+ summary: string;
117
+ /** Server + credential, filled in from this deployment. */
118
+ facts: GuideFact[];
119
+ clients: GuideClient[];
120
+ notes: GuideBlock[];
121
+ }
122
+
123
+ export type GuideBuilder = (ctx: GuideContext, t: Translate) => ProtocolGuide;
124
+
125
+ /** `https://fm.example.com` → `fm.example.com`; anything unparseable comes back whole. */
126
+ export function hostOf(origin: string): string {
127
+ try {
128
+ return new URL(origin).host;
129
+ } catch {
130
+ return origin.replace(/^https?:\/\//i, '').replace(/\/+$/, '');
131
+ }
132
+ }
133
+
134
+ /** True when the deployment is not on TLS — several clients refuse that,
135
+ * and Windows refuses it silently, which is worse. */
136
+ export function isPlainHttp(origin: string): boolean {
137
+ return /^http:\/\//i.test(origin);
138
+ }
139
+
140
+ /**
141
+ * The username a client should be given. Falls back to a placeholder so a
142
+ * guide rendered before `/api/auth/me` answers is still readable rather
143
+ * than showing `undefined` in the middle of a command line.
144
+ */
145
+ function userOf(ctx: GuideContext, t: Translate): string {
146
+ return ctx.user || t('conn.guide.userPlaceholder');
147
+ }
148
+
149
+ // ─────────────────────────────────────────────────────────────────────
150
+ // WebDAV
151
+ // ─────────────────────────────────────────────────────────────────────
152
+
153
+ export const buildWebdavGuide: GuideBuilder = (ctx, t) => {
154
+ const origin = ctx.origin.replace(/\/+$/, '');
155
+ const root = `${origin}/dav/`;
156
+ const target = ctx.storage ? `${origin}/dav/${ctx.storage}/` : root;
157
+ const user = userOf(ctx, t);
158
+ const secret = t('conn.guide.secretPlaceholder');
159
+
160
+ const facts: GuideFact[] = [
161
+ { label: t('conn.guide.fact.url'), value: target, hint: ctx.storage ? t('conn.guide.fact.urlStorageHint', { storage: ctx.storage }) : t('conn.guide.fact.urlRootHint') },
162
+ { label: t('conn.guide.fact.user'), value: user, hint: t('conn.guide.webdav.userHint') },
163
+ { label: t('conn.guide.fact.password'), value: secret, hint: t('conn.guide.webdav.passwordHint'), placeholderOnly: true },
164
+ ];
165
+
166
+ const clients: GuideClient[] = [
167
+ {
168
+ id: 'windows',
169
+ name: 'Windows Explorer',
170
+ platform: 'windows',
171
+ blocks: [
172
+ {
173
+ kind: 'steps',
174
+ steps: [
175
+ t('conn.guide.webdav.win.s1'),
176
+ t('conn.guide.webdav.win.s2', { url: target }),
177
+ t('conn.guide.webdav.win.s3'),
178
+ ],
179
+ },
180
+ {
181
+ kind: 'code',
182
+ caption: t('conn.guide.webdav.win.cmdCaption'),
183
+ code: `net use Z: "${target}" /user:${user} ${secret} /persistent:yes`,
184
+ },
185
+ {
186
+ kind: 'warn',
187
+ text: t('conn.guide.webdav.win.limits'),
188
+ },
189
+ {
190
+ kind: 'code',
191
+ caption: t('conn.guide.webdav.win.regCaption'),
192
+ code: [
193
+ 'reg add "HKLM\\SYSTEM\\CurrentControlSet\\Services\\WebClient\\Parameters" /v FileSizeLimitInBytes /t REG_DWORD /d 4294967295 /f',
194
+ 'reg add "HKLM\\SYSTEM\\CurrentControlSet\\Services\\WebClient\\Parameters" /v FileAttributesLimitInBytes /t REG_DWORD /d 20000000 /f',
195
+ 'net stop webclient && net start webclient',
196
+ ].join('\n'),
197
+ },
198
+ { kind: 'note', text: t('conn.guide.webdav.win.https') },
199
+ { kind: 'note', text: t('conn.guide.webdav.win.persist') },
200
+ { kind: 'note', text: t('conn.guide.webdav.win.service') },
201
+ ],
202
+ },
203
+ {
204
+ id: 'macos',
205
+ name: 'macOS Finder',
206
+ platform: 'macos',
207
+ blocks: [
208
+ {
209
+ kind: 'steps',
210
+ steps: [
211
+ t('conn.guide.webdav.mac.s1'),
212
+ t('conn.guide.webdav.mac.s2', { url: target }),
213
+ t('conn.guide.webdav.mac.s3'),
214
+ ],
215
+ },
216
+ { kind: 'note', text: t('conn.guide.webdav.mac.note') },
217
+ ],
218
+ },
219
+ {
220
+ id: 'linux',
221
+ name: 'Linux (davfs2 / GNOME / KDE)',
222
+ platform: 'linux',
223
+ blocks: [
224
+ {
225
+ kind: 'code',
226
+ caption: t('conn.guide.webdav.linux.mountCaption'),
227
+ code: [
228
+ `sudo mount -t davfs ${target} /mnt/filex`,
229
+ '',
230
+ `# ${t('conn.guide.webdav.linux.gvfsComment')}`,
231
+ `gio mount ${target.replace(/^https:/i, 'davs:').replace(/^http:/i, 'dav:')}`,
232
+ ].join('\n'),
233
+ },
234
+ {
235
+ kind: 'warn',
236
+ text: t('conn.guide.webdav.linux.locks'),
237
+ },
238
+ {
239
+ kind: 'code',
240
+ caption: '/etc/davfs2/davfs2.conf',
241
+ code: 'use_locks 0',
242
+ },
243
+ ],
244
+ },
245
+ {
246
+ id: 'rclone',
247
+ name: 'rclone',
248
+ platform: 'any',
249
+ blocks: [
250
+ {
251
+ kind: 'code',
252
+ caption: t('conn.guide.webdav.rclone.obscureCaption'),
253
+ code: `rclone obscure "${secret}"`,
254
+ },
255
+ {
256
+ kind: 'code',
257
+ caption: '~/.config/rclone/rclone.conf',
258
+ code: [
259
+ '[filex]',
260
+ 'type = webdav',
261
+ `url = ${origin}/dav`,
262
+ 'vendor = other',
263
+ `user = ${user}`,
264
+ `pass = ${t('conn.guide.webdav.rclone.passPlaceholder')}`,
265
+ ].join('\n'),
266
+ },
267
+ {
268
+ kind: 'code',
269
+ caption: t('conn.guide.webdav.rclone.useCaption'),
270
+ code: [
271
+ 'rclone lsd filex:',
272
+ ctx.storage ? `rclone lsl filex:${ctx.storage}` : 'rclone lsl filex:<storage>',
273
+ ctx.storage
274
+ ? `rclone copy ./local filex:${ctx.storage}/backup`
275
+ : 'rclone copy ./local filex:<storage>/backup',
276
+ 'rclone mount filex: /mnt/filex',
277
+ ].join('\n'),
278
+ },
279
+ ],
280
+ },
281
+ {
282
+ id: 'cyberduck',
283
+ name: 'Cyberduck / Mountain Duck',
284
+ platform: 'any',
285
+ blocks: [
286
+ {
287
+ kind: 'steps',
288
+ steps: [
289
+ t('conn.guide.webdav.duck.s1'),
290
+ t('conn.guide.webdav.duck.s2', { host: hostOf(origin) }),
291
+ t('conn.guide.webdav.duck.s3', { path: ctx.storage ? `/dav/${ctx.storage}/` : '/dav/' }),
292
+ t('conn.guide.webdav.duck.s4', { user }),
293
+ ],
294
+ },
295
+ ],
296
+ },
297
+ ];
298
+
299
+ const notes: GuideBlock[] = [
300
+ { kind: 'note', text: t('conn.guide.webdav.note.delete') },
301
+ { kind: 'note', text: t('conn.guide.webdav.note.locks') },
302
+ { kind: 'note', text: t('conn.guide.webdav.note.permissions') },
303
+ ];
304
+
305
+ if (isPlainHttp(origin)) {
306
+ // Not decoration: over plain HTTP Windows refuses to send Basic
307
+ // credentials at all, and says nothing useful about why.
308
+ notes.unshift({ kind: 'warn', text: t('conn.guide.webdav.note.http') });
309
+ }
310
+
311
+ return {
312
+ id: 'webdav',
313
+ name: 'WebDAV',
314
+ summary: t('conn.guide.webdav.summary'),
315
+ facts,
316
+ clients,
317
+ notes,
318
+ };
319
+ };
320
+
321
+ // ─────────────────────────────────────────────────────────────────────
322
+ // S3
323
+ // ─────────────────────────────────────────────────────────────────────
324
+
325
+ /**
326
+ * Every command here was RUN against the endpoint on 2026-08-16, not copied
327
+ * from a vendor page: rclone 1.73.5, aws-cli 2.11.6, restic 0.19.1, mc
328
+ * 2025-08-13 and s3fs 1.93 each did a full round trip, and the flags below are
329
+ * the ones those runs actually needed. Five of the endpoint's bugs were found
330
+ * that way — a guide assembled from documentation would have shipped them.
331
+ */
332
+ export const buildS3Guide: GuideBuilder = (ctx, t) => {
333
+ const endpoint = (ctx.s3Endpoint || `${ctx.origin.replace(/\/+$/, '')}/s3`).replace(/\/+$/, '');
334
+ const bucket = ctx.storage || ctx.storages[0] || '<bucket>';
335
+ const akid = ctx.s3AccessKeyID || t('conn.guide.s3.keyPlaceholder');
336
+ const secret = ctx.s3Secret || t('conn.guide.s3.secretPlaceholder');
337
+ const pathStyle = ctx.s3PathStyle !== false;
338
+ const host = hostOf(endpoint);
339
+
340
+ const facts: GuideFact[] = [
341
+ { label: t('conn.guide.s3.fact.endpoint'), value: endpoint, hint: t('conn.guide.s3.fact.endpointHint') },
342
+ { label: t('conn.guide.s3.fact.bucket'), value: bucket, hint: t('conn.guide.s3.fact.bucketHint') },
343
+ { label: t('conn.guide.s3.fact.key'), value: akid, hint: t('conn.guide.s3.fact.keyHint') },
344
+ {
345
+ label: t('conn.guide.s3.fact.secret'),
346
+ value: secret,
347
+ hint: t('conn.guide.s3.fact.secretHint'),
348
+ // ⚠ Only real right after minting. filex stores the secret sealed and
349
+ // cannot show it again, and a guide that printed something secret-shaped
350
+ // would be teaching a value that authenticates as nothing.
351
+ placeholderOnly: !ctx.s3Secret,
352
+ },
353
+ { label: t('conn.guide.s3.fact.region'), value: 'us-east-1', hint: t('conn.guide.s3.fact.regionHint') },
354
+ {
355
+ label: t('conn.guide.s3.fact.addressing'),
356
+ value: pathStyle ? t('conn.guide.s3.fact.pathStyle') : t('conn.guide.s3.fact.virtualHosted'),
357
+ hint: pathStyle ? t('conn.guide.s3.fact.pathStyleHint') : t('conn.guide.s3.fact.virtualHostedHint'),
358
+ },
359
+ ];
360
+
361
+ const clients: GuideClient[] = [
362
+ {
363
+ id: 'rclone',
364
+ name: 'rclone',
365
+ platform: 'any',
366
+ blocks: [
367
+ {
368
+ kind: 'code',
369
+ caption: '~/.config/rclone/rclone.conf',
370
+ code: [
371
+ '[filex]',
372
+ 'type = s3',
373
+ 'provider = Other',
374
+ `endpoint = ${endpoint}`,
375
+ `access_key_id = ${akid}`,
376
+ `secret_access_key = ${secret}`,
377
+ 'region = us-east-1',
378
+ ...(pathStyle ? ['force_path_style = true'] : []),
379
+ ].join('\n'),
380
+ },
381
+ {
382
+ kind: 'code',
383
+ caption: t('conn.guide.s3.rclone.useCaption'),
384
+ code: [
385
+ 'rclone lsd filex:',
386
+ `rclone copy ./local filex:${bucket}/backup -P`,
387
+ `rclone sync ./local filex:${bucket}/backup`,
388
+ `rclone mount filex:${bucket} /mnt/filex`,
389
+ ].join('\n'),
390
+ },
391
+ { kind: 'note', text: t('conn.guide.s3.rclone.mtime') },
392
+ ],
393
+ },
394
+ {
395
+ id: 'awscli',
396
+ name: 'AWS CLI',
397
+ platform: 'any',
398
+ blocks: [
399
+ {
400
+ kind: 'code',
401
+ caption: t('conn.guide.s3.aws.configureCaption'),
402
+ code: [
403
+ 'aws configure set aws_access_key_id ' + akid,
404
+ 'aws configure set aws_secret_access_key ' + secret,
405
+ 'aws configure set region us-east-1',
406
+ ].join('\n'),
407
+ },
408
+ {
409
+ kind: 'code',
410
+ caption: t('conn.guide.s3.aws.useCaption'),
411
+ code: [
412
+ `aws --endpoint-url ${endpoint} s3 ls`,
413
+ `aws --endpoint-url ${endpoint} s3 cp ./file.pdf s3://${bucket}/file.pdf`,
414
+ `aws --endpoint-url ${endpoint} s3 sync ./local s3://${bucket}/backup`,
415
+ `aws --endpoint-url ${endpoint} s3 presign s3://${bucket}/file.pdf --expires-in 300`,
416
+ ].join('\n'),
417
+ },
418
+ ...(pathStyle
419
+ ? [
420
+ {
421
+ kind: 'note' as GuideBlockKind,
422
+ text: t('conn.guide.s3.aws.pathStyle'),
423
+ },
424
+ {
425
+ kind: 'code' as GuideBlockKind,
426
+ caption: '~/.aws/config',
427
+ code: ['[default]', 's3 =', ' addressing_style = path'].join('\n'),
428
+ },
429
+ ]
430
+ : []),
431
+ ],
432
+ },
433
+ {
434
+ id: 'restic',
435
+ name: 'restic',
436
+ platform: 'any',
437
+ blocks: [
438
+ {
439
+ kind: 'code',
440
+ caption: t('conn.guide.s3.restic.envCaption'),
441
+ code: [
442
+ `export AWS_ACCESS_KEY_ID=${akid}`,
443
+ `export AWS_SECRET_ACCESS_KEY=${secret}`,
444
+ `export RESTIC_REPOSITORY="s3:${endpoint}/${bucket}/restic"`,
445
+ 'export RESTIC_PASSWORD=…',
446
+ ].join('\n'),
447
+ },
448
+ {
449
+ kind: 'code',
450
+ caption: t('conn.guide.s3.restic.useCaption'),
451
+ code: [
452
+ `restic init${pathStyle ? ' -o s3.bucket-lookup=path' : ''}`,
453
+ `restic backup ~/Documents${pathStyle ? ' -o s3.bucket-lookup=path' : ''}`,
454
+ `restic check --read-data${pathStyle ? ' -o s3.bucket-lookup=path' : ''}`,
455
+ `restic forget --keep-daily 7 --prune${pathStyle ? ' -o s3.bucket-lookup=path' : ''}`,
456
+ ].join('\n'),
457
+ },
458
+ { kind: 'note', text: t('conn.guide.s3.restic.verified') },
459
+ ],
460
+ },
461
+ {
462
+ id: 'mc',
463
+ name: 'MinIO Client (mc)',
464
+ platform: 'any',
465
+ blocks: [
466
+ {
467
+ kind: 'code',
468
+ caption: t('conn.guide.s3.mc.aliasCaption'),
469
+ code: `mc alias set filex ${endpoint} ${akid} ${secret} --api S3v4${pathStyle ? ' --path on' : ''}`,
470
+ },
471
+ {
472
+ kind: 'code',
473
+ caption: t('conn.guide.s3.mc.useCaption'),
474
+ code: [
475
+ 'mc ls filex',
476
+ `mc cp ./file.pdf filex/${bucket}/file.pdf`,
477
+ `mc mirror ./local filex/${bucket}/backup`,
478
+ `mc du filex/${bucket}`,
479
+ ].join('\n'),
480
+ },
481
+ ],
482
+ },
483
+ {
484
+ id: 's3fs',
485
+ name: 's3fs (Linux / macOS)',
486
+ platform: 'linux',
487
+ blocks: [
488
+ {
489
+ kind: 'code',
490
+ caption: t('conn.guide.s3.s3fs.credsCaption'),
491
+ code: [
492
+ `echo "${akid}:${secret}" > ~/.passwd-s3fs`,
493
+ 'chmod 600 ~/.passwd-s3fs',
494
+ ].join('\n'),
495
+ },
496
+ {
497
+ kind: 'code',
498
+ caption: t('conn.guide.s3.s3fs.mountCaption'),
499
+ code: [
500
+ `s3fs ${bucket} /mnt/filex \\`,
501
+ ' -o passwd_file=~/.passwd-s3fs \\',
502
+ ` -o url=${endpoint} \\`,
503
+ ...(pathStyle ? [' -o use_path_request_style'] : []),
504
+ ].join('\n'),
505
+ },
506
+ { kind: 'note', text: t('conn.guide.s3.s3fs.note') },
507
+ ],
508
+ },
509
+ {
510
+ id: 'cyberduck',
511
+ name: 'Cyberduck / Mountain Duck',
512
+ platform: 'any',
513
+ blocks: [
514
+ {
515
+ kind: 'steps',
516
+ steps: [
517
+ t('conn.guide.s3.duck.s1'),
518
+ t('conn.guide.s3.duck.s2', { host }),
519
+ t('conn.guide.s3.duck.s3', { key: akid }),
520
+ t('conn.guide.s3.duck.s4'),
521
+ ],
522
+ },
523
+ ...(pathStyle ? [{ kind: 'warn' as GuideBlockKind, text: t('conn.guide.s3.duck.pathStyle') }] : []),
524
+ ],
525
+ },
526
+ {
527
+ id: 'sdk',
528
+ name: 'SDK (Go / Python / JS)',
529
+ platform: 'any',
530
+ blocks: [
531
+ {
532
+ kind: 'code',
533
+ caption: 'boto3',
534
+ code: [
535
+ 'import boto3',
536
+ 's3 = boto3.client(',
537
+ ' "s3",',
538
+ ` endpoint_url="${endpoint}",`,
539
+ ` aws_access_key_id="${akid}",`,
540
+ ` aws_secret_access_key="${secret}",`,
541
+ ' region_name="us-east-1",',
542
+ ...(pathStyle
543
+ ? [
544
+ ' config=boto3.session.Config(s3={"addressing_style": "path"}),',
545
+ ]
546
+ : []),
547
+ ')',
548
+ `print([o["Key"] for o in s3.list_objects_v2(Bucket="${bucket}").get("Contents", [])])`,
549
+ ].join('\n'),
550
+ },
551
+ { kind: 'note', text: t('conn.guide.s3.sdk.note') },
552
+ ],
553
+ },
554
+ ];
555
+
556
+ const notes: GuideBlock[] = [
557
+ { kind: 'note', text: t('conn.guide.s3.note.buckets') },
558
+ { kind: 'note', text: t('conn.guide.s3.note.permissions') },
559
+ { kind: 'note', text: t('conn.guide.s3.note.trash') },
560
+ { kind: 'note', text: t('conn.guide.s3.note.mtime') },
561
+ ];
562
+
563
+ if (pathStyle) {
564
+ // First, because it is the one setting that makes a current SDK fail with
565
+ // a DNS error that names neither filex nor the cause.
566
+ notes.unshift({ kind: 'warn', text: t('conn.guide.s3.note.pathStyle') });
567
+ }
568
+ if (isPlainHttp(endpoint)) {
569
+ notes.unshift({ kind: 'warn', text: t('conn.guide.s3.note.http') });
570
+ }
571
+
572
+ return {
573
+ id: 's3',
574
+ name: 'S3',
575
+ summary: t('conn.guide.s3.summary'),
576
+ facts,
577
+ clients,
578
+ notes,
579
+ };
580
+ };
581
+
582
+
583
+ // ─────────────────────────────────────────────────────────────────────
584
+ // SFTP
585
+ // ─────────────────────────────────────────────────────────────────────
586
+
587
+ /**
588
+ * Every command here was RUN against the endpoint on 2026-08-16 with OpenSSH
589
+ * 9.6 and rclone 1.73 — including the two settings rclone needs because filex
590
+ * has no shell, which are the difference between a clean run and a pile of
591
+ * warnings about a missing `md5sum`.
592
+ */
593
+ export const buildSftpGuide: GuideBuilder = (ctx, t) => {
594
+ const host = ctx.sftpHost || hostOf(ctx.origin);
595
+ const port = ctx.sftpPort || 2022;
596
+ const user = ctx.sftpLogin || userOf(ctx, t);
597
+ const target = ctx.storage ? `/${ctx.storage}` : '/<storage>';
598
+ const portFlag = port === 22 ? '' : ` -P ${port}`;
599
+
600
+ const facts: GuideFact[] = [
601
+ { label: t('conn.guide.sftp.fact.host'), value: host, hint: t('conn.guide.sftp.fact.hostHint') },
602
+ { label: t('conn.guide.sftp.fact.port'), value: String(port), hint: t('conn.guide.sftp.fact.portHint') },
603
+ { label: t('conn.guide.fact.user'), value: user, hint: t('conn.guide.sftp.fact.userHint') },
604
+ {
605
+ label: t('conn.guide.sftp.fact.auth'),
606
+ value: ctx.sftpHasKey ? t('conn.guide.sftp.fact.authKey') : t('conn.guide.sftp.fact.authPassword'),
607
+ hint: t('conn.guide.sftp.fact.authHint'),
608
+ placeholderOnly: true,
609
+ },
610
+ { label: t('conn.guide.sftp.fact.path'), value: target, hint: t('conn.guide.sftp.fact.pathHint') },
611
+ ];
612
+
613
+ const clients: GuideClient[] = [
614
+ {
615
+ id: 'openssh',
616
+ name: 'OpenSSH (sftp / scp)',
617
+ platform: 'any',
618
+ blocks: [
619
+ {
620
+ kind: 'code',
621
+ caption: t('conn.guide.sftp.openssh.connectCaption'),
622
+ code: [
623
+ `sftp${portFlag} ${user}@${host}`,
624
+ `scp${portFlag} ./report.pdf ${user}@${host}:${target}/report.pdf`,
625
+ `scp${portFlag} ${user}@${host}:${target}/report.pdf ./`,
626
+ ].join('\n'),
627
+ },
628
+ {
629
+ kind: 'code',
630
+ caption: '~/.ssh/config',
631
+ code: [
632
+ 'Host filex',
633
+ ` HostName ${host}`,
634
+ ` Port ${port}`,
635
+ ` User ${user}`,
636
+ ' # IdentityFile ~/.ssh/id_ed25519',
637
+ ].join('\n'),
638
+ },
639
+ { kind: 'note', text: t('conn.guide.sftp.openssh.noShell') },
640
+ ],
641
+ },
642
+ {
643
+ id: 'key',
644
+ name: t('conn.guide.sftp.key.tab'),
645
+ platform: 'any',
646
+ blocks: [
647
+ {
648
+ kind: 'steps',
649
+ steps: [
650
+ t('conn.guide.sftp.key.s1'),
651
+ t('conn.guide.sftp.key.s2'),
652
+ t('conn.guide.sftp.key.s3'),
653
+ ],
654
+ },
655
+ {
656
+ kind: 'code',
657
+ caption: t('conn.guide.sftp.key.genCaption'),
658
+ code: ['ssh-keygen -t ed25519 -C "filex"', 'cat ~/.ssh/id_ed25519.pub'].join('\n'),
659
+ },
660
+ // ⚠ The one command people reach for, and the one that cannot work.
661
+ { kind: 'warn', text: t('conn.guide.sftp.key.noCopyId') },
662
+ ],
663
+ },
664
+ {
665
+ id: 'winscp',
666
+ name: 'WinSCP',
667
+ platform: 'windows',
668
+ blocks: [
669
+ {
670
+ kind: 'steps',
671
+ steps: [
672
+ t('conn.guide.sftp.winscp.s1'),
673
+ t('conn.guide.sftp.winscp.s2', { host, port: String(port) }),
674
+ t('conn.guide.sftp.winscp.s3', { user }),
675
+ t('conn.guide.sftp.winscp.s4'),
676
+ ],
677
+ },
678
+ ],
679
+ },
680
+ {
681
+ id: 'filezilla',
682
+ name: 'FileZilla',
683
+ platform: 'any',
684
+ blocks: [
685
+ {
686
+ kind: 'steps',
687
+ steps: [
688
+ t('conn.guide.sftp.filezilla.s1'),
689
+ t('conn.guide.sftp.filezilla.s2', { host: `sftp://${host}`, port: String(port) }),
690
+ t('conn.guide.sftp.filezilla.s3', { user }),
691
+ ],
692
+ },
693
+ ],
694
+ },
695
+ {
696
+ id: 'rclone',
697
+ name: 'rclone',
698
+ platform: 'any',
699
+ blocks: [
700
+ {
701
+ kind: 'code',
702
+ caption: '~/.config/rclone/rclone.conf',
703
+ code: [
704
+ '[filex-sftp]',
705
+ 'type = sftp',
706
+ `host = ${host}`,
707
+ `port = ${port}`,
708
+ `user = ${user}`,
709
+ 'key_file = ~/.ssh/id_ed25519',
710
+ '# filex has no shell, so tell rclone not to look for one:',
711
+ 'shell_type = none',
712
+ 'md5sum_command = none',
713
+ 'sha1sum_command = none',
714
+ ].join('\n'),
715
+ },
716
+ {
717
+ kind: 'code',
718
+ caption: t('conn.guide.sftp.rclone.useCaption'),
719
+ code: [
720
+ 'rclone lsd filex-sftp:',
721
+ `rclone copy ./local filex-sftp:${target}/backup -P`,
722
+ `rclone sync ./local filex-sftp:${target}/backup`,
723
+ ].join('\n'),
724
+ },
725
+ ],
726
+ },
727
+ {
728
+ id: 'sshfs',
729
+ name: 'sshfs (Linux / macOS)',
730
+ platform: 'linux',
731
+ blocks: [
732
+ {
733
+ kind: 'code',
734
+ caption: t('conn.guide.sftp.sshfs.mountCaption'),
735
+ code: [
736
+ `sshfs -p ${port} ${user}@${host}:${target} /mnt/filex`,
737
+ 'fusermount -u /mnt/filex',
738
+ ].join('\n'),
739
+ },
740
+ { kind: 'note', text: t('conn.guide.sftp.sshfs.note') },
741
+ ],
742
+ },
743
+ ];
744
+
745
+ const notes: GuideBlock[] = [
746
+ { kind: 'note', text: t('conn.guide.sftp.note.storages') },
747
+ { kind: 'note', text: t('conn.guide.sftp.note.permissions') },
748
+ { kind: 'note', text: t('conn.guide.sftp.note.trash') },
749
+ { kind: 'note', text: t('conn.guide.sftp.note.totp') },
750
+ ];
751
+ if (ctx.sftpEnabled === false) {
752
+ notes.unshift({ kind: 'warn', text: t('conn.guide.sftp.note.disabled') });
753
+ }
754
+
755
+ return {
756
+ id: 'sftp',
757
+ name: 'SFTP',
758
+ summary: t('conn.guide.sftp.summary'),
759
+ facts,
760
+ clients,
761
+ notes,
762
+ };
763
+ };
764
+
765
+
766
+ // ─────────────────────────────────────────────────────────────────────
767
+ // FTPS
768
+ // ─────────────────────────────────────────────────────────────────────
769
+
770
+ /**
771
+ * Every command here was RUN against the endpoint on 2026-08-16 with curl
772
+ * 8.5, lftp 4.9 and rclone 1.73 — including the settings each of them needs to
773
+ * negotiate explicit TLS, which is the half of FTPS that goes wrong quietly.
774
+ */
775
+ export const buildFtpsGuide: GuideBuilder = (ctx, t) => {
776
+ const host = ctx.ftpsHost || hostOf(ctx.origin);
777
+ const port = ctx.ftpsPort || 2121;
778
+ const user = ctx.sftpLogin || userOf(ctx, t);
779
+ const secret = t('conn.guide.secretPlaceholder');
780
+ const target = ctx.storage ? `/${ctx.storage}` : '/<storage>';
781
+ const pasv =
782
+ ctx.ftpsPasvMin && ctx.ftpsPasvMax ? `${ctx.ftpsPasvMin}-${ctx.ftpsPasvMax}` : '30000-30100';
783
+
784
+ const facts: GuideFact[] = [
785
+ { label: t('conn.guide.ftps.fact.host'), value: host, hint: t('conn.guide.ftps.fact.hostHint') },
786
+ { label: t('conn.guide.ftps.fact.port'), value: String(port), hint: t('conn.guide.ftps.fact.portHint') },
787
+ { label: t('conn.guide.ftps.fact.mode'), value: t('conn.guide.ftps.fact.modeValue'), hint: t('conn.guide.ftps.fact.modeHint') },
788
+ { label: t('conn.guide.fact.user'), value: user, hint: t('conn.guide.ftps.fact.userHint') },
789
+ { label: t('conn.guide.fact.password'), value: secret, hint: t('conn.guide.ftps.fact.passwordHint'), placeholderOnly: true },
790
+ { label: t('conn.guide.ftps.fact.pasv'), value: pasv, hint: t('conn.guide.ftps.fact.pasvHint') },
791
+ ];
792
+
793
+ const clients: GuideClient[] = [
794
+ {
795
+ id: 'filezilla',
796
+ name: 'FileZilla',
797
+ platform: 'any',
798
+ blocks: [
799
+ {
800
+ kind: 'steps',
801
+ steps: [
802
+ t('conn.guide.ftps.filezilla.s1'),
803
+ t('conn.guide.ftps.filezilla.s2', { host, port: String(port) }),
804
+ t('conn.guide.ftps.filezilla.s3', { user }),
805
+ t('conn.guide.ftps.filezilla.s4'),
806
+ ],
807
+ },
808
+ ],
809
+ },
810
+ {
811
+ id: 'winscp',
812
+ name: 'WinSCP',
813
+ platform: 'windows',
814
+ blocks: [
815
+ {
816
+ kind: 'steps',
817
+ steps: [
818
+ t('conn.guide.ftps.winscp.s1'),
819
+ t('conn.guide.ftps.winscp.s2', { host, port: String(port) }),
820
+ t('conn.guide.ftps.winscp.s3', { user }),
821
+ ],
822
+ },
823
+ ],
824
+ },
825
+ {
826
+ id: 'curl',
827
+ name: 'curl',
828
+ platform: 'any',
829
+ blocks: [
830
+ {
831
+ kind: 'code',
832
+ caption: t('conn.guide.ftps.curl.caption'),
833
+ code: [
834
+ `curl --ssl-reqd --ftp-pasv --user ${user} \\`,
835
+ ` -T ./report.pdf "ftp://${host}:${port}${target}/report.pdf"`,
836
+ '',
837
+ `curl --ssl-reqd --ftp-pasv --user ${user} \\`,
838
+ ` -o ./report.pdf "ftp://${host}:${port}${target}/report.pdf"`,
839
+ ].join('\n'),
840
+ },
841
+ // ⚠ The flag that matters. Without it curl will happily fall back to
842
+ // plaintext against a server that allows it — this one does not, but
843
+ // the habit is what protects you against the ones that do.
844
+ { kind: 'note', text: t('conn.guide.ftps.curl.sslReqd') },
845
+ ],
846
+ },
847
+ {
848
+ id: 'lftp',
849
+ name: 'lftp',
850
+ platform: 'any',
851
+ blocks: [
852
+ {
853
+ kind: 'code',
854
+ caption: '~/.lftprc',
855
+ code: [
856
+ 'set ftp:ssl-force true',
857
+ 'set ftp:ssl-protect-data true',
858
+ 'set ftp:passive-mode true',
859
+ ].join('\n'),
860
+ },
861
+ {
862
+ kind: 'code',
863
+ caption: t('conn.guide.ftps.lftp.caption'),
864
+ code: [
865
+ `lftp -u ${user} ftp://${host}:${port}`,
866
+ `# then: cd ${target}; put ./report.pdf; ls`,
867
+ ].join('\n'),
868
+ },
869
+ { kind: 'note', text: t('conn.guide.ftps.lftp.protectData') },
870
+ ],
871
+ },
872
+ {
873
+ id: 'rclone',
874
+ name: 'rclone',
875
+ platform: 'any',
876
+ blocks: [
877
+ {
878
+ kind: 'code',
879
+ caption: '~/.config/rclone/rclone.conf',
880
+ code: [
881
+ '[filex-ftp]',
882
+ 'type = ftp',
883
+ `host = ${host}`,
884
+ `port = ${port}`,
885
+ `user = ${user}`,
886
+ '# rclone obscure "<your password>"',
887
+ 'pass = …',
888
+ 'explicit_tls = true',
889
+ ].join('\n'),
890
+ },
891
+ {
892
+ kind: 'code',
893
+ caption: t('conn.guide.ftps.rclone.useCaption'),
894
+ code: [
895
+ 'rclone lsd filex-ftp:',
896
+ `rclone copy ./local filex-ftp:${target}/backup -P`,
897
+ ].join('\n'),
898
+ },
899
+ ],
900
+ },
901
+ {
902
+ id: 'printer',
903
+ name: t('conn.guide.ftps.printer.tab'),
904
+ platform: 'any',
905
+ blocks: [
906
+ {
907
+ kind: 'steps',
908
+ steps: [
909
+ t('conn.guide.ftps.printer.s1', { host, port: String(port) }),
910
+ t('conn.guide.ftps.printer.s2', { user }),
911
+ t('conn.guide.ftps.printer.s3', { path: target }),
912
+ t('conn.guide.ftps.printer.s4'),
913
+ ],
914
+ },
915
+ // ⚠ The honest warning: plenty of scan-to-FTP firmware cannot do TLS
916
+ // at all, and this endpoint will not talk to it. Saying so beats an
917
+ // afternoon spent debugging a device that was never going to connect.
918
+ { kind: 'warn', text: t('conn.guide.ftps.printer.noTLS') },
919
+ ],
920
+ },
921
+ ];
922
+
923
+ const notes: GuideBlock[] = [
924
+ { kind: 'warn', text: t('conn.guide.ftps.note.tls') },
925
+ { kind: 'note', text: t('conn.guide.ftps.note.passive') },
926
+ { kind: 'note', text: t('conn.guide.ftps.note.storages') },
927
+ { kind: 'note', text: t('conn.guide.ftps.note.trash') },
928
+ { kind: 'note', text: t('conn.guide.ftps.note.prefer') },
929
+ ];
930
+ if (ctx.ftpsSelfSigned) {
931
+ notes.push({ kind: 'warn', text: t('conn.guide.ftps.note.selfSigned') });
932
+ }
933
+ if (ctx.ftpsEnabled === false) {
934
+ notes.unshift({ kind: 'warn', text: t('conn.guide.ftps.note.disabled') });
935
+ }
936
+
937
+ return {
938
+ id: 'ftps',
939
+ name: 'FTPS',
940
+ summary: t('conn.guide.ftps.summary'),
941
+ facts,
942
+ clients,
943
+ notes,
944
+ };
945
+ };
946
+
947
+
948
+ // ─────────────────────────────────────────────────────────────────────
949
+ // NFS
950
+ // ─────────────────────────────────────────────────────────────────────
951
+
952
+ /**
953
+ * Every command here was RUN against the endpoint on 2026-08-16 with the LINUX
954
+ * KERNEL's own NFSv3 client — including the two options a mount fails silently
955
+ * without (`port=` and `mountport=`, because filex serves both on one port and
956
+ * runs no portmapper).
957
+ */
958
+ export const buildNfsGuide: GuideBuilder = (ctx, t) => {
959
+ const host = ctx.nfsHost || hostOf(ctx.origin);
960
+ const port = ctx.nfsPort || 2049;
961
+ const path = ctx.nfsPath || t('conn.guide.nfs.pathPlaceholder');
962
+ const ro = ctx.nfsReadOnly ? ',ro' : '';
963
+ const opts = `nfsvers=3,tcp,port=${port},mountport=${port},nolock${ro}`;
964
+
965
+ const facts: GuideFact[] = [
966
+ { label: t('conn.guide.nfs.fact.host'), value: host, hint: t('conn.guide.nfs.fact.hostHint') },
967
+ { label: t('conn.guide.nfs.fact.port'), value: String(port), hint: t('conn.guide.nfs.fact.portHint') },
968
+ {
969
+ label: t('conn.guide.nfs.fact.export'),
970
+ value: path,
971
+ hint: t('conn.guide.nfs.fact.exportHint'),
972
+ // ⚠ Only real right after minting: the path is stored hashed and cannot
973
+ // be shown again, and printing something path-shaped would produce a
974
+ // mount line that fails with no clue why.
975
+ placeholderOnly: !ctx.nfsPath,
976
+ },
977
+ { label: t('conn.guide.nfs.fact.options'), value: opts, hint: t('conn.guide.nfs.fact.optionsHint') },
978
+ ];
979
+
980
+ const clients: GuideClient[] = [
981
+ {
982
+ id: 'linux',
983
+ name: 'Linux',
984
+ platform: 'linux',
985
+ blocks: [
986
+ {
987
+ kind: 'code',
988
+ caption: t('conn.guide.nfs.linux.mountCaption'),
989
+ code: [
990
+ 'sudo mkdir -p /mnt/filex',
991
+ `sudo mount -t nfs -o ${opts} ${host}:${path} /mnt/filex`,
992
+ '',
993
+ '# and to unmount:',
994
+ 'sudo umount /mnt/filex',
995
+ ].join('\n'),
996
+ },
997
+ {
998
+ kind: 'code',
999
+ caption: '/etc/fstab',
1000
+ code: `${host}:${path} /mnt/filex nfs ${opts},_netdev,noauto,x-systemd.automount 0 0`,
1001
+ },
1002
+ // ⚠ The warning that belongs next to the fstab line rather than in a
1003
+ // document: that file is world-readable on most systems.
1004
+ { kind: 'warn', text: t('conn.guide.nfs.linux.fstabSecret') },
1005
+ ],
1006
+ },
1007
+ {
1008
+ id: 'macos',
1009
+ name: 'macOS',
1010
+ platform: 'macos',
1011
+ blocks: [
1012
+ {
1013
+ kind: 'code',
1014
+ caption: t('conn.guide.nfs.mac.mountCaption'),
1015
+ code: [
1016
+ 'sudo mkdir -p /Volumes/filex',
1017
+ `sudo mount -t nfs -o vers=3,tcp,port=${port},mountport=${port},nolock${ro},resvport ${host}:${path} /Volumes/filex`,
1018
+ ].join('\n'),
1019
+ },
1020
+ { kind: 'note', text: t('conn.guide.nfs.mac.resvport') },
1021
+ ],
1022
+ },
1023
+ {
1024
+ id: 'windows',
1025
+ name: 'Windows (Client for NFS)',
1026
+ platform: 'windows',
1027
+ blocks: [
1028
+ {
1029
+ kind: 'steps',
1030
+ steps: [
1031
+ t('conn.guide.nfs.win.s1'),
1032
+ t('conn.guide.nfs.win.s2'),
1033
+ t('conn.guide.nfs.win.s3'),
1034
+ ],
1035
+ },
1036
+ {
1037
+ kind: 'code',
1038
+ caption: t('conn.guide.nfs.win.cmdCaption'),
1039
+ code: `mount -o anon nolock ${host}:${path} Z:`,
1040
+ },
1041
+ // ⚠ Windows' client has no way to say "the mount service is on this
1042
+ // port", so it only works when filex is on 2049.
1043
+ { kind: 'warn', text: t('conn.guide.nfs.win.port') },
1044
+ ],
1045
+ },
1046
+ {
1047
+ id: 'synology',
1048
+ name: t('conn.guide.nfs.nas.tab'),
1049
+ platform: 'any',
1050
+ blocks: [
1051
+ {
1052
+ kind: 'steps',
1053
+ steps: [
1054
+ t('conn.guide.nfs.nas.s1', { host, port: String(port) }),
1055
+ t('conn.guide.nfs.nas.s2'),
1056
+ t('conn.guide.nfs.nas.s3'),
1057
+ ],
1058
+ },
1059
+ ],
1060
+ },
1061
+ ];
1062
+
1063
+ const notes: GuideBlock[] = [
1064
+ { kind: 'warn', text: t('conn.guide.nfs.note.unencrypted') },
1065
+ { kind: 'warn', text: t('conn.guide.nfs.note.pathIsSecret') },
1066
+ { kind: 'note', text: t('conn.guide.nfs.note.noPortmapper') },
1067
+ { kind: 'note', text: t('conn.guide.nfs.note.uid') },
1068
+ { kind: 'note', text: t('conn.guide.nfs.note.trash') },
1069
+ { kind: 'note', text: t('conn.guide.nfs.note.revoke') },
1070
+ ];
1071
+ if (ctx.nfsEnabled === false) {
1072
+ notes.unshift({ kind: 'warn', text: t('conn.guide.nfs.note.disabled') });
1073
+ }
1074
+
1075
+ return {
1076
+ id: 'nfs',
1077
+ name: 'NFS',
1078
+ summary: t('conn.guide.nfs.summary'),
1079
+ facts,
1080
+ clients,
1081
+ notes,
1082
+ };
1083
+ };
1084
+
1085
+
1086
+ // ─────────────────────────────────────────────────────────────────────
1087
+ // filex mount (FUSE)
1088
+ // ─────────────────────────────────────────────────────────────────────
1089
+
1090
+ /**
1091
+ * `filex mount` is the odd one out here: there is no server to point a
1092
+ * third-party client at, because filex's OWN binary is the client. It speaks
1093
+ * the REST API over the same HTTPS the browser uses, which is what makes it
1094
+ * the only one of these that works from anywhere — NFS needs a LAN, SFTP needs
1095
+ * sshfs or WinFsp already configured, and this needs a URL and a token.
1096
+ *
1097
+ * ⚠ It is not a sync. Nothing is copied except a bounded read cache, so it
1098
+ * opens one file out of a hundred thousand without downloading the rest;
1099
+ * `filex sync` is still the answer for having the files offline. The guide says
1100
+ * so, because somebody who picks the wrong one finds out slowly.
1101
+ */
1102
+ export const buildMountGuide: GuideBuilder = (ctx, t) => {
1103
+ const origin = ctx.origin.replace(/\/+$/, '');
1104
+ const storage = ctx.storage || ctx.storages[0] || 'main';
1105
+
1106
+ const facts: GuideFact[] = [
1107
+ { label: t('conn.guide.mount.fact.url'), value: origin, hint: t('conn.guide.mount.fact.urlHint') },
1108
+ {
1109
+ label: t('conn.guide.mount.fact.token'),
1110
+ value: t('conn.guide.mount.fact.tokenPlaceholder'),
1111
+ hint: t('conn.guide.mount.fact.tokenHint'),
1112
+ // filex never has the plaintext of a token after it is minted, and a
1113
+ // fact row that looked like one would be a lie the user pastes.
1114
+ placeholderOnly: true,
1115
+ },
1116
+ { label: t('conn.guide.mount.fact.remote'), value: `${storage}://`, hint: t('conn.guide.mount.fact.remoteHint') },
1117
+ ];
1118
+
1119
+ const clients: GuideClient[] = [
1120
+ {
1121
+ id: 'linux',
1122
+ name: 'Linux',
1123
+ platform: 'linux',
1124
+ blocks: [
1125
+ {
1126
+ kind: 'code',
1127
+ caption: t('conn.guide.mount.linux.mountCaption'),
1128
+ code: [
1129
+ `export FILEX_URL=${origin}`,
1130
+ 'export FILEX_TOKEN=<token>',
1131
+ '',
1132
+ 'mkdir -p ~/filex',
1133
+ 'filex mount ~/filex',
1134
+ '',
1135
+ `# one storage only, or a subfolder of it:`,
1136
+ `filex mount --remote '${storage}://' ~/filex`,
1137
+ `filex mount --remote '${storage}://projects/acme' --read-only ~/acme`,
1138
+ ].join('\n'),
1139
+ },
1140
+ {
1141
+ kind: 'code',
1142
+ caption: t('conn.guide.mount.linux.umountCaption'),
1143
+ code: 'fusermount -u ~/filex',
1144
+ },
1145
+ // ⚠ The one that costs an afternoon if it is not said: killing the
1146
+ // process leaves a directory where every `ls` hangs.
1147
+ { kind: 'warn', text: t('conn.guide.mount.linux.umountWarn') },
1148
+ {
1149
+ kind: 'code',
1150
+ caption: t('conn.guide.mount.linux.systemdCaption'),
1151
+ code: [
1152
+ '# ~/.config/systemd/user/filex-mount.service',
1153
+ '[Unit]',
1154
+ 'Description=filex mount',
1155
+ 'After=network-online.target',
1156
+ '',
1157
+ '[Service]',
1158
+ `Environment=FILEX_URL=${origin}`,
1159
+ 'Environment=FILEX_TOKEN=<token>',
1160
+ 'ExecStart=%h/.local/bin/filex mount %h/filex',
1161
+ 'ExecStop=/bin/fusermount -u %h/filex',
1162
+ 'Restart=on-failure',
1163
+ '',
1164
+ '[Install]',
1165
+ 'WantedBy=default.target',
1166
+ ].join('\n'),
1167
+ },
1168
+ ],
1169
+ },
1170
+ {
1171
+ id: 'windows',
1172
+ name: 'Windows',
1173
+ platform: 'windows',
1174
+ blocks: [
1175
+ { kind: 'note', text: t('conn.guide.mount.win.winfsp') },
1176
+ {
1177
+ kind: 'code',
1178
+ caption: t('conn.guide.mount.win.mountCaption'),
1179
+ code: [
1180
+ `$env:FILEX_URL = "${origin}"`,
1181
+ '$env:FILEX_TOKEN = "<token>"',
1182
+ '',
1183
+ 'filex mount Z:',
1184
+ ].join('\n'),
1185
+ },
1186
+ // ⚠ The drive letter is CREATED, not reused — pointing it at one that
1187
+ // already exists is the commonest way this fails, and the message the
1188
+ // driver gives for it says nothing useful.
1189
+ { kind: 'warn', text: t('conn.guide.mount.win.freeLetter') },
1190
+ { kind: 'note', text: t('conn.guide.mount.win.stop') },
1191
+ ],
1192
+ },
1193
+ {
1194
+ id: 'macos',
1195
+ name: 'macOS',
1196
+ platform: 'macos',
1197
+ blocks: [
1198
+ // ⚠⚠ A refusal stated up front rather than discovered. macFUSE's Go
1199
+ // binding needs a C toolchain filex deliberately does not use, and its
1200
+ // licence forbids a commercial program from installing it. Pretending
1201
+ // otherwise would mean a command that appears to work and does nothing.
1202
+ { kind: 'warn', text: t('conn.guide.mount.mac.unsupported') },
1203
+ { kind: 'note', text: t('conn.guide.mount.mac.alternatives') },
1204
+ ],
1205
+ },
1206
+ ];
1207
+
1208
+ const notes: GuideBlock[] = [
1209
+ { kind: 'note', text: t('conn.guide.mount.note.notASync') },
1210
+ { kind: 'note', text: t('conn.guide.mount.note.reachable') },
1211
+ { kind: 'note', text: t('conn.guide.mount.note.wholeFileWrites') },
1212
+ { kind: 'note', text: t('conn.guide.mount.note.trash') },
1213
+ { kind: 'note', text: t('conn.guide.mount.note.revoke') },
1214
+ ];
1215
+
1216
+ return {
1217
+ id: 'mount',
1218
+ name: 'filex mount',
1219
+ summary: t('conn.guide.mount.summary'),
1220
+ facts,
1221
+ clients,
1222
+ notes,
1223
+ };
1224
+ };
1225
+
1226
+ // ─────────────────────────────────────────────────────────────────────
1227
+ // Registry
1228
+ // ─────────────────────────────────────────────────────────────────────
1229
+
1230
+ /**
1231
+ * Protocol id → builder.
1232
+ *
1233
+ * S3 and SFTP land here as one entry each once their servers exist; the
1234
+ * panel, the copy buttons, the client tabs and the i18n plumbing are
1235
+ * already written for them. That is the whole point of the shape.
1236
+ */
1237
+ export const GUIDE_BUILDERS: Record<string, GuideBuilder> = {
1238
+ webdav: buildWebdavGuide,
1239
+ s3: buildS3Guide,
1240
+ sftp: buildSftpGuide,
1241
+ ftps: buildFtpsGuide,
1242
+ nfs: buildNfsGuide,
1243
+ mount: buildMountGuide,
1244
+ };
1245
+
1246
+ /**
1247
+ * Display name per protocol id.
1248
+ *
1249
+ * ⚠ Not `id.toUpperCase()`, which the picker used to do. That reads fine for
1250
+ * S3 and NFS and turns `filex mount` into "MOUNT" — a name for a thing that is
1251
+ * not a protocol at all, in a list where every other entry is one.
1252
+ */
1253
+ export const GUIDE_NAMES: Record<string, string> = {
1254
+ webdav: 'WebDAV',
1255
+ s3: 'S3',
1256
+ sftp: 'SFTP',
1257
+ ftps: 'FTPS',
1258
+ nfs: 'NFS',
1259
+ mount: 'filex mount',
1260
+ };
1261
+
1262
+ /** The label to show for a protocol id in a picker. */
1263
+ export function guideName(id: string): string {
1264
+ return GUIDE_NAMES[id] || id.toUpperCase();
1265
+ }
1266
+
1267
+ /** Protocol ids that have a guide, in display order. */
1268
+ export function guideProtocols(): string[] {
1269
+ return Object.keys(GUIDE_BUILDERS);
1270
+ }
1271
+
1272
+ export function buildGuide(
1273
+ protocol: string,
1274
+ ctx: GuideContext,
1275
+ t: Translate,
1276
+ ): ProtocolGuide | null {
1277
+ const builder = GUIDE_BUILDERS[protocol];
1278
+ return builder ? builder(ctx, t) : null;
1279
+ }