Transmit Directus-File-Id when creating TUS upload (#23086)

This commit is contained in:
Hannes Küttner
2024-07-24 15:46:03 +02:00
committed by GitHub
parent 141b8adbf4
commit 9cb927f5c7
9 changed files with 70 additions and 40 deletions

View 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

View File

@@ -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();
});
}
}

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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 [];
}

View File

@@ -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>