<script setup lang="ts"> import { onMounted, reactive, watch, computed } from "vue"; import type { ComputedRef } from "vue"; import type { S3ObjectMetaInformation, BucketPermissionOut, } from "@/client/s3proxy"; import type { FolderTree, S3PseudoFolder, S3ObjectWithFolder, } from "@/types/PseudoFolder"; import { ObjectService } from "@/client/s3proxy"; import BootstrapIcon from "@/components/BootstrapIcon.vue"; import { filesize } from "filesize"; import dayjs from "dayjs"; import { Toast, Tooltip } from "bootstrap"; import PermissionListModal from "@/components/object-storage/modals/PermissionListModal.vue"; import UploadObjectModal from "@/components/object-storage/modals/UploadObjectModal.vue"; import CopyObjectModal from "@/components/object-storage/modals/CopyObjectModal.vue"; import PermissionModal from "@/components/object-storage/modals/PermissionModal.vue"; import ObjectDetailModal from "@/components/object-storage/modals/ObjectDetailModal.vue"; import CreateFolderModal from "@/components/object-storage/modals/CreateFolderModal.vue"; import DeleteModal from "@/components/modals/DeleteModal.vue"; import { S3Client, DeleteObjectCommand, DeleteObjectsCommand, GetObjectCommand, } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { awsAuthMiddlewareOptions } from "@aws-sdk/middleware-signing"; import { useAuthStore } from "@/stores/auth"; import { useBucketStore } from "@/stores/buckets"; import { environment } from "@/environment"; const authStore = useAuthStore(); const bucketRepository = useBucketStore(); const middleware = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore (next) => async (args) => { args.request.headers["host"] = environment.S3_URL.split("://")[1]; return await next(args); }, { relation: "before", toMiddleware: awsAuthMiddlewareOptions?.name ?? "impossible", }, ]; let client = new S3Client({ region: "us-east-1", endpoint: environment.S3_URL, forcePathStyle: true, credentials: { accessKeyId: authStore.s3key?.access_key ?? "", secretAccessKey: authStore.s3key?.secret_key ?? "", }, }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore client.middlewareStack.addRelativeTo(middleware[0], middleware[1]); // If S3 Key changes authStore.$onAction(({ name, args }) => { if (name === "setS3Key") { if (args[0] === null) { console.error("There are no S3 Keys"); } else { client = new S3Client({ region: "us-east-1", endpoint: environment.S3_URL, forcePathStyle: true, credentials: { accessKeyId: args[0].access_key, secretAccessKey: args[0].secret_key, }, }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore client.middlewareStack.addRelativeTo(middleware[0], middleware[1]); } } }); // Constants // ----------------------------------------------------------------------------- const props = defineProps<{ bucketName: string; subFolders: string[] | string; permission: BucketPermissionOut | undefined; }>(); const randomIDSuffix = Math.random().toString(16).substr(2, 8); let successToast: Toast | null = null; // Reactive State // ----------------------------------------------------------------------------- const deleteObjectsState = reactive({ deletedItem: "", potentialObjectToDelete: "", deleteFolder: true, } as { deletedItem: string; potentialObjectToDelete: string; deleteFolder: boolean; }); const objectState = reactive({ objects: [], loading: true, filterString: "", bucketNotFoundError: false, bucketPermissionError: false, createdPermission: undefined, editObjectKey: "", copyObject: { key: "", size: 0, bucket: "", last_modified: "2022-01-01", content_type: "text/plain", }, viewDetailObject: { key: "", size: 0, bucket: "", last_modified: "2022-01-01", content_type: "text/plain", }, } as { objects: S3ObjectMetaInformation[]; loading: boolean; filterString: string; bucketNotFoundError: boolean; bucketPermissionError: boolean; createdPermission: undefined | BucketPermissionOut; editObjectKey: string; copyObject: S3ObjectMetaInformation; viewDetailObject: S3ObjectMetaInformation; }); // Watcher // ----------------------------------------------------------------------------- watch( () => props.bucketName, (newBucketName, oldBucketName) => { if (oldBucketName !== newBucketName) { // If bucket is changed, update the objects updateObjects(newBucketName); objectState.filterString = ""; } } ); // Computed Properties // ----------------------------------------------------------------------------- const filteredObjects: ComputedRef<(S3ObjectWithFolder | S3PseudoFolder)[]> = computed(() => { return objectState.filterString.length > 0 ? visibleObjects.value.filter((obj) => obj.key.includes(objectState.filterString) ) : visibleObjects.value; }); const folderStructure: ComputedRef<FolderTree> = computed(() => { /** * Store the entire folder structure in a bucket in a tree-like data structure */ return objectsWithFolders.value.reduce( // Add one object after another to the folder structure (fTree, currentObject) => { // If the object is not in a sub folder, but it in the top level 'folder' if (currentObject.folder.length === 0) { fTree.files.push(currentObject); } else { // If the object is in a sub folder let currentFolder: FolderTree = fTree; // For every sub folder the object is in , navigate into the sub folder for (const folderName of currentObject.folder) { // If the sub folder doesn't exist yet, create it if ( Object.keys(currentFolder.subFolders).find( (subFolderName) => subFolderName === folderName ) == undefined ) { currentFolder.subFolders[folderName] = { subFolders: {}, files: [], }; } // navigate into the sub folder currentFolder = currentFolder.subFolders[folderName] as FolderTree; } // Add object to the folder currentFolder.files.push(currentObject); } return fTree; }, // Empty folder structure as initial value { subFolders: {}, files: [], } as FolderTree ); }); const objectsWithFolders: ComputedRef<S3ObjectWithFolder[]> = computed(() => { /** * Add to the meta information from objects the pseudo filename and their pseudo folder * This can be inferred from the key of the object where the '/' character is the delimiter, e.g. * dir1/dir2/text.txt -> * folder: dir1, dir2 * filename: text.txt */ return objectState.objects.map((obj) => { const splittedKey = obj.key.split("/"); return { ...obj, pseudoFileName: splittedKey[splittedKey.length - 1], folder: splittedKey.slice(0, splittedKey.length - 1), }; }); }); const currentSubFolders: ComputedRef<string[]> = computed(() => { /** * Transform a single sub folder from a string to an array containing the string and * replace an empty string with an empty list */ return props.subFolders instanceof Array ? props.subFolders : props.subFolders.length > 0 ? [props.subFolders] : []; }); const visibleObjects: ComputedRef<(S3ObjectWithFolder | S3PseudoFolder)[]> = computed(() => { /** * Compute the visible objects based on the current sub folder */ let currentFolder = folderStructure.value; // Navigate into right sub folder for (const subFolder of currentSubFolders.value) { if (currentFolder.subFolders[subFolder] == null) { // If sub folder doesn't exist, no object is visible return []; } else { currentFolder = currentFolder.subFolders[subFolder]; } } // Add all objects and sub folders from the current sub folder as visible object const arr = []; arr.push(...currentFolder.files); arr.push( ...Object.keys(currentFolder.subFolders).map((subFolderName) => { const folderSize = calculateFolderSize( currentFolder.subFolders[subFolderName] ); const folderLastModified = dayjs( calculateFolderLastModified(currentFolder.subFolders[subFolderName]) ).toISOString(); return { name: subFolderName, size: folderSize, key: subFolderName, parentFolder: currentSubFolders.value, last_modified: folderLastModified, } as S3PseudoFolder; }) ); return arr.filter((obj) => !obj.key.endsWith(".s3keep")); }); const subFolderInUrl: ComputedRef<boolean> = computed( () => currentSubFolders.value.length > 0 ); const errorLoadingObjects: ComputedRef<boolean> = computed( () => objectState.bucketPermissionError || objectState.bucketNotFoundError ); const writableBucket: ComputedRef<boolean> = computed(() => bucketRepository.writableBucket(props.bucketName) ); const readableBucket: ComputedRef<boolean> = computed(() => bucketRepository.readableBucket(props.bucketName) ); // Lifecycle Hooks // ----------------------------------------------------------------------------- onMounted(() => { updateObjects(props.bucketName); document .querySelectorAll(".tooltip-container") .forEach( (tooltipTriggerEl) => new Tooltip(tooltipTriggerEl, { trigger: "hover" }) ); successToast = new Toast("#successToast-" + randomIDSuffix); }); // Functions // ----------------------------------------------------------------------------- /** * Calculate recursively the cumulative file size of all o objects in a folder * @param folder Folder to inspect * @returns The size of this folder in bytes */ function calculateFolderSize(folder: FolderTree): number { let folderSize = 0; folderSize += folder.files.reduce((acc, file) => acc + file.size, 0); for (const subFolderName of Object.keys(folder.subFolders)) { folderSize += calculateFolderSize(folder.subFolders[subFolderName]); } return folderSize; } /** * Calculate recursively when an object in a folder were modified the last time * @param folder Folder to inspect * @returns The last modified timestamp as ISO string */ function calculateFolderLastModified(folder: FolderTree): string { let lastModified: dayjs.Dayjs; lastModified = folder.files .map((f) => dayjs(f.last_modified)) .reduce( (acc, fileAccessed) => (fileAccessed.isAfter(acc) ? fileAccessed : acc), dayjs("2000-01-01") ); for (const subFolderName of Object.keys(folder.subFolders)) { const lastModifiedSubFolder = dayjs( calculateFolderLastModified(folder.subFolders[subFolderName]) ); if (lastModifiedSubFolder.isAfter(lastModified)) { lastModified = lastModifiedSubFolder; } } return lastModified.toISOString(); } /** * Load the meta information about objects from a bucket * @param bucketName Name of a bucket */ function updateObjects(bucketName: string) { objectState.bucketNotFoundError = false; objectState.bucketPermissionError = false; objectState.loading = true; ObjectService.objectGetBucketObjects(bucketName) .then((objs) => { objectState.objects = objs; }) .catch((error) => { if (error.status === 404) { objectState.bucketNotFoundError = true; } else if (error.status == 403) { objectState.bucketPermissionError = true; } }) .finally(() => { objectState.loading = false; }); } function isS3Object( obj: S3PseudoFolder | S3ObjectWithFolder ): obj is S3ObjectWithFolder { return (obj as S3ObjectWithFolder).folder !== undefined; } /** * callback function when an object has been uploaded * @param newObject Uploaded object */ function objectUploaded(newObject: S3ObjectMetaInformation) { bucketRepository.fetchBucket(newObject.bucket); const index = objectState.objects.findIndex( (obj) => obj.key === newObject.key ); if (index > -1) { objectState.objects[index] = newObject; } else { objectState.objects.push(newObject); } } /** * callback function when an object has been copied * @param copiedObject Uploaded object */ function objectCopied(copiedObject: S3ObjectMetaInformation) { bucketRepository.fetchBucket(copiedObject.bucket); if (copiedObject.bucket === props.bucketName) { objectState.objects.push(copiedObject); } } function deleteObject(key: string) { deleteObjectsState.potentialObjectToDelete = key; deleteObjectsState.deleteFolder = false; } /** * Delete an Object in the current folder * @param key Key of the Object */ function confirmedDeleteObject(key: string) { const command = new DeleteObjectCommand({ Bucket: props.bucketName, Key: key, }); client .send(command) .then(() => { bucketRepository.fetchBucket(props.bucketName); const splittedKey = key.split("/"); deleteObjectsState.deletedItem = splittedKey[splittedKey.length - 1]; successToast?.show(); objectState.objects = objectState.objects.filter( (obj) => obj.key !== key ); }) .catch((err) => { console.error(err); }); } /** * Initiate the download of the provided object * @param key Key of the object * @param bucket Bucket of the object */ async function downloadObject(key: string, bucket: string) { const command = new GetObjectCommand({ Bucket: bucket, Key: key, }); const url = await getSignedUrl(client, command, { expiresIn: 30 }); //creating an invisible element const element = document.createElement("a"); element.setAttribute("href", url); element.setAttribute("target", "_blank"); document.body.appendChild(element); element.click(); document.body.removeChild(element); } function deleteFolder(folderPath: string) { deleteObjectsState.potentialObjectToDelete = folderPath; deleteObjectsState.deleteFolder = true; } /** * Delete a folder in the current Bucket * @param folderPath Path to the folder with a trailing "/", e.g. some/path/to/a/folder/ */ function confirmedDeleteFolder(folderPath: string) { const command = new DeleteObjectsCommand({ Bucket: props.bucketName, Delete: { Objects: objectState.objects .filter((obj) => obj.key.startsWith(folderPath)) .map((obj) => { return { Key: obj.key }; }), }, }); client .send(command) .then(() => { bucketRepository.fetchBucket(props.bucketName); const splittedPath = folderPath.split("/"); deleteObjectsState.deletedItem = splittedPath[splittedPath.length - 2]; successToast?.show(); objectState.objects = objectState.objects.filter( (obj) => !obj.key.startsWith(folderPath) ); }) .catch((err) => { console.error(err); }); } function getObjectFileName(key: string): string { const splittedKey = key.split("/"); return splittedKey[splittedKey.length - 1]; } watch( visibleObjects, (visObjs) => { if (visObjs.length > 0) { // Initialise tooltips after DOM changes setTimeout(() => { document .querySelectorAll("span.date-tooltip") .forEach((tooltipTriggerEl) => new Tooltip(tooltipTriggerEl)); }, 500); } }, { flush: "post" } ); </script> <template> <div class="toast-container position-fixed top-toast end-0 p-3"> <div role="alert" aria-live="assertive" aria-atomic="true" class="toast text-bg-success align-items-center border-0" data-bs-autohide="true" :id="'successToast-' + randomIDSuffix" > <div class="d-flex"> <div class="toast-body"> Successfully deleted {{ deleteObjectsState.deletedItem }} </div> <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close" ></button> </div> </div> </div> <DeleteModal modalID="delete-object-modal" :object-name-delete="deleteObjectsState.potentialObjectToDelete" :back-modal-id="undefined" @confirm-delete=" deleteObjectsState.deleteFolder ? confirmedDeleteFolder(deleteObjectsState.potentialObjectToDelete) : confirmedDeleteObject(deleteObjectsState.potentialObjectToDelete) " /> <!-- Navbar Breadcrumb --> <nav aria-label="breadcrumb" class="fs-2"> <ol class="breadcrumb"> <li class="breadcrumb-item" :class="{ active: subFolderInUrl }"> <router-link v-if="subFolderInUrl" :to="{ name: 'bucket', params: { bucketName: props.bucketName, subFolders: [] }, }" >{{ props.bucketName }} </router-link> <span v-else class="text-secondary">{{ props.bucketName }}</span> </li> <li class="breadcrumb-item" v-for="(folder, index) in currentSubFolders" :key="folder" :class="{ active: index === currentSubFolders.length }" > <router-link v-if="index !== currentSubFolders.length - 1" :to="{ name: 'bucket', params: { bucketName: props.bucketName, subFolders: currentSubFolders.slice(0, index + 1), }, }" >{{ folder }} </router-link> <span v-else class="text-secondary">{{ folder }}</span> </li> </ol> </nav> <!-- Inputs on top --> <!-- Search bucket text input --> <div class="row"> <div class="col-8"> <div class="input-group mt-2"> <span class="input-group-text" id="objects-search-wrapping" ><bootstrap-icon icon="search" :width="16" :height="16" /></span> <input type="text" class="form-control" placeholder="Search Objects" aria-label="Search Objects" aria-describedby="objects-search-wrapping" :disabled="errorLoadingObjects" v-model.trim="objectState.filterString" /> </div> </div> <!-- Upload object button --> <div id="BucketViewButtons" class="col-auto"> <button type="button" class="btn btn-secondary me-2 tooltip-container" :disabled="errorLoadingObjects || !writableBucket" data-bs-toggle="modal" data-bs-title="Upload Object" data-bs-target="#upload-object-modal" > <bootstrap-icon icon="upload" :width="16" :height="16" fill="white" /> <span class="visually-hidden">Upload Object</span> </button> <upload-object-modal :bucket-name="props.bucketName" :s3-client="client" modalID="upload-object-modal" :key-prefix="currentSubFolders.join('/')" :edit-object-file-name="undefined" @object-created="objectUploaded" /> <!-- Add folder button --> <button type="button" class="btn btn-secondary m-2 tooltip-container" :disabled="errorLoadingObjects || !writableBucket" data-bs-toggle="modal" data-bs-title="Create Folder" data-bs-target="#create-folder-modal" > <bootstrap-icon icon="plus-lg" :width="16" :height="16" fill="white" /> Folder <span class="visually-hidden">Add Folder</span> </button> <create-folder-modal :bucket-name="props.bucketName" :s3-client="client" modalID="create-folder-modal" :key-prefix="currentSubFolders.join('/')" @folder-created="objectUploaded" /> <!-- Add bucket permission button --> <button v-if="!authStore.foreignUser" :hidden="!bucketRepository.permissionFeatureAllowed(props.bucketName)" type="button" class="btn btn-secondary m-2 tooltip-container" :disabled="errorLoadingObjects" data-bs-toggle="modal" data-bs-title="Create Bucket Permission" data-bs-target="#create-permission-modal" > <bootstrap-icon icon="person-plus-fill" :width="16" :height="16" fill="white" /> <span class="visually-hidden">Add Bucket Permission</span> </button> <permission-modal v-if="!authStore.foreignUser" modalID="create-permission-modal" :bucket-name="props.bucketName" :sub-folders="folderStructure" :edit-user-permission="undefined" :editable="false" :readonly="false" :deletable="false" :back-modal-id="undefined" @permission-created=" (newPermission) => (objectState.createdPermission = newPermission) " /> <button v-if="!authStore.foreignUser" :hidden="!bucketRepository.permissionFeatureAllowed(props.bucketName)" type="button" class="btn btn-secondary m-2 tooltip-container" :disabled="errorLoadingObjects" data-bs-title="List Bucket Permission" data-bs-toggle="modal" data-bs-target="#permission-list-modal" > <bootstrap-icon icon="person-lines-fill" :width="16" :height="16" fill="white" /> <span class="visually-hidden">View Bucket Permissions</span> </button> <permission-list-modal v-if=" bucketRepository.getBucketPermission(props.bucketName) == null && !authStore.foreignUser " :bucket-name="props.bucketName" :sub-folders="folderStructure" modalID="permission-list-modal" :add-permission="objectState.createdPermission" /> </div> </div> <!-- Body --> <div class="pt-3"> <!-- If bucket not found --> <div v-if="objectState.bucketNotFoundError" class="text-center fs-2 mt-5"> <bootstrap-icon icon="search" class="mb-3" :width="64" :height="64" style="color: var(--bs-secondary)" fill="currentColor" /> <p> Bucket <i>{{ props.bucketName }}</i> not found </p> </div> <!-- If no permission for bucket --> <div v-else-if="objectState.bucketPermissionError" class="text-center fs-2 mt-5" > <bootstrap-icon icon="folder-x" class="mb-3" :width="64" :height="64" style="color: var(--bs-secondary)" fill="currentColor" /> <p>You don't have permission for this bucket</p> </div> <!-- Show content of bucket --> <div v-else> <!-- Table header --> <table class="table table-dark table-striped table-hover caption-top align-middle" > <caption> Displaying {{ objectState.loading ? 0 : filteredObjects.length }} Objects </caption> <thead> <tr> <th scope="col">Name</th> <th scope="col">Last Accessed</th> <th scope="col">Size</th> <th scope="col"></th> </tr> </thead> <!-- Table body when loading the objects --> <tbody v-if="objectState.loading"> <tr v-for="n in 5" :key="n" class="placeholder-glow"> <th scope="row"> <span class="placeholder w-100 bg-secondary"></span> </th> <td><span class="placeholder w-50 bg-secondary"></span></td> <td><span class="placeholder w-50 bg-secondary"></span></td> <td></td> </tr> </tbody> <!-- Table body when no objects are in the bucket --> <tbody v-else-if="filteredObjects.length === 0"> <tr> <td colspan="4" class="text-center fst-italic fw-light"> No objects to display </td> </tr> </tbody> <!-- Table body when showing objects --> <tbody v-else> <tr v-for="obj in filteredObjects" :key="obj.key"> <th scope="row" class="text-truncate"> <!-- Show file name if row is an object --> <div v-if="isS3Object(obj)">{{ obj.pseudoFileName }}</div> <!-- Show link to subfolder if row is a folder --> <div v-else> <router-link class="text-decoration-none" :to="{ name: 'bucket', params: { bucketName: props.bucketName, subFolders: obj.parentFolder.concat(obj.name), }, }" >{{ obj.name }} </router-link> </div> </th> <td> <span class="date-tooltip" data-bs-toggle="tooltip" :data-bs-title=" dayjs(obj.last_modified).format('DD.MM.YYYY HH:mm:ss') " >{{ dayjs(obj.last_modified).fromNow() }}</span > </td> <td>{{ filesize(obj.size) }}</td> <!-- Show buttons with dropdown menu if row is an object --> <td class="text-end"> <div v-if="isS3Object(obj)" class="btn-group btn-group-sm dropdown-center dropdown-menu-start" > <!-- Download Button --> <button type="button" class="btn btn-secondary" @click="downloadObject(obj.key, props.bucketName)" :disabled="!readableBucket" > Download </button> <button type="button" class="btn btn-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false" > <span class="visually-hidden">Toggle Dropdown</span> </button> <!-- Dropdown menu --> <ul class="dropdown-menu dropdown-menu-dark"> <li> <button class="dropdown-item" type="button" data-bs-toggle="modal" data-bs-target="#detail-object-modal" @click="objectState.viewDetailObject = obj" > Details </button> </li> <li> <button class="dropdown-item" type="button" :disabled="!writableBucket" data-bs-toggle="modal" data-bs-target="#edit-object-modal" @click="objectState.editObjectKey = obj.key" > Edit </button> </li> <li> <button class="dropdown-item" type="button" :disabled="!readableBucket" data-bs-toggle="modal" data-bs-target="#copy-object-modal" @click="objectState.copyObject = obj" > Copy </button> </li> <li> <button class="dropdown-item text-danger align-middle" type="button" @click="deleteObject(obj.key)" data-bs-toggle="modal" data-bs-target="#delete-object-modal" :disabled="!writableBucket" > <bootstrap-icon icon="trash-fill" class="text-danger" :width="13" :height="13" fill="currentColor" /> <span class="ms-1">Delete</span> </button> </li> </ul> </div> <!-- Show delete button when row is a folder --> <div v-else> <button type="button" class="btn btn-danger btn-sm align-middle" :disabled="!writableBucket" data-bs-toggle="modal" data-bs-target="#delete-object-modal" @click=" deleteFolder( obj.parentFolder.concat(['']).join('/') + obj.name + '/' ) " > <bootstrap-icon icon="trash-fill" class="text-danger me-2" :width="12" :height="12" fill="white" /> <span>Delete</span> </button> </div> </td> </tr> </tbody> </table> <upload-object-modal :bucket-name="props.bucketName" :s3-client="client" modalID="edit-object-modal" :key-prefix="currentSubFolders.join('/')" :edit-object-file-name="getObjectFileName(objectState.editObjectKey)" @object-created="objectUploaded" /> <copy-object-modal :source-object="objectState.copyObject" :s3-client="client" modalID="copy-object-modal" @object-copied="objectCopied" /> <object-detail-modal :s3-object="objectState.viewDetailObject" modalID="detail-object-modal" /> </div> </div> </template> <style scoped></style>