mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-09-05 17:21:16 +02:00
520 lines
15 KiB
PHP
520 lines
15 KiB
PHP
<?php
|
|
/**
|
|
* Represents a folder in the assets/ directory.
|
|
* The folder path is stored in the "Filename" property.
|
|
*
|
|
* Updating the "Name" or "Filename" properties on
|
|
* a folder object also updates all associated children
|
|
* (both {@link File} and {@link Folder} records).
|
|
*
|
|
* Deleting a folder will also remove the folder from the filesystem,
|
|
* including any subfolders and contained files. Use {@link deleteDatabaseOnly()}
|
|
* to avoid touching the filesystem.
|
|
*
|
|
* See {@link File} documentation for more details about the
|
|
* relationship between the database and filesystem in the SilverStripe file APIs.
|
|
*
|
|
* @package framework
|
|
* @subpackage filesystem
|
|
*/
|
|
class Folder extends File {
|
|
|
|
private static $singular_name = "Folder";
|
|
|
|
private static $plural_name = "Folders";
|
|
|
|
private static $default_sort = "\"Name\"";
|
|
|
|
/**
|
|
*
|
|
*/
|
|
public function populateDefaults() {
|
|
parent::populateDefaults();
|
|
|
|
if(!$this->Name) $this->Name = _t('AssetAdmin.NEWFOLDER',"NewFolder");
|
|
}
|
|
|
|
/**
|
|
* Find the given folder or create it both as {@link Folder} database records
|
|
* and on the filesystem. If necessary, creates parent folders as well. If it's
|
|
* unable to find or make the folder, it will return null (as /assets is unable
|
|
* to be represented by a Folder DataObject)
|
|
*
|
|
* @param $folderPath string Absolute or relative path to the file.
|
|
* If path is relative, its interpreted relative to the "assets/" directory.
|
|
* @return Folder|null
|
|
*/
|
|
public static function find_or_make($folderPath) {
|
|
// Create assets directory, if it is missing
|
|
if(!file_exists(ASSETS_PATH)) Filesystem::makeFolder(ASSETS_PATH);
|
|
|
|
$folderPath = trim(Director::makeRelative($folderPath));
|
|
// replace leading and trailing slashes
|
|
$folderPath = preg_replace('/^\/?(.*)\/?$/', '$1', $folderPath);
|
|
$parts = explode("/",$folderPath);
|
|
|
|
$parentID = 0;
|
|
$item = null;
|
|
$filter = FileNameFilter::create();
|
|
foreach($parts as $part) {
|
|
if(!$part) continue; // happens for paths with a trailing slash
|
|
|
|
// Ensure search includes folders with illegal characters removed, but
|
|
// err in favour of matching existing folders if $folderPath
|
|
// includes illegal characters itself.
|
|
$partSafe = $filter->filter($part);
|
|
$item = Folder::get()->filter(array(
|
|
'ParentID' => $parentID,
|
|
'Name' => array($partSafe, $part)
|
|
))->first();
|
|
|
|
if(!$item) {
|
|
$item = new Folder();
|
|
$item->ParentID = $parentID;
|
|
$item->Name = $partSafe;
|
|
$item->Title = $part;
|
|
$item->write();
|
|
}
|
|
if(!file_exists($item->getFullPath())) {
|
|
Filesystem::makeFolder($item->getFullPath());
|
|
}
|
|
$parentID = $item->ID;
|
|
}
|
|
|
|
return $item;
|
|
}
|
|
|
|
/**
|
|
* Synchronize the file database with the actual content of the assets
|
|
* folder.
|
|
*/
|
|
public function syncChildren() {
|
|
$parentID = (int)$this->ID; // parentID = 0 on the singleton, used as the 'root node';
|
|
$added = 0;
|
|
$deleted = 0;
|
|
$skipped = 0;
|
|
|
|
// First, merge any children that are duplicates
|
|
$duplicateChildrenNames = DB::prepared_query(
|
|
'SELECT "Name" FROM "File" WHERE "ParentID" = ? GROUP BY "Name" HAVING count(*) > 1',
|
|
array($parentID)
|
|
)->column();
|
|
if($duplicateChildrenNames) foreach($duplicateChildrenNames as $childName) {
|
|
// Note, we do this in the database rather than object-model; otherwise we get all sorts of problems
|
|
// about deleting files
|
|
$children = DB::prepared_query(
|
|
'SELECT "ID" FROM "File" WHERE "Name" = ? AND "ParentID" = ?',
|
|
array($childName, $parentID)
|
|
)->column();
|
|
if($children) {
|
|
$keptChild = array_shift($children);
|
|
foreach($children as $removedChild) {
|
|
DB::prepared_query('UPDATE "File" SET "ParentID" = ? WHERE "ParentID" = ?',
|
|
array($keptChild, $removedChild));
|
|
DB::prepared_query('DELETE FROM "File" WHERE "ID" = ?', array($removedChild));
|
|
}
|
|
} else {
|
|
user_error("Inconsistent database issue: SELECT ID FROM \"File\" WHERE Name = '$childName'"
|
|
. " AND ParentID = $parentID should have returned data", E_USER_WARNING);
|
|
}
|
|
}
|
|
|
|
|
|
// Get index of database content
|
|
// We don't use DataObject so that things like subsites doesn't muck with this.
|
|
$dbChildren = DB::prepared_query('SELECT * FROM "File" WHERE "ParentID" = ?', array($parentID));
|
|
$hasDbChild = array();
|
|
|
|
if($dbChildren) {
|
|
foreach($dbChildren as $dbChild) {
|
|
$className = $dbChild['ClassName'];
|
|
if(!$className) $className = "File";
|
|
$hasDbChild[$dbChild['Name']] = new $className($dbChild);
|
|
}
|
|
}
|
|
|
|
$unwantedDbChildren = $hasDbChild;
|
|
|
|
// if we're syncing a folder with no ID, we assume we're syncing the root assets folder
|
|
// however the Filename field is populated with "NewFolder", so we need to set this to empty
|
|
// to satisfy the baseDir variable below, which is the root folder to scan for new files in
|
|
if(!$parentID) $this->Filename = '';
|
|
|
|
// Iterate through the actual children, correcting the database as necessary
|
|
$baseDir = $this->FullPath;
|
|
|
|
// @todo this shouldn't call die() but log instead
|
|
if($parentID && !$this->Filename) die($this->ID . " - " . $this->FullPath);
|
|
|
|
if(file_exists($baseDir)) {
|
|
$actualChildren = scandir($baseDir);
|
|
$ignoreRules = Filesystem::config()->sync_blacklisted_patterns;
|
|
$allowedExtensions = File::config()->allowed_extensions;
|
|
$checkExtensions = $this->config()->apply_restrictions_to_admin || !Permission::check('ADMIN');
|
|
|
|
foreach($actualChildren as $actualChild) {
|
|
$skip = false;
|
|
|
|
// Check ignore patterns
|
|
if($ignoreRules) foreach($ignoreRules as $rule) {
|
|
if(preg_match($rule, $actualChild)) {
|
|
$skip = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Check allowed extensions, unless admin users are allowed to bypass these exclusions
|
|
if($checkExtensions
|
|
&& !is_dir($baseDir . $actualChild)
|
|
&& ($extension = self::get_file_extension($actualChild))
|
|
&& !in_array(strtolower($extension), $allowedExtensions)
|
|
) {
|
|
$skip = true;
|
|
}
|
|
|
|
if($skip) {
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
|
|
// A record with a bad class type doesn't deserve to exist. It must be purged!
|
|
if(isset($hasDbChild[$actualChild])) {
|
|
$child = $hasDbChild[$actualChild];
|
|
if(( !( $child instanceof Folder ) && is_dir($baseDir . $actualChild) )
|
|
|| (( $child instanceof Folder ) && !is_dir($baseDir . $actualChild)) ) {
|
|
DB::prepared_query('DELETE FROM "File" WHERE "ID" = ?', array($child->ID));
|
|
unset($hasDbChild[$actualChild]);
|
|
}
|
|
}
|
|
|
|
if(isset($hasDbChild[$actualChild])) {
|
|
$child = $hasDbChild[$actualChild];
|
|
unset($unwantedDbChildren[$actualChild]);
|
|
} else {
|
|
$added++;
|
|
$childID = $this->constructChild($actualChild);
|
|
$child = DataObject::get_by_id("File", $childID);
|
|
}
|
|
|
|
if( $child && is_dir($baseDir . $actualChild)) {
|
|
$childResult = $child->syncChildren();
|
|
$added += $childResult['added'];
|
|
$deleted += $childResult['deleted'];
|
|
$skipped += $childResult['skipped'];
|
|
}
|
|
|
|
// Clean up the child record from memory after use. Important!
|
|
$child->destroy();
|
|
$child = null;
|
|
}
|
|
|
|
// Iterate through the unwanted children, removing them all
|
|
if(isset($unwantedDbChildren)) foreach($unwantedDbChildren as $unwantedDbChild) {
|
|
DB::prepared_query('DELETE FROM "File" WHERE "ID" = ?', array($unwantedDbChild->ID));
|
|
$deleted++;
|
|
}
|
|
} else {
|
|
DB::prepared_query('DELETE FROM "File" WHERE "ID" = ?', array($this->ID));
|
|
}
|
|
|
|
return array(
|
|
'added' => $added,
|
|
'deleted' => $deleted,
|
|
'skipped' => $skipped
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Construct a child of this Folder with the given name.
|
|
* It does this without actually using the object model, as this starts messing
|
|
* with all the data. Rather, it does a direct database insert.
|
|
*
|
|
* @param string $name Name of the file or folder
|
|
* @return integer the ID of the newly saved File record
|
|
*/
|
|
public function constructChild($name) {
|
|
// Determine the class name - File, Folder or Image
|
|
$baseDir = $this->FullPath;
|
|
if(is_dir($baseDir . $name)) {
|
|
$className = "Folder";
|
|
} else {
|
|
$className = File::get_class_for_file_extension(pathinfo($name, PATHINFO_EXTENSION));
|
|
}
|
|
|
|
$ownerID = Member::currentUserID();
|
|
|
|
$filename = $this->Filename . $name;
|
|
if($className == 'Folder' ) $filename .= '/';
|
|
|
|
$nowExpression = DB::get_conn()->now();
|
|
DB::prepared_query("INSERT INTO \"File\"
|
|
(\"ClassName\", \"ParentID\", \"OwnerID\", \"Name\", \"Filename\", \"Created\", \"LastEdited\", \"Title\")
|
|
VALUES (?, ?, ?, ?, ?, $nowExpression, $nowExpression, ?)",
|
|
array($className, $this->ID, $ownerID, $name, $filename, $name)
|
|
);
|
|
|
|
return DB::get_generated_id("File");
|
|
}
|
|
|
|
/**
|
|
* Take a file uploaded via a POST form, and save it inside this folder.
|
|
* File names are filtered through {@link FileNameFilter}, see class documentation
|
|
* on how to influence this behaviour.
|
|
*/
|
|
public function addUploadToFolder($tmpFile) {
|
|
if(!is_array($tmpFile)) {
|
|
user_error("Folder::addUploadToFolder() Not passed an array."
|
|
. " Most likely, the form hasn't got the right enctype", E_USER_ERROR);
|
|
}
|
|
if(!isset($tmpFile['size'])) {
|
|
return;
|
|
}
|
|
|
|
$base = BASE_PATH;
|
|
// $parentFolder = Folder::findOrMake("Uploads");
|
|
|
|
// Generate default filename
|
|
$nameFilter = FileNameFilter::create();
|
|
$file = $nameFilter->filter($tmpFile['name']);
|
|
while($file[0] == '_' || $file[0] == '.') {
|
|
$file = substr($file, 1);
|
|
}
|
|
|
|
$file = $this->RelativePath . $file;
|
|
Filesystem::makeFolder(dirname("$base/$file"));
|
|
|
|
$doubleBarrelledExts = array('.gz', '.bz', '.bz2');
|
|
|
|
$ext = "";
|
|
if(preg_match('/^(.*)(\.[^.]+)$/', $file, $matches)) {
|
|
$file = $matches[1];
|
|
$ext = $matches[2];
|
|
// Special case for double-barrelled
|
|
if(in_array($ext, $doubleBarrelledExts) && preg_match('/^(.*)(\.[^.]+)$/', $file, $matches)) {
|
|
$file = $matches[1];
|
|
$ext = $matches[2] . $ext;
|
|
}
|
|
}
|
|
$origFile = $file;
|
|
|
|
$i = 1;
|
|
while(file_exists("$base/$file$ext")) {
|
|
$i++;
|
|
$oldFile = $file;
|
|
|
|
if(strpos($file, '.') !== false) {
|
|
$file = preg_replace('/[0-9]*(\.[^.]+$)/', $i . '\\1', $file);
|
|
} elseif(strpos($file, '_') !== false) {
|
|
$file = preg_replace('/_([^_]+$)/', '_' . $i, $file);
|
|
} else {
|
|
$file .= '_'.$i;
|
|
}
|
|
|
|
if($oldFile == $file && $i > 2) user_error("Couldn't fix $file$ext with $i", E_USER_ERROR);
|
|
}
|
|
|
|
if (move_uploaded_file($tmpFile['tmp_name'], "$base/$file$ext")) {
|
|
// Update with the new image
|
|
return $this->constructChild(basename($file . $ext));
|
|
} else {
|
|
if(!file_exists($tmpFile['tmp_name'])) {
|
|
user_error("Folder::addUploadToFolder: '$tmpFile[tmp_name]' doesn't exist", E_USER_ERROR);
|
|
} else {
|
|
user_error("Folder::addUploadToFolder: Couldn't copy '$tmpFile[tmp_name]' to '$base/$file$ext'",
|
|
E_USER_ERROR);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
protected function validate() {
|
|
return new ValidationResult(true);
|
|
}
|
|
|
|
//-------------------------------------------------------------------------------------------------
|
|
// Data Model Definition
|
|
|
|
public function getRelativePath() {
|
|
return parent::getRelativePath() . "/";
|
|
}
|
|
|
|
public function onBeforeDelete() {
|
|
if($this->ID && ($children = $this->AllChildren())) {
|
|
foreach($children as $child) {
|
|
if(!$this->Filename || !$this->Name || !file_exists($this->getFullPath())) {
|
|
$child->setField('Name',null);
|
|
$child->Filename = null;
|
|
}
|
|
$child->delete();
|
|
}
|
|
}
|
|
|
|
// Do this after so a folder's contents are removed before we delete the folder.
|
|
if($this->Filename && $this->Name && file_exists($this->getFullPath())) {
|
|
$files = glob( $this->getFullPath() . '/*' );
|
|
|
|
if( !$files || ( count( $files ) == 1 && preg_match( '/\/_resampled$/', $files[0] ) ) )
|
|
Filesystem::removeFolder( $this->getFullPath() );
|
|
}
|
|
|
|
parent::onBeforeDelete();
|
|
}
|
|
|
|
/**
|
|
* Return the relative URL of an icon for this file type
|
|
*
|
|
* @return String
|
|
*/
|
|
public function Icon() {
|
|
return FRAMEWORK_DIR . "/images/app_icons/folder_32.png";
|
|
}
|
|
|
|
/** Override setting the Title of Folders to that Name, Filename and Title are always in sync.
|
|
* Note that this is not appropriate for files, because someone might want to create a human-readable name
|
|
* of a file that is different from its name on disk. But folders should always match their name on disk. */
|
|
public function setTitle($title) {
|
|
$this->setName($title);
|
|
}
|
|
|
|
public function getTitle() {
|
|
return $this->Name;
|
|
}
|
|
|
|
public function setName($name) {
|
|
parent::setName($name);
|
|
$this->setField('Title', $this->Name);
|
|
}
|
|
|
|
public function setFilename($filename) {
|
|
$this->setField('Title',pathinfo($filename, PATHINFO_BASENAME));
|
|
parent::setFilename($filename);
|
|
}
|
|
|
|
/**
|
|
* A folder doesn't have a (meaningful) file size.
|
|
*
|
|
* @return Null
|
|
*/
|
|
public function getSize() {
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Delete the database record (recursively for folders) without touching the filesystem
|
|
*/
|
|
public function deleteDatabaseOnly() {
|
|
if($children = $this->myChildren()) {
|
|
foreach($children as $child) $child->deleteDatabaseOnly();
|
|
}
|
|
|
|
parent::deleteDatabaseOnly();
|
|
}
|
|
|
|
/**
|
|
* Returns all children of this folder
|
|
*
|
|
* @return DataList
|
|
*/
|
|
public function myChildren() {
|
|
return File::get()->filter("ParentID", $this->ID);
|
|
}
|
|
|
|
/**
|
|
* Returns true if this folder has children
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function hasChildren() {
|
|
return $this->myChildren()->exists();
|
|
}
|
|
|
|
/**
|
|
* Returns true if this folder has children
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function hasChildFolders() {
|
|
return $this->ChildFolders()->exists();
|
|
}
|
|
|
|
/**
|
|
* Overloaded to call recursively on all contained {@link File} records.
|
|
*/
|
|
public function updateFilesystem() {
|
|
parent::updateFilesystem();
|
|
|
|
// Note: Folders will have been renamed on the filesystem already at this point,
|
|
// File->updateFilesystem() needs to take this into account.
|
|
if($this->ID && ($children = $this->AllChildren())) {
|
|
foreach($children as $child) {
|
|
$child->updateFilesystem();
|
|
$child->write();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the FieldList used to edit this folder in the CMS.
|
|
* You can modify this FieldList by subclassing folder, or by creating a {@link DataExtension}
|
|
* and implemeting updateCMSFields(FieldList $fields) on that extension.
|
|
*/
|
|
public function getCMSFields() {
|
|
// Hide field on root level, which can't be renamed
|
|
if(!$this->ID || $this->ID === "root") {
|
|
$titleField = new HiddenField("Name");
|
|
} else {
|
|
$titleField = new TextField("Name", $this->fieldLabel('Name'));
|
|
}
|
|
|
|
$fields = new FieldList(
|
|
$titleField,
|
|
new HiddenField('ParentID')
|
|
);
|
|
$this->extend('updateCMSFields', $fields);
|
|
|
|
return $fields;
|
|
}
|
|
|
|
/**
|
|
* Get the children of this folder that are also folders.
|
|
*
|
|
* @return DataList
|
|
*/
|
|
public function ChildFolders() {
|
|
return Folder::get()->filter('ParentID', $this->ID);
|
|
}
|
|
|
|
/**
|
|
* Get the number of children of this folder that are also folders.
|
|
*/
|
|
public function numChildFolders() {
|
|
return $this->ChildFolders()->count();
|
|
}
|
|
/**
|
|
* @return String
|
|
*/
|
|
public function CMSTreeClasses() {
|
|
$classes = sprintf('class-%s', $this->class);
|
|
|
|
if(!$this->canDelete())
|
|
$classes .= " nodelete";
|
|
|
|
if(!$this->canEdit())
|
|
$classes .= " disabled";
|
|
|
|
$classes .= $this->markingClasses('numChildFolders');
|
|
|
|
return $classes;
|
|
}
|
|
|
|
/**
|
|
* @return string
|
|
*/
|
|
public function getTreeTitle() {
|
|
return $treeTitle = sprintf(
|
|
"<span class=\"jstree-foldericon\"></span><span class=\"item\">%s</span>",
|
|
Convert::raw2xml(str_replace(array("\n","\r"),"",$this->Title))
|
|
);
|
|
}
|
|
}
|