@brftech/filex-core 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ArchiveViewer-EGgNqLPE.js → ArchiveViewer-Qi4L2jj9.js} +2 -2
- package/dist/{ArchiveViewer-EGgNqLPE.js.map → ArchiveViewer-Qi4L2jj9.js.map} +1 -1
- package/dist/{CsvViewer-BvriJ42M.js → CsvViewer-IOIrJDsJ.js} +2 -2
- package/dist/{CsvViewer-BvriJ42M.js.map → CsvViewer-IOIrJDsJ.js.map} +1 -1
- package/dist/{DrawioViewer-B_bSPsWX.js → DrawioViewer-D0KtUQBO.js} +2 -2
- package/dist/{DrawioViewer-B_bSPsWX.js.map → DrawioViewer-D0KtUQBO.js.map} +1 -1
- package/dist/{EpubViewer-hl68h4gG.js → EpubViewer-DJOXiOi4.js} +2 -2
- package/dist/{EpubViewer-hl68h4gG.js.map → EpubViewer-DJOXiOi4.js.map} +1 -1
- package/dist/{IpynbViewer-lhwOjXK7.js → IpynbViewer-D16jXVJc.js} +2 -2
- package/dist/{IpynbViewer-lhwOjXK7.js.map → IpynbViewer-D16jXVJc.js.map} +1 -1
- package/dist/{MermaidViewer-H2Z6AYXg.js → MermaidViewer-BnSaVg1L.js} +2 -2
- package/dist/{MermaidViewer-H2Z6AYXg.js.map → MermaidViewer-BnSaVg1L.js.map} +1 -1
- package/dist/{PsdViewer-Bsk-11yH.js → PsdViewer-BUerXaQA.js} +2 -2
- package/dist/{PsdViewer-Bsk-11yH.js.map → PsdViewer-BUerXaQA.js.map} +1 -1
- package/dist/{TiffViewer-D-j9vycG.js → TiffViewer-D0Qn3IV-.js} +2 -2
- package/dist/{TiffViewer-D-j9vycG.js.map → TiffViewer-D0Qn3IV-.js.map} +1 -1
- package/dist/{Viewer3D-Bcr70rTe.js → Viewer3D-BtRK-k1B.js} +2 -2
- package/dist/{Viewer3D-Bcr70rTe.js.map → Viewer3D-BtRK-k1B.js.map} +1 -1
- package/dist/filex-core.js +66 -50
- package/dist/filex-core.umd.cjs +35 -35
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-CGNE1bSv.js +11802 -0
- package/dist/index-CGNE1bSv.js.map +1 -0
- package/dist/index.d.ts +184 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +416 -15
- package/src/components/EncryptedFolderModal.vue +125 -0
- package/src/components/GalleryView.vue +1 -0
- package/src/components/GridView.vue +1 -0
- package/src/components/ListView.vue +1 -0
- package/src/components/SecondaryPane.vue +72 -78
- package/src/components/Toolbar.vue +112 -3
- package/src/composables/useFileApi.ts +7 -0
- package/src/composables/useKeyboardShortcuts.ts +6 -0
- package/src/composables/useTabs.ts +11 -2
- package/src/index.ts +21 -0
- package/src/lib/e2ecrypto.ts +299 -0
- package/src/locales/en.ts +34 -0
- package/src/locales/tr.ts +34 -0
- package/src/modals/NewFolderModal.vue +18 -0
- package/src/modals/PreviewModal.vue +7 -2
- package/src/styles/base.css +126 -1
- package/src/styles/variables.css +2 -2
- package/src/types/FileNode.ts +3 -0
- package/dist/index-C_ZSnk_I.js +0 -11109
- package/dist/index-C_ZSnk_I.js.map +0 -1
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* e2ecrypto — client-side crypto for E2E-encrypted folders (wiring:e2).
|
|
3
|
+
*
|
|
4
|
+
* WebCrypto ONLY — zero dependencies. Design doc: docs/E2E-ENCRYPTION.md.
|
|
5
|
+
*
|
|
6
|
+
* Scheme (v1):
|
|
7
|
+
* folder password ─PBKDF2-SHA256(600k iter, per-folder 16B salt)─▶ KEK (AES-256-GCM)
|
|
8
|
+
* per-file random 32B DEK (AES-256-GCM) encrypts the content one-shot;
|
|
9
|
+
* the DEK is wrapped with the KEK and stored in the file's own header.
|
|
10
|
+
*
|
|
11
|
+
* File layout ('filexe2e' magic, fixed 97-byte header):
|
|
12
|
+
* [0..8) magic "filexe2e"
|
|
13
|
+
* [8] version 0x01
|
|
14
|
+
* [9..21) wrapIV (12B) — GCM IV of the DEK wrap
|
|
15
|
+
* [21..69) wrappedDEK (48B = 32B DEK + 16B GCM tag)
|
|
16
|
+
* [69..81) dataIV (12B) — GCM IV of the content
|
|
17
|
+
* [81..97) reserved (zeros; v2 chunking/metadata)
|
|
18
|
+
* [97..) ciphertext (content + 16B GCM tag)
|
|
19
|
+
*
|
|
20
|
+
* Folder marker `.filex-e2e.json` at the encrypted-folder root:
|
|
21
|
+
* { v:1, salt:<b64 16B>, iter:600000, verify:<b64 12B IV || GCM('filex-e2e-verify-v1')> }
|
|
22
|
+
*
|
|
23
|
+
* The KEK NEVER leaves memory — no storage of any kind. Password loss is
|
|
24
|
+
* data loss by design (no recovery path exists anywhere).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const E2E_MARKER_NAME = '.filex-e2e.json';
|
|
28
|
+
export const E2E_MAGIC = 'filexe2e';
|
|
29
|
+
export const E2E_VERSION = 1;
|
|
30
|
+
export const E2E_DEFAULT_ITERATIONS = 600_000;
|
|
31
|
+
export const E2E_MIN_ITERATIONS = 600_000;
|
|
32
|
+
/** MVP single-shot in-memory ceiling — larger uploads are refused with a warning. */
|
|
33
|
+
export const E2E_MAX_FILE_BYTES = 200 * 1024 * 1024;
|
|
34
|
+
export const E2E_MIN_PASSWORD_LEN = 8;
|
|
35
|
+
|
|
36
|
+
const VERIFY_PLAINTEXT = 'filex-e2e-verify-v1';
|
|
37
|
+
const MAGIC_BYTES = new TextEncoder().encode(E2E_MAGIC); // 8 bytes
|
|
38
|
+
const HEADER_LEN = 97;
|
|
39
|
+
const WRAP_IV_OFF = 9;
|
|
40
|
+
const WRAPPED_DEK_OFF = 21;
|
|
41
|
+
const WRAPPED_DEK_LEN = 48;
|
|
42
|
+
const DATA_IV_OFF = 69;
|
|
43
|
+
const IV_LEN = 12;
|
|
44
|
+
|
|
45
|
+
export interface E2eMarker {
|
|
46
|
+
v: number;
|
|
47
|
+
salt: string; // base64
|
|
48
|
+
iter: number;
|
|
49
|
+
verify: string; // base64: 12B IV || AES-GCM ciphertext of VERIFY_PLAINTEXT
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Thrown on wrong password / corrupted ciphertext (GCM tag mismatch). */
|
|
53
|
+
export class E2eDecryptError extends Error {
|
|
54
|
+
constructor(msg = 'e2e: decrypt failed') {
|
|
55
|
+
super(msg);
|
|
56
|
+
this.name = 'E2eDecryptError';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------
|
|
61
|
+
// base64 helpers (no deps)
|
|
62
|
+
// ---------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export function bytesToB64(b: Uint8Array): string {
|
|
65
|
+
let s = '';
|
|
66
|
+
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
|
67
|
+
return btoa(s);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function b64ToBytes(s: string): Uint8Array {
|
|
71
|
+
const raw = atob(s);
|
|
72
|
+
const out = new Uint8Array(raw.length);
|
|
73
|
+
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------
|
|
78
|
+
// Key derivation
|
|
79
|
+
// ---------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Derive the folder KEK from a password. Returns a NON-extractable
|
|
83
|
+
* AES-256-GCM CryptoKey — it can encrypt/decrypt but never be exported,
|
|
84
|
+
* so even a same-origin script can't read the raw key material back.
|
|
85
|
+
*/
|
|
86
|
+
export async function deriveKek(
|
|
87
|
+
password: string,
|
|
88
|
+
salt: Uint8Array,
|
|
89
|
+
iterations: number,
|
|
90
|
+
): Promise<CryptoKey> {
|
|
91
|
+
const material = await crypto.subtle.importKey(
|
|
92
|
+
'raw',
|
|
93
|
+
new TextEncoder().encode(password),
|
|
94
|
+
'PBKDF2',
|
|
95
|
+
false,
|
|
96
|
+
['deriveKey'],
|
|
97
|
+
);
|
|
98
|
+
return crypto.subtle.deriveKey(
|
|
99
|
+
// Copy into a fresh ArrayBuffer-backed view — TS 5.9 BufferSource typing
|
|
100
|
+
// rejects Uint8Array<ArrayBufferLike> that may wrap a SharedArrayBuffer.
|
|
101
|
+
{ name: 'PBKDF2', salt: new Uint8Array(salt).buffer as ArrayBuffer, iterations, hash: 'SHA-256' },
|
|
102
|
+
material,
|
|
103
|
+
{ name: 'AES-GCM', length: 256 },
|
|
104
|
+
false, // non-extractable
|
|
105
|
+
['encrypt', 'decrypt'],
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------
|
|
110
|
+
// Marker create / verify
|
|
111
|
+
// ---------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/** Create a fresh folder marker for `password` (also returns the derived KEK). */
|
|
114
|
+
export async function createMarker(
|
|
115
|
+
password: string,
|
|
116
|
+
iterations: number = E2E_DEFAULT_ITERATIONS,
|
|
117
|
+
): Promise<{ marker: E2eMarker; kek: CryptoKey }> {
|
|
118
|
+
const iter = Math.max(E2E_MIN_ITERATIONS, iterations);
|
|
119
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
120
|
+
const kek = await deriveKek(password, salt, iter);
|
|
121
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
122
|
+
const ct = new Uint8Array(
|
|
123
|
+
await crypto.subtle.encrypt(
|
|
124
|
+
{ name: 'AES-GCM', iv: iv.buffer as ArrayBuffer },
|
|
125
|
+
kek,
|
|
126
|
+
new TextEncoder().encode(VERIFY_PLAINTEXT),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
const verify = new Uint8Array(IV_LEN + ct.length);
|
|
130
|
+
verify.set(iv, 0);
|
|
131
|
+
verify.set(ct, IV_LEN);
|
|
132
|
+
return {
|
|
133
|
+
marker: { v: E2E_VERSION, salt: bytesToB64(salt), iter, verify: bytesToB64(verify) },
|
|
134
|
+
kek,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Parse marker JSON text; returns null when the shape is not a v1 marker. */
|
|
139
|
+
export function parseMarker(text: string): E2eMarker | null {
|
|
140
|
+
try {
|
|
141
|
+
const m = JSON.parse(text) as E2eMarker;
|
|
142
|
+
if (!m || m.v !== E2E_VERSION) return null;
|
|
143
|
+
if (typeof m.salt !== 'string' || typeof m.verify !== 'string') return null;
|
|
144
|
+
if (typeof m.iter !== 'number' || m.iter < 1) return null;
|
|
145
|
+
return m;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check `password` against a folder marker. Resolves to the derived KEK on
|
|
153
|
+
* success, or `null` on a wrong password (GCM tag mismatch on the verify
|
|
154
|
+
* blob). Never talks to any server.
|
|
155
|
+
*/
|
|
156
|
+
export async function verifyPassword(
|
|
157
|
+
marker: E2eMarker,
|
|
158
|
+
password: string,
|
|
159
|
+
): Promise<CryptoKey | null> {
|
|
160
|
+
const salt = b64ToBytes(marker.salt);
|
|
161
|
+
const kek = await deriveKek(password, salt, marker.iter);
|
|
162
|
+
const verify = b64ToBytes(marker.verify);
|
|
163
|
+
if (verify.length <= IV_LEN) return null;
|
|
164
|
+
try {
|
|
165
|
+
const pt = await crypto.subtle.decrypt(
|
|
166
|
+
{ name: 'AES-GCM', iv: verify.slice(0, IV_LEN).buffer as ArrayBuffer },
|
|
167
|
+
kek,
|
|
168
|
+
verify.slice(IV_LEN).buffer as ArrayBuffer,
|
|
169
|
+
);
|
|
170
|
+
if (new TextDecoder().decode(pt) !== VERIFY_PLAINTEXT) return null;
|
|
171
|
+
return kek;
|
|
172
|
+
} catch {
|
|
173
|
+
return null; // wrong password
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------
|
|
178
|
+
// Magic sniff
|
|
179
|
+
// ---------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/** True when the buffer starts with the 'filexe2e' magic. */
|
|
182
|
+
export function hasMagic(buf: ArrayBuffer | Uint8Array): boolean {
|
|
183
|
+
const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
184
|
+
if (b.length < MAGIC_BYTES.length) return false;
|
|
185
|
+
for (let i = 0; i < MAGIC_BYTES.length; i++) {
|
|
186
|
+
if (b[i] !== MAGIC_BYTES[i]) return false;
|
|
187
|
+
}
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------
|
|
192
|
+
// File encrypt / decrypt (one-shot MVP)
|
|
193
|
+
// ---------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Encrypt `content` under the folder KEK: mints a fresh DEK, encrypts the
|
|
197
|
+
* content one-shot, wraps the DEK with the KEK and prepends the fixed
|
|
198
|
+
* 'filexe2e' header. Throws when content exceeds E2E_MAX_FILE_BYTES.
|
|
199
|
+
*/
|
|
200
|
+
export async function encryptFile(kek: CryptoKey, content: ArrayBuffer): Promise<ArrayBuffer> {
|
|
201
|
+
if (content.byteLength > E2E_MAX_FILE_BYTES) {
|
|
202
|
+
throw new Error('e2e: file exceeds the 200MB single-shot limit');
|
|
203
|
+
}
|
|
204
|
+
const rawDek = crypto.getRandomValues(new Uint8Array(32));
|
|
205
|
+
const dek = await crypto.subtle.importKey('raw', rawDek.buffer as ArrayBuffer, { name: 'AES-GCM' }, false, [
|
|
206
|
+
'encrypt',
|
|
207
|
+
]);
|
|
208
|
+
const wrapIV = crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
209
|
+
const dataIV = crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
210
|
+
const wrappedDek = new Uint8Array(
|
|
211
|
+
await crypto.subtle.encrypt({ name: 'AES-GCM', iv: wrapIV.buffer as ArrayBuffer }, kek, rawDek.buffer as ArrayBuffer),
|
|
212
|
+
);
|
|
213
|
+
const ct = new Uint8Array(
|
|
214
|
+
await crypto.subtle.encrypt({ name: 'AES-GCM', iv: dataIV.buffer as ArrayBuffer }, dek, content),
|
|
215
|
+
);
|
|
216
|
+
// Zero the raw DEK copy as a hygiene measure (best-effort — GC may have
|
|
217
|
+
// other copies, but don't leave the obvious one around).
|
|
218
|
+
rawDek.fill(0);
|
|
219
|
+
|
|
220
|
+
const out = new Uint8Array(HEADER_LEN + ct.length);
|
|
221
|
+
out.set(MAGIC_BYTES, 0);
|
|
222
|
+
out[8] = E2E_VERSION;
|
|
223
|
+
out.set(wrapIV, WRAP_IV_OFF);
|
|
224
|
+
out.set(wrappedDek, WRAPPED_DEK_OFF); // 48 bytes
|
|
225
|
+
out.set(dataIV, DATA_IV_OFF);
|
|
226
|
+
// [81..97) reserved zeros
|
|
227
|
+
out.set(ct, HEADER_LEN);
|
|
228
|
+
return out.buffer;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Decrypt a 'filexe2e' blob with the folder KEK. Throws E2eDecryptError on
|
|
233
|
+
* a wrong key / tampered data, and a plain Error when the header is not an
|
|
234
|
+
* e2e file at all.
|
|
235
|
+
*/
|
|
236
|
+
export async function decryptFile(kek: CryptoKey, data: ArrayBuffer): Promise<ArrayBuffer> {
|
|
237
|
+
const b = new Uint8Array(data);
|
|
238
|
+
if (!hasMagic(b) || b.length < HEADER_LEN) {
|
|
239
|
+
throw new Error('e2e: not an encrypted file');
|
|
240
|
+
}
|
|
241
|
+
if (b[8] !== E2E_VERSION) {
|
|
242
|
+
throw new Error(`e2e: unsupported version ${b[8]}`);
|
|
243
|
+
}
|
|
244
|
+
const wrapIV = b.slice(WRAP_IV_OFF, WRAP_IV_OFF + IV_LEN);
|
|
245
|
+
const wrappedDek = b.slice(WRAPPED_DEK_OFF, WRAPPED_DEK_OFF + WRAPPED_DEK_LEN);
|
|
246
|
+
const dataIV = b.slice(DATA_IV_OFF, DATA_IV_OFF + IV_LEN);
|
|
247
|
+
let rawDek: ArrayBuffer;
|
|
248
|
+
try {
|
|
249
|
+
rawDek = await crypto.subtle.decrypt(
|
|
250
|
+
{ name: 'AES-GCM', iv: wrapIV.buffer as ArrayBuffer },
|
|
251
|
+
kek,
|
|
252
|
+
wrappedDek.buffer as ArrayBuffer,
|
|
253
|
+
);
|
|
254
|
+
} catch {
|
|
255
|
+
throw new E2eDecryptError('e2e: DEK unwrap failed (wrong password?)');
|
|
256
|
+
}
|
|
257
|
+
const dek = await crypto.subtle.importKey('raw', rawDek, { name: 'AES-GCM' }, false, ['decrypt']);
|
|
258
|
+
try {
|
|
259
|
+
return await crypto.subtle.decrypt(
|
|
260
|
+
{ name: 'AES-GCM', iv: dataIV.buffer as ArrayBuffer },
|
|
261
|
+
dek,
|
|
262
|
+
b.slice(HEADER_LEN).buffer as ArrayBuffer,
|
|
263
|
+
);
|
|
264
|
+
} catch {
|
|
265
|
+
throw new E2eDecryptError('e2e: content decrypt failed');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------
|
|
270
|
+
// In-memory session key ring
|
|
271
|
+
// ---------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Tiny per-explorer key ring: encrypted-folder root (wire path) → KEK.
|
|
275
|
+
* Lives ONLY in memory — "Kilitle" drops the entry, a reload drops all.
|
|
276
|
+
*/
|
|
277
|
+
export function createKeyRing() {
|
|
278
|
+
const keys = new Map<string, CryptoKey>();
|
|
279
|
+
return {
|
|
280
|
+
get(root: string): CryptoKey | undefined {
|
|
281
|
+
return keys.get(root);
|
|
282
|
+
},
|
|
283
|
+
set(root: string, kek: CryptoKey): void {
|
|
284
|
+
keys.set(root, kek);
|
|
285
|
+
},
|
|
286
|
+
/** Drop one folder's key ("Kilitle"). */
|
|
287
|
+
lock(root: string): void {
|
|
288
|
+
keys.delete(root);
|
|
289
|
+
},
|
|
290
|
+
has(root: string): boolean {
|
|
291
|
+
return keys.has(root);
|
|
292
|
+
},
|
|
293
|
+
clear(): void {
|
|
294
|
+
keys.clear();
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export type E2eKeyRing = ReturnType<typeof createKeyRing>;
|
package/src/locales/en.ts
CHANGED
|
@@ -365,4 +365,38 @@ export const en: Record<string, string> = {
|
|
|
365
365
|
'inspector.comments.placeholder': 'Write a comment…',
|
|
366
366
|
'inspector.comments.send': 'Send',
|
|
367
367
|
'inspector.comments.delete': 'Delete comment',
|
|
368
|
+
/* wiring:e2 — end-to-end encrypted folders */
|
|
369
|
+
'e2e.create.title': 'Create encrypted folder',
|
|
370
|
+
'e2e.create.option': 'Create encrypted folder…',
|
|
371
|
+
'e2e.create.pw_placeholder': 'Folder password (min 8 characters)',
|
|
372
|
+
'e2e.create.pw2_placeholder': 'Repeat the password',
|
|
373
|
+
'e2e.create.warn_title': 'NO WAY BACK',
|
|
374
|
+
'e2e.create.warn_body':
|
|
375
|
+
'Files open ONLY with this password. The password is NEVER stored on the server; if you forget it the content is lost forever. There is no recovery — not even an admin can open it.',
|
|
376
|
+
'e2e.create.ack': 'I understand: if I lose the password my data cannot be recovered.',
|
|
377
|
+
'e2e.create.ack_required': 'Please confirm the warning to continue.',
|
|
378
|
+
'e2e.create.pw_short': 'Password must be at least 8 characters.',
|
|
379
|
+
'e2e.create.pw_mismatch': 'Passwords do not match.',
|
|
380
|
+
'e2e.create.bad_name': 'Invalid folder name.',
|
|
381
|
+
'e2e.create.create': 'Create encrypted folder',
|
|
382
|
+
'e2e.create.busy': 'Creating…',
|
|
383
|
+
'e2e.create.done': 'Encrypted folder created',
|
|
384
|
+
'e2e.create.failed': 'Could not create the encrypted folder',
|
|
385
|
+
'e2e.create.nested': 'You cannot create an encrypted folder inside another one.',
|
|
386
|
+
'e2e.locked.title': 'This folder is end-to-end encrypted',
|
|
387
|
+
'e2e.locked.hint': 'Enter the folder password to see its contents. The password stays in this tab’s memory only.',
|
|
388
|
+
'e2e.locked.pw_placeholder': 'Folder password',
|
|
389
|
+
'e2e.locked.unlock': 'Unlock',
|
|
390
|
+
'e2e.locked.busy': 'Verifying…',
|
|
391
|
+
'e2e.unlock.wrong': 'Wrong password.',
|
|
392
|
+
'e2e.unlock.marker_missing': 'Could not read the key file (.filex-e2e.json).',
|
|
393
|
+
'e2e.strip.label': 'Encrypted folder — unlocked',
|
|
394
|
+
'e2e.strip.lock': 'Lock',
|
|
395
|
+
'e2e.locked_toast': 'Folder locked',
|
|
396
|
+
'e2e.upload.too_big': 'Files over 200 MB cannot be uploaded to an encrypted folder (MVP limit)',
|
|
397
|
+
'e2e.upload.locked': 'Unlock the folder first',
|
|
398
|
+
'e2e.decrypting': 'Decrypting…',
|
|
399
|
+
'e2e.decrypt_failed': 'Could not decrypt the file (password changed or file corrupted).',
|
|
400
|
+
'e2e.download.failed': 'Could not download the encrypted file.',
|
|
401
|
+
/* /wiring:e2 */
|
|
368
402
|
};
|
package/src/locales/tr.ts
CHANGED
|
@@ -365,4 +365,38 @@ export const tr: Record<string, string> = {
|
|
|
365
365
|
'inspector.comments.placeholder': 'Yorum yaz…',
|
|
366
366
|
'inspector.comments.send': 'Gönder',
|
|
367
367
|
'inspector.comments.delete': 'Yorumu sil',
|
|
368
|
+
/* wiring:e2 — uçtan uca şifreli klasörler */
|
|
369
|
+
'e2e.create.title': 'Şifreli klasör oluştur',
|
|
370
|
+
'e2e.create.option': 'Şifreli klasör oluştur…',
|
|
371
|
+
'e2e.create.pw_placeholder': 'Klasör parolası (en az 8 karakter)',
|
|
372
|
+
'e2e.create.pw2_placeholder': 'Parolayı tekrar girin',
|
|
373
|
+
'e2e.create.warn_title': 'GERİ DÖNÜŞÜ YOK',
|
|
374
|
+
'e2e.create.warn_body':
|
|
375
|
+
'Dosyalar yalnız bu parolayla açılır. Parola sunucuda TUTULMAZ; unutursanız içerik sonsuza dek kaybolur. Kurtarma yolu yoktur — yönetici dahil kimse açamaz.',
|
|
376
|
+
'e2e.create.ack': 'Anladım: parolayı kaybedersem verilerim kurtarılamaz.',
|
|
377
|
+
'e2e.create.ack_required': 'Devam etmek için uyarıyı onaylayın.',
|
|
378
|
+
'e2e.create.pw_short': 'Parola en az 8 karakter olmalı.',
|
|
379
|
+
'e2e.create.pw_mismatch': 'Parolalar birbirini tutmuyor.',
|
|
380
|
+
'e2e.create.bad_name': 'Geçersiz klasör adı.',
|
|
381
|
+
'e2e.create.create': 'Şifreli klasörü oluştur',
|
|
382
|
+
'e2e.create.busy': 'Oluşturuluyor…',
|
|
383
|
+
'e2e.create.done': 'Şifreli klasör oluşturuldu',
|
|
384
|
+
'e2e.create.failed': 'Şifreli klasör oluşturulamadı',
|
|
385
|
+
'e2e.create.nested': 'Şifreli klasörün içine ikinci bir şifreli klasör açılamaz.',
|
|
386
|
+
'e2e.locked.title': 'Bu klasör uçtan uca şifreli',
|
|
387
|
+
'e2e.locked.hint': 'İçeriği görmek için klasör parolasını girin. Parola yalnız bu sekmede, bellekte tutulur.',
|
|
388
|
+
'e2e.locked.pw_placeholder': 'Klasör parolası',
|
|
389
|
+
'e2e.locked.unlock': 'Kilidi aç',
|
|
390
|
+
'e2e.locked.busy': 'Doğrulanıyor…',
|
|
391
|
+
'e2e.unlock.wrong': 'Parola yanlış.',
|
|
392
|
+
'e2e.unlock.marker_missing': 'Şifre dosyası (.filex-e2e.json) okunamadı.',
|
|
393
|
+
'e2e.strip.label': 'Şifreli klasör — kilit açık',
|
|
394
|
+
'e2e.strip.lock': 'Kilitle',
|
|
395
|
+
'e2e.locked_toast': 'Klasör kilitlendi',
|
|
396
|
+
'e2e.upload.too_big': '200 MB üstü dosyalar şifreli klasöre yüklenemez (MVP sınırı)',
|
|
397
|
+
'e2e.upload.locked': 'Önce klasörün kilidini açın',
|
|
398
|
+
'e2e.decrypting': 'Çözülüyor…',
|
|
399
|
+
'e2e.decrypt_failed': 'Dosya çözülemedi (parola değişmiş ya da dosya bozulmuş olabilir).',
|
|
400
|
+
'e2e.download.failed': 'Şifreli dosya indirilemedi.',
|
|
401
|
+
/* /wiring:e2 */
|
|
368
402
|
};
|
|
@@ -7,11 +7,17 @@ import Modal from './Modal.vue';
|
|
|
7
7
|
const props = defineProps<{
|
|
8
8
|
open: boolean;
|
|
9
9
|
locale: LocaleCode;
|
|
10
|
+
/* wiring:e2 — hosts hide the encrypted option (e.g. already inside an
|
|
11
|
+
* encrypted folder / feature-gated embeds). Default: shown. */
|
|
12
|
+
encryptedOption?: boolean;
|
|
10
13
|
}>();
|
|
11
14
|
|
|
12
15
|
const emit = defineEmits<{
|
|
13
16
|
(e: 'close'): void;
|
|
14
17
|
(e: 'submit', name: string): void;
|
|
18
|
+
/* wiring:e2 — user picked "Create encrypted folder…" — the parent swaps
|
|
19
|
+
* this modal for EncryptedFolderModal. */
|
|
20
|
+
(e: 'encrypted'): void;
|
|
15
21
|
}>();
|
|
16
22
|
|
|
17
23
|
const { t } = useLocale(() => props.locale);
|
|
@@ -51,6 +57,18 @@ function submit() {
|
|
|
51
57
|
@keydown.enter.prevent="submit"
|
|
52
58
|
/>
|
|
53
59
|
<p v-if="err" class="fe-form__error">{{ err }}</p>
|
|
60
|
+
<!-- wiring:e2 — encrypted-folder entry point lives inside the normal
|
|
61
|
+
new-folder flow so every trigger (toolbar / context menu / palette)
|
|
62
|
+
reaches it without extra wiring. -->
|
|
63
|
+
<button
|
|
64
|
+
v-if="encryptedOption !== false"
|
|
65
|
+
type="button"
|
|
66
|
+
class="fe-e2e-optlink"
|
|
67
|
+
@click="emit('encrypted')"
|
|
68
|
+
>
|
|
69
|
+
🔒 {{ t('e2e.create.option') }}
|
|
70
|
+
</button>
|
|
71
|
+
<!-- /wiring:e2 -->
|
|
54
72
|
</form>
|
|
55
73
|
<template #actions>
|
|
56
74
|
<button type="button" class="fe-btn" @click="emit('close')">
|
|
@@ -46,6 +46,11 @@ const props = defineProps<{
|
|
|
46
46
|
pdfSaveUrl?: string | null;
|
|
47
47
|
/** Standalone full-screen viewer route — `?path=…&storage=…&type=…`. */
|
|
48
48
|
viewerBaseUrl?: string | null;
|
|
49
|
+
/* wiring:e2 — false hides the "Düzenle" / "Yeni Sekmede Aç" buttons.
|
|
50
|
+
* The standalone route fetches RAW server bytes, which inside an
|
|
51
|
+
* E2E-encrypted folder is ciphertext (and its save path could clobber
|
|
52
|
+
* the encrypted blob) — the host disables the escape hatches there. */
|
|
53
|
+
newTabEnabled?: boolean;
|
|
49
54
|
/** Drop the dialog chrome (backdrop tint, header bar, footer actions)
|
|
50
55
|
* so the viewer fills the full viewport. Used by the standalone
|
|
51
56
|
* /files/edit route where the browser tab IS the container — a
|
|
@@ -1020,13 +1025,13 @@ function loadOnlyOfficeScript(base: string): Promise<void> {
|
|
|
1020
1025
|
</div>
|
|
1021
1026
|
<template #actions>
|
|
1022
1027
|
<button
|
|
1023
|
-
v-if="file && openMode === 'view' && canEditKind"
|
|
1028
|
+
v-if="file && openMode === 'view' && canEditKind && newTabEnabled !== false /* wiring:e2 */"
|
|
1024
1029
|
type="button"
|
|
1025
1030
|
class="fe-btn fe-btn--primary"
|
|
1026
1031
|
@click="openEditInNewTab"
|
|
1027
1032
|
>✏ {{ t('viewer.edit') }}</button>
|
|
1028
1033
|
<button
|
|
1029
|
-
v-if="file"
|
|
1034
|
+
v-if="file && newTabEnabled !== false /* wiring:e2 */"
|
|
1030
1035
|
type="button"
|
|
1031
1036
|
class="fe-btn"
|
|
1032
1037
|
@click="openInNewTab"
|
package/src/styles/base.css
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
position: relative;
|
|
5
5
|
display: flex;
|
|
6
6
|
flex-direction: column;
|
|
7
|
+
box-sizing: border-box;
|
|
7
8
|
min-height: 420px;
|
|
8
9
|
height: 100%;
|
|
9
10
|
background: var(--fe-bg);
|
|
@@ -3099,7 +3100,12 @@ filex-explorer {
|
|
|
3099
3100
|
flex: 1 1 auto;
|
|
3100
3101
|
min-width: 0;
|
|
3101
3102
|
overflow-x: auto;
|
|
3102
|
-
|
|
3103
|
+
/* Tab overflow scrolls (wheel / drag / keyboard) but never shows a
|
|
3104
|
+
* scrollbar strip under the tabs — the 30px bar cannot fit one. */
|
|
3105
|
+
scrollbar-width: none;
|
|
3106
|
+
}
|
|
3107
|
+
.fe-tabs__scroll::-webkit-scrollbar {
|
|
3108
|
+
display: none;
|
|
3103
3109
|
}
|
|
3104
3110
|
.fe-tabs__tab {
|
|
3105
3111
|
display: flex;
|
|
@@ -3615,3 +3621,122 @@ filex-explorer {
|
|
|
3615
3621
|
opacity: 0.6;
|
|
3616
3622
|
}
|
|
3617
3623
|
/* === /wiring:d3 === */
|
|
3624
|
+
|
|
3625
|
+
/* === wiring:e2 — uçtan uca şifreli klasörler === */
|
|
3626
|
+
/* Kilit açık şeridi (presence şeridiyle aynı yatay dil). */
|
|
3627
|
+
.fe-e2e-strip {
|
|
3628
|
+
display: flex;
|
|
3629
|
+
align-items: center;
|
|
3630
|
+
gap: 8px;
|
|
3631
|
+
padding: 4px 12px;
|
|
3632
|
+
font-size: 12.5px;
|
|
3633
|
+
color: var(--fe-text-muted);
|
|
3634
|
+
border-bottom: 1px solid var(--fe-border);
|
|
3635
|
+
background: var(--fe-bg-elev);
|
|
3636
|
+
}
|
|
3637
|
+
.fe-e2e-strip__icon {
|
|
3638
|
+
font-size: 13px;
|
|
3639
|
+
line-height: 1;
|
|
3640
|
+
}
|
|
3641
|
+
.fe-e2e-strip__label {
|
|
3642
|
+
flex: 1;
|
|
3643
|
+
overflow: hidden;
|
|
3644
|
+
text-overflow: ellipsis;
|
|
3645
|
+
white-space: nowrap;
|
|
3646
|
+
}
|
|
3647
|
+
.fe-e2e-strip__btn {
|
|
3648
|
+
padding: 2px 10px;
|
|
3649
|
+
font-size: 12px;
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
/* Kilit ekranı: fe-state dilinin üstüne parola formu. */
|
|
3653
|
+
.fe-e2e-lock__form {
|
|
3654
|
+
display: flex;
|
|
3655
|
+
gap: 8px;
|
|
3656
|
+
align-items: stretch;
|
|
3657
|
+
margin-top: 12px;
|
|
3658
|
+
width: min(340px, 100%);
|
|
3659
|
+
}
|
|
3660
|
+
.fe-e2e-lock__input {
|
|
3661
|
+
flex: 1;
|
|
3662
|
+
min-width: 0;
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
/* Şifreli klasör oluşturma modalı. */
|
|
3666
|
+
.fe-e2e-form {
|
|
3667
|
+
display: flex;
|
|
3668
|
+
flex-direction: column;
|
|
3669
|
+
gap: 10px;
|
|
3670
|
+
}
|
|
3671
|
+
.fe-e2e-warn {
|
|
3672
|
+
border: 1px solid var(--fe-danger);
|
|
3673
|
+
border-radius: var(--fe-radius-sm);
|
|
3674
|
+
padding: 8px 10px;
|
|
3675
|
+
font-size: 12.5px;
|
|
3676
|
+
color: var(--fe-text);
|
|
3677
|
+
background: color-mix(in srgb, var(--fe-danger) 8%, transparent);
|
|
3678
|
+
}
|
|
3679
|
+
.fe-e2e-warn strong {
|
|
3680
|
+
display: block;
|
|
3681
|
+
color: var(--fe-danger);
|
|
3682
|
+
margin-bottom: 2px;
|
|
3683
|
+
}
|
|
3684
|
+
.fe-e2e-warn p {
|
|
3685
|
+
margin: 0;
|
|
3686
|
+
color: var(--fe-text-muted);
|
|
3687
|
+
}
|
|
3688
|
+
.fe-e2e-ack {
|
|
3689
|
+
display: flex;
|
|
3690
|
+
align-items: flex-start;
|
|
3691
|
+
gap: 8px;
|
|
3692
|
+
font-size: 12.5px;
|
|
3693
|
+
color: var(--fe-text);
|
|
3694
|
+
cursor: pointer;
|
|
3695
|
+
}
|
|
3696
|
+
.fe-e2e-ack input {
|
|
3697
|
+
margin-top: 2px;
|
|
3698
|
+
accent-color: var(--fe-primary);
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3701
|
+
/* NewFolderModal içindeki "şifreli klasör" giriş linki. */
|
|
3702
|
+
.fe-e2e-optlink {
|
|
3703
|
+
margin-top: 10px;
|
|
3704
|
+
padding: 0;
|
|
3705
|
+
border: none;
|
|
3706
|
+
background: none;
|
|
3707
|
+
font: inherit;
|
|
3708
|
+
font-size: 12.5px;
|
|
3709
|
+
color: var(--fe-primary);
|
|
3710
|
+
cursor: pointer;
|
|
3711
|
+
text-align: left;
|
|
3712
|
+
}
|
|
3713
|
+
.fe-e2e-optlink:hover {
|
|
3714
|
+
text-decoration: underline;
|
|
3715
|
+
}
|
|
3716
|
+
/* === /wiring:e2 === */
|
|
3717
|
+
|
|
3718
|
+
/* === ui-fix — toolbar tek satır + katlama + ölçüm şeridi ============= */
|
|
3719
|
+
.fe:not(.fe--narrow) .fe-toolbar {
|
|
3720
|
+
flex-wrap: nowrap;
|
|
3721
|
+
}
|
|
3722
|
+
.fe:not(.fe--narrow) .fe-toolbar__primary {
|
|
3723
|
+
flex: 1 1 auto;
|
|
3724
|
+
min-width: 0;
|
|
3725
|
+
flex-wrap: nowrap;
|
|
3726
|
+
overflow: hidden;
|
|
3727
|
+
position: relative;
|
|
3728
|
+
}
|
|
3729
|
+
.fe-toolbar__measure {
|
|
3730
|
+
position: absolute;
|
|
3731
|
+
visibility: hidden;
|
|
3732
|
+
pointer-events: none;
|
|
3733
|
+
height: 0;
|
|
3734
|
+
overflow: hidden;
|
|
3735
|
+
display: flex;
|
|
3736
|
+
gap: 8px;
|
|
3737
|
+
white-space: nowrap;
|
|
3738
|
+
}
|
|
3739
|
+
.fe-toolbar__measure .fe-btn {
|
|
3740
|
+
flex: none;
|
|
3741
|
+
}
|
|
3742
|
+
/* === /ui-fix ========================================================= */
|
package/src/styles/variables.css
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
--fe-text: #1a1e27;
|
|
14
14
|
--fe-text-muted: #5a6475;
|
|
15
15
|
--fe-text-on-primary: #ffffff;
|
|
16
|
-
--fe-primary: #3b82f6
|
|
16
|
+
--fe-primary: #2f6fe0; /* ui-fix: beyaz metinle WCAG AA (eski #3b82f6 = 3.68) */
|
|
17
17
|
--fe-primary-hover: #2563eb;
|
|
18
18
|
--fe-danger: #dc2626;
|
|
19
19
|
--fe-danger-hover: #b91c1c;
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
--fe-icon-archive: #78716c;
|
|
103
103
|
--fe-icon-code: #4f46e5;
|
|
104
104
|
--fe-icon-text: #64748b;
|
|
105
|
-
--fe-icon-unknown: #
|
|
105
|
+
--fe-icon-unknown: #64748b; /* ui-fix: acik temada 3:1 UI kontrasti */
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/* Dark values — same selector strategy as the palette blocks above. */
|
package/src/types/FileNode.ts
CHANGED
|
@@ -42,6 +42,9 @@ export interface FileNode {
|
|
|
42
42
|
* projectFileNodes emits it when a storage has RBAC on). '' / undefined =
|
|
43
43
|
* ACL not enforced. Used to gate edit/manage affordances client-side. */
|
|
44
44
|
perm?: 'none' | 'viewer' | 'editor' | 'owner';
|
|
45
|
+
/* wiring:e2 — dir rows: true when the folder is E2E-encrypted (carries a
|
|
46
|
+
* `.filex-e2e.json` marker). Drives the 🔒 badge in the listings. */
|
|
47
|
+
e2e?: boolean;
|
|
45
48
|
/** Generic — any additional fields the backend wants to inline. */
|
|
46
49
|
[k: string]: unknown;
|
|
47
50
|
}
|