/*
* Copyright (C) 2012-2014 Glencoe Software, Inc. All rights reserved.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
package ome.services.blitz.repo;
import java.io.File;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import javax.activation.MimetypesFileTypeMap;
import loci.formats.FormatException;
import loci.formats.ReaderWrapper;
import org.apache.commons.io.FileUtils;
import com.google.common.collect.ImmutableSet;
import ome.io.nio.FileBuffer;
import ome.services.blitz.repo.path.FsFile;
import ome.services.blitz.repo.path.ServerFilePathTransformer;
import ome.services.blitz.util.ChecksumAlgorithmMapper;
import ome.util.checksum.ChecksumProvider;
import ome.util.checksum.ChecksumProviderFactory;
import ome.util.checksum.ChecksumType;
import omero.ValidationException;
import omero.model.ChecksumAlgorithm;
/**
* To prevent frequently re-calculating paths and re-creating File objects,
* {@link CheckedPath} objects store various interpretations of paths that
* are passed in by users. One of these objects should be created at the
* very beginning of any {@link PublicRepositoryI} remote method (i.e. those
* public methods which take {@link Ice.Current} instance arguments. Methods
* are then available to check various capabilities by the current user. When
* a null {@link CheckedPath} object is passed into the constructor the caller
* indicates that the path is the root path, hence {@link CheckedPath#isRoot}
* will not be called.
*
* @author josh at glencoesoftware.com
* @author m.t.b.carroll@dundee.ac.uk
*/
public class CheckedPath {
private static final String SAME_DIR = ".";
private static final String PARENT_DIR = "..";
private static final ImmutableSet<String> SPECIAL_DIRS = ImmutableSet.of(SAME_DIR, PARENT_DIR);
public final FsFile fsFile;
public /*final*/ boolean isRoot;
private final File file;
private /*final*/ String parentDir;
private /*final*/ String baseName;
private final String original; // for error reporting
private final ChecksumProvider checksumProvider;
// HIGH-OVERHEAD FIELDS (non-final)
protected Long id;
protected String hash;
protected String mime;
/**
* Adjust an FsFile to remove "." components and to remove ".." components with the previous component.
*
* @param fsFile a file path
* @return a file path with "." and ".." processed away
* @throws ValidationException if ".." components rise above root
*/
// TODO: May not actually be necessary.
private FsFile processSpecialDirectories(FsFile fsFile) throws ValidationException {
final List<String> oldComponents = fsFile.getComponents();
final Deque<String> newComponents = new ArrayDeque<String>(oldComponents.size());
for (final String oldComponent : oldComponents) {
if (PARENT_DIR.equals(oldComponent)) {
if (newComponents.isEmpty()) {
throw new ValidationException(null, null, "Path may not make references above root");
} else {
newComponents.removeLast();
}
}
else if (!SAME_DIR.equals(oldComponent)) {
newComponents.addLast(oldComponent);
}
}
return new FsFile(newComponents);
}
/**
* Construct a CheckedPath from a relative "/"-delimited path rooted at the repository.
* The path may not contain weird or special-meaning path components,
* though <q>.</q> and <q>..</q> are understood to have their usual meaning.
* An empty path is the repository root.
* @param serverPaths the server path handling service
* @param path a repository path
* @param checksumProviderFactory a source of checksum providers,
* may be <code>null</code> if <code>checksumAlgorithm</code> is also <code>null</code>
* @param checksumAlgorithm the algorithm to use in {@link #hash()}'s calculations
* @throws ValidationException if the path is empty or contains illegal components
*/
public CheckedPath(ServerFilePathTransformer serverPaths, String path,
ChecksumProviderFactory checksumProviderFactory, ChecksumAlgorithm checksumAlgorithm)
throws ValidationException {
this.original = path;
if (checksumAlgorithm == null) {
this.checksumProvider = null;
} else {
final ChecksumType checksumType = ChecksumAlgorithmMapper.getChecksumType(checksumAlgorithm);
if (checksumType == null) {
throw new ValidationException(null, null,
"unknown checksum algorithm: " + checksumAlgorithm.getValue().getValue());
}
this.checksumProvider = checksumProviderFactory.getProvider(checksumType);
}
this.fsFile = processSpecialDirectories(new FsFile(path));
if (!serverPaths.isLegalFsFile(fsFile)) // unsanitary
throw new ValidationException(null, null, "Path contains illegal components");
this.file = serverPaths.getServerFileFromFsFile(fsFile);
breakPath();
}
private CheckedPath(File filePath, FsFile fsFilePath) throws ValidationException {
this.original = filePath.getPath();
this.fsFile = fsFilePath;
this.file = filePath;
this.checksumProvider = null;
breakPath();
}
/**
* Set parentDir and baseName according to the last separator in the fsFile.
* @throws ValidationException if the path is empty
*/
private void breakPath() throws ValidationException {
final String fullPath = fsFile.toString();
this.isRoot = "".equals(fullPath);
final int lastSeparator = fullPath.lastIndexOf(FsFile.separatorChar);
if (lastSeparator < 0) {
this.parentDir = "";
this.baseName = fullPath;
} else {
this.parentDir = fullPath.substring(0, lastSeparator);
this.baseName = fullPath.substring(lastSeparator + 1);
}
}
//
// Public methods (mutable state)
//
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String hash() {
if (hash == null && this.checksumProvider != null) {
hash = this.checksumProvider
.putFile(file.getPath())
.checksumAsString();
}
return hash;
}
/**
* Get the mimetype for a file.
*
* @return A String representing the mimetype.
*/
public String getMimetype() {
if (mime == null) {
mime = new MimetypesFileTypeMap().getContentType(file);
}
return mime;
}
//
// Public methods (immutable state)
//
/**
* Returns a new {@link CheckedPath} using {@link File#getParent()} and
* passing in all other values. Just as if calling the constructor,
* bad paths will cause a {@link ValidationException} to be thrown.
* {@link CheckedPath}s generated with this method always return a
* <code>null</code> hash.
*/
public CheckedPath parent() throws ValidationException {
List<String> components = this.fsFile.getComponents();
if (components.isEmpty())
throw new ValidationException(null, null, "May not obtain parent of repository root");
components = components.subList(0, components.size() - 1);
return new CheckedPath(this.file.getParentFile(), new FsFile(components));
}
/**
* Returns a new {@link CheckedPath} that has the given path appended
* to the end of this instances path. A check is made that the name does
* not contain "/" (i.e. subpaths) nor that it is ".." or ".".
* {@link CheckedPath}s generated with this method always return a
* <code>null</code> hash.
*
* @param name
* @return See above.
*/
public CheckedPath child(String name) throws ValidationException {
if (name == null || "".equals(name)) {
throw new ValidationException(null, null, "null or empty name");
} else if (SPECIAL_DIRS.contains(name)) {
final StringBuffer message = new StringBuffer();
message.append("Only proper child name is allowed, not ");
for (final String dir : SPECIAL_DIRS) {
message.append('\'');
message.append(dir);
message.append('\'');
message.append(", ");
}
message.setLength(message.length() - 2); // remove trailing ", "
message.append('.');
throw new ValidationException(null, null, message.toString());
} else if (name.indexOf(FsFile.separatorChar)>=0) {
throw new ValidationException(null, null,
"No subpaths allowed. Path contains '" + FsFile.separatorChar + "'");
}
final FsFile fullChild = FsFile.concatenate(this.fsFile, new FsFile(name));
return new CheckedPath(new File(original, name), fullChild);
}
/**
* Check if this file actually exists on the underlying filesystem.
* Analogous to {@link java.io.File#exists()}.
* @return <code>true</code> if the file exists, <code>false</code> otherwise
*/
public boolean exists() {
return this.file.exists();
}
/**
* Checks for existence of the original path, throwing an exception if
* not present.
*
* @return this instance for chaining.
* @throws ValidationException
*/
public CheckedPath mustExist() throws ValidationException {
if (!exists()) {
throw new ValidationException(null, null, original
+ " does not exist");
}
return this;
}
boolean renameTo(CheckedPath target) {
return file.renameTo(target.file);
}
void moveToDir(CheckedPath target, boolean createDestDir) throws IOException {
FileUtils.moveToDirectory(file, target.file, createDestDir);
}
boolean delete() {
return FileUtils.deleteQuietly(file);
}
/**
* @return this instance for chaining
* @throws omero.SecurityViolation
*/
public CheckedPath mustEdit() throws omero.SecurityViolation {
if (!canEdit()) {
throw new omero.SecurityViolation(null, null,
original + " is not editable.");
}
return this;
}
/**
* Check if this file is actually readable on the underlying filesystem.
* Analogous to {@link java.io.File#canRead()}.
* @return <code>true</code> if the file is readable, <code>false</code> otherwise
*/
public boolean canRead() {
return this.file.canRead();
}
public boolean canEdit() {
return true;
}
public boolean isDirectory() {
return this.file.isDirectory();
}
/**
* Assuming this is a directory, return relative path plus name with a final
* slash.
*/
protected String getDirname() {
return this.fsFile.toString() + FsFile.separatorChar;
}
/**
* Get the last component of this path, the entity to which the path corresponds.
* If this entity {@link #isRoot} then this is the empty string.
* @return the last path component
*/
protected String getName() {
return this.baseName;
}
/**
* Get the parent path of the entity to which this path corresponds.
* If this entity is not in some sub-directory below root,
* then this relative path is just the {@link FsFile#separatorChar}.
* @return the path components above the last,
* with separators including a trailing {@link FsFile#separatorChar}.
*/
protected String getRelativePath() {
return this.parentDir + FsFile.separatorChar;
}
/**
* The full path of the entity to which this path corresponds.
* Path components are separated by {@link FsFile#separatorChar}.
* @return the full path
*/
protected String getFullFsPath() {
return this.fsFile.toString();
}
/**
* Get a {@link FileBuffer} corresponding to this instance.
* It is the caller's responsibility to {@link FileBuffer#close()} it.
* @param mode as for {@link java.io.RandomAccessFile#RandomAccessFile(File, String)},
* <code>"r"</code> and <code>"rw"</code> being common choices
* @return a new {@link FileBuffer}
*/
public FileBuffer getFileBuffer(String mode) {
return new FileBuffer(this.file.getPath(), mode);
}
/**
* Return the size of this file on the underlying filesystem.
* Analogous to {@link java.io.File#length()}.
* @return the file size
*/
public long size() {
return this.file.length();
}
/**
* Create this directory on the underlying filesystem.
* Analogous to {@link java.io.File#mkdir()}.
* @return <code>true</code> if the directory was created, <code>false</code> otherwise
*/
public boolean mkdir() {
return this.file.mkdir();
}
/**
* Create this directory, and parents if necessary, on the underlying filesystem.
* Analogous to {@link java.io.File#mkdirs()}.
* @return <code>true</code> if the directory was created, <code>false</code> otherwise
*/
public boolean mkdirs() {
return this.file.mkdirs();
}
/**
* Mark this existing file as having been modified at the present moment.
* @return <code>true</code> if the file's modification time was updated, <code>false</code> otherwise
*/
public boolean markModified() {
return this.file.setLastModified(System.currentTimeMillis());
}
/**
* Perform BioFormats {@link ReaderWrapper#setId(String)} for this file.
* @param reader the BioFormats reader upon which to operate
* @throws FormatException passed up from {@link ReaderWrapper#setId(String)}
* @throws IOException passed up from {@link ReaderWrapper#setId(String)}
*/
public void bfSetId(ReaderWrapper reader) throws FormatException, IOException {
reader.setId(file.getPath());
}
public String toString() {
return getClass().getSimpleName() + '(' + this.fsFile + ')';
}
/**
* Creates an {@link ome.model.core.OriginalFile} instance for the given
* {@link CheckedPath} even if it doesn't exist. If it does exist, then
* the size and hash will be properly set. Further, if it's a directory,
* the mimetype passed in by the user must either be null, in which case
* "Directory" will be used, or must be that correct value.
*
* @param mimetype The mimetype to handle.
* @return See above.
*/
public ome.model.core.OriginalFile asOriginalFile(String mimetype) {
ome.model.core.OriginalFile ofile =
new ome.model.core.OriginalFile();
// only non-conditional properties
ofile.setName(getName());
ofile.setMimetype(mimetype); // null takes DB default
ofile.setPath(getRelativePath());
final boolean mimeDir = PublicRepositoryI.DIRECTORY_MIMETYPE.equals(mimetype);
final boolean actualDir = file.isDirectory();
if (file.exists()) {
ofile.setMtime(new Timestamp(file.lastModified()));
if (actualDir) {
// TODO: model directories as a subclass?
ofile.setMimetype(PublicRepositoryI.DIRECTORY_MIMETYPE);
if (mimetype != null && !mimeDir) {
// This is a directory, but the user has requested something
// else. Throw.
if (actualDir && !mimeDir) {
throw new ome.conditions.ValidationException(
"File is a directory but mimetype is: " + mimetype);
}
}
} else {
ofile.setHash(hash());
ofile.setSize(file.length());
}
}
// TODO atime/ctime??
return ofile;
}
/**
* {@inheritDoc}
* Instances are equal if their string representations match.
* On Windows systems the comparison is not case-sensitive.
*/
@Override
public boolean equals(Object object) {
if (this == object)
return true;
if (!(object instanceof CheckedPath))
return false;
return this.file.equals(((CheckedPath) object).file);
}
/**
* {@inheritDoc}
* On Windows systems the calculation is not case-sensitive.
*/
@Override
public int hashCode() {
return this.file.hashCode() * 98;
}
}