@brftech/filex-core 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -4
- package/dist/filex-core.js +10626 -6501
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +94 -63
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index.d.ts +1026 -7
- package/dist/style.css +1 -1
- package/package.json +3 -3
- package/src/FileExplorer.vue +103 -68
- package/src/components/ConnectionGuideView.vue +333 -0
- package/src/components/ConnectionsPanel.vue +912 -0
- package/src/components/NFSExportsPanel.vue +283 -0
- package/src/components/S3KeysPanel.vue +381 -0
- package/src/components/SSHKeysPanel.vue +222 -0
- package/src/components/StorageFields.vue +362 -0
- package/src/components/TokensPanel.vue +191 -0
- package/src/components/UploadProgress.vue +5 -1
- package/src/composables/useConnections.ts +271 -0
- package/src/composables/useFileApi.ts +15 -2
- package/src/composables/useNFSExports.ts +148 -0
- package/src/composables/useS3Keys.ts +175 -0
- package/src/composables/useSSHKeys.ts +119 -0
- package/src/composables/useThumbs.ts +1 -1
- package/src/composables/useTokens.ts +121 -0
- package/src/composables/useUploadChunked.ts +433 -164
- package/src/index.ts +77 -2
- package/src/lib/connectionGuides.ts +1279 -0
- package/src/lib/realtime.ts +1 -1
- package/src/lib/uploadResume.ts +157 -0
- package/src/locales/en.ts +413 -0
- package/src/locales/tr.ts +416 -0
- package/src/modals/ConvertModal.vue +1 -1
- package/src/styles/base.css +12 -12
- package/src/types/Connections.ts +122 -0
- package/src/types/ExplorerConfig.ts +23 -2
- package/src/types/NFSExports.ts +47 -0
- package/src/types/S3Keys.ts +55 -0
- package/src/types/SSHKeys.ts +54 -0
- package/src/types/Tokens.ts +39 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* TokensPanel — mint and revoke the API tokens this account signs in with on
|
|
4
|
+
* the protocols whose credential IS a token.
|
|
5
|
+
*
|
|
6
|
+
* ⚠⚠ Why this had to exist. FTPS, WebDAV and `filex mount` all take an API
|
|
7
|
+
* token as the password — the guides next to this panel say so in as many
|
|
8
|
+
* words. Until 2026-08-17 the only place to mint one was the admin panel's
|
|
9
|
+
* `/api/admin/ai-tokens` screen, so a normal user read "use an API token" and
|
|
10
|
+
* had nowhere to get one. The route it calls (`/api/tokens`) has always been
|
|
11
|
+
* open to every account and caps what it hands out to the caller's own role
|
|
12
|
+
* and grants; only the UI was missing.
|
|
13
|
+
*
|
|
14
|
+
* One component, every surface (see S3KeysPanel for the rule at length): the
|
|
15
|
+
* admin panel, the web explorer and the desktop app render THIS.
|
|
16
|
+
*/
|
|
17
|
+
import { computed, onMounted, ref } from 'vue';
|
|
18
|
+
import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
|
|
19
|
+
import type { ApiToken } from '../types/Tokens';
|
|
20
|
+
import { useLocale } from '../composables/useLocale';
|
|
21
|
+
import { useTokens } from '../composables/useTokens';
|
|
22
|
+
|
|
23
|
+
const props = defineProps<{
|
|
24
|
+
config: ExplorerConfig;
|
|
25
|
+
/**
|
|
26
|
+
* Which protocol the surrounding guide is showing. It only changes the
|
|
27
|
+
* default label — a token minted here works on all of them, and pretending
|
|
28
|
+
* otherwise would have people mint one per protocol.
|
|
29
|
+
*/
|
|
30
|
+
protocol?: string;
|
|
31
|
+
}>();
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits<{
|
|
34
|
+
(e: 'active', v: { hasToken: boolean }): void;
|
|
35
|
+
}>();
|
|
36
|
+
|
|
37
|
+
const locale = computed<LocaleCode>(() => props.config.locale ?? 'tr');
|
|
38
|
+
const { t } = useLocale(locale);
|
|
39
|
+
|
|
40
|
+
const { tokens, loading, error, canMint, revealed, load, create, remove, dismiss } = useTokens(
|
|
41
|
+
props.config,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const label = ref('');
|
|
45
|
+
const busy = ref(false);
|
|
46
|
+
const copied = ref(false);
|
|
47
|
+
const confirming = ref<number | null>(null);
|
|
48
|
+
|
|
49
|
+
onMounted(async () => {
|
|
50
|
+
await load();
|
|
51
|
+
emit('active', { hasToken: tokens.value.length > 0 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function defaultLabel(): string {
|
|
55
|
+
const p = (props.protocol || '').toUpperCase();
|
|
56
|
+
return p ? `${p} — ${hostLabel()}` : hostLabel();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A name the user will recognise in the list later. */
|
|
60
|
+
function hostLabel(): string {
|
|
61
|
+
try {
|
|
62
|
+
return new URL(props.config.apiBase || window.location.origin).host;
|
|
63
|
+
} catch {
|
|
64
|
+
return 'filex';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function mint(): Promise<void> {
|
|
69
|
+
if (busy.value) return;
|
|
70
|
+
busy.value = true;
|
|
71
|
+
copied.value = false;
|
|
72
|
+
try {
|
|
73
|
+
// ⚠ `read,write,delete` and nothing more. `share` is a web-surface verb
|
|
74
|
+
// and `admin` is refused by the server anyway; a token for mounting a
|
|
75
|
+
// drive should not be able to publish public links. The server caps this
|
|
76
|
+
// again against the caller's own role, so asking is not granting.
|
|
77
|
+
await create({ label: label.value.trim() || defaultLabel(), scopes: 'read,write,delete' });
|
|
78
|
+
await load();
|
|
79
|
+
emit('active', { hasToken: tokens.value.length > 0 });
|
|
80
|
+
label.value = '';
|
|
81
|
+
} finally {
|
|
82
|
+
busy.value = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function revoke(row: ApiToken): Promise<void> {
|
|
87
|
+
if (confirming.value !== row.id) {
|
|
88
|
+
confirming.value = row.id;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
confirming.value = null;
|
|
92
|
+
busy.value = true;
|
|
93
|
+
try {
|
|
94
|
+
await remove(row.id);
|
|
95
|
+
emit('active', { hasToken: tokens.value.length > 0 });
|
|
96
|
+
} finally {
|
|
97
|
+
busy.value = false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function copySecret(): Promise<void> {
|
|
102
|
+
if (!revealed.value?.token) return;
|
|
103
|
+
try {
|
|
104
|
+
await navigator.clipboard.writeText(revealed.value.token);
|
|
105
|
+
copied.value = true;
|
|
106
|
+
window.setTimeout(() => (copied.value = false), 1600);
|
|
107
|
+
} catch {
|
|
108
|
+
/* clipboard refused (insecure origin, no permission) — the value is on
|
|
109
|
+
screen and selectable, which is the fallback that always works. */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function fmtDate(v?: string | null): string {
|
|
114
|
+
if (!v) return '';
|
|
115
|
+
const d = new Date(v);
|
|
116
|
+
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function usedLabel(row: ApiToken): string {
|
|
120
|
+
return row.last_used_at ? fmtDate(row.last_used_at) : t('conn.tokens.neverUsed');
|
|
121
|
+
}
|
|
122
|
+
</script>
|
|
123
|
+
|
|
124
|
+
<template>
|
|
125
|
+
<section class="fe-s3keys" data-testid="api-tokens">
|
|
126
|
+
<header class="fe-s3keys__head">
|
|
127
|
+
<h4 class="fe-s3keys__title">{{ t('conn.tokens.title') }}</h4>
|
|
128
|
+
<p class="fe-s3keys__lead">{{ t('conn.tokens.lead') }}</p>
|
|
129
|
+
</header>
|
|
130
|
+
|
|
131
|
+
<p v-if="canMint === false" class="fe-s3keys__muted">{{ t('conn.tokens.cannotMint') }}</p>
|
|
132
|
+
<p v-if="error" class="fe-s3keys__warn">{{ error }}</p>
|
|
133
|
+
|
|
134
|
+
<div v-if="canMint" class="fe-s3keys__form">
|
|
135
|
+
<input
|
|
136
|
+
v-model="label"
|
|
137
|
+
class="fe-cfield__input"
|
|
138
|
+
:placeholder="defaultLabel()"
|
|
139
|
+
data-testid="token-label"
|
|
140
|
+
/>
|
|
141
|
+
<button class="fe-s3keys__btn" :disabled="busy" data-testid="token-mint" @click="mint">
|
|
142
|
+
{{ t('conn.tokens.mint') }}
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<!-- The secret, once. -->
|
|
147
|
+
<div v-if="revealed" class="fe-s3keys__secret" data-testid="token-secret">
|
|
148
|
+
<p class="fe-s3keys__once">{{ t('conn.tokens.once') }}</p>
|
|
149
|
+
<div class="fe-s3keys__pair">
|
|
150
|
+
<code>{{ revealed.token }}</code>
|
|
151
|
+
<button class="fe-s3keys__copy" @click="copySecret">
|
|
152
|
+
{{ copied ? t('conn.guide.copied') : t('conn.guide.copy') }}
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
<button class="fe-s3keys__dismiss" @click="dismiss">
|
|
156
|
+
{{ t('conn.tokens.dismiss') }}
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<p v-if="loading" class="fe-s3keys__muted">…</p>
|
|
161
|
+
<table v-else-if="tokens.length" class="fe-s3keys__table">
|
|
162
|
+
<thead>
|
|
163
|
+
<tr>
|
|
164
|
+
<th>{{ t('conn.tokens.col.label') }}</th>
|
|
165
|
+
<th>{{ t('conn.tokens.col.scopes') }}</th>
|
|
166
|
+
<th>{{ t('conn.tokens.col.used') }}</th>
|
|
167
|
+
<th></th>
|
|
168
|
+
</tr>
|
|
169
|
+
</thead>
|
|
170
|
+
<tbody>
|
|
171
|
+
<tr v-for="row in tokens" :key="row.id">
|
|
172
|
+
<td>{{ row.label || '—' }}</td>
|
|
173
|
+
<td><code>{{ row.scopes }}</code></td>
|
|
174
|
+
<td>{{ usedLabel(row) }}</td>
|
|
175
|
+
<td class="fe-s3keys__actions">
|
|
176
|
+
<button class="fe-s3keys__link is-danger" :disabled="busy" @click="revoke(row)">
|
|
177
|
+
{{ confirming === row.id ? t('conn.tokens.confirm') : t('conn.tokens.revoke') }}
|
|
178
|
+
</button>
|
|
179
|
+
</td>
|
|
180
|
+
</tr>
|
|
181
|
+
</tbody>
|
|
182
|
+
</table>
|
|
183
|
+
<p v-else-if="canMint" class="fe-s3keys__muted">{{ t('conn.tokens.empty') }}</p>
|
|
184
|
+
|
|
185
|
+
<!-- ⚠ Said next to the button rather than in a document nobody opens: a
|
|
186
|
+
revoked token stops a session that is already open, not only the next
|
|
187
|
+
login. That is a change in behaviour worth knowing before relying on
|
|
188
|
+
it, and it is the honest answer to "how do I stop this machine". -->
|
|
189
|
+
<p v-if="canMint" class="fe-s3keys__hint">{{ t('conn.tokens.revokeHint') }}</p>
|
|
190
|
+
</section>
|
|
191
|
+
</template>
|
|
@@ -32,7 +32,9 @@ function mapStatus(s: UploadJob['status']): OperationStatus {
|
|
|
32
32
|
if (s === 'done') return 'done';
|
|
33
33
|
if (s === 'error') return 'error';
|
|
34
34
|
if (s === 'aborted') return 'aborted';
|
|
35
|
-
|
|
35
|
+
// pending | initializing | uploading | committing | transferring — all of
|
|
36
|
+
// them are still in flight as far as the tray is concerned.
|
|
37
|
+
return 'running';
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
watch(
|
|
@@ -50,6 +52,8 @@ watch(
|
|
|
50
52
|
error: j.error ?? null,
|
|
51
53
|
uploadedBytes: j.uploadedBytes,
|
|
52
54
|
totalBytes: j.totalBytes,
|
|
55
|
+
// Cancellable only while chunks are still moving: once the commit is
|
|
56
|
+
// accepted the bytes are filex's and the ops worker owns the rest.
|
|
53
57
|
cancellable: j.status === 'uploading' || j.status === 'initializing',
|
|
54
58
|
retryable: j.status === 'error',
|
|
55
59
|
},
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useConnections — the one client for the storage-connection surface.
|
|
3
|
+
*
|
|
4
|
+
* It talks to the same server the explorer does, through the same auth
|
|
5
|
+
* plumbing (`useFileApi` owns bearer/CSRF/basic resolution, including the
|
|
6
|
+
* function-token the desktop app hands down), so there is no second place
|
|
7
|
+
* that knows how to authenticate.
|
|
8
|
+
*
|
|
9
|
+
* Two audiences, deliberately separated:
|
|
10
|
+
*
|
|
11
|
+
* • an ADMIN gets the driver descriptors and full CRUD over storages
|
|
12
|
+
* (`/api/admin/*`);
|
|
13
|
+
* • everybody else gets `visible` — the storage names the manager root
|
|
14
|
+
* already returns to them — plus their own identity, which is all the
|
|
15
|
+
* "how to connect" pages need.
|
|
16
|
+
*
|
|
17
|
+
* ⚠ Permission is decided by ASKING THE SERVER, never by reading a role
|
|
18
|
+
* off `/api/auth/me`. An API token whose scopes exclude `admin` belongs to
|
|
19
|
+
* an admin account and still gets 403 on `/api/admin/storages`; trusting
|
|
20
|
+
* the role would render a form whose every submit fails.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { computed, ref, shallowRef } from 'vue';
|
|
24
|
+
import type { ExplorerConfig } from '../types/ExplorerConfig';
|
|
25
|
+
import type {
|
|
26
|
+
ConnectionsUser,
|
|
27
|
+
ManageDenial,
|
|
28
|
+
StorageDriverDescriptor,
|
|
29
|
+
StorageField,
|
|
30
|
+
StorageRow,
|
|
31
|
+
StorageTestResult,
|
|
32
|
+
StorageWrite,
|
|
33
|
+
} from '../types/Connections';
|
|
34
|
+
import { useFileApi } from './useFileApi';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The URL prefix the `/api/...` routes hang off.
|
|
38
|
+
*
|
|
39
|
+
* `apiBase: ''` is a legitimate value (the admin SPA is same-origin), so
|
|
40
|
+
* only `undefined`/`null` means "not given" — a falsy check would send the
|
|
41
|
+
* SPA's requests to the wrong place. Legacy embedders configure `endpoint`
|
|
42
|
+
* instead of `apiBase`; the prefix is recovered from it so they are not
|
|
43
|
+
* excluded.
|
|
44
|
+
*/
|
|
45
|
+
export function connectionsBase(config: ExplorerConfig): string {
|
|
46
|
+
if (config.apiBase != null) return config.apiBase.replace(/\/+$/, '');
|
|
47
|
+
const m = config.endpoint ?? '';
|
|
48
|
+
const cut = m.indexOf('/api/');
|
|
49
|
+
return cut >= 0 ? m.slice(0, cut) : '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The origin a client program should be pointed at.
|
|
54
|
+
*
|
|
55
|
+
* The instruction pages are only worth anything if they name the real
|
|
56
|
+
* deployment, so this resolves to an absolute origin: the configured
|
|
57
|
+
* apiBase when there is one (the desktop app, embeds), otherwise the page's
|
|
58
|
+
* own origin (the admin SPA, which is served by the same binary).
|
|
59
|
+
*/
|
|
60
|
+
export function connectionsOrigin(config: ExplorerConfig): string {
|
|
61
|
+
const base = connectionsBase(config);
|
|
62
|
+
if (/^https?:\/\//i.test(base)) {
|
|
63
|
+
try {
|
|
64
|
+
return new URL(base).origin;
|
|
65
|
+
} catch {
|
|
66
|
+
return base;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (typeof window !== 'undefined' && window.location) return window.location.origin;
|
|
70
|
+
return base;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useConnections(config: ExplorerConfig) {
|
|
74
|
+
const api = useFileApi(config);
|
|
75
|
+
const base = connectionsBase(config);
|
|
76
|
+
const url = (path: string) => `${base}${path}`;
|
|
77
|
+
|
|
78
|
+
const drivers = shallowRef<StorageDriverDescriptor[]>([]);
|
|
79
|
+
const storages = shallowRef<StorageRow[]>([]);
|
|
80
|
+
/** Storage names a non-admin may see (manager root). */
|
|
81
|
+
const visible = shallowRef<string[]>([]);
|
|
82
|
+
const me = ref<ConnectionsUser | null>(null);
|
|
83
|
+
|
|
84
|
+
const loading = ref(false);
|
|
85
|
+
const loaded = ref(false);
|
|
86
|
+
const error = ref<string | null>(null);
|
|
87
|
+
/** null until the first load decides; then true, or a reason it is false. */
|
|
88
|
+
const canManage = ref<boolean | null>(null);
|
|
89
|
+
const denial = ref<ManageDenial | null>(null);
|
|
90
|
+
|
|
91
|
+
function statusOf(e: unknown): number | undefined {
|
|
92
|
+
return (e as { status?: number } | null)?.status;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function messageOf(e: unknown): string {
|
|
96
|
+
const err = e as { message?: string } | null;
|
|
97
|
+
return err?.message || String(e);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The full picture, in as few round-trips as the permission allows. */
|
|
101
|
+
async function load(): Promise<void> {
|
|
102
|
+
loading.value = true;
|
|
103
|
+
error.value = null;
|
|
104
|
+
try {
|
|
105
|
+
// Identity first: the guides need the caller's own e-mail (it IS the
|
|
106
|
+
// WebDAV username), and it is the one call every role may make.
|
|
107
|
+
try {
|
|
108
|
+
const body = await api.jsonFetch<{ user: ConnectionsUser }>(url('/api/auth/me'));
|
|
109
|
+
me.value = body?.user ?? null;
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (statusOf(e) === 401) {
|
|
112
|
+
canManage.value = false;
|
|
113
|
+
denial.value = 'anonymous';
|
|
114
|
+
}
|
|
115
|
+
me.value = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
drivers.value = await api.jsonFetch<StorageDriverDescriptor[]>(
|
|
120
|
+
url('/api/admin/storage-drivers'),
|
|
121
|
+
);
|
|
122
|
+
canManage.value = true;
|
|
123
|
+
denial.value = null;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
drivers.value = [];
|
|
126
|
+
canManage.value = false;
|
|
127
|
+
// 401/403 is a permission answer, not a fault: a viewer is meant to
|
|
128
|
+
// land on the guides. Anything else is a real failure and is shown.
|
|
129
|
+
const st = statusOf(e);
|
|
130
|
+
if (st === 403) denial.value = 'none';
|
|
131
|
+
else if (st === 401) denial.value = 'anonymous';
|
|
132
|
+
else {
|
|
133
|
+
denial.value = 'unreachable';
|
|
134
|
+
error.value = messageOf(e);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (canManage.value) {
|
|
139
|
+
try {
|
|
140
|
+
const rows = await api.jsonFetch<StorageRow[]>(url('/api/admin/storages'));
|
|
141
|
+
storages.value = Array.isArray(rows) ? rows : [];
|
|
142
|
+
visible.value = storages.value.map((s) => s.name);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
error.value = messageOf(e);
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
// What a non-admin may see, from the endpoint they already use.
|
|
148
|
+
try {
|
|
149
|
+
const root = await api.index('');
|
|
150
|
+
visible.value = Array.isArray(root?.storages) ? root.storages : [];
|
|
151
|
+
} catch {
|
|
152
|
+
visible.value = [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
loaded.value = true;
|
|
156
|
+
} finally {
|
|
157
|
+
loading.value = false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function createStorage(body: StorageWrite): Promise<StorageRow> {
|
|
162
|
+
const created = await api.jsonFetch<StorageRow>(url('/api/admin/storages'), {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: { 'Content-Type': 'application/json' },
|
|
165
|
+
body: JSON.stringify(body),
|
|
166
|
+
});
|
|
167
|
+
await load();
|
|
168
|
+
return created;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function updateStorage(id: number, body: Partial<StorageWrite>): Promise<StorageRow> {
|
|
172
|
+
const updated = await api.jsonFetch<StorageRow>(url(`/api/admin/storages/${id}`), {
|
|
173
|
+
method: 'PATCH',
|
|
174
|
+
headers: { 'Content-Type': 'application/json' },
|
|
175
|
+
body: JSON.stringify(body),
|
|
176
|
+
});
|
|
177
|
+
await load();
|
|
178
|
+
return updated;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function deleteStorage(id: number): Promise<void> {
|
|
182
|
+
await api.jsonFetch(url(`/api/admin/storages/${id}`), { method: 'DELETE' });
|
|
183
|
+
await load();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Try a driver config without saving it.
|
|
188
|
+
*
|
|
189
|
+
* The endpoint answers 200 with `{ok:false,error}` for a connection that
|
|
190
|
+
* did not work — a failed *test* is a successful *request* — so a thrown
|
|
191
|
+
* error here means the call itself failed and is reported as such.
|
|
192
|
+
*/
|
|
193
|
+
async function testStorage(body: {
|
|
194
|
+
driver: string;
|
|
195
|
+
config: Record<string, unknown>;
|
|
196
|
+
}): Promise<StorageTestResult> {
|
|
197
|
+
try {
|
|
198
|
+
return await api.jsonFetch<StorageTestResult>(url('/api/admin/storages/test'), {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
headers: { 'Content-Type': 'application/json' },
|
|
201
|
+
body: JSON.stringify(body),
|
|
202
|
+
});
|
|
203
|
+
} catch (e) {
|
|
204
|
+
return { ok: false, error: messageOf(e) };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function descriptor(driver: string): StorageDriverDescriptor | undefined {
|
|
209
|
+
return drivers.value.find((d) => d.driver === driver);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function fields(driver: string): StorageField[] {
|
|
213
|
+
return descriptor(driver)?.fields ?? [];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The config a fresh form starts from: every declared default, nothing else. */
|
|
217
|
+
function defaults(driver: string): Record<string, unknown> {
|
|
218
|
+
const out: Record<string, unknown> = {};
|
|
219
|
+
for (const f of fields(driver)) {
|
|
220
|
+
if (f.default !== undefined && f.default !== null) out[f.key] = f.default;
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Required fields the config has not filled — checked HERE as well as on
|
|
227
|
+
* the server so the user is told before a round-trip, and with the same
|
|
228
|
+
* alias rules (`base_path` still counts as `root`).
|
|
229
|
+
*/
|
|
230
|
+
function missingRequired(driver: string, cfg: Record<string, unknown>): StorageField[] {
|
|
231
|
+
return fields(driver).filter((f) => {
|
|
232
|
+
if (!f.required || f.default !== undefined) return false;
|
|
233
|
+
for (const k of [f.key, ...(f.aliases ?? [])]) {
|
|
234
|
+
const v = cfg[k];
|
|
235
|
+
if (v === undefined || v === null) continue;
|
|
236
|
+
if (typeof v === 'string' && v.trim() === '') continue;
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const driverNames = computed(() => drivers.value.map((d) => d.driver));
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
// state
|
|
247
|
+
drivers,
|
|
248
|
+
driverNames,
|
|
249
|
+
storages,
|
|
250
|
+
visible,
|
|
251
|
+
me,
|
|
252
|
+
loading,
|
|
253
|
+
loaded,
|
|
254
|
+
error,
|
|
255
|
+
canManage,
|
|
256
|
+
denial,
|
|
257
|
+
// actions
|
|
258
|
+
load,
|
|
259
|
+
createStorage,
|
|
260
|
+
updateStorage,
|
|
261
|
+
deleteStorage,
|
|
262
|
+
testStorage,
|
|
263
|
+
// descriptor helpers
|
|
264
|
+
descriptor,
|
|
265
|
+
fields,
|
|
266
|
+
defaults,
|
|
267
|
+
missingRequired,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export type ConnectionsApi = ReturnType<typeof useConnections>;
|
|
@@ -201,6 +201,9 @@ export function resolveEndpoints(config: ExplorerConfig): EndpointMap {
|
|
|
201
201
|
|
|
202
202
|
return {
|
|
203
203
|
manager,
|
|
204
|
+
// Staged (chunked + resumable) uploads — what useUploadChunked speaks on
|
|
205
|
+
// every driver. The {id} routes are derived from this one.
|
|
206
|
+
uploadBegin: derive(config.uploadBegin, '/api/files/upload/begin'),
|
|
204
207
|
uploadInit: derive(config.uploadInit, '/api/files/upload/init'),
|
|
205
208
|
uploadFinalize: derive(config.uploadFinalize, '/api/files/upload/finalize'),
|
|
206
209
|
uploadAbort: derive(config.uploadAbort, '/api/files/upload/abort'),
|
|
@@ -322,7 +325,7 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
322
325
|
|
|
323
326
|
// Map an HTTP status to a short, human-readable message in the explorer's
|
|
324
327
|
// locale. The raw JSON body is attached as `.detail` for debugging but never
|
|
325
|
-
// shown in the toast (
|
|
328
|
+
// shown in the toast (Ada: "404/403 falan verince ham json görüyorum").
|
|
326
329
|
function statusMessage(status: number): string {
|
|
327
330
|
const tr = (config.locale ?? 'tr') !== 'en';
|
|
328
331
|
const m: Record<number, [string, string]> = {
|
|
@@ -361,7 +364,17 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
361
364
|
err.detail = text.slice(0, 300);
|
|
362
365
|
throw err;
|
|
363
366
|
}
|
|
364
|
-
|
|
367
|
+
// ⚠⚠ A 204 carries NO BODY, and several endpoints answer with one (every
|
|
368
|
+
// delete does). Parsing it throws "Unexpected end of JSON input" AFTER the
|
|
369
|
+
// server has already done the work, so the caller reports a failure for an
|
|
370
|
+
// operation that succeeded — measured 2026-08-16 in a browser: revoking an
|
|
371
|
+
// S3 access key deleted it on the server and left it on screen with an
|
|
372
|
+
// error under it, which invites the user to trust a credential that is
|
|
373
|
+
// gone. An empty success is a success.
|
|
374
|
+
if (res.status === 204 || res.status === 205) return undefined as T;
|
|
375
|
+
const body = await res.text();
|
|
376
|
+
if (!body) return undefined as T;
|
|
377
|
+
return JSON.parse(body) as T;
|
|
365
378
|
}
|
|
366
379
|
|
|
367
380
|
// --------------------------------------------------------------------
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useNFSExports — the one client for the NFS export surface.
|
|
3
|
+
*
|
|
4
|
+
* Same shape as useS3Keys and useSSHKeys, and mounted by every surface from
|
|
5
|
+
* packages/core for the same reason: a credential screen with two
|
|
6
|
+
* implementations eventually hands out access one of them cannot revoke.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { computed, ref, shallowRef } from 'vue';
|
|
10
|
+
import type { ExplorerConfig } from '../types/ExplorerConfig';
|
|
11
|
+
import type { NFSConnection, NFSExport, NFSExportCreated } from '../types/NFSExports';
|
|
12
|
+
import { connectionsBase } from './useConnections';
|
|
13
|
+
import { useFileApi } from './useFileApi';
|
|
14
|
+
|
|
15
|
+
export function useNFSExports(config: ExplorerConfig) {
|
|
16
|
+
const api = useFileApi(config);
|
|
17
|
+
const base = connectionsBase(config);
|
|
18
|
+
const url = (path: string) => `${base}${path}`;
|
|
19
|
+
|
|
20
|
+
const exports = shallowRef<NFSExport[]>([]);
|
|
21
|
+
const connection = ref<NFSConnection | null>(null);
|
|
22
|
+
const loading = ref(false);
|
|
23
|
+
const loaded = ref(false);
|
|
24
|
+
const error = ref<string | null>(null);
|
|
25
|
+
const canMint = ref<boolean | null>(null);
|
|
26
|
+
/** The path, held only while the user is looking at it. */
|
|
27
|
+
const revealed = ref<NFSExportCreated | null>(null);
|
|
28
|
+
|
|
29
|
+
function messageOf(e: unknown): string {
|
|
30
|
+
const err = e as { message?: string; detail?: string } | null;
|
|
31
|
+
if (err?.detail) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(err.detail) as { error?: string };
|
|
34
|
+
if (parsed?.error) return parsed.error;
|
|
35
|
+
} catch {
|
|
36
|
+
/* not JSON */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return err?.message || String(e);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function statusOf(e: unknown): number | undefined {
|
|
43
|
+
return (e as { status?: number } | null)?.status;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function load(): Promise<void> {
|
|
47
|
+
loading.value = true;
|
|
48
|
+
error.value = null;
|
|
49
|
+
try {
|
|
50
|
+
const body = await api.jsonFetch<{ exports: NFSExport[] } & NFSConnection>(
|
|
51
|
+
url('/api/auth/nfs-exports'),
|
|
52
|
+
);
|
|
53
|
+
exports.value = Array.isArray(body?.exports) ? body.exports : [];
|
|
54
|
+
connection.value = {
|
|
55
|
+
enabled: body?.enabled !== false,
|
|
56
|
+
host: body?.host ?? '',
|
|
57
|
+
port: body?.port ?? 2049,
|
|
58
|
+
};
|
|
59
|
+
canMint.value = true;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
exports.value = [];
|
|
62
|
+
canMint.value = false;
|
|
63
|
+
if (statusOf(e) !== 401 && statusOf(e) !== 403) error.value = messageOf(e);
|
|
64
|
+
} finally {
|
|
65
|
+
loading.value = false;
|
|
66
|
+
loaded.value = true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function create(req: {
|
|
71
|
+
label: string;
|
|
72
|
+
storage?: string;
|
|
73
|
+
prefix?: string;
|
|
74
|
+
read_only?: boolean;
|
|
75
|
+
allow_cidrs?: string;
|
|
76
|
+
}): Promise<NFSExportCreated | null> {
|
|
77
|
+
error.value = null;
|
|
78
|
+
try {
|
|
79
|
+
const body = await api.jsonFetch<NFSExportCreated>(url('/api/auth/nfs-exports'), {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify(req),
|
|
83
|
+
});
|
|
84
|
+
revealed.value = body;
|
|
85
|
+
if (body?.host) {
|
|
86
|
+
connection.value = { enabled: body.enabled !== false, host: body.host, port: body.port };
|
|
87
|
+
}
|
|
88
|
+
await load();
|
|
89
|
+
return body;
|
|
90
|
+
} catch (e) {
|
|
91
|
+
error.value = messageOf(e);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function setDisabled(id: number, disabled: boolean): Promise<void> {
|
|
97
|
+
error.value = null;
|
|
98
|
+
try {
|
|
99
|
+
await api.jsonFetch(url(`/api/auth/nfs-exports/${id}/state`), {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'Content-Type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({ disabled }),
|
|
103
|
+
});
|
|
104
|
+
await load();
|
|
105
|
+
} catch (e) {
|
|
106
|
+
error.value = messageOf(e);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function remove(id: number): Promise<void> {
|
|
111
|
+
error.value = null;
|
|
112
|
+
try {
|
|
113
|
+
await api.jsonFetch(url(`/api/auth/nfs-exports/${id}`), { method: 'DELETE' });
|
|
114
|
+
if (revealed.value?.export?.id === id) revealed.value = null;
|
|
115
|
+
await load();
|
|
116
|
+
} catch (e) {
|
|
117
|
+
error.value = messageOf(e);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function dismissPath() {
|
|
122
|
+
revealed.value = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The export a guide should render a mount line for. */
|
|
126
|
+
const guideExport = computed<NFSExport | null>(() => {
|
|
127
|
+
if (revealed.value?.export) return revealed.value.export;
|
|
128
|
+
const usable = exports.value.filter((e) => !e.disabled_at);
|
|
129
|
+
if (!usable.length) return null;
|
|
130
|
+
return usable.reduce((a, b) => (a.created_at >= b.created_at ? a : b));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
exports,
|
|
135
|
+
connection,
|
|
136
|
+
loading,
|
|
137
|
+
loaded,
|
|
138
|
+
error,
|
|
139
|
+
canMint,
|
|
140
|
+
revealed,
|
|
141
|
+
guideExport,
|
|
142
|
+
load,
|
|
143
|
+
create,
|
|
144
|
+
setDisabled,
|
|
145
|
+
remove,
|
|
146
|
+
dismissPath,
|
|
147
|
+
};
|
|
148
|
+
}
|