mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Transmit Directus-File-Id when creating TUS upload (#23086)
This commit is contained in:
6
.changeset/shy-chairs-return.md
Normal file
6
.changeset/shy-chairs-return.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@directus/api': patch
|
||||
'@directus/app': patch
|
||||
---
|
||||
|
||||
Fixed TUS file upload logic to include the file UUID to enable file info fetching
|
||||
@@ -31,7 +31,7 @@ const checkFileAccess = asyncHandler(async (req, _res, next) => {
|
||||
const action = mapAction(req.method);
|
||||
|
||||
if (action === 'create') {
|
||||
// checkAccess doesnt seem to work as expected for "create" actions
|
||||
// checkAccess doesn't seem to work as expected for "create" actions
|
||||
const hasPermission = Boolean(
|
||||
req.accountability?.permissions?.find((permission) => {
|
||||
return permission.collection === 'directus_files' && permission.action === action;
|
||||
@@ -52,12 +52,14 @@ const checkFileAccess = asyncHandler(async (req, _res, next) => {
|
||||
});
|
||||
|
||||
const handler = asyncHandler(async (req, res) => {
|
||||
const tusServer = await createTusServer({
|
||||
const [tusServer, cleanupServer] = await createTusServer({
|
||||
schema: req.schema,
|
||||
accountability: req.accountability,
|
||||
});
|
||||
|
||||
await tusServer.handle(req, res);
|
||||
|
||||
cleanupServer();
|
||||
});
|
||||
|
||||
export function scheduleTusCleanup() {
|
||||
@@ -65,11 +67,13 @@ export function scheduleTusCleanup() {
|
||||
|
||||
if (validateCron(RESUMABLE_UPLOADS.SCHEDULE)) {
|
||||
scheduleSynchronizedJob('tus-cleanup', RESUMABLE_UPLOADS.SCHEDULE, async () => {
|
||||
const tusServer = await createTusServer({
|
||||
const [tusServer, cleanupServer] = await createTusServer({
|
||||
schema: await getSchema(),
|
||||
});
|
||||
|
||||
await tusServer.cleanUpExpiredUploads();
|
||||
|
||||
cleanupServer();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,11 @@ export class TusDataStore extends DataStore {
|
||||
// If this is a new file upload, we need to generate a new primary key and DB record
|
||||
const primaryKey = await itemsService.createOne(fileData, { emitEvents: false });
|
||||
|
||||
// Set the file id, so it is available to be sent as a header on upload creation / resume
|
||||
if (!upload.metadata['id']) {
|
||||
upload.metadata['id'] = primaryKey as string;
|
||||
}
|
||||
|
||||
const fileExtension =
|
||||
extname(upload.metadata['filename_download']) ||
|
||||
(upload.metadata['type'] && '.' + extension(upload.metadata['type'])) ||
|
||||
@@ -124,13 +129,6 @@ export class TusDataStore extends DataStore {
|
||||
// The filename_disk is the FINAL filename on disk
|
||||
fileData.filename_disk ||= primaryKey + (fileExtension || '');
|
||||
|
||||
// Temp filename is used for replacements
|
||||
// const tempFilenameDisk = fileData.tus_id! + (fileExtension || '');
|
||||
|
||||
// if (isReplacement) {
|
||||
// upload.metadata['temp_file'] = tempFilenameDisk;
|
||||
// }
|
||||
|
||||
try {
|
||||
// If this is a replacement, we'll write the file to a temp location first to ensure we don't overwrite the existing file if something goes wrong
|
||||
upload = (await this.storageDriver.createChunkedUpload(fileData.filename_disk, upload)) as Upload;
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Driver, TusDriver } from '@directus/storage';
|
||||
import { supportsTus } from '@directus/storage';
|
||||
import type { Accountability, File, SchemaOverview } from '@directus/types';
|
||||
import { toArray } from '@directus/utils';
|
||||
import { Server } from '@tus/server';
|
||||
import { Server, EVENTS } from '@tus/server';
|
||||
import { RESUMABLE_UPLOADS } from '../../constants.js';
|
||||
import { getStorage } from '../../storage/index.js';
|
||||
import { extractMetadata } from '../files/lib/extract-metadata.js';
|
||||
@@ -41,11 +41,11 @@ async function createTusStore(context: Context) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTusServer(context: Context) {
|
||||
export async function createTusServer(context: Context): Promise<[Server, () => void]> {
|
||||
const env = useEnv();
|
||||
const store = await createTusStore(context);
|
||||
|
||||
return new Server({
|
||||
const server = new Server({
|
||||
path: '/files/tus',
|
||||
datastore: store,
|
||||
locker: getTusLocker(),
|
||||
@@ -97,4 +97,14 @@ export async function createTusServer(context: Context) {
|
||||
},
|
||||
relativeLocation: String(env['PUBLIC_URL']).startsWith('http'),
|
||||
});
|
||||
|
||||
server.on(EVENTS.POST_CREATE, async (_req, res, upload) => {
|
||||
res.setHeader('Directus-File-Id', upload.metadata!['id']!);
|
||||
});
|
||||
|
||||
return [server, cleanup];
|
||||
|
||||
function cleanup() {
|
||||
server.removeAllListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api';
|
||||
import type { File } from '@directus/types';
|
||||
import { emitter, Events } from '@/events';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { uploadFile } from '@/utils/upload-file';
|
||||
import { uploadFiles } from '@/utils/upload-files';
|
||||
import DrawerFiles from '@/views/private/components/drawer-files.vue';
|
||||
import { sum } from 'lodash';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onUnmounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { Upload } from 'tus-js-client';
|
||||
|
||||
@@ -47,6 +48,10 @@ const { setSelection } = useSelection();
|
||||
const activeDialog = ref<'choose' | 'url' | null>(null);
|
||||
const input = ref<HTMLInputElement>();
|
||||
|
||||
onUnmounted(() => {
|
||||
uploadController?.abort();
|
||||
});
|
||||
|
||||
function validFiles(files: FileList) {
|
||||
if (files.length === 0) return false;
|
||||
|
||||
@@ -125,9 +130,13 @@ function useUpload() {
|
||||
preset,
|
||||
});
|
||||
|
||||
uploadedFiles && emit('input', uploadedFiles);
|
||||
uploadedFiles &&
|
||||
emit(
|
||||
'input',
|
||||
uploadedFiles.filter((f): f is File => !!f),
|
||||
);
|
||||
} else {
|
||||
const uploadedFile = await uploadFile(Array.from(files)[0] as File, {
|
||||
const uploadedFile = await uploadFile(Array.from(files)[0]!, {
|
||||
onProgressChange: (percentage) => {
|
||||
progress.value = percentage;
|
||||
done.value = percentage === 100 ? 1 : 0;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import VUpload, { UploadController } from '@/components/v-upload.vue';
|
||||
import VUpload from '@/components/v-upload.vue';
|
||||
import { useDialogRoute } from '@/composables/use-dialog-route';
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
@@ -14,10 +13,8 @@ const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const isOpen = useDialogRoute();
|
||||
const uploadRef = ref<InstanceType<typeof VUpload> | null>(null);
|
||||
|
||||
function close() {
|
||||
uploadRef.value?.abort();
|
||||
router.push(props.folder ? { path: `/files/folders/${props.folder}` } : { path: '/files' });
|
||||
}
|
||||
</script>
|
||||
@@ -27,7 +24,7 @@ function close() {
|
||||
<v-card>
|
||||
<v-card-title>{{ t('add_file') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-upload ref="uploadRef" :folder="props.folder" multiple from-url @input="close" />
|
||||
<v-upload :folder="props.folder" multiple from-url @input="close" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="close">{{ t('done') }}</v-button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import api from '@/api';
|
||||
import type { File } from '@directus/types';
|
||||
import { emitter, Events } from '@/events';
|
||||
import { i18n } from '@/lang';
|
||||
import { useServerStore } from '@/stores/server';
|
||||
@@ -10,29 +11,23 @@ import type { PreviousUpload } from 'tus-js-client';
|
||||
import { Upload } from 'tus-js-client';
|
||||
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
file: globalThis.File,
|
||||
options?: {
|
||||
onProgressChange?: (percentage: number) => void;
|
||||
onChunkedUpload?: (controller: Upload) => void;
|
||||
notifications?: boolean;
|
||||
preset?: Record<string, any>;
|
||||
preset?: Partial<File>;
|
||||
fileId?: string;
|
||||
requirePreviousUpload?: boolean;
|
||||
},
|
||||
): Promise<any> {
|
||||
): Promise<File | undefined> {
|
||||
const progressHandler = options?.onProgressChange || (() => undefined);
|
||||
|
||||
const server = useServerStore();
|
||||
let notified = false;
|
||||
|
||||
if (server.info.uploads) {
|
||||
const fileInfo: Record<string, any> = {};
|
||||
|
||||
if (options?.preset) {
|
||||
for (const [key, value] of Object.entries(options.preset)) {
|
||||
fileInfo[key] = value;
|
||||
}
|
||||
}
|
||||
const fileInfo: Partial<File> = { ...(options?.preset ?? {}) };
|
||||
|
||||
if (options?.fileId) {
|
||||
fileInfo.id = options?.fileId;
|
||||
@@ -45,7 +40,7 @@ export async function uploadFile(
|
||||
const upload = new Upload(file, {
|
||||
endpoint: getRootPath() + `files/tus`,
|
||||
chunkSize: server.info.uploads?.chunkSize ?? 10_000_000,
|
||||
metadata: fileInfo,
|
||||
metadata: fileInfo as Record<string, string>,
|
||||
// Allow user to re-upload of the same file
|
||||
// https://github.com/tus/tus-js-client/blob/main/docs/api.md#removefingerprintonsuccess
|
||||
removeFingerprintOnSuccess: true,
|
||||
@@ -66,7 +61,7 @@ export async function uploadFile(
|
||||
notified = true;
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
async onSuccess() {
|
||||
if (options?.notifications) {
|
||||
notify({
|
||||
title: i18n.global.t('upload_file_success'),
|
||||
@@ -76,11 +71,20 @@ export async function uploadFile(
|
||||
emitter.emit(Events.upload);
|
||||
emitter.emit(Events.tusResumableUploadsChanged);
|
||||
|
||||
resolve(fileInfo);
|
||||
const response = await api.get(`files/${fileInfo.id}`);
|
||||
|
||||
if (response) {
|
||||
resolve(response.data.data);
|
||||
} else {
|
||||
resolve(fileInfo as File);
|
||||
}
|
||||
},
|
||||
onShouldRetry() {
|
||||
return false;
|
||||
},
|
||||
onAfterResponse(_req, res) {
|
||||
fileInfo.id ??= res.getHeader('Directus-File-Id');
|
||||
},
|
||||
});
|
||||
|
||||
options?.onChunkedUpload?.({
|
||||
@@ -99,6 +103,7 @@ export async function uploadFile(
|
||||
// Found previous uploads so we select the first one.
|
||||
if (previousUploads.length > 0) {
|
||||
upload.resumeFromPreviousUpload(previousUploads[0]!);
|
||||
fileInfo.id = previousUploads[0]!.metadata['id'];
|
||||
}
|
||||
|
||||
// Start the upload
|
||||
@@ -141,6 +146,8 @@ export async function uploadFile(
|
||||
} catch (error) {
|
||||
unexpectedError(error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function onUploadProgress(progressEvent: AxiosProgressEvent) {
|
||||
|
||||
@@ -2,10 +2,11 @@ import { i18n } from '@/lang';
|
||||
import { notify } from '@/utils/notify';
|
||||
import { uploadFile } from '@/utils/upload-file';
|
||||
import { unexpectedError } from './unexpected-error';
|
||||
import type { File } from '@directus/types';
|
||||
import type { Upload } from 'tus-js-client';
|
||||
|
||||
export async function uploadFiles(
|
||||
files: File[],
|
||||
files: globalThis.File[],
|
||||
options?: {
|
||||
onProgressChange?: (percentages: number[]) => void;
|
||||
onChunkedUpload?: (controllers: (Upload | null)[]) => void;
|
||||
@@ -13,7 +14,7 @@ export async function uploadFiles(
|
||||
preset?: Record<string, any>;
|
||||
folder?: string;
|
||||
},
|
||||
): Promise<File[] | undefined> {
|
||||
): Promise<(File | undefined)[]> {
|
||||
const progressHandler = options?.onProgressChange || (() => undefined);
|
||||
const progressForFiles = files.map(() => 0);
|
||||
const uploadControllers: (Upload | null)[] = Array(files.length).fill(null);
|
||||
@@ -48,5 +49,5 @@ export async function uploadFiles(
|
||||
unexpectedError(error);
|
||||
}
|
||||
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const dialogActive = ref(false);
|
||||
const uploadRef = ref<InstanceType<typeof VUpload> | null>(null);
|
||||
|
||||
function onInput() {
|
||||
dialogActive.value = false;
|
||||
@@ -22,7 +21,6 @@ function onInput() {
|
||||
|
||||
function close() {
|
||||
dialogActive.value = false;
|
||||
uploadRef.value?.abort();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -38,7 +36,7 @@ function close() {
|
||||
<v-card>
|
||||
<v-card-title>{{ t('replace_file') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-upload ref="uploadRef" :file-id="file.id" from-url @input="onInput" />
|
||||
<v-upload :file-id="file.id" from-url @input="onInput" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="close">{{ t('done') }}</v-button>
|
||||
|
||||
Reference in New Issue
Block a user