xref: /webtrees/app/Factories/ImageFactory.php (revision 06c3e14e4fed5ad862e3566855102053125b09c6)
16577bfc3SGreg Roach<?php
26577bfc3SGreg Roach
36577bfc3SGreg Roach/**
46577bfc3SGreg Roach * webtrees: online genealogy
5d11be702SGreg Roach * Copyright (C) 2023 webtrees development team
66577bfc3SGreg Roach * This program is free software: you can redistribute it and/or modify
76577bfc3SGreg Roach * it under the terms of the GNU General Public License as published by
86577bfc3SGreg Roach * the Free Software Foundation, either version 3 of the License, or
96577bfc3SGreg Roach * (at your option) any later version.
106577bfc3SGreg Roach * This program is distributed in the hope that it will be useful,
116577bfc3SGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of
126577bfc3SGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
136577bfc3SGreg Roach * GNU General Public License for more details.
146577bfc3SGreg Roach * You should have received a copy of the GNU General Public License
1589f7189bSGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>.
166577bfc3SGreg Roach */
176577bfc3SGreg Roach
186577bfc3SGreg Roachdeclare(strict_types=1);
196577bfc3SGreg Roach
206577bfc3SGreg Roachnamespace Fisharebest\Webtrees\Factories;
216577bfc3SGreg Roach
226577bfc3SGreg Roachuse Fig\Http\Message\StatusCodeInterface;
236577bfc3SGreg Roachuse Fisharebest\Webtrees\Auth;
246577bfc3SGreg Roachuse Fisharebest\Webtrees\Contracts\ImageFactoryInterface;
256577bfc3SGreg Roachuse Fisharebest\Webtrees\Contracts\UserInterface;
266577bfc3SGreg Roachuse Fisharebest\Webtrees\MediaFile;
27f0448b68SGreg Roachuse Fisharebest\Webtrees\Mime;
286b9cb339SGreg Roachuse Fisharebest\Webtrees\Registry;
296577bfc3SGreg Roachuse Fisharebest\Webtrees\Webtrees;
306577bfc3SGreg Roachuse Imagick;
31*06c3e14eSGreg Roachuse Intervention\Gif\Exceptions\NotReadableException;
32*06c3e14eSGreg Roachuse Intervention\Image\Drivers\Gd\Driver as GdDriver;
33*06c3e14eSGreg Roachuse Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
346577bfc3SGreg Roachuse Intervention\Image\ImageManager;
35*06c3e14eSGreg Roachuse Intervention\Image\Interfaces\ImageInterface;
36*06c3e14eSGreg Roachuse InvalidArgumentException;
37f7cf8a15SGreg Roachuse League\Flysystem\FilesystemException;
38f7cf8a15SGreg Roachuse League\Flysystem\FilesystemOperator;
39f32d77e6SGreg Roachuse League\Flysystem\UnableToReadFile;
40f0448b68SGreg Roachuse League\Flysystem\UnableToRetrieveMetadata;
416577bfc3SGreg Roachuse Psr\Http\Message\ResponseInterface;
426577bfc3SGreg Roachuse RuntimeException;
436577bfc3SGreg Roachuse Throwable;
446577bfc3SGreg Roach
456577bfc3SGreg Roachuse function addcslashes;
466577bfc3SGreg Roachuse function basename;
476577bfc3SGreg Roachuse function extension_loaded;
48f32d77e6SGreg Roachuse function get_class;
497729532eSGreg Roachuse function implode;
506577bfc3SGreg Roachuse function pathinfo;
516577bfc3SGreg Roachuse function response;
527729532eSGreg Roachuse function str_contains;
536577bfc3SGreg Roachuse function view;
546577bfc3SGreg Roach
556577bfc3SGreg Roachuse const PATHINFO_EXTENSION;
566577bfc3SGreg Roach
576577bfc3SGreg Roach/**
586577bfc3SGreg Roach * Make an image (from another image).
596577bfc3SGreg Roach */
606577bfc3SGreg Roachclass ImageFactory implements ImageFactoryInterface
616577bfc3SGreg Roach{
626577bfc3SGreg Roach    // Imagick can detect the quality setting for images.  GD cannot.
636577bfc3SGreg Roach    protected const GD_DEFAULT_IMAGE_QUALITY     = 90;
646577bfc3SGreg Roach    protected const GD_DEFAULT_THUMBNAIL_QUALITY = 70;
656577bfc3SGreg Roach
666577bfc3SGreg Roach    protected const WATERMARK_FILE = 'resources/img/watermark.png';
676577bfc3SGreg Roach
686577bfc3SGreg Roach    protected const THUMBNAIL_CACHE_TTL = 8640000;
696577bfc3SGreg Roach
7071166947SGreg Roach    public const SUPPORTED_FORMATS = [
716577bfc3SGreg Roach        'image/jpeg' => 'jpg',
726577bfc3SGreg Roach        'image/png'  => 'png',
736577bfc3SGreg Roach        'image/gif'  => 'gif',
746577bfc3SGreg Roach        'image/tiff' => 'tif',
756577bfc3SGreg Roach        'image/bmp'  => 'bmp',
766577bfc3SGreg Roach        'image/webp' => 'webp',
776577bfc3SGreg Roach    ];
786577bfc3SGreg Roach
796577bfc3SGreg Roach    /**
806577bfc3SGreg Roach     * Send the original file - either inline or as a download.
816577bfc3SGreg Roach     */
82f7cf8a15SGreg Roach    public function fileResponse(FilesystemOperator $filesystem, string $path, bool $download): ResponseInterface
836577bfc3SGreg Roach    {
846577bfc3SGreg Roach        try {
85f0448b68SGreg Roach            try {
86*06c3e14eSGreg Roach                $mime_type = $filesystem->mimeType(path: $path);
8728d026adSGreg Roach            } catch (UnableToRetrieveMetadata) {
887729532eSGreg Roach                $mime_type = Mime::DEFAULT_TYPE;
89f0448b68SGreg Roach            }
90f0448b68SGreg Roach
91*06c3e14eSGreg Roach            $filename = $download ? addcslashes(string: basename(path: $path), characters: '"') : '';
926577bfc3SGreg Roach
93*06c3e14eSGreg Roach            return $this->imageResponse(data: $filesystem->read(location: $path), mime_type: $mime_type, filename: $filename);
94fc904122SGreg Roach        } catch (UnableToReadFile | FilesystemException $ex) {
95*06c3e14eSGreg Roach            return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND)
96*06c3e14eSGreg Roach                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
976577bfc3SGreg Roach        }
986577bfc3SGreg Roach    }
996577bfc3SGreg Roach
1006577bfc3SGreg Roach    /**
1016577bfc3SGreg Roach     * Send a thumbnail.
1026577bfc3SGreg Roach     */
1036577bfc3SGreg Roach    public function thumbnailResponse(
104f7cf8a15SGreg Roach        FilesystemOperator $filesystem,
1056577bfc3SGreg Roach        string $path,
1066577bfc3SGreg Roach        int $width,
1076577bfc3SGreg Roach        int $height,
1086577bfc3SGreg Roach        string $fit
1096577bfc3SGreg Roach    ): ResponseInterface {
1106577bfc3SGreg Roach        try {
111*06c3e14eSGreg Roach            $mime_type = $filesystem->mimeType(path: $path);
112*06c3e14eSGreg Roach            $image     = $this->imageManager()->read(input: $filesystem->readStream($path));
113*06c3e14eSGreg Roach            $image     = $this->resizeImage(image: $image, width: $width, height: $height, fit: $fit);
114*06c3e14eSGreg Roach            $quality   = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_THUMBNAIL_QUALITY);
115*06c3e14eSGreg Roach            $data      = $image->encodeByMediaType(type: $mime_type, quality: $quality)->toString();
1166577bfc3SGreg Roach
117*06c3e14eSGreg Roach            return $this->imageResponse(data: $data, mime_type: $mime_type, filename: '');
118f0448b68SGreg Roach        } catch (FilesystemException | UnableToReadFile $ex) {
119*06c3e14eSGreg Roach            return $this
120*06c3e14eSGreg Roach                ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND)
121*06c3e14eSGreg Roach                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
122*06c3e14eSGreg Roach        } catch (RuntimeException $ex) {
123*06c3e14eSGreg Roach            return $this
124*06c3e14eSGreg Roach                ->replacementImageResponse(text: '.' . pathinfo(path: $path, flags: PATHINFO_EXTENSION))
125*06c3e14eSGreg Roach                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
1266577bfc3SGreg Roach        } catch (Throwable $ex) {
127*06c3e14eSGreg Roach            return $this
128*06c3e14eSGreg Roach                ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
129*06c3e14eSGreg Roach                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
1306577bfc3SGreg Roach        }
1316577bfc3SGreg Roach    }
1326577bfc3SGreg Roach
1336577bfc3SGreg Roach    /**
1346577bfc3SGreg Roach     * Create a full-size version of an image.
1356577bfc3SGreg Roach     */
1366577bfc3SGreg Roach    public function mediaFileResponse(MediaFile $media_file, bool $add_watermark, bool $download): ResponseInterface
1376577bfc3SGreg Roach    {
1389458f20aSGreg Roach        $filesystem = $media_file->media()->tree()->mediaFilesystem();
1397729532eSGreg Roach        $path       = $media_file->filename();
1406577bfc3SGreg Roach
1416577bfc3SGreg Roach        if (!$add_watermark || !$media_file->isImage()) {
142*06c3e14eSGreg Roach            return $this->fileResponse(filesystem: $filesystem, path: $path, download: $download);
1436577bfc3SGreg Roach        }
1446577bfc3SGreg Roach
1456577bfc3SGreg Roach        try {
146*06c3e14eSGreg Roach            $mime_type = $media_file->mimeType();
147*06c3e14eSGreg Roach            $image     = $this->imageManager()->read(input: $filesystem->readStream($path));
148*06c3e14eSGreg Roach            $watermark = $this->createWatermark(width: $image->width(), height: $image->height(), media_file: $media_file);
149*06c3e14eSGreg Roach            $image     = $this->addWatermark(image: $image, watermark: $watermark);
150*06c3e14eSGreg Roach            $filename  = $download ? basename(path: $path) : '';
151*06c3e14eSGreg Roach            $quality   = $this->extractImageQuality(image: $image, default: static::GD_DEFAULT_IMAGE_QUALITY);
152*06c3e14eSGreg Roach            $data      = $image->encodeByMediaType(type: $mime_type, quality:  $quality)->toString();
1536577bfc3SGreg Roach
154*06c3e14eSGreg Roach            return $this->imageResponse(data: $data, mime_type: $mime_type, filename: $filename);
1556577bfc3SGreg Roach        } catch (NotReadableException $ex) {
156*06c3e14eSGreg Roach            return $this->replacementImageResponse(text: pathinfo(path: $path, flags: PATHINFO_EXTENSION))
1576172e7f6SGreg Roach                ->withHeader('x-image-exception', $ex->getMessage());
158f0448b68SGreg Roach        } catch (FilesystemException | UnableToReadFile $ex) {
159*06c3e14eSGreg Roach            return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND)
160*06c3e14eSGreg Roach                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
1616577bfc3SGreg Roach        } catch (Throwable $ex) {
162*06c3e14eSGreg Roach            return $this->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
1636172e7f6SGreg Roach                ->withHeader('x-image-exception', $ex->getMessage());
1646577bfc3SGreg Roach        }
1656577bfc3SGreg Roach    }
1666577bfc3SGreg Roach
1676577bfc3SGreg Roach    /**
1686577bfc3SGreg Roach     * Create a smaller version of an image.
1696577bfc3SGreg Roach     */
1706577bfc3SGreg Roach    public function mediaFileThumbnailResponse(
1716577bfc3SGreg Roach        MediaFile $media_file,
1726577bfc3SGreg Roach        int $width,
1736577bfc3SGreg Roach        int $height,
1746577bfc3SGreg Roach        string $fit,
1756577bfc3SGreg Roach        bool $add_watermark
1766577bfc3SGreg Roach    ): ResponseInterface {
1776577bfc3SGreg Roach        // Where are the images stored.
1789458f20aSGreg Roach        $filesystem = $media_file->media()->tree()->mediaFilesystem();
1796577bfc3SGreg Roach
1806577bfc3SGreg Roach        // Where is the image stored in the filesystem.
1816577bfc3SGreg Roach        $path = $media_file->filename();
1826577bfc3SGreg Roach
1836577bfc3SGreg Roach        try {
184*06c3e14eSGreg Roach            $mime_type = $filesystem->mimeType(path: $path);
1856577bfc3SGreg Roach
186*06c3e14eSGreg Roach            $key = implode(separator: ':', array: [
1876577bfc3SGreg Roach                $media_file->media()->tree()->name(),
1886577bfc3SGreg Roach                $path,
189*06c3e14eSGreg Roach                $filesystem->lastModified(path: $path),
1906577bfc3SGreg Roach                (string) $width,
1916577bfc3SGreg Roach                (string) $height,
1926577bfc3SGreg Roach                $fit,
1936577bfc3SGreg Roach                (string) $add_watermark,
1946577bfc3SGreg Roach            ]);
1956577bfc3SGreg Roach
1966577bfc3SGreg Roach            $closure = function () use ($filesystem, $path, $width, $height, $fit, $add_watermark, $media_file): string {
197*06c3e14eSGreg Roach                $image = $this->imageManager()->read(input: $filesystem->readStream($path));
198*06c3e14eSGreg Roach                $image = $this->resizeImage(image: $image, width: $width, height: $height, fit: $fit);
1996577bfc3SGreg Roach
2006577bfc3SGreg Roach                if ($add_watermark) {
201*06c3e14eSGreg Roach                    $watermark = $this->createWatermark(width: $image->width(), height: $image->height(), media_file: $media_file);
202*06c3e14eSGreg Roach                    $image     = $this->addWatermark(image: $image, watermark: $watermark);
2036577bfc3SGreg Roach                }
2046577bfc3SGreg Roach
205*06c3e14eSGreg Roach                $quality = $this->extractImageQuality(image: $image, default:  static::GD_DEFAULT_THUMBNAIL_QUALITY);
2066577bfc3SGreg Roach
207*06c3e14eSGreg Roach                return $image->encodeByMediaType(type: $media_file->mimeType(), quality: $quality)->toString();
2086577bfc3SGreg Roach            };
2096577bfc3SGreg Roach
2106577bfc3SGreg Roach            // Images and Responses both contain resources - which cannot be serialized.
2116577bfc3SGreg Roach            // So cache the raw image data.
212*06c3e14eSGreg Roach            $data = Registry::cache()->file()->remember(key: $key, closure: $closure, ttl: static::THUMBNAIL_CACHE_TTL);
2136577bfc3SGreg Roach
214*06c3e14eSGreg Roach            return $this->imageResponse(data: $data, mime_type:  $mime_type, filename:  '');
2156577bfc3SGreg Roach        } catch (NotReadableException $ex) {
216*06c3e14eSGreg Roach            return $this
217*06c3e14eSGreg Roach                ->replacementImageResponse(text: '.' . pathinfo(path: $path, flags:  PATHINFO_EXTENSION))
218*06c3e14eSGreg Roach                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
219f0448b68SGreg Roach        } catch (FilesystemException | UnableToReadFile $ex) {
220*06c3e14eSGreg Roach            return $this
221*06c3e14eSGreg Roach                ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_NOT_FOUND)
222*06c3e14eSGreg Roach                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
2236577bfc3SGreg Roach        } catch (Throwable $ex) {
224*06c3e14eSGreg Roach            return $this
225*06c3e14eSGreg Roach                ->replacementImageResponse(text: (string) StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR)
226*06c3e14eSGreg Roach                ->withHeader('x-thumbnail-exception', get_class(object: $ex) . ': ' . $ex->getMessage());
2276577bfc3SGreg Roach        }
2286577bfc3SGreg Roach    }
2296577bfc3SGreg Roach
2306577bfc3SGreg Roach    /**
2316577bfc3SGreg Roach     * Does a full-sized image need a watermark?
2326577bfc3SGreg Roach     */
2336577bfc3SGreg Roach    public function fileNeedsWatermark(MediaFile $media_file, UserInterface $user): bool
2346577bfc3SGreg Roach    {
2356577bfc3SGreg Roach        $tree = $media_file->media()->tree();
2366577bfc3SGreg Roach
237*06c3e14eSGreg Roach        return Auth::accessLevel(tree: $tree, user: $user) > (int) $tree->getPreference(setting_name: 'SHOW_NO_WATERMARK');
2386577bfc3SGreg Roach    }
2396577bfc3SGreg Roach
2406577bfc3SGreg Roach    /**
2416577bfc3SGreg Roach     * Does a thumbnail image need a watermark?
2426577bfc3SGreg Roach     */
2436577bfc3SGreg Roach    public function thumbnailNeedsWatermark(MediaFile $media_file, UserInterface $user): bool
2446577bfc3SGreg Roach    {
245*06c3e14eSGreg Roach        return $this->fileNeedsWatermark(media_file: $media_file, user:  $user);
2466577bfc3SGreg Roach    }
2476577bfc3SGreg Roach
2486577bfc3SGreg Roach    /**
2496577bfc3SGreg Roach     * Create a watermark image, perhaps specific to a media-file.
2506577bfc3SGreg Roach     */
251*06c3e14eSGreg Roach    public function createWatermark(int $width, int $height, MediaFile $media_file): ImageInterface
2526577bfc3SGreg Roach    {
2536577bfc3SGreg Roach        return $this->imageManager()
254*06c3e14eSGreg Roach            ->read(input: Webtrees::ROOT_DIR . static::WATERMARK_FILE)
255*06c3e14eSGreg Roach            ->contain(width: $width, height: $height);
2566577bfc3SGreg Roach    }
2576577bfc3SGreg Roach
2586577bfc3SGreg Roach    /**
2596577bfc3SGreg Roach     * Add a watermark to an image.
2606577bfc3SGreg Roach     */
261*06c3e14eSGreg Roach    public function addWatermark(ImageInterface $image, ImageInterface $watermark): ImageInterface
2626577bfc3SGreg Roach    {
263*06c3e14eSGreg Roach        return $image->place(element: $watermark, position:  'center');
2646577bfc3SGreg Roach    }
2656577bfc3SGreg Roach
2666577bfc3SGreg Roach    /**
2676577bfc3SGreg Roach     * Send a replacement image, to replace one that could not be found or created.
2686577bfc3SGreg Roach     */
2696577bfc3SGreg Roach    public function replacementImageResponse(string $text): ResponseInterface
2706577bfc3SGreg Roach    {
2716577bfc3SGreg Roach        // We can't create a PNG/BMP/JPEG image, as the GD/IMAGICK libraries may be missing.
272*06c3e14eSGreg Roach        $svg = view(name: 'errors/image-svg', data: ['status' => $text]);
2736577bfc3SGreg Roach
2746577bfc3SGreg Roach        // We can't send the actual status code, as browsers won't show images with 4xx/5xx.
275*06c3e14eSGreg Roach        return response(content: $svg, code: StatusCodeInterface::STATUS_OK, headers: [
2767729532eSGreg Roach            'content-type' => 'image/svg+xml',
2776577bfc3SGreg Roach        ]);
2786577bfc3SGreg Roach    }
2796577bfc3SGreg Roach
2806577bfc3SGreg Roach    /**
281*06c3e14eSGreg Roach     * Create a response from image data.
2826577bfc3SGreg Roach     */
2836577bfc3SGreg Roach    protected function imageResponse(string $data, string $mime_type, string $filename): ResponseInterface
2846577bfc3SGreg Roach    {
285*06c3e14eSGreg Roach        if ($mime_type === 'image/svg+xml' && str_contains(haystack: $data, needle: '<script')) {
286*06c3e14eSGreg Roach            return $this->replacementImageResponse(text: 'XSS')
2876172e7f6SGreg Roach                ->withHeader('x-image-exception', 'SVG image blocked due to XSS.');
2886577bfc3SGreg Roach        }
2896577bfc3SGreg Roach
2908340714cSGreg Roach        // HTML files may contain javascript and iframes, so use content-security-policy to disable them.
2917729532eSGreg Roach        $response = response($data)
292fc904122SGreg Roach            ->withHeader('content-type', $mime_type)
2938340714cSGreg Roach            ->withHeader('content-security-policy', 'script-src none;frame-src none');
2947729532eSGreg Roach
2957729532eSGreg Roach        if ($filename === '') {
2967729532eSGreg Roach            return $response;
2977729532eSGreg Roach        }
2987729532eSGreg Roach
2997729532eSGreg Roach        return $response
300*06c3e14eSGreg Roach            ->withHeader('content-disposition', 'attachment; filename="' . addcslashes(string: basename(path: $filename), characters: '"'));
3016577bfc3SGreg Roach    }
3026577bfc3SGreg Roach
3036577bfc3SGreg Roach    /**
304*06c3e14eSGreg Roach     * Choose an image library, based on what is installed.
3056577bfc3SGreg Roach     */
3066577bfc3SGreg Roach    protected function imageManager(): ImageManager
3076577bfc3SGreg Roach    {
308*06c3e14eSGreg Roach        if (extension_loaded(extension: 'imagick')) {
309*06c3e14eSGreg Roach            return new ImageManager(driver: new ImagickDriver());
3106577bfc3SGreg Roach        }
3116577bfc3SGreg Roach
312*06c3e14eSGreg Roach        if (extension_loaded(extension: 'gd')) {
313*06c3e14eSGreg Roach            return new ImageManager(driver: new GdDriver());
3146577bfc3SGreg Roach        }
3156577bfc3SGreg Roach
316*06c3e14eSGreg Roach        throw new RuntimeException(message: 'No PHP graphics library is installed.  Need Imagick or GD');
3176577bfc3SGreg Roach    }
3186577bfc3SGreg Roach
3196577bfc3SGreg Roach    /**
3206577bfc3SGreg Roach     * Resize an image.
3216577bfc3SGreg Roach     */
322*06c3e14eSGreg Roach    protected function resizeImage(ImageInterface $image, int $width, int $height, string $fit): ImageInterface
3236577bfc3SGreg Roach    {
324*06c3e14eSGreg Roach        return match ($fit) {
325*06c3e14eSGreg Roach            'crop'    => $image->cover(width: $width, height: $height),
326*06c3e14eSGreg Roach            'contain' => $image->scale(width: $width, height: $height),
327*06c3e14eSGreg Roach            default   => throw new InvalidArgumentException(message: 'Unknown fit type: ' . $fit),
328*06c3e14eSGreg Roach        };
3296577bfc3SGreg Roach    }
3306577bfc3SGreg Roach
3316577bfc3SGreg Roach    /**
3326577bfc3SGreg Roach     * Extract the quality/compression parameter from an image.
3336577bfc3SGreg Roach     */
334*06c3e14eSGreg Roach    protected function extractImageQuality(ImageInterface $image, int $default): int
3356577bfc3SGreg Roach    {
336*06c3e14eSGreg Roach        $native = $image->core()->native();
3376577bfc3SGreg Roach
338*06c3e14eSGreg Roach        if ($native instanceof Imagick) {
339*06c3e14eSGreg Roach            return $native->getImageCompressionQuality();
3406577bfc3SGreg Roach        }
3416577bfc3SGreg Roach
3426577bfc3SGreg Roach        return $default;
3436577bfc3SGreg Roach    }
3446577bfc3SGreg Roach}
345