From b11365cfa3569e76ee1ebf350e2f7c7afa26208a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de>
Date: Mon, 5 Sep 2022 16:35:41 +0200
Subject: [PATCH] Add copy object modal to copy a file to another bucket

#15
---
 src/components/BucketView.vue               |  68 +++---
 src/components/Modals/CopyObjectModal.vue   | 216 ++++++++++++++++++++
 src/components/Modals/UploadObjectModal.vue |  13 +-
 src/views/object-storage/BucketsView.vue    |  17 +-
 4 files changed, 283 insertions(+), 31 deletions(-)
 create mode 100644 src/components/Modals/CopyObjectModal.vue

diff --git a/src/components/BucketView.vue b/src/components/BucketView.vue
index 6d261da..2a9c4f0 100644
--- a/src/components/BucketView.vue
+++ b/src/components/BucketView.vue
@@ -1,7 +1,11 @@
 <script setup lang="ts">
 import { onMounted, reactive, watch, computed } 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 BootstrapIcon from "@/components/BootstrapIcon.vue";
 import fileSize from "filesize";
@@ -9,6 +13,7 @@ import dayjs from "dayjs";
 import { Toast, Tooltip } from "bootstrap";
 import PermissionListModal from "@/components/Modals/PermissionListModal.vue";
 import UploadObjectModal from "@/components/Modals/UploadObjectModal.vue";
+import CopyObjectModal from "@/components/Modals/CopyObjectModal.vue";
 import PermissionModal from "@/components/Modals/PermissionModal.vue";
 import CreateFolderModal from "@/components/Modals/CreateFolderModal.vue";
 import {
@@ -23,6 +28,19 @@ 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,
@@ -31,20 +49,11 @@ let client = new S3Client({
     accessKeyId: authStore.s3key?.access_key ?? "",
     secretAccessKey: authStore.s3key?.secret_key ?? "",
   },
-  tls: false,
+  tls: import.meta.env.VITE_S3_URL.startsWith("https"),
 });
-client.middlewareStack.addRelativeTo(
-  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-  // @ts-ignore
-  (next) => async (args) => {
-    args.request.headers["host"] = "localhost:9998";
-    return await next(args);
-  },
-  {
-    relation: "before",
-    toMiddleware: awsAuthMiddlewareOptions?.name ?? "impossible",
-  }
-);
+// 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 }) => {
@@ -60,20 +69,11 @@ authStore.$onAction(({ name, args }) => {
           accessKeyId: args[0].access_key,
           secretAccessKey: args[0].secret_key,
         },
-        tls: false,
+        tls: import.meta.env.VITE_S3_URL.startsWith("https"),
       });
-      client.middlewareStack.addRelativeTo(
-        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-        // @ts-ignore
-        (next) => async (args) => {
-          args.request.headers["host"] = "localhost:9998";
-          return await next(args);
-        },
-        {
-          relation: "before",
-          toMiddleware: awsAuthMiddlewareOptions?.name ?? "impossible",
-        }
-      );
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+      // @ts-ignore
+      client.middlewareStack.addRelativeTo(middleware[0], middleware[1]);
     }
   }
 });
@@ -85,6 +85,7 @@ const props = defineProps<{
   bucketName: string;
   subFolders: string[] | string;
   permission: BucketPermission | undefined;
+  writableBuckets: BucketOut[];
 }>();
 const randomIDSuffix = Math.random().toString(16).substr(2, 8);
 let successToast: Toast | null = null;
@@ -119,6 +120,7 @@ const objectState = reactive({
   createdPermission: undefined,
   deletedItem: "",
   editObjectKey: "",
+  copyObjectKey: "",
 } as {
   objects: S3ObjectMetaInformation[];
   loading: boolean;
@@ -127,6 +129,7 @@ const objectState = reactive({
   createdPermission: undefined | BucketPermission;
   deletedItem: string;
   editObjectKey: string;
+  copyObjectKey: string;
 });
 
 // Watcher
@@ -759,6 +762,9 @@ watch(
                       class="dropdown-item"
                       type="button"
                       :disabled="!writeS3Permission"
+                      data-bs-toggle="modal"
+                      data-bs-target="#copy-object-modal"
+                      @click="objectState.copyObjectKey = obj.key"
                     >
                       Copy
                     </button>
@@ -817,6 +823,14 @@ watch(
         :edit-object-file-name="getObjectFileName(objectState.editObjectKey)"
         @object-created="objectUploaded"
       />
+      <copy-object-modal
+        :source-bucket-name="props.bucketName"
+        :source-key="objectState.copyObjectKey"
+        :s3-client="client"
+        modalID="copy-object-modal"
+        modal-label="some-label"
+        :available-buckets="props.writableBuckets"
+      />
     </div>
   </div>
 </template>
diff --git a/src/components/Modals/CopyObjectModal.vue b/src/components/Modals/CopyObjectModal.vue
new file mode 100644
index 0000000..690a1be
--- /dev/null
+++ b/src/components/Modals/CopyObjectModal.vue
@@ -0,0 +1,216 @@
+<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, computed } from "vue";
+import type { ComputedRef } from "vue";
+
+const props = defineProps<{
+  modalID: string;
+  modalLabel: string;
+  sourceBucketName: string;
+  sourceKey: string;
+  s3Client: S3Client;
+  availableBuckets: BucketOut[];
+}>();
+
+const formState = reactive({
+  destKey: "",
+  destBucket: "",
+  uploading: false,
+} as {
+  destKey: string;
+  destBucket: string;
+  uploading: boolean;
+});
+
+const randomIDSuffix = Math.random().toString(16).substr(2, 8);
+let copyModal: Modal | null = null;
+let successToast: Toast | null = null;
+let errorToast: Toast | null = null;
+
+const sourceFilteredBuckets: ComputedRef<BucketOut[]> = computed(() => {
+  return props.availableBuckets.filter(
+    (bucket) => bucket.name !== props.sourceBucketName
+  );
+});
+
+function getFileName(key: string): string {
+  const spliitedKey = key.split("/");
+  return spliitedKey[spliitedKey.length - 1];
+}
+
+function copyObject() {
+  const command = new CopyObjectCommand({
+    Bucket: formState.destBucket,
+    CopySource: encodeURI(`/${props.sourceBucketName}/${props.sourceKey}`),
+    Key: formState.destKey,
+  });
+  formState.uploading = true;
+  props.s3Client
+    .send(command)
+    .then(() => {
+      copyModal?.hide();
+      successToast?.show();
+      formState.destBucket = "";
+    })
+    .catch((e) => {
+      console.error(e);
+      errorToast?.show();
+    })
+    .finally(() => {
+      formState.uploading = false;
+    });
+}
+
+function modalClosed() {
+  formState.destBucket = "";
+}
+
+watch(
+  () => props.sourceKey,
+  (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.sourceKey) }}</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 sourceFilteredBuckets"
+                  :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>
diff --git a/src/components/Modals/UploadObjectModal.vue b/src/components/Modals/UploadObjectModal.vue
index ca819ef..90eacc2 100644
--- a/src/components/Modals/UploadObjectModal.vue
+++ b/src/components/Modals/UploadObjectModal.vue
@@ -194,7 +194,18 @@ onMounted(() => {
             @submit.prevent="uploadObject"
           >
             <div class="mb-3">
-              <label :for="'objectFile' + randomIDSuffix" class="form-label">
+              <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
diff --git a/src/views/object-storage/BucketsView.vue b/src/views/object-storage/BucketsView.vue
index 2f02c94..c01d35e 100644
--- a/src/views/object-storage/BucketsView.vue
+++ b/src/views/object-storage/BucketsView.vue
@@ -1,8 +1,8 @@
 <script setup lang="ts">
-import { onMounted, reactive, computed } from "vue";
 import type { ComputedRef } from "vue";
+import { computed, onMounted, reactive } from "vue";
 import type { BucketOut, BucketPermission } from "@/client";
-import { BucketService, BucketPermissionsService } from "@/client";
+import { BucketPermissionsService, BucketService } from "@/client";
 import { useRoute, useRouter } from "vue-router";
 import BootstrapIcon from "@/components/BootstrapIcon.vue";
 import CreateBucketModal from "@/components/Modals/CreateBucketModal.vue";
@@ -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(
   () => {
     return bucketsState.permissions[route.params.bucketName as string];
@@ -148,7 +156,10 @@ onMounted(() => {
       </div>
     </div>
     <div class="col-9">
-      <router-view :permission="currentPermission"></router-view>
+      <router-view
+        :permission="currentPermission"
+        :writable-buckets="writableBuckets"
+      ></router-view>
     </div>
   </div>
 </template>
-- 
GitLab