Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
10 / 10
CRAP
100.00% covered (success)
100.00%
51 / 51
TemporaryFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
10 / 10
31
100.00% covered (success)
100.00%
51 / 51
 __construct
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
9 / 9
 __destruct
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 write
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
2 / 2
 close
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
6 / 6
 cleanup
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
6 / 6
 getResource
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 move
100.00% covered (success)
100.00%
1 / 1
5
100.00% covered (success)
100.00%
6 / 6
 commit
100.00% covered (success)
100.00%
1 / 1
7
100.00% covered (success)
100.00%
13 / 13
 getMimeType
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
1 / 1
 getName
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
4 / 4
1<?php
2
3// {{{ License
4// This file is part of GNU social - https://www.gnu.org/software/social
5//
6// GNU social is free software: you can redistribute it and/or modify
7// it under the terms of the GNU Affero General Public License as published by
8// the Free Software Foundation, either version 3 of the License, or
9// (at your option) any later version.
10//
11// GNU social is distributed in the hope that it will be useful,
12// but WITHOUT ANY WARRANTY; without even the implied warranty of
13// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14// GNU Affero General Public License for more details.
15//
16// You should have received a copy of the GNU Affero General Public License
17// along with GNU social.  If not, see <http://www.gnu.org/licenses/>.
18// }}}
19
20namespace App\Util;
21
22use App\Util\Exception\TemporaryFileException;
23use Symfony\Component\Mime\MimeTypes;
24
25/**
26 * Class oriented at providing automatic temporary file handling.
27 *
28 * @package   GNUsocial
29 *
30 * @author    Alexei Sorokin <sor.alexei@meowr.ru>
31 * @author    Hugo Sales <hugo@hsal.es>
32 * @author    Diogo Peralta Cordeiro <mail@diogo.site>
33 * @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
34 * @license   https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
35 */
36class TemporaryFile extends \SplFileInfo
37{
38    // Cannot type annotate currently. `resource` is the expected type, but it's not a builtin type
39    protected $resource;
40
41    /**
42     * @param array $options - ['prefix' => ?string, 'suffix' => ?string, 'mode' => ?string, 'directory' => ?string, 'attempts' => ?int]
43     *                       Description of options:
44     *                       > prefix: The file name will begin with that prefix, default is 'gs-php'
45     *                       > suffix: The file name will end with that suffix, default is ''
46     *                       > mode: Operation mode, default is 'w+b'
47     *                       > directory: Directory where the file will be used, default is the system's temporary
48     *                       > attempts: Default 16, how many times to attempt to find a unique file
49     *
50     * @throws TemporaryFileException
51     */
52    public function __construct(array $options = [])
53    {
54        // todo options permission
55        $attempts = $options['attempts'] ?? 16;
56        $filepath = uniqid(($options['directory'] ?? (sys_get_temp_dir() . '/')) . ($options['prefix'] ?? 'gs-php')) . ($options['suffix'] ?? '');
57        for ($count = 0; $count < $attempts; ++$count) {
58            $this->resource = @fopen($filepath, $options['mode'] ?? 'w+b');
59            if ($this->resource !== false) {
60                break;
61            }
62        }
63        if ($this->resource === false) {
64            // @codeCoverageIgnoreStart
65            $this->cleanup();
66            throw new TemporaryFileException('Could not open file: ' . $filepath);
67            // @codeCoverageIgnoreEnd
68        }
69
70        parent::__construct($filepath);
71    }
72
73    public function __destruct()
74    {
75        $this->close();
76        $this->cleanup();
77    }
78
79    /**
80     * Binary-safe file write
81     *
82     * @see https://php.net/manual/en/function.fwrite.php
83     *
84     * @param string $data The string that is to be written.
85     *
86     * @throws ServerException when the resource is null
87     *
88     * @return false|int the number of bytes written, false on error
89     */
90    public function write(string $data): int | false
91    {
92        if (!is_null($this->resource)) {
93            return fwrite($this->resource, $data);
94        } else {
95            // @codeCoverageIgnoreStart
96            throw new TemporaryFileException(_m('Temporary file attempted to write to a null resource'));
97            // @codeCoverageIgnoreEnd
98        }
99    }
100
101    /**
102     * Closes the file descriptor if opened.
103     *
104     * @return bool true on success or false on failure.
105     */
106    protected function close(): bool
107    {
108        $ret = true;
109        if (!is_null($this->resource) && $this->resource !== false) {
110            $ret = fclose($this->resource);
111        }
112        if ($ret) {
113            $this->resource = null;
114        }
115        return $ret;
116    }
117
118    /**
119     * Closes the file descriptor and removes the temporary file.
120     *
121     * @return void
122     */
123    protected function cleanup(): void
124    {
125        if ($this->resource !== false) {
126            $path = $this->getRealPath();
127            $this->close();
128            if (file_exists($path)) {
129                @unlink($path);
130            }
131        }
132    }
133
134    /**
135     * Get the file resource.
136     *
137     * @return resource
138     */
139    public function getResource()
140    {
141        return $this->resource;
142    }
143
144    /**
145     * Release the hold on the temporary file and move it to the desired
146     * location, setting file permissions in the process.
147     *
148     * @param string $directory Path where the file should be stored
149     * @param string $filename  The filename
150     * @param int    $dirmode   New directory permissions (in octal mode)
151     * @param int    $filemode  New file permissions (in octal mode)
152     *
153     * @throws TemporaryFileException
154     *
155     * @return void
156     */
157    public function move(string $directory, string $filename, int $dirmode = 0755, int $filemode = 0644): void
158    {
159        if (!is_dir($directory)) {
160            if (false === @mkdir($directory, $dirmode, true) && !is_dir($directory)) {
161                // @codeCoverageIgnoreStart
162                throw new TemporaryFileException(sprintf('Unable to create the "%s" directory.', $directory));
163                // @codeCoverageIgnoreEnd
164            }
165        } elseif (!is_writable($directory)) {
166            // @codeCoverageIgnoreStart
167            throw new TemporaryFileException(sprintf('Unable to write in the "%s" directory.', $directory));
168            // @codeCoverageIgnoreEnd
169        }
170
171        $destpath = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR . $this->getName($filename);
172
173        $this->commit($destpath, $dirmode, $filemode);
174    }
175
176    /**
177     * Release the hold on the temporary file and move it to the desired
178     * location, setting file permissions in the process.
179     *
180     * @param string $destpath Full path of destination file
181     * @param int    $dirmode  New directory permissions (in octal mode)
182     * @param int    $filemode New file permissions (in octal mode)
183     *
184     * @throws TemporaryFileException
185     *
186     * @return void
187     */
188    public function commit(string $destpath, int $dirmode = 0755, int $filemode = 0644): void
189    {
190        $temppath = $this->getRealPath();
191
192        // Might be attempted, and won't end well
193        if ($destpath === $temppath) {
194            throw new TemporaryFileException('Cannot use self as destination');
195        }
196
197        // Memorise if the file was there and see if there is access
198        $existed = file_exists($destpath);
199
200        if (!$this->close()) {
201            // @codeCoverageIgnoreStart
202            throw new TemporaryFileException('Could not close the resource');
203            // @codeCoverageIgnoreEnd
204        }
205
206        set_error_handler(function ($type, $msg) use (&$error) { $error = $msg; });
207        $renamed = rename($this->getPathname(), $destpath);
208        $chmoded = chmod($destpath, $filemode);
209        restore_error_handler();
210        if (!$renamed || !$chmoded) {
211            if (!$existed && file_exists($destpath)) {
212                // If the file wasn't there, clean it up in case of a later failure
213                // @codeCoverageIgnoreStart
214                unlink($destpath);
215                // @codeCoverageIgnoreEnd
216            }
217            throw new TemporaryFileException(sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $destpath, strip_tags($error)));
218        }
219    }
220
221    /**
222     * This function is a copy of Symfony\Component\HttpFoundation\File\File->getMimeType()
223     * Returns the mime type of the file.
224     *
225     * The mime type is guessed using a MimeTypeGuesserInterface instance,
226     * which uses finfo_file() then the "file" system binary,
227     * depending on which of those are available.
228     *
229     * @return null|string The guessed mime type (e.g. "application/pdf")
230     *
231     * @see MimeTypes
232     */
233    public function getMimeType()
234    {
235        // @codeCoverageIgnoreStart
236        if (!class_exists(MimeTypes::class)) {
237            throw new \LogicException('You cannot guess the mime type as the Mime component is not installed. Try running "composer require symfony/mime".');
238        }
239        // @codeCoverageIgnoreEnd
240
241        return MimeTypes::getDefault()->guessMimeType($this->getPathname());
242    }
243
244    /**
245     * This function is a copy of Symfony\Component\HttpFoundation\File\File->getName()
246     * Returns locale independent base name of the given path.
247     *
248     * @return string
249     */
250    protected function getName(string $name)
251    {
252        $originalName = str_replace('\\', '/', $name);
253        $pos          = strrpos($originalName, '/');
254        $originalName = false === $pos ? $originalName : substr($originalName, $pos + 1);
255
256        return $originalName;
257    }
258}