From 17eb0e401c2bc50bdb53f541c537dcbf65c0c6f1 Mon Sep 17 00:00:00 2001
From: Sonia Zorba <sonia.zorba@inaf.it>
Date: Tue, 19 Jan 2021 17:53:11 +0100
Subject: [PATCH] Implemented file upload

---
 .../ia2/vospace/ui/client/VOSpaceClient.java  |  2 +-
 .../vospace/ui/controller/BaseController.java | 31 ++++++
 .../ui/controller/NodesController.java        | 20 ++--
 .../ui/controller/UploadController.java       | 96 +++++++++++++++++++
 .../ia2/vospace/ui/data/UploadFilesData.java  | 28 ++++++
 .../exception/PermissionDeniedException.java  | 12 +++
 vospace-ui-frontend/src/api/mock/index.js     |  6 ++
 vospace-ui-frontend/src/api/server/index.js   | 24 +++++
 vospace-ui-frontend/src/components/Main.vue   |  7 +-
 .../components/modal/CreateFolderModal.vue    |  5 +-
 .../src/components/modal/UploadFilesModal.vue | 57 +++++++++++
 vospace-ui-frontend/src/store.js              | 16 ++++
 12 files changed, 289 insertions(+), 15 deletions(-)
 create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/BaseController.java
 create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/UploadController.java
 create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/UploadFilesData.java
 create mode 100644 vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/exception/PermissionDeniedException.java
 create mode 100644 vospace-ui-frontend/src/components/modal/UploadFilesModal.vue

diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java
index eecc188..d8185b3 100644
--- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java
@@ -87,7 +87,7 @@ public class VOSpaceClient {
         return call(request, BodyHandlers.ofInputStream(), 200, res -> unmarshal(res, JobSummary.class));
     }
 
-    public List<Protocol> getDownloadEndpoints(Transfer transfer) {
+    public List<Protocol> getFileServiceEndpoints(Transfer transfer) {
 
         HttpRequest request = getRequest("/synctrans")
                 .header("Accept", useJson ? "application/json" : "text/xml")
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/BaseController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/BaseController.java
new file mode 100644
index 0000000..6d35848
--- /dev/null
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/BaseController.java
@@ -0,0 +1,31 @@
+package it.inaf.ia2.vospace.ui.controller;
+
+import it.inaf.ia2.aa.data.User;
+import it.inaf.ia2.vospace.ui.exception.BadRequestException;
+import java.util.Map;
+import javax.servlet.http.HttpSession;
+import org.springframework.beans.factory.annotation.Autowired;
+
+public class BaseController {
+
+    @Autowired
+    private HttpSession session;
+
+    protected User getUser() {
+        return (User) session.getAttribute("user_data");
+    }
+
+    protected String getRequiredParam(Map<String, Object> params, String key) {
+        if (!params.containsKey(key)) {
+            throw new BadRequestException("Missing mandatory parameter " + key);
+        }
+        return (String) params.get(key);
+    }
+
+    protected <T> T getRequiredParam(Map<String, Object> params, String key, Class<T> type) {
+        if (!params.containsKey(key)) {
+            throw new BadRequestException("Missing mandatory parameter " + key);
+        }
+        return (T) params.get(key);
+    }
+}
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
index 71dfa9b..52f306a 100644
--- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
@@ -1,11 +1,11 @@
 package it.inaf.ia2.vospace.ui.controller;
 
 import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
-import it.inaf.ia2.vospace.ui.exception.BadRequestException;
 import it.inaf.ia2.vospace.ui.service.NodesService;
 import java.util.Map;
 import javax.servlet.http.HttpServletRequest;
 import net.ivoa.xml.vospace.v2.ContainerNode;
+import net.ivoa.xml.vospace.v2.Property;
 import net.ivoa.xml.vospace.v2.Protocol;
 import net.ivoa.xml.vospace.v2.Transfer;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RestController;
 
 @RestController
-public class NodesController {
+public class NodesController extends BaseController {
 
     @Value("${vospace-authority}")
     private String authority;
@@ -63,14 +63,14 @@ public class NodesController {
         protocol.setUri("ivo://ivoa.net/vospace/core#httpget");
         transfer.getProtocols().add(protocol);
 
-        String url = client.getDownloadEndpoints(transfer).get(0).getEndpoint();
+        String url = client.getFileServiceEndpoints(transfer).get(0).getEndpoint();
         HttpHeaders headers = new HttpHeaders();
         headers.set("Location", url);
         return new ResponseEntity<>(headers, HttpStatus.SEE_OTHER);
     }
 
     @PostMapping(value = "/folder")
-    public void newFolder(@RequestBody Map<String, String> params) {
+    public void newFolder(@RequestBody Map<String, Object> params) {
 
         String parentPath = getRequiredParam(params, "parentPath");
         if (!parentPath.startsWith("/")) {
@@ -80,17 +80,15 @@ public class NodesController {
 
         ContainerNode node = new ContainerNode();
         node.setUri("vos://" + authority + parentPath + "/" + name);
+        
+        Property creator = new Property();
+        creator.setUri("ivo://ivoa.net/vospace/core#creator");
+        creator.setValue(getUser().getName());
+        node.getProperties().add(creator);
 
         client.createNode(node);
     }
 
-    private String getRequiredParam(Map<String, String> params, String key) {
-        if (!params.containsKey(key)) {
-            throw new BadRequestException("Missing mandatory parameter " + key);
-        }
-        return params.get(key);
-    }
-
     /**
      * Slash is a special character in defining REST endpoints and trying to
      * define a PathVariable containing slashes doesn't work, so the endpoint
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/UploadController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/UploadController.java
new file mode 100644
index 0000000..96516bd
--- /dev/null
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/UploadController.java
@@ -0,0 +1,96 @@
+package it.inaf.ia2.vospace.ui.controller;
+
+import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
+import it.inaf.ia2.vospace.ui.data.UploadFilesData;
+import it.inaf.ia2.vospace.ui.exception.PermissionDeniedException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Collectors;
+import javax.validation.Valid;
+import net.ivoa.xml.vospace.v2.DataNode;
+import net.ivoa.xml.vospace.v2.Property;
+import net.ivoa.xml.vospace.v2.Protocol;
+import net.ivoa.xml.vospace.v2.Transfer;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class UploadController extends BaseController {
+
+    @Value("${vospace-authority}")
+    private String authority;
+
+    @Autowired
+    private VOSpaceClient client;
+
+    @PostMapping(value = "/preupload", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE)
+    public ResponseEntity<List<String>> prepareForUpload(@RequestBody @Valid UploadFilesData data) {
+
+        if (getUser() == null) {
+            throw new PermissionDeniedException("File upload not allowed to anonymous users");
+        }
+
+        CompletableFuture<String>[] calls
+                = data.getFiles().stream().map(fileName -> prepareForDownload(getParentPath(data), fileName))
+                        .toArray(CompletableFuture[]::new);
+
+        List<String> uploadUrls = CompletableFuture.allOf(calls)
+                .thenApplyAsync(ignore -> {
+                    return Arrays.stream(calls).map(c -> c.join()).collect(Collectors.toList());
+                }).join();
+
+        return ResponseEntity.ok(uploadUrls);
+    }
+
+    private String getParentPath(UploadFilesData data) {
+        String parentPath = data.getParentPath();
+        if (!parentPath.startsWith("/")) {
+            parentPath = "/" + parentPath;
+        }
+        return parentPath;
+    }
+
+    public CompletableFuture<String> prepareForDownload(String parentPath, String fileName) {
+
+        return CompletableFuture.supplyAsync(() -> {
+            String nodeUri = "vos://" + authority + parentPath + "/" + fileName;
+
+            createDataNode(nodeUri, getUser().getName());
+
+            return obtainUploadUrl(nodeUri);
+        }, Runnable::run); // Passing current thread Executor to CompletableFuture to avoid "No thread-bound request found" exception
+    }
+
+    private void createDataNode(String nodeUri, String userId) {
+
+        DataNode node = new DataNode();
+        node.setUri(nodeUri);
+
+        Property creator = new Property();
+        creator.setUri("ivo://ivoa.net/vospace/core#creator");
+        creator.setValue(userId);
+
+        node.getProperties().add(creator);
+
+        client.createNode(node);
+    }
+
+    private String obtainUploadUrl(String uri) {
+
+        Transfer transfer = new Transfer();
+        transfer.setDirection("pushToVoSpace");
+        transfer.setTarget(uri);
+
+        Protocol protocol = new Protocol();
+        protocol.setUri("ivo://ivoa.net/vospace/core#httpget");
+        transfer.getProtocols().add(protocol);
+
+        return client.getFileServiceEndpoints(transfer).get(0).getEndpoint();
+    }
+}
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/UploadFilesData.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/UploadFilesData.java
new file mode 100644
index 0000000..fb9c26c
--- /dev/null
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/data/UploadFilesData.java
@@ -0,0 +1,28 @@
+package it.inaf.ia2.vospace.ui.data;
+
+import java.util.List;
+import javax.validation.constraints.NotNull;
+
+public class UploadFilesData {
+
+    @NotNull
+    private String parentPath;
+    @NotNull
+    private List<String> files;
+
+    public String getParentPath() {
+        return parentPath;
+    }
+
+    public void setParentPath(String parentPath) {
+        this.parentPath = parentPath;
+    }
+
+    public List<String> getFiles() {
+        return files;
+    }
+
+    public void setFiles(List<String> files) {
+        this.files = files;
+    }
+}
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/exception/PermissionDeniedException.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/exception/PermissionDeniedException.java
new file mode 100644
index 0000000..30a6b6e
--- /dev/null
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/exception/PermissionDeniedException.java
@@ -0,0 +1,12 @@
+package it.inaf.ia2.vospace.ui.exception;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+@ResponseStatus(value = HttpStatus.FORBIDDEN)
+public class PermissionDeniedException extends VOSpaceException {
+
+    public PermissionDeniedException(String message) {
+        super(message);
+    }
+}
diff --git a/vospace-ui-frontend/src/api/mock/index.js b/vospace-ui-frontend/src/api/mock/index.js
index d43470f..c0e1079 100644
--- a/vospace-ui-frontend/src/api/mock/index.js
+++ b/vospace-ui-frontend/src/api/mock/index.js
@@ -48,5 +48,11 @@ export default {
   },
   createFolder() {
     return fetch({});
+  },
+  prepareForUpload() {
+    return fetch(['http://fileservice/upload']);
+  },
+  uploadFile() {
+    return fetch({});
   }
 }
diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js
index a21e61f..dcba84e 100644
--- a/vospace-ui-frontend/src/api/server/index.js
+++ b/vospace-ui-frontend/src/api/server/index.js
@@ -99,5 +99,29 @@ export default {
         name: newFolderName
       }
     });
+  },
+  prepareForUpload(path, files) {
+    let url = BASE_API_URL + 'preupload';
+    return apiRequest({
+      method: 'POST',
+      url: url,
+      withCredentials: true,
+      headers: {
+        'Cache-Control': 'no-cache'
+      },
+      data: {
+        parentPath: path,
+        files: files
+      }
+    });
+  },
+  uploadFile(url, file) {
+    let formData = new FormData();
+    formData.append('file', file);
+    axios.put(url, formData, {
+      headers: {
+        'Content-Type': 'multipart/form-data'
+      }
+    })
   }
 }
diff --git a/vospace-ui-frontend/src/components/Main.vue b/vospace-ui-frontend/src/components/Main.vue
index a2e17ae..8f2ea80 100644
--- a/vospace-ui-frontend/src/components/Main.vue
+++ b/vospace-ui-frontend/src/components/Main.vue
@@ -3,7 +3,7 @@
   <b-breadcrumb :items="breadcrumbs"></b-breadcrumb>
   <div class="mb-3">
     <b-button variant="success" class="mr-2" :disabled="false" v-b-modal.create-folder-modal>New folder</b-button>
-    <b-button variant="success" class="mr-2" :disabled="true">Upload files</b-button>
+    <b-button variant="success" class="mr-2" :disabled="false" v-b-modal.upload-files-modal>Upload files</b-button>
     <b-button variant="primary" class="mr-2" v-if="tapeButtonEnabled" @click="startRecallFromTapeJob">Recall from tape</b-button>
   </div>
   <b-card>
@@ -29,18 +29,21 @@
     </table>
   </b-card>
   <CreateFolderModal />
+  <UploadFilesModal />
 </div>
 </template>
 
 <script>
 import { BIconCheckSquare, BIconSquare } from 'bootstrap-vue'
 import CreateFolderModal from './modal/CreateFolderModal.vue'
+import UploadFilesModal from './modal/UploadFilesModal.vue'
 
 export default {
   components: {
     BIconCheckSquare,
     BIconSquare,
-    CreateFolderModal
+    CreateFolderModal,
+    UploadFilesModal
   },
   computed: {
     breadcrumbs() {
diff --git a/vospace-ui-frontend/src/components/modal/CreateFolderModal.vue b/vospace-ui-frontend/src/components/modal/CreateFolderModal.vue
index 3184b9b..41ef2dc 100644
--- a/vospace-ui-frontend/src/components/modal/CreateFolderModal.vue
+++ b/vospace-ui-frontend/src/components/modal/CreateFolderModal.vue
@@ -1,5 +1,5 @@
 <template>
-<b-modal id="create-folder-modal" title="Create folder" okTitle="Create" @show="reset" @ok="createFolder">
+<b-modal id="create-folder-modal" title="Create folder" okTitle="Create" @show="reset" @shown="afterShow" @ok="createFolder">
   <b-form inline>
     <label class="w-25" for="new-folder-name-input">Folder name</label>
     <b-form-input v-model.trim="newFolderName" id="new-folder-name-input" ref="newFolderNameInput" class="w-75" aria-describedby="new-folder-name-input-feedback" :state="newFolderNameState" v-on:input="resetError"
@@ -27,6 +27,9 @@ export default {
     }
   },
   methods: {
+    afterShow: function() {
+      this.$refs.newFolderNameInput.focus();
+    },
     reset() {
       this.newFolderName = null;
       this.resetError();
diff --git a/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue b/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue
new file mode 100644
index 0000000..90216f5
--- /dev/null
+++ b/vospace-ui-frontend/src/components/modal/UploadFilesModal.vue
@@ -0,0 +1,57 @@
+<template>
+<b-modal id="upload-files-modal" title="Upload file" okTitle="Upload" @show="reset" @ok="uploadFiles">
+  <b-form-file v-model="files" :multiple="true" :state="fileState" placeholder="Choose your files or drop them here..." drop-placeholder="Drop files here..."></b-form-file>
+  <b-form-invalid-feedback id="upload-file-input-feedback" class="text-right">{{uploadFileError}}</b-form-invalid-feedback>
+  <div class="mt-3">Selected files: {{ selectedFiles }}</div>
+</b-modal>
+</template>
+<script>
+export default {
+  data() {
+    return {
+      files: [],
+      uploadFileError: null
+    }
+  },
+  computed: {
+    fileState() {
+      if (this.uploadFileError) {
+        return false;
+      }
+      return null;
+    },
+    selectedFiles() {
+      if (this.files.length === 0) {
+        return '';
+      }
+      let names = [];
+      for (let file of this.files) {
+        names.push(file.name);
+      }
+      return names.join(', ');
+    }
+  },
+  methods: {
+    reset() {
+      this.files.splice(0, this.files.length);
+      this.resetError();
+    },
+    resetError() {
+      this.uploadFileError = null;
+    },
+    uploadFiles(event) {
+      // Prevent modal from closing
+      event.preventDefault();
+
+      if (this.files.length === 0) {
+        this.uploadFileError = "Select at least one file";
+      } else {
+        this.$store.dispatch('uploadFiles', this.files)
+          .then(() => {
+            this.$bvModal.hide('upload-files-modal');
+          });
+      }
+    }
+  }
+}
+</script>
diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js
index cbf3bb9..b1b108f 100644
--- a/vospace-ui-frontend/src/store.js
+++ b/vospace-ui-frontend/src/store.js
@@ -87,6 +87,22 @@ export default new Vuex.Store({
           // Reload current node
           dispatch('setPath', state.path);
         });
+    },
+    uploadFiles({ state, dispatch }, files) {
+      let names = [];
+      for (let file of files) {
+        names.push(file.name);
+      }
+      client.prepareForUpload(state.path, names)
+        .then(uploadUrls => {
+          for (let i = 0; i < files.length; i++) {
+            client.uploadFile(uploadUrls[i], files[i]);
+          }
+        })
+        .then(() => {
+          // Reload current node
+          dispatch('setPath', state.path);
+        });
     }
   }
 });
-- 
GitLab