@brftech/filex-core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/{ArchiveViewer-DKW-HyXT.js → ArchiveViewer-w6GDJJt9.js} +2 -2
  2. package/dist/{ArchiveViewer-DKW-HyXT.js.map → ArchiveViewer-w6GDJJt9.js.map} +1 -1
  3. package/dist/{CsvViewer-D2HmP2yr.js → CsvViewer-Ci5-mke8.js} +2 -2
  4. package/dist/{CsvViewer-D2HmP2yr.js.map → CsvViewer-Ci5-mke8.js.map} +1 -1
  5. package/dist/{DrawioViewer-DPNvOFCs.js → DrawioViewer-D2iMuQfD.js} +2 -2
  6. package/dist/{DrawioViewer-DPNvOFCs.js.map → DrawioViewer-D2iMuQfD.js.map} +1 -1
  7. package/dist/{EpubViewer-ByqjNuNL.js → EpubViewer-D4ydsa9X.js} +2 -2
  8. package/dist/{EpubViewer-ByqjNuNL.js.map → EpubViewer-D4ydsa9X.js.map} +1 -1
  9. package/dist/{IpynbViewer-2iONhbuJ.js → IpynbViewer-CWBwiFKX.js} +2 -2
  10. package/dist/{IpynbViewer-2iONhbuJ.js.map → IpynbViewer-CWBwiFKX.js.map} +1 -1
  11. package/dist/{MermaidViewer-CmiU7hIT.js → MermaidViewer-CDhuqQli.js} +2 -2
  12. package/dist/{MermaidViewer-CmiU7hIT.js.map → MermaidViewer-CDhuqQli.js.map} +1 -1
  13. package/dist/{PsdViewer-Inhl2yN9.js → PsdViewer-C_TkFb2H.js} +2 -2
  14. package/dist/{PsdViewer-Inhl2yN9.js.map → PsdViewer-C_TkFb2H.js.map} +1 -1
  15. package/dist/{TiffViewer-C2AFFEze.js → TiffViewer-BmWCXR7e.js} +2 -2
  16. package/dist/{TiffViewer-C2AFFEze.js.map → TiffViewer-BmWCXR7e.js.map} +1 -1
  17. package/dist/{Viewer3D-YdW14q_n.js → Viewer3D-5kPT1QsY.js} +2 -2
  18. package/dist/{Viewer3D-YdW14q_n.js.map → Viewer3D-5kPT1QsY.js.map} +1 -1
  19. package/dist/filex-core.js +66 -47
  20. package/dist/filex-core.umd.cjs +41 -40
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/index-nc0-oI_l.js +11723 -0
  23. package/dist/index-nc0-oI_l.js.map +1 -0
  24. package/dist/index.d.ts +364 -1
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +756 -23
  28. package/src/components/CommandPalette.vue +12 -1
  29. package/src/components/EncryptedFolderModal.vue +125 -0
  30. package/src/components/GalleryView.vue +215 -0
  31. package/src/components/GridView.vue +2 -0
  32. package/src/components/InspectorPanel.vue +170 -0
  33. package/src/components/ListView.vue +2 -0
  34. package/src/components/SecondaryPane.vue +411 -0
  35. package/src/components/TabBar.vue +174 -0
  36. package/src/components/Toolbar.vue +23 -5
  37. package/src/composables/useFileApi.ts +60 -0
  38. package/src/composables/useKeyboardShortcuts.ts +19 -0
  39. package/src/composables/useTabs.ts +222 -0
  40. package/src/index.ts +27 -0
  41. package/src/lib/e2ecrypto.ts +299 -0
  42. package/src/locales/en.ts +67 -0
  43. package/src/locales/tr.ts +67 -0
  44. package/src/modals/NewFolderModal.vue +18 -0
  45. package/src/modals/PreviewModal.vue +7 -2
  46. package/src/styles/base.css +631 -0
  47. package/src/types/FileNode.ts +4 -1
  48. package/dist/index-eARevpz0.js +0 -9952
  49. package/dist/index-eARevpz0.js.map +0 -1
@@ -55,6 +55,13 @@ export interface ManagerResponse {
55
55
  /** RBAC effective level for the current user on this directory ('' when ACL
56
56
  * is not enforced on the storage). Gates the folder-level write actions. */
57
57
  perm?: 'none' | 'viewer' | 'editor' | 'owner';
58
+ /* wiring:e2 — E2E-encrypted folder awareness: `e2e` is true when the listed
59
+ * dir IS an encrypted root; `e2e_root` is the adapter-qualified path of the
60
+ * nearest encrypted root covering this dir (set for the root itself AND for
61
+ * every subfolder inside the subtree). Absent on plain folders / old
62
+ * backends — consumers must stay undefined-safe. */
63
+ e2e?: boolean;
64
+ e2e_root?: string;
58
65
  files: FileNode[];
59
66
  }
60
67
 
@@ -106,6 +113,23 @@ export interface UserSearchResponse {
106
113
  users: UserSuggestion[];
107
114
  }
108
115
 
116
+ /* === calisma:d3 — node comments (inspector panel) === */
117
+
118
+ /** Mirrors backend `model.NodeComment` (GET /api/files/comments?node_id=…). */
119
+ export interface NodeComment {
120
+ id: number;
121
+ node_id: number;
122
+ user_id: number;
123
+ body: string;
124
+ created_at: string;
125
+ updated_at?: string;
126
+ /** Joined author display name (email fallback), filled by the backend. */
127
+ author_name?: string;
128
+ /** Whether the CURRENT caller may delete this row (author or admin). */
129
+ can_delete?: boolean;
130
+ }
131
+ /* === /calisma:d3 === */
132
+
109
133
  export interface InviteResponse {
110
134
  mode: 'granted' | 'user_created' | 'shared';
111
135
  user_id?: number;
@@ -741,6 +765,38 @@ export function useFileApi(config: ExplorerConfig) {
741
765
  }
742
766
  /* === /koru:k1 === */
743
767
 
768
+ /* === calisma:d3 — node comments (inspector panel) ===
769
+ * Same manager-URL derivation trick as versions/permissions so embedded
770
+ * proxies forwarding the whole /api/files/* subtree keep working.
771
+ * GET /api/files/comments?node_id=N → {comments, node_id}
772
+ * POST /api/files/comments → {node_id, body}
773
+ * DELETE /api/files/comments/{id} → {ok}
774
+ */
775
+ function commentsUrl(sub = ''): string {
776
+ const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/comments');
777
+ return base + sub;
778
+ }
779
+ async function listComments(nodeId: number): Promise<NodeComment[]> {
780
+ const data = await jsonFetch<{ comments?: NodeComment[] | null }>(
781
+ commentsUrl() + '?node_id=' + encodeURIComponent(String(nodeId)),
782
+ );
783
+ return Array.isArray(data?.comments) ? data.comments : [];
784
+ }
785
+ async function addComment(nodeId: number, body: string): Promise<NodeComment> {
786
+ const data = await jsonFetch<{ comment: NodeComment }>(commentsUrl(), {
787
+ method: 'POST',
788
+ headers: { 'Content-Type': 'application/json' },
789
+ body: JSON.stringify({ node_id: nodeId, body }),
790
+ });
791
+ return data.comment;
792
+ }
793
+ async function deleteComment(id: number): Promise<void> {
794
+ await jsonFetch(commentsUrl('/' + encodeURIComponent(String(id))), {
795
+ method: 'DELETE',
796
+ });
797
+ }
798
+ /* === /calisma:d3 === */
799
+
744
800
  // Mint a short-lived WebSocket auth ticket for the realtime layer. Derived
745
801
  // from the manager URL (so it flows through the same host proxy) and uses the
746
802
  // same auth/creds as every other call. Returns null on any failure (a backend
@@ -790,6 +846,10 @@ export function useFileApi(config: ExplorerConfig) {
790
846
  listVersions,
791
847
  restoreVersion,
792
848
  snapshotVersion,
849
+ // Node comments (calisma:d3 inspector)
850
+ listComments,
851
+ addComment,
852
+ deleteComment,
793
853
  // Permissions (RBAC panel)
794
854
  listPermissions,
795
855
  resolveEmail,
@@ -38,6 +38,12 @@ export interface ShortcutHandlers {
38
38
  onShowHelp?: () => void; // ? (Shift+/ on most layouts)
39
39
  onToggleInspector?: () => void; // i (koru:k1 details panel)
40
40
  onQuickLook?: () => void; // Space (wiring:c2 quick-look overlay)
41
+ /* wiring:d1 — tab strip actions */
42
+ onTabNew?: () => void; // Ctrl+T
43
+ onTabClose?: () => void; // Ctrl+W
44
+ onTabNext?: () => void; // Ctrl+Tab
45
+ onTabPrev?: () => void; // Ctrl+Shift+Tab
46
+ /* /wiring:d1 */
41
47
  hasSelection?: () => boolean; // disambiguates Backspace
42
48
  }
43
49
 
@@ -87,6 +93,14 @@ export const SHORTCUT_ACTIONS: ShortcutActionDef[] = [
87
93
  { id: 'cut', defaultCombo: 'Ctrl+X', labelKey: 'shortcuts.cut', groupKey: 'shortcuts.group.file' },
88
94
  { id: 'copy', defaultCombo: 'Ctrl+C', labelKey: 'shortcuts.copy', groupKey: 'shortcuts.group.file' },
89
95
  { id: 'paste', defaultCombo: 'Ctrl+V', labelKey: 'shortcuts.paste', groupKey: 'shortcuts.group.file' },
96
+ /* wiring:d1 — tabs. Note: browsers reserve Ctrl+T/W/Tab in normal pages
97
+ * (preventDefault can't stop them there); they work in webcomponent/PWA/
98
+ * kiosk contexts and stay remappable through the settings modal. */
99
+ { id: 'tab-new', defaultCombo: 'Ctrl+T', labelKey: 'shortcuts.tab_new', groupKey: 'shortcuts.group.tabs' },
100
+ { id: 'tab-close', defaultCombo: 'Ctrl+W', labelKey: 'shortcuts.tab_close', groupKey: 'shortcuts.group.tabs' },
101
+ { id: 'tab-next', defaultCombo: 'Ctrl+Tab', labelKey: 'shortcuts.tab_next', groupKey: 'shortcuts.group.tabs' },
102
+ { id: 'tab-prev', defaultCombo: 'Ctrl+Shift+Tab', labelKey: 'shortcuts.tab_prev', groupKey: 'shortcuts.group.tabs' },
103
+ /* /wiring:d1 */
90
104
  ];
91
105
 
92
106
  /** action id → ShortcutHandlers callback name. */
@@ -105,6 +119,11 @@ const HANDLER_KEY: Record<string, keyof ShortcutHandlers> = {
105
119
  cut: 'onCut',
106
120
  copy: 'onCopy',
107
121
  paste: 'onPaste',
122
+ /* wiring:d1 */
123
+ 'tab-new': 'onTabNew',
124
+ 'tab-close': 'onTabClose',
125
+ 'tab-next': 'onTabNext',
126
+ 'tab-prev': 'onTabPrev',
108
127
  };
109
128
 
110
129
  // --------------------------------------------------------------------
@@ -0,0 +1,222 @@
1
+ /**
2
+ * useTabs — wiring:d1 sekmeler (çalışma alanı tab şeridi).
3
+ *
4
+ * A LAYER ABOVE FileExplorer's location state, never a replacement for
5
+ * it: each tab is a location snapshot `{ id, path, viewMode, split }`.
6
+ * The composable performs no fetching and owns no navigation — the host
7
+ * activates a tab by running its EXISTING `load(path)` pipeline and
8
+ * reports navigations back through `syncActive()`, which keeps the
9
+ * active snapshot glued to wherever the user actually is.
10
+ *
11
+ * `path` is stored in the SAME form as FileExplorer's `currentPath`
12
+ * (virtual `<storage>/<rel>` in multi-storage mode, bare relative path
13
+ * in single-storage mode) — i.e. the exact string `load(path)` accepts.
14
+ * Storing the wire form (`adapter://rel`) instead would break the
15
+ * rootPath-floor clamp inside load() (the floor is compared in user-path
16
+ * form), so the user-path form is deliberate.
17
+ *
18
+ * Persistence: localStorage under a host-supplied key (the host derives
19
+ * it from the pathPersist scope: disabled when `pathPersist: 'none'`,
20
+ * suffixed with the rootPath confine so embedded instances with
21
+ * different confines never clobber each other). Schema:
22
+ *
23
+ * { "v": 1, "active": "<id>",
24
+ * "tabs": [ { "id", "path", "viewMode", "split": {"path"}|null } ] }
25
+ */
26
+
27
+ import { computed, ref, watch, type Ref } from 'vue';
28
+ import type { ViewMode } from '../types/FileNode';
29
+
30
+ /** Per-tab split state — the secondary pane's own location. */
31
+ export interface TabSplit {
32
+ path: string;
33
+ }
34
+
35
+ export interface TabState {
36
+ id: string;
37
+ /** Location snapshot in `currentPath` form (see module docs). */
38
+ path: string;
39
+ viewMode: ViewMode;
40
+ split: TabSplit | null;
41
+ }
42
+
43
+ export interface UseTabsOptions {
44
+ /** localStorage key; null disables persistence entirely. */
45
+ storageKey: string | null;
46
+ }
47
+
48
+ function makeId(): string {
49
+ try {
50
+ return crypto.randomUUID();
51
+ } catch {
52
+ return `t${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
53
+ }
54
+ }
55
+
56
+ export function useTabs(opts: UseTabsOptions) {
57
+ const tabs: Ref<TabState[]> = ref([]);
58
+ const activeId = ref<string>('');
59
+
60
+ const activeTab = computed<TabState | null>(
61
+ () => tabs.value.find((t) => t.id === activeId.value) ?? null,
62
+ );
63
+ const activeIndex = computed(() => tabs.value.findIndex((t) => t.id === activeId.value));
64
+ const hasMultiple = computed(() => tabs.value.length > 1);
65
+
66
+ // ------------------------------------------------------------------
67
+ // Persistence
68
+ // ------------------------------------------------------------------
69
+
70
+ /** Restore from storage. Returns true when at least one tab loaded. */
71
+ function restore(): boolean {
72
+ if (!opts.storageKey) return false;
73
+ try {
74
+ const raw = localStorage.getItem(opts.storageKey);
75
+ if (!raw) return false;
76
+ const parsed = JSON.parse(raw) as { v?: unknown; active?: unknown; tabs?: unknown };
77
+ if (!parsed || !Array.isArray(parsed.tabs) || parsed.tabs.length === 0) return false;
78
+ const clean: TabState[] = [];
79
+ for (const t of parsed.tabs as Array<Record<string, unknown>>) {
80
+ if (!t || typeof t.path !== 'string') continue;
81
+ // ViewMode is validated loosely on purpose: future modes (e.g. the
82
+ // gallery wave) must survive a round-trip through an older schema.
83
+ const vm =
84
+ typeof t.viewMode === 'string' && t.viewMode ? (t.viewMode as ViewMode) : 'list';
85
+ const rawSplit = t.split as { path?: unknown } | null | undefined;
86
+ const split =
87
+ rawSplit && typeof rawSplit === 'object' && typeof rawSplit.path === 'string'
88
+ ? { path: rawSplit.path }
89
+ : null;
90
+ clean.push({
91
+ id: typeof t.id === 'string' && t.id ? t.id : makeId(),
92
+ path: t.path,
93
+ viewMode: vm,
94
+ split,
95
+ });
96
+ }
97
+ if (clean.length === 0) return false;
98
+ tabs.value = clean;
99
+ activeId.value = clean.some((t) => t.id === parsed.active)
100
+ ? String(parsed.active)
101
+ : clean[0].id;
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ function persist(): void {
109
+ if (!opts.storageKey) return;
110
+ try {
111
+ localStorage.setItem(
112
+ opts.storageKey,
113
+ JSON.stringify({ v: 1, active: activeId.value, tabs: tabs.value }),
114
+ );
115
+ } catch {
116
+ /* private mode / quota */
117
+ }
118
+ }
119
+
120
+ watch([tabs, activeId], persist, { deep: true });
121
+
122
+ // ------------------------------------------------------------------
123
+ // Mutations
124
+ // ------------------------------------------------------------------
125
+
126
+ /** Create the first tab (no-op once any tab exists). */
127
+ function seed(path: string, viewMode: ViewMode): void {
128
+ if (tabs.value.length > 0) return;
129
+ const t: TabState = { id: makeId(), path, viewMode, split: null };
130
+ tabs.value = [t];
131
+ activeId.value = t.id;
132
+ }
133
+
134
+ /** Update the ACTIVE tab's snapshot (navigation / view-mode change). */
135
+ function syncActive(patch: Partial<Pick<TabState, 'path' | 'viewMode'>>): void {
136
+ const t = activeTab.value;
137
+ if (!t) return;
138
+ if (patch.path !== undefined) t.path = patch.path;
139
+ if (patch.viewMode !== undefined) t.viewMode = patch.viewMode;
140
+ }
141
+
142
+ /** Open a new tab right after the active one (browser convention). */
143
+ function openTab(
144
+ path: string,
145
+ o: { viewMode: ViewMode; background?: boolean; split?: TabSplit | null },
146
+ ): TabState {
147
+ const t: TabState = { id: makeId(), path, viewMode: o.viewMode, split: o.split ?? null };
148
+ const idx = activeIndex.value;
149
+ tabs.value.splice(idx === -1 ? tabs.value.length : idx + 1, 0, t);
150
+ if (!o.background) activeId.value = t.id;
151
+ return t;
152
+ }
153
+
154
+ /**
155
+ * Close a tab. The last remaining tab never closes. Returns the tab
156
+ * the host must ACTIVATE (right neighbour, else left) when the active
157
+ * one was closed; null when the visible location is unchanged.
158
+ */
159
+ function closeTab(id: string): TabState | null {
160
+ if (tabs.value.length <= 1) return null;
161
+ const idx = tabs.value.findIndex((t) => t.id === id);
162
+ if (idx === -1) return null;
163
+ const wasActive = tabs.value[idx].id === activeId.value;
164
+ tabs.value.splice(idx, 1);
165
+ if (!wasActive) return null;
166
+ const next = tabs.value[Math.min(idx, tabs.value.length - 1)];
167
+ activeId.value = next.id;
168
+ return next;
169
+ }
170
+
171
+ /** Make a tab active. Returns it when the active tab actually changed. */
172
+ function activate(id: string): TabState | null {
173
+ const t = tabs.value.find((x) => x.id === id);
174
+ if (!t || t.id === activeId.value) return null;
175
+ activeId.value = t.id;
176
+ return t;
177
+ }
178
+
179
+ /** Cycle: +1 = next, -1 = previous (wraps). */
180
+ function step(delta: number): TabState | null {
181
+ if (tabs.value.length < 2) return null;
182
+ const idx = activeIndex.value === -1 ? 0 : activeIndex.value;
183
+ const next = tabs.value[(idx + delta + tabs.value.length) % tabs.value.length];
184
+ return activate(next.id);
185
+ }
186
+
187
+ /** Drag-sort support: move the tab at `from` to position `to`. */
188
+ function move(from: number, to: number): void {
189
+ if (from === to) return;
190
+ if (from < 0 || to < 0 || from >= tabs.value.length || to >= tabs.value.length) return;
191
+ const list = [...tabs.value];
192
+ const [t] = list.splice(from, 1);
193
+ list.splice(to, 0, t);
194
+ tabs.value = list;
195
+ }
196
+
197
+ /** Set (or clear with null) the ACTIVE tab's split state. */
198
+ function setSplit(split: TabSplit | null): void {
199
+ const t = activeTab.value;
200
+ if (!t) return;
201
+ t.split = split;
202
+ }
203
+
204
+ return {
205
+ tabs,
206
+ activeId,
207
+ activeTab,
208
+ activeIndex,
209
+ hasMultiple,
210
+ restore,
211
+ seed,
212
+ syncActive,
213
+ openTab,
214
+ closeTab,
215
+ activate,
216
+ step,
217
+ move,
218
+ setSplit,
219
+ };
220
+ }
221
+
222
+ export type TabsApi = ReturnType<typeof useTabs>;
package/src/index.ts CHANGED
@@ -127,3 +127,30 @@ export type {
127
127
  OperationsStore,
128
128
  } from './composables/useOperations';
129
129
  export { default as OperationsCenter } from './components/OperationsCenter.vue';
130
+ /* wiring:d1 — sekmeler + split panel */
131
+ export { useTabs } from './composables/useTabs';
132
+ export type { TabState, TabSplit, TabsApi } from './composables/useTabs';
133
+ export { default as TabBar } from './components/TabBar.vue';
134
+ export { default as SecondaryPane } from './components/SecondaryPane.vue';
135
+ /* /wiring:d1 */
136
+ /* wiring:e2 — uçtan uca şifreli klasörler (WebCrypto; docs/E2E-ENCRYPTION.md) */
137
+ export {
138
+ E2E_MARKER_NAME,
139
+ E2E_MAGIC,
140
+ E2E_VERSION,
141
+ E2E_DEFAULT_ITERATIONS,
142
+ E2E_MAX_FILE_BYTES,
143
+ E2E_MIN_PASSWORD_LEN,
144
+ E2eDecryptError,
145
+ deriveKek,
146
+ createMarker,
147
+ parseMarker,
148
+ verifyPassword,
149
+ hasMagic,
150
+ encryptFile,
151
+ decryptFile,
152
+ createKeyRing,
153
+ } from './lib/e2ecrypto';
154
+ export type { E2eMarker, E2eKeyRing } from './lib/e2ecrypto';
155
+ export { default as EncryptedFolderModal } from './components/EncryptedFolderModal.vue';
156
+ /* /wiring:e2 */
@@ -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>;