<?php
/*
* Copyright (C) 2017 Karmabunny Pty Ltd.
*
* This file is a part of SproutCMS.
*
* SproutCMS 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.
*
* For more information, visit <http://getsproutcms.com>.
*/
namespace Sprout\Controllers\Admin;
use Exception;
use Kohana;
use Kohana_404_Exception;
use karmabunny\pdb\Exceptions\QueryException;
use Sprout\Exceptions\WorkerJobException;
use Sprout\Helpers\Admin;
use Sprout\Helpers\AdminAuth;
use Sprout\Helpers\AdminPerms;
use Sprout\Helpers\Category;
use Sprout\Helpers\ColModifierBinary;
use Sprout\Helpers\ColModifierLookupArray;
use Sprout\Helpers\Cron;
use Sprout\Helpers\Csrf;
use Sprout\Helpers\Enc;
use Sprout\Helpers\FileConstants;
use Sprout\Helpers\FileIndexing;
use Sprout\Helpers\FileUpload;
use Sprout\Helpers\Form;
use Sprout\Helpers\FrontEndSearch;
use Sprout\Helpers\Image;
use Sprout\Helpers\Json;
use Sprout\Helpers\Notification;
use Sprout\Helpers\Pdb;
use Sprout\Helpers\RefineBar;
use Sprout\Helpers\RefineWidgetSelect;
use Sprout\Helpers\RefineWidgetTextbox;
use Sprout\Helpers\Replication;
use Sprout\Helpers\Search;
use Sprout\Helpers\Security;
use Sprout\Helpers\Sprout;
use Sprout\Helpers\Text;
use Sprout\Helpers\Upload;
use Sprout\Helpers\Url;
use Sprout\Helpers\Validator;
use Sprout\Helpers\View;
use Sprout\Helpers\WorkerCtrl;
/**
* Handles most of the processing for files
**/
class FileAdminController extends HasCategoriesAdminController implements FrontEndSearch
{
protected $controller_name = 'file';
protected $friendly_name = 'Files';
protected $add_defaults = array( 'indexing' => 1,
);
protected $category_archive = true;
protected $main_delete = true;
/**
* Constructor
**/
public function __construct()
{
$this->main_columns = [
'Name' => 'name',
'Type' => [new ColModifierLookupArray(FileConstants::$type_names), 'type'],
'Filename' => 'filename',
'Author' => 'author',
'Show Author' => [new ColModifierBinary(), 'embed_author'],
];
$this->main_where = array( "item.type != 0",
"item.name != ''",
);
$this->refine_bar = new RefineBar();
$this->refine_bar->setGroup('Files');
$this->refine_bar->addWidget(new RefineWidgetTextbox('name', 'Name'));
$this->refine_bar->addWidget(new RefineWidgetSelect('type', 'Type', FileConstants::$type_names));
$this->refine_bar->addWidget(new RefineWidgetTextbox('filename', 'Filename'));
$this->refine_bar->setGroup('Documents');
$this->refine_bar->addWidget(new RefineWidgetSelect('document_type', 'Document Type', Pdb::lookup('document_types')));
$this->main_modes['thumb'] = array('Thumbnails', 'grid');
parent::__construct();
}
/**
* Return the fields to show in the sidebar when adding or editing a record.
* These fields are shown under a heading of "Visibility"
*
* Key is the field name, value is the field label
*
* @return array
*/
public function _getVisibilityFields()
{
$file_id = Admin::getRecordId();
$file = !empty($file_id) ? Pdb
::get('files', $file_id) : null; $list = [];
if (!empty($file['type']) and
$file['type'] == FileConstants
::TYPE_IMAGE) $list['embed_author'] = 'Embed author credit in image';
$list['enable_indexing'] = 'Show in search results';
return $list;
}
/**
* Is the "add" action saved?
* These may be false if the UI provides its own save mechanism (e.g. multi-add)
*
* @return bool True if they are saved, false if they are not
*/
public function _isAddSaved()
{
return false;
}
/**
* Hook called by _getAddForm() just before the view is rendered
**/
protected function _addPreRender($view)
{
parent::_addPreRender($view);
$opts['chunk_url'] = 'admin/call/file/ajaxDragdropChunk';
$opts['done_url'] = 'admin/call/file/ajaxDragdropDone';
$opts['form_url'] = 'admin/call/file/ajaxDragdropForm';
$opts['cancel_url'] = 'admin/call/file/ajaxDragdropCancel';
$opts['form_params'] = [];
$opts['form_el'] = '.drag-drop__form';
$opts['max_files'] = 1000;
$view->opts = $opts;
}
/**
* Save a single chunk of a multi-part file upload
*
* @post string chunk Binary data
* @post int index Chunk index, 0-based
* @post string code Unique code for this upload
* @return void Outputs JSON
**/
public function ajaxDragdropChunk()
{
Json::error('Invalid "index" param');
}
if (!preg_match('/^[A-Za-z0-9]{32}$/', $_POST['code'])) { Json::error('Invalid "code" param');
}
if (!is_dir(APPPATH
. 'temp')) { Json::error('Temporary directory does not exist');
}
Json::error('Temporary directory is not writable');
}
$filename = APPPATH . 'temp/chunk-' . $_POST['code'] . '-' . $_POST['index'] . '.dat';
$result = rename($_FILES['chunk']['tmp_name'], $filename); if (!$result) {
Json::error('Move of chunk to temporary directory failed');
}
Json::confirm();
}
/**
* Stitch together uploaded chunks into an actual file
*
* Outputs a JSON response.
* The field "success" will be checked (= 1) to determine success.
* On error, the field "message" will be used as an error message.
* Other keys provided are passed to the ajaxDragdropForm method.
*
* @post num The total number of chunks uploaded
* @post string code Unique code for this upload
* @return void Outputs JSON
**/
public function ajaxDragdropDone()
{
Json::error('Invalid "num" param');
}
if (!preg_match('/^[A-Za-z0-9]{32}$/', $_POST['code'])) { Json::error('Invalid "code" param');
}
$dest_filename = 'upload-' . time() . '-' . $_POST['code'] . '.dat';
try {
$this->stitchChunks(APPPATH . 'temp/' . $dest_filename, $_POST['code'], $_POST['num']);
} catch (Exception $ex) {
Json::error($ex->getMessage());
}
'tmp_file' => $dest_filename,
));
}
/**
* Stitch together the uploaded file from multiple chunks
*
* @param string $dest_filename The destination filename
* @param string $code Upload code
* @param string $num_chunks The number of chunks to stitch together
**/
private function stitchChunks($dest_filename, $code, $num_chunks) {
$num_chunks = (int) $num_chunks;
$out = @fopen($dest_filename, 'w'); if (! $out) {
throw new Exception('Unable to open file for writing');
}
// Copy chunks into the file. If anything goes wrong, the file will not be complete so bail
$damaged = false;
for ($i = 0; $i < $num_chunks; ++$i) {
$chunk = APPPATH . 'temp/chunk-' . $code . '-' . $i . '.dat';
$damaged = true;
break;
}
$in = @fopen($chunk, 'r'); if (! $in) {
$damaged = true;
break;
}
if (! $result) {
$damaged = true;
break;
}
if (! $result) {
$damaged = true;
break;
}
}
if (! $result) {
$damaged = true;
}
// Nuke all the chunks prior to error handling
for ($i = 0; $i < $num_chunks; ++$i) {
$chunk = APPPATH . 'temp/chunk-' . $code . '-' . $i . '.dat';
}
if ($damaged) {
throw new Exception('One or more chunks failed to be read');
}
}
/**
* Returns the form for updating a file which has been uploaded
*
* @get array file File details, as per the File API; 'lastModifiedDate', 'name', 'size', 'type'
* @get array result The full JSON response from the ajaxDragdropDone call
* @get array form Details of the form shown above the drag-n-drop field
* @return void Outputs HTML
**/
public function ajaxDragdropForm()
{
$_GET['file']['name'] = trim(Enc
::cleanfunky($_GET['file']['name']));
if (!FileUpload::checkFilename($_GET['file']['name'])) {
echo '<p>This type of file cannot be uploaded.</p>';
return;
}
$data = [];
$data['name'] = str_replace('_', ' ', File::getNoext($_GET['file']['name']));
// Determine type from extension
$data['type'] = FileConstants::TYPE_OTHER;
foreach (FileConstants::$type_exts as $type => $exts) {
$data['type'] = $type;
break;
}
}
// Attempt to use the last modified date as the publish date
$ts = strtotime(@$_GET['file']['lastModifiedDate']); $data['date_published'] = date('Y-m-d', $ts);
$data['embed_author'] = 1;
$view = new View('sprout/admin/file_add_dragdrop_form');
$view->tmp_file = $_GET['result']['tmp_file'];
$view->orig_file = $_GET['file'];
$view->size_bytes = filesize(APPPATH
. 'temp/' . $_GET['result']['tmp_file']); $view->errors = [];
$view->categories = Pdb::lookup('files_cat_list');
if ($data['type'] == FileConstants::TYPE_IMAGE) {
$temp_path = APPPATH . 'temp/' . $view->tmp_file;
try {
$view->shrunk_img = File::base64Thumb($temp_path, 200, 200);
$max_dims = Kohana::config('image.original_size');
$shrink_original = false;
if ($view->shrunk_img['original_width'] > $max_dims['width']) {
$shrink_original = true;
} else if ($view->shrunk_img['original_height'] > $max_dims['height']) {
$shrink_original = true;
}
$view->shrink_original = $shrink_original;
$data['shrink_original'] = 1;
}
} catch (Exception $ex) {
$view->image_too_large = true;
}
}
$view->data = $data;
// Only one category? Select that. Category specified? Select that.
if (count($view->categories) == 1) { $view->data['category_id'] = key($view->categories); } else if (!empty($_GET['form']['category_id'])) { $view->data['category_id'] = $_GET['form']['category_id'];
}
$q = "SELECT id, name
FROM ~document_types
ORDER BY record_order";
$view->document_types = Pdb::q($q, [], 'map');
echo $view->render();
}
/**
* Handles the drag-and-drop upload form
*
* Output JSON should be:
* success 1 or 0
* message Error message, if success is 0
* html Confirmation HTML, if success is 1
*
* @return void Outputs JSON
**/
public function ajaxDragdropSave()
{
Csrf::checkOrDie();
$_POST['orig_name'] = trim(Enc
::cleanfunky($_POST['orig_name'])); $_POST['name'] = trim(Enc
::cleanfunky($_POST['name']));
if (!FileUpload::checkFilename($_POST['orig_name'])) {
Json::error('This type of file cannot be uploaded');
}
if (!$_POST['name']) {
Json::error('You must specify a name');
}
// For images, calculate the expected RAM requirement of the resizing
// and confirm it's within the memory limit
if ($type == FileConstants::TYPE_IMAGE) {
$dimensions = getimagesize(APPPATH
. 'temp/' . $_POST['tmp_file']); try {
File::calculateResizeRam($dimensions); } catch (Exception $ex) {
Json::error($ex);
}
}
Pdb::transact();
$update_fields = array(); $update_fields['name'] = $_POST['name'];
$update_fields['type'] = $type;
$update_fields['date_added'] = Pdb::now();
$update_fields['date_modified'] = Pdb::now();
if (isset($_POST['document_type'])) { $update_fields['document_type'] = $_POST['document_type'];
}
if (isset($_POST['date_published'])) { $update_fields['date_published'] = $_POST['date_published'];
} else {
$update_fields['date_published'] = Pdb::now();
}
$update_fields['author'] = @$_POST['author'];
$update_fields['embed_author'] = @$_POST['embed_author'] ? 1 : 0;
$update_fields['sha1'] = hash_file('sha1', APPPATH
. 'temp/' . $_POST['tmp_file'], false);
try {
$file_id = Pdb::insert('files', $update_fields);
} catch (QueryException $ex) {
return Json::error('Database error');
}
$filename = $file_id . '_' . File::filenameMakeSane($_POST['orig_name']);
// Filename is only set after upload because the ID is in the name
$update_fields = array(); $update_fields['filename'] = $filename;
try {
Pdb::update('files', $update_fields, ['id' => $file_id]);
} catch (QueryException $ex) {
return Json::error('Database error');
}
// Update categories
if (!empty($_POST['category_id'])) { Category::insertInto('files', $file_id, $_POST['category_id']);
}
// Actually move the file in
$src = APPPATH . 'temp/' . $_POST['tmp_file'];
if (!empty($_POST['shrink_original'])) { $max_dims = Kohana::config('image.original_size');
if ($size[0] > $max_dims['width'] or $size[1] > $max_dims['height']) {
$temp_path = APPPATH
. 'temp/original_image_' . time() . '_' . Sprout
::randStr(4); $temp_path .= '.' . File::getExt($filename); $img = new Image($src);
$img->resize($max_dims['width'], $max_dims['height']);
$img->save($temp_path);
$result = File::putExisting($filename, $temp_path); } else {
$result = File::moveUpload($src, $filename); }
} else {
$result = File::moveUpload($src, $filename); }
if (!$result) {
return Json::error('Copying temporary file into media repository failed');
}
// Index documents, resize images, etc
try {
File::postUploadProcessing($filename, $file_id, $type); } catch (Exception $ex) {
Json::error($ex);
}
Pdb::commit();
$html = '<div class="file-upload__item__feedback__response file-upload__item__feedback__response--success file-upload__item__feedback__response--success--not-image">';
$html .= '<p class="file-upload__item__feedback__name"><a href="admin/edit/file/' . $file_id . '" target="_blank">' . Enc::html($filename) . '</a></p>';
$html .= '<p class="file-upload__item__feedback__size">' . File::humanSize(File::size($filename)) . '</p>'; $html .= '</div>';
Json::confirm([
'html' => $html,
'file_id' => $file_id,
'filename' => $filename,
]);
}
/**
* Cancel an upload - delete temporary files.
*
* May receive two different variations on the provided POST data:
* [result][tmp_file] Whole file was uploaded
* [partial_upload][code] Only some chunks of the file have been uploaded
*
* @return void
*/
public function ajaxDragdropCancel()
{
// The file upload controller has a perfect implementation of this, so just use that
$ctlr = new \Sprout\Controllers\FileUploadController();
$ctlr->uploadCancel();
}
/**
* Matches user input against a list of possible authors for files
* @return void Outputs JSON directly (see {@see Json::out})
*/
public function ajaxAuthorLookup()
{
if (empty($_GET['term'])) Json
::out([]);
// Check extant author list
$conditions = [];
foreach ($terms as $term) {
$conditions[] = ['author', 'CONTAINS', Pdb::likeEscape($term)];
}
$params = [];
$clause = Pdb::buildClause($conditions, $params);
$q = "SELECT DISTINCT author
FROM ~files
WHERE {$clause}
ORDER BY author";
Json::out(Pdb::q($q, $params, 'col'));
}
/**
* Not used.
**/
public function _addSave(&$item_id)
{
return false;
}
/**
* Used by some AJAX stuff to return a JSON error
**/
private function ajaxErr($message)
{
Json
::out(array('valid' => 0, 'error' => $message)); return 0;
}
/**
* Does a quick upload (from the fileselector)
* Returns JSON.
**/
public function quickUpload()
{
Csrf::checkOrDie();
if (! AdminPerms::controllerAccess('file', 'add')) {
throw new Kohana_404_Exception();
}
$result = $this->doUpload(@$_POST['category_id']);
}
/**
* Used by the quick upload tool
* @param int $category_id ID of category to store file in
*/
private function doUpload($category_id)
{
$category_id = (int) $category_id;
// Check upload exists and has valid metadata
$allowed_exts = [];
if (@$_POST['type'] == 'image') {
$allowed_exts = ['png', 'jpg', 'jpeg', 'gif'];
}
try {
$temp_file = FileUpload::verify('admin_quick_upload', 'file', 0, $allowed_exts);
$filename = @$_POST['file'][0];
if (!$filename) {
return ['error' => 'File uploading failed'];
}
} catch (Exception $ex) {
return ['error' => $ex->getMessage()];
}
$filename = File::filenameMakeSane($filename);
// Don't allow executable files
if (!FileUpload::checkFilename($filename)) {
return array('error' => 'This type of file cannot be uploaded'); }
// Check name
$name = trim($_POST['name']); if (!$name) {
return array('error' => 'No name was supplied'); }
// Get type
$file_type = 0;
$ext = File::getExt($filename); foreach (FileConstants::$type_exts as $type => $exts) {
$file_type = $type;
break;
}
}
if ($file_type == 0) $file_type = FileConstants::TYPE_OTHER;
Pdb::transact();
// Add file
$update_data['name'] = $name;
$update_data['filename'] = $filename;
$update_data['type'] = $file_type;
$update_data['date_added'] = Pdb::now();
$update_data['date_modified'] = Pdb::now();
$update_data['date_file_modified'] = Pdb::now();
$update_data['sha1'] = hash_file('sha1', $temp_file, false);
try {
$file_id = Pdb::insert('files', $update_data);
} catch (Exception $ex) {
return array('error' => 'Unable to upload file; database error (main)'); }
// Add category
if (!empty($_POST['category_new'])) { if (!AdminPerms::controllerAccess('file', 'categories')) {
return array('error' => 'Unable to create category; no permissions'); }
try {
$category_id = Category::create('files', $_POST['category_new']);
} catch (Exception $ex) {
return array('error' => 'Unable to upload file; database error (cat)'); }
}
// Add file category
if ($category_id) {
try {
Category::insertInto('files', $file_id, $category_id);
} catch (Exception $ex) {
return array('error' => 'Unable to upload file; database error (joiner)'); }
}
// Upload the file - uses the file id
$filename = $file_id . '_' . $filename;
$result = File::moveUpload($temp_file, $filename); if (! $result) {
return array('error' => 'Failed to save the uploaded file in media repository'); }
// Update file name and do image resizing, text indexing, and other postprocessing
try {
File::postUploadProcessing($filename, $file_id, $file_type); } catch (Exception $ex) {
return array('error' => $ex->getMessage()); }
Pdb::commit();
'id' => $file_id,
'filename' => $filename,
'type' => $file_type,
'cat_id' => $category_id,
'rel_url' => File::relUrl($filename), );
}
/**
* Pre-render hook
**/
public function _editPreRender($view, $item_id)
{
if ($view->data['type'] == FileConstants::TYPE_IMAGE) {
$size = File::imageSize($view->item['filename']);
$view->img_dimensions = 'Unkown';
$view->sizes = [];
$view->original_image = '';
Notification::error('Image may be missing from server');
return;
}
$view->img_dimensions = $size[0] . 'x' . $size[1];
$parts = explode('.', $view->item['filename']); $view->sizes = File::glob($parts[0] . '.*.' . $parts[1]);
$image_url = File::resizeUrl($view->data['filename'], 'r200x0'); $image_url .= (strpos($image_url, '?') === false ?
'?' : '&'); $view->original_image = $image_url . 'version=' . Sprout::randStr(10);
} else if ($view->data['type'] == FileConstants::TYPE_DOCUMENT) {
$view->document_types = Pdb::lookup('document_types');
// Date published is a DATETIME, but the datepicker can't handle that
$view->data['date_published'] = date('Y-m-d', strtotime($view->data['date_published']));
// Clean up and prepare text preview
$preview = trim(Enc
::cleanFunky($view->data['plaintext'])); $preview = Text::limitWords($preview, 50, '...');
$view->preview = $preview;
}
}
/**
* Return the sub-actions for editing; for spec {@see AdminController::renderSubActions}
* @return array
*/
public function _getEditSubActions($item_id)
{
$actions = parent::_getEditSubActions($item_id);
$actions['usage'] = [
'url' => 'admin/extra/' . $this->controller_name . '/find_usage/' . $item_id,
'name' => 'Find Usage',
'class' => 'icon-link-button icon-before icon-search',
];
return $actions;
}
/**
* Saves the provided POST data into this file in the database
*
* @param int $item_id The record to update
* @param bool True on success, false on failure
* @throws QueryException
*/
public function _editSave($item_id)
{
$item_id = (int) $item_id;
$file = Pdb::get('files', $item_id);
$_SESSION['admin']['field_values'] = Validator
::trim($_POST);
$valid = new Validator($_POST);
$valid->required(['name']);
$valid->check('name', 'Validity::length', 1, 200);
$valid->check('description', 'Validity::length', 1, 10000);
$valid->check('author', 'Validity::length', 1, 80);
if (!empty($_FILES['replace']['name'])) { // Check upload is valid
if (!Upload::valid($_FILES['replace'])) {
Notification::error('Error with upload of replacement; you will need to re-select your file');
$valid->addFieldError('replace', 'File upload failed');
}
// Check type matches
$file_type = File::getType($_FILES['replace']['name']); if ($file['type'] != $file_type) {
Notification::error('Error with file upload; you will need to re-select your file');
$valid->addFieldError('replace', 'File must be of type: ' . FileConstants::$type_names[$file['type']]);
}
}
if ($valid->hasErrors()) {
$_SESSION['admin']['field_errors'] = $valid->getFieldErrors();
$valid->createNotifications();
return false;
}
$needs_regenerate_sizes = false;
// Upload the new file
$filename = $file['filename'];
$original_filename = $filename;
if (!empty($_FILES['replace']['name'])) { $new_filename = $item_id . '_' . File::filenameMakeSane($_FILES['replace']['name']);
if (!File::putExisting($new_filename, $_FILES['replace']['tmp_name'])) { Notification::error('File upload failed');
return false;
}
if ($file['type'] == FileConstants::TYPE_DOCUMENT) {
// Do document indexing
$ext = File::getExt($new_filename); $plain = '';
if (FileIndexing::isExtSupported($ext)) {
$plain = FileIndexing::getPlaintext($new_filename, $ext);
}
} else if ($file['type'] == FileConstants::TYPE_IMAGE) {
$needs_regenerate_sizes = true;
// No sense in keeping the focal point for a replaced image
$_POST['focal_points'] = '';
}
Notification::confirm('New file uploaded successfully');
// Delete original
if ($filename != $new_filename) {
// Remove any redirects from new filename, to prevent unnecessary redirection
$pattern = 'files/' . File::getNoext($filename) . '.'; $params = [];
$conditions = [['path_exact', 'BEGINS', $pattern]];
$q = "DELETE FROM ~redirects WHERE " . Pdb::buildClause($conditions, $params);
Pdb::q($q, $params, 'null');
}
$filename = $new_filename;
}
// Image manipulations
if ($file['type'] == FileConstants::TYPE_IMAGE) {
// Do image manipulations, if requested
if ($_POST['manipulate'] != '') {
$temp_filename = File::createLocalCopy($file['filename']); if (! $temp_filename) return false;
$img = new Image($temp_filename);
if (! $img) return false;
switch ($_POST['manipulate']) {
case 'rotate-90-clockwise': $img->rotate(90); break;
case 'rotate-90-counterclockwise': $img->rotate(-90); break;
case 'rotate-180': $img->rotate(180); break;
case 'flip-horizontal': $img->flip(Image::HORIZONTAL); break;
case 'flip-vertical': $img->flip(Image::VERTICAL); break;
default:
throw new Exception('Invalid image manipulation "' . $_POST['manipulate'] . '"');
}
$res = $img->save();
if (! $res) return false;
$result = File::putExisting($file['filename'], $temp_filename); if (! $result) return false;
File::cleanupLocalCopy($temp_filename);
$res = Replication::postFileUpdate($file['filename']);
if (! $res) return false;
$needs_regenerate_sizes = true;
// No sense in keeping the focal point for a manipulated image
$_POST['focal_points'] = '';
Notification::confirm('Image was manipulated successfully');
}
// If author (or embed option) has changed, the sizes will need regeneration
if (
$file['embed_author'] != (int) @$_POST['embed_author']
or
((int) @$_POST['embed_author']) and $file['author'] != $_POST['author']
) {
$needs_regenerate_sizes = true;
}
}
Pdb::transact();
// Update record
$data = [];
$data['date_modified'] = Pdb::now();
$data['name'] = $_POST['name'];
$data['description'] = $_POST['description'];
$data['author'] = $_POST['author'];
$data['filename'] = $filename;
$data['enable_indexing'] = (int) @$_POST['enable_indexing'];
if ($file['type'] == FileConstants::TYPE_IMAGE) {
$data['embed_author'] = (int) @$_POST['embed_author'];
foreach ($points as $key => $point) {
continue;
}
continue;
}
}
} else {
$data['focal_points'] = '';
}
if ($data['focal_points'] != $file['focal_points']) {
$needs_regenerate_sizes = true;
}
} elseif ($file['type'] == FileConstants::TYPE_DOCUMENT) {
$data['document_type'] = $_POST['document_type'];
$data['date_published'] = $_POST['date_published'];
}
Pdb::update('files', $data, ['id' => $item_id]);
$this->reindexItem($item_id, $_POST['name'], $file['plaintext'], $data['enable_indexing']);
if ($file['type'] == FileConstants::TYPE_IMAGE and $needs_regenerate_sizes) {
File::createDefaultSizes($filename); File::deleteCache($filename); }
$this->updateCategories($item_id, @$_POST['categories']);
if ($original_filename != $filename) {
File::delete($original_filename); File::deleteCache($original_filename);
if ($file['type'] == FileConstants::TYPE_IMAGE) {
}
// Make sure old links still function by adding a redirect from the old file name to the new one
foreach ($variants as $variant) {
$old_path = 'files/' . $original_filename;
$new_path = 'file/download/' . $item_id;
// For image variants:
// convert e.g. 123_blah.jpg to 123_blah.small.jpg
// append size to redirect URL, e.g. file/123/small
if ($variant) {
$old_path = File::getResizeFilename($old_path, $variant); $new_path .= '/' . $variant;
}
'class' => '\\Sprout\\Helpers\\LinkSpecInternal',
'data' => $new_path,
]);
$redirect = [
'path_exact' => $old_path,
'destination' => $dest_link_spec,
'type' => 'Temporary',
'date_added' => Pdb::now(),
'date_modified' => Pdb::now(),
];
Pdb::insert('redirects', $redirect);
}
if ($file['type'] == FileConstants::TYPE_IMAGE) {
Pdb::update('pages', ['banner' => $filename], ['banner' => $original_filename]);
}
}
Pdb::commit();
return true;
}
/**
* Deletes an item and logs the deleted data
*
* This method DOES NOT remove files from disk, in case the deleted DB record needs to be restored.
* They are removed later, when the deletion log is cleared; see {@see ActionLogAdminController::cronCleanup}.
* If lack of disk space is an issue, the log should be cleared more often, or alternate file backends should be
* used; see {@see FilesBackend}.
*
* @param int $item_id The record to delete
* @return bool True on success, false on failure
* @throws QueryException
*/
public function _deleteSave($item_id)
{
return parent::_deleteSave($item_id);
}
/**
* Does a re-index for a file
*
* @param int $item_id
* @param string $name
* @param string $plaintext
* @param bool $enabled
* @return bool True on success
*/
private function reindexItem($item_id, $name, $plaintext, $enabled = true)
{
$enabled = (bool) $enabled;
Search::selectIndex('file_keywords', $item_id);
$res = Search::clearIndex();
if (! $res) return false;
// File is marked as not to be included in search results
if (!$enabled) return true;
$res = Search::indexText($name, 4);
if (! $res) return false;
if ($plaintext) {
$res = Search::indexHtml($plaintext, 1);
if (! $res) return false;
}
$res = Search::cleanup('files');
if (! $res) return false;
return true;
}
/**
* Does a complete re-index of all files
**/
public function reindexAll()
{
AdminAuth::checkLogin();
Pdb::transact();
$q = "SELECT id, name, filename, plaintext, enable_indexing FROM ~files";
$res = Pdb::query($q, [], 'pdo');
foreach ($res as $row) {
$plain = '';
if ($row['plaintext'] == '') {
$ext = File::getExt($row['filename']);
if (FileIndexing::isExtSupported($ext)) {
$plain = FileIndexing::getPlaintext($row['filename'], $ext);
if ($plain) {
Pdb::update('files', ['plaintext' => $plain], ['id' => $row['id']]);
}
}
}
$this->reindexItem($row['id'], $row['name'], $plain ?: $row['plaintext'], $row['enable_indexing']);
}
$res->closeCursor();
Pdb::commit();
echo '<p>Success</p>';
}
/**
* Process the results of a search.
*
* @param array $row A single row of data to output
* @return string The result string
**/
public function frontEndSearch($item_id, $relevancy, $keywords)
{
$q = "SELECT name, filename, plaintext, enable_indexing FROM sprout_files WHERE id = ?";
$row = Pdb::q($q, [$item_id], 'row');
// File is marked as not to be included in search results
if ($row['enable_indexing'] == 0) return null;
$text = substr($text, 0, 5000);
// Look for the first keyword in the text
$pos = 5000;
$matches = null;
foreach ($keywords as $k) {
if (preg_match("/(^|\W){$k}($|\W)/i", $text, $matches, PREG_OFFSET_CAPTURE
)) { $pos = min($pos, $matches[0][1]); }
}
// If anything was found in first 5000 chars, show that bit
if ($pos < 5000) {
$pos -= 10;
if ($pos > 1) {
$text = '...' . substr($text, $pos); }
}
// Limit to something more reasonable
$text = Text::limitWords($text, 40, '...');
// Bolden keywords
$name = $row['name'];
foreach ($keywords as $k) {
$name = preg_replace("/(^|\W)({$k})($|\W)/i", '$1<b>$2</b>$3', $name); $text = preg_replace("/(^|\W)({$k})($|\W)/i", '$1<b>$2</b>$3', $text); }
$view = new View('sprout/search_results_page');
$view->name = $name;
$view->url = File::url($row['filename']); $view->text = $text;
$view->relevancy = $relevancy;
return $view->render();
}
/**
* Return the list of sidebar tools
**/
public function _getTools()
{
$tools = parent::_getTools();
$tools[] = '<li class="config"><a href="admin/extra/file/cleanup_invalid">Cleanup invalid files</a></li>';
if (AdminAuth::isSuper()) {
$tools[] = '<li class="config"><a href="admin/extra/file/redo_sizes">Recreate resized images</a></li>';
}
return $tools;
}
/**
* Provides a UI for doing a 'cleanup' - removes invalid files
**/
public function _extraCleanupInvalid()
{
$view = new View("sprout/admin/file_cleanup_invalid");
$view->count_delete = 0;
$q = "SELECT id, filename, name, type FROM ~files";
$res = Pdb::query($q, [], 'pdo');
foreach ($res as $file) {
if ($file['name'] == '') {
$view->count_delete++;
} else if ($file['type'] == FileConstants::TYPE_NONE) {
$view->count_delete++;
} else if (!File::exists($file['filename'])) { $view->count_delete++;
}
}
$res->closeCursor();
'title' => 'Cleanup invalid files',
'content' => $view->render()
);
}
/**
* Does file cleanup - wrapper (admin)
**/
public function cleanupInvalidAction()
{
AdminAuth::checkLogin();
Csrf::checkOrDie();
try {
$this->cleanupInvalidActionInner();
} catch (QueryException $ex) {
Notification::error('Database error performing cleanup');
Url::redirect('admin/extra/file/cleanup_invalid');
}
Notification::confirm('Cleanup was successful');
Url::redirect('admin/intro/file');
}
/**
* Does file cleanup - wrapper (cron)
**/
public function cronCleanupInvalid()
{
Cron::start('Cleanup invalid files');
$this->cleanupInvalidActionInner();
Cron::success();
}
/**
* Remove invalid files, such as:
* - Files without a name
* - Files without a type
* - Files which don't actually exist
*
* @throws QueryExeption
**/
private function cleanupInvalidActionInner()
{
Pdb::transact();
$joiner_table = Category::tableMain2joiner('files');
$q = "SELECT id, filename, name, type FROM ~files";
$res = Pdb::query($q, [], 'pdo');
foreach ($res as $file) {
$delete = false;
if ($file['name'] == '') {
$delete = true;
} else if ($file['type'] == FileConstants::TYPE_NONE) {
$delete = true;
} else if (!File::exists($file['filename'])) { $delete = true;
}
if ($delete) {
Pdb::delete('files', ['id' => $file['id']]);
Pdb::delete($joiner_table, ['file_id' => $file['id']]);
}
}
$res->closeCursor();
Pdb::commit();
}
/**
* Return HTML for a resultset of items
* The returned HTML will be sandwiched between the refinebar and the pagination bar.
*
* @param Traversable $items The items to render.
* @param string $mode The mode of the display.
* @param StdClass $category Category details if a category has been selected.
**/
public function _getContentsView($items, $mode, $category)
{
if ($mode == 'list') {
return $this->_getContentsViewList($items, $category);
} else if ($mode == 'thumb') {
return $this->_getContentsViewThumb($items, $category);
}
}
/**
* Thumbnail view for files
**/
private function _getContentsViewThumb($items, $category)
{
$view = new View("sprout/admin/file_contents_thumbs");
$view->controller_name = $this->controller_name;
$view->friendly_name = $this->friendly_name;
$view->items = $items;
$view->allow_add = $this->main_add;
$view->category = $category;
return $view->render();
}
/**
* On the fly image resizing
*
* The size parameter is the new size.
* The first character is taken to be the resize type, accepts 'r' or 'c'.
* The width and height is specified width . 'x' . height (e.g. 200x100)
**/
public function previewTransform($transform, $filename)
{
$temp_filename = File::createLocalCopy($filename);
$img = new Image($temp_filename);
$img->resize(200, 0);
switch ($transform) {
case 'rotate-90-clockwise':
$img->rotate(90);
break;
case 'rotate-90-counterclockwise':
$img->rotate(-90);
break;
case 'rotate-180':
$img->rotate(180);
break;
case 'flip-horizontal':
$img->flip(Image::HORIZONTAL);
break;
case 'flip-vertical':
$img->flip(Image::VERTICAL);
break;
}
// Content-type
'gif' => 'image/gif',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
);
$mime = $mime[$ext];
if (! $mime) $mime = 'application/octet-stream';
header('Content-type: ' . $mime); $img->render();
File::cleanupLocalCopy($temp_filename); }
/**
* Outputs the file selector HTML
*
* @return void
*/
public function selectorPopup()
{
$field_name = trim(@$_GET['field']);
$view = new View('sprout/admin/file_selector_popup');
$view->field_name = $field_name;
$view->f_type = (int) $_GET['f_type'];
$view->cats = Pdb::lookup('files_cat_list');
$view->upload = isset($_GET['upload']) ?
(int
) $_GET['upload'] : 1; $view->browse = isset($_GET['browse']) ?
(int
) $_GET['browse'] : 1; $view->req_category = isset($_GET['req_category']) ?
(int
) $_GET['req_category'] : 1;
if (! AdminPerms::controllerAccess('file', 'add')) {
$view->upload = false;
}
if (! AdminPerms::controllerAccess('file', 'contents')) {
$view->browse = false;
}
$view->cat_create = AdminPerms::controllerAccess('file', 'categories');
echo $view->render();
}
/**
* Does the backend selector search
**/
public function selectorPopupSearch()
{
if (! AdminPerms::controllerAccess('file', 'contents')) {
throw new Kohana_404_Exception();
}
$_GET['page'] = (int) $_GET['page'];
$_GET['f_type'] = (int) $_GET['f_type'];
$_GET['name'] = trim($_GET['name']); $_GET['category_id'] = (int) $_GET['category_id'];
$joins = [];
$clauses = [];
$params = [];
if ($_GET['category_id'] > 0) {
$joins[] = "INNER JOIN ~files_cat_join AS joiner
ON files.id = joiner.file_id AND joiner.cat_id = ?";
$params[] = $_GET['category_id'];
}
if ($_GET['f_type'] > 0) {
$clauses[] = 'files.type = ?';
$params[] = $_GET['f_type'];
} else {
$clauses[] = 'files.type != 0';
}
if ($_GET['name']) {
foreach ($parts as $part) {
$part = Pdb::likeEscape($part);
$clauses[] = "(files.name LIKE CONCAT('%', ?, '%') OR files.filename LIKE CONCAT('%', ?, '%'))";
// Doubly add param, once for each LIKE clause
$params[] = $part;
$params[] = $part;
}
}
$clauses = implode(' AND ', $clauses);
$q = "SELECT COUNT(DISTINCT id) AS C
FROM ~files AS files
{$joins}
WHERE {$clauses}";
$num_results = Pdb::q($q, $params, 'val');
$num_pages = ceil($num_results / 10);
$offset = 10 * $_GET['page'];
$q = "SELECT DISTINCT files.id, files.name, files.filename
FROM ~files AS files
{$joins}
WHERE {$clauses}
ORDER BY files.name
LIMIT 10 OFFSET {$offset}";
$res = Pdb::q($q, $params, 'arr');
// Json return object
'num_results' => $num_results,
'num_pages' => $num_pages,
'curr_page' => $_GET['page'],
);
// Append one json record for each db record
foreach ($res as $row) {
$preview = $preview_large = '';
if ($_GET['f_type'] == FileConstants
::TYPE_IMAGE or
strpos(File::mimetype($row['filename']), 'image/') === 0) { if (File::exists($row['filename'])) { $preview = '<img src="' . str_replace('SITE/', '', File::resizeUrl($row['filename'], 'c50x50')) . '">'; $preview_large = str_replace('SITE/', '', File::resizeUrl($row['filename'], 'm400x150')); }
}
'id' => $row['id'],
'filename' => $row['filename'],
'name' => $row['name'],
'preview' => $preview,
'preview_large' => $preview_large,
);
}
Json::out($json);
}
/**
* List of links to redo the sizes
**/
public function _extraRedoSizes()
{
$sizes = Kohana::config('file.image_transformations');
$sz = ['' => 'All'];
foreach ($sizes as $size_name => $transform) {
$sz[$size_name] = ucfirst($size_name); }
$out = '<form action="admin/call/file/redoSizesAction" method="post">';
$out .= Csrf::token();
Form::nextFieldDetails('Size', true);
$out .= Form::dropdown('size', [], $sz);
$out .= '<button type="submit" class="button">Re-generate sizes</button>';
$out .= '</form>';
return $out;
}
/**
* Fixes files which don't have the sizes they should
**/
public function redoSizesAction()
{
AdminAuth::checkLogin();
Csrf::checkOrDie();
try {
$info = WorkerCtrl::start('Sprout\\Helpers\\WorkerRedoSizes', $_POST['size']);
} catch (WorkerJobException $ex) {
Notification::error('Unable to create worker job');
Url::redirect('admin/intro/file');
}
Url::redirect($info['log_url']);
}
/**
* Find usage of a given file
*
* @param int $file_id
* @return array
*/
public function _extraFindUsage($file_id)
{
$file = Pdb::get('files', $file_id);
$view = new View('sprout/admin/file_usage');
$view->usage = File::findUsage($file['filename']);
return [
'title' => sprintf("Usage of file '%s'", $file['name'] ?
$file['name'] : $file['filename']), 'content' => $view
];
}
/**
* Renders a HTML subset containing a focal crop image
*
* @param string $size WxH, e.g. 300x200
* @param string $filename E.g. 123_image.jpg
* @param string $focal_point_data JSON to store in files.focal_points
*/
public function previewFocalCrop($size, $filename, $focal_point_data)
{
if ($size[0] != 'c') {
$size = 'c' . $size;
}
// Copy original file to test location
$content = File::getString($filename); $temp_filename = 'focal_preview_' . $filename;
File::putString($temp_filename, $content);
Pdb::transact();
Pdb::update('files', ['focal_points' => $focal_point_data, 'filename' => $temp_filename], ['filename' => $filename]);
$_GET['s'] = Security::serverKeySign(['filename' => $temp_filename, 'size' => $size]);
$_GET['force'] = 1;
$cont = new \Sprout\Controllers\FileController();
$cont->resize($size, $temp_filename);
Pdb::rollback();
File::deleteCache($temp_filename); File::delete($temp_filename); }
/**
* Provide the contents of a temporarily uploaded file, for e.g. listening to uploaded audio
*
* @return void Sets headers and outputs file content
*/
public function downloadTemp($filename)
{
$path = APPPATH . 'temp/' . $filename;
http_response_code(404);
}
header('Content-type: application/octet-stream'); header("Cache-Control: no-store, no-cache"); header('Expires: Thu, 01 Jan 1970 00:00:00 GMT'); header('Last-modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); Kohana::closeBuffers();
}
}