@antelopejs/dms 0.5.1 → 0.5.3
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/CHANGELOG.md +54 -0
- package/dist/attachments/access.d.ts +1 -0
- package/dist/attachments/access.js +7 -3
- package/dist/attachments/access.js.map +1 -1
- package/dist/attachments/constraints.d.ts +4 -0
- package/dist/attachments/constraints.js +28 -0
- package/dist/attachments/constraints.js.map +1 -0
- package/dist/attachments/save.js +23 -12
- package/dist/attachments/save.js.map +1 -1
- package/dist/attachments/uploads.js +2 -0
- package/dist/attachments/uploads.js.map +1 -1
- package/dist/implementations/dms/page.js +69 -22
- package/dist/implementations/dms/page.js.map +1 -1
- package/dist/implementations/dms-base/search-route.js +3 -4
- package/dist/implementations/dms-base/search-route.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/realtime/index.js +2 -0
- package/dist/realtime/index.js.map +1 -1
- package/dist/realtime/sessions.d.ts +3 -0
- package/dist/realtime/sessions.js +5 -0
- package/dist/realtime/sessions.js.map +1 -1
- package/dist/realtime/sse.d.ts +1 -1
- package/dist/realtime/sse.js +9 -3
- package/dist/realtime/sse.js.map +1 -1
- package/dist/routes/realtime.js +8 -3
- package/dist/routes/realtime.js.map +1 -1
- package/dist/test/attachment-host/native.d.ts +25 -0
- package/dist/test/attachment-host/native.js +54 -1
- package/dist/test/attachment-host/native.js.map +1 -1
- package/dist/test/helpers/attachments.d.ts +1 -1
- package/dist/test/helpers/attachments.js +2 -2
- package/dist/test/helpers/attachments.js.map +1 -1
- package/dist/test/helpers/reload-host.d.ts +7 -2
- package/dist/test/helpers/reload-host.js +23 -4
- package/dist/test/helpers/reload-host.js.map +1 -1
- package/dist/test/integration/files/field-constraints.test.d.ts +1 -0
- package/dist/test/integration/files/field-constraints.test.js +100 -0
- package/dist/test/integration/files/field-constraints.test.js.map +1 -0
- package/dist/test/integration/files/page-mode-form-files.test.d.ts +1 -0
- package/dist/test/integration/files/page-mode-form-files.test.js +114 -0
- package/dist/test/integration/files/page-mode-form-files.test.js.map +1 -0
- package/dist/test/integration/files/resource-form-files.test.d.ts +1 -0
- package/dist/test/integration/files/resource-form-files.test.js +121 -0
- package/dist/test/integration/files/resource-form-files.test.js.map +1 -0
- package/dist/test/integration/files/shared-controller-files.test.d.ts +1 -0
- package/dist/test/integration/files/shared-controller-files.test.js +136 -0
- package/dist/test/integration/files/shared-controller-files.test.js.map +1 -0
- package/dist/test/reload-host/index.js +27 -1
- package/dist/test/reload-host/index.js.map +1 -1
- package/dist/test/unit/implementations/dms/missing-category.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms/missing-category.test.js +174 -0
- package/dist/test/unit/implementations/dms/missing-category.test.js.map +1 -0
- package/dist/test/unit/implementations/dms-base/table-view-shared-controller.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms-base/table-view-shared-controller.test.js +113 -0
- package/dist/test/unit/implementations/dms-base/table-view-shared-controller.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms/database-initialized-hook.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms/database-initialized-hook.test.js +92 -0
- package/dist/test/unit/interfaces/dms/database-initialized-hook.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms/invite-extension.test.js +59 -0
- package/dist/test/unit/interfaces/dms/invite-extension.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms/module-reload-registrations.test.js +2 -2
- package/dist/test/unit/interfaces/dms/module-reload-registrations.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms/page-extension-ownership.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms/page-extension-ownership.test.js +153 -0
- package/dist/test/unit/interfaces/dms/page-extension-ownership.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/resource-form.test.js +19 -0
- package/dist/test/unit/interfaces/dms-base/resource-form.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/table-view-tenant-gate.test.js +2 -2
- package/dist/test/unit/interfaces/dms-base/table-view-tenant-gate.test.js.map +1 -1
- package/dist/test/unit/realtime/stop-closes-streams.test.d.ts +1 -0
- package/dist/test/unit/realtime/stop-closes-streams.test.js +99 -0
- package/dist/test/unit/realtime/stop-closes-streams.test.js.map +1 -0
- package/dist/test/unit/skills/dms-dev-script.test.d.ts +1 -0
- package/dist/test/unit/skills/dms-dev-script.test.js +137 -0
- package/dist/test/unit/skills/dms-dev-script.test.js.map +1 -0
- package/dist/validation/auth.schema.d.ts +2 -2
- package/frontend-vue/dms.frontend.ts +4 -3
- package/frontend-vue/layers/dms-core/app/composables/realtime/useUserRealtime.ts +116 -44
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +2 -0
- package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +50 -21
- package/frontend-vue/layers/dms-ui/app/components/form/components/Image.vue +54 -21
- package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +4 -3
- package/frontend-vue/layers/dms-ui/app/composables/form/useUploadWithProgress.ts +44 -3
- package/frontend-vue/layers/dms-ui/app/plugins/shortcuts.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/types/shortcuts-aggregated.d.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/utils/metadataRetry.ts +47 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +3 -1
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +3 -1
- package/frontend-vue/tests/file-unavailable.test.ts +107 -0
- package/frontend-vue/tests/image-hydration-ids.test.ts +87 -0
- package/frontend-vue/tests/image-reference-preservation.test.ts +12 -2
- package/frontend-vue/tests/metadata-retry.test.ts +187 -0
- package/frontend-vue/tests/realtime-reconnect.test.ts +215 -0
- package/frontend-vue/tests/shortcuts-aggregate.test.ts +41 -0
- package/frontend-vue/tests/upload-forbidden-headers.test.ts +52 -0
- package/frontend-vue/vitest.config.ts +4 -0
- package/package.json +2 -2
- package/skills/dms-dev/SKILL.md +28 -16
- package/skills/dms-dev/scripts/dms-dev.sh +275 -83
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
isUnauthorizedStreamError,
|
|
3
|
+
openSseStream,
|
|
4
|
+
type SseStreamHandle,
|
|
5
|
+
} from "./useSseStream";
|
|
2
6
|
|
|
3
7
|
const REALTIME_USER_PATH = "/api/realtime/user";
|
|
4
8
|
const REALTIME_SUBSCRIBE_PATH_PREFIX = "/api/realtime/subscribe/";
|
|
@@ -117,57 +121,109 @@ const handleEvent =
|
|
|
117
121
|
}
|
|
118
122
|
};
|
|
119
123
|
|
|
124
|
+
interface ConnectionCallbacks {
|
|
125
|
+
onConnected: () => void;
|
|
126
|
+
onDisconnected: () => void;
|
|
127
|
+
refreshSession: () => Promise<boolean>;
|
|
128
|
+
redirectToAuth: () => Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface Reconnector {
|
|
132
|
+
isCancelled(): boolean;
|
|
133
|
+
schedule(open: () => void): void;
|
|
134
|
+
resetDelay(): void;
|
|
135
|
+
cancel(): void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const createReconnector = (): Reconnector => {
|
|
139
|
+
let isCancelled = false;
|
|
140
|
+
let delay = RECONNECT_INITIAL_DELAY_MS;
|
|
141
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
142
|
+
return {
|
|
143
|
+
isCancelled: () => isCancelled,
|
|
144
|
+
schedule: (open) => {
|
|
145
|
+
// A pending reconnect absorbs concurrent triggers: one stream per tab.
|
|
146
|
+
if (isCancelled || timer) return;
|
|
147
|
+
timer = setTimeout(() => {
|
|
148
|
+
timer = undefined;
|
|
149
|
+
open();
|
|
150
|
+
}, delay);
|
|
151
|
+
delay = Math.min(delay * RECONNECT_MULTIPLIER, RECONNECT_MAX_DELAY_MS);
|
|
152
|
+
},
|
|
153
|
+
resetDelay: () => {
|
|
154
|
+
delay = RECONNECT_INITIAL_DELAY_MS;
|
|
155
|
+
},
|
|
156
|
+
cancel: () => {
|
|
157
|
+
isCancelled = true;
|
|
158
|
+
if (timer) clearTimeout(timer);
|
|
159
|
+
timer = undefined;
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const recoverFromError = (
|
|
165
|
+
error: unknown,
|
|
166
|
+
reconnect: () => void,
|
|
167
|
+
callbacks: ConnectionCallbacks,
|
|
168
|
+
): void => {
|
|
169
|
+
if (!isUnauthorizedStreamError(error)) {
|
|
170
|
+
reconnect();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
void callbacks.refreshSession().then((isRefreshed) => {
|
|
174
|
+
if (isRefreshed) {
|
|
175
|
+
reconnect();
|
|
176
|
+
} else {
|
|
177
|
+
void callbacks.redirectToAuth();
|
|
178
|
+
}
|
|
179
|
+
}, reconnect);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Keeps one user stream open until cancelled. The server ends the stream when
|
|
184
|
+
* it stops (a dev reload included), so a clean EOF reconnects just like an
|
|
185
|
+
* error does: the user stream has no business completion.
|
|
186
|
+
*/
|
|
120
187
|
const connectWithBackoff = (
|
|
121
188
|
url: string,
|
|
122
189
|
state: UserRealtimeState,
|
|
123
|
-
|
|
124
|
-
refreshSession: () => Promise<boolean>,
|
|
125
|
-
redirectToAuth: () => Promise<void>,
|
|
190
|
+
callbacks: ConnectionCallbacks,
|
|
126
191
|
): (() => void) => {
|
|
127
|
-
|
|
128
|
-
let delay = RECONNECT_INITIAL_DELAY_MS;
|
|
129
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
130
|
-
let activeHandle: { close(): void } | undefined;
|
|
192
|
+
const reconnector = createReconnector();
|
|
131
193
|
const dispatch = handleEvent(state);
|
|
194
|
+
let activeHandle: SseStreamHandle | undefined;
|
|
132
195
|
const open = () => {
|
|
133
|
-
if (
|
|
134
|
-
activeHandle
|
|
196
|
+
if (reconnector.isCancelled()) return;
|
|
197
|
+
activeHandle?.close();
|
|
198
|
+
const handle: SseStreamHandle = openSseStream({
|
|
135
199
|
url,
|
|
136
200
|
onEvent: (eventName, data) => {
|
|
137
|
-
if (
|
|
138
|
-
|
|
201
|
+
if (handle !== activeHandle) return;
|
|
202
|
+
reconnector.resetDelay();
|
|
139
203
|
dispatch(eventName, data);
|
|
140
|
-
if (eventName === HELLO_EVENT) onConnected();
|
|
204
|
+
if (eventName === HELLO_EVENT) callbacks.onConnected();
|
|
141
205
|
},
|
|
142
206
|
onError: (error) => {
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
delay * RECONNECT_MULTIPLIER,
|
|
150
|
-
RECONNECT_MAX_DELAY_MS,
|
|
151
|
-
);
|
|
152
|
-
};
|
|
153
|
-
if (isUnauthorizedStreamError(error)) {
|
|
154
|
-
void refreshSession().then((refreshed) => {
|
|
155
|
-
if (refreshed) {
|
|
156
|
-
scheduleReconnect();
|
|
157
|
-
} else {
|
|
158
|
-
void redirectToAuth();
|
|
159
|
-
}
|
|
160
|
-
}, scheduleReconnect);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
scheduleReconnect();
|
|
207
|
+
if (!retire(handle)) return;
|
|
208
|
+
recoverFromError(error, reconnect, callbacks);
|
|
209
|
+
},
|
|
210
|
+
onClose: (reason) => {
|
|
211
|
+
if (reason !== "eof" || !retire(handle)) return;
|
|
212
|
+
reconnect();
|
|
164
213
|
},
|
|
165
214
|
});
|
|
215
|
+
activeHandle = handle;
|
|
216
|
+
};
|
|
217
|
+
const reconnect = () => reconnector.schedule(open);
|
|
218
|
+
const retire = (handle: SseStreamHandle): boolean => {
|
|
219
|
+
if (handle !== activeHandle || reconnector.isCancelled()) return false;
|
|
220
|
+
activeHandle = undefined;
|
|
221
|
+
callbacks.onDisconnected();
|
|
222
|
+
return true;
|
|
166
223
|
};
|
|
167
224
|
open();
|
|
168
225
|
return () => {
|
|
169
|
-
|
|
170
|
-
if (timer) clearTimeout(timer);
|
|
226
|
+
reconnector.cancel();
|
|
171
227
|
activeHandle?.close();
|
|
172
228
|
activeHandle = undefined;
|
|
173
229
|
};
|
|
@@ -196,14 +252,29 @@ const reconcilePage = async (
|
|
|
196
252
|
baseUrl: string,
|
|
197
253
|
): Promise<void> => {
|
|
198
254
|
const desired = state.pageId.value ?? null;
|
|
255
|
+
const sessionId = state.sessionId.value;
|
|
199
256
|
if (state.appliedPageId.value === desired) return;
|
|
200
|
-
if (!
|
|
257
|
+
if (!sessionId) return;
|
|
201
258
|
await callPageEndpoint(state, desired, baseUrl);
|
|
259
|
+
// A reconnect during the call replaced the session: the page is applied to
|
|
260
|
+
// the dead one, and the new one still needs it.
|
|
261
|
+
if (state.sessionId.value !== sessionId) return;
|
|
202
262
|
if ((state.pageId.value ?? null) === desired) {
|
|
203
263
|
state.appliedPageId.value = desired;
|
|
204
264
|
}
|
|
205
265
|
};
|
|
206
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Forgets what the server held for the closed session. The next greeting
|
|
269
|
+
* brings a new session id, which re-applies the page topics here and lets the
|
|
270
|
+
* components holding presence re-acquire it.
|
|
271
|
+
*/
|
|
272
|
+
const resetSession = (state: UserRealtimeState): void => {
|
|
273
|
+
state.sessionId.value = undefined;
|
|
274
|
+
state.appliedPageId.value = null;
|
|
275
|
+
state.snapshots.clear();
|
|
276
|
+
};
|
|
277
|
+
|
|
207
278
|
const buildApi = (): UserRealtimeApi => {
|
|
208
279
|
const sessionId = useDmsState<string | undefined>(
|
|
209
280
|
SESSION_STATE_KEY,
|
|
@@ -232,19 +303,20 @@ const buildApi = (): UserRealtimeApi => {
|
|
|
232
303
|
(isLoggedIn) => {
|
|
233
304
|
state.cancelConnect?.();
|
|
234
305
|
state.cancelConnect = undefined;
|
|
235
|
-
state
|
|
306
|
+
resetSession(state);
|
|
236
307
|
state.menuTopics.value = [];
|
|
237
|
-
state.appliedPageId.value = null;
|
|
238
|
-
state.snapshots.clear();
|
|
239
308
|
if (!isLoggedIn) return;
|
|
240
309
|
state.cancelConnect = connectWithBackoff(
|
|
241
310
|
`${baseUrl}${REALTIME_USER_PATH}`,
|
|
242
311
|
state,
|
|
243
|
-
|
|
244
|
-
|
|
312
|
+
{
|
|
313
|
+
onConnected: () => {
|
|
314
|
+
void reconcilePage(state, baseUrl);
|
|
315
|
+
},
|
|
316
|
+
onDisconnected: () => resetSession(state),
|
|
317
|
+
refreshSession,
|
|
318
|
+
redirectToAuth,
|
|
245
319
|
},
|
|
246
|
-
refreshSession,
|
|
247
|
-
redirectToAuth,
|
|
248
320
|
);
|
|
249
321
|
},
|
|
250
322
|
{ immediate: true },
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { withoutTrailingSlash } from "ufo";
|
|
3
3
|
|
|
4
|
+
defineOptions({ inheritAttrs: false });
|
|
5
|
+
|
|
4
6
|
// Dev-only: created behind the flag so the key never reaches a production
|
|
5
7
|
// SSR payload.
|
|
6
8
|
const devReloading = import.meta.env.DEV ? useDevReloading() : ref(false);
|
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
type FileConstraintViolation,
|
|
8
8
|
type FileFieldConstraints,
|
|
9
9
|
} from "../../../utils/fileConstraints";
|
|
10
|
+
import {
|
|
11
|
+
isDefinitiveMetadataFailure,
|
|
12
|
+
resolveMetadataRetryDelay,
|
|
13
|
+
} from "../../../utils/metadataRetry";
|
|
10
14
|
|
|
11
15
|
export type FileConstraints = FileFieldConstraints;
|
|
12
16
|
|
|
@@ -53,12 +57,13 @@ const selectedFiles = ref<File | File[] | null>(null);
|
|
|
53
57
|
const uploadingFiles = ref<UploadingFile[]>([]);
|
|
54
58
|
const uploadedKeys = ref<string[]>([]);
|
|
55
59
|
const fileMetadata = ref<Map<string, FileMetadataResponse>>(new Map());
|
|
60
|
+
const unavailableKeys = ref<Set<string>>(new Set());
|
|
56
61
|
const metadataTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
62
|
+
const metadataFailures = new Map<string, number>();
|
|
57
63
|
const pendingMetadata = new Map<string, Promise<void>>();
|
|
58
64
|
let isDisposed = false;
|
|
59
65
|
const contentLanguage = inject(FORM_CONTENT_LANGUAGE_KEY, undefined);
|
|
60
66
|
const URL_REFRESH_LEAD_MS = 5000;
|
|
61
|
-
const TRANSIENT_RETRY_MS = 1000;
|
|
62
67
|
|
|
63
68
|
const initializeFromModelValue = (
|
|
64
69
|
val: string | string[] | null | undefined,
|
|
@@ -106,6 +111,33 @@ const fetchMetadata = async (
|
|
|
106
111
|
await request;
|
|
107
112
|
};
|
|
108
113
|
|
|
114
|
+
const markUnavailable = (resourceKey: string, error: unknown) => {
|
|
115
|
+
const cached = fileMetadata.value.get(resourceKey);
|
|
116
|
+
const isExpired =
|
|
117
|
+
cached?.expiresAt !== undefined && cached.expiresAt <= Date.now();
|
|
118
|
+
if (cached && !isExpired && !isDefinitiveMetadataFailure(error)) return;
|
|
119
|
+
fileMetadata.value.set(resourceKey, {
|
|
120
|
+
resourceKey,
|
|
121
|
+
filename: cached?.filename ?? resourceKey,
|
|
122
|
+
size: cached?.size ?? 0,
|
|
123
|
+
mimetype: cached?.mimetype ?? "application/octet-stream",
|
|
124
|
+
url: "",
|
|
125
|
+
});
|
|
126
|
+
unavailableKeys.value.add(resourceKey);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const handleMetadataFailure = (resourceKey: string, error: unknown) => {
|
|
130
|
+
markUnavailable(resourceKey, error);
|
|
131
|
+
const failures = (metadataFailures.get(resourceKey) ?? 0) + 1;
|
|
132
|
+
metadataFailures.set(resourceKey, failures);
|
|
133
|
+
const delay = resolveMetadataRetryDelay(error, failures);
|
|
134
|
+
if (delay === null || import.meta.env.SSR) return;
|
|
135
|
+
metadataTimers.set(
|
|
136
|
+
resourceKey,
|
|
137
|
+
setTimeout(() => fetchMetadata(resourceKey, true), delay),
|
|
138
|
+
);
|
|
139
|
+
};
|
|
140
|
+
|
|
109
141
|
const requestMetadata = async (resourceKey: string): Promise<void> => {
|
|
110
142
|
try {
|
|
111
143
|
const metadata = await $authFetch<FileMetadataResponse>(
|
|
@@ -122,27 +154,12 @@ const requestMetadata = async (resourceKey: string): Promise<void> => {
|
|
|
122
154
|
);
|
|
123
155
|
if (isDisposed || !uploadedKeys.value.includes(resourceKey)) return;
|
|
124
156
|
fileMetadata.value.set(resourceKey, metadata);
|
|
157
|
+
unavailableKeys.value.delete(resourceKey);
|
|
158
|
+
metadataFailures.delete(resourceKey);
|
|
125
159
|
scheduleMetadataRefresh(resourceKey, metadata.expiresAt);
|
|
126
|
-
} catch (
|
|
160
|
+
} catch (error) {
|
|
127
161
|
if (isDisposed || !uploadedKeys.value.includes(resourceKey)) return;
|
|
128
|
-
|
|
129
|
-
const isExpired =
|
|
130
|
-
cached?.expiresAt !== undefined && cached.expiresAt <= Date.now();
|
|
131
|
-
if (!cached || isExpired) {
|
|
132
|
-
fileMetadata.value.set(resourceKey, {
|
|
133
|
-
resourceKey,
|
|
134
|
-
filename: cached?.filename ?? resourceKey,
|
|
135
|
-
size: cached?.size ?? 0,
|
|
136
|
-
mimetype: cached?.mimetype ?? "application/octet-stream",
|
|
137
|
-
url: "",
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
if (!import.meta.env.SSR) {
|
|
141
|
-
metadataTimers.set(
|
|
142
|
-
resourceKey,
|
|
143
|
-
setTimeout(() => fetchMetadata(resourceKey, true), TRANSIENT_RETRY_MS),
|
|
144
|
-
);
|
|
145
|
-
}
|
|
162
|
+
handleMetadataFailure(resourceKey, error);
|
|
146
163
|
} finally {
|
|
147
164
|
pendingMetadata.delete(resourceKey);
|
|
148
165
|
}
|
|
@@ -284,6 +301,8 @@ const removeUploadingFile = (id: string) => {
|
|
|
284
301
|
const removeUploadedFile = (key: string) => {
|
|
285
302
|
uploadedKeys.value = uploadedKeys.value.filter((k) => k !== key);
|
|
286
303
|
fileMetadata.value.delete(key);
|
|
304
|
+
unavailableKeys.value.delete(key);
|
|
305
|
+
metadataFailures.delete(key);
|
|
287
306
|
const timer = metadataTimers.get(key);
|
|
288
307
|
if (timer) clearTimeout(timer);
|
|
289
308
|
emitValue();
|
|
@@ -293,6 +312,8 @@ const getMetadata = (key: string): FileMetadataResponse | undefined => {
|
|
|
293
312
|
return fileMetadata.value.get(key);
|
|
294
313
|
};
|
|
295
314
|
|
|
315
|
+
const isUnavailable = (key: string): boolean => unavailableKeys.value.has(key);
|
|
316
|
+
|
|
296
317
|
const isImage = (mimetype: string): boolean => {
|
|
297
318
|
return mimetype.startsWith("image/");
|
|
298
319
|
};
|
|
@@ -399,6 +420,11 @@ onScopeDispose(() => {
|
|
|
399
420
|
:alt="getMetadata(key)!.filename"
|
|
400
421
|
class="h-8 w-8 rounded object-cover"
|
|
401
422
|
/>
|
|
423
|
+
<UIcon
|
|
424
|
+
v-else-if="isUnavailable(key)"
|
|
425
|
+
name="i-lucide-alert-circle"
|
|
426
|
+
class="text-error h-4 w-4"
|
|
427
|
+
/>
|
|
402
428
|
<UIcon
|
|
403
429
|
v-else
|
|
404
430
|
name="i-lucide-file-check"
|
|
@@ -412,7 +438,10 @@ onScopeDispose(() => {
|
|
|
412
438
|
>
|
|
413
439
|
{{ getMetadata(key)!.filename }}
|
|
414
440
|
</ULink>
|
|
415
|
-
<span class="text-
|
|
441
|
+
<span v-if="isUnavailable(key)" class="text-error text-xs">
|
|
442
|
+
{{ $t("dms.form.file.file_unavailable") }}
|
|
443
|
+
</span>
|
|
444
|
+
<span v-else class="text-muted text-xs">
|
|
416
445
|
{{ formatFileSize(getMetadata(key)!.size) }}
|
|
417
446
|
</span>
|
|
418
447
|
</template>
|
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
type ImageResizeBounds,
|
|
11
11
|
resizeImageToBounds,
|
|
12
12
|
} from "../../../utils/imageResize";
|
|
13
|
+
import {
|
|
14
|
+
isDefinitiveMetadataFailure,
|
|
15
|
+
resolveMetadataRetryDelay,
|
|
16
|
+
} from "../../../utils/metadataRetry";
|
|
13
17
|
|
|
14
18
|
export type ImageConstraints = FileFieldConstraints;
|
|
15
19
|
|
|
@@ -25,7 +29,11 @@ interface ImageProps {
|
|
|
25
29
|
disabled?: boolean;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
/**
|
|
33
|
+
* `error` is a failed upload of a picked file; `unavailable` is a stored value
|
|
34
|
+
* whose metadata could not be read (deleted, swept from staging, or denied).
|
|
35
|
+
*/
|
|
36
|
+
type ItemStatus = "uploading" | "done" | "error" | "unavailable";
|
|
29
37
|
|
|
30
38
|
interface GalleryItem {
|
|
31
39
|
id: string;
|
|
@@ -67,7 +75,18 @@ const contentLanguage = inject(FORM_CONTENT_LANGUAGE_KEY, undefined);
|
|
|
67
75
|
|
|
68
76
|
const DEFAULT_ACCEPT = "image/*";
|
|
69
77
|
const URL_REFRESH_LEAD_MS = 5000;
|
|
70
|
-
const
|
|
78
|
+
const FAILURE_LABEL_KEYS: Partial<Record<ItemStatus, string>> = {
|
|
79
|
+
error: "dms.form.image.upload_failed",
|
|
80
|
+
unavailable: "dms.form.image.file_unavailable",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Item ids are random, so they differ between the server render and
|
|
85
|
+
* hydration; DOM ids derive from `useId()`, which both sides agree on.
|
|
86
|
+
*/
|
|
87
|
+
const fieldId = useId();
|
|
88
|
+
const singleAltInputId = `${fieldId}-alt`;
|
|
89
|
+
const detailAltInputId = `${fieldId}-alt-detail`;
|
|
71
90
|
|
|
72
91
|
const items = ref<GalleryItem[]>([]);
|
|
73
92
|
const fileInput = ref<HTMLInputElement | null>(null);
|
|
@@ -77,6 +96,7 @@ const dragOverItemId = ref<string | null>(null);
|
|
|
77
96
|
const detailItemId = ref<string | null>(null);
|
|
78
97
|
const detailAltDraft = ref("");
|
|
79
98
|
const metadataTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
99
|
+
const metadataFailures = new Map<string, number>();
|
|
80
100
|
const pendingMetadata = new Set<string>();
|
|
81
101
|
let isDisposed = false;
|
|
82
102
|
|
|
@@ -120,6 +140,21 @@ const scheduleItemExpiryRefresh = (item: GalleryItem) => {
|
|
|
120
140
|
scheduleItemMetadataRefresh(item, remaining - refreshLead);
|
|
121
141
|
};
|
|
122
142
|
|
|
143
|
+
const handleItemMetadataFailure = (item: GalleryItem, error: unknown) => {
|
|
144
|
+
if (
|
|
145
|
+
isDefinitiveMetadataFailure(error) ||
|
|
146
|
+
!item.url ||
|
|
147
|
+
(item.expiresAt !== undefined && item.expiresAt <= Date.now())
|
|
148
|
+
) {
|
|
149
|
+
item.url = "";
|
|
150
|
+
item.status = "unavailable";
|
|
151
|
+
}
|
|
152
|
+
const failures = (metadataFailures.get(item.id) ?? 0) + 1;
|
|
153
|
+
metadataFailures.set(item.id, failures);
|
|
154
|
+
const delay = resolveMetadataRetryDelay(error, failures);
|
|
155
|
+
if (delay !== null) scheduleItemMetadataRefresh(item, delay);
|
|
156
|
+
};
|
|
157
|
+
|
|
123
158
|
const fetchItemMetadata = async (item: GalleryItem, force = false) => {
|
|
124
159
|
if (isDisposed) return;
|
|
125
160
|
if (!item.key || (!force && item.url) || pendingMetadata.has(item.id)) return;
|
|
@@ -141,17 +176,11 @@ const fetchItemMetadata = async (item: GalleryItem, force = false) => {
|
|
|
141
176
|
item.size = metadata.size;
|
|
142
177
|
item.expiresAt = metadata.expiresAt;
|
|
143
178
|
item.status = "done";
|
|
179
|
+
metadataFailures.delete(item.id);
|
|
144
180
|
scheduleItemExpiryRefresh(item);
|
|
145
|
-
} catch (
|
|
181
|
+
} catch (error) {
|
|
146
182
|
if (isDisposed || !items.value.some(({ id }) => id === item.id)) return;
|
|
147
|
-
|
|
148
|
-
!item.url ||
|
|
149
|
-
(item.expiresAt !== undefined && item.expiresAt <= Date.now())
|
|
150
|
-
) {
|
|
151
|
-
item.url = "";
|
|
152
|
-
item.status = "error";
|
|
153
|
-
}
|
|
154
|
-
scheduleItemMetadataRefresh(item, TRANSIENT_RETRY_MS);
|
|
183
|
+
handleItemMetadataFailure(item, error);
|
|
155
184
|
} finally {
|
|
156
185
|
pendingMetadata.delete(item.id);
|
|
157
186
|
}
|
|
@@ -180,6 +209,7 @@ const releaseItem = (item: GalleryItem) => {
|
|
|
180
209
|
const timer = metadataTimers.get(item.id);
|
|
181
210
|
if (timer) clearTimeout(timer);
|
|
182
211
|
metadataTimers.delete(item.id);
|
|
212
|
+
metadataFailures.delete(item.id);
|
|
183
213
|
};
|
|
184
214
|
|
|
185
215
|
const initializeFromModelValue = () => {
|
|
@@ -524,6 +554,9 @@ const formatFileSize = (bytes: number): string => {
|
|
|
524
554
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
|
525
555
|
};
|
|
526
556
|
|
|
557
|
+
const failureLabelKey = (item: GalleryItem): string | undefined =>
|
|
558
|
+
FAILURE_LABEL_KEYS[item.status];
|
|
559
|
+
|
|
527
560
|
const itemMeta = (item: GalleryItem): string => {
|
|
528
561
|
const parts: string[] = [];
|
|
529
562
|
if (item.width && item.height) parts.push(`${item.width}×${item.height}`);
|
|
@@ -650,13 +683,13 @@ onBeforeUnmount(() => {
|
|
|
650
683
|
</div>
|
|
651
684
|
|
|
652
685
|
<div
|
|
653
|
-
v-else-if="item
|
|
686
|
+
v-else-if="failureLabelKey(item)"
|
|
654
687
|
class="bg-error/15 absolute inset-0 z-[5] flex flex-col items-center justify-center gap-2 p-2 text-center backdrop-blur-[1px]"
|
|
655
688
|
>
|
|
656
689
|
<span
|
|
657
690
|
class="bg-error rounded-md px-2 py-0.5 text-[11px] font-semibold text-white"
|
|
658
691
|
>
|
|
659
|
-
{{ t(
|
|
692
|
+
{{ t(failureLabelKey(item)!) }}
|
|
660
693
|
</span>
|
|
661
694
|
<UButton
|
|
662
695
|
v-if="item.file"
|
|
@@ -794,13 +827,13 @@ onBeforeUnmount(() => {
|
|
|
794
827
|
</div>
|
|
795
828
|
|
|
796
829
|
<div
|
|
797
|
-
v-else-if="singleItem
|
|
830
|
+
v-else-if="failureLabelKey(singleItem)"
|
|
798
831
|
class="bg-error/15 absolute inset-0 z-[5] flex flex-col items-center justify-center gap-2 p-2 text-center backdrop-blur-[1px]"
|
|
799
832
|
>
|
|
800
833
|
<span
|
|
801
834
|
class="bg-error rounded-md px-2 py-0.5 text-[11px] font-semibold text-white"
|
|
802
835
|
>
|
|
803
|
-
{{ t(
|
|
836
|
+
{{ t(failureLabelKey(singleItem)!) }}
|
|
804
837
|
</span>
|
|
805
838
|
<UButton
|
|
806
839
|
v-if="singleItem.file"
|
|
@@ -835,21 +868,21 @@ onBeforeUnmount(() => {
|
|
|
835
868
|
t("dms.form.image.uploading", { progress: singleItem.progress })
|
|
836
869
|
}}
|
|
837
870
|
</template>
|
|
838
|
-
<template v-else-if="singleItem
|
|
839
|
-
{{ t(
|
|
871
|
+
<template v-else-if="failureLabelKey(singleItem)">
|
|
872
|
+
{{ t(failureLabelKey(singleItem)!) }}
|
|
840
873
|
</template>
|
|
841
874
|
<template v-else>{{ itemMeta(singleItem) }}</template>
|
|
842
875
|
</div>
|
|
843
876
|
|
|
844
877
|
<div class="mt-3">
|
|
845
878
|
<label
|
|
846
|
-
:for="
|
|
879
|
+
:for="singleAltInputId"
|
|
847
880
|
class="text-muted mb-1.5 block text-xs font-semibold"
|
|
848
881
|
>
|
|
849
882
|
{{ t("dms.form.image.alt_label") }}
|
|
850
883
|
</label>
|
|
851
884
|
<UInput
|
|
852
|
-
:id="
|
|
885
|
+
:id="singleAltInputId"
|
|
853
886
|
v-model="singleItem.alt"
|
|
854
887
|
:placeholder="t('dms.form.image.alt_placeholder')"
|
|
855
888
|
:disabled="disabled || singleItem.status !== 'done'"
|
|
@@ -903,13 +936,13 @@ onBeforeUnmount(() => {
|
|
|
903
936
|
|
|
904
937
|
<div class="flex flex-col gap-2 p-4">
|
|
905
938
|
<label
|
|
906
|
-
:for="
|
|
939
|
+
:for="detailAltInputId"
|
|
907
940
|
class="text-muted text-xs font-semibold"
|
|
908
941
|
>
|
|
909
942
|
{{ t("dms.form.image.alt_label") }}
|
|
910
943
|
</label>
|
|
911
944
|
<UTextarea
|
|
912
|
-
:id="
|
|
945
|
+
:id="detailAltInputId"
|
|
913
946
|
v-model="detailAltDraft"
|
|
914
947
|
:placeholder="t('dms.form.image.alt_placeholder')"
|
|
915
948
|
:rows="3"
|
|
@@ -31,7 +31,7 @@ interface RelationProps extends SelectMenuProps<T, "value", M> {
|
|
|
31
31
|
disabled?: string;
|
|
32
32
|
};
|
|
33
33
|
addForm?: { componentName: string; options?: FormProps };
|
|
34
|
-
|
|
34
|
+
addPermissionIds?: string[];
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
const props = withDefaults(defineProps<RelationProps>(), {
|
|
@@ -48,7 +48,7 @@ const forwardedProps = computed(() => {
|
|
|
48
48
|
deselectable: ____,
|
|
49
49
|
modelValue: _____,
|
|
50
50
|
addForm: ______,
|
|
51
|
-
|
|
51
|
+
addPermissionIds: _______,
|
|
52
52
|
...rest
|
|
53
53
|
} = props;
|
|
54
54
|
return rest as SelectMenuProps<T, "value", M>;
|
|
@@ -110,7 +110,8 @@ const canAdd = computed(
|
|
|
110
110
|
() =>
|
|
111
111
|
!!props.addForm &&
|
|
112
112
|
!!props.addForm.options?.submitUrl &&
|
|
113
|
-
(!props.
|
|
113
|
+
(!props.addPermissionIds?.length ||
|
|
114
|
+
props.addPermissionIds.some((id) => hasPermission(id))),
|
|
114
115
|
);
|
|
115
116
|
|
|
116
117
|
interface RelationResponse {
|
|
@@ -7,6 +7,47 @@ export interface PresignResponse {
|
|
|
7
7
|
const UPLOAD_SUCCESS_MIN = 200;
|
|
8
8
|
const UPLOAD_SUCCESS_MAX = 300;
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Request headers a browser refuses to let script set (Fetch standard,
|
|
12
|
+
* "forbidden request-header"). A presign may still list them -- the storage
|
|
13
|
+
* signs `Content-Length` -- and the browser sends them itself, so copying them
|
|
14
|
+
* only logs `Refused to set unsafe header` on every upload.
|
|
15
|
+
*/
|
|
16
|
+
const FORBIDDEN_REQUEST_HEADERS = new Set([
|
|
17
|
+
"accept-charset",
|
|
18
|
+
"accept-encoding",
|
|
19
|
+
"access-control-request-headers",
|
|
20
|
+
"access-control-request-method",
|
|
21
|
+
"connection",
|
|
22
|
+
"content-length",
|
|
23
|
+
"cookie",
|
|
24
|
+
"cookie2",
|
|
25
|
+
"date",
|
|
26
|
+
"dnt",
|
|
27
|
+
"expect",
|
|
28
|
+
"host",
|
|
29
|
+
"keep-alive",
|
|
30
|
+
"origin",
|
|
31
|
+
"referer",
|
|
32
|
+
"set-cookie",
|
|
33
|
+
"te",
|
|
34
|
+
"trailer",
|
|
35
|
+
"transfer-encoding",
|
|
36
|
+
"upgrade",
|
|
37
|
+
"via",
|
|
38
|
+
]);
|
|
39
|
+
const FORBIDDEN_REQUEST_HEADER_PREFIXES = ["proxy-", "sec-"];
|
|
40
|
+
|
|
41
|
+
const isForbiddenRequestHeader = (name: string): boolean => {
|
|
42
|
+
const normalized = name.trim().toLowerCase();
|
|
43
|
+
return (
|
|
44
|
+
FORBIDDEN_REQUEST_HEADERS.has(normalized) ||
|
|
45
|
+
FORBIDDEN_REQUEST_HEADER_PREFIXES.some((prefix) =>
|
|
46
|
+
normalized.startsWith(prefix),
|
|
47
|
+
)
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
10
51
|
/**
|
|
11
52
|
* Uploads a file to the presigned storage URL with a PUT request, reporting
|
|
12
53
|
* real upload progress and rejecting on a non-2xx response (a plain `fetch`
|
|
@@ -21,9 +62,9 @@ export function useUploadWithProgress() {
|
|
|
21
62
|
new Promise((resolve, reject) => {
|
|
22
63
|
const xhr = new XMLHttpRequest();
|
|
23
64
|
xhr.open("PUT", presign.uploadUrl);
|
|
24
|
-
Object.entries(presign.headers ?? {})
|
|
25
|
-
|
|
26
|
-
|
|
65
|
+
Object.entries(presign.headers ?? {})
|
|
66
|
+
.filter(([name]) => !isForbiddenRequestHeader(name))
|
|
67
|
+
.forEach(([name, value]) => xhr.setRequestHeader(name, value));
|
|
27
68
|
xhr.upload.onprogress = (event) => {
|
|
28
69
|
if (!event.lengthComputable) return;
|
|
29
70
|
onProgress(Math.round((event.loaded / event.total) * 100));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Generated by the renderer in the workspace: every module's
|
|
2
|
+
// `app/config/shortcuts-registry.ts`, spread in priority order.
|
|
3
|
+
declare module "#shortcuts-aggregated" {
|
|
4
|
+
import type { ComponentShortcuts } from "#dms-ui/app/types/shortcuts";
|
|
5
|
+
|
|
6
|
+
const aggregatedShortcuts: ComponentShortcuts[];
|
|
7
|
+
export default aggregatedShortcuts;
|
|
8
|
+
}
|