diff --git a/components/Avatar/Avatar.php b/components/Avatar/Avatar.php index 27a3392609..918c231f19 100644 --- a/components/Avatar/Avatar.php +++ b/components/Avatar/Avatar.php @@ -75,12 +75,6 @@ class Avatar extends Component return Event::next; } - public function onAttachmentRefCount(int $attachment_id, int &$dependencies): bool - { - $dependencies += DB::count('avatar', ['attachment_id' => $attachment_id]); - return Event::next; - } - // UTILS ---------------------------------- /** @@ -126,7 +120,7 @@ class Avatar extends Component { $res = Cache::get("avatar-file-info-{$gsactor_id}", function () use ($gsactor_id) { - return DB::dql('select f.id, f.filename, f.mimetype, f.title ' . + return DB::dql('select f.id, f.filename, f.mimetype ' . 'from App\Entity\Attachment f ' . 'join Component\Avatar\Entity\Avatar a with f.id = a.attachment_id ' . 'where a.gsactor_id = :gsactor_id', diff --git a/components/Avatar/Controller/Avatar.php b/components/Avatar/Controller/Avatar.php index adea96a18b..3002c50b50 100644 --- a/components/Avatar/Controller/Avatar.php +++ b/components/Avatar/Controller/Avatar.php @@ -29,7 +29,6 @@ use App\Core\GSFile; use App\Core\GSFile as M; use function App\Core\I18n\_m; use App\Core\Log; -use App\Core\Security; use App\Util\Common; use App\Util\Exception\ClientException; use App\Util\Exception\NotFoundException; @@ -106,12 +105,9 @@ class Avatar extends Controller } else { throw new ClientException('Invalid form'); } - $attachment = GSFile::validateAndStoreFileAsAttachment( + $attachment = GSFile::sanitizeAndStoreFileAsAttachment( $file, - dest_dir: Common::config('attachments', 'dir'), - actor_id: $gsactor_id, - title: Security::sanitize($file->getClientOriginalName()), - is_local: true + dest_dir: Common::config('attachments', 'dir') ); // Delete current avatar if there's one $avatar = DB::find('avatar', ['gsactor_id' => $gsactor_id]); diff --git a/components/Avatar/Entity/Avatar.php b/components/Avatar/Entity/Avatar.php index 9e20ab11c7..061799c0c4 100644 --- a/components/Avatar/Entity/Avatar.php +++ b/components/Avatar/Entity/Avatar.php @@ -122,24 +122,17 @@ class Avatar extends Entity } /** - * Delete this avatar and, if safe, the corresponding file and thumbnails, which this owns + * Delete this avatar and kill corresponding attachment * - * @param bool $cascade - * @param bool $flush + * @return bool */ - public function delete(bool $cascade = true, bool $flush = true): void + public function delete(): bool { DB::remove($this); - if ($cascade) { - $attachment = $this->getAttachment(); - // We can't use $attachment->isSafeDelete() because underlying findBy doesn't respect remove persistence - if ($attachment->refCount() - 1 === 0) { - $attachment->delete(cascade: true, flush: false); - } - } - if ($flush) { - DB::flush(); - } + $attachment = $this->getAttachment(); + $attachment->kill(); + DB::flush(); + return true; } public static function schemaDef(): array diff --git a/components/Posting/Posting.php b/components/Posting/Posting.php index 345fcc62cd..9597a898ec 100644 --- a/components/Posting/Posting.php +++ b/components/Posting/Posting.php @@ -28,13 +28,17 @@ use App\Core\Form; use App\Core\GSFile; use function App\Core\I18n\_m; use App\Core\Modules\Component; -use App\Core\Security; use App\Entity\Attachment; use App\Entity\AttachmentToNote; +use App\Entity\GSActorToAttachment; +use App\Entity\GSActorToRemoteURL; use App\Entity\Note; +use App\Entity\RemoteURL; +use App\Entity\RemoteURLToNote; use App\Util\Common; use App\Util\Exception\InvalidFormException; use App\Util\Exception\RedirectException; +use InvalidArgumentException; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; @@ -115,31 +119,42 @@ END; $processed_attachments = []; foreach ($attachments as $f) { // where $f is a Symfony\Component\HttpFoundation\File\UploadedFile - $processed_attachments[] = GSFile::validateAndStoreFileAsAttachment( + $filesize = $f->getSize(); + Event::handle('EnforceQuota', [$actor_id, $filesize]); + $processed_attachments[] = GSFile::sanitizeAndStoreFileAsAttachment( $f, - dest_dir: Common::config('attachments', 'dir'), - actor_id: $actor_id, - title: Security::sanitize($f->getClientOriginalName()), - is_local: true + dest_dir: Common::config('attachments', 'dir') ); } - $matched_urls = []; - preg_match_all(self::URL_REGEX, $content, $matched_urls, PREG_SET_ORDER); - foreach ($matched_urls as $match) { - $processed_attachments[] = GSFile::validateAndStoreURLAsAttachment($match[0]); - } - DB::persist($note); // Need file and note ids for the next step DB::flush(); if ($processed_attachments != []) { foreach ($processed_attachments as $a) { + DB::persist(GSActorToAttachment::create(['attachment_id' => $a->getId(), 'gsactor_id' => $actor_id])); DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId()])); } DB::flush(); } + + $matched_urls = []; + $processed_urls = false; + preg_match_all(self::URL_REGEX, $content, $matched_urls, PREG_SET_ORDER); + foreach ($matched_urls as $match) { + try { + $remoteurl_id = RemoteURL::getOrCreate($match[0])->getId(); + DB::persist(GSActorToRemoteURL::create(['remoteurl_id' => $remoteurl_id, 'gsactor_id' => $actor_id])); + DB::persist(RemoteURLToNote::create(['remoteurl_id' => $a->getId(), 'note_id' => $note->getId()])); + $processed_urls = true; + } catch (InvalidArgumentException) { + continue; + } + } + if ($processed_urls) { + DB::flush(); + } } /** diff --git a/plugins/ImageEncoder/ImageEncoder.php b/plugins/ImageEncoder/ImageEncoder.php index 3ca0b63bcc..6ce39cbc3b 100644 --- a/plugins/ImageEncoder/ImageEncoder.php +++ b/plugins/ImageEncoder/ImageEncoder.php @@ -71,7 +71,7 @@ class ImageEncoder extends Plugin * * @return bool */ - public function onAttachmentSanitization(SplFileInfo &$file, ?string &$mimetype, ?string &$title, ?int &$width, ?int &$height): bool + public function onAttachmentSanitization(SplFileInfo &$file, ?string &$mimetype, ?int &$width, ?int &$height): bool { $original_mimetype = $mimetype; if (GSFile::mimetypeMajor($original_mimetype) != 'image') { @@ -81,8 +81,8 @@ class ImageEncoder extends Plugin // Try to maintain original mimetype extension, otherwise default to preferred. $extension = image_type_to_extension($this->preferredType(), include_dot: true); - GSFile::titleToFilename( - title: $title, + GSFile::ensureFilenameWithProperExtension( + title: $file->getFilename(), mimetype: $original_mimetype, ext: $extension, force: false diff --git a/plugins/ImageEncoder/templates/imageEncoder/imageEncoderView.html.twig b/plugins/ImageEncoder/templates/imageEncoder/imageEncoderView.html.twig index 48af683f3b..e26773cbc2 100644 --- a/plugins/ImageEncoder/templates/imageEncoder/imageEncoderView.html.twig +++ b/plugins/ImageEncoder/templates/imageEncoder/imageEncoderView.html.twig @@ -1,7 +1,7 @@
{{ attachment.getTitle() }} + alt="{{ attachment.getFilename() }}">
{{ attachment.getTitle() }} + href="{{ path('attachment_show', {'id': attachment.getId()}) }}">{{ attachment.getFilename() }}
diff --git a/plugins/VideoEncoder/templates/videoEncoder/videoEncoderView.html.twig b/plugins/VideoEncoder/templates/videoEncoder/videoEncoderView.html.twig index ee35b23ea9..ca156c4660 100644 --- a/plugins/VideoEncoder/templates/videoEncoder/videoEncoderView.html.twig +++ b/plugins/VideoEncoder/templates/videoEncoder/videoEncoderView.html.twig @@ -3,7 +3,7 @@
{{ attachment.getTitle() }} + href="{{ path('attachment_show', {'id': attachment.getId()}) }}">{{ attachment.getFilename() }}
\ No newline at end of file diff --git a/src/Controller/Attachment.php b/src/Controller/Attachment.php index 8cec064cdf..31261ac45b 100644 --- a/src/Controller/Attachment.php +++ b/src/Controller/Attachment.php @@ -75,7 +75,6 @@ class Attachment extends Controller return $this->attachment($id, function ($res) use ($id, $attachment) { return [ '_template' => 'attachments/show.html.twig', - 'title' => $res['title'], 'download' => Router::url('attachment_download', ['id' => $id]), 'attachment' => $attachment, 'right_panel_vars' => ['attachment_id' => $id], @@ -91,12 +90,12 @@ class Attachment extends Controller */ public function attachment_view(Request $request, int $id) { - return $this->attachment($id, fn (array $res) => GSFile::sendFile($res['filepath'], $res['mimetype'], GSFile::titleToFilename($res['title'], $res['mimetype']), HeaderUtils::DISPOSITION_INLINE)); + return $this->attachment($id, fn (array $res) => GSFile::sendFile($res['filepath'], $res['mimetype'], GSFile::ensureFilenameWithProperExtension($res['filename'], $res['mimetype']) ?? $res['filename'], HeaderUtils::DISPOSITION_INLINE)); } public function attachment_download(Request $request, int $id) { - return $this->attachment($id, fn (array $res) => GSFile::sendFile($res['filepath'], $res['mimetype'], GSFile::titleToFilename($res['title'], $res['mimetype']), HeaderUtils::DISPOSITION_ATTACHMENT)); + return $this->attachment($id, fn (array $res) => GSFile::sendFile($res['filepath'], $res['mimetype'], GSFile::ensureFilenameWithProperExtension($res['filename'], $res['mimetype']) ?? $res['filename'], HeaderUtils::DISPOSITION_ATTACHMENT)); } /** @@ -116,14 +115,6 @@ class Attachment extends Controller { $attachment = DB::findOneBy('attachment', ['id' => $id]); - if (!is_null($attachment->getScope())) { - // @codeCoverageIgnoreStart - // && ($attachment->scope | VisibilityScope::PUBLIC) != 0 - // $user = Common::ensureLoggedIn(); - assert(false, 'Attachment scope not implemented'); - // @codeCoverageIgnoreEnd - } - $default_width = Common::config('thumbnail', 'width'); $default_height = Common::config('thumbnail', 'height'); $default_crop = Common::config('thumbnail', 'smart_crop'); @@ -140,7 +131,7 @@ class Attachment extends Controller $filename = $thumbnail->getFilename(); $path = $thumbnail->getPath(); - $mimetype = $attachment->getMimetype(); + $mimetype = $thumbnail->getMimetype(); return GSFile::sendFile(filepath: $path, mimetype: $mimetype, output_filename: $filename . '.' . MimeTypes::getDefault()->getExtensions($mimetype)[0], disposition: HeaderUtils::DISPOSITION_INLINE); } diff --git a/src/Core/GSFile.php b/src/Core/GSFile.php index 0463bd893b..4782a0086a 100644 --- a/src/Core/GSFile.php +++ b/src/Core/GSFile.php @@ -30,7 +30,6 @@ use App\Util\Exception\NoSuchFileException; use App\Util\Exception\NotFoundException; use App\Util\Exception\ServerException; use App\Util\Formatting; -use InvalidArgumentException; use SplFileInfo; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\HeaderUtils; @@ -51,7 +50,7 @@ use Symfony\Component\Mime\MimeTypes; class GSFile { /** - * Perform file validation (checks and normalization) and store the given file + * Perform file validation (checks and normalization), store the given file if needed and increment lives * * @param SplFileInfo $file * @param string $dest_dir @@ -63,11 +62,8 @@ class GSFile * * @return Attachment */ - public static function validateAndStoreFileAsAttachment(SplFileInfo $file, - string $dest_dir, - int $actor_id, - ?string $title = null, - bool $is_local = true): Attachment + public static function sanitizeAndStoreFileAsAttachment(SplFileInfo $file, + string $dest_dir): Attachment { if (!Formatting::startsWith($dest_dir, Common::config('storage', 'dir'))) { throw new \InvalidArgumentException("Attempted to store a file in a directory outside the GNU social files location: {$dest_dir}"); @@ -76,65 +72,27 @@ class GSFile $hash = null; Event::handle('HashFile', [$file->getPathname(), &$hash]); try { - return DB::findOneBy('attachment', ['file_hash' => $hash]); + $attachment = DB::findOneBy('attachment', ['filehash' => $hash]); + $attachment->livesIncrementAndGet(); } catch (NotFoundException) { // The following properly gets the mimetype with `file` or other // available methods, so should be safe $mimetype = $file->getMimeType(); $width = $height = null; - Event::handle('AttachmentSanitization', [&$file, &$mimetype, &$title, &$width, &$height]); - if ($is_local) { - $filesize = $file->getSize(); - Event::handle('EnforceQuota', [$actor_id, $filesize]); - } + Event::handle('AttachmentSanitization', [&$file, &$mimetype, &$width, &$height]); $attachment = Attachment::create([ - 'file_hash' => $hash, - 'gsactor_id' => $actor_id, - 'mimetype' => $mimetype, - 'title' => $title, - 'filename' => Formatting::removePrefix($dest_dir, Common::config('attachments', 'dir')) . $hash, - 'is_local' => $is_local, - 'size' => $file->getSize(), - 'width' => $width, - 'height' => $height, + 'filehash' => $hash, + 'mimetype' => $mimetype, + 'filename' => Formatting::removePrefix($dest_dir, Common::config('attachments', 'dir')) . $hash, + 'size' => $file->getSize(), + 'width' => $width, + 'height' => $height, ]); $file->move($dest_dir, $hash); DB::persist($attachment); Event::handle('AttachmentStoreNew', [&$attachment]); - return $attachment; - } - } - - /** - * Create an attachment for the given URL, fetching the mimetype - * - * @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) - $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); - $attachment = Attachment::create([ - 'remote_url' => $url, - 'remote_url_hash' => $url_hash, - 'mimetype' => $headers['content-type'][0], - 'is_local' => false, - ]); - DB::persist($attachment); - Event::handle('AttachmentStoreNew', [&$attachment]); - return $attachment; - } - } else { - throw new InvalidArgumentException(); } + return $attachment; } /** @@ -203,7 +161,7 @@ class GSFile $id, Cache::get("file-info-{$id}", function () use ($id) { - return DB::dql('select at.filename, at.mimetype, at.title ' . + return DB::dql('select at.filename, at.mimetype ' . 'from App\\Entity\\Attachment at ' . 'where at.id = :id', ['id' => $id]); @@ -255,16 +213,16 @@ class GSFile } /** - * Given an attachment title and mimetype allows to generate the most appropriate filename. + * Given an attachment filename and mimetype allows to generate the most appropriate filename. * - * @param string $title - * @param string $mimetype - * @param null|string $ext - * @param bool $force + * @param string $title Original filename with or without extension + * @param string $mimetype Original mimetype of the file + * @param null|string $ext Extension we believe to be best + * @param bool $force Should we force the extension we believe to be best? Defaults to false * * @return null|string */ - public static function titleToFilename(string $title, string $mimetype, ?string &$ext = null, bool $force = false): string | null + public static function ensureFilenameWithProperExtension(string $title, string $mimetype, ?string &$ext = null, bool $force = false): string | null { $valid_extensions = MimeTypes::getDefault()->getExtensions($mimetype); @@ -286,6 +244,9 @@ class GSFile if (!empty($valid_extensions)) { return "{$title}.{$valid_extensions[0]}"; } else { + if (!is_null($ext)) { + return ($title_without_extension ?? $title) . ".{$ext}"; + } return null; } } diff --git a/src/DataFixtures/CoreFixtures.php b/src/DataFixtures/CoreFixtures.php index 2051a39c40..2de0dc7a44 100644 --- a/src/DataFixtures/CoreFixtures.php +++ b/src/DataFixtures/CoreFixtures.php @@ -51,7 +51,7 @@ class CoreFixtures extends Fixture $copy_filepath = $filepath . '.copy'; copy($filepath, $copy_filepath); $file = new File($copy_filepath, checkPath: true); - GSFile::validateAndStoreFileAsAttachment($file, dest_dir: Common::config('attachments', 'dir') . 'test/', title: '1x1 JPEG image title', actor_id: $actors['taken_user']->getId()); + GSFile::sanitizeAndStoreFileAsAttachment($file, dest_dir: Common::config('attachments', 'dir') . 'test/'); $manager->flush(); } } diff --git a/src/Entity/Attachment.php b/src/Entity/Attachment.php index b5308eccb4..3c5226b5d6 100644 --- a/src/Entity/Attachment.php +++ b/src/Entity/Attachment.php @@ -23,7 +23,6 @@ namespace App\Entity; use App\Core\DB\DB; use App\Core\Entity; -use App\Core\Event; use App\Core\GSFile; use App\Core\Log; use App\Util\Common; @@ -40,6 +39,7 @@ use DateTimeInterface; * @author Mikael Nordfeldth * @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org * @author Hugo Sales + * @author Diogo Peralta Cordeiro * @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later */ @@ -48,16 +48,10 @@ class Attachment extends Entity // {{{ Autocode // @codeCoverageIgnoreStart private int $id; - private ?string $remote_url; - private ?string $remote_url_hash; - private ?string $file_hash; - private ?int $gsactor_id; + private int $lives = 1; + private ?string $filehash; private ?string $mimetype; - private ?string $title; private ?string $filename; - private ?bool $is_local; - private ?int $source; - private ?int $scope; private ?int $size; private ?int $width; private ?int $height; @@ -74,48 +68,32 @@ class Attachment extends Entity return $this->id; } - public function setRemoteUrl(?string $remote_url): self + /** + * @return int + */ + + public function getLives(): int { - $this->remote_url = $remote_url; + return $this->lives; + } + + /** + * @param int $lives + */ + public function setLives(int $lives): void + { + $this->lives = $lives; + } + + public function setFilehash(?string $filehash): self + { + $this->filehash = $filehash; return $this; } - public function getRemoteUrl(): ?string + public function getFilehash(): ?string { - return $this->remote_url; - } - - public function setRemoteUrlHash(?string $remote_url_hash): self - { - $this->remote_url_hash = $remote_url_hash; - return $this; - } - - public function getRemoteUrlHash(): ?string - { - return $this->remote_url_hash; - } - - public function setFileHash(?string $file_hash): self - { - $this->file_hash = $file_hash; - return $this; - } - - public function getFileHash(): ?string - { - return $this->file_hash; - } - - public function setGSActorId(?int $gsactor_id): self - { - $this->gsactor_id = $gsactor_id; - return $this; - } - - public function getGSActorId(): ?int - { - return $this->gsactor_id; + return $this->filehash; } public function setMimetype(?string $mimetype): self @@ -141,17 +119,6 @@ class Attachment extends Entity return is_null($mime) ? $mime : GSFile::mimetypeMinor($mime); } - public function setTitle(?string $title): self - { - $this->title = $title; - return $this; - } - - public function getTitle(): ?string - { - return $this->title; - } - public function setFilename(?string $filename): self { $this->filename = $filename; @@ -163,39 +130,6 @@ class Attachment extends Entity return $this->filename; } - public function setIsLocal(?bool $is_local): self - { - $this->is_local = $is_local; - return $this; - } - - public function getIsLocal(): ?bool - { - return $this->is_local; - } - - public function setSource(?int $source): self - { - $this->source = $source; - return $this; - } - - public function getSource(): ?int - { - return $this->source; - } - - public function setScope(?int $scope): self - { - $this->scope = $scope; - return $this; - } - - public function getScope(): ?int - { - return $this->scope; - } - public function setSize(?int $size): self { $this->size = $size; @@ -243,51 +177,63 @@ class Attachment extends Entity // @codeCoverageIgnoreEnd // }}} Autocode - const URLHASH_ALGO = 'sha256'; + /** + * @return int + */ + public function livesIncrementAndGet(): int + { + ++$this->lives; + return $this->lives; + } + + /** + * @return int + */ + public function livesDecrementAndGet(): int + { + --$this->lives; + return $this->lives; + } + const FILEHASH_ALGO = 'sha256'; - public function refCount(): int + public function kill(): bool { - $attachment_id = $this->getId(); - $dependencies = DB::count('attachment_to_note', ['attachment_id' => $attachment_id]); - Event::handle('AttachmentRefCount', [$attachment_id, &$dependencies]); - return $dependencies; + if ($this->livesDecrementAndGet() <= 0) { + return $this->delete(); + } + return true; } /** - * @depends Attachment->refCount() - * - * @return bool + * Attachment delete always removes dependencies, cleanups and flushes */ - public function isSafeDelete(): bool - { - return $this->refCount() === 0; - } - - /** - * Delete this attachment and optionally all the associated entities (avatar and/or thumbnails, which this owns) - */ - public function delete(bool $cascade = true, bool $flush = true): void + public function delete(): bool { + if ($this->getLives() > 0) { + Log::warning("Deleting file {$this->getId()} with {$this->getLives()} lives. Why are you killing it so young?"); + } + // Delete related files from storage $files = []; - if ($cascade) { - foreach ($this->getThumbnails() as $at) { - $files[] = $at->getPath(); - $at->delete(flush: false); - } + if (!is_null($filepath = $this->getPath())) { + $files[] = $filepath; + } + foreach ($this->getThumbnails() as $at) { + $files[] = $at->getPath(); + $at->delete(flush: false); } - $files[] = $this->getPath(); DB::remove($this); - if ($flush) { - DB::flush(); - } foreach ($files as $f) { if (file_exists($f)) { if (@unlink($f) === false) { - Log::warning("Failed deleting file for attachment with id={$this->id} at {$f}"); + Log::error("Failed deleting file for attachment with id={$this->getId()} at {$f}."); } + } else { + Log::warning("File for attachment with id={$this->getId()} at {$f} was already deleted when I was going to handle it."); } } + DB::flush(); + return true; } /** @@ -300,7 +246,8 @@ class Attachment extends Entity public function getPath() { - return Common::config('attachments', 'dir') . $this->getFilename(); + $filename = $this->getFilename(); + return is_null($filename) ? null : Common::config('attachments', 'dir') . $filename; } public function getAttachmentUrl() @@ -313,28 +260,23 @@ class Attachment extends Entity return [ 'name' => 'attachment', 'fields' => [ - 'id' => ['type' => 'serial', 'not null' => true], - 'remote_url' => ['type' => 'text', 'description' => 'URL after following possible redirections'], - 'remote_url_hash' => ['type' => 'varchar', 'length' => 64, 'description' => 'sha256 of destination URL (url field)'], - 'file_hash' => ['type' => 'varchar', 'length' => 64, 'description' => 'sha256 of the file contents, if the file is stored locally'], - 'gsactor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'GSActor.id', 'multiplicity' => 'one to one', 'description' => 'If set, used so each actor can have a version of this file (for avatars, for instance)'], - 'mimetype' => ['type' => 'varchar', 'length' => 50, 'description' => 'mime type of resource'], - 'title' => ['type' => 'text', 'description' => 'title of resource when available'], - 'filename' => ['type' => 'varchar', 'length' => 191, 'description' => 'file name of resource when available'], - 'is_local' => ['type' => 'bool', 'description' => 'whether the file is stored locally'], - 'source' => ['type' => 'int', 'default' => null, 'description' => 'Source of the Attachment (upload, TFN, embed)'], - 'scope' => ['type' => 'int', 'default' => null, 'description' => 'visibility scope for this attachment'], - 'size' => ['type' => 'int', 'description' => 'size of resource when available'], - 'width' => ['type' => 'int', 'description' => 'width in pixels, if it can be described as such and data is available'], - 'height' => ['type' => 'int', 'description' => 'height in pixels, if it can be described as such and data is available'], - 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], + 'id' => ['type' => 'serial', 'not null' => true], + 'lives' => ['type' => 'int', 'not null' => true, 'description' => 'RefCount'], + 'filehash' => ['type' => 'varchar', 'length' => 64, 'description' => 'sha256 of the file contents, if the file is stored locally'], + 'mimetype' => ['type' => 'varchar', 'length' => 50, 'description' => 'mime type of resource'], + 'filename' => ['type' => 'varchar', 'length' => 191, 'description' => 'file name of resource when available'], + 'size' => ['type' => 'int', 'description' => 'size of resource when available'], + 'width' => ['type' => 'int', 'description' => 'width in pixels, if it can be described as such and data is available'], + 'height' => ['type' => 'int', 'description' => 'height in pixels, if it can be described as such and data is available'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], ], 'primary key' => ['id'], 'unique keys' => [ - 'attachment_file_hash_uniq' => ['file_hash'], + 'attachment_filehash_uniq' => ['filehash'], + 'attachment_filename_uniq' => ['filename'], ], 'indexes' => [ - 'file_filehash_idx' => ['file_hash'], + 'file_filehash_idx' => ['filehash'], ], ]; } diff --git a/src/Entity/AttachmentThumbnail.php b/src/Entity/AttachmentThumbnail.php index f0798e294e..98d8b8f170 100644 --- a/src/Entity/AttachmentThumbnail.php +++ b/src/Entity/AttachmentThumbnail.php @@ -185,7 +185,7 @@ class AttachmentThumbnail extends Entity $thumbnail->setWidth($predicted_width); $thumbnail->setHeight($predicted_height); $ext = '.' . MimeTypes::getDefault()->getExtensions($temp->getMimeType())[0]; - $filename = "{$predicted_width}x{$predicted_height}{$ext}-" . $attachment->getFileHash(); + $filename = "{$predicted_width}x{$predicted_height}{$ext}-" . $attachment->getFilehash(); $thumbnail->setFilename($filename); $thumbnail->setMimetype($mimetype); DB::persist($thumbnail); diff --git a/src/Entity/AttachmentToNote.php b/src/Entity/AttachmentToNote.php index 0f57dfe0ec..84b17e126e 100644 --- a/src/Entity/AttachmentToNote.php +++ b/src/Entity/AttachmentToNote.php @@ -42,6 +42,7 @@ class AttachmentToNote extends Entity // @codeCoverageIgnoreStart private int $attachment_id; private int $note_id; + private ?string $title; private \DateTimeInterface $modified; public function setAttachmentId(int $attachment_id): self @@ -66,6 +67,17 @@ class AttachmentToNote extends Entity return $this->note_id; } + public function setTitle(?string $title): self + { + $this->title = $title; + return $this; + } + + public function getTitle(): ?string + { + return $this->title; + } + public function setModified(DateTimeInterface $modified): self { $this->modified = $modified; @@ -87,6 +99,7 @@ class AttachmentToNote extends Entity 'fields' => [ 'attachment_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Attachment.id', 'multiplicity' => 'one to one', 'name' => 'attachment_to_note_attachment_id_fkey', 'not null' => true, 'description' => 'id of attachment'], 'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'name' => 'attachment_to_note_note_id_fkey', 'not null' => true, 'description' => 'id of the note it belongs to'], + 'title' => ['type' => 'text', 'description' => 'title of resource when available'], 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], ], 'primary key' => ['attachment_id', 'note_id'], diff --git a/src/Entity/GSActorToAttachment.php b/src/Entity/GSActorToAttachment.php new file mode 100644 index 0000000000..be09b6b723 --- /dev/null +++ b/src/Entity/GSActorToAttachment.php @@ -0,0 +1,95 @@ +. +// }}} + +namespace App\Entity; + +use App\Core\Entity; +use DateTimeInterface; + +/** + * Entity for relating an actor to an attachment + * + * @category DB + * @package GNUsocial + * + * @author Diogo Peralta Cordeiro + * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +class GSActorToAttachment extends Entity +{ + // {{{ Autocode + // @codeCoverageIgnoreStart + private int $attachment_id; + private int $gsactor_id; + private \DateTimeInterface $modified; + + public function setAttachmentId(int $attachment_id): self + { + $this->attachment_id = $attachment_id; + return $this; + } + + public function getAttachmentId(): int + { + return $this->attachment_id; + } + + public function setGSActorId(int $gsactor_id): self + { + $this->gsactor_id = $gsactor_id; + return $this; + } + + public function getGSActorId(): int + { + return $this->gsactor_id; + } + + public function setModified(DateTimeInterface $modified): self + { + $this->modified = $modified; + return $this; + } + + public function getModified(): DateTimeInterface + { + return $this->modified; + } + + // @codeCoverageIgnoreEnd + // }}} Autocode + + public static function schemaDef(): array + { + return [ + 'name' => 'gsactor_to_attachment', + 'fields' => [ + 'attachment_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Attachment.id', 'multiplicity' => 'one to one', 'name' => 'attachment_to_note_attachment_id_fkey', 'not null' => true, 'description' => 'id of attachment'], + 'gsactor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'GSActor.id', 'multiplicity' => 'one to one', 'name' => 'attachment_to_note_note_id_fkey', 'not null' => true, 'description' => 'id of the note it belongs to'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], + ], + 'primary key' => ['attachment_id', 'gsactor_id'], + 'indexes' => [ + 'attachment_id_idx' => ['attachment_id'], + 'gsactor_id_idx' => ['gsactor_id'], + ], + ]; + } +} diff --git a/src/Entity/GSActorToRemoteURL.php b/src/Entity/GSActorToRemoteURL.php new file mode 100644 index 0000000000..1575fca46b --- /dev/null +++ b/src/Entity/GSActorToRemoteURL.php @@ -0,0 +1,95 @@ +. +// }}} + +namespace App\Entity; + +use App\Core\Entity; +use DateTimeInterface; + +/** + * Entity for relating an actor to a remote url + * + * @category DB + * @package GNUsocial + * + * @author Diogo Peralta Cordeiro + * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +class GSActorToRemoteURL extends Entity +{ + // {{{ Autocode + // @codeCoverageIgnoreStart + private int $remoteurl_id; + private int $gsactor_id; + private \DateTimeInterface $modified; + + public function setRemoteURLId(int $remoteurl_id): self + { + $this->remoteurl_id = $remoteurl_id; + return $this; + } + + public function getRemoteURLId(): int + { + return $this->remoteurl_id; + } + + public function setGSActorId(int $gsactor_id): self + { + $this->gsactor_id = $gsactor_id; + return $this; + } + + public function getGSActorId(): int + { + return $this->gsactor_id; + } + + public function setModified(DateTimeInterface $modified): self + { + $this->modified = $modified; + return $this; + } + + public function getModified(): DateTimeInterface + { + return $this->modified; + } + + // @codeCoverageIgnoreEnd + // }}} Autocode + + public static function schemaDef(): array + { + return [ + 'name' => 'gsactor_to_remoteurl', + 'fields' => [ + 'remoteurl_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'RemoteURL.id', 'multiplicity' => 'one to one', 'name' => 'attachment_to_note_attachment_id_fkey', 'not null' => true, 'description' => 'id of attachment'], + 'gsactor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'GSActor.id', 'multiplicity' => 'one to one', 'name' => 'attachment_to_note_note_id_fkey', 'not null' => true, 'description' => 'id of the note it belongs to'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], + ], + 'primary key' => ['remoteurl_id', 'gsactor_id'], + 'indexes' => [ + 'remoteurl_id_idx' => ['remoteurl_id'], + 'gsactor_id_idx' => ['gsactor_id'], + ], + ]; + } +} diff --git a/src/Entity/RemoteURL.php b/src/Entity/RemoteURL.php new file mode 100644 index 0000000000..6b28db3d93 --- /dev/null +++ b/src/Entity/RemoteURL.php @@ -0,0 +1,177 @@ +. +// }}} + +namespace App\Entity; + +use App\Core\DB\DB; +use App\Core\Entity; +use App\Core\Event; +use App\Core\HTTPClient; +use App\Util\Common; +use App\Util\Exception\DuplicateFoundException; +use App\Util\Exception\NotFoundException; +use DateTimeInterface; +use InvalidArgumentException; + +/** + * Entity for representing a RemoteURL + * + * @category DB + * @package GNUsocial + * + * @author Diogo Peralta Cordeiro + * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +class RemoteURL extends Entity +{ + // {{{ Autocode + // @codeCoverageIgnoreStart + private int $id; + private ?string $remote_url; + private ?string $remote_url_hash; + private ?string $mimetype; + private DateTimeInterface $modified; + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function getRemoteUrl(): ?string + { + return $this->remote_url; + } + + public function setRemoteUrl(?string $remote_url): self + { + $this->remote_url = $remote_url; + return $this; + } + + public function setRemoteUrlHash(?string $remote_url_hash): self + { + $this->remote_url_hash = $remote_url_hash; + return $this; + } + + public function getRemoteUrlHash(): ?string + { + return $this->remote_url_hash; + } + + public function setMimetype(?string $mimetype): self + { + $this->mimetype = $mimetype; + return $this; + } + + public function getMimetype(): ?string + { + return $this->mimetype; + } + + public function getMimetypeMajor(): ?string + { + $mime = $this->getMimetype(); + return is_null($mime) ? $mime : GSFile::mimetypeMajor($mime); + } + + public function getMimetypeMinor(): ?string + { + $mime = $this->getMimetype(); + return is_null($mime) ? $mime : GSFile::mimetypeMinor($mime); + } + + public function setModified(DateTimeInterface $modified): self + { + $this->modified = $modified; + return $this; + } + + public function getModified(): DateTimeInterface + { + return $this->modified; + } + + // @codeCoverageIgnoreEnd + // }}} Autocode + + const URLHASH_ALGO = 'sha256'; + + /** + * Create an attachment for the given URL, fetching the mimetype + * + * @param string $url + * + * @throws DuplicateFoundException + * @throws InvalidArgumentException + * + * @return RemoteURL + */ + public static function getOrCreate(string $url): self + { + 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) + $url_hash = hash(self::URLHASH_ALGO, $url); + try { + return DB::findOneBy('remoteurl', ['remote_url_hash' => $url_hash]); + } catch (NotFoundException) { + $headers = array_change_key_case($headers, CASE_LOWER); + $remoteurl = self::create([ + 'remote_url' => $url, + 'remote_url_hash' => $url_hash, + 'mimetype' => $headers['content-type'][0], + ]); + DB::persist($remoteurl); + Event::handle('RemoteURLStoreNew', [&$remoteurl]); + return $remoteurl; + } + } else { + throw new InvalidArgumentException(); + } + } + + public static function schemaDef(): array + { + return [ + 'name' => 'remoteurl', + 'fields' => [ + 'id' => ['type' => 'serial', 'not null' => true], + 'remote_url' => ['type' => 'text', 'description' => 'URL after following possible redirections'], + 'remote_url_hash' => ['type' => 'varchar', 'length' => 64, 'description' => 'sha256 of destination URL (url field)'], + 'mimetype' => ['type' => 'varchar', 'length' => 50, 'description' => 'mime type of resource'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], + ], + 'primary key' => ['id'], + 'indexes' => [ + 'gsactor_remote_url_hash_idx' => ['remote_url_hash'], + ], + ]; + } +} diff --git a/src/Entity/RemoteURLToAttachment.php b/src/Entity/RemoteURLToAttachment.php new file mode 100644 index 0000000000..e6094e06d9 --- /dev/null +++ b/src/Entity/RemoteURLToAttachment.php @@ -0,0 +1,95 @@ +. +// }}} + +namespace App\Entity; + +use App\Core\Entity; +use DateTimeInterface; + +/** + * Entity for relating a remote url to an attachment + * + * @category DB + * @package GNUsocial + * + * @author Diogo Peralta Cordeiro + * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +class RemoteURLToAttachment extends Entity +{ + // {{{ Autocode + // @codeCoverageIgnoreStart + private int $attachment_id; + private int $remoteurl_id; + private \DateTimeInterface $modified; + + public function setAttachmentId(int $attachment_id): self + { + $this->attachment_id = $attachment_id; + return $this; + } + + public function getAttachmentId(): int + { + return $this->attachment_id; + } + + public function setRemoteURLId(int $remoteurl_id): self + { + $this->remoteurl_id = $remoteurl_id; + return $this; + } + + public function getRemoteURLId(): int + { + return $this->remoteurl_id; + } + + public function setModified(DateTimeInterface $modified): self + { + $this->modified = $modified; + return $this; + } + + public function getModified(): DateTimeInterface + { + return $this->modified; + } + + // @codeCoverageIgnoreEnd + // }}} Autocode + + public static function schemaDef(): array + { + return [ + 'name' => 'remoteurl_to_attachment', + 'fields' => [ + 'remoteurl_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'RemoteURL.id', 'multiplicity' => 'one to one', 'name' => 'attachment_to_note_note_id_fkey', 'not null' => true, 'description' => 'id of the note it belongs to'], + 'attachment_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Attachment.id', 'multiplicity' => 'one to one', 'name' => 'attachment_to_note_attachment_id_fkey', 'not null' => true, 'description' => 'id of attachment'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], + ], + 'primary key' => ['remoteurl_id', 'attachment_id'], + 'indexes' => [ + 'remoteurl_id_idx' => ['remoteurl_id'], + 'attachment_id_idx' => ['attachment_id'], + ], + ]; + } +} diff --git a/src/Entity/RemoteURLToNote.php b/src/Entity/RemoteURLToNote.php new file mode 100644 index 0000000000..a34ac71771 --- /dev/null +++ b/src/Entity/RemoteURLToNote.php @@ -0,0 +1,95 @@ +. +// }}} + +namespace App\Entity; + +use App\Core\Entity; +use DateTimeInterface; + +/** + * Entity for relating a RemoteURL to a post + * + * @category DB + * @package GNUsocial + * + * @author Diogo Peralta Cordeiro + * @copyright 2021 Free Software Foundation, Inc http://www.fsf.org + * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later + */ +class RemoteURLToNote extends Entity +{ + // {{{ Autocode + // @codeCoverageIgnoreStart + private int $remoteurl_id; + private int $note_id; + private \DateTimeInterface $modified; + + public function setRemoteURLId(int $remoteurl_id): self + { + $this->remoteurl_id = $remoteurl_id; + return $this; + } + + public function getRemoteURLId(): int + { + return $this->remoteurl_id; + } + + public function setNoteId(int $note_id): self + { + $this->note_id = $note_id; + return $this; + } + + public function getNoteId(): int + { + return $this->note_id; + } + + public function setModified(DateTimeInterface $modified): self + { + $this->modified = $modified; + return $this; + } + + public function getModified(): DateTimeInterface + { + return $this->modified; + } + + // @codeCoverageIgnoreEnd + // }}} Autocode + + public static function schemaDef(): array + { + return [ + 'name' => 'remoteurl_to_note', + 'fields' => [ + 'remoteurl_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'remoteurl.id', 'multiplicity' => 'one to one', 'name' => 'remoteurl_to_note_remoteurl_id_fkey', 'not null' => true, 'description' => 'id of remoteurl'], + 'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'name' => 'remoteurl_to_note_note_id_fkey', 'not null' => true, 'description' => 'id of the note it belongs to'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], + ], + 'primary key' => ['remoteurl_id', 'note_id'], + 'indexes' => [ + 'remoteurl_id_idx' => ['remoteurl_id'], + 'note_id_idx' => ['note_id'], + ], + ]; + } +} diff --git a/templates/attachments/view.html.twig b/templates/attachments/view.html.twig index 40bf772a95..c444ebb7a3 100644 --- a/templates/attachments/view.html.twig +++ b/templates/attachments/view.html.twig @@ -1,17 +1,12 @@ {% set thumbnail_parameters = {'id': attachment.getId(), 'w': config('thumbnail','width'), 'h': config('thumbnail','height')} %} -{% if attachment.getIsLocal() %} - {% set handled = false %} - {% for block in handle_event('ViewAttachment' ~ attachment.getMimetypeMajor() | capitalize , {'attachment': attachment, 'thumbnail_parameters': thumbnail_parameters}) %} - {% set handled = true %} - {{ block | raw }} - {% endfor %} - {% if not handled %} - - {% endif %} -{% else %} - {% for block in handle_event('ViewRemoteAttachment', {'attachment': attachment, 'thumbnail_parameters': thumbnail_parameters}) %} - {{ block | raw }} - {% endfor %} +{% set handled = false %} +{% for block in handle_event('ViewAttachment' ~ attachment.getMimetypeMajor() | capitalize , {'attachment': attachment, 'thumbnail_parameters': thumbnail_parameters}) %} + {% set handled = true %} + {{ block | raw }} +{% endfor %} +{% if not handled %} + {% endif %} + diff --git a/tests/Controller/AttachmentTest.php b/tests/Controller/AttachmentTest.php index 866cd6a59d..0501bc1eaa 100644 --- a/tests/Controller/AttachmentTest.php +++ b/tests/Controller/AttachmentTest.php @@ -46,7 +46,7 @@ class AttachmentTest extends GNUsocialTestCase private function testAttachment(string $suffix) { $client = static::createClient(); - $attachment = DB::findOneBy('attachment', ['title' => '1x1 JPEG image title']); + $attachment = DB::findOneBy('attachment', ['filehash' => '5d8ee7ead51a28803b4ee5cb2306a0b90b6ba570f1e5bcc2209926f6ab08e7ea']); $crawler = $client->request('GET', "/attachment/{$attachment->getId()}{$suffix}"); } @@ -54,7 +54,7 @@ class AttachmentTest extends GNUsocialTestCase { $this->testAttachment(''); $this->assertResponseIsSuccessful(); - $this->assertSelectorTextContains('figure figcaption', '1x1 JPEG image title'); + $this->assertSelectorTextContains('figure figcaption', '5d8ee7ead51a28803b4ee5cb2306a0b90b6ba570f1e5bcc2209926f6ab08e7ea'); } public function testAttachmentView()