| /********************************************************************************* |
| * Copyright (c) 2020 Robert Bosch GmbH and others. |
| * |
| * This program and the accompanying materials are made |
| * available under the terms of the Eclipse Public License 2.0 |
| * which is available at https://www.eclipse.org/legal/epl-2.0/ |
| * |
| * SPDX-License-Identifier: EPL-2.0 |
| * |
| * Contributors: |
| * Robert Bosch GmbH - initial API and implementation |
| ******************************************************************************** |
| */ |
| package org.eclipse.app4mc.cloud.manager; |
| |
| import java.io.File; |
| import java.io.IOException; |
| import java.nio.file.Files; |
| import java.nio.file.Path; |
| import java.util.ArrayList; |
| import java.util.List; |
| |
| import org.apache.http.HttpStatus; |
| import org.eclipse.app4mc.cloud.manager.administration.CloudServiceDefinition; |
| import org.eclipse.app4mc.cloud.manager.storage.StorageException; |
| import org.eclipse.app4mc.cloud.manager.storage.StorageFileNotFoundException; |
| import org.eclipse.app4mc.cloud.manager.storage.StorageService; |
| import org.springframework.beans.factory.annotation.Autowired; |
| import org.springframework.core.io.Resource; |
| import org.springframework.http.MediaType; |
| import org.springframework.http.ResponseEntity; |
| import org.springframework.stereotype.Controller; |
| import org.springframework.ui.Model; |
| import org.springframework.util.StringUtils; |
| import org.springframework.web.bind.annotation.ExceptionHandler; |
| import org.springframework.web.bind.annotation.GetMapping; |
| import org.springframework.web.bind.annotation.ModelAttribute; |
| import org.springframework.web.bind.annotation.PathVariable; |
| import org.springframework.web.bind.annotation.PostMapping; |
| import org.springframework.web.bind.annotation.RequestParam; |
| import org.springframework.web.bind.annotation.ResponseBody; |
| import org.springframework.web.bind.annotation.SessionAttributes; |
| import org.springframework.web.multipart.MultipartFile; |
| import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; |
| |
| import kong.unirest.HttpResponse; |
| import kong.unirest.Unirest; |
| |
| @Controller |
| @SessionAttributes("workflowStatus") |
| public class WorkflowController { |
| |
| private final StorageService storageService; |
| |
| @javax.annotation.Resource(name = "cloudServiceDefinitions") |
| List<CloudServiceDefinition> cloudServiceDefinitions; |
| |
| @Autowired |
| public WorkflowController(StorageService storageService) { |
| this.storageService = storageService; |
| } |
| |
| @GetMapping("/workflow") |
| public String workflow(Model model) { |
| |
| // TODO move this to a configuration resource mechanism |
| CloudServiceDefinition csd = this.cloudServiceDefinitions.stream() |
| .filter(sd -> sd.getName().equals("Validation")) |
| .findFirst() |
| .orElse(null); |
| |
| List<?> allProfiles = new ArrayList<>(); |
| if (csd != null) { |
| try { |
| String baseUrl = csd.getBaseUrl(); |
| if (!baseUrl.endsWith("/")) { |
| baseUrl += "/"; |
| } |
| allProfiles = Unirest.get(baseUrl + "profiles").asJson().getBody().getArray().toList(); |
| } catch (Exception e) { |
| // do nothing, we will handle configurations in a different way in the future |
| } |
| } |
| |
| model.addAttribute("allProfiles", allProfiles); |
| |
| // render the form view |
| return "workflow"; |
| } |
| |
| @PostMapping("/select/{selected}") |
| public String selectService( |
| @PathVariable(name = "selected") String selected, |
| @ModelAttribute WorkflowStatus ws) { |
| |
| ws.addSelectedService(selected); |
| |
| // render the form view |
| return "workflow"; |
| } |
| |
| @GetMapping("/selectedServices") |
| public String getSelectedServices() { |
| // render the servicesList fragment contained in selectedServices.html |
| return "selectedServices :: servicesList"; |
| } |
| |
| @PostMapping("/workflow") |
| public String handleFileUpload( |
| @RequestParam("file") MultipartFile file, |
| @RequestParam(name = "services", required = false) String[] services, |
| @RequestParam(name = "profiles", required = false) String[] validationProfiles, |
| Model model, |
| @ModelAttribute WorkflowStatus ws) { |
| |
| if (ws == null) { |
| ws = new WorkflowStatus(); |
| } else { |
| ws.clear(); |
| } |
| final WorkflowStatus workflowStatus = ws; |
| |
| if (file.isEmpty()) { |
| workflowStatus.addError("Select a file to upload"); |
| return "redirect:/workflow"; |
| } |
| |
| // upload the input file |
| String uuid = storageService.store(file); |
| |
| workflowStatus.setUuid(uuid); |
| workflowStatus.addMessage(file.getOriginalFilename() + " successfully uploaded!"); |
| model.addAttribute("workflowStatus", workflowStatus); |
| |
| if (services != null) { |
| Path inputFile = storageService.load(uuid, file.getOriginalFilename()); |
| for (String service : services) { |
| if (StringUtils.isEmpty(service)) { |
| continue; |
| } |
| |
| // if an error occurred stop the workflow |
| if (!workflowStatus.getErrors().isEmpty()) { |
| break; |
| } |
| |
| try { |
| inputFile = executeCloudService(service, workflowStatus, inputFile, file.getOriginalFilename()); |
| } catch (ProcessingFailedException e) { |
| workflowStatus.addError(e.getMessage()); |
| } |
| } |
| } |
| |
| return "redirect:/workflow"; |
| } |
| |
| @GetMapping("/{uuid}/files/{servicepath}/{filename:.+}") |
| @ResponseBody |
| public ResponseEntity<Resource> serveFile( |
| @PathVariable String uuid, |
| @PathVariable String servicepath, |
| @PathVariable String filename) { |
| |
| Resource file = storageService.loadAsResource(uuid, servicepath, filename); |
| |
| return ResponseEntity.ok().contentType(MediaType.TEXT_PLAIN).body(file); |
| |
| // return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, |
| // "attachment; filename=\"" + file.getFilename() + "\"").body(file); |
| } |
| |
| @GetMapping("/{uuid}/delete") |
| public String delete( |
| @PathVariable String uuid, |
| @ModelAttribute WorkflowStatus workflowStatus) { |
| |
| storageService.delete(uuid); |
| workflowStatus.clear(); |
| |
| return "redirect:/workflow"; |
| } |
| |
| private Path executeCloudService(String serviceName, WorkflowStatus workflowStatus, Path inputFile, String originalFilename) { |
| try { |
| CloudServiceDefinition csd = this.cloudServiceDefinitions.stream() |
| .filter(sd -> sd.getName().equals(serviceName)) |
| .findFirst() |
| .orElse(null); |
| |
| String baseUrl = ""; |
| if (csd != null) { |
| baseUrl = csd.getBaseUrl(); |
| } else { |
| workflowStatus.addError("No service with name " + serviceName + " found! Workflow stopped!"); |
| return null; |
| } |
| |
| // upload to service |
| HttpResponse<?> uploadResponse = Unirest.post(baseUrl) |
| .field("file", Files.newInputStream(inputFile), originalFilename) |
| .asEmpty(); |
| |
| // extract status link from result |
| String statusUrl = null; |
| if (uploadResponse.getStatus() == 201) { |
| statusUrl = uploadResponse.getHeaders().getFirst("Location"); |
| if (StringUtils.isEmpty(statusUrl)) { |
| // fallback check for Link header if Location header is not set |
| statusUrl = getUrlFromLink(uploadResponse.getHeaders().get("Link"), "status", baseUrl); |
| } else if (!statusUrl.startsWith(baseUrl)) { |
| throw new ProcessingFailedException( |
| "The status URL from the Location header does not match with the service base URL '" + baseUrl + "': " + statusUrl); |
| } |
| } else if (uploadResponse.getStatus() == 200) { |
| // fallback if return code is 200, then follow up needs to be placed in Link header |
| statusUrl = getUrlFromLink(uploadResponse.getHeaders().get("Link"), "status", baseUrl); |
| } else { |
| // error |
| workflowStatus.addError("Upload to " + serviceName + " failed! Error code: " + uploadResponse.getStatus() + " - Workflow stopped!"); |
| return null; |
| } |
| |
| workflowStatus.addMessage("Upload to " + serviceName + " service succeeded"); |
| |
| Path result = inputFile; |
| |
| // trigger status URL until process is finished or error occured |
| if (statusUrl != null) { |
| HttpResponse<?> statusResponse = Unirest.get(statusUrl).asEmpty(); |
| List<String> linkHeaders = statusResponse.getHeaders().get("Link"); |
| |
| // TODO make this asynchronous using websockets |
| long start = System.currentTimeMillis(); |
| long end = System.currentTimeMillis(); |
| while (linkHeaders.size() <= 1) { |
| try { |
| Thread.sleep(1000); |
| } catch (InterruptedException e) { |
| // TODO Auto-generated catch block |
| e.printStackTrace(); |
| } |
| |
| statusResponse = Unirest.get(statusUrl).asEmpty(); |
| linkHeaders = statusResponse.getHeaders().get("Link"); |
| |
| end = System.currentTimeMillis(); |
| |
| // don't request more than 30 seconds |
| if (end - start > 30_000) { |
| break; |
| } |
| } |
| |
| // first check if there is a result link |
| String downloadUrl = getUrlFromLink(linkHeaders, "result", baseUrl); |
| String deleteUrl = null; |
| if (downloadUrl != null) { |
| workflowStatus.addMessage(serviceName + " processing finished"); |
| |
| // download file |
| Path migrationSubDir = storageService.load(workflowStatus.getUuid(), "_" + serviceName.toLowerCase()); |
| Files.createDirectories(migrationSubDir); |
| HttpResponse<File> downloadResponse = Unirest.get(downloadUrl) |
| .asFile(migrationSubDir.resolve("result").toString()); |
| |
| |
| Path migrationResult = downloadResponse.getBody().toPath(); |
| String filename = getFilenameFromHeader(downloadResponse.getHeaders().getFirst("Content-Disposition")); |
| if (filename != null) { |
| migrationResult = Files.move(migrationResult, migrationSubDir.resolve(filename)); |
| } |
| |
| workflowStatus.addMessage(serviceName + " result downloaded"); |
| |
| workflowStatus.addResult( |
| serviceName + " Result", |
| MvcUriComponentsBuilder.fromMethodName( |
| WorkflowController.class, |
| "serveFile", |
| workflowStatus.getUuid(), |
| "_" + serviceName.toLowerCase(), |
| migrationResult.getFileName().toString()).build().toUri().toString()); |
| |
| // extract header information if result should be used in workflow |
| String useResult = downloadResponse.getHeaders().getFirst("x-app4mc-use-result"); |
| if (useResult == null || !useResult.toLowerCase().equals("false")) { |
| // the result should not be used in the workflow |
| result = migrationResult; |
| } |
| |
| // check the response result code |
| if (downloadResponse.getStatus() == HttpStatus.SC_OK) { |
| workflowStatus.addMessage(serviceName + " successfull"); |
| } else { |
| workflowStatus.addError(serviceName + " failed with errors"); |
| } |
| |
| // extract delete |
| deleteUrl = getUrlFromLink(downloadResponse.getHeaders().get("Link"), "delete", baseUrl); |
| } else { |
| String errorUrl = getUrlFromLink(linkHeaders, "error", baseUrl); |
| if (errorUrl != null) { |
| workflowStatus.addError(serviceName + " processing finished with error"); |
| |
| // download error file |
| Path migrationSubDir = storageService.load(workflowStatus.getUuid(), "_" + serviceName.toLowerCase()); |
| Files.createDirectories(migrationSubDir); |
| HttpResponse<File> errorResponse = Unirest.get(errorUrl) |
| .asFile(migrationSubDir.resolve("error").toString()); |
| |
| Path migrationError = errorResponse.getBody().toPath(); |
| String filename = getFilenameFromHeader(errorResponse.getHeaders().getFirst("Content-Disposition")); |
| migrationError = Files.move(migrationError, migrationSubDir.resolve(filename)); |
| |
| workflowStatus.addMessage(serviceName + " error result downloaded"); |
| |
| workflowStatus.addResult( |
| serviceName + " Error", |
| MvcUriComponentsBuilder.fromMethodName( |
| WorkflowController.class, |
| "serveFile", |
| workflowStatus.getUuid(), |
| "_" + serviceName.toLowerCase(), |
| migrationError.getFileName().toString()).build().toUri().toString()); |
| |
| // extract delete |
| deleteUrl = getUrlFromLink(errorResponse.getHeaders().get("Link"), "delete", baseUrl); |
| } |
| } |
| |
| if (deleteUrl != null) { |
| // delete upload again |
| Unirest.delete(deleteUrl).asEmpty(); |
| workflowStatus.addMessage(serviceName + " cleaned up"); |
| } |
| |
| } else { |
| // no status URL in the upload response, stop further processing |
| workflowStatus.addMessage("No status URL found for service " + serviceName); |
| return inputFile; |
| } |
| |
| return result; |
| |
| } catch (StorageException | IOException e) { |
| throw new ProcessingFailedException("Error on file operations in converter workflow", e); |
| } |
| } |
| |
| @ExceptionHandler(StorageFileNotFoundException.class) |
| public ResponseEntity<?> handleStorageFileNotFound(StorageFileNotFoundException exc) { |
| return ResponseEntity.notFound().build(); |
| } |
| |
| @ModelAttribute("workflowStatus") |
| public WorkflowStatus workflowStatus() { |
| return new WorkflowStatus(); |
| } |
| |
| @ModelAttribute("cloudServiceDefinitions") |
| public List<CloudServiceDefinition> cloudServiceDefinitions() { |
| return cloudServiceDefinitions; |
| } |
| |
| |
| private static final String LINK_DELIMITER = ","; |
| private static final String LINK_PARAM_DELIMITER = ";"; |
| |
| /** |
| * Extracts the url from the link header and returns the url that has the |
| * specified rel attribute set. |
| * |
| * @param linkHeaders The link headers to parse. |
| * @param rel The value for the rel param for which the url is |
| * requested. |
| * @param baseUrl The base URL of the service. |
| * @return The url for the specified rel param or <code>null</code> if there is |
| * no link for the given rel. |
| */ |
| public static String getUrlFromLink(List<String> linkHeaders, String rel, String baseUrl) { |
| |
| for (String linkHeader : linkHeaders) { |
| String[] links = linkHeader.split(LINK_DELIMITER); |
| |
| for (String link : links) { |
| String[] segments = link.split(LINK_PARAM_DELIMITER); |
| |
| if (segments.length < 2) { |
| continue; |
| } |
| |
| String url = segments[0].trim(); |
| if (!url.startsWith("<") || !url.endsWith(">")) { |
| continue; |
| } |
| url = url.substring(1, url.length() - 1); |
| |
| for (int i = 1; i < segments.length; i++) { |
| String[] param = segments[i].trim().split("="); |
| if (param.length < 2 || !"rel".equals(param[0])) { |
| continue; |
| } |
| |
| String relValue = param[1]; |
| if (relValue.startsWith("\"") && relValue.endsWith("\"")) { |
| relValue = relValue.substring(1, relValue.length() - 1); |
| } |
| |
| if (rel.equals(relValue)) { |
| // SECURITY: ensure that host in link matches host in configured service |
| if (!url.startsWith(baseUrl)) { |
| throw new ProcessingFailedException( |
| "The link for rel '" + rel + "' does not match with the service base URL '" + baseUrl + "': " + url); |
| } |
| |
| return url; |
| } |
| } |
| } |
| } |
| |
| return null; |
| } |
| |
| /** |
| * Extracts the filename from the Content-Disposition header. |
| * |
| * @param header The Content-Disposition header to parse. |
| * @return The filename from the header or <code>null</code> if no filename |
| * could be extracted. |
| */ |
| public static String getFilenameFromHeader(String header) { |
| String[] segments = header.split(LINK_PARAM_DELIMITER); |
| for (String segment : segments) { |
| if (segment.trim().startsWith("filename=")) { |
| String filename = segment.substring(segment.indexOf('=') + 1); |
| if (filename.startsWith("\"") && filename.endsWith("\"")) { |
| filename = filename.substring(1, filename.length() - 1); |
| } |
| return filename; |
| } |
| } |
| return null; |
| } |
| |
| } |