xref: /web-bugs/src/Utils/Uploader.php (revision 8ab9a1d1)
1<?php
2
3namespace App\Utils;
4
5/**
6 * A basic upload service class for uploading files via HTML forms.
7 */
8class Uploader
9{
10    /**
11     * Maximum allowed file size in bytes.
12     * @var int
13     */
14    private $maxFileSize = 2 * 1024 * 1024;
15
16    /**
17     * Valid file extension.
18     * @var string
19     */
20    private $validExtension;
21
22    /**
23     * Valid media types.
24     * @var array
25     */
26    private $validMediaTypes;
27
28    /**
29     * Destination directory.
30     * @var string
31     */
32    private $dir;
33
34    /**
35     * Destination file name.
36     * @var string
37     */
38    private $destinationFileName;
39
40    /**
41     * Set the maximum allowed file size in bytes.
42     */
43    public function setMaxFileSize(int $maxFileSize): void
44    {
45        $this->maxFileSize = $maxFileSize;
46    }
47
48    /**
49     * Set allowed file extension without leading dot. For example, 'tgz'.
50     */
51    public function setValidExtension(string $validExtension): void
52    {
53        $this->validExtension = $validExtension;
54    }
55
56    /**
57     * Set array of valid media types.
58     */
59    public function setValidMediaTypes(array $validMediaTypes): void
60    {
61        $this->validMediaTypes = $validMediaTypes;
62    }
63
64    /**
65     * Set destination directory.
66     */
67    public function setDir(string $dir): void
68    {
69        $this->dir = $dir;
70    }
71
72    /**
73     * Set the destination file name.
74     */
75    public function setDestinationFileName(string $destinationFileName): void
76    {
77        $this->destinationFileName = $destinationFileName;
78    }
79
80    /**
81     * Upload file.
82     */
83    public function upload(string $key): string
84    {
85        $files = isset($_FILES[$key]) ? $_FILES[$key] : [];
86
87        // Check if uploaded file size exceeds the ini post_max_size directive.
88        if(
89            empty($_FILES)
90            && empty($_POST)
91            && isset($_SERVER['REQUEST_METHOD'])
92            && strtolower($_SERVER['REQUEST_METHOD']) === 'post'
93        ) {
94            $max = ini_get('post_max_size');
95            throw new \Exception('Error on upload: Exceeded POST content length server limit of '.$max);
96        }
97
98        // Some other upload error happened
99        if (empty($files) || $files['error'] !== UPLOAD_ERR_OK) {
100            throw new \Exception('Error on upload: Something went wrong. Error code: '.$files['error']);
101        }
102
103        // Be sure we're dealing with an upload
104        if ($this->isUploadedFile($files['tmp_name']) === false) {
105            throw new \Exception('Error on upload: Invalid file definition');
106        }
107
108        // Check file extension
109        $uploadedName = $files['name'];
110        $ext = $this->getFileExtension($uploadedName);
111        if (isset($this->validExtension) && $ext !== $this->validExtension) {
112            throw new \Exception('Error on upload: Invalid file extension. Should be .'.$this->validExtension);
113        }
114
115        // Check file size
116        if ($files['size'] > $this->maxFileSize) {
117            throw new \Exception('Error on upload: Exceeded file size limit '.$this->maxFileSize.' bytes');
118        }
119
120        // Check zero length file size
121        if (!$files['size']) {
122            throw new \Exception('Error on upload: Zero-length patches are not allowed');
123        }
124
125        // Check media type
126        $type = $this->getMediaType($files['tmp_name']);
127        if (isset($this->validMediaTypes) && !in_array($type, $this->validMediaTypes)) {
128            throw new \Exception('Error: Uploaded patch file must be text file
129                (save as e.g. "patch.txt" or "package.diff")
130                (detected "'.htmlspecialchars($type, ENT_QUOTES).'")'
131            );
132        }
133
134        // Rename the uploaded file
135        $destination = $this->dir.'/'.$this->destinationFileName;
136
137        // Move uploaded file to final destination
138        if (!$this->moveUploadedFile($files['tmp_name'], $destination)) {
139            throw new \Exception('Error on upload: Something went wrong');
140        }
141
142        return $destination;
143    }
144
145    /**
146     * Checks if given file has been uploaded via POST method. This is wrapped
147     * into a separate method for convenience of testing it via phpunit and using
148     * a mock.
149     */
150    protected function isUploadedFile(string $file): bool
151    {
152        return is_uploaded_file($file);
153    }
154
155    /**
156     * Move uploaded file to destination. This method is wrapping PHP function
157     * to allow testing with PHPUnit and creating a mock object.
158     */
159    protected function moveUploadedFile(string $source, string $destination): bool
160    {
161        return move_uploaded_file($source, $destination);
162    }
163
164    /**
165     * Rename file to a unique name.
166     */
167    protected function renameFile(string $filename): ?string
168    {
169        $ext = $this->getFileExtension($filename);
170
171        $rand = uniqid(rand());
172
173        $i = 0;
174        while (true) {
175            $newName = $rand.$i.'.'.$ext;
176
177            if (!file_exists($this->dir.'/'.$newName)) {
178                return $newName;
179            }
180
181            $i++;
182        }
183    }
184
185    /**
186     * Returns file extension without a leading dot.
187     */
188    protected function getFileExtension(string $filename): string
189    {
190        return strtolower(substr($filename, strripos($filename, '.') + 1));
191    }
192
193    /**
194     * Guess file media type (formerly known as MIME type) using the fileinfo
195     * extension. If fileinfo extension is not installed fallback to plain text
196     * type.
197     */
198    protected function getMediaType(string $file): string
199    {
200        // If fileinfo extension is not available it defaults to text/plain.
201        if (!class_exists('finfo')) {
202            return 'text/plain';
203        }
204
205        $finfo = new \finfo(FILEINFO_MIME);
206
207        if (!$finfo) {
208            throw new \Exception('Error: Opening fileinfo database failed.');
209        }
210
211        // Get type for a specific file
212        $type = $finfo->file($file);
213
214        // Remove the charset part
215        $mediaType = explode(';', $type);
216
217        return isset($mediaType[0]) ? $mediaType[0] : 'text/plain';
218    }
219}
220