Skip to content

Commit 9d38a9b

Browse files
Initial commit
0 parents  commit 9d38a9b

12 files changed

+748
-0
lines changed

.editorconfig

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
root = true
2+
3+
[*]
4+
end_of_line = lf
5+
insert_final_newline = true
6+
indent_style = space
7+
indent_size = 2
8+
trim_trailing_whitespace = true
9+
charset = utf-8
10+
11+
[*.md]
12+
trim_trailing_whitespace = false

.gitignore

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/.idea
2+
/etc
3+
/studio.json
4+
/tests/coverage
5+
/vendor
6+
.DS_Store
7+
composer.lock

CHANGELOG.MD

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Release Notes for Chunked File Uploads
2+
3+
## 1.0.0 - 2019-05-25
4+
- Initial release.

LICENSE.md

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) Sebastian Lenz
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Chunked File Uploads for Craft CMS
2+
3+
This plugin enables chunked file uploads in the control panel of
4+
the Craft CMS. It allows users to upload files being larger then the
5+
file upload limit given by your web server.
6+
7+
8+
## Requirements
9+
10+
This plugin requires Craft CMS 3.1 or later.
11+
12+
13+
## Installation
14+
15+
To install the plugin either use the plugin store or follow these
16+
instructions:
17+
18+
1. Open your terminal and go to your Craft project:
19+
20+
cd /path/to/project
21+
22+
2. Then tell Composer to load the plugin:
23+
24+
composer require sebastianlenz/craft-chunked-uploads
25+
26+
3. Install the plugin:
27+
28+
./craft install/plugin chunked-uploads
29+
30+
31+
## Settings
32+
33+
Within your control panel visit the page `Settings` and look
34+
for the plugin in the `Plug-ins` section. The plugin allows
35+
you to both configure the global maximum upload size as well
36+
as upload limits for individual folders.

composer.json

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "sebastianlenz/craft-chunked-uploads",
3+
"description": "Enable large uploads within the Craft CMS control panel",
4+
"type": "craft-plugin",
5+
"license": "MIT",
6+
"require": {
7+
"php": "^7.0",
8+
"craftcms/cms": "^3.1.0"
9+
},
10+
"autoload": {
11+
"psr-4": {
12+
"lenz\\craft\\chunkedUploads\\": "src/"
13+
}
14+
},
15+
"extra": {
16+
"handle": "chunked-uploads",
17+
"name": "Chunked File Uploads",
18+
"developer": "Sebastian Lenz",
19+
"developerUrl": "https://github.com/sebastian-lenz/"
20+
}
21+
}

src/Plugin.php

+263
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
<?php
2+
3+
namespace lenz\craft\chunkedUploads;
4+
5+
use Craft;
6+
use craft\base\Model;
7+
use craft\web\Application;
8+
use craft\web\assets\fileupload\FileUploadAsset;
9+
use craft\web\Request;
10+
use Exception;
11+
use Imagick;
12+
use lenz\craft\chunkedUploads\assets\FileUploadPatch;
13+
use Throwable;
14+
use yii\base\Event;
15+
use yii\base\InvalidConfigException;
16+
use yii\web\HeaderCollection;
17+
use yii\web\View;
18+
19+
/**
20+
* Class Plugin
21+
* @method Settings getSettings()
22+
*/
23+
class Plugin extends \craft\base\Plugin
24+
{
25+
/**
26+
* @inheritDoc
27+
*/
28+
public $hasCpSettings = true;
29+
30+
/**
31+
* @var array
32+
*/
33+
public static $ALLOWED_IMAGE_FORMATS = ['GIF', 'PNG', 'JPEG'];
34+
35+
/**
36+
* @var string
37+
*/
38+
public static $DEFAULT_FORMAT = 'JPEG';
39+
40+
/**
41+
* The name of the uploaded file we are watching for.
42+
*/
43+
const FILE_NAME = 'assets-upload';
44+
45+
46+
/**
47+
* Plugin constructor.
48+
*
49+
* @param $id
50+
* @param null $parent
51+
* @param array $config
52+
*/
53+
public function __construct($id, $parent = null, array $config = []) {
54+
parent::__construct($id, $parent, $config);
55+
56+
if (Craft::$app->request->isCpRequest) {
57+
Event::on(Application::class, Application::EVENT_BEFORE_ACTION, [$this, 'onBeforeAction']);
58+
Event::on(View::class, View::EVENT_END_BODY, [$this, 'onViewEndBody']);
59+
}
60+
}
61+
62+
/**
63+
* @param Event $event
64+
* @throws Exception
65+
*/
66+
public function onBeforeAction(Event $event) {
67+
$request = Craft::$app->request;
68+
69+
if (
70+
$request->getIsPost() &&
71+
$request->getHeaders()->has('content-disposition') &&
72+
$request->getHeaders()->has('content-range') &&
73+
is_array($_FILES) &&
74+
isset($_FILES[self::FILE_NAME])
75+
) {
76+
if (!$this->processUpload($request)) {
77+
die();
78+
}
79+
}
80+
}
81+
82+
/**
83+
* @param Event $event
84+
* @throws InvalidConfigException
85+
*/
86+
public function onViewEndBody(Event $event) {
87+
/** @var View $view */
88+
$view = $event->sender;
89+
if (array_key_exists(FileUploadAsset::class, $view->assetBundles)) {
90+
$view->registerAssetBundle(FileUploadPatch::class);
91+
}
92+
}
93+
94+
/**
95+
* @inheritDoc
96+
* @throws Exception
97+
*/
98+
protected function settingsHtml() {
99+
return Craft::$app->view->renderTemplate(
100+
'chunked-uploads/_settings.twig',
101+
[
102+
'settings' => $this->getSettings(),
103+
]
104+
);
105+
}
106+
107+
108+
// Protected methods
109+
// -----------------
110+
111+
/**
112+
* @return Model|null
113+
*/
114+
protected function createSettingsModel() {
115+
return new Settings();
116+
}
117+
118+
/**
119+
* @param HeaderCollection $headers
120+
* @return string|null
121+
*/
122+
private function getContentDisposition(HeaderCollection $headers) {
123+
$contentDisposition = $headers->get('content-disposition');
124+
return $contentDisposition ?
125+
rawurldecode(preg_replace(
126+
'/(^[^"]+")|("$)/',
127+
'',
128+
$contentDisposition
129+
)) : null;
130+
}
131+
132+
/**
133+
* @param HeaderCollection $headers
134+
* @return array[]
135+
*/
136+
private function getContentRange(HeaderCollection $headers) {
137+
$contentRange = $headers->get('content-range');
138+
$parts = $contentRange
139+
? preg_split('/[^0-9]+/', $contentRange)
140+
: null;
141+
142+
$offset = is_array($parts) && isset($parts[1]) ? intval($parts[1]) : null;
143+
$size = is_array($parts) && isset($parts[3]) ? intval($parts[3]) : null;
144+
145+
return [$offset, $size];
146+
}
147+
148+
/**
149+
* @param string $uploadedFile
150+
*/
151+
private function processImage(Request $request, $uploadedFile) {
152+
if (!extension_loaded('imagick')) {
153+
return;
154+
}
155+
156+
list($maxWidth, $maxHeight) = $this
157+
->getSettings()
158+
->getMaxImageDimension($request->getParam('folderId'));
159+
160+
if (is_null($maxWidth) && is_null($maxHeight)) {
161+
return;
162+
}
163+
164+
try {
165+
$hasChanged = false;
166+
$image = new Imagick($uploadedFile);
167+
$format = $image->getImageFormat();
168+
$geometry = $image->getImageGeometry();
169+
$nativeWidth = $geometry['width'];
170+
$nativeHeight = $geometry['height'];
171+
$scale = 1;
172+
173+
if (
174+
is_array(self::$ALLOWED_IMAGE_FORMATS) &&
175+
!in_array($format, self::$ALLOWED_IMAGE_FORMATS)
176+
) {
177+
$hasChanged = true;
178+
$image->setFormat(self::$DEFAULT_FORMAT);
179+
}
180+
181+
if (!is_null($maxWidth) && $nativeWidth > $maxWidth) {
182+
$scale = $maxWidth / $nativeWidth;
183+
}
184+
185+
if (!is_null($maxHeight) && $nativeHeight > $maxHeight) {
186+
$scale = min($scale, $maxHeight / $nativeHeight);
187+
}
188+
189+
if ($scale < 1) {
190+
$hasChanged = true;
191+
$image->resizeImage(
192+
round($nativeWidth * $scale),
193+
round($nativeHeight * $scale),
194+
Imagick::FILTER_LANCZOS,
195+
1
196+
);
197+
}
198+
199+
if ($hasChanged) {
200+
$image->setCompressionQuality(100);
201+
file_put_contents($uploadedFile, $image->getImageBlob());
202+
}
203+
} catch (Throwable $error) {
204+
Craft::error($error->getMessage());
205+
}
206+
}
207+
208+
/**
209+
* @param Request $request
210+
* @return bool
211+
* @throws Exception
212+
*/
213+
private function processUpload(Request $request) {
214+
$headers = $request->getHeaders();
215+
$upload = $_FILES[self::FILE_NAME];
216+
$uploadedFile = $upload['tmp_name'];
217+
$originalFileName = $this->getContentDisposition($headers);
218+
219+
list($chunkOffset, $totalSize) = $this->getContentRange($headers);
220+
221+
if (is_array($uploadedFile)) {
222+
throw new Exception('Multiple files are not supported.');
223+
}
224+
225+
if (!is_uploaded_file($uploadedFile)) {
226+
throw new Exception('Invalid upload.');
227+
}
228+
229+
if (is_null($originalFileName) || is_null($chunkOffset) || is_null($totalSize)) {
230+
throw new Exception('Missing upload header data.');
231+
}
232+
233+
// Recompose chunks
234+
235+
$tempFile = sys_get_temp_dir() . '/craft_upload_chunks_' . md5($originalFileName);
236+
if ($chunkOffset > 0) {
237+
$uploadedSize = filesize($tempFile);
238+
if ($uploadedSize != $chunkOffset) {
239+
throw new Exception('Invalid chunk offset.');
240+
}
241+
242+
file_put_contents($tempFile, fopen($uploadedFile, 'r'), FILE_APPEND);
243+
} else {
244+
if (file_exists($tempFile)) {
245+
unlink($tempFile);
246+
}
247+
248+
move_uploaded_file($uploadedFile, $tempFile);
249+
}
250+
251+
// Check for upload completion
252+
253+
clearstatcache();
254+
$uploadedSize = filesize($tempFile);
255+
$isFinished = $uploadedSize == $totalSize;
256+
if ($isFinished) {
257+
rename($tempFile, $uploadedFile);
258+
$this->processImage($request, $uploadedFile);
259+
}
260+
261+
return $isFinished;
262+
}
263+
}

0 commit comments

Comments
 (0)