[FILE][TemporaryFile] Fix various issues now that we also have Symfony's file abstractions

This commit is contained in:
2021-07-20 21:17:53 +01:00
committed by Hugo Sales
parent 6c0f3a336e
commit c8cf8c3f13
14 changed files with 196 additions and 87 deletions

View File

@@ -22,49 +22,73 @@
namespace App\Core;
use App\Core\DB\DB;
use function App\Core\I18n\_m;
use App\Entity\Attachment;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\NoSuchFileException;
use App\Util\Exception\NotFoundException;
use App\Util\Exception\ServerException;
use InvalidArgumentException;
use SplFileInfo;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\File as SymfonyFile;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use function App\Core\I18n\_m;
/**
* GNU social's File Abstraction
*
* @category Files
* @package GNUsocial
*
* @author Hugo Sales <hugo@hsal.es>
* @author Diogo Peralta Cordeiro <mail@diogo.site>
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class GSFile
{
/**
* Perform file validation (checks and normalization) and store the given file
*
* @param SplFileInfo $file
* @param string $dest_dir
* @param null|string $title
* @param bool $is_local
* @param null|int $actor_id
*
* @return Attachment
* @throws DuplicateFoundException
*/
public static function validateAndStoreFileAsAttachment(SymfonyFile $sfile,
public static function validateAndStoreFileAsAttachment(SplFileInfo $file,
string $dest_dir,
?string $title = null,
bool $is_local = true,
int $actor_id = null): Attachment
{
Event::handle('HashFile', [$sfile->getPathname(), &$hash]);
$hash = null;
Event::handle('HashFile', [$file->getPathname(), &$hash]);
try {
return DB::findOneBy('attachment', ['file_hash' => $hash]);
} catch (NotFoundException) {
// The following properly gets the mimetype with `file` or other
// available methods, so should be safe
$mimetype = $sfile->getMimeType();
Event::handle('AttachmentValidation', [&$sfile, &$mimetype, &$title, &$width, &$height]);
$mimetype = $file->getMimeType();
$title = $width = $height = null;
Event::handle('AttachmentValidation', [&$file, &$mimetype, &$title, &$width, &$height]);
$attachment = Attachment::create([
'file_hash' => $hash,
'file_hash' => $hash,
'gsactor_id' => $actor_id,
'mimetype' => $mimetype,
'title' => $title ?: _m('Untitled attachment'),
'filename' => $hash,
'is_local' => $is_local,
'size' => $sfile->getSize(),
'width' => $width,
'height' => $height,
'mimetype' => $mimetype,
'title' => $title ?: _m('Untitled attachment'),
'filename' => $hash,
'is_local' => $is_local,
'size' => $file->getSize(),
'width' => $width,
'height' => $height,
]);
$sfile->move($dest_dir, $hash);
$file->move($dest_dir, $hash);
DB::persist($attachment);
Event::handle('AttachmentStoreNew', [&$attachment]);
return $attachment;
@@ -74,32 +98,32 @@ class GSFile
/**
* Create an attachment for the given URL, fetching the mimetype
*
* @throws \InvalidArgumentException
* @throws InvalidArgumentException
*/
public static function validateAndStoreURLAsAttachment(string $url): Attachment
{
if (Common::isValidHttpUrl($url)) {
$head = HTTPClient::head($url);
// This must come before getInfo given that Symfony HTTPClient is lazy (thus forcing curl exec)
$headers = $head->getHeaders();
$url = $head->getInfo('url'); // The last effective url (after getHeaders so it follows redirects)
$headers = $head->getHeaders();
$url = $head->getInfo('url'); // The last effective url (after getHeaders so it follows redirects)
$url_hash = hash(Attachment::URLHASH_ALGO, $url);
try {
return DB::findOneBy('attachment', ['remote_url_hash' => $url_hash]);
} catch (NotFoundException) {
$headers = array_change_key_case($headers, CASE_LOWER);
$headers = array_change_key_case($headers, CASE_LOWER);
$attachment = Attachment::create([
'remote_url' => $url,
'remote_url' => $url,
'remote_url_hash' => $url_hash,
'mimetype' => $headers['content-type'][0],
'is_local' => false,
'mimetype' => $headers['content-type'][0],
'is_local' => false,
]);
DB::persist($attachment);
Event::handle('AttachmentStoreNew', [&$attachment]);
return $attachment;
}
} else {
throw new \InvalidArgumentException();
throw new InvalidArgumentException();
}
}
@@ -116,9 +140,9 @@ class GSFile
Response::HTTP_OK,
[
'Content-Description' => 'File Transfer',
'Content-Type' => $mimetype,
'Content-Type' => $mimetype,
'Content-Disposition' => HeaderUtils::makeDisposition($disposition, $output_filename ?: _m('Untitled attachment'), _m('Untitled attachment')),
'Cache-Control' => 'public',
'Cache-Control' => 'public',
],
$public = true,
$disposition = null,
@@ -181,7 +205,7 @@ class GSFile
*/
public static function getAttachmentFileInfo(int $id): array
{
$res = self::getFileInfo($id);
$res = self::getFileInfo($id);
$res['filepath'] = Common::config('attachments', 'dir') . $res['file_hash'];
return $res;
}

View File

@@ -44,6 +44,7 @@ use DateTimeInterface;
* @author Mikael Nordfeldth <mmn@hethane.se>
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
* @author Hugo Sales <hugo@hsal.es>
* @author Diogo Peralta Cordeiro <mail@diogo.site>
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
@@ -131,6 +132,17 @@ class AttachmentThumbnail extends Entity
}
}
/**
* @param Attachment $attachment
* @param int $width
* @param int $height
* @param bool $crop
*
* @throws ServerException
* @throws \App\Util\Exception\TemporaryFileException
*
* @return mixed
*/
public static function getOrCreate(Attachment $attachment, int $width, int $height, bool $crop)
{
try {
@@ -141,16 +153,15 @@ class AttachmentThumbnail extends Entity
});
} catch (NotFoundException $e) {
$ext = image_type_to_extension(IMAGETYPE_WEBP, include_dot: true);
$temp = new TemporaryFile(['prefix' => 'thumbnail', 'suffix' => $ext]);
$temp = new TemporaryFile(['prefix' => 'gs-thumbnail', 'suffix' => $ext]);
$thumbnail = self::create(['attachment_id' => $attachment->getId()]);
$event_map = ['image' => 'ResizeImagePath', 'video' => 'ResizeVideoPath'];
$major_mime = GSFile::mimetypeMajor($attachment->getMimetype());
if (in_array($major_mime, array_keys($event_map))) {
Event::handle($event_map[$major_mime], [$attachment->getPath(), $temp->getRealPath(), &$width, &$height, $crop, &$mimetype]);
if (in_array($major_mime, array_keys($event_map)) && !Event::handle($event_map[$major_mime], [$attachment->getPath(), $temp->getRealPath(), &$width, &$height, $crop, &$mimetype])) {
$thumbnail->setWidth($width);
$thumbnail->setHeight($height);
$filename = "{$width}x{$height}{$ext}-" . $attachment->getFileHash();
$temp->commit(Common::config('thumbnail', 'dir') . $filename);
$temp->move(Common::config('thumbnail', 'dir'), $filename);
$thumbnail->setFilename($filename);
DB::persist($thumbnail);
DB::flush();

View File

@@ -20,6 +20,7 @@
namespace App\Util;
use App\Util\Exception\TemporaryFileException;
use Symfony\Component\Mime\MimeTypes;
/**
* Class oriented at providing automatic temporary file handling.
@@ -27,7 +28,9 @@ use App\Util\Exception\TemporaryFileException;
* @package GNUsocial
*
* @author Alexei Sorokin <sor.alexei@meowr.ru>
* @copyright 2020 Free Software Foundation, Inc http://www.fsf.org
* @author Hugo Sales <hugo@hsal.es>
* @author Diogo Peralta Cordeiro <mail@diogo.site>
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class TemporaryFile extends \SplFileInfo
@@ -36,19 +39,25 @@ class TemporaryFile extends \SplFileInfo
/**
* @param array $options - ['prefix' => ?string, 'suffix' => ?string, 'mode' => ?string, 'directory' => ?string]
* Description of options:
* > prefix: The file name will begin with that prefix, default is 'gs-php'
* > suffix: The file name will begin with that prefix, default is ''
* > mode: The file name will begin with that prefix, default is 'w+b'
* > directory: The file name will begin with that prefix, default is the system's temporary
*
* @throws TemporaryFileException
*/
public function __construct(array $options = [])
{
$attempts = 16;
$filename = uniqid(($options['directory'] ?? (sys_get_temp_dir() . '/')) . ($options['prefix'] ?? 'gs-php')) . ($options['suffix'] ?? '');
for ($count = 0; $count < $attempts; ++$count) {
$filename = uniqid(($options['directory'] ?? (sys_get_temp_dir() . '/')) . ($options['prefix'] ?? 'gs-php')) . ($options['suffix'] ?? '');
$this->resource = @fopen($filename, $options['mode'] ?? 'w+b');
if ($this->resource !== false) {
break;
}
}
if ($count == $attempts) {
if ($count == $attempts && $this->resource !== false) {
// @codeCoverageIgnoreStart
$this->cleanup();
throw new TemporaryFileException('Could not open file: ' . $filename);
@@ -64,21 +73,28 @@ class TemporaryFile extends \SplFileInfo
$this->cleanup();
}
public function write($data): int
/**
* Binary-safe file write
*
* @see https://php.net/manual/en/function.fwrite.php
*
* @param string $data The string that is to be written.
*
* @return null|false|int the number of bytes written, false on error, null on null resource/stream
*/
public function write(string $data): int | false | null
{
if (!is_null($this->resource)) {
return fwrite($this->resource, $data);
} else {
// @codeCoverageIgnoreStart
return null;
// @codeCoverageIgnoreEnd
}
}
/**
* Closes the file descriptor if opened.
*
* @return bool Whether successful
* @return bool true on success or false on failure.
*/
protected function close(): bool
{
@@ -120,15 +136,27 @@ class TemporaryFile extends \SplFileInfo
* Release the hold on the temporary file and move it to the desired
* location, setting file permissions in the process.
*
* @param string File destination
* @param int New file permissions (in octal mode)
* @param string $directory Path where the file should be stored
* @param string $filename The filename
* @param int $dirmode New directory permissions (in octal mode)
* @param int $filemode New file permissions (in octal mode)
*
* @throws TemporaryFileException
*
* @return void
*/
public function commit(string $destpath, int $umode = 0644): void
public function move(string $directory, string $filename, int $dirmode = 0655, int $filemode = 0644): void
{
if (!is_dir($directory)) {
if (false === @mkdir($directory, $dirmode, true) && !is_dir($directory)) {
throw new TemporaryFileException(sprintf('Unable to create the "%s" directory.', $directory));
}
} elseif (!is_writable($directory)) {
throw new TemporaryFileException(sprintf('Unable to write in the "%s" directory.', $directory));
}
$destpath = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR . $this->getName($filename);
$temppath = $this->getRealPath();
// Might be attempted, and won't end well
@@ -138,21 +166,59 @@ class TemporaryFile extends \SplFileInfo
// Memorise if the file was there and see if there is access
$exists = file_exists($destpath);
if (!@touch($destpath)) {
throw new TemporaryFileException(
'Insufficient permissions for destination: "' . $destpath . '"'
);
} elseif (!$exists) {
// If the file wasn't there, clean it up in case of a later failure
unlink($destpath);
}
if (!$this->close()) {
// @codeCoverageIgnoreStart
throw new TemporaryFileException('Could not close the resource');
// @codeCoverageIgnoreEnd
}
rename($temppath, $destpath);
chmod($destpath, $umode);
set_error_handler(function ($type, $msg) use (&$error) { $error = $msg; });
$renamed = rename($this->getPathname(), $destpath);
restore_error_handler();
chmod($destpath, $filemode);
if (!$renamed) {
if (!$exists) {
// If the file wasn't there, clean it up in case of a later failure
unlink($destpath);
}
throw new TemporaryFileException(sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $destpath, strip_tags($error)));
}
}
/**
* This function is a copy of Symfony\Component\HttpFoundation\File\File->getMimeType()
* Returns the mime type of the file.
*
* The mime type is guessed using a MimeTypeGuesserInterface instance,
* which uses finfo_file() then the "file" system binary,
* depending on which of those are available.
*
* @return null|string The guessed mime type (e.g. "application/pdf")
*
* @see MimeTypes
*/
public function getMimeType()
{
if (!class_exists(MimeTypes::class)) {
throw new \LogicException('You cannot guess the mime type as the Mime component is not installed. Try running "composer require symfony/mime".');
}
return MimeTypes::getDefault()->guessMimeType($this->getPathname());
}
/**
* This function is a copy of Symfony\Component\HttpFoundation\File\File->getName()
* Returns locale independent base name of the given path.
*
* @return string
*/
protected function getName(string $name)
{
$originalName = str_replace('\\', '/', $name);
$pos = strrpos($originalName, '/');
$originalName = false === $pos ? $originalName : substr($originalName, $pos + 1);
return $originalName;
}
}