@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.
- package/dist/{ArchiveViewer-DKW-HyXT.js → ArchiveViewer-w6GDJJt9.js} +2 -2
- package/dist/{ArchiveViewer-DKW-HyXT.js.map → ArchiveViewer-w6GDJJt9.js.map} +1 -1
- package/dist/{CsvViewer-D2HmP2yr.js → CsvViewer-Ci5-mke8.js} +2 -2
- package/dist/{CsvViewer-D2HmP2yr.js.map → CsvViewer-Ci5-mke8.js.map} +1 -1
- package/dist/{DrawioViewer-DPNvOFCs.js → DrawioViewer-D2iMuQfD.js} +2 -2
- package/dist/{DrawioViewer-DPNvOFCs.js.map → DrawioViewer-D2iMuQfD.js.map} +1 -1
- package/dist/{EpubViewer-ByqjNuNL.js → EpubViewer-D4ydsa9X.js} +2 -2
- package/dist/{EpubViewer-ByqjNuNL.js.map → EpubViewer-D4ydsa9X.js.map} +1 -1
- package/dist/{IpynbViewer-2iONhbuJ.js → IpynbViewer-CWBwiFKX.js} +2 -2
- package/dist/{IpynbViewer-2iONhbuJ.js.map → IpynbViewer-CWBwiFKX.js.map} +1 -1
- package/dist/{MermaidViewer-CmiU7hIT.js → MermaidViewer-CDhuqQli.js} +2 -2
- package/dist/{MermaidViewer-CmiU7hIT.js.map → MermaidViewer-CDhuqQli.js.map} +1 -1
- package/dist/{PsdViewer-Inhl2yN9.js → PsdViewer-C_TkFb2H.js} +2 -2
- package/dist/{PsdViewer-Inhl2yN9.js.map → PsdViewer-C_TkFb2H.js.map} +1 -1
- package/dist/{TiffViewer-C2AFFEze.js → TiffViewer-BmWCXR7e.js} +2 -2
- package/dist/{TiffViewer-C2AFFEze.js.map → TiffViewer-BmWCXR7e.js.map} +1 -1
- package/dist/{Viewer3D-YdW14q_n.js → Viewer3D-5kPT1QsY.js} +2 -2
- package/dist/{Viewer3D-YdW14q_n.js.map → Viewer3D-5kPT1QsY.js.map} +1 -1
- package/dist/filex-core.js +66 -47
- package/dist/filex-core.umd.cjs +41 -40
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-nc0-oI_l.js +11723 -0
- package/dist/index-nc0-oI_l.js.map +1 -0
- package/dist/index.d.ts +364 -1
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +756 -23
- package/src/components/CommandPalette.vue +12 -1
- package/src/components/EncryptedFolderModal.vue +125 -0
- package/src/components/GalleryView.vue +215 -0
- package/src/components/GridView.vue +2 -0
- package/src/components/InspectorPanel.vue +170 -0
- package/src/components/ListView.vue +2 -0
- package/src/components/SecondaryPane.vue +411 -0
- package/src/components/TabBar.vue +174 -0
- package/src/components/Toolbar.vue +23 -5
- package/src/composables/useFileApi.ts +60 -0
- package/src/composables/useKeyboardShortcuts.ts +19 -0
- package/src/composables/useTabs.ts +222 -0
- package/src/index.ts +27 -0
- package/src/lib/e2ecrypto.ts +299 -0
- package/src/locales/en.ts +67 -0
- package/src/locales/tr.ts +67 -0
- package/src/modals/NewFolderModal.vue +18 -0
- package/src/modals/PreviewModal.vue +7 -2
- package/src/styles/base.css +631 -0
- package/src/types/FileNode.ts +4 -1
- package/dist/index-eARevpz0.js +0 -9952
- 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>;
|