@brftech/filex-core 0.31.0 → 0.32.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/filex-core.js +9289 -8103
- 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 +186 -8
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +510 -12
- package/src/components/CommandPalette.vue +18 -2
- package/src/components/E2eRecoveryUnlockModal.vue +29 -4
- package/src/components/FilterBar.vue +244 -0
- package/src/components/GalleryView.vue +11 -0
- package/src/components/GridView.vue +46 -3
- package/src/components/InspectorPanel.vue +231 -8
- package/src/components/ListView.vue +10 -1
- package/src/components/SecondaryPane.vue +15 -1
- package/src/components/SideNav.vue +161 -1
- package/src/components/Toolbar.vue +235 -49
- package/src/components/ViewSwitcher.vue +81 -0
- package/src/composables/useFileApi.ts +45 -0
- package/src/index.ts +6 -0
- package/src/lib/e2ecrypto.ts +191 -3
- package/src/lib/fileFilters.ts +143 -0
- package/src/lib/listing.ts +47 -0
- package/src/locales/en.ts +83 -0
- package/src/locales/tr.ts +83 -0
- package/src/modals/PermissionsModal.vue +18 -2
- package/src/styles/base.css +491 -0
- package/src/types/ExplorerConfig.ts +17 -1
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/locales/en.ts
CHANGED
|
@@ -467,6 +467,10 @@ export const en: Record<string, string> = {
|
|
|
467
467
|
'e2e.recover.escrow_warn_body':
|
|
468
468
|
'Opening a folder with the escrow key notifies its owner. Use it only when you are entitled to.',
|
|
469
469
|
'e2e.recover.escrow_kid': 'Escrow key',
|
|
470
|
+
'e2e.recover.escrow_predates':
|
|
471
|
+
'This installation has an escrow key, but this folder does not. It was created before escrow was turned on here, so its master key was never wrapped to the escrow identity. The escrow key will not open it. Nothing the operator can do changes that — adding a slot needs this folder\u2019s password, which the server has never had. The folder\u2019s owner can grant it: unlock with the password, then choose to give the operator a key. Until they do, use the password or the recovery key.',
|
|
472
|
+
'e2e.recover.escrow_other_key':
|
|
473
|
+
'This folder carries an escrow key from a DIFFERENT installation. It was created elsewhere and restored here, so this installation\u2019s escrow key does not open it. Only the private key belonging to the escrow key it was sealed to can.',
|
|
470
474
|
'e2e.recover.escrow_placeholder': 'Paste the escrow private key (PKCS#8, base64 or PEM)',
|
|
471
475
|
'e2e.recover.bad_format':
|
|
472
476
|
'That does not look like a recovery key. It is 32 characters in eight groups of four.',
|
|
@@ -481,6 +485,25 @@ export const en: Record<string, string> = {
|
|
|
481
485
|
'e2e.recover.escrow_done': 'Unlocked with the escrow key — the owner has been notified',
|
|
482
486
|
'e2e.recover.unlock': 'Unlock',
|
|
483
487
|
'e2e.recover.busy': 'Unlocking…',
|
|
488
|
+
'e2e.escrowoffer.title': 'Give the operator a key to this folder?',
|
|
489
|
+
'e2e.escrowoffer.body':
|
|
490
|
+
'This folder was created before key escrow was turned on here, so the operator of this installation cannot open it. Right now — while your password is in memory — filex can seal this folder to the escrow key. Your files are not re-encrypted or moved; only the key file changes.',
|
|
491
|
+
'e2e.escrowoffer.consequence':
|
|
492
|
+
'What this means: the operator gains a second, permanent way into this folder, without your password. You are notified when that key is used, but that notification is an announcement rather than a control.',
|
|
493
|
+
'e2e.escrowoffer.learn_more': 'What escrow can and cannot do',
|
|
494
|
+
'e2e.escrowoffer.password_label': 'Folder password',
|
|
495
|
+
'e2e.escrowoffer.password_required': 'Enter the folder password.',
|
|
496
|
+
'e2e.escrowoffer.wrong_password': 'That is not this folder\u2019s password.',
|
|
497
|
+
'e2e.escrowoffer.accept': 'Give the operator a key',
|
|
498
|
+
'e2e.escrowoffer.decline': 'No, keep it to myself',
|
|
499
|
+
'e2e.escrowoffer.cancel': 'Cancel',
|
|
500
|
+
'e2e.escrowoffer.busy': 'Sealing…',
|
|
501
|
+
'e2e.escrowoffer.done': 'The operator can now open this folder with the escrow key',
|
|
502
|
+
'e2e.escrowoffer.declined_toast': 'Kept to yourself. filex will not ask again for this folder.',
|
|
503
|
+
'e2e.escrowoffer.decline_failed':
|
|
504
|
+
'Could not record your answer, so filex will ask again next time. Nothing about the folder changed.',
|
|
505
|
+
'e2e.escrowoffer.failed': 'Could not give the operator a key to this folder',
|
|
506
|
+
'e2e.escrowoffer.strip_action': 'Escrow key…',
|
|
484
507
|
'e2e.upgrade.title': 'This folder has no recovery key',
|
|
485
508
|
'e2e.upgrade.body':
|
|
486
509
|
'It was created before recovery keys existed, so its password is the only way in. Right now — and only right now, while the password is in memory — filex can add one. Your files are not re-encrypted or moved.',
|
|
@@ -943,4 +966,64 @@ export const en: Record<string, string> = {
|
|
|
943
966
|
'conn.tokens.expiryNever': 'never',
|
|
944
967
|
'conn.tokens.capNote': 'Your account and its permissions are the ceiling — asking for more than you have is refused, not granted.',
|
|
945
968
|
/* === /gezinti:g1 === */
|
|
969
|
+
|
|
970
|
+
/* === surucu:d1 — the Drive shell (uiProfile: 'drive', GitHub #14) ===== */
|
|
971
|
+
'drive.new': 'New',
|
|
972
|
+
'drive.new.upload': 'Upload files',
|
|
973
|
+
'drive.new.folder': 'New folder',
|
|
974
|
+
'drive.new.request': 'Request files',
|
|
975
|
+
'drive.search.placeholder': 'Search in {scope}',
|
|
976
|
+
'drive.search.placeholder_all': 'Search your files',
|
|
977
|
+
'drive.search.hint_title': 'Search everywhere and run commands ({combo})',
|
|
978
|
+
'drive.storage.used': '{used} of {total} used',
|
|
979
|
+
'drive.storage.used_unlimited': '{used} used',
|
|
980
|
+
'drive.storage.label': 'Storage',
|
|
981
|
+
'drive.section.folders': 'Folders',
|
|
982
|
+
'drive.section.files': 'Files',
|
|
983
|
+
'filter.aria': 'Filters',
|
|
984
|
+
'filter.type': 'Type',
|
|
985
|
+
'filter.type.any': 'Any type',
|
|
986
|
+
'filter.type.folder': 'Folders',
|
|
987
|
+
'filter.type.document': 'Documents',
|
|
988
|
+
'filter.type.spreadsheet': 'Spreadsheets',
|
|
989
|
+
'filter.type.presentation': 'Presentations',
|
|
990
|
+
'filter.type.pdf': 'PDFs',
|
|
991
|
+
'filter.type.image': 'Images',
|
|
992
|
+
'filter.type.video': 'Videos',
|
|
993
|
+
'filter.type.audio': 'Audio',
|
|
994
|
+
'filter.type.archive': 'Archives',
|
|
995
|
+
'filter.type.code': 'Code',
|
|
996
|
+
'filter.modified': 'Modified',
|
|
997
|
+
'filter.modified.any': 'Any time',
|
|
998
|
+
'filter.modified.today': 'Today',
|
|
999
|
+
'filter.modified.7d': 'Last 7 days',
|
|
1000
|
+
'filter.modified.30d': 'Last 30 days',
|
|
1001
|
+
'filter.modified.year': 'This year',
|
|
1002
|
+
'filter.size': 'Size',
|
|
1003
|
+
'filter.size.any': 'Any size',
|
|
1004
|
+
'filter.size.lt1': 'Under 1 MB',
|
|
1005
|
+
'filter.size.1to10': '1 - 10 MB',
|
|
1006
|
+
'filter.size.10to100': '10 - 100 MB',
|
|
1007
|
+
'filter.size.gt100': 'Over 100 MB',
|
|
1008
|
+
'filter.clear': 'Clear filters',
|
|
1009
|
+
'filter.empty.title': 'Nothing here matches those filters',
|
|
1010
|
+
'filter.empty.hint': 'Clear a filter, or search everywhere with the field above.',
|
|
1011
|
+
'filter.count': '{shown} of {total}',
|
|
1012
|
+
'inspector.tab.details': 'Details',
|
|
1013
|
+
'inspector.tab.activity': 'Activity',
|
|
1014
|
+
'inspector.people': 'People with access',
|
|
1015
|
+
'inspector.people.you': 'You',
|
|
1016
|
+
'inspector.people.inherited': 'from a parent folder',
|
|
1017
|
+
'inspector.people.empty': 'Only you, and anyone with a link below.',
|
|
1018
|
+
'inspector.people.manage': 'Manage access',
|
|
1019
|
+
'inspector.link': 'Shared link',
|
|
1020
|
+
'inspector.link.none': 'Not shared',
|
|
1021
|
+
'inspector.link.create': 'Create link',
|
|
1022
|
+
'inspector.link.copy': 'Copy link',
|
|
1023
|
+
'inspector.activity.empty': 'No activity recorded for this item yet.',
|
|
1024
|
+
'inspector.activity.hint': 'Versions are written when a file changes; comments are written by people.',
|
|
1025
|
+
'inspector.activity.version': 'Version {n} saved',
|
|
1026
|
+
'inspector.activity.comment': 'Comment by {who}',
|
|
1027
|
+
'inspector.activity.select': 'Select a file or folder to see its activity.',
|
|
1028
|
+
/* === /surucu:d1 === */
|
|
946
1029
|
};
|
package/src/locales/tr.ts
CHANGED
|
@@ -467,6 +467,10 @@ export const tr: Record<string, string> = {
|
|
|
467
467
|
'e2e.recover.escrow_warn_body':
|
|
468
468
|
'Emanet anahtarıyla açılan klasörün sahibine bildirim gönderilir. Yalnız yetkiniz olduğunda kullanın.',
|
|
469
469
|
'e2e.recover.escrow_kid': 'Emanet anahtarı',
|
|
470
|
+
'e2e.recover.escrow_predates':
|
|
471
|
+
'Bu kurulumun emanet anahtarı var ama bu klasörün yok. Klasör, emanet burada açılmadan önce oluşturulmuş; ana anahtarı emanet kimliğine hiç sarmalanmadı. Emanet anahtarı bu klasörü açmaz. İşletmecinin yapabileceği hiçbir şey bunu değiştirmez — sarmalama için klasörün parolası gerekir ve sunucu onu hiçbir zaman görmedi. Klasörün sahibi verebilir: parolayla kilidi açıp işletmeciye anahtar vermeyi seçebilir. O yapılana kadar parolayı ya da kurtarma anahtarını kullanın.',
|
|
472
|
+
'e2e.recover.escrow_other_key':
|
|
473
|
+
'Bu klasör BAŞKA bir kurulumun emanet anahtarını taşıyor. Başka yerde oluşturulup buraya geri yüklenmiş, bu yüzden bu kurulumun emanet anahtarı onu açmaz. Yalnızca sarmalandığı emanet anahtarının özel yarısı açabilir.',
|
|
470
474
|
'e2e.recover.escrow_placeholder': 'Emanet özel anahtarını yapıştırın (PKCS#8, base64 ya da PEM)',
|
|
471
475
|
'e2e.recover.bad_format':
|
|
472
476
|
'Bu bir kurtarma anahtarına benzemiyor. Dörtlü sekiz grup, toplam 32 karakter olmalı.',
|
|
@@ -481,6 +485,25 @@ export const tr: Record<string, string> = {
|
|
|
481
485
|
'e2e.recover.escrow_done': 'Emanet anahtarıyla açıldı — klasör sahibine bildirildi',
|
|
482
486
|
'e2e.recover.unlock': 'Kilidi aç',
|
|
483
487
|
'e2e.recover.busy': 'Açılıyor…',
|
|
488
|
+
'e2e.escrowoffer.title': 'İşletmeciye bu klasörün anahtarı verilsin mi?',
|
|
489
|
+
'e2e.escrowoffer.body':
|
|
490
|
+
'Bu klasör, burada anahtar emaneti açılmadan önce oluşturulmuş; bu yüzden kurulumun işletmecisi onu açamıyor. Şu anda — parolanız bellekteyken — filex bu klasörü emanet anahtarına sarmalayabilir. Dosyalarınız yeniden şifrelenmez, hiçbir yere taşınmaz; yalnızca anahtar dosyası değişir.',
|
|
491
|
+
'e2e.escrowoffer.consequence':
|
|
492
|
+
'Bunun anlamı: işletmeci, parolanız olmadan bu klasöre giren ikinci ve kalıcı bir yol kazanır. O anahtar kullanıldığında size bildirim gelir, ama bu bildirim bir denetim değil yalnızca bir duyurudur.',
|
|
493
|
+
'e2e.escrowoffer.learn_more': 'Emanet anahtarı neyi yapar, neyi yapamaz',
|
|
494
|
+
'e2e.escrowoffer.password_label': 'Klasör parolası',
|
|
495
|
+
'e2e.escrowoffer.password_required': 'Klasör parolasını girin.',
|
|
496
|
+
'e2e.escrowoffer.wrong_password': 'Bu, bu klasörün parolası değil.',
|
|
497
|
+
'e2e.escrowoffer.accept': 'İşletmeciye anahtar ver',
|
|
498
|
+
'e2e.escrowoffer.decline': 'Hayır, bende kalsın',
|
|
499
|
+
'e2e.escrowoffer.cancel': 'Vazgeç',
|
|
500
|
+
'e2e.escrowoffer.busy': 'Sarmalanıyor…',
|
|
501
|
+
'e2e.escrowoffer.done': 'İşletmeci artık bu klasörü emanet anahtarıyla açabilir',
|
|
502
|
+
'e2e.escrowoffer.declined_toast': 'Sizde kaldı. filex bu klasör için bir daha sormayacak.',
|
|
503
|
+
'e2e.escrowoffer.decline_failed':
|
|
504
|
+
'Cevabınız kaydedilemedi, bu yüzden filex bir dahakine yine soracak. Klasörde hiçbir şey değişmedi.',
|
|
505
|
+
'e2e.escrowoffer.failed': 'İşletmeciye bu klasörün anahtarı verilemedi',
|
|
506
|
+
'e2e.escrowoffer.strip_action': 'Emanet anahtarı…',
|
|
484
507
|
'e2e.upgrade.title': 'Bu klasörün kurtarma anahtarı yok',
|
|
485
508
|
'e2e.upgrade.body':
|
|
486
509
|
'Kurtarma anahtarları eklenmeden önce oluşturulmuş, bu yüzden tek giriş yolu parolası. Şu anda — ve yalnız şu anda, parola bellekteyken — filex bir tane ekleyebilir. Dosyalarınız yeniden şifrelenmez, hiçbir yere taşınmaz.',
|
|
@@ -946,4 +969,64 @@ export const tr: Record<string, string> = {
|
|
|
946
969
|
'conn.tokens.expiryNever': 'süresiz',
|
|
947
970
|
'conn.tokens.capNote': 'Tavan hesabın ve yetkilerin — sahip olduğundan fazlasını istemek reddedilir, verilmez.',
|
|
948
971
|
/* === /gezinti:g1 === */
|
|
972
|
+
|
|
973
|
+
/* === surucu:d1 — Sürücü kabuğu (uiProfile: 'drive', GitHub #14) ======== */
|
|
974
|
+
'drive.new': 'Yeni',
|
|
975
|
+
'drive.new.upload': 'Dosya yükle',
|
|
976
|
+
'drive.new.folder': 'Yeni klasör',
|
|
977
|
+
'drive.new.request': 'Dosya iste',
|
|
978
|
+
'drive.search.placeholder': '{scope} içinde ara',
|
|
979
|
+
'drive.search.placeholder_all': 'Dosyalarında ara',
|
|
980
|
+
'drive.search.hint_title': 'Her yerde ara ve komut çalıştır ({combo})',
|
|
981
|
+
'drive.storage.used': '{total} alanın {used} kadarı dolu',
|
|
982
|
+
'drive.storage.used_unlimited': '{used} kullanılıyor',
|
|
983
|
+
'drive.storage.label': 'Depolama',
|
|
984
|
+
'drive.section.folders': 'Klasörler',
|
|
985
|
+
'drive.section.files': 'Dosyalar',
|
|
986
|
+
'filter.aria': 'Süzgeçler',
|
|
987
|
+
'filter.type': 'Tür',
|
|
988
|
+
'filter.type.any': 'Her tür',
|
|
989
|
+
'filter.type.folder': 'Klasörler',
|
|
990
|
+
'filter.type.document': 'Belgeler',
|
|
991
|
+
'filter.type.spreadsheet': 'Hesap tabloları',
|
|
992
|
+
'filter.type.presentation': 'Sunumlar',
|
|
993
|
+
'filter.type.pdf': 'PDF’ler',
|
|
994
|
+
'filter.type.image': 'Görseller',
|
|
995
|
+
'filter.type.video': 'Videolar',
|
|
996
|
+
'filter.type.audio': 'Ses dosyaları',
|
|
997
|
+
'filter.type.archive': 'Arşivler',
|
|
998
|
+
'filter.type.code': 'Kod',
|
|
999
|
+
'filter.modified': 'Değiştirilme',
|
|
1000
|
+
'filter.modified.any': 'Her zaman',
|
|
1001
|
+
'filter.modified.today': 'Bugün',
|
|
1002
|
+
'filter.modified.7d': 'Son 7 gün',
|
|
1003
|
+
'filter.modified.30d': 'Son 30 gün',
|
|
1004
|
+
'filter.modified.year': 'Bu yıl',
|
|
1005
|
+
'filter.size': 'Boyut',
|
|
1006
|
+
'filter.size.any': 'Her boyut',
|
|
1007
|
+
'filter.size.lt1': '1 MB altı',
|
|
1008
|
+
'filter.size.1to10': '1 - 10 MB',
|
|
1009
|
+
'filter.size.10to100': '10 - 100 MB',
|
|
1010
|
+
'filter.size.gt100': '100 MB üstü',
|
|
1011
|
+
'filter.clear': 'Süzgeçleri temizle',
|
|
1012
|
+
'filter.empty.title': 'Bu süzgeçlere uyan bir şey yok',
|
|
1013
|
+
'filter.empty.hint': 'Bir süzgeci kaldır ya da yukarıdaki alandan her yerde ara.',
|
|
1014
|
+
'filter.count': '{total} ögeden {shown} tanesi',
|
|
1015
|
+
'inspector.tab.details': 'Ayrıntılar',
|
|
1016
|
+
'inspector.tab.activity': 'Etkinlik',
|
|
1017
|
+
'inspector.people': 'Erişimi olan kişiler',
|
|
1018
|
+
'inspector.people.you': 'Sen',
|
|
1019
|
+
'inspector.people.inherited': 'üst klasörden',
|
|
1020
|
+
'inspector.people.empty': 'Yalnızca sen ve aşağıdaki bağlantıya sahip olanlar.',
|
|
1021
|
+
'inspector.people.manage': 'Erişimi yönet',
|
|
1022
|
+
'inspector.link': 'Paylaşım bağlantısı',
|
|
1023
|
+
'inspector.link.none': 'Paylaşılmadı',
|
|
1024
|
+
'inspector.link.create': 'Bağlantı oluştur',
|
|
1025
|
+
'inspector.link.copy': 'Bağlantıyı kopyala',
|
|
1026
|
+
'inspector.activity.empty': 'Bu öge için henüz etkinlik kaydı yok.',
|
|
1027
|
+
'inspector.activity.hint': 'Sürümler dosya değiştikçe, yorumlar ise insanlar yazdıkça birikir.',
|
|
1028
|
+
'inspector.activity.version': '{n}. sürüm kaydedildi',
|
|
1029
|
+
'inspector.activity.comment': '{who} yorum yaptı',
|
|
1030
|
+
'inspector.activity.select': 'Etkinliğini görmek için bir dosya ya da klasör seç.',
|
|
1031
|
+
/* === /surucu:d1 === */
|
|
949
1032
|
};
|