Working on file uploader

This commit is contained in:
Bob den Otter
2018-10-17 13:19:03 +02:00
parent 6cde6d7ab6
commit fd00a4ca9f
10 changed files with 723 additions and 11 deletions
+3 -2
View File
@@ -93,11 +93,12 @@ class Config
/**
* @param string $path
* @param bool $absolute
* @param bool $absolute
* @param mixed $additional
*
* @return string
*/
public function getPath(string $path, bool $absolute = true, string $additional = ''): string
public function getPath(string $path, bool $absolute = true, $additional = null): string
{
return $this->pathResolver->resolve($path, $absolute, $additional);
}
+7 -5
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Bolt\Configuration;
use Exception;
use Tightenco\Collect\Support\Collection;
use Webmozart\PathUtil\Path;
@@ -92,12 +93,13 @@ class PathResolver
* - `foo/bar` - A relative path that will be resolved against the root path.
* - `/tmp` - An absolute path will be returned as is.
*
* @param string $path the path
* @param bool $absolute if the path is relative, resolve it against the root path
* @param string $path the path
* @param bool $absolute if the path is relative, resolve it against the root path
* @param mixed $additional
*
* @return string
*/
public function resolve(string $path, bool $absolute = true, string $additional = ''): string
public function resolve(string $path, bool $absolute = true, $additional = null): string
{
if (isset($this->paths[$path])) {
$path = $this->paths[$path];
@@ -129,8 +131,8 @@ class PathResolver
$path = Path::makeAbsolute($path, $this->paths['root']);
}
if ($additional !== '') {
$path .= '/' . $additional;
if (!empty($additional)) {
$path .= DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, (array) $additional);
}
// Make sure we don't have lingering unneeded dir-seperators
+56
View File
@@ -7,14 +7,19 @@ declare(strict_types=1);
namespace Bolt\Content;
use Bolt\Common\Json;
use Bolt\Configuration\Config;
use Bolt\Entity\Media;
use Bolt\Media\Item;
use Bolt\Repository\MediaRepository;
use Carbon\Carbon;
use Cocur\Slugify\Slugify;
use Faker\Factory;
use PHPExif\Reader\Reader;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\SplFileInfo;
use Webmozart\PathUtil\Path;
class MediaFactory
{
@@ -116,4 +121,55 @@ class MediaFactory
return $user;
}
/**
* @param Item $item
* @param $params
* @return Media
*/
public function createFromUpload(Item $item, $params): Media
{
if (Json::test($params)) {
$params = Json::parse($params);
$addedPath = $params['path'];
$area = $params['area'];
} else {
$addedPath = '';
$area = 'files';
}
$targetFilename = $addedPath . DIRECTORY_SEPARATOR . $this->sanitiseFilename($item->getName());
$source = $this->config->getPath('cache', true, ['uploads', $item->getId(), $item->getName()]);
$target = $this->config->getPath($area, true, $targetFilename);
$relPath = Path::getDirectory($targetFilename);
$relName = Path::getFilename($targetFilename);
// Move the file over
$fileSystem = new Filesystem();
$fileSystem->rename($source, $target, true);
$file = new SplFileInfo($target, $relPath, $relName);
$media = $this->createOrUpdateMedia($file, $area);
return $media;
}
/**
* @param string $filename
* @return string
*/
private function sanitiseFilename(string $filename): string
{
$extensionSlug = new Slugify(['regexp' => '/([^a-z0-9]|-)+/']);
$filenameSlug = new Slugify(['lowercase' => false]);
$extension = $extensionSlug->slugify(Path::getExtension($filename));
$filename = $filenameSlug->slugify(Path::getFilenameWithoutExtension($filename));
return $filename . '.' . $extension;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace Bolt\Controller\Async;
use Bolt\Content\MediaFactory;
use Bolt\Media\RequestHandler;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class Uploader
{
/** @var MediaFactory*/
private $mediaFactory;
/** @var RequestHandler */
private $requestHandler;
/** @var ObjectManager */
private $manager;
public function __construct(MediaFactory $mediaFactory, RequestHandler $requestHandler, ObjectManager $manager)
{
$this->mediaFactory = $mediaFactory;
$this->requestHandler = $requestHandler;
$this->manager = $manager;
}
/**
*
* @Route("/async/upload", name="bolt_upload_post", methods={"POST"})
*
*/
public function upload(Request $request)
{
// Get submitted field data item, will always be one item in case of async upload
$items = $this->requestHandler->loadFilesByField('filepond');
// If no items, exit
if (count($items) === 0) {
// Something went wrong, most likely a field name mismatch
http_response_code(400);
return;
}
$params = $request->request->get('filepond', []);
foreach($items as $item) {
$media = $this->mediaFactory->createFromUpload($item, current($params));
dump($media);
$this->manager->persist($media);
$this->manager->flush();
}
// Returns plain text content
header('Content-Type: text/plain');
// Remove item from array Response contains uploaded file server id
echo array_shift($items)->getId();
return new Response("Finis!");
}
}
+5 -2
View File
@@ -37,11 +37,14 @@ class EditRecordController extends BaseController
/**
* @Route("/edit/{id}", name="bolt_edit_record", methods={"GET"})
*
* @param string $id
* @param Request $request
* @param string $id
* @param Request $request
* @param Content|null $content
*
* @return Response
* @throws \Twig_Error_Loader
* @throws \Twig_Error_Runtime
* @throws \Twig_Error_Syntax
*/
public function edit(string $id, Request $request, Content $content = null): Response
{
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace Bolt\Media;
/**
* A wrapper class for easier access to $_FILES object
*/
class Item {
// counter that helps in ensuring each file receives a truly unique id
public static $item_counter = 0;
// item props
private $id;
private $file;
private $name;
public function __construct($file, $id = null) {
$this->id = isset($id) ? $id : md5( uniqid(self::$item_counter++, true) );
$this->file = $file;
$this->name = $file['name'];
}
public function rename($name, $extension = null) {
$info = pathinfo($this->name);
$this->name = $name . '.' . ( isset($extension) ? $extension : $info['extension'] );
}
public function getId() {
return $this->id;
}
public function getFilename() {
return $this->file['tmp_name'];
}
public function getName() {
return basename($this->name);
}
public function getNameWithoutExtension() {
$info = pathinfo($this->name);
return $info['filename'];
}
public function getExtension() {
$info = pathinfo($this->name);
return $info['extension'];
}
public function getSize() {
return $this->file['size'];
}
public function getType() {
return $this->file['mime'];
}
}
+477
View File
@@ -0,0 +1,477 @@
<?php
namespace Bolt\Media;
use Bolt\Configuration\Config;
/**
* FilePond RequestHandler helper class
*/
/*
1. get files (from $files and $post)
2. store files in tmp/ directory and give them a unique server id
3. return server id's to client
4. either client reverts upload or finalizes form
5. call revert($server_id) to remove file from tmp/ directory
6. call save() to save file to final directory
*/
class RequestHandler
{
// the default location to save tmp files to
private $tmp_dir;
// regex to use for testing if a string is a file id
private $file_id_format = '/^[0-9a-fA-F]{32}$/';
/** @var Config */
private $config;
public function __construct(Config $config)
{
$this->config = $config;
$this->tmp_dir = $this->config->getPath('cache', true, ['uploads']);
}
/**
* @param $str
* @return bool
*/
public function isFileId($str) {
return preg_match($this->file_id_format, $str);
}
/**
* @param $str
* @return bool
*/
public function isURL($str) {
return filter_var($str, FILTER_VALIDATE_URL);
}
/**
* Catch all exceptions so we can return a 500 error when the server bugs out
*/
public function catchExceptions() {
set_exception_handler('FilePond\RequestHandler::handleException');
}
public function handleException($ex) {
// write to error log so we can still find out what's up
error_log('Uncaught exception in class="' . get_class($ex) . '" message="' . $ex->getMessage() . '" line="' . $ex->getLine() . '"');
// clean up buffer
ob_end_clean();
// server error mode go!
http_response_code(500);
}
private function createItem($file, $id = null) {
return new Item($file, $id);
}
/**
* @param $fieldName
* @return array
*/
public function loadFilesByField($fieldName) {
// See if files are posted as JSON string (each file being base64 encoded)
$base64Items = $this->loadBase64FormattedFiles($fieldName);
// retrieves posted file objects
$fileItems = $this->loadFileObjects($fieldName);
// retrieves files already on server
$tmpItems = $this->loadFilesFromTemp($fieldName);
// save newly received files to temp files folder (tmp items already are in that folder)
$this->saveAsTempFiles(array_merge($base64Items, $fileItems));
// return items
return array_merge($base64Items, $fileItems, $tmpItems);
}
private function loadFileObjects($fieldName) {
$items = [];
if ( !isset($_FILES[$fieldName]) ) {
return $items;
}
$FILE = $_FILES[$fieldName];
if (is_array($FILE['tmp_name'])) {
foreach( $FILE['tmp_name'] as $index => $tmpName ) {
array_push( $items, $this->createItem( array(
'tmp_name' => $FILE['tmp_name'][$index],
'name' => $FILE['name'][$index],
'size' => $FILE['size'][$index],
'error' => $FILE['error'][$index],
'type' => $FILE['type'][$index]
)) );
}
}
else {
array_push( $items, $this->createItem($FILE) );
}
return $items;
}
private function loadBase64FormattedFiles($fieldName) {
/*
// format:
{
"id": "iuhv2cpsu",
"name": "picture.jpg",
"type": "image/jpeg",
"size": 20636,
"metadata" : {...}
"data": "/9j/4AAQSkZJRgABAQEASABIAA..."
}
*/
$items = [];
if ( !isset($_POST[$fieldName] ) ) {
return $items;
}
// Handle posted files array
$values = $_POST[$fieldName];
// Turn values in array if is submitted as single value
if (!is_array($values)) {
$values = isset($values) ? array($values) : array();
}
// If files are found, turn base64 strings into actual file objects
foreach ($values as $value) {
// suppress error messages, we'll just investigate the object later
$obj = @json_decode($value);
// skip values that failed to be decoded
if (!isset($obj)) {
continue;
}
// test if this is a file object (matches the object described above)
if (!$this->isEncodedFile($obj)) {
continue;
}
array_push($items, $this->createItem( $this->createTempFile($obj) ) );
}
return $items;
}
private function isEncodedFile($obj) {
return isset($obj->id) && isset($obj->data) && isset($obj->name) && isset($obj->type) && isset($obj->size);
}
private function loadFilesFromTemp($fieldName) {
$items = [];
if ( !isset($_POST[$fieldName] ) ) {
return $items;
}
// Handle posted ids array
$values = $_POST[$fieldName];
// Turn values in array if is submitted as single value
if (!is_array($values)) {
$values = isset($values) ? array($values) : array();
}
// test if value is actually a file id
foreach ($values as $value) {
if ( $this->isFileId($value) ) {
array_push($items, $this->createItem($this->getTempFile($value), $value));
}
}
return $items;
}
public function save($items, $path = 'uploads' . DIRECTORY_SEPARATOR) {
// is list of files
if ( is_array($items) ) {
$results = [];
foreach($items as $item) {
array_push($results, $this->saveFile($item, $path));
}
return $results;
}
// is single item
return $this->saveFile($items, $path);
}
/**
* @param $file_id
* @return bool
*/
public function deleteTempFile($file_id) {
return $this->deleteTempDirectory($file_id);
}
/**
* @param $url
* @return array|bool
*/
public function getRemoteURLData($url)
{
try {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$content = curl_exec($ch);
if ($content === FALSE) {
throw new Exception(curl_error($ch), curl_errno($ch));
}
$type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$length = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close ($ch);
$success = $code >= 200 && $code < 300;
return array(
'code' => $code,
'content' => $content,
'type' => $type,
'length' => $length,
'success' => $success
);
}
catch(Exception $e) {
return null;
}
}
private function saveAsTempFiles($items) {
foreach($items as $item) {
$this->saveTempFile($item);
}
}
private function saveTempFile($file) {
// make sure path name is safe
$path = $this->getSecureTempPath() . $file->getId() . DIRECTORY_SEPARATOR;
// Creates a secure temporary directory to store the files in
$this->createSecureDirectory($path);
// get source and target values
$source = $file->getFilename();
$target = $path . $file->getName();
// Move uploaded file to this new secure directory
$result = $this->moveFile($source, $target);
// Was not saved
if ($result !== true) { return $result; }
// Make sure file is secure
$this->setSecureFilePermissions($target);
// temp file stored successfully
return true;
}
public function getTempFile($fileId) {
// select all files in directory except .htaccess
foreach(glob($this->getSecureTempPath() . $fileId . DIRECTORY_SEPARATOR . '*.*') as $file) {
try {
$handle = fopen($file, 'r');
$content = fread($handle, filesize($file));
fclose($handle);
return array(
'name' => basename($file),
'content' => $content,
'type' => mime_content_type($file),
'length' => filesize($file)
);
}
catch(Exception $e) {
return null;
}
}
return false;
}
public function getFile($file, $path) {
try {
$filename = $path . DIRECTORY_SEPARATOR . $file;
$handle = fopen($filename, 'r');
$content = fread($handle, filesize($filename));
fclose($handle);
return array(
'name' => basename($filename),
'content' => $content,
'type' => mime_content_type($filename),
'length' => filesize($filename)
);
}
catch(Exception $e) {
return null;
}
}
private function saveFile($item, $path) {
// nope
if (!isset($item)) {
return false;
}
// if is file id
if (is_string($item)) {
return $this->moveFileById($item, $path);
}
// is file object
else {
return $this->moveFileById($item->getId(), $path, $item->getName());
}
}
private function moveFileById($fileId, $path, $fileName = null) {
// select all files in directory except .htaccess
foreach(glob($this->getSecureTempPath() . $fileId . DIRECTORY_SEPARATOR . '*.*') as $file) {
$source = $file;
$target = $this->getSecurePath($path);
$this->createDirectory($target);
rename($source, $target . (isset($fileName) ? basename($fileName) : basename($file)));
}
// remove directory
$this->deleteTempDirectory($fileId);
// done!
return true;
}
private function deleteTempDirectory($id) {
@array_map('unlink', glob($this->getSecureTempPath() . $id . DIRECTORY_SEPARATOR . '{.,}*', GLOB_BRACE));
// remove temp directory
@rmdir($this->getSecureTempPath() . $id);
}
private function createTempFile($file) {
$tmp = tmpfile();
fwrite($tmp, base64_decode($file->data));
$meta = stream_get_meta_data($tmp);
$filename = $meta['uri'];
return array(
'error' => 0,
'size' => filesize($filename),
'type' => $file->type,
'name' => $file->name,
'tmp_name' => $filename,
'tmp' => $tmp
);
}
private function moveFile($source, $target) {
if (is_uploaded_file($source)) {
return move_uploaded_file($source, $target);
}
else {
$tmp = fopen($source, 'r');
$result = file_put_contents( $target, fread($tmp, filesize($source) ) );
fclose($tmp);
return $result;
}
}
private function getSecurePath($path) {
return pathinfo($path)['dirname'] . DIRECTORY_SEPARATOR . basename($path) . DIRECTORY_SEPARATOR;
}
private function getSecureTempPath() {
return $this->getSecurePath($this->tmp_dir);
}
private function setSecureFilePermissions($target) {
$stat = stat( dirname($target) );
$perms = $stat['mode'] & 0000666;
@chmod($target, $perms);
}
private function createDirectory($path) {
if (is_dir($path)) {
return false;
}
mkdir($path, 0755, true);
return true;
}
private function createSecureDirectory($path) {
// !! If directory already exists we assume security is handled !!
// Test if directory already exists and correct
if ($this->createDirectory($path)) {
// Add .htaccess file for security purposes
$content = '# Don\'t list directory contents
IndexIgnore *
# Disable script execution
AddHandler cgi-script .php .pl .jsp .asp .sh .cgi
Options -ExecCGI -Indexes';
file_put_contents($path . '.htaccess', $content);
}
}
}
+1 -1
View File
@@ -24,7 +24,7 @@
{% set dimensions = '' %}
{% if extension in imageformats %}
{% set thumbnail = filename|thumbnail(100, 72) %}
{% set thumbnail = filename|thumbnail(width = 100, height = 72, area = area) %}
{% set icon = 'fa-image' %}
{% set link = path('bolt_media_new', {'area': area, 'file': filename}) %}
+40
View File
@@ -0,0 +1,40 @@
<link rel="stylesheet" href="https://unpkg.com/filepond/dist/filepond.min.css">
<link rel="stylesheet" href="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css">
<script src="https://unpkg.com/filepond-plugin-image-preview"></script>
<script src="https://unpkg.com/filepond-plugin-file-metadata/dist/filepond-plugin-file-metadata.js"></script>
<script src="https://unpkg.com/filepond"></script>
<script src="https://unpkg.com/vue"></script>
<script src="https://unpkg.com/vue-filepond"></script>
<div class="card mb-4">
<div class="card-header">File uploader</div>
<div id="uploader">
<file-pond name="filepond[]"
ref="pond"
allow-multiple="true"
accepted-file-types="image/jpeg, image/png"
server="/async/upload"></file-pond>
</div>
</div>
<script>
FilePond.registerPlugin(
FilePondPluginFileMetadata,
FilePondPluginImagePreview
);
FilePond.setOptions({
fileMetadataObject: {
'area': '{{ area }}',
'path': '{{ path }}'
}
});
new Vue({
el: '#uploader',
components: {
FilePond: vueFilePond.default()
}
});
</script>
+9 -1
View File
@@ -18,13 +18,16 @@
{% block aside %}
<div class="card">
<div class="card mb-4">
<div class="card-header">Meta information</div>
<div class="card-body">
</div>
</div>
{% include '@bolt/finder/_uploader.twig' %}
{% endblock aside %}
@@ -32,6 +35,11 @@
{{ parent() }}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/selectize.js/0.12.6/css/selectize.min.css" integrity="sha256-EhmqrzYSImS7269rfDxk4H+AHDyu/KwV1d8FDgIXScI=" crossorigin="anonymous" />
<style>
.filepond--root {
margin-bottom: 0 !important;
}
</style>
{% endblock %}