diff --git a/.changeset/shy-chairs-return.md b/.changeset/shy-chairs-return.md new file mode 100644 index 0000000000..54e7ced1bc --- /dev/null +++ b/.changeset/shy-chairs-return.md @@ -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 diff --git a/api/src/controllers/tus.ts b/api/src/controllers/tus.ts index 07b327fbd5..22ac71f22c 100644 --- a/api/src/controllers/tus.ts +++ b/api/src/controllers/tus.ts @@ -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(); }); } } diff --git a/api/src/services/tus/data-store.ts b/api/src/services/tus/data-store.ts index 4a03f52b42..a218f26575 100644 --- a/api/src/services/tus/data-store.ts +++ b/api/src/services/tus/data-store.ts @@ -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; diff --git a/api/src/services/tus/server.ts b/api/src/services/tus/server.ts index 05a67be8cf..bbf3536c90 100644 --- a/api/src/services/tus/server.ts +++ b/api/src/services/tus/server.ts @@ -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(); + } } diff --git a/app/src/components/v-upload.vue b/app/src/components/v-upload.vue index f4a279eea5..63e406b9fe 100644 --- a/app/src/components/v-upload.vue +++ b/app/src/components/v-upload.vue @@ -1,12 +1,13 @@ @@ -27,7 +24,7 @@ function close() { {{ t('add_file') }} - + {{ t('done') }} diff --git a/app/src/utils/upload-file.ts b/app/src/utils/upload-file.ts index 2125f15027..2cc47be9aa 100644 --- a/app/src/utils/upload-file.ts +++ b/app/src/utils/upload-file.ts @@ -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; + preset?: Partial; fileId?: string; requirePreviousUpload?: boolean; }, -): Promise { +): Promise { const progressHandler = options?.onProgressChange || (() => undefined); const server = useServerStore(); let notified = false; if (server.info.uploads) { - const fileInfo: Record = {}; - - if (options?.preset) { - for (const [key, value] of Object.entries(options.preset)) { - fileInfo[key] = value; - } - } + const fileInfo: Partial = { ...(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, // 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) { diff --git a/app/src/utils/upload-files.ts b/app/src/utils/upload-files.ts index 08e59dbb52..507c366767 100644 --- a/app/src/utils/upload-files.ts +++ b/app/src/utils/upload-files.ts @@ -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; folder?: string; }, -): Promise { +): 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 []; } diff --git a/app/src/views/private/components/file-preview-replace.vue b/app/src/views/private/components/file-preview-replace.vue index cfc27b3753..1bbdfdd75d 100644 --- a/app/src/views/private/components/file-preview-replace.vue +++ b/app/src/views/private/components/file-preview-replace.vue @@ -13,7 +13,6 @@ const emit = defineEmits<{ const { t } = useI18n(); const dialogActive = ref(false); -const uploadRef = ref | null>(null); function onInput() { dialogActive.value = false; @@ -22,7 +21,6 @@ function onInput() { function close() { dialogActive.value = false; - uploadRef.value?.abort(); } @@ -38,7 +36,7 @@ function close() { {{ t('replace_file') }} - + {{ t('done') }}