Newer
Older
<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 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 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,
bucketNotFoundError: false,
bucketPermissionError: false,
editObjectKey: "",
copyObject: {
key: "",
size: 0,
bucket: "",
last_modified: "2022-01-01",
},
viewDetailObject: {
key: "",
size: 0,
bucket: "",
last_modified: "2022-01-01",
} as {
objects: S3ObjectMetaInformation[];
loading: boolean;
bucketNotFoundError: boolean;
bucketPermissionError: boolean;
createdPermission: undefined | BucketPermissionOut;
editObjectKey: string;
copyObject: S3ObjectMetaInformation;
viewDetailObject: S3ObjectMetaInformation;
// Watcher
// -----------------------------------------------------------------------------
() => 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;
});
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
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]
: [];
});
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
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
// -----------------------------------------------------------------------------
updateObjects(props.bucketName);
document
.querySelectorAll(".tooltip-container")
.forEach(
(tooltipTriggerEl) => new Tooltip(tooltipTriggerEl, { trigger: "hover" })
);
successToast = new Toast("#successToast-" + randomIDSuffix);
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
// 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;
ObjectService.objectGetBucketObjects(bucketName)
.then((objs) => {
objectState.objects = objs;
})
.catch((error) => {
if (error.status === 404) {
objectState.bucketNotFoundError = true;
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);
}
}
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);
deleteObjectsState.deletedItem = splittedKey[splittedKey.length - 1];
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
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);
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" }
);
<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"
@confirm-delete="
deleteObjectsState.deleteFolder
? confirmedDeleteFolder(deleteObjectsState.potentialObjectToDelete)
: confirmedDeleteObject(deleteObjectsState.potentialObjectToDelete)
"
<nav aria-label="breadcrumb" class="fs-2">
<ol class="breadcrumb">
<li class="breadcrumb-item" :class="{ active: subFolderInUrl }">
params: { bucketName: props.bucketName, subFolders: [] },
>{{ props.bucketName }}
<span v-else class="text-secondary">{{ props.bucketName }}</span>
</li>
<li
class="breadcrumb-item"
v-for="(folder, index) in currentSubFolders"
:class="{ active: index === currentSubFolders.length }"
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>
<!-- 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"
>
<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"
>
<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
: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
modalID="create-permission-modal"
:bucket-name="props.bucketName"
:sub-folders="folderStructure"
:edit-user-permission="undefined"
:readonly="false"
:deletable="false"
:back-modal-id="undefined"
@permission-created="
(newPermission) => (objectState.createdPermission = newPermission)
"
/>
<button
: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>
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 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>
<!-- 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>
<!-- Show content of bucket -->
<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">
<td colspan="4" class="text-center fst-italic fw-light">
No objects to display
<!-- Table body when showing objects -->
<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>
<!-- Show buttons with dropdown menu if row is an object -->
class="btn-group btn-group-sm dropdown-center dropdown-menu-start"
>
<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>
<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>
<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>
<button
class="dropdown-item"
type="button"
:disabled="!readableBucket"
data-bs-toggle="modal"
data-bs-target="#copy-object-modal"
@click="objectState.copyObject = obj"
>
Copy
</button>
<button
class="dropdown-item text-danger align-middle"
type="button"
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>