Skip to content
Snippets Groups Projects
Commit c9ca980f authored by Daniel Göbel's avatar Daniel Göbel
Browse files

Add direct S3 interaction

parent d7e7739c
No related branches found
No related tags found
2 merge requests!22Version 1.0.0,!12Add direct S3 interaction
Showing
with 3386 additions and 1288 deletions
Source diff could not be displayed: it is too large. Options to address this: view the blob.
...@@ -11,34 +11,39 @@ ...@@ -11,34 +11,39 @@
"generate-client": "openapi --input http://localhost:9999/api/openapi.json --output src/client --client axios" "generate-client": "openapi --input http://localhost:9999/api/openapi.json --output src/client --client axios"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.154.0", "@aws-sdk/client-s3": "^3.165.0",
"@popperjs/core": "^2.11.5", "@aws-sdk/s3-request-presigner": "^3.165.0",
"bootstrap": "^5.2.0", "@aws-sdk/lib-storage": "^3.165.0",
"@popperjs/core": "^2.11.6",
"bootstrap": "^5.2.1",
"bootstrap-icons": "^1.9.1", "bootstrap-icons": "^1.9.1",
"dayjs": "^1.11.4", "dayjs": "^1.11.5",
"filesize": "^9.0.11", "filesize": "^9.0.11",
"pinia": "^2.0.16", "pinia": "^2.0.22",
"vue": "^3.2.37", "vue": "3.2.37",
"vue-router": "^4.1.2", "vue-router": "^4.1.5",
"vue3-cookies": "^1.0.6" "vue3-cookies": "^1.0.6"
}, },
"devDependencies": { "devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@esbuild-plugins/node-modules-polyfill": "^0.1.4",
"@rushstack/eslint-patch": "^1.1.0", "@rushstack/eslint-patch": "^1.1.0",
"@types/bootstrap": "^5.2.0", "@types/bootstrap": "^5.2.0",
"@types/node": "^16.11.45", "@types/node": "^16.11.45",
"@vitejs/plugin-vue": "^3.0.1", "@vitejs/plugin-vue": "^3.1.0",
"@vue/eslint-config-prettier": "^7.0.0", "@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.0", "@vue/eslint-config-typescript": "^11.0.1",
"@vue/tsconfig": "^0.1.3", "@vue/tsconfig": "^0.1.3",
"axios": "^0.27.2", "axios": "^0.27.2",
"eslint": "^8.5.0", "eslint": "^8.23.0",
"eslint-plugin-vue": "^9.0.0", "eslint-plugin-vue": "^9.4.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"openapi-typescript-codegen": "^0.23.0", "openapi-typescript-codegen": "^0.23.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"sass": "^1.53.0", "rollup-plugin-node-polyfills": "^0.2.1",
"sass": "^1.54.9",
"typescript": "~4.7.4", "typescript": "~4.7.4",
"vite": "^3.0.1", "vite": "^3.1.0",
"vue-tsc": "^0.38.8" "vue-tsc": "^0.40.13"
} }
} }
<script setup lang="ts"> <script setup lang="ts">
import type { BucketOut, BucketPermission } from "@/client"; import type { BucketOut, BucketPermission } from "@/client";
import BootstrapIcon from "@/components/BootstrapIcon.vue"; import BootstrapIcon from "@/components/BootstrapIcon.vue";
import PermissionModal from "@/components/PermissionModal.vue"; import PermissionModal from "@/components/Modals/PermissionModal.vue";
import dayjs from "dayjs"; import dayjs from "dayjs";
import fileSize from "filesize"; import fileSize from "filesize";
import { onMounted } from "vue"; import { onMounted } from "vue";
......
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, watch, computed } from "vue"; import { onMounted, reactive, watch, computed } from "vue";
import type { ComputedRef } from "vue"; import type { ComputedRef } from "vue";
import type { S3ObjectMetaInformation, BucketPermission } from "@/client"; import type {
S3ObjectMetaInformation,
BucketPermission,
BucketOut,
} from "@/client";
import { ObjectService } from "@/client"; import { ObjectService } from "@/client";
import BootstrapIcon from "@/components/BootstrapIcon.vue"; import BootstrapIcon from "@/components/BootstrapIcon.vue";
import fileSize from "filesize"; import fileSize from "filesize";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Tooltip } from "bootstrap"; import { Toast, Tooltip } from "bootstrap";
import PermissionListModal from "@/components/PermissionListModal.vue"; import PermissionListModal from "@/components/Modals/PermissionListModal.vue";
import PermissionModal from "@/components/PermissionModal.vue"; import UploadObjectModal from "@/components/Modals/UploadObjectModal.vue";
import CopyObjectModal from "@/components/Modals/CopyObjectModal.vue";
import PermissionModal from "@/components/Modals/PermissionModal.vue";
import ObjectDetailModal from "@/components/Modals/ObjectDetailModal.vue";
import CreateFolderModal from "@/components/Modals/CreateFolderModal.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";
const authStore = useAuthStore();
const middleware = [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(next) => async (args) => {
args.request.headers["host"] = import.meta.env.VITE_S3_URL.split("://")[1];
return await next(args);
},
{
relation: "before",
toMiddleware: awsAuthMiddlewareOptions?.name ?? "impossible",
},
];
let client = new S3Client({
region: "us-east-1",
endpoint: import.meta.env.VITE_S3_URL,
forcePathStyle: true,
credentials: {
accessKeyId: authStore.s3key?.access_key ?? "",
secretAccessKey: authStore.s3key?.secret_key ?? "",
},
tls: import.meta.env.VITE_S3_URL.startsWith("https"),
});
// 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: import.meta.env.VITE_S3_URL,
forcePathStyle: true,
credentials: {
accessKeyId: args[0].access_key,
secretAccessKey: args[0].secret_key,
},
tls: import.meta.env.VITE_S3_URL.startsWith("https"),
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
client.middlewareStack.addRelativeTo(middleware[0], middleware[1]);
}
}
});
// Constants // Constants
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
...@@ -17,7 +86,10 @@ const props = defineProps<{ ...@@ -17,7 +86,10 @@ const props = defineProps<{
bucketName: string; bucketName: string;
subFolders: string[] | string; subFolders: string[] | string;
permission: BucketPermission | undefined; permission: BucketPermission | undefined;
writableBuckets: BucketOut[];
}>(); }>();
const randomIDSuffix = Math.random().toString(16).substr(2, 8);
let successToast: Toast | null = null;
// Typescript types // Typescript types
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
...@@ -47,12 +119,30 @@ const objectState = reactive({ ...@@ -47,12 +119,30 @@ const objectState = reactive({
bucketNotFoundError: false, bucketNotFoundError: false,
bucketPermissionError: false, bucketPermissionError: false,
createdPermission: undefined, createdPermission: undefined,
deletedItem: "",
editObjectKey: "",
copyObject: {
key: "",
size: 0,
bucket: "",
last_modified: "2022-01-01",
},
viewDetailObject: {
key: "",
size: 0,
bucket: "",
last_modified: "2022-01-01",
},
} as { } as {
objects: S3ObjectMetaInformation[]; objects: S3ObjectMetaInformation[];
loading: boolean; loading: boolean;
bucketNotFoundError: boolean; bucketNotFoundError: boolean;
bucketPermissionError: boolean; bucketPermissionError: boolean;
createdPermission: undefined | BucketPermission; createdPermission: undefined | BucketPermission;
deletedItem: string;
editObjectKey: string;
copyObject: S3ObjectMetaInformation;
viewDetailObject: S3ObjectMetaInformation;
}); });
// Watcher // Watcher
...@@ -176,7 +266,7 @@ const visibleObjects: ComputedRef<(S3ObjectWithFolder | S3PseudoFolder)[]> = ...@@ -176,7 +266,7 @@ const visibleObjects: ComputedRef<(S3ObjectWithFolder | S3PseudoFolder)[]> =
} as S3PseudoFolder; } as S3PseudoFolder;
}) })
); );
return arr; return arr.filter((obj) => !obj.key.endsWith(".s3keep"));
}); });
const subFolderInUrl: ComputedRef<boolean> = computed( const subFolderInUrl: ComputedRef<boolean> = computed(
...@@ -186,6 +276,11 @@ const errorLoadingObjects: ComputedRef<boolean> = computed( ...@@ -186,6 +276,11 @@ const errorLoadingObjects: ComputedRef<boolean> = computed(
() => objectState.bucketPermissionError || objectState.bucketNotFoundError () => objectState.bucketPermissionError || objectState.bucketNotFoundError
); );
const writeS3Permission: ComputedRef<boolean> = computed(
() =>
props.permission == undefined || props.permission.permission == "READWRITE"
);
// Lifecycle Hooks // Lifecycle Hooks
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
onMounted(() => { onMounted(() => {
...@@ -195,6 +290,7 @@ onMounted(() => { ...@@ -195,6 +290,7 @@ onMounted(() => {
.forEach( .forEach(
(tooltipTriggerEl) => new Tooltip(tooltipTriggerEl, { trigger: "hover" }) (tooltipTriggerEl) => new Tooltip(tooltipTriggerEl, { trigger: "hover" })
); );
successToast = new Toast("#successToast-" + randomIDSuffix);
}); });
// Functions // Functions
...@@ -267,6 +363,110 @@ function isS3Object( ...@@ -267,6 +363,110 @@ function isS3Object(
return (obj as S3ObjectWithFolder).folder !== undefined; return (obj as S3ObjectWithFolder).folder !== undefined;
} }
/**
* callback function when an object has been uploaded
* @param newObject Uploaded object
*/
function objectUploaded(newObject: S3ObjectMetaInformation) {
const index = objectState.objects
.map((obj) => obj.key)
.indexOf(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) {
if (copiedObject.bucket === props.bucketName) {
objectState.objects.push(copiedObject);
}
}
/**
* Delete an Object in the current folder
* @param key Key of the Object
*/
function deleteObject(key: string) {
const command = new DeleteObjectCommand({
Bucket: props.bucketName,
Key: key,
});
client
.send(command)
.then(() => {
const splittedKey = key.split("/");
objectState.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);
}
/**
* Delete a folder in the current Bucket
* @param folderPath Path to the folder with a trailing "/", e.g. some/path/to/a/folder/
*/
function deleteFolder(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(() => {
const splittedPath = folderPath.split("/");
objectState.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( watch(
visibleObjects, visibleObjects,
(visObjs) => { (visObjs) => {
...@@ -284,6 +484,28 @@ watch( ...@@ -284,6 +484,28 @@ watch(
</script> </script>
<template> <template>
<div class="toast-container position-fixed top-0 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 {{ objectState.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>
<!-- Navbar Breadcrumb --> <!-- Navbar Breadcrumb -->
<nav aria-label="breadcrumb" class="fs-2"> <nav aria-label="breadcrumb" class="fs-2">
<ol class="breadcrumb"> <ol class="breadcrumb">
...@@ -342,25 +564,44 @@ watch( ...@@ -342,25 +564,44 @@ watch(
<button <button
type="button" type="button"
class="btn btn-secondary me-2 tooltip-container" class="btn btn-secondary me-2 tooltip-container"
:disabled="errorLoadingObjects" :disabled="errorLoadingObjects || !writeS3Permission"
data-bs-toggle="tooltip" data-bs-toggle="modal"
data-bs-title="Upload Object" data-bs-title="Upload Object"
data-bs-target="#upload-object-modal"
> >
<bootstrap-icon icon="upload" :width="16" :height="16" fill="white" /> <bootstrap-icon icon="upload" :width="16" :height="16" fill="white" />
<span class="visually-hidden">Upload Object</span> <span class="visually-hidden">Upload Object</span>
</button> </button>
<upload-object-modal
:bucket-name="props.bucketName"
:s3-client="client"
modalID="upload-object-modal"
modal-label="some-label"
:key-prefix="currentSubFolders.join('/')"
:edit-object-file-name="undefined"
@object-created="objectUploaded"
/>
<!-- Add folder button --> <!-- Add folder button -->
<button <button
type="button" type="button"
class="btn btn-secondary m-2 tooltip-container" class="btn btn-secondary m-2 tooltip-container"
:disabled="errorLoadingObjects" :disabled="errorLoadingObjects || !writeS3Permission"
data-bs-toggle="tooltip" data-bs-toggle="modal"
data-bs-title="Create Folder" data-bs-title="Create Folder"
data-bs-target="#create-folder-modal"
> >
<bootstrap-icon icon="plus-lg" :width="16" :height="16" fill="white" /> <bootstrap-icon icon="plus-lg" :width="16" :height="16" fill="white" />
Folder Folder
<span class="visually-hidden">Add Folder</span> <span class="visually-hidden">Add Folder</span>
</button> </button>
<create-folder-modal
:bucket-name="props.bucketName"
:s3-client="client"
modalID="create-folder-modal"
modal-label="some-label"
:key-prefix="currentSubFolders.join('/')"
@folder-created="objectUploaded"
/>
<!-- Add bucket permission button --> <!-- Add bucket permission button -->
<button <button
:hidden="props.permission != null" :hidden="props.permission != null"
...@@ -507,7 +748,11 @@ watch( ...@@ -507,7 +748,11 @@ watch(
class="btn-group btn-group-sm dropdown-center dropdown-menu-start" class="btn-group btn-group-sm dropdown-center dropdown-menu-start"
> >
<!-- Download Button --> <!-- Download Button -->
<button type="button" class="btn btn-secondary"> <button
type="button"
class="btn btn-secondary"
@click="downloadObject(obj.key, props.bucketName)"
>
Download Download
</button> </button>
<button <button
...@@ -521,18 +766,46 @@ watch( ...@@ -521,18 +766,46 @@ watch(
<!-- Dropdown menu --> <!-- Dropdown menu -->
<ul class="dropdown-menu dropdown-menu-dark"> <ul class="dropdown-menu dropdown-menu-dark">
<li> <li>
<button class="dropdown-item" type="button">Details</button> <button
class="dropdown-item"
type="button"
data-bs-toggle="modal"
data-bs-target="#detail-object-modal"
@click="objectState.viewDetailObject = obj"
>
Details
</button>
</li> </li>
<li> <li>
<button class="dropdown-item" type="button">Edit</button> <button
class="dropdown-item"
type="button"
:disabled="!writeS3Permission"
data-bs-toggle="modal"
data-bs-target="#edit-object-modal"
@click="objectState.editObjectKey = obj.key"
>
Edit
</button>
</li> </li>
<li> <li>
<button class="dropdown-item" type="button">Copy</button> <button
class="dropdown-item"
type="button"
:disabled="!writeS3Permission"
data-bs-toggle="modal"
data-bs-target="#copy-object-modal"
@click="objectState.copyObject = obj"
>
Copy
</button>
</li> </li>
<li> <li>
<button <button
class="dropdown-item text-danger align-middle" class="dropdown-item text-danger align-middle"
type="button" type="button"
@click="deleteObject(obj.key)"
:disabled="!writeS3Permission"
> >
<bootstrap-icon <bootstrap-icon
icon="trash-fill" icon="trash-fill"
...@@ -551,6 +824,12 @@ watch( ...@@ -551,6 +824,12 @@ watch(
<button <button
type="button" type="button"
class="btn btn-danger btn-sm align-middle" class="btn btn-danger btn-sm align-middle"
:disabled="!writeS3Permission"
@click="
deleteFolder(
obj.parentFolder.concat(['']).join('/') + obj.name + '/'
)
"
> >
<bootstrap-icon <bootstrap-icon
icon="trash-fill" icon="trash-fill"
...@@ -566,6 +845,28 @@ watch( ...@@ -566,6 +845,28 @@ watch(
</tr> </tr>
</tbody> </tbody>
</table> </table>
<upload-object-modal
:bucket-name="props.bucketName"
:s3-client="client"
modalID="edit-object-modal"
modal-label="some-label"
: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"
modal-label="some-label"
:available-buckets="props.writableBuckets"
@object-copied="objectCopied"
/>
<object-detail-modal
:s3-object="objectState.viewDetailObject"
modalID="detail-object-modal"
modal-label="some-label"
/>
</div> </div>
</div> </div>
</template> </template>
......
...@@ -17,7 +17,7 @@ defineProps<{ ...@@ -17,7 +17,7 @@ defineProps<{
> >
<div <div
class="modal-dialog modal-dialog-centered modal-dialog-scrollable text-dark" class="modal-dialog modal-dialog-centered modal-dialog-scrollable text-dark"
style="min-width: 25%" style="min-width: 35%"
> >
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
......
<script setup lang="ts">
import type { S3Client } from "@aws-sdk/client-s3";
import { CopyObjectCommand } from "@aws-sdk/client-s3";
import BootstrapModal from "@/components/Modals/BootstrapModal.vue";
import type { BucketOut } from "@/client";
import { Modal, Toast } from "bootstrap";
import { onMounted, reactive, watch } from "vue";
import type { S3ObjectMetaInformation } from "@/client";
import dayjs from "dayjs";
const props = defineProps<{
modalID: string;
modalLabel: string;
sourceObject: S3ObjectMetaInformation;
s3Client: S3Client;
availableBuckets: BucketOut[];
}>();
const formState = reactive({
destKey: "",
destBucket: "",
uploading: false,
} as {
destKey: string;
destBucket: string;
uploading: boolean;
});
const emit = defineEmits<{
(e: "object-copied", object: S3ObjectMetaInformation): void;
}>();
const randomIDSuffix = Math.random().toString(16).substr(2, 8);
let copyModal: Modal | null = null;
let successToast: Toast | null = null;
let errorToast: Toast | null = null;
function getFileName(key: string): string {
const splittedKey = key.split("/");
return splittedKey[splittedKey.length - 1];
}
function copyObject() {
const command = new CopyObjectCommand({
Bucket: formState.destBucket,
CopySource: encodeURI(
`/${props.sourceObject.bucket}/${props.sourceObject.key}`
),
Key: formState.destKey,
});
formState.uploading = true;
props.s3Client
.send(command)
.then(() => {
emit("object-copied", {
key: formState.destKey,
bucket: formState.destBucket,
size: props.sourceObject.size,
last_modified: dayjs().toISOString(),
});
copyModal?.hide();
successToast?.show();
formState.destBucket = "";
})
.catch((e) => {
console.error(e);
errorToast?.show();
})
.finally(() => {
formState.uploading = false;
});
}
function modalClosed() {
formState.destBucket = "";
}
watch(
() => props.sourceObject.key,
(newKey) => {
formState.destKey = newKey;
}
);
onMounted(() => {
copyModal = new Modal("#" + props.modalID);
successToast = new Toast("#successToast-" + randomIDSuffix);
errorToast = new Toast("#errorToast-" + randomIDSuffix);
});
</script>
<template>
<div class="toast-container position-fixed top-0 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 copied file</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>
<div class="toast-container position-fixed top-0 end-0 p-3">
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
class="toast text-bg-danger align-items-center border-0"
data-bs-autohide="true"
:id="'errorToast-' + randomIDSuffix"
>
<div class="d-flex">
<div class="toast-body">
There has been some Error.<br />
Try again later
</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>
<bootstrap-modal
:modalID="modalID"
:static-backdrop="true"
:modal-label="modalLabel"
v-on="{ 'hidden.bs.modal': modalClosed }"
>
<template v-slot:header>
<h4>Copy file {{ getFileName(props.sourceObject.key) }}</h4>
</template>
<template v-slot:body>
<div class="container-fluid">
<div class="row">
<form
class="col-7"
:id="'copyObjectForm' + randomIDSuffix"
@submit.prevent="copyObject"
>
<div class="mb-3">
<label
:for="'destinationBucket' + randomIDSuffix"
class="form-label"
>
Destination Bucket *
</label>
<select
class="form-select text-lowercase"
:id="'destinationBucket' + randomIDSuffix"
required
v-model="formState.destBucket"
>
<option disabled selected>Select one...</option>
<option
v-for="bucket in props.availableBuckets"
:key="bucket.name"
:value="bucket.name"
>
{{ bucket.name }}
</option>
</select>
</div>
<div class="mb-3">
<label :for="'objectKey' + randomIDSuffix" class="form-label"
>Destination Filename *</label
>
<input
type="text"
class="form-control"
:id="'objectKey' + randomIDSuffix"
required
v-model="formState.destKey"
/>
</div>
</form>
<div class="col-5">
You can copy objects. You have to create destination container prior
to copy.<br />
You can specify folder by using '/' at destination object field. For
example, if you want to copy object under the folder named
'folder1', you need to specify destination object like
'folder1/[your object name]'.
</div>
</div>
</div>
</template>
<template v-slot:footer>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Close
</button>
<button
:disabled="formState.uploading"
type="submit"
:form="'copyObjectForm' + randomIDSuffix"
class="btn btn-primary"
>
<span
v-if="formState.uploading"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
Copy
</button>
</template>
</bootstrap-modal>
</template>
<style scoped></style>
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { BucketService } from "@/client"; import { BucketService } from "@/client";
import type { BucketIn } from "@/client"; import type { BucketIn } from "@/client";
import { reactive, onMounted } from "vue"; import { reactive, onMounted } from "vue";
import BootstrapModal from "@/components/BootstrapModal.vue"; import BootstrapModal from "@/components/Modals/BootstrapModal.vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { Modal } from "bootstrap"; import { Modal } from "bootstrap";
......
<script setup lang="ts">
import type { S3Client } from "@aws-sdk/client-s3";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import BootstrapModal from "@/components/Modals/BootstrapModal.vue";
import { computed, onMounted, reactive } from "vue";
import type { ComputedRef } from "vue";
import type { S3ObjectMetaInformation } from "@/client";
import dayjs from "dayjs";
import { Modal, Toast } from "bootstrap";
const props = defineProps<{
modalID: string;
modalLabel: string;
bucketName: string;
keyPrefix: string;
s3Client: S3Client;
}>();
const randomIDSuffix = Math.random().toString(16).substr(2, 8);
let uploadModal: Modal | null = null;
let successToast: Toast | null = null;
let errorToast: Toast | null = null;
const currentFolders: ComputedRef<string[]> = computed(() =>
props.keyPrefix.split("/")
);
const emit = defineEmits<{
(e: "folder-created", object: S3ObjectMetaInformation): void;
}>();
const formState = reactive({
folderName: "",
uploading: false,
} as {
folderName: string;
uploading: boolean;
});
function uploadFolder() {
const key =
(props.keyPrefix.length > 0
? props.keyPrefix + "/" + formState.folderName
: formState.folderName) + "/.s3keep";
const command = new PutObjectCommand({
Bucket: props.bucketName,
Body: "",
ContentType: "text/plain",
Key: key,
});
formState.uploading = true;
props.s3Client
.send(command)
.then(() => {
uploadModal?.hide();
successToast?.show();
emit("folder-created", {
key: key,
bucket: props.bucketName,
size: 0,
last_modified: dayjs().toISOString(),
});
formState.folderName = "";
})
.catch((e) => {
console.error(e);
errorToast?.show();
})
.finally(() => {
formState.uploading = false;
});
}
onMounted(() => {
uploadModal = new Modal("#" + props.modalID);
successToast = new Toast("#successToast-" + randomIDSuffix);
errorToast = new Toast("#errorToast-" + randomIDSuffix);
});
</script>
<template>
<div class="toast-container position-fixed top-0 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 created Folder</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>
<div class="toast-container position-fixed top-0 end-0 p-3">
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
class="toast text-bg-danger align-items-center border-0"
data-bs-autohide="true"
:id="'errorToast-' + randomIDSuffix"
>
<div class="d-flex">
<div class="toast-body">
There has been some Error.<br />
Try again later
</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>
<bootstrap-modal
:modalID="modalID"
:static-backdrop="true"
:modal-label="modalLabel"
>
<template v-slot:header>
<h4>Create folder in</h4>
<ol class="breadcrumb">
<li class="breadcrumb-item">{{ props.bucketName }}</li>
<li
class="breadcrumb-item"
v-for="folder in currentFolders"
:key="folder"
>
{{ folder }}
</li>
</ol>
</template>
<template v-slot:body>
<div class="container-fluid">
<div class="row">
<form
class="col-7"
:id="'uploadFolderForm' + randomIDSuffix"
@submit.prevent="uploadFolder"
>
<div class="mb-3">
<label :for="'folderName' + randomIDSuffix" class="form-label"
>Folder Name</label
>
<input
type="text"
class="form-control"
:id="'folderName' + randomIDSuffix"
required
v-model="formState.folderName"
/>
</div>
</form>
<div class="col-5">
Note: Delimiters ('/') are allowed in the folder name to place the
new folder into a folder that will be created when the folder is
created (to any depth of folders).
</div>
</div>
</div>
</template>
<template v-slot:footer>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Close
</button>
<button
:disabled="formState.uploading"
type="submit"
:form="'uploadFolderForm' + randomIDSuffix"
class="btn btn-primary"
>
<span
v-if="formState.uploading"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
Create
</button>
</template>
</bootstrap-modal>
</template>
<style scoped></style>
<script setup lang="ts">
import BootstrapModal from "@/components/Modals/BootstrapModal.vue";
import type { S3ObjectMetaInformation } from "@/client";
import dayjs from "dayjs";
import fileSize from "filesize";
const props = defineProps<{
modalID: string;
modalLabel: string;
s3Object: S3ObjectMetaInformation;
}>();
</script>
<template>
<bootstrap-modal
:modalID="modalID"
:static-backdrop="false"
:modal-label="modalLabel"
>
<template v-slot:header>
<h4>Object Details</h4>
</template>
<template v-slot:body>
<div class="container-fluid">
<table class="table table-hover table-sm table-borderless">
<tbody>
<tr>
<th scope="row" class="col-4">Bucket</th>
<td class="col-8">{{ props.s3Object.bucket }}</td>
</tr>
<tr>
<th scope="row">Name</th>
<td>{{ props.s3Object.key }}</td>
</tr>
<tr>
<th scope="row">Content Type</th>
<td>text/plain</td>
</tr>
<tr>
<th scope="row">Timestamp</th>
<td>
{{
dayjs(props.s3Object.last_modified).format(
"YYYY-MM-DD HH:mm:ss"
)
}}
</td>
</tr>
<tr>
<th scope="row">Size</th>
<td>{{ fileSize(props.s3Object.size) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<template v-slot:footer>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Close
</button>
</template>
</bootstrap-modal>
</template>
<style scoped>
th {
font-weight: bold;
text-align: end;
}
</style>
...@@ -3,8 +3,8 @@ import type { BucketPermission, S3ObjectMetaInformation } from "@/client"; ...@@ -3,8 +3,8 @@ import type { BucketPermission, S3ObjectMetaInformation } from "@/client";
import { reactive } from "vue"; import { reactive } from "vue";
import { BucketPermissionsService } from "@/client"; import { BucketPermissionsService } from "@/client";
import { onBeforeMount, watch } from "vue"; import { onBeforeMount, watch } from "vue";
import BootstrapModal from "@/components/BootstrapModal.vue"; import BootstrapModal from "@/components/Modals/BootstrapModal.vue";
import PermissionModal from "@/components/PermissionModal.vue"; import PermissionModal from "@/components/Modals/PermissionModal.vue";
// Types // Types
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
interface S3ObjectWithFolder extends S3ObjectMetaInformation { interface S3ObjectWithFolder extends S3ObjectMetaInformation {
......
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, watch, ref, computed } from "vue"; import { onMounted, reactive, watch, ref, computed } from "vue";
import BootstrapModal from "@/components/BootstrapModal.vue"; import BootstrapModal from "@/components/Modals/BootstrapModal.vue";
import { Modal } from "bootstrap"; import { Modal } from "bootstrap";
import dayjs from "dayjs"; import dayjs from "dayjs";
import type { import type {
...@@ -274,7 +274,7 @@ function deletePermission(bucketName: string, uid: string) { ...@@ -274,7 +274,7 @@ function deletePermission(bucketName: string, uid: string) {
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
onMounted(() => { onMounted(() => {
permissionModal = new Modal("#" + props.modalID); permissionModal = new Modal("#" + props.modalID);
successToast = new Toast("#" + "toast-" + randomIDSuffix, { autohide: true }); successToast = new Toast("#" + "toast-" + randomIDSuffix);
updatePermission(); updatePermission();
}); });
</script> </script>
...@@ -286,7 +286,7 @@ onMounted(() => { ...@@ -286,7 +286,7 @@ onMounted(() => {
aria-live="assertive" aria-live="assertive"
aria-atomic="true" aria-atomic="true"
class="toast text-bg-success align-items-center border-0" class="toast text-bg-success align-items-center border-0"
data-bs-autohide="false" data-bs-autohide="true"
:id="'toast-' + randomIDSuffix" :id="'toast-' + randomIDSuffix"
v-on="{ 'hidden.bs.toast': toastHidden }" v-on="{ 'hidden.bs.toast': toastHidden }"
> >
......
<script setup lang="ts">
import type { S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import BootstrapModal from "@/components/Modals/BootstrapModal.vue";
import { computed, onMounted, reactive, watch } from "vue";
import type { ComputedRef } from "vue";
import type { S3ObjectMetaInformation } from "@/client";
import dayjs from "dayjs";
import fileSize from "filesize";
import { Modal, Toast } from "bootstrap";
const props = defineProps<{
modalID: string;
modalLabel: string;
bucketName: string;
keyPrefix: string;
s3Client: S3Client;
editObjectFileName: string | undefined;
}>();
const randomIDSuffix = Math.random().toString(16).substr(2, 8);
let uploadModal: Modal | null = null;
let successToast: Toast | null = null;
let errorToast: Toast | null = null;
const currentFolders: ComputedRef<string[]> = computed(() =>
props.keyPrefix.split("/")
);
const emit = defineEmits<{
(e: "object-created", object: S3ObjectMetaInformation): void;
}>();
watch(
() => props.editObjectFileName,
(nextFileName) => {
formState.key = nextFileName ?? "";
}
);
const formState = reactive({
file: {},
key: "",
uploading: false,
uploadDone: 0,
uploadTotal: 1,
} as {
file: File;
key: string;
uploading: boolean;
uploadDone: number;
uploadTotal: number;
});
const uploadProgress: ComputedRef<number> = computed(() =>
Math.round((100 * formState.uploadDone) / formState.uploadTotal)
);
const editObject: ComputedRef<boolean> = computed(
() => props.editObjectFileName !== undefined
);
async function uploadObject() {
const key =
props.keyPrefix.length > 0
? props.keyPrefix + "/" + formState.key
: formState.key;
try {
formState.uploadDone = 0;
formState.uploading = true;
const parallelUploads3 = new Upload({
client: props.s3Client,
params: {
Bucket: props.bucketName,
Body: formState.file,
ContentType: formState.file.type,
Key: key,
},
queueSize: 4, // optional concurrency configuration
partSize: 1024 * 1024 * 5, // optional size of each part, in bytes, at least 5MB
leavePartsOnError: false, // optional manually handle dropped parts
});
parallelUploads3.on("httpUploadProgress", (progress) => {
if (progress.loaded != null && progress.total != null) {
formState.uploadDone = progress.loaded;
formState.uploadTotal = progress.total;
}
});
await parallelUploads3.done();
uploadModal?.hide();
successToast?.show();
emit("object-created", {
key: key,
bucket: props.bucketName,
size: formState.file?.size ?? 0,
last_modified: dayjs().toISOString(),
});
formState.key = "";
(
document.getElementById("objectFile" + randomIDSuffix) as HTMLInputElement
).value = "";
} catch (e) {
console.error(e);
errorToast?.show();
} finally {
formState.uploading = false;
}
}
// eslint-disable-next-line
function fileChange(event: any) {
formState.file = event.target.files[0];
if (!editObject.value) {
formState.key = formState.file.name;
}
}
onMounted(() => {
uploadModal = new Modal("#" + props.modalID);
successToast = new Toast("#successToast-" + randomIDSuffix);
errorToast = new Toast("#errorToast-" + randomIDSuffix);
});
</script>
<template>
<div class="toast-container position-fixed top-0 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 uploaded file</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>
<div class="toast-container position-fixed top-0 end-0 p-3">
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
class="toast text-bg-danger align-items-center border-0"
data-bs-autohide="true"
:id="'errorToast-' + randomIDSuffix"
>
<div class="d-flex">
<div class="toast-body">
There has been some Error.<br />
Try again later
</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>
<bootstrap-modal
:modalID="modalID"
:static-backdrop="true"
:modal-label="modalLabel"
>
<template v-slot:header>
<h4>Upload file to</h4>
<ol class="breadcrumb">
<li class="breadcrumb-item">{{ props.bucketName }}</li>
<li
class="breadcrumb-item"
v-for="folder in currentFolders"
:key="folder"
>
{{ folder }}
</li>
</ol>
</template>
<template v-slot:body>
<div class="container-fluid">
<div class="row">
<form
class="col-7"
:id="'uploadObjectForm' + randomIDSuffix"
@submit.prevent="uploadObject"
>
<div class="mb-3">
<label
:for="'objectFile' + randomIDSuffix"
class="form-label"
v-if="editObject"
>
New File Content *
</label>
<label
:for="'objectFile' + randomIDSuffix"
class="form-label"
v-else
>
File *
</label>
<input
class="form-control"
type="file"
:id="'objectFile' + randomIDSuffix"
required
@change="fileChange"
/>
</div>
<div class="mb-3">
<label :for="'objectKey' + randomIDSuffix" class="form-label"
>Filename</label
>
<input
type="text"
:class="{
'form-control-plaintext': editObject,
'form-control': !editObject,
}"
:id="'objectKey' + randomIDSuffix"
required
:disabled="editObject"
v-model="formState.key"
/>
</div>
</form>
<div class="col-5">
Note: Delimiters ('/') are allowed in the file name to place the new
file into a folder that will be created when the file is uploaded
(to any depth of folders).
</div>
</div>
</div>
</template>
<template v-slot:footer>
<div class="w-50 me-auto" v-if="formState.uploading">
<div class="progress">
<div
class="progress-bar bg-info"
role="progressbar"
aria-label="Basic example"
:style="{ width: uploadProgress + '%' }"
:aria-valuenow="uploadProgress"
aria-valuemin="0"
aria-valuemax="100"
>
{{ uploadProgress }}%
</div>
</div>
<span v-if="formState.uploadDone > 0">
{{ fileSize(formState.uploadDone) }} /
{{ fileSize(formState.uploadTotal) }}
</span>
</div>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Close
</button>
<button
:disabled="formState.uploading"
type="submit"
:form="'uploadObjectForm' + randomIDSuffix"
class="btn btn-primary"
>
<span
v-if="formState.uploading"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
Upload
</button>
</template>
</bootstrap-modal>
</template>
<style scoped></style>
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import type { User } from "@/client"; import type { S3Key, User } from "@/client";
import { UserService } from "@/client"; import { KeyService, UserService } from "@/client";
import { OpenAPI } from "@/client"; import { OpenAPI } from "@/client";
export type RootState = { export type RootState = {
token: string | null; token: string | null;
user: User | null; user: User | null;
s3key: S3Key | null;
}; };
export const useAuthStore = defineStore({ export const useAuthStore = defineStore({
...@@ -14,6 +15,7 @@ export const useAuthStore = defineStore({ ...@@ -14,6 +15,7 @@ export const useAuthStore = defineStore({
({ ({
token: null, token: null,
user: null, user: null,
s3key: null,
} as RootState), } as RootState),
getters: { getters: {
authenticated: (state) => state.token != null, authenticated: (state) => state.token != null,
...@@ -25,7 +27,7 @@ export const useAuthStore = defineStore({ ...@@ -25,7 +27,7 @@ export const useAuthStore = defineStore({
this.token = token; this.token = token;
UserService.userGetLoggedInUser() UserService.userGetLoggedInUser()
.then((user) => { .then((user) => {
this.user = user; this.updateUser(user);
}) })
.catch(() => { .catch(() => {
this.token = null; this.token = null;
...@@ -35,8 +37,22 @@ export const useAuthStore = defineStore({ ...@@ -35,8 +37,22 @@ export const useAuthStore = defineStore({
this.user = null; this.user = null;
} }
}, },
updateUser() { setS3Key(key: S3Key | null) {
this.setToken(this.token); this.s3key = key;
},
updateUser(user: User) {
this.user = user;
KeyService.keyGetUserKeys(user.uid)
.then((keys) => {
if (keys.length > 0) {
this.setS3Key(keys[0]);
} else {
this.setS3Key(null);
}
})
.catch(() => {
this.setS3Key(null);
});
}, },
logout() { logout() {
this.setToken(null); this.setToken(null);
......
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, computed } from "vue";
import type { ComputedRef } from "vue"; import type { ComputedRef } from "vue";
import { computed, onMounted, reactive } from "vue";
import type { BucketOut, BucketPermission } from "@/client"; import type { BucketOut, BucketPermission } from "@/client";
import { BucketService, BucketPermissionsService } from "@/client"; import { BucketPermissionsService, BucketService } from "@/client";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import BootstrapIcon from "@/components/BootstrapIcon.vue"; import BootstrapIcon from "@/components/BootstrapIcon.vue";
import CreateBucketComponent from "@/components/CreateBucketComponent.vue"; import CreateBucketModal from "@/components/Modals/CreateBucketModal.vue";
import BucketListItem from "@/components/BucketListItem.vue"; import BucketListItem from "@/components/BucketListItem.vue";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
...@@ -44,6 +44,14 @@ function fetchBuckets() { ...@@ -44,6 +44,14 @@ function fetchBuckets() {
} }
} }
const writableBuckets: ComputedRef<BucketOut[]> = computed(() => {
return bucketsState.buckets.filter(
(bucket) =>
bucketsState.permissions[bucket.name] === undefined ||
bucketsState.permissions[bucket.name].permission !== "READ"
);
});
const currentPermission: ComputedRef<BucketPermission | undefined> = computed( const currentPermission: ComputedRef<BucketPermission | undefined> = computed(
() => { () => {
return bucketsState.permissions[route.params.bucketName as string]; return bucketsState.permissions[route.params.bucketName as string];
...@@ -55,7 +63,7 @@ function addBucket(bucket: BucketOut) { ...@@ -55,7 +63,7 @@ function addBucket(bucket: BucketOut) {
} }
function deleteBucket(bucketName: string) { function deleteBucket(bucketName: string) {
BucketService.bucketDeleteBucket(bucketName).then(() => { BucketService.bucketDeleteBucket(bucketName, true).then(() => {
bucketDeleted(bucketName); bucketDeleted(bucketName);
}); });
} }
...@@ -100,7 +108,7 @@ onMounted(() => { ...@@ -100,7 +108,7 @@ onMounted(() => {
<span class="visually-hidden">Create Bucket</span> <span class="visually-hidden">Create Bucket</span>
</button> </button>
</div> </div>
<create-bucket-component <create-bucket-modal
modalID="create-bucket-modal" modalID="create-bucket-modal"
modal-label="create-bucket-modal-label" modal-label="create-bucket-modal-label"
@bucket-created="addBucket" @bucket-created="addBucket"
...@@ -148,7 +156,10 @@ onMounted(() => { ...@@ -148,7 +156,10 @@ onMounted(() => {
</div> </div>
</div> </div>
<div class="col-9"> <div class="col-9">
<router-view :permission="currentPermission"></router-view> <router-view
:permission="currentPermission"
:writable-buckets="writableBuckets"
></router-view>
</div> </div>
</div> </div>
</template> </template>
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url";
import { NodeGlobalsPolyfillPlugin } from "@esbuild-plugins/node-globals-polyfill";
import { NodeModulesPolyfillPlugin } from "@esbuild-plugins/node-modules-polyfill";
import rollupNodePolyFill from "rollup-plugin-node-polyfills";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
...@@ -9,6 +12,35 @@ export default defineConfig({ ...@@ -9,6 +12,35 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)), "@": fileURLToPath(new URL("./src", import.meta.url)),
buffer: "rollup-plugin-node-polyfills/polyfills/buffer-es6",
util: "rollup-plugin-node-polyfills/polyfills/util",
stream: "rollup-plugin-node-polyfills/polyfills/stream",
process: "rollup-plugin-node-polyfills/polyfills/process-es6",
},
},
optimizeDeps: {
esbuildOptions: {
// Node.js global to browser globalThis
define: {
global: "globalThis",
},
// Enable esbuild polyfill plugins
plugins: [
NodeGlobalsPolyfillPlugin({
buffer: true,
process: true,
}),
NodeModulesPolyfillPlugin(),
],
},
},
build: {
rollupOptions: {
plugins: [
// Enable rollup polyfills plugin
// used during production bundling
rollupNodePolyFill(),
],
}, },
}, },
}); });
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment