From 0eaccc32fed773332968ddc85c546ce3f82c0895 Mon Sep 17 00:00:00 2001 From: Diogo Peralta Cordeiro Date: Sun, 18 Apr 2021 02:17:57 +0100 Subject: [PATCH] [ATTACHMENTS] Further refactoring Some key points: - Components and Plugins shouldn't extend Module directly - Avatars should be fetched via GSActor ID, not by nickname as that isn't unique - Avatar now is a separate Component - Common file utilities are now to be placed in Core\GSFile, this will handle storage and trigger validation - Some bug fixes --- components/Avatar/Avatar.php | 122 +++++++++ components/Avatar/Controller/Avatar.php | 44 ++++ .../Exception/NoAvatarException.php | 2 +- components/Bridge/Bridge.php | 4 +- components/Left/Left.php | 4 +- components/Media/Media.php | 62 ----- components/Media/Utils.php | 239 ------------------ components/Posting/Posting.php | 10 +- plugins/Cover/Controller/Cover.php | 6 +- .../Controller/ImageThumbnail.php | 4 +- src/Controller/.gitignore | 0 .../Controller/Attachment.php | 18 +- src/Controller/UserPanel.php | 16 +- src/Core/GSFile.php | 170 +++++++++++++ ...{Thumbnail.php => AttachmentThumbnail.php} | 4 +- ...entDownload.php => AttachmentValidate.php} | 4 +- src/Core/Modules/Component.php | 7 + src/Core/Modules/Plugin.php | 7 + src/Core/Modules/Upload.php | 11 - src/Entity/Attachment.php | 4 +- src/Entity/AttachmentThumbnail.php | 4 +- src/Entity/Avatar.php | 34 +-- src/Entity/Note.php | 2 +- src/Routes/Main.php | 3 + src/Util/Common.php | 5 + 25 files changed, 408 insertions(+), 378 deletions(-) create mode 100644 components/Avatar/Avatar.php create mode 100644 components/Avatar/Controller/Avatar.php rename components/{Media => Avatar}/Exception/NoAvatarException.php (95%) delete mode 100644 components/Media/Media.php delete mode 100644 components/Media/Utils.php delete mode 100644 src/Controller/.gitignore rename components/Media/Controller/Media.php => src/Controller/Attachment.php (69%) create mode 100644 src/Core/GSFile.php rename src/Core/Modules/{Thumbnail.php => AttachmentThumbnail.php} (81%) rename src/Core/Modules/{RemoteAttachmentDownload.php => AttachmentValidate.php} (84%) create mode 100644 src/Core/Modules/Component.php create mode 100644 src/Core/Modules/Plugin.php delete mode 100644 src/Core/Modules/Upload.php diff --git a/components/Avatar/Avatar.php b/components/Avatar/Avatar.php new file mode 100644 index 0000000000..2e3e77f21e --- /dev/null +++ b/components/Avatar/Avatar.php @@ -0,0 +1,122 @@ +. +// }}} + +namespace Component\Avatar; + +use App\Core\Cache; +use App\Core\DB\DB; +use App\Core\Event; +use App\Core\GSFile; +use App\Core\Modules\Component; +use App\Util\Common; +use Component\Avatar\Exception\NoAvatarException; +use Exception; +use Symfony\Component\Asset\Package; +use Symfony\Component\Asset\VersionStrategy\EmptyVersionStrategy; + +class Avatar extends Component +{ + public function onAddRoute($r) + { + $r->connect('avatar', '/{gsactor_id<\d+>}/avatar/{size?full}', [Controller\Avatar::class, 'avatar']); + return Event::next; + } + + public function onEndTwigPopulateVars(array &$vars) + { + if (Common::user() != null) { + $vars['user_avatar'] = self::getAvatarUrl(); + } + return Event::next; + } + + public function onGetAvatarUrl(int $gsactor_id, ?string &$url) + { + $url = self::getAvatarUrl($gsactor_id); + return Event::next; + } + + public function onDeleteCachedAvatar(int $gsactor_id) + { + Cache::delete('avatar-' . $gsactor_id); + Cache::delete('avatar-url-' . $gsactor_id); + Cache::delete('avatar-file-info-' . $gsactor_id); + } + + // UTILS ---------------------------------- + + /** + * Get the avatar associated with the given nickname + */ + public static function getAvatar(?int $gsactor_id = null): \App\Entity\Avatar + { + $gsactor_id = $gsactor_id ?: Common::userNickname(); + return GSFile::error(NoAvatarException::class, + $gsactor_id, + Cache::get("avatar-{$gsactor_id}", + function () use ($gsactor_id) { + return DB::dql('select a from App\Entity\Avatar a ' . + 'where a.gsactor_id = :gsactor_id', + ['gsactor_id' => $gsactor_id]); + })); + } + + /** + * Get the cached avatar associated with the given nickname, or the current user if not given + */ + public static function getAvatarUrl(?int $gsactor_id = null): string + { + $gsactor_id = $gsactor_id ?: Common::userId(); + return Cache::get("avatar-url-{$gsactor_id}", function () use ($gsactor_id) { + try { + return self::getAvatar($gsactor_id)->getUrl(); + } catch (NoAvatarException $e) { + } + $package = new Package(new EmptyVersionStrategy()); + return $package->getUrl(Common::config('avatar', 'default')); + }); + } + + /** + * Get the cached avatar file info associated with the given GSActor id + * + * Returns the avatar file's hash, mimetype, title and path. + * Ensures exactly one cached value exists + */ + public static function getAvatarFileInfo(int $gsactor_id): array + { + try { + $res = GSFile::error(NoAvatarException::class, + $gsactor_id, + Cache::get("avatar-file-info-{$gsactor_id}", + function () use ($gsactor_id) { + return DB::dql('select f.file_hash, f.mimetype, f.title ' . + 'from App\Entity\Attachment f ' . + 'join App\Entity\Avatar a with f.id = a.attachment_id ' . + 'where a.gsactor_id = :gsactor_id', + ['gsactor_id' => $gsactor_id]); + })); + $res['file_path'] = \App\Entity\Avatar::getFilePathStatic($res['file_hash']); + return $res; + } catch (Exception $e) { + $filepath = INSTALLDIR . '/public/assets/default-avatar.svg'; + return ['file_path' => $filepath, 'mimetype' => 'image/svg+xml', 'title' => null]; + } + } +} diff --git a/components/Avatar/Controller/Avatar.php b/components/Avatar/Controller/Avatar.php new file mode 100644 index 0000000000..d3046cd62c --- /dev/null +++ b/components/Avatar/Controller/Avatar.php @@ -0,0 +1,44 @@ +. + +// }}} + +namespace Component\Avatar\Controller; + +use App\Core\Controller; +use App\Core\GSFile as M; +use Exception; +use Symfony\Component\HttpFoundation\Request; + +class Avatar extends Controller +{ + /** + * @throws Exception + */ + public function avatar(Request $request, int $gsactor_id, string $size) + { + switch ($size) { + case 'full': + $res = \Component\Avatar\Avatar::getAvatarFileInfo($gsactor_id); + return M::sendFile($res['file_path'], $res['mimetype'], $res['title']); + default: + throw new Exception('Not implemented'); + } + } +} diff --git a/components/Media/Exception/NoAvatarException.php b/components/Avatar/Exception/NoAvatarException.php similarity index 95% rename from components/Media/Exception/NoAvatarException.php rename to components/Avatar/Exception/NoAvatarException.php index 0c78246a16..a065de364a 100644 --- a/components/Media/Exception/NoAvatarException.php +++ b/components/Avatar/Exception/NoAvatarException.php @@ -19,7 +19,7 @@ // }}} -namespace Component\Media\Exception; +namespace Component\Avatar\Exception; use Exception; diff --git a/components/Bridge/Bridge.php b/components/Bridge/Bridge.php index 8d7ada332e..e952780728 100644 --- a/components/Bridge/Bridge.php +++ b/components/Bridge/Bridge.php @@ -19,8 +19,8 @@ namespace Component\Bridge; -use App\Core\Modules\Module; +use App\Core\Modules\Component; -class Bridge extends Module +class Bridge extends Component { } diff --git a/components/Left/Left.php b/components/Left/Left.php index 78c50d2660..c4e0050fe3 100644 --- a/components/Left/Left.php +++ b/components/Left/Left.php @@ -21,11 +21,11 @@ namespace Component\Left; use App\Core\Event; use App\Core\Log; -use App\Core\Modules\Module; +use App\Core\Modules\Component; use App\Util\Common; use Exception; -class Left extends Module +class Left extends Component { public function onEndTwigPopulateVars(array &$vars) { diff --git a/components/Media/Media.php b/components/Media/Media.php deleted file mode 100644 index 1c7c40bdbf..0000000000 --- a/components/Media/Media.php +++ /dev/null @@ -1,62 +0,0 @@ -. -// }}} - -namespace Component\Media; - -use App\Core\Cache; -use App\Core\Event; -use App\Core\Modules\Module; -use App\Util\Common; -use App\Util\Nickname; - -class Media extends Module -{ - public static function __callStatic(string $name, array $args) - { - return Utils::{$name}(...$args); - } - - public function onAddRoute($r) - { - $r->connect('avatar', '/{nickname<' . Nickname::DISPLAY_FMT . '>}/avatar/{size?full}', [Controller\Media::class, 'avatar']); - $r->connect('attachment_inline', '/attachment/{id<\d+>}', [Controller\Media::class, 'attachment_inline']); - return Event::next; - } - - public function onEndTwigPopulateVars(array &$vars) - { - if (Common::user() != null) { - $vars['user_avatar'] = self::getAvatarUrl(); - } - return Event::next; - } - - public function onGetAvatarUrl(string $nickname, ?string &$url) - { - $url = self::getAvatarUrl($nickname); - return Event::next; - } - - public function onDeleteCachedAvatar(string $nickname) - { - Cache::delete('avatar-' . $nickname); - Cache::delete('avatar-url-' . $nickname); - Cache::delete('avatar-file-info-' . $nickname); - } -} diff --git a/components/Media/Utils.php b/components/Media/Utils.php deleted file mode 100644 index 1f720a8160..0000000000 --- a/components/Media/Utils.php +++ /dev/null @@ -1,239 +0,0 @@ -. - -// }}} - -namespace Component\Media; - -use App\Core\Cache; -use App\Core\DB\DB; -use function App\Core\I18n\_m; -use App\Core\Log; -use App\Entity\Attachment; -use App\Entity\Avatar; -use App\Util\Common; -use App\Util\Exception\ClientException; -use Component\Media\Exception\NoAvatarException; -use Exception; -use Symfony\Component\Asset\Package; -use Symfony\Component\Asset\VersionStrategy\EmptyVersionStrategy; -use Symfony\Component\HttpFoundation\BinaryFileResponse; -use Symfony\Component\HttpFoundation\File\File as SymfonyFile; -use Symfony\Component\HttpFoundation\HeaderUtils; -use Symfony\Component\HttpFoundation\Response; - -abstract class Utils -{ - /** - * Perform file validation (checks and normalization) and store the given file - */ - public static function validateAndStoreAttachment(SymfonyFile $sfile, - string $dest_dir, - ?string $title = null, - bool $is_local = true, - ?int $actor_id = null): Attachment - { - // The following properly gets the mimetype with `file` or other - // available methods, so should be safe - $hash = hash_file(Attachment::FILEHASH_ALGO, $sfile->getPathname()); - $file = Attachment::create([ - 'file_hash' => $hash, - 'actor_id' => $actor_id, - 'mimetype' => $sfile->getMimeType(), - 'title' => $title ?: _m('Untitled attachment'), - 'filename' => $hash, - 'is_local' => $is_local, - ]); - $sfile->move($dest_dir, $hash); - // TODO Normalize file types - return $file; - } - - /** - * Include $filepath in the response, for viewing or downloading. - * - * @throws ServerException - */ - public static function sendFile(string $filepath, string $mimetype, ?string $output_filename, string $disposition = 'inline'): Response - { - $response = new BinaryFileResponse( - $filepath, - Response::HTTP_OK, - [ - 'Content-Description' => 'File Transfer', - 'Content-Type' => $mimetype, - 'Content-Disposition' => HeaderUtils::makeDisposition($disposition, $output_filename ?: _m('Untitled attachment')), - 'Cache-Control' => 'public', - ], - $public = true, - $disposition = null, - $add_etag = true, - $add_last_modified = true - ); - if (Common::config('site', 'x_static_delivery')) { - $response->trustXSendfileTypeHeader(); - } - return $response; - } - - /** - * Throw a client exception if the cache key $id doesn't contain - * exactly one entry - * - * @param mixed $except - * @param mixed $id - */ - private static function error($except, $id, array $res) - { - switch (count($res)) { - case 0: - throw new $except(); - case 1: - return $res[0]; - default: - Log::error('Media query returned more than one result for identifier: \"' . $id . '\"'); - throw new ClientException(_m('Internal server error')); - } - } - - /** - * Get the file info by id - * - * Returns the file's hash, mimetype and title - */ - public static function getFileInfo(int $id) - { - return self::error(NoSuchFileException::class, - $id, - Cache::get("file-info-{$id}", - function () use ($id) { - return DB::dql('select at.file_hash, at.mimetype, at.title ' . - 'from App\\Entity\\Attachment at ' . - 'where at.id = :id', - ['id' => $id]); - })); - } - - // ----- Attachment ------ - - /** - * Get the attachment file info by id - * - * Returns the attachment file's hash, mimetype, title and path - */ - public static function getAttachmentFileInfo(int $id): array - { - $res = self::getFileInfo($id); - $res['file_path'] = Common::config('attachments', 'dir') . $res['file_hash']; - return $res; - } - - // ----- Avatar ------ - - /** - * Get the avatar associated with the given nickname - */ - public static function getAvatar(?string $nickname = null): Avatar - { - $nickname = $nickname ?: Common::userNickname(); - return self::error(NoAvatarException::class, - $nickname, - Cache::get("avatar-{$nickname}", - function () use ($nickname) { - return DB::dql('select a from App\\Entity\\Avatar a ' . - 'join App\Entity\GSActor g with a.gsactor_id = g.id ' . - 'where g.nickname = :nickname', - ['nickname' => $nickname]); - })); - } - - /** - * Get the cached avatar associated with the given nickname, or the current user if not given - */ - public static function getAvatarUrl(?string $nickname = null): string - { - $nickname = $nickname ?: Common::userNickname(); - return Cache::get("avatar-url-{$nickname}", function () use ($nickname) { - try { - return self::getAvatar($nickname)->getUrl(); - } catch (NoAvatarException $e) { - } - $package = new Package(new EmptyVersionStrategy()); - return $package->getUrl(Common::config('avatar', 'default')); - }); - } - - /** - * Get the cached avatar file info associated with the given nickname - * - * Returns the avatar file's hash, mimetype, title and path. - * Ensures exactly one cached value exists - */ - public static function getAvatarFileInfo(string $nickname): array - { - try { - $res = self::error(NoAvatarException::class, - $nickname, - Cache::get("avatar-file-info-{$nickname}", - function () use ($nickname) { - return DB::dql('select f.file_hash, f.mimetype, f.title ' . - 'from App\\Entity\\Attachment f ' . - 'join App\\Entity\\Avatar a with f.id = a.file_id ' . - 'join App\\Entity\\GSActor g with g.id = a.gsactor_id ' . - 'where g.nickname = :nickname', - ['nickname' => $nickname]); - })); - $res['file_path'] = Avatar::getFilePathStatic($res['file_hash']); - return $res; - } catch (Exception $e) { - $filepath = INSTALLDIR . '/public/assets/default-avatar.svg'; - return ['file_path' => $filepath, 'mimetype' => 'image/svg+xml', 'title' => null]; - } - } - - // ------------------------------ - - /** - * Get the minor part of a mimetype. image/webp -> image - */ - public static function mimetypeMajor(string $mime) - { - return explode('/', self::mimeBare($mime))[0]; - } - - /** - * Get the minor part of a mimetype. image/webp -> webp - */ - public static function mimetypeMinor(string $mime) - { - return explode('/', self::mimeBare($mime))[1]; - } - - /** - * Get only the mimetype and not additional info (separated from bare mime with semi-colon) - */ - public static function mimeBare(string $mimetype) - { - $mimetype = mb_strtolower($mimetype); - if (($semicolon = mb_strpos($mimetype, ';')) !== false) { - $mimetype = mb_substr($mimetype, 0, $semicolon); - } - return trim($mimetype); - } -} diff --git a/components/Posting/Posting.php b/components/Posting/Posting.php index 72de26c6a8..313a18448a 100644 --- a/components/Posting/Posting.php +++ b/components/Posting/Posting.php @@ -25,21 +25,21 @@ use App\Core\Cache; use App\Core\DB\DB; use App\Core\Event; use App\Core\Form; +use App\Core\GSFile; use function App\Core\I18n\_m; -use App\Core\Modules\Module; +use App\Core\Modules\Component; use App\Core\Security; use App\Entity\AttachmentToNote; use App\Entity\Note; use App\Util\Common; -use App\Util\Exceptiion\InvalidFormException; +use App\Util\Exception\InvalidFormException; use App\Util\Exception\RedirectException; -use Component\Media\Media; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; -class Posting extends Module +class Posting extends Component { /** * HTML render event handler responsible for adding and handling @@ -105,7 +105,7 @@ class Posting extends Module ]); $processed_attachments = []; foreach ($attachments as $f) { - $na = Media::validateAndStoreAttachment( + $na = GSFile::validateAndStoreAttachment( $f, Common::config('attachments', 'dir'), Security::sanitize($title = $f->getClientOriginalName()), $is_local = true, $actor_id diff --git a/plugins/Cover/Controller/Cover.php b/plugins/Cover/Controller/Cover.php index 1f6639d374..a9dbe90c11 100644 --- a/plugins/Cover/Controller/Cover.php +++ b/plugins/Cover/Controller/Cover.php @@ -29,8 +29,8 @@ use App\Util\Common; use App\Util\Exception\ClientException; use App\Util\Exception\RedirectException; use App\Util\Exception\ServerException; -use Component\Media\Media; -use Component\Media\Media as M; +use Component\Media\Attachment; +use Component\Media\Attachment as M; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; @@ -97,7 +97,7 @@ class Cover if (explode('/',$sfile->getMimeType())[0] != 'image') { throw new ServerException('Invalid file type'); } - $file = Media::validateAndStoreFile($sfile, Common::config('cover', 'dir'), $title = null, $is_local = true, $use_unique = $actor_id); + $file = Attachment::validateAndStoreFile($sfile, Common::config('cover', 'dir'), $title = null, $is_local = true, $use_unique = $actor_id); $old_file = null; $cover = DB::find('cover', ['gsactor_id' => $actor_id]); // Must get old id before inserting another one diff --git a/plugins/ImageThumbnail/Controller/ImageThumbnail.php b/plugins/ImageThumbnail/Controller/ImageThumbnail.php index 6239eed1e2..ae656fb25d 100644 --- a/plugins/ImageThumbnail/Controller/ImageThumbnail.php +++ b/plugins/ImageThumbnail/Controller/ImageThumbnail.php @@ -23,9 +23,9 @@ namespace Plugin\ImageThumbnail\Controller; use App\Core\Controller; use App\Core\DB\DB; +use App\Core\GSFile; use App\Entity\AttachmentThumbnail; use App\Util\Common; -use Component\Media\Media; use Symfony\Component\HttpFoundation\Request; class ImageThumbnail extends Controller @@ -57,6 +57,6 @@ class ImageThumbnail extends Controller $filename = $thumbnail->getFilename(); $path = $thumbnail->getPath(); - return Media::sendFile(filepath: $path, mimetype: $attachment->getMimetype(), output_filename: $filename, disposition: 'inline'); + return GSFile::sendFile(filepath: $path, mimetype: $attachment->getMimetype(), output_filename: $filename, disposition: 'inline'); } } diff --git a/src/Controller/.gitignore b/src/Controller/.gitignore deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/components/Media/Controller/Media.php b/src/Controller/Attachment.php similarity index 69% rename from components/Media/Controller/Media.php rename to src/Controller/Attachment.php index 368720fda6..869eba4ceb 100644 --- a/components/Media/Controller/Media.php +++ b/src/Controller/Attachment.php @@ -19,26 +19,14 @@ // }}} -namespace Component\Media\Controller; +namespace App\Controller; use App\Core\Controller; -use Component\Media\Media as M; -use Exception; +use App\Core\GSFile as M; use Symfony\Component\HttpFoundation\Request; -class Media extends Controller +class Attachment extends Controller { - public function avatar(Request $request, string $nickname, string $size) - { - switch ($size) { - case 'full': - $res = M::getAvatarFileInfo($nickname); - return M::sendFile($res['file_path'], $res['mimetype'], $res['title']); - default: - throw new Exception('Not implemented'); - } - } - public function attachment_inline(Request $request, int $id) { $res = M::getAttachmentFileInfo($id); diff --git a/src/Controller/UserPanel.php b/src/Controller/UserPanel.php index fdfc9669ca..e3e9f56766 100644 --- a/src/Controller/UserPanel.php +++ b/src/Controller/UserPanel.php @@ -38,13 +38,13 @@ namespace App\Controller; use App\Core\DB\DB; use App\Core\Event; use App\Core\Form; +use App\Core\GSFile; use function App\Core\I18n\_m; use App\Core\Log; use App\Entity\Avatar; use App\Util\ClientException; use App\Util\Common; use App\Util\Form\ArrayTransformer; -use Component\Media\Media; use Doctrine\DBAL\Types\Types; use Exception; use Functional as F; @@ -144,11 +144,11 @@ class UserPanel extends AbstractController } else { throw new ClientException('Invalid form'); } - $user = Common::user(); - $actor_id = $user->getId(); - $file = Media::validateAndStoreAttachment($sfile, Common::config('avatar', 'dir'), $title = null, $is_local = true, $use_unique = $actor_id); - $old_file = null; - $avatar = DB::find('avatar', ['gsactor_id' => $actor_id]); + $user = Common::user(); + $gsactor_id = $user->getId(); + $file = GSFile::validateAndStoreAttachment($sfile, Common::config('avatar', 'dir'), $title = null, $is_local = true, $use_unique = $gsactor_id); + $old_file = null; + $avatar = DB::find('avatar', ['gsactor_id' => $gsactor_id]); // Must get old id before inserting another one if ($avatar != null) { $old_file = $avatar->delete(); @@ -156,13 +156,13 @@ class UserPanel extends AbstractController DB::persist($file); // Can only get new id after inserting DB::flush(); - DB::persist(Avatar::create(['gsactor_id' => $actor_id, 'file_id' => $file->getId()])); + DB::persist(Avatar::create(['gsactor_id' => $gsactor_id, 'attachment_id' => $file->getId()])); DB::flush(); // Only delete files if the commit went through if ($old_file != null) { @unlink($old_file); } - Event::handle('DeleteCachedAvatar', [$user->getNickname()]); + Event::handle('DeleteCachedAvatar', [$user->getId()]); } return ['_template' => 'settings/avatar.html.twig', 'avatar' => $form->createView()]; diff --git a/src/Core/GSFile.php b/src/Core/GSFile.php new file mode 100644 index 0000000000..dd4118f972 --- /dev/null +++ b/src/Core/GSFile.php @@ -0,0 +1,170 @@ +. + +// }}} + +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\NoSuchFileException; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\File\File as SymfonyFile; +use Symfony\Component\HttpFoundation\HeaderUtils; +use Symfony\Component\HttpFoundation\Response; + +class GSFile +{ + /** + * Perform file validation (checks and normalization) and store the given file + */ + public static function validateAndStoreAttachment(SymfonyFile $sfile, + string $dest_dir, + ?string $title = null, + bool $is_local = true, + int $actor_id = null): Attachment + { + // The following properly gets the mimetype with `file` or other + // available methods, so should be safe + $hash = hash_file(Attachment::FILEHASH_ALGO, $sfile->getPathname()); + $file = Attachment::create([ + 'file_hash' => $hash, + 'gsactor_id' => $actor_id, + 'mimetype' => $sfile->getMimeType(), + 'title' => $title ?: _m('Untitled attachment'), + 'filename' => $hash, + 'is_local' => $is_local, + ]); + $sfile->move($dest_dir, $hash); + // TODO Normalize file types + return $file; + } + + /** + * Include $filepath in the response, for viewing or downloading. + * + * @throws ServerException + */ + public static function sendFile(string $filepath, string $mimetype, ?string $output_filename, string $disposition = 'inline'): Response + { + $response = new BinaryFileResponse( + $filepath, + Response::HTTP_OK, + [ + 'Content-Description' => 'File Transfer', + 'Content-Type' => $mimetype, + 'Content-Disposition' => HeaderUtils::makeDisposition($disposition, $output_filename ?: _m('Untitled attachment')), + 'Cache-Control' => 'public', + ], + $public = true, + $disposition = null, + $add_etag = true, + $add_last_modified = true + ); + if (Common::config('site', 'x_static_delivery')) { + $response->trustXSendfileTypeHeader(); + } + return $response; + } + + /** + * Throw a client exception if the cache key $id doesn't contain + * exactly one entry + * + * @param mixed $except + * @param mixed $id + */ + public static function error($except, $id, array $res) + { + switch (count($res)) { + case 0: + throw new $except(); + case 1: + return $res[0]; + default: + Log::error('Media query returned more than one result for identifier: \"' . $id . '\"'); + throw new ClientException(_m('Internal server error')); + } + } + + /** + * Get the file info by id + * + * Returns the file's hash, mimetype and title + */ + public static function getFileInfo(int $id) + { + return self::error(NoSuchFileException::class, + $id, + Cache::get("file-info-{$id}", + function () use ($id) { + return DB::dql('select at.file_hash, at.mimetype, at.title ' . + 'from App\\Entity\\Attachment at ' . + 'where at.id = :id', + ['id' => $id]); + })); + } + + // ----- Attachment ------ + + /** + * Get the attachment file info by id + * + * Returns the attachment file's hash, mimetype, title and path + */ + public static function getAttachmentFileInfo(int $id): array + { + $res = self::getFileInfo($id); + $res['file_path'] = Common::config('attachments', 'dir') . $res['file_hash']; + return $res; + } + + // ------------------------ + + /** + * Get the minor part of a mimetype. image/webp -> image + */ + public static function mimetypeMajor(string $mime) + { + return explode('/', self::mimeBare($mime))[0]; + } + + /** + * Get the minor part of a mimetype. image/webp -> webp + */ + public static function mimetypeMinor(string $mime) + { + return explode('/', self::mimeBare($mime))[1]; + } + + /** + * Get only the mimetype and not additional info (separated from bare mime with semi-colon) + */ + public static function mimeBare(string $mimetype) + { + $mimetype = mb_strtolower($mimetype); + if (($semicolon = mb_strpos($mimetype, ';')) !== false) { + $mimetype = mb_substr($mimetype, 0, $semicolon); + } + return trim($mimetype); + } +} diff --git a/src/Core/Modules/Thumbnail.php b/src/Core/Modules/AttachmentThumbnail.php similarity index 81% rename from src/Core/Modules/Thumbnail.php rename to src/Core/Modules/AttachmentThumbnail.php index 6c9b6f3705..b72fc6419a 100644 --- a/src/Core/Modules/Thumbnail.php +++ b/src/Core/Modules/AttachmentThumbnail.php @@ -1,11 +1,9 @@ $this->id]); + $avatar = DB::findBy('avatar', ['attachment_id' => $this->id]); foreach ($avatar as $a) { $files[] = $a->getFilePath(); $a->delete($flush, $delete_files_now, $cascading = true); } - foreach (DB::findBy('file_thumbnail', ['file_id' => $this->id]) as $ft) { + foreach (DB::findBy('attachment_thumbnail', ['attachment_id' => $this->id]) as $ft) { $files[] = $ft->delete($flush, $delete_files_now, $cascading); } } diff --git a/src/Entity/AttachmentThumbnail.php b/src/Entity/AttachmentThumbnail.php index 19b6fa13aa..faa7ee8fb1 100644 --- a/src/Entity/AttachmentThumbnail.php +++ b/src/Entity/AttachmentThumbnail.php @@ -25,11 +25,11 @@ use App\Core\Cache; 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; use App\Util\Exception\NotFoundException; use App\Util\Exception\ServerException; -use Component\Media\Media; use DateTimeInterface; /** @@ -126,7 +126,7 @@ class AttachmentThumbnail extends Entity } catch (NotFoundException $e) { $thumbnail = self::create(['attachment_id' => $attachment->getId(), 'width' => $width, 'height' => $height, 'attachment' => $attachment]); $event_map = ['image' => 'ResizeImage', 'video' => 'ResizeVideo']; - $major_mime = Media::mimetypeMajor($attachment->getMimetype()); + $major_mime = GSFile::mimetypeMajor($attachment->getMimetype()); if (in_array($major_mime, array_keys($event_map))) { Event::handle($event_map[$major_mime], [$attachment, $thumbnail, $width, $height, $crop]); return $thumbnail; diff --git a/src/Entity/Avatar.php b/src/Entity/Avatar.php index 85453b726a..a7deebb1cb 100644 --- a/src/Entity/Avatar.php +++ b/src/Entity/Avatar.php @@ -45,7 +45,7 @@ class Avatar extends Entity { // {{{ Autocode private int $gsactor_id; - private int $file_id; + private int $attachment_id; private DateTimeInterface $created; private DateTimeInterface $modified; @@ -60,15 +60,15 @@ class Avatar extends Entity return $this->gsactor_id; } - public function setFileId(int $file_id): self + public function setAttachmentId(int $attachment_id): self { - $this->file_id = $file_id; + $this->attachment_id = $attachment_id; return $this; } - public function getFileId(): int + public function getAttachmentId(): int { - return $this->file_id; + return $this->attachment_id; } public function setCreated(DateTimeInterface $created): self @@ -95,17 +95,17 @@ class Avatar extends Entity // }}} Autocode - private ?File $file = null; + private ?Attachment $attachment = null; public function getUrl(): string { - return Router::url('avatar', ['nickname' => GSActor::getNicknameFromId($this->gsactor_id)]); + return Router::url('avatar', ['gsactor_id' => $this->gsactor_id]); } - public function getFile(): File + public function getAttachment(): Attachment { - $this->file = $this->file ?: DB::find('file', ['id' => $this->file_id]); - return $this->file; + $this->attachment = $this->attachment ?: DB::find('attachment', ['id' => $this->attachment_id]); + return $this->attachment; } public static function getFilePathStatic(string $filename): string @@ -115,7 +115,7 @@ class Avatar extends Entity public function getFilePath(): string { - return Common::config('avatar', 'dir') . $this->getFile()->getFileName(); + return Common::config('avatar', 'dir') . $this->getAttachment()->getFileName(); } /** @@ -125,7 +125,7 @@ class Avatar extends Entity { // Don't go into a loop if we're deleting from File if (!$cascading) { - $files = $this->getFile()->delete($cascade = true, $file_flush = false, $delete_files_now); + $files = $this->getAttachment()->delete($cascade = true, $file_flush = false, $delete_files_now); } else { DB::remove(DB::getReference('avatar', ['gsactor_id' => $this->gsactor_id])); $file_path = $this->getFilePath(); @@ -143,14 +143,14 @@ class Avatar extends Entity return [ 'name' => 'avatar', 'fields' => [ - 'gsactor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'GSActor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to gsactor table'], - 'file_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'File.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to file table'], - 'created' => ['type' => 'datetime', 'not null' => true, 'description' => 'date this record was created', 'default' => 'CURRENT_TIMESTAMP'], - 'modified' => ['type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified', 'default' => 'CURRENT_TIMESTAMP'], + 'gsactor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'GSActor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to gsactor table'], + 'attachment_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'File.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to file table'], + 'created' => ['type' => 'datetime', 'not null' => true, 'description' => 'date this record was created', 'default' => 'CURRENT_TIMESTAMP'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified', 'default' => 'CURRENT_TIMESTAMP'], ], 'primary key' => ['gsactor_id'], 'indexes' => [ - 'avatar_file_id_idx' => ['file_id'], + 'avatar_attachment_id_idx' => ['attachment_id'], ], ]; } diff --git a/src/Entity/Note.php b/src/Entity/Note.php index 8bc4960389..9449dc96a8 100644 --- a/src/Entity/Note.php +++ b/src/Entity/Note.php @@ -196,7 +196,7 @@ class Note extends Entity public function getAvatarUrl() { $url = null; - Event::handle('GetAvatarUrl', [$this->getActorNickname(), &$url]); + Event::handle('GetAvatarUrl', [$this->getGSActorId(), &$url]); return $url; } public static function getAllNotes(int $noteScope): array diff --git a/src/Routes/Main.php b/src/Routes/Main.php index 04c370502a..6f7c2b726a 100644 --- a/src/Routes/Main.php +++ b/src/Routes/Main.php @@ -69,5 +69,8 @@ abstract class Main foreach (['personal_info', 'avatar', 'notifications', 'account'] as $s) { $r->connect('settings_' . $s, '/settings/' . $s, [C\UserPanel::class, $s]); } + + // Attachments + $r->connect('attachment_inline', '/attachment/{id<\d+>}', [C\Attachment::class, 'attachment_inline']); } } diff --git a/src/Util/Common.php b/src/Util/Common.php index d6c0b4ad5e..4d34453350 100644 --- a/src/Util/Common.php +++ b/src/Util/Common.php @@ -94,6 +94,11 @@ abstract class Common return self::ensureLoggedIn()->getNickname(); } + public static function userId(): ?string + { + return self::ensureLoggedIn()->getId(); + } + public static function ensureLoggedIn(): LocalUser { if (($user = self::user()) == null) {