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