1<?php
2/**
3*
4* This file is part of the phpBB Forum Software package.
5*
6* @copyright (c) phpBB Limited <https://www.phpbb.com>
7* @license GNU General Public License, version 2 (GPL-2.0)
8*
9* For full copyright and license information, please see
10* the docs/CREDITS.txt file.
11*
12*/
13
14namespace phpbb\plupload;
15
16/**
17* This class handles all server-side plupload functions
18*/
19class plupload
20{
21	/**
22	* @var string
23	*/
24	protected $phpbb_root_path;
25
26	/**
27	* @var \phpbb\config\config
28	*/
29	protected $config;
30
31	/**
32	* @var \phpbb\request\request_interface
33	*/
34	protected $request;
35
36	/**
37	* @var \phpbb\user
38	*/
39	protected $user;
40
41	/**
42	* @var \bantu\IniGetWrapper\IniGetWrapper
43	*/
44	protected $php_ini;
45
46	/**
47	* @var \phpbb\mimetype\guesser
48	*/
49	protected $mimetype_guesser;
50
51	/**
52	* Final destination for uploaded files, i.e. the "files" directory.
53	* @var string
54	*/
55	protected $upload_directory;
56
57	/**
58	* Temporary upload directory for plupload uploads.
59	* @var string
60	*/
61	protected $temporary_directory;
62
63	/**
64	* Constructor.
65	*
66	* @param string $phpbb_root_path
67	* @param \phpbb\config\config $config
68	* @param \phpbb\request\request_interface $request
69	* @param \phpbb\user $user
70	* @param \bantu\IniGetWrapper\IniGetWrapper $php_ini
71	* @param \phpbb\mimetype\guesser $mimetype_guesser
72	*/
73	public function __construct($phpbb_root_path, \phpbb\config\config $config, \phpbb\request\request_interface $request, \phpbb\user $user, \bantu\IniGetWrapper\IniGetWrapper $php_ini, \phpbb\mimetype\guesser $mimetype_guesser)
74	{
75		$this->phpbb_root_path = $phpbb_root_path;
76		$this->config = $config;
77		$this->request = $request;
78		$this->user = $user;
79		$this->php_ini = $php_ini;
80		$this->mimetype_guesser = $mimetype_guesser;
81
82		$this->set_default_directories();
83	}
84
85	/**
86	* Plupload allows for chunking so we must check for that and assemble
87	* the whole file first before performing any checks on it.
88	*
89	* @param string $form_name The name of the file element in the upload form
90	*
91	* @return array|null	null if there are no chunks to piece together
92	*						otherwise array containing the path to the
93	*						pieced-together file and its size
94	*/
95	public function handle_upload($form_name)
96	{
97		$chunks_expected = $this->request->variable('chunks', 0);
98
99		// If chunking is disabled or we are not using plupload, just return
100		// and handle the file as usual
101		if ($chunks_expected < 2)
102		{
103			return;
104		}
105
106		$file_name = $this->request->variable('name', '');
107		$chunk = $this->request->variable('chunk', 0);
108
109		$this->user->add_lang('plupload');
110		$this->prepare_temporary_directory();
111
112		$file_path = $this->temporary_filepath($file_name);
113		$this->integrate_uploaded_file($form_name, $chunk, $file_path);
114
115		// If we are done with all the chunks, strip the .part suffix and then
116		// handle the resulting file as normal, otherwise die and await the
117		// next chunk.
118		if ($chunk == $chunks_expected - 1)
119		{
120			rename("{$file_path}.part", $file_path);
121
122			// Reset upload directories to defaults once completed
123			$this->set_default_directories();
124
125			// Need to modify some of the $_FILES values to reflect the new file
126			return array(
127				'tmp_name' => $file_path,
128				'name' => $this->request->variable('real_filename', '', true),
129				'size' => filesize($file_path),
130				'type' => $this->mimetype_guesser->guess($file_path, $file_name),
131			);
132		}
133		else
134		{
135			$json_response = new \phpbb\json_response();
136			$json_response->send(array(
137				'jsonrpc' => '2.0',
138				'id' => 'id',
139				'result' => null,
140			));
141		}
142	}
143
144	/**
145	* Fill in the plupload configuration options in the template
146	*
147	* @param \phpbb\cache\service		$cache
148	* @param \phpbb\template\template	$template
149	* @param string						$s_action The URL to submit the POST data to
150	* @param int						$forum_id The ID of the forum
151	* @param int						$max_files Maximum number of files allowed. 0 for unlimited.
152	*
153	* @return null
154	*/
155	public function configure(\phpbb\cache\service $cache, \phpbb\template\template $template, $s_action, $forum_id, $max_files)
156	{
157		$filters = $this->generate_filter_string($cache, $forum_id);
158		$chunk_size = $this->get_chunk_size();
159		$resize = $this->generate_resize_string();
160
161		$template->assign_vars(array(
162			'S_RESIZE'			=> $resize,
163			'S_PLUPLOAD'		=> true,
164			'FILTERS'			=> $filters,
165			'CHUNK_SIZE'		=> $chunk_size,
166			'S_PLUPLOAD_URL'	=> htmlspecialchars_decode($s_action, ENT_COMPAT),
167			'MAX_ATTACHMENTS'	=> $max_files,
168			'ATTACH_ORDER'		=> ($this->config['display_order']) ? 'asc' : 'desc',
169			'L_TOO_MANY_ATTACHMENTS'	=> $this->user->lang('TOO_MANY_ATTACHMENTS', $max_files),
170		));
171
172		$this->user->add_lang('plupload');
173	}
174
175	/**
176	* Checks whether the page request was sent by plupload or not
177	*
178	* @return bool
179	*/
180	public function is_active()
181	{
182		return $this->request->header('X-PHPBB-USING-PLUPLOAD', false);
183	}
184
185	/**
186	* Returns whether the current HTTP request is a multipart request.
187	*
188	* @return bool
189	*/
190	public function is_multipart()
191	{
192		$content_type = $this->request->server('CONTENT_TYPE');
193
194		return strpos($content_type, 'multipart') === 0;
195	}
196
197	/**
198	* Sends an error message back to the client via JSON response
199	*
200	* @param int $code		The error code
201	* @param string $msg	The translation string of the message to be sent
202	*
203	* @return null
204	*/
205	public function emit_error($code, $msg)
206	{
207		$json_response = new \phpbb\json_response();
208		$json_response->send(array(
209			'jsonrpc' => '2.0',
210			'id' => 'id',
211			'error' => array(
212				'code' => $code,
213				'message' => $this->user->lang($msg),
214			),
215		));
216	}
217
218	/**
219	 * Looks at the list of allowed extensions and generates a string
220	 * appropriate for use in configuring plupload with
221	 *
222	 * @param \phpbb\cache\service	$cache		Cache service object
223	 * @param string				$forum_id	The forum identifier
224	 *
225	 * @return string
226	 */
227	public function generate_filter_string(\phpbb\cache\service $cache, $forum_id)
228	{
229		$groups = [];
230		$filters = [];
231
232		$attach_extensions = $cache->obtain_attach_extensions($forum_id);
233		unset($attach_extensions['_allowed_']);
234
235		// Re-arrange the extension array to $groups[$group_name][]
236		foreach ($attach_extensions as $extension => $extension_info)
237		{
238			$groups[$extension_info['group_name']]['extensions'][] = $extension;
239			$groups[$extension_info['group_name']]['max_file_size'] = (int) $extension_info['max_filesize'];
240		}
241
242		foreach ($groups as $group => $group_info)
243		{
244			$filters[] = sprintf(
245				"{title: '%s', extensions: '%s', max_file_size: %s}",
246				addslashes(ucfirst(strtolower($group))),
247				addslashes(implode(',', $group_info['extensions'])),
248				$group_info['max_file_size']
249			);
250		}
251
252		return implode(',', $filters);
253	}
254
255	/**
256	* Generates a string that is used to tell plupload to automatically resize
257	* files before uploading them.
258	*
259	* @return string
260	*/
261	public function generate_resize_string()
262	{
263		$resize = '';
264		if ($this->config['img_max_height'] > 0 && $this->config['img_max_width'] > 0)
265		{
266			$preserve_headers_value = $this->config['img_strip_metadata'] ? 'false' : 'true';
267			$resize = sprintf(
268				'resize: {width: %d, height: %d, quality: %d, preserve_headers: %s},',
269				(int) $this->config['img_max_width'],
270				(int) $this->config['img_max_height'],
271				(int) $this->config['img_quality'],
272				$preserve_headers_value
273			);
274		}
275
276		return $resize;
277	}
278
279	/**
280	 * Checks various php.ini values to determine the maximum chunk
281	 * size a file should be split into for upload.
282	 *
283	 * The intention is to calculate a value which reflects whatever
284	 * the most restrictive limit is set to.  And to then set the chunk
285	 * size to half that value, to ensure any required transfer overhead
286	 * and POST data remains well within the limit.  Or, if all of the
287	 * limits are set to unlimited, the chunk size will also be unlimited.
288	 *
289	 * @return int
290	 *
291	 * @access public
292	 */
293	public function get_chunk_size()
294	{
295		$max = 0;
296
297		$limits = [
298			$this->php_ini->getBytes('memory_limit'),
299			$this->php_ini->getBytes('upload_max_filesize'),
300			$this->php_ini->getBytes('post_max_size'),
301		];
302
303		foreach ($limits as $limit_type)
304		{
305			if ($limit_type > 0)
306			{
307				$max = ($max !== 0) ? min($limit_type, $max) : $limit_type;
308			}
309		}
310
311		return floor($max / 2);
312	}
313
314	protected function temporary_filepath($file_name)
315	{
316		// Must preserve the extension for plupload to work.
317		return sprintf(
318			'%s/%s_%s%s',
319			$this->temporary_directory,
320			$this->config['plupload_salt'],
321			md5($file_name),
322			\phpbb\files\filespec::get_extension($file_name)
323		);
324	}
325
326	/**
327	* Checks whether the chunk we are about to deal with was actually uploaded
328	* by PHP and actually exists, if not, it generates an error
329	*
330	* @param string $form_name The name of the file in the form data
331	* @param int $chunk Chunk number
332	* @param string $file_path File path
333	*
334	* @return null
335	*/
336	protected function integrate_uploaded_file($form_name, $chunk, $file_path)
337	{
338		$is_multipart = $this->is_multipart();
339		$upload = $this->request->file($form_name);
340		if ($is_multipart && (!isset($upload['tmp_name']) || !is_uploaded_file($upload['tmp_name'])))
341		{
342			$this->emit_error(103, 'PLUPLOAD_ERR_MOVE_UPLOADED');
343		}
344
345		$tmp_file = $this->temporary_filepath($upload['tmp_name']);
346
347		if (!phpbb_is_writable($this->temporary_directory) || !move_uploaded_file($upload['tmp_name'], $tmp_file))
348		{
349			$this->emit_error(103, 'PLUPLOAD_ERR_MOVE_UPLOADED');
350		}
351
352		$out = fopen("{$file_path}.part", $chunk == 0 ? 'wb' : 'ab');
353		if (!$out)
354		{
355			$this->emit_error(102, 'PLUPLOAD_ERR_OUTPUT');
356		}
357
358		$in = fopen(($is_multipart) ? $tmp_file : 'php://input', 'rb');
359		if (!$in)
360		{
361			$this->emit_error(101, 'PLUPLOAD_ERR_INPUT');
362		}
363
364		while ($buf = fread($in, 4096))
365		{
366			fwrite($out, $buf);
367		}
368
369		fclose($in);
370		fclose($out);
371
372		if ($is_multipart)
373		{
374			unlink($tmp_file);
375		}
376	}
377
378	/**
379	* Creates the temporary directory if it does not already exist.
380	*
381	* @return null
382	*/
383	protected function prepare_temporary_directory()
384	{
385		if (!file_exists($this->temporary_directory))
386		{
387			mkdir($this->temporary_directory);
388
389			copy(
390				$this->upload_directory . '/index.htm',
391				$this->temporary_directory . '/index.htm'
392			);
393		}
394	}
395
396	/**
397	* Sets the default directories for uploads
398	*
399	* @return null
400	*/
401	protected function set_default_directories()
402	{
403		$this->upload_directory = $this->phpbb_root_path . $this->config['upload_path'];
404		$this->temporary_directory = $this->upload_directory . '/plupload';
405	}
406
407	/**
408	* Sets the upload directories to the specified paths
409	*
410	* @param string $upload_directory Upload directory
411	* @param string $temporary_directory Temporary directory
412	*
413	* @return null
414	*/
415	public function set_upload_directories($upload_directory, $temporary_directory)
416	{
417		$this->upload_directory = $upload_directory;
418		$this->temporary_directory = $temporary_directory;
419	}
420}
421