diff --git a/plugins/Embed/Embed.php b/plugins/Embed/Embed.php index 42a42f78fa..b6654aaa42 100644 --- a/plugins/Embed/Embed.php +++ b/plugins/Embed/Embed.php @@ -106,101 +106,6 @@ class Embed extends Plugin return $this->smart_crop ?? Common::config('thumbnail', 'smart_crop'); } - /** - * Add common favicon, embed sizes and social media image sizes - * (Commented out ancient sizes) - * TODO: This is a temporary "solution" until we handle different sizes properly, discuss with Eliseu - * - * @param array $sizes - * - * @return bool - */ - public function onGetAllowedThumbnailSizes(array &$sizes): bool - { - $sizes[] = ['width' => 16, 'height' => 16]; // Standard favicon size for browsers - $sizes[] = ['width' => 24, 'height' => 24]; // IE9 pinned site size for user interface - $sizes[] = ['width' => 32, 'height' => 32]; // Standard for most desktop browsers, facebook small photo thumbnail - $sizes[] = ['width' => 36, 'height' => 36]; // Facebook big photo thumbnail - // $sizes[] = ['width' => 48, 'height' => 48]; // Windows site (mid-size standard favicon) - $sizes[] = ['width' => 55, 'height' => 55]; // Pinterest small thumb - // $sizes[] = ['width' => 57, 'height' => 57]; // Standard iOS home screen (iPod Touch, iPhone first generation to 3G) - // $sizes[] = ['width' => 60, 'height' => 60]; // iPhone touch up to iOS 7 - // $sizes[] = ['width' => 64, 'height' => 64]; // Windows site, Safari Reader List sidebar in HiDPI/Retina - // $sizes[] = ['width' => 70, 'height' => 70]; // Win 8.1 Metro tile - // $sizes[] = ['width' => 72, 'height' => 72]; // iPad touch up to iOS 6 - // $sizes[] = ['width' => 76, 'height' => 76]; // iPad home screen icon up to iOS 7 - // $sizes[] = ['width' => 96, 'height' => 96]; // GoogleTV icon - $sizes[] = ['width' => 110, 'height' => 110]; // Instagram profile picture - // $sizes[] = ['width' => 114, 'height' => 114]; // iPhone retina touch up to iOS6 - $sizes[] = ['width' => 116, 'height' => 116]; // Facebook page small square shared link - // $sizes[] = ['width' => 120, 'height' => 120]; // iPhone retina touch up to iOS6 - $sizes[] = ['width' => 128, 'height' => 128]; // Chrome Web Store icon and Small Windows 8 Star Screen Icon, Facebook profile picture smartphone - // $sizes[] = ['width' => 144, 'height' => 144]; // IE10 Metro tile for pinned site, Apple iPad retina iOS 6 to iOS 7 - $sizes[] = ['width' => 150, 'height' => 150]; // Win 8.1 Metro tile (standard MS tile) - $sizes[] = ['width' => 154, 'height' => 154]; // Facebook feed small square shared link - $sizes[] = ['width' => 152, 'height' => 152]; // Apple iPad iOS 10 retina touch icon - $sizes[] = ['width' => 161, 'height' => 161]; // Instagram thumbnails - $sizes[] = ['width' => 165, 'height' => 165]; // Pinterest profile picture - $sizes[] = ['width' => 167, 'height' => 167]; // Apple iPad Retina touch icon - $sizes[] = ['width' => 170, 'height' => 170]; // Facebook Profile Picture desktop - $sizes[] = ['width' => 180, 'height' => 180]; // Apple iPhone Retina touch icon, Facebook Profile Picture - $sizes[] = ['width' => 192, 'height' => 192]; // Google Developer Web App Manifest Recommendation - // $sizes[] = ['width' => 195, 'height' => 195]; // Opera Speed Dial icon - $sizes[] = ['width' => 196, 'height' => 196]; // Chrome for Android home screen icon - $sizes[] = ['width' => 200, 'height' => 200]; // GitHub profile photo - $sizes[] = ['width' => 222, 'height' => 150]; // Pinterest large thumb - // $sizes[] = ['width' => 228, 'height' => 228]; // Opera Coast icon - $sizes[] = ['width' => 250, 'height' => 250]; // Google My Business minimum - // $sizes[] = ['width' => 260, 'height' => 260]; - $sizes[] = ['width' => 300, 'height' => 300]; // Instagram personal profile image - // $sizes[] = ['width' => 310, 'height' => 150]; // Win 8.1 wide Metro tile - // $sizes[] = ['width' => 310, 'height' => 310]; // Win 8.1 big Metro tile - $sizes[] = ['width' => 360, 'height' => 360]; - $sizes[] = ['width' => 400, 'height' => 150]; // Facebook small cover photo, facebook small fundraiser image - // $sizes[] = ['width' => 400, 'height' => 300]; - $sizes[] = ['width' => 400, 'height' => 400]; // Twitter profile photo - $sizes[] = ['width' => 470, 'height' => 174]; // Facebook event small image - $sizes[] = ['width' => 470, 'height' => 246]; // Facebook feed small rectangular link - $sizes[] = ['width' => 480, 'height' => 360]; // YouTube video thumbnail - $sizes[] = ['width' => 484, 'height' => 252]; // Facebook page small rectangular link - $sizes[] = ['width' => 510, 'height' => 510]; // Instagram feed image - $sizes[] = ['width' => 512, 'height' => 512]; // Android Chrome big - // $sizes[] = ['width' => 560, 'height' => 315]; - // $sizes[] = ['width' => 560, 'height' => 340]; - $sizes[] = ['width' => 600, 'height' => 900]; // Pinterest expanded pin - $sizes[] = ['width' => 612, 'height' => 612]; // Instagram small photo - // $sizes[] = ['width' => 640, 'height' => 385]; - $sizes[] = ['width' => 700, 'height' => 800]; // Twitter two image post - $sizes[] = ['width' => 720, 'height' => 720]; // Google My Business - $sizes[] = ['width' => 800, 'height' => 300]; // Facebook fundraiser image - $sizes[] = ['width' => 800, 'height' => 800]; // Youtube channel profile image - $sizes[] = ['width' => 820, 'height' => 312]; // Facebook cover photo - // $sizes[] = ['width' => 853, 'height' => 505]; - $sizes[] = ['width' => 900, 'height' => 600]; // Instagram company photo - $sizes[] = ['width' => 943, 'height' => 943]; // Big safari pinned tab - $sizes[] = ['width' => 1024, 'height' => 768]; // Standard 4:3 ratio photo - $sizes[] = ['width' => 1080, 'height' => 720]; // Standard 3:2 ratio photo - $sizes[] = ['width' => 1080, 'height' => 1080]; // Standard 1:1 ratio photo, Instagram photo - $sizes[] = ['width' => 1080, 'height' => 1350]; // Instagram portrait photo - $sizes[] = ['width' => 1080, 'height' => 1920]; // Instagram story - $sizes[] = ['width' => 1128, 'height' => 191]; // Instagram company cover image - $sizes[] = ['width' => 1128, 'height' => 376]; // Instagram Main image - $sizes[] = ['width' => 1200, 'height' => 600]; // Twitter four images - $sizes[] = ['width' => 1200, 'height' => 627]; // Instagram shared link - $sizes[] = ['width' => 1200, 'height' => 628]; // Facebook and Twitter shared link - $sizes[] = ['width' => 1200, 'height' => 630]; // Facebook shared image - $sizes[] = ['width' => 1200, 'height' => 686]; // Twitter three images - $sizes[] = ['width' => 1200, 'height' => 675]; // Twitter shared image - $sizes[] = ['width' => 1280, 'height' => 720]; // Standard small 16:9 ratio photo, YouTube HD - $sizes[] = ['width' => 1500, 'height' => 1500]; // Twitter header photo - $sizes[] = ['width' => 1584, 'height' => 396]; // Instagram personal background image - $sizes[] = ['width' => 1920, 'height' => 1005]; // Facebook event image - $sizes[] = ['width' => 1920, 'height' => 1080]; // Standard big 16:9 ratio photo - $sizes[] = ['width' => 2048, 'height' => 1152]; // YouTube channel cover photo - - return Event::next; - } - /** * This code executes when GNU social creates the page routing, and we hook * on this event to add our action handler for Embed. @@ -329,7 +234,7 @@ class Embed extends Plugin $temp_file = new TemporaryFile(); $temp_file->write($img_data); try { - $attachment = GSFile::sanitizeAndStoreFileAsAttachment($temp_file); + $attachment = GSFile::storeFileAsAttachment($temp_file); $embed_data['attachment_id'] = $attachment->getId(); } catch (ClientException) { DB::persist($attachment = Attachment::create(['mimetype' => $link->getMimetype()])); diff --git a/plugins/StoreRemoteMedia/StoreRemoteMedia.php b/plugins/StoreRemoteMedia/StoreRemoteMedia.php index fc2f990442..85f50f6bcf 100644 --- a/plugins/StoreRemoteMedia/StoreRemoteMedia.php +++ b/plugins/StoreRemoteMedia/StoreRemoteMedia.php @@ -157,7 +157,7 @@ class StoreRemoteMedia extends Plugin // Create an attachment for this $temp_file = new TemporaryFile(); $temp_file->write($media); - $attachment = GSFile::sanitizeAndStoreFileAsAttachment($temp_file); + $attachment = GSFile::storeFileAsAttachment($temp_file); // Relate the link with the attachment DB::persist(AttachmentToLink::create([ @@ -177,8 +177,7 @@ class StoreRemoteMedia extends Plugin if (!$this->getStoreOriginal()) { $thumbnail = AttachmentThumbnail::getOrCreate( attachment: $attachment, - width: $this->getThumbnailWidth(), - height: $this->getThumbnailHeight(), + size: 'small', crop: $this->getSmartCrop() ); $attachment->deleteStorage(); diff --git a/social.yaml b/social.yaml index 05b645d3ab..82616b1516 100644 --- a/social.yaml +++ b/social.yaml @@ -102,7 +102,7 @@ parameters: uploads: true show_thumbs: true process_links: true - sanitize: true + sanitize: false ext_blacklist: [] memory_limit: 1024M @@ -111,17 +111,23 @@ parameters: ssl: dir: "%kernel.project_dir%/file/thumbnails/" smart_crop: false - max_size_px: 1000 - width: 450 - height: 600 + maximum_pixels: 256000 + minimum_width: 16 + minimum_height: 16 + small: 32 + medium: 256 + big: 496 + default_size: medium mimetype: 'image/webp' extension: '.webp' - plugin_embed: - width: 128 - height: 128 + plugin_store_remote_media: + max_file_size: 4000000 smart_crop: false + plugin_embed: + max_px: 64000 + smart_crop: false theme: server: diff --git a/src/Controller/Attachment.php b/src/Controller/Attachment.php index b079734c69..fd43ff3255 100644 --- a/src/Controller/Attachment.php +++ b/src/Controller/Attachment.php @@ -49,7 +49,7 @@ class Attachment extends Controller if ($id <= 0) { // This should never happen coming from the router, but let's bail if it does // @codeCoverageIgnoreStart Log::critical("Attachment controller called with {$id}, which should not be possible"); - throw new ClientException(_m('No such attachment'), 404); + throw new ClientException(_m('No such attachment.'), 404); // @codeCoverageIgnoreEnd } else { $res = null; @@ -92,7 +92,7 @@ class Attachment extends Controller ]; }); } catch (NotFoundException) { - throw new ClientException(_m('No such attachment'), 404); + throw new ClientException(_m('No such attachment.'), 404); } } @@ -132,29 +132,17 @@ class Attachment extends Controller * * @return Response */ - public function attachment_thumbnail(Request $request, int $id): Response + public function attachment_thumbnail(Request $request, int $id, string $size = 'small'): Response { $attachment = DB::findOneBy('attachment', ['id' => $id]); - $default_width = Common::config('thumbnail', 'width'); - $default_height = Common::config('thumbnail', 'height'); - $default_crop = Common::config('thumbnail', 'smart_crop'); - $width = $this->int('w') ?: $default_width; - $height = $this->int('h') ?: $default_height; - $crop = $this->bool('c') ?: $default_crop; + $crop = Common::config('thumbnail', 'smart_crop'); - $sizes = null; - Event::handle('GetAllowedThumbnailSizes', [&$sizes]); - if (!in_array(['width' => $width, 'height' => $height], $sizes)) { - throw new ClientException(_m('The requested thumbnail dimensions are not allowed'), 400); // 400 Bad Request + $thumbnail = AttachmentThumbnail::getOrCreate(attachment: $attachment, size: $size, crop: $crop); + if (is_null($thumbnail)) { + throw new ClientException(_m('Can not generate thumbnail for attachment with id={id}', ['id' => $attachment->getId()])); } - if (!is_null($attachment->getWidth()) && !is_null($attachment->getHeight())) { - [$width, $height] = AttachmentThumbnail::predictScalingValues($attachment->getWidth(), $attachment->getHeight(), $width, $height, $crop); - } - - $thumbnail = AttachmentThumbnail::getOrCreate(attachment: $attachment, width: $width, height: $height, crop: $crop); - $filename = $thumbnail->getFilename(); $path = $thumbnail->getPath(); $mimetype = $thumbnail->getMimetype(); diff --git a/src/Entity/Attachment.php b/src/Entity/Attachment.php index 5e9aed84db..49c031e3cc 100644 --- a/src/Entity/Attachment.php +++ b/src/Entity/Attachment.php @@ -345,9 +345,9 @@ class Attachment extends Entity return Router::url('attachment_view', ['id' => $this->getId()]); } - public function getThumbnailUrl() + public function getThumbnailUrl(?string $size = null) { - return Router::url('attachment_thumbnail', ['id' => $this->getId(), 'w' => Common::config('thumbnail', 'width'), 'h' => Common::config('thumbnail', 'height')]); + return Router::url('attachment_thumbnail', ['id' => $this->getId(), 'size' => $size ?? Common::config('thumbnail', 'default_size')]); } public static function schemaDef(): array diff --git a/src/Entity/AttachmentThumbnail.php b/src/Entity/AttachmentThumbnail.php index e0a28a39d6..b50f6a5b1e 100644 --- a/src/Entity/AttachmentThumbnail.php +++ b/src/Entity/AttachmentThumbnail.php @@ -26,7 +26,6 @@ use App\Core\DB\DB; use App\Core\Entity; use App\Core\Event; use App\Core\GSFile; -use function App\Core\I18n\_m; use App\Core\Log; use App\Core\Router\Router; use App\Util\Common; @@ -50,17 +49,21 @@ use Symfony\Component\Mime\MimeTypes; * @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org * @author Hugo Sales * @author Diogo Peralta Cordeiro + * @author Eliseu Amaro * @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 AttachmentThumbnail extends Entity { + public const SIZE_SMALL = 0; + public const SIZE_MEDIUM = 1; + public const SIZE_BIG = 2; + // {{{ Autocode // @codeCoverageIgnoreStart private int $attachment_id; private ?string $mimetype; - private int $width; - private int $height; + private int $size = self::SIZE_SMALL; private string $filename; private \DateTimeInterface $modified; @@ -97,15 +100,15 @@ class AttachmentThumbnail extends Entity return $this->width; } - public function setHeight(int $height): self + public function getSize(): int { - $this->height = $height; - return $this; + return $this->size; } - public function getHeight(): int + public function setSize(int $size): self { - return $this->height; + $this->size = $size; + return $this; } public function setFilename(string $filename): self @@ -161,24 +164,24 @@ class AttachmentThumbnail extends Entity * * @return mixed */ - public static function getOrCreate(Attachment $attachment, int $width, int $height, bool $crop) + public static function getOrCreate(Attachment $attachment, ?string $size = null, bool $crop = false) { - // We need to keep these in mind for DB indexing - $predicted_width = null; - $predicted_height = null; + $size = $size ?? Common::config('thumbnail', 'default_size'); + $size_int = match ($size) { + 'medium' => self::SIZE_MEDIUM, + 'big' => self::SIZE_BIG, + default => self::SIZE_SMALL, + }; try { - if (is_null($attachment->getWidth()) || is_null($attachment->getHeight())) { - // @codeCoverageIgnoreStart - // TODO: check if we can generate from an existing thumbnail - throw new ClientException(_m('Invalid dimensions requested for thumbnail.')); - // @codeCoverageIgnoreEnd - } - return Cache::get('thumb-' . $attachment->getId() . "-{$width}x{$height}", - function () use ($crop, $attachment, $width, $height, &$predicted_width, &$predicted_height) { - [$predicted_width, $predicted_height] = self::predictScalingValues($attachment->getWidth(), $attachment->getHeight(), $width, $height, $crop); - return DB::findOneBy('attachment_thumbnail', ['attachment_id' => $attachment->getId(), 'width' => $predicted_width, 'height' => $predicted_height]); + return Cache::get('thumb-' . $attachment->getId() . "-{$size}", + function () use ($crop, $attachment, $size_int) { + return DB::findOneBy('attachment_thumbnail', ['attachment_id' => $attachment->getId(), 'size' => $size_int]); }); - } catch (NotFoundException $e) { + } catch (NotFoundException) { + if (is_null($attachment->getWidth()) || is_null($attachment->getHeight())) { + return null; + } + [$predicted_width, $predicted_height] = self::predictScalingValues($attachment->getWidth(), $attachment->getHeight(), $size, $crop); if (!file_exists($attachment->getPath())) { throw new NotStoredLocallyException(); } @@ -194,10 +197,9 @@ class AttachmentThumbnail extends Entity foreach ($encoders as $encoder) { /** @var ?TemporaryFile */ $temp = null; // Let the EncoderPlugin create a temporary file for us - if ($encoder($attachment->getPath(), $temp, $width, $height, $crop, $mimetype)) { + if ($encoder($attachment->getPath(), $temp, $predicted_width, $predicted_height, $crop, $mimetype)) { $thumbnail->setAttachment($attachment); - $thumbnail->setWidth($predicted_width); - $thumbnail->setHeight($predicted_height); + $thumbnail->setSize($size_int); $mimetype = $temp->getMimeType(); $ext = '.' . MimeTypes::getDefault()->getExtensions($mimetype)[0]; $filename = "{$predicted_width}x{$predicted_height}{$ext}-" . $attachment->getFilehash(); @@ -209,7 +211,7 @@ class AttachmentThumbnail extends Entity return $thumbnail; } } - throw new ClientException(_m('Can not generate thumbnail for attachment with id={id}', ['id' => $attachment->getId()])); + return null; } } @@ -220,20 +222,7 @@ class AttachmentThumbnail extends Entity public function getUrl() { - return Router::url('attachment_thumbnail', ['id' => $this->getAttachmentId(), 'w' => $this->getWidth(), 'h' => $this->getHeight()]); - } - - /** - * Get the HTML attributes for this thumbnail - */ - public function getHTMLAttributes(array $orig = [], bool $overwrite = true) - { - $attrs = [ - 'height' => $this->getHeight(), - 'width' => $this->getWidth(), - 'src' => $this->getUrl(), - ]; - return $overwrite ? array_merge($orig, $attrs) : array_merge($attrs, $orig); + return Router::url('attachment_thumbnail', ['id' => $this->getAttachmentId(), 'size' => $this->getSize()]); } /** @@ -245,7 +234,7 @@ class AttachmentThumbnail extends Entity if (file_exists($filepath)) { if (@unlink($filepath) === false) { // @codeCoverageIgnoreStart - Log::warning("Failed deleting file for attachment thumbnail with id={$this->attachment_id}, width={$this->width}, height={$this->height} at {$filepath}"); + Log::warning("Failed deleting file for attachment thumbnail with id={$this->getAttachmentId()}, size={$this->getSize()} at {$filepath}"); // @codeCoverageIgnoreEnd } } @@ -261,35 +250,78 @@ class AttachmentThumbnail extends Entity * Values will scale _up_ to fit max values if cropping is enabled! * With cropping disabled, the max value of each axis will be respected. * - * @param int $existing_width Original width - * @param int $existing_height Original height - * @param int $requested_width Resulting max width - * @param int $requested_height Resulting max height - * @param bool $crop Crop to the size (not preserving aspect ratio) + * @param int $existing_width Original width + * @param int $existing_height Original height + * @param int $allowed_aspect_ratios + * @param int $requested_size + * @param bool $crop * * @return array [predicted width, predicted height] */ public static function predictScalingValues( int $existing_width, int $existing_height, - int $requested_width, - int $requested_height, + string $requested_size, bool $crop ): array { - if ($crop) { - $rw = min($existing_width, $requested_width); - $rh = min($existing_height, $requested_height); - } else { - if ($existing_width > $existing_height) { - $rw = min($existing_width, $requested_width); - $rh = ceil($existing_height * $rw / $existing_width); - } else { - $rh = min($existing_height, $requested_height); - $rw = ceil($existing_width * $rh / $existing_height); - } + /** + * 1:1 => Square + * 4:3 => SD + * 11:8 => Academy Ratio + * 3:2 => Classic 35mm + * 16:10 => Golden Ratio + * 16:9 => Widescreen + * 2.2:1 => Standard 70mm film + */ + $allowed_aspect_ratios = [1, 1.3, 1.376, 1.5, 1.6, 1.7, 2.2]; // Ascending array + $sizes = [ + 'small' => Common::config('thumbnail', 'small'), + 'medium' => Common::config('thumbnail', 'medium'), + 'big' => Common::config('thumbnail', 'big'), + ]; + + // We only scale if the image is larger than the minimum width and height for a thumbnail + if ($existing_width < Common::config('thumbnail', 'minimum_width') && $existing_height < Common::config('thumbnail', 'minimum_height')) { + return [$existing_width, $existing_height]; } - return [(int) $rw, (int) $rh]; + // We only scale if the total of pixels is greater than the maximum allowed for a thumbnail + $total_of_pixels = $existing_width * $existing_height; + if ($total_of_pixels < Common::config('thumbnail', 'maximum_pixels')) { + return [$existing_width, $existing_height]; + } + + // Is this a portrait image? + $flip = $existing_height > $existing_width; + + // Find the aspect ratio of the given image + $existing_aspect_ratio = !$flip ? $existing_width / $existing_height : $existing_height / $existing_width; + + // Binary search the closer allowed aspect ratio + $left = 0; + $right = count($allowed_aspect_ratios) - 1; + while ($left < $right) { + $mid = floor($left + ($right - $left) / 2); + + // Comparing absolute distances with middle value and right value + if (abs($existing_aspect_ratio - $allowed_aspect_ratios[$mid]) < abs($existing_aspect_ratio - $allowed_aspect_ratios[$right])) { + // search the left side of the array + $right = $mid; + } else { + // search the right side of the array + $left = $mid + 1; + } + } + $closest_aspect_ratio = $allowed_aspect_ratios[$left]; + unset($mid, $left, $right); + + // TODO: For crop, we should test a threshold and understand if the image would better be cropped + + // Resulting width and height + $rw = (int) ($sizes[$requested_size]); + $rh = (int) ($rw / $closest_aspect_ratio); + + return !$flip ? [$rw, $rh] : [$rh, $rw]; } public static function schemaDef(): array @@ -299,12 +331,11 @@ class AttachmentThumbnail extends Entity 'fields' => [ 'attachment_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Attachment.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'thumbnail for what attachment'], 'mimetype' => ['type' => 'varchar', 'length' => 129, 'description' => 'resource mime type 64+1+64, images hardly will show up with long mimetypes, this is probably safe considering rfc6838#section-4.2'], - 'width' => ['type' => 'int', 'not null' => true, 'description' => 'width of thumbnail'], - 'height' => ['type' => 'int', 'not null' => true, 'description' => 'height of thumbnail'], + 'size' => ['type' => 'int', 'not null' => true, 'default' => 0, 'description' => '0 = small; 1 = medium; 2 = big'], 'filename' => ['type' => 'varchar', 'length' => 191, 'not null' => true, 'description' => 'thumbnail filename'], 'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'], ], - 'primary key' => ['attachment_id', 'width', 'height'], + 'primary key' => ['attachment_id', 'size'], 'indexes' => [ 'attachment_thumbnail_attachment_id_idx' => ['attachment_id'], ], diff --git a/src/Routes/Attachments.php b/src/Routes/Attachments.php index 3004263195..c07677d494 100644 --- a/src/Routes/Attachments.php +++ b/src/Routes/Attachments.php @@ -45,7 +45,6 @@ abstract class Attachments $r->connect('attachment_show', '/attachment/{id<\d+>}', [C\Attachment::class, 'attachment_show']); $r->connect('attachment_view', '/attachment/{id<\d+>}/view', [C\Attachment::class, 'attachment_view']); $r->connect('attachment_download', '/attachment/{id<\d+>}/download', [C\Attachment::class, 'attachment_download']); - $r->connect('attachment_thumbnail', '/attachment/{id<\d+>}/thumbnail', [C\Attachment::class, 'attachment_thumbnail']); - $r->connect('thumbnail', '/thumbnail/{id<\d+>}', [C\Attachment::class, 'attachment_thumbnail']); // Backwards-compatibility + $r->connect('attachment_thumbnail', '/attachment/{id<\d+>}/thumbnail/{size}', [C\Attachment::class, 'attachment_thumbnail']); } }