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