View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  package org.opencastproject.workspace.impl;
23  
24  import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
25  import static javax.servlet.http.HttpServletResponse.SC_OK;
26  import static org.opencastproject.util.EqualsUtil.ne;
27  import static org.opencastproject.util.IoSupport.locked;
28  import static org.opencastproject.util.PathSupport.path;
29  import static org.opencastproject.util.RequireUtil.notNull;
30  import static org.opencastproject.util.data.Arrays.cons;
31  import static org.opencastproject.util.data.Either.left;
32  import static org.opencastproject.util.data.Either.right;
33  import static org.opencastproject.util.data.Prelude.sleep;
34  import static org.opencastproject.util.data.functions.Misc.chuck;
35  
36  import org.opencastproject.assetmanager.util.AssetPathUtils;
37  import org.opencastproject.assetmanager.util.DistributionPathUtils;
38  import org.opencastproject.cleanup.RecursiveDirectoryCleaner;
39  import org.opencastproject.mediapackage.identifier.Id;
40  import org.opencastproject.security.api.SecurityService;
41  import org.opencastproject.security.api.TrustedHttpClient;
42  import org.opencastproject.security.api.TrustedHttpClientException;
43  import org.opencastproject.storage.StorageUsage;
44  import org.opencastproject.util.FileSupport;
45  import org.opencastproject.util.HttpUtil;
46  import org.opencastproject.util.IoSupport;
47  import org.opencastproject.util.NotFoundException;
48  import org.opencastproject.util.PathSupport;
49  import org.opencastproject.util.data.Either;
50  import org.opencastproject.workingfilerepository.api.PathMappable;
51  import org.opencastproject.workingfilerepository.api.WorkingFileRepository;
52  import org.opencastproject.workspace.api.Workspace;
53  
54  import org.apache.commons.codec.digest.DigestUtils;
55  import org.apache.commons.io.FileUtils;
56  import org.apache.commons.io.FilenameUtils;
57  import org.apache.commons.io.IOUtils;
58  import org.apache.commons.io.input.TeeInputStream;
59  import org.apache.commons.lang3.StringUtils;
60  import org.apache.http.HttpResponse;
61  import org.apache.http.client.methods.HttpGet;
62  import org.apache.http.client.utils.URIBuilder;
63  import org.osgi.service.component.ComponentContext;
64  import org.osgi.service.component.annotations.Activate;
65  import org.osgi.service.component.annotations.Component;
66  import org.osgi.service.component.annotations.Deactivate;
67  import org.osgi.service.component.annotations.Reference;
68  import org.slf4j.Logger;
69  import org.slf4j.LoggerFactory;
70  
71  import java.io.File;
72  import java.io.FileInputStream;
73  import java.io.FileNotFoundException;
74  import java.io.FileOutputStream;
75  import java.io.IOException;
76  import java.io.InputStream;
77  import java.io.OutputStream;
78  import java.net.URI;
79  import java.net.URISyntaxException;
80  import java.nio.file.Files;
81  import java.nio.file.Paths;
82  import java.nio.file.StandardCopyOption;
83  import java.time.Duration;
84  import java.util.Collections;
85  import java.util.List;
86  import java.util.Map;
87  import java.util.Optional;
88  import java.util.UUID;
89  import java.util.concurrent.CopyOnWriteArraySet;
90  import java.util.function.Function;
91  
92  import javax.servlet.http.HttpServletResponse;
93  import javax.ws.rs.core.UriBuilder;
94  
95  /**
96   * Implements a simple cache for remote URIs. Delegates methods to {@link WorkingFileRepository} wherever possible.
97   * <p>
98   * Note that if you are running the workspace on the same machine as the
99   * singleton working file repository, you can save a lot of space if you
100  * configure both root directories onto the same volume (that is, if your file
101  * system supports hard links).
102  *
103  * TODO Implement cache invalidation using the caching headers, if provided, from the remote server.
104  */
105 @Component(
106     property = {
107     "service.description=Workspace"
108     },
109     immediate = true,
110     service = { Workspace.class, StorageUsage.class }
111 )
112 public final class WorkspaceImpl implements Workspace {
113   /** The logging facility */
114   private static final Logger logger = LoggerFactory.getLogger(WorkspaceImpl.class);
115 
116   /** Configuration key for the workspace root directory */
117   public static final String WORKSPACE_DIR_KEY = "org.opencastproject.workspace.rootdir";
118   /** Configuration key for the storage directory */
119   public static final String STORAGE_DIR_KEY = "org.opencastproject.storage.dir";
120   /** Configuration key for garbage collection period. */
121   public static final String WORKSPACE_CLEANUP_PERIOD_KEY = "org.opencastproject.workspace.cleanup.period";
122   /** Configuration key for garbage collection max age. */
123   public static final String WORKSPACE_CLEANUP_MAX_AGE_KEY = "org.opencastproject.workspace.cleanup.max.age";
124 
125   /** Unknown file name string */
126   private static final String UNKNOWN_FILENAME = "unknown";
127 
128   private final Object lock = new Object();
129 
130   /** The workspace root directory */
131   private String wsRoot = null;
132 
133   /** If true, hardlinking can be done between working file repository and workspace */
134   private boolean linkingEnabled = false;
135 
136   private TrustedHttpClient trustedHttpClient;
137 
138   private SecurityService securityService = null;
139 
140   /** The working file repository */
141   private WorkingFileRepository wfr = null;
142 
143   /** The path mappable */
144   private PathMappable pathMappable = null;
145 
146   private CopyOnWriteArraySet<String> staticCollections = new CopyOnWriteArraySet<String>();
147 
148   private boolean waitForResourceFlag = false;
149 
150   /** the asset manager directory if locally available */
151   private List<String> assetManagerPaths = null;
152 
153   /** the download url and directory if locally available */
154   private String downloadUrl = null;
155   private String downloadPath = null;
156 
157   /** The workspce cleaner */
158   private WorkspaceCleaner workspaceCleaner = null;
159 
160   public WorkspaceImpl() {
161   }
162 
163   /**
164    * Creates a workspace implementation which is located at the given root directory.
165    * <p>
166    * Note that if you are running the workspace on the same machine as the singleton working file repository, you can
167    * save a lot of space if you configure both root directories onto the same volume (that is, if your file system
168    * supports hard links).
169    *
170    * @param rootDirectory
171    *          the repository root directory
172    */
173   public WorkspaceImpl(String rootDirectory, boolean waitForResource) {
174     this.wsRoot = rootDirectory;
175     this.waitForResourceFlag = waitForResource;
176   }
177 
178   /**
179    * Check is a property exists in a given bundle context.
180    *
181    * @param cc
182    *          the OSGi component context
183    * @param prop
184    *          property to check for.
185    */
186   private boolean ensureContextProp(ComponentContext cc, String prop) {
187     return cc != null && cc.getBundleContext().getProperty(prop) != null;
188   }
189 
190   /**
191    * OSGi service activation callback.
192    *
193    * @param cc
194    *          the OSGi component context
195    */
196   @Activate
197   public void activate(ComponentContext cc) {
198     if (this.wsRoot == null) {
199       if (ensureContextProp(cc, WORKSPACE_DIR_KEY)) {
200         // use rootDir from CONFIG
201         this.wsRoot = cc.getBundleContext().getProperty(WORKSPACE_DIR_KEY);
202         logger.info("CONFIG " + WORKSPACE_DIR_KEY + ": " + this.wsRoot);
203       } else if (ensureContextProp(cc, STORAGE_DIR_KEY)) {
204         // create rootDir by adding "workspace" to the default data directory
205         this.wsRoot = PathSupport.concat(cc.getBundleContext().getProperty(STORAGE_DIR_KEY), "workspace");
206         logger.warn("CONFIG " + WORKSPACE_DIR_KEY + " is missing: falling back to " + this.wsRoot);
207       } else {
208         throw new IllegalStateException("Configuration '" + WORKSPACE_DIR_KEY + "' is missing");
209       }
210     }
211 
212     // Create the root directory
213     File f = new File(this.wsRoot);
214     if (!f.exists()) {
215       try {
216         FileUtils.forceMkdir(f);
217       } catch (Exception e) {
218         throw new IllegalStateException("Could not create workspace directory.", e);
219       }
220     }
221 
222     // Test whether hard linking between working file repository and workspace is possible
223     if (pathMappable != null) {
224       String wfrRoot = pathMappable.getPathPrefix();
225       File srcFile = new File(wfrRoot, ".linktest");
226       try {
227         FileUtils.touch(srcFile);
228       } catch (IOException e) {
229         throw new IllegalStateException("The working file repository seems read-only", e);
230       }
231 
232       // Create a unique target file
233       File targetFile;
234       try {
235         targetFile = File.createTempFile(".linktest.", ".tmp", new File(wsRoot));
236         targetFile.delete();
237       } catch (IOException e) {
238         throw new IllegalStateException("The workspace seems read-only", e);
239       }
240 
241       // Test hard linking
242       linkingEnabled = FileSupport.supportsLinking(srcFile, targetFile);
243 
244       // Clean up
245       FileUtils.deleteQuietly(targetFile);
246 
247       if (linkingEnabled) {
248         logger.info("Hard links between the working file repository and the workspace enabled");
249       } else {
250         logger.warn("Hard links between the working file repository and the workspace are not possible");
251         logger.warn("This will increase the overall amount of disk space used");
252       }
253     }
254 
255     // Set up the garbage collection timer
256     int garbageCollectionPeriodInSeconds = -1;
257     if (ensureContextProp(cc, WORKSPACE_CLEANUP_PERIOD_KEY)) {
258       String period = cc.getBundleContext().getProperty(WORKSPACE_CLEANUP_PERIOD_KEY);
259       try {
260         garbageCollectionPeriodInSeconds = Integer.parseInt(period);
261       } catch (NumberFormatException e) {
262         logger.warn("Invalid configuration for workspace garbage collection period ({}={})",
263                 WORKSPACE_CLEANUP_PERIOD_KEY, period);
264         garbageCollectionPeriodInSeconds = -1;
265       }
266     }
267 
268     // Activate garbage collection
269     int maxAgeInSeconds = -1;
270     if (ensureContextProp(cc, WORKSPACE_CLEANUP_MAX_AGE_KEY)) {
271       String age = cc.getBundleContext().getProperty(WORKSPACE_CLEANUP_MAX_AGE_KEY);
272       try {
273         maxAgeInSeconds = Integer.parseInt(age);
274       } catch (NumberFormatException e) {
275         logger.warn("Invalid configuration for workspace garbage collection max age ({}={})",
276                 WORKSPACE_CLEANUP_MAX_AGE_KEY, age);
277         maxAgeInSeconds = -1;
278       }
279     }
280 
281     // Start cleanup scheduler if we have sensible cleanup values:
282     if (garbageCollectionPeriodInSeconds > 0) {
283       workspaceCleaner = new WorkspaceCleaner(this, garbageCollectionPeriodInSeconds, maxAgeInSeconds);
284       workspaceCleaner.schedule();
285     }
286 
287     // Initialize the list of static collections
288     // TODO MH-12440 replace with a different mechanism that doesn't hardcode collection names
289     staticCollections.add("archive");
290     staticCollections.add("captions");
291     staticCollections.add("composer");
292     staticCollections.add("composite");
293     staticCollections.add("coverimage");
294     staticCollections.add("executor");
295     staticCollections.add("inbox");
296     staticCollections.add("ocrtext");
297     staticCollections.add("subtitles");
298     staticCollections.add("uploaded");
299     staticCollections.add("videoeditor");
300     staticCollections.add("videosegments");
301     staticCollections.add("waveform");
302 
303     // Check if we can read from the asset manager locally to avoid downloading files via HTTP
304     assetManagerPaths = AssetPathUtils.getAssetManagerPath(cc);
305 
306     // Check if we can read published files locally to avoid downloading files via HTTP
307     downloadUrl = DistributionPathUtils.getDownloadUrl(cc);
308     downloadPath = DistributionPathUtils.getDownloadPath(cc);
309   }
310 
311   /** Callback from OSGi on service deactivation. */
312   @Deactivate
313   public void deactivate() {
314     if (workspaceCleaner != null) {
315       workspaceCleaner.shutdown();
316     }
317   }
318 
319   /**
320    * Returns the filename translated into a version that can safely be used as part of a file system path.
321    *
322    * The method shortens both the base file name and the extension to a maximum of 255 characters each,
323    * and replaces unsafe characters with &lt;doce&gt;_&lt;/doce&gt;.
324    *
325    * @param fileName
326    *          The file name
327    * @return the safe version
328    */
329   @Override
330   public String toSafeName(String fileName) {
331     return wfr.toSafeName(fileName);
332   }
333 
334   @Override
335   public File get(final URI uri) throws NotFoundException, IOException {
336     return get(uri, false);
337   }
338 
339   @Override
340   public File get(final URI uri, final boolean uniqueFilename) throws NotFoundException, IOException {
341     File inWs = toWorkspaceFile(uri);
342 
343     if (uniqueFilename) {
344       inWs = new File(FilenameUtils.removeExtension(inWs.getAbsolutePath()) + '-' + UUID.randomUUID() + '.'
345               + FilenameUtils.getExtension(inWs.getName()));
346       logger.debug("Created unique filename: {}", inWs);
347     }
348 
349     if (pathMappable != null && StringUtils.isNotBlank(pathMappable.getPathPrefix())
350             && StringUtils.isNotBlank(pathMappable.getUrlPrefix())) {
351       if (uri.toString().startsWith(pathMappable.getUrlPrefix())) {
352         final String localPath = uri.toString().substring(pathMappable.getUrlPrefix().length());
353         final File wfrCopy = workingFileRepositoryFile(localPath);
354         // does the file exist and is it up to date?
355         logger.trace("Looking up {} at {}", uri.toString(), wfrCopy.getAbsolutePath());
356         if (wfrCopy.isFile()) {
357           final long workspaceFileLastModified = inWs.isFile() ? inWs.lastModified() : 0L;
358           // if the file exists in the workspace, but is older than the wfr copy, replace it
359           if (workspaceFileLastModified < wfrCopy.lastModified()) {
360             logger.debug("Replacing {} with an updated version from the file repository", inWs.getAbsolutePath());
361 //            locked(inWs, copyOrLink(wfrCopy));
362             locked(inWs, f -> {
363               copyOrLink(wfrCopy, f).run();
364               return null;
365             });
366           } else {
367             logger.debug("{} is up to date", inWs);
368           }
369           logger.debug("Getting {} directly from working file repository root at {}", uri, inWs);
370           return new File(inWs.getAbsolutePath());
371         } else {
372           logger.warn("The working file repository and workspace paths don't match. Looking up {} at {} failed",
373                   uri.toString(), wfrCopy.getAbsolutePath());
374         }
375       }
376     }
377 
378     // Check if we can get the files directly from the asset manager
379     final File asset = AssetPathUtils.getLocalFile(assetManagerPaths, securityService.getOrganization().getId(), uri);
380     if (asset != null) {
381       logger.debug("Copy local file {} from asset manager to workspace", asset);
382       Files.copy(asset.toPath(), inWs.toPath(), StandardCopyOption.REPLACE_EXISTING);
383       return new File(inWs.getAbsolutePath());
384     }
385 
386     // do HTTP transfer
387     return locked(inWs, downloadIfNecessary(uri));
388   }
389 
390   @Override
391   public InputStream read(final URI uri) throws NotFoundException, IOException {
392 
393     // Check if we can get the file from the working file repository directly
394     if (pathMappable != null) {
395       if (uri.toString().startsWith(pathMappable.getUrlPrefix())) {
396         final String localPath = uri.toString().substring(pathMappable.getUrlPrefix().length());
397         final File wfrCopy = workingFileRepositoryFile(localPath);
398         // does the file exist?
399         logger.trace("Looking up {} at {} for read", uri, wfrCopy);
400         if (wfrCopy.isFile()) {
401           logger.debug("Getting {} directly from working file repository root at {} for read", uri, wfrCopy);
402           return new FileInputStream(wfrCopy);
403         }
404         logger.warn("The working file repository URI and paths don't match. Looking up {} at {} failed", uri, wfrCopy);
405       }
406     }
407 
408     // Check if we can get the files directly from the asset manager
409     final File asset = AssetPathUtils.getLocalFile(assetManagerPaths, securityService.getOrganization().getId(), uri);
410     if (asset != null) {
411       return new FileInputStream(asset);
412     }
413 
414     // Check if we can get the files directly from the distribution download directory
415     final File publishedFile = DistributionPathUtils.getLocalFile(
416         downloadPath, downloadUrl, securityService.getOrganization().getId(), uri);
417     if (publishedFile != null) {
418       return new FileInputStream(publishedFile);
419     }
420 
421     // fall back to get() which should download the file into local workspace if necessary
422     return new DeleteOnCloseFileInputStream(get(uri, true));
423   }
424 
425   /** Copy or link <code>src</code> to <code>dst</code>. */
426   private Runnable copyOrLink(File src, File dst) {
427     return () -> {
428       try {
429         if (linkingEnabled) {
430           FileUtils.deleteQuietly(dst);
431           FileSupport.link(src, dst);
432         } else {
433           FileSupport.copy(src, dst);
434         }
435       } catch (Exception e) {
436         chuck(e);
437       }
438     };
439   }
440 
441   /**
442    * Handle the HTTP response.
443    *
444    * @return either a token to initiate a follow-up request or a file or none if the requested URI cannot be found
445    * @throws IOException
446    *           in case of any IO related issues
447    */
448   private Either<String, Optional<File>> handleDownloadResponse(HttpResponse response, URI src, File dst)
449           throws IOException {
450     final String url = src.toString();
451     final int status = response.getStatusLine().getStatusCode();
452     switch (status) {
453       case HttpServletResponse.SC_NOT_FOUND:
454         return right(Optional.empty());
455       case HttpServletResponse.SC_NOT_MODIFIED:
456         logger.debug("{} has not been modified.", url);
457         return right(Optional.of(dst));
458       case HttpServletResponse.SC_ACCEPTED:
459         logger.debug("{} is not ready, try again later.", url);
460         return left(response.getHeaders("token")[0].getValue());
461       case HttpServletResponse.SC_OK:
462         logger.debug("Downloading {} to {}", url, dst.getAbsolutePath());
463         return right(Optional.of(downloadTo(response, dst)));
464       default:
465         logger.warn("Received unexpected response status {} while trying to download from {}", status, url);
466         FileUtils.deleteQuietly(dst);
467         return right(Optional.empty());
468     }
469   }
470 
471   /** Create a get request to the given URI. */
472   private HttpGet createGetRequest(final URI src, final File dst, final Map<String, String> params) throws IOException {
473     try {
474       URIBuilder builder = new URIBuilder(src.toString());
475       for (Map.Entry<String, String> param : params.entrySet()) {
476         builder.setParameter(param.getKey(), param.getValue());
477       }
478       final HttpGet get = new HttpGet(builder.build());
479       // if the destination file already exists add the If-None-Match header
480       if (dst.isFile() && dst.length() > 0) {
481         get.setHeader("If-None-Match", md5(dst));
482       }
483       return get;
484     } catch (URISyntaxException e) {
485       throw new IOException(e);
486     }
487   }
488 
489   /**
490    * Download content of <code>uri</code> to file <code>dst</code> only if necessary, i.e. either the file does not yet
491    * exist in the workspace or a newer version is available at <code>uri</code>.
492    *
493    * @return the file
494    */
495   private File downloadIfNecessary(final URI src, final File dst) throws IOException, NotFoundException {
496     HttpGet get = createGetRequest(src, dst, Collections.emptyMap());
497     while (true) {
498       // run the http request and handle its response
499       try {
500         HttpResponse response = null;
501         final Either<String, Optional<File>> result;
502         try {
503           response = trustedHttpClient.execute(get);
504           result = handleDownloadResponse(response, src, dst);
505         } finally {
506           if (response != null) {
507             trustedHttpClient.close(response);
508           }
509         }
510         for (Optional<File> ff : result.right()) {
511           if (ff.isPresent()) {
512             return ff.get();
513           }
514           FileUtils.deleteQuietly(dst);
515           // none
516           throw new NotFoundException();
517         }
518         // left: file will be ready later
519         for (String token : result.left()) {
520           get = createGetRequest(src, dst, Collections.singletonMap("token", token));
521           sleep(60000);
522         }
523       } catch (TrustedHttpClientException e) {
524         FileUtils.deleteQuietly(dst);
525         throw new NotFoundException(String.format("Could not copy %s to %s", src, dst.getAbsolutePath()), e);
526       }
527     }
528   }
529 
530   /**
531    * {@link #downloadIfNecessary(java.net.URI, java.io.File)} as a function.
532    * <code>src_uri -&gt; dst_file -&gt; dst_file</code>
533    */
534   private Function<File, File> downloadIfNecessary(final URI src) {
535     return dst -> {
536       try {
537         return downloadIfNecessary(src, dst);
538       } catch (Exception e) {
539         return chuck(e);
540       }
541     };
542   }
543 
544   /**
545    * Download content of an HTTP response to a file.
546    *
547    * @return the destination file
548    */
549   private static File downloadTo(final HttpResponse response, final File dst) throws IOException {
550     // ignore return value
551     dst.createNewFile();
552     try (InputStream in = response.getEntity().getContent()) {
553       try (OutputStream out = new FileOutputStream(dst)) {
554         IOUtils.copyLarge(in, out);
555       }
556     }
557     return dst;
558   }
559 
560   /**
561    * Returns the md5 of a file
562    *
563    * @param file
564    *          the source file
565    * @return the md5 hash
566    * @throws IOException
567    *           if the file cannot be accessed
568    * @throws IllegalArgumentException
569    *           if <code>file</code> is <code>null</code>
570    * @throws IllegalStateException
571    *           if <code>file</code> does not exist or is not a regular file
572    */
573   protected String md5(File file) throws IOException, IllegalArgumentException, IllegalStateException {
574     if (file == null) {
575       throw new IllegalArgumentException("File must not be null");
576     }
577     if (!file.isFile()) {
578       throw new IllegalArgumentException("File " + file.getAbsolutePath() + " can not be read");
579     }
580 
581     try (InputStream in = new FileInputStream(file)) {
582       return DigestUtils.md5Hex(in);
583     }
584   }
585 
586   @Override
587   public void delete(URI uri) throws NotFoundException, IOException {
588 
589     String uriPath = uri.toString();
590     String[] uriElements = uriPath.split("/");
591     String collectionId = null;
592     boolean isMediaPackage = false;
593 
594     logger.trace("delete {}", uriPath);
595 
596     if (uriPath.startsWith(wfr.getBaseUri().toString())) {
597       if (uriPath.indexOf(WorkingFileRepository.COLLECTION_PATH_PREFIX) > 0) {
598         if (uriElements.length > 2) {
599           collectionId = uriElements[uriElements.length - 2];
600           String filename = uriElements[uriElements.length - 1];
601           wfr.deleteFromCollection(collectionId, filename);
602         }
603       } else if (uriPath.indexOf(WorkingFileRepository.MEDIAPACKAGE_PATH_PREFIX) > 0) {
604         isMediaPackage = true;
605         if (uriElements.length >= 3) {
606           String mediaPackageId = uriElements[uriElements.length - 3];
607           String elementId = uriElements[uriElements.length - 2];
608           wfr.delete(mediaPackageId, elementId);
609         }
610       }
611     }
612 
613     // Remove the file and optionally its parent directory if empty
614     File f = toWorkspaceFile(uri);
615     if (f.isFile()) {
616       synchronized (lock) {
617         File mpElementDir = f.getParentFile();
618         FileUtils.forceDelete(f);
619 
620         // Remove containing folder if a mediapackage element or a not a static collection
621         if (isMediaPackage || !isStaticCollection(collectionId)) {
622           FileSupport.delete(mpElementDir);
623         }
624 
625         // Also delete mediapackage itself when empty
626         if (isMediaPackage) {
627           FileSupport.delete(mpElementDir.getParentFile());
628         }
629       }
630     }
631 
632     // wait for WFR
633     waitForResource(uri, HttpServletResponse.SC_NOT_FOUND, "File %s does not disappear in WFR");
634   }
635 
636   @Override
637   public void delete(String mediaPackageID, String mediaPackageElementID) throws NotFoundException, IOException {
638     // delete locally
639     final File f = workspaceFile(WorkingFileRepository.MEDIAPACKAGE_PATH_PREFIX, mediaPackageID, mediaPackageElementID);
640     FileUtils.deleteQuietly(f);
641     FileSupport.delete(f.getParentFile());
642     // delete in WFR
643     wfr.delete(mediaPackageID, mediaPackageElementID);
644     // todo check in WFR
645   }
646 
647   @Override
648   public URI put(String mediaPackageID, String mediaPackageElementID, String fileName, InputStream in)
649           throws IOException {
650     String safeFileName = toSafeName(fileName);
651     final URI uri = wfr.getURI(mediaPackageID, mediaPackageElementID, fileName);
652     notNull(in, "in");
653 
654     // Determine the target location in the workspace
655     File workspaceFile = null;
656     synchronized (lock) {
657       workspaceFile = toWorkspaceFile(uri);
658       FileUtils.touch(workspaceFile);
659     }
660 
661     // Try hard linking first and fall back to tee-ing to both the working file repository and the workspace
662     if (linkingEnabled) {
663       // The WFR stores an md5 hash along with the file, so we need to use the API and not try to write (link) the file
664       // there ourselves
665       wfr.put(mediaPackageID, mediaPackageElementID, fileName, in);
666       File workingFileRepoDirectory = workingFileRepositoryFile(WorkingFileRepository.MEDIAPACKAGE_PATH_PREFIX,
667               mediaPackageID, mediaPackageElementID);
668       File workingFileRepoCopy = new File(workingFileRepoDirectory, safeFileName);
669       FileSupport.link(workingFileRepoCopy, workspaceFile, true);
670     } else {
671       try (FileOutputStream out = new FileOutputStream(workspaceFile)) {
672         try (InputStream tee = new TeeInputStream(in, out, true)) {
673           wfr.put(mediaPackageID, mediaPackageElementID, fileName, tee);
674         }
675       }
676     }
677     // wait until the file appears on the WFR node
678     waitForResource(uri, HttpServletResponse.SC_OK, "File %s does not appear in WFR");
679     return uri;
680   }
681 
682   @Override
683   public URI putInCollection(String collectionId, String fileName, InputStream in) throws IOException {
684     String safeFileName = toSafeName(fileName);
685     URI uri = wfr.getCollectionURI(collectionId, fileName);
686 
687     // Determine the target location in the workspace
688     InputStream tee = null;
689     File tempFile = null;
690     FileOutputStream out = null;
691     try {
692       synchronized (lock) {
693         tempFile = toWorkspaceFile(uri);
694         FileUtils.touch(tempFile);
695         out = new FileOutputStream(tempFile);
696       }
697 
698       // Try hard linking first and fall back to tee-ing to both the working file repository and the workspace
699       if (linkingEnabled) {
700         tee = in;
701         wfr.putInCollection(collectionId, fileName, tee);
702         FileUtils.forceMkdir(tempFile.getParentFile());
703         File workingFileRepoDirectory = workingFileRepositoryFile(WorkingFileRepository.COLLECTION_PATH_PREFIX,
704                 collectionId);
705         File workingFileRepoCopy = new File(workingFileRepoDirectory, safeFileName);
706         FileSupport.link(workingFileRepoCopy, tempFile, true);
707       } else {
708         tee = new TeeInputStream(in, out, true);
709         wfr.putInCollection(collectionId, fileName, tee);
710       }
711     } catch (IOException e) {
712       FileUtils.deleteQuietly(tempFile);
713       throw e;
714     } finally {
715       IoSupport.closeQuietly(tee);
716       IoSupport.closeQuietly(out);
717     }
718     waitForResource(uri, HttpServletResponse.SC_OK, "File %s does not appear in WFR");
719     return uri;
720   }
721 
722   @Override
723   public URI getURI(String mediaPackageID, String mediaPackageElementID) {
724     return wfr.getURI(mediaPackageID, mediaPackageElementID);
725   }
726 
727   @Override
728   public URI getCollectionURI(String collectionID, String fileName) {
729     return wfr.getCollectionURI(collectionID, fileName);
730   }
731 
732   @Override
733   public URI moveTo(URI collectionURI, String toMediaPackage, String toMediaPackageElement, String toFileName)
734           throws NotFoundException, IOException {
735     String path = collectionURI.toString();
736     String filename = FilenameUtils.getName(path);
737     String collection = getCollection(collectionURI);
738     logger.debug("Moving {} from {} to {}/{}", filename, collection, toMediaPackage, toMediaPackageElement);
739     // move locally
740     File original = toWorkspaceFile(collectionURI);
741     if (original.isFile()) {
742       URI copyURI = wfr.getURI(toMediaPackage, toMediaPackageElement, toFileName);
743       File copy = toWorkspaceFile(copyURI);
744       FileUtils.forceMkdir(copy.getParentFile());
745       FileUtils.deleteQuietly(copy);
746       FileUtils.moveFile(original, copy);
747       if (!isStaticCollection(collection)) {
748         FileSupport.delete(original.getParentFile());
749       }
750     }
751     // move in WFR
752     final URI wfrUri = wfr.moveTo(collection, filename, toMediaPackage, toMediaPackageElement, toFileName);
753     // wait for WFR
754     waitForResource(wfrUri, SC_OK, "File %s does not appear in WFR");
755     return wfrUri;
756   }
757 
758   @Override
759   public URI[] getCollectionContents(String collectionId) throws NotFoundException {
760     return wfr.getCollectionContents(collectionId);
761   }
762 
763   private void deleteFromCollection(String collectionId, String fileName, boolean removeCollection)
764           throws NotFoundException, IOException {
765     // local delete
766     final File f = workspaceFile(WorkingFileRepository.COLLECTION_PATH_PREFIX, collectionId, toSafeName(fileName));
767     FileUtils.deleteQuietly(f);
768     if (removeCollection) {
769       FileSupport.delete(f.getParentFile());
770     }
771     // delete in WFR
772     try {
773       wfr.deleteFromCollection(collectionId, fileName, removeCollection);
774     } catch (IllegalArgumentException e) {
775       throw new NotFoundException(e);
776     }
777     // wait for WFR
778     waitForResource(wfr.getCollectionURI(collectionId, fileName), SC_NOT_FOUND, "File %s does not disappear in WFR");
779   }
780 
781   @Override
782   public void deleteFromCollection(String collectionId, String fileName) throws NotFoundException, IOException {
783     deleteFromCollection(collectionId, fileName, false);
784   }
785 
786   /**
787    * Transforms a URI into a workspace File. If the file comes from the working file repository, the path in the
788    * workspace mirrors that of the repository. If the file comes from another source, directories are created for each
789    * segment of the URL. Sub-directories may be created as needed.
790    *
791    * @param uri
792    *          the uri
793    * @return the local file representation
794    */
795   File toWorkspaceFile(URI uri) {
796     // MH-11497: Fix for compatibility with stream security: the query parameters are deleted.
797     // TODO Refactor this class to use the URI class and methods instead of String for handling URIs
798     String uriString = UriBuilder.fromUri(uri).replaceQuery(null).build().toString();
799     String wfrPrefix = wfr.getBaseUri().toString();
800     String serverPath = FilenameUtils.getPath(uriString);
801     if (uriString.startsWith(wfrPrefix)) {
802       serverPath = serverPath.substring(wfrPrefix.length());
803     } else {
804       serverPath = serverPath.replaceAll(":/*", "_");
805     }
806     String wsDirectoryPath = PathSupport.concat(wsRoot, serverPath);
807     File wsDirectory = new File(wsDirectoryPath);
808     wsDirectory.mkdirs();
809 
810     String safeFileName = toSafeName(FilenameUtils.getName(uriString));
811     if (StringUtils.isBlank(safeFileName)) {
812       safeFileName = UNKNOWN_FILENAME;
813     }
814     return new File(wsDirectory, safeFileName);
815   }
816 
817   /** Return a file object pointing into the workspace. */
818   private File workspaceFile(String... path) {
819     return new File(path(cons(String.class, wsRoot, path)));
820   }
821 
822   /** Return a file object pointing into the working file repository. */
823   private File workingFileRepositoryFile(String... path) {
824     return new File(path(cons(String.class, pathMappable.getPathPrefix(), path)));
825   }
826 
827   /**
828    * Returns the working file repository collection.
829    * <p>
830    *
831    * <pre>
832    * http://localhost:8080/files/collection/&lt;collection&gt;/ -> &lt;collection&gt;
833    * </pre>
834    *
835    * @param uri
836    *          the working file repository collection uri
837    * @return the collection name
838    */
839   private String getCollection(URI uri) {
840     String path = uri.toString();
841     if (path.indexOf(WorkingFileRepository.COLLECTION_PATH_PREFIX) < 0) {
842       throw new IllegalArgumentException(uri + " must point to a working file repository collection");
843     }
844 
845     String collection = FilenameUtils.getPath(path);
846     if (collection.endsWith("/")) {
847       collection = collection.substring(0, collection.length() - 1);
848     }
849     collection = collection.substring(collection.lastIndexOf("/"));
850     collection = collection.substring(collection.lastIndexOf("/") + 1, collection.length());
851     return collection;
852   }
853 
854   private boolean isStaticCollection(String collection) {
855     return staticCollections.contains(collection);
856   }
857 
858   @Override
859   public Optional<Long> getTotalSpace() {
860     return Optional.of(new File(wsRoot).getTotalSpace());
861   }
862 
863   @Override
864   public Optional<Long> getUsableSpace() {
865     return Optional.of(new File(wsRoot).getUsableSpace());
866   }
867 
868   @Override
869   public Optional<Long> getUsedSpace() {
870     return Optional.of(FileUtils.sizeOfDirectory(new File(wsRoot)));
871   }
872 
873   @Override
874   public URI getBaseUri() {
875     return wfr.getBaseUri();
876   }
877 
878   @Reference
879   public void setRepository(WorkingFileRepository repo) {
880     this.wfr = repo;
881     if (repo instanceof PathMappable) {
882       this.pathMappable = (PathMappable) repo;
883       logger.info("Mapping workspace to working file repository using {}", pathMappable.getPathPrefix());
884     }
885   }
886 
887   @Reference
888   public void setTrustedHttpClient(TrustedHttpClient trustedHttpClient) {
889     this.trustedHttpClient = trustedHttpClient;
890   }
891 
892   @Reference
893   public void setSecurityService(SecurityService securityService) {
894     this.securityService = securityService;
895   }
896 
897   private static final long TIMEOUT = 2L * 60L * 1000L;
898   private static final long INTERVAL = 1000L;
899 
900   private void waitForResource(final URI uri, final int expectedStatus, final String errorMsg) throws IOException {
901     if (waitForResourceFlag) {
902       HttpUtil.waitForResource(trustedHttpClient, uri, expectedStatus, TIMEOUT, INTERVAL)
903           .fold(
904               chuck(),
905               status -> {
906                 if (ne(status, expectedStatus)) {
907                   final String msg = String.format(errorMsg, uri.toString());
908                   logger.warn(msg);
909                   chuck(new IOException(msg));
910                 }
911                 return null;
912               }
913           );
914     }
915   }
916 
917   @Override
918   public void cleanup(final int maxAgeInSeconds) {
919     // Cancel cleanup if we do not have a valid setting for the maximum file age
920     if (maxAgeInSeconds < 0) {
921       logger.debug("Canceling cleanup of workspace due to maxAge ({}) <= 0", maxAgeInSeconds);
922       return;
923     }
924 
925     // Warn if time is very short since this operation is dangerous and *should* only be a fallback for if stuff
926     // remained in the workspace due to some errors. If we have a very short maxAge, we may delete file which are
927     // currently being processed. The warn value is 2 days:
928     if (maxAgeInSeconds < 60 * 60 * 24 * 2) {
929       logger.warn("The max age for the workspace cleaner is dangerously low. Please consider increasing the value to "
930               + "avoid deleting data in use by running workflows.");
931     }
932 
933     // Clean workspace root directly
934     RecursiveDirectoryCleaner.cleanDirectory(Paths.get(wsRoot), Duration.ofSeconds(maxAgeInSeconds));
935   }
936 
937   @Override
938   public void cleanup(Id mediaPackageId) throws IOException {
939     cleanup(mediaPackageId, false);
940   }
941 
942   @Override
943   public void cleanup(Id mediaPackageId, boolean filesOnly) throws IOException {
944     final File mediaPackageDir = workspaceFile(
945         WorkingFileRepository.MEDIAPACKAGE_PATH_PREFIX, mediaPackageId.toString());
946 
947     if (filesOnly) {
948       logger.debug("Clean workspace media package directory {} (files only)", mediaPackageDir);
949       FileSupport.delete(mediaPackageDir, FileSupport.DELETE_FILES);
950     }
951     else {
952       logger.debug("Clean workspace media package directory {}", mediaPackageDir);
953       FileUtils.deleteDirectory(mediaPackageDir);
954     }
955   }
956 
957   @Override
958   public String rootDirectory() {
959     return wsRoot;
960   }
961 
962   private class DeleteOnCloseFileInputStream extends FileInputStream {
963     private File file;
964 
965     DeleteOnCloseFileInputStream(File file) throws FileNotFoundException {
966       super(file);
967       this.file = file;
968     }
969 
970     public void close() throws IOException {
971       try {
972         super.close();
973       } finally {
974         if (file != null) {
975           logger.debug("Cleaning up {}", file);
976           file.delete();
977           file = null;
978         }
979       }
980     }
981   }
982 
983   @Override
984   public String getStorageName() {
985     return "Workspace";
986   }
987 }