package org.molgenis.gavin.controller; import org.molgenis.file.FileStore; import org.molgenis.gavin.job.GavinJob; import org.molgenis.gavin.job.GavinJobExecution; import org.molgenis.gavin.job.GavinJobFactory; import org.molgenis.gavin.job.JobNotFoundException; import org.molgenis.gavin.job.meta.GavinJobExecutionFactory; import org.molgenis.security.user.UserAccountService; import org.molgenis.ui.controller.AbstractStaticContentController; import org.molgenis.ui.menu.MenuReaderService; import org.molgenis.util.ErrorMessageResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.util.List; import java.util.concurrent.ExecutorService; import static java.io.File.separator; import static java.text.MessageFormat.format; import static java.time.ZonedDateTime.now; import static java.util.Objects.requireNonNull; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static org.molgenis.gavin.controller.GavinController.URI; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE; import static org.springframework.web.bind.annotation.RequestMethod.GET; import static org.springframework.web.bind.annotation.RequestMethod.POST; @Controller @RequestMapping(URI) @EnableScheduling public class GavinController extends AbstractStaticContentController { private static final Logger LOG = LoggerFactory.getLogger(GavinController.class); public static final String GAVIN_APP = "gavin-app"; static final String URI = PLUGIN_URI_PREFIX + GAVIN_APP; public static final String TSV_GZ = "tsv.gz"; public static final String TSV = "tsv"; public static final String GZ = "gz"; private final ExecutorService executorService; private final GavinJobFactory gavinJobFactory; private final GavinJobExecutionFactory gavinJobExecutionFactory; private final FileStore fileStore; private final UserAccountService userAccountService; private final SecureIdGenerator secureIdGenerator; private final MenuReaderService menuReaderService; @Autowired public GavinController(@Qualifier("gavinExecutors") ExecutorService executorService, GavinJobFactory gavinJobFactory, GavinJobExecutionFactory gavinJobExecutionFactory, FileStore fileStore, UserAccountService userAccountService, MenuReaderService menuReaderService) { super(GAVIN_APP, URI); this.executorService = requireNonNull(executorService); this.gavinJobFactory = requireNonNull(gavinJobFactory); this.gavinJobExecutionFactory = requireNonNull(gavinJobExecutionFactory); this.fileStore = requireNonNull(fileStore); this.userAccountService = requireNonNull(userAccountService); this.menuReaderService = menuReaderService; secureIdGenerator = new SecureIdGenerator(); } /** * Shows the gavin page. * This page shows the configuration wheels if the annotation resources are not yet properly configured. * If the annotation resources are fine, it shows the upload control. * * @return the view name */ @RequestMapping(method = RequestMethod.GET) public String init(Model model) { super.init(model); List<String> annotatorsWithMissingResources = gavinJobFactory.getAnnotatorsWithMissingResources(); if (!annotatorsWithMissingResources.isEmpty()) { model.addAttribute("annotatorsWithMissingResources", annotatorsWithMissingResources); } return "view-gavin"; } /** * Starts a job to annotate a VCF file * * @param inputFile the uploaded input file * @param entityName the name of the file * @return the ID of the created {@link GavinJobExecution} * @throws IOException if interaction with the file store fails */ @RequestMapping(value = "/annotate-file", method = POST) public ResponseEntity<String> annotateFile(@RequestParam(value = "file") MultipartFile inputFile, @RequestParam String entityName) throws IOException { String extension = TSV; if (inputFile.getOriginalFilename().endsWith(GZ)) { extension = TSV_GZ; } final GavinJobExecution gavinJobExecution = gavinJobExecutionFactory.create(secureIdGenerator.generateId()); gavinJobExecution.setFilename(entityName); gavinJobExecution.setUser(userAccountService.getCurrentUser().getUsername()); gavinJobExecution.setInputFileExtension(extension); final GavinJob gavinJob = gavinJobFactory.createJob(gavinJobExecution); final String gavinJobIdentifier = gavinJobExecution.getIdentifier(); fileStore.createDirectory(GAVIN_APP); final String jobDir = format("{0}{1}{2}", GAVIN_APP, separator, gavinJobIdentifier); fileStore.createDirectory(jobDir); final String fileName = format("{0}{1}input.{2}", jobDir, separator, extension); fileStore.writeToFile(inputFile.getInputStream(), fileName); executorService.submit(gavinJob); String location = "/plugin/gavin-app/job/" + gavinJobIdentifier; return ResponseEntity.created(java.net.URI.create(location)).body(location); } /** * Retrieves {@link GavinJobExecution} job. * May be called by anyone who has the identifier. * * @param jobIdentifier identifier of the annotation job * @return GavinJobExecution, or null if no GavinJobExecution exists with this ID. */ @RequestMapping(value = "/job/{jobIdentifier}", method = GET, produces = APPLICATION_JSON_VALUE) public @ResponseBody GavinJobExecution getGavinJobExecution(@PathVariable(value = "jobIdentifier") String jobIdentifier) throws JobNotFoundException { return gavinJobFactory.findGavinJobExecution(jobIdentifier); } /** * Shows result page for a job. The job may still be running. * * @param jobIdentifier identifier of the annotation job * @return {@link FileSystemResource} with the annotated file */ @RequestMapping(value = "/result/{jobIdentifier}", method = GET) public String result(@PathVariable(value = "jobIdentifier") String jobIdentifier, Model model, HttpServletRequest request) throws JobNotFoundException { model.addAttribute("jobExecution", gavinJobFactory.findGavinJobExecution(jobIdentifier)); model.addAttribute("downloadFileExists", getDownloadFileForJob(jobIdentifier).exists()); model.addAttribute("errorFileExists", getErrorFileForJob(jobIdentifier).exists()); model.addAttribute("pageUrl", getPageUrl(jobIdentifier, request)); return "view-gavin-result"; } private String getPageUrl(String jobIdentifier, HttpServletRequest request) { String host; if (StringUtils.isEmpty(request.getHeader("X-Forwarded-Host"))) { host = request.getScheme() + "://" + request.getServerName() + ":" + request.getLocalPort(); } else { host = request.getScheme() + "://" + request.getHeader("X-Forwarded-Host"); } return format("{0}{1}/result/{2}", host, menuReaderService.getMenu().findMenuItemPath(GAVIN_APP), jobIdentifier); } /** * Downloads the result of a gavin annotation job. * * @param response {@link HttpServletResponse} to write the Content-Disposition header to with the filename * @param jobIdentifier GAVIN_APP of the annotation job * @return {@link FileSystemResource} with the annotated file */ @RequestMapping(value = "/download/{jobIdentifier}", method = GET, produces = APPLICATION_OCTET_STREAM_VALUE) @ResponseBody public FileSystemResource download(HttpServletResponse response, @PathVariable(value = "jobIdentifier") String jobIdentifier) throws FileNotFoundException, JobNotFoundException { GavinJobExecution jobExecution = gavinJobFactory.findGavinJobExecution(jobIdentifier); File file = getDownloadFileForJob(jobIdentifier); if (!file.exists()) { LOG.warn("No result file found for job {}", jobIdentifier); throw new FileNotFoundException("No result file found for this job. Results are removed every night."); } response.setHeader("Content-Disposition", format("inline; filename=\"{0}-gavin.vcf\"", jobExecution.getFilename())); return new FileSystemResource(file); } private File getDownloadFileForJob(String jobIdentifier) { return fileStore.getFile(GAVIN_APP + separator + jobIdentifier + separator + "gavin-result.vcf"); } private File getErrorFileForJob(String jobIdentifier) { return fileStore.getFile(GAVIN_APP + separator + jobIdentifier + separator + "error.txt"); } /** * Downloads the error report of a gavin annotation job. * * @param response {@link HttpServletResponse} to write the Content-Disposition header to with the filename * @param jobIdentifier GAVIN_APP of the annotation job * @return {@link FileSystemResource} with the annotated file */ @RequestMapping(value = "/error/{jobIdentifier}", method = GET, produces = APPLICATION_OCTET_STREAM_VALUE) @ResponseBody public Resource downloadErrorReport(HttpServletResponse response, @PathVariable(value = "jobIdentifier") String jobIdentifier) throws FileNotFoundException, JobNotFoundException { GavinJobExecution jobExecution = gavinJobFactory.findGavinJobExecution(jobIdentifier); response.setHeader("Content-Disposition", format("inline; filename=\"{0}-error.txt\"", jobExecution.getFilename())); final File file = getErrorFileForJob(jobIdentifier); if (!file.exists()) { LOG.warn("No error file found for job {}", jobIdentifier); throw new FileNotFoundException("No error report found for this job. Results are removed every night."); } return new FileSystemResource(file); } @ExceptionHandler(value = FileNotFoundException.class) public void handleFileNotFound(FileNotFoundException ex, HttpServletResponse res) throws IOException { res.sendError(404, ex.getMessage()); } @ExceptionHandler(value = JobNotFoundException.class) public void handleJobNotFound(JobNotFoundException ex, HttpServletResponse res) throws IOException { res.sendError(404, ex.getMessage()); } @ExceptionHandler(RuntimeException.class) @ResponseBody @ResponseStatus(HttpStatus.BAD_REQUEST) public ErrorMessageResponse handleRuntimeException(RuntimeException e) { LOG.warn(e.getMessage(), e); return new ErrorMessageResponse(new ErrorMessageResponse.ErrorMessage(e.getMessage())); } /** * Removes old files in the gavin working directory from the file store. */ @Scheduled(cron = "0 0 * * * *") public void cleanUp() { LOG.debug("Clean up old jobs in the file store..."); try { final File[] oldFiles = fileStore.getFile(GAVIN_APP).listFiles( file -> file.isDirectory() && MILLISECONDS.toSeconds(file.lastModified()) < now().minusHours(24) .toEpochSecond()); if (oldFiles != null) { for (File file : oldFiles) { LOG.info("Deleting job directory {}", file.getName()); fileStore.deleteDirectory(GAVIN_APP + separator + file.getName()); } } LOG.debug("Done."); } catch (IOException e) { LOG.error("Failed to clean up working directory", e); } } }