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