Implemented a Javascript viewmodel for analysing an album, added checks for if uploads cannot be completed. Implemented handling if POST request is over the max size configured in php.ini

This commit is contained in:
Andy Heathershaw 2016-09-24 08:17:51 +01:00
parent 6be14d385a
commit fde988e359
11 changed files with 333 additions and 92 deletions

View File

@ -4,6 +4,39 @@ namespace App\Helpers;
class MiscHelper
{
public static function convertToBytes($val)
{
if(empty($val))return 0;
$val = trim($val);
preg_match('#([0-9]+)[\s]*([a-z]+)#i', $val, $matches);
$last = '';
if(isset($matches[2])){
$last = $matches[2];
}
if(isset($matches[1])){
$val = (int) $matches[1];
}
switch (strtolower($last))
{
case 'g':
case 'gb':
$val *= 1024;
case 'm':
case 'mb':
$val *= 1024;
case 'k':
case 'kb':
$val *= 1024;
}
return (int) $val;
}
public static function gravatarUrl($emailAddress, $size = 48, $default = 'identicon')
{
$hash = md5(strtolower(trim($emailAddress)));

View File

@ -5,6 +5,7 @@ namespace app\Http\Controllers\Admin;
use App\Album;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Helpers\MiscHelper;
use App\Http\Controllers\Controller;
use App\Http\Requests;
use App\Photo;
@ -32,7 +33,7 @@ class AlbumController extends Controller
->orderBy('created_at')
->get();
return Theme::render('admin.album_analyse_progress', ['album' => $album, 'photos' => $photos]);
return Theme::render('admin.analyse_album', ['album' => $album, 'photos' => $photos]);
}
/**
@ -130,6 +131,12 @@ class AlbumController extends Controller
->orderBy(DB::raw('COALESCE(taken_at, created_at)'))
->paginate(UserConfig::get('items_per_page_admin'));
// See if we can upload (need the GD extension)
$isUploadEnabled = extension_loaded('gd');
$fileUploadLimit = MiscHelper::convertToBytes(ini_get('upload_max_filesize')) / (1024*1024);
$postLimit = MiscHelper::convertToBytes(ini_get('post_max_size')) / (1024*1024);
$fileUploadOrPostLowerLimit = ($postLimit < $fileUploadLimit) ? $postLimit : $fileUploadLimit;
return Theme::render('admin.show_album', [
'album' => $album,
'bulk_actions' => [
@ -140,6 +147,10 @@ class AlbumController extends Controller
'delete' => trans('admin.photo_actions.delete')
],
'error' => $request->session()->get('error'),
'file_upload_limit' => $fileUploadLimit,
'is_upload_enabled' => $isUploadEnabled,
'max_post_limit' => $postLimit,
'max_post_limit_bulk' => $fileUploadOrPostLowerLimit,
'photos' => $photos
]);
}

View File

@ -25,7 +25,7 @@ class PhotoController extends Controller
{
public function __construct()
{
$this->middleware('auth');
$this->middleware(['auth', 'max_post_size_exceeded']);
}
public function analyse($photoId)
@ -215,11 +215,17 @@ class PhotoController extends Controller
{
$this->authorize('admin-access');
$archiveFile = UploadedFile::createFromBase($request->files->get('archive'));
// Load the linked album
$album = $this->loadAlbum($request->get('album_id'));
$archiveFile = UploadedFile::createFromBase($request->files->get('archive'));
if ($archiveFile->getError() != UPLOAD_ERR_OK)
{
Log::error('Bulk image upload failed.', ['error' => $archiveFile->getError(), 'reason' => $archiveFile->getErrorMessage()]);
$request->session()->flash('error', $archiveFile->getErrorMessage());
return redirect(route('albums.show', ['id' => $album->id]));
}
// Create a temporary folder to hold the extracted files
$tempFolder = sprintf('%s/btw_upload_%s', env('TEMP_FOLDER', '/tmp'), MiscHelper::randomString());
mkdir($tempFolder);

View File

@ -2,6 +2,7 @@
namespace App\Http;
use App\Http\Middleware\CheckMaxPostSizeExceeded;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
@ -51,6 +52,7 @@ class Kernel extends HttpKernel
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'max_post_size_exceeded' => CheckMaxPostSizeExceeded::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
];
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use App\Helpers\MiscHelper;
use Closure;
use Illuminate\Http\Request;
class CheckMaxPostSizeExceeded
{
protected $exclude = [
'/admin/photos/analyse/*',
'/admin/photos/regenerate-thumbnails/*'
];
public function handle(Request $request, Closure $next)
{
if ($request->method() == 'POST' && !$this->shouldExclude($request))
{
// Check post limit and see if it may have been exceeded
$postLimit = MiscHelper::convertToBytes(ini_get('post_max_size'));
if (
(isset($_SERVER['CONTENT_LENGTH']) && $_SERVER['CONTENT_LENGTH'] > $postLimit) ||
(empty($_POST) && empty($_REQUEST))
)
{
$request->session()->flash('error', trans('global.post_max_exceeded'));
return back();
}
}
return $next($request);
}
protected function shouldExclude(Request $request)
{
foreach ($this->exclude as $exclude)
{
if ($exclude !== '/') {
$exclude = trim($exclude, '/');
}
if ($request->is($exclude)) {
return true;
}
}
return false;
}
}

View File

@ -12,6 +12,7 @@ class VerifyCsrfToken extends BaseVerifier
* @var array
*/
protected $except = [
//
'/admin/photos',
'/admin/photos/store-bulk'
];
}

View File

@ -1,3 +1,103 @@
function AnalyseAlbumViewModel()
{
var self = this;
self.imagesFailed = ko.observableArray();
self.imagesToAnalyse = ko.observableArray();
self.numberSuccessful = ko.observable(0);
self.numberFailed = ko.observable(0);
self.numberTotal = ko.computed(function() {
return self.imagesToAnalyse().length;
});
self.failedPercentage = ko.computed(function()
{
return ((self.numberFailed() / self.numberTotal()) * 100).toFixed(2) + '%';
});
self.successfulPercentage = ko.computed(function()
{
return ((self.numberSuccessful() / self.numberTotal()) * 100).toFixed(2) + '%';
});
self.isCompleted = ko.computed(function()
{
return self.numberTotal() > 0 && (self.numberSuccessful() + self.numberFailed() >= self.numberTotal());
});
// When an image is added to the array, automatically issue it for analysis
self.imagesToAnalyse.subscribe(function(changes)
{
// changes[0].value is an instance of AnalyseImageViewModel
var item = changes[0].value;
$.ajax(
item.url(),
{
dataType: 'json',
error: function (xhr, textStatus, errorThrown)
{
self.numberFailed(self.numberFailed() + 1);
self.imagesFailed.push({
'name': item.name(),
'reason': textStatus
});
item.isSuccessful(false);
item.isPending(false);
},
method: 'POST',
success: function (data)
{
if (data.is_successful)
{
self.numberSuccessful(self.numberSuccessful() + 1);
item.isSuccessful(true);
item.isPending(false);
}
else
{
self.numberFailed(self.numberFailed() + 1);
self.imagesFailed.push({
'name': item.name(),
'reason': data.message
});
item.isSuccessful(false);
item.isPending(false);
}
}
}
);
}, null, 'arrayChange');
}
function AnalyseImageViewModel(image_info)
{
var self = this;
self.isPending = ko.observable(true);
self.isSuccessful = ko.observable(false);
self.name = ko.observable(image_info.name);
self.photoID = ko.observable(image_info.photo_id);
self.url = ko.observable(image_info.url);
self.iconClass = ko.computed(function() {
var string = 'fa fa-fw ';
if (!self.isPending())
{
string += (self.isSuccessful() ? 'check text-success' : 'times text-danger')
}
return string;
});
}
/**
* This file is used by admin/show_album.blade.php to handle photo uploads.
* @param album_id ID of the album the photos are being uploaded to
* @param language Array containing language strings
* @param urls Array containing URLs
* @constructor
*/
function UploadPhotosViewModel(album_id, language, urls)
{
var self = this;
@ -6,6 +106,7 @@ function UploadPhotosViewModel(album_id, language, urls)
self.imagesFailed = ko.observable(0);
self.imagesUploaded = ko.observable(0);
self.imagesTotal = ko.observable(0);
self.isBulkUploadInProgress = ko.observable(false);
self.isUploadInProgress = ko.observable(false);
self.statusMessages = ko.observableArray();

View File

@ -10,6 +10,7 @@ return [
'album_photos_tab' => 'Photos',
'album_settings_tab' => 'Settings',
'album_upload_tab' => 'Upload',
'analyse_photos_failed' => 'The following items could not be analysed and were removed:',
'create_album' => 'Create a photo album',
'create_album_intro' => 'Photo albums contain individual photographs together in the same way as a physical photo album or memory book.',
'create_album_intro2' => 'Complete the form below to create a photo album.',
@ -57,10 +58,18 @@ return [
'upload_max_limit' => 'Upload limit (maximum size):'
],
'title' => 'Gallery Admin',
'upload_bulk_heading' => 'Upload a zip archive',
'upload_bulk_text' => 'You can use the form below to upload a zip archive that contains your photographs. Any valid image files in the zip archive will be imported. Common hidden folders used by operating systems will be ignored.',
'upload_bulk_text2' => 'Your web server is configured to allow files up to :max_upload_size to be uploaded.',
'upload_disabled_heading' => 'Uploading is not supported on this system.',
'upload_disabled_text' => 'This system does not have the GD extension installed, so images cannot be processed. Please ensure this extension is installed and refresh this page to try again.',
'upload_file_failed_continue' => 'Click the "Continue" button to analyse the file(s) that were uploaded.',
'upload_file_not_image_messages' => 'The file ":file_name" is not recognised as an image and won\'t be uploaded.',
'upload_file_number_failed' => 'file(s) failed to upload.',
'upload_file_status_failed' => ':file_name failed to upload',
'upload_file_status_progress' => ':current of :total files completed',
'upload_file_status_success' => ':file_name uploaded successfully',
'upload_single_file_heading' => 'Upload photos individually',
'upload_single_file_text' => 'You can use the form below to upload individual files. To upload multiple files at once, hold down CTRL in the file browser.',
'upload_single_file_text2' => 'Your web server is configured to allow files up to :file_size. If you browser does not support HTML 5 (most modern browsers do), the combined size of all selected files must be less than :max_upload_size.'
];

View File

@ -1,4 +1,8 @@
<?php
return [
'app_name' => 'Blue Twilight'
'app_name' => 'Blue Twilight',
'post_max_exceeded' => 'Your upload exceeded the maximum size the web server is configured to allow. Please check the value of the "post_max_size" parameter in php.ini.',
'units' => [
'megabytes' => 'MB'
]
];

View File

@ -5,26 +5,37 @@
<div class="container" style="margin-top: 40px;">
<div class="row">
<div class="col-xs-12 col-sm-8 col-sm-offset-2">
<div class="panel panel-default" id="status-panel">
<div class="panel panel-default" data-bind="visible: !isCompleted()">
<div class="panel-heading">Analysing...</div>
<div class="panel-body">
<p>Your uploaded photos are now being analysed.</p>
<div id="progress-bar-container">
<div class="progress"></div>
<div class="progress">
<div class="progress-bar progress-bar-success" data-bind="style: { width: successfulPercentage() }">
<span class="sr-only"><span class="percentage-success" data-bind="text: successfulPercentage"></span></span>
</div>
<div class="progress-bar progress-bar-danger" data-bind="style: { width: failedPercentage() }">
<span class="sr-only"><span class="percentage-danger" data-bind="text: failedPercentage"></span></span>
</div>
</div>
<div id="file-list" style="margin-top: 20px;">
@foreach ($photos as $photo)
<p data-photo-id="{{ $photo->id }}">{{ $photo->name }} ... <i class="fa fa-fw"></i></p>
@endforeach
<div data-bind="foreach: imagesToAnalyse" style="margin-top: 20px;">
<p><span data-bind="text: name"></span> ... <i data-bind="css: iconClass"></i></p>
</div>
</div>
</div>
<div class="panel panel-default" id="complete-panel" style="display: none;">
<div class="panel panel-default" data-bind="visible: isCompleted">
<div class="panel-heading">Upload completed</div>
<div class="panel-body">
<p>Your upload has completed.</p>
<div data-bind="visible: numberFailed() > 0">
<p class="text-danger">@lang('admin.analyse_photos_failed')</p>
<ul class="text-danger" data-bind="foreach: imagesFailed">
<li><span data-bind="text: name"></span>: <span data-bind="text: reason"></span></li>
</ul>
</div>
<div class="btn-toolbar btn-group-sm pull-right">
<a class="btn btn-default" href="{{ $album->url() }}">View album</a>
<a class="btn btn-primary" href="{{ route('albums.show', ['id' => $album->id]) }}">Back to album settings</a>
@ -38,37 +49,20 @@
@push('scripts')
<script type="text/javascript">
var number_successful = 0;
var number_error = 0;
var number_total = 0;
var viewModel = new AnalyseAlbumViewModel();
function redrawProgressBar()
{
var successPercentage = (number_successful / number_total) * 100;
var failedPercentage = (number_error / number_total) * 100;
{{-- For each photo to analyse, push an instance of AnalyseImageViewModel to our master view model --}}
@foreach ($photos as $photo)
viewModel.imagesToAnalyse.push(new AnalyseImageViewModel({
'id': '{{ $photo->id }}',
'name': '{!! addslashes($photo->name) !!}',
'url': '{{ route('photos.analyse', ['id' => $photo->id]) }}'
}));
@endforeach
{{-- Render a Bootstrap-3 compatible progress bar --}}
var progressBar = $('<div/>').addClass('progress');
ko.applyBindings(viewModel);
{{-- Successful --}}
var progressBarSuccess = $('<div/>')
.addClass('progress-bar')
.addClass('progress-bar-success')
.css('width', parseInt(successPercentage) + '%')
.appendTo(progressBar);
var progressBarSuccessSpan = $('<span/>').addClass('sr-only').html(parseInt(successPercentage) + '% successful').appendTo(progressBarSuccess);
{{-- Failed --}}
var progressBarError = $('<div/>')
.addClass('progress-bar')
.addClass('progress-bar-warning')
.css('width', parseInt(failedPercentage) + '%')
.appendTo(progressBar);
var progressBarErrorSpan = $('<span/>').addClass('sr-only').html(parseInt(failedPercentage) + '% failed').appendTo(progressBarError);
{{-- Add to DOM --}}
$('#progress-bar-container').html(progressBar[0].outerHTML);
}
/*
$(document).ready(function() {
number_total = $('#file-list p').length;
@ -121,6 +115,6 @@
);
});
}
});
});*/
</script>
@endpush

View File

@ -67,67 +67,89 @@
{{-- Upload --}}
<div role="tabpanel" class="tab-pane" id="upload-tab">
<h4>Upload images</h4>
<div class="row">
<div class="col-sm-5" style="margin-bottom: 20px;">
{!! Form::open(['route' => 'photos.store', 'method' => 'POST', 'files' => true, 'id' => 'single-upload-form']) !!}
{!! Form::hidden('album_id', $album->id) !!}
<div class="form-group">
{!! Form::file('photo[]', ['class' => 'control-label', 'multiple' => 'multiple', 'id' => 'single-upload-files']) !!}
@if (!$is_upload_enabled)
<div class="row">
<div class="col-xs-12">
<p class="text-danger" style="font-weight: bold">@lang('admin.upload_disabled_heading')</p>
<p>@lang('admin.upload_disabled_text')</p>
</div>
<div>
<button type="submit" class="btn btn-success" data-bind="disable: isUploadInProgress, text: isUploadInProgress() ? '@lang('admin.is_uploading')' : '@lang('forms.upload_action')'">@lang('forms.upload_action')</button>
</div>
{!! Form::close() !!}
</div>
@else
<h4>@lang('admin.upload_single_file_heading')</h4>
<p>@lang('admin.upload_single_file_text')</p>
<div class="alert alert-info">
<p>@lang('admin.upload_single_file_text2', [
'file_size' => sprintf('<b>%s%s</b>', round($file_upload_limit, 2), trans('global.units.megabytes')),
'max_upload_size' => sprintf('<b>%s%s</b>', round($max_post_limit, 2), trans('global.units.megabytes'))
])</p>
</div>
<div class="col-sm-5">
<div class="text-center" data-bind="visible: isUploadInProgress">
<p><b>@lang('admin.is_uploading')</b></p>
<div class="progress">
<div class="progress-bar progress-bar-success" data-bind="style: { width: successfulPercentage() }">
<span class="sr-only"><span class="percentage-success" data-bind="text: successfulPercentage"></span></span>
</div>
<div class="progress-bar progress-bar-danger" data-bind="style: { width: failedPercentage() }">
<span class="sr-only"><span class="percentage-danger" data-bind="text: failedPercentage"></span></span>
</div>
<div class="row">
<div class="col-sm-5" style="margin-bottom: 20px;">
{!! Form::open(['route' => 'photos.store', 'method' => 'POST', 'files' => true, 'id' => 'single-upload-form']) !!}
{!! Form::hidden('album_id', $album->id) !!}
<div class="form-group">
{!! Form::file('photo[]', ['class' => 'control-label', 'multiple' => 'multiple', 'id' => 'single-upload-files']) !!}
</div>
<p data-bind="text: currentStatus"></p>
<div>
<button type="submit" class="btn btn-success" data-bind="disable: (isUploadInProgress() || isBulkUploadInProgress()), text: isUploadInProgress() ? '@lang('admin.is_uploading')' : '@lang('forms.upload_action')'">@lang('forms.upload_action')</button>
</div>
{!! Form::close() !!}
</div>
<div data-bind="visible: statusMessages().length > 0">
<p data-bind="visible: !isUploadInProgress()" class="text-danger" style="font-weight: bold">
<span data-bind="text: imagesFailed"></span> @lang('admin.upload_file_number_failed')
</p>
<p data-bind="visible: imagesUploaded() > 0">
@lang('admin.upload_file_failed_continue')<br /><br/>
<a href="{{ route('albums.analyse', ['id' => $album->id]) }}" class="btn btn-primary">@lang('forms.continue_action')</a>
</p>
<div class="col-sm-5">
<div class="text-center" data-bind="visible: isUploadInProgress">
<p><b>@lang('admin.is_uploading')</b></p>
<div class="progress">
<div class="progress-bar progress-bar-success" data-bind="style: { width: successfulPercentage() }">
<span class="sr-only"><span class="percentage-success" data-bind="text: successfulPercentage"></span></span>
</div>
<div class="progress-bar progress-bar-danger" data-bind="style: { width: failedPercentage() }">
<span class="sr-only"><span class="percentage-danger" data-bind="text: failedPercentage"></span></span>
</div>
</div>
<p data-bind="text: currentStatus"></p>
</div>
<ul data-bind="foreach: statusMessages">
<li data-bind="css: message_class, text: message_text"></li>
</ul>
<div data-bind="visible: statusMessages().length > 0">
<p data-bind="visible: !isUploadInProgress()" class="text-danger" style="font-weight: bold">
<span data-bind="text: imagesFailed"></span> @lang('admin.upload_file_number_failed')
</p>
<p data-bind="visible: imagesUploaded() > 0">
@lang('admin.upload_file_failed_continue')<br /><br/>
<a href="{{ route('albums.analyse', ['id' => $album->id]) }}" class="btn btn-primary">@lang('forms.continue_action')</a>
</p>
<ul data-bind="foreach: statusMessages">
<li data-bind="css: message_class, text: message_text"></li>
</ul>
</div>
</div>
</div>
</div>
<hr/>
<h4>Bulk upload</h4>
<hr/>
<h4>@lang('admin.upload_bulk_heading')</h4>
<p>@lang('admin.upload_bulk_text')</p>
<div class="alert alert-info">
<p>@lang('admin.upload_bulk_text2', [
'max_upload_size' => sprintf('<b>%s%s</b>', round($max_post_limit_bulk, 2), trans('global.units.megabytes'))
])</p>
</div>
{!! Form::open(['route' => 'photos.storeBulk', 'method' => 'POST', 'files' => true]) !!}
{!! Form::hidden('album_id', $album->id) !!}
{!! Form::open(['route' => 'photos.storeBulk', 'method' => 'POST', 'files' => true, 'id' => 'bulk-upload-form']) !!}
{!! Form::hidden('album_id', $album->id) !!}
<div class="form-group">
{!! Form::file('archive', ['class' => 'control-label']) !!}
</div>
<div class="form-group">
{!! Form::file('archive', ['class' => 'control-label']) !!}
</div>
<div>
{!! Form::submit(trans('forms.upload_action'), ['class' => 'btn btn-success']) !!}
</div>
{!! Form::close() !!}
<div>
<button type="submit" class="btn btn-success" data-bind="disable: (isUploadInProgress() || isBulkUploadInProgress()), text: isBulkUploadInProgress() ? '@lang('admin.is_uploading')' : '@lang('forms.upload_action')'">@lang('forms.upload_action')</button>
</div>
{!! Form::close() !!}
@endif
</div>
{{-- Settings --}}
@ -282,7 +304,7 @@
if (!file.type.match('image.*'))
{
alert(notImageString.replace(':file_name', file.name));
viewModel.onUploadFailed();
viewModel.onUploadFailed(null, file.name);
continue;
}
@ -294,6 +316,13 @@
event.preventDefault();
return false;
});
$('#bulk-upload-form').submit(function(event) {
// Set the in progress flag - no need to unset it as this is a synchronous process so the browser
// will reload the page in some way after completion
viewModel.isBulkUploadInProgress(true);
return true;
});
}
ko.applyBindings(viewModel);