@brftech/filex-core 0.31.0 → 0.33.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/README.md +26 -2
- package/dist/{CsvViewer-DxujFEM3.js → CsvViewer-CqWeV8VO.js} +22 -22
- package/dist/CsvViewer-CqWeV8VO.js.map +1 -0
- package/dist/{DrawioViewer-DCvWnX2t.js → DrawioViewer-BNALOB04.js} +16 -16
- package/dist/DrawioViewer-BNALOB04.js.map +1 -0
- package/dist/filex-core.js +9346 -8069
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +49 -49
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +235 -16
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +776 -246
- package/src/components/CommandPalette.vue +21 -5
- package/src/components/ConnectionsPanel.vue +2 -1
- package/src/components/E2eRecoveryUnlockModal.vue +29 -4
- package/src/components/FilterBar.vue +244 -0
- package/src/components/GalleryView.vue +12 -1
- package/src/components/GridView.vue +48 -5
- package/src/components/InspectorPanel.vue +231 -8
- package/src/components/ListView.vue +12 -3
- package/src/components/NFSExportsPanel.vue +2 -1
- package/src/components/PendingOpsTray.vue +1 -1
- package/src/components/PresenceBar.vue +3 -2
- package/src/components/S3KeysPanel.vue +2 -1
- package/src/components/SSHKeysPanel.vue +2 -1
- package/src/components/SecondaryPane.vue +28 -13
- package/src/components/SideNav.vue +161 -1
- package/src/components/StarButton.vue +2 -1
- package/src/components/TabBar.vue +2 -2
- package/src/components/ThemeGallery.vue +1 -1
- package/src/components/TokensPanel.vue +2 -1
- package/src/components/Toolbar.vue +244 -58
- package/src/components/ViewSwitcher.vue +81 -0
- package/src/composables/useFileApi.ts +49 -2
- package/src/composables/useLocale.ts +1 -1
- package/src/composables/useOperations.ts +3 -3
- package/src/composables/usePendingOps.ts +1 -1
- package/src/composables/useTabs.ts +1 -1
- package/src/composables/useUploadChunked.ts +39 -7
- package/src/index.ts +8 -2
- package/src/lib/e2ecrypto.ts +191 -3
- package/src/lib/fileFilters.ts +143 -0
- package/src/lib/listing.ts +47 -0
- package/src/lib/themes.ts +7 -7
- package/src/lib/transfer.ts +6 -6
- package/src/locales/en.ts +115 -0
- package/src/locales/index.ts +1 -0
- package/src/locales/resolve.ts +66 -0
- package/src/locales/tr.ts +116 -0
- package/src/modals/ConvertModal.vue +14 -12
- package/src/modals/NewFolderModal.vue +1 -1
- package/src/modals/PermissionsModal.vue +30 -13
- package/src/modals/PreviewModal.vue +9 -9
- package/src/styles/base.css +520 -26
- package/src/styles/variables.css +1 -1
- package/src/types/ExplorerConfig.ts +18 -2
- package/src/types/FileNode.ts +1 -1
- package/src/viewers/CsvViewer.vue +4 -4
- package/src/viewers/DrawioViewer.vue +2 -2
- package/dist/CsvViewer-DxujFEM3.js.map +0 -1
- package/dist/DrawioViewer-DCvWnX2t.js.map +0 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Backend POST /files/{copy,move,delete} returns `{op}` immediately
|
|
5
5
|
* (status='pending') and the actual S3 work runs in a worker. The
|
|
6
6
|
* client polls `/files/ops` every 2s while there's anything still
|
|
7
|
-
* pending or running so the user gets live feedback ("3/5
|
|
7
|
+
* pending or running so the user gets live feedback ("3/5 copied").
|
|
8
8
|
*
|
|
9
9
|
* Polling is reference-counted: callers `register(op)` after starting a
|
|
10
10
|
* new op; an interval ticks while the registered set has any non-
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useTabs — wiring:d1
|
|
2
|
+
* useTabs — wiring:d1 tabs (the workspace tab strip).
|
|
3
3
|
*
|
|
4
4
|
* A LAYER ABOVE FileExplorer's location state, never a replacement for
|
|
5
5
|
* it: each tab is a location snapshot `{ id, path, viewMode, split }`.
|
|
@@ -85,10 +85,17 @@ export interface UploadOptions {
|
|
|
85
85
|
onDone?: (job: UploadJob, result: UploadResult) => void;
|
|
86
86
|
onError?: (job: UploadJob, err: Error) => void;
|
|
87
87
|
/**
|
|
88
|
-
* Wait for the backend transfer before resolving.
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
88
|
+
* Wait for the backend transfer before resolving. Defaults to TRUE, because
|
|
89
|
+
* "the server has the bytes" and "the storage has the bytes" are different
|
|
90
|
+
* claims and only the second one is an upload.
|
|
91
|
+
*
|
|
92
|
+
* ⚠ It used to default to false and no caller ever set it, so the job went
|
|
93
|
+
* straight to `done` on the 202 — a transfer that failed afterwards in the
|
|
94
|
+
* ops worker left the user looking at a finished upload for a file that was
|
|
95
|
+
* never stored (issue #16: every file over the chunk threshold, on every S3
|
|
96
|
+
* backend). The `transferring` phase is short for a healthy storage and is
|
|
97
|
+
* the only place a failure can still be shown, so it is the default; pass
|
|
98
|
+
* false to opt out where the wait is genuinely unwanted.
|
|
92
99
|
*/
|
|
93
100
|
waitForTransfer?: boolean;
|
|
94
101
|
}
|
|
@@ -389,7 +396,7 @@ export function useUploadChunked(
|
|
|
389
396
|
job.opId = result?.op_id;
|
|
390
397
|
job.nodeId = result?.node_id;
|
|
391
398
|
|
|
392
|
-
if (opts.waitForTransfer && result?.op_id && api.endpoints.opsShow) {
|
|
399
|
+
if (opts.waitForTransfer !== false && result?.op_id && api.endpoints.opsShow) {
|
|
393
400
|
job.status = 'transferring';
|
|
394
401
|
report();
|
|
395
402
|
await waitForOp(result.op_id);
|
|
@@ -484,17 +491,31 @@ export function useUploadChunked(
|
|
|
484
491
|
if (!tmpl) return;
|
|
485
492
|
const url = tmpl.replace('{id}', String(opId));
|
|
486
493
|
let delay = 200;
|
|
494
|
+
// ⚠ A tray read that keeps failing must not park the upload in
|
|
495
|
+
// `transferring` for ever. One hiccup is not a verdict, but a row we
|
|
496
|
+
// cannot read at all (purged, or the endpoint is gone) is unknowable, so
|
|
497
|
+
// after a bounded run of failures the wait ends and the job settles the
|
|
498
|
+
// way it did before the wait existed — optimistic, but not hung.
|
|
499
|
+
let misses = 0;
|
|
487
500
|
for (;;) {
|
|
488
501
|
if (cancelled) throw new DOMException('Aborted by user', 'AbortError');
|
|
489
502
|
try {
|
|
490
503
|
const op = await api.jsonFetch<{ status?: string; error?: string }>(url);
|
|
491
504
|
if (op?.status === 'ok') return;
|
|
492
505
|
if (op?.status === 'failed' || op?.status === 'partial') {
|
|
493
|
-
throw new
|
|
506
|
+
throw new TransferFailedError(op.error || 'transfer failed');
|
|
494
507
|
}
|
|
508
|
+
misses = 0;
|
|
495
509
|
} catch (err) {
|
|
496
|
-
|
|
510
|
+
// ⚠ The verdict is told apart from the hiccup by TYPE, not by
|
|
511
|
+
// message. It used to be `message === 'transfer failed'`, which only
|
|
512
|
+
// matched when the server sent NO error text: the moment it sent a
|
|
513
|
+
// real one — which is every interesting failure — the throw above was
|
|
514
|
+
// swallowed here and the poll span for ever. Harmless while nothing
|
|
515
|
+
// set waitForTransfer; a hang the moment anything did.
|
|
516
|
+
if (err instanceof TransferFailedError || (err as Error).name === 'AbortError') throw err;
|
|
497
517
|
/* a hiccup reading the tray is not a failed transfer */
|
|
518
|
+
if (++misses >= MAX_OP_POLL_MISSES) return;
|
|
498
519
|
}
|
|
499
520
|
await sleep(delay);
|
|
500
521
|
delay = Math.min(delay * 2, 2000);
|
|
@@ -514,6 +535,17 @@ export function useUploadChunked(
|
|
|
514
535
|
};
|
|
515
536
|
}
|
|
516
537
|
|
|
538
|
+
/** How many consecutive unreadable op polls end the transfer wait (~7s). */
|
|
539
|
+
const MAX_OP_POLL_MISSES = 6;
|
|
540
|
+
|
|
541
|
+
/** The backend said the transfer failed — as opposed to "I could not ask". */
|
|
542
|
+
class TransferFailedError extends Error {
|
|
543
|
+
constructor(message: string) {
|
|
544
|
+
super(message);
|
|
545
|
+
this.name = 'TransferFailedError';
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
517
549
|
function sleep(ms: number): Promise<void> {
|
|
518
550
|
return new Promise((r) => setTimeout(r, ms));
|
|
519
551
|
}
|
package/src/index.ts
CHANGED
|
@@ -98,7 +98,7 @@ export {
|
|
|
98
98
|
} from './composables/useMonacoLoader';
|
|
99
99
|
|
|
100
100
|
// ——— Locale catalogue (consumers may merge their own keys) ———
|
|
101
|
-
export { messages, tr, en } from './locales';
|
|
101
|
+
export { messages, tr, en, resolveLocale, detectLocale } from './locales';
|
|
102
102
|
|
|
103
103
|
/* wiring:c1 — theme registry + gallery (hosts can list/apply themes programmatically) */
|
|
104
104
|
export {
|
|
@@ -150,7 +150,7 @@ export type { TabState, TabSplit, TabsApi } from './composables/useTabs';
|
|
|
150
150
|
export { default as TabBar } from './components/TabBar.vue';
|
|
151
151
|
export { default as SecondaryPane } from './components/SecondaryPane.vue';
|
|
152
152
|
/* /wiring:d1 */
|
|
153
|
-
/* wiring:e2 —
|
|
153
|
+
/* wiring:e2 — end-to-end encrypted folders (WebCrypto; docs/E2E-ENCRYPTION.md) */
|
|
154
154
|
export {
|
|
155
155
|
E2E_MARKER_NAME,
|
|
156
156
|
E2E_MAGIC,
|
|
@@ -173,6 +173,10 @@ export {
|
|
|
173
173
|
unlockWithEscrowKey,
|
|
174
174
|
markerHasRecovery,
|
|
175
175
|
markerHasEscrow,
|
|
176
|
+
escrowAvailability,
|
|
177
|
+
escrowOfferState,
|
|
178
|
+
addEscrowSlot,
|
|
179
|
+
declineEscrowSlot,
|
|
176
180
|
generateRecoveryKey,
|
|
177
181
|
formatRecoveryKey,
|
|
178
182
|
parseRecoveryKey,
|
|
@@ -190,6 +194,8 @@ export type {
|
|
|
190
194
|
E2eFmkMode,
|
|
191
195
|
E2eRecoverySlot,
|
|
192
196
|
E2eEscrowSlot,
|
|
197
|
+
EscrowAvailability,
|
|
198
|
+
EscrowOfferState,
|
|
193
199
|
CreateFolderOptions,
|
|
194
200
|
CreatedFolder,
|
|
195
201
|
} from './lib/e2ecrypto';
|
package/src/lib/e2ecrypto.ts
CHANGED
|
@@ -40,10 +40,19 @@
|
|
|
40
40
|
* - No key, password or recovery key is ever stored, logged or sent to a
|
|
41
41
|
* server. The FMK lives in an in-memory key ring and dies with the tab.
|
|
42
42
|
* - `deriveKek` imports non-extractable. Raw KEK bytes are produced ONLY
|
|
43
|
-
* by `deriveKekBits`, only
|
|
44
|
-
*
|
|
43
|
+
* by `deriveKekBits`, only for a marker whose FMK *is* the KEK
|
|
44
|
+
* (`upgradeMarkerV1`, and `addEscrowSlot` on a folder it produced), and
|
|
45
|
+
* only long enough to wrap them into a slot.
|
|
45
46
|
* - A folder created while escrow was off carries no escrow slot, so the
|
|
46
|
-
* escrow key cannot open it
|
|
47
|
+
* escrow key cannot open it, and nothing the OPERATOR does changes
|
|
48
|
+
* that — not enabling escrow, not adopting it, not any admin action or
|
|
49
|
+
* future version. That is arithmetic, not policy: adding a slot needs
|
|
50
|
+
* the folder master key, and the server has never held a credential
|
|
51
|
+
* that produces one.
|
|
52
|
+
* - The folder's OWNER can, from inside, with the password:
|
|
53
|
+
* `addEscrowSlot`. That is the only door, it opens from one side only,
|
|
54
|
+
* and it is the reason `escrowAvailability` says "not as things stand"
|
|
55
|
+
* rather than "never".
|
|
47
56
|
*
|
|
48
57
|
* File layout ('filexe2e' magic, fixed 97-byte header) — UNCHANGED in v2:
|
|
49
58
|
* [0..8) magic "filexe2e"
|
|
@@ -114,6 +123,23 @@ export interface E2eMarker {
|
|
|
114
123
|
rk?: E2eRecoverySlot;
|
|
115
124
|
/** v2 only, optional: the operator escrow slot. */
|
|
116
125
|
esc?: E2eEscrowSlot;
|
|
126
|
+
/**
|
|
127
|
+
* v2 only, optional: an ISO timestamp recording that this folder's owner
|
|
128
|
+
* was OFFERED an escrow slot and said no.
|
|
129
|
+
*
|
|
130
|
+
* It lives in the marker rather than in browser storage because the unit
|
|
131
|
+
* of the decision is the FOLDER, not the device: the same person opening
|
|
132
|
+
* the folder from their phone must not be asked again, and a decision
|
|
133
|
+
* that vanished when someone cleared their site data would be no decision
|
|
134
|
+
* at all. It travels with the folder through a move, a backup and a
|
|
135
|
+
* restore, for the same reason the key slots do.
|
|
136
|
+
*
|
|
137
|
+
* It holds no key material and hides nothing from the operator — it is a
|
|
138
|
+
* record of an answer, and its only effect is that filex stops asking.
|
|
139
|
+
* `addEscrowSlot` clears it, so a decline is reversible by the one person
|
|
140
|
+
* who can reverse it.
|
|
141
|
+
*/
|
|
142
|
+
esc_declined?: string;
|
|
117
143
|
}
|
|
118
144
|
|
|
119
145
|
/** Thrown on wrong password / corrupted ciphertext (GCM tag mismatch). */
|
|
@@ -500,6 +526,126 @@ export async function upgradeMarkerV1(
|
|
|
500
526
|
return { marker: next, fmk: kek, recoveryKey };
|
|
501
527
|
}
|
|
502
528
|
|
|
529
|
+
/**
|
|
530
|
+
* Give an EXISTING v2 folder an escrow slot, in place, using the folder
|
|
531
|
+
* password its owner has just typed.
|
|
532
|
+
*
|
|
533
|
+
* ── Why this exists ─────────────────────────────────────────────────
|
|
534
|
+
*
|
|
535
|
+
* Escrow used to be all-or-nothing at install time, and then adoptable but
|
|
536
|
+
* never retroactive: on any installation that had been running for a while,
|
|
537
|
+
* escrow covered only folders nobody had created yet. On a real deployment
|
|
538
|
+
* the folders that matter already exist, so "new folders only" means escrow
|
|
539
|
+
* covers nothing anyone cares about.
|
|
540
|
+
*
|
|
541
|
+
* The server still cannot do this, and that has not changed: adding a slot
|
|
542
|
+
* needs the folder master key, which needs a credential the server has never
|
|
543
|
+
* held. What CAN do it is the browser, at the one moment the password is in
|
|
544
|
+
* memory — exactly where `upgradeMarkerV1` already lives. Same moment, same
|
|
545
|
+
* shape, different slot.
|
|
546
|
+
*
|
|
547
|
+
* ⚠⚠ Accepting hands the operator of this installation a second, permanent
|
|
548
|
+
* way into this folder. It is the folder's owner who decides, from inside,
|
|
549
|
+
* with the password; no configuration change and no admin action can do it
|
|
550
|
+
* for them. The caller MUST say that in those words before calling this —
|
|
551
|
+
* see `e2e.escrowoffer.*` in the locales.
|
|
552
|
+
*
|
|
553
|
+
* ⚠ v2 only. A v1 marker has no slots at all; the path for those is
|
|
554
|
+
* `upgradeMarkerV1`, which already seals an escrow slot when the
|
|
555
|
+
* installation has a key and already discloses it. Two doors into the same
|
|
556
|
+
* room would be two chances to get the disclosure wrong.
|
|
557
|
+
*
|
|
558
|
+
* ⚠ No file is re-encrypted, moved or rewritten. Only `.filex-e2e.json`
|
|
559
|
+
* changes, and only by gaining `esc` (and losing `esc_declined`).
|
|
560
|
+
*/
|
|
561
|
+
export async function addEscrowSlot(
|
|
562
|
+
marker: E2eMarker,
|
|
563
|
+
password: string,
|
|
564
|
+
escrowPublicKey: string,
|
|
565
|
+
): Promise<E2eMarker> {
|
|
566
|
+
if (marker.v !== 2) throw new Error('e2e: not a v2 marker');
|
|
567
|
+
if (marker.esc) throw new Error('e2e: this folder already has an escrow slot');
|
|
568
|
+
if (!escrowPublicKey) throw new Error('e2e: no escrow public key');
|
|
569
|
+
|
|
570
|
+
const salt = b64ToBytes(marker.salt);
|
|
571
|
+
const kek = await deriveKek(password, salt, marker.iter);
|
|
572
|
+
// Prove the password before touching anything, exactly as the v1 upgrade
|
|
573
|
+
// does. A wrong password here must not produce a marker at all — half a
|
|
574
|
+
// marker is a folder nobody can open.
|
|
575
|
+
const proof = await gcmOpen(kek, marker.verify);
|
|
576
|
+
if (!proof || new TextDecoder().decode(proof) !== VERIFY_PLAINTEXT) {
|
|
577
|
+
throw new E2eDecryptError('e2e: wrong password');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// The raw FMK, by mode. `wrapped` keeps a random FMK in `fmk_pw`, so the
|
|
581
|
+
// bytes come back from a GCM open and no extractable KEK is ever derived.
|
|
582
|
+
// `kek` (a v1 folder upgraded in place) defines the FMK AS the password
|
|
583
|
+
// key, so those very bytes are what the slot has to wrap — the one case
|
|
584
|
+
// that needs `deriveKekBits`, for the same reason `upgradeMarkerV1` does.
|
|
585
|
+
let rawFmk: Uint8Array;
|
|
586
|
+
if (marker.fmk === 'wrapped') {
|
|
587
|
+
const opened = marker.fmk_pw ? await gcmOpen(kek, marker.fmk_pw) : null;
|
|
588
|
+
if (!opened || opened.length !== FMK_LEN) {
|
|
589
|
+
throw new E2eDecryptError('e2e: could not unwrap the folder master key');
|
|
590
|
+
}
|
|
591
|
+
rawFmk = opened;
|
|
592
|
+
} else {
|
|
593
|
+
rawFmk = await deriveKekBits(password, salt, marker.iter);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const esc = await sealEscrowSlot(rawFmk, escrowPublicKey);
|
|
597
|
+
rawFmk.fill(0);
|
|
598
|
+
|
|
599
|
+
const next: E2eMarker = { ...marker, esc };
|
|
600
|
+
// A decline that is now moot. Leaving it would make the record say two
|
|
601
|
+
// contradictory things about the same folder.
|
|
602
|
+
delete next.esc_declined;
|
|
603
|
+
return next;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Record that this folder's owner was offered an escrow slot and declined.
|
|
608
|
+
*
|
|
609
|
+
* A refusal is a decision, not a delay: without this the offer would come
|
|
610
|
+
* back on every single unlock, which is how people learn to click past
|
|
611
|
+
* security dialogs without reading them. Nothing about the folder's keys
|
|
612
|
+
* changes — the only effect is that filex stops asking.
|
|
613
|
+
*
|
|
614
|
+
* Reversible by `addEscrowSlot`, which is the way back for somebody who
|
|
615
|
+
* says no today and changes their mind next month.
|
|
616
|
+
*/
|
|
617
|
+
export function declineEscrowSlot(marker: E2eMarker, when: string): E2eMarker {
|
|
618
|
+
return { ...marker, esc_declined: when };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Whether this folder's owner should be offered an escrow slot, and whether
|
|
623
|
+
* they have already answered.
|
|
624
|
+
*
|
|
625
|
+
* 'n/a' nothing to offer: the installation has no escrow key, the
|
|
626
|
+
* folder already has a slot, or the marker is v1 (whose path
|
|
627
|
+
* is `upgradeMarkerV1`).
|
|
628
|
+
* 'offer' the offer applies and no answer has been recorded.
|
|
629
|
+
* 'declined' the offer applies and the owner said no. Do not ask again;
|
|
630
|
+
* leave a way back.
|
|
631
|
+
*
|
|
632
|
+
* ⚠ This deliberately does NOT look at whether the folder is unlocked. That
|
|
633
|
+
* is the caller's business, and it matters: the offer may only be shown
|
|
634
|
+
* after an unlock actually succeeded, because accepting needs the password
|
|
635
|
+
* and because asking someone who cannot open the folder to give away a key
|
|
636
|
+
* to it is asking the wrong person.
|
|
637
|
+
*/
|
|
638
|
+
export type EscrowOfferState = 'n/a' | 'offer' | 'declined';
|
|
639
|
+
|
|
640
|
+
export function escrowOfferState(
|
|
641
|
+
m: E2eMarker | null,
|
|
642
|
+
installationKid: string | null | undefined,
|
|
643
|
+
): EscrowOfferState {
|
|
644
|
+
if (!installationKid) return 'n/a';
|
|
645
|
+
if (!m || m.v !== 2 || m.esc) return 'n/a';
|
|
646
|
+
return m.esc_declined ? 'declined' : 'offer';
|
|
647
|
+
}
|
|
648
|
+
|
|
503
649
|
/** Parse marker JSON text; returns null when the shape is not a marker we read. */
|
|
504
650
|
export function parseMarker(text: string): E2eMarker | null {
|
|
505
651
|
try {
|
|
@@ -527,6 +673,48 @@ export function markerHasEscrow(m: E2eMarker | null): boolean {
|
|
|
527
673
|
return !!m && m.v === 2 && !!m.esc;
|
|
528
674
|
}
|
|
529
675
|
|
|
676
|
+
/**
|
|
677
|
+
* Why the escrow door is, or is not, on offer for this folder.
|
|
678
|
+
*
|
|
679
|
+
* 'off' this installation has no escrow key at all.
|
|
680
|
+
* 'available' the folder is sealed to THIS installation's escrow key.
|
|
681
|
+
* 'predates' the installation has an escrow key, and this folder has no
|
|
682
|
+
* escrow slot: it was created before escrow existed here.
|
|
683
|
+
* 'other-key' the folder carries an escrow slot sealed to a DIFFERENT
|
|
684
|
+
* key id — it came from another installation, via a restore
|
|
685
|
+
* or a copied data directory.
|
|
686
|
+
*
|
|
687
|
+
* ⚠ 'predates' exists because escrow can be ADOPTED by an installation
|
|
688
|
+
* that already has folders (FILEX_INSTALLATION_E2E_ESCROW_ADOPT), and
|
|
689
|
+
* adoption is not retroactive: the folder's master key was wrapped to its
|
|
690
|
+
* recovery paths when the folder was created. Before this distinction
|
|
691
|
+
* existed the dialog simply showed no Escrow tab, which is true but says
|
|
692
|
+
* nothing — an admin who knows escrow is on reads a missing tab as a bug,
|
|
693
|
+
* tries the key anyway, and learns the real answer from a failure. The UI
|
|
694
|
+
* has to say it instead.
|
|
695
|
+
*
|
|
696
|
+
* ⚠⚠ 'predates' means "not as things stand", NOT "never". The folder's
|
|
697
|
+
* owner can add a slot from inside with the password (`addEscrowSlot`,
|
|
698
|
+
* offered at unlock). Any wording built on this state has to leave that
|
|
699
|
+
* door visible, or it tells an operator their escrow key can never reach a
|
|
700
|
+
* folder whose owner could hand it over this afternoon.
|
|
701
|
+
*
|
|
702
|
+
* ⚠ 'other-key' was a quieter lie: the dialog labelled the escrow field
|
|
703
|
+
* with the INSTALLATION's key id whatever the folder's slot said, so a
|
|
704
|
+
* folder restored from another install looked openable by the key the
|
|
705
|
+
* operator has, and was not.
|
|
706
|
+
*/
|
|
707
|
+
export type EscrowAvailability = 'off' | 'available' | 'predates' | 'other-key';
|
|
708
|
+
|
|
709
|
+
export function escrowAvailability(
|
|
710
|
+
m: E2eMarker | null,
|
|
711
|
+
installationKid: string | null | undefined,
|
|
712
|
+
): EscrowAvailability {
|
|
713
|
+
if (!installationKid) return 'off';
|
|
714
|
+
if (!markerHasEscrow(m)) return 'predates';
|
|
715
|
+
return m!.esc!.kid === installationKid ? 'available' : 'other-key';
|
|
716
|
+
}
|
|
717
|
+
|
|
530
718
|
/**
|
|
531
719
|
* Check `password` against a folder marker. Resolves to the derived KEK on
|
|
532
720
|
* success, or `null` on a wrong password (GCM tag mismatch on the verify
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surucu:d1 — the filter row's model, kept out of the components so the
|
|
3
|
+
* predicate has one definition and can be tested without a DOM.
|
|
4
|
+
*
|
|
5
|
+
* ⚠ EVERY filter here answers from a field the listing row ALREADY carries
|
|
6
|
+
* (`type`, `extension`, `mime_type`, `size`, `last_modified`). That is not a
|
|
7
|
+
* shortcut, it is the constraint: `GET /api/files/manager?action=index` reads
|
|
8
|
+
* no `mime` / `min_size` / `modified_after` / `owner` parameter — it returns
|
|
9
|
+
* the whole directory and ignores anything else you send it (handlers/manager.go
|
|
10
|
+
* `List`, the complete parameter list is `action`, `path`, `filter`, `storage`,
|
|
11
|
+
* `parent`, `cache`). A control wired to a parameter the server does not read
|
|
12
|
+
* looks like it works and quietly changes nothing, which is worse than not
|
|
13
|
+
* shipping it.
|
|
14
|
+
*
|
|
15
|
+
* Because the endpoint has no `limit`/`offset` either, the listing in hand IS
|
|
16
|
+
* the folder — so filtering it client-side is complete for the folder, not a
|
|
17
|
+
* filter over "the first page". That is exactly why these three are honest and
|
|
18
|
+
* a People/owner filter is not: `nodes.owner_id` exists for quota accounting,
|
|
19
|
+
* is nil for anything a sync discovered, and is serialized by nothing
|
|
20
|
+
* (handlers/shared.go: "There is no per-node owner").
|
|
21
|
+
*/
|
|
22
|
+
import type { FileNode } from '../types/FileNode';
|
|
23
|
+
import { iconFamilyFor, type IconFamily } from './fileIcons';
|
|
24
|
+
|
|
25
|
+
export type TypeFilter =
|
|
26
|
+
| 'any'
|
|
27
|
+
| 'folder'
|
|
28
|
+
| 'document'
|
|
29
|
+
| 'spreadsheet'
|
|
30
|
+
| 'presentation'
|
|
31
|
+
| 'pdf'
|
|
32
|
+
| 'image'
|
|
33
|
+
| 'video'
|
|
34
|
+
| 'audio'
|
|
35
|
+
| 'archive'
|
|
36
|
+
| 'code';
|
|
37
|
+
|
|
38
|
+
export type ModifiedFilter = 'any' | 'today' | '7d' | '30d' | 'year';
|
|
39
|
+
|
|
40
|
+
export type SizeFilter = 'any' | 'lt1' | '1to10' | '10to100' | 'gt100';
|
|
41
|
+
|
|
42
|
+
export interface DriveFilters {
|
|
43
|
+
type: TypeFilter;
|
|
44
|
+
modified: ModifiedFilter;
|
|
45
|
+
size: SizeFilter;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const EMPTY_FILTERS: DriveFilters = { type: 'any', modified: 'any', size: 'any' };
|
|
49
|
+
|
|
50
|
+
export function filtersActive(f: DriveFilters): boolean {
|
|
51
|
+
return f.type !== 'any' || f.modified !== 'any' || f.size !== 'any';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function activeFilterCount(f: DriveFilters): number {
|
|
55
|
+
return (f.type !== 'any' ? 1 : 0) + (f.modified !== 'any' ? 1 : 0) + (f.size !== 'any' ? 1 : 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Families a type choice accepts. `iconFamilyFor` is the taxonomy the icons
|
|
59
|
+
* already use, so a row's filter group and its glyph can never disagree. */
|
|
60
|
+
const TYPE_FAMILIES: Record<Exclude<TypeFilter, 'any'>, IconFamily[]> = {
|
|
61
|
+
folder: ['folder'],
|
|
62
|
+
document: ['doc', 'text'],
|
|
63
|
+
spreadsheet: ['sheet'],
|
|
64
|
+
presentation: ['slides'],
|
|
65
|
+
pdf: ['pdf'],
|
|
66
|
+
image: ['image'],
|
|
67
|
+
video: ['video'],
|
|
68
|
+
audio: ['audio'],
|
|
69
|
+
archive: ['archive'],
|
|
70
|
+
code: ['code'],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** MIME fallback — a file with no extension still has a `mime_type` from the
|
|
74
|
+
* backend sniffer, and "IMG_0042" with no suffix is a real thing people have. */
|
|
75
|
+
const MIME_PREFIXES: Partial<Record<Exclude<TypeFilter, 'any'>, string[]>> = {
|
|
76
|
+
image: ['image/'],
|
|
77
|
+
video: ['video/'],
|
|
78
|
+
audio: ['audio/'],
|
|
79
|
+
pdf: ['application/pdf'],
|
|
80
|
+
document: ['text/plain', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessing'],
|
|
81
|
+
spreadsheet: ['text/csv', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheet'],
|
|
82
|
+
presentation: ['application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentation'],
|
|
83
|
+
archive: ['application/zip', 'application/x-tar', 'application/gzip', 'application/x-7z'],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function matchesType(n: FileNode, t: TypeFilter): boolean {
|
|
87
|
+
if (t === 'any') return true;
|
|
88
|
+
if (t === 'folder') return n.type === 'dir';
|
|
89
|
+
if (n.type === 'dir') return false;
|
|
90
|
+
if (TYPE_FAMILIES[t].includes(iconFamilyFor(n))) return true;
|
|
91
|
+
const mime = (n.mime_type || '').toLowerCase();
|
|
92
|
+
return !!mime && (MIME_PREFIXES[t] ?? []).some((p) => mime.startsWith(p));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `now` is a parameter so a test can pin the clock instead of sleeping. */
|
|
96
|
+
function matchesModified(n: FileNode, f: ModifiedFilter, now: number): boolean {
|
|
97
|
+
if (f === 'any') return true;
|
|
98
|
+
const ms = typeof n.last_modified === 'number' ? n.last_modified : 0;
|
|
99
|
+
// No timestamp = no answer. Dropping the row would hide files whose driver
|
|
100
|
+
// gave us nothing; keeping it would put them in "Today". Hiding is the
|
|
101
|
+
// honest one: the row does not satisfy "modified today", it is unknown.
|
|
102
|
+
if (!ms) return false;
|
|
103
|
+
if (f === 'year') {
|
|
104
|
+
return new Date(ms).getFullYear() === new Date(now).getFullYear();
|
|
105
|
+
}
|
|
106
|
+
if (f === 'today') {
|
|
107
|
+
const start = new Date(now);
|
|
108
|
+
start.setHours(0, 0, 0, 0);
|
|
109
|
+
return ms >= start.getTime();
|
|
110
|
+
}
|
|
111
|
+
const days = f === '7d' ? 7 : 30;
|
|
112
|
+
return ms >= now - days * 86_400_000;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const MB = 1024 * 1024;
|
|
116
|
+
|
|
117
|
+
function matchesSize(n: FileNode, f: SizeFilter): boolean {
|
|
118
|
+
if (f === 'any') return true;
|
|
119
|
+
// ⚠ Folders drop out of every size choice rather than passing through. A
|
|
120
|
+
// directory row's `size` is 0 from the projector, so "under 1 MB" would
|
|
121
|
+
// otherwise list every folder in the drive — an answer that looks like a
|
|
122
|
+
// measurement and is not one.
|
|
123
|
+
if (n.type === 'dir') return false;
|
|
124
|
+
const s = typeof n.size === 'number' ? n.size : 0;
|
|
125
|
+
if (f === 'lt1') return s < MB;
|
|
126
|
+
if (f === '1to10') return s >= MB && s < 10 * MB;
|
|
127
|
+
if (f === '10to100') return s >= 10 * MB && s < 100 * MB;
|
|
128
|
+
return s >= 100 * MB;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function applyFilters(
|
|
132
|
+
files: FileNode[],
|
|
133
|
+
f: DriveFilters,
|
|
134
|
+
now: number = Date.now(),
|
|
135
|
+
): FileNode[] {
|
|
136
|
+
// No active filter → the SAME array reference, so an unfiltered explorer
|
|
137
|
+
// renders exactly what it rendered before this file existed.
|
|
138
|
+
if (!filtersActive(f)) return files;
|
|
139
|
+
return files.filter(
|
|
140
|
+
(n) =>
|
|
141
|
+
matchesType(n, f.type) && matchesModified(n, f.modified, now) && matchesSize(n, f.size),
|
|
142
|
+
);
|
|
143
|
+
}
|
package/src/lib/listing.ts
CHANGED
|
@@ -120,14 +120,61 @@ export function makeTrashRow(adapter: string): FileNode {
|
|
|
120
120
|
* Inject the virtual `.trash` row at the front of a root listing when
|
|
121
121
|
* enabled. Returns true if a row was added (so the caller can hydrate
|
|
122
122
|
* it). Mutates `files` in place. Single source for both panes.
|
|
123
|
+
*
|
|
124
|
+
* ⚠ `isSearchResult` is not optional politeness — it is the one thing
|
|
125
|
+
* `dirname` cannot tell you. A search answers with the SCOPE it searched, so
|
|
126
|
+
* `?action=search&path=main://&filter=brief` comes back with
|
|
127
|
+
* `dirname: "main://"`, identical to the folder listing of the same path
|
|
128
|
+
* (measured). Without this flag the sentinel is unshifted into the search
|
|
129
|
+
* results, and a search for "brief" answers with `brief.md` and a folder
|
|
130
|
+
* called Trash that is not a folder, is not a hit, and cannot be searched for.
|
|
131
|
+
* Same family as the `.trash` / `.shared` sentinel bugs fixed in v0.31.0: a
|
|
132
|
+
* virtual row rendered somewhere it has no meaning.
|
|
133
|
+
*
|
|
134
|
+
* Both flags default to `false`, i.e. to the historical behaviour: a caller
|
|
135
|
+
* that never searches and has no panel — which is what an embed with
|
|
136
|
+
* `sideNav: false` is — keeps the row it has always had by saying nothing.
|
|
123
137
|
*/
|
|
138
|
+
export interface TrashRowContext {
|
|
139
|
+
/**
|
|
140
|
+
* This listing is the answer to a SEARCH, not the contents of a folder.
|
|
141
|
+
*
|
|
142
|
+
* ⚠ The one thing `dirname` cannot tell you: a search answers with the scope
|
|
143
|
+
* it searched, so `?action=search&path=main://&filter=notes` comes back
|
|
144
|
+
* carrying `dirname: "main://"`, byte-identical to that folder's listing
|
|
145
|
+
* (measured). Without this the sentinel lands among the hits.
|
|
146
|
+
*/
|
|
147
|
+
isSearchResult?: boolean;
|
|
148
|
+
/**
|
|
149
|
+
* The navigation panel is already offering Trash as a destination.
|
|
150
|
+
*
|
|
151
|
+
* ⚠ This row only ever existed because a listing had no other way into the
|
|
152
|
+
* bin. Once the panel carries a Trash entry that reason is gone and the row
|
|
153
|
+
* is a second door to the same place — drawn as a 0-byte folder that is not
|
|
154
|
+
* a folder, sitting among real ones, while the panel's own Trash entry is
|
|
155
|
+
* three inches to its left. Owner's decision, this release: do not offer the
|
|
156
|
+
* same door twice.
|
|
157
|
+
*
|
|
158
|
+
* ⚠ It is a question about the PANEL, not about the profile. The duplication
|
|
159
|
+
* is exactly as wrong in `standard` with the panel on as it is in `drive`;
|
|
160
|
+
* keying it to `uiProfile` would be a rule about the wrong thing.
|
|
161
|
+
*/
|
|
162
|
+
navOffersTrash?: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
124
165
|
export function injectTrashRow(
|
|
125
166
|
files: FileNode[],
|
|
126
167
|
adapter: string,
|
|
127
168
|
dirname: string,
|
|
128
169
|
trashVisible: boolean,
|
|
170
|
+
ctx: TrashRowContext = {},
|
|
129
171
|
): boolean {
|
|
172
|
+
// The host's own switch, and still the outer gate: `trashVisible: false`
|
|
173
|
+
// means no Trash anywhere — no row here, and no entry in the panel either,
|
|
174
|
+
// so this must be answered before anything about the panel is considered.
|
|
130
175
|
if (!trashVisible) return false;
|
|
176
|
+
if (ctx.navOffersTrash) return false;
|
|
177
|
+
if (ctx.isSearchResult) return false;
|
|
131
178
|
if (isTrashListing(dirname)) return false;
|
|
132
179
|
if (!isStorageRootDir(dirname)) return false;
|
|
133
180
|
files.unshift(makeTrashRow(adapter));
|
package/src/lib/themes.ts
CHANGED
|
@@ -105,7 +105,7 @@ export const THEMES: ThemeDef[] = [
|
|
|
105
105
|
},
|
|
106
106
|
},
|
|
107
107
|
{
|
|
108
|
-
//
|
|
108
|
+
// Night Blue — deep indigo/navy.
|
|
109
109
|
id: 'night',
|
|
110
110
|
nameKey: 'theme.name.night',
|
|
111
111
|
light: {
|
|
@@ -140,7 +140,7 @@ export const THEMES: ThemeDef[] = [
|
|
|
140
140
|
},
|
|
141
141
|
},
|
|
142
142
|
{
|
|
143
|
-
//
|
|
143
|
+
// Forest — calm greens.
|
|
144
144
|
id: 'forest',
|
|
145
145
|
nameKey: 'theme.name.forest',
|
|
146
146
|
light: {
|
|
@@ -175,7 +175,7 @@ export const THEMES: ThemeDef[] = [
|
|
|
175
175
|
},
|
|
176
176
|
},
|
|
177
177
|
{
|
|
178
|
-
//
|
|
178
|
+
// Amber — warm amber/bronze.
|
|
179
179
|
id: 'amber',
|
|
180
180
|
nameKey: 'theme.name.amber',
|
|
181
181
|
light: {
|
|
@@ -210,7 +210,7 @@ export const THEMES: ThemeDef[] = [
|
|
|
210
210
|
},
|
|
211
211
|
},
|
|
212
212
|
{
|
|
213
|
-
//
|
|
213
|
+
// Lilac — soft purple.
|
|
214
214
|
id: 'lilac',
|
|
215
215
|
nameKey: 'theme.name.lilac',
|
|
216
216
|
light: {
|
|
@@ -245,7 +245,7 @@ export const THEMES: ThemeDef[] = [
|
|
|
245
245
|
},
|
|
246
246
|
},
|
|
247
247
|
{
|
|
248
|
-
//
|
|
248
|
+
// High Contrast — black/white with strong borders, saturated accents.
|
|
249
249
|
id: 'contrast',
|
|
250
250
|
nameKey: 'theme.name.contrast',
|
|
251
251
|
light: {
|
|
@@ -284,7 +284,7 @@ export const THEMES: ThemeDef[] = [
|
|
|
284
284
|
},
|
|
285
285
|
},
|
|
286
286
|
{
|
|
287
|
-
//
|
|
287
|
+
// Soft Gray — desaturated, quiet neutral.
|
|
288
288
|
id: 'gray',
|
|
289
289
|
nameKey: 'theme.name.gray',
|
|
290
290
|
light: {
|
|
@@ -319,7 +319,7 @@ export const THEMES: ThemeDef[] = [
|
|
|
319
319
|
},
|
|
320
320
|
},
|
|
321
321
|
{
|
|
322
|
-
// Terminal
|
|
322
|
+
// Terminal Green — phosphor green, monospace face for the full CRT vibe.
|
|
323
323
|
id: 'terminal',
|
|
324
324
|
nameKey: 'theme.name.terminal',
|
|
325
325
|
light: {
|
package/src/lib/transfer.ts
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Three gestures land on the same wire call — drag a row onto a folder, paste
|
|
5
5
|
* after Ctrl+X, paste after Ctrl+C — and they do NOT mean the same thing once
|
|
6
|
-
* the two ends live in different
|
|
6
|
+
* the two ends live in different storages:
|
|
7
7
|
*
|
|
8
8
|
* • Ctrl+C → paste is a copy, wherever it lands.
|
|
9
|
-
* • Ctrl+X → paste is a move, wherever it lands. Across
|
|
9
|
+
* • Ctrl+X → paste is a move, wherever it lands. Across storages the server
|
|
10
10
|
* streams the bytes over and then deletes the original; before v0.27.0 the
|
|
11
11
|
* explorer quietly downgraded this to a copy and the user was left with the
|
|
12
12
|
* file in both places, believing they had moved it.
|
|
13
|
-
* • Dragging is a move inside one
|
|
13
|
+
* • Dragging is a move inside one storage and a COPY across two — the rule
|
|
14
14
|
* Explorer and Finder have taught everyone: a drag between drives copies.
|
|
15
15
|
*
|
|
16
16
|
* Kept as a pure function so both the pane path and the clipboard path ask the
|
|
@@ -23,13 +23,13 @@ export function wireAdapterOf(p: string): string {
|
|
|
23
23
|
return i === -1 ? '' : p.slice(0, i);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** What the caller asked for. `auto` is a drag: let the
|
|
26
|
+
/** What the caller asked for. `auto` is a drag: let the storages decide. */
|
|
27
27
|
export type TransferIntent = 'auto' | 'copy' | 'move';
|
|
28
28
|
|
|
29
29
|
export interface TransferPlan {
|
|
30
30
|
/** What to actually ask the server for. */
|
|
31
31
|
kind: 'copy' | 'move';
|
|
32
|
-
/** True when at least one source lives in another
|
|
32
|
+
/** True when at least one source lives in another storage than the target. */
|
|
33
33
|
cross: boolean;
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -40,7 +40,7 @@ export function resolveTransfer(
|
|
|
40
40
|
): TransferPlan {
|
|
41
41
|
const target = wireAdapterOf(targetWire);
|
|
42
42
|
// A source with no prefix is a legacy embedder's bare path: it can only mean
|
|
43
|
-
// "the same
|
|
43
|
+
// "the same storage I am looking at", so it never counts as crossing.
|
|
44
44
|
const cross = sources.some((p) => {
|
|
45
45
|
const a = wireAdapterOf(p);
|
|
46
46
|
return a !== '' && target !== '' && a !== target;
|