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.workingfilerepository.impl;
23  
24  import org.opencastproject.cleanup.RecursiveDirectoryCleaner;
25  import org.opencastproject.rest.RestConstants;
26  import org.opencastproject.security.api.SecurityService;
27  import org.opencastproject.serviceregistry.api.ServiceRegistry;
28  import org.opencastproject.systems.OpencastConstants;
29  import org.opencastproject.util.Checksum;
30  import org.opencastproject.util.FileSupport;
31  import org.opencastproject.util.NotFoundException;
32  import org.opencastproject.util.PathSupport;
33  import org.opencastproject.util.UrlSupport;
34  import org.opencastproject.util.data.Option;
35  import org.opencastproject.util.jmx.JmxUtil;
36  import org.opencastproject.workingfilerepository.api.PathMappable;
37  import org.opencastproject.workingfilerepository.api.WorkingFileRepository;
38  import org.opencastproject.workingfilerepository.jmx.WorkingFileRepositoryBean;
39  
40  import org.apache.commons.codec.digest.DigestUtils;
41  import org.apache.commons.io.FileUtils;
42  import org.apache.commons.io.FilenameUtils;
43  import org.apache.commons.io.IOUtils;
44  import org.apache.commons.lang3.StringUtils;
45  import org.osgi.service.component.ComponentContext;
46  import org.slf4j.Logger;
47  import org.slf4j.LoggerFactory;
48  
49  import java.io.File;
50  import java.io.FileInputStream;
51  import java.io.FileOutputStream;
52  import java.io.FilenameFilter;
53  import java.io.IOException;
54  import java.io.InputStream;
55  import java.net.URI;
56  import java.net.URISyntaxException;
57  import java.nio.file.AtomicMoveNotSupportedException;
58  import java.nio.file.Files;
59  import java.nio.file.Paths;
60  import java.nio.file.StandardCopyOption;
61  import java.security.DigestInputStream;
62  import java.security.MessageDigest;
63  import java.security.NoSuchAlgorithmException;
64  import java.time.Duration;
65  import java.util.Arrays;
66  import java.util.Date;
67  import java.util.List;
68  import java.util.Map;
69  import java.util.Objects;
70  
71  import javax.management.ObjectInstance;
72  
73  /**
74   * A very simple (read: inadequate) implementation that stores all files under a root directory using the media package
75   * ID as a subdirectory and the media package element ID as the file name.
76   */
77  public class WorkingFileRepositoryImpl implements WorkingFileRepository, PathMappable {
78    /** The logger */
79    private static final Logger logger = LoggerFactory.getLogger(WorkingFileRepositoryImpl.class);
80  
81    /** The extension we use for the md5 hash calculated from the file contents */
82    public static final String MD5_EXTENSION = ".md5";
83  
84    /** The filename filter matching .md5 files */
85    private static final FilenameFilter MD5_FINAME_FILTER = new FilenameFilter() {
86      public boolean accept(File dir, String name) {
87        return name.endsWith(MD5_EXTENSION);
88      }
89    };
90  
91    /** Working file repository JMX type */
92    private static final String JMX_WORKING_FILE_REPOSITORY_TYPE = "WorkingFileRepository";
93    /** Configuration key for garbage collection period. */
94    public static final String WORKING_FILE_REPOSITORY_CLEANUP_PERIOD_KEY = "org.opencastproject.working.file.repository.cleanup.period";
95    /** Configuration key for garbage collection max age. */
96    public static final String WORKING_FILE_REPOSITORY_CLEANUP_MAX_AGE_KEY = "org.opencastproject.working.file.repository.cleanup.max.age";
97    /** Configuration key for collections to clean up. */
98    private static final String WORKING_FILE_REPOSITORY_CLEANUP_COLLECTIONS_KEY = "org.opencastproject.working.file.repository.cleanup.collections";
99  
100   /** The JMX working file repository bean */
101   private WorkingFileRepositoryBean workingFileRepositoryBean = new WorkingFileRepositoryBean(this);
102 
103   /** The JMX bean object instance */
104   private ObjectInstance registeredMXBean;
105 
106   /** The remote service manager */
107   protected ServiceRegistry remoteServiceManager;
108 
109   /** The root directory for storing files */
110   protected String rootDirectory = null;
111 
112   /** The Base URL for this server */
113   protected String serverUrl = null;
114 
115   /** The URL path for the services provided by the working file repository */
116   protected String servicePath = null;
117 
118   /** The default pattern for characters forbidden in filenames */
119   private static final String FILENAME_REGEX_DEFAULT = "(^\\W|[^\\w-.]|\\.\\.|\\.$)";
120 
121   /** Key for configuring the filename pattern specifying forbidden characters */
122   private static final String FILENAME_REGEX_KEY = "filename.forbidden.pattern";
123 
124   /** The pattern for characters allowed in filenames */
125   private String filenameRegex = FILENAME_REGEX_DEFAULT;
126 
127 
128   /** The security service to get current organization from */
129   protected SecurityService securityService;
130 
131   /** The working file repository cleaner */
132   private WorkingFileRepositoryCleaner workingFileRepositoryCleaner;
133 
134   /**
135    * Activate the component
136    */
137   public void activate(ComponentContext cc) throws IOException {
138     if (rootDirectory != null)
139       return; // If the root directory was set, respect that setting
140 
141     filenameRegex = Objects.toString(
142         cc.getProperties().get(FILENAME_REGEX_KEY),
143         FILENAME_REGEX_DEFAULT);
144     logger.debug("Configured filename forbidden pattern: {}", filenameRegex);
145 
146     // server url
147     serverUrl = cc.getBundleContext().getProperty(OpencastConstants.SERVER_URL_PROPERTY);
148     if (StringUtils.isBlank(serverUrl))
149       throw new IllegalStateException("Server URL must be set");
150 
151     // working file repository 'facade' configuration
152     servicePath = (String) cc.getProperties().get(RestConstants.SERVICE_PATH_PROPERTY);
153 
154     // root directory
155     rootDirectory = StringUtils.trimToNull(cc.getBundleContext().getProperty("org.opencastproject.file.repo.path"));
156     if (rootDirectory == null) {
157       String storageDir = cc.getBundleContext().getProperty("org.opencastproject.storage.dir");
158       if (storageDir == null) {
159         throw new IllegalStateException("Storage directory must be set");
160       }
161       rootDirectory = storageDir + File.separator + "files";
162     }
163 
164     try {
165       createRootDirectory();
166     } catch (IOException e) {
167       logger.error("Unable to create the working file repository root directory at {}", rootDirectory);
168       throw e;
169     }
170 
171     registeredMXBean = JmxUtil.registerMXBean(workingFileRepositoryBean, JMX_WORKING_FILE_REPOSITORY_TYPE);
172 
173     // Determine garbage collection period
174     int garbageCollectionPeriodInSeconds = -1;
175     String period = StringUtils.trimToNull(
176             cc.getBundleContext().getProperty(WORKING_FILE_REPOSITORY_CLEANUP_PERIOD_KEY));
177     if (period != null) {
178       try {
179         garbageCollectionPeriodInSeconds = Integer.parseInt(period);
180       } catch (NumberFormatException e) {
181         logger.error("The garbage collection period for the working file repository is not an integer {}", period);
182         throw e;
183       }
184     }
185 
186     // Determine the max age of garbage collection entries
187     int maxAgeInDays = -1;
188     String age = StringUtils.trimToNull(cc.getBundleContext().getProperty(WORKING_FILE_REPOSITORY_CLEANUP_MAX_AGE_KEY));
189     if (age != null) {
190       try {
191         maxAgeInDays = Integer.parseInt(age);
192       } catch (NumberFormatException e) {
193         logger.error("The max age for the working file repository garbage collection is not an integer {}", age);
194         throw e;
195       }
196     }
197 
198     // Determine which collections should be garbage collected
199     List<String> collectionsToCleanUp = null;
200     String collectionsToCleanUpStr = StringUtils.trimToNull(
201             cc.getBundleContext().getProperty(WORKING_FILE_REPOSITORY_CLEANUP_COLLECTIONS_KEY));
202     if (collectionsToCleanUpStr != null) {
203       collectionsToCleanUp = Arrays.asList(collectionsToCleanUpStr.split("\\s*,\\s*"));
204     }
205 
206     // Start cleanup scheduler if we have sensible cleanup values:
207     if (garbageCollectionPeriodInSeconds > 0 && maxAgeInDays > 0 && collectionsToCleanUp != null) {
208       workingFileRepositoryCleaner = new WorkingFileRepositoryCleaner(this,
209               garbageCollectionPeriodInSeconds, maxAgeInDays, collectionsToCleanUp);
210       workingFileRepositoryCleaner.schedule();
211     }
212 
213     logger.info(getDiskSpace());
214   }
215 
216   /**
217    * Callback from OSGi on service deactivation.
218    */
219   public void deactivate() {
220     JmxUtil.unregisterMXBean(registeredMXBean);
221     if (workingFileRepositoryCleaner != null) {
222       workingFileRepositoryCleaner.shutdown();
223     }
224   }
225 
226   /**
227    * Returns the filename translated into a version that can safely be used as part of a file system path.
228    *
229    * The method shortens both the base file name and the extension to a maximum of 255 characters each,
230    * and replaces unsafe characters with &lt;doce&gt;_&lt;/doce&gt;.
231    *
232    * @param fileName
233    *          The file name
234    * @return the safe version
235    */
236   @Override
237   public String toSafeName(String fileName) {
238     var extension = FilenameUtils.getExtension(fileName)
239         .replaceAll(filenameRegex, "_");
240     var baseName = FilenameUtils.getBaseName(fileName)
241         .replaceAll(filenameRegex, "_");
242 
243     if (StringUtils.isEmpty(extension)) {
244       return StringUtils.left(baseName, 255);
245     }
246     return String.format("%.255s.%.255s", baseName, extension);
247   }
248 
249   /**
250    * {@inheritDoc}
251    *
252    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#delete(java.lang.String, java.lang.String)
253    */
254   public boolean delete(String mediaPackageID, String mediaPackageElementID) throws IOException {
255     File f;
256     try {
257       f = getFile(mediaPackageID, mediaPackageElementID);
258 
259       File parentDirectory = f.getParentFile();
260       logger.debug("Attempting to delete {}", parentDirectory.getAbsolutePath());
261       FileUtils.forceDelete(parentDirectory);
262       File parentsParentDirectory = parentDirectory.getParentFile();
263       if (parentsParentDirectory.isDirectory() && parentsParentDirectory.list().length == 0)
264         FileUtils.forceDelete(parentDirectory.getParentFile());
265       return true;
266     } catch (NotFoundException e) {
267       logger.info("Unable to delete non existing media package element {}@{}", mediaPackageElementID, mediaPackageID);
268       return false;
269     }
270   }
271 
272   /**
273    * {@inheritDoc}
274    *
275    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#get(java.lang.String, java.lang.String)
276    */
277   public InputStream get(String mediaPackageID, String mediaPackageElementID) throws NotFoundException, IOException {
278     File f = getFile(mediaPackageID, mediaPackageElementID);
279     logger.debug("Attempting to read file {}", f.getAbsolutePath());
280     return new FileInputStream(f);
281   }
282 
283   /**
284    * {@inheritDoc}
285    *
286    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#getCollectionURI(java.lang.String,
287    * java.lang.String)
288    */
289   @Override
290   public URI getCollectionURI(String collectionID, String fileName) {
291     try {
292       return new URI(getBaseUri() + COLLECTION_PATH_PREFIX + collectionID + "/" + toSafeName(fileName));
293     } catch (URISyntaxException e) {
294       throw new IllegalStateException("Unable to create valid uri from " + collectionID + " and " + fileName);
295     }
296   }
297 
298   /**
299    * {@inheritDoc}
300    *
301    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#getURI(java.lang.String, java.lang.String)
302    */
303   public URI getURI(String mediaPackageID, String mediaPackageElementID) {
304     return getURI(mediaPackageID, mediaPackageElementID, null);
305   }
306 
307   /**
308    * {@inheritDoc}
309    *
310    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#getURI(java.lang.String, java.lang.String,
311    * java.lang.String)
312    */
313   @Override
314   public URI getURI(String mediaPackageID, String mediaPackageElementID, String fileName) {
315     String uri = UrlSupport.concat(getBaseUri().toString(), MEDIAPACKAGE_PATH_PREFIX, mediaPackageID,
316         mediaPackageElementID);
317     if (fileName == null) {
318       File existingDirectory = getElementDirectory(mediaPackageID, mediaPackageElementID);
319       if (existingDirectory.isDirectory()) {
320         File[] files = existingDirectory.listFiles();
321         boolean md5Exists = false;
322         for (File f : files) {
323           if (f.getName().endsWith(MD5_EXTENSION)) {
324             md5Exists = true;
325           } else {
326             fileName = f.getName();
327           }
328         }
329         if (md5Exists && fileName != null) {
330           uri = UrlSupport.concat(uri, toSafeName(fileName));
331         }
332       }
333     } else {
334       uri = UrlSupport.concat(uri, toSafeName(fileName));
335     }
336     try {
337       return new URI(uri);
338     } catch (URISyntaxException e) {
339       throw new IllegalArgumentException(e);
340     }
341 
342   }
343 
344   /**
345    * {@inheritDoc}
346    *
347    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#put(java.lang.String, java.lang.String,
348    * java.lang.String, java.io.InputStream)
349    */
350   public URI put(String mediaPackageID, String mediaPackageElementID, String filename, InputStream in)
351           throws IOException {
352     checkPathSafe(mediaPackageID);
353     checkPathSafe(mediaPackageElementID);
354     File dir = getElementDirectory(mediaPackageID, mediaPackageElementID);
355 
356     File[] filesToDelete = null;
357 
358     if (dir.exists()) {
359       filesToDelete = dir.listFiles();
360     } else {
361       logger.debug("Attempting to create a new directory at {}", dir.getAbsolutePath());
362       FileUtils.forceMkdir(dir);
363     }
364 
365     // Destination files
366     File f = new File(dir, toSafeName(filename));
367     File md5File = getMd5File(f);
368 
369     // Temporary files while adding
370     File fTmp = null;
371     File md5FileTmp = null;
372 
373     if (f.exists()) {
374       logger.debug("Updating file {}", f.getAbsolutePath());
375     } else {
376       logger.debug("Adding file {}", f.getAbsolutePath());
377     }
378 
379     FileOutputStream out = null;
380     try {
381 
382       fTmp = File.createTempFile(f.getName(), ".tmp", dir);
383       md5FileTmp = File.createTempFile(md5File.getName(), ".tmp", dir);
384 
385       logger.trace("Writing to new temporary file {}", fTmp.getAbsolutePath());
386 
387       out = new FileOutputStream(fTmp);
388 
389       // Wrap the input stream and copy the input stream to the file
390       MessageDigest messageDigest = null;
391       DigestInputStream dis = null;
392       try {
393         messageDigest = MessageDigest.getInstance("MD5");
394         dis = new DigestInputStream(in, messageDigest);
395         IOUtils.copy(dis, out);
396       } catch (NoSuchAlgorithmException e1) {
397         logger.error("Unable to create md5 message digest");
398       }
399 
400       // Store the hash
401       String md5 = Checksum.convertToHex(dis.getMessageDigest().digest());
402       try {
403         FileUtils.writeStringToFile(md5FileTmp, md5);
404       } catch (IOException e) {
405         FileUtils.deleteQuietly(md5FileTmp);
406         throw e;
407       } finally {
408         IOUtils.closeQuietly(dis);
409       }
410 
411     } catch (IOException e) {
412       IOUtils.closeQuietly(out);
413       FileUtils.deleteQuietly(dir);
414       throw e;
415     } finally {
416       IOUtils.closeQuietly(out);
417       IOUtils.closeQuietly(in);
418     }
419 
420     // Rename temporary files to the final version atomically
421     try {
422       Files.move(md5FileTmp.toPath(), md5File.toPath(), StandardCopyOption.ATOMIC_MOVE);
423       Files.move(fTmp.toPath(), f.toPath(), StandardCopyOption.ATOMIC_MOVE);
424     } catch (AtomicMoveNotSupportedException e) {
425       logger.trace("Atomic move not supported by this filesystem: using replace instead");
426       Files.move(md5FileTmp.toPath(), md5File.toPath(), StandardCopyOption.REPLACE_EXISTING);
427       Files.move(fTmp.toPath(), f.toPath(), StandardCopyOption.REPLACE_EXISTING);
428     }
429 
430     // Clean up any other files
431     if (filesToDelete != null && filesToDelete.length > 0) {
432       for (File fileToDelete : filesToDelete) {
433         if (!fileToDelete.equals(f) && !fileToDelete.equals(md5File)
434             // On shared filesystems like NFS the move operation may create temporary .nfsXXX files
435             // which will be removed by the NFS subsystem itself. We should skip these files.
436             && !StringUtils.startsWith(fileToDelete.getName(), ".nfs")) {
437           logger.trace("delete {}", fileToDelete.getAbsolutePath());
438           if (!fileToDelete.delete() && fileToDelete.exists()) {
439             throw new IllegalStateException("Unable to delete file: " + fileToDelete.getAbsolutePath());
440           }
441         }
442       }
443     }
444 
445     return getURI(mediaPackageID, mediaPackageElementID, filename);
446   }
447 
448   /**
449    * Creates a file containing the md5 hash for the contents of a source file.
450    *
451    * @param f
452    *         the source file containing the data to hash
453    * @throws IOException
454    *         if the hash cannot be created
455    */
456   protected File createMd5(File f) throws IOException {
457     FileInputStream md5In = null;
458     File md5File = null;
459     try {
460       md5In = new FileInputStream(f);
461       String md5 = DigestUtils.md5Hex(md5In);
462       IOUtils.closeQuietly(md5In);
463       md5File = getMd5File(f);
464       FileUtils.writeStringToFile(md5File, md5);
465       return md5File;
466     } catch (IOException e) {
467       FileUtils.deleteQuietly(md5File);
468       throw e;
469     } finally {
470       IOUtils.closeQuietly(md5In);
471     }
472   }
473 
474   /**
475    * Gets the file handle for an md5 associated with a content file. Calling this method and obtaining a File handle is
476    * not a guarantee that the md5 file exists.
477    *
478    * @param f
479    *         The source file
480    * @return The md5 file
481    */
482   private File getMd5File(File f) {
483     return new File(f.getParent(), f.getName() + MD5_EXTENSION);
484   }
485 
486   /**
487    * Gets the file handle for a source file from its md5 file.
488    *
489    * @param md5File
490    *         The md5 file
491    * @return The source file
492    */
493   protected File getSourceFile(File md5File) {
494     return new File(md5File.getParent(), md5File.getName().substring(0, md5File.getName().length() - 4));
495   }
496 
497   protected void checkPathSafe(String id) {
498     if (id == null)
499       throw new NullPointerException("IDs can not be null");
500     if (id.indexOf("..") > -1 || id.indexOf(File.separator) > -1) {
501       throw new IllegalArgumentException("Invalid media package, element ID, or file name");
502     }
503   }
504 
505   /**
506    * Returns the file to the media package element.
507    *
508    * @param mediaPackageID
509    *         the media package identifier
510    * @param mediaPackageElementID
511    *         the media package element identifier
512    * @return the file or <code>null</code> if no such element exists
513    * @throws IllegalStateException
514    *         if more than one matching elements were found
515    * @throws NotFoundException
516    *         if the file cannot be found in the Working File Repository
517    */
518   protected File getFile(String mediaPackageID, String mediaPackageElementID) throws IllegalStateException,
519           NotFoundException {
520     checkPathSafe(mediaPackageID);
521     checkPathSafe(mediaPackageElementID);
522     File directory = getElementDirectory(mediaPackageID, mediaPackageElementID);
523 
524     File[] md5Files = directory.listFiles(MD5_FINAME_FILTER);
525     if (md5Files == null) {
526       logger.debug("Element directory {} does not exist", directory);
527       throw new NotFoundException("Element directory " + directory + " does not exist");
528     } else if (md5Files.length == 0) {
529       logger.debug("There are no complete files in the element directory {}", directory.getAbsolutePath());
530       throw new NotFoundException("There are no complete files in the element directory " + directory.getAbsolutePath());
531     } else if (md5Files.length == 1) {
532       File f = getSourceFile(md5Files[0]);
533       if (f.exists())
534         return f;
535       else
536         throw new NotFoundException("Unable to locate " + f + " in the working file repository");
537     } else {
538       logger.error("Integrity error: Element directory {} contains more than one element", mediaPackageID + "/"
539               + mediaPackageElementID);
540       throw new IllegalStateException("Directory " + mediaPackageID + "/" + mediaPackageElementID
541                                               + "does not contain exactly one element");
542     }
543   }
544 
545   /**
546    * Returns the file from the given collection.
547    *
548    * @param collectionId
549    *         the collection identifier
550    * @param fileName
551    *         the file name
552    * @return the file
553    * @throws NotFoundException
554    *         if either the collection or the file don't exist
555    */
556   public File getFileFromCollection(String collectionId, String fileName) throws NotFoundException,
557           IllegalArgumentException {
558     checkPathSafe(collectionId);
559 
560     File directory = null;
561     try {
562       directory = getCollectionDirectory(collectionId, false);
563       if (directory == null) {
564         //getCollectionDirectory returns null on a non-existant directory which is not being created...
565         directory = new File(PathSupport.concat(new String[] { rootDirectory, COLLECTION_PATH_PREFIX, collectionId }));
566         throw new NotFoundException(directory.getAbsolutePath());
567       }
568     } catch (IOException e) {
569       // can be ignored, since we don't want the directory to be created, so it will never happen
570     }
571     File sourceFile = new File(directory, toSafeName(fileName));
572     File md5File = getMd5File(sourceFile);
573     if (!sourceFile.exists())
574       throw new NotFoundException(sourceFile.getAbsolutePath());
575     if (!md5File.exists())
576       throw new NotFoundException(md5File.getAbsolutePath());
577     return sourceFile;
578   }
579 
580   private File getElementDirectory(String mediaPackageID, String mediaPackageElementID) {
581     return Paths.get(rootDirectory, MEDIAPACKAGE_PATH_PREFIX, mediaPackageID, mediaPackageElementID).toFile();
582   }
583 
584   /**
585    * Returns a <code>File</code> reference to collection. If the collection does not exist, the method either returns
586    * <code>null</code> or creates it, depending on the parameter <code>create</code>.
587    *
588    * @param collectionId
589    *         the collection identifier
590    * @param create
591    *         whether to create a collection directory if it does not exist
592    * @return the collection directory or <code>null</code> if it does not exist and should not be created
593    * @throws IOException
594    *         if creating a non-existing directory fails
595    */
596   private File getCollectionDirectory(String collectionId, boolean create) throws IOException {
597     File collectionDir = new File(
598             PathSupport.concat(new String[]{rootDirectory, COLLECTION_PATH_PREFIX, collectionId}));
599     if (!collectionDir.exists()) {
600       if (!create)
601         return null;
602       try {
603         FileUtils.forceMkdir(collectionDir);
604         logger.debug("Created collection directory " + collectionId);
605       } catch (IOException e) {
606         // We check again to see if it already exists because this collection dir may live on a shared disk.
607         // Synchronizing does not help because the other instance is not in the same JVM.
608         if (!collectionDir.exists()) {
609           throw new IllegalStateException("Can not create collection directory" + collectionDir);
610         }
611       }
612     }
613     return collectionDir;
614   }
615 
616   void createRootDirectory() throws IOException {
617     File f = new File(rootDirectory);
618     if (!f.exists())
619       FileUtils.forceMkdir(f);
620   }
621 
622   public long getCollectionSize(String id) throws NotFoundException {
623     File collectionDir = null;
624     try {
625       collectionDir = getCollectionDirectory(id, false);
626       if (collectionDir == null || !collectionDir.canRead())
627         throw new NotFoundException("Can not find collection " + id);
628     } catch (IOException e) {
629       // can be ignored, since we don't want the directory to be created, so it will never happen
630     }
631     File[] files = collectionDir.listFiles(MD5_FINAME_FILTER);
632     if (files == null)
633       throw new IllegalArgumentException("Collection " + id + " is not a directory");
634     return files.length;
635   }
636 
637   public InputStream getFromCollection(String collectionId, String fileName) throws NotFoundException, IOException {
638     File f = getFileFromCollection(collectionId, fileName);
639     if (f == null || !f.isFile()) {
640       throw new NotFoundException("Unable to locate " + f + " in the working file repository");
641     }
642     logger.debug("Attempting to read file {}", f.getAbsolutePath());
643     return new FileInputStream(f);
644   }
645 
646   /**
647    * {@inheritDoc}
648    *
649    * @throws IOException
650    *         if the hash can't be created
651    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#putInCollection(java.lang.String,
652    * java.lang.String, java.io.InputStream)
653    */
654   @Override
655   public URI putInCollection(String collectionId, String fileName, InputStream in) throws IOException {
656     checkPathSafe(collectionId);
657     checkPathSafe(fileName);
658     File f = Paths.get(rootDirectory, COLLECTION_PATH_PREFIX, collectionId, toSafeName(fileName)).toFile();
659     logger.debug("Attempting to write a file to {}", f.getAbsolutePath());
660     FileOutputStream out = null;
661     try {
662       if (!f.exists()) {
663         logger.debug("Attempting to create a new file at {}", f.getAbsolutePath());
664         File collectionDirectory = getCollectionDirectory(collectionId, true);
665         if (!collectionDirectory.exists()) {
666           logger.debug("Attempting to create a new directory at {}", collectionDirectory.getAbsolutePath());
667           FileUtils.forceMkdir(collectionDirectory);
668         }
669         f.createNewFile();
670       } else {
671         logger.debug("Attempting to overwrite the file at {}", f.getAbsolutePath());
672       }
673       out = new FileOutputStream(f);
674 
675       // Wrap the input stream and copy the input stream to the file
676       MessageDigest messageDigest = null;
677       DigestInputStream dis = null;
678       try {
679         messageDigest = MessageDigest.getInstance("MD5");
680         dis = new DigestInputStream(in, messageDigest);
681         IOUtils.copy(dis, out);
682       } catch (NoSuchAlgorithmException e1) {
683         logger.error("Unable to create md5 message digest");
684       }
685 
686       // Store the hash
687       String md5 = Checksum.convertToHex(dis.getMessageDigest().digest());
688       File md5File = null;
689       try {
690         md5File = getMd5File(f);
691         FileUtils.writeStringToFile(md5File, md5);
692       } catch (IOException e) {
693         FileUtils.deleteQuietly(md5File);
694         throw e;
695       } finally {
696         IOUtils.closeQuietly(dis);
697       }
698 
699     } catch (IOException e) {
700       FileUtils.deleteQuietly(f);
701       throw e;
702     } finally {
703       IOUtils.closeQuietly(out);
704       IOUtils.closeQuietly(in);
705     }
706     return getCollectionURI(collectionId, fileName);
707   }
708 
709   public URI copyTo(String fromCollection, String fromFileName, String toMediaPackage, String toMediaPackageElement,
710                     String toFileName) throws NotFoundException, IOException {
711     File source = getFileFromCollection(fromCollection, fromFileName);
712     if (source == null)
713       throw new IllegalArgumentException("Source file " + fromCollection + "/" + fromFileName + " does not exist");
714     File destDir = getElementDirectory(toMediaPackage, toMediaPackageElement);
715     if (!destDir.exists()) {
716       // we needed to create the directory, but couldn't
717       try {
718         FileUtils.forceMkdir(destDir);
719       } catch (IOException e) {
720         throw new IllegalStateException("could not create mediapackage/element directory '" + destDir.getAbsolutePath()
721                                                 + "' : " + e);
722       }
723     }
724     File destFile;
725     try {
726       destFile = new File(destDir, toSafeName(toFileName));
727       FileSupport.link(source, destFile);
728       createMd5(destFile);
729     } catch (Exception e) {
730       FileUtils.deleteDirectory(destDir);
731     }
732     return getURI(toMediaPackage, toMediaPackageElement, toFileName);
733   }
734 
735   /**
736    * {@inheritDoc}
737    *
738    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#moveTo(java.lang.String, java.lang.String,
739    * java.lang.String, java.lang.String, java.lang.String)
740    */
741   @Override
742   public URI moveTo(String fromCollection, String fromFileName, String toMediaPackage, String toMediaPackageElement,
743                     String toFileName) throws NotFoundException, IOException {
744     File source = getFileFromCollection(fromCollection, fromFileName);
745     File sourceMd5 = getMd5File(source);
746     File destDir = getElementDirectory(toMediaPackage, toMediaPackageElement);
747 
748     logger.debug("Moving {} from {} to {}/{}", new String[]{fromFileName, fromCollection, toMediaPackage,
749             toMediaPackageElement});
750     if (!destDir.exists()) {
751       // we needed to create the directory, but couldn't
752       try {
753         FileUtils.forceMkdir(destDir);
754       } catch (IOException e) {
755         throw new IllegalStateException("could not create mediapackage/element directory '" + destDir.getAbsolutePath()
756                                                 + "' : " + e);
757       }
758     }
759 
760     File dest = null;
761     try {
762       dest = getFile(toMediaPackage, toMediaPackageElement);
763       logger.debug("Removing existing file from target location at {}", dest);
764       delete(toMediaPackage, toMediaPackageElement);
765     } catch (NotFoundException e) {
766       dest = new File(getElementDirectory(toMediaPackage, toMediaPackageElement), toSafeName(toFileName));
767     }
768 
769     try {
770       FileUtils.moveFile(source, dest);
771       FileUtils.moveFile(sourceMd5, getMd5File(dest));
772     } catch (IOException e) {
773       FileUtils.deleteDirectory(destDir);
774       throw new IllegalStateException("unable to copy file" + e);
775     }
776     return getURI(toMediaPackage, toMediaPackageElement, dest.getName());
777   }
778 
779   /**
780    * {@inheritDoc}
781    *
782    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#deleteFromCollection(java.lang.String,
783    * java.lang.String,boolean)
784    */
785   @Override
786   public boolean deleteFromCollection(String collectionId, String fileName, boolean removeCollection) throws IOException {
787     File f = null;
788     try {
789       f = getFileFromCollection(collectionId, fileName);
790     } catch (NotFoundException e) {
791       logger.trace("File {}/{} does not exist", collectionId, fileName);
792       return false;
793     }
794     File md5File = getMd5File(f);
795 
796     if (!f.isFile())
797       throw new IllegalStateException(f + " is not a regular file");
798     if (!md5File.isFile())
799       throw new IllegalStateException(md5File + " is not a regular file");
800     if (!md5File.delete())
801       throw new IOException("MD5 hash " + md5File + " cannot be deleted");
802     if (!f.delete())
803       throw new IOException(f + " cannot be deleted");
804 
805     if (removeCollection) {
806       File parentDirectory = f.getParentFile();
807       if (parentDirectory.isDirectory() && parentDirectory.list().length == 0) {
808         logger.debug("Attempting to delete empty collection directory {}", parentDirectory.getAbsolutePath());
809         try {
810           FileUtils.forceDelete(parentDirectory);
811         } catch (IOException e) {
812           logger.warn("Unable to delete empty collection directory {}", parentDirectory.getAbsolutePath());
813           return false;
814         }
815       }
816     }
817     return true;
818   }
819 
820   /**
821    * {@inheritDoc}
822    *
823    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#deleteFromCollection(java.lang.String,
824    * java.lang.String)
825    */
826   @Override
827   public boolean deleteFromCollection(String collectionId, String fileName) throws IOException {
828     return deleteFromCollection(collectionId, fileName, false);
829   }
830 
831   /**
832    * {@inheritDoc}
833    *
834    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#getCollectionContents(java.lang.String)
835    */
836   @Override
837   public URI[] getCollectionContents(String collectionId) throws NotFoundException {
838     File collectionDir = null;
839     try {
840       collectionDir = getCollectionDirectory(collectionId, false);
841       if (collectionDir == null)
842         throw new NotFoundException(collectionId);
843     } catch (IOException e) {
844       // We are not asking for the collection to be created, so this exception is never thrown
845     }
846 
847     File[] files = collectionDir.listFiles(MD5_FINAME_FILTER);
848     URI[] uris = new URI[files.length];
849     for (int i = 0; i < files.length; i++) {
850       try {
851         uris[i] = new URI(getBaseUri() + COLLECTION_PATH_PREFIX + collectionId + "/"
852                                   + toSafeName(getSourceFile(files[i]).getName()));
853       } catch (URISyntaxException e) {
854         throw new IllegalStateException("Invalid URI for " + files[i]);
855       }
856     }
857 
858     return uris;
859   }
860 
861   /**
862    * Returns the md5 hash value for the given mediapackage element.
863    *
864    * @throws NotFoundException
865    *         if the media package element does not exist
866    */
867   String getMediaPackageElementDigest(String mediaPackageID, String mediaPackageElementID) throws IOException,
868           IllegalStateException, NotFoundException {
869     File f = getFile(mediaPackageID, mediaPackageElementID);
870     if (f == null)
871       throw new NotFoundException(mediaPackageID + "/" + mediaPackageElementID);
872     return getFileDigest(f);
873   }
874 
875   /**
876    * Returns the md5 of a file
877    *
878    * @param file
879    *         the source file
880    * @return the md5 hash
881    */
882   private String getFileDigest(File file) throws IOException {
883     if (file == null)
884       throw new IllegalArgumentException("File must not be null");
885     if (!file.exists() || !file.isFile())
886       throw new IllegalArgumentException("File " + file.getAbsolutePath() + " can not be read");
887 
888     // Check if there is a precalculated md5 hash
889     File md5HashFile = getMd5File(file);
890     if (file.exists()) {
891       logger.trace("Reading precalculated hash for {} from {}", file, md5HashFile.getName());
892       return FileUtils.readFileToString(md5HashFile, "utf-8");
893     }
894 
895     // Calculate the md5 hash
896     InputStream in = null;
897     String md5 = null;
898     try {
899       in = new FileInputStream(file);
900       md5 = DigestUtils.md5Hex(in);
901     } finally {
902       IOUtils.closeQuietly(in);
903     }
904 
905     // Write the md5 hash to disk for later reference
906     try {
907       FileUtils.writeStringToFile(md5HashFile, md5, "utf-8");
908     } catch (IOException e) {
909       logger.warn("Error storing cached md5 checksum at {}", md5HashFile);
910       throw e;
911     }
912 
913     return md5;
914   }
915 
916   /**
917    * {@inheritDoc}
918    *
919    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#getTotalSpace()
920    */
921   public Option<Long> getTotalSpace() {
922     File f = new File(rootDirectory);
923     return Option.some(f.getTotalSpace());
924   }
925 
926   /**
927    * {@inheritDoc}
928    *
929    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#getUsableSpace()
930    */
931   public Option<Long> getUsableSpace() {
932     File f = new File(rootDirectory);
933     return Option.some(f.getUsableSpace());
934   }
935 
936   /**
937    * {@inheritDoc}
938    *
939    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#getUsedSpace()
940    */
941   @Override
942   public Option<Long> getUsedSpace() {
943     return Option.some(FileUtils.sizeOfDirectory(new File(rootDirectory)));
944   }
945 
946   /**
947    * {@inheritDoc}
948    *
949    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#getDiskSpace()
950    */
951   public String getDiskSpace() {
952     int usable = Math.round(getUsableSpace().get() / 1024 / 1024 / 1024);
953     int total = Math.round(getTotalSpace().get() / 1024 / 1024 / 1024);
954     long percent = Math.round(100.0 * getUsableSpace().get() / (1 + getTotalSpace().get()));
955     return "Usable space " + usable + " Gb out of " + total + " Gb (" + percent + "%)";
956   }
957 
958   /**
959    * {@inheritDoc}
960    *
961    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#cleanupOldFilesFromCollection
962    */
963   @Override
964   public boolean cleanupOldFilesFromCollection(String collectionId, long days) throws IOException {
965     File colDir = getCollectionDirectory(collectionId, false);
966     // Collection doesn't exist?
967     if (colDir == null) {
968       logger.trace("Collection {} does not exist", collectionId);
969       return false;
970     }
971 
972     logger.info("Cleaning up files older than {} days from collection {}", days, collectionId);
973 
974     if (!colDir.isDirectory())
975       throw new IllegalStateException(colDir + " is not a directory");
976 
977     long referenceTime = System.currentTimeMillis() - days * 24 * 3600 * 1000;
978     for (File f : colDir.listFiles()) {
979       long lastModified = f.lastModified();
980       logger.trace("{} last modified: {}, reference date: {}",
981               f.getName(), new Date(lastModified), new Date(referenceTime));
982       if (lastModified <= referenceTime) {
983         // Delete file
984         deleteFromCollection(collectionId, f.getName());
985         logger.info("Cleaned up file {} from collection {}", f.getName(), collectionId);
986       }
987     }
988 
989     return true;
990   }
991 
992   @Override
993   public boolean cleanupOldFilesFromMediaPackage(long days) throws IOException {
994     return RecursiveDirectoryCleaner.cleanDirectory(
995             Paths.get(rootDirectory, MEDIAPACKAGE_PATH_PREFIX),
996             Duration.ofDays(days));
997   }
998 
999   /**
1000    * {@inheritDoc}
1001    *
1002    * @see org.opencastproject.workingfilerepository.api.PathMappable#getPathPrefix()
1003    */
1004   @Override
1005   public String getPathPrefix() {
1006     return rootDirectory;
1007   }
1008 
1009   /**
1010    * {@inheritDoc}
1011    *
1012    * @see org.opencastproject.workingfilerepository.api.PathMappable#getUrlPrefix()
1013    */
1014   @Override
1015   public String getUrlPrefix() {
1016     return getBaseUri().toString();
1017   }
1018 
1019   /**
1020    * {@inheritDoc}
1021    *
1022    * @see org.opencastproject.workingfilerepository.api.WorkingFileRepository#getBaseUri()
1023    */
1024   @Override
1025   public URI getBaseUri() {
1026     if (securityService.getOrganization() != null) {
1027       Map<String, String> orgProps = securityService.getOrganization().getProperties();
1028       if (orgProps != null && orgProps.containsKey(OpencastConstants.WFR_URL_ORG_PROPERTY)) {
1029         try {
1030           return new URI(UrlSupport.concat(orgProps.get(OpencastConstants.WFR_URL_ORG_PROPERTY), servicePath));
1031         } catch (URISyntaxException ex) {
1032           logger.warn("Organization working file repository URL not set, fallback to server URL");
1033         }
1034       }
1035     }
1036 
1037     return URI.create(UrlSupport.concat(serverUrl, servicePath));
1038   }
1039 
1040   /**
1041    * Sets the remote service manager.
1042    *
1043    * @param remoteServiceManager
1044    */
1045   public void setRemoteServiceManager(ServiceRegistry remoteServiceManager) {
1046     this.remoteServiceManager = remoteServiceManager;
1047   }
1048 
1049   public void setSecurityService(SecurityService securityService) {
1050     this.securityService = securityService;
1051   }
1052 
1053 }