package service.filestore; import jackrabbit.AorraAccessManager; import java.io.InputStream; import java.security.AccessControlException; import java.security.Principal; import java.util.Calendar; import java.util.Collections; import java.util.Comparator; import java.util.Deque; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import javax.jcr.AccessDeniedException; import javax.jcr.ItemExistsException; import javax.jcr.RepositoryException; import javax.jcr.Session; import javax.jcr.lock.LockException; import javax.jcr.nodetype.ConstraintViolationException; import javax.jcr.version.VersionException; import models.GroupManager; import models.User; import models.UserDAO; import models.filestore.Child; import models.filestore.FileDAO; import models.filestore.FolderDAO; import org.apache.commons.lang.NotImplementedException; import org.apache.commons.lang.StringUtils; import org.apache.commons.lang.builder.CompareToBuilder; import org.apache.jackrabbit.api.security.user.Group; import org.apache.jackrabbit.core.security.principal.EveryonePrincipal; import org.apache.jackrabbit.spi.Path; import org.apache.jackrabbit.spi.commons.conversion.IdentifierResolver; import org.apache.jackrabbit.spi.commons.conversion.MalformedPathException; import org.jcrom.Jcrom; import org.jcrom.dao.JcrDAO; import org.jcrom.util.NodeFilter; import org.jcrom.util.PathUtils; import play.Logger; import play.libs.F.Function; import service.EventManager; import service.EventManager.Event; import service.JcrSessionFactory; import service.filestore.roles.Admin; import com.google.common.base.Joiner; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSortedSet; import com.google.common.collect.Iterables; import com.google.common.collect.Sets; import com.google.inject.Inject; import com.google.inject.Singleton; @Singleton public class FileStoreImpl implements FileStore { public static final String FILE_STORE_PATH = "/filestore"; public static final String FILE_STORE_NODE_NAME = StringUtils.stripStart(FILE_STORE_PATH, "/"); public static final String FILE_STORE_NAME = "AORRA"; private final EventManager eventManager; private final Jcrom jcrom; @Inject public FileStoreImpl( final JcrSessionFactory sessionFactory, final Jcrom jcrom, final EventManager eventManager) { this.jcrom = jcrom; this.eventManager = eventManager; Logger.debug(this+" - Creating file store."); sessionFactory.inSession(new Function<Session, Session>() { @Override public Session apply(Session session) { try { ensureRootExists(session); return session; } catch (RepositoryException e) { throw new RuntimeException(e); } } private void ensureRootExists(Session session) throws RepositoryException { final FolderDAO dao = new FolderDAO(session, jcrom); if (dao.get(FILE_STORE_PATH) == null) { final models.filestore.Folder entity = new models.filestore.Folder(null, FILE_STORE_NODE_NAME); final String path = FILE_STORE_PATH.replaceFirst(FILE_STORE_NODE_NAME+"$", ""); Logger.debug("Creating new filestore root at "+path); dao.create(path, entity); ((FileStoreImpl.Folder) getManager(session).getRoot()) .resetPermissions(); } } }); } /* (non-Javadoc) * @see service.filestore.FileStore#getManager(javax.jcr.Session) */ @Override public Manager getManager(final Session session) { return new Manager(session, jcrom, eventManager); } /* (non-Javadoc) * @see service.filestore.FileStore#getEventManager() */ @Override public EventManager getEventManager() { return eventManager; } public static class Manager implements FileStore.Manager { private final Session session; private final Jcrom jcrom; private final EventManager eventManagerImpl; private final FileDAO fileDAO; private final FolderDAO folderDAO; private final UserDAO userDAO; private final Map<String, User> userCache = new HashMap<String, User>(); private final Cache<models.filestore.File, FileStore.File> fileCache = CacheBuilder.newBuilder().build(); private final Cache<models.filestore.Folder, FileStore.Folder> folderCache = CacheBuilder.newBuilder().build(); private FileStore.Folder rootFolder; protected Manager(final Session session, final Jcrom jcrom, final EventManager eventManagerImpl) { this.session = session; this.jcrom = jcrom; this.eventManagerImpl = eventManagerImpl; fileDAO = new FileDAO(session, jcrom); folderDAO = new FolderDAO(session, jcrom); userDAO = new UserDAO(session, jcrom); } protected FileStore.Folder wrap( final models.filestore.Folder entity, final FileStore.Folder parent) { final FileStoreImpl.Manager fm = this; try { return folderCache.get(entity, new Callable<FileStore.Folder>() { @Override public FileStore.Folder call() throws RepositoryException { return new FileStoreImpl.Folder(entity, parent, fm, eventManagerImpl); } }); } catch (ExecutionException e) { throw new RuntimeException(e.getCause()); } } protected FileStore.File wrap( final models.filestore.File entity, final FileStore.Folder parent) { final FileStoreImpl.Manager fm = this; try { return fileCache.get(entity, new Callable<FileStore.File>() { @Override public FileStore.File call() throws RepositoryException { return new FileStoreImpl.File(entity, parent, fm, eventManagerImpl); } }); } catch (ExecutionException e) { throw new RuntimeException(e.getCause()); } } protected models.filestore.Folder reload( final models.filestore.Folder entity) { // Get existing value final FileStore.Folder f = folderCache.getIfPresent(entity); // Get new key final models.filestore.Folder newEntity = getFolderDAO().loadById(entity.getId()); folderCache.invalidate(entity); folderCache.put(newEntity, f); return newEntity; } protected models.filestore.File reload(final models.filestore.File entity) { // Get existing value final FileStore.File f = fileCache.getIfPresent(entity); // Get new key final models.filestore.File newEntity = getFileDAO().loadById(entity.getId()); fileCache.invalidate(entity); fileCache.put(newEntity, f); return newEntity; } @Override public FileOrFolder getByIdentifier(final String id) throws RepositoryException { try { final String absPath = NodeWrapper.getPath(session, id); if (absPath == null) return null; return getFileOrFolder(absPath); } catch (IllegalArgumentException e) { return null; } } @Override public FileOrFolder getFileOrFolder(final String absPath) throws RepositoryException { // We want a new copy getRoot().reload(); if (absPath.equals("/")) return getRoot(); final Deque<String> parts = new LinkedList<String>(); for (String part : PathUtils.relativePath(absPath).split("/+")) { parts.add(part); } FileStore.Folder folder = getRoot(); while (!parts.isEmpty()) { String part = parts.removeFirst(); final FileOrFolder fof = folder.getFileOrFolder(part); if (fof == null) { Logger.debug("Unable to find file or folder at: "+absPath); return null; } if (parts.isEmpty()) return fof; if (fof instanceof File) { Logger.debug("File "+part+" is not a folder in "+absPath); return null; } folder = (service.filestore.FileStore.Folder) fof; } return null; } @Override public Set<FileStore.Folder> getFolders() throws RepositoryException { try { return getFolders(getRoot()); } catch (NullPointerException e) { // Root probably doesn't exist return Collections.emptySet(); } } private Set<FileStore.Folder> getFolders(final FileStore.Folder rootFolder) throws RepositoryException { if (rootFolder.getAccessLevel() != Permission.NONE) { return ImmutableSet.<FileStore.Folder>of(rootFolder); } final ImmutableSet.Builder<FileStore.Folder> b = ImmutableSet.builder(); for (FileStore.Folder folder : rootFolder.getFolders()) { b.addAll(getFolders(folder)); } return b.build(); } @Override public FileStore.Folder getRoot() throws RepositoryException { if (rootFolder == null) { rootFolder = wrap(getFolderDAO().get(FILE_STORE_PATH), null); } return rootFolder; } public Session getSession() { return session; } public Jcrom getJcrom() { return jcrom; } public FileDAO getFileDAO() { return fileDAO; } public FolderDAO getFolderDAO() { return folderDAO; } public User getUserFromJackrabbitID(final String jackrabbitUserId) { if (!userCache.containsKey(jackrabbitUserId)) { userCache.put(jackrabbitUserId, getUserDAO().findByJackrabbitID(jackrabbitUserId)); Logger.debug("Adding "+jackrabbitUserId+" to user ID cache: "+ userCache.get(jackrabbitUserId)); } return userCache.get(jackrabbitUserId); } protected UserDAO getUserDAO() { return userDAO; } public String userId() { return session.getUserID(); } } public static class Folder extends NodeWrapper<models.filestore.Folder> implements FileStore.Folder { private Set<FileStore.Folder> folders = null; private Set<FileStore.File> files = null; protected Folder(models.filestore.Folder entity, FileStore.Folder parent, Manager filestoreManager, EventManager eventManagerImpl) throws RepositoryException { super(entity, parent, filestoreManager, eventManagerImpl); } @Override public void reload() { entity = filestoreManager.reload(entity); // Clear local collection caches folders = null; files = null; } @Override public FileStore.Folder createFolder(final String name) throws RepositoryException { ensureDoesNotExist(name); final models.filestore.Folder newFolderEntity = getDAO().create( new models.filestore.Folder(entity, name)); final FileStore.Folder folder = filestoreManager.wrap(newFolderEntity, this); getMutableFolderSet().add(folder); eventManagerImpl.tell(Events.create(folder, filestoreManager.userId())); return folder; } @Override public FileStore.File createFile(final String name, final String mime, final InputStream data) throws RepositoryException { ensureDoesNotExist(name); final models.filestore.File newFileEntity = filestoreManager.getFileDAO().create( new models.filestore.File(entity, name, mime, data)); final FileStore.File file = filestoreManager.wrap(newFileEntity, this); getMutableFileSet().add(file); Logger.debug("New file, version "+newFileEntity.getVersion()); eventManagerImpl.tell(Events.create(file, filestoreManager.userId())); return file; } protected void ensureDoesNotExist(final String name) throws RepositoryException { reload(); final FileOrFolder fof = getFileOrFolder(name); if (fof != null) throw new ItemExistsException(String.format( "Can't create file '%s'. %s with same name already exists.", name, fof.getClass().getSimpleName())); } @Override public Set<FileStore.Folder> getFolders() throws RepositoryException { return ImmutableSet.copyOf(getMutableFolderSet()); } public Set<FileStore.Folder> getMutableFolderSet() throws RepositoryException { if (folders == null) { final Set<FileStore.Folder> set = Sets.newHashSet(); for (final Object child : entity.getFolders().values()) { set.add(filestoreManager.wrap((models.filestore.Folder) child, this)); } folders = set; } return folders; } @Override public FileOrFolder getFileOrFolder(final String name) throws RepositoryException { for (FileStore.Folder folder : getFolders()) { if (folder.getName().equals(name)) { return folder; } } for (FileStore.File file : getFiles()) { if (file.getName().equals(name)) { return file; } } return null; } @Override public Set<FileStore.File> getFiles() throws RepositoryException { return ImmutableSet.copyOf(getMutableFileSet()); } protected Set<FileStore.File> getMutableFileSet() throws RepositoryException { if (files == null) { final Set<FileStore.File> set = Sets.newLinkedHashSet(); for (final Object child : entity.getFiles().values()) { set.add(filestoreManager.wrap((models.filestore.File) child, this)); } files = set; } return files; } public void resetPermissions() throws RepositoryException { final Group group = Admin.getInstance(session()).getGroup(); final AorraAccessManager acm = (AorraAccessManager)session().getAccessControlManager(); final Principal everyone = EveryonePrincipal.getInstance(); acm.grant(everyone, FILE_STORE_PATH, jackrabbit.Permission.NONE); acm.grant(group.getPrincipal(), FILE_STORE_PATH, jackrabbit.Permission.RW); } @Override public void rename(final String newName) throws ItemExistsException, RepositoryException { super.rename(newName); eventManagerImpl.tell(Events.update(this, filestoreManager.userId())); } @Override public void delete() throws AccessDeniedException, VersionException, LockException, ConstraintViolationException, RepositoryException { final Event event = Events.delete(this, filestoreManager.userId()); getDAO().remove(rawPath()); eventManagerImpl.tell(event); } @Override public String getIdentifier() { return entity.getId(); } @Override protected String rawPath() { return entity.getPath(); } @Override public void grantAccess(String groupName, Permission permission) throws RepositoryException { final AorraAccessManager aam = (AorraAccessManager) session().getAccessControlManager(); final Group group = (new GroupManager(session())).find(groupName); aam.grant(group.getPrincipal(), this.rawPath(), permission.toJackrabbitPermission()); } @Override public void revokeAccess(final String groupName) throws RepositoryException { final AorraAccessManager aam = (AorraAccessManager) session().getAccessControlManager(); aam.revoke(session().getWorkspace().getName(), groupName, this.getIdentifier()); } @Override public Permission getAccessLevel() throws RepositoryException { final Permission onEntity = super.getAccessLevel(); if (onEntity == Permission.RO) { // Exclude permissions based purely on ancestry by accessing child node // which should exist, but won't appear in those cases. if (!session().itemExists(rawPath()+"/files")) { return Permission.NONE; } } return onEntity; } @Override public JcrDAO<models.filestore.Folder> getDAO() { return filestoreManager.getFolderDAO(); } @Override public void move(service.filestore.FileStore.Folder destination) throws ItemExistsException, RepositoryException { FileStore.Folder formerParent = this.getParent(); move(destination, "folders"); eventManagerImpl.tell(Events.move(this, formerParent, destination, filestoreManager.userId())); } } public static class File extends NodeWrapper<models.filestore.File> implements FileStore.File { private boolean hasRetrievedData; protected File(models.filestore.File entity, FileStore.Folder parent, Manager filestoreManager, EventManager eventManagerImpl) throws RepositoryException { super(entity, parent, filestoreManager, eventManagerImpl); this.hasRetrievedData = false; } @Override protected void reload() { entity = filestoreManager.reload(entity); } @Override public File update(final String mime, final InputStream data) throws RepositoryException { entity.setMimeType(mime); entity.setData(data); getDAO().update(entity); eventManagerImpl.tell(Events.update(this, filestoreManager.userId())); return this; } @Override public InputStream getData() { // Unsafe to use the same underlying entity twice when getting an // input stream, so load again if necessary. final models.filestore.File f = this.hasRetrievedData ? getDAO().get(rawPath()) : entity; this.hasRetrievedData = true; return f.getDataProvider().getInputStream(); } @Override public String getMimeType() { return entity.getMimeType(); } @Override public String getDigest() { return entity.getDigest(); } @Override public boolean equals(final Object other) { if (other == null) return false; if (other instanceof File) { return ((File) other).getIdentifier().equals(getIdentifier()); } return false; } @Override public void rename(final String newName) throws ItemExistsException, RepositoryException { super.rename(newName); eventManagerImpl.tell(Events.update(this, filestoreManager.userId())); } @Override public void delete() throws AccessDeniedException, VersionException, LockException, ConstraintViolationException, RepositoryException { final Event event = Events.delete(this, filestoreManager.userId()); getDAO().remove(rawPath()); eventManagerImpl.tell(event); } @Override public String getIdentifier() { return entity.getId(); } @Override public SortedSet<service.filestore.FileStore.File> getVersions() throws RepositoryException { final Comparator<FileStore.File> c = new Comparator<FileStore.File>() { @Override public int compare(service.filestore.FileStore.File o1, service.filestore.FileStore.File o2) { return new CompareToBuilder() .append(o1.getModificationTime(), o2.getModificationTime()) .append(o1.getName(), o2.getName()) .toComparison(); } }; final ImmutableSortedSet.Builder<FileStore.File> b = ImmutableSortedSet.<FileStore.File>orderedBy(c); final List<models.filestore.File> versions = getDAO().getVersionListById(entity.getId()); models.filestore.File lastVersion = null; for (models.filestore.File version : versions) { if (lastVersion == null || !version.getDigest().equals(lastVersion.getDigest())) { b.add(new FileVersion(this, version, filestoreManager, eventManagerImpl)); } lastVersion = version; } return b.build(); } @Override public User getAuthor() { return filestoreManager.getUserFromJackrabbitID( entity.getLastModifiedBy()); } @Override public Calendar getModificationTime() { return entity.getLastModified(); } @Override public JcrDAO<models.filestore.File> getDAO() { return filestoreManager.getFileDAO(); } @Override public String toString() { return "File [getIdentifier()=" + getIdentifier() + ", getPath()=" + getPath() + "]"; } @Override public void move(FileStore.Folder destination) throws ItemExistsException, RepositoryException { FileStore.Folder formerParent = this.getParent(); move(destination, "files"); eventManagerImpl.tell(Events.move(this, formerParent, destination, filestoreManager.userId())); } } protected static class FileVersion extends File { protected final File file; protected FileVersion( File file, models.filestore.File versionEntity, Manager filestoreManager, EventManager eventManagerImpl) throws RepositoryException { super(versionEntity, null, filestoreManager, eventManagerImpl); this.file = file; } @Override public String getName() { return entity.getVersion(); } @Override public FileStore.Folder getParent() { throw new NotImplementedException(); } @Override protected void reload() { throw new NotImplementedException(); } @Override public SortedSet<service.filestore.FileStore.File> getVersions() { throw new NotImplementedException(); } @Override public void delete() throws AccessDeniedException, VersionException, LockException, ConstraintViolationException, RepositoryException { if (entity.getVersion().equals(file.entity.getLatestVersion())) { final List<models.filestore.File> versions = getDAO().getVersionListById(file.getIdentifier()); if (versions.size() < 2) { throw new AccessDeniedException("You can't remove the last version!"); } getDAO().restoreVersionById(file.getIdentifier(), versions.get(versions.size()-2).getVersion(), true); } getDAO().removeVersionById(file.getIdentifier(), entity.getVersion()); final Event event = Events.update(this.file, filestoreManager.userId()); eventManagerImpl.tell(event); file.reload(); } } protected abstract static class NodeWrapper<T extends Child<models.filestore.Folder>> { protected final FileStoreImpl.Manager filestoreManager; protected final EventManager eventManagerImpl; protected Iterable<String> pathParts; protected T entity; private final FileStore.Folder parent; protected NodeWrapper( T entity, final FileStore.Folder parent, final FileStoreImpl.Manager filestoreManager, final EventManager eventManagerImpl) throws RepositoryException { this.parent = parent; this.filestoreManager = filestoreManager; this.eventManagerImpl = eventManagerImpl; if (entity == null) throw new NullPointerException("Underlying entity cannot be null."); this.entity = entity; updatePath(); } public static String getPath(Session session, String id) throws RepositoryException { try { return getPathFromParts(getPathParts(session, id)); } catch (MalformedPathException e) { // Node with identifier doesn't exist return null; } } protected static Iterable<String> getPathParts(Session session, String id) throws RepositoryException { final IdentifierResolver resolver = (IdentifierResolver) session; final Path rootPath = resolver.getPath(session.getNode(FILE_STORE_PATH) .getIdentifier()); return calculatePathParts( rootPath.computeRelativePath(resolver.getPath(id))); } // Calculate parts from relative path private static Iterable<String> calculatePathParts(Path path) throws RepositoryException { if (path.getDepth() <= 0) return Collections.singleton(""); return Iterables.concat( calculatePathParts(path.getAncestor(2)), // Skip file/folder container Collections.singleton(path.getName().getLocalName())); } public int getDepth() { return Iterables.size(pathParts) - 1; } private static String getPathFromParts(Iterable<String> pathParts) { final String path = Joiner.on('/').join(pathParts); return path.isEmpty() ? "/" : path; } public String getPath() { return getPathFromParts(pathParts); } public Iterable<String> getPathParts() { return pathParts; } /** * Used after moving or renaming. * @throws RepositoryException */ protected void updatePath() throws RepositoryException { this.pathParts = calculatePathParts(); } private Iterable<String> calculatePathParts() throws RepositoryException { return getPathParts(filestoreManager.getSession(), entity.getId()); } public String getName() { if (rawPath().equals(FILE_STORE_PATH)) return FILE_STORE_NAME; return entity.getName(); } protected String rawPath() { return entity.getPath(); } protected abstract void reload(); protected Session session() throws RepositoryException { return filestoreManager.getSession(); } public Map<String, Permission> getGroupPermissions() throws RepositoryException { final ImmutableMap.Builder<String, Permission> b = ImmutableMap .<String, Permission> builder(); final Set<Group> groups = (new GroupManager(filestoreManager.getSession())).list(); final Map<Principal, Permission> perms = getPrincipalPermissions(); for (final Group group : groups) { b.put(group.getPrincipal().getName(), resolvePermission(group, perms)); } return b.build(); } private Permission resolvePermission(final Group group, final Map<Principal, Permission> perms) throws RepositoryException { final Principal p = group.getPrincipal(); if (perms.containsKey(p)) { return perms.get(p); } else { final SortedSet<Permission> inherited = new TreeSet<Permission>(); // Get all potentially inherited permissions final Iterator<Group> iter = group.declaredMemberOf(); while (iter.hasNext()) { inherited.add(resolvePermission(iter.next(), perms)); } if (inherited.isEmpty()) { return Permission.NONE; } // Use natural order of enum to return highest permission return inherited.last(); } } private Map<Principal, Permission> getPrincipalPermissions() throws RepositoryException { final ImmutableMap.Builder<Principal, Permission> b = ImmutableMap .<Principal, Permission> builder(); AorraAccessManager aam = (AorraAccessManager)session().getAccessControlManager(); Map<Principal, jackrabbit.Permission> permissions = aam.getPermissions(rawPath()); for(Map.Entry<Principal, jackrabbit.Permission> me : permissions.entrySet()) { b.put(me.getKey(), Permission.fromJackrabbitPermission(me.getValue())); } return b.build(); } public FileStore.Folder getParent() throws RepositoryException { return parent; } public void rename(final String newName) throws ItemExistsException, RepositoryException { if (getParent() == null) throw new ItemExistsException("Can't rename root folder."); { final FileStore.FileOrFolder fof = getParent().getFileOrFolder(newName); if (fof != null) throw new ItemExistsException(String.format( "Can't rename '%s' to '%s'. %s with same name already exists.", getName(), newName, fof.getClass().getSimpleName())); } entity.setName(newName); getDAO().update(entity, new NodeFilter(0)); // We cache the path, so it has to be updated updatePath(); } protected void move(FileStore.Folder destination, String subfolder) throws ItemExistsException, RepositoryException { if(getParent() == null) { throw new RepositoryException("Can't move root folder."); } Folder dest = ((Folder)destination); dest.ensureDoesNotExist(entity.getName()); String destAbsPath = String.format("%s/%s", dest.rawPath(), subfolder); getDAO().move(entity, destAbsPath); destination.reload(); } public abstract JcrDAO<T> getDAO(); @Override public int hashCode() { return entity.hashCode(); } public Permission getAccessLevel() throws RepositoryException { try { session().checkPermission(rawPath(), "read"); } catch (AccessControlException e) { return Permission.NONE; } try { session().checkPermission(rawPath(), "set_property"); } catch (AccessControlException e) { return Permission.RO; } return Permission.RW; } } }