From 06fd123e7042e7d35aa09bb9121ac4e6b7e9a1e1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de>
Date: Fri, 3 Nov 2023 11:57:26 +0100
Subject: [PATCH] Fetch meta information about S3 Object dynamically

#79
---
 src/App.vue                                   |  2 +-
 src/components/NavbarTop.vue                  |  2 +-
 .../modals/ObjectDetailModal.vue              | 97 ++++++++++++++++---
 src/main.ts                                   |  7 ++
 src/stores/s3objects.ts                       | 30 +++++-
 src/views/object-storage/BucketView.vue       | 13 +--
 6 files changed, 127 insertions(+), 24 deletions(-)

diff --git a/src/App.vue b/src/App.vue
index ba93cee..a9b94a9 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -27,7 +27,7 @@ onBeforeMount(() => {
         err.response.data.detail?.includes("JWT")
       ) {
         store.logout();
-        cookies.remove("bearer", undefined, window.location.hostname);
+        cookies.remove("bearer");
         router.push({
           name: "login",
           query: {
diff --git a/src/components/NavbarTop.vue b/src/components/NavbarTop.vue
index ee07760..c3bb9a4 100644
--- a/src/components/NavbarTop.vue
+++ b/src/components/NavbarTop.vue
@@ -17,7 +17,7 @@ const route = useRoute();
 
 function logout() {
   store.logout();
-  cookies.remove("bearer", undefined, window.location.hostname);
+  cookies.remove("bearer");
 }
 
 const activeRoute = ref("");
diff --git a/src/components/object-storage/modals/ObjectDetailModal.vue b/src/components/object-storage/modals/ObjectDetailModal.vue
index 9de2908..08f5280 100644
--- a/src/components/object-storage/modals/ObjectDetailModal.vue
+++ b/src/components/object-storage/modals/ObjectDetailModal.vue
@@ -1,14 +1,54 @@
 <script setup lang="ts">
 import BootstrapModal from "@/components/modals/BootstrapModal.vue";
-import type { _Object as S3Object } from "@aws-sdk/client-s3";
 import dayjs from "dayjs";
 import { filesize } from "filesize";
+import { computed, onMounted, watch, reactive } from "vue";
+import { useS3ObjectStore } from "@/stores/s3objects";
+
+const objectRepository = useS3ObjectStore();
 
 const props = defineProps<{
   modalID: string;
-  s3Object: S3Object;
+  objectKey: string | undefined;
   bucket: string;
 }>();
+
+const detailState = reactive<{
+  loading: boolean;
+  error: boolean;
+}>({
+  loading: true,
+  error: false,
+});
+
+const metaIdentifier = computed<string>(() => {
+  return props.bucket + "/" + props.objectKey;
+});
+
+watch(props, (newProps) => {
+  detailState.loading = true;
+  if (newProps.objectKey) {
+    fetchMetaInfo(newProps.bucket, newProps.objectKey);
+  }
+});
+
+function fetchMetaInfo(bucket: string, key: string) {
+  detailState.loading = true;
+  detailState.error = false;
+  objectRepository
+    .fetchS3ObjectMeta(bucket, key, () => {
+      detailState.loading = false;
+    })
+    .catch(() => {
+      detailState.error = true;
+    });
+}
+
+onMounted(() => {
+  if (props.objectKey) {
+    fetchMetaInfo(props.bucket, props.objectKey);
+  }
+});
 </script>
 
 <template>
@@ -30,32 +70,63 @@ const props = defineProps<{
             </tr>
             <tr>
               <th scope="row">Name</th>
-              <td>{{ props.s3Object.Key }}</td>
+              <td>{{ props.objectKey }}</td>
             </tr>
             <tr>
               <th scope="row">Size</th>
-              <td>
+              <td v-if="detailState.error">N/A</td>
+              <td v-else-if="detailState.loading" class="placeholder-glow">
+                <span class="placeholder col-2"></span>
+              </td>
+              <td v-else>
                 {{
-                  filesize(props.s3Object.Size ?? 0, {
-                    base: 2,
-                    standard: "jedec",
-                  })
+                  filesize(
+                    objectRepository.objectMetaMapping[metaIdentifier]
+                      .ContentLength ?? 0,
+                    {
+                      base: 2,
+                      standard: "jedec",
+                    },
+                  )
                 }}
               </td>
             </tr>
             <tr>
               <th scope="row">Timestamp</th>
-              <td>
+              <td v-if="detailState.error">N/A</td>
+              <td v-else-if="detailState.loading" class="placeholder-glow">
+                <span class="placeholder col-6"></span>
+              </td>
+              <td v-else>
                 {{
-                  dayjs(props.s3Object.LastModified).format(
-                    "YYYY-MM-DD HH:mm:ss",
-                  )
+                  dayjs(
+                    objectRepository.objectMetaMapping[metaIdentifier]
+                      .LastModified,
+                  ).format("YYYY-MM-DD HH:mm:ss")
                 }}
               </td>
             </tr>
             <tr>
               <th scope="row">Content Type</th>
-              <td>binary/octet-stream</td>
+              <td v-if="detailState.error">N/A</td>
+              <td v-else-if="detailState.loading" class="placeholder-glow">
+                <span class="placeholder col-5"></span>
+              </td>
+              <td v-else>
+                {{
+                  objectRepository.objectMetaMapping[metaIdentifier].ContentType
+                }}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">ETag</th>
+              <td v-if="detailState.error">N/A</td>
+              <td v-else-if="detailState.loading" class="placeholder-glow">
+                <span class="placeholder col-10"></span>
+              </td>
+              <td v-else>
+                {{ objectRepository.objectMetaMapping[metaIdentifier].ETag }}
+              </td>
             </tr>
           </tbody>
         </table>
diff --git a/src/main.ts b/src/main.ts
index a0b5f35..ca92ccf 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -28,6 +28,13 @@ import "@fortawesome/fontawesome-free/css/brands.css";
 
 import "./assets/main.css";
 
+import { globalCookiesConfig } from "vue3-cookies";
+
+globalCookiesConfig({
+  expireTimes: "8d",
+  domain: window.location.hostname,
+});
+
 const app = createApp(App);
 
 app.use(createPinia());
diff --git a/src/stores/s3objects.ts b/src/stores/s3objects.ts
index c2ed8e1..ab68890 100644
--- a/src/stores/s3objects.ts
+++ b/src/stores/s3objects.ts
@@ -1,8 +1,9 @@
 import { defineStore } from "pinia";
-import type { _Object as S3Object } from "@aws-sdk/client-s3";
+import type { _Object as S3Object, HeadObjectOutput } from "@aws-sdk/client-s3";
 import {
   CopyObjectCommand,
   GetObjectCommand,
+  HeadObjectCommand,
   PutObjectCommand,
   S3Client,
 } from "@aws-sdk/client-s3";
@@ -24,6 +25,7 @@ export const useS3ObjectStore = defineStore({
   state: () =>
     ({
       objectMapping: {},
+      objectMetaMapping: {},
       client: new S3Client({
         region: "us-east-1",
         endpoint: environment.S3_URL,
@@ -35,14 +37,19 @@ export const useS3ObjectStore = defineStore({
       }),
     }) as {
       objectMapping: Record<string, S3Object[]>;
+      objectMetaMapping: Record<string, HeadObjectOutput>;
       client: S3Client;
     },
   getters: {
     getPresignedUrl(): (bucketName: string, key: string) => Promise<string> {
       return (bucketName, key) => {
+        const keySplit = key.split("/");
         const command = new GetObjectCommand({
           Bucket: bucketName,
           Key: key,
+          ResponseContentDisposition: `attachment; filename=${
+            keySplit[keySplit.length - 1]
+          }`,
         });
         return getSignedUrl(this.client, command, {
           expiresIn: 30,
@@ -99,6 +106,27 @@ export const useS3ObjectStore = defineStore({
       }
       return objs;
     },
+    fetchS3ObjectMeta(
+      bucketName: string,
+      key: string,
+      onFinally?: () => void,
+    ): Promise<HeadObjectOutput> {
+      const identifier = bucketName + "/" + key;
+      if (this.objectMetaMapping[identifier]) {
+        onFinally?.();
+      }
+      const command = new HeadObjectCommand({
+        Bucket: bucketName,
+        Key: key,
+      });
+      return this.client
+        .send(command)
+        .then((resp) => {
+          this.objectMetaMapping[identifier] = resp;
+          return resp;
+        })
+        .finally(onFinally);
+    },
     deleteObject(bucketName: string, key: string): Promise<void> {
       const command = new DeleteObjectCommand({
         Bucket: bucketName,
diff --git a/src/views/object-storage/BucketView.vue b/src/views/object-storage/BucketView.vue
index 6665e36..2d1c823 100644
--- a/src/views/object-storage/BucketView.vue
+++ b/src/views/object-storage/BucketView.vue
@@ -58,7 +58,7 @@ const objectState = reactive<{
   bucketPermissionError: boolean;
   editObjectKey: string;
   copyObject: S3Object;
-  viewDetailObject: S3Object;
+  viewDetailKey: string | undefined;
 }>({
   loading: true,
   filterString: "",
@@ -70,11 +70,7 @@ const objectState = reactive<{
     Size: 0,
     LastModified: new Date(),
   },
-  viewDetailObject: {
-    Key: "",
-    Size: 0,
-    LastModified: new Date(),
-  },
+  viewDetailKey: undefined,
 });
 
 // Computed Properties
@@ -232,6 +228,7 @@ watch(
   () => props.bucketName,
   (newBucketName, oldBucketName) => {
     if (oldBucketName !== newBucketName) {
+      objectState.viewDetailKey = undefined;
       // If bucket is changed, update the objects
       objectState.bucketPermissionError = false;
       objectState.bucketNotFoundError = false;
@@ -735,7 +732,7 @@ function getObjectFileName(key: string): string {
                       type="button"
                       data-bs-toggle="modal"
                       data-bs-target="#detail-object-modal"
-                      @click="objectState.viewDetailObject = obj"
+                      @click="objectState.viewDetailKey = obj.Key"
                     >
                       Details
                     </button>
@@ -814,7 +811,7 @@ function getObjectFileName(key: string): string {
       />
       <object-detail-modal
         :bucket="bucketName"
-        :s3-object="objectState.viewDetailObject"
+        :object-key="objectState.viewDetailKey"
         modalID="detail-object-modal"
       />
     </div>
-- 
GitLab