/* * See the NOTICE file distributed with this work for additional * information regarding copyright ownership. * * This is free software; you can redistribute it and/or modify it * under the terms of the GNU Lesser General Public License as * published by the Free Software Foundation; either version 2.1 of * the License, or (at your option) any later version. * * This software 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 * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this software; if not, write to the Free * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA * 02110-1301 USA, or see the FSF site: http://www.fsf.org. */ package org.xwiki.store.filesystem.internal; import java.io.File; import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.concurrent.locks.ReadWriteLock; import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; import org.apache.commons.lang3.RandomStringUtils; import org.xwiki.component.annotation.Component; import org.xwiki.component.phase.Initializable; import org.xwiki.environment.Environment; import org.xwiki.model.reference.DocumentReference; import org.xwiki.model.reference.EntityReferenceSerializer; import org.xwiki.store.locks.LockProvider; import com.xpn.xwiki.doc.XWikiAttachment; import com.xpn.xwiki.doc.XWikiDocument; /** * Default tools for getting files to store data in the filesystem. This should be replaced by a module which provides a * secure extension of java.io.File. * * @version $Id: 80a98281e01c2be26695f611f7d0a1b221ed276f $ * @since 3.0M2 */ @Component @Singleton public class DefaultFilesystemStoreTools implements FilesystemStoreTools, Initializable { /** * The name of the directory in the work directory where the hirearchy will be stored. */ private static final String STORAGE_DIR_NAME = "storage"; /** * The name of the directory where document information is stored. This must have a URL illegal character in it, * otherwise it will be confused if/when nested spaces are implemented. */ private static final String DOCUMENT_DIR_NAME = "~this"; /** * The name of the directory where document locale information is stored. This must have a URL illegal character in * it, otherwise it will be confused if/when nested spaces are implemented. * * @since 9.0RC1 */ private static final String DOCUMENTLOCALE_DIR_NAME = DOCUMENT_DIR_NAME; /** * The directory within each document's directory where the document's attachments are stored. */ private static final String ATTACHMENT_DIR_NAME = "attachments"; /** * The directory within each document's directory for attachments which have been deleted. */ private static final String DELETED_ATTACHMENT_DIR_NAME = "deleted-attachments"; /** * The part of the deleted attachment directory name after this is the date of deletion, The part before this is the * URL encoded attachment filename. */ private static final String DELETED_ATTACHMENT_NAME_SEPARATOR = "-"; /** * The directory within each document's directory for documents which have been deleted. */ private static final String DELETED_DOCUMENT_DIR_NAME = "deleted-documents"; /** * The directory within each document's directory for document locales. * * @since 9.0RC1 */ private static final String DOCUMENT_LOCALES_DIR_NAME = "locales"; /** * The folder name of {@link Locale#ROOT}. * * @since 9.0RC1 */ private static final String DOCUMENT_LOCALES_ROOT_NAME = "~"; /** * When a file is being saved, the original will be moved to the same name with this after it. If the save operation * fails then this file will be moved back to the regular position to come as close as possible to ACID transaction * handling. */ private static final String BACKUP_FILE_SUFFIX = "~bak"; /** * When a file is being deleted, it will be renamed with this at the end of the filename in the transaction. If the * transaction succeeds then the temp file will be deleted, if it fails then the temp file will be renamed back to * the original filename. */ private static final String TEMP_FILE_SUFFIX = "~tmp"; /** * Serializer used for obtaining a safe file path from a document reference. */ @Inject @Named("path") private EntityReferenceSerializer<String> pathSerializer; @Inject private FilesystemAttachmentsConfiguration config; /** * A means of acquiring locks for attachments. Because the attachments temp files are randomly named and rename is * atomic, locks are not needed. DummyLockProvider provides fake locks. */ @Inject @Named("dummy") private LockProvider lockProvider; /** * Used to get store directory. */ @Inject private Environment environment; /** * This is the directory where all of the attachments will stored. */ private File storageDir; /** * Testing Constructor. * * @param pathSerializer an EntityReferenceSerializer for generating file paths. * @param storageDir the directory to store the content in. * @param lockProvider a means of getting locks for making sure only one thread accesses an attachment at a time. */ public DefaultFilesystemStoreTools(final EntityReferenceSerializer<String> pathSerializer, final File storageDir, final LockProvider lockProvider) { this.pathSerializer = pathSerializer; this.storageDir = storageDir; this.lockProvider = lockProvider; } /** * Constructor for component manager. */ public DefaultFilesystemStoreTools() { } @Override public void initialize() { this.storageDir = new File(this.environment.getPermanentDirectory(), STORAGE_DIR_NAME); if (config.cleanOnStartup()) { final File dir = this.storageDir; new Thread(new Runnable() { public void run() { deleteEmptyDirs(dir, 0); } }).start(); } } /** * Delete all empty directories under the given directory. A directory which contains only empty directories is also * considered an empty ditectory. This function will not delete *location* unless depth is non-zero. * * @param location a directory to delete. * @param depth used for recursion, should always be zero. * @return true if the directory existed, was empty and was deleted. */ private static boolean deleteEmptyDirs(final File location, int depth) { if (location != null && location.exists() && location.isDirectory()) { final File[] dirs = location.listFiles(); boolean empty = true; for (int i = 0; i < dirs.length; i++) { if (!deleteEmptyDirs(dirs[i], depth + 1)) { empty = false; } } if (empty && depth != 0) { location.delete(); return true; } } return false; } @Override public File getBackupFile(final File storageFile) { // We pad our file names with random alphanumeric characters so that multiple operations on the same // file in the same transaction do not collide, the set of all capital and lower case letters // and numbers has 62 possibilities and 62^8 = 218340105584896 between 2^47 and 2^48. return new File(storageFile.getAbsolutePath() + BACKUP_FILE_SUFFIX + RandomStringUtils.randomAlphanumeric(8)); } @Override public File getTempFile(final File storageFile) { return new File(storageFile.getAbsolutePath() + TEMP_FILE_SUFFIX + RandomStringUtils.randomAlphanumeric(8)); } @Override public DeletedAttachmentFileProvider getDeletedAttachmentFileProvider(final XWikiAttachment attachment, final Date deleteDate) { return new DefaultDeletedAttachmentFileProvider(getDeletedAttachmentDir(attachment, deleteDate), attachment.getFilename()); } @Override public DeletedAttachmentFileProvider getDeletedAttachmentFileProvider(final String pathToDirectory) { final File attachDir = new File(this.storageDir, getStorageLocationPath()); return new DefaultDeletedAttachmentFileProvider(attachDir, getFilenameFromDeletedAttachmentDirectory(attachDir)); } @Override public DeletedDocumentContentFileProvider getDeletedDocumentFileProvider(DocumentReference documentReference, long index) { return new DefaultDeletedDocumentContentFileProvider(getDeletedDocumentContentDir(documentReference, index)); } @Override public Map<String, Map<Date, DeletedAttachmentFileProvider>> deletedAttachmentsForDocument( final DocumentReference docRef) { final File docDir = getDocumentDir(docRef, this.storageDir, this.pathSerializer); final File deletedAttachmentsDir = new File(docDir, DELETED_ATTACHMENT_DIR_NAME); final Map<String, Map<Date, DeletedAttachmentFileProvider>> out = new HashMap<String, Map<Date, DeletedAttachmentFileProvider>>(); if (!deletedAttachmentsDir.exists()) { return out; } for (File file : Arrays.asList(deletedAttachmentsDir.listFiles())) { final String currentName = getFilenameFromDeletedAttachmentDirectory(file); if (out.get(currentName) == null) { out.put(currentName, new HashMap<Date, DeletedAttachmentFileProvider>()); } out.get(currentName).put(getDeleteDateFromDeletedAttachmentDirectory(file), new DefaultDeletedAttachmentFileProvider(file, getFilenameFromDeletedAttachmentDirectory(file))); } return out; } /** * @param directory the location of the data for the deleted attachment. * @return the name of the attachment file as extracted from the directory name. */ private static String getFilenameFromDeletedAttachmentDirectory(final File directory) { final String name = directory.getName(); final String encodedOut = name.substring(0, name.lastIndexOf(DELETED_ATTACHMENT_NAME_SEPARATOR)); return GenericFileUtils.getURLDecoded(encodedOut); } /** * @param directory the location of the data for the deleted attachment. * @return the deletion date as extracted from the directory name. */ private static Date getDeleteDateFromDeletedAttachmentDirectory(final File directory) { final String name = directory.getName(); // no need to url decode this since it should only contain numbers 0-9. long time = Long.parseLong(name.substring(name.lastIndexOf(DELETED_ATTACHMENT_NAME_SEPARATOR) + 1)); return new Date(time); } @Override public String getStorageLocationPath() { return this.storageDir.getAbsolutePath(); } @Override public File getGlobalFile(final String name) { return new File(this.storageDir, "~GLOBAL_" + GenericFileUtils.getURLEncoded(name)); } @Override public AttachmentFileProvider getAttachmentFileProvider(final XWikiAttachment attachment) { return new DefaultAttachmentFileProvider(this.getAttachmentDir(attachment), attachment.getFilename()); } /** * Get the directory for storing files for an attachment. This will look like * storage/xwiki/Main/WebHome/~this/attachments/file.name/ * * @param attachment the attachment to get the directory for. * @return a File representing the directory. Note: The directory may not exist. */ private File getAttachmentDir(final XWikiAttachment attachment) { final XWikiDocument doc = attachment.getDoc(); if (doc == null) { throw new NullPointerException( "Could not store attachment because it is not " + "associated with a document."); } final File docDir = getDocumentDir(doc.getDocumentReference(), this.storageDir, this.pathSerializer); final File attachmentsDir = new File(docDir, ATTACHMENT_DIR_NAME); return new File(attachmentsDir, GenericFileUtils.getURLEncoded(attachment.getFilename())); } /** * Get a directory for storing the contentes of a deleted attachment. The format is <document * name>/~this/deleted-attachments/<attachment name>-<delete date>/ <delete date> is expressed in "unix time" so it * might look like: WebHome/~this/deleted-attachments/file.txt-0123456789/ * * @param attachment the attachment to get the file for. * @param deleteDate the date the attachment was deleted. * @return a directory which will be repeatable only with the same inputs. */ private File getDeletedAttachmentDir(final XWikiAttachment attachment, final Date deleteDate) { final XWikiDocument doc = attachment.getDoc(); if (doc == null) { throw new NullPointerException( "Could not store deleted attachment because " + "it is not attached to any document."); } final File docDir = getDocumentDir(doc.getDocumentReference(), this.storageDir, this.pathSerializer); final File deletedAttachmentsDir = new File(docDir, DELETED_ATTACHMENT_DIR_NAME); final String fileName = attachment.getFilename() + DELETED_ATTACHMENT_NAME_SEPARATOR + deleteDate.getTime(); return new File(deletedAttachmentsDir, GenericFileUtils.getURLEncoded(fileName)); } /** * Get a directory for storing the content of a deleted document. The format is <document * name>/~this/deleted-documents/<index>/content.xml. * * @param documentReference the document to get the file for. * @param index the index of the deleted document. * @return a directory which will be repeatable only with the same inputs. */ private File getDeletedDocumentContentDir(final DocumentReference documentReference, final long index) { final File docDir = getDocumentDir(documentReference, this.storageDir, this.pathSerializer); final File deletedDocumentContentsDir = new File(docDir, DELETED_DOCUMENT_DIR_NAME); return new File(deletedDocumentContentsDir, String.valueOf(index)); } /** * Get the directory associated with this document. This is a path obtained from the owner document reference, where * each reference segment (wiki, spaces, document name) contributes to the final path. For a document called * xwiki:Main.WebHome, the directory will be: <code>(storageDir)/xwiki/Main/WebHome/~this/</code> * * @param docRef the DocumentReference for the document to get the directory for. * @param storageDir the directory to place the directory hirearcy for attachments in. * @param pathSerializer an EntityReferenceSerializer which will make a directory path from an an EntityReference. * @return a file path corresponding to the attachment location; each segment in the path is URL-encoded in order to * be safe. */ private static File getDocumentDir(final DocumentReference docRef, final File storageDir, final EntityReferenceSerializer<String> pathSerializer) { final File path = new File(storageDir, pathSerializer.serialize(docRef)); File docDir = new File(path, DOCUMENT_DIR_NAME); // Add the locale Locale docLocale = docRef.getLocale(); if (docLocale != null) { final File docLocalesDir = new File(docDir, DOCUMENT_LOCALES_DIR_NAME); final File docLocaleDir = new File(docLocalesDir, docLocale.equals(Locale.ROOT) ? DOCUMENT_LOCALES_ROOT_NAME : docLocale.toString()); docDir = new File(docLocaleDir, DOCUMENTLOCALE_DIR_NAME); } return docDir; } @Override public ReadWriteLock getLockForFile(final File toLock) { return this.lockProvider.getLock(toLock); } }