silverstripe-framework/filesystem/Folder.php
Damian Mooyman d8e9af8af8 API New Database abstraction layer. Ticket #7429
Database abstraction broken up into controller, connector, query builder, and schema manager, each independently configurable via YAML / Injector
Creation of new DBQueryGenerator for database specific generation of SQL
Support for parameterised queries, move of code base to use these over escaped conditions
Refactor of SQLQuery into separate query classes for each of INSERT UPDATE DELETE and SELECT
Support for PDO
Installation process upgraded to use new ORM
SS_DatabaseException created to handle database errors, maintaining details of raw sql and parameter details for user code designed interested in that data.
Renamed DB static methods to conform correctly to naming conventions (e.g. DB::getConn -> DB::get_conn)
3.2 upgrade docs
Performance Optimisation and simplification of code to use more concise API
API Ability for database adapters to register extensions to ConfigureFromEnv.php
2014-07-09 18:04:05 +12:00

504 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
&& ($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;
}
}
public 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();
}
/** 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);
}
/**
* @return String
*/
public function CMSTreeClasses() {
$classes = sprintf('class-%s', $this->class);
if(!$this->canDelete())
$classes .= " nodelete";
if(!$this->canEdit())
$classes .= " disabled";
$classes .= $this->markingClasses();
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))
);
}
}