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