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("subtitles");
315     staticCollections.add("uploaded");
316     staticCollections.add("videoeditor");
317     staticCollections.add("videosegments");
318     staticCollections.add("waveform");
319 
320     // Check if we can read from the asset manager locally to avoid downloading files via HTTP
321     assetManagerPaths = AssetPathUtils.getAssetManagerPath(cc);
322 
323     // Check if we can read published files locally to avoid downloading files via HTTP
324     downloadUrl = DistributionPathUtils.getDownloadUrl(cc);
325     downloadPath = DistributionPathUtils.getDownloadPath(cc);
326   }
327 
328   /** Callback from OSGi on service deactivation. */
329   @Deactivate
330   public void deactivate() {
331     JmxUtil.unregisterMXBean(registeredMXBean);
332     if (workspaceCleaner != null) {
333       workspaceCleaner.shutdown();
334     }
335   }
336 
337   /**
338    * Returns the filename translated into a version that can safely be used as part of a file system path.
339    *
340    * The method shortens both the base file name and the extension to a maximum of 255 characters each,
341    * and replaces unsafe characters with &lt;doce&gt;_&lt;/doce&gt;.
342    *
343    * @param fileName
344    *          The file name
345    * @return the safe version
346    */
347   @Override
348   public String toSafeName(String fileName) {
349     return wfr.toSafeName(fileName);
350   }
351 
352   @Override
353   public File get(final URI uri) throws NotFoundException, IOException {
354     return get(uri, false);
355   }
356 
357   @Override
358   public File get(final URI uri, final boolean uniqueFilename) throws NotFoundException, IOException {
359     File inWs = toWorkspaceFile(uri);
360 
361     if (uniqueFilename) {
362       inWs = new File(FilenameUtils.removeExtension(inWs.getAbsolutePath()) + '-' + UUID.randomUUID() + '.'
363               + FilenameUtils.getExtension(inWs.getName()));
364       logger.debug("Created unique filename: {}", inWs);
365     }
366 
367     if (pathMappable != null && StringUtils.isNotBlank(pathMappable.getPathPrefix())
368             && StringUtils.isNotBlank(pathMappable.getUrlPrefix())) {
369       if (uri.toString().startsWith(pathMappable.getUrlPrefix())) {
370         final String localPath = uri.toString().substring(pathMappable.getUrlPrefix().length());
371         final File wfrCopy = workingFileRepositoryFile(localPath);
372         // does the file exist and is it up to date?
373         logger.trace("Looking up {} at {}", uri.toString(), wfrCopy.getAbsolutePath());
374         if (wfrCopy.isFile()) {
375           final long workspaceFileLastModified = inWs.isFile() ? inWs.lastModified() : 0L;
376           // if the file exists in the workspace, but is older than the wfr copy, replace it
377           if (workspaceFileLastModified < wfrCopy.lastModified()) {
378             logger.debug("Replacing {} with an updated version from the file repository", inWs.getAbsolutePath());
379             locked(inWs, copyOrLink(wfrCopy));
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 void copyOrLink(final File src, final File dst) throws IOException {
441     if (linkingEnabled) {
442       FileUtils.deleteQuietly(dst);
443       FileSupport.link(src, dst);
444     } else {
445       FileSupport.copy(src, dst);
446     }
447   }
448 
449   /** {@link #copyOrLink(java.io.File, java.io.File)} as an effect. <code>src -> dst -> ()</code> */
450   private Effect<File> copyOrLink(final File src) {
451     return new Effect.X<>() {
452       @Override
453       protected void xrun(File dst) throws IOException {
454         copyOrLink(src, dst);
455       }
456     };
457   }
458 
459   /**
460    * Handle the HTTP response.
461    *
462    * @return either a token to initiate a follow-up request or a file or none if the requested URI cannot be found
463    * @throws IOException
464    *           in case of any IO related issues
465    */
466   private Either<String, Option<File>> handleDownloadResponse(HttpResponse response, URI src, File dst)
467           throws IOException {
468     final String url = src.toString();
469     final int status = response.getStatusLine().getStatusCode();
470     switch (status) {
471       case HttpServletResponse.SC_NOT_FOUND:
472         return right(none(File.class));
473       case HttpServletResponse.SC_NOT_MODIFIED:
474         logger.debug("{} has not been modified.", url);
475         return right(some(dst));
476       case HttpServletResponse.SC_ACCEPTED:
477         logger.debug("{} is not ready, try again later.", url);
478         return left(response.getHeaders("token")[0].getValue());
479       case HttpServletResponse.SC_OK:
480         logger.debug("Downloading {} to {}", url, dst.getAbsolutePath());
481         return right(some(downloadTo(response, dst)));
482       default:
483         logger.warn("Received unexpected response status {} while trying to download from {}", status, url);
484         FileUtils.deleteQuietly(dst);
485         return right(none(File.class));
486     }
487   }
488 
489   /** Create a get request to the given URI. */
490   private HttpGet createGetRequest(final URI src, final File dst, final Map<String, String> params) throws IOException {
491     try {
492       URIBuilder builder = new URIBuilder(src.toString());
493       for (Map.Entry<String, String> param : params.entrySet()) {
494         builder.setParameter(param.getKey(), param.getValue());
495       }
496       final HttpGet get = new HttpGet(builder.build());
497       // if the destination file already exists add the If-None-Match header
498       if (dst.isFile() && dst.length() > 0) {
499         get.setHeader("If-None-Match", md5(dst));
500       }
501       return get;
502     } catch (URISyntaxException e) {
503       throw new IOException(e);
504     }
505   }
506 
507   /**
508    * Download content of <code>uri</code> to file <code>dst</code> only if necessary, i.e. either the file does not yet
509    * exist in the workspace or a newer version is available at <code>uri</code>.
510    *
511    * @return the file
512    */
513   private File downloadIfNecessary(final URI src, final File dst) throws IOException, NotFoundException {
514     HttpGet get = createGetRequest(src, dst, Collections.emptyMap());
515     while (true) {
516       // run the http request and handle its response
517       try {
518         HttpResponse response = null;
519         final Either<String, Option<File>> result;
520         try {
521           response = trustedHttpClient.execute(get);
522           result = handleDownloadResponse(response, src, dst);
523         } finally {
524           if (response != null) {
525             trustedHttpClient.close(response);
526           }
527         }
528         for (Option<File> ff : result.right()) {
529           for (File f : ff) {
530             return f;
531           }
532           FileUtils.deleteQuietly(dst);
533           // none
534           throw new NotFoundException();
535         }
536         // left: file will be ready later
537         for (String token : result.left()) {
538           get = createGetRequest(src, dst, Collections.singletonMap("token", token));
539           sleep(60000);
540         }
541       } catch (TrustedHttpClientException e) {
542         FileUtils.deleteQuietly(dst);
543         throw new NotFoundException(String.format("Could not copy %s to %s", src, dst.getAbsolutePath()), e);
544       }
545     }
546   }
547 
548   /**
549    * {@link #downloadIfNecessary(java.net.URI, java.io.File)} as a function.
550    * <code>src_uri -&gt; dst_file -&gt; dst_file</code>
551    */
552   private Function<File, File> downloadIfNecessary(final URI src) {
553     return new Function.X<File, File>() {
554       @Override
555       public File xapply(final File dst) throws Exception {
556         return downloadIfNecessary(src, dst);
557       }
558     };
559   }
560 
561   /**
562    * Download content of an HTTP response to a file.
563    *
564    * @return the destination file
565    */
566   private static File downloadTo(final HttpResponse response, final File dst) throws IOException {
567     // ignore return value
568     dst.createNewFile();
569     try (InputStream in = response.getEntity().getContent()) {
570       try (OutputStream out = new FileOutputStream(dst)) {
571         IOUtils.copyLarge(in, out);
572       }
573     }
574     return dst;
575   }
576 
577   /**
578    * Returns the md5 of a file
579    *
580    * @param file
581    *          the source file
582    * @return the md5 hash
583    * @throws IOException
584    *           if the file cannot be accessed
585    * @throws IllegalArgumentException
586    *           if <code>file</code> is <code>null</code>
587    * @throws IllegalStateException
588    *           if <code>file</code> does not exist or is not a regular file
589    */
590   protected String md5(File file) throws IOException, IllegalArgumentException, IllegalStateException {
591     if (file == null) {
592       throw new IllegalArgumentException("File must not be null");
593     }
594     if (!file.isFile()) {
595       throw new IllegalArgumentException("File " + file.getAbsolutePath() + " can not be read");
596     }
597 
598     try (InputStream in = new FileInputStream(file)) {
599       return DigestUtils.md5Hex(in);
600     }
601   }
602 
603   @Override
604   public void delete(URI uri) throws NotFoundException, IOException {
605 
606     String uriPath = uri.toString();
607     String[] uriElements = uriPath.split("/");
608     String collectionId = null;
609     boolean isMediaPackage = false;
610 
611     logger.trace("delete {}", uriPath);
612 
613     if (uriPath.startsWith(wfr.getBaseUri().toString())) {
614       if (uriPath.indexOf(WorkingFileRepository.COLLECTION_PATH_PREFIX) > 0) {
615         if (uriElements.length > 2) {
616           collectionId = uriElements[uriElements.length - 2];
617           String filename = uriElements[uriElements.length - 1];
618           wfr.deleteFromCollection(collectionId, filename);
619         }
620       } else if (uriPath.indexOf(WorkingFileRepository.MEDIAPACKAGE_PATH_PREFIX) > 0) {
621         isMediaPackage = true;
622         if (uriElements.length >= 3) {
623           String mediaPackageId = uriElements[uriElements.length - 3];
624           String elementId = uriElements[uriElements.length - 2];
625           wfr.delete(mediaPackageId, elementId);
626         }
627       }
628     }
629 
630     // Remove the file and optionally its parent directory if empty
631     File f = toWorkspaceFile(uri);
632     if (f.isFile()) {
633       synchronized (lock) {
634         File mpElementDir = f.getParentFile();
635         FileUtils.forceDelete(f);
636 
637         // Remove containing folder if a mediapackage element or a not a static collection
638         if (isMediaPackage || !isStaticCollection(collectionId)) {
639           FileSupport.delete(mpElementDir);
640         }
641 
642         // Also delete mediapackage itself when empty
643         if (isMediaPackage) {
644           FileSupport.delete(mpElementDir.getParentFile());
645         }
646       }
647     }
648 
649     // wait for WFR
650     waitForResource(uri, HttpServletResponse.SC_NOT_FOUND, "File %s does not disappear in WFR");
651   }
652 
653   @Override
654   public void delete(String mediaPackageID, String mediaPackageElementID) throws NotFoundException, IOException {
655     // delete locally
656     final File f = workspaceFile(WorkingFileRepository.MEDIAPACKAGE_PATH_PREFIX, mediaPackageID, mediaPackageElementID);
657     FileUtils.deleteQuietly(f);
658     FileSupport.delete(f.getParentFile());
659     // delete in WFR
660     wfr.delete(mediaPackageID, mediaPackageElementID);
661     // todo check in WFR
662   }
663 
664   @Override
665   public URI put(String mediaPackageID, String mediaPackageElementID, String fileName, InputStream in)
666           throws IOException {
667     String safeFileName = toSafeName(fileName);
668     final URI uri = wfr.getURI(mediaPackageID, mediaPackageElementID, fileName);
669     notNull(in, "in");
670 
671     // Determine the target location in the workspace
672     File workspaceFile = null;
673     synchronized (lock) {
674       workspaceFile = toWorkspaceFile(uri);
675       FileUtils.touch(workspaceFile);
676     }
677 
678     // Try hard linking first and fall back to tee-ing to both the working file repository and the workspace
679     if (linkingEnabled) {
680       // 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
681       // there ourselves
682       wfr.put(mediaPackageID, mediaPackageElementID, fileName, in);
683       File workingFileRepoDirectory = workingFileRepositoryFile(WorkingFileRepository.MEDIAPACKAGE_PATH_PREFIX,
684               mediaPackageID, mediaPackageElementID);
685       File workingFileRepoCopy = new File(workingFileRepoDirectory, safeFileName);
686       FileSupport.link(workingFileRepoCopy, workspaceFile, true);
687     } else {
688       try (FileOutputStream out = new FileOutputStream(workspaceFile)) {
689         try (InputStream tee = new TeeInputStream(in, out, true)) {
690           wfr.put(mediaPackageID, mediaPackageElementID, fileName, tee);
691         }
692       }
693     }
694     // wait until the file appears on the WFR node
695     waitForResource(uri, HttpServletResponse.SC_OK, "File %s does not appear in WFR");
696     return uri;
697   }
698 
699   @Override
700   public URI putInCollection(String collectionId, String fileName, InputStream in) throws IOException {
701     String safeFileName = toSafeName(fileName);
702     URI uri = wfr.getCollectionURI(collectionId, fileName);
703 
704     // Determine the target location in the workspace
705     InputStream tee = null;
706     File tempFile = null;
707     FileOutputStream out = null;
708     try {
709       synchronized (lock) {
710         tempFile = toWorkspaceFile(uri);
711         FileUtils.touch(tempFile);
712         out = new FileOutputStream(tempFile);
713       }
714 
715       // Try hard linking first and fall back to tee-ing to both the working file repository and the workspace
716       if (linkingEnabled) {
717         tee = in;
718         wfr.putInCollection(collectionId, fileName, tee);
719         FileUtils.forceMkdir(tempFile.getParentFile());
720         File workingFileRepoDirectory = workingFileRepositoryFile(WorkingFileRepository.COLLECTION_PATH_PREFIX,
721                 collectionId);
722         File workingFileRepoCopy = new File(workingFileRepoDirectory, safeFileName);
723         FileSupport.link(workingFileRepoCopy, tempFile, true);
724       } else {
725         tee = new TeeInputStream(in, out, true);
726         wfr.putInCollection(collectionId, fileName, tee);
727       }
728     } catch (IOException e) {
729       FileUtils.deleteQuietly(tempFile);
730       throw e;
731     } finally {
732       IoSupport.closeQuietly(tee);
733       IoSupport.closeQuietly(out);
734     }
735     waitForResource(uri, HttpServletResponse.SC_OK, "File %s does not appear in WFR");
736     return uri;
737   }
738 
739   @Override
740   public URI getURI(String mediaPackageID, String mediaPackageElementID) {
741     return wfr.getURI(mediaPackageID, mediaPackageElementID);
742   }
743 
744   @Override
745   public URI getCollectionURI(String collectionID, String fileName) {
746     return wfr.getCollectionURI(collectionID, fileName);
747   }
748 
749   @Override
750   public URI moveTo(URI collectionURI, String toMediaPackage, String toMediaPackageElement, String toFileName)
751           throws NotFoundException, IOException {
752     String path = collectionURI.toString();
753     String filename = FilenameUtils.getName(path);
754     String collection = getCollection(collectionURI);
755     logger.debug("Moving {} from {} to {}/{}", filename, collection, toMediaPackage, toMediaPackageElement);
756     // move locally
757     File original = toWorkspaceFile(collectionURI);
758     if (original.isFile()) {
759       URI copyURI = wfr.getURI(toMediaPackage, toMediaPackageElement, toFileName);
760       File copy = toWorkspaceFile(copyURI);
761       FileUtils.forceMkdir(copy.getParentFile());
762       FileUtils.deleteQuietly(copy);
763       FileUtils.moveFile(original, copy);
764       if (!isStaticCollection(collection)) {
765         FileSupport.delete(original.getParentFile());
766       }
767     }
768     // move in WFR
769     final URI wfrUri = wfr.moveTo(collection, filename, toMediaPackage, toMediaPackageElement, toFileName);
770     // wait for WFR
771     waitForResource(wfrUri, SC_OK, "File %s does not appear in WFR");
772     return wfrUri;
773   }
774 
775   @Override
776   public URI[] getCollectionContents(String collectionId) throws NotFoundException {
777     return wfr.getCollectionContents(collectionId);
778   }
779 
780   private void deleteFromCollection(String collectionId, String fileName, boolean removeCollection)
781           throws NotFoundException, IOException {
782     // local delete
783     final File f = workspaceFile(WorkingFileRepository.COLLECTION_PATH_PREFIX, collectionId, toSafeName(fileName));
784     FileUtils.deleteQuietly(f);
785     if (removeCollection) {
786       FileSupport.delete(f.getParentFile());
787     }
788     // delete in WFR
789     try {
790       wfr.deleteFromCollection(collectionId, fileName, removeCollection);
791     } catch (IllegalArgumentException e) {
792       throw new NotFoundException(e);
793     }
794     // wait for WFR
795     waitForResource(wfr.getCollectionURI(collectionId, fileName), SC_NOT_FOUND, "File %s does not disappear in WFR");
796   }
797 
798   @Override
799   public void deleteFromCollection(String collectionId, String fileName) throws NotFoundException, IOException {
800     deleteFromCollection(collectionId, fileName, false);
801   }
802 
803   /**
804    * Transforms a URI into a workspace File. If the file comes from the working file repository, the path in the
805    * workspace mirrors that of the repository. If the file comes from another source, directories are created for each
806    * segment of the URL. Sub-directories may be created as needed.
807    *
808    * @param uri
809    *          the uri
810    * @return the local file representation
811    */
812   File toWorkspaceFile(URI uri) {
813     // MH-11497: Fix for compatibility with stream security: the query parameters are deleted.
814     // TODO Refactor this class to use the URI class and methods instead of String for handling URIs
815     String uriString = UriBuilder.fromUri(uri).replaceQuery(null).build().toString();
816     String wfrPrefix = wfr.getBaseUri().toString();
817     String serverPath = FilenameUtils.getPath(uriString);
818     if (uriString.startsWith(wfrPrefix)) {
819       serverPath = serverPath.substring(wfrPrefix.length());
820     } else {
821       serverPath = serverPath.replaceAll(":/*", "_");
822     }
823     String wsDirectoryPath = PathSupport.concat(wsRoot, serverPath);
824     File wsDirectory = new File(wsDirectoryPath);
825     wsDirectory.mkdirs();
826 
827     String safeFileName = toSafeName(FilenameUtils.getName(uriString));
828     if (StringUtils.isBlank(safeFileName)) {
829       safeFileName = UNKNOWN_FILENAME;
830     }
831     return new File(wsDirectory, safeFileName);
832   }
833 
834   /** Return a file object pointing into the workspace. */
835   private File workspaceFile(String... path) {
836     return new File(path(cons(String.class, wsRoot, path)));
837   }
838 
839   /** Return a file object pointing into the working file repository. */
840   private File workingFileRepositoryFile(String... path) {
841     return new File(path(cons(String.class, pathMappable.getPathPrefix(), path)));
842   }
843 
844   /**
845    * Returns the working file repository collection.
846    * <p>
847    *
848    * <pre>
849    * http://localhost:8080/files/collection/&lt;collection&gt;/ -> &lt;collection&gt;
850    * </pre>
851    *
852    * @param uri
853    *          the working file repository collection uri
854    * @return the collection name
855    */
856   private String getCollection(URI uri) {
857     String path = uri.toString();
858     if (path.indexOf(WorkingFileRepository.COLLECTION_PATH_PREFIX) < 0) {
859       throw new IllegalArgumentException(uri + " must point to a working file repository collection");
860     }
861 
862     String collection = FilenameUtils.getPath(path);
863     if (collection.endsWith("/")) {
864       collection = collection.substring(0, collection.length() - 1);
865     }
866     collection = collection.substring(collection.lastIndexOf("/"));
867     collection = collection.substring(collection.lastIndexOf("/") + 1, collection.length());
868     return collection;
869   }
870 
871   private boolean isStaticCollection(String collection) {
872     return staticCollections.contains(collection);
873   }
874 
875   @Override
876   public Option<Long> getTotalSpace() {
877     return some(new File(wsRoot).getTotalSpace());
878   }
879 
880   @Override
881   public Option<Long> getUsableSpace() {
882     return some(new File(wsRoot).getUsableSpace());
883   }
884 
885   @Override
886   public Option<Long> getUsedSpace() {
887     return some(FileUtils.sizeOfDirectory(new File(wsRoot)));
888   }
889 
890   @Override
891   public URI getBaseUri() {
892     return wfr.getBaseUri();
893   }
894 
895   @Reference
896   public void setRepository(WorkingFileRepository repo) {
897     this.wfr = repo;
898     if (repo instanceof PathMappable) {
899       this.pathMappable = (PathMappable) repo;
900       logger.info("Mapping workspace to working file repository using {}", pathMappable.getPathPrefix());
901     }
902   }
903 
904   @Reference
905   public void setTrustedHttpClient(TrustedHttpClient trustedHttpClient) {
906     this.trustedHttpClient = trustedHttpClient;
907   }
908 
909   @Reference
910   public void setSecurityService(SecurityService securityService) {
911     this.securityService = securityService;
912   }
913 
914   private static final long TIMEOUT = 2L * 60L * 1000L;
915   private static final long INTERVAL = 1000L;
916 
917   private void waitForResource(final URI uri, final int expectedStatus, final String errorMsg) throws IOException {
918     if (waitForResourceFlag) {
919       HttpUtil.waitForResource(trustedHttpClient, uri, expectedStatus, TIMEOUT, INTERVAL)
920               .fold(Misc.<Exception, Void> chuck(), new Effect.X<Integer>() {
921                 @Override
922                 public void xrun(Integer status) throws Exception {
923                   if (ne(status, expectedStatus)) {
924                     final String msg = format(errorMsg, uri.toString());
925                     logger.warn(msg);
926                     throw new IOException(msg);
927                   }
928                 }
929               });
930     }
931   }
932 
933   @Override
934   public void cleanup(final int maxAgeInSeconds) {
935     // Cancel cleanup if we do not have a valid setting for the maximum file age
936     if (maxAgeInSeconds < 0) {
937       logger.debug("Canceling cleanup of workspace due to maxAge ({}) <= 0", maxAgeInSeconds);
938       return;
939     }
940 
941     // Warn if time is very short since this operation is dangerous and *should* only be a fallback for if stuff
942     // remained in the workspace due to some errors. If we have a very short maxAge, we may delete file which are
943     // currently being processed. The warn value is 2 days:
944     if (maxAgeInSeconds < 60 * 60 * 24 * 2) {
945       logger.warn("The max age for the workspace cleaner is dangerously low. Please consider increasing the value to "
946               + "avoid deleting data in use by running workflows.");
947     }
948 
949     // Clean workspace root directly
950     RecursiveDirectoryCleaner.cleanDirectory(Paths.get(wsRoot), Duration.ofSeconds(maxAgeInSeconds));
951   }
952 
953   @Override
954   public void cleanup(Id mediaPackageId) throws IOException {
955     cleanup(mediaPackageId, false);
956   }
957 
958   @Override
959   public void cleanup(Id mediaPackageId, boolean filesOnly) throws IOException {
960     final File mediaPackageDir = workspaceFile(
961         WorkingFileRepository.MEDIAPACKAGE_PATH_PREFIX, mediaPackageId.toString());
962 
963     if (filesOnly) {
964       logger.debug("Clean workspace media package directory {} (files only)", mediaPackageDir);
965       FileSupport.delete(mediaPackageDir, FileSupport.DELETE_FILES);
966     }
967     else {
968       logger.debug("Clean workspace media package directory {}", mediaPackageDir);
969       FileUtils.deleteDirectory(mediaPackageDir);
970     }
971   }
972 
973   @Override
974   public String rootDirectory() {
975     return wsRoot;
976   }
977 
978   private class DeleteOnCloseFileInputStream extends FileInputStream {
979     private File file;
980 
981     DeleteOnCloseFileInputStream(File file) throws FileNotFoundException {
982       super(file);
983       this.file = file;
984     }
985 
986     public void close() throws IOException {
987       try {
988         super.close();
989       } finally {
990         if (file != null) {
991           logger.debug("Cleaning up {}", file);
992           file.delete();
993           file = null;
994         }
995       }
996     }
997   }
998 }