diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml index 17732e47c05..786adef1664 100644 --- a/.github/workflows/Build.yml +++ b/.github/workflows/Build.yml @@ -90,7 +90,7 @@ jobs: extensions: ${{ env.extensions }} coverage: xdebug tools: pecl, composer - + - name: Set Up imagick & Exiftools run: | sudo apt-get update @@ -148,7 +148,7 @@ jobs: if: ${{ matrix.mode == 'dev' }} run: php artisan migrate:rollback - # end of DEV + # end of DEV # begin of DIST - name: Install Composer dependencies (dist) if: ${{ matrix.mode == 'dist' }} diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php index ddba515010b..5249b029e75 100644 --- a/.phpstorm.meta.php +++ b/.phpstorm.meta.php @@ -13,10 +13,14 @@ override(new \Illuminate\Contracts\Container\Container(), map([ '' => '@', 'AccessControl' => \App\ModelFunctions\SessionFunctions::class, - 'App\Actions\Albums\Extensions\PublicIds' => \App\Actions\Albums\Extensions\PublicIds::class, + 'App\Actions\AlbumAuthorisationProvider' => \App\Actions\AlbumAuthorisationProvider::class, + 'App\Actions\PhotoAuthorisationProvider' => \App\Actions\PhotoAuthorisationProvider::class, 'App\Actions\Update\Apply' => \App\Actions\Update\Apply::class, 'App\Actions\Update\Check' => \App\Actions\Update\Check::class, 'App\Assets\Helpers' => \App\Assets\Helpers::class, + 'App\Contracts\SizeVariantFactory' => \App\Image\SizeVariantDefaultFactory::class, + 'App\Contracts\SizeVariantNamingStrategy' => \App\Assets\SizeVariantLegacyNamingStrategy::class, + 'App\Factories\AlbumFactory' => \App\Factories\AlbumFactory::class, 'App\Image\ImageHandlerInterface' => \App\Image\ImageHandler::class, 'App\Metadata\GitHubFunctions' => \App\Metadata\GitHubFunctions::class, 'App\Metadata\GitRequest' => \App\Metadata\GitRequest::class, @@ -224,10 +228,14 @@ override(\Illuminate\Container\Container::makeWith(0), map([ '' => '@', 'AccessControl' => \App\ModelFunctions\SessionFunctions::class, - 'App\Actions\Albums\Extensions\PublicIds' => \App\Actions\Albums\Extensions\PublicIds::class, + 'App\Actions\AlbumAuthorisationProvider' => \App\Actions\AlbumAuthorisationProvider::class, + 'App\Actions\PhotoAuthorisationProvider' => \App\Actions\PhotoAuthorisationProvider::class, 'App\Actions\Update\Apply' => \App\Actions\Update\Apply::class, 'App\Actions\Update\Check' => \App\Actions\Update\Check::class, 'App\Assets\Helpers' => \App\Assets\Helpers::class, + 'App\Contracts\SizeVariantFactory' => \App\Image\SizeVariantDefaultFactory::class, + 'App\Contracts\SizeVariantNamingStrategy' => \App\Assets\SizeVariantLegacyNamingStrategy::class, + 'App\Factories\AlbumFactory' => \App\Factories\AlbumFactory::class, 'App\Image\ImageHandlerInterface' => \App\Image\ImageHandler::class, 'App\Metadata\GitHubFunctions' => \App\Metadata\GitHubFunctions::class, 'App\Metadata\GitRequest' => \App\Metadata\GitRequest::class, @@ -435,10 +443,14 @@ override(\Illuminate\Contracts\Container\Container::get(0), map([ '' => '@', 'AccessControl' => \App\ModelFunctions\SessionFunctions::class, - 'App\Actions\Albums\Extensions\PublicIds' => \App\Actions\Albums\Extensions\PublicIds::class, + 'App\Actions\AlbumAuthorisationProvider' => \App\Actions\AlbumAuthorisationProvider::class, + 'App\Actions\PhotoAuthorisationProvider' => \App\Actions\PhotoAuthorisationProvider::class, 'App\Actions\Update\Apply' => \App\Actions\Update\Apply::class, 'App\Actions\Update\Check' => \App\Actions\Update\Check::class, 'App\Assets\Helpers' => \App\Assets\Helpers::class, + 'App\Contracts\SizeVariantFactory' => \App\Image\SizeVariantDefaultFactory::class, + 'App\Contracts\SizeVariantNamingStrategy' => \App\Assets\SizeVariantLegacyNamingStrategy::class, + 'App\Factories\AlbumFactory' => \App\Factories\AlbumFactory::class, 'App\Image\ImageHandlerInterface' => \App\Image\ImageHandler::class, 'App\Metadata\GitHubFunctions' => \App\Metadata\GitHubFunctions::class, 'App\Metadata\GitRequest' => \App\Metadata\GitRequest::class, @@ -646,10 +658,14 @@ override(\Illuminate\Contracts\Container\Container::make(0), map([ '' => '@', 'AccessControl' => \App\ModelFunctions\SessionFunctions::class, - 'App\Actions\Albums\Extensions\PublicIds' => \App\Actions\Albums\Extensions\PublicIds::class, + 'App\Actions\AlbumAuthorisationProvider' => \App\Actions\AlbumAuthorisationProvider::class, + 'App\Actions\PhotoAuthorisationProvider' => \App\Actions\PhotoAuthorisationProvider::class, 'App\Actions\Update\Apply' => \App\Actions\Update\Apply::class, 'App\Actions\Update\Check' => \App\Actions\Update\Check::class, 'App\Assets\Helpers' => \App\Assets\Helpers::class, + 'App\Contracts\SizeVariantFactory' => \App\Image\SizeVariantDefaultFactory::class, + 'App\Contracts\SizeVariantNamingStrategy' => \App\Assets\SizeVariantLegacyNamingStrategy::class, + 'App\Factories\AlbumFactory' => \App\Factories\AlbumFactory::class, 'App\Image\ImageHandlerInterface' => \App\Image\ImageHandler::class, 'App\Metadata\GitHubFunctions' => \App\Metadata\GitHubFunctions::class, 'App\Metadata\GitRequest' => \App\Metadata\GitRequest::class, @@ -857,10 +873,14 @@ override(\Illuminate\Contracts\Container\Container::makeWith(0), map([ '' => '@', 'AccessControl' => \App\ModelFunctions\SessionFunctions::class, - 'App\Actions\Albums\Extensions\PublicIds' => \App\Actions\Albums\Extensions\PublicIds::class, + 'App\Actions\AlbumAuthorisationProvider' => \App\Actions\AlbumAuthorisationProvider::class, + 'App\Actions\PhotoAuthorisationProvider' => \App\Actions\PhotoAuthorisationProvider::class, 'App\Actions\Update\Apply' => \App\Actions\Update\Apply::class, 'App\Actions\Update\Check' => \App\Actions\Update\Check::class, 'App\Assets\Helpers' => \App\Assets\Helpers::class, + 'App\Contracts\SizeVariantFactory' => \App\Image\SizeVariantDefaultFactory::class, + 'App\Contracts\SizeVariantNamingStrategy' => \App\Assets\SizeVariantLegacyNamingStrategy::class, + 'App\Factories\AlbumFactory' => \App\Factories\AlbumFactory::class, 'App\Image\ImageHandlerInterface' => \App\Image\ImageHandler::class, 'App\Metadata\GitHubFunctions' => \App\Metadata\GitHubFunctions::class, 'App\Metadata\GitRequest' => \App\Metadata\GitRequest::class, @@ -1068,10 +1088,14 @@ override(\App::get(0), map([ '' => '@', 'AccessControl' => \App\ModelFunctions\SessionFunctions::class, - 'App\Actions\Albums\Extensions\PublicIds' => \App\Actions\Albums\Extensions\PublicIds::class, + 'App\Actions\AlbumAuthorisationProvider' => \App\Actions\AlbumAuthorisationProvider::class, + 'App\Actions\PhotoAuthorisationProvider' => \App\Actions\PhotoAuthorisationProvider::class, 'App\Actions\Update\Apply' => \App\Actions\Update\Apply::class, 'App\Actions\Update\Check' => \App\Actions\Update\Check::class, 'App\Assets\Helpers' => \App\Assets\Helpers::class, + 'App\Contracts\SizeVariantFactory' => \App\Image\SizeVariantDefaultFactory::class, + 'App\Contracts\SizeVariantNamingStrategy' => \App\Assets\SizeVariantLegacyNamingStrategy::class, + 'App\Factories\AlbumFactory' => \App\Factories\AlbumFactory::class, 'App\Image\ImageHandlerInterface' => \App\Image\ImageHandler::class, 'App\Metadata\GitHubFunctions' => \App\Metadata\GitHubFunctions::class, 'App\Metadata\GitRequest' => \App\Metadata\GitRequest::class, @@ -1279,10 +1303,14 @@ override(\App::make(0), map([ '' => '@', 'AccessControl' => \App\ModelFunctions\SessionFunctions::class, - 'App\Actions\Albums\Extensions\PublicIds' => \App\Actions\Albums\Extensions\PublicIds::class, + 'App\Actions\AlbumAuthorisationProvider' => \App\Actions\AlbumAuthorisationProvider::class, + 'App\Actions\PhotoAuthorisationProvider' => \App\Actions\PhotoAuthorisationProvider::class, 'App\Actions\Update\Apply' => \App\Actions\Update\Apply::class, 'App\Actions\Update\Check' => \App\Actions\Update\Check::class, 'App\Assets\Helpers' => \App\Assets\Helpers::class, + 'App\Contracts\SizeVariantFactory' => \App\Image\SizeVariantDefaultFactory::class, + 'App\Contracts\SizeVariantNamingStrategy' => \App\Assets\SizeVariantLegacyNamingStrategy::class, + 'App\Factories\AlbumFactory' => \App\Factories\AlbumFactory::class, 'App\Image\ImageHandlerInterface' => \App\Image\ImageHandler::class, 'App\Metadata\GitHubFunctions' => \App\Metadata\GitHubFunctions::class, 'App\Metadata\GitRequest' => \App\Metadata\GitRequest::class, @@ -1490,10 +1518,14 @@ override(\App::makeWith(0), map([ '' => '@', 'AccessControl' => \App\ModelFunctions\SessionFunctions::class, - 'App\Actions\Albums\Extensions\PublicIds' => \App\Actions\Albums\Extensions\PublicIds::class, + 'App\Actions\AlbumAuthorisationProvider' => \App\Actions\AlbumAuthorisationProvider::class, + 'App\Actions\PhotoAuthorisationProvider' => \App\Actions\PhotoAuthorisationProvider::class, 'App\Actions\Update\Apply' => \App\Actions\Update\Apply::class, 'App\Actions\Update\Check' => \App\Actions\Update\Check::class, 'App\Assets\Helpers' => \App\Assets\Helpers::class, + 'App\Contracts\SizeVariantFactory' => \App\Image\SizeVariantDefaultFactory::class, + 'App\Contracts\SizeVariantNamingStrategy' => \App\Assets\SizeVariantLegacyNamingStrategy::class, + 'App\Factories\AlbumFactory' => \App\Factories\AlbumFactory::class, 'App\Image\ImageHandlerInterface' => \App\Image\ImageHandler::class, 'App\Metadata\GitHubFunctions' => \App\Metadata\GitHubFunctions::class, 'App\Metadata\GitRequest' => \App\Metadata\GitRequest::class, @@ -1701,10 +1733,14 @@ override(\app(0), map([ '' => '@', 'AccessControl' => \App\ModelFunctions\SessionFunctions::class, - 'App\Actions\Albums\Extensions\PublicIds' => \App\Actions\Albums\Extensions\PublicIds::class, + 'App\Actions\AlbumAuthorisationProvider' => \App\Actions\AlbumAuthorisationProvider::class, + 'App\Actions\PhotoAuthorisationProvider' => \App\Actions\PhotoAuthorisationProvider::class, 'App\Actions\Update\Apply' => \App\Actions\Update\Apply::class, 'App\Actions\Update\Check' => \App\Actions\Update\Check::class, 'App\Assets\Helpers' => \App\Assets\Helpers::class, + 'App\Contracts\SizeVariantFactory' => \App\Image\SizeVariantDefaultFactory::class, + 'App\Contracts\SizeVariantNamingStrategy' => \App\Assets\SizeVariantLegacyNamingStrategy::class, + 'App\Factories\AlbumFactory' => \App\Factories\AlbumFactory::class, 'App\Image\ImageHandlerInterface' => \App\Image\ImageHandler::class, 'App\Metadata\GitHubFunctions' => \App\Metadata\GitHubFunctions::class, 'App\Metadata\GitRequest' => \App\Metadata\GitRequest::class, @@ -1912,10 +1948,14 @@ override(\resolve(0), map([ '' => '@', 'AccessControl' => \App\ModelFunctions\SessionFunctions::class, - 'App\Actions\Albums\Extensions\PublicIds' => \App\Actions\Albums\Extensions\PublicIds::class, + 'App\Actions\AlbumAuthorisationProvider' => \App\Actions\AlbumAuthorisationProvider::class, + 'App\Actions\PhotoAuthorisationProvider' => \App\Actions\PhotoAuthorisationProvider::class, 'App\Actions\Update\Apply' => \App\Actions\Update\Apply::class, 'App\Actions\Update\Check' => \App\Actions\Update\Check::class, 'App\Assets\Helpers' => \App\Assets\Helpers::class, + 'App\Contracts\SizeVariantFactory' => \App\Image\SizeVariantDefaultFactory::class, + 'App\Contracts\SizeVariantNamingStrategy' => \App\Assets\SizeVariantLegacyNamingStrategy::class, + 'App\Factories\AlbumFactory' => \App\Factories\AlbumFactory::class, 'App\Image\ImageHandlerInterface' => \App\Image\ImageHandler::class, 'App\Metadata\GitHubFunctions' => \App\Metadata\GitHubFunctions::class, 'App\Metadata\GitRequest' => \App\Metadata\GitRequest::class, @@ -2123,10 +2163,14 @@ override(\Psr\Container\ContainerInterface::get(0), map([ '' => '@', 'AccessControl' => \App\ModelFunctions\SessionFunctions::class, - 'App\Actions\Albums\Extensions\PublicIds' => \App\Actions\Albums\Extensions\PublicIds::class, + 'App\Actions\AlbumAuthorisationProvider' => \App\Actions\AlbumAuthorisationProvider::class, + 'App\Actions\PhotoAuthorisationProvider' => \App\Actions\PhotoAuthorisationProvider::class, 'App\Actions\Update\Apply' => \App\Actions\Update\Apply::class, 'App\Actions\Update\Check' => \App\Actions\Update\Check::class, 'App\Assets\Helpers' => \App\Assets\Helpers::class, + 'App\Contracts\SizeVariantFactory' => \App\Image\SizeVariantDefaultFactory::class, + 'App\Contracts\SizeVariantNamingStrategy' => \App\Assets\SizeVariantLegacyNamingStrategy::class, + 'App\Factories\AlbumFactory' => \App\Factories\AlbumFactory::class, 'App\Image\ImageHandlerInterface' => \App\Image\ImageHandler::class, 'App\Metadata\GitHubFunctions' => \App\Metadata\GitHubFunctions::class, 'App\Metadata\GitRequest' => \App\Metadata\GitRequest::class, diff --git a/app/Actions/Album/Action.php b/app/Actions/Album/Action.php index eada1a98406..357a66187b8 100644 --- a/app/Actions/Album/Action.php +++ b/app/Actions/Album/Action.php @@ -6,10 +6,7 @@ class Action { - /** - * @var AlbumFactory - */ - protected $albumFactory; + protected AlbumFactory $albumFactory; public function __construct() { diff --git a/app/Actions/Album/Archive.php b/app/Actions/Album/Archive.php index b09e1449a7c..5e56286744b 100644 --- a/app/Actions/Album/Archive.php +++ b/app/Actions/Album/Archive.php @@ -2,58 +2,50 @@ namespace App\Actions\Album; -use App\Actions\Albums\Extensions\PublicIds; -use App\Actions\ReadAccessFunctions; +use App\Contracts\AbstractAlbum; +use App\Contracts\BaseAlbum; use App\Facades\AccessControl; use App\Facades\Helpers; +use App\Models\Album; use App\Models\Configs; use App\Models\Logs; use App\Models\Photo; -use Illuminate\Support\Facades\Storage; +use App\Models\TagAlbum; +use App\SmartAlbums\BaseSmartAlbum; +use Illuminate\Support\Collection; use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\StreamedResponse; +use ZipStream\Exception\FileNotFoundException; +use ZipStream\Exception\FileNotReadableException; use ZipStream\ZipStream; class Archive extends Action { - private $badChars; - private $readAccessFunctions; - - public function __construct(ReadAccessFunctions $readAccessFunctions) - { - parent::__construct(); - // Illicit chars - $this->readAccessFunctions = $readAccessFunctions; - $this->badChars = array_merge(array_map('chr', range(0, 31)), ['<', '>', ':', '"', '/', '\\', '|', '?', '*']); - } + public const BAD_CHARS = [ + "\x00", "\x01", "\x02", "\x03", "\x04", "\x05", "\x06", "\x07", + "\x08", "\x09", "\x0a", "\x0b", "\x0c", "\x0d", "\x0e", "\x0f", + "\x10", "\x11", "\x12", "\x13", "\x14", "\x15", "\x16", "\x17", + "\x18", "\x19", "\x1a", "\x1b", "\x1c", "\x1d", "\x1e", "\x1f", + '<', '>', ':', '"', '/', '\\', '|', '?', '*', + ]; /** - * @param string $albumID + * @param array $albumIDs * * @return StreamedResponse */ public function do(array $albumIDs): StreamedResponse { - $zipTitle = $this->setTitle($albumIDs); + $albums = $this->albumFactory->findWhereIDsIn($albumIDs); - $response = new StreamedResponse(function () use ($albumIDs) { + $response = new StreamedResponse(function () use ($albums) { $options = new \ZipStream\Option\Archive(); $options->setEnableZip64(Configs::get_value('zip64', '1') === '1'); $zip = new ZipStream(null, $options); - $dirs = []; - foreach ($albumIDs as $albumID) { - //! may Fail - $album = $this->albumFactory->make($albumID); - - $dir = $album->title; - if ($album->smart) { - $publicAlbums = resolve(PublicIds::class)->getPublicAlbumsId(); - $album->setAlbumIDs($publicAlbums); - } - $photos_sql = $album->get_photos(); - - $this->compress_album($photos_sql, $dir, $dirs, '', $album, $albumID, $zip); + $usedDirNames = []; + foreach ($albums as $album) { + $this->compressAlbum($album, $usedDirNames, null, $zip); } // finish the zip stream @@ -61,8 +53,13 @@ public function do(array $albumIDs): StreamedResponse }); // Set file type and destination + $zipTitle = self::createZipTitle($albums); + $disposition = HeaderUtils::makeDisposition( + HeaderUtils::DISPOSITION_ATTACHMENT, + $zipTitle . '.zip', + mb_check_encoding($zipTitle, 'ASCII') ? '' : 'Album.zip' + ); $response->headers->set('Content-Type', 'application/x-zip'); - $disposition = HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $zipTitle . '.zip', mb_check_encoding($zipTitle, 'ASCII') ? '' : 'Album.zip'); $response->headers->set('Content-Disposition', $disposition); // Disable caching @@ -74,71 +71,90 @@ public function do(array $albumIDs): StreamedResponse } /** - * Set the Archive title. + * Create the title of the ZIP archive. */ - private function setTitle(array $albumIDs) + private static function createZipTitle(Collection $albums): string { - if (count($albumIDs) === 1) { - return $this->makeTitle($albumIDs[0]); - } - - return 'Albums'; + return $albums->containsOneItem() ? + self::createValidTitle($albums->first()->title) : + 'Albums'; } /** - * Given an ID return the desired title (may need refactor). + * Creates a title which only contains valid characters. + * + * Removes all invalid characters from the given title. + * If the title happens to become the empty string after removing all + * illegal characters, the fixed string 'Untitled' is returned. + * + * @param string $title the title with possibly invalid characters + * + * @return string the title without any invalid characters */ - private function makeTitle(string $id) + private static function createValidTitle(string $title): string { - if ($this->albumFactory->is_smart($id)) { - return $id; - } - - //! will fail if not found - $album = $this->albumFactory->make($id); - - return str_replace($this->badChars, '', $album->title) ?: 'Untitled'; // 'Untitled' if empty string. + return str_replace(self::BAD_CHARS, '', $title) ?? 'Untitled'; } /** - * Album compression - * ! include recursive call. + * Returns a unique string. + * + * Returns the input value `$str` possibly augmented by a counter + * suffix `-` such that the returned value is not contained in the + * input array `$used`. + * The method adds the return value to `$used`. + * + * @param string $str the input string which shall be made unique + * @param array $used an input array of previously used strings; + * the output array will contain the result value + * + * @return string the unique string */ - private function compress_album($photos_sql, $dir_name, &$dirs, $parent_dir, $album, $albumID, &$zip) + private function makeUnique(string $str, array &$used): string { - if (!$album->is_downloadable()) { - if ($this->albumFactory->is_smart($albumID)) { - if (!AccessControl::is_logged_in()) { - return; - } - } elseif (!AccessControl::is_current_user($album->owner_id)) { - return; + if (!empty($used)) { + $i = 1; + $tmp = $str; + while (in_array($tmp, $used)) { + $tmp = $str . '-' . $i; + $i++; } + $str = $tmp; } + $used[] = $str; - $dir_name = str_replace($this->badChars, '', $dir_name) ?: 'Untitled'; + return $str; + } - // Check for duplicates - if (!empty($dirs)) { - $i = 1; - $tmp_dir = $dir_name; - while (in_array($tmp_dir, $dirs)) { - // Set new directory name - $tmp_dir = $dir_name . '-' . $i; - $i++; - } - $dir_name = $tmp_dir; + /** + * Compresses an album recursively. + * + * @param AbstractAlbum $album the album which shall be added + * to the archive + * @param array $usedDirNames the list of already used + * directory names on the same level + * as `$album` + * ("siblings" of `$album`) + * @param string|null $fullNameOfParent the fully qualified path name + * of the parent directory + * @param ZipStream $zip the archive + * + * @throws FileNotFoundException + * @throws FileNotReadableException + */ + private function compressAlbum(AbstractAlbum $album, array &$usedDirNames, ?string $fullNameOfParent, ZipStream $zip): void + { + if (!self::isArchivable($album)) { + return; } - $dirs[] = $dir_name; - if ($parent_dir !== '') { - $dir_name = $parent_dir . '/' . $dir_name; + $fullNameOfDirectory = $this->makeUnique(self::createValidTitle($album->title), $usedDirNames); + if (!empty($fullNameOfParent)) { + $fullNameOfDirectory = $fullNameOfParent . '/' . $fullNameOfDirectory; } - $files = []; - $photos = $photos_sql->get(); - // We don't bother with additional sorting here; who - // cares in what order photos are zipped? + $usedFileNames = []; + $photos = $album->photos; /** @var Photo $photo */ foreach ($photos as $photo) { @@ -146,70 +162,51 @@ private function compress_album($photos_sql, $dir_name, &$dirs, $parent_dir, $al // downloadable based on their actual parent album. The test for // album_id == null shouldn't really be needed as all such photos // in smart albums should be owned by the current user... - if ( - $album->smart && !AccessControl::is_current_user($photo->owner_id) && - !($photo->album_id == null ? $album->is_downloadable() : $photo->album->is_downloadable()) - ) { + if (($album instanceof BaseSmartAlbum || $album instanceof TagAlbum) && + !AccessControl::is_current_user($photo->owner_id) && + !($photo->album_id == null ? $album->is_downloadable : $photo->album->is_downloadable)) { continue; } - $is_raw = ($photo->type == 'raw'); - - $prefix_url = $is_raw ? 'raw/' : 'big/'; - $url = Storage::path($prefix_url . $photo->url); + $fullPath = $photo->size_variants->getOriginal()->full_path; // Check if readable - if (!@is_readable($url)) { - Logs::error(__METHOD__, __LINE__, 'Original photo missing: ' . $url); + if (!@is_readable($fullPath)) { + Logs::error(__METHOD__, __LINE__, 'Original photo missing: ' . $fullPath); continue; } - // Get extension of image - $extension = Helpers::getExtension($url, false); - // Set title for photo - $title = str_replace($this->badChars, '', $photo->title); - if (!isset($title) || $title === '') { - $title = 'Untitled'; - } - - $file = $title . ($is_raw ? '' : $extension); - - // Check for duplicates - if (!empty($files)) { - $i = 1; - $tmp_file = $file; - $pos = strrpos($tmp_file, '.'); - while (in_array($tmp_file, $files)) { - // Set new title for photo - if ($pos !== false) { - $tmp_file = substr_replace($file, '-' . $i, $pos, 0); - } else { - // No extension. - $tmp_file = $file . '-' . $i; - } - $i++; - } - $file = $tmp_file; - } - // Add to array - $files[] = $file; + $extension = Helpers::getExtension($fullPath, false); + $fileBaseName = $this->makeUnique(self::createValidTitle($photo->title), $usedFileNames); + $fileName = $fullNameOfDirectory . '/' . $fileBaseName . $extension; // Reset the execution timeout for every iteration. set_time_limit(ini_get('max_execution_time')); + $zip->addFileFromPath($fileName, $fullPath); + } - // add a file named 'some_image.jpg' from a local file 'path/to/image.jpg' - $zip->addFileFromPath($dir_name . '/' . $file, $url); - } // foreach ($photos) - - // Recursively compress subalbums - if (!$album->smart) { + // Recursively compress sub-albums + if ($album instanceof Album) { $subDirs = []; - foreach ($album->children as $subAlbum) { - if ($this->readAccessFunctions->album($subAlbum, true) === 1) { - $subSql = Photo::where('album_id', '=', $subAlbum->id); - $this->compress_album($subSql, $subAlbum->title, $subDirs, $dir_name, $subAlbum, $subAlbum->id, $zip); - } + $subAlbums = $album->children; + foreach ($subAlbums as $subAlbum) { + $this->compressAlbum($subAlbum, $subDirs, $fullNameOfDirectory, $zip); } } } + + /** + * Tests whether the given album may be archived by the current user. + * + * @param AbstractAlbum $album + * + * @return bool + */ + private static function isArchivable(AbstractAlbum $album): bool + { + return + $album->is_downloadable || + ($album instanceof BaseSmartAlbum && AccessControl::is_logged_in()) || + ($album instanceof BaseAlbum && AccessControl::is_current_user($album->owner_id)); + } } diff --git a/app/Actions/Album/Create.php b/app/Actions/Album/Create.php index ee92bbead34..a00712cca1c 100644 --- a/app/Actions/Album/Create.php +++ b/app/Actions/Album/Create.php @@ -2,51 +2,49 @@ namespace App\Actions\Album; -use App\Actions\Album\Extensions\StoreAlbum; use App\Facades\AccessControl; use App\Models\Album; class Create extends Action { - use StoreAlbum; - /** - * @param string $albumID + * @param string $title + * @param string|null $parent_id * - * @return Album|SmartAlbum|Response + * @return Album */ - public function create(string $title, int $parent_id): Album + public function create(string $title, ?string $parent_id = null): Album { - $album = $this->albumFactory->makeFromTitle($title); - + $album = new Album(); + $album->title = $title; $this->set_parent($album, $parent_id); + if (!$album->save()) { + throw new \RuntimeException('could not persist album to DB'); + } - return $this->store_album($album); + return $album; } /** * Setups parent album on album structure. * - * @param Album $album - * @param int $parent_id - * @param int $user_id - * - * @return Album + * @param Album $album + * @param string|null $parent_id */ - private function set_parent(Album &$album, int $parent_id): void + private function set_parent(Album $album, ?string $parent_id): void { - $parent = Album::find($parent_id); - - // we get the parent if it exists. - if ($parent !== null) { - $album->parent_id = $parent->id; - + if ($parent_id !== null) { + /** @var Album $parent */ + $parent = Album::query()->findOrFail($parent_id); // Admin can add subalbums to other users' albums. Make sure that // the ownership stays with that user. $album->owner_id = $parent->owner_id; + // Don't set attribute `parent_id` manually, but use specialized + // methods of the nested set `NodeTrait`. + $album->appendToNode($parent); } else { - $album->parent_id = null; $album->owner_id = AccessControl::id(); + $album->makeRoot(); } } } diff --git a/app/Actions/Album/CreateTag.php b/app/Actions/Album/CreateTag.php deleted file mode 100644 index 9d93e08276b..00000000000 --- a/app/Actions/Album/CreateTag.php +++ /dev/null @@ -1,33 +0,0 @@ -albumFactory->makeFromTitle($title); - - $album->parent_id = null; - $album->owner_id = AccessControl::id(); - - $album->smart = true; - $album->showtags = $show_tags; - - return $this->store_album($album); - } -} diff --git a/app/Actions/Album/CreateTagAlbum.php b/app/Actions/Album/CreateTagAlbum.php new file mode 100644 index 00000000000..2982d416ffd --- /dev/null +++ b/app/Actions/Album/CreateTagAlbum.php @@ -0,0 +1,30 @@ +title = $title; + $album->show_tags = $show_tags; + $album->owner_id = AccessControl::id(); + if (!$album->save()) { + throw new \RuntimeException('could not persist album to DB'); + } + + return $album; + } +} diff --git a/app/Actions/Album/Delete.php b/app/Actions/Album/Delete.php index 8a6030e8632..b75c8413e1c 100644 --- a/app/Actions/Album/Delete.php +++ b/app/Actions/Album/Delete.php @@ -2,65 +2,29 @@ namespace App\Actions\Album; -use App\Facades\AccessControl; -use App\Models\Album; -use App\Models\Photo; -use Illuminate\Support\Facades\Schema; +use App\Contracts\AbstractAlbum; +use App\Models\Extensions\BaseAlbum; +use App\SmartAlbums\UnsortedAlbum; -class Delete +class Delete extends Action { /** - * @param string $albumID + * @param array $albumIDs * * @return bool */ - public function do(string $albumIDs): bool + public function do(array $albumIDs): bool { - $no_error = true; - // root = unsorted - if ($albumIDs == 'unsorted') { - $photos = Photo::OwnedBy(AccessControl::id())->where('album_id', '=', null)->get(); - - foreach ($photos as $photo) { - $no_error &= $photo->predelete(); - $no_error &= $photo->delete(); - } - - return $no_error; - } - - $albums = Album::whereIn('id', explode(',', $albumIDs))->get(); - - $sqlPhoto = Photo::leftJoin('albums', 'photos.album_id', '=', 'albums.id') - ->select('photos.*'); + $albums = $this->albumFactory->findWhereIDsIn($albumIDs); + $success = true; + /** @var AbstractAlbum $album */ foreach ($albums as $album) { - $sqlPhoto = $sqlPhoto->orWhere(fn ($q) => $q->where('albums._lft', '>=', $album->_lft) - ->where('albums._rgt', '<=', $album->_rgt)); - } - - $photos = $sqlPhoto->get(); - foreach ($photos as $photo) { - $no_error &= $photo->predelete(); - $no_error &= $photo->delete(); - } - - $sql_delete = Album::query(); - - //! We break the tree (because delete() is broken see https://github.com/lazychaser/laravel-nestedset/issues/485) - Schema::disableForeignKeyConstraints(); - foreach ($albums as $album) { - $sql_delete = $sql_delete->orWhere(fn ($q) => $q - ->where('_lft', '>=', $album->_lft)->where('_rgt', '<=', $album->_rgt)); - } - $sql_delete->delete(); - Schema::enableForeignKeyConstraints(); - - //? We fix the tree :) - if (Album::isBroken()) { - Album::fixTree(); + if ($album instanceof BaseAlbum || $album instanceof UnsortedAlbum) { + $success &= $album->delete(); + } } - return $no_error; + return $success; } } diff --git a/app/Actions/Album/Extensions/LocationData.php b/app/Actions/Album/Extensions/LocationData.php deleted file mode 100644 index c6288865b75..00000000000 --- a/app/Actions/Album/Extensions/LocationData.php +++ /dev/null @@ -1,45 +0,0 @@ -whereNotNull('latitude') - ->whereNotNull('longitude') - ->with('album') - ->get(); - - /* - * @var Photo - */ - foreach ($photos as $photo_model) { - $photo = $photo_model->toReturnArray(); - $symLinkFunctions->getUrl($photo_model, $photo); - - // Add to return - $return_photos[$photo_counter] = $photo; - - $photo_counter++; - } - - return $return_photos; - } -} diff --git a/app/Actions/Album/Extensions/StoreAlbum.php b/app/Actions/Album/Extensions/StoreAlbum.php deleted file mode 100644 index 816aa0bca08..00000000000 --- a/app/Actions/Album/Extensions/StoreAlbum.php +++ /dev/null @@ -1,46 +0,0 @@ -save()) { - throw new JsonError('Could not save album in database!'); - } - } catch (QueryException $e) { - $errorCode = $e->getCode(); - if ($errorCode == 23000 || $errorCode == 23505) { - // Duplicate entry - do { - usleep(rand(0, 1000000)); - $newId = Helpers::generateID(); - } while ($newId === $album->id); - - $album->id = $newId; - $retry = true; - } else { - Logs::error(__METHOD__, __LINE__, 'Something went wrong, error ' . $errorCode . ', ' . $e->getMessage()); - - throw new JsonError('Something went wrong, error' . $errorCode . ', please check the logs'); - } - } - } while ($retry); - - return $album; - } -} diff --git a/app/Actions/Album/Merge.php b/app/Actions/Album/Merge.php index cfb459b9387..100dc4d4a47 100644 --- a/app/Actions/Album/Merge.php +++ b/app/Actions/Album/Merge.php @@ -5,56 +5,49 @@ use App\Models\Album; use App\Models\Logs; use App\Models\Photo; -use Illuminate\Support\Facades\DB; class Merge extends Action { /** - * @param string $albumID + * Merges the content of the given source albums (photos and sub-albums) + * into the target. * - * @return bool + * @param string $albumID + * @param string[] $sourceAlbumIDs */ - public function do(string $albumID, array $albumIDs): bool + public function do(string $albumID, array $sourceAlbumIDs): void { - $album_master = $this->albumFactory->make($albumID); - if ($album_master->is_smart()) { - Logs::error(__METHOD__, __LINE__, 'Merge is not possible on smart albums'); - - return false; + $targetAlbum = $this->albumFactory->findOrFail($albumID, false); + if (!($targetAlbum instanceof Album)) { + $msg = 'Merge is only possible for real albums'; + Logs::error(__METHOD__, __LINE__, $msg); + throw new \InvalidArgumentException($msg); } - $no_error = true; - // Merge Photos - if (DB::table('photos')->whereIn('album_id', $albumIDs)->count() > 0) { - $no_error &= Photo::whereIn('album_id', $albumIDs)->update(['album_id' => $album_master->id]); - } + // Merge photos of source albums into target + Photo::query() + ->whereIn('album_id', $sourceAlbumIDs) + ->update(['album_id' => $targetAlbum->id]); - // Merge Sub-albums - // ! we have to do it via Model::save() in order to not break the tree - $albums = Album::whereIn('parent_id', $albumIDs)->get(); + // Merge sub-albums of source albums into target + $albums = Album::query()->whereIn('parent_id', $sourceAlbumIDs)->get(); + /** @var Album $album */ foreach ($albums as $album) { - $album->parent_id = $album_master->id; - $album->save(); + // Don't set attribute `parent_id` manually, but use specialized + // methods of the nested set `NodeTrait` to keep the enumeration + // of the tree consistent + // `appendNode` also internally calls `save` on the model + $targetAlbum->appendNode($album); } - // now we delete the albums + // Now we delete the source albums // ! we have to do it via Model::delete() in order to not break the tree - $albums = Album::whereIn('id', $albumIDs)->get(); + $albums = Album::query()->whereIn('id', $sourceAlbumIDs)->get(); + /** @var Album $album */ foreach ($albums as $album) { $album->delete(); } - if (Album::isBroken()) { - $errors = Album::countErrors(); - $sum = $errors['oddness'] + $errors['duplicates'] + $errors['wrong_parent'] + $errors['missing_parent']; - Logs::warning(__METHOD__, __LINE__, 'Tree is broken with ' . $sum . ' errors.'); - Album::fixTree(); - Logs::notice(__METHOD__, __LINE__, 'Tree has been fixed.'); - } - - $album_master->descendants()->update(['owner_id' => $album_master->owner_id]); - $album_master->get_all_photos()->update(['photos.owner_id' => $album_master->owner_id]); - - return $no_error; + $targetAlbum->fixOwnershipOfChildren(); } } diff --git a/app/Actions/Album/Move.php b/app/Actions/Album/Move.php index 8b0f0211d06..8e09ff1a8a6 100644 --- a/app/Actions/Album/Move.php +++ b/app/Actions/Album/Move.php @@ -3,47 +3,45 @@ namespace App\Actions\Album; use App\Models\Album; -use App\Models\Logs; class Move extends Action { /** - * @param string $albumID + * Moves the given albums into the target. * - * @return bool + * @param string|null $targetAlbumID + * @param string[] $albumIDs */ - public function do(string $albumID, array $albumIDs): bool + public function do(?string $targetAlbumID, array $albumIDs): void { - $album_master = null; - // $albumID = 0 is root - // ! check type - if ($albumID != 0) { - $album_master = $this->albumFactory->make($albumID); - - if ($album_master->is_smart()) { - Logs::error(__METHOD__, __LINE__, 'Move is not possible on smart albums'); - - return false; - } + if (empty($targetAlbumID)) { + $targetAlbum = null; } else { - $albumID = null; - } - - $albums = Album::whereIn('id', $albumIDs)->get(); - $no_error = true; - - foreach ($albums as $album) { - $album->parent_id = $albumID; - $no_error &= $album->save(); + /** @var Album $targetAlbum */ + $targetAlbum = Album::query()->findOrFail($targetAlbumID); } - // Tree should be updated by itself here. - if ($no_error && $album_master !== null) { - // updat owner - $album_master->descendants()->update(['owner_id' => $album_master->owner_id]); - $album_master->get_all_photos()->update(['photos.owner_id' => $album_master->owner_id]); + $albums = Album::query()->whereIn('id', $albumIDs)->get(); + + // Move source albums into target + if ($targetAlbum) { + /** @var Album $album */ + foreach ($albums as $album) { + // Don't set attribute `parent_id` manually, but use specialized + // methods of the nested set `NodeTrait` to keep the enumeration + // of the tree consistent + // `appendNode` also internally calls `save` on the model + $targetAlbum->appendNode($album); + } + $targetAlbum->fixOwnershipOfChildren(); + } else { + /** @var Album $album */ + foreach ($albums as $album) { + // Don't set attribute `parent_id` manually, but use specialized + // methods of the nested set `NodeTrait` to keep the enumeration + // of the tree consistent + $album->saveAsRoot(); + } } - - return $no_error; } } diff --git a/app/Actions/Album/Photos.php b/app/Actions/Album/Photos.php deleted file mode 100644 index 309f04e08d0..00000000000 --- a/app/Actions/Album/Photos.php +++ /dev/null @@ -1,116 +0,0 @@ -symLinkFunctions = $symLinkFunctions; - } - - /** - * take a $photo_sql query and return an array containing their pictures. - * - * @param bool $full_photo - * - * @return array - */ - public function get(Album $album): array - { - [$sortingCol, $sortingOrder] = $album->get_sort(); - $photos_sql = $album->get_photos(); - - $previousPhotoID = ''; - $return_photos = []; - $photo_counter = 0; - - /** - * @var Collection - */ - $photos = $album->customSort($photos_sql, $sortingCol, $sortingOrder); - - if ($sortingCol === 'title' || $sortingCol === 'description') { - // The result is supposed to be sorted by the user-specified - // column as the primary key and by 'id' as the secondary key. - // Unfortunately, sortBy can't be chained the way orderBy can. - // Instead, we use array_multisort which can be used in a - // stable fashion, preserving the ordering of elements that - // compare equal. We depend here on the collection already - // being sorted by 'id', via the SQL query. - - // Convert to array so that we can use standard PHP functions. - // TODO: use collections? - // * see if this works - // $photos = $photos - // ->sortBy($sortingCol, SORT_NATURAL | SORT_FLAG_CASE, $sortingOrder === 'ASC' ? SORT_ASC : SORT_DESC) - // ->sortBy('id', SORT_ASC); - $photos = $photos->all(); - // Primary sorting key. - $values = array_column($photos, $sortingCol); - // Secondary sorting key -- just preserves current order. - $keys = array_keys($photos); - array_multisort($values, $sortingOrder === 'ASC' ? SORT_ASC : SORT_DESC, SORT_NATURAL | SORT_FLAG_CASE, $keys, SORT_ASC, $photos); - } - - /** @var Photo $photo_model */ - foreach ($photos as $photo_model) { - // Turn data from the database into a front-end friendly format - $photo = $photo_model->toReturnArray(); - $photo['license'] = $photo_model->get_license($album->get_license()); - - $this->symLinkFunctions->getUrl($photo_model, $photo); - if (!AccessControl::is_current_user($photo_model->owner_id) && !$album->is_full_photo_visible()) { - $photo_model->downgrade($photo); - } - - // Set previous and next photoID for navigation purposes - $photo['previousPhoto'] = $previousPhotoID; - $photo['nextPhoto'] = ''; - - // Set current photoID as nextPhoto of previous photo - if ($previousPhotoID !== '') { - $return_photos[$photo_counter - 1]['nextPhoto'] = $photo['id']; - } - $previousPhotoID = $photo['id']; - - // Add to return - $return_photos[$photo_counter] = $photo; - - $photo_counter++; - } - - $this->wrapAroundPhotos($return_photos); - - return $return_photos; - } - - /** - * Set up the wrap around of the photos if setting is true and if there are enough pictures. - */ - private function wrapAroundPhotos(array &$return_photos): void - { - $photo_counter = count($return_photos); - - if ($photo_counter > 1 && Configs::get_value('photos_wraparound', '1') === '1') { - // Enable next and previous for the first and last photo - $lastElement = end($return_photos); - $lastElementId = $lastElement['id']; - $firstElement = reset($return_photos); - $firstElementId = $firstElement['id']; - - $return_photos[$photo_counter - 1]['nextPhoto'] = $firstElementId; - $return_photos[0]['previousPhoto'] = $lastElementId; - } - } -} diff --git a/app/Actions/Album/PositionData.php b/app/Actions/Album/PositionData.php index 16c9833c720..9e0bfb9f3c3 100644 --- a/app/Actions/Album/PositionData.php +++ b/app/Actions/Album/PositionData.php @@ -2,29 +2,32 @@ namespace App\Actions\Album; -use App\Actions\Album\Extensions\LocationData; -use App\Actions\Albums\Extensions\PublicIds; +use App\Models\Album; class PositionData extends Action { - use LocationData; - - public function get(string $albumID, array $data) + public function get(string $albumID, array $data): array { - $album = $this->albumFactory->make($albumID); + // Avoid loading all photos and sub-albums of an album, because we are + // only interested in a particular subset of photos. + $album = $this->albumFactory->findOrFail($albumID, false); - if ($album->smart) { - $album->setAlbumIDs(resolve(PublicIds::class)->getPublicAlbumsId()); - $photos_sql = $album->get_photos(); - } elseif ($data['includeSubAlbums']) { - $photos_sql = $album->get_all_photos(); + if ($album instanceof Album && $data['includeSubAlbums']) { + $photoRelation = $album->all_photos(); } else { - $photos_sql = $album->get_photos(); + $photoRelation = $album->photos(); } - $return['photos'] = $this->photosLocationData($photos_sql); - $return['id'] = strval($album->id); + $result = []; + $result['id'] = $album->id; + $result['title'] = $album->title; + $result['photos'] = $photoRelation + ->with(['album', 'size_variants', 'size_variants.sym_links']) + ->whereNotNull('latitude') + ->whereNotNull('longitude') + ->get() + ->toArray(); - return $return; + return $result; } } diff --git a/app/Actions/Album/Prepare.php b/app/Actions/Album/Prepare.php deleted file mode 100644 index 0e0b2c23d86..00000000000 --- a/app/Actions/Album/Prepare.php +++ /dev/null @@ -1,47 +0,0 @@ -photos = $photos; - } - - /** - * @param Album $album - * - * @return array - */ - public function do(Album $album): array - { - if ($album->smart) { - $publicAlbums = resolve(PublicIds::class)->getPublicAlbumsId(); - $album->setAlbumIDs($publicAlbums); - } else { - // we only do this when not in smart mode (i.e. no sub albums) - // that way we limit the number of times we have to query. - resolve(PublicIds::class)->setAlbum($album); - } - $return = $album->toReturnArray(); - - // take care of sub albums - $return['albums'] = $album->get_children()->map(fn ($a) => $a->toReturnArray())->values(); - - // take care of photos - $return['photos'] = $this->photos->get($album); - $return['id'] = strval($album->id); - $return['num'] = strval(count($return['photos'])); - - return $return; - } -} diff --git a/app/Actions/Album/SetCover.php b/app/Actions/Album/SetCover.php index 16fbd2121ee..c9f5bdec4f5 100644 --- a/app/Actions/Album/SetCover.php +++ b/app/Actions/Album/SetCover.php @@ -2,11 +2,12 @@ namespace App\Actions\Album; +use App\Models\Album; + class SetCover extends Setter { public function __construct() { - parent::__construct(); - $this->property = 'cover_id'; + parent::__construct(Album::query(), 'cover_id'); } } diff --git a/app/Actions/Album/SetDescription.php b/app/Actions/Album/SetDescription.php index aaece40a3d4..acfc7c6ebf4 100644 --- a/app/Actions/Album/SetDescription.php +++ b/app/Actions/Album/SetDescription.php @@ -2,11 +2,12 @@ namespace App\Actions\Album; +use App\Models\BaseAlbumImpl; + class SetDescription extends Setter { public function __construct() { - parent::__construct(); - $this->property = 'description'; + parent::__construct(BaseAlbumImpl::query(), 'description'); } } diff --git a/app/Actions/Album/SetLicense.php b/app/Actions/Album/SetLicense.php index 682836a71be..c1178716dcd 100644 --- a/app/Actions/Album/SetLicense.php +++ b/app/Actions/Album/SetLicense.php @@ -2,11 +2,12 @@ namespace App\Actions\Album; +use App\Models\Album; + class SetLicense extends Setter { public function __construct() { - parent::__construct(); - $this->property = 'license'; + parent::__construct(Album::query(), 'license'); } } diff --git a/app/Actions/Album/SetNSFW.php b/app/Actions/Album/SetNSFW.php index a7eca21f6b4..e17d425cf5b 100644 --- a/app/Actions/Album/SetNSFW.php +++ b/app/Actions/Album/SetNSFW.php @@ -2,25 +2,12 @@ namespace App\Actions\Album; -use App\Models\Logs; +use App\Models\BaseAlbumImpl; class SetNSFW extends Setter { public function __construct() { - parent::__construct(); - $this->property = 'nsfw'; - } - - public function do(string $albumID, ?string $_): bool - { - if ($this->albumFactory->is_smart($albumID)) { - Logs::warning(__METHOD__, __LINE__, 'NSFW tag is not possible on smart albums.'); - - return false; - } - $album = $this->albumFactory->make($albumID); - - return $this->execute($album, ($album->nsfw != 1) ? 1 : 0); + parent::__construct(BaseAlbumImpl::query(), 'is_nsfw'); } } diff --git a/app/Actions/Album/SetPublic.php b/app/Actions/Album/SetPublic.php index 26d9c5f8de3..c4c8427b460 100644 --- a/app/Actions/Album/SetPublic.php +++ b/app/Actions/Album/SetPublic.php @@ -2,27 +2,19 @@ namespace App\Actions\Album; -use App\Models\Logs; - class SetPublic extends Action { public function do(string $albumID, array $values): bool { - if ($this->albumFactory->is_smart($albumID)) { - Logs::error(__METHOD__, __LINE__, 'Not applicable to smart albums.'); - - return false; - } - - $album = $this->albumFactory->make($albumID); + $album = $this->albumFactory->findModelOrFail($albumID); // Convert values - $album->full_photo = ($values['full_photo'] === '1' ? 1 : 0); - $album->public = ($values['public'] === '1' ? 1 : 0); - $album->viewable = ($values['visible'] === '1' ? 1 : 0); - $album->nsfw = ($values['nsfw'] === '1' ? 1 : 0); - $album->downloadable = ($values['downloadable'] === '1' ? 1 : 0); - $album->share_button_visible = ($values['share_button_visible'] === '1' ? 1 : 0); + $album->grants_full_photo = $values['grants_full_photo']; + $album->is_public = $values['is_public']; + $album->requires_link = $values['requires_link']; + $album->is_nsfw = $values['is_nsfw']; + $album->is_downloadable = $values['is_downloadable']; + $album->is_share_button_visible = $values['is_share_button_visible']; // Set password if provided if (array_key_exists('password', $values)) { @@ -43,8 +35,8 @@ public function do(string $albumID, array $values): bool } // Reset permissions for photos - if ($album->public == 1) { - $album->photos()->update(['public' => '0']); + if ($album->is_public) { + $album->photos()->update(['photos.is_public' => false]); } return true; diff --git a/app/Actions/Album/SetShowTags.php b/app/Actions/Album/SetShowTags.php index 525f35fbf7c..532732e771a 100644 --- a/app/Actions/Album/SetShowTags.php +++ b/app/Actions/Album/SetShowTags.php @@ -2,26 +2,12 @@ namespace App\Actions\Album; -use App\Models\Logs; +use App\Models\TagAlbum; class SetShowTags extends Setter { public function __construct() { - parent::__construct(); - $this->property = 'showtags'; - } - - public function do(string $albumID, ?string $value): bool - { - $album = $this->albumFactory->make($albumID); - - if (!$album->is_tag_album()) { - Logs::error(__METHOD__, __LINE__, 'Could not change show tags on non tag album'); - - return false; - } - - return $this->execute($album, $value); + parent::__construct(TagAlbum::query(), 'show_tags'); } } diff --git a/app/Actions/Album/SetSorting.php b/app/Actions/Album/SetSorting.php index 06029191bc4..775dffe462e 100644 --- a/app/Actions/Album/SetSorting.php +++ b/app/Actions/Album/SetSorting.php @@ -2,21 +2,13 @@ namespace App\Actions\Album; -use App\Models\Logs; - class SetSorting extends Action { - public function do(string $albumID, array $value): bool + public function do(string $albumID, ?string $sortingCol, ?string $sortingOrder): bool { - if ($this->albumFactory->is_smart($albumID)) { - Logs::error(__METHOD__, __LINE__, 'Not applicable to smart albums.'); - - return false; - } - - $album = $this->albumFactory->make($albumID); - $album->sorting_col = $value['typePhotos'] ?? ''; - $album->sorting_order = $value['orderPhotos'] ?? 'ASC'; + $album = $this->albumFactory->findModelOrFail($albumID); + $album->sorting_col = $sortingCol; + $album->sorting_order = $sortingOrder; return $album->save(); } diff --git a/app/Actions/Album/SetTitle.php b/app/Actions/Album/SetTitle.php index fe58f6e8784..fa88fcee5be 100644 --- a/app/Actions/Album/SetTitle.php +++ b/app/Actions/Album/SetTitle.php @@ -2,11 +2,12 @@ namespace App\Actions\Album; +use App\Models\BaseAlbumImpl; + class SetTitle extends Setters { public function __construct() { - parent::__construct(); - $this->property = 'title'; + parent::__construct(BaseAlbumImpl::query(), 'title'); } } diff --git a/app/Actions/Album/Setter.php b/app/Actions/Album/Setter.php index 700855a8c43..9c31c72ca85 100644 --- a/app/Actions/Album/Setter.php +++ b/app/Actions/Album/Setter.php @@ -2,30 +2,47 @@ namespace App\Actions\Album; -use App\Models\Album; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\ModelNotFoundException; /** - * This class is used to set a property of a SINGLE album. - * As a result, the do function takes as input an albumID. + * This class updates a property of a **single** album. + * Hence, {@link Setter::do()} takes a single `albumID` as input. + * + * The method {@link Setter::do()} **will crash** throwing an + * {@link \Illuminate\Database\Eloquent\ModelNotFoundException} + * exception, if `albumID` does not point to an existing album. * - * do will crash if albumID is not correct, throwing an exception Model not found. * This is intended behaviour. */ class Setter extends Action { - public $property; + private Builder $query; + private string $property; - public function do(string $albumID, ?string $value): bool + /** + * Setter constructor. + * + * @param string $property the name of the property + */ + protected function __construct(Builder $query, string $property) { - $album = $this->albumFactory->make($albumID); - - return $this->execute($album, $value); + parent::__construct(); + $this->query = $query; + $this->property = $property; } - public function execute(Album $album, $value): bool + /** + * @param string $albumID the ID of the album + * @param mixed $value the value to be set + */ + public function do(string $albumID, mixed $value): void { - $album->{$this->property} = $value; - - return $album->save(); + if ($this->query + ->where('id', '=', $albumID) + ->update([$this->property => $value]) !== 1 + ) { + throw new ModelNotFoundException(); + } } } diff --git a/app/Actions/Album/Setters.php b/app/Actions/Album/Setters.php index d9c3e17bb29..3488e7c5a05 100644 --- a/app/Actions/Album/Setters.php +++ b/app/Actions/Album/Setters.php @@ -2,20 +2,40 @@ namespace App\Actions\Album; -use App\Models\Album; +use Illuminate\Database\Eloquent\Builder; /** - * This class updates a property of a MULTIPLE albums at the same time. - * As a result, the do function takes as input an array containing the desired albumIDs. + * This class updates a property of **multiple** albums at once. + * Hence, {@link Setters::do()} takes an array of album IDs as input. * - * This will NOT CRASH if one of the albumID is incorrect due to the nature of the SQL query. + * The method {@link Setters::do()} **will not** crash if `albumIDs` evaluates + * to the empty set due to the nature of the SQL query. */ class Setters extends Action { - public $property; + private Builder $query; + private string $property; - public function do(array $albumIDs, string $value): bool + /** + * Setters constructor. + * + * @param string $property the name of the property + */ + protected function __construct(Builder $query, string $property) { - return Album::whereIn('id', $albumIDs)->update([$this->property => $value]); + parent::__construct(); + $this->query = $query; + $this->property = $property; + } + + /** + * @param array $albumIDs the IDs of the albums + * @param mixed $value the value to be set + */ + public function do(array $albumIDs, mixed $value): void + { + $this->query + ->whereIn('id', $albumIDs) + ->update([$this->property => $value]); } } diff --git a/app/Actions/Album/Unlock.php b/app/Actions/Album/Unlock.php index e985646c06b..c7aed2b367c 100644 --- a/app/Actions/Album/Unlock.php +++ b/app/Actions/Album/Unlock.php @@ -2,35 +2,45 @@ namespace App\Actions\Album; -use App\Facades\AccessControl; -use App\Models\Album; +use App\Actions\AlbumAuthorisationProvider; +use App\Models\BaseAlbumImpl; use Illuminate\Support\Facades\Hash; class Unlock extends Action { + private AlbumAuthorisationProvider $albumAuthorisationProvider; + + public function __construct() + { + parent::__construct(); + $this->albumAuthorisationProvider = resolve(AlbumAuthorisationProvider::class); + } + /** - * Provided a password and an album, check if the album can be - * unlocked. If yes, unlock all albums with the same password. + * Tries to unlock the given album with the given password. + * + * If the password is correct, then all albums which can be unlocked with + * the same password are unlocked, too. * * @param string $albumID + * @param string $password * - * @return array + * @return bool true on success, false if password was wrong */ - public function do(?string $albumid, $password): bool + public function do(string $albumID, string $password): bool { - if ($this->albumFactory->is_smart($albumid)) { + if ($this->albumFactory->isBuiltInSmartAlbum($albumID)) { return false; } - $album = $this->albumFactory->make($albumid); - if ($album->is_public()) { - if ($album->password === '') { - return true; - } - if (AccessControl::has_visible_album($album->id)) { + $album = $this->albumFactory->findModelOrFail($albumID); + if ($album->is_public) { + if ( + empty($album->password) || + $this->albumAuthorisationProvider->isAlbumUnlocked($album->id) + ) { return true; } - $password ??= ''; if (Hash::check($password, $album->password)) { $this->propagate($password); @@ -51,14 +61,15 @@ public function propagate(string $password): void // browse through the hierarchy. This should be safe as the // list of such albums is not exposed to the user and is // considered as the last access check criteria. - $albums = Album::whereNotNull('password')->where('password', '!=', '')->get(); - $albumIDs = []; + $albums = BaseAlbumImpl::query() + ->where('is_public', '=', true) + ->whereNotNull('password') + ->get(); + /** @var BaseAlbumImpl $album */ foreach ($albums as $album) { if (Hash::check($password, $album->password)) { - $albumIDs[] = $album->id; + $this->albumAuthorisationProvider->unlockAlbum($album->id); } } - - AccessControl::add_visible_albums($albumIDs); } } diff --git a/app/Actions/AlbumAuthorisationProvider.php b/app/Actions/AlbumAuthorisationProvider.php new file mode 100644 index 00000000000..7eaec1c4062 --- /dev/null +++ b/app/Actions/AlbumAuthorisationProvider.php @@ -0,0 +1,615 @@ +albumFactory = $albumFactory; + } + + /** + * Restricts an album query to _visible_ albums. + * + * An album is called _visible_ if the current user is allowed to see the + * album (itself) within a listing or similar. + * An album is _visible_ if any of the following conditions hold + * (OR-clause) + * + * - the user is an admin + * - the user is the owner of the album + * - the album is shared with the user and the album does not require a direct link + * - the album is public and the album does not require a direct link + * + * @param Builder $query + * + * @return Builder + */ + public function applyVisibilityFilter(Builder $query): Builder + { + $this->prepareModelQueryOrFail($query); + + if (AccessControl::is_admin()) { + return $query; + } + + $userID = AccessControl::is_logged_in() ? AccessControl::id() : null; + + // We must wrap everything into an outer query to avoid any undesired + // effects in case that the original query already contains an + // "OR"-clause. + // The sub-query only uses properties (i.e. columns) which are + // defined on the common base model for all albums. + $visibilitySubQuery = function (Builder $query2) use ($userID) { + $query2 + ->where(fn (Builder $q) => $q + ->where('base_albums.requires_link', '=', false) + ->where('base_albums.is_public', '=', true) + ); + if ($userID !== null) { + $query2 + ->orWhere('base_albums.owner_id', '=', $userID) + ->orWhere(fn (Builder $q) => $q + ->where('base_albums.requires_link', '=', false) + ->where('user_base_album.user_id', '=', $userID) + ); + } + }; + + return $query->where($visibilitySubQuery); + } + + /** + * Restricts an album query to _accessible_ albums. + * + * An album is called _accessible_ if the current user is allowed to + * browse into it, i.e. if the current user may open it and see its + * content. + * An album is _accessible_ if any of the following conditions hold + * (OR-clause) + * + * - the user is an admin + * - the user is the owner of the album + * - the album is shared with the user + * - the album is public AND no password is set + * - the album is public AND has been unlocked + * + * @param Builder $query + * + * @return Builder + */ + public function applyAccessibilityFilter(Builder $query): Builder + { + $this->prepareModelQueryOrFail($query); + + if (AccessControl::is_admin()) { + return $query; + } + + return $query->where( + fn (Builder $q) => $this->appendAccessibilityConditions($q->getQuery()) + ); + } + + /** + * Adds the conditions of an accessible album to the query. + * + * **Attention:** This method is only meant for internal use by + * this class or {@link PhotoAuthorisationProvider}. + * Use {@link AlbumAuthorisationProvider::applyAccessibilityFilter()} + * if called from other places instead. + * + * This method adds the WHERE conditions without any further pre-cautions. + * The method silently assumes that the SELECT clause contains the tables + * + * - **`base_albums`** and + * - **`user_base_album`**. + * + * Moreover, the raw OR-clauses are added. + * They are not wrapped into a nesting braces `()`. + * + * @param Builder $query + * + * @return Builder + * + * @throws \InvalidArgumentException + */ + public function appendAccessibilityConditions(BaseBuilder $query): BaseBuilder + { + $unlockedAlbumIDs = $this->getUnlockedAlbumIDs(); + $userID = AccessControl::is_logged_in() ? AccessControl::id() : null; + + $query + ->orWhere(fn (BaseBuilder $q) => $q + ->where('base_albums.is_public', '=', true) + ->whereNull('base_albums.password') + ) + ->orWhere(fn (BaseBuilder $q) => $q + ->where('base_albums.is_public', '=', true) + ->whereIn('base_albums.id', $unlockedAlbumIDs) + ); + if ($userID !== null) { + $query + ->orWhere('base_albums.owner_id', '=', $userID) + ->orWhere('user_base_album.user_id', '=', $userID); + } + + return $query; + } + + /** + * Restricts an album query to _reachable_ albums. + * + * An album is called _reachable_, if it is _visible_ and _accessible_ + * simultaneously. + * An album is reachable, if the user is able to see the album + * within its parent album and has the privilege to enter it. + * + * The result of this filter is strictly identical to the concatenation + * of {@link AlbumAuthorisationProvider::applyVisibilityFilter()} and + * {@link AlbumAuthorisationProvider::applyAccessibilityFilter()}, i.e. + * + * $aap = resolve(AlbumAuthorisationProvider::class); + * $aap->applyVisibilityFilter( + * $aap->applyAccessibilityFilter( + * $model::query() + * ) + * )->get() + * + * returns the exact same result set. + * The only advantage of this combined filter is that the `WHERE` clause + * is already in disjunctive normal form (DNF) which results in a + * slightly better SQL performance. + * + * The combination of both sets of conditions yields that an album is + * _reachable_, if any of the following conditions hold + * (OR-clause) + * + * - the user is the admin, or + * - the user is the owner, or + * - the album does not require a direct link and is shared with the user, or + * - the album does not require a direct link, is public and has no password set, or + * - the album does not require a direct link, is public and has been unlocked + * + * @param Builder $query + * + * @return Builder + * + * @throws \InvalidArgumentException + */ + public function applyReachabilityFilter(Builder $query): Builder + { + $this->prepareModelQueryOrFail($query); + + if (AccessControl::is_admin()) { + return $query; + } + + $unlockedAlbumIDs = $this->getUnlockedAlbumIDs(); + $userID = AccessControl::is_logged_in() ? AccessControl::id() : null; + + // We must wrap everything into an outer query to avoid any undesired + // effects in case that the original query already contains an + // "OR"-clause. + // The sub-query only uses properties (i.e. columns) which are + // defined on the common base model for all albums. + $reachabilitySubQuery = function (Builder $query2) use ($userID, $unlockedAlbumIDs) { + $query2 + ->where(fn (Builder $q) => $q + ->where('base_albums.requires_link', '=', false) + ->where('base_albums.is_public', '=', true) + ->whereNull('base_albums.password') + ) + ->orWhere(fn (Builder $q) => $q + ->where('base_albums.requires_link', '=', false) + ->where('base_albums.is_public', '=', true) + ->whereIn('base_albums.id', $unlockedAlbumIDs) + ); + if ($userID !== null) { + $query2 + ->orWhere('base_albums.owner_id', '=', $userID) + ->orWhere(fn (Builder $q) => $q + ->where('base_albums.requires_link', '=', false) + ->where('user_base_album.user_id', '=', $userID) + ); + } + }; + + return $query->where($reachabilitySubQuery); + } + + /** + * Checks whether the album is accessible by the current user. + * + * For real albums (i.e. albums that are stored in the DB), see + * {@link AlbumAuthorisationProvider::applyAccessibilityFilter()} for a + * specification of the rules when an album is accessible. + * In other cases, the following holds: + * - the root album is accessible by everybody + * - the built-in smart albums are accessible, if + * - the user is authenticated and is granted the right of uploading, or + * - the album is the album of recent photos and public by configuration, or + * - the album is the album of starred photos and public by configuration + * + * @param string|null $albumID + * + * @return bool + */ + public function isAccessibleByID(?string $albumID): bool + { + // the admin may access everything, the root album may be accessed by everybody + if (AccessControl::is_admin() || empty($albumID)) { + return true; + } + + // Deal with built-in smart albums + if ($this->albumFactory->isBuiltInSmartAlbum($albumID)) { + return $this->isAuthorizedForSmartAlbum( + $this->albumFactory->createSmartAlbum($albumID) + ); + } + + // Use `applyAccessibilityFilter` to build a query, but don't hydrate + // a model + return $this->applyAccessibilityFilter( + BaseAlbumImpl::query()->where('base_albums.id', '=', $albumID) + )->count() !== 0; + } + + public function isAccessible(Album $album): bool + { + if (AccessControl::is_admin()) { + return true; + } + if (!AccessControl::is_logged_in()) { + return + ($album->is_public && $album->password === null) || + ($album->is_public && $this->isAlbumUnlocked($album->id)); + } else { + $userID = AccessControl::id(); + + return + ($album->owner_id === $userID) || + ($album->is_public && $album->password === null) || + ($album->is_public && $this->isAlbumUnlocked($album->id)) || + ($album->shared_with()->where('user_id', '=', $userID)->count()); + } + } + + /** + * Restricts an album query to _browsable_ albums. + * + * Intuitively, an album is browsable if users can find a path to the + * album by "clicking around". + * An album is called _browsable_, if + * + * 1. there is a path from the origin to the album, and + * 2. all albums on the path are _reachable_ + * + * See {@link AlbumAuthorisationProvider::applyReachabilityFilter()} + * for the definition of reachability. + * Note, while _reachability_ (as well as _visibility_ and _accessibility_) + * are a _local_ properties, _browsability_ is a _global_ property. + * + * **Attention**: + * For efficiency reasons this method does not check if `$origin` itself + * is reachable. + * The method simply assumes that the user has already legitimately + * accessed the origin album, if the caller provides an album model. + * + * Due to constraints in the SQL syntax, the query actually checks that + * + * 1. there is a path from the origin to the album, and + * 2. no album on that path is unreachable + * + * Note that the worst case efficiency of this query is O(n²), if n is + * the number of query results. + * The query does not "know" that albums are organized in a tree structure + * and thus re-examines the entire path for each album in the result and + * does not take a short-cut for sub-paths which have already been examined + * previously. + * In other words for a flat tree (all result nodes are direct children + * of the origin), the runtime is O(n), but for a high tree (the nodes are + * basically a sequence), the runtime is O(n²). + * + * @param Builder $query the album query which shall be restricted + * @param Album|null $origin the optional top album which is used as a search base + * + * @return Builder the restricted album query + */ + public function applyBrowsabilityFilter(Builder $query, ?Album $origin = null): Builder + { + $table = $query->getQuery()->from; + if (!($query->getModel() instanceof Album) || $table !== 'albums') { + throw new \InvalidArgumentException('the given query does not query for albums'); + } + + // Ensures that only those albums of the original query are + // returned for which a path from the origin to the album exist ... + if ($origin) { + $query + // (We include the origin here, because we want the + // origin to be browsable from itself) + ->where('albums._lft', '>=', $origin->_lft) + ->where('albums._rgt', '<=', $origin->_rgt); + } + + // ... such that there are no blocked albums on the path to the album. + if (AccessControl::is_admin()) { + return $query; + } else { + return $query->whereNotExists(function (BaseBuilder $q) use ($origin) { + $this->appendUnreachableAlbumsCondition( + $q, + $origin?->_lft, + $origin?->_rgt, + ); + }); + } + } + + /** + * Adds the conditions of an unreachable album to the query. + * + * **Attention:** This method is only meant for internal use by + * this class or {@link PhotoAuthorisationProvider}. + * Use {@link AlbumAuthorisationProvider::applyBrowsabilityFilter()} + * if called from other places instead. + * + * This method adds the WHERE conditions without any further pre-cautions. + * The method silently assumes that the passed query builder is used + * within an outer query whose SELECT clause contains the table + * + * - **`albums`**. + * + * Moreover, the raw clauses are added. + * They are not wrapped into a nesting braces `()`. + * + * @param BaseBuilder $builder the album query which shall be + * restricted + * @param int|string|null $originLeft optionally constraints the search + * base; an integer value is + * interpreted a raw left bound of the + * search base; a string value is + * interpreted as a reference to a + * column which shall be used as a + * left bound + * @param int|string|null $originRight like `$originLeft` but for the + * right bound + * + * @return BaseBuilder + * + * @throws \InvalidArgumentException + */ + public function appendUnreachableAlbumsCondition(BaseBuilder $builder, int|string|null $originLeft, int|string|null $originRight): BaseBuilder + { + if (gettype($originLeft) !== gettype($originRight)) { + throw new \InvalidArgumentException('$originLeft and $originRight must simultaneously either be integers, strings or null'); + } + + $unlockedAlbumIDs = $this->getUnlockedAlbumIDs(); + $userID = AccessControl::is_logged_in() ? AccessControl::id() : null; + + // There are inner albums ... + $builder + ->from('albums', 'inner') + ->join('base_albums as inner_base_albums', 'inner_base_albums.id', '=', 'inner.id'); + // ... on the path from the origin ... + if (is_int($originLeft)) { + // (We must exclude the origin as an inner node + // because the origin might have set "require_link", but + // we do not care, because the user has already got + // somehow into the origin) + $builder + ->where('inner._lft', '>', $originLeft) + ->where('inner._rgt', '<', $originRight); + } elseif (is_string($originLeft)) { + $builder + ->whereColumn('inner._lft', '>', $originLeft) + ->whereColumn('inner._rgt', '<', $originRight); + } + // ... to the target ... + $builder + // (We must include the target into the list of inner nodes, + // because we must also check whether the target is unreachable.) + ->whereColumn('inner._lft', '<=', 'albums._lft') + ->whereColumn('inner._rgt', '>=', 'albums._rgt'); + // ... which are unreachable. + $builder + ->where(fn (BaseBuilder $q) => $q + ->where('inner_base_albums.requires_link', '=', true) + ->orWhere('inner_base_albums.is_public', '=', false) + ->orWhereNotNull('inner_base_albums.password') + ) + ->where(fn (BaseBuilder $q) => $q + ->where('inner_base_albums.requires_link', '=', true) + ->orWhere('inner_base_albums.is_public', '=', false) + ->orWhereNotIn('inner_base_albums.id', $unlockedAlbumIDs) + ); + if ($userID !== null) { + $builder + ->where('inner_base_albums.owner_id', '<>', $userID) + ->where(fn (BaseBuilder $q) => $q + ->where('inner_base_albums.requires_link', '=', true) + ->orWhereNotExists(fn (BaseBuilder $q2) => $q2 + ->from('user_base_album', 'user_inner_base_album') + ->whereColumn('user_inner_base_album.base_album_id', '=', 'inner_base_albums.id') + ->where('user_inner_base_album.user_id', '=', $userID) + ) + ); + } + + return $builder; + } + + /** + * Pushes an album ID onto the stack of unlocked albums. + * + * @param string $albumID + */ + public function unlockAlbum(string $albumID): void + { + Session::push(self::UNLOCKED_ALBUMS_SESSION_KEY, $albumID); + } + + /** + * Check if the given album ID has previously been unlocked. + * + * @param string $albumID + * + * @return bool + */ + public function isAlbumUnlocked(string $albumID): bool + { + return in_array($albumID, $this->getUnlockedAlbumIDs()); + } + + private function getUnlockedAlbumIDs(): array + { + return Session::get(self::UNLOCKED_ALBUMS_SESSION_KEY, []); + } + + /** + * Checks whether the albums with the given IDs are editable by the + * current user. + * + * An album is called _editable_ if the current user is allowed to edit + * the album's properties. + * This also covers adding new photos to an album. + * An album is _editable_ if any of the following conditions hold + * (OR-clause) + * + * - the user is an admin + * - the user has the upload privilege and is the owner of the album + * + * Note about built-in smart albums: + * The built-in smart albums (starred, public, recent, unsorted) do not + * have any editable properties. + * Hence, it is pointless whether a smart album is editable or not. + * In order to silently ignore/skip this condition for smart albums, + * this method always returns `true` for a smart album. + * + * @param string[] $albumIDs + * + * @return bool + */ + public function areEditable(array $albumIDs): bool + { + if (AccessControl::is_admin()) { + return true; + } + if (!AccessControl::is_logged_in()) { + return false; + } + + $user = AccessControl::user(); + + if (!$user->may_upload) { + return false; + } + + // Remove root and smart albums (they get a pass). + // Since we count the result we need to ensure that there are no + // duplicates. + $albumIDs = array_diff(array_unique($albumIDs), array_keys(AlbumFactory::BUILTIN_SMARTS), [null]); + if (count($albumIDs) > 0) { + return BaseAlbumImpl::query() + ->whereIn('base_albums.id', $albumIDs) + ->where('base_albums.owner_id', '=', $user->id) + ->count() === count($albumIDs); + } + + return true; + } + + /** + * Throws an exception if the given query does not query for an album. + * + * @throws \InvalidArgumentException + * + * @param Builder $query + */ + private function prepareModelQueryOrFail(Builder $query): void + { + $model = $query->getModel(); + $table = $query->getQuery()->from; + if ( + !( + $model instanceof Album || + $model instanceof TagAlbum || + $model instanceof BaseAlbumImpl + ) || + $table !== $model->getTable() + ) { + throw new \InvalidArgumentException('the given query does not query for albums'); + } + + // Ensure that only columns of the targeted model are selected, + // if no specific columns are yet set. + // Otherwise, we cannot add a JOIN clause below + // without accidentally adding all columns of the join, too. + if (empty($query->columns)) { + $query->select([$table . '.*']); + } + + if ($model instanceof Album || $model instanceof TagAlbum) { + $query->join('base_albums', 'base_albums.id', '=', $table . '.id'); + } + + if (AccessControl::is_logged_in()) { + $userID = AccessControl::id(); + // We must left join with `user_base_album` if and only if we + // restrict the eventual query to the ID of the authenticated + // user by a `WHERE`-clause. + // If we were doing a left join unconditionally, then some + // albums might appear multiple times as part of the result + // because an album might be shared with more than one user. + // Hence, we must restrict the `LEFT JOIN` to the user ID which + // is also used in the outer `WHERE`-clause. + // See `applyVisibilityFilter` and `appendAccessibilityConditions`. + $query->leftJoin('user_base_album', + function (JoinClause $join) use ($userID) { + $join + ->on('user_base_album.base_album_id', '=', 'base_albums.id') + ->where('user_base_album.user_id', '=', $userID); + } + ); + } + } + + /** + * This is the common code to decide whether the given smart album is + * visible/accessible by the current user. + * + * Note, that the logic for visibility and/or accessibility of a smart + * album is identical. + * + * @param BaseSmartAlbum $smartAlbum + * + * @return bool true, if the smart album is visible/accessible by the user + */ + public function isAuthorizedForSmartAlbum(BaseSmartAlbum $smartAlbum): bool + { + return + (AccessControl::is_logged_in() && AccessControl::can_upload()) || + $smartAlbum->is_public; + } +} diff --git a/app/Actions/Albums/Extensions/PublicIds.php b/app/Actions/Albums/Extensions/PublicIds.php deleted file mode 100644 index cbe99db15f6..00000000000 --- a/app/Actions/Albums/Extensions/PublicIds.php +++ /dev/null @@ -1,177 +0,0 @@ -initNotAccessible(); - } - - /*------------------------------------------------------------------------------- */ - /** - * Queries. - */ - - /** - * Build a query that removes all non public albums - * or public albums which are hidden - * or public albums with a password. - * - * @param Builder - * - * @return Builder - */ - private function notPublicNotViewable(Builder $query): Builder - { - return $query - // remove NOT public - ->where('public', '<>', '1') - // or PUBLIC BUT NOT VIEWABLE (hidden) - ->orWhere(fn ($q) => $q->where('public', '=', '1')->where('viewable', '<>', '1')) - // or PUBLIC BUT PASSWORD LOCKED - ->orWhere(fn ($q) => $q->where('public', '=', '1')->where('password', '<>', '')); - } - - private function init(): Builder - { - // unlocked albums - $query = DB::table('albums')->select('_lft', '_rgt') - ->whereNotIn('id', AccessControl::get_visible_albums()); - - if ($this->parent == null) { - return $query; - } - - // add descendant constraints. - return $query->where('_lft', '>', $this->parent->_lft)->where('_rgt', '<', $this->parent->_rgt); - } - - /** - * Return a collection of Album that are not directly accessible by visibility criteria - * ! we do not include password protected albums from other users. - * - * @return BaseCollection - */ - private function getDirectlyNotAccessible(): BaseCollection - { - if (AccessControl::is_admin()) { - return new BaseCollection(); - } - - if (AccessControl::is_logged_in()) { - $shared_ids = DB::table('user_album')->select('album_id') - ->where('user_id', '=', AccessControl::id()) - ->pluck('album_id'); - - return $this->init() - ->where('owner_id', '<>', AccessControl::id()) - // shared are accessible - ->whereNotIn('id', $shared_ids) - // remove NOT public - ->where(fn ($q) => $this->notPublicNotViewable($q)) - ->get(); - } - - // remove NOT public - return $this->init()->where(fn ($q) => $this->notPublicNotViewable($q)) - ->get(); - } - - /*------------------------------------------------------------------------------- */ - - /** - * Initializers. - */ - private function initNotAccessible(?Album $parent = null): BaseCollection - { - $this->parent = $parent; - - /** - * @var BaseCollection - */ - $directly = $this->getDirectlyNotAccessible(); - - if ($directly->count() > 0) { - $sql = DB::table('albums')->select('id'); - foreach ($directly as $alb) { - $sql = $sql->orWhereBetween('_lft', [$alb->_lft, $alb->_rgt]); - } - - $this->forbidden_list = $sql->pluck('id'); - - return $this->forbidden_list; - } - - $this->forbidden_list = new BaseCollection(); - - return $this->forbidden_list; - } - - /*------------------------------------------------------------------------------- */ - /** - * Getters. - */ - - /** - * This function must only be called from ROOT. In other words for: - * => smart albums - * => search - * => map - * => random - * => RSS. - * - * @return BaseCollection of all recursive albums ID accessible by the current user from the top level - */ - public function getPublicAlbumsId(): BaseCollection - { - $id_not_accessible = $this->getNotAccessible(null); - - return DB::table('albums')->select('id')->whereNotIn('id', $id_not_accessible)->pluck('id'); - } - - /** - * Return an array of ids of albums that are not accessible. - * - * @return BaseCollection - */ - public function getNotAccessible(): BaseCollection - { - return $this->forbidden_list ?? $this->initNotAccessible(); - } - - /** - * We need to refresh PublicIds in our test suite. - */ - public function refresh() - { - $this->forbidden_list = null; - } - - /*------------------------------------------------------------------------------- */ - - /** - * Setter. - */ - public function setAlbum(Album $album) - { - if ($this->parent == $album) { - return; - } - - $this->initNotAccessible($album); - } -} diff --git a/app/Actions/Albums/Extensions/PublicViewable.php b/app/Actions/Albums/Extensions/PublicViewable.php deleted file mode 100644 index 5339e36511a..00000000000 --- a/app/Actions/Albums/Extensions/PublicViewable.php +++ /dev/null @@ -1,31 +0,0 @@ -where(fn ($query) => $query->where('owner_id', '=', $id) - ->orWhereIn('id', DB::table('user_album')->select('album_id')->where('user_id', '=', $id)) - ->orWhere(fn ($q) => $q->where('public', '=', '1')->where('viewable', '=', '1'))); - } - - // or PUBLIC AND VIEWABLE (not hidden) - return $query->where('public', '=', '1')->where('viewable', '=', '1'); - } -} diff --git a/app/Actions/Albums/Extensions/TopQuery.php b/app/Actions/Albums/Extensions/TopQuery.php deleted file mode 100644 index 0f18ef7299e..00000000000 --- a/app/Actions/Albums/Extensions/TopQuery.php +++ /dev/null @@ -1,23 +0,0 @@ -whereIsRoot(); - - return $this->publicViewable($baseQuery)->orderBy('owner_id', 'ASC'); - } - - return $this->publicViewable(Album::query()->whereIsRoot()); - } -} diff --git a/app/Actions/Albums/PositionData.php b/app/Actions/Albums/PositionData.php index 3b03b749d96..03482a7d286 100644 --- a/app/Actions/Albums/PositionData.php +++ b/app/Actions/Albums/PositionData.php @@ -2,37 +2,37 @@ namespace App\Actions\Albums; -use App\Actions\Album\Extensions\LocationData; -use App\Actions\Albums\Extensions\PublicIds; +use App\Actions\PhotoAuthorisationProvider; use App\Models\Configs; use App\Models\Photo; class PositionData { - use LocationData; + protected PhotoAuthorisationProvider $photoAuthorisationProvider; + + public function __construct(PhotoAuthorisationProvider $photoAuthorisationProvider) + { + $this->photoAuthorisationProvider = $photoAuthorisationProvider; + // caching to avoid further request + Configs::get(); + } /** * Given a list of albums, generate an array to be returned. * - * @param Collection $albums - * * @return array */ - public function do() + public function do(): array { - // caching to avoid further request - Configs::get(); - - // Initialize return var - $return = []; - - $albumIDs = resolve(PublicIds::class)->getPublicAlbumsId(); - - $query = Photo::with('album')->whereIn('album_id', $albumIDs); - - $full_photo = Configs::get_value('full_photo', '1') == '1'; - $return['photos'] = $this->photosLocationData($query, $full_photo); - - return $return; + $result = []; + $result['id'] = null; + $result['title'] = null; + $result['photos'] = $this->photoAuthorisationProvider->applySearchabilityFilter( + Photo::with(['album', 'size_variants', 'size_variants.sym_links']) + ->whereNotNull('latitude') + ->whereNotNull('longitude') + )->get()->toArray(); + + return $result; } } diff --git a/app/Actions/Albums/Prepare.php b/app/Actions/Albums/Prepare.php deleted file mode 100644 index 9c14da3c5c3..00000000000 --- a/app/Actions/Albums/Prepare.php +++ /dev/null @@ -1,44 +0,0 @@ -readAccessFunctions = $readAccessFunctions; - } - - /** - * Given a list of albums, generate an array to be returned. - * - * @param BaseCollection $albums - * - * @return array - */ - public function do(BaseCollection $albums) - { - $return = []; - foreach ($albums as $_ => $album) { - $album_array = $album->toReturnArray(); - - if (AccessControl::is_logged_in()) { - $album_array['owner'] = $album->owner->name(); - } - - // Add to return - $return[] = $album_array; - } - - return $return; - } -} diff --git a/app/Actions/Albums/Smart.php b/app/Actions/Albums/Smart.php index fa19f676f1e..ec72c0d5ece 100644 --- a/app/Actions/Albums/Smart.php +++ b/app/Actions/Albums/Smart.php @@ -2,73 +2,61 @@ namespace App\Actions\Albums; -use AccessControl; -use App\Actions\Albums\Extensions\PublicIds; -use App\Actions\Albums\Extensions\TopQuery; -use App\Factories\SmartFactory; -use App\ModelFunctions\SymLinkFunctions; +use App\Actions\AlbumAuthorisationProvider; +use App\Factories\AlbumFactory; +use App\Models\Configs; +use App\Models\Extensions\BaseAlbum; +use App\Models\Extensions\SortingDecorator; +use App\Models\TagAlbum; +use App\SmartAlbums\BaseSmartAlbum; class Smart { - use TopQuery; + private AlbumAuthorisationProvider $albumAuthorisationProvider; + private AlbumFactory $albumFactory; + private string $sortingCol; + private string $sortingOrder; - /** - * @var SymLinkFunctions - */ - public $symLinkFunctions; - - /** - * @var Tag - */ - public $tag; - - /** - * @var SmartFactory - */ - public $smartFactory; - - public function __construct(SymLinkFunctions $symLinkFunctions, SmartFactory $smartFactory, Tag $tag) + public function __construct(AlbumFactory $albumFactory, AlbumAuthorisationProvider $albumAuthorisationProvider) { - $this->symLinkFunctions = $symLinkFunctions; - $this->smartFactory = $smartFactory; - $this->tag = $tag; + $this->albumAuthorisationProvider = $albumAuthorisationProvider; + $this->albumFactory = $albumFactory; + $this->sortingCol = Configs::get_value('sorting_Albums_col', 'created_at'); + $this->sortingOrder = Configs::get_value('sorting_Albums_order', 'ASC'); } /** - * Returns an array of top-level albums and shared albums visible to - * the current user. - * Note: the array may include password-protected albums that are not - * accessible (but are visible). + * Returns the array of smart albums visible to the current user. + * + * The array includes the built-in smart albums and the user-defined + * smart albums (i.e. tag albums). + * Note, the array may include password-protected albums that are visible + * but not accessible. * - * @return array>|null + * @return BaseAlbum[] the array of smart albums */ - public function get(): ?array + public function get(): array { - /** - * Initialize return var. - */ $return = []; - - /** - * @var Collection - */ - $publicAlbums = resolve(PublicIds::class)->getPublicAlbumsId(); - $smartAlbums = $this->smartFactory->makeAll(); - - foreach ($this->tag->get() as $tagAlbum) { - $smartAlbums->push($tagAlbum); - } - - /* @var SmartAlbum */ + // Do not eagerly load the relation `photos` for each smart album. + // On the albums overview, we only need a thumbnail for each album. + $smartAlbums = $this->albumFactory->getAllBuiltInSmartAlbums(false); + /** @var BaseSmartAlbum $smartAlbum */ foreach ($smartAlbums as $smartAlbum) { - if (AccessControl::can_upload() || $smartAlbum->is_public()) { - $smartAlbum->setAlbumIDs($publicAlbums); - $return[$smartAlbum->title] = $smartAlbum->toReturnArray(); + if ($this->albumAuthorisationProvider->isAuthorizedForSmartAlbum($smartAlbum)) { + $return[$smartAlbum->id] = $smartAlbum; } } - if (empty($return)) { - return null; + $tagAlbumQuery = $this->albumAuthorisationProvider + ->applyVisibilityFilter(TagAlbum::query()); + $tagAlbums = (new SortingDecorator($tagAlbumQuery)) + ->orderBy($this->sortingCol, $this->sortingOrder) + ->get(); + + /** @var TagAlbum $tagAlbum */ + foreach ($tagAlbums as $tagAlbum) { + $return[$tagAlbum->id] = $tagAlbum; } return $return; diff --git a/app/Actions/Albums/Tag.php b/app/Actions/Albums/Tag.php deleted file mode 100644 index afc834374d8..00000000000 --- a/app/Actions/Albums/Tag.php +++ /dev/null @@ -1,39 +0,0 @@ -sortingCol = Configs::get_value('sorting_Albums_col'); - $this->sortingOrder = Configs::get_value('sorting_Albums_order'); - } - - public function get(): Collection - { - $sql = $this->createTopleveAlbumsQuery()->where('smart', '=', true); - - return $this->customSort($sql, $this->sortingCol, $this->sortingOrder) - ->map(fn (Album $album) => $album->toTagAlbum()); - } -} diff --git a/app/Actions/Albums/Top.php b/app/Actions/Albums/Top.php index 37b476a968a..63c86da7e40 100644 --- a/app/Actions/Albums/Top.php +++ b/app/Actions/Albums/Top.php @@ -2,56 +2,74 @@ namespace App\Actions\Albums; -use App\Actions\Albums\Extensions\TopQuery; +use App\Actions\AlbumAuthorisationProvider; use App\Facades\AccessControl; +use App\Models\Album; use App\Models\Configs; -use App\Models\Extensions\CustomSort; +use App\Models\Extensions\SortingDecorator; use Illuminate\Support\Collection as BaseCollection; +use Kalnoy\Nestedset\QueryBuilder as NsQueryBuilder; class Top { - use TopQuery; - use CustomSort; + private AlbumAuthorisationProvider $albumAuthorisationProvider; + private string $sortingCol; + private string $sortingOrder; - /** - * @var string - */ - private $sortingCol; - - /** - * @var string - */ - private $sortingOrder; - - public function __construct() + public function __construct(AlbumAuthorisationProvider $albumAuthorisationProvider) { - $this->sortingCol = Configs::get_value('sorting_Albums_col'); - $this->sortingOrder = Configs::get_value('sorting_Albums_order'); + $this->albumAuthorisationProvider = $albumAuthorisationProvider; + $this->sortingCol = Configs::get_value('sorting_Albums_col', 'created_at'); + $this->sortingOrder = Configs::get_value('sorting_Albums_order', 'ASC'); } /** - * Returns an array of top-level albums and shared albums visible to - * the current user. - * Note: the array may include password-protected albums that are not + * Returns an array of top-level albums (but not tag albums) visible + * to the current user. + * + * If the user is authenticated, then the result differentiates between + * albums which are owned by the user and "shared" albums which the + * user does not own, but is allowed to see. + * The term "shared album" might be a little bit misleading here. + * Albums which are owned by the user himself may also be shared (with + * other users.) + * Actually, in this context "shared albums" means "foreign albums". + * + * Note, the array may include password-protected albums that are not * accessible (but are visible). * - * @return array[Collection] + * @return array{albums: BaseCollection, shared_albums: BaseCollection} */ public function get(): array { - $return = [ - 'albums' => new BaseCollection(), - 'shared_albums' => new BaseCollection(), - ]; - - $sql = $this->createTopleveAlbumsQuery()->where('smart', '=', false); - $albumCollection = $this->customSort($sql, $this->sortingCol, $this->sortingOrder); + /** @var NsQueryBuilder $query */ + $query = $this->albumAuthorisationProvider + ->applyVisibilityFilter(Album::query()->whereIsRoot()); if (AccessControl::is_logged_in()) { + // For authenticated users we group albums by ownership. + $albums = (new SortingDecorator($query)) + ->orderBy('owner_id') + ->orderBy($this->sortingCol, $this->sortingOrder) + ->get(); + $id = AccessControl::id(); - list($return['albums'], $return['shared_albums']) = $albumCollection->partition(fn ($album) => $album->owner_id == $id); + list($a, $b) = $albums->partition(fn ($album) => $album->owner_id == $id); + $return = [ + 'albums' => $a->values(), + 'shared_albums' => $b->values(), + ]; } else { - $return['albums'] = $albumCollection; + // For anonymous users we don't want to implicitly expose + // ownership via sorting. + $albums = (new SortingDecorator($query)) + ->orderBy($this->sortingCol, $this->sortingOrder) + ->get(); + + $return = [ + 'albums' => $albums, + 'shared_albums' => new BaseCollection(), + ]; } return $return; diff --git a/app/Actions/Albums/Tree.php b/app/Actions/Albums/Tree.php index ca8b05feee8..d6fbd2dfcd7 100644 --- a/app/Actions/Albums/Tree.php +++ b/app/Actions/Albums/Tree.php @@ -2,68 +2,99 @@ namespace App\Actions\Albums; -use App\Actions\Albums\Extensions\PublicIds; -use App\Actions\Albums\Extensions\TopQuery; +use App\Actions\AlbumAuthorisationProvider; use App\Facades\AccessControl; use App\Models\Album; use App\Models\Configs; -use App\Models\Extensions\CustomSort; +use App\Models\Extensions\SortingDecorator; +use Illuminate\Database\Eloquent\Collection; +use Kalnoy\Nestedset\Collection as NsCollection; +use Kalnoy\Nestedset\QueryBuilder as NsQueryBuilder; class Tree { - use TopQuery; - use CustomSort; + private AlbumAuthorisationProvider $albumAuthorisationProvider; + private string $sortingCol; + private string $sortingOrder; - /** - * @var string - */ - private $sortingCol; - - /** - * @var string - */ - private $sortingOrder; - - public function __construct() + public function __construct(AlbumAuthorisationProvider $albumAuthorisationProvider) { - $this->sortingCol = Configs::get_value('sorting_Albums_col'); - $this->sortingOrder = Configs::get_value('sorting_Albums_order'); + $this->albumAuthorisationProvider = $albumAuthorisationProvider; + $this->sortingCol = Configs::get_value('sorting_Albums_col', 'created_at'); + $this->sortingOrder = Configs::get_value('sorting_Albums_order', 'ASC'); } public function get(): array { $return = []; - $PublicIds = resolve(PublicIds::class); - $sql = Album::query() - ->where('smart', '=', false) - ->whereNotIn('id', $PublicIds->getNotAccessible()) - ->orderBy('owner_id', 'ASC'); - $albumCollection = $this->customSort($sql, $this->sortingCol, $this->sortingOrder); + /** + * Note, strictly speaking + * {@link AlbumAuthorisationProvider::applyBrowsabilityFilter()} + * would be the correct function in order to scope the query below, + * because we only want albums which are browsable. + * But + * {@link AlbumAuthorisationProvider::applyBrowsabilityFilter()} + * is rather slow for large sets of albums (O(n²) runtime). + * Luckily, + * {@link AlbumAuthorisationProvider::applyReachabilityFilter()} + * is sufficient here, although it does only consider an album's + * reachability _locally_. + * We rely on `->toTree` below to remove orphaned sub-tress and hence + * only return a tree of browsable albums. + * + * @var NsQueryBuilder $query + */ + $query = $this->albumAuthorisationProvider + ->applyReachabilityFilter(Album::query()); if (AccessControl::is_logged_in()) { + // For authenticated users we group albums by ownership. + $albums = (new SortingDecorator($query)) + ->orderBy('owner_id') + ->orderBy($this->sortingCol, $this->sortingOrder) + ->get(); + $id = AccessControl::id(); - list($albumCollection, $albums_shared) = $albumCollection->partition(fn ($album) => $album->owner_id == $id); - $return['shared_albums'] = $this->prepare($albums_shared->toTree()); + // ATTENTION: + // For this to work correctly, it is crucial that all child albums + // below each top-level album have the same owner! + // Otherwise, this partitioning tears apart albums of the same + // (sub)-tree and then `toTree` will return garbage as it does + // not find connected paths within `$albums` or `$sharedAlbums`, + // resp. + /** @var NsCollection $sharedAlbums */ + list($albums, $sharedAlbums) = $albums->partition(fn ($album) => $album->owner_id == $id); + // We must explicitly pass `null` as the ID of the root album + // as there are several top-level albums below root. + // Otherwise, `toTree` uses the ID of the album with the lowest + // `_lft` value as the (wrong) root album. + $return['shared_albums'] = $this->toArray($sharedAlbums->toTree(null)); + } else { + // For anonymous users we don't want to implicitly expose + // ownership via sorting. + $albums = (new SortingDecorator($query)) + ->orderBy($this->sortingCol, $this->sortingOrder) + ->get(); } - $return['albums'] = $this->prepare($albumCollection->toTree()); + // We must explicitly pass `null` as the ID of the root album + // as there are several top-level albums below root. + // Otherwise, `toTree` uses the ID of the album with the lowest + // `_lft` value as the (wrong) root album. + $return['albums'] = $this->toArray($albums->toTree(null)); return $return; } - private function prepare($albums) + private function toArray(Collection $albums): array { - return $albums->map(function ($album) { - $ret = [ - 'id' => strval($album->id), - 'title' => $album->title, - 'parent_id' => strval($album->parent_id), - 'thumb' => optional($album->get_thumb())->toArray(), - ]; - $ret['albums'] = $this->prepare($album->children); - - return $ret; - }); + return $albums->map(fn (Album $album) => [ + 'id' => $album->id, + 'title' => $album->title, + 'thumb' => optional($album->thumb)->toArray(), + 'parent_id' => $album->parent_id, + 'albums' => $this->toArray($album->children), + ])->all(); } } diff --git a/app/Actions/Diagnostics/Checks/MissingUserCheck.php b/app/Actions/Diagnostics/Checks/MissingUserCheck.php index c9a436397f9..7e0c360c6ed 100644 --- a/app/Actions/Diagnostics/Checks/MissingUserCheck.php +++ b/app/Actions/Diagnostics/Checks/MissingUserCheck.php @@ -10,7 +10,7 @@ class MissingUserCheck implements DiagnosticCheckInterface { public function check(array &$errors): void { - $album_owners = DB::table('albums')->select('owner_id')->groupBy('owner_id')->pluck('owner_id'); + $album_owners = DB::table('base_albums')->select('owner_id')->groupBy('owner_id')->pluck('owner_id'); $photo_owners = DB::table('photos')->select('owner_id')->groupBy('owner_id')->pluck('owner_id'); $owner_ids = $album_owners->concat($photo_owners)->unique()->values(); foreach ($owner_ids as $owner_id) { diff --git a/app/Actions/Diagnostics/Info.php b/app/Actions/Diagnostics/Info.php index 3ba4f30a3b9..1bcc6f01faf 100644 --- a/app/Actions/Diagnostics/Info.php +++ b/app/Actions/Diagnostics/Info.php @@ -4,8 +4,8 @@ use App\Metadata\LycheeVersion; use App\Models\Configs; -use Config; use Illuminate\Database\QueryException; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; use Imagick; diff --git a/app/Actions/Import/Exec.php b/app/Actions/Import/Exec.php index 51ef4a6018f..5b38793b650 100644 --- a/app/Actions/Import/Exec.php +++ b/app/Actions/Import/Exec.php @@ -2,9 +2,11 @@ namespace App\Actions\Import; -use App\Actions\Album\Create; -use App\Actions\Import\Extensions\ImportPhoto; +use App\Actions\Album\Create as AlbumCreate; +use App\Actions\Photo\Create as PhotoCreate; use App\Actions\Photo\Extensions\Constants; +use App\Actions\Photo\Extensions\SourceFileInfo; +use App\Actions\Photo\Strategies\ImportMode; use App\Exceptions\PhotoResyncedException; use App\Exceptions\PhotoSkippedException; use App\Facades\Helpers; @@ -17,9 +19,9 @@ class Exec { - use ImportPhoto; use Constants; + // TODO: Refactor this and use `ImportMode` instead of four boolean properties public $skip_duplicates = false; public $resync_metadata = false; public $delete_imported; @@ -88,7 +90,7 @@ private function parsePath(string &$path, string $origPath) $path = Storage::path('import'); // @codeCoverageIgnoreEnd } - if (substr($path, -1) === '/') { + if (str_ends_with($path, '/')) { $path = substr($path, 0, -1); } if (is_dir($path) === false) { @@ -158,16 +160,14 @@ private function memWarningCheck() } /** - * @param string $path - * @param int $albumID - * @param array $ignore_list - * - * @throws ImagickException + * @param string $path + * @param string|null $albumID + * @param string[] $ignore_list */ public function do( string $path, - $albumID, - $ignore_list = null + ?string $albumID, + array $ignore_list = [] ) { // Parse path $origPath = $path; @@ -243,7 +243,22 @@ public function do( if (@exif_imagetype($file) !== false || in_array(strtolower($extension), $this->validExtensions, true) || $is_raw) { // Photo or Video try { - if ($this->photo($file, $this->delete_imported, $this->import_via_symlink, $albumID, $this->skip_duplicates, $this->resync_metadata) === false) { + // TODO: Refactor this, rationale see below + // This is not the way how `PhotoCreate` is supposed + // to be used. + // Actually, an instance of the class should only + // be created once using a single instance of + // `ImportMode` and then `PhotoCreate::add` should + // be called for each file. + $photoCreate = new PhotoCreate(new ImportMode( + $this->delete_imported, + $this->skip_duplicates, + $this->import_via_symlink, + $this->resync_metadata + )); + if ( + $photoCreate->add(SourceFileInfo::createForLocalFile($file), $albumID) == null + ) { $this->status_error($file, 'Could not import file'); Logs::error(__METHOD__, __LINE__, 'Could not import file (' . $file . ')'); } @@ -267,13 +282,16 @@ public function do( // Folder $album = null; if ($this->skip_duplicates) { - $album = Album::where('parent_id', '=', $albumID == 0 ? null : $albumID) - ->where('title', '=', basename($dir)) + $album = Album::query() + ->select(['albums.*']) + ->join('base_albums', 'base_albums.id', '=', 'albums.id') + ->where('albums.parent_id', '=', $albumID) + ->where('base_albums.title', '=', basename($dir)) ->get() ->first(); } if ($album === null) { - $create = resolve(Create::class); + $create = resolve(AlbumCreate::class); $album = $create->create(basename($dir), $albumID); // this actually should not fail. if ($album === false) { diff --git a/app/Actions/Import/Extensions/ImportPhoto.php b/app/Actions/Import/Extensions/ImportPhoto.php deleted file mode 100644 index 26ce9f282c7..00000000000 --- a/app/Actions/Import/Extensions/ImportPhoto.php +++ /dev/null @@ -1,51 +0,0 @@ -add will take care of it. - $mime = mime_content_type($path); - - $nameFile = []; - $nameFile['name'] = $path; - $nameFile['type'] = $mime; - $nameFile['tmp_name'] = $path; - - $create = resolve(Create::class); - - // avoid incompatible settings (delete originals takes precedence over symbolic links) - if ($delete_imported) { - $import_via_symlink = false; - } - // (re-syncing metadata makes no sense when importing duplicates) - if (!$skip_duplicates) { - $resync_metadata = false; - } - - if ($create->add($nameFile, $albumID, $delete_imported, $skip_duplicates, $import_via_symlink, $resync_metadata) === false) { - // @codeCoverageIgnoreStart - return false; - // @codeCoverageIgnoreEnd - } - - return true; - } -} diff --git a/app/Actions/Import/FromUrl.php b/app/Actions/Import/FromUrl.php index f61a7851526..847c69546a5 100644 --- a/app/Actions/Import/FromUrl.php +++ b/app/Actions/Import/FromUrl.php @@ -3,16 +3,18 @@ namespace App\Actions\Import; use App\Actions\Import\Extensions\Checks; -use App\Actions\Import\Extensions\ImportPhoto; +use App\Actions\Photo\Create; use App\Actions\Photo\Extensions\Constants; +use App\Actions\Photo\Extensions\SourceFileInfo; +use App\Actions\Photo\Strategies\ImportMode; use App\Facades\Helpers; +use App\Models\Configs; use App\Models\Logs; use Illuminate\Support\Facades\Storage; class FromUrl { use Constants; - use ImportPhoto; use Checks; public function __construct() @@ -20,9 +22,13 @@ public function __construct() $this->checkPermissions(); } - public function do(array $urls, $albumId): bool + public function do(array $urls, ?string $albumId): bool { $error = false; + $create = new Create(new ImportMode( + true, + Configs::get_value('skip_duplicates', '0') === '1' + )); foreach ($urls as &$url) { // Reset the execution timeout for every iteration. @@ -55,7 +61,7 @@ public function do(array $urls, $albumId): bool } // Import photo - if (!$this->photo($tmp_name, true, false, $albumId)) { + if ($create->add(SourceFileInfo::createForLocalFile($tmp_name), $albumId) == null) { $error = true; Logs::error(__METHOD__, __LINE__, 'Could not import file (' . $tmp_name . ')'); } diff --git a/app/Actions/Install/ApplyMigration.php b/app/Actions/Install/ApplyMigration.php index 63095253ca3..d2cf98cc321 100644 --- a/app/Actions/Install/ApplyMigration.php +++ b/app/Actions/Install/ApplyMigration.php @@ -39,7 +39,7 @@ public function migrate(array &$output) * We also double check there is no "QueryException" in the output (just to be sure). */ foreach ($output as $line) { - if (strpos($line, 'QueryException') !== false) { + if (str_contains($line, 'QueryException')) { // @codeCoverageIgnoreStart return true; // @codeCoverageIgnoreEnd diff --git a/app/Actions/Photo/Archive.php b/app/Actions/Photo/Archive.php index 558d3e5f6f5..3bc768e8d93 100644 --- a/app/Actions/Photo/Archive.php +++ b/app/Actions/Photo/Archive.php @@ -2,15 +2,18 @@ namespace App\Actions\Photo; +use App\Actions\Photo\Extensions\ArchiveFileInfo; use App\Actions\Photo\Extensions\Constants; use App\Facades\AccessControl; -use App\Facades\Helpers; use App\Models\Configs; use App\Models\Logs; use App\Models\Photo; +use App\Models\SizeVariant; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\HeaderUtils; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpFoundation\StreamedResponse; use ZipStream\ZipStream; @@ -19,7 +22,37 @@ class Archive { use Constants; - private $badChars; + public const LIVEPHOTOVIDEO = 'LIVEPHOTOVIDEO'; + public const FULL = 'FULL'; + public const MEDIUM2X = 'MEDIUM2X'; + public const MEDIUM = 'MEDIUM'; + public const SMALL2X = 'SMALL2X'; + public const SMALL = 'SMALL'; + public const THUMB2X = 'THUMB2X'; + public const THUMB = 'THUMB'; + + public const VARIANTS = [ + self::LIVEPHOTOVIDEO, + self::FULL, + self::MEDIUM2X, + self::MEDIUM, + self::SMALL2X, + self::SMALL, + self::THUMB2X, + self::THUMB, + ]; + + public const VARIANT2VARIANT = [ + self::FULL => SizeVariant::ORIGINAL, + self::MEDIUM2X => SizeVariant::MEDIUM2X, + self::MEDIUM => SizeVariant::MEDIUM, + self::SMALL2X => SizeVariant::SMALL2X, + self::SMALL => SizeVariant::SMALL, + self::THUMB2X => SizeVariant::THUMB2X, + self::THUMB => SizeVariant::THUMB, + ]; + + private array $badChars; public function __construct() { @@ -28,75 +61,160 @@ public function __construct() } /** - * @param string $albumID + * Returns a response for a downloadable file. * - * @return StreamedResponse + * The file is either a media file (if the array of photo IDs contains + * a single element) or a ZIP file (if the array of photo IDs contains + * more than one element). + * + * @param int[] $photoIDs the IDs of the photos which shall be included + * in the response + * @param string $variant the desired variant of the photo; valid values + * are + * {@link Archive::LIVEPHOTOVIDEO}, + * {@link Archive::FULL}, + * {@link Archive::MEDIUM2X}, + * {@link Archive::MEDIUM}, + * {@link Archive::SMALL2X}, + * {@link Archive::SMALL}, + * {@link Archive::THUMB2X}, + * {@link Archive::THUMB} + * + * @return Response */ - public function do(array $photoIDs, $kind_request) + public function do(array $photoIDs, string $variant): Response { - if (count($photoIDs) === 1) { - $response = $this->file($photoIDs[0], $kind_request); + /** @var Collection $photos */ + $photos = Photo::with(['album', 'size_variants']) + ->whereIn('id', $photoIDs) + ->get(); + + if ($photos->count() === 1) { + $response = $this->file($photos->first(), $variant); } else { - $response = $this->zip($photoIDs, $kind_request); + $response = $this->zip($photos, $variant); } return $response; } - public function file($photoID, $kind_request) + protected function file(Photo $photo, $variant): BinaryFileResponse { - $ret = $this->extract_names($photoID, $kind_request); - if ($ret === null) { - return abort(404); + $archiveFileInfo = $this->extractFileInfo($photo, $variant); + if ($archiveFileInfo === null) { + abort(404); } + $response = new BinaryFileResponse($archiveFileInfo->getFullPath()); - list($title, $kind, $extension, $url) = $ret; - - // Set title for photo - $file = $title . $kind . $extension; - - $response = new BinaryFileResponse($url); - - return $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $file); + return $response->setContentDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $archiveFileInfo->getFilename() + ); } - public function zip(array $photoIDs, string $kind_request) + protected function zip(Collection $photos, string $variant): StreamedResponse { - $response = new StreamedResponse(function () use ($kind_request, $photoIDs) { + $response = new StreamedResponse(function () use ($variant, $photos) { $options = new \ZipStream\Option\Archive(); $options->setEnableZip64(Configs::get_value('zip64', '1') === '1'); $zip = new ZipStream(null, $options); - $files = []; - foreach ($photoIDs as $photoID) { - $ret = $this->extract_names($photoID, $kind_request); - if ($ret == null) { - return abort(404); + // We first need to scan the whole array of files to avoid + // problems with duplicate file names. + // If a file name occurs multiple times, the files are named + // filename-1, filename-2, filename-3 and so on. + // Unfortunately, the naive approach which uses a simple online + // algorithm that only applies a singly pass (without look-ahead) + // and maintains a counter for every file name will fail, if the + // list of file names already contains another files which uses + // the same naming pattern accidentally. + // Assume that the album itself contains the images + // `my-file.jpg`, `my-file-2.jpg`, `my-file.jpg`. + // The naive approach would first store `my-file.jpg` and + // `my-file-2.jpg` (both unaltered). + // Both counters for `my-file.jpg` and `my-file-2.jpg` equal one + // because those file names are actually treated as independent + // file names. + // When the naive approach comes across the last file + // `my-file.jpg`, the counter for `my-file.jpg` is incremented + // and the file is stored as ``my-file-2.jpg`. + // However, this accidentally overwrite the original + // `my-file-2.jpg`. + // Long story short, if we append a counter as a suffix to a + // filename, we must take care that the result is not also used as + // a base file name. + // Further note, that this problem does not occur if both file + // names occurred multiple times. + // E.g., if we had + // - `my-file.jpg`, + // - `my-file-2.jpg`, + // - `my-file.jpg` and + // - `my-file-2.jpg` again, + // then the result would be + // - `my-file-1.jpg`, + // - `my-file-2-1.jpg`, + // - `my-file-2.jpg` and + // - `my-file-2-2.jpg`. + // Note that the problematic case can only occur due to a clash + // of file names between file names which occur multiple times + // (and thus are appended by a suffix) and a file name that only + // occurs a single time. + // + // Here, we take the following approach: + // + // We scan the list of photos once and partition the set of file + // names into a set of unique file names and a set of ambitious + // file names. + // In the second run, all photos with unique file names are + // stored under their unaltered file name. + // For photo with an ambiguous file name a counter for that file + // name is tracked and incremented. + // If the resulting file name accidentally equals one of the + // unique file names, then the counter is incremented until the + // next "free" file name is found. + + $archiveFileInfos = []; + $uniqueFilenames = []; + $ambiguousFilenames = []; + + // Partition the set + /** @var Photo $photo */ + foreach ($photos as $photo) { + $archiveFileInfo = $this->extractFileInfo($photo, $variant); + if ($archiveFileInfo == null) { + abort(404); } - - list($title, $kind, $extension, $url) = $ret; - - // Set title for photo - $file = $title . $kind . $extension; - // Check for duplicates - if (!empty($files)) { - $i = 1; - $tmp_file = $file; - while (in_array($tmp_file, $files)) { - // Set new title for photo - $tmp_file = $title . $kind . '-' . $i . $extension; - $i++; - } - $file = $tmp_file; + $archiveFileInfos[] = $archiveFileInfo; + $filename = $archiveFileInfo->getFilename(); + if (array_key_exists($filename, $ambiguousFilenames)) { + continue; + } elseif (array_key_exists($filename, $uniqueFilenames)) { + unset($uniqueFilenames[$filename]); + $ambiguousFilenames[$filename] = 0; + } else { + $uniqueFilenames[$filename] = 0; } - // Add to array - $files[] = $file; + } + /** @var ArchiveFileInfo $archiveFileInfo */ + foreach ($archiveFileInfos as $archiveFileInfo) { + $trueFilename = $archiveFileInfo->getFilename(); + if (array_key_exists($trueFilename, $uniqueFilenames)) { + // Easy case: Unique file names are used unaltered + $filename = $trueFilename; + } else { + do { + // Append suffix for multiple copies of same file name + // but skip results which exist as a unique file name + $filename = $archiveFileInfo->getFilename( + '-' . ++$ambiguousFilenames[$trueFilename] + ); + } while (array_key_exists($filename, $uniqueFilenames)); + } + $zip->addFileFromPath($filename, $archiveFileInfo->getFullPath()); // Reset the execution timeout for every iteration. set_time_limit(ini_get('max_execution_time')); - - $zip->addFileFromPath($file, $url); - } // foreach ($photoIDs) + } // finish the zip stream $zip->finish(); @@ -111,21 +229,28 @@ public function zip(array $photoIDs, string $kind_request) } /** - * extract the file names. + * Creates a {@link ArchiveFileInfo} for the indicated photo and variant. * - * @param $photoID - * @param $request + * @param Photo $photo the photo whose archive information + * shall be returned + * @param string $variant the desired variant of the photo; valid values + * are + * {@link Archive::LIVEPHOTOVIDEO}, + * {@link Archive::FULL}, + * {@link Archive::MEDIUM2X}, + * {@link Archive::MEDIUM}, + * {@link Archive::SMALL2X}, + * {@link Archive::SMALL}, + * {@link Archive::THUMB2X}, + * {@link Archive::THUMB} * - * @return array|null + * @return ArchiveFileInfo|null the created archive info */ - public function extract_names($photoID, $kind_input) + public function extractFileInfo(Photo $photo, string $variant): ?ArchiveFileInfo { - /** @var Photo $photo */ - $photo = Photo::with('album')->findOrFail($photoID); - if (!AccessControl::is_current_user($photo->owner_id)) { if ($photo->album_id !== null) { - if (!$photo->album->is_downloadable()) { + if (!$photo->album->is_downloadable) { return null; } } elseif (Configs::get_value('downloadable', '0') === '0') { @@ -133,71 +258,38 @@ public function extract_names($photoID, $kind_input) } } - $title = str_replace($this->badChars, '', $photo->title) ?: 'Untitled'; - - $prefix_path = $photo->type == 'raw' ? 'raw/' : 'big/'; - - // determine the file based on given size - if ($photo->isVideo() === false) { - $fileName = $photo->url; + $baseFilename = str_replace($this->badChars, '', $photo->title) ?: 'Untitled'; + + if ($variant === self::LIVEPHOTOVIDEO) { + $shortPath = $photo->live_photo_short_path; + $baseFilenameAddon = ''; + } elseif (array_key_exists($variant, self::VARIANT2VARIANT)) { + $sv = $photo->size_variants->getSizeVariant(self::VARIANT2VARIANT[$variant]); + $shortPath = ''; + $baseFilenameAddon = ''; + if ($sv) { + $shortPath = $sv->short_path; + // The filename of the original size variant shall get no + // particular suffix but remain as is. + // All other size variants (i.e. the generated, smaller ones) + // get a size information as suffix. + if ($sv->type !== SizeVariant::ORIGINAL) { + $baseFilenameAddon = '-' . $sv->width . 'x' . $sv->height; + } + } } else { - $fileName = $photo->thumbUrl; + $msg = 'Invalid variant ' . $variant; + Logs::error(__METHOD__, __LINE__, $msg); + throw new \InvalidArgumentException($msg); } - switch ($kind_input) { - case 'FULL': - $path = $prefix_path . $photo->url; - $kind = ''; - break; - case 'LIVEPHOTOVIDEO': - $path = $prefix_path . $photo->livePhotoUrl; - $kind = ''; - break; - case 'MEDIUM2X': - $path = 'medium/' . Helpers::ex2x($fileName); - $kind = '-' . $photo->medium2x_width . 'x' . $photo->medium2x_height; - break; - case 'MEDIUM': - $path = 'medium/' . $fileName; - $kind = '-' . $photo->medium_width . 'x' . $photo->medium_height; - break; - case 'SMALL2X': - $path = 'small/' . Helpers::ex2x($fileName); - $kind = '-' . $photo->small2x_width . 'x' . $photo->small2x_height; - break; - case 'SMALL': - $path = 'small/' . $fileName; - $kind = '-' . $photo->small_width . 'x' . $photo->small_height; - break; - case 'THUMB2X': - $path = 'thumb/' . Helpers::ex2x($photo->thumbUrl); - $kind = '-' . Photo::THUMBNAIL2X_DIM . 'x' . Photo::THUMBNAIL2X_DIM; - break; - case 'THUMB': - $path = 'thumb/' . $photo->thumbUrl; - $kind = '-' . Photo::THUMBNAIL_DIM . 'x' . Photo::THUMBNAIL_DIM; - break; - default: - Logs::error(__METHOD__, __LINE__, 'Invalid kind ' . $kind_input); - - return null; - } - - $fullpath = Storage::path($path); - - // Check the file actually exists - if (!Storage::exists($path)) { - Logs::error(__METHOD__, __LINE__, 'File is missing: ' . $fullpath . ' (' . $title . ')'); + // Check if file actually exists + if (empty($shortPath) || !Storage::exists($shortPath)) { + Logs::error(__METHOD__, __LINE__, 'File is missing: ' . $shortPath . ' (' . $baseFilename . ')'); return null; } - // Get extension of image - $extension = ''; - if ($photo->type != 'raw') { - $extension = Helpers::getExtension($fullpath, false); - } - - return [$title, $kind, $extension, $fullpath]; + return new ArchiveFileInfo($baseFilename, $baseFilenameAddon, Storage::path($shortPath)); } } diff --git a/app/Actions/Photo/Create.php b/app/Actions/Photo/Create.php index b1c43cd1138..4a3c0fab73e 100644 --- a/app/Actions/Photo/Create.php +++ b/app/Actions/Photo/Create.php @@ -3,131 +3,220 @@ namespace App\Actions\Photo; use App\Actions\Photo\Extensions\Checks; -use App\Actions\Photo\Extensions\Checksum; use App\Actions\Photo\Extensions\Constants; -use App\Actions\Photo\Extensions\ImageEditing; -use App\Actions\Photo\Extensions\ParentAlbum; -use App\Actions\Photo\Extensions\Save; -use App\Actions\Photo\Extensions\VideoEditing; -use App\Actions\Photo\Strategies\StrategyDuplicate; -use App\Actions\Photo\Strategies\StrategyPhoto; +use App\Actions\Photo\Extensions\SourceFileInfo; +use App\Actions\Photo\Strategies\AddDuplicateStrategy; +use App\Actions\Photo\Strategies\AddPhotoPartnerStrategy; +use App\Actions\Photo\Strategies\AddStandaloneStrategy; +use App\Actions\Photo\Strategies\AddStrategyParameters; +use App\Actions\Photo\Strategies\AddVideoPartnerStrategy; +use App\Actions\Photo\Strategies\ImportMode; use App\Actions\User\Notify; -use App\Facades\Helpers; +use App\Exceptions\JsonError; +use App\Factories\AlbumFactory; +use App\Metadata\Extractor; use App\Models\Album; -use App\Models\Logs; use App\Models\Photo; -use Illuminate\Support\Facades\Storage; +use App\SmartAlbums\BaseSmartAlbum; +use App\SmartAlbums\PublicAlbum; +use App\SmartAlbums\StarredAlbum; class Create { use Checks; - use Checksum; use Constants; - use ImageEditing; - use ParentAlbum; - use Save; - use VideoEditing; - - public bool $public; - public bool $star; - public ?int $albumID; - public ?Album $parentAlbum = null; - public ?Photo $photo = null; - public string $photo_filename; - public string $kind; - public string $extension; - public string $path_prefix; - public string $path; - public string $tmp_name; - public string $mimeType; - public array $info = []; - public ?Photo $livePhotoPartner = null; - public bool $is_uploaded; - - public function add( - array $file, - $albumID_in = 0, - bool $delete_imported = false, - bool $skip_duplicates = false, - bool $import_via_symlink = false, - bool $resync_metadata = false - ) { - // Check permissions - $this->checkPermissions(); - $this->public = 0; - $this->star = 0; - $this->albumID = null; + /** @var AddStrategyParameters the strategy parameters prepared and compiled by this class */ + protected AddStrategyParameters $strategyParameters; + + public function __construct(?ImportMode $importMode) + { + $this->strategyParameters = new AddStrategyParameters($importMode); + } - $this->initParentId($albumID_in); + /** + * Adds/imports the designated source file to Lychee. + * + * Depending on the type and origin of the source file as well as + * depending on operational settings, this method applies different + * strategies. + * This method may create a new database entry or update an existing + * database entry. + * + * @param SourceFileInfo $sourceFileInfo information about source file + * @param string|null $albumID the targeted parent album either + * null, the id of a real album or + * (if it is a string) one of the + * array keys in + * {@link \App\Factories\AlbumFactory::BUILTIN_SMARTS} + * + * @return Photo the newly created or updated photo + * + * @throws \App\Exceptions\FolderIsNotWritable + * @throws \App\Exceptions\JsonError + */ + public function add(SourceFileInfo $sourceFileInfo, ?string $albumID = null): Photo + { + // Check permissions + $this->checkPermissions(); - // Verify extension - $this->extension = Helpers::getExtension($file['name'], false); - $this->mimeType = $file['type']; - $this->kind = $this->file_type($file, $this->extension); + // Fill in information about targeted parent album + $this->initParentId($albumID); - // Generate id - $this->photo = new Photo(); - $this->photo->id = Helpers::generateID(); + // Fill in information about source file + $this->strategyParameters->kind = $this->file_kind($sourceFileInfo); + $this->strategyParameters->sourceFileInfo = $sourceFileInfo; - // Set paths - $this->tmp_name = $file['tmp_name']; - $this->is_uploaded = is_uploaded_file($file['tmp_name']); - $this->path_prefix = ($this->kind != 'raw') ? 'big/' : 'raw/'; + // Fill in meta data extracted from source file + $this->loadFileMetadata($sourceFileInfo); - // Calculate checksum - $this->photo->checksum = $this->checksum($this->tmp_name); - $duplicate = $this->get_duplicate($this->photo->checksum); - $exists = ($duplicate !== null); + // Look up potential duplicates/partners in order to select the + // proper strategy + $duplicate = $this->get_duplicate($this->strategyParameters->info['checksum']); + $livePartner = $this->findLivePartner( + $this->strategyParameters->info['live_photo_content_id'], + $this->strategyParameters->info['type'], + $this->strategyParameters->album?->id + ); - $this->photo_Url = substr($this->photo->checksum, 0, 32) . $this->extension; - $this->path = Storage::path($this->path_prefix . $this->photo_Url); /* - * ! From here we need to use a Strategy depending if we have - * ! a duplicate - * ! a "normal" picture - * ! a live picture - * ! a video + * From here we need to use a strategy depending if we have + * + * - a duplicate + * - a "stand-alone" media file (i.e. a photo or video without a partner) + * - a photo which is the partner of an already existing video + * - a video which is the partner of an already existing photo */ - - if (!$duplicate) { - $strategy = new StrategyPhoto($import_via_symlink); + if ($duplicate) { + $strategy = new AddDuplicateStrategy($this->strategyParameters, $duplicate); } else { - $strategy = new StrategyDuplicate($skip_duplicates, $resync_metadata, $delete_imported); + if ($livePartner == null) { + $strategy = new AddStandaloneStrategy($this->strategyParameters); + } else { + if ($this->strategyParameters->kind === 'video') { + $strategy = new AddVideoPartnerStrategy($this->strategyParameters, $livePartner); + } else { + $strategy = new AddPhotoPartnerStrategy($this->strategyParameters, $livePartner); + } + } } - $strategy->storeFile($this); - $strategy->hydrate($this, $duplicate, $file); - - // set $this->info - $strategy->loadMetadata($this, $file); - - $strategy->setParentAndOwnership($this); + $photo = $strategy->do(); - // set $this->livePhotoPartner - $strategy->findLivePartner($this); - - $no_error = true; - $skip_db_entry_creation = false; + if ($photo->album_id) { + $notify = new Notify(); + $notify->do($photo); + } - $strategy->generate_thumbs($this, $skip_db_entry_creation, $no_error); + return $photo; + } - // In case it's a live photo and we've uploaded the video - if ($skip_db_entry_creation === true) { - $res = $this->livePhotoPartner->id; - } else { - $res = $this->save($this->photo); + /** + * Extracts the meta-data of the source file and initializes + * {@link AddStrategyParameters::$info} of {@link Create::$strategyParameters}. + * + * @param SourceFileInfo $sourceFileInfo information about the source file + * + * @throws JsonError + */ + protected function loadFileMetadata(SourceFileInfo $sourceFileInfo) + { + /* @var Extractor $metadataExtractor */ + $metadataExtractor = resolve(Extractor::class); + + $this->strategyParameters->info = $metadataExtractor->extract($sourceFileInfo->getTmpFullPath(), $this->strategyParameters->kind); + if ($this->strategyParameters->kind == 'raw') { + $this->strategyParameters->info['type'] = 'raw'; + } + if (empty($this->strategyParameters->info['type'])) { + $this->strategyParameters->info['type'] = $sourceFileInfo->getOriginalMimeType(); } - if ($delete_imported && !$this->is_uploaded && ($exists || !$import_via_symlink) && !@unlink($this->tmp_name)) { - Logs::warning(__METHOD__, __LINE__, 'Failed to delete file (' . $this->tmp_name . ')'); + // Use title of file if IPTC title missing + if ($this->strategyParameters->info['title'] === '') { + if ($this->strategyParameters->kind == 'raw') { + $this->strategyParameters->info['title'] = substr(basename($sourceFileInfo->getOriginalFilename()), 0, 98); + } else { + $this->strategyParameters->info['title'] = substr(basename($sourceFileInfo->getOriginalFilename(), $sourceFileInfo->getOriginalFileExtension()), 0, 98); + } } + } - if ($this->albumID) { - $notify = new Notify(); - $notify->do($this->photo); + /** + * Finds a "lonely" live partner if it exists. + * + * A lonely live partner is a media entry which + * - has the same content ID + * - is in the same album + * - which has an "opposed" mime type (i.e. only mixed (video,photo) or + * (photo,video) pairs can be partners + * - which has no live partner yet + * + * @param ?string $contentID the content id to identify a matching partner + * @param string $mimeType the mime type of the media which a partner is looked for, e.g. the returned {@link Photo} has an "opposed" mime type + * @param ?string $albumID the album of which the partner must be member of + * + * @return Photo|null The live partner if found + */ + protected function findLivePartner( + ?string $contentID, string $mimeType, ?string $albumID + ): ?Photo { + $livePartner = null; + // find a potential partner which has the same content id + if ($contentID) { + /** @var Photo|null $livePartner */ + $livePartner = Photo::query() + ->where('live_photo_content_id', '=', $contentID) + ->where('album_id', '=', $albumID) + ->whereNull('live_photo_short_path')->first(); + } + if ($livePartner != null) { + // if a potential partner has been found, ensure that it is of a + // different kind then the uploaded media. + // Photo+Photo or Video+Video does not work + // TODO: This condition is probably erroneous, if one of the types equals 'raw'. + if (in_array($mimeType, $this->validVideoTypes, true) === in_array($livePartner->type, $this->validVideoTypes, true)) { + $livePartner = null; + } } - return $res; + return $livePartner; + } + + /** + * Loads the album for the designated ID and initializes + * {@link AddStrategyParameters::$album}, {@link AddStrategyParameters::$public} + * and {@link AddStrategyParameters::$star} of + * {@link Create::$strategyParameters} accordingly. + * + * @param string|null $albumID the targeted parent album either null, + * the id of a real album or (if it is + * string) one of the array keys in + * {@link \App\Factories\AlbumFactory::BUILTIN_SMARTS} + * + * @throws JsonError + * @throws \Illuminate\Contracts\Container\BindingResolutionException + */ + protected function initParentId(?string $albumID = null) + { + /** @var AlbumFactory */ + $factory = resolve(AlbumFactory::class); + if (!empty($albumID)) { + $album = $factory->findOrFail($albumID); + + if ($album instanceof Album) { + // we save it so we don't have to query it again later + $this->strategyParameters->album = $album; + } elseif ($album instanceof BaseSmartAlbum) { + $this->strategyParameters->album = null; + if ($album instanceof PublicAlbum) { + $this->strategyParameters->is_public = true; + } elseif ($album instanceof StarredAlbum) { + $this->strategyParameters->is_starred = true; + } + } else { + throw new JsonError('This album does not support uploading'); + } + } } } diff --git a/app/Actions/Photo/Delete.php b/app/Actions/Photo/Delete.php index ec410a4b8d8..f471f541227 100644 --- a/app/Actions/Photo/Delete.php +++ b/app/Actions/Photo/Delete.php @@ -2,24 +2,23 @@ namespace App\Actions\Photo; -use App\Actions\Photo\Extensions\Save; use App\Models\Photo; class Delete { - use Save; - - public function do(array $photoIds) + public function do(array $photoIds): void { - $photos = Photo::whereIn('id', $photoIds)->get(); - - $no_error = true; - + $photos = Photo::query() + ->with(['size_variants', 'size_variants.sym_links']) + ->whereIn('id', $photoIds) + ->get(); + $success = true; + /** @var Photo $photo */ foreach ($photos as $photo) { - $no_error &= $photo->predelete(); - $no_error &= $photo->delete(); + // we must call delete on the model and not on the database + // in order to remove the files, too + $success &= $photo->delete(); } - - return $no_error; + abort_if(!$success, 500, 'could not delete photo(s)'); } } diff --git a/app/Actions/Photo/Duplicate.php b/app/Actions/Photo/Duplicate.php index fda3daf55e3..01b49585194 100644 --- a/app/Actions/Photo/Duplicate.php +++ b/app/Actions/Photo/Duplicate.php @@ -2,74 +2,43 @@ namespace App\Actions\Photo; -use App\Actions\Photo\Extensions\Save; -use App\Facades\Helpers; -use App\Factories\AlbumFactory; +use App\Models\Album; use App\Models\Photo; +use Illuminate\Support\Collection; class Duplicate { - use Save; - - private $albumFactory; - - public function __construct(AlbumFactory $albumFactory) + /** + * Duplicates a set of photos. + * + * @param string[] $photoIds the IDs of the source photos + * @param string|null $albumID the ID of the destination album; + * `null` means root album + * + * @return Collection the duplicates + */ + public function do(array $photoIds, ?string $albumID): Collection { - $this->albumFactory = $albumFactory; - } - - public function do(array $photoIds, ?string $albumID) - { - $photos = Photo::query()->whereIn('id', $photoIds)->get(); + $album = null; + if ($albumID) { + $album = Album::query()->findOrFail($albumID); + } + $duplicates = new Collection(); + $photos = Photo::query() + ->with(['size_variants']) + ->whereIn('id', $photoIds)->get(); - $duplicate = null; /** @var Photo $photo */ foreach ($photos as $photo) { - $duplicate = new Photo(); - $duplicate->id = Helpers::generateID(); - $duplicate->title = $photo->title; - $duplicate->description = $photo->description; - $duplicate->url = $photo->url; - $duplicate->tags = $photo->tags; - $duplicate->public = $photo->public; - $duplicate->type = $photo->type; - $duplicate->width = $photo->width; - $duplicate->height = $photo->height; - $duplicate->filesize = $photo->filesize; - $duplicate->iso = $photo->iso; - $duplicate->aperture = $photo->aperture; - $duplicate->make = $photo->make; - $duplicate->model = $photo->model; - $duplicate->lens = $photo->lens; - $duplicate->shutter = $photo->shutter; - $duplicate->focal = $photo->focal; - $duplicate->latitude = $photo->latitude; - $duplicate->longitude = $photo->longitude; - $duplicate->altitude = $photo->altitude; - $duplicate->imgDirection = $photo->imgDirection; - $duplicate->location = $photo->location; - $duplicate->taken_at = $photo->taken_at; - $duplicate->star = $photo->star; - $duplicate->thumbUrl = $photo->thumbUrl; - $duplicate->thumb2x = $photo->thumb2x; - $duplicate->album_id = $albumID ?? $photo->album_id; - if ($duplicate->album_id === '0') { - $duplicate->album_id = null; + $duplicate = $photo->replicate(); + $duplicate->album_id = $albumID; + if ($album) { + $duplicate->owner_id = $album->owner_id; } - $duplicate->checksum = $photo->checksum; - $duplicate->medium_width = $photo->medium_width; - $duplicate->medium_height = $photo->medium_height; - $duplicate->medium2x_width = $photo->medium2x_width; - $duplicate->medium2x_height = $photo->medium2x_height; - $duplicate->small_width = $photo->small_width; - $duplicate->small_height = $photo->small_height; - $duplicate->small2x_width = $photo->small2x_width; - $duplicate->small2x_height = $photo->small2x_height; - $duplicate->owner_id = $photo->owner_id; - $duplicate->livePhotoContentID = $photo->livePhotoContentID; - $duplicate->livePhotoUrl = $photo->livePhotoUrl; - $duplicate->livePhotoChecksum = $photo->livePhotoChecksum; - $this->save($duplicate); + $duplicate->save(); + $duplicates->add($duplicate); } + + return $duplicates; } } diff --git a/app/Actions/Photo/Extensions/ArchiveFileInfo.php b/app/Actions/Photo/Extensions/ArchiveFileInfo.php new file mode 100644 index 00000000000..c490b6c973c --- /dev/null +++ b/app/Actions/Photo/Extensions/ArchiveFileInfo.php @@ -0,0 +1,115 @@ +baseFilename = $baseFilename; + $this->baseFilenameAddon = $baseFilenameAddon; + $this->fullPath = $fullPath; + } + + /** + * Returns the base filename. + * + * The base file name should be used to create a "meaningful" filename + * which is offered to the client for download or put into the archive. + * + * @return string the base filename + */ + public function getBaseFilename(): string + { + return $this->baseFilename; + } + + /** + * Returns the addon to the base filename. + * + * The base file name should be used to create a "meaningful" filename + * which is offered to the client for download or put into the archive. + * The addon enables to create different filenames for different variants + * of the same photo. + * + * @return string the addon to the base filename + */ + public function getBaseFileNameAddon(): string + { + return $this->baseFilenameAddon; + } + + /** + * Returns the file extension of the original source file. + * + * @return string the original file extension with a preceding dot + */ + public function getExtension(): string + { + return Helpers::getExtension($this->fullPath, false); + } + + /** + * Returns the filename as it should be advertised to the downloading + * client or put into the archive. + * + * @param string $extraAddon an extra addon which should be added to the filename + * + * @return string the filename + */ + public function getFilename(string $extraAddon = ''): string + { + return $this->getBaseFilename() . $this->getBaseFileNameAddon() . $extraAddon . $this->getExtension(); + } + + /** + * Returns the path at which Lychee has stored the source file. + * + * @return string the full path + */ + public function getFullPath(): string + { + return $this->fullPath; + } +} diff --git a/app/Actions/Photo/Extensions/Checks.php b/app/Actions/Photo/Extensions/Checks.php index b57901fcb91..0ebdd433e04 100644 --- a/app/Actions/Photo/Extensions/Checks.php +++ b/app/Actions/Photo/Extensions/Checks.php @@ -37,7 +37,7 @@ public function folderPermission($folder) $path = Storage::path($folder); if (Helpers::hasPermissions($path) === false) { - Logs::notice(__METHOD__, __LINE__, 'Skipped extaction of video from live photo, because ' . $path . ' is missing or not readable and writable.'); + Logs::notice(__METHOD__, __LINE__, 'Skipped extraction of video from live photo, because ' . $path . ' is missing or not readable and writable.'); throw new FolderIsNotWritable(); } @@ -48,30 +48,41 @@ public function folderPermission($folder) * Check if a picture has a duplicate * We compare the checksum to the other Photos or LivePhotos. * - * @return false|Photo + * @param string $checksum + * + * @return ?Photo */ - public function get_duplicate($checksum, $photoID = null) + public function get_duplicate(string $checksum): ?Photo { - return Photo::where(function ($q) use ($checksum) { - $q->where('checksum', '=', $checksum) - ->orWhere('livePhotoChecksum', '=', $checksum); - })->where('id', '<>', $photoID)->first(); + /** @var Photo|null $photo */ + $photo = Photo::query() + ->where('checksum', '=', $checksum) + ->orWhere('original_checksum', '=', $checksum) + ->orWhere('live_photo_checksum', '=', $checksum) + ->first(); + + return $photo; } /** - * Returns 'photo' if it is a photo - * Returns 'video' if it is a video - * Returns 'raw' if it is an accepted file (we only check extensions). + * Returns the kind of a media file. + * + * The kind is one out of: + * + * - `'photo'` if the media file is a photo + * - `'video'` if the media file is a video + * - `'raw'` if the media file is an accepted file, but none of the other + * two kinds (we only check extensions). * - * @throws 'error message' if it is something else + * @param SourceFileInfo $sourceFileInfo information about source file * - * @param $file - * @param $extension + * @return string either `'photo'`, `'video'` or `'raw'` * - * @return string + * @throws JsonError thrown if it is something else */ - public function file_type($file, string $extension) + public function file_kind(SourceFileInfo $sourceFileInfo): string { + $extension = $sourceFileInfo->getOriginalFileExtension(); // check raw files $raw_formats = strtolower(Configs::get_value('raw_formats', '')); if (in_array(strtolower($extension), explode('|', $raw_formats), true)) { @@ -79,7 +90,7 @@ public function file_type($file, string $extension) } if (in_array(strtolower($extension), $this->validExtensions, true)) { - $mimeType = $file['type']; + $mimeType = $sourceFileInfo->getOriginalMimeType(); if (in_array($mimeType, $this->validVideoTypes, true)) { return 'video'; } @@ -94,12 +105,12 @@ public function file_type($file, string $extension) throw new JsonError('EXIF library not loaded on the server!'); } - $type = @exif_imagetype($file['tmp_name']); + $type = @exif_imagetype($sourceFileInfo->getTmpFullPath()); if (in_array($type, $this->validTypes, true)) { return 'photo'; } - Logs::error(__METHOD__, __LINE__, 'Photo type not supported: ' . $file['name']); + Logs::error(__METHOD__, __LINE__, 'Photo type not supported: ' . $sourceFileInfo->getOriginalFilename()); throw new JsonError('Photo type not supported!'); } } diff --git a/app/Actions/Photo/Extensions/Checksum.php b/app/Actions/Photo/Extensions/Checksum.php deleted file mode 100644 index 6f25dd41431..00000000000 --- a/app/Actions/Photo/Extensions/Checksum.php +++ /dev/null @@ -1,23 +0,0 @@ -type == 'raw') { - // Create medium file for normal photos and for raws - $mediumMaxWidth = intval(Configs::get_value('medium_max_width')); - $mediumMaxHeight = intval(Configs::get_value('medium_max_height')); - $this->resizePhoto($photo, 'medium', $mediumMaxWidth, $mediumMaxHeight, $frame_tmp); - - if (Configs::get_value('medium_2x') === '1') { - $this->resizePhoto($photo, 'medium2x', $mediumMaxWidth * 2, $mediumMaxHeight * 2, $frame_tmp); - } - } - - $smallMaxWidth = intval(Configs::get_value('small_max_width')); - $smallMaxHeight = intval(Configs::get_value('small_max_height')); - $this->resizePhoto($photo, 'small', $smallMaxWidth, $smallMaxHeight, $frame_tmp); - - if (Configs::get_value('small_2x') === '1') { - $this->resizePhoto($photo, 'small2x', $smallMaxWidth * 2, $smallMaxHeight * 2, $frame_tmp); - } - } - - /** - * @param Photo $photo - * - * @return string Path of the jpg file - */ - public function createJpgFromRaw(Photo $photo): string - { - // we need imagick to do the job - if (!Configs::hasImagick()) { - Logs::notice(__METHOD__, __LINE__, 'Saving JPG of raw file failed: Imagick not installed.'); - - return ''; - } - - $filename = $photo->url; - $url = Storage::path('raw/' . $filename); - $ext = pathinfo($filename)['extension']; - - // test if Imagick supports the filetype - // Query return file extensions as all upper case - if (!in_array(strtoupper($ext), \Imagick::queryformats())) { - Logs::notice(__METHOD__, __LINE__, 'Filetype ' . $ext . ' not supported by Imagick.'); - - return ''; - } - - if (!($tmp_file = tempnam(sys_get_temp_dir(), 'lychee')) || - !rename($tmp_file, $tmp_file . '.jpeg')) { - Logs::notice(__METHOD__, __LINE__, 'Could not create a temporary file.'); - - return ''; - } - $tmp_file .= '.jpeg'; - Logs::notice(__METHOD__, __LINE__, 'Saving JPG of raw file to ' . $tmp_file); - - $resWidth = $resHeight = 0; - $width = $photo->width; - $height = $photo->height; - - try { - $this->imageHandler->scale($url, $tmp_file, $width, $height, $resWidth, $resHeight); - } catch (Exception $e) { - Logs::error(__METHOD__, __LINE__, 'Failed to create JPG from raw file ' . $url . $filename); - - return ''; - } - - return $tmp_file; - } - - /** - * Creates smaller copies of Photo. - * - * @param Photo $photo - * @param string $type - * @param int $maxWidth - * @param int $maxHeight - * @param string Path of the video frame - * - * @return bool - */ - public function resizePhoto(Photo $photo, string $type, int $maxWidth, int $maxHeight, string $frame_tmp = ''): bool - { - $width = $photo->width; - $height = $photo->height; - - if ($frame_tmp === '') { - $filename = $photo->url; - $url = Storage::path('big/' . $filename); - } else { - $filename = $photo->thumbUrl; - $url = $frame_tmp; - } - - // Both image sizes of the same type are stored in the same folder - // ie: medium and medium2x both belong in LYCHEE_UPLOADS_MEDIUM - $pathType = strtoupper($type); - if (($split = strpos($pathType, '2')) !== false) { - $pathType = substr($pathType, 0, $split); - } - - $uploadFolder = Storage::path(strtolower($pathType) . '/'); - if (Helpers::hasPermissions($uploadFolder) === false) { - Logs::notice(__METHOD__, __LINE__, 'Skipped creation of ' . $type . '-photo, because ' . $uploadFolder . ' is missing or not readable and writable.'); - - return false; - } - - // Add the @2x postfix if we're dealing with an HiDPI type - if (strpos($type, '2x') > 0) { - $filename = Helpers::ex2x($filename); - } - - // Is photo big enough? - if (($width <= $maxWidth || $maxWidth == 0) && ($height <= $maxHeight || $maxHeight == 0)) { - Logs::notice(__METHOD__, __LINE__, 'No resize (image is too small: ' . $maxWidth . 'x' . $maxHeight . ')!'); - - return false; - } - - $resWidth = $resHeight = 0; - if (!$this->imageHandler->scale($url, $uploadFolder . $filename, $maxWidth, $maxHeight, $resWidth, $resHeight)) { - Logs::error(__METHOD__, __LINE__, 'Failed to ' . $type . ' resize image'); - - return false; - } - - $photo->{$type . '_width'} = $resWidth; - $photo->{$type . '_height'} = $resHeight; - - return true; - } - - /** - * Create thumbnail for a picture. - * - * @param Photo $photo - * @param string Path of the video frame - * - * @return bool returns true when successful - */ - public function createThumb(Photo $photo, string $frame_tmp = ''): bool - { - Logs::notice(__METHOD__, __LINE__, 'Photo URL is ' . $photo->url); - - $src = ($frame_tmp === '') ? Storage::path('big/' . $photo->url) : $frame_tmp; - $photoName = explode('.', $photo->url); - - $this->imageHandler->crop($src, Storage::path('thumb/' . $photoName[0] . '.jpeg'), Photo::THUMBNAIL_DIM, Photo::THUMBNAIL_DIM); - - if (Configs::get_value('thumb_2x') === '1' && $photo->width >= Photo::THUMBNAIL2X_DIM && $photo->height >= Photo::THUMBNAIL2X_DIM) { - // Retina thumbs - $this->imageHandler->crop($src, Storage::path('thumb/' . $photoName[0] . '@2x.jpeg'), Photo::THUMBNAIL2X_DIM, Photo::THUMBNAIL2X_DIM); - $photo->thumb2x = 1; - } else { - $photo->thumb2x = 0; - } - - return true; - } -} diff --git a/app/Actions/Photo/Extensions/Metadata.php b/app/Actions/Photo/Extensions/Metadata.php deleted file mode 100644 index eb358f2a3d7..00000000000 --- a/app/Actions/Photo/Extensions/Metadata.php +++ /dev/null @@ -1,40 +0,0 @@ -extract($path, $kind); - if ($kind == 'raw') { - $info['type'] = 'raw'; - } - - // Use title of file if IPTC title missing - if ($info['title'] === '') { - if ($kind == 'raw') { - $info['title'] = substr(basename($file['name']), 0, 98); - } elseif ($info['title'] === '') { - $info['title'] = substr(basename($file['name'], $extension), 0, 98); - } - } - - return $info; - } -} diff --git a/app/Actions/Photo/Extensions/ParentAlbum.php b/app/Actions/Photo/Extensions/ParentAlbum.php deleted file mode 100644 index 15ea6fb3b3c..00000000000 --- a/app/Actions/Photo/Extensions/ParentAlbum.php +++ /dev/null @@ -1,33 +0,0 @@ -albumID = null; - if ($albumID_in != '0') { - $album = $factory->make($albumID_in); - - if ($album->is_tag_album()) { - throw new JsonError('Sorry, cannot upload to Tag Album.'); - } - - if (!$album->is_smart()) { - $this->parentAlbum = $album; // we save it so we don't have to query it again later - $this->albumID = $albumID_in; - } else { - $this->public = ($album->id == 'public'); - $this->star = ($album->id == 'starred'); - } - } - } -} diff --git a/app/Actions/Photo/Extensions/Save.php b/app/Actions/Photo/Extensions/Save.php deleted file mode 100644 index eec4199f656..00000000000 --- a/app/Actions/Photo/Extensions/Save.php +++ /dev/null @@ -1,64 +0,0 @@ -save()) { - throw new JsonError('Could not save photo in database!'); - } - } catch (QueryException $e) { - $retry = true; - $this->recover($e, $photo); - } - } while ($retry); - - // return the ID. - return $photo->id; - } - - /** - * Manage recovery from the Exception. - * - * @throws JsonError if code is neither 23000 or 23505 - */ - private function recover(QueryException $e, Photo &$photo) - { - $errorCode = $e->getCode(); - if ($errorCode == 23000 || $errorCode == 23505) { - // houston, we have a duplicate entry problem - do { - // Our ids are based on current system time, so - // wait randomly up to 1s before retrying. - usleep(rand(0, 1000000)); - $newId = Helpers::generateID(); - } while ($newId === $photo->id); - - $photo->id = $newId; - } else { - Logs::error(__METHOD__, __LINE__, 'Something went wrong, error ' . $errorCode . ', ' . $e->getMessage()); - - throw new JsonError('Something went wrong, error' . $errorCode . ', please check the logs'); - } - } -} diff --git a/app/Actions/Photo/Extensions/SourceFileInfo.php b/app/Actions/Photo/Extensions/SourceFileInfo.php new file mode 100644 index 00000000000..bc2374d258f --- /dev/null +++ b/app/Actions/Photo/Extensions/SourceFileInfo.php @@ -0,0 +1,121 @@ +originalFilename = $originalFilename; + $this->originalMimeType = $originalMimeType; + $this->tmpFullPath = $tmpFullPath; + } + + /** + * Creates a new instance which is suitable, if the source file is a + * local file on the server. + * + * @param string $path the absolute path of the source file on the same server as Lychee is running on + * + * @return SourceFileInfo the new instance + */ + public static function createForLocalFile(string $path): SourceFileInfo + { + return new self($path, mime_content_type($path), $path); + } + + /** + * Creates a new instance which is suitable, if the source file is an + * uploaded file from a remote HTTP client. + * + * @param UploadedFile $file the uploaded file + * + * @return SourceFileInfo the new instance + */ + public static function createForUploadedFile(UploadedFile $file): SourceFileInfo + { + return new self($file->getClientOriginalName(), $file->getMimeType(), $file->getPathName()); + } + + /** + * Returns the original filename of the source file. + * + * Note that this filename differs from the final filename which Lychee + * uses to store the file in the image storage. + * + * This attribute has previously been called `name` in an anonymous array. + * + * @return string the original filename from the client side before upload + */ + public function getOriginalFilename(): string + { + return $this->originalFilename; + } + + /** + * Returns the mime type of the source file. + * + * Note that this mime-type may differ from the mime-type which Lychee + * extracts through the media extractor from the file. + * It is the mime-type as reported by the client (in case of an upload) + * or the (remote) server (in case of an import). + * This mime-type may even be wrong, if the client or (remote) server is + * buggy and reports an erroneous mime-type. + * + * This attribute has previously been called `type` in an anonymous array. + * + * @return string the mime type + */ + public function getOriginalMimeType(): string + { + return $this->originalMimeType; + } + + /** + * Returns the path at which Lychee has temporarily stored + * the uploaded or fetched file. + * + * This attribute has previously been called `tmp_name` in an anonymous + * array. + * + * @return string the mime type + */ + public function getTmpFullPath(): string + { + return $this->tmpFullPath; + } + + /** + * Returns the file extension of the original source file. + * + * @return string the original file extension with a preceding dot + */ + public function getOriginalFileExtension(): string + { + return Helpers::getExtension($this->originalFilename, false); + } +} \ No newline at end of file diff --git a/app/Actions/Photo/Extensions/VideoEditing.php b/app/Actions/Photo/Extensions/VideoEditing.php deleted file mode 100644 index 57f426a0bbe..00000000000 --- a/app/Actions/Photo/Extensions/VideoEditing.php +++ /dev/null @@ -1,171 +0,0 @@ -aperture === '') { - $path = Storage::path('big/' . $photo->url); - - /* @var Extractor $metadataExtractor */ - $metadataExtractor = resolve(Extractor::class); - $info = $metadataExtractor->extract($path, 'video'); - $photo->aperture = $info['aperture']; - } - // we check again, just to be sure. - if ($photo->aperture === '') { - return ''; - } - - /** - * ! check if we can use path instead of this ugly thing. - */ - $ffmpeg = FFMpeg::create(); - /** @var Video */ - $video = $ffmpeg->open(Storage::path('big/' . $photo->url)); - if ( - !($tmp = tempnam(sys_get_temp_dir(), 'lychee')) || - !rename($tmp, $tmp . '.jpeg') - ) { - Logs::notice(__METHOD__, __LINE__, 'Could not create a temporary file.'); - - return ''; - } - $tmp .= '.jpeg'; - Logs::notice(__METHOD__, __LINE__, 'Saving frame to ' . $tmp); - - try { - /** - * ! check if we can use path instead of this ugly thing. - */ - $frame = $video->frame(TimeCode::fromSeconds($photo->aperture / 2)); - $frame->save($tmp); - } catch (Exception $e) { - Logs::notice(__METHOD__, __LINE__, 'Failed to extract snapshot from video ' . $tmp); - } - - // check if the image has data - $success = file_exists($tmp) ? (filesize($tmp) > 0) : false; - - if ($success) { - // Optimize image - if (Configs::get_value('lossless_optimization')) { - ImageOptimizer::optimize($tmp); - } - } else { - Logs::notice(__METHOD__, __LINE__, 'Failed to extract snapshot from video ' . $tmp); - try { - /** - * ! check if we can use path instead of this ugly thing. - */ - $frame = $video->frame(TimeCode::fromSeconds(0)); - $frame->save($tmp); - $success = file_exists($tmp) ? (filesize($tmp) > 0) : false; - if (!$success) { - Logs::notice(__METHOD__, __LINE__, 'Fallback failed to extract snapshot from video ' . $tmp); - } else { - Logs::notice(__METHOD__, __LINE__, 'Fallback successful - snapshot from video ' . $tmp . ' at t=0 created.'); - } - } catch (Exception $e) { - Logs::notice(__METHOD__, __LINE__, 'Fallback failed to extract snapshot from video ' . $tmp); - - return ''; - } - } - - return $tmp; - } - - /** - * Extract the video part of the a Livephoto. - * - * @param Photo $photo - * @param string $type - * @param int $maxWidth - * @param int $maxHeight - * @param string Path of the video frame - * - * @return bool - */ - public function extractVideo(Photo $photo, int $videoLengthBytes, string $frame_tmp = ''): bool - { - // We extract the video from the jpg file - // Google Motion Photo: See here for details - // - - if ($frame_tmp === '') { - $filename = $photo->url; - } else { - $filename = $photo->thumbUrl; - } - - $filename_video_mov = basename($filename, Helpers::getExtension($filename, false)) . '.mov'; - - $uploadFolder = $this->folderPermission('big/'); - - try { - // 1. Extract the video part - $fp = fopen($uploadFolder . $photo->url, 'r'); - $fp_video = tmpfile(); // use a temporary file, will be delted once closed - - // The MP4 file is located in the last bytes of the file - fseek($fp, -1 * $videoLengthBytes, SEEK_END); // It needs to be negative - $data = fread($fp, $videoLengthBytes); - fwrite($fp_video, $data, $videoLengthBytes); - - // 2. Convert file from mp4 to mov, but keeping audio and video codec - // This is needed to LivePhotosKit which only accepts mov files - // Computation is fast, since codecs, resolution, framerate etc. remain unchanged - - /** - * ! check if we can use path instead of this ugly thing. - */ - $ffmpeg = FFMpeg::create(); - $video = $ffmpeg->open(stream_get_meta_data($fp_video)['uri']); - $format = new MOVFormat(); - // Add additional parameter to extract the first video stream - $format->setAdditionalParameters(['-map', '0:0']); - $video->save($format, $uploadFolder . $filename_video_mov); - - // 3. Close files ($fp_video will be again deleted) - fclose($fp); - fclose($fp_video); - - // Save file path; Checksum calclation not needed since - // we do not perform matching for Google Motion Photos (as for iOS Live Photos) - $photo->livePhotoUrl = $filename_video_mov; - } catch (Exception $exception) { - Logs::error(__METHOD__, __LINE__, $exception->getMessage()); - - return false; - } - - return true; - } -} diff --git a/app/Actions/Photo/Prepare.php b/app/Actions/Photo/Prepare.php deleted file mode 100644 index caf5e8f1268..00000000000 --- a/app/Actions/Photo/Prepare.php +++ /dev/null @@ -1,56 +0,0 @@ -toReturnArray(); - - $this->symLinkFunctions->getUrl($photo, $return); - - //! This can probably be refactored - if (!AccessControl::is_current_user($photo->owner_id)) { - if ($photo->album_id != null) { - $album = $photo->album; - if (!$album->is_full_photo_visible()) { - $photo->downgrade($return); - } - - // if 2 : picture is public by album being public (if being in an album). - if ($album->is_public()) { - $return['public'] = '2'; - } - - $return['downloadable'] = $album->is_downloadable() ? '1' : '0'; - $return['share_button_visible'] = $album->is_share_button_visible() ? '1' : '0'; - } else { // Unsorted - if (Configs::get_value('full_photo', '1') != '1') { - $photo->downgrade($return); - } - $return['downloadable'] = Configs::get_value('downloadable', '0'); - $return['share_button_visible'] = Configs::get_value('share_button_visible', '0'); - } - } else { - if ($photo->album_id != null && $photo->album->is_public()) { - $return['public'] = '2'; - } - $return['downloadable'] = '1'; - $return['share_button_visible'] = '1'; - } - - $return['license'] = $photo->get_license(); - - return $return; - } -} diff --git a/app/Actions/Photo/Random.php b/app/Actions/Photo/Random.php index 8d4ca30637c..3a9a9f9a3c9 100644 --- a/app/Actions/Photo/Random.php +++ b/app/Actions/Photo/Random.php @@ -2,29 +2,22 @@ namespace App\Actions\Photo; -use App\Actions\Albums\Extensions\PublicIds; use App\Exceptions\JsonError; +use App\Models\Photo; use App\SmartAlbums\StarredAlbum; -class Random extends SymLinker +class Random { - public function do(): array + public function do(): Photo { - // here we need to refine. - $starred = new StarredAlbum(); - $starred->setAlbumIDs(resolve(PublicIds::class)->getPublicAlbumsId()); - $photo = $starred->get_photos()->inRandomOrder()->first(); + $starred = StarredAlbum::getInstance(); + /** @var Photo $photo */ + $photo = $starred->photos()->inRandomOrder()->first(); if ($photo == null) { throw new JsonError('no pictures found!'); } - $return = $photo->toReturnArray(); - $this->symLinkFunctions->getUrl($photo, $return); - if ($photo->album_id !== null && !$photo->album->is_full_photo_visible()) { - $photo->downgrade($return); - } - - return $return; + return $photo; } } diff --git a/app/Actions/Photo/Rotate.php b/app/Actions/Photo/Rotate.php index 39b921c1b6c..afe92abe643 100644 --- a/app/Actions/Photo/Rotate.php +++ b/app/Actions/Photo/Rotate.php @@ -2,176 +2,13 @@ namespace App\Actions\Photo; -use App\Actions\Photo\Extensions\Checksum; -use App\Actions\Photo\Extensions\Constants; -use App\Actions\Photo\Extensions\ImageEditing; -use App\Facades\Helpers; -use App\Image\ImageHandlerInterface; -use App\Metadata\Extractor; -use App\Models\Logs; +use App\Actions\Photo\Strategies\RotateStrategy; use App\Models\Photo; -use Illuminate\Support\Facades\Storage; class Rotate { - use Checksum; - use Constants; - use ImageEditing; - - public $imageHandler; - - public function __construct() - { - $this->imageHandler = app(ImageHandlerInterface::class); - } - - private function check(Photo $photo, int $direction): bool - { - if ($photo->isVideo()) { - Logs::error(__METHOD__, __LINE__, 'Trying to rotate a video'); - - return false; - } - - if ($photo->livePhotoUrl !== null) { - Logs::error(__METHOD__, __LINE__, 'Trying to rotate a live photo'); - - return false; - } - - if ($photo->type == 'raw') { - Logs::error(__METHOD__, __LINE__, 'Trying to rotate a raw file'); - - return false; - } - - // direction is valid? - if (($direction != 1) && ($direction != -1)) { - Logs::error(__METHOD__, __LINE__, 'Direction must be 1 or -1'); - - return false; - } - - return true; - } - - public function do(Photo $photo, int $direction) + public function do(Photo $photo, int $direction): Photo { - if (!$this->check($photo, $direction)) { - return false; - } - - // Generate a temporary name for the rotated file. - $big_folder = Storage::path('big/'); - $url = $photo->url; - $path = $big_folder . $url; - $extension = Helpers::getExtension($url); - if ( - !($new_tmp = tempnam($big_folder, 'lychee')) || - !@rename($new_tmp, $new_tmp . $extension) - ) { - Logs::notice(__METHOD__, __LINE__, 'Could not create a temporary file.'); - - return false; - } - $new_tmp .= $extension; - - // Rotate the original image. - if ($this->imageHandler->rotate($path, ($direction == 1) ? 90 : -90, $new_tmp) === false) { - Logs::error(__METHOD__, __LINE__, 'Failed to rotate ' . $path); - - return false; - } - - // We will use new names to avoid problems with image caching. - $new_prefix = substr($this->checksum($new_tmp), 0, 32); - $new_url = $new_prefix . $extension; - $new_path = $big_folder . $new_url; - - // Rename the temporary file, now that we know its final name. - if (!@rename($new_tmp, $new_path)) { - Logs::error(__METHOD__, __LINE__, 'Failed to rename ' . $new_tmp); - - return false; - } - - $photo->url = $new_url; - $old_width = $photo->width; - $photo->width = $photo->height; - $photo->height = $old_width; - - // The file size may have changed after the rotation. - /* @var Extractor $metadataExtractor */ - $metadataExtractor = resolve(Extractor::class); - $photo->filesize = $metadataExtractor->filesize($new_path); - // Also restore the original date. - if ($photo->taken_at !== null) { - @touch($new_path, $photo->taken_at->getTimestamp()); - } - - // Delete all old image files, including the original. - if ($photo->thumbUrl != '') { - @unlink(Storage::path('thumb/' . $photo->thumbUrl)); - if ($photo->thumb2x != 0) { - @unlink(Storage::path('thumb/' . Helpers::ex2x($photo->thumbUrl))); - $photo->thumb2x = 0; - } - $photo->thumbUrl = ''; - } - if ($photo->small_width !== null) { - @unlink(Storage::path('small/' . $url)); - $photo->small_width = null; - $photo->small_height = null; - if ($photo->small2x_width !== null) { - @unlink(Storage::path('small/' . Helpers::ex2x($url))); - $photo->small2x_width = null; - $photo->small2x_height = null; - } - } - if ($photo->medium_width !== null) { - @unlink(Storage::path('medium/' . $url)); - $photo->medium_width = null; - $photo->medium_height = null; - if ($photo->medium2x_width !== null) { - @unlink(Storage::path('medium/' . Helpers::ex2x($url))); - $photo->medium2x_width = null; - $photo->medium2x_height = null; - } - } - @unlink($path); - - // Create new thumbs and intermediate sizes. - if ($this->createThumb($photo) === false) { - Logs::error(__METHOD__, __LINE__, 'Could not create thumbnail for photo'); - } else { - $photo->thumbUrl = $new_prefix . '.jpeg'; - } - $this->createSmallerImages($photo); - - // Finally save the updated photo. - $photo->save(); - - // Deal with duplicates. We simply update all of them to match. - Photo::query() - ->where('checksum', $photo->checksum) - ->where('id', '<>', $photo->id) - ->update([ - 'url' => $photo->url, - 'width' => $photo->width, - 'height' => $photo->height, - 'filesize' => $photo->filesize, - 'thumbUrl' => $photo->thumbUrl, - 'thumb2x' => $photo->thumb2x, - 'small_width' => $photo->small_width, - 'small_height' => $photo->small_height, - 'small2x_width' => $photo->small2x_width, - 'small2x_height' => $photo->small2x_height, - 'medium_width' => $photo->medium_width, - 'medium_height' => $photo->medium_height, - 'medium2x_width' => $photo->medium2x_width, - 'medium2x_height' => $photo->medium2x_height, - ]); - - return true; + return (new RotateStrategy($photo, $direction))->do(); } } diff --git a/app/Actions/Photo/SetAlbum.php b/app/Actions/Photo/SetAlbum.php index 00fd4536585..e3d5589cae6 100644 --- a/app/Actions/Photo/SetAlbum.php +++ b/app/Actions/Photo/SetAlbum.php @@ -3,42 +3,38 @@ namespace App\Actions\Photo; use App\Actions\User\Notify; -use App\Exceptions\JsonError; -use App\Factories\AlbumFactory; +use App\Models\Album; use App\Models\Photo; class SetAlbum extends Setters { - private $albumFactory; - - public function __construct(AlbumFactory $albumFactory) + public function __construct() { $this->property = 'album_id'; - $this->albumFactory = $albumFactory; } - public function execute(array $photoIDs, string $albumID) + public function execute(array $photoIDs, ?string $albumID): bool { $album = null; - - if ($albumID != '0') { - $album = $this->albumFactory->make($albumID); - - if ($album->is_tag_album()) { - throw new JsonError('Sorry, cannot Set to tag Album.'); - } - - if ($album->is_smart()) { - throw new JsonError('Sorry, cannot Set to smart Album.'); - } + if ($albumID) { + /** @var Album $album */ + $album = Album::query()->findOrFail($albumID); foreach ($photoIDs as $id) { - $photo = Photo::find($id); + $photo = Photo::query()->find($id); $notify = new Notify(); $notify->do($photo, $albumID); } } - return $this->do($photoIDs, $albumID == '0' ? null : $albumID); + if ($this->do($photoIDs, $albumID)) { + if ($album) { + return Photo::query()->whereIn('id', $photoIDs)->update(['owner_id' => $album->owner_id]) > 0; + } + + return true; + } else { + return false; + } } } diff --git a/app/Actions/Photo/SetPublic.php b/app/Actions/Photo/SetPublic.php index f2843d3dfa4..98369f47813 100644 --- a/app/Actions/Photo/SetPublic.php +++ b/app/Actions/Photo/SetPublic.php @@ -6,6 +6,6 @@ class SetPublic extends Toggle { public function __construct() { - $this->property = 'public'; + $this->property = 'is_public'; } } diff --git a/app/Actions/Photo/SetStar.php b/app/Actions/Photo/SetStar.php index 431fa0c6216..230c0b627d9 100644 --- a/app/Actions/Photo/SetStar.php +++ b/app/Actions/Photo/SetStar.php @@ -6,6 +6,6 @@ class SetStar extends Toggles { public function __construct() { - $this->property = 'star'; + $this->property = 'is_starred'; } } diff --git a/app/Actions/Photo/Setter.php b/app/Actions/Photo/Setter.php index e0f8a4c9b77..a9e1732ed59 100644 --- a/app/Actions/Photo/Setter.php +++ b/app/Actions/Photo/Setter.php @@ -13,11 +13,12 @@ */ class Setter { - public $property; + public string $property; public function do(string $photoID, string $value): bool { - $photo = Photo::findOrFail($photoID); + /** @var Photo $photo */ + $photo = Photo::query()->findOrFail($photoID); return $this->execute($photo, $value); } diff --git a/app/Actions/Photo/Setters.php b/app/Actions/Photo/Setters.php index 6b805e6b089..25d5f75ff66 100644 --- a/app/Actions/Photo/Setters.php +++ b/app/Actions/Photo/Setters.php @@ -12,10 +12,10 @@ */ class Setters { - public $property; + public string $property; public function do(array $photoIDs, ?string $value): bool { - return Photo::whereIn('id', $photoIDs)->update([$this->property => $value]); + return Photo::query()->whereIn('id', $photoIDs)->update([$this->property => $value]) > 0; } } diff --git a/app/Actions/Photo/Strategies/AddBaseStrategy.php b/app/Actions/Photo/Strategies/AddBaseStrategy.php new file mode 100644 index 00000000000..7fc004f4f6a --- /dev/null +++ b/app/Actions/Photo/Strategies/AddBaseStrategy.php @@ -0,0 +1,161 @@ +parameters = $parameters; + $this->photo = $photo; + } + + abstract public function do(): Photo; + + /** + * Hydrates meta-info of the media file from the + * {@link AddStrategyParameters::$info} attribute of the associated + * {@link AddStrategyParameters} object into the associated {@link Photo} + * object. + * + * Meta information is conditionally copied if and only if the target + * attribute of the {@link Photo} object is null or empty and the + * meta-info is not. + * This way this method is usable by {@link AddStandaloneStrategy} and + * {@link AddDuplicateStrategy}. + * For a freshly created {@link Photo} object (with empty attributes) + * all available meta-data is hydrated, but for an already existing + * {@link Photo} object existing attributes are not overwritten. + */ + protected function hydrateMetadata() + { + if (empty($this->photo->title) && !empty($this->parameters->info['title'])) { + $this->photo->title = $this->parameters->info['title']; + } + if (empty($this->photo->description) && !empty($this->parameters->info['description'])) { + $this->photo->description = $this->parameters->info['description']; + } + if (empty($this->photo->tags) && !empty($this->parameters->info['tags'])) { + $this->photo->tags = $this->parameters->info['tags']; + } + if (empty($this->photo->type) && !empty($this->parameters->info['type'])) { + $this->photo->type = $this->parameters->info['type']; + } + $tmp = empty($this->parameters->info['filesize']) ? 0 : intval($this->parameters->info['filesize']); + if ($tmp > 0) { + $this->photo->filesize = $tmp; + } + if (empty($this->photo->checksum) && !empty($this->parameters->info['checksum'])) { + $this->photo->checksum = $this->parameters->info['checksum']; + } + if (empty($this->photo->original_checksum) && !empty($this->parameters->info['checksum'])) { + $this->photo->original_checksum = $this->parameters->info['checksum']; + } + if (empty($this->photo->iso) && !empty($this->parameters->info['iso'])) { + $this->photo->iso = $this->parameters->info['iso']; + } + if (empty($this->photo->aperture) && !empty($this->parameters->info['aperture'])) { + $this->photo->aperture = $this->parameters->info['aperture']; + } + if (empty($this->photo->make) && !empty($this->parameters->info['make'])) { + $this->photo->make = $this->parameters->info['make']; + } + if (empty($this->photo->model) && !empty($this->parameters->info['model'])) { + $this->photo->model = $this->parameters->info['model']; + } + if (empty($this->photo->lens) && !empty($this->parameters->info['lens'])) { + $this->photo->lens = $this->parameters->info['lens']; + } + if (empty($this->photo->shutter) && !empty($this->parameters->info['shutter'])) { + $this->photo->shutter = $this->parameters->info['shutter']; + } + if (empty($this->photo->focal) && !empty($this->parameters->info['focal'])) { + $this->photo->focal = $this->parameters->info['focal']; + } + if ($this->photo->taken_at === null && !empty($this->parameters->info['taken_at'])) { + $this->photo->taken_at = $this->parameters->info['taken_at']; + } + if ($this->photo->latitude === null && !empty($this->parameters->info['latitude'])) { + $this->photo->latitude = floatval($this->parameters->info['latitude']); + } + if ($this->photo->longitude === null && !empty($this->parameters->info['longitude'])) { + $this->photo->longitude = floatval($this->parameters->info['longitude']); + } + if ($this->photo->altitude === null && !empty($this->parameters->info['altitude'])) { + $this->photo->altitude = floatval($this->parameters->info['altitude']); + } + if ($this->photo->img_direction === null && !empty($this->parameters->info['imgDirection'])) { + $this->photo->img_direction = floatval($this->parameters->info['imgDirection']); + } + if (empty($this->photo->location) && !empty($this->parameters->info['location'])) { + $this->photo->location = $this->parameters->info['location']; + } + if (empty($this->photo->live_photo_content_id) && !empty($this->parameters->info['live_photo_content_id'])) { + $this->photo->live_photo_content_id = $this->parameters->info['live_photo_content_id']; + } + } + + protected function setParentAndOwnership() + { + if ($this->parameters->album !== null) { + $this->photo->album_id = $this->parameters->album->id; + $this->photo->owner_id = $this->parameters->album->owner_id; + } else { + $this->photo->album_id = null; + $this->photo->owner_id = AccessControl::id(); + } + } + + /** + * Moves/copies/symlinks source file to final destination. + * + * @param string $targetFullPath the path of the final destination + * + * @throws JsonError + */ + protected function putSourceIntoFinalDestination(string $targetFullPath): void + { + $tmpFullPath = $this->parameters->sourceFileInfo->getTmpFullPath(); + if ($this->parameters->importMode->shallDeleteImported()) { + // Note: We cannot use the storage facade here, because the + // temporary name of the original file may be outside of the + // disk of the storage + if (!rename($tmpFullPath, $targetFullPath)) { + $msg = 'Could not move photo from "' . $tmpFullPath . '" to "' . $targetFullPath . '"'; + Logs::error(__METHOD__, __LINE__, $msg); + throw new JsonError('$msg'); + } + if (!chmod($targetFullPath, 0666 & ~umask(null))) { + $msg = 'Could not set permissions of "' . $targetFullPath . '"'; + Logs::error(__METHOD__, __LINE__, $msg); + throw new JsonError('$msg'); + } + } else { + // Check if the user wants to create symlinks instead of copying the photo + if ($this->parameters->importMode->shallImportViaSymlink()) { + if (!symlink($tmpFullPath, $targetFullPath)) { + $msg = 'Could not create symbolic link at "' . $targetFullPath . '" for photo at "' . $tmpFullPath . '"'; + Logs::error(__METHOD__, __LINE__, $msg); + throw new JsonError($msg); + } + } elseif (!copy($tmpFullPath, $targetFullPath)) { + $msg = 'Could not copy photo from "' . $tmpFullPath . '" to "' . $targetFullPath . '"'; + Logs::error(__METHOD__, __LINE__, $msg); + throw new JsonError($msg); + } + } + + // Set original date + if ($this->photo->taken_at !== null) { + @touch($targetFullPath, $this->photo->taken_at->getTimestamp()); + } + } +} diff --git a/app/Actions/Photo/Strategies/AddDuplicateStrategy.php b/app/Actions/Photo/Strategies/AddDuplicateStrategy.php new file mode 100644 index 00000000000..1d24e684082 --- /dev/null +++ b/app/Actions/Photo/Strategies/AddDuplicateStrategy.php @@ -0,0 +1,43 @@ +hydrateMetadata(); + if ($this->photo->isDirty()) { + Logs::notice(__METHOD__, __LINE__, 'Updating metadata of existing photo.'); + $this->photo->save(); + } + + if ($this->parameters->importMode->shallSkipDuplicates()) { + Logs::notice(__METHOD__, __LINE__, 'Skipped upload of existing photo because skipDuplicates is activated'); + throw new PhotoSkippedException('This photo has been skipped because it\'s already in your library.'); + } else { + // Duplicate the existing photo, this will also duplicate all + // size variants without actually duplicating physical files + $existing = $this->photo; + $this->photo = $existing->replicate(); + // Adopt settings of duplicated photo acc. to target album + $this->photo->is_public = $this->parameters->is_public; + $this->photo->is_starred = $this->parameters->is_starred; + $this->setParentAndOwnership(); + $this->photo->save(); + } + + return $this->photo; + } +} diff --git a/app/Actions/Photo/Strategies/AddPhotoPartnerStrategy.php b/app/Actions/Photo/Strategies/AddPhotoPartnerStrategy.php new file mode 100644 index 00000000000..3cc391215e3 --- /dev/null +++ b/app/Actions/Photo/Strategies/AddPhotoPartnerStrategy.php @@ -0,0 +1,48 @@ +existingVideo = $existingVideo; + } + + public function do(): Photo + { + // First add the source file as if it was a stand-alone photo + // This creates and persists $this->photo as a new DB entry + parent::do(); + + // Now we re-use the same strategy as if the freshly created photo + // entity had been uploaded first and as if the already existing video + // had been uploaded after that. + // We use the original size variant of the video as the "source file" + // We request that the "imported" file shall be deleted, this actually + // "steals away" the stored video file from the existing video entity + // and moves it to the correct destination of a live partner for the + // photo. + $parameters = new AddStrategyParameters(new ImportMode(true)); + $parameters->sourceFileInfo = new SourceFileInfo( + $this->existingVideo->title, + $this->existingVideo->type, + $this->existingVideo->size_variants->getOriginal()->full_path + ); + $videoStrategy = new AddVideoPartnerStrategy($parameters, $this->photo); + $videoStrategy->do(); + + // Delete the existing video from whom we have stolen the video file + // `delete()` also takes care of erasing all other size variants + // from storage + $this->existingVideo->delete(); + + return $this->photo; + } +} diff --git a/app/Actions/Photo/Strategies/AddStandaloneStrategy.php b/app/Actions/Photo/Strategies/AddStandaloneStrategy.php new file mode 100644 index 00000000000..b834141f7aa --- /dev/null +++ b/app/Actions/Photo/Strategies/AddStandaloneStrategy.php @@ -0,0 +1,185 @@ +updateTimestamps(); + parent::__construct($parameters, $newPhoto); + } + + public function do(): Photo + { + // Create and save "bare" photo object without size variants + $this->hydrateMetadata(); + $this->photo->is_public = $this->parameters->is_public; + $this->photo->is_starred = $this->parameters->is_starred; + $this->setParentAndOwnership(); + $this->photo->save(); + + // Initialize factory for size variants + /** @var SizeVariantNamingStrategy $namingStrategy */ + $namingStrategy = resolve(SizeVariantNamingStrategy::class); + $namingStrategy->setFallbackExtension( + $this->parameters->sourceFileInfo->getOriginalFileExtension() + ); + /** @var SizeVariantFactory $sizeVariantFactory */ + $sizeVariantFactory = resolve(SizeVariantFactory::class); + $sizeVariantFactory->init($this->photo, $namingStrategy); + + // Create size variant for original + $original = $sizeVariantFactory->createOriginal( + $this->parameters->info['width'], + $this->parameters->info['height'] + ); + $this->putSourceIntoFinalDestination($original->full_path); + // The orientation can only be normalized after the source file has + // been put into its final destination, because we need an actual file + // which can be rotated if we import the source file from another + // directory on the server (i.e. file copy) or if we import the source + // from a link (i.e. file download), + $this->normalizeOrientation($original); + + // Create remaining size variants + try { + $sizeVariantFactory->createSizeVariants(); + } catch (\Throwable $t) { + // Don't re-throw the exception, because we do not want the + // import to fail completely only due to missing size variants. + // There are just too many options why the creation of size + // variants may fail: the user has uploaded an unsupported file + // format, GD and Imagick are both not available or disabled + // by configuration, etc. + Logs::error(__METHOD__, __LINE__, 'Failed to generate size variants, error was ' . $t->getMessage()); + } + + $this->handleGoogleMotionPicture(); + + // Clean up factory + $sizeVariantFactory->cleanup(); + + return $this->photo; + } + + /** + * Correct orientation of original size variant based on EXIF data. + * + * The method does not actual modify the underlying file if it is only a + * symlink. + * This method also updated the attributes {@link SizeVariant::$width}, + * {@link SizeVariant::$height} and {@link Photo::$filesize} to the new + * values after rotation. + * + * @param SizeVariant $original the original size variant + */ + protected function normalizeOrientation(SizeVariant $original): void + { + $orientation = $this->parameters->info['orientation']; + $fullPath = $original->full_path; + if ($this->photo->type === 'image/jpeg' && $orientation != 1) { + // If we are importing via symlink, we don't actually overwrite + // the source but we still need to fix the dimensions. + /** @var ImageHandlerInterface $imageHandler */ + $imageHandler = resolve(ImageHandlerInterface::class); + $newDim = $imageHandler->autoRotate( + $fullPath, + $orientation, + $this->parameters->importMode->shallImportViaSymlink() + ); + + if ($newDim !== [false, false]) { + $original->width = $newDim['width']; + $original->height = $newDim['height']; + $original->save(); + // If the image has actually been rotated, the size + // and the checksum may have changed. + /* @var Extractor $metadataExtractor */ + $metadataExtractor = resolve(Extractor::class); + $this->photo->filesize = $metadataExtractor->filesize($fullPath); + $this->photo->checksum = $metadataExtractor->checksum($fullPath); + $this->photo->save(); + } + } + + // Set original date + if ($this->parameters->info['taken_at'] !== null) { + @touch($fullPath, $this->parameters->info['taken_at']->getTimestamp()); + } + } + + protected function handleGoogleMotionPicture(): void + { + if (empty($this->parameters->info['MicroVideoOffset'])) { + return; + } + + $videoLengthBytes = intval($this->parameters->info['MicroVideoOffset']); + $original = $this->photo->size_variants->getOriginal(); + $shortPathPhoto = $original->short_path; + $fullPathPhoto = $original->full_path; + $shortPathVideo = pathinfo($shortPathPhoto, PATHINFO_FILENAME) . '.mov'; + $fullPathVideo = pathinfo($fullPathPhoto, PATHINFO_FILENAME) . '.mov'; + + try { + // 1. Extract the video part + $fp = fopen($fullPathPhoto, 'r'); + $fp_video = tmpfile(); // use a temporary file, will be deleted once closed + + // The MP4 file is located in the last bytes of the file + fseek($fp, -1 * $videoLengthBytes, SEEK_END); // It needs to be negative + $data = fread($fp, $videoLengthBytes); + fwrite($fp_video, $data, $videoLengthBytes); + + // 2. Convert file from mp4 to mov, but keeping audio and video codec + // This is needed to LivePhotosKit which only accepts mov files + // Computation is fast, since codecs, resolution, framerate etc. remain unchanged + + /** + * ! check if we can use path instead of this ugly thing. + */ + $ffmpeg = FFMpeg::create(); + $video = $ffmpeg->open(stream_get_meta_data($fp_video)['uri']); + $format = new MOVFormat(); + // Add additional parameter to extract the first video stream + $format->setAdditionalParameters(['-map', '0:0']); + $video->save($format, $fullPathVideo); + + // 3. Close files ($fp_video will be again deleted) + fclose($fp); + fclose($fp_video); + + // Save file path; Checksum calculation not needed since + // we do not perform matching for Google Motion Photos (as for iOS Live Photos) + $this->photo->live_photo_short_path = $shortPathVideo; + $this->photo->save(); + } catch (\Throwable $e) { + Logs::error(__METHOD__, __LINE__, $e->getMessage()); + throw new \RuntimeException('unable to extract video from Google Motion Picture', 0, $e); + } + } +} diff --git a/app/Actions/Photo/Strategies/AddStrategyParameters.php b/app/Actions/Photo/Strategies/AddStrategyParameters.php new file mode 100644 index 00000000000..880fb9abf03 --- /dev/null +++ b/app/Actions/Photo/Strategies/AddStrategyParameters.php @@ -0,0 +1,29 @@ +importMode = $importMode ?: new ImportMode(false, + Configs::get_value('skip_duplicates', '0') === '1' + ); + } +} diff --git a/app/Actions/Photo/Strategies/AddVideoPartnerStrategy.php b/app/Actions/Photo/Strategies/AddVideoPartnerStrategy.php new file mode 100644 index 00000000000..d165c7ab762 --- /dev/null +++ b/app/Actions/Photo/Strategies/AddVideoPartnerStrategy.php @@ -0,0 +1,26 @@ +photo->size_variants->getOriginal(); + $ext = $this->parameters->sourceFileInfo->getOriginalFileExtension(); + $dstShortPath = substr($original->short_path, 0, -strlen($ext)) . $ext; + $dstFullPath = substr($original->full_path, 0, -strlen($ext)) . $ext; + $this->putSourceIntoFinalDestination($dstFullPath); + $this->photo->live_photo_short_path = $dstShortPath; + $this->photo->save(); + + return $this->photo; + } +} diff --git a/app/Actions/Photo/Strategies/ImportMode.php b/app/Actions/Photo/Strategies/ImportMode.php new file mode 100644 index 00000000000..a2ba951ad7d --- /dev/null +++ b/app/Actions/Photo/Strategies/ImportMode.php @@ -0,0 +1,71 @@ +setMode( + $deleteImported, $skipDuplicates, $importViaSymlink, $resyncMetadata + ); + } + + public function setMode( + bool $deleteImported = false, + bool $skipDuplicates = false, + bool $importViaSymlink = false, + bool $resyncMetadata = false + ): void { + $this->deleteImported = $deleteImported; + $this->skipDuplicates = $skipDuplicates; + $this->importViaSymlink = $importViaSymlink; + $this->resyncMetadata = $resyncMetadata; + // avoid incompatible settings (delete originals takes precedence over symbolic links) + if ($deleteImported) { + $this->importViaSymlink = false; + } + // (re-syncing metadata makes no sense when importing duplicates) + if (!$skipDuplicates) { + $this->resyncMetadata = false; + } + } + + public function setDeleteImported(bool $flag): void + { + $this->deleteImported = $flag; + // avoid incompatible settings (delete originals takes precedence over symbolic links) + if ($this->deleteImported) { + $this->importViaSymlink = false; + } + } + + public function shallDeleteImported(): bool + { + return $this->deleteImported; + } + + public function shallSkipDuplicates(): bool + { + return $this->skipDuplicates; + } + + public function shallImportViaSymlink(): bool + { + return $this->importViaSymlink; + } + + public function shallResyncMetadata(): bool + { + return $this->resyncMetadata; + } +} diff --git a/app/Actions/Photo/Strategies/RotateStrategy.php b/app/Actions/Photo/Strategies/RotateStrategy.php new file mode 100644 index 00000000000..284530049c9 --- /dev/null +++ b/app/Actions/Photo/Strategies/RotateStrategy.php @@ -0,0 +1,187 @@ +isVideo()) { + $msg = 'Trying to rotate a video'; + Logs::error(__METHOD__, __LINE__, $msg); + throw new \InvalidArgumentException($msg); + } + if ($photo->live_photo_short_path !== null) { + $msg = 'Trying to rotate a live photo'; + Logs::error(__METHOD__, __LINE__, $msg); + throw new \InvalidArgumentException($msg); + } + if ($photo->isRaw()) { + $msg = 'Trying to rotate a raw file'; + Logs::error(__METHOD__, __LINE__, $msg); + throw new \InvalidArgumentException($msg); + } + // direction is valid? + if (($direction != 1) && ($direction != -1)) { + $msg = 'Direction must be 1 or -1'; + Logs::error(__METHOD__, __LINE__, $msg); + throw new \InvalidArgumentException($msg); + } + $this->direction = $direction; + } + + public function do(): Photo + { + // Generate a temporary name for the rotated file. + $oldOriginalSizeVariant = $this->photo->size_variants->getOriginal(); + $oldOriginalFullPath = $oldOriginalSizeVariant->full_path; + $oldOriginalWidth = $oldOriginalSizeVariant->width; + $oldOriginalHeight = $oldOriginalSizeVariant->height; + $oldChecksum = $this->photo->checksum; + $oldExtension = Helpers::getExtension($oldOriginalFullPath); + $tmpFullPath = Helpers::createTemporaryFile($oldExtension); + + // Rotate the image and save result as the temporary file + /** @var ImageHandlerInterface $imageHandler */ + $imageHandler = resolve(ImageHandlerInterface::class); + if ($imageHandler->rotate($oldOriginalFullPath, ($this->direction == 1) ? 90 : -90, $tmpFullPath) === false) { + $msg = 'Failed to rotate ' . $oldOriginalFullPath; + Logs::error(__METHOD__, __LINE__, $msg); + throw new \RuntimeException($msg); + } + + // The file size and checksum may have changed after the rotation. + /* @var Extractor $metadataExtractor */ + $metadataExtractor = resolve(Extractor::class); + $this->photo->filesize = $metadataExtractor->filesize($tmpFullPath); + $this->photo->checksum = $metadataExtractor->checksum($tmpFullPath); + $this->photo->save(); + + // Delete all size variants from current photo, this will also take + // care of erasing the actual "physical" files from storage and any + // potential symbolic link which points to one of the original files. + // This will bring photo entity into the same state as it would be if + // we were importing a new photo. + $this->photo->size_variants->deleteAll(); + + // Initialize factory for size variants + $this->parameters->sourceFileInfo = new SourceFileInfo( + pathinfo($oldOriginalFullPath, PATHINFO_BASENAME), + $this->photo->type, + $tmpFullPath + ); + /** @var SizeVariantNamingStrategy $namingStrategy */ + $namingStrategy = resolve(SizeVariantNamingStrategy::class); + $namingStrategy->setFallbackExtension($this->parameters->sourceFileInfo->getOriginalFileExtension()); + /** @var SizeVariantFactory $sizeVariantFactory */ + $sizeVariantFactory = resolve(SizeVariantFactory::class); + $sizeVariantFactory->init($this->photo, $namingStrategy); + + // Create size variant for rotated original + // Note that this also creates a different file name than before + // because the checksum of the photo has changed. + // Using a different filename allows to avoid caching effects. + // Sic! Swap width and height here, because the image has been rotated + $newOriginalSizeVariant = $sizeVariantFactory->createOriginal($oldOriginalHeight, $oldOriginalWidth); + $this->putSourceIntoFinalDestination($newOriginalSizeVariant->full_path); + + // Create remaining size variants + $newSizeVariants = null; + try { + $newSizeVariants = $sizeVariantFactory->createSizeVariants(); + // add new original size variant to collection of newly created + // size variants; we need this to correctly update the duplicates + // below + $newSizeVariants->add($newOriginalSizeVariant); + } catch (\Throwable $t) { + // Don't re-throw the exception, because we do not want the + // import to fail completely only due to missing size variants. + // There are just too many options why the creation of size + // variants may fail: the user has uploaded an unsupported file + // format, GD and Imagick are both not available or disabled + // by configuration, etc. + Logs::error(__METHOD__, __LINE__, 'Failed to generate size variants, error was ' . $t->getMessage()); + } + + // Clean up factory + $sizeVariantFactory->cleanup(); + + // Deal with duplicates. We simply update all of them to match. + $duplicates = Photo::query() + ->where('checksum', '=', $oldChecksum) + ->get(); + /** @var Photo $duplicate */ + foreach ($duplicates as $duplicate) { + $duplicate->filesize = $this->photo->filesize; + $duplicate->checksum = $this->photo->checksum; + // Note: It is not correct to simply update the existing size + // variants of the duplicates. + // Due to rotation the number and type of size variants may have + // changed, too. + // So we actually have to do a 3-way merge and update: + // a) delete size variants of the duplicates which do not exist + // anymore, b) update size variants of the duplicates which + // still exist and c) add new size variants to duplicates which + // haven't existed before. + // For simplicity, we simply delete all size variants of the + // duplicates and re-create them. + // Deleting the size variants of the duplicates has also the + // advantage that the actual files are erased from storage. + $duplicate->size_variants->deleteAll(); + if ($newSizeVariants) { + /** @var SizeVariant $newSizeVariant */ + foreach ($newSizeVariants as $newSizeVariant) { + $duplicate->size_variants->create( + $newSizeVariant->type, + $newSizeVariant->short_path, + $newSizeVariant->width, + $newSizeVariant->height + ); + } + } + $duplicate->save(); + } + + return $this->photo; + } +} diff --git a/app/Actions/Photo/Strategies/StrategyDuplicate.php b/app/Actions/Photo/Strategies/StrategyDuplicate.php deleted file mode 100644 index 1fa522b1e69..00000000000 --- a/app/Actions/Photo/Strategies/StrategyDuplicate.php +++ /dev/null @@ -1,97 +0,0 @@ -skip_duplicates = $skip_duplicates; - $this->resync_metadata = $resync_metadata; - $this->delete_imported = $delete_imported; - } - - public function storeFile(Create $create) - { - Logs::notice(__METHOD__, __LINE__, 'Nothing to store, image is a duplicate'); - } - - public function hydrate(Create &$create, ?Photo &$existing = null, ?array $file = null) - { - $create->photo_Url = $existing->url; - $create->path = Storage::path($create->path_prefix . $existing->url); - $create->photo->thumbUrl = $existing->thumbUrl; - $create->photo->thumb2x = $existing->thumb2x; - $create->photo->medium_width = $existing->medium_width; - $create->photo->medium_height = $existing->medium_height; - $create->photo->medium2x_width = $existing->medium2x_width; - $create->photo->medium2x_height = $existing->medium2x_height; - $create->photo->small_width = $existing->small_width; - $create->photo->small_height = $existing->small_height; - $create->photo->small2x_width = $existing->small2x_width; - $create->photo->small2x_height = $existing->small2x_height; - $create->photo->livePhotoUrl = $existing->livePhotoUrl; - $create->photo->livePhotoChecksum = $existing->livePhotoChecksum; - $create->photo->checksum = $existing->checksum; - $create->photo->type = $existing->type; - $create->mimeType = $create->photo->type; - - // Photo already exists - // Check if the user wants to skip duplicates - if ($this->skip_duplicates) { - $metadataChanged = false; - - // Before we skip entirely, check if there is a sidecar file and if the metadata needs to be updated (from a sidecar) - if ($this->resync_metadata === true) { - $info = $this->getMetadata($file, $create->path, $create->kind, $create->extension); - $attr = $existing->attributesToArray(); - foreach ($info as $key => $value) { - if (array_key_exists($key, $attr) // check if key exists, even if null - && (($existing->$key !== null && $value !== $existing->$key) || ($existing->$key === null && $value !== null && $value !== '')) - && $value != $existing->$key) { // avoid false positives when comparing variables of different types (e.g string vs int) - $metadataChanged = true; - $existing->$key = $value; - } - } - } - - if ($metadataChanged === true) { - Logs::notice(__METHOD__, __LINE__, 'Updating metdata of existing photo.'); - $existing->save(); - - $res = new PhotoResyncedException('This photo has been skipped because it\'s already in your library, but its metadata has been updated.'); - } else { - Logs::notice(__METHOD__, __LINE__, 'Skipped upload of existing photo because skipDuplicates is activated'); - - $res = new PhotoSkippedException('This photo has been skipped because it\'s already in your library.'); - } - - if ($this->delete_imported && !$create->is_uploaded) { - @unlink($create->tmp_name); - } - - throw $res; - } - //? else we do not skip duplicate and continue. - } - - public function generate_thumbs(Create &$create, bool &$skip_db_entry_creation, bool &$no_error) - { - Logs::notice(__METHOD__, __LINE__, 'Nothing to generate, image is a duplicate'); - } -} diff --git a/app/Actions/Photo/Strategies/StrategyPhoto.php b/app/Actions/Photo/Strategies/StrategyPhoto.php deleted file mode 100644 index 9f5bdc69bb8..00000000000 --- a/app/Actions/Photo/Strategies/StrategyPhoto.php +++ /dev/null @@ -1,154 +0,0 @@ -imageHandler = app(ImageHandlerInterface::class); - $this->import_via_symlink = $import_via_symlink; - } - - public function storeFile(Create $create) - { - // Import if not uploaded via web - if (!$create->is_uploaded) { - // TODO: use the storage facade here - // Check if the user wants to create symlinks instead of copying the photo - if ($this->import_via_symlink) { - if (!symlink($create->tmp_name, $create->path)) { - // @codeCoverageIgnoreStart - Logs::error(__METHOD__, __LINE__, 'Could not create symlink'); - - throw new JsonError('Could not create symlink!'); - // @codeCoverageIgnoreEnd - } - } elseif (!@copy($create->tmp_name, $create->path)) { - // @codeCoverageIgnoreStart - Logs::error(__METHOD__, __LINE__, 'Could not copy photo to uploads'); - - throw new JsonError('Could not copy photo to uploads!'); - // @codeCoverageIgnoreEnd - } - } else { - // TODO: use the storage facade here - if (!@move_uploaded_file($create->tmp_name, $create->path)) { - Logs::error(__METHOD__, __LINE__, 'Could not move photo to uploads'); - - throw new JsonError('Could not move photo to uploads!'); - } - } - } - - public function hydrate(Create &$create, ?Photo &$existing = null, ?array $file = null) - { - // do nothing. - } - - public function generate_thumbs(Create &$create, bool &$skip_db_entry_creation, bool &$no_error) - { - // Generate small files for 2 options: - // (1) There is no Live Photo Partner - // (2) There is a partner and we're uploading a photo - if (($create->livePhotoPartner == null) || !(in_array($create->photo->type, $create->validVideoTypes, true))) { - // Set orientation based on EXIF data - // but do not rotate if the image shall not be modified - if ( - $create->photo->type === 'image/jpeg' - && isset($create->info['orientation']) - && $create->info['orientation'] !== '' - ) { - // If we are importing via symlink, we don't actually overwrite - // the source but we still need to fix the dimensions. - $pretend = (!$create->is_uploaded && $this->import_via_symlink); - $rotation = $this->imageHandler->autoRotate($create->path, $create->info, $pretend); - - if ($rotation !== [false, false]) { - $create->photo->width = $rotation['width']; - $create->photo->height = $rotation['height']; - - if (!$pretend) { - // If the image was rotated, the size may have changed. - /* @var Extractor $metadataExtractor */ - $metadataExtractor = resolve(Extractor::class); - $create->photo->filesize = $metadataExtractor->filesize($create->path); - } - } - } - - // Set original date - if ($create->info['taken_at'] !== null) { - @touch($create->path, $create->info['taken_at']->getTimestamp()); - } - - // For videos extract a frame from the middle - $frame_tmp = ''; - if (in_array($create->photo->type, $create->validVideoTypes, true)) { - try { - $frame_tmp = $this->extractVideoFrame($create->photo); - } catch (Exception $exception) { - Logs::error(__METHOD__, __LINE__, $exception->getMessage()); - } - } - - if ($create->kind == 'raw') { - try { - $frame_tmp = $this->createJpgFromRaw($create->photo); - } catch (Exception $exception) { - Logs::error(__METHOD__, __LINE__, $exception->getMessage()); - } - } - - // Create Thumb - if ($create->kind == 'raw' && $frame_tmp == '') { - $create->photo->thumbUrl = ''; - $create->photo->thumb2x = 0; - } elseif (!in_array($create->photo->type, $create->validVideoTypes, true) || $frame_tmp !== '') { - if (!$this->createThumb($create->photo, $frame_tmp)) { - Logs::error(__METHOD__, __LINE__, 'Could not create thumbnail for photo'); - - throw new JsonError('Could not create thumbnail for photo!'); - } - - $create->photo->thumbUrl = basename($create->photo_Url, $create->extension) . '.jpeg'; - - $this->createSmallerImages($create->photo, $frame_tmp); - - //? GoogleMicroVideoOffset - if ($create->info['MicroVideoOffset']) { - $this->extractVideo($create->photo, $create->info['MicroVideoOffset'], $frame_tmp); - } - - if ($frame_tmp !== '') { - unlink($frame_tmp); - } - } else { - $create->photo->thumbUrl = ''; - $create->photo->thumb2x = 0; - } - } else { - // We're uploading a video -> overwrite everything from partner - $create->livePhotoPartner->livePhotoUrl = $create->photo->url; - $create->livePhotoPartner->livePhotoChecksum = $create->photo->checksum; - $no_error &= $create->livePhotoPartner->save(); - $skip_db_entry_creation = true; - } - } -} diff --git a/app/Actions/Photo/Strategies/StrategyPhotoBase.php b/app/Actions/Photo/Strategies/StrategyPhotoBase.php deleted file mode 100644 index 9fd3442372d..00000000000 --- a/app/Actions/Photo/Strategies/StrategyPhotoBase.php +++ /dev/null @@ -1,104 +0,0 @@ -getMetadata($file, $create->path, $create->kind, $create->extension); - - $create->photo->title = $info['title']; - $create->photo->url = $create->photo_Url; - $create->photo->description = $info['description']; - $create->photo->tags = $info['tags']; - $create->photo->width = $info['width'] ? $info['width'] : 0; - $create->photo->height = $info['height'] ? $info['height'] : 0; - $create->photo->type = ($info['type'] ? $info['type'] : $create->mimeType); - $create->photo->filesize = $info['filesize']; - $create->photo->iso = $info['iso']; - $create->photo->aperture = $info['aperture']; - $create->photo->make = $info['make']; - $create->photo->model = $info['model']; - $create->photo->lens = $info['lens']; - $create->photo->shutter = $info['shutter']; - $create->photo->focal = $info['focal']; - $create->photo->taken_at = $info['taken_at']; - $create->photo->latitude = $info['latitude']; - $create->photo->longitude = $info['longitude']; - $create->photo->altitude = $info['altitude']; - $create->photo->imgDirection = $info['imgDirection']; - $create->photo->location = $info['location']; - $create->photo->livePhotoContentID = $info['livePhotoContentID']; - $create->photo->public = $create->public; - $create->photo->star = $create->star; - - $create->info = $info; - } - - public function getMetadata($file, $path, $kind, $extension): array - { - // forward call to trait. - return $this->getFileMetadata($file, $path, $kind, $extension); - } - - public function setParentAndOwnership(Create &$create) - { - if ($create->parentAlbum !== null) { - $create->photo->album_id = $create->albumID; - $create->photo->owner_id = $create->parentAlbum->owner_id; - } else { - $create->photo->album_id = null; - $create->photo->owner_id = AccessControl::id(); - } - } - - public function findLivePartner(Create &$create) - { - $livePhotoPartner = null; - if ($create->photo->livePhotoContentID) { - // Todo: We need to search for pairs (Video + Photo) - // Photo+Photo or Video+Video does not work - - $livePhotoPartner = Photo::query() - ->where('livePhotoContentID', '=', $create->photo->livePhotoContentID) - ->where('album_id', '=', $create->photo->album_id) - ->whereNull('livePhotoUrl')->first(); - } - - if ($livePhotoPartner != null) { - // if both are a photo or a video -> it's not a live photo - if (in_array($create->photo->type, $create->validVideoTypes, true) === in_array($livePhotoPartner->type, $create->validVideoTypes, true)) { - $livePhotoPartner = null; - } - } - - if ($livePhotoPartner != null) { - // I'm uploading a photo, video already exists - if (!(in_array($create->photo->type, $create->validVideoTypes, true))) { - $create->photo->livePhotoUrl = $create->livePhotoPartner->url; - $create->photo->livePhotoChecksum = $create->livePhotoPartner->checksum; - // Todo: Delete the livePhotoPartner - - $create->livePhotoPartner->predelete(true); - $create->livePhotoPartner->delete(); - } - } - - $create->livePhotoPartner = $livePhotoPartner; - } -} diff --git a/app/Actions/Photo/SymLinker.php b/app/Actions/Photo/SymLinker.php deleted file mode 100644 index 8addde5d655..00000000000 --- a/app/Actions/Photo/SymLinker.php +++ /dev/null @@ -1,16 +0,0 @@ -symLinkFunctions = $symLinkFunctions; - } -} diff --git a/app/Actions/Photo/Toggle.php b/app/Actions/Photo/Toggle.php index 2fbdcbacda1..fd3615b689a 100644 --- a/app/Actions/Photo/Toggle.php +++ b/app/Actions/Photo/Toggle.php @@ -13,7 +13,7 @@ */ class Toggle { - public $property; + public string $property; public function do(string $photoID): bool { diff --git a/app/Actions/Photo/Toggles.php b/app/Actions/Photo/Toggles.php index 25e95c065c3..b3778088820 100644 --- a/app/Actions/Photo/Toggles.php +++ b/app/Actions/Photo/Toggles.php @@ -3,8 +3,6 @@ namespace App\Actions\Photo; use App\Models\Photo; -use Illuminate\Database\QueryException; -use Illuminate\Support\Facades\DB; /** * This class toggle a boolean property of a MULTIPLE photos at the same time. @@ -14,21 +12,15 @@ */ class Toggles { - public $property; + public string $property; public function do(array $photoIDs): bool { - try { - //! DB::raw is safe because WE (dev) have control over $property. It is not influced by user inputs. - $no_error = Photo::whereIn('id', $photoIDs)->update([$this->property => DB::raw('1 XOR `' . $this->property . '`')]); - } catch (QueryException $e) { - // for Sqlite we need the slow approach - $photos = Photo::whereIn('id', $photoIDs)->get(); - $no_error = true; - foreach ($photos as $photo) { - $photo->{$this->property} = $photo->{$this->property} != 1 ? 1 : 0; - $no_error &= $photo->save(); - } + $photos = Photo::query()->whereIn('id', $photoIDs)->get(); + $no_error = true; + foreach ($photos as $photo) { + $photo->{$this->property} = !($photo->{$this->property}); + $no_error &= $photo->save(); } return $no_error; diff --git a/app/Actions/PhotoAuthorisationProvider.php b/app/Actions/PhotoAuthorisationProvider.php new file mode 100644 index 00000000000..cc215ba63df --- /dev/null +++ b/app/Actions/PhotoAuthorisationProvider.php @@ -0,0 +1,297 @@ +albumAuthorisationProvider = resolve(AlbumAuthorisationProvider::class); + } + + /** + * Restricts a photo query to _visible_ photos. + * + * A photo is called _visible_ if the current user is allowed to see the + * photo. + * A photo is _visible_ if any of the following conditions hold + * (OR-clause): + * + * - the user is the admin + * - the user is the owner of the photo + * - the photo is part of an album which the user is allowed to access + * (cp. {@link AlbumAuthorisationProvider::applyAccessibilityFilter()}. + * - the photo is public + * + * @param Builder $query + * + * @return Builder + */ + public function applyVisibilityFilter(Builder $query): Builder + { + $this->prepareModelQueryOrFail($query, false, true, true); + + if (AccessControl::is_admin()) { + return $query; + } + + $userID = AccessControl::is_logged_in() ? AccessControl::id() : null; + + // We must wrap everything into an outer query to avoid any undesired + // effects in case that the original query already contains an + // "OR"-clause. + $visibilitySubQuery = function (Builder $query2) use ($userID) { + $this->albumAuthorisationProvider->appendAccessibilityConditions($query2->getQuery()); + $query2->orWhere('photos.is_public', '=', true); + if ($userID !== null) { + $query2->orWhere('photos.owner_id', '=', $userID); + } + }; + + return $query->where($visibilitySubQuery); + } + + /** + * Checks whether the photo is visible by the current user. + * + * See {@link PhotoAuthorisationProvider::applyVisibilityFilter()} for a + * specification of the rules when a photo is visible. + * + * @param string $photoID + * + * @return bool + */ + public function isVisible(string $photoID): bool + { + if (AccessControl::is_admin()) { + return true; + } + + // We use `applyVisibilityFilter` to build a query, but don't hydrate + // a model + return $this->applyVisibilityFilter( + Photo::query()->where('photos.id', '=', $photoID) + )->count() !== 0; + } + + /** + * Restricts a photo query to _searchable_ photos. + * + * A photo is _searchable_ if at least one of the following conditions + * hold: + * + * - the photo is part of an album which the user is allowed to browse + * - the user is the owner of the photo + * - the photo is public and searching through public photos is enabled + * + * See {@link AlbumAuthorisationProvider::applyBrowsabilityFilter()} + * for a definition of a browsable album. + * + * The search result is restricted to photos in albums which are below + * `$origin`. + * + * **Attention**: + * For efficiency reasons this method does not check if `$origin` itself + * is accessible. + * The method simply assumes that the user has already legitimately + * accessed the origin album, if the caller provides an album model. + * + * @param Builder $query the photo query which shall be restricted + * @param Album|null $origin the optional top album which is used as a search base + * + * @return Builder the restricted photo query + */ + public function applySearchabilityFilter(Builder $query, ?Album $origin = null): Builder + { + $this->prepareModelQueryOrFail($query, true, false, false); + + // If origin is set, also restrict the search result for admin + // to photos which are in albums below origin. + // This is not a security filter, but simply functional. + if ($origin) { + $query + ->where('albums._lft', '>=', $origin->_lft) + ->where('albums._rgt', '<=', $origin->_rgt); + } + + if (AccessControl::is_admin()) { + return $query; + } else { + return $query->where(function (Builder $query) use ($origin) { + $this->appendSearchabilityConditions( + $query->getQuery(), + $origin?->_lft, + $origin?->_rgt + ); + }); + } + } + + /** + * Adds the conditions of _searchable_ photos to the query. + * + * **Attention:** This method is only meant for internal use. + * Use {@link PhotoAuthorisationProvider::applySearchabilityFilter()} + * if called from other places instead. + * + * This method adds the WHERE conditions without any further pre-cautions. + * The method silently assumes that the SELECT clause contains the tables + * + * - **`albums`**. + * + * See {@link AlbumAuthorisationProvider::applySearchabilityFilter()} + * for a definition of a searchable photo. + * + * Moreover, the raw clauses are added. + * They are not wrapped into a nesting braces `()`. + * + * @param BaseBuilder $query the photo query which shall be + * restricted + * @param int|string|null $originLeft optionally constraints the search + * base; an integer value is + * interpreted a raw left bound of the + * search base; a string value is + * interpreted as a reference to a + * column which shall be used as a + * left bound + * @param int|string|null $originRight like `$originLeft` but for the + * right bound + * + * @return Builder the restricted photo query + */ + public function appendSearchabilityConditions(BaseBuilder $query, int|string|null $originLeft, int|string|null $originRight): BaseBuilder + { + $userID = AccessControl::is_logged_in() ? AccessControl::id() : null; + $maySearchPublic = Configs::get_value('public_photos_hidden', '1') !== '1'; + + // there must be no unreachable album between the origin and the photo + $query->whereNotExists(function (BaseBuilder $q) use ($originLeft, $originRight) { + $this->albumAuthorisationProvider->appendUnreachableAlbumsCondition($q, $originLeft, $originRight); + }); + + // Special care needs to be taken for unsorted photo, i.e. photos on + // the root level: + // The condition for "no unreachable albums along the path" fails for + // the root album due to two reasons: + // a) the path of albums between to the root album is empty; hence, + // there are never any unreachable albums in between + // b) while all users (even unauthenticated users) may access the + // root album, they must only see their own photos or public + // photos (this is different to any other album: if users are + // allowed to access an album, they may also see its content) + $query->whereNotNull('photos.album_id'); + + if ($maySearchPublic) { + $query->orWhere('photos.is_public', '=', true); + } + if ($userID !== null) { + $query->orWhere('photos.owner_id', '=', $userID); + } + + return $query; + } + + /** + * Checks whether the photos with the given IDs are editable by the + * current user. + * + * A photo is called _editable_ if the current user is allowed to edit + * the photo's properties. + * A photo is _editable_ if any of the following conditions hold + * (OR-clause) + * + * - the user is an admin + * - the user is the owner of the photo + * + * @param string[] $photoIDs + * + * @return bool + */ + public function areEditable(array $photoIDs): bool + { + if (AccessControl::is_admin()) { + return true; + } + if (!AccessControl::is_logged_in()) { + return false; + } + + $userID = AccessControl::id(); + // Since we count the result we need to ensure that there are no + // duplicates. + // Also remove the `null` photo. It gets a pass. + // This case may happen, if a user sets `null` as a cover. + $photoIDs = array_diff(array_unique($photoIDs), [null]); + if (count($photoIDs) > 0) { + return Photo::query() + ->whereIn('photos.id', $photoIDs) + ->where('photos.owner_id', '=', $userID) + ->count() === count($photoIDs); + } + + return true; + } + + /** + * Throws an exception if the given query does not query for a photo. + * + * @param Builder $query the query to prepare + * @param bool $addAlbums if true, joins photo query with (parent) albums + * @param bool $addBaseAlbums if true, joins photos query with (parent) base albums + * @param bool $addShares if true, joins photo query with user share table of (parent) album + */ + private function prepareModelQueryOrFail(Builder $query, bool $addAlbums, bool $addBaseAlbums, bool $addShares): void + { + $model = $query->getModel(); + $table = $query->getQuery()->from; + if (!($model instanceof Photo && $table === 'photos')) { + throw new \InvalidArgumentException('the given query does not query for photos'); + } + + // We must only add the share, i.e. left join with `user_base_album`, + // if and only if we restrict the eventual query to the ID of the + // authenticated user by a `WHERE`-clause. + // If we were doing a left join unconditionally, then some + // photos might appear multiple times as part of the result + // because the parent album of a photo might be shared with more than + // one user. + // Hence, we must restrict the `LEFT JOIN` to the user ID which + // is also used in the outer `WHERE`-clause. + // See `applyVisibilityFilter`. + $addShares = $addShares && AccessControl::is_logged_in(); + + // Ensure that only columns of the photos are selected, + // if no specific columns are yet set. + // Otherwise, we cannot add a JOIN clause below + // without accidentally adding all columns of the join, too. + if (empty($query->columns)) { + $query->select(['photos.*']); + } + if ($addAlbums) { + $query->leftJoin('albums', 'albums.id', '=', 'photos.album_id'); + } + if ($addBaseAlbums) { + $query->leftJoin('base_albums', 'base_albums.id', '=', 'photos.album_id'); + } + if ($addShares) { + $userID = AccessControl::id(); + $query->leftJoin('user_base_album', + function (JoinClause $join) use ($userID) { + $join + ->on('user_base_album.base_album_id', '=', 'base_albums.id') + ->where('user_base_album.user_id', '=', $userID); + } + ); + } + } +} diff --git a/app/Actions/RSS/Generate.php b/app/Actions/RSS/Generate.php index fef02c246b7..16987ef3d57 100644 --- a/app/Actions/RSS/Generate.php +++ b/app/Actions/RSS/Generate.php @@ -2,96 +2,60 @@ namespace App\Actions\RSS; -use App\Actions\Albums\Extensions\PublicIds; -use App\ModelFunctions\SymLinkFunctions; +use App\Actions\PhotoAuthorisationProvider; use App\Models\Configs; use App\Models\Photo; use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Storage; use Spatie\Feed\FeedItem; class Generate { - /** - * @var SymLinkFunctions - */ - private $symLinkFunctions; + protected PhotoAuthorisationProvider $photoAuthorisationProvider; - /** - * @param SymLinkFunctions $symLinkFunctions - */ - public function __construct( - SymLinkFunctions $symLinkFunctions - ) { - $this->symLinkFunctions = $symLinkFunctions; - } - - private function make_enclosure(array $photo_array) + public function __construct(PhotoAuthorisationProvider $photoAuthorisationProvider) { - $enclosure = new \stdClass(); - - $path = public_path($photo_array['url']); - $enclosure->length = File::size($path); - $enclosure->mime_type = File::mimeType($path); - $enclosure->url = url('/' . $photo_array['url']); - - return $enclosure; + $this->photoAuthorisationProvider = $photoAuthorisationProvider; } - private function create_link(Photo $photo_model, array &$photo_array) + private function create_link_to_page(Photo $photo_model): string { if ($photo_model->album_id != null) { - if (!$photo_model->album->is_full_photo_visible()) { - $photo_model->downgrade($photo_array); - } - - return '#' . $photo_model->album_id . '/' . $photo_model->id; + return url('/#' . $photo_model->album_id . '/' . $photo_model->id); } - if (Configs::get_value('full_photo', '1') != '1') { - $photo_model->downgrade($photo_array); - } - - return 'view?p=' . $photo_model->id; + return url('/view?p=' . $photo_model->id); } - private function toFeedItem(Photo $photo_model) + private function toFeedItem(Photo $photo_model): FeedItem { - $photo_array = $photo_model->toReturnArray(); - - $this->symLinkFunctions->getUrl($photo_model, $photo_array); - - $photo_array['url'] = $photo_array['url'] ?: ($photo_array['medium2x'] ?: $photo_array['medium']); - // TODO: this will need to be fixed for s3 and when the upload folder is NOT the Lychee folder. - $enclosure = $this->make_enclosure($photo_array); - - $id = $this->create_link($photo_model, $photo_array); + $page_link = $this->create_link_to_page($photo_model); + $sizeVariant = $photo_model->size_variants->getOriginal(); return FeedItem::create([ - 'id' => url('/' . $id), + 'id' => $page_link, 'title' => $photo_model->title, - 'summary' => $photo_model->description, + 'summary' => $photo_model->description ?? '', 'updated' => $photo_model->updated_at, - 'link' => $photo_array['url'], - 'enclosure' => $enclosure->url, - 'enclosureLength' => $enclosure->length, - 'enclosureType' => $enclosure->mime_type, - 'authorName' => $photo_model->owner->name(), + 'link' => $page_link, + 'enclosure' => $sizeVariant->url, + 'enclosureLength' => Storage::size($sizeVariant->short_path), + 'enclosureType' => $photo_model->type, + 'authorName' => $photo_model->owner->username, ]); } public function do() { - $publicIds = resolve(PublicIds::class)->getNotAccessible(); $rss_recent = intval(Configs::get_value('rss_recent_days', '7')); $rss_max = Configs::get_Value('rss_max_items', '100'); $nowMinus = Carbon::now()->subDays($rss_recent)->toDateTimeString(); - $photos = Photo::with('album', 'owner') - ->where('created_at', '>=', $nowMinus) - // we select photo which album IS PUBLICALLY ACCESSIBLE - // or PHOTO MARKED AS PUBLIC. - ->where(fn ($q) => $q->whereIn('album_id', $publicIds)->orWhere('public', '=', '1')) + $photos = $this->photoAuthorisationProvider + ->applySearchabilityFilter( + Photo::with('album', 'owner', 'size_variants', 'size_variants.sym_links') + ) + ->where('photos.created_at', '>=', $nowMinus) ->limit($rss_max) ->get(); diff --git a/app/Actions/ReadAccessFunctions.php b/app/Actions/ReadAccessFunctions.php deleted file mode 100644 index cb1add02442..00000000000 --- a/app/Actions/ReadAccessFunctions.php +++ /dev/null @@ -1,116 +0,0 @@ -owner_id)) { - return 1; // access granted - } - - // Check if the album is shared with us - if ( - AccessControl::is_logged_in() && - $album->shared_with->map(function ($user) { - return $user->id; - })->contains(AccessControl::id()) - ) { - return 1; // access granted - } - - if ( - !$album->is_public() || - ($obeyHidden && $album->viewable !== 1) - ) { - return 2; // Warning: Album private! - } - - if ($album->password == '') { - return 1; // access granted - } - - if (AccessControl::has_visible_album($album->id)) { - return 1; // access granted - } - - return 3; // Please enter password first. // Warning: Wrong password! - } - - /** - * Check if a (public) user has access to an album - * if 0 : album does not exist - * if 1 : access is granted - * if 2 : album is private - * if 3 : album is password protected and require user input. - * - * @param int|string $album: Album object or Album id - * @param bool obeyHidden - * - * @return int - */ - public function albumID($album, bool $obeyHidden = false): int - { - if (in_array($album, [ - 'starred', - 'public', - 'recent', - 'unsorted', - ])) { - if (AccessControl::is_logged_in() && AccessControl::can_upload()) { - return 1; - } - if (($album === 'recent' && Configs::get_value('public_recent', '0') === '1') || - ($album === 'starred' && Configs::get_value('public_starred', '0') === '1') - ) { - return 1; // access granted - } else { - return 2; // Warning: Album private! - } - } - - $album = Album::findOrFail($album); - - return $this->album($album, $obeyHidden); - } - - /** - * Check if a (public) user has access to a picture. - * - * @param Photo $photo - * - * @return bool - */ - public function photo(Photo $photo) - { - if (AccessControl::is_current_user($photo->owner_id)) { - return true; - } - if ($photo->public === 1) { - return true; - } - if ($this->albumID($photo->album_id) === 1) { - return true; - } - - return false; - } -} diff --git a/app/Actions/Search/AlbumSearch.php b/app/Actions/Search/AlbumSearch.php index 09a7ca4ea4c..1b5a115e091 100644 --- a/app/Actions/Search/AlbumSearch.php +++ b/app/Actions/Search/AlbumSearch.php @@ -2,36 +2,71 @@ namespace App\Actions\Search; -use App\Actions\Albums\Extensions\PublicIds; -use App\Facades\AccessControl; +use App\Actions\AlbumAuthorisationProvider; use App\Models\Album; +use App\Models\Configs; +use App\Models\Extensions\SortingDecorator; +use App\Models\TagAlbum; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; class AlbumSearch { - public function query(array $terms) + protected AlbumAuthorisationProvider $albumAuthorisationProvider; + + public function __construct(AlbumAuthorisationProvider $albumAuthorisationProvider) + { + $this->albumAuthorisationProvider = $albumAuthorisationProvider; + } + + public function query(array $terms): Collection + { + $sortingCol = Configs::get_value('sorting_Albums_col', 'created_at'); + $sortingOrder = Configs::get_value('sorting_Albums_order', 'ASC'); + + $tagAlbums = (new SortingDecorator($this->createTagAlbumQuery($terms))) + ->orderBy($sortingCol, $sortingOrder) + ->get(); + $albums = (new SortingDecorator($this->createAlbumQuery($terms))) + ->orderBy($sortingCol, $sortingOrder) + ->get(); + + return $tagAlbums->concat($albums); + } + + private function createAlbumQuery($terms): Builder { - $albumIDs = resolve(PublicIds::class)->getPublicAlbumsId(); + $albumQuery = Album::query() + ->select(['albums.*']) + ->join('base_albums', 'base_albums.id', '=', 'albums.id'); + $this->addSearchCondition($terms, $albumQuery); + $this->albumAuthorisationProvider->applyBrowsabilityFilter($albumQuery); - $query = Album::with(['owner'])->whereIn('id', $albumIDs); + return $albumQuery; + } + + private function createTagAlbumQuery(array $terms): Builder + { + // Note: `applyVisibilityFilter` already adds a JOIN clause with `base_albums`. + // No need to add a second JOIN clause. + $albumQuery = $this->albumAuthorisationProvider->applyVisibilityFilter( + TagAlbum::query() + ); + $this->addSearchCondition($terms, $albumQuery); + + return $albumQuery; + } + private function addSearchCondition(array $terms, Builder $query): Builder + { foreach ($terms as $term) { $query->where( - fn (Builder $query) => $query->where('title', 'like', '%' . $term . '%') - ->orWhere('description', 'like', '%' . $term . '%') + fn (Builder $query) => $query + ->where('base_albums.title', 'like', '%' . $term . '%') + ->orWhere('base_albums.description', 'like', '%' . $term . '%') ); } - $albums = $query->get(); - - return $albums->map(function ($album_model) { - $album = $album_model->toReturnArray(); - - if (AccessControl::is_logged_in()) { - $album['owner'] = $album_model->owner->username; - } - - return $album; - }); + return $query; } } diff --git a/app/Actions/Search/PhotoSearch.php b/app/Actions/Search/PhotoSearch.php index c6cca7919cd..b9f4cb22dcb 100644 --- a/app/Actions/Search/PhotoSearch.php +++ b/app/Actions/Search/PhotoSearch.php @@ -2,73 +2,45 @@ namespace App\Actions\Search; -use App\Actions\Albums\Extensions\PublicIds; -use App\Facades\AccessControl; -use App\ModelFunctions\SymLinkFunctions; +use App\Actions\PhotoAuthorisationProvider; use App\Models\Configs; +use App\Models\Extensions\SortingDecorator; use App\Models\Photo; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; class PhotoSearch { - /** - * @var SymLinkFunctions - */ - private $symLinkFunctions; + protected PhotoAuthorisationProvider $photoAuthorisationProvider; - /** - * @param SymLinkFunctions $symLinkFunctions - */ - public function __construct( - SymLinkFunctions $symLinkFunctions - ) { - $this->symLinkFunctions = $symLinkFunctions; - } - - private function unsorted_or_public(Builder $query) + public function __construct(PhotoAuthorisationProvider $photoAuthorisationProvider) { - if (AccessControl::is_admin()) { - return $query->orWhere('album_id', '=', null); - } - - if (AccessControl::can_upload()) { - $query = $query->orWhere(fn ($q) => $q->where('album_id', '=', null)->where('owner_id', '=', AccessControl::id())); - } - - if (Configs::get_value('public_photos_hidden', '1') === '0') { - $query = $query->orWhere('public', '=', 1); - } - - return $query; + $this->photoAuthorisationProvider = $photoAuthorisationProvider; } - public function query(array $terms) + public function query(array $terms): Collection { - $albumIDs = resolve(PublicIds::class)->getPublicAlbumsId(); - - $query = Photo::with('album') - ->where(fn ($q) => $this->unsorted_or_public($q->whereIn('album_id', $albumIDs))); + $query = $this->photoAuthorisationProvider->applySearchabilityFilter( + Photo::with(['album', 'size_variants', 'size_variants.sym_links']) + ); - foreach ($terms as $escaped_term) { + foreach ($terms as $term) { $query->where( - fn (Builder $query) => $query->where('title', 'like', '%' . $escaped_term . '%') - ->orWhere('description', 'like', '%' . $escaped_term . '%') - ->orWhere('tags', 'like', '%' . $escaped_term . '%') - ->orWhere('location', 'like', '%' . $escaped_term . '%') - ->orWhere('model', 'like', '%' . $escaped_term . '%') - ->orWhere('taken_at', 'like', '%' . $escaped_term . '%') + fn (Builder $query) => $query + ->where('title', 'like', '%' . $term . '%') + ->orWhere('description', 'like', '%' . $term . '%') + ->orWhere('tags', 'like', '%' . $term . '%') + ->orWhere('location', 'like', '%' . $term . '%') + ->orWhere('model', 'like', '%' . $term . '%') + ->orWhere('taken_at', 'like', '%' . $term . '%') ); } - $photos = $query->get(); - - return $photos->map( - function ($photo) { - $photo_array = $photo->toReturnArray(); - $this->symLinkFunctions->getUrl($photo, $photo_array); + $sortingCol = Configs::get_value('sorting_Photos_col'); + $sortingOrder = Configs::get_value('sorting_Photos_order'); - return $photo_array; - } - ); + return (new SortingDecorator($query)) + ->orderBy($sortingCol, $sortingOrder) + ->get(); } } diff --git a/app/Actions/Settings/Login.php b/app/Actions/Settings/Login.php index f10a415fa6b..aec0778d460 100644 --- a/app/Actions/Settings/Login.php +++ b/app/Actions/Settings/Login.php @@ -12,6 +12,9 @@ class Login { + /** + * @throws JsonError + */ public function do(Request $request) { $oldPassword = $request->has('oldPassword') ? $request['oldPassword'] : ''; @@ -51,14 +54,15 @@ public function do(Request $request) $id = AccessControl::id(); // this is probably sensitive to timing attacks... - $user = User::findOrFail($id); + /** @var User $user */ + $user = User::query()->findOrFail($id); - if ($user->lock) { + if ($user->is_locked) { Logs::notice(__METHOD__, __LINE__, 'Locked user (' . $user->username . ') tried to change their identity from ' . $request->ip()); throw new JsonError('Locked account!'); } - if (User::where('username', '=', $request['username'])->where('id', '!=', $id)->count()) { + if (User::query()->where('username', '=', $request['username'])->where('id', '!=', $id)->count()) { Logs::notice(__METHOD__, __LINE__, 'User (' . $user->username . ') tried to change their identity to ' . $request['username'] . ' from ' . $request->ip()); throw new JsonError('Username already exists.'); diff --git a/app/Actions/Sharing/ListShare.php b/app/Actions/Sharing/ListShare.php index a63bdaab707..8a0080c9e70 100644 --- a/app/Actions/Sharing/ListShare.php +++ b/app/Actions/Sharing/ListShare.php @@ -2,8 +2,7 @@ namespace App\Actions\Sharing; -use App\Models\Album; -use App\Models\User; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class ListShare @@ -11,42 +10,51 @@ class ListShare public function do(int $UserId): array { // prepare query - $shared_query = DB::table('user_album') - ->select( - 'user_album.id', + $shared_query = DB::table('user_base_album') + ->select([ + 'user_base_album.id', 'user_id', - 'album_id', + DB::raw('base_album_id as album_id'), 'username', 'title', - 'parent_id' - ) - ->join('users', 'user_id', 'users.id') - ->join('albums', 'album_id', 'albums.id'); + ]) + ->join('users', 'user_id', '=', 'users.id') + ->join('base_albums', 'base_album_id', '=', 'base_albums.id'); - $albums_query = Album::select(['id', 'title', 'parent_id']); + $albums_query = DB::table('base_albums') + ->leftJoin('albums', 'albums.id', '=', 'base_albums.id') + ->select(['base_albums.id', 'title', 'parent_id']); // apply filter if ($UserId != 0) { - $shared_query = $shared_query->where('albums.owner_id', '=', $UserId); + $shared_query = $shared_query->where('base_albums.owner_id', '=', $UserId); $albums_query = $albums_query->where('owner_id', '=', $UserId); } // get arrays - $shared = $shared_query->orderBy('title', 'ASC') + $shared = $shared_query + ->orderBy('title', 'ASC') ->orderBy('username', 'ASC') - ->get() - ->each(function (&$s) { - $s->album_id = strval($s->album_id); - $s->title = Album::getFullPath($s); - }); + ->get(); - $albums = $albums_query->get()->each(function (&$album) { - $album->title = Album::getFullPath($album); + $albums = $albums_query->get(); + $this->linkAlbums($albums); + $albums->each(function ($album) { + $album->title = $this->breadcrumbPath($album); + }); + $albums->each(function ($album) { + unset($album->parent_id); + unset($album->parent); }); - $users = User::select(['id', 'username']) + $users = DB::table('users') + ->select(['id', 'username']) ->where('id', '>', 0) - ->orderBy('username', 'ASC')->get(); + ->orderBy('username', 'ASC') + ->get() + ->each(function ($user) { + $user->id = intval($user->id); + }); return [ 'shared' => $shared, @@ -54,4 +62,35 @@ public function do(int $UserId): array 'users' => $users, ]; } + + private function breadcrumbPath(object $album): string + { + $title = [$album->title]; + $parent = $album->parent; + while ($parent) { + array_unshift($title, $parent->title); + $parent = $parent->parent; + } + + return implode('/', $title); + } + + private function linkAlbums(Collection $albums): void + { + if ($albums->isEmpty()) { + return; + } + + $groupedAlbums = $albums->groupBy('parent_id'); + + foreach ($albums as $album) { + if (!$album->parent_id) { + $album->parent = null; + } + $childAlbums = $groupedAlbums->get($album->id, []); + foreach ($childAlbums as $childAlbum) { + $childAlbum->parent = $album; + } + } + } } diff --git a/app/Actions/User/Create.php b/app/Actions/User/Create.php index 0d3336fce5d..6975025680c 100644 --- a/app/Actions/User/Create.php +++ b/app/Actions/User/Create.php @@ -7,18 +7,24 @@ class Create { - public function do(array $data): bool + /** + * @throws JsonError + */ + public function do(array $data): User { - if (User::where('username', '=', $data['username'])->count()) { + if (User::query()->where('username', '=', $data['username'])->count()) { throw new JsonError('username must be unique'); } $user = new User(); - $user->upload = ($data['upload'] == '1'); - $user->lock = ($data['lock'] == '1'); + $user->may_upload = $data['may_upload']; + $user->is_locked = $data['is_locked']; $user->username = $data['username']; $user->password = bcrypt($data['password']); + if (!$user->save()) { + throw new \RuntimeException('could not save new user'); + } - return @$user->save(); + return $user; } } diff --git a/app/Actions/User/Save.php b/app/Actions/User/Save.php index 593eedb7167..d69e4b93aad 100644 --- a/app/Actions/User/Save.php +++ b/app/Actions/User/Save.php @@ -15,8 +15,8 @@ public function do(User $user, array $data): bool // check for duplicate name here ! $user->username = $data['username']; - $user->upload = ($data['upload'] == '1'); - $user->lock = ($data['lock'] == '1'); + $user->may_upload = $data['may_upload']; + $user->is_locked = $data['is_locked']; if (isset($data['password'])) { $user->password = bcrypt($data['password']); } diff --git a/app/Assets/Helpers.php b/app/Assets/Helpers.php index 0ec5f37dc56..4802cb8d144 100644 --- a/app/Assets/Helpers.php +++ b/app/Assets/Helpers.php @@ -3,7 +3,7 @@ namespace App\Assets; use App\Exceptions\DivideByZeroException; -use App\Models\Configs; +use App\Models\Logs; use Illuminate\Support\Facades\File; use WhichBrowser\Parser as BrowserParser; @@ -55,36 +55,6 @@ public function getDeviceType(): string return $result->getType(); } - /* - * Generate an id from current microtime. - * - * @return string generated ID - */ - public function generateID(): string - { - // Generate id based on the current microtime - - if ( - PHP_INT_MAX == 2147483647 - || Configs::get_value('force_32bit_ids', '0') === '1' - ) { - // For 32-bit installations, we can only afford to store the - // full seconds in id. The calling code needs to be able to - // handle duplicate ids. Note that this also exposes us to - // the year 2038 problem. - $id = sprintf('%010d', microtime(true)); - } else { - // Ensure 4 digits after the decimal point, 15 characters - // total (including the decimal point), 0-padded on the - // left if needed (shouldn't be needed unless we move back in - // time :-) ) - $id = sprintf('%015.4f', microtime(true)); - $id = str_replace('.', '', $id); - } - - return $id; - } - /** * Return the 32bit truncated version of a number seen as string. * @@ -179,6 +149,29 @@ public function hasFullPermissions(string $path): bool return false; } + /** + * Creates a temporary file in the local system's temporary directory with + * a random name and the designated extension. + * + * The caller is responsible to move/delete the temporary file after it is + * not needed anymore. + * + * @param string $extension the desired file extension, must include a starting dot + * + * @return string the path to the newly created file + */ + public function createTemporaryFile(string $extension): string + { + if (!($result = tempnam(sys_get_temp_dir(), 'lychee')) || + !rename($result, $result . $extension)) { + $msg = 'Could not create a temporary file.'; + Logs::notice(__METHOD__, __LINE__, $msg); + throw new \RuntimeException($msg); + } + + return $result . $extension; + } + /** * Compute the GCD of a and b * This function is used to simplify the shutter speed when given in the form of e.g. 50/100. @@ -208,19 +201,6 @@ public function str_of_bool(bool $b): string return $b ? '1' : '0'; } - /** - * Given a filename generate the @2x corresponding filename. - * This is used for thumbs, small and medium. - */ - public function ex2x(string $filename): string - { - $filename2x = explode('.', $filename); - - return (count($filename2x) === 2) ? - $filename2x[0] . '@2x.' . $filename2x[1] : - $filename2x[0] . '@2x'; - } - /** * Returns the available licenses. */ diff --git a/app/Assets/SizeVariantLegacyNamingStrategy.php b/app/Assets/SizeVariantLegacyNamingStrategy.php new file mode 100644 index 00000000000..a092fe27082 --- /dev/null +++ b/app/Assets/SizeVariantLegacyNamingStrategy.php @@ -0,0 +1,106 @@ + 'thumb', + SizeVariant::THUMB2X => 'thumb', + SizeVariant::SMALL => 'small', + SizeVariant::SMALL2X => 'small', + SizeVariant::MEDIUM => 'medium', + SizeVariant::MEDIUM2X => 'medium', + SizeVariant::ORIGINAL => 'big', + ]; + + /** + * The file extension which is always used by both "thumb" variants and + * also by all other size variants but the original, if the original media + * file is not a photo. + * If the original media file is a photo, then the "small" and "medium" + * size variants use the same extension as the original file. + */ + public const DEFAULT_EXTENSION = '.jpeg'; + + public function setPhoto(?Photo $photo): void + { + parent::setPhoto($photo); + $this->originalExtension = ''; + if ($this->photo) { + $sv = $this->photo->size_variants->getOriginal(); + if ($sv) { + if (!empty($sv->short_path)) { + $this->originalExtension = Helpers::getExtension($sv->short_path, false); + } + } + } + } + + /** + * Generates a short path for the designated size variant. + * + * @param int $sizeVariant the size variant + * + * @return string The short path + */ + public function generateShortPath(int $sizeVariant): string + { + if (SizeVariant::ORIGINAL > $sizeVariant || $sizeVariant > SizeVariant::THUMB) { + throw new \InvalidArgumentException('invalid $sizeVariant = ' . $sizeVariant); + } + if ($this->photo == null) { + throw new \InvalidArgumentException('associated photo model must not be null'); + } + if (empty($this->photo->checksum)) { + throw new \BadFunctionCallException('cannot generate short path for photo before checksum has been set'); + } + $directory = self::VARIANT_2_PATH_PREFIX[$sizeVariant] . '/'; + if ($sizeVariant === SizeVariant::ORIGINAL && $this->photo->isRaw()) { + $directory = 'raw/'; + } + $filename = substr($this->photo->checksum, 0, 32); + if ($sizeVariant === SizeVariant::MEDIUM2X || + $sizeVariant === SizeVariant::SMALL2X || + $sizeVariant === SizeVariant::THUMB2X) { + $filename .= '@2x'; + } + $extension = $this->generateExtension($sizeVariant); + + return $directory . $filename . $extension; + } + + protected function generateExtension(int $sizeVariant): string + { + if ($sizeVariant === SizeVariant::THUMB || + $sizeVariant === SizeVariant::THUMB2X || + ($sizeVariant !== SizeVariant::ORIGINAL && $this->photo->isVideo()) || + ($sizeVariant !== SizeVariant::ORIGINAL && $this->photo->isRaw()) + ) { + return self::DEFAULT_EXTENSION; + } elseif (!empty($this->originalExtension)) { + return $this->originalExtension; + } else { + if (empty($this->fallbackExtension)) { + throw new \LogicException('file extension must not be empty'); + } + + return $this->fallbackExtension; + } + } + + public function getDefaultExtension(): string + { + return self::DEFAULT_EXTENSION; + } +} diff --git a/app/Casts/DateTimeWithTimezoneCast.php b/app/Casts/DateTimeWithTimezoneCast.php index 6fe3321d7dd..8aaf0b70d05 100644 --- a/app/Casts/DateTimeWithTimezoneCast.php +++ b/app/Casts/DateTimeWithTimezoneCast.php @@ -68,7 +68,7 @@ public function set($model, string $key, $value, array $attributes): array throw new \InvalidArgumentException('$value must extend \DateTimeInterface'); } $sqlDatetimeString = $model->fromDateTime($value); - $sqlTimezoneString = $value === null ? null : $value->getTimezone()->getName(); + $sqlTimezoneString = $value?->getTimezone()->getName(); $tzKey = $key . self::TZ_ATTRIBUTE_SUFFIX; return [ diff --git a/app/Casts/MustNotSetCast.php b/app/Casts/MustNotSetCast.php new file mode 100644 index 00000000000..c37930a3416 --- /dev/null +++ b/app/Casts/MustNotSetCast.php @@ -0,0 +1,59 @@ +alternative = $alternative; + } + + /** + * The mutator of the attribute. + * + * This function is called by the framework during an attempt to set the + * affected attribute. + * This mutator always throws an exception and thus prevents the attribute + * from being altered. + * + * @param Model $model the model which owns the attribute + * @param string $key the name of attribute which has been + * attempted to be set + * @param mixed $value the value which has been attempted to assign + * to the attribute + * @param array $attributes all attributes of the model + * + * @return void + */ + public function set($model, string $key, $value, array $attributes): void + { + $msg = 'must not set read-only attribute \'' . get_class($model) . '::$' . $key . '\' directly'; + if ($this->alternative) { + $msg = $msg . ', use \'' . get_class($model) . '::$' . $this->alternative . ' instead'; + } + throw new \BadMethodCallException($msg); + } +} diff --git a/app/Console/Commands/ExifLens.php b/app/Console/Commands/ExifLens.php index 5c9d45ce7fc..1895da87e7d 100644 --- a/app/Console/Commands/ExifLens.php +++ b/app/Console/Commands/ExifLens.php @@ -8,7 +8,6 @@ use App\Metadata\Extractor; use App\Models\Photo; use Illuminate\Console\Command; -use Storage; class ExifLens extends Command { @@ -58,7 +57,8 @@ public function handle() set_time_limit($timeout); // we use lens because this is the one which is most likely to be empty. - $photos = Photo::where('lens', '=', '') + $photos = Photo::query() + ->where('lens', '=', '') ->whereNotIn('type', $this->getValidVideoTypes()) ->offset($from) ->limit($argument) @@ -70,10 +70,11 @@ public function handle() } $i = $from; + /** @var Photo $photo */ foreach ($photos as $photo) { - $url = Storage::path('big/' . $photo->url); - if (file_exists($url)) { - $info = $this->metadataExtractor->extract($url, $photo->type); + $fullPath = $photo->full_path; + if (file_exists($fullPath)) { + $info = $this->metadataExtractor->extract($fullPath, $photo->type); $updated = false; if ($photo->filesize == '' && $info['filesize'] != '') { $photo->filesize = $info['filesize']; diff --git a/app/Console/Commands/GenerateThumbs.php b/app/Console/Commands/GenerateThumbs.php index 13c7b1d8770..cb8f2ce777f 100644 --- a/app/Console/Commands/GenerateThumbs.php +++ b/app/Console/Commands/GenerateThumbs.php @@ -2,23 +2,22 @@ namespace App\Console\Commands; -use App\Actions\Photo\Extensions\ImageEditing; -use App\Models\Configs; +use App\Contracts\SizeVariantFactory; use App\Models\Photo; +use App\Models\SizeVariant; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Builder; class GenerateThumbs extends Command { - use ImageEditing; - /** * @var array */ - public const THUMB_TYPES = [ - 'small', - 'small2x', - 'medium', - 'medium2x', + public const SIZE_VARIANTS = [ + 'small' => SizeVariant::SMALL, + 'small2x' => SizeVariant::SMALL2X, + 'medium' => SizeVariant::MEDIUM, + 'medium2x' => SizeVariant::MEDIUM2X, ]; /** @@ -38,48 +37,40 @@ class GenerateThumbs extends Command /** * Execute the console command. * - * @return mixed + * @return int */ - public function handle() + public function handle(): int { - $type = $this->argument('type'); - - if (!in_array($type, self::THUMB_TYPES)) { - $this->error(sprintf('Type %s is not one of %s', $type, implode(', ', self::THUMB_TYPES))); + $sizeVariantName = $this->argument('type'); + if (!array_key_exists($sizeVariantName, self::SIZE_VARIANTS)) { + $this->error(sprintf('Type %s is not one of %s', $sizeVariantName, implode(', ', array_flip(self::SIZE_VARIANTS)))); return 1; } + $sizeVariantType = self::SIZE_VARIANTS[$sizeVariantName]; set_time_limit($this->argument('timeout')); - $multiplier = 1; - $basicType = $type; - if (($split = strpos($basicType, '2')) !== false) { - $basicType = substr($basicType, 0, $split); - $multiplier = 2; - } - - $maxWidth = intval(Configs::get_value($basicType . '_max_width')) * $multiplier; - $maxHeight = intval(Configs::get_value($basicType . '_max_height')) * $multiplier; - $this->line( sprintf( - 'Will attempt to generate up to %s %s (%dx%d) images with a timeout of %d seconds...', + 'Will attempt to generate up to %s %s images with a timeout of %d seconds...', $this->argument('amount'), - $type, - $maxWidth, - $maxHeight, + $sizeVariantName, $this->argument('timeout') ) ); - $photos = Photo::where($type, '=', '') + $photos = Photo::query() ->where('type', 'like', 'image/%') + ->with('size_variants') + ->whereDoesntHave('size_variants', function (Builder $query) use ($sizeVariantType) { + $query->where('type', '=', $sizeVariantType); + }) ->take($this->argument('amount')) ->get(); if (count($photos) == 0) { - $this->line('No picture requires ' . $type . '.'); + $this->line('No picture requires ' . $sizeVariantName . '.'); return 0; } @@ -87,22 +78,23 @@ public function handle() $bar = $this->output->createProgressBar(count($photos)); $bar->start(); + // Initialize factory for size variants + $sizeVariantFactory = resolve(SizeVariantFactory::class); + /** @var Photo $photo */ foreach ($photos as $photo) { - if ($this->resizePhoto( - $photo, - $type, - $maxWidth, - $maxHeight - )) { - $photo->save(); - $this->line(' ' . $type . ' (' . $photo->{$type} . ') for ' . $photo->title . ' created.'); + $sizeVariantFactory->init($photo); + $sizeVariant = $sizeVariantFactory->createSizeVariantCond($sizeVariantType); + if ($sizeVariant) { + $this->line(' ' . $sizeVariantName . ' (' . $sizeVariant->width . 'x' . $sizeVariant->height . ') for ' . $photo->title . ' created.'); } else { - $this->line(' Could not create ' . $type . ' for ' . $photo->title . ' (' . $photo->width . 'x' . $photo->height . ').'); + $this->line(' Did not create ' . $sizeVariantName . ' for ' . $photo->title . '.'); } $bar->advance(); } $bar->finish(); $this->line(' '); + + return 0; } } diff --git a/app/Console/Commands/Ghostbuster.php b/app/Console/Commands/Ghostbuster.php index 87ffe5cf38c..3c439c189a9 100644 --- a/app/Console/Commands/Ghostbuster.php +++ b/app/Console/Commands/Ghostbuster.php @@ -3,10 +3,12 @@ namespace App\Console\Commands; use App\Console\Commands\Utilities\Colorize; -use App\Facades\Helpers; use App\Models\Photo; +use App\Models\SizeVariant; +use App\Models\SymLink; use Illuminate\Console\Command; -use Storage; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Storage; class Ghostbuster extends Command { @@ -22,7 +24,7 @@ class Ghostbuster extends Command * * @var string */ - protected $signature = 'lychee:ghostbuster {removeDeadSymLinks=0 : Remove Photos with dead symlinks} {dryrun=1 : Dry Run default is True}'; + protected $signature = 'lychee:ghostbuster {removeDeadSymLinks=0 : Removes dead symlinks and the photos pointing to them} {removeZombiePhotos=0 : Removes photos pointing to non-existing files} {dryrun=1 : Dry Run default is True}'; /** * The console command description. @@ -48,15 +50,27 @@ public function __construct(Colorize $colorize) /** * Execute the console command. * - * @return mixed + * @return int */ - public function handle() + public function handle(): int { - $this->line(''); $removeDeadSymLinks = (bool) $this->argument('removeDeadSymLinks'); + $removeZombiePhotos = (bool) $this->argument('removeZombiePhotos'); $dryrun = (bool) $this->argument('dryrun'); + $uploadDisk = Storage::disk(); + $symlinkDisk = Storage::disk(SymLink::DISK_NAME); + $isLocalDisk = ($uploadDisk->getDriver()->getAdapter() instanceof \League\Flysystem\Adapter\Local); + + $this->line(''); + + if ($removeDeadSymLinks && !$isLocalDisk) { + $this->line($this->col->yellow('Removal of dead symlinks requested, but filesystem does not support symlinks.')); + $this->line('Proceeding as if removeDeadSymlinks was not set.'); + $this->line(''); + $removeDeadSymLinks = false; + } if ($removeDeadSymLinks) { - $this->line('Also parsing database for pictures where the url does not point to an existing file.'); + $this->line('Also parsing database for pictures which point to non-existing files.'); $this->line($this->col->yellow('This may modify the database.')); $this->line(''); } @@ -65,104 +79,120 @@ public function handle() $this->line(''); } - $path = Storage::path('big'); - $files = array_slice(scandir($path), 2); - $total = 0; + /** @var string[] $filenames */ + $filenames = $uploadDisk->allFiles(); - foreach ($files as $url) { - if ($url == 'index.html') { + $totalDeadSymLinks = 0; + $totalFiles = 0; + $totalDbEntries = 0; + + /** @var string $filename */ + foreach ($filenames as $filename) { + if (str_contains($filename, 'index.html')) { continue; } - $isDeadSymlink = is_link($path . '/' . $url) && !file_exists(readlink($path . '/' . $url)); - $photos = Photo::where(function ($query) use ($url) { - return $query->where('url', '=', $url)->orWhere('livePhotoUrl', '=', $url); - })->get(); - - if (count($photos) === 0 || ($isDeadSymlink && $removeDeadSymLinks)) { - $photoName = explode('.', $url); - - $to_delete = []; - $to_delete[] = 'thumb/' . $photoName[0] . '.jpeg'; - $to_delete[] = 'thumb/' . $photoName[0] . '@2x.jpeg'; - - // for videos - $to_delete[] = 'small/' . $photoName[0] . '.jpeg'; - $to_delete[] = 'small/' . $photoName[0] . '@2x.jpeg'; - $to_delete[] = 'medium/' . $photoName[0] . '.jpeg'; - $to_delete[] = 'medium/' . $photoName[0] . '@2x.jpeg'; - - // for normal pictures - $to_delete[] = 'small/' . $url; - $to_delete[] = 'small/' . Helpers::ex2x($url); - $to_delete[] = 'medium/' . $url; - $to_delete[] = 'medium/' . Helpers::ex2x($url); - $to_delete[] = 'big/' . $url; - - foreach ($to_delete as $del) { - $delete = 0; - if (Storage::exists($del)) { - $delete = 1; - } elseif (file_exists($path . '/' . $del)) { - // symbolic link... - $delete = 2; - } + $isDeadSymlink = false; + if ($isLocalDisk) { + $fullPath = $uploadDisk->path($filename); + $isDeadSymlink = is_link($fullPath) && !file_exists(readlink($fullPath)); + } - if ($delete > 0) { - $total++; - if ($dryrun) { - $this->line(str_pad($del, 50) . $this->col->red(' file will be removed') . '.'); - } else { - if ($delete == 1) { - Storage::delete($del); - } else { - // symbolic link - unlink($path . '/' . $del); - } - $this->line($this->col->red('removed file: ') . $del); - } + /** @var Collection $sizeVariants */ + $photos = Photo::query() + ->where('live_photo_short_path', '=', $filename) + ->get(); + /** @var Collection $sizeVariants */ + $sizeVariants = SizeVariant::query() + ->with('photo') + ->where('short_path', '=', $filename) + ->get(); + + if ($isDeadSymlink && $removeDeadSymLinks) { + $totalDeadSymLinks++; + if ($dryrun) { + $this->line(str_pad($filename, 50) . $this->col->red(' is dead symlink and would be removed') . '.'); + } else { + // Laravel apparently doesn't think dead symlinks 'exist', so use low-level commands + unlink($uploadDisk->path($filename)); + $this->line(str_pad($filename, 50) . $this->col->red(' removed') . '.'); + $totalDbEntries += $sizeVariants->count() + $photos->count(); + /** @var SizeVariant $sizeVariant */ + foreach ($sizeVariants as $sizeVariant) { + $sizeVariant->photo->delete(); } - } - - if ($isDeadSymlink && $removeDeadSymLinks) { + /** @var Photo $photo */ foreach ($photos as $photo) { - if ($dryrun) { - $this->line(str_pad($photo->url, 50) . $this->col->red(' photo will be removed') . '.'); - } else { - // Laravel apparently doesn't think dead symlinks 'exist', so manually remove the original here. - unlink($path . '/' . $url); - - $photo->predelete(); - $photo->delete(); - - $this->line($this->col->red('removed photo: ') . $photo->url); - } + $photo->live_photo_short_path = null; + $photo->save(); } } + } elseif ($photos->count() + $sizeVariants->count() === 0) { + // Remove orphaned files + $totalFiles++; + if ($dryrun) { + $this->line(str_pad($filename, 50) . $this->col->red(' would be removed') . '.'); + } else { + $uploadDisk->delete($filename); + $this->line(str_pad($filename, 50) . $this->col->red(' removed') . '.'); + } } } $this->line(''); + if ($removeZombiePhotos) { + $sizeVariants = SizeVariant::query() + ->with('photo') + ->get(); + /** @var SizeVariant $sizeVariant */ + foreach ($sizeVariants as $sizeVariant) { + if ($uploadDisk->exists($sizeVariant->short_path)) { + continue; + } + $totalDbEntries++; + if ($dryrun) { + $this->line(str_pad($filename, 50) . $this->col->red(' does not exist and photo would be removed') . '.'); + } else { + if ($sizeVariant->type == SizeVariant::ORIGINAL) { + $sizeVariant->photo->delete(); + } else { + $sizeVariant->delete(); + } + $this->line(str_pad($filename, 50) . $this->col->red(' removed') . '.'); + } + } + } + + $total = $totalDeadSymLinks + $totalFiles + $totalDbEntries; if ($total == 0) { $this->line($this->col->green('No pictures found to be deleted')); } if ($total > 0 && $dryrun) { - $this->line($total . ' pictures will be deleted.'); + $this->line($totalDeadSymLinks . ' dead symbolic links would be deleted.'); + $this->line($totalFiles . ' files would be deleted.'); + $this->line($totalDbEntries . ' photos would be deleted or sanitized'); $this->line(''); - $this->line("Rerun the command '" . $this->col->yellow('php artisan lychee:ghostbuster ' . ($removeDeadSymLinks ? '1' : '0') . ' 0') . "' to effectively remove the files."); + $this->line("Rerun the command '" . $this->col->yellow('php artisan lychee:ghostbuster ' . ($removeDeadSymLinks ? '1' : '0') . ' ' . ($removeZombiePhotos ? '1' : '0') . ' 0') . "' to effectively remove the files."); } if ($total > 0 && !$dryrun) { - $this->line($total . ' pictures have been deleted.'); + $this->line($totalDeadSymLinks . ' dead symbolic links have been deleted.'); + $this->line($totalFiles . ' files have been deleted.'); + $this->line($totalDbEntries . ' photos have been deleted or sanitized'); } - $sym_dir = Storage::drive('symbolic')->path(''); - $syms = array_slice(scandir($sym_dir), 3); - - foreach ($syms as $sym) { - $link_path = $sym_dir . $sym; - if (!file_exists(readlink($link_path))) { - unlink($link_path); - $this->line($this->col->red('removed symbolic link: ') . $link_path); + // Method $symlinkDisk->allFiles() crashes, if the scanned directory + // contains symbolic links. + // So we must use low-level methods here. + $symlinkDiskPath = $symlinkDisk->path(''); + $symLinks = array_slice(scandir($symlinkDiskPath), 3); + /** @var string $symLink */ + foreach ($symLinks as $symLink) { + $fullPath = $symlinkDiskPath . $symLink; + $isDeadSymlink = !file_exists(readlink($fullPath)); + if ($isDeadSymlink) { + // Laravel apparently doesn't think dead symlinks 'exist', so use low-level commands + unlink($fullPath); + $this->line($this->col->red('removed symbolic link: ') . $fullPath); } } diff --git a/app/Console/Commands/PhotosAddedNotification.php b/app/Console/Commands/PhotosAddedNotification.php index a2c25abd8bc..7b7d4ce4810 100644 --- a/app/Console/Commands/PhotosAddedNotification.php +++ b/app/Console/Commands/PhotosAddedNotification.php @@ -4,11 +4,12 @@ use App\Mail\PhotosAdded; use App\Models\Configs; +use App\Models\Logs; use App\Models\Photo; use App\Models\User; use Illuminate\Console\Command; +use Illuminate\Notifications\DatabaseNotification; use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\Storage; class PhotosAddedNotification extends Command { @@ -41,50 +42,60 @@ public function __construct() * * @return int */ - public function handle() + public function handle(): int { - if (Configs::get_Value('new_photos_notification', '0') == '1') { - $users = User::whereNotNull('email')->get(); - - foreach ($users as $user) { - $photos = []; + if (Configs::get_Value('new_photos_notification', '0') !== '1') { + return 0; + } + $users = User::query()->whereNotNull('email')->get(); - foreach ($user->unreadNotifications as $notification) { - $photo = Photo::find($notification->data['id']); + /** @var User $user */ + foreach ($users as $user) { + $photos = []; - if ($photo && $photo->thumbUrl) { - if (!isset($photos[$photo->album_id])) { - $photos[$photo->album_id] = [ - 'name' => $photo->album->title, - 'photos' => [], - ]; - } + /** @var DatabaseNotification $notification */ + foreach ($user->unreadNotifications()->get() as $notification) { + /** @var Photo $photo */ + $photo = Photo::query() + ->with(['size_variants', 'size_variants.sym_links']) + ->find($notification->data['id']); - logger(Storage::url(Photo::VARIANT_2_PATH_PREFIX[Photo::VARIANT_THUMB] . '/' . $photo->thumbUrl)); + if ($photo) { + if (!isset($photos[$photo->album_id])) { + $photos[$photo->album_id] = [ + 'name' => $photo->album->title, + 'photos' => [], + ]; + } - // If the url config doesn't contain a trailing slash then add it - if (substr(config('app.url'), -1) == '/') { - $trailing_slash = ''; - } else { - $trailing_slash = '/'; - } + $thumbUrl = $photo->size_variants->getThumb()->url; + logger($thumbUrl); - $photos[$photo->album_id]['photos'][$photo->id] = [ - 'thumb' => Storage::url(Photo::VARIANT_2_PATH_PREFIX[Photo::VARIANT_THUMB] . '/' . $photo->thumbUrl), - 'link' => config('app.url') . $trailing_slash . 'r/' . $photo->album_id . '/' . $photo->id, - ]; + // If the url config doesn't contain a trailing slash then add it + if (str_ends_with(config('app.url'), '/')) { + $trailing_slash = ''; + } else { + $trailing_slash = '/'; } + + $photos[$photo->album_id]['photos'][$photo->id] = [ + 'thumb' => $thumbUrl, + // TODO: Clean this up. There should be a better way to get the URL of a photo than constructing it manually + 'link' => config('app.url') . $trailing_slash . 'r/' . $photo->album_id . '/' . $photo->id, + ]; } + } - if (count($photos) > 0) { - try { - Mail::to($user->email)->send(new PhotosAdded($photos)); - $user->notifications()->delete(); - } catch (Exception $e) { - Logs::error(__METHOD__, __LINE__, 'Failed to send email notification for ' . $user->username); - } + if (count($photos) > 0) { + try { + Mail::to($user->email)->send(new PhotosAdded($photos)); + $user->notifications()->delete(); + } catch (\Exception $e) { + Logs::error(__METHOD__, __LINE__, 'Failed to send email notification for ' . $user->username); } } } + + return 0; } } diff --git a/app/Console/Commands/ResetAdmin.php b/app/Console/Commands/ResetAdmin.php index 9fc20d98248..634733aed51 100644 --- a/app/Console/Commands/ResetAdmin.php +++ b/app/Console/Commands/ResetAdmin.php @@ -52,22 +52,14 @@ public function handle() { Legacy::resetAdmin(); - // delete to avoid collisions. - User::where('username', '=', '')->delete(); - User::where('password', '=', '')->delete(); - User::where('id', '=', 0)->delete(); - - // recreate an admin user - $user = new User(); + /** @var User $user */ + $user = User::query()->findOrNew(0); + $user->incrementing = false; // disable auto-generation of ID + $user->id = 0; $user->username = Configs::get_value('username', ''); $user->password = Configs::get_value('password', ''); $user->save(); - // created user will have a id which is NOT 0. - // we want this user to have an ID of 0 as it is the ADMIN ID. - $user->id = 0; - $user->save(); - $this->line($this->col->yellow('Admin username and password reset.')); } } diff --git a/app/Console/Commands/ShowLogs.php b/app/Console/Commands/ShowLogs.php index 6a23a4524d5..64571e6fe4b 100644 --- a/app/Console/Commands/ShowLogs.php +++ b/app/Console/Commands/ShowLogs.php @@ -90,15 +90,11 @@ private function action_show($n, $order) private function color_type($type) { - switch ($type) { - case 'error ': - return $this->col->red($type); - case 'warning': - return $this->col->yellow($type); - case 'notice ': - return $this->col->cyan($type); - default: - return $type; - } + return match ($type) { + 'error ' => $this->col->red($type), + 'warning' => $this->col->yellow($type), + 'notice ' => $this->col->cyan($type), + default => $type, + }; } } diff --git a/app/Console/Commands/Takedate.php b/app/Console/Commands/Takedate.php index bd35de8e7f3..6db6e3ed3db 100644 --- a/app/Console/Commands/Takedate.php +++ b/app/Console/Commands/Takedate.php @@ -5,7 +5,6 @@ use App\Metadata\Extractor; use App\Models\Photo; use Illuminate\Console\Command; -use Storage; class Takedate extends Command { @@ -59,13 +58,13 @@ public function handle(Extractor $metadataExtractor) $i = $from - 1; /* @var Photo $photo */ foreach ($photos as $photo) { - $url = Storage::path('big/' . $photo->url); + $fullPath = $photo->full_path; $i++; - if (!file_exists($url)) { - $this->line($i . ': File ' . $url . ' not found for ' . $photo->title . '.'); + if (!file_exists($fullPath)) { + $this->line($i . ': File ' . $fullPath . ' not found for ' . $photo->title . '.'); continue; } - $info = $metadataExtractor->extract($url, $photo->type); + $info = $metadataExtractor->extract($fullPath, $photo->type); /* @var \DateTime $stamp */ $stamp = $info['taken_at']; if ($stamp != null) { @@ -85,10 +84,10 @@ public function handle(Extractor $metadataExtractor) $this->line($i . ': Failed to get Takestamp data for ' . $photo->title . '.'); continue; } - if (is_link($url)) { - $url = readlink($url); + if (is_link($fullPath)) { + $fullPath = readlink($fullPath); } - $created_at = filemtime($url); + $created_at = filemtime($fullPath); if ($created_at == $photo->created_at->timestamp) { $this->line($i . ': Created_at up to date for ' . $photo->title); continue; diff --git a/app/Console/Commands/VideoData.php b/app/Console/Commands/VideoData.php index 4fa8a0f09ed..1ba49ca6681 100644 --- a/app/Console/Commands/VideoData.php +++ b/app/Console/Commands/VideoData.php @@ -3,21 +3,14 @@ namespace App\Console\Commands; use App\Actions\Photo\Extensions\Constants; -use App\Actions\Photo\Extensions\ImageEditing; -use App\Actions\Photo\Extensions\VideoEditing; -use App\Image\ImageHandlerInterface; +use App\Contracts\SizeVariantFactory; use App\Metadata\Extractor; use App\Models\Photo; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Storage; class VideoData extends Command { use Constants; - use VideoEditing; - use ImageEditing; - - public $imageHandler; /** * The name and signature of the console command. @@ -49,16 +42,15 @@ public function __construct(Extractor $metadataExtractor) { parent::__construct(); - $this->imageHandler = app(ImageHandlerInterface::class); $this->metadataExtractor = $metadataExtractor; } /** * Execute the console command. * - * @return mixed + * @return int */ - public function handle() + public function handle(): int { set_time_limit($this->argument('timeout')); @@ -70,7 +62,9 @@ public function handle() ) ); - $photos = Photo::whereIn('type', $this->getValidVideoTypes()) + $photos = Photo::query() + ->with(['size_variants']) + ->whereIn('type', $this->getValidVideoTypes()) ->where('width', '=', 0) ->take($this->argument('count')) ->get(); @@ -81,83 +75,48 @@ public function handle() return 0; } + // Initialize factory for size variants + $sizeVariantFactory = resolve(SizeVariantFactory::class); /** @var Photo $photo */ foreach ($photos as $photo) { $this->line('Processing ' . $photo->title . '...'); - $url = Storage::path('big/' . $photo->url); - - if ($photo->thumbUrl != '') { - $thumb = Storage::path('thumb/') . $photo->thumbUrl; - if (file_exists($thumb)) { - $urlBase = explode('.', $photo->url); - $thumbBase = explode('.', $photo->thumbUrl); - if ($urlBase[0] !== $thumbBase[0]) { - $photo->thumbUrl = $urlBase[0] . '.' . $thumbBase[1]; - rename($thumb, Storage::path('thumb/') . $photo->thumbUrl); - $this->line('Renamed thumb to match the video file'); - } - } - } + $originalSizeVariant = $photo->size_variants->getOriginal(); + $fullPath = $originalSizeVariant->full_path; - if (file_exists($url)) { - $info = $this->metadataExtractor->extract($url, $photo->type); + if (file_exists($fullPath)) { + $info = $this->metadataExtractor->extract($fullPath, 'video'); - $updated = false; - if ($photo->width == 0 && $info['width'] !== 0) { - $photo->width = $info['width']; - $updated = true; + if ($originalSizeVariant->width == 0 && $info['width'] !== 0) { + $originalSizeVariant->width = $info['width']; } - if ($photo->height == 0 && $info['height'] !== 0) { - $photo->height = $info['height']; - $updated = true; + if ($originalSizeVariant->height == 0 && $info['height'] !== 0) { + $originalSizeVariant->height = $info['height']; } if ($photo->focal == '' && $info['focal'] !== '') { $photo->focal = $info['focal']; - $updated = true; } if ($photo->aperture == '' && $info['aperture'] !== '') { $photo->aperture = $info['aperture']; - $updated = true; } if ($photo->latitude == null && $info['latitude'] !== null) { $photo->latitude = $info['latitude']; - $updated = true; } if ($photo->longitude == null && $info['longitude'] !== null) { $photo->longitude = $info['longitude']; - $updated = true; } - if ($updated) { + if ($photo->isDirty()) { $this->line('Updated metadata'); } - if ($photo->thumbUrl === '' || $photo->thumb2x === 0 || $photo->small_width === null || $photo->small2x_width === null) { - $frame_tmp = ''; - try { - $frame_tmp = $this->extractVideoFrame($photo); - } catch (\Exception $exception) { - $this->line($exception->getMessage()); - } - if ($frame_tmp !== '') { - $this->line('Extracted video frame for thumbnails'); - if ($photo->thumbUrl === '' || $photo->thumb2x === 0) { - if (!$this->createThumb($photo, $frame_tmp)) { - $this->line('Could not create thumbnail for video'); - } - $urlBase = explode('.', $photo->url); - $photo->thumbUrl = $urlBase[0] . '.jpeg'; - } - if ($photo->small_width === null || $photo->small2x_width === null) { - $this->createSmallerImages($photo, $frame_tmp); - } - unlink($frame_tmp); - } - } + $sizeVariantFactory->init($photo); + $sizeVariantFactory->createSizeVariants(); } else { $this->line('File does not exist'); } $photo->save(); } + + return 0; } } diff --git a/app/Contracts/AbstractAlbum.php b/app/Contracts/AbstractAlbum.php new file mode 100644 index 00000000000..1741a139f21 --- /dev/null +++ b/app/Contracts/AbstractAlbum.php @@ -0,0 +1,33 @@ +128 bit) of randomness are considered sufficient to only + * allow for a small chance to guess an ID. + * The length must be divisible by 4 as otherwise the Base64 encoding + * uses the character `=` for padding which must not be used within a URL. + * We use Base64 encoding (instead of an encoding with hex digits), + * because Base64 encoding is more space efficient and also more time + * efficient when used as a primary ID in a database. + * + * @var int + */ + public const ID_LENGTH = 24; + public const ID_TYPE = 'string'; + public const LEGACY_ID_NAME = 'legacy_id'; + public const LEGACY_ID_TYPE = 'integer'; +} \ No newline at end of file diff --git a/app/Contracts/SizeVariantFactory.php b/app/Contracts/SizeVariantFactory.php new file mode 100644 index 00000000000..f672b947f31 --- /dev/null +++ b/app/Contracts/SizeVariantFactory.php @@ -0,0 +1,136 @@ +fallbackExtension = $fallbackExtension; + } + + public function setPhoto(?Photo $photo): void + { + $this->photo = $photo; + } + + /** + * Generates a short path for the designated size variant. + * + * @param int $sizeVariant the size variant + * + * @return string The short path + */ + abstract public function generateShortPath(int $sizeVariant): string; + + /** + * Returns the default extension. + * + * @return string the default extension (incl. a preceding dot) which is + * used by the naming strategy + */ + abstract public function getDefaultExtension(): string; +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 9ffd196be65..2cd5a9d5853 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -5,10 +5,11 @@ use App\Exceptions\Handlers\AccessDBDenied; use App\Exceptions\Handlers\ApplyComposer; use App\Exceptions\Handlers\InvalidPayload; -use App\Exceptions\Handlers\ModelNotFound; use App\Exceptions\Handlers\NoEncryptionKey; -use Exception; +use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\Response; use Throwable; class Handler extends ExceptionHandler @@ -53,19 +54,20 @@ public function report(Throwable $exception) /** * Render an exception into an HTTP response. * - * @param \Illuminate\Http\Request $request - * @param Throwable $exception + * @param Request $request + * @param Throwable $exception + * + * @return Response * - * @return \Illuminate\Http\Response + * @throws Throwable */ - public function render($request, Throwable $exception) + public function render($request, Throwable $exception): Response { $checks = []; $checks[] = new NoEncryptionKey(); $checks[] = new InvalidPayload(); $checks[] = new AccessDBDenied(); $checks[] = new ApplyComposer(); - $checks[] = new ModelNotFound(); foreach ($checks as $check) { if ($check->check($request, $exception)) { diff --git a/app/Exceptions/Handlers/AccessDBDenied.php b/app/Exceptions/Handlers/AccessDBDenied.php index a526a8fbb99..077ee5e3bf4 100644 --- a/app/Exceptions/Handlers/AccessDBDenied.php +++ b/app/Exceptions/Handlers/AccessDBDenied.php @@ -4,8 +4,8 @@ use App\Redirections\ToInstall; use Illuminate\Database\QueryException as QueryException; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Http\Response; use Throwable; class AccessDBDenied @@ -18,17 +18,17 @@ class AccessDBDenied * * @return bool */ - public function check($request, Throwable $exception) + public function check(Request $request, Throwable $exception) { // encryption key does not exist, we need to run the installation - return $exception instanceof QueryException && (strpos($exception->getMessage(), 'Access denied') !== false); + return $exception instanceof QueryException && (str_contains($exception->getMessage(), 'Access denied')); } /** - * @return Response + * @return RedirectResponse */ // @codeCoverageIgnoreStart - public function go() + public function go(): RedirectResponse { return ToInstall::go(); } diff --git a/app/Exceptions/Handlers/ApplyComposer.php b/app/Exceptions/Handlers/ApplyComposer.php index 0601b4b7932..b263de3ed7e 100644 --- a/app/Exceptions/Handlers/ApplyComposer.php +++ b/app/Exceptions/Handlers/ApplyComposer.php @@ -6,7 +6,6 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\View\View; use Throwable; class ApplyComposer @@ -19,16 +18,16 @@ class ApplyComposer * * @return bool */ - public function check($request, Throwable $exception) + public function check(Request $request, Throwable $exception) { - return $exception instanceof ErrorException && (strpos($exception->getFile(), 'laravel/framework/src/Illuminate/Routing/Router.php') !== false); + return $exception instanceof ErrorException && (str_contains($exception->getFile(), 'laravel/framework/src/Illuminate/Routing/Router.php')); } /** - * @return Response|View + * @return Response */ // @codeCoverageIgnoreStart - public function go() + public function go(): Response { return response()->view('error.error', ['code' => '500', 'message' => 'Missing dependency, please do: composer install --no-dev
(or use the release channel.)']); } diff --git a/app/Exceptions/Handlers/InvalidPayload.php b/app/Exceptions/Handlers/InvalidPayload.php index 1950dfd0775..8c55ba41c94 100644 --- a/app/Exceptions/Handlers/InvalidPayload.php +++ b/app/Exceptions/Handlers/InvalidPayload.php @@ -3,8 +3,8 @@ namespace App\Exceptions\Handlers; use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Http\Response; use Throwable; class InvalidPayload @@ -17,16 +17,16 @@ class InvalidPayload * * @return bool */ - public function check($request, Throwable $exception) + public function check(Request $request, Throwable $exception) { return $exception instanceof DecryptException; } /** - * @return Response + * @return JsonResponse */ // @codeCoverageIgnoreStart - public function go() + public function go(): JsonResponse { return response()->json(['error' => 'Session timed out'], 400); } diff --git a/app/Exceptions/Handlers/ModelNotFound.php b/app/Exceptions/Handlers/ModelNotFound.php deleted file mode 100644 index fbbd03ae182..00000000000 --- a/app/Exceptions/Handlers/ModelNotFound.php +++ /dev/null @@ -1,33 +0,0 @@ -json('false', 200); - } - - // @codeCoverageIgnoreEnd -} diff --git a/app/Exceptions/Handlers/NoEncryptionKey.php b/app/Exceptions/Handlers/NoEncryptionKey.php index 55cfae3677c..27d9de59812 100644 --- a/app/Exceptions/Handlers/NoEncryptionKey.php +++ b/app/Exceptions/Handlers/NoEncryptionKey.php @@ -4,9 +4,9 @@ use App\Redirections\ToInstall; use Exception; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\View\View; use RuntimeException; use Throwable; @@ -20,14 +20,14 @@ class NoEncryptionKey * * @return bool */ - public function check($request, Throwable $exception) + public function check(Request $request, Throwable $exception) { // encryption key does not exist, we need to run the installation return $exception instanceof RuntimeException && $exception->getMessage() === 'No application encryption key has been specified.'; } /** - * @return Response|View + * @return Response|RedirectResponse */ // @codeCoverageIgnoreStart public function go() diff --git a/app/Facades/AccessControl.php b/app/Facades/AccessControl.php index ff9690c710a..a287f6cc0c3 100644 --- a/app/Facades/AccessControl.php +++ b/app/Facades/AccessControl.php @@ -14,7 +14,7 @@ * @internal keep the list of documented method in sync with * {@link \App\ModelFunctions\SessionFunctions} * - * @method static void log_as_id() + * @method static void log_as_id(int $userId) * @method static bool is_logged_in() * @method static bool is_admin() * @method static bool can_upload() @@ -26,9 +26,6 @@ * @method static bool noLogin() * @method static bool log_as_user(string $username, string $password, string $ip) * @method static bool log_as_admin(string $username, string $password, string $ip) - * @method static bool has_visible_album($albumID) - * @method static void add_visible_albums($albumIDs) - * @method static array get_visible_albums() * @method static void logout() */ class AccessControl extends Facade diff --git a/app/Facades/Helpers.php b/app/Facades/Helpers.php index 40de5a9d6d4..8f6d5b02eb1 100644 --- a/app/Facades/Helpers.php +++ b/app/Facades/Helpers.php @@ -14,17 +14,17 @@ * * @method static string cacheBusting(string $filePath) * @method static string getDeviceType() - * @method static string generateID() * @method static string trancateIf32(string $id, int $prevShortId = 0) * @method static string getExtension(string $filename, bool $isURI = false) * @method static bool hasPermissions(string $path) - * @method static bool hasFullPermissions(string $path): + * @method static bool hasFullPermissions(string $path) + * @method static string createTemporaryFile(string $extension) * @method static int gcd(int $a, int $b) * @method static string str_of_bool(bool $b) - * @method static string ex2x(string $filename) * @method static int data_index() * @method static int data_index_r() * @method static void data_index_set(int $idx = 0) + * @method static array get_all_licenses() */ class Helpers extends Facade { diff --git a/app/Factories/AlbumFactory.php b/app/Factories/AlbumFactory.php index 3626a86f542..09b24bd4c14 100644 --- a/app/Factories/AlbumFactory.php +++ b/app/Factories/AlbumFactory.php @@ -1,64 +1,161 @@ UnsortedAlbum::class, + StarredAlbum::ID => StarredAlbum::class, + PublicAlbum::ID => PublicAlbum::class, + RecentAlbum::ID => RecentAlbum::class, + ]; + /** - * @var SmartFactory + * Returns an existing instance of an album with the given ID or fails + * with an exception. + * + * @param string $albumId the ID of the requested album + * @param bool $withRelations indicates if the relations of an + * album (i.e. photos and sub-albums, + * if applicable) shall be loaded, too. + * + * @return AbstractAlbum the album for the ID + * + * @throws ModelNotFoundException thrown, if no album with the given ID exists */ - private $smartFactory; - - public function __construct(SmartFactory $smartFactory) + public function findOrFail(string $albumId, bool $withRelations = true): AbstractAlbum { - $this->smartFactory = $smartFactory; + if ($this->isBuiltInSmartAlbum($albumId)) { + return $this->createSmartAlbum($albumId, $withRelations); + } + + return $this->findModelOrFail($albumId, $withRelations); } /** - * In the case of is_smart we forward the call to the smart factory. + * Returns an existing model instance of an album with the given ID or + * fails with an exception. + * + * @param string $albumId the ID of the requested album + * @param bool $withRelations indicates if the relations of an + * album (i.e. photos and sub-albums, + * if applicable) shall be loaded, too. * - * @param string|ing + * @return BaseAlbum the album for the ID + * + * @throws ModelNotFoundException thrown, if no album with the given ID exists + * @noinspection PhpIncompatibleReturnTypeInspection */ - public function is_smart($kind): bool + public function findModelOrFail(string $albumId, bool $withRelations = true): BaseAlbum { - return $this->smartFactory->is_smart($kind); + try { + if ($withRelations) { + return Album::query()->with(['photos', 'children', 'photos.size_variants'])->findOrFail($albumId); + } else { + return Album::query()->findOrFail($albumId); + } + } catch (ModelNotFoundException $e) { + if ($withRelations) { + return TagAlbum::query()->with(['photos'])->findOrFail($albumId); + } else { + return TagAlbum::query()->findOrFail($albumId); + } + } } /** - * @param string $albumID + * Returns a collection of {@link AbstractAlbum} instances whose IDs are + * contained in the given set of IDs. * - * @return Album|SmartAlbum|TagAlbum + * @param string[] $albumIDs a list of IDs + * + * @return Collection a possibly empty list of {@link AbstractAlbum} */ - public function make(string $albumId) + public function findWhereIDsIn(array $albumIDs): Collection { - if ($this->smartFactory->is_smart($albumId)) { - return $this->smartFactory->make($albumId); + $smartAlbumIDs = array_intersect($albumIDs, array_keys(self::BUILTIN_SMARTS)); + $modelAlbumIDs = array_diff($albumIDs, array_keys(self::BUILTIN_SMARTS)); + + $smartAlbums = []; + foreach ($smartAlbumIDs as $smartID) { + $smartAlbums[] = $this->createSmartAlbum($smartID); } - //! We need to catch that one, otherwise it is returned as a 404 by Laravel - $album = Album::findOrFail($albumId); + return new Collection(array_merge( + $smartAlbums, + TagAlbum::query()->findMany($modelAlbumIDs)->all(), + Album::query()->findMany($modelAlbumIDs)->all(), + )); + } - if ($album->smart) { - // we reload it. - return TagAlbum::findOrFail($albumId); + /** + * Returns a collection of {@link \App\SmartAlbums\BaseSmartAlbum} with one instance for each built-in smart album. + * + * @param bool $withRelations Eagerly loads the relation + * {@link BaseSmartAlbum::photos()} + * for each smart album + * + * @return Collection + */ + public function getAllBuiltInSmartAlbums(bool $withRelations = true): Collection + { + $smartAlbums = new Collection(); + foreach (self::BUILTIN_SMARTS as $smartAlbumId => $smartAlbumClass) { + $smartAlbums->push($this->createSmartAlbum($smartAlbumId, $withRelations)); } - return $album; + return $smartAlbums; + } + + /** + * Checks if the given album ID denotes one of the built-in smart albums. + * + * @param string $albumId + * + * @return bool true, if the album ID refers to a built-in smart album + */ + public function isBuiltInSmartAlbum(string $albumId): bool + { + return array_key_exists($albumId, self::BUILTIN_SMARTS); } - public function makeFromTitle(string $title): Album + /** + * Returns the instance of the built-in smart album with the designated ID. + * + * @param string $smartAlbumId the ID of the smart album + * @param bool $withRelations Eagerly loads the relation + * {@link BaseSmartAlbum::photos()} + * for the smart album + * + * @return BaseSmartAlbum + */ + public function createSmartAlbum(string $smartAlbumId, bool $withRelations = true): BaseSmartAlbum { - $album = new Album(); - $album->id = Helpers::generateID(); - $album->title = $title; - $album->description = ''; + if (!$this->isBuiltInSmartAlbum($smartAlbumId)) { + throw new \InvalidArgumentException('given ID does not identify a smart album'); + } + + /** @var BaseSmartAlbum $smartAlbum */ + $smartAlbum = call_user_func([self::BUILTIN_SMARTS[$smartAlbumId], 'getInstance']); + if ($withRelations) { + // Just try to get the photos. + // This loads the relation from DB and caches it. + $ignore = $smartAlbum->photos; + } - return $album; + return $smartAlbum; } } diff --git a/app/Factories/SmartFactory.php b/app/Factories/SmartFactory.php deleted file mode 100644 index 562cdba7af0..00000000000 --- a/app/Factories/SmartFactory.php +++ /dev/null @@ -1,56 +0,0 @@ - UnsortedAlbum::class, - 'starred' => StarredAlbum::class, - 'public' => PublicAlbum::class, - 'recent' => RecentAlbum::class, - ]; - - public function is_smart($kind): bool - { - return array_key_exists($kind, $this->base_smarts); - } - - /** - * Factory method. - */ - public function make(string $kind): SmartAlbum - { - if ($this->is_smart($kind)) { - return resolve($this->base_smarts[$kind]); - } - - if ($kind == 'tag') { - return resolve(TagAlbum::class); - } - - return null; - } - - public function makeAll(): Collection - { - $smartAlbums = new Collection(); - - foreach ($this->base_smarts as $smart_kind => $_) { - $smartAlbums->push($this->make($smart_kind)); - } - - return $smartAlbums; - } -} diff --git a/app/Http/Controllers/Administration/SettingsController.php b/app/Http/Controllers/Administration/SettingsController.php index d641ef541f7..86e53e27431 100644 --- a/app/Http/Controllers/Administration/SettingsController.php +++ b/app/Http/Controllers/Administration/SettingsController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Administration; use App\Actions\Settings\Login; +use App\Exceptions\JsonError; use App\Facades\Helpers; use App\Http\Controllers\Controller; use App\Http\Requests\UserRequests\UsernamePasswordRequest; @@ -25,11 +26,14 @@ class SettingsController extends Controller * To be noted this function will change the CONFIG table if used by admin * or the USER table if used by any other user * - * @param Request $request + * @param UsernamePasswordRequest $request + * @param Login $login * * @return string + * + * @throws JsonError */ - public function setLogin(UsernamePasswordRequest $request, Login $login) + public function setLogin(UsernamePasswordRequest $request, Login $login): string { return $login->do($request) ? 'true' : 'false'; } @@ -350,7 +354,10 @@ public function setCSS(Request $request) */ public function getAll() { - return Configs::orderBy('cat', 'ASC')->get(); + return Configs::query() + ->orderBy('cat') + ->orderBy('id') + ->get(); } /** diff --git a/app/Http/Controllers/Administration/SharingController.php b/app/Http/Controllers/Administration/SharingController.php index 061685d176f..858ea42152e 100644 --- a/app/Http/Controllers/Administration/SharingController.php +++ b/app/Http/Controllers/Administration/SharingController.php @@ -1,7 +1,5 @@ validate([ 'UserIDs' => 'string|required', - 'albumIDs' => 'string|required', + 'albumIDs' => 'required', ]); - $users = User::whereIn('id', explode(',', $request['UserIDs']))->get(); + $albumIDs = explode(',', $request['albumIDs']); + + $users = User::query()->whereIn('id', explode(',', $request['UserIDs']))->get(); + /** @var User $user */ foreach ($users as $user) { - $user->shared()->sync(explode(',', $request['albumIDs']), false); + $user->shared()->sync($albumIDs, false); } return 'true'; diff --git a/app/Http/Controllers/Administration/UserController.php b/app/Http/Controllers/Administration/UserController.php index 1afb3cf8e9f..f4afafd563e 100644 --- a/app/Http/Controllers/Administration/UserController.php +++ b/app/Http/Controllers/Administration/UserController.php @@ -4,18 +4,21 @@ use App\Actions\User\Create; use App\Actions\User\Save; +use App\Exceptions\JsonError; use App\Facades\AccessControl; use App\Http\Controllers\Controller; use App\Http\Requests\UserRequests\UserPostIdRequest; use App\Http\Requests\UserRequests\UserPostRequest; use App\Models\User; -use Illuminate\Http\Request; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Http\Request as IlluminateRequest; +use Illuminate\Http\Response as IlluminateResponse; class UserController extends Controller { - public function list() + public function list(): Collection { - return User::where('id', '>', 0)->get(); + return User::query()->where('id', '>', 0)->get(); } /** @@ -23,14 +26,18 @@ public function list() * Note that an admin can change the password of a user at will. * * @param UserPostRequest $request + * @param Save $save * - * @return string + * @return IlluminateResponse + * + * @throws JsonError */ - public function save(UserPostRequest $request, Save $save) + public function save(UserPostRequest $request, Save $save): IlluminateResponse { - $user = User::findOrFail($request['id']); + /** @var User $user */ + $user = User::query()->findOrFail($request['id']); - return $save->do($user, $request->all()) ? 'true' : 'false'; + return $save->do($user, $request->all()) ? response()->noContent() : response('', 500); } /** @@ -39,43 +46,46 @@ public function save(UserPostRequest $request, Save $save) * * @param UserPostIdRequest $request * - * @return string + * @return IlluminateResponse */ - public function delete(UserPostIdRequest $request) + public function delete(UserPostIdRequest $request): IlluminateResponse { - $user = User::findOrFail($request['id']); + $user = User::query()->findOrFail($request['id']); - return $user->delete() ? 'true' : 'false'; + return $user->delete() ? response()->noContent() : response('', 500); } /** * Create a new user. * - * @param Request $request + * @param IlluminateRequest $request + * @param Create $create * - * @return string + * @return User + * + * @throws JsonError */ - public function create(Request $request, Create $create) + public function create(IlluminateRequest $request, Create $create): User { $data = $request->validate([ 'username' => 'required|string|max:100', 'password' => 'required|string|max:50', - 'upload' => 'required', - 'lock' => 'required', + 'may_upload' => 'present|boolean', + 'is_locked' => 'present|boolean', ]); - return $create->do($data) ? 'true' : 'false'; + return $create->do($data); } /** * Update the email of a user. * Will delete all notifications if the email is left empty. * - * @param Request $request + * @param IlluminateRequest $request * - * @return string + * @return IlluminateResponse */ - public function updateEmail(Request $request, Save $save) + public function updateEmail(IlluminateRequest $request): IlluminateResponse { if ($request->email != '') { $request->validate([ @@ -91,7 +101,7 @@ public function updateEmail(Request $request, Save $save) $user->notifications()->delete(); } - return $user->save() ? 'true' : 'false'; + return $user->save() ? response()->noContent() : response('', 500); } /** @@ -99,7 +109,7 @@ public function updateEmail(Request $request, Save $save) * * @return string */ - public function getEmail() + public function getEmail(): string { $user = AccessControl::user(); diff --git a/app/Http/Controllers/AlbumController.php b/app/Http/Controllers/AlbumController.php index 0dcc7a4b0be..494c94fa3bb 100644 --- a/app/Http/Controllers/AlbumController.php +++ b/app/Http/Controllers/AlbumController.php @@ -1,17 +1,14 @@ validate([ - 'title' => 'string|required|max:100', - 'parent_id' => 'int|nullable', + 'title' => 'required|string|max:100', + 'parent_id' => 'present|nullable|string|size:' . HasRandomID::ID_LENGTH, ]); - $album = $create->create($request['title'], $request['parent_id']); - - return Response::json($album->id, JSON_NUMERIC_CHECK); + return $create->create($request['title'], $request['parent_id']); } /** * Add a new album generated by tags. * - * @param Request $request + * @param IlluminateRequest $request + * @param CreateTagAlbum $create * * @return false|string */ - public function addByTags(Request $request, CreateTag $create) + public function addTagAlbum(IlluminateRequest $request, CreateTagAlbum $create): TagAlbum { $request->validate([ - 'title' => 'string|required|max:100', + 'title' => 'required|string|max:100', 'tags' => 'string', ]); - $album = $create->create($request['title'], $request['tags']); - - return Response::json($album->id, JSON_NUMERIC_CHECK); + return $create->create($request['title'], $request['tags']); } /** * Provided an albumID, returns the album. * - * @param Request $request + * @param AlbumIDRequest $request + * @param AlbumFactory $albumFactory * - * @return array|string + * @return AbstractAlbum */ - public function get(AlbumIDRequest $request, AlbumFactory $albumFactory, Prepare $prepare) + public function get(AlbumIDRequest $request, AlbumFactory $albumFactory): AbstractAlbum { $validated = $request->validated(); - $album = $albumFactory->make($validated['albumID']); - return $prepare->do($album); + return $albumFactory->findOrFail($validated['albumID']); } /** * Provided an albumID, returns the album with only map related data. * - * @param Request $request + * @param AlbumIDRequest $request + * @param PositionData $positionData * - * @return array|string + * @return array */ - public function getPositionData(AlbumIDRequest $request, PositionData $positionData) + public function getPositionData(AlbumIDRequest $request, PositionData $positionData): array { - $validated = $request->validate(['includeSubAlbums' => 'string|required']); + $validated = $request->validate(['includeSubAlbums' => 'required|boolean']); return $positionData->get($request['albumID'], $validated); } /** - * Provided the albumID and passwords, return whether the album can be accessed or not. + * Provided the albumID and password, return whether the album can be accessed or not. * - * @param Request $request + * @param AlbumIDRequest $request + * @param Unlock $unlock * - * @return string + * @return IlluminateResponse */ - public function getPublic(AlbumIDRequest $request, Unlock $unlock) + public function unlock(AlbumIDRequest $request, Unlock $unlock): IlluminateResponse { - $request->validate(['password' => 'string|nullable']); - - return $unlock->do($request['albumID'], $request['password']) ? 'true' : 'false'; + $request->validate(['password' => 'required|string']); + if ($unlock->do($request['albumID'], $request['password'])) { + return response()->noContent(); + } else { + return response('', 403); + } } /** * Provided a title and albumIDs, change the title of the albums. * - * @param Request $request + * @param AlbumIDsRequest $request + * @param SetTitle $setTitle * - * @return string + * @return IlluminateResponse */ - public function setTitle(AlbumIDsRequest $request, SetTitle $setTitle) + public function setTitle(AlbumIDsRequest $request, SetTitle $setTitle): IlluminateResponse { - $request->validate(['title' => 'string|required|max:100']); + $request->validate(['title' => 'required|string|max:100']); + $setTitle->do(explode(',', $request['albumIDs']), $request['title']); - return $setTitle->do(explode(',', $request['albumIDs']), $request['title']) ? 'true' : 'false'; + return response()->noContent(); } /** * Change the sharing properties of the album. * - * @param Request $request + * @param AlbumModelIDRequest $request + * @param SetPublic $setPublic * - * @return bool|string + * @return IlluminateResponse */ - public function setPublic(AlbumIDRequestInt $request, SetPublic $setPublic) + public function setPublic(AlbumModelIDRequest $request, SetPublic $setPublic): IlluminateResponse { $validated = $request->validate([ - 'public' => 'integer|required', - 'visible' => 'integer|required', - 'nsfw' => 'integer|required', - 'downloadable' => 'integer|required', - 'share_button_visible' => 'integer|required', - 'full_photo' => 'integer|required', - 'password' => 'sometimes|string|nullable', + 'is_public' => 'required|boolean', + 'requires_link' => 'required|boolean', + 'is_nsfw' => 'required|boolean', + 'is_downloadable' => 'required|boolean', + 'is_share_button_visible' => 'required|boolean', + 'grants_full_photo' => 'required|boolean', + 'password' => 'sometimes|nullable|string', ]); + $setPublic->do($request['albumID'], $validated); - return $setPublic->do($request['albumID'], $validated) ? 'true' : 'false'; // we should return a 422 or similar + return response()->noContent(); } /** * Change the description of the album. * - * @param Request $request + * @param AlbumModelIDRequest $request + * @param SetDescription $setDescription * - * @return bool|string + * @return IlluminateResponse */ - public function setDescription(AlbumIDRequestInt $request, SetDescription $setDescription) + public function setDescription(AlbumModelIDRequest $request, SetDescription $setDescription): IlluminateResponse { $request->validate(['description' => 'string|nullable|max:1000']); + $setDescription->do($request['albumID'], $request['description'] ?? null); - return $setDescription->do($request['albumID'], $request['description'] ?? '') ? 'true' : 'false'; + return response()->noContent(); } /** * Change show tags of the tag album. * - * @param Request $request + * @param AlbumModelIDRequest $request + * @param SetShowTags $setShowTags * - * @return bool|string + * @return IlluminateResponse */ - public function setShowTags(AlbumIDRequestInt $request, SetShowTags $setShowTags) + public function setShowTags(AlbumModelIDRequest $request, SetShowTags $setShowTags): IlluminateResponse { - $request->validate(['show_tags' => 'string|required|max:1000|min:1']); + $request->validate(['show_tags' => 'required|string|max:1000']); + $setShowTags->do($request['albumID'], $request['show_tags']); - return $setShowTags->do($request['albumID'], $request['show_tags']) ? 'true' : 'false'; + return response()->noContent(); } /** * Set cover image of the album. * - * @param Request $request + * @param AlbumModelIDRequest $request + * @param SetCover $setCover * - * @return bool|string + * @return IlluminateResponse */ - public function setCover(AlbumIDRequestInt $request, SetCover $setCover) + public function setCover(AlbumModelIDRequest $request, SetCover $setCover): IlluminateResponse { $request->validate([ - 'photoID' => 'integer|nullable', + 'photoID' => 'present|nullable|string|size:' . HasRandomID::ID_LENGTH, ]); + $setCover->do($request['albumID'], $request['photoID']); - return $setCover->do($request['albumID'], $request['photoID']) ? 'true' : 'false'; + return response()->noContent(); } /** * Set the license of the Album. * - * @param Request $request + * @param AlbumModelIDRequest $request + * @param SetLicense $setLicense * - * @return string + * @return IlluminateResponse */ - public function setLicense(AlbumIDRequestInt $request, SetLicense $setLicense) + public function setLicense(AlbumModelIDRequest $request, SetLicense $setLicense): IlluminateResponse { - $request->validate(['license' => 'required|string']); - - $licenses = Helpers::get_all_licenses(); - - if (!in_array($request['license'], $licenses, true)) { - Logs::error(__METHOD__, __LINE__, 'License not recognised: ' . $request['license']); - - return Response::error('License not recognised!'); - } + $request->validate([ + 'license' => ['required', 'string', Rule::in(Helpers::get_all_licenses())], + ]); + $setLicense->do($request['albumID'], $request['license']); - return $setLicense->do($request['albumID'], $request['license']) ? 'true' : 'false'; + return response()->noContent(); } /** * Delete the album and all pictures in the album. * - * @param Request $request + * @param AlbumIDsRequest $request + * @param Delete $delete * - * @return string + * @return IlluminateResponse */ - public function delete(AlbumIDsRequest $request, Delete $delete) + public function delete(AlbumIDsRequest $request, Delete $delete): IlluminateResponse { - return $delete->do($request['albumIDs']) ? 'true' : 'false'; + if ($delete->do(explode(',', $request['albumIDs']))) { + return response()->noContent(); + } else { + return response('', 500); + } } /** * Merge albums. The first of the list is the destination of the merge. * - * @param Request $request + * @param AlbumIDsRequest $request + * @param Merge $merge * - * @return string + * @return IlluminateResponse */ - public function merge(AlbumIDsRequest $request, Merge $merge) + public function merge(AlbumIDsRequest $request, Merge $merge): IlluminateResponse { - // Convert to array + $targetAlbumID = $request['albumID']; $albumIDs = explode(',', $request['albumIDs']); - // Get first albumID - $albumID = array_shift($albumIDs); + $merge->do($targetAlbumID, $albumIDs); - return $merge->do($albumID, $albumIDs) ? 'true' : 'false'; + return response()->noContent(); } /** * Move multiple albums into another album. * - * @param Request $request + * @param AlbumIDsRequest $request + * @param Move $move * - * @return string + * @return IlluminateResponse */ - public function move(AlbumIDsRequest $request, Move $move) + public function move(AlbumIDsRequest $request, Move $move): IlluminateResponse { - // Convert to array + $targetAlbumID = $request['albumID']; $albumIDs = explode(',', $request['albumIDs']); + $move->do($targetAlbumID, $albumIDs); - // Get first albumID - $albumID = array_shift($albumIDs); - - return $move->do($albumID, $albumIDs) ? 'true' : 'false'; + return response()->noContent(); } /** * Set if an album contains sensitive pictures. * - * @param Request $request + * @param AlbumIDRequest $request + * @param SetNSFW $setNSFW * - * @return string + * @return IlluminateResponse */ - public function setNSFW(Request $request, SetNSFW $setNSFW) + public function setNSFW(AlbumIDRequest $request, SetNSFW $setNSFW): IlluminateResponse { - $request->validate(['albumID' => 'required|string']); + $setNSFW->do($request['albumID'], true); - return $setNSFW->do($request['albumID'], '_') ? 'true' : 'false'; + return response()->noContent(); } /** * Define the default sorting type. * - * @param Request $request + * @param AlbumModelIDRequest $request + * @param SetSorting $setSorting * - * @return string + * @return IlluminateResponse */ - public function setSorting(AlbumIDRequest $request, SetSorting $setSorting) + public function setSorting(AlbumModelIDRequest $request, SetSorting $setSorting): IlluminateResponse { - $validated = $request->validate([ - 'typePhotos' => 'nullable', - 'orderPhotos' => 'required|string', + $request->validate([ + 'sortingCol' => ['present', Rule::in([ + null, + 'created_at', + 'taken_at', + 'title', + 'description', + 'is_public', + 'is_starred', + 'type', + ])], + 'sortingOrder' => ['present', Rule::in([null, 'ASC', 'DESC'])], ]); - return $setSorting->do($request['albumID'], $validated) ? 'true' : 'false'; + if ($setSorting->do($request['albumID'], $request['sortingCol'], $request['sortingOrder'])) { + return response()->noContent(); + } else { + return response('', 500); + } } /** - * Return the archive of the pictures of the album and its subalbums. + * Return the archive of the pictures of the album and its sub-albums. * - * @param Request $request + * @param AlbumIDsRequest $request + * @param Archive $archive * - * @return string|StreamedResponse + * @return SymfonyResponse */ - public function getArchive(AlbumIDsRequest $request, Archive $archive) + public function getArchive(AlbumIDsRequest $request, Archive $archive): SymfonyResponse { if (Storage::getDefaultDriver() === 's3') { Logs::error(__METHOD__, __LINE__, 'getArchive not implemented for S3'); - return 'false'; + return response('', 501); } $albumIDs = explode(',', $request['albumIDs']); diff --git a/app/Http/Controllers/AlbumsController.php b/app/Http/Controllers/AlbumsController.php index 572f0e426d2..1ea234dde08 100644 --- a/app/Http/Controllers/AlbumsController.php +++ b/app/Http/Controllers/AlbumsController.php @@ -1,11 +1,8 @@ null, + 'smart_albums' => null, 'albums' => null, 'shared_albums' => null, ]; - // $toplevel containts Collection accessible at the root: albums shared_albums. + // $toplevel contains Collection accessible at the root: albums shared_albums. $toplevel = $top->get(); - $return['albums'] = $prepareAlbums->do($toplevel['albums']); - $return['shared_albums'] = $prepareAlbums->do($toplevel['shared_albums']); - - $return['smartalbums'] = $smart->get(); + $return['albums'] = $toplevel['albums']; + $return['shared_albums'] = $toplevel['shared_albums']; + $return['smart_albums'] = $smart->get(); return $return; } @@ -42,15 +38,15 @@ public function get(Top $top, Smart $smart, Prepare $prepareAlbums) /** * @return array as the full tree of visible albums */ - public function tree(Tree $tree) + public function tree(Tree $tree): array { return $tree->get(); } /** - * @return array|string returns an array of photos of all albums or false on failure + * @return array returns an array of visible photos which have positioning data */ - public function getPositionData(PositionData $positionData) + public function getPositionData(PositionData $positionData): array { return $positionData->do(); } diff --git a/app/Http/Controllers/DemoController.php b/app/Http/Controllers/DemoController.php index 5e6495281f2..bdf6fb1f2fb 100644 --- a/app/Http/Controllers/DemoController.php +++ b/app/Http/Controllers/DemoController.php @@ -2,14 +2,14 @@ namespace App\Http\Controllers; -use App\Actions\Album\Prepare; -use App\Actions\Albums\Prepare as AlbumsPrepare; use App\Actions\Albums\Smart; use App\Actions\Albums\Top; use App\Models\Album; use App\Models\Configs; use App\Models\Photo; -use Response; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Response; class DemoController extends Controller { @@ -20,7 +20,7 @@ class DemoController extends Controller * * Call /demo and use the generated code to replace the api.post() function * - * @return \Illuminate\Http\Response|string + * @return Response|RedirectResponse */ public function js() { @@ -47,12 +47,11 @@ public function js() $albums_controller = resolve(AlbumsController::class); $top = resolve(Top::class); $smart = resolve(Smart::class); - $prepareAlbums = resolve(AlbumsPrepare::class); $return_albums = []; $return_albums['name'] = 'Albums::get'; $return_albums['type'] = 'string'; - $return_albums['data'] = json_encode($albums_controller->get($top, $smart, $prepareAlbums)); + $return_albums['data'] = json_encode($albums_controller->get($top, $smart)); $functions[] = $return_albums; @@ -65,29 +64,22 @@ public function js() $return_album_list['kind'] = 'albumID'; $return_album_list['array'] = []; - /** - * @var Collection - */ - $albums = Album::where('public', '=', '1') - ->where('viewable', '=', '1') + /** @var Collection $albums */ + $albums = Album::query() + ->select(['albums.*']) + ->with(['photos', 'photos.size_variants', 'photos.size_variants.sym_links']) + ->join('base_albums', 'base_albums.id', '=', 'albums.id') + ->where('base_albums.is_public', '=', true) + ->where('base_albums.requires_link', '=', false) ->get(); - /* - * @var Album - */ - $prepare = resolve(Prepare::class); + /** @var Album $album */ foreach ($albums as $album) { /** - * Copy paste from Album::get(). + * Copied and pasted from Album::get(). */ - // Get photos - // Get album information - - $return_album_json = $prepare->do($album); - $return_album = []; $return_album['id'] = $album->id; - $return_album['data'] = json_encode($return_album_json); - + $return_album['data'] = json_encode($album->toArray()); $return_album_list['array'][] = $return_album; } @@ -106,8 +98,8 @@ public function js() /** @var Photo $photo */ foreach ($album->photos as $photo) { $return_photo = []; - $return_photo_json = $photo->toReturnArray(); - $return_photo_json['original_album'] = $return_photo_json['album']; + $return_photo_json = $photo->toArray(); + $return_photo_json['original_album'] = $return_photo_json['album_id']; $return_photo_json['album'] = $album->id; $return_photo['id'] = $photo->id; $return_photo['data'] = json_encode($return_photo_json); @@ -119,7 +111,7 @@ public function js() $functions[] = $return_photo_list; $contents = view('demo', ['functions' => $functions]); - $response = Response::make($contents, 200); + $response = \response($contents, 200); $response->header('Content-Type', 'text/plain'); return $response; diff --git a/app/Http/Controllers/FrameController.php b/app/Http/Controllers/FrameController.php index 26f56ce31cc..e795276b2e3 100644 --- a/app/Http/Controllers/FrameController.php +++ b/app/Http/Controllers/FrameController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers; +use App\Facades\Lang; use App\ModelFunctions\ConfigFunctions; use App\Models\Configs; use App\Response; -use Lang; class FrameController extends Controller { diff --git a/app/Http/Controllers/LegacyController.php b/app/Http/Controllers/LegacyController.php new file mode 100644 index 00000000000..5547828c425 --- /dev/null +++ b/app/Http/Controllers/LegacyController.php @@ -0,0 +1,40 @@ +validate([ + 'albumID' => 'sometimes|required_without:photoID|integer', + 'photoID' => 'sometimes|required_without:albumID|integer', + ]); + /** @var int $legacyAlbumID */ + $legacyAlbumID = $request->get('albumID', 0); + /** @var int $legacyPhotoID */ + $legacyPhotoID = $request->get('photoID', 0); + + $return = []; + if ($legacyAlbumID !== 0) { + $return['albumID'] = Legacy::isLegacyModelID($legacyAlbumID) ? + Legacy::translateLegacyAlbumID($legacyAlbumID, $request) : + null; + } + if ($legacyPhotoID !== 0) { + $return['photoID'] = Legacy::isLegacyModelID($legacyPhotoID) ? + Legacy::translateLegacyPhotoID($legacyPhotoID, $request) : + null; + } + + return $return; + } +} diff --git a/app/Http/Controllers/PhotoController.php b/app/Http/Controllers/PhotoController.php index faafef5a852..a0b058f206b 100644 --- a/app/Http/Controllers/PhotoController.php +++ b/app/Http/Controllers/PhotoController.php @@ -1,14 +1,12 @@ findOrFail($request['photoID']); - - return $prepare->do($photo); + return Photo::query() + ->with(['size_variants', 'size_variants.sym_links']) + ->findOrFail($request['photoID']); } /** * Return a random public photo (starred) * This is used in the Frame Controller. * - * @return array + * @param Random $random + * + * @return Photo + * + * @throws JsonError */ - public function getRandom(Random $random) + public function getRandom(Random $random): Photo { return $random->do(); } @@ -78,49 +79,41 @@ public function getRandom(Random $random) /** * Add a function given an AlbumID. * - * @param Request $request + * @param AlbumIDRequest $request * - * @return false|string + * @return Photo + * + * @throws FolderIsNotWritable + * @throws JsonError */ - public function add(AlbumIDRequest $request, Create $create) + public function add(AlbumIDRequest $request): Photo { - try { - $request->validate(['0' => 'required']); - } catch (ValidationException $e) { - return Response::error('validation failed'); - } - - if (!$request->hasfile('0')) { - return Response::error('missing files'); - } - + $request->validate(['0' => 'required|file']); // Only process the first photo in the array + /** @var UploadedFile $file */ $file = $request->file('0'); + $sourceFileInfo = SourceFileInfo::createForUploadedFile($file); + $albumID = $request['albumID']; - $nameFile = []; - $nameFile['name'] = $file->getClientOriginalName(); - $nameFile['type'] = $file->getMimeType(); - $nameFile['tmp_name'] = $file->getPathName(); - - try { - $res = $create->add($nameFile, $request['albumID'], false, (Configs::get_value('skip_duplicates', '0') === '1')); - } catch (JsonWarning $e) { - $res = $e->render(); - } catch (JsonError $e) { - $res = $e->render(); - } + // If the file has been uploaded, the (temporary) source file shall be + // deleted + $create = new Create(new ImportMode( + is_uploaded_file($sourceFileInfo->getTmpFullPath()), + Configs::get_value('skip_duplicates', '0') === '1' + )); - return $res; + return $create->add($sourceFileInfo, $albumID); } /** * Change the title of a photo. * - * @param Request $request + * @param PhotoIDsRequest $request + * @param SetTitle $setTitle * * @return string */ - public function setTitle(PhotoIDsRequest $request, SetTitle $setTitle) + public function setTitle(PhotoIDsRequest $request, SetTitle $setTitle): string { $request->validate(['title' => 'required|string|max:100']); @@ -130,23 +123,25 @@ public function setTitle(PhotoIDsRequest $request, SetTitle $setTitle) /** * Set if a photo is a favorite. * - * @param Request $request + * @param PhotoIDsRequest $request + * @param SetStar $setStar * * @return string */ - public function setStar(PhotoIDsRequest $request, SetStar $setStar) + public function setStar(PhotoIDsRequest $request, SetStar $setStar): string { - return $setStar->do(explode(',', $request['photoIDs']), $request['title']) ? 'true' : 'false'; + return $setStar->do(explode(',', $request['photoIDs'])) ? 'true' : 'false'; } /** * Set the description of a photo. * - * @param Request $request + * @param PhotoIDRequest $request + * @param SetDescription $setDescription * * @return string */ - public function setDescription(PhotoIDRequest $request, SetDescription $setDescription) + public function setDescription(PhotoIDRequest $request, SetDescription $setDescription): string { $request->validate(['description' => 'string|nullable']); @@ -158,11 +153,12 @@ public function setDescription(PhotoIDRequest $request, SetDescription $setDescr * We do not advise the use of this and would rather see people use albums visibility * This would highly simplify the code if we remove this. Do we really want to keep it ? * - * @param Request $request + * @param PhotoIDRequest $request + * @param SetPublic $setPublic * * @return string */ - public function setPublic(PhotoIDRequest $request, SetPublic $setPublic) + public function setPublic(PhotoIDRequest $request, SetPublic $setPublic): string { return $setPublic->do($request['photoID']) ? 'true' : 'false'; } @@ -170,11 +166,12 @@ public function setPublic(PhotoIDRequest $request, SetPublic $setPublic) /** * Set the tags of a photo. * - * @param Request $request + * @param PhotoIDsRequest $request + * @param SetTags $setTags * * @return string */ - public function setTags(PhotoIDsRequest $request, SetTags $setTags) + public function setTags(PhotoIDsRequest $request, SetTags $setTags): string { $request->validate(['tags' => 'string|nullable']); @@ -184,74 +181,81 @@ public function setTags(PhotoIDsRequest $request, SetTags $setTags) /** * Define the album of a photo. * - * @param Request $request + * @param PhotoIDsRequest $request + * @param SetAlbum $setAlbum * * @return string */ - public function setAlbum(PhotoIDsRequest $request, SetAlbum $setAlbum) + public function setAlbum(PhotoIDsRequest $request, SetAlbum $setAlbum): string { - $request->validate(['albumID' => 'required|string']); + $request->validate(['albumID' => ['present', new ModelIDRule()]]); return $setAlbum->execute(explode(',', $request['photoIDs']), $request['albumID']) ? 'true' : 'false'; } /** - * Define the license of the photo. + * Sets the license of the photo. * - * @param Request $request + * @param PhotoIDRequest $request + * @param SetLicense $setLicense * - * @return false|string + * @return IlluminateResponse */ - public function setLicense(PhotoIDRequest $request, SetLicense $setLicense) + public function setLicense(PhotoIDRequest $request, SetLicense $setLicense): IlluminateResponse { - $request->validate(['license' => 'required|string']); - $licenses = Helpers::get_all_licenses(); + $request->validate([ + 'license' => [ + 'string', + 'required', + Rule::in($licenses), + ], + ]); - if (!in_array($request['license'], $licenses, true)) { - Logs::error(__METHOD__, __LINE__, 'License not recognised: ' . $request['license']); - - return Response::error('License not recognised!'); - } + $setLicense->do($request['photoID'], $request['license']); - return $setLicense->do($request['photoID'], $request['license']) ? 'true' : 'false'; + return response()->noContent(); } /** - * Delete a photo. + * Delete one or more photos. * - * @param Request $request + * @param PhotoIDsRequest $request + * @param Delete $delete * - * @return string + * @return IlluminateResponse */ - public function delete(PhotoIDsRequest $request, Delete $delete) + public function delete(PhotoIDsRequest $request, Delete $delete): IlluminateResponse { - return $delete->do(explode(',', $request['photoIDs'])) ? 'true' : 'false'; + $delete->do(explode(',', $request['photoIDs'])); + + return response()->noContent(); } /** - * Duplicate a photo. + * Duplicates a set of photos. * Only the SQL entry is duplicated for space reason. * - * @param Request $request + * @param PhotoIDsRequest $request + * @param Duplicate $duplicate * - * @return string + * @return Photo|Collection the duplicated photo or collection of duplicated photos */ public function duplicate(PhotoIDsRequest $request, Duplicate $duplicate) { - $request->validate(['albumID' => 'string']); - - $duplicate->do(explode(',', $request['photoIDs']), $request['albumID'] ?? null); + $request->validate(['albumID' => ['present', new ModelIDRule()]]); + $duplicates = $duplicate->do(explode(',', $request['photoIDs']), $request['albumID']); - return 'true'; + return ($duplicates->count() === 1) ? $duplicates->first() : $duplicates; } /** * Return the archive of pictures or just a picture if only one. * - * @param Request $request + * @param PhotoIDsRequest $request + * @param Archive $archive * - * @return StreamedResponse|Response|string|void + * @return SymfonyResponse|string */ public function getArchive(PhotoIDsRequest $request, Archive $archive) { @@ -284,7 +288,7 @@ public function getArchive(PhotoIDsRequest $request, Archive $archive) * * @throws \Exception */ - public function clearSymLink() + public function clearSymLink(): string { return $this->symLinkFunctions->clearSymLink(); } diff --git a/app/Http/Controllers/PhotoEditorController.php b/app/Http/Controllers/PhotoEditorController.php index 0bfb4329b31..5b0561ede17 100644 --- a/app/Http/Controllers/PhotoEditorController.php +++ b/app/Http/Controllers/PhotoEditorController.php @@ -1,14 +1,13 @@ validate(['direction' => 'integer|required']); - - $photo = Photo::findOrFail($request['photoID']); - - if (!$rotate->do($photo, intval($request['direction']))) { - return 'false'; + throw new UnprocessableEntityHttpException('support for rotation disabled by configuration'); } - - return $prepare->do($photo); + $request->validate([ + 'direction' => [ + 'integer', + 'required', + Rule::in([-1, 1]), + ], + ]); + /** @var Photo $photo */ + $photo = Photo::query() + ->with(['size_variants']) + ->findOrFail($request['photoID']); + + return $rotate->do($photo, intval($request['direction'])); } } diff --git a/app/Http/Controllers/RedirectController.php b/app/Http/Controllers/RedirectController.php index 13654257400..89c7ea03084 100644 --- a/app/Http/Controllers/RedirectController.php +++ b/app/Http/Controllers/RedirectController.php @@ -1,22 +1,29 @@ filled('password')) { if (Configs::get_value('unlock_password_photos_with_url_param', '0') == '1') { $unlock->propagate($request['password']); } else { - $unlock->do($albumid, $request['password']); + $unlock->do($albumID, $request['password']); } } } @@ -25,26 +32,47 @@ private function passwordManagement(Request $request, $albumid, Unlock $unlock) * Trivial redirection. * * @param Request $request - * @param string $albumid + * @param string $albumID */ - public function album(Request $request, $albumid, Unlock $unlock) + public function album(Request $request, string $albumID, Unlock $unlock): SymfonyResponse { - $this->passwordManagement($request, $albumid, $unlock); + if (Legacy::isLegacyModelID($albumID)) { + $albumID = Legacy::translateLegacyAlbumID($albumID, $request); + if ($albumID === null) { + abort(SymfonyResponse::HTTP_NOT_FOUND); + } + } - return redirect('gallery#' . $albumid); + $this->passwordManagement($request, $albumID, $unlock); + + return redirect('gallery#' . $albumID); } /** * Trivial redirection. * * @param Request $request - * @param string $albumid - * @param string $photoid + * @param string $albumID + * @param string $photoID */ - public function photo(Request $request, $albumid, $photoid, Unlock $unlock) + public function photo(Request $request, string $albumID, string $photoID, Unlock $unlock): SymfonyResponse { - $this->passwordManagement($request, $albumid, $unlock); + if (Legacy::isLegacyModelID($albumID)) { + $albumID = Legacy::translateLegacyAlbumID($albumID, $request); + if ($albumID === null) { + abort(SymfonyResponse::HTTP_NOT_FOUND); + } + } + + if (Legacy::isLegacyModelID($photoID)) { + $photoID = Legacy::translateLegacyPhotoID($photoID, $request); + if ($photoID === null) { + abort(SymfonyResponse::HTTP_NOT_FOUND); + } + } + + $this->passwordManagement($request, $albumID, $unlock); - return redirect('gallery#' . $albumid . '/' . $photoid); + return redirect('gallery#' . $albumID . '/' . $photoID); } } diff --git a/app/Http/Controllers/SessionController.php b/app/Http/Controllers/SessionController.php index 7a0659151c3..0d84bca0025 100644 --- a/app/Http/Controllers/SessionController.php +++ b/app/Http/Controllers/SessionController.php @@ -1,33 +1,24 @@ configFunctions->admin(); $return['config']['location'] = base_path('public/'); } else { - $user = User::find($user_id); + /** @var User $user */ + $user = User::query()->find($user_id); if ($user == null) { Logs::notice(__METHOD__, __LINE__, 'UserID ' . $user_id . ' not found!'); @@ -78,13 +70,13 @@ public function init() $return['status'] = Config::get('defines.status.LYCHEE_STATUS_LOGGEDIN'); $return['config'] = $this->configFunctions->public(); - $return['lock'] = ($user->lock == '1'); // can user change their password - $return['upload'] = ($user->upload == '1'); // can user upload ? + $return['is_locked'] = $user->is_locked; // can user change their password ? + $return['may_upload'] = $user->may_upload; // can user upload ? $return['username'] = $user->username; } } - // here we say whether we looged in because there is no login/password or if we actually entered a login/password + // here we say whether we logged in because there is no login/password or if we actually entered a login/password $return['config']['login'] = $logged_in; $return['config']['lang_available'] = Lang::get_lang_available(); } else { @@ -114,49 +106,49 @@ public function init() /** * Login tentative. * - * @param Request $request + * @param UsernamePasswordRequest $request * - * @return string + * @return IlluminateResponse */ - public function login(UsernamePasswordRequest $request) + public function login(UsernamePasswordRequest $request): IlluminateResponse { // No login if (AccessControl::noLogin() === true) { Logs::warning(__METHOD__, __LINE__, 'DEFAULT LOGIN!'); - return 'true'; + return response()->noContent(); } // this is probably sensitive to timing attacks... if (AccessControl::log_as_admin($request['username'], $request['password'], $request->ip()) === true) { - return 'true'; + return response()->noContent(); } if (AccessControl::log_as_user($request['username'], $request['password'], $request->ip()) === true) { - return 'true'; + return response()->noContent(); } Logs::error(__METHOD__, __LINE__, 'User (' . $request['username'] . ') has tried to log in from ' . $request->ip()); - return 'false'; + return response('', 401); } /** * Unset the session values. * - * @return bool returns true when logout was successful + * @return IlluminateResponse */ - public function logout() + public function logout(): IlluminateResponse { Session::flush(); - return 'true'; + return response()->noContent(); } /** * Show the session values. */ - public function show() + public function show(): void { dd(Session::all()); } diff --git a/app/Http/Controllers/ViewController.php b/app/Http/Controllers/ViewController.php index 4b3756a1cf6..875b890e4a8 100644 --- a/app/Http/Controllers/ViewController.php +++ b/app/Http/Controllers/ViewController.php @@ -1,74 +1,69 @@ middleware([]); - } - /** * View is only used when sharing a single picture. * * @param Request $request * - * @return View|void + * @return View|RedirectResponse */ - public function view(Request $request) + public function view(Request $request): View|RedirectResponse { $request->validate([ 'p' => 'required', ]); + $photoID = $request->get('p'); + if (Legacy::isLegacyModelID($photoID)) { + $photoID = Legacy::translateLegacyPhotoID($photoID, $request); + if ($photoID === null) { + abort(SymfonyResponse::HTTP_NOT_FOUND); + } else { + return redirect()->route('view', ['p' => $photoID]); + } + } + /** @var Photo $photo */ - $photo = Photo::find($request->get('p')); + $photo = Photo::with(['album', 'size_variants', 'size_variants.sym_links']) + ->find($photoID); if ($photo == null) { Logs::error(__METHOD__, __LINE__, 'Could not find photo in database'); - return abort(404); + return abort(SymfonyResponse::HTTP_NOT_FOUND); } + // TODO: Instead of re-coding the logic here whether an photo is visible or not, the query for a photo above, should be filtered with `PhotoAuthorisationProvider` + // is the picture public ? - $public = $photo->public == '1'; + $public = $photo->is_public || ($photo->album_id && $photo->album->is_public); - // is the album (if exist) public ? - if ($photo->album_id != null) { - $public = $photo->album->public == '1' || $public; - } // return 403 if not allowed if (!$public) { - return abort(403); + return abort(SymfonyResponse::HTTP_FORBIDDEN); } - if ($photo->medium == '1') { - $dir = 'medium'; - } else { - $dir = 'big'; - } + $sizeVariant = $photo->size_variants->getMedium() ?: $photo->size_variants->getOriginal(); $title = Configs::get_value('site_title', Config::get('defines.defaults.SITE_TITLE')); $rss_enable = Configs::get_value('rss_enable', '0') == '1'; $url = config('app.url') . $request->server->get('REQUEST_URI'); - $picture = config('app.url') . '/uploads/' . $dir . '/' . $photo->url; + $picture = $sizeVariant->url; return view('view', [ 'url' => $url, diff --git a/app/Http/Livewire/Album.php b/app/Http/Livewire/Album.php index 469300adca6..f11a4d47640 100644 --- a/app/Http/Livewire/Album.php +++ b/app/Http/Livewire/Album.php @@ -2,9 +2,8 @@ namespace App\Http\Livewire; -use App\Actions\Album\Photos; -use App\Actions\Albums\Extensions\PublicIds; use App\Factories\AlbumFactory; +use App\Models\Album as AlbumModel; use App\Models\Configs; use Livewire\Component; @@ -14,46 +13,27 @@ class Album extends Component public const MASONRY = 'masonry'; public const SQUARE = 'square'; - /** - * @var string - */ - public $layout = Album::MASONRY; - - /** - * @var int - */ - public $albumId; - - /** - * @var Album - */ - public $album; - + public string $layout = Album::MASONRY; + public int $albumId; + public AlbumModel $album; /** * @var array (for now) */ - public $info; - + public array $info; /** * @var array (for now) */ - public $photos; - - /** - * @var AlbumFactory - */ - private $albumFactory; + public array $photos; - private $photosAction; + private AlbumFactory $albumFactory; - public function mount($album, AlbumFactory $albumFactory, Photos $photosAction) + public function mount(AlbumModel $album, AlbumFactory $albumFactory) { $this->album = $album; $this->info = []; $this->info['albums'] = []; $this->albumFactory = $albumFactory; - $this->photosAction = $photosAction; } public function render() @@ -72,23 +52,7 @@ public function render() $this->layout = Album::FLKR; } - if ($this->album->smart) { - $publicAlbums = resolve(PublicIds::class)->getPublicAlbumsId(); - $this->album->setAlbumIDs($publicAlbums); - } else { - // we only do this when not in smart mode (i.e. no sub albums) - // that way we limit the number of times we have to query. - resolve(PublicIds::class)->setAlbum($this->album); - } - $this->info = $this->album->toReturnArray(); - - // take care of sub albums - $this->info['albums'] = $this->album->get_children()->map(fn ($a) => $a->toReturnArray())->values(); - - // take care of photos - $this->photos = $this->photosAction->get($this->album); - $this->info['id'] = strval($this->album->id); - $this->info['num'] = strval(count($this->photos)); + $this->info = $this->album->toArray(); return view('livewire.album'); } diff --git a/app/Http/Livewire/Albums.php b/app/Http/Livewire/Albums.php index cc781b814af..ed59e2e737e 100644 --- a/app/Http/Livewire/Albums.php +++ b/app/Http/Livewire/Albums.php @@ -2,7 +2,6 @@ namespace App\Http\Livewire; -use App\Actions\Albums\Prepare; use App\Actions\Albums\Smart; use App\Actions\Albums\Top; use Livewire\Component; @@ -13,9 +12,6 @@ class Albums extends Component public $smartalbums; public $shared_albums; - /** @var Prepare */ - private $prepareAlbum; - /** @var Top */ private $top; @@ -25,20 +21,17 @@ class Albums extends Component /** * Initialize component. * - * @param AlbumsFunctions $albumsFunctions - * @param Top $top - * @param Smart $smart + * @param Top $top + * @param Smart $smart */ public function mount( - Prepare $prepareAlbum, Top $top, Smart $smart ) { - $this->prepareAlbum = $prepareAlbum; $this->top = $top; $this->smart = $smart; - // $toplevel containts Collection accessible at the root: albums shared_albums. + // $toplevel contains Collection accessible at the root: albums shared_albums. $toplevel = $this->top->get(); $this->albums = $this->prepareAlbum->do($toplevel['albums']); diff --git a/app/Http/Livewire/Fullpage.php b/app/Http/Livewire/Fullpage.php index 0b758ac0f38..1bc629704a3 100644 --- a/app/Http/Livewire/Fullpage.php +++ b/app/Http/Livewire/Fullpage.php @@ -2,11 +2,11 @@ namespace App\Http\Livewire; +use App\Contracts\AbstractAlbum; use App\Factories\AlbumFactory; use App\Models\Album; use App\Models\Photo; -use App\SmartAlbums\SmartAlbum; -use App\SmartAlbums\TagAlbum; +use App\SmartAlbums\BaseSmartAlbum; use Livewire\Component; class Fullpage extends Component @@ -15,15 +15,8 @@ class Fullpage extends Component * @var */ public $mode; - /** - * @var Photo - */ - public $photo = null; - - /** - * @var Album|SmartAlbum|TagAlbum - */ - public $album = null; + public ?Photo $photo = null; + public ?AbstractAlbum $album = null; protected $listeners = ['openAlbum', 'openPhoto', 'back']; @@ -34,7 +27,7 @@ public function mount($albumId = null, $photoId = null) $this->mode = 'albums'; } else { $this->mode = 'album'; - $this->album = $albumFactory->make($albumId); + $this->album = $albumFactory->findOrFail($albumId); if ($photoId != null) { $this->mode = 'photo'; @@ -61,11 +54,11 @@ public function back() return redirect('/livewire/' . $this->album->id); } if ($this->album != null) { - if ($this->album->is_smart()) { + if ($this->album instanceof BaseSmartAlbum) { // $this->album = null; return redirect('/livewire/'); } - if ($this->album->parent_id != null) { + if ($this->album instanceof Album && $this->album->parent_id != null) { return redirect('/livewire/' . $this->album->parent_id); } diff --git a/app/Http/Livewire/PhotoOverlay.php b/app/Http/Livewire/PhotoOverlay.php index c85dedcf296..865173682b0 100644 --- a/app/Http/Livewire/PhotoOverlay.php +++ b/app/Http/Livewire/PhotoOverlay.php @@ -2,9 +2,9 @@ namespace App\Http\Livewire; +use App\Facades\Lang; use App\Models\Configs; use Illuminate\Support\Str; -use Lang; use Livewire\Component; class PhotoOverlay extends Component diff --git a/app/Http/Livewire/Sidebar.php b/app/Http/Livewire/Sidebar.php index dc491ea6b00..9590dbd8ef9 100644 --- a/app/Http/Livewire/Sidebar.php +++ b/app/Http/Livewire/Sidebar.php @@ -5,6 +5,7 @@ use AccessControl; use App\Models\Album; use App\Models\Photo; +use App\Models\TagAlbum; use DebugBar; use Lang; use Livewire\Component; @@ -42,7 +43,7 @@ public function generateAlbumStructure() ['head' => Lang::get('ALBUM_DESCRIPTION'), 'value' => $this->album->description]; } - if ($this->album->is_tag_album()) { + if ($this->album instanceof TagAlbum) { $basic->content[] = ['head' => Lang::get('ALBUM_SHOW_TAGS'), 'value' => $this->album->showtags]; } @@ -76,11 +77,11 @@ public function generateAlbumStructure() $share = new \stdClass(); $share->title = Lang::get('ALBUM_SHARING'); - $_public = $this->album->is_public() ? Lang::get('ALBUM_SHR_YES') : Lang::get('ALBUM_SHR_NO'); - $_hidden = $this->album->viewable == '0' ? Lang::get('ALBUM_SHR_YES') : Lang::get('ALBUM_SHR_NO'); // TODO : double check; - $_downloadable = $this->album->is_downloadable() ? Lang::get('ALBUM_SHR_YES') : Lang::get('ALBUM_SHR_NO'); - $_share_button_visible = $this->album->is_share_button_visible() ? Lang::get('ALBUM_SHR_YES') : Lang::get('ALBUM_SHR_NO'); - $_password = $this->album->password != '' ? Lang::get('ALBUM_SHR_YES') : Lang::get('ALBUM_SHR_NO'); + $_public = $this->album->is_public ? Lang::get('ALBUM_SHR_YES') : Lang::get('ALBUM_SHR_NO'); + $_hidden = $this->album->requires_link ? Lang::get('ALBUM_SHR_YES') : Lang::get('ALBUM_SHR_NO'); // TODO : double check; + $_downloadable = $this->album->is_downloadable ? Lang::get('ALBUM_SHR_YES') : Lang::get('ALBUM_SHR_NO'); + $_share_button_visible = $this->album->is_share_button_visible ? Lang::get('ALBUM_SHR_YES') : Lang::get('ALBUM_SHR_NO'); + $_password = $this->album->has_password ? Lang::get('ALBUM_SHR_YES') : Lang::get('ALBUM_SHR_NO'); $share->content = [ ['head' => Lang::get('ALBUM_PUBLIC'), 'value' => $_public], ['head' => Lang::get('ALBUM_HIDDEN'), 'value' => $_hidden], @@ -95,7 +96,7 @@ public function generateAlbumStructure() $license = new \stdClass(); $license->title = Lang::get('ALBUM_REUSE'); $license->content = [ - ['head' => Lang::get('ALBUM_LICENSE'), 'value' => $this->album->get_license()], + ['head' => Lang::get('ALBUM_LICENSE'), 'value' => $this->album->license], ]; $this->data = [$basic, $album, $license]; diff --git a/app/Http/Middleware/ReadCheck.php b/app/Http/Middleware/ReadCheck.php index 839643ff075..ac6bf4bb317 100644 --- a/app/Http/Middleware/ReadCheck.php +++ b/app/Http/Middleware/ReadCheck.php @@ -1,25 +1,21 @@ readAccessFunctions = $readAccessFunctions; + $this->albumAuthorisationProvider = $albumAuthorisationProvider; + $this->photoAuthorisationProvider = $photoAuthorisationProvider; } /** @@ -40,17 +36,8 @@ public function handle($request, Closure $next) $albumIDs[] = $request['albumID']; } foreach ($albumIDs as $albumID) { - $sess = $this->readAccessFunctions->albumID($albumID); - if ($sess === 0) { - Logs::error(__METHOD__, __LINE__, 'Could not find specified album'); - - return response('false'); - } - if ($sess === 2) { - return response('"Warning: Album private!"'); - } - if ($sess === 3) { - return response('"Warning: Wrong password!"'); + if (!$this->albumAuthorisationProvider->isAccessibleByID($albumID)) { + return response('', 403); } } @@ -62,14 +49,8 @@ public function handle($request, Closure $next) $photoIDs[] = $request['photoID']; } foreach ($photoIDs as $photoID) { - $photo = Photo::with('album')->find($photoID); - if ($photo === null) { - Logs::error(__METHOD__, __LINE__, 'Could not find specified photo'); - - return response('false'); - } - if ($this->readAccessFunctions->photo($photo) === false) { - return response('false'); + if (!$this->photoAuthorisationProvider->isVisible($photoID)) { + return response('', 403); } } diff --git a/app/Http/Middleware/UploadCheck.php b/app/Http/Middleware/UploadCheck.php index ee7704b94e4..ba129917998 100644 --- a/app/Http/Middleware/UploadCheck.php +++ b/app/Http/Middleware/UploadCheck.php @@ -1,28 +1,27 @@ albumFactory = $albumFactory; + private AlbumAuthorisationProvider $albumAuthorisationProvider; + private PhotoAuthorisationProvider $photoAuthorisationProvider; + + public function __construct( + AlbumAuthorisationProvider $albumAuthorisationProvider, + PhotoAuthorisationProvider $photoAuthorisationProvider + ) { + $this->albumAuthorisationProvider = $albumAuthorisationProvider; + $this->photoAuthorisationProvider = $photoAuthorisationProvider; } /** @@ -37,7 +36,7 @@ public function handle(Request $request, Closure $next) { // not logged! if (!AccessControl::is_logged_in()) { - return response('false'); + return response('', 401); } // is admin @@ -48,24 +47,24 @@ public function handle(Request $request, Closure $next) $user = AccessControl::user(); // is not admin and does not have upload rights - if (!$user->upload) { - return response('false'); + if (!$user->may_upload) { + return response('', 403); } - $ret = $this->album_check($request, $user->id); + $ret = $this->album_check($request); if ($ret === false) { - return response('false'); + return response('', 403); } $ret = $this->photo_check($request, $user->id); if ($ret === false) { - return response('false'); + return response('', 403); } // Only used for /api/Sharing::Delete $ret = $this->share_check($request, $user->id); if ($ret === false) { - return response('false'); + return response('', 403); } return $next($request); @@ -74,12 +73,11 @@ public function handle(Request $request, Closure $next) /** * Take of checking if a user can actually modify that Album. * - * @param $request - * @param int $user_id + * @param Request $request * - * @return ResponseFactory|Response|mixed + * @return bool */ - public function album_check(Request $request, int $user_id) + private function album_check(Request $request): bool { $albumIDs = []; if ($request->has('albumIDs')) { @@ -92,39 +90,17 @@ public function album_check(Request $request, int $user_id) $albumIDs[] = $request['parent_id']; } - // Remove smart albums (they get a pass). - for ($i = 0; $i < count($albumIDs);) { - if ($this->albumFactory->is_smart($albumIDs[$i]) || $albumIDs[$i] === '0') { - array_splice($albumIDs, $i, 1); - } else { - $i++; - } - } - - // Since we count the result we need to ensure no duplicates. - $albumIDs = array_unique($albumIDs); - - if (count($albumIDs) > 0) { - $count = Album::whereIn('id', $albumIDs)->where('owner_id', '=', $user_id)->count(); - if ($count !== count($albumIDs)) { - Logs::error(__METHOD__, __LINE__, 'Albums not found or ownership mismatch!'); - - return false; - } - } - - return true; + return $this->albumAuthorisationProvider->areEditable($albumIDs); } /** * Check if the user is authorized to do anything to that picture. * * @param Request $request - * @param int $user_id * - * @return ResponseFactory|Response|mixed + * @return bool */ - public function photo_check(Request $request, int $user_id) + private function photo_check(Request $request): bool { $photoIDs = []; if ($request->has('photoIDs')) { @@ -134,19 +110,7 @@ public function photo_check(Request $request, int $user_id) $photoIDs[] = $request['photoID']; } - // Since we count the result we need to ensure no duplicates. - $photoIDs = array_unique($photoIDs); - - if (count($photoIDs) > 0) { - $count = Photo::whereIn('id', $photoIDs)->where('owner_id', '=', $user_id)->count(); - if ($count !== count($photoIDs)) { - Logs::error(__METHOD__, __LINE__, 'Photos not found or ownership mismatch!'); - - return false; - } - } - - return true; + return $this->photoAuthorisationProvider->areEditable($photoIDs); } /** @@ -155,14 +119,14 @@ public function photo_check(Request $request, int $user_id) * * @return bool */ - public function share_check(Request $request, int $user_id) + private function share_check(Request $request, int $user_id): bool { if ($request->has('ShareIDs')) { $shareIDs = $request['ShareIDs']; - $albums = Album::whereIn('id', function (Builder $query) use ($shareIDs) { + $albums = Album::query()->whereIn('id', function (Builder $query) use ($shareIDs) { $query->select('album_id') - ->from('user_album') + ->from('user_base_album') ->whereIn('id', explode(',', $shareIDs)); })->select('owner_id')->get(); @@ -182,6 +146,8 @@ public function share_check(Request $request, int $user_id) Logs::error(__METHOD__, __LINE__, 'Album ownership mismatch!'); return false; + } else { + return true; } } } diff --git a/app/Http/Requests/AlbumRequests/AlbumIDRequest.php b/app/Http/Requests/AlbumRequests/AlbumIDRequest.php index 0e8fdfbdca3..748dd3c253d 100644 --- a/app/Http/Requests/AlbumRequests/AlbumIDRequest.php +++ b/app/Http/Requests/AlbumRequests/AlbumIDRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\AlbumRequests; +use App\Rules\AlbumIDRule; use Illuminate\Foundation\Http\FormRequest; class AlbumIDRequest extends FormRequest @@ -11,7 +12,7 @@ class AlbumIDRequest extends FormRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return true; } @@ -21,10 +22,8 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { - return [ - 'albumID' => 'required|string', - ]; + return ['albumID' => ['present', new AlbumIDRule()]]; } } diff --git a/app/Http/Requests/AlbumRequests/AlbumIDsRequest.php b/app/Http/Requests/AlbumRequests/AlbumIDsRequest.php index 66b568496d8..bf626ca5087 100644 --- a/app/Http/Requests/AlbumRequests/AlbumIDsRequest.php +++ b/app/Http/Requests/AlbumRequests/AlbumIDsRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\AlbumRequests; +use App\Rules\AlbumIDListRule; use Illuminate\Foundation\Http\FormRequest; class AlbumIDsRequest extends FormRequest @@ -11,7 +12,7 @@ class AlbumIDsRequest extends FormRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return true; } @@ -21,10 +22,8 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { - return [ - 'albumIDs' => 'required|string', - ]; + return ['albumIDs' => ['required', new AlbumIDListRule()]]; } } diff --git a/app/Http/Requests/AlbumRequests/AlbumIDRequestInt.php b/app/Http/Requests/AlbumRequests/AlbumModelIDRequest.php similarity index 61% rename from app/Http/Requests/AlbumRequests/AlbumIDRequestInt.php rename to app/Http/Requests/AlbumRequests/AlbumModelIDRequest.php index b50b3175715..3be37f6137d 100644 --- a/app/Http/Requests/AlbumRequests/AlbumIDRequestInt.php +++ b/app/Http/Requests/AlbumRequests/AlbumModelIDRequest.php @@ -2,16 +2,17 @@ namespace App\Http\Requests\AlbumRequests; +use App\Rules\ModelIDRule; use Illuminate\Foundation\Http\FormRequest; -class AlbumIDRequestInt extends FormRequest +class AlbumModelIDRequest extends FormRequest { /** * Determine if the user is authorized to make this request. * * @return bool */ - public function authorize() + public function authorize(): bool { return true; } @@ -21,10 +22,8 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { - return [ - 'albumID' => 'required|integer', - ]; + return ['albumID' => ['required', new ModelIDRule()]]; } } diff --git a/app/Http/Requests/ImportRequests/ImportServerRequest.php b/app/Http/Requests/ImportRequests/ImportServerRequest.php index efbd0a2e5b9..42fb3aa0f75 100644 --- a/app/Http/Requests/ImportRequests/ImportServerRequest.php +++ b/app/Http/Requests/ImportRequests/ImportServerRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\ImportRequests; +use App\Rules\AlbumIDRule; use Illuminate\Foundation\Http\FormRequest; class ImportServerRequest extends FormRequest @@ -25,7 +26,7 @@ public function rules() { return [ 'path' => 'string|required', - 'albumID' => 'int|required', + 'albumID' => ['present', new AlbumIDRule()], 'delete_imported' => 'int', 'import_via_symlink' => 'int', 'skip_duplicates' => 'int', diff --git a/app/Http/Requests/ImportRequests/ImportUrlRequest.php b/app/Http/Requests/ImportRequests/ImportUrlRequest.php index ee291e81311..966b14511d5 100644 --- a/app/Http/Requests/ImportRequests/ImportUrlRequest.php +++ b/app/Http/Requests/ImportRequests/ImportUrlRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\ImportRequests; +use App\Rules\AlbumIDRule; use Illuminate\Foundation\Http\FormRequest; class ImportUrlRequest extends FormRequest @@ -25,7 +26,7 @@ public function rules() { return [ 'url' => 'string|required', - 'albumID' => 'string|required', + 'albumID' => ['present', new AlbumIDRule()], ]; } } diff --git a/app/Http/Requests/PhotoRequests/PhotoIDRequest.php b/app/Http/Requests/PhotoRequests/PhotoIDRequest.php index a9653b1f42e..ee575687b14 100644 --- a/app/Http/Requests/PhotoRequests/PhotoIDRequest.php +++ b/app/Http/Requests/PhotoRequests/PhotoIDRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\PhotoRequests; +use App\Rules\ModelIDRule; use Illuminate\Foundation\Http\FormRequest; class PhotoIDRequest extends FormRequest @@ -11,7 +12,7 @@ class PhotoIDRequest extends FormRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return true; } @@ -21,10 +22,8 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { - return [ - 'photoID' => 'required|string', - ]; + return ['photoID' => ['required', new ModelIDRule()]]; } } diff --git a/app/Http/Requests/PhotoRequests/PhotoIDsRequest.php b/app/Http/Requests/PhotoRequests/PhotoIDsRequest.php index 621b68e1ffe..135f6bbd9d4 100644 --- a/app/Http/Requests/PhotoRequests/PhotoIDsRequest.php +++ b/app/Http/Requests/PhotoRequests/PhotoIDsRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\PhotoRequests; +use App\Rules\ModelIDListRule; use Illuminate\Foundation\Http\FormRequest; class PhotoIDsRequest extends FormRequest @@ -11,7 +12,7 @@ class PhotoIDsRequest extends FormRequest * * @return bool */ - public function authorize() + public function authorize(): bool { return true; } @@ -21,10 +22,8 @@ public function authorize() * * @return array */ - public function rules() + public function rules(): array { - return [ - 'photoIDs' => 'required|string', - ]; + return ['photoIDs' => ['required', new ModelIDListRule()]]; } } diff --git a/app/Http/Requests/UserRequests/UserPostRequest.php b/app/Http/Requests/UserRequests/UserPostRequest.php index 2e502552ab1..517f223cee4 100644 --- a/app/Http/Requests/UserRequests/UserPostRequest.php +++ b/app/Http/Requests/UserRequests/UserPostRequest.php @@ -26,8 +26,8 @@ public function rules() return [ 'id' => 'required|numeric|min:1', 'username' => 'required|string|max:100', - 'upload' => 'required', - 'lock' => 'required', + 'may_upload' => 'present|boolean', + 'is_locked' => 'present|boolean', ]; } } diff --git a/app/Image/GdHandler.php b/app/Image/GdHandler.php index 1b8693ba73b..2b138cd6276 100644 --- a/app/Image/GdHandler.php +++ b/app/Image/GdHandler.php @@ -171,11 +171,10 @@ public function crop( /** * {@inheritdoc} */ - public function autoRotate(string $path, array $info, bool $pretend = false): array + public function autoRotate(string $path, int $orientation = 1, bool $pretend = false): array { $image = imagecreatefromjpeg($path); - $orientation = isset($info['orientation']) && $info['orientation'] !== '' ? $info['orientation'] : 1; $rotate = $orientation !== 1; $dimensions = $this->autoRotateInternal($image, $orientation); @@ -296,7 +295,6 @@ private function readImage(string $source) Logs::error(__METHOD__, __LINE__, 'Type of photo "' . $mime . '" is not supported'); return false; - break; } if ($image === false) { @@ -325,23 +323,13 @@ private function readImage(string $source) */ private function writeImage(string $destination, $image, int $mime, int $quality = null): bool { - $ret = false; - - switch ($mime) { - case IMAGETYPE_JPEG: - case IMAGETYPE_JPEG2000: - $ret = imagejpeg($image, $destination, $quality ?? $this->compressionQuality); - break; - case IMAGETYPE_PNG: - $ret = imagepng($image, $destination); - break; - case IMAGETYPE_GIF: - $ret = imagegif($image, $destination); - break; - case IMAGETYPE_WEBP: - $ret = imagewebp($image, $destination); - break; - } + $ret = match ($mime) { + IMAGETYPE_JPEG, IMAGETYPE_JPEG2000 => imagejpeg($image, $destination, $quality ?? $this->compressionQuality), + IMAGETYPE_PNG => imagepng($image, $destination), + IMAGETYPE_GIF => imagegif($image, $destination), + IMAGETYPE_WEBP => imagewebp($image, $destination), + default => false, + }; return $ret; } diff --git a/app/Image/ImageHandler.php b/app/Image/ImageHandler.php index 8bc64cf6a04..87a01d4ba7b 100644 --- a/app/Image/ImageHandler.php +++ b/app/Image/ImageHandler.php @@ -64,19 +64,13 @@ public function crop(string $source, string $destination, int $newWidth, int $ne } /** - * Rotates and flips a photo based on its EXIF orientation. - * - * @param string $path - * @param array $info - * @param bool $pretend - * - * @return array + * {@inheritDoc} */ - public function autoRotate(string $path, array $info, bool $pretend = false): array + public function autoRotate(string $path, int $orientation = 1, bool $pretend = false): array { $i = 0; $ret = [false, false]; - while ($i < count($this->engines) && ($ret = $this->engines[$i]->autoRotate($path, $info, $pretend)) == [false, false]) { + while ($i < count($this->engines) && ($ret = $this->engines[$i]->autoRotate($path, $orientation, $pretend)) == [false, false]) { $i++; } diff --git a/app/Image/ImageHandlerInterface.php b/app/Image/ImageHandlerInterface.php index 3c6d0ec6de6..c7d9af4d337 100644 --- a/app/Image/ImageHandlerInterface.php +++ b/app/Image/ImageHandlerInterface.php @@ -47,12 +47,12 @@ public function crop( * Rotates and flips a photo based on its EXIF orientation. * * @param string $path - * @param array $info - * @param boo $pretend + * @param int $orientation the orientation value (1..8) as defined by EXIF specification, default is 1 (means up-right and not mirrored/flipped) + * @param bool $pretend * - * @return array + * @return array an associative array `['width' => (int), 'height' => (int)]` with the new width and height after rotation */ - public function autoRotate(string $path, array $info, bool $pretend = false): array; + public function autoRotate(string $path, int $orientation = 1, bool $pretend = false): array; /** * @param string $source diff --git a/app/Image/ImagickHandler.php b/app/Image/ImagickHandler.php index 1fb1ea504a8..2c8f54f025b 100644 --- a/app/Image/ImagickHandler.php +++ b/app/Image/ImagickHandler.php @@ -178,7 +178,7 @@ public function crop( /** * {@inheritdoc} */ - public function autoRotate(string $path, array $info, bool $pretend = false): array + public function autoRotate(string $path, int $orientation = 1, bool $pretend = false): array { try { $image = new \Imagick(); diff --git a/app/Image/SizeVariantDefaultFactory.php b/app/Image/SizeVariantDefaultFactory.php new file mode 100644 index 00000000000..885aecae4a4 --- /dev/null +++ b/app/Image/SizeVariantDefaultFactory.php @@ -0,0 +1,389 @@ +imageHandler = $imageHandler; + } + + /** + * {@inheritDoc} + */ + public function init(Photo $photo, ?SizeVariantNamingStrategy $namingStrategy = null): void + { + if ($this->photo) { + $this->cleanup(); + } + $this->photo = $photo; + if ($namingStrategy) { + $this->namingStrategy = $namingStrategy; + } elseif (!$this->namingStrategy) { + $this->namingStrategy = resolve(SizeVariantNamingStrategy::class); + } + // Ensure that the naming strategy is linked to this photo + $this->namingStrategy->setPhoto($this->photo); + } + + protected function extractReferenceImage(): void + { + $original = $this->photo->size_variants->getOriginal(); + if ($this->photo->isRaw()) { + $this->extractReferenceFromRaw($original->full_path, $original->width, $original->height); + } elseif ($this->photo->isVideo()) { + if (empty($this->photo->aperture)) { + Logs::error(__METHOD__, __LINE__, 'Media file is reported to be a video, but aperture (aka duration) has not been extracted'); + throw new \RuntimeException('Media file is reported to be a video, but aperture (aka duration) has not been extracted'); + } + $position = floatval($this->photo->aperture) / 2; + $this->extractReferenceFromVideo($original->full_path, $position); + } else { + $this->referenceFullPath = $original->full_path; + $this->referenceWidth = $original->width; + $this->referenceHeight = $original->height; + } + } + + /** + * {@inheritDoc} + */ + public function cleanup(): void + { + $this->photo = null; + $this->namingStrategy = null; + if ($this->needsCleanup) { + @unlink($this->referenceFullPath); + } + $this->referenceFullPath = ''; + } + + /** + * Extracts a reference image from a raw file. + * + * @param string $fullPath The full path to the original (raw) file + * @param int $width The original width + * @param int $height The original height + */ + protected function extractReferenceFromRaw(string $fullPath, int $width, int $height): void + { + // we need imagick to do the job + if (!Configs::hasImagick()) { + $msg = 'Saving JPG of raw file failed: Imagick not installed.'; + Logs::notice(__METHOD__, __LINE__, $msg); + throw new \RuntimeException($msg); + } + $ext = pathinfo($fullPath, PATHINFO_EXTENSION); + // test if Imagick supports the filetype + // Query return file extensions as all upper case + if (!in_array(strtoupper($ext), \Imagick::queryformats())) { + $msg = 'Filetype ' . $ext . ' not supported by Imagick.'; + Logs::notice(__METHOD__, __LINE__, $msg); + throw new \RuntimeException($msg); + } + $this->createTmpPathForReference(); + try { + $this->imageHandler->scale($fullPath, $this->referenceFullPath, $width, $height, $this->referenceWidth, $this->referenceHeight); + } catch (\Throwable $e) { + $msg = 'Failed to create JPG from raw file ' . $fullPath; + Logs::error(__METHOD__, __LINE__, $msg); + throw new \RuntimeException($msg, 0, $e); + } + } + + /** + * Extracts a reference image from a video file at the given position. + * + * @param string $fullPath + * @param float $framePosition The temporal position in seconds of the frame to be extracted + */ + protected function extractReferenceFromVideo(string $fullPath, float $framePosition): void + { + if (!Configs::hasFFmpeg()) { + Logs::notice(__METHOD__, __LINE__, 'Failed to extract reference image from video as FFmpeg is unavailable'); + throw new \RuntimeException('Failed to extract reference image from video as FFmpeg is unavailable'); + } + $this->createTmpPathForReference(); + $ffmpeg = FFMpeg::create(); + /** @var Video $video */ + $video = $ffmpeg->open($fullPath); + try { + $this->extractFrame($video, $framePosition); + } catch (\RuntimeException $e) { + Logs::notice(__METHOD__, __LINE__, 'Fallback: Try to extract snapshot at position 0'); + $this->extractFrame($video, 0); + } + } + + /** + * Extracts a frame from a loaded `Video` object at the given position. + * + * @param Video $video the video object + * @param float $framePosition the position in seconds + * + * @throws \RuntimeException thrown, if FFmpeg failed to extract a frame + */ + protected function extractFrame(Video $video, float $framePosition): void + { + $errMsg = 'Failed to extract snapshot from video ' . $this->referenceFullPath . ' at position ' . $framePosition; + try { + $dim = $video->getStreams()->videos()->first()->getDimensions(); + $frame = $video->frame(TimeCode::fromSeconds($framePosition)); + $frame->save($this->referenceFullPath); + $this->referenceWidth = $dim->getWidth(); + $this->referenceHeight = $dim->getHeight(); + } catch (\RuntimeException $e) { + Logs::error(__METHOD__, __LINE__, $errMsg); + throw new \RuntimeException($errMsg, 0, $e); + } + if (!file_exists($this->referenceFullPath) || filesize($this->referenceFullPath) == 0) { + throw new \RuntimeException($errMsg); + } + if (Configs::get_value('lossless_optimization')) { + ImageOptimizer::optimize($this->referenceFullPath); + } + } + + /** + * Creates a temporary path to store the extracted reference image. + * + * This method modifies `referenceFullPath` and also sets `needsCleanup` + * to true such that the file which is stored at `referenceFullPath` will + * be removed by {@link SizeVariantFactory::cleanup()}. + */ + protected function createTmpPathForReference(): void + { + $this->referenceFullPath = Helpers::createTemporaryFile( + $this->namingStrategy->getDefaultExtension() + ); + $this->needsCleanup = true; + Logs::notice(__METHOD__, __LINE__, 'Saving JPG of raw/video file to ' . $this->referenceFullPath); + } + + /** + * {@inheritDoc} + */ + public function createSizeVariants(): Collection + { + $allVariants = [ + SizeVariant::THUMB, + SizeVariant::THUMB2X, + SizeVariant::SMALL, + SizeVariant::SMALL2X, + SizeVariant::MEDIUM, + SizeVariant::MEDIUM2X, + ]; + $collection = new Collection(); + + foreach ($allVariants as $variant) { + $sv = $this->createSizeVariantCond($variant); + if ($sv) { + $collection->add($sv); + } + } + + return $collection; + } + + /** + * {@inheritDoc} + */ + public function createSizeVariant(int $sizeVariant): SizeVariant + { + if ($sizeVariant === SizeVariant::ORIGINAL) { + throw new \InvalidArgumentException('createSizeVariant() must not be used to create original size, use createOriginal() instead'); + } + if (empty($this->referenceFullPath)) { + $this->extractReferenceImage(); + } + list($maxWidth, $maxHeight) = $this->getMaxDimensions($sizeVariant); + + return $this->createSizeVariantInternal( + $sizeVariant, $maxWidth, $maxHeight + ); + } + + /** + * {@inheritDoc} + */ + public function createSizeVariantCond(int $sizeVariant): ?SizeVariant + { + if ($sizeVariant === SizeVariant::ORIGINAL) { + throw new \InvalidArgumentException('createSizeVariantCond() must not be used to create original size, use createOriginal() instead'); + } + if (!$this->isEnabledByConfiguration($sizeVariant)) { + return null; + } + if (empty($this->referenceFullPath)) { + $this->extractReferenceImage(); + } + + list($maxWidth, $maxHeight) = $this->getMaxDimensions($sizeVariant); + + if ($sizeVariant === SizeVariant::THUMB) { + $isLargeEnough = true; + } elseif ($sizeVariant === SizeVariant::THUMB2X) { + $isLargeEnough = $this->referenceWidth > $maxWidth && $this->referenceHeight > $maxHeight; + } else { + $isLargeEnough = $this->referenceWidth > $maxWidth || $this->referenceHeight > $maxHeight; + } + + if ($isLargeEnough) { + return $this->createSizeVariantInternal( + $sizeVariant, + $maxWidth, + $maxHeight + ); + } else { + Logs::notice( + __METHOD__, + __LINE__, + 'Did not create size variant ' . $sizeVariant . ' (' . $maxWidth . 'x' . $maxHeight . '); original image is too small: ' . $this->referenceWidth . 'x' . $this->referenceHeight . '!' + ); + + return null; + } + } + + protected function createSizeVariantInternal(int $sizeVariant, int $maxWidth, int $maxHeight): SizeVariant + { + $shortPath = $this->namingStrategy->generateShortPath($sizeVariant); + + $sv = $this->photo->size_variants->getSizeVariant($sizeVariant); + if (!$sv) { + $sv = $this->photo->size_variants->create($sizeVariant, $shortPath, $maxWidth, $maxHeight); + if ($sizeVariant === SizeVariant::THUMB || $sizeVariant === SizeVariant::THUMB2X) { + $success = $this->imageHandler->crop($this->referenceFullPath, $sv->full_path, $sv->width, $sv->height); + } else { + $resWidth = $resHeight = 0; + $success = $this->imageHandler->scale($this->referenceFullPath, $sv->full_path, $sv->width, $sv->height, $resWidth, $resHeight); + $sv->width = $resWidth; + $sv->height = $resHeight; + $sv->save(); + } + if (!$success) { + Logs::error(__METHOD__, __LINE__, 'Failed to resize image: ' . $this->referenceFullPath); + // If scaling/cropping has failed, remove the freshly created DB entity again + // This will also take care of removing a potentially created file from storage + $sv->delete(); + throw new \RuntimeException('Failed to resize image: ' . $this->referenceFullPath); + } + } + + return $sv; + } + + /** + * Determines the maximum dimensions of the designated size variant. + * + * @param int $sizeVariant the size variant + * + * @return int[] an array with exactly two integers, the first integer is + * the width, the second integer is the height + */ + protected function getMaxDimensions(int $sizeVariant): array + { + switch ($sizeVariant) { + case SizeVariant::MEDIUM2X: + $maxWidth = 2 * intval(Configs::get_value('medium_max_width')); + $maxHeight = 2 * intval(Configs::get_value('medium_max_height')); + break; + case SizeVariant::MEDIUM: + $maxWidth = intval(Configs::get_value('medium_max_width')); + $maxHeight = intval(Configs::get_value('medium_max_height')); + break; + case SizeVariant::SMALL2X: + $maxWidth = 2 * intval(Configs::get_value('small_max_width')); + $maxHeight = 2 * intval(Configs::get_value('small_max_height')); + break; + case SizeVariant::SMALL: + $maxWidth = intval(Configs::get_value('small_max_width')); + $maxHeight = intval(Configs::get_value('small_max_height')); + break; + case SizeVariant::THUMB2X: + $maxWidth = self::THUMBNAIL2X_DIM; + $maxHeight = self::THUMBNAIL2X_DIM; + break; + case SizeVariant::THUMB: + $maxWidth = self::THUMBNAIL_DIM; + $maxHeight = self::THUMBNAIL_DIM; + break; + default: + throw new \InvalidArgumentException('unknown size variant: ' . $sizeVariant); + } + + return [$maxWidth, $maxHeight]; + } + + /** + * Checks whether the requested size variant is enabled by configuration. + * + * This function always returns true, for size variants which are not + * configurable and are always enabled (e.g. a thumb). + * Hence, it is save to call this function for all size variants. + * For size variants which may be enabled/disabled trough configuration at + * runtime, the method only returns true, if a) the size variant is + * enabled and b) the allowed maximum width or maximum height is not zero. + * In other words, even if a size variant is enabled, this function + * still returns false, if both the allowed maximum width and height + * equal zero. + * + * @param int $sizeVariant the indicated size variant + * + * @return bool true, if the size variant is enabled and the allowed width + * or height is unequal to zero + */ + protected function isEnabledByConfiguration(int $sizeVariant): bool + { + list($maxWidth, $maxHeight) = $this->getMaxDimensions($sizeVariant); + if ($maxWidth === 0 && $maxHeight === 0) { + return false; + } + + return match ($sizeVariant) { + SizeVariant::MEDIUM2X => Configs::get_value('medium_2x', 0) == 1, + SizeVariant::SMALL2X => Configs::get_value('small_2x', 0) == 1, + SizeVariant::THUMB2X => Configs::get_value('thumb_2x', 0) == 1, + SizeVariant::SMALL, SizeVariant::MEDIUM, SizeVariant::THUMB => true, + default => throw new \InvalidArgumentException('unknown size variant: ' . $sizeVariant), + }; + } + + /** + * {@inheritDoc} + */ + public function createOriginal(int $width, int $height): SizeVariant + { + return $this->photo->size_variants->create( + SizeVariant::ORIGINAL, + $this->namingStrategy->generateShortPath(SizeVariant::ORIGINAL), + $width, + $height + ); + } +} diff --git a/app/Legacy/Legacy.php b/app/Legacy/Legacy.php index 99d3008eade..b85a52c85f1 100644 --- a/app/Legacy/Legacy.php +++ b/app/Legacy/Legacy.php @@ -4,6 +4,8 @@ use App\Models\Configs; use App\Models\Logs; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Session; @@ -67,4 +69,42 @@ public static function log_as_admin(string $username, string $password, string $ return false; } + + public static function isLegacyModelID(string $id): bool + { + return preg_match('/^[-_a-zA-Z0-9]{24}$/', $id) !== 1 && + filter_var($id, FILTER_VALIDATE_INT) !== false; + } + + private static function translateLegacyID(string $id, string $tableName, Request $request): ?string + { + $newID = DB::table($tableName) + ->where('legacy_id', '=', intval($id)) + ->value('id'); + + if ($newID) { + $referer = $request->header('Referer', '(unknown)'); + $msg = 'Request for ' . $tableName . + ' with legacy ID ' . $id . + ' instead of new ID ' . $newID . + ' from ' . $referer; + if (Configs::get_value('legacy_id_redirection', '0') !== '1') { + $msg .= ' (translation disabled by configuration)'; + $newID = null; + } + Logs::warning(__METHOD__, __LINE__, $msg); + } + + return $newID; + } + + public static function translateLegacyAlbumID(string $albumID, Request $request): ?string + { + return self::translateLegacyID($albumID, 'base_albums', $request); + } + + public static function translateLegacyPhotoID(string $photoID, Request $request): ?string + { + return self::translateLegacyID($photoID, 'photos', $request); + } } diff --git a/app/Locale/Italian.php b/app/Locale/Italian.php index 14f27e819a1..279189af9e9 100644 --- a/app/Locale/Italian.php +++ b/app/Locale/Italian.php @@ -200,7 +200,6 @@ public function get_locale(): array 'PHOTO_ABOUT' => 'Informazioni', 'PHOTO_BASICS' => 'Base', 'PHOTO_TITLE' => 'Titolo', - 'PHOTO_NEW_TITLE' => 'Inserire un nuovo titolo per questa foto:', 'PHOTO_SET_TITLE' => 'Imposta Titolo', 'PHOTO_UPLOADED' => 'Caricata', 'PHOTO_DESCRIPTION' => 'Descrizione', diff --git a/app/Mail/PhotosAdded.php b/app/Mail/PhotosAdded.php index c08ff7c1fe1..ff00bb7fe27 100644 --- a/app/Mail/PhotosAdded.php +++ b/app/Mail/PhotosAdded.php @@ -12,17 +12,18 @@ class PhotosAdded extends Mailable use Queueable; use SerializesModels; - public $photos; + protected array $photos; + protected string $title; /** * Create a new message instance. * * @return void */ - public function __construct($photos) + public function __construct(array $photos) { $this->photos = $photos; - $this->settings = Configs::get(); + $this->title = Configs::get_value('site_title', ''); } /** @@ -30,10 +31,10 @@ public function __construct($photos) * * @return $this */ - public function build() + public function build(): self { return $this->markdown('emails.photos-added', [ - 'title' => $this->settings['site_title'], + 'title' => $this->title, ]); } } diff --git a/app/Metadata/Extractor.php b/app/Metadata/Extractor.php index 9d1eee140b1..e3e06bf80f2 100644 --- a/app/Metadata/Extractor.php +++ b/app/Metadata/Extractor.php @@ -23,7 +23,7 @@ public function bare() 'height' => 0, 'title' => '', 'description' => '', - 'orientation' => '', + 'orientation' => 1, 'iso' => '', 'aperture' => '', 'make' => '', @@ -43,6 +43,7 @@ public function bare() 'livePhotoContentID' => null, 'livePhotoStillImageTime' => null, 'MicroVideoOffset' => null, + 'checksum' => null, ]; } @@ -56,28 +57,61 @@ public function bare() public function filesize(string $path): int { return (int) filesize($path); - /*$size = $filesize_raw / 1024; - if ($size >= 1024) { - $metadata['filesize'] = round($size / 1024, 1) . ' MB'; - } else { - $metadata['filesize'] = round($size, 1) . ' KB'; - }*/ } /** - * Extracts metadata from an image file. + * Returns the SHA-1 checksum of a file. + * + * @param string $path The relative file path + * + * @return string the checksum + */ + public function checksum(string $path): string + { + $checksum = sha1_file($path); + if ($checksum === false) { + $msg = 'Could not compute checksum for: ' . $path; + Logs::error(__METHOD__, __LINE__, $msg); + throw new \RuntimeException($msg); + } + + return $checksum; + } + + /** + * Extracts metadata from a file. + * + * **Warning:** + * + * The parameter `$kind` is enum-like parameter and accepts the values + * `photo`, `video` or `raw` (see + * {@link \App\Actions\Photo\Extensions\Checks::file_kind}). + * In other words `kind` is a coarsening of the mime type of a file, but + * not identical to the mime type. + * See {@link \App\Actions\Photo\Create::add()} which sets `$kind` to the + * result of {@link \App\Actions\Photo\Extensions\Checks::file_kind()}. + * However, there are at least three other occurrences where this method + * is called and the full mime type is passed as the second parameter: + * see {@link \App\Console\Commands\ExifLens::handle()}, + * {@link \App\Console\Commands\Takedate::handle()} and + * {@link \App\Console\Commands\VideoData::handle()}. + * + * IMHO, there is an amazing number of places which somehow deal with + * "mime type-ish" sort of values with subtle differences. + * + * TODO: Thoroughly refactor this. * - * @param string $filename - * @param string file kind + * @param string $fullPath the full path to the file + * @param string $kind the kind of file either 'image', 'video' or 'raw' * * @return array */ - public function extract(string $filename, string $kind): array + public function extract(string $fullPath, string $kind): array { $reader = null; // Get kind of file (photo, video, raw) - $extension = Helpers::getExtension($filename, false); + $extension = Helpers::getExtension($fullPath, false); // check raw files $is_raw = false; @@ -116,7 +150,7 @@ public function extract(string $filename, string $kind): array try { // this can throw an exception in the case of Exiftool adapter! - $exif = $reader->read($filename); + $exif = $reader->read($fullPath); } catch (\Exception $e) { Logs::error(__METHOD__, __LINE__, $e->getMessage()); $exif = false; @@ -126,19 +160,19 @@ public function extract(string $filename, string $kind): array Logs::notice(__METHOD__, __LINE__, 'Falling back to native adapter.'); // Use Php native tools $reader = Reader::factory(Reader::TYPE_NATIVE); - $exif = $reader->read($filename); + $exif = $reader->read($fullPath); } // Attempt to get sidecar metadata if it exists, make sure to check 'real' path in case of symlinks $sidecarData = []; // readlink fails if it's not a link -> we need to separate it - $realFile = $filename; - if (is_link($filename)) { + $realFile = $fullPath; + if (is_link($fullPath)) { try { // if readlink($filename) == False then $realFile = $filename. // if readlink($filename) != False then $realFile = readlink($filename) - $realFile = readlink($filename) ?: $filename; + $realFile = readlink($fullPath) ?: $fullPath; } catch (\Exception $e) { Logs::error(__METHOD__, __LINE__, $e->getMessage()); } @@ -168,7 +202,7 @@ public function extract(string $filename, string $kind): array $metadata['height'] = ($exif->getHeight() !== false) ? $exif->getHeight() : 0; $metadata['title'] = ($exif->getTitle() !== false) ? $exif->getTitle() : ''; $metadata['description'] = ($exif->getDescription() !== false) ? $exif->getDescription() : ''; - $metadata['orientation'] = ($exif->getOrientation() !== false) ? $exif->getOrientation() : ''; + $metadata['orientation'] = ($exif->getOrientation() !== false) ? $exif->getOrientation() : 1; $metadata['iso'] = ($exif->getIso() !== false) ? $exif->getIso() : ''; $metadata['make'] = ($exif->getMake() !== false) ? $exif->getMake() : ''; $metadata['model'] = ($exif->getCamera() !== false) ? $exif->getCamera() : ''; @@ -180,8 +214,9 @@ public function extract(string $filename, string $kind): array $metadata['altitude'] = ($exif->getAltitude() !== false) ? $exif->getAltitude() : null; $metadata['imgDirection'] = ($exif->getImgDirection() !== false) ? $exif->getImgDirection() : null; $metadata['filesize'] = ($exif->getFileSize() !== false) ? $exif->getFileSize() : 0; - $metadata['livePhotoContentID'] = ($exif->getContentIdentifier() !== false) ? $exif->getContentIdentifier() : null; + $metadata['live_photo_content_id'] = ($exif->getContentIdentifier() !== false) ? $exif->getContentIdentifier() : null; $metadata['MicroVideoOffset'] = ($exif->getMicroVideoOffset() !== false) ? $exif->getMicroVideoOffset() : null; + $metadata['checksum'] = $this->checksum($fullPath); $taken_at = $exif->getCreationDate(); if ($taken_at !== false) { diff --git a/app/Metadata/GitHubFunctions.php b/app/Metadata/GitHubFunctions.php index 1c5fb5aba6d..48f9aaa3ad2 100644 --- a/app/Metadata/GitHubFunctions.php +++ b/app/Metadata/GitHubFunctions.php @@ -7,8 +7,8 @@ use App\Facades\Helpers; use App\ModelFunctions\JsonRequestFunctions; use App\Models\Configs; -use Config; use Exception; +use Illuminate\Support\Facades\Config; class GitHubFunctions { diff --git a/app/Metadata/GitRequest.php b/app/Metadata/GitRequest.php index 87cff7273cf..d9276506bf0 100644 --- a/app/Metadata/GitRequest.php +++ b/app/Metadata/GitRequest.php @@ -4,7 +4,7 @@ use App\ModelFunctions\JsonRequestFunctions; use App\Models\Configs; -use Config; +use Illuminate\Support\Facades\Config; class GitRequest extends JsonRequestFunctions { diff --git a/app/ModelFunctions/ConfigFunctions.php b/app/ModelFunctions/ConfigFunctions.php index 43af032ee76..2faafc80f6d 100644 --- a/app/ModelFunctions/ConfigFunctions.php +++ b/app/ModelFunctions/ConfigFunctions.php @@ -2,9 +2,9 @@ namespace App\ModelFunctions; +use App\Facades\Lang; use App\Models\Configs; use Illuminate\Database\QueryException; -use Lang; class ConfigFunctions { diff --git a/app/ModelFunctions/SessionFunctions.php b/app/ModelFunctions/SessionFunctions.php index babc8178e59..7f4bd274e9f 100644 --- a/app/ModelFunctions/SessionFunctions.php +++ b/app/ModelFunctions/SessionFunctions.php @@ -1,7 +1,5 @@ is_logged_in() && ($this->id() == 0 || $this->user()->upload); + return $this->is_logged_in() && ($this->id() == 0 || $this->user()->may_upload); } /** @@ -188,57 +186,6 @@ public function log_as_admin(string $username, string $password, string $ip): bo return Legacy::log_as_admin($username, $password, $ip); } - /** - * Given an albumID, check if it exists in the visible_albums session variable. - * - * @param $albumID - * - * @return bool - */ - public function has_visible_album($albumID): bool - { - if (!Session::has('visible_albums')) { - return false; - } - - $visible_albums = Session::get('visible_albums'); - $visible_albums = explode('|', $visible_albums); - - return in_array($albumID, $visible_albums); - } - - /** - * Add new album to the visible_albums session variable. - * - * @param $albumIDs - */ - public function add_visible_albums($albumIDs): void - { - $visible_albums = []; - if (Session::has('visible_albums')) { - $visible_albums = Session::get('visible_albums'); - $visible_albums = explode('|', $visible_albums); - } - - foreach ($albumIDs as $albumID) { - if (!in_array($albumID, $visible_albums)) { - $visible_albums[] = $albumID; - } - } - - $visible_albums = implode('|', $visible_albums); - Session::put('visible_albums', $visible_albums); - } - - public function get_visible_albums(): array - { - if (Session::has('visible_albums')) { - return explode('|', Session::get('visible_albums')); - } - - return []; - } - /** * Log out the current user. */ diff --git a/app/ModelFunctions/SymLinkFunctions.php b/app/ModelFunctions/SymLinkFunctions.php index 8d2e3bde3d1..51c88cd5c6f 100644 --- a/app/ModelFunctions/SymLinkFunctions.php +++ b/app/ModelFunctions/SymLinkFunctions.php @@ -2,71 +2,10 @@ namespace App\ModelFunctions; -use App\Facades\AccessControl; -use App\Models\Configs; -use App\Models\Photo; use App\Models\SymLink; -use Illuminate\Support\Facades\Storage; class SymLinkFunctions { - /** - * @param Photo $photo - * - * @return SymLink|null - */ - public function find(Photo $photo): ?SymLink - { - if (Storage::getDefaultDriver() == 's3') { - // @codeCoverageIgnoreStart - return null; - // @codeCoverageIgnoreEnd - } - if (Configs::get_value('SL_enable', '0') === '0') { - return null; - } - - if (AccessControl::is_admin() && Configs::get_value('SL_for_admin', '0') === '0') { - // @codeCoverageIgnoreStart - return null; - // @codeCoverageIgnoreEnd - } - - $sym = null; - - $sym = SymLink::where('photo_id', $photo->id) - ->orderBy('created_at', 'DESC') - ->first(); - if ($sym == null) { - $sym = new SymLink(); - $sym->set($photo); - $sym->save(); - } - - return $sym; - } - - /** - * Get URLS of pictures. - * - * This method modifies the serialization of a photo such that the original URLs are replaced by symlinks. - * *Attention:* The passed $photo and the passed array $return which represents the serialization of the photo must - * match. - * It is the caller's responsibility to ensure that $return equals $photo->toReturnArray(). - * - * @param Photo $photo The photo that is going to be serialized - * @param array $return The serialization of the passed photo as returned by Photo#toReturnArray() - */ - public function getUrl( - Photo $photo, - array &$return - ) { - $sym = $this->find($photo); - if ($sym != null) { - $sym->override($return); - } - } - /** * Clear the table of existing SymLinks. * @@ -74,7 +13,7 @@ public function getUrl( * * @throws \Exception */ - public function clearSymLink() + public function clearSymLink(): string { $symlinks = SymLink::all(); $no_error = true; @@ -92,12 +31,10 @@ public function clearSymLink() */ public function remove_outdated() { - $symlinks = SymLink::query() - ->where('created_at', '<', now()->subDays(intval(Configs::get_value('SL_life_time_days', '3')))->toDateTimeString()) - ->get(); + $symlinks = SymLink::expired()->get(); $success = true; + /** @var SymLink $symlink */ foreach ($symlinks as $symlink) { - // it may be faster to just do the unlink and then one query for all the delete. $success &= $symlink->delete(); } diff --git a/app/Models/Album.php b/app/Models/Album.php index bc9961abd2e..10a805c0825 100644 --- a/app/Models/Album.php +++ b/app/Models/Album.php @@ -1,184 +1,119 @@ $children - * @property User $owner - * @property Album $parent - * @property Collection $photos - * - * @method static Builder|Album newModelQuery() - * @method static Builder|Album newQuery() - * @method static Builder|Album query() - * @method static Builder|Album whereCreatedAt($value) - * @method static Builder|Album whereDescription($value) - * @method static Builder|Album whereDownloadable($value) - * @method static Builder|Album whereShareButtonVisible($value) - * @method static Builder|Album whereId($value) - * @method static Builder|Album whereLicense($value) - * @method static Builder|Album whereMaxTakestamp($value) - * @method static Builder|Album whereMinTakestamp($value) - * @method static Builder|Album whereOwnerId($value) - * @method static Builder|Album whereParentId($value) - * @method static Builder|Album wherePassword($value) - * @method static Builder|Album wherePublic($value) - * @method static Builder|Album whereTitle($value) - * @method static Builder|Album whereUpdatedAt($value) - * @method static Builder|Album whereVisibleHidden($value) - * @method static Builder|Album whereSmart($value) - * @mixin Eloquent + * @property Collection $all_photos + * @property string $license + * @property string|null $cover_id + * @property Photo|null $cover + * @property int $_lft + * @property int $_rgt * - * @property Collection|User[] $shared_with + * @method static AlbumBuilder query() + * @method AlbumBuilder newModelQuery() */ -class Album extends Model implements AlbumInterface +class Album extends BaseAlbum implements Node { use NodeTrait; - use AlbumBooleans; - use AlbumStringify; - use AlbumGetters; - use AlbumCast; - use AlbumSetters; - use CustomSort; - use UTCBasedTimes; - protected $casts - = [ - 'public' => 'int', - 'nsfw' => 'int', - 'viewable' => 'int', - 'downloadable' => 'int', - 'share_button_visible' => 'int', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', + /** + * The model's attributes. + * + * We must list all attributes explicitly here, otherwise the attributes + * of a new model will accidentally be set on the parent class. + * The trait {@link \App\Models\Extensions\ForwardsToParentImplementation} + * only works properly, if it knows which attributes belong to the parent + * class and which attributes belong to the child class. + * + * @var array + */ + protected $attributes = [ + 'id' => null, + 'parent_id' => null, + 'license' => 'none', + 'cover_id' => null, + '_lft' => null, + '_rgt' => null, + ]; + + protected $casts = [ 'min_taken_at' => 'datetime', 'max_taken_at' => 'datetime', + '_lft' => 'integer', + '_rgt' => 'integer', + ]; + + /** + * @var string[] The list of attributes which exist as columns of the DB + * relation but shall not be serialized to JSON + */ + protected $hidden = [ + 'base_class', // don't serialize base class as a relation, the attributes of the base class are flatly merged into the JSON result + 'cover', // instead of cover, serialize thumb + '_lft', + '_rgt', + 'parent', // avoid infinite recursions + 'all_photos', // never serialize recursive child photos of an album, even if the relation is loaded ]; /** * The relationships that should always be eagerly loaded by default. */ - protected $with = ['owner', 'cover']; + protected $with = ['cover', 'thumb']; /** - * This method is called by the framework after the model has been - * booted. + * Return the relationship between this album and photos which are + * direct children of this album. * - * This method alters the default query builder for this model and - * adds a "scope" to the query builder in order to add the "virtual" - * columns `max_taken_at` and `min_taken_at` to every query. + * @return HasManyChildPhotos */ - protected static function booted() + public function photos(): HasManyChildPhotos { - parent::booted(); - // Normally "scopes" are used to restrict the result of the query - // to a particular subset through adding additional WHERE-clauses - // to the default query. - // However, "scopes" can be used to manipulate the query in any way. - // Here we add to additional "virtual" columns to the query. - static::addGlobalScope('add_minmax_taken_at', function (Builder $builder) { - $builder->addSelect([ - 'max_taken_at' => Photo::query() - ->select('taken_at') - ->leftJoin('albums as a', 'a.id', '=', 'album_id') - ->whereColumn('a._lft', '>=', 'albums._lft') - ->whereColumn('a._rgt', '<=', 'albums._rgt') - ->whereNotNull('taken_at') - ->orderBy('taken_at', 'desc') - ->limit(1), - 'min_taken_at' => Photo::query() - ->select('taken_at') - ->leftJoin('albums as a', 'a.id', '=', 'album_id') - ->whereColumn('a._lft', '>=', 'albums._lft') - ->whereColumn('a._rgt', '<=', 'albums._rgt') - ->whereNotNull('taken_at') - ->orderBy('taken_at', 'asc') - ->limit(1), - ]); - }); + return new HasManyChildPhotos($this); } /** - * Return the relationship between Photos and their Album. + * Returns the relationship between this album and all photos incl. + * photos which are recursive children of this album. * - * @return HasMany + * @return HasManyPhotosRecursively */ - public function photos(): HasMany + public function all_photos(): HasManyPhotosRecursively { - return $this->hasMany('App\Models\Photo', 'album_id', 'id'); + return new HasManyPhotosRecursively($this); } - /** - * Return the relationship between an album and its owner. - * - * @return BelongsTo - */ - public function owner(): BelongsTo + public function thumb(): HasAlbumThumb { - return $this->belongsTo('App\Models\User', 'owner_id', 'id'); + return new HasAlbumThumb($this); } /** - * Return the relationship between an album and its sub albums. + * Return the relationship between an album and its sub-albums. * - * Note: Actually, the return type should be non-nullable. - * However, {@link \App\SmartAlbums\BareSmartAlbum} extends this class and - * {@link \App\SmartAlbums\SmartAlbum::children()} cannot return an - * correctly instantiated object of `HasMany` but must return `null`, - * because a `SmartAlbum` is not a real Eloquent model and does not exist - * as a database entity. - * TODO: Refactor the inheritance relationships of all album types. - * A `SmartAlbum` (which cannot have sub-albums} should not inherit from - * `Album`. - * Instead both kind of albums should share an interface. - * Then the return type of this method could be repaired. - * - * @return ?HasMany + * @return HasManyChildAlbums */ - public function children(): ?HasMany + public function children(): HasManyChildAlbums { - return $this->hasMany('App\Models\Album', 'parent_id', 'id'); + return new HasManyChildAlbums($this); } /** @@ -188,110 +123,122 @@ public function children(): ?HasMany */ public function cover(): HasOne { - return $this->hasOne('App\Models\Photo', 'id', 'cover_id'); + return $this->hasOne(Photo::class, 'id', 'cover_id'); } - /** - * Return the relationship between an album and its parent. - * - * @return BelongsTo - */ - public function parent(): BelongsTo + protected function getLicenseAttribute(string $value): string { - return $this->belongsTo('App\Models\Album', 'parent_id', 'id'); - } + if ($value === 'none') { + return Configs::get_value('default_license'); + } - /** - * @return BelongsToMany - */ - public function shared_with(): BelongsToMany - { - return $this->belongsToMany( - 'App\Models\User', - 'user_album', - 'album_id', - 'user_id' - ); + return $value; } - /** - * Before calling delete() to remove the album from the database - * we need to go through each sub album and delete it. - * Idem we also delete each pictures inside an album (recursively). - * - * @return bool|null - * - * @throws Exception - */ - public function predelete() + public function toArray(): array { - $no_error = true; - $photos = $this->get_all_photos()->get(); - foreach ($photos as $photo) { - $no_error &= $photo->predelete(); - $no_error &= $photo->delete(); + $result = parent::toArray(); + $result['has_albums'] = !$this->isLeaf(); + + // The client expect the relation "children" to be named "albums". + // Rename it + if (key_exists('children', $result)) { + $result['albums'] = $result['children']; + unset($result['children']); } - return $no_error; + return $result; } - /** - * Return the full path of the album consisting of all its parents' titles. - * - * @return string - */ - public static function getFullPath($album) + public function delete(): bool { - $title = [$album->title]; - $parentId = $album->parent_id; - while ($parentId) { - $parent = Album::find($parentId); - array_unshift($title, $parent->title); - $parentId = $parent->parent_id; + try { + $this->refreshNode(); + + $success = true; + + // Delete all recursive child photos first + $photos = $this->all_photos()->lazy(); + /** @var Photo $photo */ + foreach ($photos as $photo) { + // This also takes care of proper deletion of physical files from disk + // Note, we need this strange condition, because `delete` may also + // return `null` on success, so we must explicitly test for + // _not `false`_. + $success &= ($photo->delete() !== false); + } + + if (!$success) { + return false; + } + + // Finally, delete the album itself + // Note, we need this strange condition, because `delete` may also + // return `null` on success, so we must explicitly test for + // _not `false`_. + $success &= (parent::delete() !== false); + + return $success; + } catch (\Exception $e) { + try { + // if anything goes wrong, don't leave the tree in an inconsistent state + $this->newModelQuery()->fixTree(); + } catch (\Throwable) { + // Sic! We cannot do anything about the inner exception + } + throw $e; } - - return implode('/', $title); } /** - * Setter/Mutator for attribute `min_taken_at`. + * Sets the ownership of all child albums and child photos to the owner + * of this album. * - * Actually, this method should be a no-op and throw an exception. - * The attribute `min_taken_at` is a transient attribute of the model - * and cannot be persisted to database. - * It is calculated by the DB back-end upon fetching the model. - * Hence, it wrong to try to set this attribute. - * However, {@link AlbumCast::toTagAlbum()} does it nonetheless, so we - * don't throw an exception until that method is fixed. + * ANSI SQL does not allow a `JOIN`-clause in the table reference + * of `UPDATE` statements. + * MySQL and PostgreSQL have their proprietary but different + * extension for that, SQLite does not support it at all. + * Hence, we must use a (slightly) less efficient, but + * SQL-compatible `WHERE EXIST` condition instead of a `JOIN`. + * This also means that we cannot use the succinct statements * - * TODO: Fix {@link AlbumCast::toTagAlbum()}. + * $this->descendants()->update(['owner_id' => $this->owner_id]) + * $this->all_photos()->update(['owner_id' => $this->owner_id]) * - * @param Carbon|null $value + * because these method return queries which use `JOINS`. + * So, we need to build the queries from scratch. + * + * @return void */ - protected function setMinTakenAtAttribute(?Carbon $value): void + public function fixOwnershipOfChildren(): void { - // Uncomment the following line, after AlbumCast::toTagAlbum() has been fixed - //throw new \BadMethodCallException('Attribute "min_taken_at" must not be set as it is a virtual attribute'); + $this->refreshNode(); + $lft = $this->_lft; + $rgt = $this->_rgt; + + BaseAlbumImpl::query() + ->whereExists(function (Builder $q) use ($lft, $rgt) { + $q + ->from('albums') + ->whereColumn('base_albums.id', '=', 'albums.id') + ->whereBetween('albums._lft', [$lft + 1, $rgt - 1]); + }) + ->update(['owner_id' => $this->owner_id]); + Photo::query() + ->whereExists(function (Builder $q) use ($lft, $rgt) { + $q + ->from('albums') + ->whereColumn('photos.album_id', '=', 'albums.id') + ->whereBetween('albums._lft', [$lft, $rgt]); + }) + ->update(['owner_id' => $this->owner_id]); } /** - * Setter/Mutator for attribute `max_taken_at`. - * - * Actually, this method should be a no-op and throw an exception. - * The attribute `max_taken_at` is a transient attribute of the model - * and cannot be persisted to database. - * It is calculated by the DB back-end upon fetching the model. - * Hence, it wrong to try to set this attribute. - * However, {@link AlbumCast::toTagAlbum()} does it nonetheless, so we - * don't throw an exception until that method is fixed. - * - * TODO: Fix {@link AlbumCast::toTagAlbum()}. - * - * @param Carbon|null $value + * {@inheritdoc} */ - protected function setMaxTakenAtAttribute(?Carbon $value): void + public function newEloquentBuilder($query): AlbumBuilder { - // Uncomment the following line, after AlbumCast::toTagAlbum() has been fixed - //throw new \BadMethodCallException('Attribute "max_taken_at" must not be set as it is a virtual attribute'); + return new AlbumBuilder($query); } } diff --git a/app/Models/BaseAlbumImpl.php b/app/Models/BaseAlbumImpl.php new file mode 100644 index 00000000000..6bd1343d5ad --- /dev/null +++ b/app/Models/BaseAlbumImpl.php @@ -0,0 +1,256 @@ +> | + * +---------+ | BaseAlbum | + * ^ ^ ^ +-----------------+ + * | | \ ^ ^ + * | | \ | | + * | \ \-----------------|------\ | + * | \----------------\ | \ | + * | +-------+ \ | + * | | Album | | | + * +---------------+ <---X +-------+ +----------+ + * | BaseAlbumImpl | | TagAlbum | + * +---------------+ <----------------X +----------+ + * + * (Note: A sideways arrow with an X, i.e. <-----X, shall denote a composite.) + * All child classes and this class extend + * {@link \Illuminate\Database\Eloquent\Model}, because they map to a single + * DB table. + * All methods and properties which are common to any sort of persistable + * album is declared in the interface {@link \App\Contracts\BaseAlbum} + * and thus {@link \App\Models\Album} and {@link \App\Models\TagAlbum} + * realize it. + * However, for any method which is implemented identically for all + * child classes and thus would normally be defined in a true parent class, + * the child classes forward the call to this class via the composite. + * For this reason, this class is called `BaseAlbumImpl` like _implementation_. + * Also note, that this class does not realize + * {@link \App\Contracts\BaseAlbum} intentionally. + * The interface {@link \App\Contracts\BaseAlbum} requires methods from + * albums which this class cannot implement reasonably, because the + * implementation depends on the specific sub-type of album and thus must + * be implemented by the child classes. + * For example, every album contains photos and thus must provide + * {@link \App\Contracts\AbstractAlbum::$photos}, but the way how an album + * defines its collection of photos is specific for the album. + * Normally, a proper parent class would use abstract methods for these cases, + * but this class is not a proper parent class (it just provides an + * implementation of it) and we need this class to be instantiable. + * + * @property string $id + * @property int $legacy_id + * @property Carbon $created_at + * @property Carbon $updated_at + * @property string $title + * @property string|null $description + * @property int $owner_id + * @property User $owner + * @property bool $is_public + * @property bool $grants_full_photo + * @property bool $requires_link + * @property bool $is_downloadable + * @property bool $is_share_button_visible + * @property bool $is_nsfw + * @property Collection $shared_with + * @property string|null $password + * @property bool $has_password + * @property string|null $sorting_col + * @property string|null $sorting_order + */ +class BaseAlbumImpl extends Model implements HasRandomID +{ + use HasAttributesPatch; + use HasRandomIDAndLegacyTimeBasedID; + use UTCBasedTimes; + use HasBidirectionalRelationships; + + protected $table = 'base_albums'; + + /** + * @var string The type of the primary key + */ + protected $keyType = \App\Contracts\HasRandomID::ID_TYPE; + + /** + * Indicates if the model's primary key is auto-incrementing. + * + * @var bool + */ + public $incrementing = false; + + /** + * The model's attributes. + * + * We must list all attributes explicitly here, otherwise the attributes + * of a new model will accidentally be set on the child class. + * The trait {@link \App\Models\Extensions\ForwardsToParentImplementation} + * only works properly, if it knows which attributes belong to the parent + * class and which attributes belong to the child class. + * + * @var array + */ + protected $attributes = [ + 'id' => null, + HasRandomID::LEGACY_ID_NAME => null, + 'created_at' => null, + 'updated_at' => null, + 'title' => null, // Sic! `title` is actually non-nullable, but using `null` here forces the caller to actually set a title before saving. + 'description' => null, + 'owner_id' => 0, + 'is_public' => false, + 'grants_full_photo' => true, + 'requires_link' => false, + 'is_downloadable' => false, + 'is_share_button_visible' => false, + 'is_nsfw' => false, + 'password' => null, + 'sorting_col' => null, + 'sorting_order' => null, + ]; + + protected $casts = [ + 'id' => HasRandomID::ID_TYPE, + HasRandomID::LEGACY_ID_NAME => HasRandomID::LEGACY_ID_TYPE, + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'is_public' => 'boolean', + 'requires_link' => 'boolean', + 'is_nsfw' => 'boolean', + 'owner_id' => 'integer', + ]; + + /** + * @var string[] The list of attributes which exist as columns of the DB + * relation but shall not be serialized to JSON + */ + protected $hidden = [ + HasRandomID::LEGACY_ID_NAME, + 'owner_id', + 'owner', + 'password', + ]; + + /** + * @var string[] The list of "virtual" attributes which do not exist as + * columns of the DB relation but which shall be appended to + * JSON from accessors + */ + protected $appends = [ + 'has_password', + ]; + + /** + * The relationships that should always be eagerly loaded by default. + */ + protected $with = ['owner']; + + /** + * Returns the relationship between an album and its owner. + * + * @return BelongsTo + */ + public function owner(): BelongsTo + { + return $this->belongsTo('App\Models\User', 'owner_id', 'id'); + } + + /** + * Returns the relationship between an album and all users with whom + * this album is shared. + * + * @return BelongsToMany + */ + public function shared_with(): BelongsToMany + { + return $this->belongsToMany( + 'App\Models\User', + 'user_base_album', + 'base_album_id', + 'user_id' + ); + } + + protected function getGrantsFullPhotoAttribute(bool $value): bool + { + if ($this->is_public) { + return $value; + } else { + return Configs::get_value('full_photo', '1') === '1'; + } + } + + protected function getIsDownloadableAttribute(bool $value): bool + { + if ($this->is_public) { + return $value; + } else { + return Configs::get_value('downloadable', '0') === '1'; + } + } + + protected function getIsShareButtonVisibleAttribute(bool $value): bool + { + if ($this->is_public) { + return $value; + } else { + return Configs::get_value('share_button_visible', '0') === '1'; + } + } + + protected function getHasPasswordAttribute(): bool + { + return !empty($this->password); + } + + public function toArray(): array + { + $result = parent::toArray(); + if (AccessControl::is_logged_in()) { + $result['owner_name'] = $this->owner->name(); + } + + return $result; + } +} diff --git a/app/Models/Configs.php b/app/Models/Configs.php index 810d92a5759..b241bfe77a0 100644 --- a/app/Models/Configs.php +++ b/app/Models/Configs.php @@ -31,7 +31,6 @@ * @method static Builder|Configs whereId($value) * @method static Builder|Configs whereKey($value) * @method static Builder|Configs whereValue($value) - * @mixin Eloquent */ class Configs extends Model { @@ -178,11 +177,7 @@ public static function get_value(string $key, $default = null) /* * For some reason the $default is not returned above... */ - try { - Logs::notice(__METHOD__, __LINE__, $key . ' does not exist in config (local) !'); - } catch (Exception $e) { - // yeah we do nothing because we cannot do anything in that case ... :p - } + Logs::notice(__METHOD__, __LINE__, $key . ' does not exist in config (local) !'); return $default; } @@ -199,9 +194,10 @@ public static function get_value(string $key, $default = null) * * @return bool returns true when successful */ - public static function set(string $key, $value) + public static function set(string $key, $value): bool { - $config = Configs::where('key', '=', $key)->first(); + /** @var Configs|null $config */ + $config = Configs::query()->where('key', '=', $key)->first(); // first() may return null, fixup 'Creating default object from empty value' error // we also log a warning diff --git a/app/Models/Extensions/AlbumBooleans.php b/app/Models/Extensions/AlbumBooleans.php deleted file mode 100644 index 6b7961e8f9c..00000000000 --- a/app/Models/Extensions/AlbumBooleans.php +++ /dev/null @@ -1,70 +0,0 @@ -public == 1; - } - - /** - * Return whether or not public users will see the full photo. - * - * @return bool - */ - public function is_full_photo_visible() - { - if ($this->is_public()) { - return $this->full_photo == 1; - } else { - return Configs::get_value('full_photo', '1') === '1'; - } - } - - /** - * Return whether or not public users can download photos. - * - * @return bool - */ - public function is_downloadable(): bool - { - if ($this->is_public()) { - return $this->downloadable == 1; - } else { - return Configs::get_value('downloadable', '0') === '1'; - } - } - - /** - * Return whether or not display share button. - * - * @return bool - */ - public function is_share_button_visible(): bool - { - if ($this->is_public()) { - return $this->share_button_visible == 1; - } else { - return Configs::get_value('share_button_visible', '0') === '1'; - } - } - - public function is_smart() - { - return $this->smart; - } - - public function is_tag_album() - { - return $this->smart && !empty($this->showtags); - } -} diff --git a/app/Models/Extensions/AlbumBuilder.php b/app/Models/Extensions/AlbumBuilder.php new file mode 100644 index 00000000000..e04b212cb5a --- /dev/null +++ b/app/Models/Extensions/AlbumBuilder.php @@ -0,0 +1,58 @@ +getQuery(); + if ( + ($columns == ['*'] || $columns == ['albums.*']) && + ($baseQuery->columns == ['*'] || $baseQuery->columns == ['albums.*'] || $baseQuery->columns == null) + ) { + $this->addSelect([ + 'min_taken_at' => Photo::query() + ->select('taken_at') + ->join('albums as a', 'a.id', '=', 'album_id') + ->whereColumn('a._lft', '>=', 'albums._lft') + ->whereColumn('a._rgt', '<=', 'albums._rgt') + ->whereNotNull('taken_at') + ->orderBy('taken_at', 'asc') + ->limit(1), + 'max_taken_at' => Photo::query() + ->select('taken_at') + ->join('albums as a', 'a.id', '=', 'album_id') + ->whereColumn('a._lft', '>=', 'albums._lft') + ->whereColumn('a._rgt', '<=', 'albums._rgt') + ->whereNotNull('taken_at') + ->orderBy('taken_at', 'desc') + ->limit(1), + ]); + } + + return parent::getModels($columns); + } +} diff --git a/app/Models/Extensions/AlbumCast.php b/app/Models/Extensions/AlbumCast.php deleted file mode 100644 index 650df22963b..00000000000 --- a/app/Models/Extensions/AlbumCast.php +++ /dev/null @@ -1,99 +0,0 @@ - strval($this->id), - 'title' => $this->title, - 'public' => Helpers::str_of_bool($this->is_public()), - 'full_photo' => Helpers::str_of_bool($this->is_full_photo_visible()), - 'visible' => strval($this->viewable), - 'nsfw' => strval($this->nsfw), - 'parent_id' => $this->str_parent_id(), - 'cover_id' => strval($this->cover_id), - 'description' => strval($this->description), - - 'downloadable' => Helpers::str_of_bool($this->is_downloadable()), - 'share_button_visible' => Helpers::str_of_bool($this->is_share_button_visible()), - - 'created_at' => $this->created_at->format(\DateTimeInterface::ATOM), - 'updated_at' => $this->updated_at->format(\DateTimeInterface::ATOM), - 'min_taken_at' => $this->min_taken_at !== null ? $this->min_taken_at->format(\DateTimeInterface::ATOM) : null, - 'max_taken_at' => $this->max_taken_at !== null ? $this->max_taken_at->format(\DateTimeInterface::ATOM) : null, - - // Parse password - 'password' => Helpers::str_of_bool($this->password != ''), - 'license' => $this->get_license(), - - // Parse Ordering - 'sorting_col' => $this->sorting_col, - 'sorting_order' => $this->sorting_order, - - 'thumb' => optional($this->get_thumb())->toArray(), - 'has_albums' => Helpers::str_of_bool($this->isLeaf() === false), - ]; - - if ($this->is_tag_album()) { - $return['tag_album'] = '1'; - $return['show_tags'] = $this->showtags; - } - - if (!empty($this->showtags) || !$this->smart) { - if (AccessControl::is_logged_in()) { - $return['owner'] = $this->owner->name(); - } - } - - return $return; - } - - public function toTagAlbum(): TagAlbum - { - /** - * ! DO NOT USE ->save() on this object! - * It is convenient to quickly convert, but if you want to ->save(), - * this will create conflict in the database as NestedTree thinks it - * is a new object and not an already existing one. - */ - $tag_album = resolve(TagAlbum::class); - $tag_album->id = $this->id; - $tag_album->title = $this->title; - $tag_album->owner_id = $this->owner_id; - $tag_album->parent_id = $this->parent_id; - $tag_album->_lft = $this->_lft; - $tag_album->_rgt = $this->_rgt; - $tag_album->description = $this->description ?? ''; - $tag_album->min_taken_at = $this->min_taken_at; - $tag_album->max_taken_at = $this->max_taken_at; - $tag_album->public = $this->public; - $tag_album->full_photo = $this->full_photo; - $tag_album->viewable = $this->viewable; - $tag_album->nsfw = $this->nsfw; - $tag_album->downloadable = $this->downloadable; - $tag_album->password = $this->password; - $tag_album->license = $this->license; - $tag_album->created_at = $this->created_at; - $tag_album->updated_at = $this->updated_at; - $tag_album->share_button_visible = $this->share_button_visible; - $tag_album->smart = $this->smart; - $tag_album->showtags = $this->showtags; - - return $tag_album; - } -} diff --git a/app/Models/Extensions/AlbumGetters.php b/app/Models/Extensions/AlbumGetters.php deleted file mode 100644 index 45ebb7f5dab..00000000000 --- a/app/Models/Extensions/AlbumGetters.php +++ /dev/null @@ -1,121 +0,0 @@ -sorting_col == null || $this->sorting_col == '') { - $sort_col = Configs::get_value('sorting_Photos_col'); - $sort_order = Configs::get_value('sorting_Photos_order'); - } else { - $sort_col = $this->sorting_col; - $sort_order = $this->sorting_order; - } - - return [$sort_col, $sort_order]; - } - - /** - * Return the Album license or the default one. - * - * @return string - */ - public function get_license(): string - { - if ($this->license == 'none') { - return Configs::get_value('default_license'); - } - - return $this->license; - } - - /** - * Return a query builder or an SQL relation for the list of photos. - * - * See comment in {@link \App\SmartAlbums\BareSmartAlbum} why we need - * an ambitious return type here. - * - * @return Builder|HasMany - */ - public function get_photos() - { - return $this->photos(); - } - - /** - * Return a Query with all the subsequent pictures. - * - * @return Builder - */ - public function get_all_photos(): Builder - { - return Photo::query() - ->leftJoin('albums', 'photos.album_id', '=', 'albums.id') - ->select('photos.*') - ->where('albums._lft', '>=', $this->_lft) - ->where('albums._rgt', '<=', $this->_rgt); - } - - public function get_thumb(): ?Thumb - { - if ($this->cover != null) { - $cover = $this->cover; - } else { - [$sort_col, $sort_order] = $this->get_sort(); - - /* @var Builder|HasMany $sql */ - if ($this->is_smart()) { - $sql = $this->get_photos(); - } else { - $sql = $this->get_all_photos(); - } - - //? apply safety filter : Do not leak pictures which are not ours - $forbiddenID = resolve(PublicIds::class)->getNotAccessible(); - - if ($forbiddenID != null && !$forbiddenID->isEmpty()) { - $sql = $sql->where( - fn ($q) => $q->whereNull('album_id') - ->orWhereNotIn('album_id', $forbiddenID) - ); - } - - $cover = $sql->orderBy('star', 'DESC') - ->orderBy($sort_col, $sort_order) - ->orderBy('photos.id', 'ASC') - ->limit(1) - ->first(); - } - - return optional($cover)->toThumb(); - } - - public function get_children() - { - $sortingCol = Configs::get_value('sorting_Albums_col'); - $sortingOrder = Configs::get_value('sorting_Albums_order'); - - $sql = self::query()->where('parent_id', '=', $this->id); - //? apply safety filter : Do not leak albums which are not visible - $sql = $this->publicViewable($sql); - - return $this->customSort($sql, $sortingCol, $sortingOrder); - } -} diff --git a/app/Models/Extensions/AlbumSetters.php b/app/Models/Extensions/AlbumSetters.php deleted file mode 100644 index 2200acc4c46..00000000000 --- a/app/Models/Extensions/AlbumSetters.php +++ /dev/null @@ -1,11 +0,0 @@ -toArray(); - } -} diff --git a/app/Models/Extensions/AlbumStringify.php b/app/Models/Extensions/AlbumStringify.php deleted file mode 100644 index ba32ce3e5aa..00000000000 --- a/app/Models/Extensions/AlbumStringify.php +++ /dev/null @@ -1,16 +0,0 @@ -parent_id == null ? '' : strval($this->parent_id); - } -} diff --git a/app/Models/Extensions/BaseAlbum.php b/app/Models/Extensions/BaseAlbum.php new file mode 100644 index 00000000000..32dff6f8db9 --- /dev/null +++ b/app/Models/Extensions/BaseAlbum.php @@ -0,0 +1,126 @@ +belongsTo(BaseAlbumImpl::class, 'id', 'id'); + } + + /** + * Returns the relationship between an album and its owner. + * + * @return BelongsTo + */ + public function owner(): BelongsTo + { + return $this->base_class->owner(); + } + + /** + * Returns the relationship between an album and all users with whom + * this album is shared. + * + * @return BelongsToMany + */ + public function shared_with(): BelongsToMany + { + return $this->base_class->shared_with(); + } + + abstract public function photos(): Relation; + + public function toArray(): array + { + return array_merge(parent::toArray(), $this->base_class->toArray()); + } + + /** + * Returns the attribute acc. to which **photos** inside the album shall be sorted. + * + * @return string the attribute acc. to which **photos** inside the album shall be sorted + */ + public function getEffectiveSortingCol(): string + { + $sortingCol = $this->sorting_col; + + return empty($sortingCol) ? + Configs::get_value('sorting_Photos_col', 'created_at') : + $sortingCol; + } + + /** + * Returns the direction acc. to which **photos** inside the album shall be sorted. + * + * @return string the direction acc. to which **photos** inside the album shall be sorted + */ + public function getEffectiveSortingOrder(): string + { + $sortingCol = $this->sorting_col; + $sortingOrder = $this->sorting_order; + + return empty($sortingCol) || empty($sortingOrder) ? + Configs::get_value('sorting_Photos_order', 'ASC') : + $sortingOrder; + } +} diff --git a/app/Models/Extensions/ConfigsHas.php b/app/Models/Extensions/ConfigsHas.php index 6ffa345a745..1c7c04bd726 100644 --- a/app/Models/Extensions/ConfigsHas.php +++ b/app/Models/Extensions/ConfigsHas.php @@ -27,7 +27,7 @@ public static function hasImagick() /** * @return bool returns the Exiftool setting */ - public static function hasExiftool() + public static function hasExiftool(): bool { // has_exiftool has the following values: // 0: No Exiftool @@ -62,9 +62,9 @@ public static function hasExiftool() } /** - * @return bool returns the Exiftool setting + * @return bool returns the FFMpeg setting */ - public static function hasFFmpeg() + public static function hasFFmpeg(): bool { // has_ffmpeg has the following values: // 0: No ffmpeg diff --git a/app/Models/Extensions/CustomSort.php b/app/Models/Extensions/CustomSort.php deleted file mode 100644 index c683aa43fbd..00000000000 --- a/app/Models/Extensions/CustomSort.php +++ /dev/null @@ -1,28 +0,0 @@ -orderBy($sortingCol, $sortingOrder) - ->get(); - } else { - return $query - ->get() - ->sortBy($sortingCol, SORT_NATURAL | SORT_FLAG_CASE, $sortingOrder === 'DESC'); - } - } -} diff --git a/app/Models/Extensions/ForwardsToParentImplementation.php b/app/Models/Extensions/ForwardsToParentImplementation.php new file mode 100644 index 00000000000..3ff8ef7c8d2 --- /dev/null +++ b/app/Models/Extensions/ForwardsToParentImplementation.php @@ -0,0 +1,460 @@ +touches = array_diff($this->touches, ['base_class']); + $this->appends = array_diff($this->appends, ['base_class']); + $this->makeHidden('base_class'); + $this->with[] = 'base_class'; + $this->timestamps = false; + $this->incrementing = false; + } + + /** + * Perform a model insert operation. + * + * @param Builder $query + * + * @return bool + */ + protected function performInsert(Builder $query): bool + { + if (!$this->relationLoaded('base_class')) { + throw new \LogicException('cannot create a child class whose base class is not loaded'); + } + /** @var Model $base_class */ + $base_class = $this->getRelation('base_class'); + if ($base_class->exists) { + throw new \LogicException('cannot create a child class whose base class already exists'); + } + // Save and therewith create the base class + if (!$base_class->save()) { + return false; + } + // Inherit the key of the base class + $this->attributes[$this->getKeyName()] = $base_class->getKey(); + + return parent::performInsert($query); + } + + /** + * Perform a model update operation. + * + * @param Builder $query + * + * @return bool + */ + protected function performUpdate(Builder $query): bool + { + /** @var Model $base_class */ + $base_class = $this->base_class; + // touch() also indirectly saves the base_class hence any other + // attributes which require an update are also saved + if (!$base_class->touch()) { + return false; + } + + return parent::performUpdate($query); + } + + /** + * Delete the model from the database. + * + * @return bool + * + * @throws \LogicException + */ + public function delete(): bool + { + /** @var ?Model $base_class */ + $base_class = $this->base_class; + + $parentDelete = parent::delete(); + if ($parentDelete === false) { + // Sic! Don't use `!$parentDelete` in condition, because we also + // need to proceed if `$parentDelete === null` . + // If Eloquent returns `null` (instead of `true`), this also + // indicates a success and we must go on. + // Eloquent, I love you .... not. + return false; + } + + // We must explicitly check if the base_class still exists in order + // to avoid an infinite recursion, as the base class will also call + // delete() on this class + if ($base_class !== null && $base_class->exists) { + $baseDelete = $base_class->delete(); + // Same stupidity as above, if Eloquent returns `null` this also + // means `true` here. + return $baseDelete !== false; + } + + return true; + } + + /** + * Indicates whether the model has timestamps. + * + * Returns always false, because the child model uses the timestamps of + * its parent model + * + * @return bool always false + */ + public function usesTimestamps(): bool + { + return false; + } + + /** + * Indicates whether the ID of the model is incrementing. + * + * Returns always false, because the child model inherits the ID of its + * parent model. + * + * @return bool always false + */ + public function getIncrementing(): bool + { + return false; + } + + /** + * Determine if the model or any of the given attribute(s) have been modified. + * + * Inspired by {@link \Illuminate\Database\Eloquent\Concerns\HasAttributes::isDirty()}. + * + * @param array|string|null $attributes + * + * @return bool + */ + public function isDirty($attributes = null): bool + { + $baseIsDirty = $this->relationLoaded('base_class') && $this->getRelation('base_class')->isDirty(); + + return $baseIsDirty || $this->hasChanges( + $this->getDirty(), is_array($attributes) ? $attributes : func_get_args() + ); + } + + /** + * Convert the model instance to an array. + * + * @return array + */ + public function toArray(): array + { + return array_merge(parent::toArray(), $this->base_class->toArray()); + } + + /** + * Get an attribute from the model. + * + * This method is heavily inspired by + * {@link \Illuminate\Database\Concerns\HasAttributes::getAttribute()}. + * This method is modified in three ways: + * + * 1. A preliminary check if the requested attribute equals `'base_class'`. + * This is necessary to avoid infinite loops in combination with 2). + * 2. A final call which forwards to the implementation of the base class + * at the end, if the default code of + * {@link \Illuminate\Database\Concerns\HasAttributes::getAttribute()} + * would have fallen through. + * 3. While the middle part is basically a copy of the original code, + * we had to tweak it slightly. + * The original code calls `getRelationValue`, if the `$key` is not + * an attribute, but we had to inline the code of `getRelationValue` + * here due to two reasons: + * + * 1. This trait also overwrites `getRelationValue` such that + * `getRelationValue` checks for a relation on both the child + * and the parent model. + * But here, we only must check on the child model, so we must + * not call `getRelationValue`. + * 2. If `getRelationValue` returns `null` it is impossible to + * distinguish, if `null` has been returned because the relation + * exists and equals null or if no relation of that name exists + * at all. + * However, only in the latter case we want to forward the call to + * the parent. + * In the former case, we must return null directly. + * + * @param string $key the name of the queried attribute or relation + * + * @return mixed the value of the attribute or relation + */ + public function getAttribute($key): mixed + { + if (!$key) { + return null; + } + + // If the primary key is requested, we must use a shortcut. + // If the primary key of the model is not yet set as it might be the + // case for new models, the implementation otherwise would fall + // through until the end and try to forward the call to the base class. + // However, asking for the primary key of the base class is + // a) insane, because it should be identical to the primary key of + // this class, and + // b) does not work, because we cannot load the base class without + // knowing the primary key. + if ($key == $this->getKeyName()) { + // Sic! + // Don't use `$this->getKey()` because this would call + // `getAttribute` again, and we would end up in an infinite loop. + // Just get the attribute directly. + return $this->getAttributeValue($key); + } + + // Avoid infinite loops, see below + if ($key == 'base_class') { + return $this->getRelationValue($key); + } + + // If the attribute exists in the attribute array or has a "get" + // mutator we will get the attribute's value. + // Otherwise, we will proceed as if the developers + // are asking for a relationship's value. This covers both types of values. + if (array_key_exists($key, $this->attributes) || + array_key_exists($key, $this->casts) || + $this->hasGetMutator($key) || + $this->isClassCastable($key)) { + return $this->getAttributeValue($key); + } + + // Here we will determine if the model base class itself contains this given key + // since we don't want to treat any of those methods as relationships because + // they are all intended as helper methods and none of these are relations. + if (method_exists(Model::class, $key)) { + return null; + } + + // If the key already exists in the relationships array, it just means the + // relationship has already been loaded, so we'll just return it out of + // here because there is no need to query within the relations twice. + if ($this->relationLoaded($key)) { + return $this->relations[$key]; + } + + // If the "attribute" exists as a method on the model, we will just assume + // it is a relationship and will load and return results from the query + // and hydrate the relationship's value on the "relationships" array. + if (method_exists($this, $key) || + (static::$relationResolvers[get_class($this)][$key] ?? null)) { + return $this->getRelationshipFromMethod($key); + } + + // If we have fallen through until here, the using "child" class has + // no matching property nor relation. + // So we try the implementation of the "parent" class. + // Note, that his will load the relation of the parent class, if it + // has not been loaded yet. + // To avoid infinite loops, we had to check for "base_class" early in + // this method. + return $this->base_class->getAttribute($key); + } + + /** + * Get the value of a relationship. + * + * This method is heavily inspired by + * {@link \Illuminate\Database\Eloquent\Concerns\HasAttributes::getRelationValue()}. + * + * @param string $key the name of the queried relation + * + * @return mixed the value of the relation if it could be loaded + */ + public function getRelationValue($key): mixed + { + // If the key already exists in the relationships array, this means the + // relationship has already been loaded, so we'll just return it out of + // here because there is no need to query the relations twice. + if ($this->relationLoaded($key)) { + return $this->getRelation($key); + } + + // Avoid infinite loops + // Here we assume that the using class provides a relation `base_class` + // (no check if such a method exists) and we rely on the fact that + // `getRelationshipFromMethod` throws an exception if no such method + // exists. + // Bailing out with an exception prevents the infinite loop. + if ($key == 'base_class') { + // If this is a newly created model, then we cannot resolve the + // relation to the base class from the database, because no such + // entity exists. + // In particular, calling the relation requires that this instance + // of a model already has a valid primary key which does not exist + // for a freshly created model. + $primaryKey = $this->getKey(); + if (!$this->exists) { + if ($primaryKey) { + throw new \LogicException('the primary key must not be set if the model does not exist'); + } + $baseModel = $this->base_class()->getRelated()->newInstance(); + $this->setRelation('base_class', $baseModel); + + return $baseModel; + } else { + // This model exists, but the relation to the base class + // has not yet been loaded. + // Load it now. + if (!$primaryKey) { + throw new \LogicException('the model allegedly exists, but we don\'t have a primary key, cannot load base model'); + } + if (!method_exists($this, 'base_class')) { + throw new \LogicException('the model "' . get_class($this) . '" does not provide a method "base_class()", cannot load base model'); + } + + return $this->getRelationshipFromMethod('base_class'); + } + } + + // If the "attribute" exists as a method on the model, we will just assume + // it is a relationship and will load and return results from the query + // and hydrate the relationship's value on the "relationships" array. + if (method_exists($this, $key) || + (static::$relationResolvers[get_class($this)][$key] ?? null)) { + return $this->getRelationshipFromMethod($key); + } + + // If we have fallen through until here, the using "child" class has + // no matching property nor relation. + // So we try the implementation of the "parent" class. + // Note, that his will load the relation of the parent class, if it + // has not been loaded yet. + // To avoid infinite loops, we had to check for "base_class" early in + // this method. + return $this->base_class->getRelationValue($key); + } + + /** + * Set a given attribute on the model. + * + * This method is heavily inspired by + * {@link \Illuminate\Database\Concerns\HasAttributes::setAttribute()}. + * + * @param string $key + * @param mixed $value + * + * @return mixed + */ + public function setAttribute($key, $value): mixed + { + // First we will check for the presence of a mutator for the set operation + // which simply lets the developers tweak the attribute as it is set on + // this model, such as "json_encoding" a listing of data for storage. + if ($this->hasSetMutator($key)) { + return $this->setMutatedAttributeValue($key, $value); + } + + // If an attribute is listed as a "date", we'll convert it from a DateTime + // instance into a form proper for storage in the database tables using + // the connection grammar's date format. We will auto set the values. + elseif ($value && $this->isDateAttribute($key)) { + $value = $this->fromDateTime($value); + } + + if ($this->isClassCastable($key)) { + $this->setClassCastableAttribute($key, $value); + + return $this; + } + + if (!is_null($value) && $this->isJsonCastable($key)) { + $value = $this->castAttributeAsJson($key, $value); + } + + // If this attribute contains a JSON ->, we'll set the proper value in the + // attribute's underlying array. This takes care of properly nesting an + // attribute in the array's value in the case of deeply nested items. + if (Str::contains($key, '->')) { + return $this->fillJsonAttribute($key, $value); + } + + if (!is_null($value) && $this->isEncryptedCastable($key)) { + $value = $this->castAttributeAsEncryptedString($key, $value); + } + + // If we have fallen through until here, we first check if the parent + // class provides an attribute of that name and then set the attribute + // on the parent class. + // Only if the parent class does not provide such an attribute either, + // we write it to the child class. + /** @var BaseAlbumImpl $baseClass */ + $baseClass = $this->base_class; + if ( + array_key_exists($key, $baseClass->getAttributes()) || + $baseClass->hasSetMutator($key) + ) { + $baseClass->setAttribute($key, $value); + } else { + $this->attributes[$key] = $value; + } + + return $this; + } + + /** + * Unset the value for a given offset. + * + * @param mixed $offset + * + * @return void + */ + public function offsetUnset($offset) + { + // Prevent that the base model is unset from the set of relations + if ($offset == 'base_class') { + return; + } + parent::offsetUnset($offset); + if ($this->relationLoaded('base_class')) { + $this->base_class->offsetUnset($offset); + } + } +} diff --git a/app/Models/Extensions/HasAttributesPatch.php b/app/Models/Extensions/HasAttributesPatch.php new file mode 100644 index 00000000000..20b0fba960c --- /dev/null +++ b/app/Models/Extensions/HasAttributesPatch.php @@ -0,0 +1,25 @@ +hasGetMutator($key)) { + $value = $this->mutateAttribute($key, $value); + } + if ($this->isClassCastable($key)) { + $value = $this->getClassCastableAttributeValue($key, $value); + } + + return $value instanceof Arrayable ? $value->toArray() : $value; + } +} diff --git a/app/Models/Extensions/HasBidirectionalRelationships.php b/app/Models/Extensions/HasBidirectionalRelationships.php new file mode 100644 index 00000000000..5251dbbb079 --- /dev/null +++ b/app/Models/Extensions/HasBidirectionalRelationships.php @@ -0,0 +1,115 @@ +$method(); + + if (!$relation instanceof Relation) { + if (is_null($relation)) { + throw new \LogicException(sprintf('%s::%s must return a relationship instance, but "null" was returned. Was the "return" keyword used?', static::class, $method)); + } + throw new \LogicException(sprintf('%s::%s must return a relationship instance.', static::class, $method)); + } + + $result = $relation->getResults(); + $this->setRelation($method, $result); + + // Now the additional code + // We also set the reverse direction of the relation, i.e. each + // hydrated model points back to this model + + if ($relation instanceof BidirectionalRelation) { + if ($result instanceof Collection) { + /** @var Model $model */ + foreach ($result as $model) { + $model->setRelation($relation->getForeignMethodName(), $this); + } + } elseif ($result instanceof Model) { + $result->setRelation($relation->getForeignMethodName(), $this); + } else { + throw new \LogicException(sprintf('$result must either be a collection of models or a model, but got %s', is_object($result) ? get_class($result) : gettype($result))); + } + } + + return $result; + } + + /** + * Define a one-to-many relationship. + * + * Inspired by {@link \Illuminate\Database\Eloquent\Concerns\HasRelationships::hasMany}. + * + * @param string $related + * @param string|null $foreignKey + * @param string|null $localKey + * @param string|null $foreignMethodName + * + * @return HasManyBidirectionally + */ + public function hasManyBidirectionally(string $related, ?string $foreignKey = null, ?string $localKey = null, ?string $foreignMethodName = null): HasManyBidirectionally + { + $instance = $this->newRelatedInstance($related); + + $foreignKey = $foreignKey ?: $this->getForeignKey(); + + $localKey = $localKey ?: $this->getKeyName(); + + $foreignMethodName = $foreignMethodName ?: $this->getForeignProperty(); + + return $this->newHasManyBidirectionally( + $instance->newQuery(), $this, $instance->getTable() . '.' . $foreignKey, $localKey, $foreignMethodName + ); + } + + /** + * Instantiate a new HasManyBidirectionally relationship. + * + * Inspired by {@link \Illuminate\Database\Eloquent\Concerns\HasRelationships::newHasMany}. + * + * @param Builder $query + * @param Model $parent + * @param string $foreignKey + * @param string $localKey + * @param string $foreignMethodName + * + * @return HasManyBidirectionally + */ + protected function newHasManyBidirectionally(Builder $query, Model $parent, string $foreignKey, string $localKey, string $foreignMethodName): HasManyBidirectionally + { + return new HasManyBidirectionally($query, $parent, $foreignKey, $localKey, $foreignMethodName); + } + + /** + * Get the default foreign method name for this model. + * + * @return string + */ + public function getForeignProperty(): string + { + return Str::snake(class_basename($this)); + } +} diff --git a/app/Models/Extensions/HasRandomIDAndLegacyTimeBasedID.php b/app/Models/Extensions/HasRandomIDAndLegacyTimeBasedID.php new file mode 100644 index 00000000000..93560b6d044 --- /dev/null +++ b/app/Models/Extensions/HasRandomIDAndLegacyTimeBasedID.php @@ -0,0 +1,158 @@ +getKeyName()) { + throw new \InvalidArgumentException('must not set primary key explicitly, primary key will be set on first insert'); + } + if ($key == HasRandomID::LEGACY_ID_NAME) { + throw new \InvalidArgumentException('must not set legacy key explicitly, legacy key will be set on first insert'); + } + + return parent::setAttribute($key, $value); + } + + /** + * Performs the `INSERT` operation of the model. + * + * This method also tries to create a unique, time-based ID. + * The method is mostly copied & pasted from {@link Model::performInsert()} + * with adoptions regarding key generation. + * + * @param Builder $query + * + * @return bool + */ + protected function performInsert(Builder $query): bool + { + if ($this->fireModelEvent('creating') === false) { + return false; + } + + // First we'll need to create a fresh query instance and touch the creation and + // update timestamps on this model, which are maintained by us for developer + // convenience. After, we will just continue saving these model instances. + if ($this->usesTimestamps()) { + $this->updateTimestamps(); + } + + $result = false; + $retryCounter = 5; + $lastException = null; + + do { + $retry = false; + try { + $retryCounter--; + $this->generateKey(); + $attributes = $this->getAttributesForInsert(); + $result = $query->insert($attributes); + } catch (QueryException $e) { + $lastException = $e; + $errorCode = $e->getCode(); + if ($errorCode == 23000 || $errorCode == 23505) { + // houston, we have a duplicate entry problem + // Our ids are based on current system time, so + // wait randomly up to 1s before retrying. + usleep(rand(0, 1000000)); + $retry = true; + } else { + throw $e; + } + } + } while ($retry && $retryCounter > 0); + + if ($retryCounter === 0) { + $msg = 'unable to persist model to DB after 5 unsuccessful attempts'; + Logs::error(__METHOD__, __LINE__, $msg); + throw new \RuntimeException($msg, 0, $lastException); + } + + // We will go ahead and set the exists property to true, so that it is set when + // the created event is fired, just in case the developer tries to update it + // during the event. This will allow them to do so and run an update here. + $this->exists = true; + $this->wasRecentlyCreated = true; + $this->fireModelEvent('created', false); + + return $result; + } + + /** + * Generates an ID for the primary key from current microtime. + */ + private function generateKey(): void + { + // URl-compatible variant of base64 encoding + // `+` and `/` are replaced by `-` and `_`, resp. + // The other characters (a-z, A-Z, 0-9) are legal within an URL. + // As the number of bytes is divisible by 3, no trailing `=` occurs. + $id = strtr(base64_encode(random_bytes(3 * HasRandomID::ID_LENGTH / 4)), '+/', '-_'); + + if ( + PHP_INT_MAX == 2147483647 + || Configs::get_value('force_32bit_ids', '0') === '1' + ) { + // For 32-bit installations, we can only afford to store the + // full seconds in id. The calling code needs to be able to + // handle duplicate ids. Note that this also exposes us to + // the year 2038 problem. + $legacyID = sprintf('%010d', microtime(true)); + } else { + // Ensure 4 digits after the decimal point, 15 characters + // total (including the decimal point), 0-padded on the + // left if needed (shouldn't be needed unless we move back in + // time :-) ) + $legacyID = sprintf('%015.4f', microtime(true)); + $legacyID = str_replace('.', '', $legacyID); + } + $this->attributes[$this->getKeyName()] = $id; + $this->attributes[HasRandomID::LEGACY_ID_NAME] = intval($legacyID); + } +} diff --git a/app/Models/Extensions/NodeTrait.php b/app/Models/Extensions/NodeTrait.php deleted file mode 100644 index 4f58afe862f..00000000000 --- a/app/Models/Extensions/NodeTrait.php +++ /dev/null @@ -1,65 +0,0 @@ -newQueryWithoutScopes(), $this); - } - - /** - * Get query ancestors of the node. - * - * @return AncestorsRelation - */ - public function ancestors() - { - return new AncestorsRelation($this->newQueryWithoutScopes(), $this); - } - - /** - * Get a new base query that includes deleted nodes. - * - * @since 1.1 - * - * @return QueryBuilder - */ - public function newNestedSetQuery($table = null) - { - return $this->applyNestedSetScope($this->newQueryWithoutScopes(), $table); - } - - /** - * @param string $table - * - * @return QueryBuilder - */ - public function newScopedQuery($table = null) - { - return $this->applyNestedSetScope($this->newQueryWithoutScopes(), $table); - } -} \ No newline at end of file diff --git a/app/Models/Extensions/PhotoBooleans.php b/app/Models/Extensions/PhotoBooleans.php index 473db427c0d..c49695d25d9 100644 --- a/app/Models/Extensions/PhotoBooleans.php +++ b/app/Models/Extensions/PhotoBooleans.php @@ -9,36 +9,26 @@ trait PhotoBooleans use Constants; /** - * Check if a photo already exists in the database via its checksum. - * - * ! Does not require the Photo Object. Should be moved. - * - * @param string $checksum - * @param $photoID + * We are checking if the beginning of the type string is + * video. * - * @return Photo|bool|Builder|Model|object + * type contains the mime information */ - public function isDuplicate(string $checksum, $photoID = null) + public function isVideo(): bool { - $sql = $this->where(function ($q) use ($checksum) { - $q->where('checksum', '=', $checksum) - ->orWhere('livePhotoChecksum', '=', $checksum); - }); - if (isset($photoID)) { - $sql = $sql->where('id', '<>', $photoID); + if (empty($this->type)) { + throw new \BadFunctionCallException('Photo::isVideo() must not be called before Photo::$type has been set'); } - return $sql->first() ?? false; + return $this->isValidVideoType($this->type); } - /** - * We are checking if the beginning of the type string is - * video. - * - * type contains the mime informations - */ - public function isVideo(): bool + public function isRaw(): bool { - return $this->isValidVideoType($this->type); + if (empty($this->type)) { + throw new \BadFunctionCallException('Photo::isRaw() must not be called before Photo::$type has been set'); + } + + return $this->type == 'raw'; } } diff --git a/app/Models/Extensions/PhotoCast.php b/app/Models/Extensions/PhotoCast.php deleted file mode 100644 index f719611abd0..00000000000 --- a/app/Models/Extensions/PhotoCast.php +++ /dev/null @@ -1,155 +0,0 @@ -isVideo()) { - $filename = $this->thumbUrl; - } elseif ($this->type == 'raw') { - // It's a raw file -> we also use jpeg as extension - $filename = $this->thumbUrl; - } else { - $filename = $this->url; - } - $filename2x = ($filename !== '') ? Helpers::ex2x($filename) : ''; - $thumbFileName2x = $this->thumb2x === 1 ? Helpers::ex2x($this->thumbUrl) : null; - - // The original size is not stored in this sub-array but on the root level of the JSON response - // TODO: Maybe harmonize and put original variant into this array, too? This would also avoid an ugly if branch in SymLink#override. - $sizeVariants = [ - Photo::VARIANT_THUMB => $this->serializeSizeVariant( - Photo::VARIANT_THUMB, $this->thumbUrl, Photo::THUMBNAIL_DIM, Photo::THUMBNAIL_DIM - ), - Photo::VARIANT_THUMB2X => $this->serializeSizeVariant( - Photo::VARIANT_THUMB2X, $thumbFileName2x, Photo::THUMBNAIL2X_DIM, Photo::THUMBNAIL2X_DIM - ), - Photo::VARIANT_SMALL => $this->serializeSizeVariant( - Photo::VARIANT_SMALL, $filename, $this->small_width, $this->small_height - ), - Photo::VARIANT_SMALL2X => $this->serializeSizeVariant( - Photo::VARIANT_SMALL2X, $filename2x, $this->small2x_width, $this->small2x_height - ), - Photo::VARIANT_MEDIUM => $this->serializeSizeVariant( - Photo::VARIANT_MEDIUM, $filename, $this->medium_width, $this->medium_height - ), - Photo::VARIANT_MEDIUM2X => $this->serializeSizeVariant( - Photo::VARIANT_MEDIUM2X, $filename2x, $this->medium2x_width, $this->medium2x_height - ), - ]; - - return [ - 'id' => strval($this->id), - 'title' => $this->title, - 'description' => $this->description == null ? '' : $this->description, - 'tags' => $this->tags, - 'star' => Helpers::str_of_bool($this->star), - 'public' => $this->get_public(), - 'album' => $this->album_id !== null ? strval($this->album_id) : null, - 'url' => ($this->type == 'raw') ? Storage::url('raw/' . $this->url) : Storage::url('big/' . $this->url), - 'width' => $this->width !== null ? $this->width : 0, - 'height' => $this->height !== null ? $this->height : 0, - 'type' => $this->type, - 'filesize' => $this->filesize, - 'iso' => $this->iso, - 'aperture' => $this->aperture, - 'make' => $this->make, - 'model' => $this->model, - 'shutter' => $this->get_shutter_str(), - // We need to format the framerate (stored as focal) -> max 2 decimal digits - 'focal' => (strpos($this->type, 'video') === 0) ? round(floatval($this->focal), 2) : $this->focal, - 'lens' => $this->lens, - 'latitude' => $this->latitude, - 'longitude' => $this->longitude, - 'altitude' => $this->altitude, - 'imgDirection' => $this->imgDirection, - 'location' => $this->location, - 'livePhotoContentID' => $this->livePhotoContentID, - 'livePhotoUrl' => (!empty($this->livePhotoUrl)) ? Storage::url('big/' . $this->livePhotoUrl) : null, - 'created_at' => $this->created_at->format(\DateTimeInterface::ATOM), - 'updated_at' => $this->updated_at->format(\DateTimeInterface::ATOM), - 'taken_at' => (!empty($this->taken_at)) ? $this->taken_at->format(\DateTimeInterface::ATOM) : null, - 'taken_at_orig_tz' => $this->taken_at_orig_tz, - 'license' => $this->license, - 'sizeVariants' => $sizeVariants, - ]; - } - - /** - * Returns a front-end friendly array which describes a particular size variant of a media file. - * - * @param string $sizeVariant The name of the size variant which is being serialized; used to determine the correct path prefix - * @param string|null $fileName The filename - * @param int|null $width The width of this variant - * @param int|null $height The height of this variant - * - * @return array|null An associative array with the following attributes "url", "width" and "height" or null, if - * any of the parameters is null - */ - protected function serializeSizeVariant(string $sizeVariant, ?string $fileName, ?int $width, ?int $height): ?array - { - if ($width === null || $height === null || $fileName === null || $fileName === '') { - return null; - } else { - return [ - 'url' => Storage::url(Photo::VARIANT_2_PATH_PREFIX[$sizeVariant] . '/' . $fileName), - 'width' => $width, - 'height' => $height, - ]; - } - } - - /** - * Given a Photo, returns the thumb version. - */ - public function toThumb(): Thumb - { - /* @var $symLinkFunctions ?SymLinkFunctions */ - $symLinkFunctions = resolve(SymLinkFunctions::class); - - $thumb = new Thumb($this->type, $this->id); - // maybe refactor? - $sym = $symLinkFunctions->find($this); - if ($sym !== null) { - $thumb->thumb = $sym->get(Photo::VARIANT_THUMB); - // default is '' so if thumb2x does not exist we just reply '' which is the behaviour we want - $thumb->thumb2x = $sym->get(Photo::VARIANT_THUMB2X); - } else { - $thumb->thumb = Storage::url( - Photo::VARIANT_2_PATH_PREFIX[Photo::VARIANT_THUMB] . '/' . $this->thumbUrl - ); - if ($this->thumb2x === 1) { - $thumb->set_thumb2x(); - } - } - - return $thumb; - } - - /** - * Downgrade the quality of the pictures. - * - * @param array $return - */ - public function downgrade(array &$return) - { - if ( - $this->isVideo() === false && - ($return['sizeVariants']['medium2x'] !== null || $return['sizeVariants']['medium'] !== null) - ) { - $return['url'] = ''; - } - } -} diff --git a/app/Models/Extensions/PhotoGetters.php b/app/Models/Extensions/PhotoGetters.php deleted file mode 100644 index 13fc65abb8d..00000000000 --- a/app/Models/Extensions/PhotoGetters.php +++ /dev/null @@ -1,78 +0,0 @@ -shutter; - // shutter speed needs to be processed. It is stored as a string `a/b s` - if ($shutter != '' && substr($shutter, 0, 2) != '1/') { - preg_match('/(\d+)\/(\d+) s/', $shutter, $matches); - if ($matches) { - $a = intval($matches[1]); - $b = intval($matches[2]); - if ($b != 0) { - try { - $gcd = Helpers::gcd($a, $b); - $a = $a / $gcd; - $b = $b / $gcd; - } catch (Exception $e) { - // this should not happen as we covered the case $b = 0; - } - if ($a == 1) { - $shutter = '1/' . $b . ' s'; - } else { - $shutter = ($a / $b) . ' s'; - } - } - } - } - - if ($shutter == '1/1 s') { - $shutter = '1 s'; - } - - return $shutter; - } - - /** - * Get the public value of a picture - * if 0 : picture is private - * if 1 : picture is public alone. - * - * @return string - */ - public function get_public(): string - { - return $this->public == 1 ? '1' : '0'; - } - - /** - * Return the Album license or the default one. - * - * @param string $license = album License - * - * @return string - */ - public function get_license(string $license = 'none'): string - { - if ($this->license != 'none') { - return $this->license; - } - - if ($license != 'none') { - return $license; - } - - return Configs::get_value('default_license'); - } -} diff --git a/app/Models/Extensions/SizeVariants.php b/app/Models/Extensions/SizeVariants.php new file mode 100644 index 00000000000..7befcb65937 --- /dev/null +++ b/app/Models/Extensions/SizeVariants.php @@ -0,0 +1,257 @@ +|null $sizeVariants a collection of size + * variants + */ + public function __construct(Photo $photo, ?Collection $sizeVariants = null) + { + $this->photo = $photo; + if ($sizeVariants) { + /** @var SizeVariant $sizeVariant */ + foreach ($sizeVariants as $sizeVariant) { + $this->add($sizeVariant); + } + } + } + + public function add(SizeVariant $sizeVariant): void + { + if ($sizeVariant->photo_id !== $this->photo->id) { + throw new \UnexpectedValueException('ID of owning photo does not match'); + } + $sizeVariant->setRelation('photo', $this->photo); + + switch ($sizeVariant->type) { + case SizeVariant::ORIGINAL: + $ref = &$this->original; + break; + case SizeVariant::MEDIUM2X: + $ref = &$this->medium2x; + break; + case SizeVariant::MEDIUM: + $ref = &$this->medium; + break; + case SizeVariant::SMALL2X: + $ref = &$this->small2x; + break; + case SizeVariant::SMALL: + $ref = &$this->small; + break; + case SizeVariant::THUMB2X: + $ref = &$this->thumb2x; + break; + case SizeVariant::THUMB: + $ref = &$this->thumb; + break; + default: + throw new \UnexpectedValueException('size variant ' . $sizeVariant . 'invalid'); + } + + if ($ref && $ref->id !== $sizeVariant->id) { + throw new \UnexpectedValueException('Another size variant of the same type has already been added'); + } + $ref = $sizeVariant; + } + + /** + * Serializes this object into an array. + * + * @return array The serialized properties of this object + */ + public function toArray(): array + { + return [ + 'original' => $this->original?->toArray(), + 'medium2x' => $this->medium2x?->toArray(), + 'medium' => $this->medium?->toArray(), + 'small2x' => $this->small2x?->toArray(), + 'small' => $this->small?->toArray(), + 'thumb2x' => $this->thumb2x?->toArray(), + 'thumb' => $this->thumb?->toArray(), + ]; + } + + /** + * Serializes this object into an array. + * + * @return array The serialized properties of this object + * + * @see SizeVariants::toArray() + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * Returns the requested size variant of the photo. + * + * @param int $sizeVariantType the type of the size variant; allowed + * values are: + * {@link SizeVariant::ORIGINAL}, + * {@link SizeVariant::MEDIUM2X}, + * {@link SizeVariant::MEDIUM2}, + * {@link SizeVariant::SMALL2X}, + * {@link SizeVariant::SMALL}, + * {@link SizeVariant::THUMB2X}, and + * {@link SizeVariant::THUMB} + * + * @return SizeVariant|null The size variant + */ + public function getSizeVariant(int $sizeVariantType): ?SizeVariant + { + return match ($sizeVariantType) { + SizeVariant::ORIGINAL => $this->original, + SizeVariant::MEDIUM2X => $this->medium2x, + SizeVariant::MEDIUM => $this->medium, + SizeVariant::SMALL2X => $this->small2x, + SizeVariant::SMALL => $this->small, + SizeVariant::THUMB2X => $this->thumb2x, + SizeVariant::THUMB => $this->thumb, + default => throw new \UnexpectedValueException('size variant ' . $sizeVariantType . 'invalid'), + }; + } + + public function getOriginal(): ?SizeVariant + { + return $this->original; + } + + public function getMedium(): ?SizeVariant + { + return $this->medium; + } + + public function getThumb2x(): ?SizeVariant + { + return $this->thumb2x; + } + + public function getThumb(): ?SizeVariant + { + return $this->thumb; + } + + /** + * Creates a new instance of {@link \App\Models\SizeVariant} for the + * associated photo and persists it to DB. + * + * @param int $sizeVariantType the type of the desired size variant; + * allowed values are: + * {@link SizeVariant::ORIGINAL}, + * {@link SizeVariant::MEDIUM2X}, + * {@link SizeVariant::MEDIUM2}, + * {@link SizeVariant::SMALL2X}, + * {@link SizeVariant::SMALL}, + * {@link SizeVariant::THUMB2X}, and + * {@link SizeVariant::THUMB} + * @param string $shortPath the short path of the media file this + * size variant shall point to + * @param int $width the width of the size variant + * @param int $height the height of the size variant + * + * @return SizeVariant The newly created and persisted size variant + */ + public function create(int $sizeVariantType, string $shortPath, int $width, int $height): SizeVariant + { + if (!$this->photo->exists) { + throw new \LogicException('cannot create a size variant for a photo whose id is not yet persisted to DB'); + } + /** @var SizeVariant $result */ + $result = new SizeVariant(); + $result->photo_id = $this->photo->id; + $result->type = $sizeVariantType; + $result->short_path = $shortPath; + $result->width = $width; + $result->height = $height; + if (!$result->save()) { + throw new \RuntimeException('could not persist size variant'); + } + $this->add($result); + + return $result; + } + + /** + * Deletes all size variants incl. the files from storage. + * + * @param bool $keepOriginalFile if true, the original size variant is + * still removed from the DB and the model, + * but the media file is kept + * @param bool $keepAllFiles if true, all size variants are still + * removed from the DB and the model, but + * the media files are kept + * + * @return bool True on success, false otherwise + */ + public function deleteAll(bool $keepOriginalFile = false, bool $keepAllFiles = false): bool + { + $success = true; + $success &= !$this->original || $this->original->delete($keepOriginalFile || $keepAllFiles); + $this->original = null; + $success &= !$this->medium2x || $this->medium2x->delete($keepAllFiles); + $this->medium2x = null; + $success &= !$this->medium || $this->medium->delete($keepAllFiles); + $this->medium = null; + $success &= !$this->small2x || $this->small2x->delete($keepAllFiles); + $this->small2x = null; + $success &= !$this->small || $this->small->delete($keepAllFiles); + $this->small = null; + $success &= !$this->thumb2x || $this->thumb2x->delete($keepAllFiles); + $this->thumb2x = null; + $success &= !$this->thumb || $this->thumb->delete($keepAllFiles); + $this->thumb = null; + + return $success; + } + + public function replicate(Photo $duplicatePhoto): SizeVariants + { + $duplicate = new SizeVariants($duplicatePhoto); + static::replicateSizeVariant($duplicate, $this->original); + static::replicateSizeVariant($duplicate, $this->medium2x); + static::replicateSizeVariant($duplicate, $this->medium); + static::replicateSizeVariant($duplicate, $this->small2x); + static::replicateSizeVariant($duplicate, $this->small); + static::replicateSizeVariant($duplicate, $this->thumb2x); + static::replicateSizeVariant($duplicate, $this->thumb); + + return $duplicate; + } + + private static function replicateSizeVariant(SizeVariants $duplicate, ?SizeVariant $sizeVariant): void + { + if ($sizeVariant !== null) { + $duplicate->create($sizeVariant->type, $sizeVariant->short_path, $sizeVariant->width, $sizeVariant->height); + } + } +} diff --git a/app/Models/Extensions/SortingDecorator.php b/app/Models/Extensions/SortingDecorator.php new file mode 100644 index 00000000000..4e52502b343 --- /dev/null +++ b/app/Models/Extensions/SortingDecorator.php @@ -0,0 +1,115 @@ +baseBuilder = $baseBuilder; + } + + /** + * The list of all sorting criteria in descending priority. + * + * The sorting criterion at index 0 is the most significant criterion; + * the sorting criterion at index `length-1` is the least significant + * criterion. + * + * If everything can be sorted on the SQL layer, then the SQL basically + * has to look like that: + * + * $query->orderBy($orderBy[0])->orderBy($orderBy[1])->...->orderBy($orderBy[length-1]) + * + * For SQL the most significant order criterion has to be put first. + * + * If everything needs to be sorted on the software layer (i.e. with + * Laravel Collections), then the criteria must be applied in reverse + * order like this + * + * $collection->sortBy($orderBy[length-1])->...->sortBy($orderBy[1])->sortBy($orderBy[0]) + * + * The reason is that each `sortBy` immediately executes a _stable_ sort + * and thus the last one "wins". + * + * The mixed case with some pre-sorting on the SQL layer and final sorting + * on the software layer is more complicated. + * + * @var array{column: string, direction:string}[] + */ + protected array $orderBy = []; + + /** + * The index for {@link SortingDecorator::$orderBy} at which we must + * switch from SQL sorting to PHP sorting. + * + * Criteria between `0` ... `$pivotIdx` are sorted on the software layer + * (in reverse order). + * Criteria between `$pivotIdx+1` ... `length-1` are sorted on the SQL + * layer. + * + * If `$pivotIdx == -1`, then everything is sorted on the SQL layer. + * `$pivotIdx` is only set to a different value, if a sorting criteria + * which must be postponed (see {@link SortingDecorator::POSTPONE_COLUMNS}) + * is added. + * Then `$pivotIdx` points to that with the least priority, because from + * there on everything must be sorted in software. + * + * @var int + */ + protected int $pivotIdx = -1; + + public function orderBy($column, $direction = 'asc'): SortingDecorator + { + $direction = strtolower($direction); + if (!in_array($direction, ['asc', 'desc'], true)) { + throw new \InvalidArgumentException('Order direction must be "asc" or "desc".'); + } + $this->orderBy[] = [ + 'column' => $column, + 'direction' => $direction, + ]; + + if (in_array($column, self::POSTPONE_COLUMNS)) { + $this->pivotIdx = sizeof($this->orderBy) - 1; + } + + return $this; + } + + public function get($columns = ['*']): Collection + { + // Sort as much as we can on the SQL layer, i.e. everything with a + // lower significance than the least significant criterion which + // requires natural sorting. + for ($i = $this->pivotIdx + 1; $i < sizeof($this->orderBy); $i++) { + $this->baseBuilder->orderBy($this->orderBy[$i]['column'], $this->orderBy[$i]['direction']); + } + + /** @var Collection $result */ + $result = $this->baseBuilder->get($columns); + + // Sort with PHP for the remaining criteria in reverse order. + for ($i = $this->pivotIdx; $i >= 0; $i--) { + $column = $this->orderBy[$i]['column']; + $options = in_array($column, self::POSTPONE_COLUMNS) ? SORT_NATURAL | SORT_FLAG_CASE : SORT_REGULAR; + $result = $result->sortBy( + $column, + $options, + $this->orderBy[$i]['direction'] === 'desc' + )->values(); + } + + return $result; + } +} diff --git a/app/Models/Extensions/TagAlbumBuilder.php b/app/Models/Extensions/TagAlbumBuilder.php new file mode 100644 index 00000000000..d57b81c67ed --- /dev/null +++ b/app/Models/Extensions/TagAlbumBuilder.php @@ -0,0 +1,48 @@ +getQuery(); + if (empty($baseQuery->columns)) { + $this->select([$baseQuery->from . '.*']); + } + + if ( + ($columns == ['*'] || $columns == ['tag_albums.*']) && + ($baseQuery->columns == ['*'] || $baseQuery->columns == ['tag_albums.*']) + ) { + $this->addSelect([ + DB::raw('null as max_taken_at'), + DB::raw('null as min_taken_at'), + ]); + } + + return parent::getModels($columns); + } +} diff --git a/app/Models/Extensions/Thumb.php b/app/Models/Extensions/Thumb.php index 130df697a0b..057f066d69a 100644 --- a/app/Models/Extensions/Thumb.php +++ b/app/Models/Extensions/Thumb.php @@ -1,36 +1,115 @@ type = $type; $this->id = $id; + $this->type = $type; + $this->thumbUrl = $thumbUrl; + $this->thumb2xUrl = $thumb2xUrl; + } + + /** + * Restricts the given relation for size variants such that only the + * necessary variants for a thumbnail are selected. + * + * @param HasMany $relation + * + * @return HasMany + */ + public static function sizeVariantsFilter(HasMany $relation): HasMany + { + return $relation->whereIn('type', [SizeVariant::THUMB, SizeVariant::THUMB2X]); + } + + /** + * Creates a thumb by using the best rated photo from the given queryable. + * + * Note, this method assumes that the relation is already restricted + * such that it only returns photos which the current user may see. + * + * @param Relation|Builder $photoQueryable the relation to or query for {@link Photo} which is used to pick a thumb + * @param string $sortingCol the name of the column which shall be used to sort + * @param string $sortingOrder the sorting order either 'ASC' or 'DESC' + * + * @return Thumb|null the created thumbnail; null if the relation is empty + */ + public static function createFromQueryable(Relation|Builder $photoQueryable, string $sortingCol, string $sortingOrder): ?Thumb + { + /** @var Photo|null $cover */ + $cover = $photoQueryable + ->withOnly(['size_variants' => fn (HasMany $r) => self::sizeVariantsFilter($r)]) + ->orderBy('photos.is_starred', 'DESC') + ->orderBy('photos.' . $sortingCol, $sortingOrder) + ->select(['photos.id', 'photos.type']) + ->first(); + + return self::createFromPhoto($cover); } - public function set_thumb2x(): void + /** + * Creates a thumbnail from the given photo. + * + * @param Photo|null $photo the photo + * + * @return Thumb|null the created thumbnail or null if null has been passed + */ + public static function createFromPhoto(?Photo $photo): ?Thumb { - $this->thumb2x = Helpers::ex2x($this->thumb); + if (!$photo) { + return null; + } + $thumb = $photo->size_variants->getThumb(); + $thumb2x = $photo->size_variants->getThumb2x(); + + return new self( + $photo->id, + $photo->type, + $thumb?->url, + $thumb2x?->url + ); } + /** + * Serializes this object into an array. + * + * @return array The serialized properties of this object + */ public function toArray(): array { return [ - 'id' => strval($this->id), + 'id' => $this->id, 'type' => $this->type, - 'thumb' => $this->thumb, - 'thumb2x' => $this->thumb2x, + 'thumb' => $this->thumbUrl, + 'thumb2x' => $this->thumb2xUrl, ]; } + + /** + * Serializes this object into an array. + * + * @return array The serialized properties of this object + * + * @see SizeVariants::toArray() + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/app/Models/Extensions/UTCBasedTimes.php b/app/Models/Extensions/UTCBasedTimes.php index 92f62462851..67d7fa91a25 100644 --- a/app/Models/Extensions/UTCBasedTimes.php +++ b/app/Models/Extensions/UTCBasedTimes.php @@ -40,7 +40,7 @@ trait UTCBasedTimes { private static string $DB_TIMEZONE_NAME = 'UTC'; - private static string $DB_DATETIME_FORMAT = 'Y-m-d H:i:s'; + private static string $DB_DATETIME_FORMAT = 'Y-m-d H:i:s.u'; private static string $STANDARD_DATE_PATTERN = '/^(\d{4})-(\d{1,2})-(\d{1,2})$/'; /** @@ -75,7 +75,7 @@ trait UTCBasedTimes public function fromDateTime($value): ?string { // If $value is already an instance of Carbon, the method returns a - // deep copy, hence it is save to change the timezone below without + // deep copy, hence it is safe to change the timezone below without // altering the original object $carbonTime = $this->asDateTime($value); if (empty($carbonTime)) { @@ -216,7 +216,7 @@ public function asDateTime($value): ?Carbon * Prepares a date for array/JSON serialization. * * In contrast to the original implementation, this one serializes the - * timezone "as is". + * timezone "as is" and includes fractions of seconds. * * @param \DateTimeInterface $date * @@ -224,6 +224,6 @@ public function asDateTime($value): ?Carbon */ protected function serializeDate(\DateTimeInterface $date): string { - return $date->format(\DateTimeInterface::ATOM); + return $date->format('Y-m-d\TH:i:s.uP'); } } diff --git a/app/Models/Logs.php b/app/Models/Logs.php index 9c51b0e0cd9..7849a6e8a03 100644 --- a/app/Models/Logs.php +++ b/app/Models/Logs.php @@ -126,7 +126,7 @@ public static function log(int $severity, string $method, int $line, string $msg 'text' => $msg, ]); $log->save(); - } catch (\Throwable $ignored) { + } catch (\Throwable) { } } } diff --git a/app/Models/Page.php b/app/Models/Page.php index 2a7d8ddebb1..0854f75a922 100644 --- a/app/Models/Page.php +++ b/app/Models/Page.php @@ -3,7 +3,6 @@ namespace App\Models; use App\Models\Extensions\UTCBasedTimes; -use Eloquent; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; @@ -38,7 +37,6 @@ * @method static Builder|Page whereOrder($value) * @method static Builder|Page whereTitle($value) * @method static Builder|Page whereUpdatedAt($value) - * @mixin Eloquent */ class Page extends Model { diff --git a/app/Models/PageContent.php b/app/Models/PageContent.php index 8411c45076c..1ab7467b394 100644 --- a/app/Models/PageContent.php +++ b/app/Models/PageContent.php @@ -2,7 +2,6 @@ namespace App\Models; -use Eloquent; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -31,7 +30,6 @@ * @method static Builder|PageContent wherePageId($value) * @method static Builder|PageContent whereType($value) * @method static Builder|PageContent whereUpdatedAt($value) - * @mixin Eloquent */ class PageContent extends Model { diff --git a/app/Models/Photo.php b/app/Models/Photo.php index ca53f3b2130..fda4e47b893 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -1,16 +1,21 @@ 'thumb', - self::VARIANT_THUMB2X => 'thumb', - self::VARIANT_SMALL => 'small', - self::VARIANT_SMALL2X => 'small', - self::VARIANT_MEDIUM => 'medium', - self::VARIANT_MEDIUM2X => 'medium', - self::VARIANT_ORIGINAL => 'big', - ]; + protected $keyType = 'string'; + + /** + * Indicates if the model's primary key is auto-incrementing. + * + * @var bool + */ + public $incrementing = false; protected $casts = [ - 'public' => 'int', - 'star' => 'int', - 'downloadable' => 'int', - 'share_button_visible' => 'int', + HasRandomID::LEGACY_ID_NAME => HasRandomID::LEGACY_ID_TYPE, 'created_at' => 'datetime', 'updated_at' => 'datetime', 'taken_at' => DateTimeWithTimezoneCast::class, + 'live_photo_full_path' => MustNotSetCast::class . ':live_photo_short_path', + 'live_photo_url' => MustNotSetCast::class . ':live_photo_short_path', + 'is_downloadable' => MustNotSetCast::class, + 'is_share_button_visible' => MustNotSetCast::class, + 'owner_id' => 'integer', + 'is_starred' => 'boolean', + 'filesize' => 'integer', + 'is_public' => 'boolean', + ]; + + /** + * @var string[] The list of attributes which exist as columns of the DB + * relation but shall not be serialized to JSON + */ + protected $hidden = [ + HasRandomID::LEGACY_ID_NAME, + 'album', // do not serialize relation in order to avoid infinite loops + 'owner', // do not serialize relation + 'owner_id', + 'live_photo_short_path', // serialize live_photo_url instead ]; + /** + * @var string[] The list of "virtual" attributes which do not exist as + * columns of the DB relation but which shall be appended to + * JSON from accessors + */ + protected $appends = [ + 'live_photo_url', + 'is_downloadable', + 'is_share_button_visible', + ]; + + protected $attributes = [ + 'tags' => '', + ]; + + /** + * Creates a new instance of {@link LinkedPhotoCollection}. + * + * The only difference between an ordinary {@link Collection} and a + * {@link LinkedPhotoCollection} is that the latter also adds links to + * the previous and next photo if the collection is serialized to JSON. + * This method is called by all relations which need to create a + * collection of photos. + * + * @param array $models a list of {@link Photo} models + * + * @return LinkedPhotoCollection + */ + public function newCollection(array $models = []): LinkedPhotoCollection + { + return new LinkedPhotoCollection($models); + } + /** * Return the relationship between a Photo and its Album. * @@ -171,180 +165,285 @@ public function owner(): BelongsTo return $this->belongsTo('App\Models\User', 'owner_id', 'id'); } + public function size_variants(): HasManySizeVariants + { + return new HasManySizeVariants($this); + } + /** - * Before calling the delete() method which will remove the entry from the database, we need to remove the files. + * Accessor for attribute {@link Photo::$shutter}. + * + * This accessor ensures that the returned string is either formatted as + * a unit fraction or a decimal number irrespective of what is stored + * in the database. + * + * Actually it would be much more efficient to write a mutator which + * ensures that the string is stored correctly formatted at the DB right + * from the beginning and then simply return the stored string instead of + * re-format the string on every fetch. + * TODO: Refactor this. * - * @param bool $keep_original + * @param ?string $shutter the value from the database passed in by + * the Eloquent framework * - * @return bool True on success, false otherwise + * @return string A properly formatted shutter value */ - public function predelete(bool $keep_original = false): bool + protected function getShutterAttribute(?string $shutter): ?string { - if ($this->isDuplicate($this->checksum, $this->id)) { - Logs::notice(__METHOD__, __LINE__, $this->id . ' is a duplicate!'); - // it is a duplicate, we do not delete! - return true; + if (empty($shutter)) { + return null; } - - $error = false; - $path_prefix = $this->type == 'raw' ? 'raw/' : 'big/'; - if ($keep_original === false) { - // quick check... - if (!Storage::exists($path_prefix . $this->url)) { - Logs::error(__METHOD__, __LINE__, 'Could not find file in ' . Storage::path($path_prefix . $this->url)); - $error = true; - } elseif (!Storage::delete($path_prefix . $this->url)) { - Logs::error(__METHOD__, __LINE__, 'Could not delete file in ' . Storage::path($path_prefix . $this->url)); - $error = true; + // shutter speed needs to be processed. It is stored as a string `a/b s` + if (!str_starts_with($shutter, '1/')) { + preg_match('/(\d+)\/(\d+) s/', $shutter, $matches); + if ($matches) { + $a = intval($matches[1]); + $b = intval($matches[2]); + if ($b != 0) { + try { + $gcd = Helpers::gcd($a, $b); + $a = $a / $gcd; + $b = $b / $gcd; + } catch (\Exception $e) { + // this should not happen as we covered the case $b = 0; + } + if ($a == 1) { + $shutter = '1/' . $b . ' s'; + } else { + $shutter = ($a / $b) . ' s'; + } + } } } - if ((strpos($this->type, 'video') === 0) || ($this->type == 'raw')) { - $photoName = $this->thumbUrl; - } else { - $photoName = $this->url; + if ($shutter == '1/1 s') { + $shutter = '1 s'; } - if ($photoName !== '') { - $photoName2x = Helpers::ex2x($photoName); - - // Delete Live Photo Video file - // TODO: USE STORAGE FOR DELETE - // check first if livePhotoUrl is available - if ($this->livePhotoUrl !== null) { - if (!Storage::exists('big/' . $this->livePhotoUrl)) { - Logs::error(__METHOD__, __LINE__, 'Could not find file in ' . Storage::path('big/' . $this->livePhotoUrl)); - $error = true; - } elseif (!Storage::delete('big/' . $this->livePhotoUrl)) { - Logs::error(__METHOD__, __LINE__, 'Could not delete file in ' . Storage::path('big/' . $this->livePhotoUrl)); - $error = true; - } - } - - // Delete medium - // TODO: USE STORAGE FOR DELETE - if (Storage::exists('medium/' . $photoName) && !unlink(Storage::path('medium/' . $photoName))) { - Logs::error(__METHOD__, __LINE__, 'Could not delete photo in uploads/medium/'); - $error = true; - } - // TODO: USE STORAGE FOR DELETE - if (Storage::exists('medium/' . $photoName2x) && !unlink(Storage::path('medium/' . $photoName2x))) { - Logs::error(__METHOD__, __LINE__, 'Could not delete high-res photo in uploads/medium/'); - $error = true; - } - - // Delete small - // TODO: USE STORAGE FOR DELETE - if (Storage::exists('small/' . $photoName) && !unlink(Storage::path('small/' . $photoName))) { - Logs::error(__METHOD__, __LINE__, 'Could not delete photo in uploads/small/'); - $error = true; - } + return $shutter; + } - // TODO: USE STORAGE FOR DELETE - if (Storage::exists('small/' . $photoName2x) && !unlink(Storage::path('small/' . $photoName2x))) { - Logs::error(__METHOD__, __LINE__, 'Could not delete high-res photo in uploads/small/'); - $error = true; - } + /** + * Accessor for attribute `license`. + * + * If the photo has an explicitly set license, that license is returned. + * Else, either the licence of the album is returned (if the photo is + * part of an album) or the default license of the application-wide + * setting is returned. + * + * @param string $license the value from the database passed in by + * the Eloquent framework + * + * @return string + */ + protected function getLicenseAttribute(string $license): string + { + if ($license !== 'none') { + return $license; } - - if ($this->thumbUrl != '') { - // Get retina thumb url - $thumbUrl2x = Helpers::ex2x($this->thumbUrl); - // Delete thumb - // TODO: USE STORAGE FOR DELETE - if (Storage::exists('thumb/' . $this->thumbUrl) && !unlink(Storage::path('thumb/' . $this->thumbUrl))) { - Logs::error(__METHOD__, __LINE__, 'Could not delete photo in uploads/thumb/'); - $error = true; - } - - // Delete thumb@2x - // TODO: USE STORAGE FOR DELETE - if (Storage::exists('thumb/' . $thumbUrl2x) && !unlink(Storage::path('thumb/' . $thumbUrl2x))) { - Logs::error(__METHOD__, __LINE__, 'Could not delete high-res photo in uploads/thumb/'); - $error = true; - } + if ($this->album_id != null) { + return $this->album->license; } - return !$error; + return Configs::get_value('default_license'); } /** - * @param $query + * Accessor for attribute `focal`. + * + * In case the photo is a video (why it is called a photo then, btw?), the + * attribute `focal` is exploited to store the framerate and rounded + * to two decimal digits. * - * @return mixed + * Again, we probably should do that when the value is set and stored, + * not every time when it is read from the database. + * TODO: Refactor this. + * + * @param ?string $focal the value from the database passed in by the + * Eloquent framework + * + * @return string */ - public static function set_order(Builder $query) + protected function getFocalAttribute(?string $focal): ?string { - $sortingCol = Configs::get_value('sorting_Photos_col'); - if ($sortingCol !== 'title' && $sortingCol !== 'description') { - $query = $query->orderBy($sortingCol, Configs::get_value('sorting_Photos_order')); + if (empty($focal)) { + return null; } - - return $query->orderBy('photos.id', 'ASC'); + // We need to format the framerate (stored as focal) -> max 2 decimal digits + return $this->isVideo() ? round($focal, 2) : $focal; } /** - * Define scopes which we can directly use e.g. Photo::stars()->all(). + * Accessor for the "virtual" attribute {@see Photo::$live_photo_full_path}. + * + * Returns the full path of the live photo as it needs to be input into + * some low-level PHP functions like `unlink`. + * This is a convenient method and wraps + * {@link Photo::$live_photo_short_path} into + * {@link \Illuminate\Support\Facades\Storage::path()}. + * + * @return string|null The full path of the live photo */ + protected function getLivePhotoFullPathAttribute(): ?string + { + return empty($this->live_photo_short_path) ? null : Storage::path($this->live_photo_short_path); + } /** - * @param $query + * Accessor for the "virtual" attribute {@see Photo::$live_photo_url}. + * + * Returns the URL of the live photo as it is seen from a client's + * point of view. + * This is a convenient method and wraps + * {@link Photo::$live_photo_short_path} into + * {@link \Illuminate\Support\Facades\Storage::url()}. * - * @return mixed + * @return string the url of the file */ - public function scopeStars($query) + protected function getLivePhotoUrlAttribute(): ?string { - return $query->where('star', '=', 1); + return empty($this->live_photo_short_path) ? null : Storage::url($this->live_photo_short_path); } /** - * @param $query + * Accessor for the "virtual" attribute {@see Photo::$is_downloadable}. * - * @return mixed + * The photo is downloadable if the currently authenticated user is the + * owner or if the photo is part of a downloadable album or if it is + * unsorted and unsorted photos are configured to be downloadable by + * default. + * + * @return bool true if the photo is downloadable */ - public function scopePublic($query) + protected function getIsDownloadableAttribute(): bool { - return $query->where('public', '=', 1); + return AccessControl::is_current_user($this->owner_id) || + ($this->album_id != null && $this->album->is_downloadable) || + ($this->album_id == null && (bool) Configs::get_value('downloadable', '0')); } /** - * @param $query + * Accessor for the "virtual" attribute {@see Photo::$is_share_button_visible}. + * + * The share button is visible if the currently authenticated user is the + * owner or if the photo is part of an album which has enabled the + * share button or if the photo is unsorted and unsorted photos are + * configured to be sharable by default. * - * @return mixed + * @return bool true if the share button is visible for this photo */ - public function scopeRecent($query) + protected function getIsShareButtonVisibleAttribute(): bool { - return $query->where('created_at', '>=', Carbon::now()->subDays(intval(Configs::get_value('recent_age', '1')))->toDateTimeString()); + $default = (bool) Configs::get_value('share_button_visible', '0'); + + return AccessControl::is_current_user($this->owner_id) || + ($this->album_id != null && $this->album->is_share_button_visible) || + ($this->album_id == null && $default); } /** - * @param $query + * Serializes the model into an array. + * + * This method is also invoked by Eloquent when someone invokes + * {@link Model::toJson()} or {@link Model::jsonSerialize()}. + * + * This method removes the URL to the full resolution of a photo, if the + * client is not allowed to see that. * - * @return mixed + * @return array */ - public function scopeUnsorted($query) + public function toArray(): array { - return $query->where('album_id', '=', null); + $result = parent::toArray(); + + // Modify the attribute `public` + // The current front-end implementation does not expect a boolean + // but a tri-state integer acc. to the following interpretation + // - 0 => the photo is not publicly visible + // - 1 => the photo is publicly visible on its own right + // - 2 => the photo is publicly visible because its album is public + if ($this->album_id != null && $this->album->is_public) { + $result['is_public'] = 2; + } else { + $result['is_public'] = $result['is_public'] ? 1 : 0; + } + + // Downgrades the accessible resolution of a photo + // The decision logic here is a merge of three formerly independent + // (and slightly different) approaches + if ( + !AccessControl::is_current_user($this->owner_id) && + $this->isVideo() === false && + ($result['size_variants']['medium2x'] !== null || $result['size_variants']['medium'] !== null) && + ( + ($this->album_id != null && !$this->album->grants_full_photo) || + ($this->album_id == null && Configs::get_value('full_photo', '1') != '1') + ) + ) { + unset($result['size_variants']['original']['url']); + } + + return $result; } /** - * @param $query - * @param $id - * - * @return mixed + * @return bool true if another DB entry exists for the same photo */ - public function scopeOwnedBy(Builder $query, $id) + protected function hasDuplicate(): bool { - return $id == 0 ? $query : $query->where('owner_id', '=', $id); + $checksum = $this->checksum; + + return self::query() + ->where(function ($q) use ($checksum) { + $q->where('checksum', '=', $checksum) + ->orWhere('original_checksum', '=', $checksum) + ->orWhere('live_photo_checksum', '=', $checksum); + }) + ->where('id', '<>', $this->id) + ->exists(); + } + + public function replicate(array $except = null): Photo + { + $duplicate = parent::replicate($except); + // A photo has the following relations: (parent) album, owner and + // size_variants. + // While the duplicate may keep the relation to the same album and + // each photo requires an individual set of size variants. + // Se we unset the relation and explicitly duplicate the size variants. + $duplicate->unsetRelation('size_variants'); + // save duplicate so that the photo gets an ID + $duplicate->save(); + + $areSizeVariantsOriginallyLoaded = $this->relationLoaded('size_variants'); + // Duplicate the size variants of this instance for the duplicate + $duplicatedSizeVariants = $this->size_variants->replicate($duplicate); + if ($areSizeVariantsOriginallyLoaded) { + $duplicate->setRelation('size_variants', $duplicatedSizeVariants); + } + + return $duplicate; } - public function withTags($tags) + public function delete(): bool { - $sql = $this; - foreach ($tags as $tag) { - $sql = $sql->where('tags', 'like', '%' . $tag . '%'); + $keepFiles = $this->hasDuplicate(); + if ($keepFiles) { + Logs::notice(__METHOD__, __LINE__, $this->id . ' is a duplicate, files are not deleted!'); + } + $success = true; + // Delete all size variants + $success &= $this->size_variants->deleteAll($keepFiles, $keepFiles); + // Delete Live Photo Video file + $livePhotoShortPath = $this->live_photo_short_path; + if (!$keepFiles && !empty($livePhotoShortPath) && Storage::exists($livePhotoShortPath)) { + $success &= Storage::delete($livePhotoShortPath); + } + + if (!$success) { + return false; } - return ($sql->count() == 0) ? false : $sql->first(); + return parent::delete() !== false; } } diff --git a/app/Models/SizeVariant.php b/app/Models/SizeVariant.php new file mode 100644 index 00000000000..e63eba851f3 --- /dev/null +++ b/app/Models/SizeVariant.php @@ -0,0 +1,242 @@ + sym_links + */ +class SizeVariant extends Model +{ + use UTCBasedTimes; + use HasAttributesPatch; + use HasBidirectionalRelationships; + + public const ORIGINAL = 0; + public const MEDIUM2X = 1; + public const MEDIUM = 2; + public const SMALL2X = 3; + public const SMALL = 4; + public const THUMB2X = 5; + public const THUMB = 6; + + /** + * This model has no own timestamps as it is inseparably bound to its + * parent {@link \App\Models\Photo} and uses the same timestamps. + * + * @var bool + */ + public $timestamps = false; + + protected $casts = [ + 'id' => 'integer', + 'type' => 'integer', + 'full_path' => MustNotSetCast::class . ':short_path', + 'url' => MustNotSetCast::class . ':short_path', + 'width' => 'integer', + 'height' => 'integer', + ]; + + /** + * @var string[] The list of attributes which exist as columns of the DB + * relation but shall not be serialized to JSON + */ + protected $hidden = [ + 'id', // irrelevant, because a size variant is always serialized as an embedded object of its photo + 'photo', // see above and otherwise infinite loops will occur + 'photo_id', // see above + 'short_path', // serialize url instead + 'sym_links', // don't serialize relation of symlinks + ]; + + /** + * @var string[] The list of "virtual" attributes which do not exist as + * columns of the DB relation but which shall be appended to + * JSON from accessors + */ + protected $appends = [ + 'url', + ]; + + /** + * Returns the association to the photo which this size variant belongs + * to. + * + * @return BelongsTo + */ + public function photo(): BelongsTo + { + return $this->belongsTo(Photo::class); + } + + /** + * Returns the association to the symbolics links which point to this + * size variant. + * + * @return HasManyBidirectionally + */ + public function sym_links(): HasManyBidirectionally + { + return $this->hasManyBidirectionally(SymLink::class); + } + + /** + * Accessor for the "virtual" attribute {@link SizeVariant::$url}. + * + * This is more than a simple convenient method which wraps + * {@link SizeVariant::$short_path} into + * {@link \Illuminate\Support\Facades\Storage::url()}. + * Based on the current application settings and the authenticated user, + * this method returns a URL to a short-living symbolic link instead of a + * direct URL to the actual size variant, if the underlying storage + * provides symbolic links. + * + * @return string the url of the size variant + */ + public function getUrlAttribute(): string + { + if ( + (AccessControl::is_admin() && Configs::get_value('SL_for_admin', '0') === '0') || + Configs::get_value('SL_enable', '0') == '0' + ) { + return Storage::url($this->short_path); + } + + // In order to allow a grace period, we create a new symbolic link, + // if the most recent existing link has reached 2/3 of its lifetime + $maxLifetime = intval(Configs::get_value('SL_life_time_days', '3')) * 24 * 60 * 60; + $gracePeriod = $maxLifetime / 3; + + $storageAdapter = Storage::disk()->getDriver()->getAdapter(); + + // TODO: Uncomment these line when Laravel really starts to support s3 + /*if ($storageAdapter instanceof AwsS3Adapter) { + return Storage::temporaryUrl($this->short_path, now()->addSeconds($maxLifetime)); + }*/ + + if ($storageAdapter instanceof Local) { + /** @var ?SymLink $symLink */ + $symLink = $this->sym_links()->latest()->first(); + if ($symLink == null || $symLink->created_at->isBefore(now()->subSeconds($gracePeriod))) { + /** @var SymLink $symLink */ + $symLink = $this->sym_links()->create(); + } + + return $symLink->url; + } + + throw new \InvalidArgumentException('the chosen storage adapter "' . Storage::getDefaultDriver() . '" does not support the symbolic linking feature'); + } + + /** + * Accessor for the "virtual" attribute {@link SizeVariant::$full_path}. + * + * Returns the full path of the size variant as it needs to be input into + * some low-level PHP functions like `unlink`. + * This is a convenient method and wraps {@link SizeVariant::$short_path} + * into {@link \Illuminate\Support\Facades\Storage::path()}. + * + * @return string the full path of the file + */ + public function getFullPathAttribute(): string + { + return Storage::path($this->short_path); + } + + /** + * Mutator of the attribute {@link SizeVariant::$type}. + * + * @param int $sizeVariantType the type of size variant; allowed values are + * {@link SizeVariant::ORIGINAL}, + * {@link SizeVariant::MEDIUM2X}, + * {@link SizeVariant::MEDIUM}, + * {@link SizeVariant::SMALL2X}, + * {@link SizeVariant::SMALL}, + * {@link SizeVariant::THUMB2X}, and + * {@link SizeVariant::THUMB} + * + * @throws \InvalidArgumentException thrown if `$sizeVariant` is + * out-of-bounds + */ + public function setSizeVariantAttribute(int $sizeVariantType): void + { + if (self::ORIGINAL > $sizeVariantType || $sizeVariantType > self::THUMB) { + throw new \InvalidArgumentException('passed size variant ' . $sizeVariantType . ' out-of-range'); + } + $this->attributes['type'] = $sizeVariantType; + } + + /** + * Deletes this model. + * + * @param bool $keepFile If true, the associated file is not removed from storage + * + * @return bool True on success, false otherwise + */ + public function delete(bool $keepFile = false): bool + { + // Delete all symbolic links pointing to this size variant + // The SymLink model takes care of actually erasing + // the physical symbolic links from disk. + // We must not use a "mass deletion" like $this->sym_links()->delete() + // here, because this doesn't invoke the method `delete` on the model + // and thus the would not delete any actual symbolic link from disk. + $symLinks = $this->sym_links; + /** @var SymLink $symLink */ + foreach ($symLinks as $symLink) { + if ($symLink->delete() === false) { + return false; + } + } + + // Delete the actual media file + if (!$keepFile) { + $disk = Storage::disk(); + $shortPath = $this->short_path; + if (!empty($shortPath) && $disk->exists($shortPath)) { + if ($disk->delete($shortPath) === false) { + return false; + } + } + } + + return parent::delete() !== false; + } +} diff --git a/app/Models/SymLink.php b/app/Models/SymLink.php index 08424617aac..74691c90b24 100644 --- a/app/Models/SymLink.php +++ b/app/Models/SymLink.php @@ -2,207 +2,148 @@ namespace App\Models; +use App\Casts\MustNotSetCast; use App\Facades\Helpers; +use App\Models\Extensions\HasAttributesPatch; use App\Models\Extensions\UTCBasedTimes; -use Eloquent; -use Exception; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Storage; /** * App\SymLink. * - * @method static Builder|SymLink newModelQuery() - * @method static Builder|SymLink newQuery() - * @method static Builder|SymLink query() - * @mixin Eloquent + * @property int $id + * @property int $size_variant_id + * @property SizeVariant size_variant + * @property string $short_path + * @property string $full_path + * @property string $url + * @property Carbon $created_at + * @property Carbon $updated_at * - * @property int $id - * @property int|null $photo_id - * @property string $url - * @property string $medium - * @property string $medium2x - * @property string $small - * @property string $small2x - * @property string $thumbUrl - * @property string $thumb2x - * @property Carbon|null $created_at - * @property Carbon|null $updated_at - * - * @method static Builder|SymLink whereCreatedAt($value) - * @method static Builder|SymLink whereId($value) - * @method static Builder|SymLink whereMedium($value) - * @method static Builder|SymLink whereMedium2x($value) - * @method static Builder|SymLink wherePhotoId($value) - * @method static Builder|SymLink whereSmall($value) - * @method static Builder|SymLink whereSmall2x($value) - * @method static Builder|SymLink whereThumb2x($value) - * @method static Builder|SymLink whereThumbUrl($value) - * @method static Builder|SymLink whereUpdatedAt($value) - * @method static Builder|SymLink whereUrl($value) + * @method static Builder expired() */ class SymLink extends Model { use UTCBasedTimes; + use HasAttributesPatch; - /** - * Maps a size variant to the name of the attribute (field) of App\Models\Photo which stores the original - * filename. - * (Despite the attributes being named "url" they actually store filenames). - */ - public const VARIANT_2_ORIGINAL_FILENAME_FIELD = [ - Photo::VARIANT_THUMB => 'thumbUrl', - Photo::VARIANT_THUMB2X => 'thumbUrl', - Photo::VARIANT_SMALL => 'url', - Photo::VARIANT_SMALL2X => 'url', - Photo::VARIANT_MEDIUM => 'url', - Photo::VARIANT_MEDIUM2X => 'url', - Photo::VARIANT_ORIGINAL => 'url', - ]; + public const DISK_NAME = 'symbolic'; - /** - * Maps a size variant to the name of an attribute (field) of the class App\Models\Photo which may be exploited - * as an indicator whether this size variant exist. - */ - public const VARIANT_2_INDICATOR_FIELD = [ - Photo::VARIANT_THUMB => 'thumbUrl', // type: string|null - Photo::VARIANT_THUMB2X => 'thumb2x', // type: integer, either 0 or 1 - Photo::VARIANT_SMALL => 'small_width', // type: int|null - Photo::VARIANT_SMALL2X => 'small2x_width', // type: int|null - Photo::VARIANT_MEDIUM => 'medium_width', // type: int|null - Photo::VARIANT_MEDIUM2X => 'medium2x_width', // type: int|null - Photo::VARIANT_ORIGINAL => 'url', // type: string|null + protected $casts = [ + 'id' => 'integer', + 'size_variant_id' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'url' => MustNotSetCast::class, ]; /** - * Maps a size variant to the name of the attribute (field) of this class/database table which stores the - * symlinked path. - * (Despite some attributes being named "url" they actually store relative paths). + * @var string[] The list of attributes which exist as columns of the DB + * relation but shall not be serialized to JSON */ - public const VARIANT_2_SYM_PATH_FIELD = [ - Photo::VARIANT_THUMB => 'thumbUrl', - Photo::VARIANT_THUMB2X => 'thumb2x', - Photo::VARIANT_SMALL => 'small', - Photo::VARIANT_SMALL2X => 'small2x', - Photo::VARIANT_MEDIUM => 'medium', - Photo::VARIANT_MEDIUM2X => 'medium2x', - Photo::VARIANT_ORIGINAL => 'url', + protected $hidden = [ + 'size_variant', // see above and otherwise infinite loops will occur + 'size_variant_id', // see above ]; + public function size_variant(): BelongsTo + { + return $this->belongsTo(SizeVariant::class); + } + /** - * Generate a sym link. - * The salt is important in order to remove the deterministic side of the address. + * Scopes the passed query to all outdated symlinks. * - * @param Photo $photo The original photo - * @param string $sizeVariant An enum-like attribute which indicates what size variant shall be sym-linked. - * Allowed values are defined as constants in class Photo. - * @param string $salt + * @param Builder $query the unscoped query + * + * @return Builder the scoped query */ - private function create(Photo $photo, string $sizeVariant, string $salt) + public function scopeExpired(Builder $query): Builder { - // in case of video and raw we always need to use the field 'thumbUrl' for anything which is not the original size - $originalFieldName = ($sizeVariant != Photo::VARIANT_ORIGINAL && ($photo->isVideo() || $photo->type == 'raw')) ? - self::VARIANT_2_ORIGINAL_FILENAME_FIELD[Photo::VARIANT_THUMB] : - self::VARIANT_2_ORIGINAL_FILENAME_FIELD[$sizeVariant]; - $originalFileName = (substr($sizeVariant, -2, 2) == '2x') ? Helpers::ex2x($photo->$originalFieldName) : $photo->$originalFieldName; - - if ($photo->type == 'raw' && $sizeVariant == Photo::VARIANT_ORIGINAL) { - $originalPath = Storage::path('raw/' . $originalFileName); - } else { - $originalPath = Storage::path(Photo::VARIANT_2_PATH_PREFIX[$sizeVariant] . '/' . $originalFileName); - } - $extension = Helpers::getExtension($originalPath); - $symFilename = hash('sha256', $salt . '|' . $originalPath) . $extension; - $symPath = Storage::drive('symbolic')->path($symFilename); + $expiration = now()->subDays(intval(Configs::get_value('SL_life_time_days', '3'))); - try { - // in theory we should be safe... - symlink($originalPath, $symPath); - } catch (Exception $exception) { - unlink($symPath); - symlink($originalPath, $symPath); - } - $this->{self::VARIANT_2_SYM_PATH_FIELD[$sizeVariant]} = $symFilename; + return $query->where('created_at', '<', $this->fromDateTime($expiration)); } /** - * Set up a link. + * Accessor for the "virtual" attribute {@link SymLink::$url}. + * + * Returns the URL to the symbolic link from the perspective of a + * web client. + * This is a convenient method and wraps {@link SymLink::$short_path} + * into {@link \Illuminate\Support\Facades\Storage::url()}. * - * @param Photo $photo + * @return string the URL to the symbolic link */ - public function set(Photo $photo) + protected function getUrlAttribute(): string { - $this->photo_id = $photo->id; - $this->timestamps = false; - // we set up the created_at - $now = now(); - $this->created_at = $now; - $this->updated_at = $now; - - foreach (self::VARIANT_2_INDICATOR_FIELD as $variant => $indicator_field) { - if ($photo->{$indicator_field} !== null && $photo->{$indicator_field} !== 0 && $photo->{$indicator_field} !== '') { - $this->create($photo, $variant, strval($now)); - } - } + return Storage::disk(self::DISK_NAME)->url($this->short_path); } /** - * Given the return array of a photo, override the link provided. + * Accessor for the "virtual" attribute {@link SymLink::$full_path}. + * + * Returns the full path of the symbolic link as it needs to be input into + * some low-level PHP functions like `unlink`. + * This is a convenient method and wraps {@link SymLink::$short_path} + * into {@link \Illuminate\Support\Facades\Storage::path()}. * - * @param array $return The serialization of a photo as returned by Photo#toReturnArray() + * @return string the full path of the symbolic link */ - public function override(array &$return) + protected function getFullPathAttribute(): string { - foreach (self::VARIANT_2_SYM_PATH_FIELD as $variant => $field) { - if ($this->$field != '') { - // TODO: This could be avoided, if the original variant was also serialized into the sub-array 'sizeVariants', see comment in PhotoCast#toReturnArray - if ($variant == Photo::VARIANT_ORIGINAL) { - $return['url'] = Storage::drive('symbolic')->url($this->$field); - } else { - $return['sizeVariants'][$variant]['url'] = Storage::drive('symbolic')->url($this->$field); - } - } - } + return Storage::disk(self::DISK_NAME)->path($this->short_path); } /** - * Returns the relative symlinked path of a particular size variant, if it exists. + * Performs the `INSERT` operation of the model and creates an actual + * symbolic link on disk. + * + * If this method cannot create the symbolic link, then this method + * cancels the insert operation. * - * @param string $sizeVariant An enum-like attribute which indicates what size variant shall be sym-linked. - * Allowed values are defined as constants in class Photo. + * @param Builder $query * - * @return string Relative path to symbolic link or the empty string ('') + * @return bool */ - public function get(string $sizeVariant): string + protected function performInsert(Builder $query): bool { - $field = self::VARIANT_2_SYM_PATH_FIELD[$sizeVariant]; - if ($this->$field != '') { - return Storage::drive('symbolic')->url($this->$field); - } else { - return ''; + $origFullPath = $this->size_variant->full_path; + $extension = Helpers::getExtension($origFullPath); + $symShortPath = hash('sha256', random_bytes(32) . '|' . $origFullPath) . $extension; + $symFullPath = Storage::disk(SymLink::DISK_NAME)->path($symShortPath); + if (is_link($symFullPath)) { + unlink($symFullPath); + } + if (!symlink($origFullPath, $symFullPath)) { + return false; } + $this->short_path = $symShortPath; + + return parent::performInsert($query); } /** - * before deleting we actually unlink the symlinks. + * Deletes the model from the database and the symbolic link from storage. + * + * If this method cannot delete the symbolic link, then this method + * cancels the delete operation. * - * @return bool|null + * @return bool */ - public function delete() + public function delete(): bool { - foreach (self::VARIANT_2_SYM_PATH_FIELD as $variant => $field) { - if ($this->$field != '') { - $path = Storage::drive('symbolic')->path($this->$field); - try { - unlink($path); - } catch (Exception $e) { - Logs::error(__METHOD__, __LINE__, 'could not unlink ' . $path); - } - } + $fullPath = $this->full_path; + // Laravel and Flysystem does not support symbolic links. + // So we must use low-level methods here. + if ((is_link($fullPath) && !unlink($fullPath)) || (file_exists($fullPath)) && !is_link($fullPath)) { + return false; } - return parent::delete(); + return parent::delete() !== false; } } diff --git a/app/Models/TagAlbum.php b/app/Models/TagAlbum.php new file mode 100644 index 00000000000..777a3455d09 --- /dev/null +++ b/app/Models/TagAlbum.php @@ -0,0 +1,110 @@ + null, + 'show_tags' => null, + ]; + + protected $casts = [ + 'min_taken_at' => 'datetime', + 'max_taken_at' => 'datetime', + ]; + + /** + * @var string[] The list of attributes which exist as columns of the DB + * relation but shall not be serialized to JSON + */ + protected $hidden = [ + 'base_class', // don't serialize base class as a relation, the attributes of the base class are flatly merged into the JSON result + ]; + + /** + * @var string[] The list of "virtual" attributes which do not exist as + * columns of the DB relation but which shall be appended to + * JSON from accessors + */ + protected $appends = [ + 'thumb', + ]; + + public function photos(): HasManyPhotosByTag + { + return new HasManyPhotosByTag($this); + } + + /** + * Returns the value for the virtual attribute {@link TagAlbum::$thumb}. + * + * Note, opposed to {@link Album} the thumbnail of a tag album cannot be + * converted into a proper relation (cp. {@link Album::thumb()}). + * However, doing so would enable to eagerly load all thumbs of all + * tag albums at once (using a single query) and cache the result. + * This would speed up rendering the root album. + * The main obstacle is the way how tags of photos and tags of albums + * are matched to each other. + * At the moment this requires string operations on the PHP level and + * the SQL query for each tag album has an individual number of + * `WHERE`-clauses which is specific for the particular + * tag album (cp. {@link HasManyPhotosByTag::addEagerConstraints()}). + * Hence, it is not possible to construct a single SQL query which fetches + * the photos for multiple tag albums. + * However, this would be possible if we had a proper `tags` table and + * two n:m-relations between photos and tags and tags and albums. + * This would allow to create a single `JOIN`-query for all tag albums. + * + * @return Thumb|null + */ + protected function getThumbAttribute(): ?Thumb + { + // Note, `photos()` already applies a "security filter" and + // only returns photos which are accessible by the current + // user + + return Thumb::createFromQueryable( + $this->photos(), + $this->getEffectiveSortingCol(), + $this->getEffectiveSortingOrder() + ); + } + + public function toArray(): array + { + $result = parent::toArray(); + $result['is_tag_album'] = true; + + return $result; + } + + /** + * {@inheritdoc} + */ + public function newEloquentBuilder($query): TagAlbumBuilder + { + return new TagAlbumBuilder($query); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 49e1d3734cf..8d1e9802fbc 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,7 +5,6 @@ use App\Models\Extensions\UTCBasedTimes; use DarkGhostHunter\Larapass\Contracts\WebAuthnAuthenticatable; use DarkGhostHunter\Larapass\WebAuthnAuthentication; -use Eloquent; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -20,21 +19,18 @@ * App\Models\User. * * @property int $id + * @property Carbon $created_at + * @property Carbon $updated_at * @property string $username - * @property string $password + * @property string|null $password * @property string|null $email - * @property int $upload - * @property int $lock + * @property bool $may_upload + * @property bool $is_locked * @property string|null $remember_token - * @property Carbon|null $created_at - * @property Carbon|null $updated_at - * @property Collection|Album[] $albums + * @property Collection $albums * @property DatabaseNotificationCollection|DatabaseNotification[] $notifications - * @property Collection|Album[] $shared + * @property Collection $shared * - * @method static Builder|User newModelQuery() - * @method static Builder|User newQuery() - * @method static Builder|User query() * @method static Builder|User whereCreatedAt($value) * @method static Builder|User whereId($value) * @method static Builder|User whereLock($value) @@ -43,7 +39,6 @@ * @method static Builder|User whereUpdatedAt($value) * @method static Builder|User whereUpload($value) * @method static Builder|User whereUsername($value) - * @mixin Eloquent */ class User extends Authenticatable implements WebAuthnAuthenticatable { @@ -71,8 +66,11 @@ class User extends Authenticatable implements WebAuthnAuthenticatable ]; protected $casts = [ - 'upload' => 'int', - 'lock' => 'int', + 'id' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'may_upload' => 'boolean', + 'is_locked' => 'boolean', ]; /** @@ -80,9 +78,9 @@ class User extends Authenticatable implements WebAuthnAuthenticatable * * @return HasMany */ - public function albums() + public function albums(): HasMany { - return $this->hasMany('App\Models\Album', 'owner_id', 'id'); + return $this->hasMany('App\Models\BaseAlbumImpl', 'owner_id', 'id'); } /** @@ -90,9 +88,14 @@ public function albums() * * @return BelongsToMany */ - public function shared() + public function shared(): BelongsToMany { - return $this->belongsToMany('App\Models\Album', 'user_album', 'user_id', 'album_id'); + return $this->belongsToMany( + BaseAlbumImpl::class, + 'user_base_album', + 'user_id', + 'base_album_id' + ); } public function is_admin(): bool @@ -102,7 +105,7 @@ public function is_admin(): bool public function can_upload(): bool { - return $this->id == 0 || $this->upload; + return $this->id == 0 || $this->may_upload; } // ! Used by Larapass diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1513f7fc905..e70875d68f9 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,13 +2,19 @@ namespace App\Providers; -use App\Actions\Albums\Extensions\PublicIds; +use App\Actions\AlbumAuthorisationProvider; +use App\Actions\PhotoAuthorisationProvider; use App\Actions\Update\Apply as ApplyUpdate; use App\Actions\Update\Check as CheckUpdate; use App\Assets\Helpers; +use App\Assets\SizeVariantLegacyNamingStrategy; +use App\Contracts\SizeVariantFactory; +use App\Contracts\SizeVariantNamingStrategy; +use App\Factories\AlbumFactory; use App\Factories\LangFactory; use App\Image; use App\Image\ImageHandler; +use App\Image\SizeVariantDefaultFactory; use App\Locale\Lang; use App\Metadata\GitHubFunctions; use App\Metadata\GitRequest; @@ -17,26 +23,28 @@ use App\ModelFunctions\SessionFunctions; use App\ModelFunctions\SymLinkFunctions; use App\Models\Configs; -use App\SmartAlbums\SmartFactory; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { - public $singletons + public array $singletons = [ SymLinkFunctions::class => SymLinkFunctions::class, ConfigFunctions::class => ConfigFunctions::class, LangFactory::class => LangFactory::class, Lang::class => Lang::class, Helpers::class => Helpers::class, - PublicIds::class => PublicIds::class, SessionFunctions::class => SessionFunctions::class, GitRequest::class => GitRequest::class, GitHubFunctions::class => GitHubFunctions::class, LycheeVersion::class => LycheeVersion::class, CheckUpdate::class => CheckUpdate::class, ApplyUpdate::class => ApplyUpdate::class, - SmartFactory::class => SmartFactory::class, + AlbumFactory::class => AlbumFactory::class, + AlbumAuthorisationProvider::class => AlbumAuthorisationProvider::class, + PhotoAuthorisationProvider::class => PhotoAuthorisationProvider::class, ]; /** @@ -46,6 +54,12 @@ class AppServiceProvider extends ServiceProvider */ public function boot() { + if (config('app.db_log_sql', false)) { + DB::listen(function ($query) { + $msg = $query->sql . ' [' . implode(', ', $query->bindings) . ']'; + Log::info($msg); + }); + } } /** @@ -72,5 +86,15 @@ public function register() $this->app->bind('Helpers', function () { return resolve(Helpers::class); }); + + $this->app->bind( + SizeVariantNamingStrategy::class, + SizeVariantLegacyNamingStrategy::class + ); + + $this->app->bind( + SizeVariantFactory::class, + SizeVariantDefaultFactory::class + ); } } diff --git a/app/Redirections/ToInstall.php b/app/Redirections/ToInstall.php index b1c8cbd528b..9624de4498f 100644 --- a/app/Redirections/ToInstall.php +++ b/app/Redirections/ToInstall.php @@ -2,9 +2,11 @@ namespace App\Redirections; +use Illuminate\Http\RedirectResponse; + class ToInstall implements Redirection { - public static function go() + public static function go(): RedirectResponse { // we remove installed.log in order to be able to access the install menu. @unlink(base_path('installed.log')); diff --git a/app/Relations/BidirectionalRelationTrait.php b/app/Relations/BidirectionalRelationTrait.php new file mode 100644 index 00000000000..a081284899b --- /dev/null +++ b/app/Relations/BidirectionalRelationTrait.php @@ -0,0 +1,13 @@ +foreignMethodName; + } +} diff --git a/app/Relations/HasAlbumThumb.php b/app/Relations/HasAlbumThumb.php new file mode 100644 index 00000000000..d28a65592f9 --- /dev/null +++ b/app/Relations/HasAlbumThumb.php @@ -0,0 +1,290 @@ +albumAuthorisationProvider = resolve(AlbumAuthorisationProvider::class); + $this->photoAuthorisationProvider = resolve(PhotoAuthorisationProvider::class); + $this->sortingCol = Configs::get_value('sorting_Photos_col'); + $this->sortingOrder = Configs::get_value('sorting_Photos_order'); + parent::__construct( + Photo::query()->with(['size_variants' => fn (HasMany $r) => Thumb::sizeVariantsFilter($r)]), + $parent + ); + } + + /** + * Adds the constraints for a single album. + * + * If the album has set an explicit cover, then we simply search for that + * photo. + * Else, we search for all photos which are (recursive) descendants of the + * given album. + */ + public function addConstraints(): void + { + if (static::$constraints) { + /** @var Album $album */ + $album = $this->parent; + if ($album->cover_id) { + $this->where('photos.id', '=', $album->cover_id); + } else { + $this->photoAuthorisationProvider + ->applySearchabilityFilter($this->query, $album); + } + } + } + + /** + * Builds a query to eagerly load the thumbnails of a sequence of albums. + * + * Note, the query is not as efficient as it could be, but it is the + * best query we can construct which is portable to MySQL, PostgreSQl and + * SQLite. + * The inefficiency comes from the inner, correlated value sub-query + * `bestPhotoIDSelect`. + * This value query refers the outer query through `covered_albums` and + * thus needs to be executed for every result. + * Moreover, the temporary query table `$album2Cover` is an in-memory + * table and thus does not provide any indexes. + * + * A faster approach would be to first JOIN the tables, then sort the + * result and finally pick the first result of each group based on + * identical `covered_album_id`. + * The approach "join first (with everything), filter last" is faster, + * because the DBMS can use its indexes. + * + * For PostgreSQL we could use the `DISTINCT ON`-clause to achieve the + * result: + * + * SELECT DISTINCT ON (covered_album_id) + * covered_albums.id AS covered_album_id, + * photos.id AS id, + * photos.type AS type + * FROM covered_albums + * LEFT JOIN + * ( + * photos + * LEFT JOIN albums + * ON (albums.id = photos.album_id) + * ) + * ON ( + * albums._lft >= covered_albums._lft AND + * albums._rgt <= covered_albums._rgt AND + * "complicated seachability filter goes here" + * ) + * WHERE covered_albums.id IN $albumKeys + * ORDER BY album_id ASC, photos.is_starred DESC, photos.created_at DESC + * + * For PostgreSQL see ["SELECT - DISTINCT Clause"](https://www.postgresql.org/docs/13/sql-select.html#SQL-DISTINCT). + * + * But `DISTINCT ON` is provided by neither MySQL nor SQLite. + * For the latter two, the following non-SQL-conformant query could be + * used: + * + * SELECT + * covered_albums.id AS covered_album_id, + * photos.id AS id, + * photos.type AS type + * FROM covered_albums + * LEFT JOIN + * ( + * photos + * LEFT JOIN albums + * ON (albums.id = photos.album_id) + * ) + * ON ( + * albums._lft >= covered_albums._lft AND + * albums._rgt <= covered_albums._rgt AND + * "complicated seachability filter goes here" + * ) + * WHERE covered_albums.id IN $albumKeys + * ORDER BY album_id ASC, photos.is_starred DESC, photos.created_at DESC + * GROUP BY album_id + * + * Instead of enforcing distinct results for `covered_album_id`, the result + * is grouped by `covered_album_id`. + * Note that this is not SQL-compliant, because the `SELECT` clause + * contains two columns (`photo.id` and `photo.type`) which are neither + * part of the `GROUP BY`-clause nor aggregates. + * However, MySQL and SQLite relax this constraint and return the + * column values of the first row of a group. + * This is exactly the specified behaviour of `DISTINCT ON`. + * For SQLite see "[Quirks, Caveats, and Gotchas In SQLite, Sec. 6](https://www.sqlite.org/quirks.html)" + * + * TODO: If the following query is too slow for large installation, we must write two separate implementations for PostgreSQL and MySQL/SQLite as outlined above. + * + * @param array $models + */ + public function addEagerConstraints(array $models): void + { + // We only use those `Album` models which have not set an explicit + // cover. + // Albums with explicit covers are treated separately in + // method `match`. + $albumKeys = collect($models) + ->whereNull('cover_id') + ->unique('id', true) + ->sortBy('id') + ->map(fn (Album $album) => $album->getKey()) + ->values(); + + $bestPhotoIDSelect = Photo::query() + ->select(['photos.id AS photo_id']) + ->join('albums', 'albums.id', '=', 'photos.album_id') + ->whereColumn('albums._lft', '>=', 'covered_albums._lft') + ->whereColumn('albums._rgt', '<=', 'covered_albums._rgt') + ->orderBy('photos.is_starred', 'desc') + ->orderBy('photos.' . $this->sortingCol, $this->sortingOrder) + ->limit(1); + if (!AccessControl::is_admin()) { + $bestPhotoIDSelect->where(function (Builder $query2) { + $this->photoAuthorisationProvider->appendSearchabilityConditions( + $query2->getQuery(), + 'covered_albums._lft', + 'covered_albums._rgt' + ); + }); + } + + $userID = AccessControl::is_logged_in() ? AccessControl::id() : null; + + $album2Cover = function (BaseBuilder $builder) use ($bestPhotoIDSelect, $albumKeys, $userID) { + $builder + ->from('albums as covered_albums') + ->join('base_albums', 'base_albums.id', '=', 'covered_albums.id'); + if ($userID !== null) { + $builder->leftJoin('user_base_album', + function (JoinClause $join) use ($userID) { + $join + ->on('user_base_album.base_album_id', '=', 'base_albums.id') + ->where('user_base_album.user_id', '=', $userID); + } + ); + } + $builder->select(['covered_albums.id AS album_id']) + ->addSelect(['photo_id' => $bestPhotoIDSelect]) + ->whereIn('covered_albums.id', $albumKeys); + if (!AccessControl::is_admin()) { + $builder->where(function (BaseBuilder $q) { + $this->albumAuthorisationProvider->appendAccessibilityConditions($q); + }); + } + }; + + $this->query + ->select([ + 'covers.id as id', + 'covers.type as type', + 'album_2_cover.album_id as covered_album_id', + ]) + ->from($album2Cover, 'album_2_cover') + ->join( + 'photos as covers', + 'covers.id', + '=', + 'album_2_cover.photo_id' + ); + } + + /** + * @param array $models an array of albums models whose thumbnails shall be initialized + * @param string $relation the name of the relation from the parent to the child models + * + * @return array the array of album models + */ + public function initRelation(array $models, $relation): array + { + foreach ($models as $model) { + $model->setRelation($relation, null); + } + + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models an array of parent models + * @param Collection $results the unified collection of all child models of all parent models + * @param string $relation the name of the relation from the parent to the child models + * + * @return array + */ + public function match(array $models, Collection $results, $relation): array + { + $dictionary = $results->mapToDictionary(function ($result) { + return [$result->covered_album_id => $result]; + })->all(); + + // Once we have the dictionary we can simply spin through the parent models to + // link them up with their children using the keyed dictionary to make the + // matching very convenient and easy work. Then we'll just return them. + /** @var Album $album */ + foreach ($models as $album) { + $albumID = $album->id; + if ($album->cover_id) { + // We do not execute a query, if `cover_id` is set, because + // `Album`always eagerly loads its cover and hence, we already + // have it. + // See {@link Album::with} + $album->setRelation($relation, Thumb::createFromPhoto($album->cover)); + } elseif (isset($dictionary[$albumID])) { + /** @var Photo $cover */ + $cover = reset($dictionary[$albumID]); + $album->setRelation($relation, Thumb::createFromPhoto($cover)); + } else { + $album->setRelation($relation, null); + } + } + + return $models; + } + + public function getResults(): ?Thumb + { + /** @var Album $album */ + $album = $this->parent; + if ($album === null || !$this->albumAuthorisationProvider->isAccessible($album)) { + return null; + } + + // We do not execute a query, if `cover_id` is set, because `Album` + // is always eagerly loaded with its cover and hence, we already + // have it. + // See {@link Album::with} + if ($album->cover_id) { + return Thumb::createFromPhoto($album->cover); + } else { + return Thumb::createFromQueryable( + $this->query, $this->sortingCol, $this->sortingOrder + ); + } + } +} diff --git a/app/Relations/HasManyBidirectionally.php b/app/Relations/HasManyBidirectionally.php new file mode 100644 index 00000000000..41ad28733ad --- /dev/null +++ b/app/Relations/HasManyBidirectionally.php @@ -0,0 +1,59 @@ +foreignMethodName = $foreignMethodName; + } + + /** + * Match the eagerly loaded results to their parents. + * + * This method is identical to + * {@link \Illuminate\Database\Eloquent\Relations\HasOneOrMany::matchOneOrMany} + * but additionally sets the reverse association of the child object + * back to its parent object. + * + * @param array $models an array of parent models + * @param Collection $results the unified collection of all child models of all parent models + * @param string $relation the name of the relation from the parent to the child models + * + * @return array + */ + public function match(array $models, Collection $results, $relation): array + { + $dictionary = $this->buildDictionary($results); + + // Once we have the dictionary we can simply spin through the parent models to + // link them up with their children using the keyed dictionary to make the + // matching very convenient and easy work. Then we'll just return them. + foreach ($models as $model) { + if (isset($dictionary[$key = $this->getDictionaryKey($model->getAttribute($this->localKey))])) { + /** @var Collection $childrenOfModel */ + $childrenOfModel = $this->getRelationValue($dictionary, $key, 'many'); + $model->setRelation($relation, $childrenOfModel); + // This is the newly added code which sets this method apart + // from the original method and additionally sets the + // reverse link + /** @var Model $childModel */ + foreach ($childrenOfModel as $childModel) { + $childModel->setRelation($this->foreignMethodName, $model); + } + } + } + + return $models; + } +} diff --git a/app/Relations/HasManyChildAlbums.php b/app/Relations/HasManyChildAlbums.php new file mode 100644 index 00000000000..278bba9e61b --- /dev/null +++ b/app/Relations/HasManyChildAlbums.php @@ -0,0 +1,100 @@ +albumAuthorisationProvider = resolve(AlbumAuthorisationProvider::class); + $this->sortingCol = Configs::get_value('sorting_Albums_col', 'created_at'); + $this->sortingOrder = Configs::get_value('sorting_Albums_order', 'ASC'); + parent::__construct( + $owningAlbum->newQuery(), + $owningAlbum, + 'parent_id', + 'id', + 'parent' + ); + } + + public function addConstraints() + { + if (static::$constraints) { + parent::addConstraints(); + $this->albumAuthorisationProvider->applyVisibilityFilter($this->query); + } + } + + public function addEagerConstraints(array $models) + { + parent::addEagerConstraints($models); + $this->albumAuthorisationProvider->applyVisibilityFilter($this->query); + } + + public function getResults() + { + if (is_null($this->getParentKey())) { + return $this->related->newCollection(); + } + + return (new SortingDecorator($this->query)) + ->orderBy($this->sortingCol, $this->sortingOrder) + ->get(); + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models an array of parent models + * @param Collection $results the unified collection of all child models of all parent models + * @param string $relation the name of the relation from the parent to the child models + * + * @return array + */ + public function match(array $models, Collection $results, $relation): array + { + $dictionary = $this->buildDictionary($results); + + $sortingCol = Configs::get_value('sorting_Albums_col', 'created_at'); + $sortingOrder = Configs::get_value('sorting_Albums_order', 'ASC'); + + // Once we have the dictionary we can simply spin through the parent models to + // link them up with their children using the keyed dictionary to make the + // matching very convenient and easy work. Then we'll just return them. + foreach ($models as $model) { + if (isset($dictionary[$key = $this->getDictionaryKey($model->getAttribute($this->localKey))])) { + /** @var Collection $childrenOfModel */ + $childrenOfModel = $this->getRelationValue($dictionary, $key, 'many'); + $childrenOfModel = $childrenOfModel + ->sortBy($sortingCol, SORT_NATURAL | SORT_FLAG_CASE, $sortingOrder === 'DESC') + ->values(); + $model->setRelation($relation, $childrenOfModel); + // This is the newly added code which sets this method apart + // from the original method and additionally sets the + // reverse link + /** @var Model $childModel */ + foreach ($childrenOfModel as $childModel) { + $childModel->setRelation($this->foreignMethodName, $model); + } + } + } + + return $models; + } +} \ No newline at end of file diff --git a/app/Relations/HasManyChildPhotos.php b/app/Relations/HasManyChildPhotos.php new file mode 100644 index 00000000000..e8d294a2354 --- /dev/null +++ b/app/Relations/HasManyChildPhotos.php @@ -0,0 +1,106 @@ +photoAuthorisationProvider = resolve(PhotoAuthorisationProvider::class); + parent::__construct( + Photo::query(), + $owningAlbum, + 'album_id', + 'id', + 'album' + ); + } + + public function addConstraints() + { + if (static::$constraints) { + parent::addConstraints(); + $this->photoAuthorisationProvider->applyVisibilityFilter($this->query); + } + } + + public function addEagerConstraints(array $models) + { + parent::addEagerConstraints($models); + $this->photoAuthorisationProvider->applyVisibilityFilter($this->query); + } + + public function getResults() + { + if (is_null($this->getParentKey())) { + return $this->related->newCollection(); + } + + /** @var Album $album */ + $album = $this->parent; + + return (new SortingDecorator($this->query)) + ->orderBy( + 'photos.' . $album->getEffectiveSortingCol(), + $album->getEffectiveSortingOrder() + ) + ->get(); + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models an array of parent models + * @param Collection $results the unified collection of all child models of all parent models + * @param string $relation the name of the relation from the parent to the child models + * + * @return array + */ + public function match(array $models, Collection $results, $relation): array + { + $dictionary = $this->buildDictionary($results); + + // Once we have the dictionary we can simply spin through the parent models to + // link them up with their children using the keyed dictionary to make the + // matching very convenient and easy work. Then we'll just return them. + /** @var Album $model */ + foreach ($models as $model) { + if (isset($dictionary[$key = $this->getDictionaryKey($model->getAttribute($this->localKey))])) { + /** @var Collection $childrenOfModel */ + $childrenOfModel = $this->getRelationValue($dictionary, $key, 'many'); + /** @var string $col */ + $col = $model->getEffectiveSortingCol(); + $childrenOfModel = $childrenOfModel + ->sortBy( + $col, + in_array($col, SortingDecorator::POSTPONE_COLUMNS) ? SORT_NATURAL | SORT_FLAG_CASE : SORT_REGULAR, + $model->getEffectiveSortingOrder() === 'DESC' + ) + ->values(); + $model->setRelation($relation, $childrenOfModel); + // This is the newly added code which sets this method apart + // from the original method and additionally sets the + // reverse link + /** @var Model $childModel */ + foreach ($childrenOfModel as $childModel) { + $childModel->setRelation($this->foreignMethodName, $model); + } + } + } + + return $models; + } +} diff --git a/app/Relations/HasManyPhotos.php b/app/Relations/HasManyPhotos.php new file mode 100644 index 00000000000..d62b9428f49 --- /dev/null +++ b/app/Relations/HasManyPhotos.php @@ -0,0 +1,105 @@ +photoAuthorisationProvider = resolve(PhotoAuthorisationProvider::class); + // This is a hack. + // The abstract class + // {@link \Illuminate\Database\Eloquent\Relations\Relation} + // stores a pointer to the parent and assumes that the parent is + // an instance of {@link Illuminate\Database\Eloquent\Model}. + // However, we cannot guarantee this, because we have smart albums + // which do not exist on the DB and therefore do not extend + // `Model`. + // Actually, it is sufficient if the owning side implements the + // method which are provided by `HasRelations`. + // Unfortunately, the constructor of `Relation` demands a true model + // and does not only ask for something which implements `HasRelations`. + // Luckily, `Relation` itself does not do anything with the passed + // model but only stores a reference in `Relation::$parent` to be + // used by child classes. + // Moreover, it is impossible to pass `null`. + // As a work-around we store the owning album in our own attribute + // `$owningAlbum` and always use that instead of `$parent`. + parent::__construct( + // Sic! We also must load the album eagerly. + // This relation is not used by albums which own the queried + // photos, but by albums which only include the photos due to some + // indirect condition. + // Hence, the actually owning albums of the photos are not + // necessarily loaded. + Photo::query()->with(['album', 'size_variants', 'size_variants.sym_links']), + $owningAlbum + ); + } + + /** + * Initializes the given owning models with a default value of this + * relation. + * + * In this case, the default value is an empty collection of + * {@link \App\Models\Photo}. + * + * @param array $models a list of owning models, i.e. a list of albums + * @param string $relation the name of the relation on the owning models + * + * @return array always returns $models + */ + public function initRelation(array $models, $relation): array + { + /** @var BaseAlbum $model */ + foreach ($models as $model) { + $model->setRelation($relation, $this->related->newCollection()); + } + + return $models; + } + + /** + * Returns the collection of photos for a single owning parent (aka + * "album"). + * + * This method also takes care of proper sorting. + * For most columns this method performs sorting on the DB layer for + * improved performance. + * But for some columns which require "natural" and locale-dependent + * sorting, the collection is sorted after is has been fetched from + * the DB. + * + * @return Collection + */ + public function getResults(): Collection + { + /** @var BaseAlbum $album */ + $album = $this->parent; + + return (new SortingDecorator($this->query)) + ->orderBy( + 'photos.' . $album->getEffectiveSortingCol(), + $album->getEffectiveSortingOrder() + ) + ->get(); + } +} diff --git a/app/Relations/HasManyPhotosByTag.php b/app/Relations/HasManyPhotosByTag.php new file mode 100644 index 00000000000..2fe91160c03 --- /dev/null +++ b/app/Relations/HasManyPhotosByTag.php @@ -0,0 +1,90 @@ +addEagerConstraints([$this->parent]); + } + } + + /** + * Adds the constraints for a list of owning album to the base query. + * + * This method is called by the framework, if the related photos of a + * list of owning albums are fetched. + * The unified result of the query is mapped to the specific albums + * by {@link HasManyPhotosByTag::match()}. + * + * @param array $albums an array of {@link \App\Models\TagAlbum} whose photos are loaded + */ + public function addEagerConstraints(array $albums): void + { + if (count($albums) !== 1) { + throw new \InvalidArgumentException('eagerly fetching all photos of an album is only implemented for a single album at once'); + } + /** @var TagAlbum $album */ + $album = $albums[0]; + $tags = explode(',', $album->show_tags); + + $this->photoAuthorisationProvider + ->applySearchabilityFilter($this->query) + ->where(function (Builder $q) use ($tags) { + // Filter for requested tags + foreach ($tags as $tag) { + $q->where('tags', 'like', '%' . trim($tag) . '%'); + } + }); + } + + /** + * Maps a collection of eagerly fetched photos to the given owning albums. + * + * This method is called by the framework after the unified result of + * photos has been fetched by {@link HasManyPhotosByTag::addEagerConstraints()}. + * + * @param array $albums the list of owning albums + * @param Collection $photos collection of {@link Photo} models which needs to be mapped to the albums + * @param string $relation the name of the relation + * + * @return array + */ + public function match(array $albums, Collection $photos, $relation): array + { + if (count($albums) !== 1) { + throw new \InvalidArgumentException('eagerly fetching all photos of an album is only implemented for a single album at once'); + } + /** @var TagAlbum $album */ + $album = $albums[0]; + /** @var string $col */ + $col = $album->getEffectiveSortingCol(); + + $photos = $photos->sortBy( + $col, + in_array($col, SortingDecorator::POSTPONE_COLUMNS) ? SORT_NATURAL | SORT_FLAG_CASE : SORT_REGULAR, + $album->getEffectiveSortingOrder() === 'DESC' + )->values(); + $album->setRelation($relation, $photos); + + return $albums; + } +} diff --git a/app/Relations/HasManyPhotosRecursively.php b/app/Relations/HasManyPhotosRecursively.php new file mode 100644 index 00000000000..35a9160645f --- /dev/null +++ b/app/Relations/HasManyPhotosRecursively.php @@ -0,0 +1,104 @@ +albumAuthorisationProvider = resolve(AlbumAuthorisationProvider::class); + parent::__construct($owningAlbum); + } + + /** + * Adds the constraints for single owning album to the base query. + * + * This method is called by the framework, if the related photos of a + * single albums are fetched. + */ + public function addConstraints(): void + { + if (static::$constraints) { + $this->addEagerConstraints([$this->parent]); + } + } + + /** + * Adds the constraints for a list of owning album to the base query. + * + * This method is called by the framework, if the related photos of a + * list of owning albums are fetched. + * The unified result of the query is mapped to the specific albums + * by {@link HasManyPhotosRecursively::match()}. + * + * @param array $albums an array of {@link \App\Models\Album} whose photos are loaded + */ + public function addEagerConstraints(array $albums): void + { + if (count($albums) !== 1) { + throw new \InvalidArgumentException('eagerly fetching all photos of an album is only implemented for a single album at once'); + } + + $this->photoAuthorisationProvider + ->applySearchabilityFilter($this->query, $albums[0]); + } + + public function getResults(): Collection + { + /** @var Album $album */ + $album = $this->parent; + if ($album === null || !$this->albumAuthorisationProvider->isAccessible($album)) { + return $this->related->newCollection(); + } else { + return parent::getResults(); + } + } + + /** + * Maps a collection of eagerly fetched photos to the given owning albums. + * + * This method is called by the framework after the unified result of + * photos has been fetched by {@link HasManyPhotosRecursively::addEagerConstraints()}. + * + * @param array $albums the list of owning albums + * @param Collection $photos collection of {@link Photo} models which needs to be mapped to the albums + * @param string $relation the name of the relation + * + * @return array + */ + public function match(array $albums, Collection $photos, $relation): array + { + if (count($albums) !== 1) { + throw new \InvalidArgumentException('eagerly fetching all photos of an album is only implemented for a single album at once'); + } + /** @var Album $album */ + $album = $albums[0]; + + if (!$this->albumAuthorisationProvider->isAccessible($album)) { + $album->setRelation($relation, $this->related->newCollection()); + } else { + /** @var string $col */ + $col = $album->getEffectiveSortingCol(); + $photos = $photos->sortBy( + $col, + in_array($col, SortingDecorator::POSTPONE_COLUMNS) ? SORT_NATURAL | SORT_FLAG_CASE : SORT_REGULAR, + $album->getEffectiveSortingOrder() === 'DESC' + )->values(); + $album->setRelation($relation, $photos); + } + + return $albums; + } +} diff --git a/app/Relations/HasManySizeVariants.php b/app/Relations/HasManySizeVariants.php new file mode 100644 index 00000000000..f847d3c2437 --- /dev/null +++ b/app/Relations/HasManySizeVariants.php @@ -0,0 +1,110 @@ +parent; + + return new SizeVariants($parent, + is_null($this->getParentKey()) ? + $this->related->newCollection() : + $this->query->get() + ); + } + + /** + * Initialize the relation on a set of models. + * + * @param array $models + * @param string $relation + * + * @return array + */ + public function initRelation(array $models, $relation): array + { + /** @var Photo $model */ + foreach ($models as $model) { + $model->setRelation( + $relation, + new SizeVariants($model, $this->related->newCollection()) + ); + } + + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * + * This method is identical to + * {@link \Illuminate\Database\Eloquent\Relations\HasOneOrMany::matchOneOrMany} + * but additionally sets the reverse association of the child object + * back to its parent object. + * + * @param array $models an array of parent models + * @param Collection $results the unified collection of all child models of all parent models + * @param string $relation the name of the relation from the parent to the child models + * + * @return array + */ + public function match(array $models, Collection $results, $relation): array + { + $dictionary = $this->buildDictionary($results); + + // Once we have the dictionary we can simply spin through the parent models to + // link them up with their children using the keyed dictionary to make the + // matching very convenient and easy work. Then we'll just return them. + /** @var Photo $model */ + foreach ($models as $model) { + if (isset($dictionary[$key = $this->getDictionaryKey($model->getAttribute($this->localKey))])) { + /** @var Collection $childrenOfModel */ + $childrenOfModel = $this->getRelationValue($dictionary, $key, 'many'); + $model->setRelation($relation, new SizeVariants($model, $childrenOfModel)); + } + } + + return $models; + } + + /** + * Set the foreign ID for creating a related model. + * + * @param Model $model + * + * @return void + */ + protected function setForeignAttributesForCreate(Model $model) + { + if (!($model instanceof SizeVariant)) { + throw new \InvalidArgumentException('model must be an instance of SizeVariant'); + } + $model->setAttribute('photo_id', $this->getParentKey()); + $model->setRelation('photo', $this->parent); + } +} diff --git a/app/Relations/LinkedPhotoCollection.php b/app/Relations/LinkedPhotoCollection.php new file mode 100644 index 00000000000..c8a8717d299 --- /dev/null +++ b/app/Relations/LinkedPhotoCollection.php @@ -0,0 +1,48 @@ +isEmpty()) { + return $photos; + } + + /** @var Photo $photo the photo */ + foreach ($this->items as $photo) { + $photos[] = $photo->toArray(); + if ($i > 0) { + $photos[$i - 1]['next_photo_id'] = $photos[$i]['id']; + $photos[$i]['previous_photo_id'] = $photos[$i - 1]['id']; + } + $i++; + } + + $count = count($photos); + + if ($count > 1 && Configs::get_value('photos_wraparound', '1') === '1') { + $photos[0]['previous_photo_id'] = $photos[$count - 1]['id']; + $photos[$count - 1]['next_photo_id'] = $photos[0]['id']; + } else { + $photos[0]['previous_photo_id'] = null; + $photos[$count - 1]['next_photo_id'] = null; + } + + return $photos; + } +} diff --git a/app/Rules/AlbumIDListRule.php b/app/Rules/AlbumIDListRule.php new file mode 100644 index 00000000000..dd7aa5b7871 --- /dev/null +++ b/app/Rules/AlbumIDListRule.php @@ -0,0 +1,45 @@ +passes('', $albumID); + } + + return $success; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): string + { + return ':attribute ' . + ' must be a comma-seperated string of strings with ' . HasRandomID::ID_LENGTH . ' characters each or the built-in IDs ' . + implode(', ', array_keys(AlbumFactory::BUILTIN_SMARTS)); + } +} diff --git a/app/Rules/AlbumIDRule.php b/app/Rules/AlbumIDRule.php new file mode 100644 index 00000000000..a3d324653dc --- /dev/null +++ b/app/Rules/AlbumIDRule.php @@ -0,0 +1,38 @@ +passes('', $modelID); + } + + return $success; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): string + { + return ':attribute must be a comma-seperated string of strings with ' . HasRandomID::ID_LENGTH . ' characters each.'; + } +} diff --git a/app/Rules/ModelIDRule.php b/app/Rules/ModelIDRule.php new file mode 100644 index 00000000000..6ceb0f998b0 --- /dev/null +++ b/app/Rules/ModelIDRule.php @@ -0,0 +1,32 @@ + - */ - protected BaseCollection $albumIds; - - public function __construct() - { - parent::__construct(); - $this->albumIds = new BaseCollection(); - $this->created_at = new Carbon(); - $this->updated_at = new Carbon(); - $this->smart = true; - } - - /** - * Set a restriction on the available albums. - * - * @param BaseCollection $albumIds - * - * @return void - */ - public function setAlbumIDs(BaseCollection $albumIds): void - { - $this->albumIds = $albumIds; - } - - public function filter($query) - { - if (AccessControl::is_admin()) { - return $query; - } - - if (AccessControl::is_logged_in()) { - $query = $query->where('owner_id', '=', AccessControl::id()) - ->orWhere( - fn ($q) => $q->whereNotNull('album_id') - ->whereIn('album_id', $this->albumIds) - ); - } else { - $query = $query->whereIn('album_id', $this->albumIds); - } - - if (Configs::get_value('public_photos_hidden', '1') === '0') { - $query = $query->orWhere('public', '=', 1); - } - - return $query; - } - - /*------------------------- STRINGS --------------------------------- */ - public function str_parent_id() - { - return ''; - } - - public function get_license(): string - { - return 'none'; - } -} diff --git a/app/SmartAlbums/BaseSmartAlbum.php b/app/SmartAlbums/BaseSmartAlbum.php new file mode 100644 index 00000000000..3513d52ee37 --- /dev/null +++ b/app/SmartAlbums/BaseSmartAlbum.php @@ -0,0 +1,133 @@ +photoAuthorisationProvider = resolve(PhotoAuthorisationProvider::class); + $this->id = $id; + $this->title = $title; + $this->isPublic = $isPublic; + $this->isDownloadable = Configs::get_value('downloadable', '0') === '1'; + $this->isShareButtonVisible = Configs::get_value('share_button_visible', '0') === '1'; + $this->thumb = null; + $this->smartPhotoCondition = $smartCondition; + } + + public function photos(): Builder + { + return $this->photoAuthorisationProvider + ->applySearchabilityFilter( + Photo::query()->with(['album', 'size_variants', 'size_variants.sym_links']) + )->where($this->smartPhotoCondition); + } + + protected function getPhotosAttribute(): Collection + { + // Cache query result for later use + // (this mimics the behaviour of relations of true Eloquent models) + if (!isset($this->photos)) { + $sortingCol = Configs::get_value('sorting_Photos_col'); + $sortingOrder = Configs::get_value('sorting_Photos_order'); + + $this->photos = (new SortingDecorator($this->photos())) + ->orderBy('photos.' . $sortingCol, $sortingOrder) + ->get(); + } + + return $this->photos; + } + + protected function getThumbAttribute(): ?Thumb + { + if (!isset($this->thumb)) { + /* + * Note, `photos()` already applies a "security filter" and + * only returns photos which are accessible by the current + * user. + */ + $this->thumb = Thumb::createFromQueryable( + $this->photos(), + Configs::get_value('sorting_Photos_col'), + Configs::get_value('sorting_Photos_order') + ); + } + + return $this->thumb; + } + + public function toArray(): array + { + // The properties `thumb` and `photos` are intentionally treated + // differently. + // + // 1. The result always includes `thumb`, hence we call the + // getter method to ensure that the property is initialized, if it + // has not already been accessed before. + // 2. The result only includes the collection `photos`, if it has + // already explicitly been accessed earlier and thus is initialized. + // + // Rationale: + // + // 1. This resembles the behaviour of a real Eloquent model, if the + // attribute `thumb` was part of the `append`-property of model. + // 2. This resembles the behaviour of a real Eloquent model for + // one-to-many relations. + // A relation is only included in the array representation, if the + // relation has been loaded. + // This avoids unnecessary hydration of photos if the album is + // only used within a listing of sub-albums. + + $result = [ + 'id' => $this->id, + 'title' => $this->title, + 'is_public' => $this->isPublic, + 'is_downloadable' => $this->isDownloadable, + 'is_share_button_visible' => $this->isShareButtonVisible, + 'thumb' => $this->getThumbAttribute(), + ]; + + if (isset($this->photos)) { + $result['photos'] = $this->photos->toArray(); + } + + return $result; + } +} diff --git a/app/SmartAlbums/PublicAlbum.php b/app/SmartAlbums/PublicAlbum.php index 67d41a4f4b3..688f668e128 100644 --- a/app/SmartAlbums/PublicAlbum.php +++ b/app/SmartAlbums/PublicAlbum.php @@ -2,22 +2,65 @@ namespace App\SmartAlbums; -use App\Models\Photo; use Illuminate\Database\Eloquent\Builder; -class PublicAlbum extends SmartAlbum +/** + * Smart built-in album "Public". + * + * The Public album lists all photos which are explicitly made public. + * The album des not include photos which are public due to being part of + * a public album. + * This behaviour is intended due to the following reasons: + * + * 1. If all photos (including those of public albums) were included, the + * album would become huge and unusable. + * Especially, the load time would be HUGE even for mid-size + * installations. + * 2. The whole purpose of the smart album is to easily spot public photos + * which are accidentally public, but are not meant to be public. + * While public albums can be easily found, this is not true for photos + * which are made public individually. + */ +class PublicAlbum extends BaseSmartAlbum { - public $id = 'public'; + private static ?self $instance = null; + public const ID = 'public'; + public const TITLE = 'Public'; - public function __construct() + /** + * Constructor. + * + * Note that the condition only includes photos which are explicitly made + * public, but does not include photos which are public due to being part + * of a public album. + * **This is intended behaviour!** + * See description of the whole class {@link PublicAlbum} for an + * explanation. + */ + protected function __construct() { - parent::__construct(); - - $this->title = 'public'; + parent::__construct( + self::ID, + self::TITLE, + false, + fn (Builder $q) => $q->where('photos.is_public', '=', true) + ); } - public function get_photos(): Builder + public static function getInstance(): self { - return Photo::public()->where(fn ($q) => $this->filter($q)); + if (!self::$instance) { + self::$instance = new self(); + } + // The following two lines are only needed due to testing. + // The same instance of this class is used for all tests, because + // the singleton stays alive during tests. + // This implies that the relation of photos is never reloaded + // but remains constant during all tests (it equals the empty set) + // and the tests fail. + unset(self::$instance->photos); + unset(self::$instance->thumb); + + return self::$instance; } } diff --git a/app/SmartAlbums/RecentAlbum.php b/app/SmartAlbums/RecentAlbum.php index 407945c902e..2665f92b0bc 100644 --- a/app/SmartAlbums/RecentAlbum.php +++ b/app/SmartAlbums/RecentAlbum.php @@ -3,23 +3,45 @@ namespace App\SmartAlbums; use App\Models\Configs; -use App\Models\Photo; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Carbon; -class RecentAlbum extends SmartAlbum +class RecentAlbum extends BaseSmartAlbum { - public $id = 'recent'; + private static ?self $instance = null; + public const ID = 'recent'; + public const TITLE = 'Recent'; - public function __construct() + protected function __construct() { - parent::__construct(); + $strRecent = $this->fromDateTime( + Carbon::now()->subDays(intval(Configs::get_value('recent_age', '1'))) + ); - $this->title = 'recent'; - $this->public = Configs::get_value('public_recent', '0') === '1'; + parent::__construct( + self::ID, + self::TITLE, + Configs::get_value('public_recent', '0') === '1', + function (Builder $query) use ($strRecent) { + $query->where('photos.created_at', '>=', $strRecent); + } + ); } - public function get_photos(): Builder + public static function getInstance(): self { - return Photo::recent()->where(fn ($q) => $this->filter($q)); + if (!self::$instance) { + self::$instance = new self(); + } + // The following two lines are only needed due to testing. + // The same instance of this class is used for all tests, because + // the singleton stays alive during tests. + // This implies that the relation of photos is never be reloaded + // but remains constant during all tests (it equals the empty set) + // and the tests fails. + unset(self::$instance->photos); + unset(self::$instance->thumb); + + return self::$instance; } } diff --git a/app/SmartAlbums/SmartAlbum.php b/app/SmartAlbums/SmartAlbum.php deleted file mode 100644 index a3ae555ae03..00000000000 --- a/app/SmartAlbums/SmartAlbum.php +++ /dev/null @@ -1,69 +0,0 @@ -title = 'starred'; - $this->public = Configs::get_value('public_starred', '0') === '1'; + parent::__construct( + self::ID, + self::TITLE, + Configs::get_value('public_starred', '0') === '1', + fn (Builder $q) => $q->where('photos.is_starred', '=', true) + ); } - public function get_photos(): Builder + public static function getInstance(): self { - return Photo::stars()->where(fn ($q) => $this->filter($q)); + if (!self::$instance) { + self::$instance = new self(); + } + // The following two lines are only needed due to testing. + // The same instance of this class is used for all tests, because + // the singleton stays alive during tests. + // This implies that the relation of photos is never be reloaded + // but remains constant during all tests (it equals the empty set) + // and the tests fails. + unset(self::$instance->photos); + unset(self::$instance->thumb); + + return self::$instance; } } diff --git a/app/SmartAlbums/TagAlbum.php b/app/SmartAlbums/TagAlbum.php deleted file mode 100644 index 8cc1439c875..00000000000 --- a/app/SmartAlbums/TagAlbum.php +++ /dev/null @@ -1,23 +0,0 @@ -showtags); - foreach ($tags as $tag) { - $sql = $sql->where('tags', 'like', '%' . trim($tag) . '%'); - } - - return $sql->where(fn ($q) => $this->filter($q)); - } -} diff --git a/app/SmartAlbums/UnsortedAlbum.php b/app/SmartAlbums/UnsortedAlbum.php index 40eadf3b080..8e1b48605ae 100644 --- a/app/SmartAlbums/UnsortedAlbum.php +++ b/app/SmartAlbums/UnsortedAlbum.php @@ -2,23 +2,66 @@ namespace App\SmartAlbums; +use App\Facades\AccessControl; use App\Models\Photo; use Illuminate\Database\Eloquent\Builder; -class UnsortedAlbum extends SmartAlbum +class UnsortedAlbum extends BaseSmartAlbum { - public $id = 'unsorted'; + private static ?self $instance = null; + public const ID = 'unsorted'; + public const TITLE = 'Unsorted'; public function __construct() { - parent::__construct(); + parent::__construct( + self::ID, + self::TITLE, + false, + fn (Builder $q) => $q->whereNull('photos.album_id') + ); + } + + public static function getInstance(): self + { + if (!self::$instance) { + self::$instance = new self(); + } + // The following two lines are only needed due to testing. + // The same instance of this class is used for all tests, because + // the singleton stays alive during tests. + // This implies that the relation of photos is never be reloaded + // but remains constant during all tests (it equals the empty set) + // and the tests fails. + unset(self::$instance->photos); + unset(self::$instance->thumb); - $this->title = 'unsorted'; - $this->public = false; + return self::$instance; } - public function get_photos(): Builder + /** + * "Deletes" the album of unsorted photos. + * + * Actually, the album itself is not deleted, because it is built-in. + * But all photos within the album which are owned by the current user + * are deleted. + * + * @return bool + */ + public function delete(): bool { - return Photo::unsorted()->where(fn ($q) => $this->filter($q)); + $success = true; + if (!AccessControl::is_admin()) { + $photos = $this->photos()->where('owner_id', '=', AccessControl::id())->get(); + } else { + $photos = $this->photos()->get(); + } + /** @var Photo $photo */ + foreach ($photos as $photo) { + // This also takes care of proper deletion of physical files from disk + $success &= $photo->delete(); + } + + return $success; } } diff --git a/app/SmartAlbums/Utils/MimicModel.php b/app/SmartAlbums/Utils/MimicModel.php new file mode 100644 index 00000000000..0ca59e7e856 --- /dev/null +++ b/app/SmartAlbums/Utils/MimicModel.php @@ -0,0 +1,84 @@ +toArray(); + } + + /** + * Convert the model instance to JSON. + * + * @param int $options + * + * @return string + * + * @throws \RuntimeException + */ + public function toJson($options = 0): string + { + $json = json_encode($this->jsonSerialize(), $options); + + if (JSON_ERROR_NONE !== json_last_error()) { + throw new \RuntimeException('Could not serialize ' . get_class($this) . ': ' . json_last_error_msg()); + } + + return $json; + } + + /** + * Gets a property dynamically. + * + * This method is inspired by + * {@link \Illuminate\Database\Eloquent\Model::__get()} + * and enables the using class to be treated the same way as real models. + * + * @param string $key + * + * @return mixed + * + * @throws \InvalidArgumentException + */ + public function __get(string $key) + { + if (empty($key)) { + throw new \InvalidArgumentException('property name must not be empty'); + } + + $studlyKey = Str::studly($key); + $getter = 'get' . $studlyKey . 'Attribute'; + $studlyKey = lcfirst($studlyKey); + + if (method_exists($this, $getter)) { + return $this->{$getter}(); + } elseif (property_exists($this, $studlyKey)) { + return $this->{$studlyKey}; + } else { + throw new \InvalidArgumentException('neither property nor getter method exist'); + } + } + + /** + * Convert the model to its string representation. + * + * @return string + * + * @throws \RuntimeException + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/app/View/Components/Album/Thumbimg.php b/app/View/Components/Album/Thumbimg.php index dfca83c7efa..790815eb4b2 100644 --- a/app/View/Components/Album/Thumbimg.php +++ b/app/View/Components/Album/Thumbimg.php @@ -32,6 +32,7 @@ public function __construct($type = '', $thumb = '', $thumb2x = '') */ public function render() { + // TODO: Don't hardcode paths if ($this->thumb == 'uploads/thumb/' && $this->isVideo) { return view('components.album.thumb-play'); } diff --git a/app/View/Components/Photo.php b/app/View/Components/Photo.php index 3e61a24ddb5..2f5f9174bb0 100644 --- a/app/View/Components/Photo.php +++ b/app/View/Components/Photo.php @@ -3,7 +3,7 @@ namespace App\View\Components; use App\Models\Configs; -use App\Models\Photo as PhotoModel; +use App\Models\SizeVariant; use Illuminate\Support\Facades\URL; use Illuminate\Support\Str; use Illuminate\View\Component; @@ -28,8 +28,8 @@ class Photo extends Component public $srcset2x = ''; public $layout = false; - public int $_w = PhotoModel::THUMBNAIL_DIM; - public int $_h = PhotoModel::THUMBNAIL_DIM; + public int $_w = SizeVariant::THUMBNAIL_DIM; + public int $_h = SizeVariant::THUMBNAIL_DIM; /** * Create a new component instance. @@ -43,12 +43,12 @@ public function __construct(array $data) $this->title = $data['title']; $this->takedate = $data['taken_at']; $this->created_at = $data['created_at']; - $this->star = $data['star'] == '1'; - $this->public = $data['public'] == '1'; + $this->is_starred = $data['is_starred']; + $this->is_public = $data['is_public']; $isVideo = Str::contains($data['type'], 'video'); $isRaw = Str::contains($data['type'], 'raw'); - $isLivePhoto = filled($data['livePhotoUrl']); + $isLivePhoto = filled($data['live_Photo_filename']); $this->class = ''; $this->class .= $isVideo ? ' video' : ''; @@ -56,6 +56,7 @@ public function __construct(array $data) $this->layout = Configs::get_value('layout', '0') == '0'; + // TODO: Don't hardcode paths if ($data['sizeVariants']['thumb']['url'] == 'uploads/thumb/') { $this->show_live = $isLivePhoto; $this->show_play = $isVideo; diff --git a/composer.json b/composer.json index 2d523e96f52..05723e85ae5 100644 --- a/composer.json +++ b/composer.json @@ -25,9 +25,9 @@ "geocoder-php/cache-provider": "^4.3", "geocoder-php/nominatim-provider": "^5.5", "graham-campbell/markdown": "^13.1", - "kalnoy/nestedset": "^6.0", "laravel/framework": "^8.0", "livewire/livewire": "^2.7", + "lychee-org/nestedset": "^5", "lychee-org/php-exif": "dev-master", "maennchen/zipstream-php": "^2.1", "php-ffmpeg/php-ffmpeg": "^0.17.0", diff --git a/composer.lock b/composer.lock index ce17407b461..1fed3556a44 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1a6990425c8cdd6d97b39db146f820f1", + "content-hash": "38adc469ce11d7180b5e84ef2395d12a", "packages": [ { "name": "alchemy/binary-driver", @@ -74,16 +74,16 @@ }, { "name": "beberlei/assert", - "version": "v3.3.1", + "version": "v3.3.2", "source": { "type": "git", "url": "https://github.com/beberlei/assert.git", - "reference": "5e721d7e937ca3ba2cdec1e1adf195f9e5188372" + "reference": "cb70015c04be1baee6f5f5c953703347c0ac1655" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/beberlei/assert/zipball/5e721d7e937ca3ba2cdec1e1adf195f9e5188372", - "reference": "5e721d7e937ca3ba2cdec1e1adf195f9e5188372", + "url": "https://api.github.com/repos/beberlei/assert/zipball/cb70015c04be1baee6f5f5c953703347c0ac1655", + "reference": "cb70015c04be1baee6f5f5c953703347c0ac1655", "shasum": "" }, "require": { @@ -135,9 +135,9 @@ ], "support": { "issues": "https://github.com/beberlei/assert/issues", - "source": "https://github.com/beberlei/assert/tree/v3.3.1" + "source": "https://github.com/beberlei/assert/tree/v3.3.2" }, - "time": "2021-04-18T20:11:03+00:00" + "time": "2021-12-16T21:41:27+00:00" }, { "name": "bepsvpt/secure-headers", @@ -606,16 +606,16 @@ }, { "name": "doctrine/dbal", - "version": "3.2.0", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "5d54f63541d7bed1156cb5c9b79274ced61890e4" + "reference": "4caf37acf14b513a91dd4f087f7eda424fa25542" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/5d54f63541d7bed1156cb5c9b79274ced61890e4", - "reference": "5d54f63541d7bed1156cb5c9b79274ced61890e4", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/4caf37acf14b513a91dd4f087f7eda424fa25542", + "reference": "4caf37acf14b513a91dd4f087f7eda424fa25542", "shasum": "" }, "require": { @@ -630,14 +630,14 @@ "require-dev": { "doctrine/coding-standard": "9.0.0", "jetbrains/phpstorm-stubs": "2021.1", - "phpstan/phpstan": "1.2.0", + "phpstan/phpstan": "1.3.0", "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "9.5.10", + "phpunit/phpunit": "9.5.11", "psalm/plugin-phpunit": "0.16.1", - "squizlabs/php_codesniffer": "3.6.1", + "squizlabs/php_codesniffer": "3.6.2", "symfony/cache": "^5.2|^6.0", "symfony/console": "^2.0.5|^3.0|^4.0|^5.0|^6.0", - "vimeo/psalm": "4.13.0" + "vimeo/psalm": "4.16.1" }, "suggest": { "symfony/console": "For helpful console commands such as SQL execution and import of files." @@ -697,7 +697,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.2.0" + "source": "https://github.com/doctrine/dbal/tree/3.2.1" }, "funding": [ { @@ -713,7 +713,7 @@ "type": "tidelift" } ], - "time": "2021-11-26T21:00:12+00:00" + "time": "2022-01-05T08:52:06+00:00" }, { "name": "doctrine/deprecations", @@ -1025,16 +1025,16 @@ }, { "name": "dragonmantank/cron-expression", - "version": "v3.1.0", + "version": "v3.2.3", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "7a8c6e56ab3ffcc538d05e8155bb42269abf1a0c" + "reference": "47c53bbb260d3c398fba9bfa9683dcf67add2579" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/7a8c6e56ab3ffcc538d05e8155bb42269abf1a0c", - "reference": "7a8c6e56ab3ffcc538d05e8155bb42269abf1a0c", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/47c53bbb260d3c398fba9bfa9683dcf67add2579", + "reference": "47c53bbb260d3c398fba9bfa9683dcf67add2579", "shasum": "" }, "require": { @@ -1074,7 +1074,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.1.0" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.2.3" }, "funding": [ { @@ -1082,7 +1082,7 @@ "type": "github" } ], - "time": "2020-11-24T19:55:57+00:00" + "time": "2022-01-06T05:35:07+00:00" }, { "name": "egulias/email-validator", @@ -1201,24 +1201,24 @@ }, { "name": "fgrosse/phpasn1", - "version": "v2.3.0", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/fgrosse/PHPASN1.git", - "reference": "20299033c35f4300eb656e7e8e88cf52d1d6694e" + "reference": "eef488991d53e58e60c9554b09b1201ca5ba9296" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fgrosse/PHPASN1/zipball/20299033c35f4300eb656e7e8e88cf52d1d6694e", - "reference": "20299033c35f4300eb656e7e8e88cf52d1d6694e", + "url": "https://api.github.com/repos/fgrosse/PHPASN1/zipball/eef488991d53e58e60c9554b09b1201ca5ba9296", + "reference": "eef488991d53e58e60c9554b09b1201ca5ba9296", "shasum": "" }, "require": { - "php": ">=7.0.0" + "php": "~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0" }, "require-dev": { - "phpunit/phpunit": "~6.3", - "satooshi/php-coveralls": "~2.0" + "php-coveralls/php-coveralls": "~2.0", + "phpunit/phpunit": "^6.3 || ^7.0 || ^8.0" }, "suggest": { "ext-bcmath": "BCmath is the fallback extension for big integer calculations", @@ -1270,9 +1270,9 @@ ], "support": { "issues": "https://github.com/fgrosse/PHPASN1/issues", - "source": "https://github.com/fgrosse/PHPASN1/tree/v2.3.0" + "source": "https://github.com/fgrosse/PHPASN1/tree/v2.4.0" }, - "time": "2021-04-24T19:01:55+00:00" + "time": "2021-12-11T12:41:06+00:00" }, { "name": "fideloper/proxy", @@ -1656,16 +1656,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.4.0", + "version": "7.4.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "868b3571a039f0ebc11ac8f344f4080babe2cb94" + "reference": "ee0a041b1760e6a53d2a39c8c34115adc2af2c79" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/868b3571a039f0ebc11ac8f344f4080babe2cb94", - "reference": "868b3571a039f0ebc11ac8f344f4080babe2cb94", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee0a041b1760e6a53d2a39c8c34115adc2af2c79", + "reference": "ee0a041b1760e6a53d2a39c8c34115adc2af2c79", "shasum": "" }, "require": { @@ -1674,7 +1674,7 @@ "guzzlehttp/psr7": "^1.8.3 || ^2.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2" + "symfony/deprecation-contracts": "^2.2 || ^3.0" }, "provide": { "psr/http-client-implementation": "1.0" @@ -1760,7 +1760,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.4.0" + "source": "https://github.com/guzzle/guzzle/tree/7.4.1" }, "funding": [ { @@ -1776,7 +1776,7 @@ "type": "tidelift" } ], - "time": "2021-10-18T09:52:00+00:00" + "time": "2021-12-06T18:43:05+00:00" }, { "name": "guzzlehttp/promises", @@ -1977,81 +1977,18 @@ ], "time": "2021-10-06T17:43:30+00:00" }, - { - "name": "kalnoy/nestedset", - "version": "v6.0.0", - "source": { - "type": "git", - "url": "https://github.com/lazychaser/laravel-nestedset.git", - "reference": "f5351234588a20b14134980552b1bf6dccb3e733" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/lazychaser/laravel-nestedset/zipball/f5351234588a20b14134980552b1bf6dccb3e733", - "reference": "f5351234588a20b14134980552b1bf6dccb3e733", - "shasum": "" - }, - "require": { - "illuminate/database": "^7.0|^8.0", - "illuminate/events": "^7.0|^8.0", - "illuminate/support": "^7.0|^8.0", - "php": ">=7.1.3" - }, - "require-dev": { - "phpunit/phpunit": "7.*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "v5.0.x-dev" - }, - "laravel": { - "providers": [ - "Kalnoy\\Nestedset\\NestedSetServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Kalnoy\\Nestedset\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Alexander Kalnoy", - "email": "lazychaser@gmail.com" - } - ], - "description": "Nested Set Model for Laravel 5.7 and up", - "keywords": [ - "database", - "hierarchy", - "laravel", - "nested sets", - "nsm" - ], - "support": { - "issues": "https://github.com/lazychaser/laravel-nestedset/issues", - "source": "https://github.com/lazychaser/laravel-nestedset/tree/v6.0.0" - }, - "time": "2021-05-28T07:08:55+00:00" - }, { "name": "laravel/framework", - "version": "v8.75.0", + "version": "v8.78.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "0bb91d3176357da232da69762a64b0e0a0988637" + "reference": "16359b5ebafba6579b397d7505b082a6d1bb2e31" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/0bb91d3176357da232da69762a64b0e0a0988637", - "reference": "0bb91d3176357da232da69762a64b0e0a0988637", + "url": "https://api.github.com/repos/laravel/framework/zipball/16359b5ebafba6579b397d7505b082a6d1bb2e31", + "reference": "16359b5ebafba6579b397d7505b082a6d1bb2e31", "shasum": "" }, "require": { @@ -2069,7 +2006,7 @@ "opis/closure": "^3.6", "php": "^7.3|^8.0", "psr/container": "^1.0", - "psr/log": "^1.0 || ^2.0", + "psr/log": "^1.0|^2.0", "psr/simple-cache": "^1.0", "ramsey/uuid": "^4.2.2", "swiftmailer/swiftmailer": "^6.3", @@ -2210,7 +2147,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2021-12-07T14:55:46+00:00" + "time": "2022-01-05T14:52:50+00:00" }, { "name": "laravel/serializable-closure", @@ -2691,16 +2628,16 @@ }, { "name": "livewire/livewire", - "version": "v2.8.1", + "version": "v2.8.2", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "0f846a93369109e445f0e5009741b1e19e6fa6f6" + "reference": "f6b1726d1068a5b7315f03dc4e58d5763b928374" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/0f846a93369109e445f0e5009741b1e19e6fa6f6", - "reference": "0f846a93369109e445f0e5009741b1e19e6fa6f6", + "url": "https://api.github.com/repos/livewire/livewire/zipball/f6b1726d1068a5b7315f03dc4e58d5763b928374", + "reference": "f6b1726d1068a5b7315f03dc4e58d5763b928374", "shasum": "" }, "require": { @@ -2751,7 +2688,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v2.8.1" + "source": "https://github.com/livewire/livewire/tree/v2.8.2" }, "funding": [ { @@ -2759,7 +2696,70 @@ "type": "github" } ], - "time": "2021-12-02T01:31:26+00:00" + "time": "2021-12-10T19:37:40+00:00" + }, + { + "name": "lychee-org/nestedset", + "version": "v5.0.9", + "source": { + "type": "git", + "url": "https://github.com/LycheeOrg/laravel-nestedset.git", + "reference": "678e24c545cbcaea60cdda762aa52e73714e4566" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/LycheeOrg/laravel-nestedset/zipball/678e24c545cbcaea60cdda762aa52e73714e4566", + "reference": "678e24c545cbcaea60cdda762aa52e73714e4566", + "shasum": "" + }, + "require": { + "illuminate/database": "~5.7.0|~5.8.0|^6.0|^7.0|^8.0", + "illuminate/events": "~5.7.0|~5.8.0|^6.0|^7.0|^8.0", + "illuminate/support": "~5.7.0|~5.8.0|^6.0|^7.0|^8.0", + "php": ">=7.1.3" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "v5.0.x-dev" + }, + "laravel": { + "providers": [ + "Kalnoy\\Nestedset\\NestedSetServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Kalnoy\\Nestedset\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alexander Kalnoy", + "email": "lazychaser@gmail.com" + } + ], + "description": "Nested Set Model for Laravel 5.7 and up (fork with patches for Lychee)", + "keywords": [ + "database", + "hierarchy", + "laravel", + "nested sets", + "nsm" + ], + "support": { + "source": "https://github.com/LycheeOrg/laravel-nestedset/tree/v5.0.9" + }, + "time": "2022-01-06T17:19:43+00:00" }, { "name": "lychee-org/php-exif", @@ -3199,24 +3199,24 @@ }, { "name": "neutron/temporary-filesystem", - "version": "3.0", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/romainneutron/Temporary-Filesystem.git", - "reference": "60e79adfd16f42f4b888e351ad49f9dcb959e3c2" + "reference": "55f3d4896eff3bf070e491916e6c564db5e640b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/romainneutron/Temporary-Filesystem/zipball/60e79adfd16f42f4b888e351ad49f9dcb959e3c2", - "reference": "60e79adfd16f42f4b888e351ad49f9dcb959e3c2", + "url": "https://api.github.com/repos/romainneutron/Temporary-Filesystem/zipball/55f3d4896eff3bf070e491916e6c564db5e640b5", + "reference": "55f3d4896eff3bf070e491916e6c564db5e640b5", "shasum": "" }, "require": { "php": ">=5.6", - "symfony/filesystem": "^2.3 || ^3.0 || ^4.0 || ^5.0" + "symfony/filesystem": "^2.3 || ^3.0 || ^4.0 || ^5.0 || ^6.0" }, "require-dev": { - "symfony/phpunit-bridge": "^5.0.4" + "symfony/phpunit-bridge": "^5.0.4 || ^6.0" }, "type": "library", "autoload": { @@ -3237,9 +3237,9 @@ "description": "Symfony filesystem extension to handle temporary files", "support": { "issues": "https://github.com/romainneutron/Temporary-Filesystem/issues", - "source": "https://github.com/romainneutron/Temporary-Filesystem/tree/3.0" + "source": "https://github.com/romainneutron/Temporary-Filesystem/tree/3.0.1" }, - "time": "2020-07-27T14:00:33+00:00" + "time": "2021-12-14T07:30:33+00:00" }, { "name": "nyholm/psr7", @@ -4602,27 +4602,27 @@ }, { "name": "spatie/image-optimizer", - "version": "1.6.1", + "version": "1.6.2", "source": { "type": "git", "url": "https://github.com/spatie/image-optimizer.git", - "reference": "8bad7f04fd7d31d021b4752ee89f8a450dad8017" + "reference": "6db75529cbf8fa84117046a9d513f277aead90a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/image-optimizer/zipball/8bad7f04fd7d31d021b4752ee89f8a450dad8017", - "reference": "8bad7f04fd7d31d021b4752ee89f8a450dad8017", + "url": "https://api.github.com/repos/spatie/image-optimizer/zipball/6db75529cbf8fa84117046a9d513f277aead90a0", + "reference": "6db75529cbf8fa84117046a9d513f277aead90a0", "shasum": "" }, "require": { "ext-fileinfo": "*", "php": "^7.3|^8.0", "psr/log": "^1.0 | ^2.0 | ^3.0", - "symfony/process": "^4.2|^5.0" + "symfony/process": "^4.2|^5.0|^6.0" }, "require-dev": { "phpunit/phpunit": "^8.5.21|^9.4.4", - "symfony/var-dumper": "^4.2|^5.0" + "symfony/var-dumper": "^4.2|^5.0|^6.0" }, "type": "library", "autoload": { @@ -4650,9 +4650,9 @@ ], "support": { "issues": "https://github.com/spatie/image-optimizer/issues", - "source": "https://github.com/spatie/image-optimizer/tree/1.6.1" + "source": "https://github.com/spatie/image-optimizer/tree/1.6.2" }, - "time": "2021-11-17T10:36:45+00:00" + "time": "2021-12-21T10:08:05+00:00" }, { "name": "spatie/laravel-feed", @@ -4817,16 +4817,16 @@ }, { "name": "spatie/laravel-package-tools", - "version": "1.9.2", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-package-tools.git", - "reference": "f710fe196c126fb9e0aee67eb5af49ad8f13f528" + "reference": "97c24d0bc58e04d55e4a6a7b6d6102cb45b75789" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f710fe196c126fb9e0aee67eb5af49ad8f13f528", - "reference": "f710fe196c126fb9e0aee67eb5af49ad8f13f528", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/97c24d0bc58e04d55e4a6a7b6d6102cb45b75789", + "reference": "97c24d0bc58e04d55e4a6a7b6d6102cb45b75789", "shasum": "" }, "require": { @@ -4835,8 +4835,8 @@ }, "require-dev": { "mockery/mockery": "^1.4", - "orchestra/testbench": "^5.0|^6.0", - "phpunit/phpunit": "^9.3", + "orchestra/testbench": "^5.0|^6.23", + "phpunit/phpunit": "^9.4", "spatie/test-time": "^1.2" }, "type": "library", @@ -4865,7 +4865,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-package-tools/issues", - "source": "https://github.com/spatie/laravel-package-tools/tree/1.9.2" + "source": "https://github.com/spatie/laravel-package-tools/tree/1.10.0" }, "funding": [ { @@ -4873,7 +4873,7 @@ "type": "github" } ], - "time": "2021-09-21T13:06:51+00:00" + "time": "2021-12-18T20:33:51+00:00" }, { "name": "spomky-labs/base64url", @@ -4942,28 +4942,37 @@ }, { "name": "spomky-labs/cbor-php", - "version": "v2.0.1", + "version": "v2.1.0", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/cbor-php.git", - "reference": "9776578000be884cd7864eeb7c37a4ac92d8c995" + "reference": "28e2712cfc0b48fae661a48ffc6896d7abe83684" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/9776578000be884cd7864eeb7c37a4ac92d8c995", - "reference": "9776578000be884cd7864eeb7c37a4ac92d8c995", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/28e2712cfc0b48fae661a48ffc6896d7abe83684", + "reference": "28e2712cfc0b48fae661a48ffc6896d7abe83684", "shasum": "" }, "require": { "brick/math": "^0.8.15|^0.9.0", + "ext-mbstring": "*", "php": ">=7.3" }, "require-dev": { - "phpstan/phpstan": "^0.12", - "phpstan/phpstan-beberlei-assert": "^0.12", - "phpstan/phpstan-deprecation-rules": "^0.12", - "phpstan/phpstan-phpunit": "^0.12", - "phpstan/phpstan-strict-rules": "^0.12" + "ekino/phpstan-banned-code": "^1.0", + "ext-json": "*", + "infection/infection": "^0.18|^0.25", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-beberlei-assert": "^1.0", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.12", + "roave/security-advisories": "dev-latest", + "symplify/easy-coding-standard": "^10.0" }, "suggest": { "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", @@ -4997,15 +5006,19 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/cbor-php/issues", - "source": "https://github.com/Spomky-Labs/cbor-php/tree/v2.0.1" + "source": "https://github.com/Spomky-Labs/cbor-php/tree/v2.1.0" }, "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, { "url": "https://www.patreon.com/FlorentMorselli", "type": "patreon" } ], - "time": "2020-08-31T20:08:03+00:00" + "time": "2021-12-13T12:46:26+00:00" }, { "name": "swiftmailer/swiftmailer", @@ -5085,16 +5098,16 @@ }, { "name": "symfony/cache", - "version": "v5.4.0", + "version": "v5.4.2", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "d97d6d7f46cb69968f094e329abd987d5ee17c79" + "reference": "8aad4b69a10c5c51ab54672e78995860f5e447ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/d97d6d7f46cb69968f094e329abd987d5ee17c79", - "reference": "d97d6d7f46cb69968f094e329abd987d5ee17c79", + "url": "https://api.github.com/repos/symfony/cache/zipball/8aad4b69a10c5c51ab54672e78995860f5e447ec", + "reference": "8aad4b69a10c5c51ab54672e78995860f5e447ec", "shasum": "" }, "require": { @@ -5162,7 +5175,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v5.4.0" + "source": "https://github.com/symfony/cache/tree/v5.4.2" }, "funding": [ { @@ -5178,7 +5191,7 @@ "type": "tidelift" } ], - "time": "2021-11-23T18:51:45+00:00" + "time": "2021-12-28T17:15:56+00:00" }, { "name": "symfony/cache-contracts", @@ -5261,23 +5274,23 @@ }, { "name": "symfony/console", - "version": "v5.4.0", + "version": "v5.4.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "ec3661faca1d110d6c307e124b44f99ac54179e3" + "reference": "a2c6b7ced2eb7799a35375fb9022519282b5405e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ec3661faca1d110d6c307e124b44f99ac54179e3", - "reference": "ec3661faca1d110d6c307e124b44f99ac54179e3", + "url": "https://api.github.com/repos/symfony/console/zipball/a2c6b7ced2eb7799a35375fb9022519282b5405e", + "reference": "a2c6b7ced2eb7799a35375fb9022519282b5405e", "shasum": "" }, "require": { "php": ">=7.2.5", "symfony/deprecation-contracts": "^2.1|^3", "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php73": "^1.8", + "symfony/polyfill-php73": "^1.9", "symfony/polyfill-php80": "^1.16", "symfony/service-contracts": "^1.1|^2|^3", "symfony/string": "^5.1|^6.0" @@ -5340,7 +5353,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v5.4.0" + "source": "https://github.com/symfony/console/tree/v5.4.2" }, "funding": [ { @@ -5356,20 +5369,20 @@ "type": "tidelift" } ], - "time": "2021-11-29T15:30:56+00:00" + "time": "2021-12-20T16:11:12+00:00" }, { "name": "symfony/css-selector", - "version": "v6.0.0", + "version": "v6.0.2", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "3a61e2e4fbda3fb7fb5d83620c30fef726139e1c" + "reference": "380f86c1a9830226f42a08b5926f18aed4195f25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/3a61e2e4fbda3fb7fb5d83620c30fef726139e1c", - "reference": "3a61e2e4fbda3fb7fb5d83620c30fef726139e1c", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/380f86c1a9830226f42a08b5926f18aed4195f25", + "reference": "380f86c1a9830226f42a08b5926f18aed4195f25", "shasum": "" }, "require": { @@ -5405,7 +5418,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.0.0" + "source": "https://github.com/symfony/css-selector/tree/v6.0.2" }, "funding": [ { @@ -5421,29 +5434,29 @@ "type": "tidelift" } ], - "time": "2021-09-09T12:56:10+00:00" + "time": "2021-12-16T22:13:01+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v2.5.0", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "6f981ee24cf69ee7ce9736146d1c57c2780598a8" + "reference": "c726b64c1ccfe2896cb7df2e1331c357ad1c8ced" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/6f981ee24cf69ee7ce9736146d1c57c2780598a8", - "reference": "6f981ee24cf69ee7ce9736146d1c57c2780598a8", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/c726b64c1ccfe2896cb7df2e1331c357ad1c8ced", + "reference": "c726b64c1ccfe2896cb7df2e1331c357ad1c8ced", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.0.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.5-dev" + "dev-main": "3.0-dev" }, "thanks": { "name": "symfony/contracts", @@ -5472,7 +5485,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.0.0" }, "funding": [ { @@ -5488,20 +5501,20 @@ "type": "tidelift" } ], - "time": "2021-07-12T14:48:14+00:00" + "time": "2021-11-01T23:48:49+00:00" }, { "name": "symfony/error-handler", - "version": "v5.4.0", + "version": "v5.4.2", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8433fa3145ac78df88b87a4a539118e950828126" + "reference": "e0c0dd0f9d4120a20158fc9aec2367d07d38bc56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8433fa3145ac78df88b87a4a539118e950828126", - "reference": "8433fa3145ac78df88b87a4a539118e950828126", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/e0c0dd0f9d4120a20158fc9aec2367d07d38bc56", + "reference": "e0c0dd0f9d4120a20158fc9aec2367d07d38bc56", "shasum": "" }, "require": { @@ -5543,7 +5556,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v5.4.0" + "source": "https://github.com/symfony/error-handler/tree/v5.4.2" }, "funding": [ { @@ -5559,44 +5572,42 @@ "type": "tidelift" } ], - "time": "2021-11-29T15:30:56+00:00" + "time": "2021-12-19T20:02:00+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v5.4.0", + "version": "v6.0.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "27d39ae126352b9fa3be5e196ccf4617897be3eb" + "reference": "7093f25359e2750bfe86842c80c4e4a6a852d05c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/27d39ae126352b9fa3be5e196ccf4617897be3eb", - "reference": "27d39ae126352b9fa3be5e196ccf4617897be3eb", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7093f25359e2750bfe86842c80c4e4a6a852d05c", + "reference": "7093f25359e2750bfe86842c80c4e4a6a852d05c", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/event-dispatcher-contracts": "^2|^3", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.0.2", + "symfony/event-dispatcher-contracts": "^2|^3" }, "conflict": { - "symfony/dependency-injection": "<4.4" + "symfony/dependency-injection": "<5.4" }, "provide": { "psr/event-dispatcher-implementation": "1.0", - "symfony/event-dispatcher-implementation": "2.0" + "symfony/event-dispatcher-implementation": "2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^4.4|^5.0|^6.0", - "symfony/dependency-injection": "^4.4|^5.0|^6.0", - "symfony/error-handler": "^4.4|^5.0|^6.0", - "symfony/expression-language": "^4.4|^5.0|^6.0", - "symfony/http-foundation": "^4.4|^5.0|^6.0", + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/error-handler": "^5.4|^6.0", + "symfony/expression-language": "^5.4|^6.0", + "symfony/http-foundation": "^5.4|^6.0", "symfony/service-contracts": "^1.1|^2|^3", - "symfony/stopwatch": "^4.4|^5.0|^6.0" + "symfony/stopwatch": "^5.4|^6.0" }, "suggest": { "symfony/dependency-injection": "", @@ -5628,7 +5639,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v5.4.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.0.2" }, "funding": [ { @@ -5644,7 +5655,7 @@ "type": "tidelift" } ], - "time": "2021-11-23T10:19:22+00:00" + "time": "2021-12-21T10:43:13+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -5727,23 +5738,22 @@ }, { "name": "symfony/filesystem", - "version": "v5.4.0", + "version": "v6.0.0", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "731f917dc31edcffec2c6a777f3698c33bea8f01" + "reference": "52b3c9cce673b014915445a432339f282e002ce6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/731f917dc31edcffec2c6a777f3698c33bea8f01", - "reference": "731f917dc31edcffec2c6a777f3698c33bea8f01", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/52b3c9cce673b014915445a432339f282e002ce6", + "reference": "52b3c9cce673b014915445a432339f282e002ce6", "shasum": "" }, "require": { - "php": ">=7.2.5", + "php": ">=8.0.2", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8", - "symfony/polyfill-php80": "^1.16" + "symfony/polyfill-mbstring": "~1.8" }, "type": "library", "autoload": { @@ -5771,7 +5781,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v5.4.0" + "source": "https://github.com/symfony/filesystem/tree/v6.0.0" }, "funding": [ { @@ -5787,20 +5797,20 @@ "type": "tidelift" } ], - "time": "2021-10-28T13:39:27+00:00" + "time": "2021-10-29T07:35:21+00:00" }, { "name": "symfony/finder", - "version": "v5.4.0", + "version": "v5.4.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "d2f29dac98e96a98be467627bd49c2efb1bc2590" + "reference": "e77046c252be48c48a40816187ed527703c8f76c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/d2f29dac98e96a98be467627bd49c2efb1bc2590", - "reference": "d2f29dac98e96a98be467627bd49c2efb1bc2590", + "url": "https://api.github.com/repos/symfony/finder/zipball/e77046c252be48c48a40816187ed527703c8f76c", + "reference": "e77046c252be48c48a40816187ed527703c8f76c", "shasum": "" }, "require": { @@ -5834,7 +5844,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v5.4.0" + "source": "https://github.com/symfony/finder/tree/v5.4.2" }, "funding": [ { @@ -5850,20 +5860,20 @@ "type": "tidelift" } ], - "time": "2021-11-28T15:25:38+00:00" + "time": "2021-12-15T11:06:13+00:00" }, { "name": "symfony/http-foundation", - "version": "v5.4.0", + "version": "v5.4.2", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "5ef86ac7927d2de08dc1e26eb91325f9ccbe6309" + "reference": "ce952af52877eaf3eab5d0c08cc0ea865ed37313" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/5ef86ac7927d2de08dc1e26eb91325f9ccbe6309", - "reference": "5ef86ac7927d2de08dc1e26eb91325f9ccbe6309", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ce952af52877eaf3eab5d0c08cc0ea865ed37313", + "reference": "ce952af52877eaf3eab5d0c08cc0ea865ed37313", "shasum": "" }, "require": { @@ -5907,7 +5917,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v5.4.0" + "source": "https://github.com/symfony/http-foundation/tree/v5.4.2" }, "funding": [ { @@ -5923,20 +5933,20 @@ "type": "tidelift" } ], - "time": "2021-11-28T15:25:38+00:00" + "time": "2021-12-28T17:15:56+00:00" }, { "name": "symfony/http-kernel", - "version": "v5.4.0", + "version": "v5.4.2", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "e012f16688bcb151e965473a70d8ebaa8b1d15ea" + "reference": "35b7e9868953e0d1df84320bb063543369e43ef5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e012f16688bcb151e965473a70d8ebaa8b1d15ea", - "reference": "e012f16688bcb151e965473a70d8ebaa8b1d15ea", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/35b7e9868953e0d1df84320bb063543369e43ef5", + "reference": "35b7e9868953e0d1df84320bb063543369e43ef5", "shasum": "" }, "require": { @@ -6019,7 +6029,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v5.4.0" + "source": "https://github.com/symfony/http-kernel/tree/v5.4.2" }, "funding": [ { @@ -6035,20 +6045,20 @@ "type": "tidelift" } ], - "time": "2021-11-29T16:56:53+00:00" + "time": "2021-12-29T13:20:26+00:00" }, { "name": "symfony/mime", - "version": "v5.4.0", + "version": "v5.4.2", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "d4365000217b67c01acff407573906ff91bcfb34" + "reference": "1bfd938cf9562822c05c4d00e8f92134d3c8e42d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/d4365000217b67c01acff407573906ff91bcfb34", - "reference": "d4365000217b67c01acff407573906ff91bcfb34", + "url": "https://api.github.com/repos/symfony/mime/zipball/1bfd938cf9562822c05c4d00e8f92134d3c8e42d", + "reference": "1bfd938cf9562822c05c4d00e8f92134d3c8e42d", "shasum": "" }, "require": { @@ -6102,7 +6112,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v5.4.0" + "source": "https://github.com/symfony/mime/tree/v5.4.2" }, "funding": [ { @@ -6118,25 +6128,28 @@ "type": "tidelift" } ], - "time": "2021-11-23T10:19:22+00:00" + "time": "2021-12-28T17:15:56+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.23.0", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce" + "reference": "30885182c981ab175d4d034db0f6f469898070ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/46cd95797e9df938fdd2b03693b5fca5e64b01ce", - "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/30885182c981ab175d4d034db0f6f469898070ab", + "reference": "30885182c981ab175d4d034db0f6f469898070ab", "shasum": "" }, "require": { "php": ">=7.1" }, + "provide": { + "ext-ctype": "*" + }, "suggest": { "ext-ctype": "For best performance" }, @@ -6181,7 +6194,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.24.0" }, "funding": [ { @@ -6197,25 +6210,28 @@ "type": "tidelift" } ], - "time": "2021-02-19T12:13:01+00:00" + "time": "2021-10-20T20:35:02+00:00" }, { "name": "symfony/polyfill-iconv", - "version": "v1.23.0", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-iconv.git", - "reference": "63b5bb7db83e5673936d6e3b8b3e022ff6474933" + "reference": "f1aed619e28cb077fc83fac8c4c0383578356e40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/63b5bb7db83e5673936d6e3b8b3e022ff6474933", - "reference": "63b5bb7db83e5673936d6e3b8b3e022ff6474933", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/f1aed619e28cb077fc83fac8c4c0383578356e40", + "reference": "f1aed619e28cb077fc83fac8c4c0383578356e40", "shasum": "" }, "require": { "php": ">=7.1" }, + "provide": { + "ext-iconv": "*" + }, "suggest": { "ext-iconv": "For best performance" }, @@ -6261,7 +6277,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-iconv/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-iconv/tree/v1.24.0" }, "funding": [ { @@ -6277,20 +6293,20 @@ "type": "tidelift" } ], - "time": "2021-05-27T09:27:20+00:00" + "time": "2022-01-04T09:04:05+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.23.1", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "16880ba9c5ebe3642d1995ab866db29270b36535" + "reference": "81b86b50cf841a64252b439e738e97f4a34e2783" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/16880ba9c5ebe3642d1995ab866db29270b36535", - "reference": "16880ba9c5ebe3642d1995ab866db29270b36535", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/81b86b50cf841a64252b439e738e97f4a34e2783", + "reference": "81b86b50cf841a64252b439e738e97f4a34e2783", "shasum": "" }, "require": { @@ -6342,7 +6358,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.23.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.24.0" }, "funding": [ { @@ -6358,20 +6374,20 @@ "type": "tidelift" } ], - "time": "2021-05-27T12:26:48+00:00" + "time": "2021-11-23T21:10:46+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.23.0", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "65bd267525e82759e7d8c4e8ceea44f398838e65" + "reference": "749045c69efb97c70d25d7463abba812e91f3a44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/65bd267525e82759e7d8c4e8ceea44f398838e65", - "reference": "65bd267525e82759e7d8c4e8ceea44f398838e65", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/749045c69efb97c70d25d7463abba812e91f3a44", + "reference": "749045c69efb97c70d25d7463abba812e91f3a44", "shasum": "" }, "require": { @@ -6429,7 +6445,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.24.0" }, "funding": [ { @@ -6445,11 +6461,11 @@ "type": "tidelift" } ], - "time": "2021-05-27T09:27:20+00:00" + "time": "2021-09-14T14:02:44+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.23.0", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -6513,7 +6529,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.24.0" }, "funding": [ { @@ -6533,21 +6549,24 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.23.1", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "9174a3d80210dca8daa7f31fec659150bbeabfc6" + "reference": "0abb51d2f102e00a4eefcf46ba7fec406d245825" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9174a3d80210dca8daa7f31fec659150bbeabfc6", - "reference": "9174a3d80210dca8daa7f31fec659150bbeabfc6", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/0abb51d2f102e00a4eefcf46ba7fec406d245825", + "reference": "0abb51d2f102e00a4eefcf46ba7fec406d245825", "shasum": "" }, "require": { "php": ">=7.1" }, + "provide": { + "ext-mbstring": "*" + }, "suggest": { "ext-mbstring": "For best performance" }, @@ -6593,7 +6612,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.23.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.24.0" }, "funding": [ { @@ -6609,11 +6628,11 @@ "type": "tidelift" } ], - "time": "2021-05-27T12:26:48+00:00" + "time": "2021-11-30T18:21:41+00:00" }, { "name": "symfony/polyfill-php72", - "version": "v1.23.0", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php72.git", @@ -6669,7 +6688,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php72/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-php72/tree/v1.24.0" }, "funding": [ { @@ -6689,16 +6708,16 @@ }, { "name": "symfony/polyfill-php73", - "version": "v1.23.0", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010" + "reference": "cc5db0e22b3cb4111010e48785a97f670b350ca5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fba8933c384d6476ab14fb7b8526e5287ca7e010", - "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/cc5db0e22b3cb4111010e48785a97f670b350ca5", + "reference": "cc5db0e22b3cb4111010e48785a97f670b350ca5", "shasum": "" }, "require": { @@ -6748,7 +6767,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-php73/tree/v1.24.0" }, "funding": [ { @@ -6764,20 +6783,20 @@ "type": "tidelift" } ], - "time": "2021-02-19T12:13:01+00:00" + "time": "2021-06-05T21:20:04+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.23.1", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "1100343ed1a92e3a38f9ae122fc0eb21602547be" + "reference": "57b712b08eddb97c762a8caa32c84e037892d2e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/1100343ed1a92e3a38f9ae122fc0eb21602547be", - "reference": "1100343ed1a92e3a38f9ae122fc0eb21602547be", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/57b712b08eddb97c762a8caa32c84e037892d2e9", + "reference": "57b712b08eddb97c762a8caa32c84e037892d2e9", "shasum": "" }, "require": { @@ -6831,7 +6850,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.23.1" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.24.0" }, "funding": [ { @@ -6847,20 +6866,20 @@ "type": "tidelift" } ], - "time": "2021-07-28T13:41:28+00:00" + "time": "2021-09-13T13:58:33+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.23.0", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "e66119f3de95efc359483f810c4c3e6436279436" + "reference": "5de4ba2d41b15f9bd0e19b2ab9674135813ec98f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/e66119f3de95efc359483f810c4c3e6436279436", - "reference": "e66119f3de95efc359483f810c4c3e6436279436", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/5de4ba2d41b15f9bd0e19b2ab9674135813ec98f", + "reference": "5de4ba2d41b15f9bd0e19b2ab9674135813ec98f", "shasum": "" }, "require": { @@ -6910,7 +6929,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.24.0" }, "funding": [ { @@ -6926,20 +6945,20 @@ "type": "tidelift" } ], - "time": "2021-05-21T13:25:03+00:00" + "time": "2021-09-13T13:58:11+00:00" }, { "name": "symfony/process", - "version": "v5.4.0", + "version": "v5.4.2", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "5be20b3830f726e019162b26223110c8f47cf274" + "reference": "2b3ba8722c4aaf3e88011be5e7f48710088fb5e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/5be20b3830f726e019162b26223110c8f47cf274", - "reference": "5be20b3830f726e019162b26223110c8f47cf274", + "url": "https://api.github.com/repos/symfony/process/zipball/2b3ba8722c4aaf3e88011be5e7f48710088fb5e4", + "reference": "2b3ba8722c4aaf3e88011be5e7f48710088fb5e4", "shasum": "" }, "require": { @@ -6972,7 +6991,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v5.4.0" + "source": "https://github.com/symfony/process/tree/v5.4.2" }, "funding": [ { @@ -6988,7 +7007,7 @@ "type": "tidelift" } ], - "time": "2021-11-28T15:25:38+00:00" + "time": "2021-12-27T21:01:00+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -7170,22 +7189,21 @@ }, { "name": "symfony/service-contracts", - "version": "v2.5.0", + "version": "v2.4.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc" + "reference": "d664541b99d6fb0247ec5ff32e87238582236204" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc", - "reference": "1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d664541b99d6fb0247ec5ff32e87238582236204", + "reference": "d664541b99d6fb0247ec5ff32e87238582236204", "shasum": "" }, "require": { "php": ">=7.2.5", - "psr/container": "^1.1", - "symfony/deprecation-contracts": "^2.1" + "psr/container": "^1.1" }, "conflict": { "ext-psr": "<1.1|>=2" @@ -7196,7 +7214,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.5-dev" + "dev-main": "2.4-dev" }, "thanks": { "name": "symfony/contracts", @@ -7233,7 +7251,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v2.5.0" + "source": "https://github.com/symfony/service-contracts/tree/v2.4.1" }, "funding": [ { @@ -7249,20 +7267,20 @@ "type": "tidelift" } ], - "time": "2021-11-04T16:48:04+00:00" + "time": "2021-11-04T16:37:19+00:00" }, { "name": "symfony/string", - "version": "v6.0.0", + "version": "v6.0.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ba727797426af0f587f4800566300bdc0cda0777" + "reference": "bae261d0c3ac38a1f802b4dfed42094296100631" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ba727797426af0f587f4800566300bdc0cda0777", - "reference": "ba727797426af0f587f4800566300bdc0cda0777", + "url": "https://api.github.com/repos/symfony/string/zipball/bae261d0c3ac38a1f802b4dfed42094296100631", + "reference": "bae261d0c3ac38a1f802b4dfed42094296100631", "shasum": "" }, "require": { @@ -7318,7 +7336,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.0.0" + "source": "https://github.com/symfony/string/tree/v6.0.2" }, "funding": [ { @@ -7334,20 +7352,20 @@ "type": "tidelift" } ], - "time": "2021-10-29T07:35:21+00:00" + "time": "2021-12-16T22:13:01+00:00" }, { "name": "symfony/translation", - "version": "v6.0.0", + "version": "v6.0.2", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "5e3848083ef1abc4814be154095946b8193f41d6" + "reference": "a16c33f93e2fd62d259222aebf792158e9a28a77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/5e3848083ef1abc4814be154095946b8193f41d6", - "reference": "5e3848083ef1abc4814be154095946b8193f41d6", + "url": "https://api.github.com/repos/symfony/translation/zipball/a16c33f93e2fd62d259222aebf792158e9a28a77", + "reference": "a16c33f93e2fd62d259222aebf792158e9a28a77", "shasum": "" }, "require": { @@ -7413,7 +7431,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.0.0" + "source": "https://github.com/symfony/translation/tree/v6.0.2" }, "funding": [ { @@ -7429,7 +7447,7 @@ "type": "tidelift" } ], - "time": "2021-11-29T15:32:57+00:00" + "time": "2021-12-25T20:10:03+00:00" }, { "name": "symfony/translation-contracts", @@ -7511,16 +7529,16 @@ }, { "name": "symfony/var-dumper", - "version": "v5.4.0", + "version": "v5.4.2", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "89ab66eaef230c9cd1992de2e9a1b26652b127b9" + "reference": "1b56c32c3679002b3a42384a580e16e2600f41c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/89ab66eaef230c9cd1992de2e9a1b26652b127b9", - "reference": "89ab66eaef230c9cd1992de2e9a1b26652b127b9", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/1b56c32c3679002b3a42384a580e16e2600f41c1", + "reference": "1b56c32c3679002b3a42384a580e16e2600f41c1", "shasum": "" }, "require": { @@ -7580,7 +7598,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v5.4.0" + "source": "https://github.com/symfony/var-dumper/tree/v5.4.2" }, "funding": [ { @@ -7596,7 +7614,7 @@ "type": "tidelift" } ], - "time": "2021-11-29T15:30:56+00:00" + "time": "2021-12-29T10:10:35+00:00" }, { "name": "symfony/var-exporter", @@ -7864,16 +7882,16 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.4.0", + "version": "v5.4.1", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "d4394d044ed69a8f244f3445bcedf8a0d7fe2403" + "reference": "264dce589e7ce37a7ba99cb901eed8249fbec92f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/d4394d044ed69a8f244f3445bcedf8a0d7fe2403", - "reference": "d4394d044ed69a8f244f3445bcedf8a0d7fe2403", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/264dce589e7ce37a7ba99cb901eed8249fbec92f", + "reference": "264dce589e7ce37a7ba99cb901eed8249fbec92f", "shasum": "" }, "require": { @@ -7911,11 +7929,13 @@ "authors": [ { "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk" + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" }, { "name": "Vance Lucas", - "email": "vance@vancelucas.com" + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" } ], "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", @@ -7926,7 +7946,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.4.0" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.4.1" }, "funding": [ { @@ -7938,7 +7958,7 @@ "type": "tidelift" } ], - "time": "2021-11-10T01:08:39+00:00" + "time": "2021-12-12T23:22:04+00:00" }, { "name": "voku/portable-ascii", @@ -8016,16 +8036,16 @@ }, { "name": "web-auth/cose-lib", - "version": "v3.3.10", + "version": "v3.3.11", "source": { "type": "git", "url": "https://github.com/web-auth/cose-lib.git", - "reference": "83f729f22e667637a149e6ddec8f36e7b4c34127" + "reference": "efa6ec2ba4e840bc1316a493973c9916028afeeb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/83f729f22e667637a149e6ddec8f36e7b4c34127", - "reference": "83f729f22e667637a149e6ddec8f36e7b4c34127", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/efa6ec2ba4e840bc1316a493973c9916028afeeb", + "reference": "efa6ec2ba4e840bc1316a493973c9916028afeeb", "shasum": "" }, "require": { @@ -8063,7 +8083,7 @@ "RFC8152" ], "support": { - "source": "https://github.com/web-auth/cose-lib/tree/v3.3.10" + "source": "https://github.com/web-auth/cose-lib/tree/v3.3.11" }, "funding": [ { @@ -8075,11 +8095,11 @@ "type": "patreon" } ], - "time": "2021-11-21T11:14:31+00:00" + "time": "2021-12-04T12:13:35+00:00" }, { "name": "web-auth/metadata-service", - "version": "v3.3.10", + "version": "v3.3.11", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-metadata-service.git", @@ -8132,7 +8152,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-metadata-service/tree/v3.3.10" + "source": "https://github.com/web-auth/webauthn-metadata-service/tree/v3.3.11" }, "funding": [ { @@ -8148,7 +8168,7 @@ }, { "name": "web-auth/webauthn-lib", - "version": "v3.3.10", + "version": "v3.3.11", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", @@ -8214,7 +8234,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/v3.3.10" + "source": "https://github.com/web-auth/webauthn-lib/tree/v3.3.11" }, "funding": [ { @@ -8416,16 +8436,16 @@ "packages-dev": [ { "name": "barryvdh/laravel-debugbar", - "version": "v3.6.4", + "version": "v3.6.5", "source": { "type": "git", "url": "https://github.com/barryvdh/laravel-debugbar.git", - "reference": "3c2d678269ba60e178bcd93e36f6a91c36b727f1" + "reference": "ccf109f8755dcc7e58779d1aeb1051b04e0b4bef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/3c2d678269ba60e178bcd93e36f6a91c36b727f1", - "reference": "3c2d678269ba60e178bcd93e36f6a91c36b727f1", + "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/ccf109f8755dcc7e58779d1aeb1051b04e0b4bef", + "reference": "ccf109f8755dcc7e58779d1aeb1051b04e0b4bef", "shasum": "" }, "require": { @@ -8453,7 +8473,7 @@ "Barryvdh\\Debugbar\\ServiceProvider" ], "aliases": { - "Debugbar": "Barryvdh\\Debugbar\\Facade" + "Debugbar": "Barryvdh\\Debugbar\\Facades\\Debugbar" } } }, @@ -8485,7 +8505,7 @@ ], "support": { "issues": "https://github.com/barryvdh/laravel-debugbar/issues", - "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.6.4" + "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.6.5" }, "funding": [ { @@ -8497,7 +8517,7 @@ "type": "github" } ], - "time": "2021-10-21T10:57:31+00:00" + "time": "2021-12-14T14:45:18+00:00" }, { "name": "barryvdh/laravel-ide-helper", @@ -8719,21 +8739,22 @@ }, { "name": "composer/composer", - "version": "2.1.14", + "version": "2.2.3", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "cd28fc05b0c9d3beaf58b57018725c4dc15a6446" + "reference": "3c92ba5cdc7d48b7db2dcd197e6fa0e8fa6d9f4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/cd28fc05b0c9d3beaf58b57018725c4dc15a6446", - "reference": "cd28fc05b0c9d3beaf58b57018725c4dc15a6446", + "url": "https://api.github.com/repos/composer/composer/zipball/3c92ba5cdc7d48b7db2dcd197e6fa0e8fa6d9f4a", + "reference": "3c92ba5cdc7d48b7db2dcd197e6fa0e8fa6d9f4a", "shasum": "" }, "require": { "composer/ca-bundle": "^1.0", "composer/metadata-minifier": "^1.0", + "composer/pcre": "^1.0", "composer/semver": "^3.0", "composer/spdx-licenses": "^1.2", "composer/xdebug-handler": "^2.0", @@ -8763,7 +8784,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.1-dev" + "dev-main": "2.2-dev" } }, "autoload": { @@ -8797,7 +8818,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", - "source": "https://github.com/composer/composer/tree/2.1.14" + "source": "https://github.com/composer/composer/tree/2.2.3" }, "funding": [ { @@ -8813,7 +8834,7 @@ "type": "tidelift" } ], - "time": "2021-11-30T09:51:43+00:00" + "time": "2021-12-31T11:18:53+00:00" }, { "name": "composer/metadata-minifier", @@ -8884,18 +8905,89 @@ ], "time": "2021-04-07T13:37:33+00:00" }, + { + "name": "composer/pcre", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "3d322d715c43a1ac36c7fe215fa59336265500f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/3d322d715c43a1ac36c7fe215fa59336265500f2", + "reference": "3d322d715c43a1ac36c7fe215fa59336265500f2", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1", + "phpstan/phpstan-strict-rules": "^1.1", + "symfony/phpunit-bridge": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/1.0.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2021-12-06T15:17:27+00:00" + }, { "name": "composer/semver", - "version": "3.2.6", + "version": "3.2.7", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "83e511e247de329283478496f7a1e114c9517506" + "reference": "deac27056b57e46faf136fae7b449eeaa71661ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/83e511e247de329283478496f7a1e114c9517506", - "reference": "83e511e247de329283478496f7a1e114c9517506", + "url": "https://api.github.com/repos/composer/semver/zipball/deac27056b57e46faf136fae7b449eeaa71661ee", + "reference": "deac27056b57e46faf136fae7b449eeaa71661ee", "shasum": "" }, "require": { @@ -8947,7 +9039,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.2.6" + "source": "https://github.com/composer/semver/tree/3.2.7" }, "funding": [ { @@ -8963,7 +9055,7 @@ "type": "tidelift" } ], - "time": "2021-10-25T11:34:17+00:00" + "time": "2022-01-04T09:57:54+00:00" }, { "name": "composer/spdx-licenses", @@ -9047,25 +9139,27 @@ }, { "name": "composer/xdebug-handler", - "version": "2.0.2", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/composer/xdebug-handler.git", - "reference": "84674dd3a7575ba617f5a76d7e9e29a7d3891339" + "reference": "0c1a3925ec58a4ec98e992b9c7d171e9e184be0a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/84674dd3a7575ba617f5a76d7e9e29a7d3891339", - "reference": "84674dd3a7575ba617f5a76d7e9e29a7d3891339", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/0c1a3925ec58a4ec98e992b9c7d171e9e184be0a", + "reference": "0c1a3925ec58a4ec98e992b9c7d171e9e184be0a", "shasum": "" }, "require": { + "composer/pcre": "^1", "php": "^5.3.2 || ^7.0 || ^8.0", "psr/log": "^1 || ^2 || ^3" }, "require-dev": { - "phpstan/phpstan": "^0.12.55", - "symfony/phpunit-bridge": "^4.2 || ^5" + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "symfony/phpunit-bridge": "^4.2 || ^5.0 || ^6.0" }, "type": "library", "autoload": { @@ -9091,7 +9185,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/2.0.2" + "source": "https://github.com/composer/xdebug-handler/tree/2.0.4" }, "funding": [ { @@ -9107,7 +9201,7 @@ "type": "tidelift" } ], - "time": "2021-07-31T17:03:58+00:00" + "time": "2022-01-04T17:06:45+00:00" }, { "name": "doctrine/annotations", @@ -9376,16 +9470,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.3.2", + "version": "v3.4.0", "source": { "type": "git", "url": "https://github.com/FriendsOfPHP/PHP-CS-Fixer.git", - "reference": "06bdbdfcd619183dd7a1a6948360f8af73b9ecec" + "reference": "47177af1cfb9dab5d1cc4daf91b7179c2efe7fad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/06bdbdfcd619183dd7a1a6948360f8af73b9ecec", - "reference": "06bdbdfcd619183dd7a1a6948360f8af73b9ecec", + "url": "https://api.github.com/repos/FriendsOfPHP/PHP-CS-Fixer/zipball/47177af1cfb9dab5d1cc4daf91b7179c2efe7fad", + "reference": "47177af1cfb9dab5d1cc4daf91b7179c2efe7fad", "shasum": "" }, "require": { @@ -9396,33 +9490,32 @@ "ext-tokenizer": "*", "php": "^7.2.5 || ^8.0", "php-cs-fixer/diff": "^2.0", - "symfony/console": "^5.1.3", - "symfony/event-dispatcher": "^5.0", - "symfony/filesystem": "^5.0", - "symfony/finder": "^5.0", - "symfony/options-resolver": "^5.0", + "symfony/console": "^4.4.20 || ^5.1.3 || ^6.0", + "symfony/event-dispatcher": "^4.4.20 || ^5.0 || ^6.0", + "symfony/filesystem": "^4.4.20 || ^5.0 || ^6.0", + "symfony/finder": "^4.4.20 || ^5.0 || ^6.0", + "symfony/options-resolver": "^4.4.20 || ^5.0 || ^6.0", "symfony/polyfill-mbstring": "^1.23", - "symfony/polyfill-php72": "^1.23", "symfony/polyfill-php80": "^1.23", "symfony/polyfill-php81": "^1.23", - "symfony/process": "^5.0", - "symfony/stopwatch": "^5.0" + "symfony/process": "^4.4.20 || ^5.0 || ^6.0", + "symfony/stopwatch": "^4.4.20 || ^5.0 || ^6.0" }, "require-dev": { "justinrainbow/json-schema": "^5.2", "keradus/cli-executor": "^1.5", "mikey179/vfsstream": "^1.6.8", - "php-coveralls/php-coveralls": "^2.4.3", + "php-coveralls/php-coveralls": "^2.5.2", "php-cs-fixer/accessible-object": "^1.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.2", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.2.1", - "phpspec/prophecy": "^1.10.3", + "phpspec/prophecy": "^1.15", "phpspec/prophecy-phpunit": "^1.1 || ^2.0", - "phpunit/phpunit": "^7.5.20 || ^8.5.14 || ^9.5", + "phpunit/phpunit": "^8.5.21 || ^9.5", "phpunitgoodpractices/polyfill": "^1.5", "phpunitgoodpractices/traits": "^1.9.1", - "symfony/phpunit-bridge": "^5.2.4", - "symfony/yaml": "^5.0" + "symfony/phpunit-bridge": "^5.2.4 || ^6.0", + "symfony/yaml": "^4.4.20 || ^5.0 || ^6.0" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -9454,7 +9547,7 @@ "description": "A tool to automatically fix PHP code style", "support": { "issues": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/issues", - "source": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/tree/v3.3.2" + "source": "https://github.com/FriendsOfPHP/PHP-CS-Fixer/tree/v3.4.0" }, "funding": [ { @@ -9462,20 +9555,20 @@ "type": "github" } ], - "time": "2021-11-15T18:06:47+00:00" + "time": "2021-12-11T16:25:08+00:00" }, { "name": "itsgoingd/clockwork", - "version": "v5.1.1", + "version": "v5.1.3", "source": { "type": "git", "url": "https://github.com/itsgoingd/clockwork.git", - "reference": "2daf30fa6dfc5a1ccfdb2142df59243a72c473d8" + "reference": "e03f8a7f4bcd99ec67e56428e4fc7424de4cefa8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/2daf30fa6dfc5a1ccfdb2142df59243a72c473d8", - "reference": "2daf30fa6dfc5a1ccfdb2142df59243a72c473d8", + "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/e03f8a7f4bcd99ec67e56428e4fc7424de4cefa8", + "reference": "e03f8a7f4bcd99ec67e56428e4fc7424de4cefa8", "shasum": "" }, "require": { @@ -9523,7 +9616,7 @@ ], "support": { "issues": "https://github.com/itsgoingd/clockwork/issues", - "source": "https://github.com/itsgoingd/clockwork/tree/v5.1.1" + "source": "https://github.com/itsgoingd/clockwork/tree/v5.1.3" }, "funding": [ { @@ -9531,7 +9624,7 @@ "type": "github" } ], - "time": "2021-11-01T17:38:35+00:00" + "time": "2021-12-24T12:24:20+00:00" }, { "name": "justinrainbow/json-schema", @@ -10198,16 +10291,16 @@ }, { "name": "phpdocumentor/type-resolver", - "version": "1.5.1", + "version": "1.6.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "a12f7e301eb7258bb68acd89d4aefa05c2906cae" + "reference": "93ebd0014cab80c4ea9f5e297ea48672f1b87706" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/a12f7e301eb7258bb68acd89d4aefa05c2906cae", - "reference": "a12f7e301eb7258bb68acd89d4aefa05c2906cae", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/93ebd0014cab80c4ea9f5e297ea48672f1b87706", + "reference": "93ebd0014cab80c4ea9f5e297ea48672f1b87706", "shasum": "" }, "require": { @@ -10242,22 +10335,22 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.5.1" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.6.0" }, - "time": "2021-10-02T14:08:47+00:00" + "time": "2022-01-04T19:58:01+00:00" }, { "name": "phpspec/prophecy", - "version": "1.14.0", + "version": "v1.15.0", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "d86dfc2e2a3cd366cee475e52c6bb3bbc371aa0e" + "reference": "bbcd7380b0ebf3961ee21409db7b38bc31d69a13" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/d86dfc2e2a3cd366cee475e52c6bb3bbc371aa0e", - "reference": "d86dfc2e2a3cd366cee475e52c6bb3bbc371aa0e", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/bbcd7380b0ebf3961ee21409db7b38bc31d69a13", + "reference": "bbcd7380b0ebf3961ee21409db7b38bc31d69a13", "shasum": "" }, "require": { @@ -10309,22 +10402,22 @@ ], "support": { "issues": "https://github.com/phpspec/prophecy/issues", - "source": "https://github.com/phpspec/prophecy/tree/1.14.0" + "source": "https://github.com/phpspec/prophecy/tree/v1.15.0" }, - "time": "2021-09-10T09:02:12+00:00" + "time": "2021-12-08T12:19:24+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "9.2.9", + "version": "9.2.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "f301eb1453c9e7a1bc912ee8b0ea9db22c60223b" + "reference": "d5850aaf931743067f4bfc1ae4cbd06468400687" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f301eb1453c9e7a1bc912ee8b0ea9db22c60223b", - "reference": "f301eb1453c9e7a1bc912ee8b0ea9db22c60223b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/d5850aaf931743067f4bfc1ae4cbd06468400687", + "reference": "d5850aaf931743067f4bfc1ae4cbd06468400687", "shasum": "" }, "require": { @@ -10380,7 +10473,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.9" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.10" }, "funding": [ { @@ -10388,7 +10481,7 @@ "type": "github" } ], - "time": "2021-11-19T15:21:02+00:00" + "time": "2021-12-05T09:12:13+00:00" }, { "name": "phpunit/php-file-iterator", @@ -10633,16 +10726,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.5.10", + "version": "9.5.11", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "c814a05837f2edb0d1471d6e3f4ab3501ca3899a" + "reference": "2406855036db1102126125537adb1406f7242fdd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c814a05837f2edb0d1471d6e3f4ab3501ca3899a", - "reference": "c814a05837f2edb0d1471d6e3f4ab3501ca3899a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2406855036db1102126125537adb1406f7242fdd", + "reference": "2406855036db1102126125537adb1406f7242fdd", "shasum": "" }, "require": { @@ -10720,11 +10813,11 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.10" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.11" }, "funding": [ { - "url": "https://phpunit.de/donate.html", + "url": "https://phpunit.de/sponsors.html", "type": "custom" }, { @@ -10732,7 +10825,7 @@ "type": "github" } ], - "time": "2021-09-25T07:38:51+00:00" + "time": "2021-12-25T07:07:57+00:00" }, { "name": "react/promise", @@ -11813,16 +11906,16 @@ }, { "name": "seld/phar-utils", - "version": "1.1.2", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/Seldaek/phar-utils.git", - "reference": "749042a2315705d2dfbbc59234dd9ceb22bf3ff0" + "reference": "9f3452c93ff423469c0d56450431562ca423dcee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/749042a2315705d2dfbbc59234dd9ceb22bf3ff0", - "reference": "749042a2315705d2dfbbc59234dd9ceb22bf3ff0", + "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/9f3452c93ff423469c0d56450431562ca423dcee", + "reference": "9f3452c93ff423469c0d56450431562ca423dcee", "shasum": "" }, "require": { @@ -11855,22 +11948,22 @@ ], "support": { "issues": "https://github.com/Seldaek/phar-utils/issues", - "source": "https://github.com/Seldaek/phar-utils/tree/1.1.2" + "source": "https://github.com/Seldaek/phar-utils/tree/1.2.0" }, - "time": "2021-08-19T21:01:38+00:00" + "time": "2021-12-10T11:20:11+00:00" }, { "name": "symfony/debug", - "version": "v4.4.31", + "version": "v4.4.36", "source": { "type": "git", "url": "https://github.com/symfony/debug.git", - "reference": "43ede438d4cb52cd589ae5dc070e9323866ba8e0" + "reference": "346e1507eeb3f566dcc7a116fefaa407ee84691b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/43ede438d4cb52cd589ae5dc070e9323866ba8e0", - "reference": "43ede438d4cb52cd589ae5dc070e9323866ba8e0", + "url": "https://api.github.com/repos/symfony/debug/zipball/346e1507eeb3f566dcc7a116fefaa407ee84691b", + "reference": "346e1507eeb3f566dcc7a116fefaa407ee84691b", "shasum": "" }, "require": { @@ -11909,7 +12002,7 @@ "description": "Provides tools to ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/debug/tree/v4.4.31" + "source": "https://github.com/symfony/debug/tree/v4.4.36" }, "funding": [ { @@ -11925,27 +12018,25 @@ "type": "tidelift" } ], - "time": "2021-09-24T13:30:14+00:00" + "time": "2021-11-29T08:40:48+00:00" }, { "name": "symfony/options-resolver", - "version": "v5.4.0", + "version": "v6.0.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "b0fb78576487af19c500aaddb269fd36701d4847" + "reference": "be0facf48a42a232d6c0daadd76e4eb5657a4798" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b0fb78576487af19c500aaddb269fd36701d4847", - "reference": "b0fb78576487af19c500aaddb269fd36701d4847", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/be0facf48a42a232d6c0daadd76e4eb5657a4798", + "reference": "be0facf48a42a232d6c0daadd76e4eb5657a4798", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-php73": "~1.0", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.0.2", + "symfony/deprecation-contracts": "^2.1|^3" }, "type": "library", "autoload": { @@ -11978,7 +12069,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v5.4.0" + "source": "https://github.com/symfony/options-resolver/tree/v6.0.0" }, "funding": [ { @@ -11994,24 +12085,24 @@ "type": "tidelift" } ], - "time": "2021-11-23T10:19:22+00:00" + "time": "2021-11-23T19:05:29+00:00" }, { "name": "symfony/stopwatch", - "version": "v5.4.0", + "version": "v6.0.0", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "208ef96122bfed82a8f3a61458a07113a08bdcfe" + "reference": "0e0ed55d1ffdfadd03af180443fbdca9876483b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/208ef96122bfed82a8f3a61458a07113a08bdcfe", - "reference": "208ef96122bfed82a8f3a61458a07113a08bdcfe", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/0e0ed55d1ffdfadd03af180443fbdca9876483b3", + "reference": "0e0ed55d1ffdfadd03af180443fbdca9876483b3", "shasum": "" }, "require": { - "php": ">=7.2.5", + "php": ">=8.0.2", "symfony/service-contracts": "^1|^2|^3" }, "type": "library", @@ -12040,7 +12131,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v5.4.0" + "source": "https://github.com/symfony/stopwatch/tree/v6.0.0" }, "funding": [ { @@ -12056,20 +12147,20 @@ "type": "tidelift" } ], - "time": "2021-11-23T10:19:22+00:00" + "time": "2021-11-23T19:05:29+00:00" }, { "name": "symfony/yaml", - "version": "v5.4.0", + "version": "v5.4.2", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "034ccc0994f1ae3f7499fa5b1f2e75d5e7a94efc" + "reference": "b9eb163846a61bb32dfc147f7859e274fab38b58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/034ccc0994f1ae3f7499fa5b1f2e75d5e7a94efc", - "reference": "034ccc0994f1ae3f7499fa5b1f2e75d5e7a94efc", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b9eb163846a61bb32dfc147f7859e274fab38b58", + "reference": "b9eb163846a61bb32dfc147f7859e274fab38b58", "shasum": "" }, "require": { @@ -12115,7 +12206,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v5.4.0" + "source": "https://github.com/symfony/yaml/tree/v5.4.2" }, "funding": [ { @@ -12131,7 +12222,7 @@ "type": "tidelift" } ], - "time": "2021-11-28T15:25:38+00:00" + "time": "2021-12-16T21:58:21+00:00" }, { "name": "theseer/tokenizer", @@ -12193,7 +12284,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^7.4.0|^8.0", + "php": "^8.0", "ext-bcmath": "*", "ext-exif": "*", "ext-gd": "*", diff --git a/config/app.php b/config/app.php index f1305d32273..64fa1c624ff 100644 --- a/config/app.php +++ b/config/app.php @@ -106,6 +106,20 @@ 'fallback_locale' => 'en', + /* + |-------------------------------------------------------------------------- + | Log DB SQL statements + |-------------------------------------------------------------------------- + | + | If set to true, all SQL statements will be logged to a text file below + | storage. + | Only use it for debugging and development purposes as it slows down + | the performance of the application + | + */ + + 'db_log_sql' => env('DB_LOG_SQL', false), + /* |-------------------------------------------------------------------------- | Encryption Key diff --git a/database/migrations/2018_08_15_102039_move_albums.php b/database/migrations/2018_08_15_102039_move_albums.php index 86a8fd12b39..4575ff930d6 100644 --- a/database/migrations/2018_08_15_102039_move_albums.php +++ b/database/migrations/2018_08_15_102039_move_albums.php @@ -16,7 +16,7 @@ class MoveAlbums extends Migration */ public function up() { - if (count(Album::query()->withoutGlobalScopes()->get()) == 0) { + if (DB::table('albums')->count('id') == 0) { if (Schema::hasTable(env('DB_OLD_LYCHEE_PREFIX', '') . 'lychee_albums')) { $results = DB::table(env('DB_OLD_LYCHEE_PREFIX', '') . 'lychee_albums')->select('*')->orderBy('id', 'asc')->get(); $id = 0; diff --git a/database/migrations/2018_08_15_103716_move_photos.php b/database/migrations/2018_08_15_103716_move_photos.php index c5035e6cf2e..e08d924ce1a 100644 --- a/database/migrations/2018_08_15_103716_move_photos.php +++ b/database/migrations/2018_08_15_103716_move_photos.php @@ -1,8 +1,7 @@ select('*')->orderBy('id', 'asc')->orderBy('album', 'asc')->get(); - $id = 0; - foreach ($results as $result) { - $photo = new Photo(); - $id = Helpers::trancateIf32($result->id, $id); - $photo->id = $id; - if ($result->album == 0) { - $photo->album_id = null; - } else { - $photo->album_id = Helpers::trancateIf32($result->album, 0); - } - $photo->title = $result->title; - $photo->description = $result->description; - $photo->url = $result->url; - $photo->tags = $result->tags; - $photo->public = $result->public; - $photo->type = $result->type; - $photo->width = $result->width; - $photo->height = $result->height; - $photo->size = $result->size; - $photo->iso = $result->iso; - $photo->aperture = $result->aperture; - $photo->make = $result->make; - $photo->lens = $result->lens ?? ''; - $photo->model = $result->model; - $photo->shutter = $result->shutter; - $photo->focal = $result->focal; - $photo->takestamp = ($result->takestamp == 0 || $result->takestamp == null) ? null : date('Y-m-d H:i:s', $result->takestamp); - $photo->star = $result->star; - $photo->thumbUrl = $result->thumbUrl; - $thumbUrl2x = explode('.', $result->thumbUrl); - if (count($thumbUrl2x) < 2) { - $photo->thumb2x = 0; - } else { - $thumbUrl2x = $thumbUrl2x[0] . '@2x.' . $thumbUrl2x[1]; - if (!Storage::exists('thumb/' . $thumbUrl2x)) { - $photo->thumb2x = 0; - } else { - $photo->thumb2x = 1; - } - } - $photo->checksum = $result->checksum; - if (Storage::exists('medium/' . $photo->url)) { - list($width, $height) = getimagesize(Storage::path('medium/' . $photo->url)); - $photo->medium = $width . 'x' . $height; - } else { - $photo->medium = ''; - } - if (Storage::exists('small/' . $photo->url)) { - list($width, $height) = getimagesize(Storage::path('small/' . $photo->url)); - $result->small = $width . 'x' . $height; + // only do if photos is empty and + // if there is a table to import from + if ( + MovePhotos_Photo::count() == 0 && + Schema::hasTable(env('DB_OLD_LYCHEE_PREFIX', '') . 'lychee_photos') + ) { + $results = DB::table(env('DB_OLD_LYCHEE_PREFIX', '') . 'lychee_photos')->select('*')->orderBy('id', 'asc')->orderBy('album', 'asc')->get(); + $id = 0; + foreach ($results as $result) { + $photoAttributes = []; + $id = Helpers::trancateIf32($result->id, $id); + $photoAttributes['id'] = $id; + if ($result->album == 0) { + $photoAttributes['album_id'] = null; + } else { + $photoAttributes['album_id'] = Helpers::trancateIf32($result->album, 0); + } + $photoAttributes['title'] = $result->title; + $photoAttributes['description'] = $result->description; + $photoAttributes['url'] = $result->url; + $photoAttributes['tags'] = $result->tags; + $photoAttributes['public'] = $result->public; + $photoAttributes['type'] = $result->type; + $photoAttributes['width'] = $result->width; + $photoAttributes['height'] = $result->height; + $photoAttributes['size'] = $result->size; + $photoAttributes['iso'] = $result->iso; + $photoAttributes['aperture'] = $result->aperture; + $photoAttributes['make'] = $result->make; + $photoAttributes['lens'] = $result->lens ?? ''; + $photoAttributes['model'] = $result->model; + $photoAttributes['shutter'] = $result->shutter; + $photoAttributes['focal'] = $result->focal; + $photoAttributes['takestamp'] = ($result->takestamp == 0 || $result->takestamp == null) ? null : date('Y-m-d H:i:s', $result->takestamp); + $photoAttributes['star'] = $result->star; + $photoAttributes['thumbUrl'] = $result->thumbUrl; + $thumbUrl2x = explode('.', $result->thumbUrl); + if (count($thumbUrl2x) < 2) { + $photoAttributes['thumb2x'] = 0; + } else { + $thumbUrl2x = $thumbUrl2x[0] . '@2x.' . $thumbUrl2x[1]; + if (!Storage::exists('thumb/' . $thumbUrl2x)) { + $photoAttributes['thumb2x'] = 0; } else { - $result->small = ''; + $photoAttributes['thumb2x'] = 1; } - $photo->license = $result->license ?? 'none'; - $photo->save(); } - } else { - Logs::notice(__FUNCTION__, __LINE__, env('DB_OLD_LYCHEE_PREFIX', '') . 'lychee_photos does not exist!'); + $photoAttributes['checksum'] = $result->checksum; + if (Storage::exists('medium/' . $photoAttributes['url'])) { + list($width, $height) = getimagesize(Storage::path('medium/' . $photoAttributes['url'])); + $photoAttributes['medium'] = $width . 'x' . $height; + } else { + $photoAttributes['medium'] = ''; + } + if (Storage::exists('small/' . $photoAttributes['url'])) { + list($width, $height) = getimagesize(Storage::path('small/' . $photoAttributes['url'])); + $result->small = $width . 'x' . $height; + } else { + $result->small = ''; + } + $photoAttributes['license'] = $result->license ?? 'none'; + + $photoModel = new MovePhotos_Photo(); + $photoModel->setRawAttributes($photoAttributes); + $photoModel->save(); } - } else { - Logs::notice(__FUNCTION__, __LINE__, 'photos is not empty.'); } } @@ -94,7 +93,12 @@ public function up() public function down() { if (Schema::hasTable('lychee_photos')) { - Photo::truncate(); + MovePhotos_Photo::query()->truncate(); } } } + +class MovePhotos_Photo extends Model +{ + protected $table = 'photos'; +} diff --git a/database/migrations/2019_12_15_0700_add_share_button_visible_option.php b/database/migrations/2019_12_15_0700_add_share_button_visible_option.php index eeecff08cf3..ca367c9956d 100644 --- a/database/migrations/2019_12_15_0700_add_share_button_visible_option.php +++ b/database/migrations/2019_12_15_0700_add_share_button_visible_option.php @@ -1,6 +1,5 @@ boolean('share_button_visible')->after('downloadable')->default(false); }); - Album::query() - ->withoutGlobalScopes() + DB::table('albums') ->where('public', '=', 1) ->update([ 'share_button_visible' => true, @@ -44,10 +42,7 @@ public function up() */ public function down() { - Schema::table('albums', function (Blueprint $table) { - $table->dropColumn('share_button_visible'); - }); - + Schema::dropColumns('albums', ['share_button_visible']); DB::table('configs')->where('key', 'share_button_visible')->delete(); } } diff --git a/database/migrations/2020_12_26_153220_nested_set_for_albums.php b/database/migrations/2020_12_26_153220_nested_set_for_albums.php index c8486c3e9c1..05c3167f893 100644 --- a/database/migrations/2020_12_26_153220_nested_set_for_albums.php +++ b/database/migrations/2020_12_26_153220_nested_set_for_albums.php @@ -1,9 +1,10 @@ index([self::LEFT, self::RIGHT]); }); - Album::fixTree(); + NestedSetForAlbums_AlbumModel::query()->fixTree(); } /** @@ -46,3 +47,24 @@ public function down() }); } } + +/** + * Model class specific for this migration. + * + * Migrations are required to be also runnable in the future after the code + * base will have evolved. + * To this end, migrations must not rely on a specific implementation of + * models, because these models may change in the future, but the migration + * must conduct its task with respect to a table layout which was valid at + * the time when the migration was written. + * In conclusion, this implies that migration should not use models but use + * low-level DB queries when necessary. + * Unfortunately, we need the `fixTree()` algorithm and there is no + * implementation which uses low-level DB queries. + */ +class NestedSetForAlbums_AlbumModel extends Model +{ + use NodeTrait; + + protected $table = 'albums'; +} diff --git a/database/migrations/2021_01_09_163715_remove_max_min_takestamps.php b/database/migrations/2021_01_09_163715_remove_max_min_takestamps.php index 6a7a5e70753..ab3a889783e 100644 --- a/database/migrations/2021_01_09_163715_remove_max_min_takestamps.php +++ b/database/migrations/2021_01_09_163715_remove_max_min_takestamps.php @@ -1,8 +1,8 @@ timestamp(self::MAX)->nullable()->after(self::MIN); }); - $albums = Album::query()->withoutGlobalScopes()->get(); - foreach ($albums as $_album) { - $_album->min_takestamp = $_album->get_all_photos()->whereNotNull('takestamp')->min('takestamp'); - $_album->max_takestamp = $_album->get_all_photos()->whereNotNull('takestamp')->max('takestamp'); - $_album->save(); + $albums = DB::table('albums') + ->select(['id']) + ->addSelect([ + 'min_takestamp' => DB::table('photos') + ->select('takestamp') + ->join('albums as a', 'a.id', '=', 'album_id') + ->whereColumn('a._lft', '>=', 'albums._lft') + ->whereColumn('a._rgt', '<=', 'albums._rgt') + ->whereNotNull('takestamp') + ->orderBy('takestamp', 'asc') + ->limit(1), + 'max_takestamp' => DB::table('photos') + ->select('takestamp') + ->join('albums as a', 'a.id', '=', 'album_id') + ->whereColumn('a._lft', '>=', 'albums._lft') + ->whereColumn('a._rgt', '<=', 'albums._rgt') + ->whereNotNull('takestamp') + ->orderBy('takestamp', 'desc') + ->limit(1), + ]) + ->get(); + foreach ($albums as $album) { + DB::table('albums') + ->where('id', '=', $album->id) + ->update([ + 'min_takestamp' => $album->min_takestamp, + 'max_takestamp' => $album->max_takestamp, + ]); } } } diff --git a/database/migrations/2021_06_01_181900_refactor_timestamps_anew.php b/database/migrations/2021_06_01_181900_refactor_timestamps_anew.php index 5841264d48a..410f677f9ce 100644 --- a/database/migrations/2021_06_01_181900_refactor_timestamps_anew.php +++ b/database/migrations/2021_06_01_181900_refactor_timestamps_anew.php @@ -134,6 +134,8 @@ protected function upgradeORMSystemTimes(): void */ protected function upgradeORMSystemTimesByTable(string $tableName): void { + $nowString = Carbon::now(self::SQL_TIMEZONE_NAME)->format(self::SQL_DATETIME_FORMAT); + // We must use three single calls to work around an SQLite limitation Schema::table($tableName, function (Blueprint $table) { $table->renameColumn(self::CREATED_AT_COL_NAME, self::CREATED_AT_COL_NAME . '_tmp'); @@ -163,14 +165,23 @@ protected function upgradeORMSystemTimesByTable(string $tableName): void $created_at = $entity->{self::CREATED_AT_COL_NAME . '_tmp'}; $updated_at = $entity->{self::UPDATED_AT_COL_NAME . '_tmp'}; if ($needsConversion) { - $created_at = $this->upgradeDatetime($created_at); - $updated_at = $this->upgradeDatetime($updated_at); + $created_at = $this->upgradeDatetime($created_at) ?? $nowString; + $updated_at = $this->upgradeDatetime($updated_at) ?? $nowString; } DB::table($tableName)->where(self::ID_COL_NAME, '=', $entity->id)->update([ self::CREATED_AT_COL_NAME => $created_at, self::UPDATED_AT_COL_NAME => $updated_at, ]); } + DB::table($tableName) + ->whereNull(self::CREATED_AT_COL_NAME) + ->update([ + self::CREATED_AT_COL_NAME => $nowString, + self::UPDATED_AT_COL_NAME => $nowString, + ]); + DB::table($tableName) + ->whereNull(self::UPDATED_AT_COL_NAME) + ->update([self::UPDATED_AT_COL_NAME => $nowString]); DB::commit(); // Make the new columns non-nullable Schema::table($tableName, function (Blueprint $table) { @@ -450,10 +461,11 @@ protected function convertDatetime(?string $sqlDatetime, ?string $oldTz, ?string return null; } $result = Carbon::createFromFormat( - self::SQL_DATETIME_FORMAT, + self::SQL_DATETIME_FORMAT . '+', $sqlDatetime, $oldTz ); + $result->setTimezone($newTz); return $result->format(self::SQL_DATETIME_FORMAT); diff --git a/database/migrations/2021_06_06_151613_fix-takedate.php b/database/migrations/2021_06_06_151613_fix-takedate.php index 7dcb32ff7b8..22843c8e4ac 100644 --- a/database/migrations/2021_06_06_151613_fix-takedate.php +++ b/database/migrations/2021_06_06_151613_fix-takedate.php @@ -1,8 +1,7 @@ update(['value' => self::TAKEN_AT]); - Album::where('sorting_col', '=', self::TAKESTAMP)->update(['sorting_col' => self::TAKEN_AT]); + DB::table('configs')->where('value', '=', self::TAKESTAMP)->update(['value' => self::TAKEN_AT]); + DB::table('albums')->where('sorting_col', '=', self::TAKESTAMP)->update(['sorting_col' => self::TAKEN_AT]); } /** @@ -27,7 +26,7 @@ public function up() */ public function down() { - Configs::where('value', '=', self::TAKEN_AT)->update(['value' => self::TAKESTAMP]); - Album::where('sorting_col', '=', self::TAKEN_AT)->update(['sorting_col' => self::TAKESTAMP]); + DB::table('configs')->where('value', '=', self::TAKEN_AT)->update(['value' => self::TAKESTAMP]); + DB::table('albums')->where('sorting_col', '=', self::TAKEN_AT)->update(['sorting_col' => self::TAKESTAMP]); } } diff --git a/database/migrations/2021_12_04_181200_refactor_models.php b/database/migrations/2021_12_04_181200_refactor_models.php new file mode 100644 index 00000000000..1ea4389268d --- /dev/null +++ b/database/migrations/2021_12_04_181200_refactor_models.php @@ -0,0 +1,1955 @@ +foreign('local_column')->references('foreign_column')->on('foreign_table'); + * }); + * + * does not work, but + * + * Schema::create('my_table', function (Blueprint $table) { + * $table->foreign('local_column')->references('foreign_column')->on('foreign_table'); + * }); + * + * works. + * + * I also noticed that some foreign constrains that should actually + * exist are already missing for SQLite. + * I guess that former migrations have already run into that trap + * without noticing, because Laravel does not throw an error, if + * a foreign constraint cannot be created. + * I checked with my PostgreSQL installation and my SQLite + * installation and found missing constraints. + * However, I did not check the actual code of past migrations. + * + * As we alter the table `albums` the foreign constraint from + * `photos` to `albums` via the column `album_id` vanishes. + * Hence, we must re-create the table `photos`. + * This has a cascading effect on `size_variants` and in turn on + * `sym_links`. + * In other words, we have to re-create the whole database more or + * less. + * (At least, if we want to keep foreign constraints in SQLite.) + * Yikes! :-( + */ +class RefactorModels extends Migration +{ + private string $driverName; + private AbstractSchemaManager $schemaManager; + private ConsoleOutput $output; + /** @var ProgressBar[] */ + private array $progressBars; + private ConsoleSectionOutput $msgSection; + + private const SQL_TIMEZONE_NAME = 'UTC'; + private const SQL_DATETIME_FORMAT = 'Y-m-d H:i:s'; + + public const THUMBNAIL_DIM = 200; + public const THUMBNAIL2X_DIM = 400; + + public const VARIANT_ORIGINAL = 0; + public const VARIANT_MEDIUM2X = 1; + public const VARIANT_MEDIUM = 2; + public const VARIANT_SMALL2X = 3; + public const VARIANT_SMALL = 4; + public const VARIANT_THUMB2X = 5; + public const VARIANT_THUMB = 6; + + /** + * 2013-11-01 in seconds since epoch. + */ + public const BIRTH_OF_LYCHEE = 1383264000; + public const MAX_SIGNED_32BIT_INT = 2147483647; + + public const RANDOM_ID_LENGTH = 24; + + /** + * Maps a size variant (0...6) to the path prefix (directory) where the + * file for that size variant is stored. + */ + public const VARIANT_2_PATH_PREFIX = [ + 'big', + 'medium', + 'medium', + 'small', + 'small', + 'thumb', + 'thumb', + ]; + + public const VALID_VIDEO_TYPES = [ + 'video/mp4', + 'video/mpeg', + 'image/x-tga', // mpg; will be corrected by the metadata extractor + 'video/ogg', + 'video/webm', + 'video/quicktime', + 'video/x-ms-asf', // wmv file + 'video/x-ms-wmv', // wmv file + 'video/x-msvideo', // Avi + 'video/x-m4v', // Avi + 'application/octet-stream', // Some mp4 files; will be corrected by the metadata extractor + ]; + + /** + * Maps a size variant (0...4) to the name of the (old) attribute which + * stores the width of that size variant. + * Note: No attribute is defined for the size variants 5 and 6 (`thumb2x` + * and `thumb`), because their width is not stored as an attribute but + * hard-coded. + * See {@link RefactorModels::THUMBNAIL2X_DIM} and + * {@link RefactorModels::THUMBNAIL_DIM}. + */ + public const VARIANT_2_WIDTH_ATTRIBUTE = [ + 'width', + 'medium2x_width', + 'medium_width', + 'small2x_width', + 'small_width', + ]; + + /** + * Maps a size variant (0...4) to the name of the (old) attribute which + * stores the height of that size variant. + * Note: No attribute is defined for the size variants 5 and 6 (`thumb2x` + * and `thumb`), because their width is not stored as an attribute but + * hard-coded. + * See {@link RefactorModels::THUMBNAIL2X_DIM} and + * {@link RefactorModels::THUMBNAIL_DIM}. + */ + public const VARIANT_2_HEIGHT_ATTRIBUTE = [ + 'height', + 'medium2x_height', + 'medium_height', + 'small2x_height', + 'small_height', + ]; + + /** + * Translates album IDs. + * + * During upgrade the array maps legacy, time-based IDs to new, random IDs. + * During downgrade the array maps random IDs to legacy, time-based IDs. + * + * @var array + */ + private array $albumIDCache = []; + + /** + * Translates photo IDs. + * + * During upgrade the array maps legacy, time-based IDs to new, random IDs. + * During downgrade the array maps random IDs to legacy, time-based IDs. + * + * @var array + */ + private array $photoIDCache = []; + + /** + * @throws DBALException + */ + public function __construct() + { + $connection = Schema::connection(null)->getConnection(); + $this->driverName = $connection->getDriverName(); + $this->schemaManager = $connection->getDoctrineSchemaManager(); + $this->output = new ConsoleOutput(); + $this->progressBars = []; + $this->msgSection = $this->output->section(); + } + + /** + * Outputs an error message. + * + * @param string $msg the message + * + * @return void + */ + private function printError(string $msg): void + { + $this->msgSection->writeln('Error: ' . $msg); + } + + /** + * Outputs a warning. + * + * @param string $msg the message + * + * @return void + */ + private function printWarning(string $msg): void + { + $this->msgSection->writeln('Warning: ' . $msg); + } + + /** + * Outputs an informational message. + * + * @param string $msg the message + * + * @return void + */ + private function printInfo(string $msg): void + { + $this->msgSection->writeln('Info: ' . $msg); + } + + /** + * Gets the progress bar for the given table. + * + * The method always returns the same instance of the progress bar for + * the same table. + * The method creates a new progress bar, when it is called for a new + * table name the first time. + * + * @param string $tableName + * + * @return ProgressBar + */ + private function getProgressBar(string $tableName): ProgressBar + { + if (!key_exists($tableName, $this->progressBars)) { + // Also start a new message section **above** the new progress bar + // This way the progress bar remains on the bottom in case too + // many warning/errors are spit out. + $this->msgSection = $this->output->section(); + $this->progressBars[$tableName] = new ProgressBar($this->output->section()); + $this->progressBars[$tableName]->setFormat('Table \'' . $tableName . '\' %current%/%max% [%bar%] %percent:3s%%'); + } + + return $this->progressBars[$tableName]; + } + + /** + * @throws InvalidArgumentException + */ + public function up() + { + Schema::drop('sym_links'); + + // Step 1 + // Create tables in correct order so that foreign keys can + // be created immediately. + $this->printInfo('Renaming existing tables'); + $this->renameTables(); + $this->printInfo('Creating new tables'); + $this->createUsersTableUp(); + $this->createBaseAlbumTable(); + $this->createAlbumTableUp(); + $this->createTagAlbumTable(); + $this->createUserBaseAlbumTableUp(); + $this->createPhotoTableUp(); + $this->createSizeVariantTableUp(); + $this->createSymLinkTableUp(); + $this->createRemainingForeignConstraints(); + $this->createWebAuthnTableUp(); + $this->createPageTableUp(); + $this->createPageContentTableUp(); + $this->createLogTableUp(); + + // Step 2 + // Happy copying :( + DB::beginTransaction(); + $this->printInfo('Start copying ...'); + $this->upgradeCopy(); + $this->copyStructurallyUnchangedTables(); + $this->printInfo('Finished copying'); + $this->printInfo('Upgrading configuration'); + $this->upgradeConfig(); + DB::commit(); + + // Step 3 + $this->printInfo('Dropping old tables'); + $this->dropTemporaryTablesUp(); + } + + /** + * @throws InvalidArgumentException + */ + public function down() + { + Schema::drop('sym_links'); + + // Step 1 + // Create tables in correct order so that foreign keys can + // be created immediately. + $this->printInfo('Renaming existing tables'); + $this->renameTables(); + $this->printInfo('Creating new tables'); + $this->createUsersTableDown(); + $this->createAlbumTableDown(); + $this->createUserAlbumTableDown(); + $this->createPhotoTableDown(); + $this->createSymLinkTableDown(); + $this->createWebAuthnTableDown(); + $this->createPageTableDown(); + $this->createPageContentTableDown(); + $this->createLogTableDown(); + + // Step 2 + // Happy copying :( + DB::beginTransaction(); + $this->printInfo('Start copying ...'); + $this->downgradeCopy(); + $this->copyStructurallyUnchangedTables(); + $this->printInfo('Finished copying'); + $this->printInfo('Downgrading configuration'); + $this->downgradeConfig(); + DB::commit(); + + // Step 3 + $this->printInfo('Dropping old tables'); + $this->dropTemporaryTablesDown(); + } + + /** + * Renames some tables to a temporary name so that we get them out of + * out way. + * + * In case of SQLite, this already destroys foreign constraints, but + * it does not destroy any other indexes. + * Again, we are facing funny differences how the schema abstraction of + * Laravel handles SQLite on the on hand side and MySQL/PostgreSQL on the + * other hand side. + * Hence, we remove all indexes in advance before we rename the table, + * so that we can re-create them later without failing. + */ + private function renameTables(): void + { + Schema::table('albums', function (Blueprint $table) { + $this->dropForeignIfExists($table, 'albums_owner_id_foreign'); + // We must remove any foreign link from `albums` to `photos` to + // break up circular dependencies. + $this->dropForeignIfExists($table, 'albums_cover_id_foreign'); + $this->dropForeignIfExists($table, 'albums_parent_id_foreign'); + $this->dropIndexIfExists($table, 'albums__lft__rgt_index'); + }); + Schema::rename('albums', 'albums_tmp'); + Schema::table('photos', function (Blueprint $table) { + $this->dropForeignIfExists($table, 'photos_album_id_foreign'); + $this->dropForeignIfExists($table, 'photos_owner_id_foreign'); + $this->dropIndexIfExists($table, 'photos_created_at_index'); + $this->dropIndexIfExists($table, 'photos_updated_at_index'); + $this->dropIndexIfExists($table, 'photos_taken_at_index'); + $this->dropIndexIfExists($table, 'photos_original_checksum_index'); + $this->dropIndexIfExists($table, 'photos_checksum_index'); + $this->dropIndexIfExists($table, 'photos_live_photo_content_id_index'); + $this->dropIndexIfExists($table, 'photos_livephotocontentid_index'); + $this->dropIndexIfExists($table, 'photos_live_photo_checksum_index'); + $this->dropIndexIfExists($table, 'photos_livephotochecksum_index'); + $this->dropIndexIfExists($table, 'photos_is_public_index'); + $this->dropIndexIfExists($table, 'photos_is_starred_index'); + }); + Schema::rename('photos', 'photos_tmp'); + Schema::table('web_authn_credentials', function (Blueprint $table) { + $this->dropForeignIfExists($table, 'web_authn_credentials_user_id_foreign'); + }); + Schema::rename('web_authn_credentials', 'web_authn_credentials_tmp'); + Schema::table('users', function (Blueprint $table) { + $this->dropUniqueIfExists($table, 'users_username_unique'); + $this->dropUniqueIfExists($table, 'users_email_unique'); + }); + Schema::rename('users', 'users_tmp'); + $this->renamePageContentTable(); + Schema::rename('pages', 'pages_tmp'); + Schema::rename('logs', 'logs_tmp'); + } + + /** + * Drops temporary tables which have been created by + * {@link RefactorAlbumModel::renameTables()} or have become unnecessary. + * + * The order is important to avoid error due to unsatisfied foreign + * constraints. + */ + private function dropTemporaryTablesUp(): void + { + Schema::drop('user_album'); + // We must remove any foreign link from `albums` to `photos` to + // break up circular dependencies. + DB::table('albums_tmp')->update(['cover_id' => null]); + Schema::drop('photos_tmp'); + Schema::drop('albums_tmp'); + Schema::drop('web_authn_credentials_tmp'); + Schema::drop('users_tmp'); + Schema::drop('page_contents_tmp'); + Schema::drop('pages_tmp'); + Schema::drop('logs_tmp'); + } + + /** + * Drops temporary tables which have been created by + * {@link RefactorAlbumModel::renameTables()} or have become unnecessary. + * + * The order is important to avoid error due to unsatisfied foreign + * constraints. + */ + private function dropTemporaryTablesDown(): void + { + Schema::drop('user_base_album'); + Schema::drop('size_variants'); + // We must remove any foreign link from `albums` to `photos` to + // break up circular dependencies. + DB::table('albums_tmp')->update(['cover_id' => null]); + Schema::drop('photos_tmp'); + Schema::drop('albums_tmp'); + Schema::drop('tag_albums'); + Schema::drop('base_albums'); + Schema::drop('web_authn_credentials_tmp'); + Schema::drop('users_tmp'); + Schema::drop('page_contents_tmp'); + Schema::drop('pages_tmp'); + Schema::drop('logs_tmp'); + } + + /** + * Creates the new table `users` with improved attribute names. + * + * Note: Actually, renaming of the attributes `lock` to `is_locked` and + * `upload` to `may_upload` should not be part of this migration, because + * it is unrelated to the refactored, new architecture. + * However, there will be a subsequent PR which aims at making the JSON + * API more consistent and in this context this migration make sense. + * Unfortunately, SQLite does not support renaming of columns in place. + * Under the hood, SQLite drops the entire table and re-creates it. + * But this fails, if there are foreign key constraints from other tables + * to `users`. + * Eventually, we would end up with re-creating the whole DB again. :-( + * Hence, we bring forward this migration when we re-create the whole DB + * anyway. + * + * @return void + */ + private function createUsersTableUp(): void + { + Schema::create('users', function (Blueprint $table) { + $table->increments('id'); + $table->dateTime('created_at', 6)->nullable(false); + $table->dateTime('updated_at', 6)->nullable(false); + $table->string('username', 100)->nullable(false)->unique(); + $table->string('password', 100)->nullable(true); + $table->string('email', 100)->nullable()->unique(); + $table->boolean('may_upload')->nullable(false)->default(false); + $table->boolean('is_locked')->nullable(false)->default(false); + $table->rememberToken(); + }); + } + + /** + * Creates the old table `users`. + * + * @return void + */ + private function createUsersTableDown(): void + { + Schema::create('users', function (Blueprint $table) { + $table->increments('id'); + $table->dateTime('created_at')->nullable(false); + $table->dateTime('updated_at')->nullable(false); + $table->string('username', 100)->nullable(false)->unique(); + $table->string('password', 100)->nullable(true); + $table->string('email', 100)->nullable()->unique(); + $table->boolean('upload')->nullable(false)->default(false); + $table->boolean('lock')->nullable(false)->default(false); + $table->rememberToken(); + }); + } + + /** + * Creates the table `base_albums`. + * + * The table `base_albums` contains all columns of the old table + * `albums` which are common to normal albums and tag albums. + */ + private function createBaseAlbumTable(): void + { + Schema::create('base_albums', function (Blueprint $table) { + // Column definitions + $table->char('id', self::RANDOM_ID_LENGTH)->nullable(false); + $table->unsignedBigInteger('legacy_id')->nullable(false); + $table->dateTime('created_at', 6)->nullable(false); + $table->dateTime('updated_at', 6)->nullable(false); + $table->string('title', 100)->nullable(false); + $table->text('description')->nullable(); + $table->unsignedInteger('owner_id')->nullable(false)->default(0); + $table->boolean('is_public')->nullable(false)->default(false); + $table->boolean('grants_full_photo')->nullable(false)->default(true); + $table->boolean('requires_link')->nullable(false)->default(false); + $table->boolean('is_downloadable')->nullable(false)->default(false); + $table->boolean('is_share_button_visible')->nullable(false)->default(false); + $table->boolean('is_nsfw')->nullable(false)->default(false); + $table->string('password', 100)->nullable()->default(null); + $table->string('sorting_col', 30)->nullable()->default(null); + $table->string('sorting_order', 4)->nullable()->default(null); + // Indices and constraint definitions + $table->primary('id'); + $table->unique('legacy_id'); + $table->foreign('owner_id')->references('id')->on('users'); + // These indices are required for efficient filtering for accessible and/or visible albums + $table->index(['requires_link', 'is_public']); // for albums which don't require a direct link and are public + $table->index(['owner_id']); // for albums which are own by the currently authenticated user + $table->index(['is_public', 'password']); // for albums which are public and how no password + }); + } + + /** + * Creates the table `albums` acc. to the new architecture. + * + * The new table `albums` only contains the columns which are specific + * to real albums and are irrelevant for tag albums. + */ + private function createAlbumTableUp(): void + { + Schema::create('albums', function (Blueprint $table) { + // Column definitions + $table->char('id', self::RANDOM_ID_LENGTH)->nullable(false); + $table->char('parent_id', self::RANDOM_ID_LENGTH)->nullable()->default(null); + $table->string('license', 20)->nullable(false)->default('none'); + $table->char('cover_id', self::RANDOM_ID_LENGTH)->nullable()->default(null); + $table->unsignedBigInteger('_lft')->nullable(false)->default(0); + $table->unsignedBigInteger('_rgt')->nullable(false)->default(0); + // Indices and constraint definitions + $table->primary('id'); + $table->index([DB::raw('_lft asc'), DB::raw('_rgt desc')], 'albums__lft__rgt__index'); + $table->foreign('id')->references('id')->on('base_albums'); + $table->foreign('parent_id')->references('id')->on('albums'); + // Sic! + // Columns `created_at` and `updated_at` left out by intention. + // The albums belong to their "parent" base album and are tied to the same timestamps + }); + } + + /** + * Creates the table `tag_albums`. + * + * The table `tag_albums` only contains the columns which are specific + * to tag albums and are irrelevant for real albums. + */ + private function createTagAlbumTable(): void + { + Schema::create('tag_albums', function (Blueprint $table) { + // Column definitions + $table->char('id', self::RANDOM_ID_LENGTH)->nullable(false); + $table->text('show_tags')->nullable(); + // Indices and constraint definitions + $table->primary('id'); + $table->foreign('id')->references('id')->on('base_albums'); + // Sic! + // Columns `created_at` and `updated_at` left out by intention. + // The tag albums belong to their "parent" base album and are tied to the same timestamps + }); + } + + /** + * Creates the table `albums` acc. to the old architecture. + * + * The old table `albums` only contains the union of all columns of + * `base_albums`, (the new table) `albums` and `tag_albums`. + * Also see + * {@link RefactorAlbumModel::createBaseAlbumTable()}, + * {@link RefactorAlbumModel::createAlbumTableUp()} and + * {@link RefactorAlbumModel::createTagAlbumTable()}. + */ + private function createAlbumTableDown(): void + { + Schema::create('albums', function (Blueprint $table) { + // Column definitions + $table->bigIncrements('id')->nullable(false); + $table->unsignedBigInteger('parent_id')->nullable()->default(null); + $table->dateTime('created_at')->nullable(false); + $table->dateTime('updated_at')->nullable(false); + $table->string('title', 100)->nullable(false); + $table->text('description')->nullable(); + $table->string('license', 20)->nullable(false)->default('none'); + $table->unsignedInteger('owner_id')->nullable(false)->default(0); + $table->boolean('smart')->nullable(false)->default(false); + $table->text('showtags')->nullable(); + $table->boolean('public')->nullable(false)->default(false); + $table->boolean('full_photo')->nullable(false)->default(true); + $table->boolean('viewable')->nullable(false)->default(false); + $table->boolean('downloadable')->nullable(false)->default(false); + $table->boolean('share_button_visible')->nullable(false)->default(false); + $table->boolean('nsfw')->nullable(false)->default(false); + $table->string('password', 100)->nullable()->default(null); + $table->unsignedBigInteger('cover_id')->nullable()->default(null); + $table->string('sorting_col', 30)->nullable()->default(null); + $table->string('sorting_order', 4)->nullable()->default(null); + $table->unsignedBigInteger('_lft')->nullable()->default(null); + $table->unsignedBigInteger('_rgt')->nullable()->default(null); + // Indices and constraint definitions + $table->foreign('parent_id')->references('id')->on('albums'); + }); + } + + /** + * Creates the table `user_base_album`. + * + * The created table is the pivot table for the (m:n)-relationship between + * an owner (user) and a base album. + */ + private function createUserBaseAlbumTableUp(): void + { + Schema::create('user_base_album', function (Blueprint $table) { + // Column definitions + $table->bigIncrements('id')->nullable(false); + $table->unsignedInteger('user_id')->nullable(false); + $table->char('base_album_id', self::RANDOM_ID_LENGTH)->nullable(false); + // Indices and constraint definitions + $table->foreign('user_id')->references('id')->on('users')->cascadeOnUpdate()->cascadeOnDelete(); + $table->foreign('base_album_id')->references('id')->on('base_albums')->cascadeOnUpdate()->cascadeOnDelete(); + // This index is required to efficiently filter those albums + // which are shared with a particular user + $table->unique(['base_album_id', 'user_id']); + }); + } + + /** + * Creates the table `user_album`. + * + * The created table is the pivot table for the (m:n)-relationship between + * an owner (user) and an album. + */ + private function createUserAlbumTableDown(): void + { + Schema::create('user_album', function (Blueprint $table) { + // Column definitions + $table->bigIncrements('id')->nullable(false); + $table->unsignedInteger('user_id')->nullable(false); + $table->unsignedBigInteger('album_id')->nullable(false); + // Indices and constraint definitions + $table->foreign('user_id')->references('id')->on('users')->cascadeOnUpdate()->cascadeOnDelete(); + $table->foreign('album_id')->references('id')->on('albums')->cascadeOnUpdate()->cascadeOnDelete(); + }); + } + + /** + * Creates the table `photos` acc. to the new architecture. + */ + private function createPhotoTableUp(): void + { + Schema::create('photos', function (Blueprint $table) { + // Column definitions + $table->char('id', self::RANDOM_ID_LENGTH)->nullable(false); + $table->unsignedBigInteger('legacy_id')->nullable(false); + $table->dateTime('created_at', 6)->nullable(false); + $table->dateTime('updated_at', 6)->nullable(false); + $table->unsignedInteger('owner_id')->unsigned()->nullable(false)->default(0); + $table->char('album_id', self::RANDOM_ID_LENGTH)->nullable()->default(null); + $table->string('title', 100)->nullable(false); + $table->text('description')->nullable(); + $table->text('tags')->nullable(); + $table->string('license', 20)->nullable(false)->default('none'); + $table->boolean('is_public')->nullable(false)->default(false); + $table->boolean('is_starred')->nullable(false)->default(false); + $table->string('iso')->nullable()->default(null); + $table->string('make')->nullable()->default(null); + $table->string('model')->nullable()->default(null); + $table->string('lens')->nullable()->default(null); + $table->string('aperture')->nullable()->default(null); + $table->string('shutter')->nullable()->default(null); + $table->string('focal')->nullable()->default(null); + $table->decimal('latitude', 10, 8)->nullable()->default(null); + $table->decimal('longitude', 11, 8)->nullable()->default(null); + $table->decimal('altitude', 10, 4)->nullable()->default(null); + $table->decimal('img_direction', 10, 4)->nullable()->default(null); + $table->string('location')->nullable()->default(null); + $table->dateTime('taken_at', 6)->nullable(true)->default(null)->comment('relative to UTC'); + $table->string('taken_at_orig_tz', 31)->nullable(true)->default(null)->comment('the timezone at which the photo has originally been taken'); + $table->string('type', 30)->nullable(false); + $table->unsignedBigInteger('filesize')->nullable(false)->default(0); + $table->string('checksum', 40)->nullable(false); + $table->string('original_checksum', 40)->nullable(false); + $table->string('live_photo_short_path')->nullable()->default(null); + $table->string('live_photo_content_id')->nullable()->default(null); + $table->string('live_photo_checksum', 40)->nullable()->default(null); + // Indices and constraint definitions + $table->primary('id'); + $table->unique('legacy_id'); + $table->foreign('owner_id')->references('id')->on('users'); + $table->foreign('album_id')->references('id')->on('albums'); + $table->index('created_at'); + $table->index('updated_at'); + $table->index('taken_at'); + $table->index('checksum'); + $table->index('original_checksum'); + $table->index('live_photo_content_id'); + $table->index('live_photo_checksum'); + $table->index('is_public'); + $table->index('is_starred'); + // This index is needed to efficiently add the range of take dates + // to each album. + $table->index(['album_id', 'taken_at']); + // These indices are needed to efficiently list all photos of an + // album acc. to different sorting criteria + // Upload time, take date, is starred or is public + $table->index(['album_id', 'created_at']); + $table->index(['album_id', 'is_starred']); + $table->index(['album_id', 'is_public']); + }); + } + + /** + * Creates the table `photos` acc. to the old architecture. + */ + private function createPhotoTableDown(): void + { + Schema::create('photos', function (Blueprint $table) { + // Column definitions + $table->bigIncrements('id')->nullable(false); + $table->dateTime('created_at')->nullable(false); + $table->dateTime('updated_at')->nullable(false); + $table->unsignedInteger('owner_id')->nullable(false)->default(0); + $table->unsignedBigInteger('album_id')->nullable()->default(null); + $table->string('title', 100)->nullable(false); + $table->text('description')->nullable(true); + $table->string('tags')->nullable(false)->default(''); + $table->string('license', 20)->nullable(false)->default('none'); + $table->boolean('public')->nullable(false)->default(false); + $table->boolean('star')->nullable(false)->default(false); + $table->string('iso')->nullable(false)->default(''); + $table->string('make')->nullable(false)->default(''); + $table->string('model')->nullable(false)->default(''); + $table->string('lens')->nullable(false)->default(''); + $table->string('aperture')->nullable(false)->default(''); + $table->string('shutter')->nullable(false)->default(''); + $table->string('focal')->nullable(false)->default(''); + $table->decimal('latitude', 10, 8)->nullable()->default(null); + $table->decimal('longitude', 11, 8)->nullable()->default(null); + $table->decimal('altitude', 10, 4)->nullable()->default(null); + $table->decimal('imgDirection', 10, 4)->nullable()->default(null); + $table->string('location')->nullable()->default(null); + $table->dateTime('taken_at')->nullable(true)->default(null)->comment('relative to UTC'); + $table->string('taken_at_orig_tz', 31)->nullable(true)->default(null)->comment('the timezone at which the photo has originally been taken'); + $table->string('type', 30)->nullable(false); + $table->string('url', 100)->default(''); + $table->unsignedBigInteger('filesize')->nullable(false)->default(0); + $table->string('checksum', 40)->nullable(false); + for ($i = self::VARIANT_ORIGINAL; $i <= self::VARIANT_SMALL; $i++) { + $table->integer(self::VARIANT_2_WIDTH_ATTRIBUTE[$i])->unsigned()->nullable()->default(null); + $table->integer(self::VARIANT_2_HEIGHT_ATTRIBUTE[$i])->unsigned()->nullable()->default(null); + } + $table->boolean('thumb2x')->default(false); + $table->string('thumbUrl', 37)->default(''); + $table->string('livePhotoUrl')->nullable()->default(null); + $table->string('livePhotoContentID')->nullable()->default(null); + $table->string('livePhotoChecksum', 40)->nullable()->default(null); + // Indices and constraint definitions + $table->foreign('album_id')->references('id')->on('albums'); + }); + } + + private function createSizeVariantTableUp(): void + { + Schema::create('size_variants', function (Blueprint $table) { + // Column definitions + $table->bigIncrements('id')->nullable(false); + $table->char('photo_id', self::RANDOM_ID_LENGTH)->nullable(false); + $table->unsignedInteger('type')->nullable(false)->default(0)->comment('0: original, ..., 6: thumb'); + $table->string('short_path')->nullable(false); + $table->integer('width')->nullable(false); + $table->integer('height')->nullable(false); + // Indices and constraint definitions + $table->unique(['photo_id', 'type']); + $table->foreign('photo_id')->references('id')->on('photos'); + // Sic! + // Columns `created_at` and `updated_at` left out by intention. + // The size variants belong to their "parent" photo model and are tied to the same timestamps + }); + } + + private function createSymLinkTableUp(): void + { + Schema::create('sym_links', function (Blueprint $table) { + // Column definitions + $table->bigIncrements('id')->nullable(false); + $table->dateTime('created_at', 6)->nullable(false); + $table->dateTime('updated_at', 6)->nullable(false); + $table->unsignedBigInteger('size_variant_id')->nullable(false); + $table->string('short_path')->nullable(false); + // Indices and constraint definitions + $table->index('created_at'); + $table->index('updated_at'); + $table->foreign('size_variant_id')->references('id')->on('size_variants'); + // This index is needed to efficiently find the latest symbolic link + // for each size variant + $table->index(['size_variant_id', 'created_at']); + }); + } + + private function createSymLinkTableDown(): void + { + Schema::create('sym_links', function (Blueprint $table) { + // Column definitions + $table->bigIncrements('id')->nullable(false); + $table->dateTime('created_at')->nullable(false); + $table->dateTime('updated_at')->nullable(false); + $table->unsignedBigInteger('photo_id')->nullable(false); + $table->string('url')->default(''); + $table->string('medium')->default(''); + $table->string('medium2x')->default(''); + $table->string('small')->default(''); + $table->string('small2x')->default(''); + $table->string('thumbUrl')->default(''); + $table->string('thumb2x')->default(''); + // Indices and constraint definitions + $table->foreign('photo_id')->references('id')->on('photos'); + }); + } + + private function createLogTable(int $precision): void + { + Schema::create('logs', function (Blueprint $table) use ($precision) { + $table->bigIncrements('id'); + $table->dateTime('created_at', $precision)->nullable(false); + $table->dateTime('updated_at', $precision)->nullable(false); + $table->string('type', 11); + $table->string('function', 100); + $table->integer('line'); + $table->text('text'); + }); + } + + private function createLogTableUp(): void + { + $this->createLogTable(6); + } + + private function createLogTableDown(): void + { + $this->createLogTable(0); + } + + private function createPageTable(int $precision): void + { + Schema::create('pages', function (Blueprint $table) use ($precision) { + $table->increments('id'); + $table->dateTime('created_at', $precision)->nullable(false); + $table->dateTime('updated_at', $precision)->nullable(false); + $table->string('title', 150)->default(''); + $table->string('menu_title', 100)->default(''); + $table->boolean('in_menu')->default(false); + $table->boolean('enabled')->default(false); + $table->string('link', 150)->default(''); + $table->integer('order')->default(0); + }); + } + + private function createPageTableUp(): void + { + $this->createPageTable(6); + } + + private function createPageTableDown(): void + { + $this->createPageTable(0); + } + + private function createPageContentTable(string $tableName, int $precision): void + { + Schema::create($tableName, function (Blueprint $table) use ($precision) { + $table->increments('id'); + $table->dateTime('created_at', $precision)->nullable(false); + $table->dateTime('updated_at', $precision)->nullable(false); + $table->unsignedInteger('page_id'); + $table->text('content'); + $table->string('class', 150); + $table->enum('type', ['div', 'img']); + $table->integer('order')->default(0); + // Indices + $table->foreign('page_id') + ->references('id')->on('pages') + ->onDelete('cascade'); + }); + } + + private function createPageContentTableUp(): void + { + $this->createPageContentTable('page_contents', 6); + } + + private function createPageContentTableDown(): void + { + $this->createPageContentTable('page_contents', 0); + } + + /** + * Renames table `page_content` to `page_content_tmp` using a work-around. + * + * Ideally, we would simply use + * `Schema::rename('page_content', 'page_content_tmp')` + * in {@link RefactorModels::renameTables()} as for any other table. + * Unfortunately, a bug in Laravel/Eloquent does not allow this, so we + * need to create a table `page_contents_tmp` copy everything into that + * table, and drop `page_contents`. + * (And yes, we do it the other way around just some minutes later.) + * Yikes! + * + * The cause of the problem is that the table uses the non-SQL type + * `enum` (see `CreatePageContentsTable::up` in + * `2019_02_21_114408_create_page_contents_table.php`). + * Under the hood, Laravel/Eloquent registers this proprietary extension + * with the DBAL (database abstraction layer) and a callback ensures + * that this type gets properly translated into an actual SQL type + * whenever the DBAL encounters this type depending on the SQL backend: + * + * - MySQL: `ENUM` + * - PostgreSQL: `VARCHAR` with a `CHECK`-constraint + * - SQLite: `VARCHAR` + * + * However, Laravel/Eloquent only registers this type extension for + * table creation. + * (That is actually a known bug which Laravel/Eloquent refuses to fix.) + * As a result, the DBAL will bail out with an exception whenever it tries + * to modify the table schema in the slightest way (rename the table, + * drop/add/rename a column, change a column) even if the modification + * does not alter the enum-column itself, because it will topple over an + * unknown type. + * Essentially, the table schema becomes immutable. + * The only possible action left which does not trigger an exception is to + * drop the table. + * + * @return void + */ + private function renamePageContentTable(): void + { + $nowString = Carbon::now(self::SQL_TIMEZONE_NAME)->format(self::SQL_DATETIME_FORMAT); + + $this->createPageContentTable('page_contents_tmp', 0); + $pageContents = DB::table('page_contents')->get(); + foreach ($pageContents as $pageContent) { + DB::table('page_contents_tmp')->insert([ + 'id' => $pageContent->id, + 'created_at' => $pageContent->created_at ?? $nowString, + 'updated_at' => $pageContent->updated_at ?? $nowString, + 'page_id' => $pageContent->page_id, + 'content' => $pageContent->content, + 'class' => $pageContent->class, + 'type' => $pageContent->type, + 'order' => $pageContent->order, + ]); + } + Schema::drop('page_contents'); + } + + private function createWebAuthnTable(int $precision): void + { + Schema::create('web_authn_credentials', function (Blueprint $table) use ($precision) { + $table->string('id', 255); + $table->dateTime('created_at', $precision)->nullable(false); + $table->dateTime('updated_at', $precision)->nullable(false); + $table->dateTime('disabled_at', $precision)->nullable(true); + $table->unsignedInteger('user_id')->nullable(false); + $table->string('name')->nullable(); + $table->string('type', 16); + $table->json('transports'); + $table->json('attestation_type'); + $table->json('trust_path'); + $table->uuid('aaguid'); + $table->binary('public_key'); + $table->unsignedInteger('counter')->default(0); + $table->uuid('user_handle')->nullable(); + // Indices + $table->primary(['id', 'user_id']); + $table->foreign('user_id') + ->references('id')->on('users') + ->cascadeOnDelete(); + }); + } + + private function createWebAuthnTableUp(): void + { + $this->createWebAuthnTable(6); + } + + private function createWebAuthnTableDown(): void + { + $this->createWebAuthnTable(0); + } + + /** + * Creates remaining foreign constraints which could not immediately be + * created while the owning table was created due to circular dependencies. + * + * Note, this method has no effect for a SQLite installation. + */ + private function createRemainingForeignConstraints(): void + { + Schema::table('albums', function (Blueprint $table) { + $table->foreign('cover_id') + ->references('id')->on('photos') + ->onUpdate('CASCADE') + ->onDelete('SET NULL'); + }); + } + + /** + * @throws InvalidArgumentException + */ + private function upgradeCopy(): void + { + $pgBar = $this->getProgressBar('users'); + $users = DB::table('users_tmp')->get(); + $pgBar->setMaxSteps($users->count()); + foreach ($users as $user) { + $pgBar->advance(); + DB::table('users')->insert([ + 'id' => $user->id, + 'created_at' => $user->created_at, + 'updated_at' => $user->updated_at, + 'username' => $user->username, + 'password' => $user->password, + 'email' => $user->email, + 'may_upload' => $user->upload, + 'is_locked' => $user->lock, + 'remember_token' => $user->remember_token, + ]); + } + + // Ordering by `_lft` is important, because we must copy parent + // albums first. + // Otherwise, foreign key constraint to `parent_id` may fail. + $pgBar = $this->getProgressBar('albums'); + $albums = DB::table('albums_tmp')->orderBy('_lft')->lazyById(); + $pgBar->setMaxSteps($albums->count()); + $mapSorting = function (?string $sortingCol): ?string { + if (empty($sortingCol)) { + return null; + } elseif ($sortingCol === 'id') { + return 'created_at'; + } elseif ($sortingCol === 'public') { + return 'is_public'; + } elseif ($sortingCol === 'star') { + return 'is_starred'; + } else { + return $sortingCol; + } + }; + foreach ($albums as $album) { + $pgBar->advance(); + $newAlbumID = $this->generateKey(); + $this->albumIDCache[strval($album->id)] = $newAlbumID; + + DB::table('base_albums')->insert([ + 'id' => $newAlbumID, + 'legacy_id' => $album->id, + 'created_at' => $this->calculateBestCreatedAt($album->id, $album->created_at), + 'updated_at' => $album->updated_at, + 'title' => $album->title, + 'description' => empty($album->description) ? null : $album->description, + 'owner_id' => $album->owner_id, + 'is_public' => $album->public, + 'grants_full_photo' => $album->full_photo, + 'requires_link' => !($album->viewable), + 'is_downloadable' => $album->downloadable, + 'is_share_button_visible' => $album->share_button_visible, + 'is_nsfw' => $album->nsfw, + 'password' => empty($album->password) ? null : $album->password, + 'sorting_col' => $mapSorting($album->sorting_col), + 'sorting_order' => empty($album->sorting_col) ? null : $album->sorting_order, + ]); + + if ($album->smart) { + DB::table('tag_albums')->insert([ + 'id' => $newAlbumID, + 'show_tags' => $album->showtags, + ]); + } else { + // Don't copy `cover_id` yet, because the photos have not been + // copied yet. + // Explicit `cover_id` needs to be set belated. + // Otherwise, the foreign key constraint between `cover_id` + // and `photos.id` fails. + DB::table('albums')->insert([ + 'id' => $newAlbumID, + 'parent_id' => $album->parent_id ? $this->albumIDCache[strval($album->parent_id)] : null, + 'license' => $album->license, + 'cover_id' => null, + '_lft' => $album->_lft ?? 0, + '_rgt' => $album->_rgt ?? 0, + ]); + } + } + + RefactorAlbumModel_AlbumModel::query()->fixTree(); + + $pgBar = $this->getProgressBar('user_base_album'); + $userAlbumRelations = DB::table('user_album')->lazyById(); + $pgBar->setMaxSteps($userAlbumRelations->count()); + foreach ($userAlbumRelations as $userAlbumRelation) { + $pgBar->advance(); + DB::table('user_base_album')->insert([ + 'id' => $userAlbumRelation->id, + 'user_id' => $userAlbumRelation->user_id, + 'base_album_id' => $this->albumIDCache[strval($userAlbumRelation->album_id)], + ]); + } + + $pgBar = $this->getProgressBar('photos'); + $photos = DB::table('photos_tmp')->lazyById(); + $pgBar->setMaxSteps($photos->count()); + foreach ($photos as $photo) { + $pgBar->advance(); + $newPhotoID = $this->generateKey(); + $this->photoIDCache[strval($photo->id)] = $newPhotoID; + + DB::table('photos')->insert([ + 'id' => $newPhotoID, + 'legacy_id' => $photo->id, + 'created_at' => $this->calculateBestCreatedAt($photo->id, $photo->created_at), + 'updated_at' => $photo->updated_at, + 'owner_id' => $photo->owner_id, + 'album_id' => $photo->album_id ? $this->albumIDCache[strval($photo->album_id)] : null, + 'title' => $photo->title, + 'description' => empty($photo->description) ? null : $photo->description, + 'tags' => empty($photo->tags) ? null : $photo->tags, + 'license' => $photo->license, + 'is_public' => $photo->public, + 'is_starred' => $photo->star, + 'iso' => empty($photo->iso) ? null : $photo->iso, + 'make' => empty($photo->make) ? null : $photo->make, + 'model' => empty($photo->model) ? null : $photo->model, + 'lens' => empty($photo->lens) ? null : $photo->lens, + 'aperture' => empty($photo->aperture) ? null : $photo->aperture, + 'shutter' => empty($photo->shutter) ? null : $photo->shutter, + 'focal' => empty($photo->focal) ? null : $photo->focal, + 'latitude' => $photo->latitude, + 'longitude' => $photo->longitude, + 'altitude' => $photo->altitude, + 'img_direction' => empty($photo->imgDirection) ? null : $photo->imgDirection, + 'location' => empty($photo->location) ? null : $photo->location, + 'taken_at' => $photo->taken_at, + 'taken_at_orig_tz' => $photo->taken_at_orig_tz, + 'type' => $photo->type, + 'filesize' => $photo->filesize, + 'checksum' => $photo->checksum, + 'original_checksum' => $photo->checksum, + 'live_photo_short_path' => $photo->livePhotoUrl, + 'live_photo_content_id' => $photo->livePhotoContentID, + 'live_photo_checksum' => $photo->livePhotoChecksum, + ]); + + for ($variantType = self::VARIANT_ORIGINAL; $variantType <= self::VARIANT_THUMB; $variantType++) { + if ($this->hasSizeVariant($photo, $variantType)) { + DB::table('size_variants')->insert([ + 'photo_id' => $newPhotoID, + 'type' => $variantType, + 'short_path' => $this->getShortPathOfPhoto($photo, $variantType), + 'width' => $this->getWidth($photo, $variantType), + 'height' => $this->getHeight($photo, $variantType), + ]); + } + } + } + + // Restore explicit covers of albums + $pgBar = $this->getProgressBar('albums (covered)'); + $coveredAlbums = DB::table('albums_tmp') + ->whereNotNull('cover_id') + ->where('smart', '=', false) + ->lazyById(); + $pgBar->setMaxSteps($coveredAlbums->count()); + foreach ($coveredAlbums as $coveredAlbum) { + $pgBar->advance(); + DB::table('albums') + ->where('id', '=', $this->albumIDCache[strval($coveredAlbum->id)]) + ->update(['cover_id' => $this->photoIDCache[strval($coveredAlbum->cover_id)]]); + } + } + + /** + * @throws InvalidArgumentException + */ + private function downgradeCopy(): void + { + $pgBar = $this->getProgressBar('users'); + $users = DB::table('users_tmp')->get(); + $pgBar->setMaxSteps($users->count()); + foreach ($users as $user) { + $pgBar->advance(); + DB::table('users')->insert([ + 'id' => $user->id, + 'created_at' => $user->created_at, + 'updated_at' => $user->updated_at, + 'username' => $user->username, + 'password' => $user->password, + 'email' => $user->email, + 'upload' => $user->may_upload, + 'lock' => $user->is_locked, + 'remember_token' => $user->remember_token, + ]); + } + + $pgBar = $this->getProgressBar('base_albums'); + $baseAlbums = DB::table('base_albums')->lazyById(); + $pgBar->setMaxSteps($baseAlbums->count()); + $mapSorting = function (?string $sortingCol): ?string { + if (empty($sortingCol)) { + return null; + } elseif ($sortingCol === 'created_at') { + return 'id'; + } elseif ($sortingCol === 'is_public') { + return 'public'; + } elseif ($sortingCol === 'is_starred') { + return 'star'; + } else { + return $sortingCol; + } + }; + foreach ($baseAlbums as $oldBaseAlbum) { + $pgBar->advance(); + $legacyAlbumID = intval($oldBaseAlbum->legacy_id); + $this->albumIDCache[$oldBaseAlbum->id] = $legacyAlbumID; + + DB::table('albums')->insert([ + 'id' => $legacyAlbumID, + 'created_at' => $oldBaseAlbum->created_at, + 'updated_at' => $oldBaseAlbum->updated_at, + 'title' => $oldBaseAlbum->title, + 'description' => empty($oldBaseAlbum->description) ? '' : $oldBaseAlbum->description, + 'owner_id' => $oldBaseAlbum->owner_id, + 'public' => $oldBaseAlbum->is_public, + 'full_photo' => $oldBaseAlbum->grants_full_photo, + 'viewable' => !($oldBaseAlbum->requires_link), + 'downloadable' => $oldBaseAlbum->is_downloadable, + 'share_button_visible' => $oldBaseAlbum->is_share_button_visible, + 'nsfw' => $oldBaseAlbum->is_nsfw, + 'password' => empty($oldBaseAlbum->password) ? null : $oldBaseAlbum->password, + 'sorting_col' => $mapSorting($oldBaseAlbum->sorting_col), + 'sorting_order' => empty($oldBaseAlbum->sorting_col) ? null : $oldBaseAlbum->sorting_order, + ]); + } + + // Ordering by `_lft` is important, because we must copy parent + // albums first. + // Otherwise, foreign key constraint to `parent_id` may fail. + // Also, don't copy `cover_id` yet, because the photos have not been + // copied yet. + // Explicit `cover_id` needs to be set belated. + $pgBar = $this->getProgressBar('albums'); + $albums = DB::table('albums_tmp')->orderBy('_lft')->lazyById(); + $pgBar->setMaxSteps($albums->count()); + foreach ($albums as $album) { + $pgBar->advance(); + DB::table('albums') + ->where('id', '=', $this->albumIDCache[$album->id]) + ->update([ + 'smart' => false, + 'parent_id' => $album->parent_id ? $this->albumIDCache[$album->parent_id] : null, + 'license' => $album->license, + 'cover_id' => null, + '_lft' => $album->_lft, + '_rgt' => $album->_rgt, + ]); + } + + $pgBar = $this->getProgressBar('tag_albums'); + $tagAlbums = DB::table('tag_albums')->lazyById(); + $pgBar->setMaxSteps($tagAlbums->count()); + foreach ($tagAlbums as $tagAlbum) { + $pgBar->advance(); + DB::table('albums') + ->where('id', '=', $this->albumIDCache[$tagAlbum->id]) + ->update([ + 'smart' => true, + 'showtags' => $tagAlbum->show_tags, + ]); + } + + RefactorAlbumModel_AlbumModel::query()->fixTree(); + + $pgBar = $this->getProgressBar('user_album'); + $userBaseAlbumRelations = DB::table('user_base_album')->lazyById(); + $pgBar->setMaxSteps($userBaseAlbumRelations->count()); + foreach ($userBaseAlbumRelations as $userBaseAlbumRelation) { + $pgBar->advance(); + DB::table('user_album')->insert([ + 'id' => $userBaseAlbumRelation->id, + 'user_id' => $userBaseAlbumRelation->user_id, + 'album_id' => $this->albumIDCache[$userBaseAlbumRelation->base_album_id], + ]); + } + + $pgBar = $this->getProgressBar('photos'); + $photos = DB::table('photos_tmp')->lazyById(); + $pgBar->setMaxSteps($photos->count()); + foreach ($photos as $photo) { + $pgBar->advance(); + $legacyPhotoID = intval($photo->legacy_id); + $this->photoIDCache[$photo->id] = $legacyPhotoID; + $photoAttributes = [ + 'id' => $legacyPhotoID, + 'created_at' => $photo->created_at, + 'updated_at' => $photo->updated_at, + 'owner_id' => $photo->owner_id, + 'album_id' => $photo->album_id ? $this->albumIDCache[$photo->album_id] : null, + 'title' => $photo->title, + 'description' => empty($photo->description) ? '' : $photo->description, + 'tags' => empty($photo->tags) ? '' : $photo->tags, + 'license' => $photo->license, + 'public' => $photo->is_public, + 'star' => $photo->is_starred, + 'iso' => empty($photo->iso) ? '' : $photo->iso, + 'make' => empty($photo->make) ? '' : $photo->make, + 'model' => empty($photo->model) ? '' : $photo->model, + 'lens' => empty($photo->lens) ? '' : $photo->lens, + 'aperture' => empty($photo->aperture) ? '' : $photo->aperture, + 'shutter' => empty($photo->shutter) ? '' : $photo->shutter, + 'focal' => empty($photo->focal) ? '' : $photo->focal, + 'latitude' => $photo->latitude, + 'longitude' => $photo->longitude, + 'altitude' => $photo->altitude, + 'imgDirection' => $photo->img_direction, + 'location' => empty($photo->location) ? null : $photo->location, + 'taken_at' => $photo->taken_at, + 'taken_at_orig_tz' => $photo->taken_at_orig_tz, + 'type' => $photo->type, + 'filesize' => $photo->filesize, + 'checksum' => $photo->original_checksum, + 'livePhotoUrl' => $photo->live_photo_short_path, + 'livePhotoContentID' => $photo->live_photo_content_id, + 'livePhotoChecksum' => $photo->live_photo_checksum, + ]; + + // Get all size variants for the photo and explicitly extract + // the size variant "original". + // If there are no size variants at all or a size variant + // "original" does not exist, continue. + // Note, this is actually an error, because there must not + // be any photo without at least a size variant "original". + $sizeVariants = DB::table('size_variants') + ->where('photo_id', '=', $photo->id) + ->orderBy('type') + ->get(); + if ($sizeVariants->isEmpty()) { + continue; + } + $originalSizeVariant = $sizeVariants->first(); + if ($originalSizeVariant->type != self::VARIANT_ORIGINAL) { + continue; + } + + // We use the original size variant as a baseline to extract the + // common core of the basename of all size variants. + // Note: The newly introduced `SizeVariantNamingStrategy` + // effectively allows that each size variant uses its own file + // name which may be completely independent of the file names of + // the other size variants. + // However, the old code assumes that the file names follow a + // certain naming pattern which is built around a shared and + // equal part within the file's basename. + // Moreover, this common portion must not be longer than 32 + // characters. + $expectedBasename = substr( + pathinfo($originalSizeVariant->short_path, PATHINFO_FILENAME), + 0, + 32 + ); + + /** + * Iterate over all size variants and ensure that they are named + * as expected by the old naming scheme. + * + * @var object $sizeVariant + */ + foreach ($sizeVariants as $sizeVariant) { + $fileExtension = '.' . pathinfo($sizeVariant->short_path, PATHINFO_EXTENSION); + if ( + $sizeVariant->type == self::VARIANT_THUMB2X || + $sizeVariant->type == self::VARIANT_SMALL2X || + $sizeVariant->type == self::VARIANT_MEDIUM2X + ) { + $expectedFilename = $expectedBasename . '@2x' . $fileExtension; + } else { + $expectedFilename = $expectedBasename . $fileExtension; + } + $expectedPathPrefix = self::VARIANT_2_PATH_PREFIX[$sizeVariant->type] . '/'; + if ($sizeVariant->type == self::VARIANT_ORIGINAL && $this->isRaw($photo)) { + $expectedPathPrefix = 'raw/'; + } + $expectedShortPath = $expectedPathPrefix . $expectedFilename; + + // Ensure that the size variant is stored at the location which + // is expected acc. to the old naming scheme + if ($sizeVariant->short_path != $expectedShortPath) { + try { + Storage::move($sizeVariant->short_path, $expectedShortPath); + } catch (FileNotFoundException $e) { + // sic! just ignore + // This exception is thrown if there are duplicate + // photos which point to the same physical file. + // Then the file is renamed when the first occurrence + // of those duplicates is processed and subsequent, + // failing attempts to rename the file must be ignored. + } + } + + if ($sizeVariant->type == self::VARIANT_THUMB2X) { + $photoAttributes['thumb2x'] = true; + } elseif ($sizeVariant->type == self::VARIANT_THUMB) { + $photoAttributes['thumbUrl'] = $expectedFilename; + } else { + if ($sizeVariant->type == self::VARIANT_ORIGINAL) { + $photoAttributes['url'] = $expectedFilename; + } + $photoAttributes[self::VARIANT_2_WIDTH_ATTRIBUTE[$sizeVariant->type]] = $sizeVariant->width; + $photoAttributes[self::VARIANT_2_HEIGHT_ATTRIBUTE[$sizeVariant->type]] = $sizeVariant->height; + } + } + + DB::table('photos')->insert($photoAttributes); + } + + // Restore explicit covers of albums + $pgBar = $this->getProgressBar('albums (covered)'); + $coveredAlbums = DB::table('albums_tmp') + ->whereNotNull('cover_id') + ->lazyById(); + $pgBar->setMaxSteps($coveredAlbums->count()); + foreach ($coveredAlbums as $coveredAlbum) { + $pgBar->advance(); + DB::table('albums') + ->where('id', '=', $this->albumIDCache[$coveredAlbum->id]) + ->update(['cover_id' => $this->photoIDCache[$coveredAlbum->cover_id]]); + } + } + + /** + * Copies those table which have not changed structurally, but whose + * date/time precision has changed. + * + * @return void + */ + private function copyStructurallyUnchangedTables(): void + { + $pgBar = $this->getProgressBar('web_authn_credentials'); + $credentials = DB::table('web_authn_credentials_tmp')->get(); + $pgBar->setMaxSteps($credentials->count()); + foreach ($credentials as $credential) { + $pgBar->advance(); + DB::table('web_authn_credentials')->insert([ + 'id' => $credential->id, + 'created_at' => $credential->created_at, + 'updated_at' => $credential->updated_at, + 'disabled_at' => $credential->disabled_at, + 'user_id' => $credential->user_id, + 'name' => $credential->name, + 'type' => $credential->type, + 'transports' => $credential->transports, + 'attestation_type' => $credential->attestation_type, + 'trust_path' => $credential->trust_path, + 'aaguid' => $credential->aaguid, + 'public_key' => $credential->public_key, + 'counter' => $credential->counter, + 'user_handle' => $credential->user_handle, + ]); + } + + $pgBar = $this->getProgressBar('pages'); + $pages = DB::table('pages_tmp')->get(); + $pgBar->setMaxSteps($pages->count()); + foreach ($pages as $page) { + $pgBar->advance(); + DB::table('pages')->insert([ + 'id' => $page->id, + 'created_at' => $page->created_at, + 'updated_at' => $page->updated_at, + 'title' => $page->title, + 'menu_title' => $page->menu_title, + 'in_menu' => $page->in_menu, + 'enabled' => $page->enabled, + 'link' => $page->link, + 'order' => $page->order, + ]); + } + + $pgBar = $this->getProgressBar('page_contents'); + $pageContents = DB::table('page_contents_tmp')->get(); + $pgBar->setMaxSteps($pageContents->count()); + foreach ($pageContents as $pageContent) { + $pgBar->advance(); + DB::table('page_contents')->insert([ + 'id' => $pageContent->id, + 'created_at' => $pageContent->created_at, + 'updated_at' => $pageContent->updated_at, + 'page_id' => $pageContent->page_id, + 'content' => $pageContent->content, + 'class' => $pageContent->class, + 'type' => $pageContent->type, + 'order' => $pageContent->order, + ]); + } + + $pgBar = $this->getProgressBar('logs'); + $logs = DB::table('logs_tmp')->get(); + $pgBar->setMaxSteps($logs->count()); + foreach ($logs as $log) { + $pgBar->advance(); + DB::table('logs')->insert([ + 'id' => $log->id, + 'created_at' => $log->created_at, + 'updated_at' => $log->updated_at, + 'type' => $log->type, + 'function' => $log->function, + 'line' => $log->line, + 'text' => $log->text, + ]); + } + } + + /** + * Upgrades the configuration of default ordering to the new column names. + * + * @throws InvalidArgumentException + */ + private function upgradeConfig(): void + { + DB::table('configs') + ->where('key', '=', 'sorting_Photos_col') + ->update(['type_range' => 'created_at|taken_at|title|description|is_public|is_starred|type']); + DB::table('configs') + ->where('key', '=', 'sorting_Photos_col') + ->where('value', '=', 'id') + ->update(['value' => 'created_at']); + DB::table('configs') + ->where('key', '=', 'sorting_Photos_col') + ->where('value', '=', 'public') + ->update(['value' => 'is_public']); + DB::table('configs') + ->where('key', '=', 'sorting_Photos_col') + ->where('value', '=', 'star') + ->update(['value' => 'is_starred']); + DB::table('configs') + ->where('key', '=', 'sorting_Albums_col') + ->update(['type_range' => 'created_at|title|description|is_public|max_taken_at|min_taken_at']); + DB::table('configs') + ->where('key', '=', 'sorting_Albums_col') + ->where('value', '=', 'id') + ->update(['value' => 'created_at']); + DB::table('configs') + ->where('key', '=', 'sorting_Albums_col') + ->where('value', '=', 'public') + ->update(['value' => 'is_public']); + DB::table('configs') + ->insert([ + 'key' => 'legacy_id_redirection', + 'value' => '1', + 'cat' => 'config', + 'confidentiality' => 0, + 'type_range' => '0|1', + 'description' => 'Enables/disables the redirection support for legacy IDs', + ]); + } + + /** + * Downgrades the configuration of default ordering to the new column names. + * + * @throws InvalidArgumentException + */ + private function downgradeConfig(): void + { + DB::table('configs') + ->where('key', '=', 'sorting_Photos_col') + ->update(['type_range' => 'id|taken_at|title|description|public|star|type']); + DB::table('configs') + ->where('key', '=', 'sorting_Photos_col') + ->where('value', '=', 'created_at') + ->update(['value' => 'id']); + DB::table('configs') + ->where('key', '=', 'sorting_Photos_col') + ->where('value', '=', 'is_public') + ->update(['value' => 'public']); + DB::table('configs') + ->where('key', '=', 'sorting_Photos_col') + ->where('value', '=', 'is_starred') + ->update(['value' => 'star']); + DB::table('configs') + ->where('key', '=', 'sorting_Albums_col') + ->update(['type_range' => 'id|title|description|public|max_taken_at|min_taken_at']); + DB::table('configs') + ->where('key', '=', 'sorting_Albums_col') + ->where('value', '=', 'created_at') + ->update(['value' => 'id']); + DB::table('configs') + ->where('key', '=', 'sorting_Albums_col') + ->where('value', '=', 'is_public') + ->update(['value' => 'public']); + DB::table('configs') + ->where('key', '=', 'legacy_id_redirection') + ->delete(); + } + + /** + * Returns the short path of a picture file for the designated size + * variant from an old-style photo wrt. to the old naming scheme. + * + * @param object $photo an object with attributes of the old photo table + * + * @return string the short path + * + * @throws InvalidArgumentException + */ + public function getShortPathOfPhoto(object $photo, int $variant): string + { + $origFilename = $photo->url; + $thumbFilename = $photo->thumbUrl; + $thumbFilename2x = $this->add2xToFilename($thumbFilename); + $otherFilename = ($this->isVideo($photo) || $this->isRaw($photo)) ? $thumbFilename : $origFilename; + $otherFilename2x = $this->add2xToFilename($otherFilename); + switch ($variant) { + case self::VARIANT_THUMB: + $filename = $thumbFilename; + break; + case self::VARIANT_THUMB2X: + $filename = $thumbFilename2x; + break; + case self::VARIANT_SMALL: + case self::VARIANT_MEDIUM: + $filename = $otherFilename; + break; + case self::VARIANT_SMALL2X: + case self::VARIANT_MEDIUM2X: + $filename = $otherFilename2x; + break; + case self::VARIANT_ORIGINAL: + $filename = $origFilename; + break; + default: + throw new InvalidArgumentException('Invalid size variant: ' . $variant); + } + $directory = self::VARIANT_2_PATH_PREFIX[$variant] . '/'; + if ($variant === self::VARIANT_ORIGINAL && $this->isRaw($photo)) { + $directory = 'raw/'; + } + + return $directory . $filename; + } + + protected function isVideo(object $photo): bool + { + return in_array($photo->type, self::VALID_VIDEO_TYPES, true); + } + + protected function isRaw(object $photo): bool + { + return $photo->type == 'raw'; + } + + /** + * Given a filename generates the @2x corresponding filename. + * This is used for thumbs, small and medium. + */ + protected function add2xToFilename(string $filename): string + { + $filename2x = explode('.', $filename); + + return (count($filename2x) === 2) ? + $filename2x[0] . '@2x.' . $filename2x[1] : + $filename2x[0] . '@2x'; + } + + /** + * @throws InvalidArgumentException + */ + protected function getWidth(object $photo, int $variant): int + { + switch ($variant) { + case self::VARIANT_THUMB: + return self::THUMBNAIL_DIM; + case self::VARIANT_THUMB2X: + return self::THUMBNAIL2X_DIM; + case self::VARIANT_SMALL: + return $photo->small_width ?: 0; + case self::VARIANT_SMALL2X: + return $photo->small2x_width ?: 0; + case self::VARIANT_MEDIUM: + return $photo->medium_width ?: 0; + case self::VARIANT_MEDIUM2X: + return $photo->medium2x_width ?: 0; + case self::VARIANT_ORIGINAL: + return $photo->width; + default: + throw new InvalidArgumentException('Invalid size variant: ' . $variant); + } + } + + /** + * @throws InvalidArgumentException + */ + protected function getHeight(object $photo, int $variant): int + { + switch ($variant) { + case self::VARIANT_THUMB: + return self::THUMBNAIL_DIM; + case self::VARIANT_THUMB2X: + return self::THUMBNAIL2X_DIM; + case self::VARIANT_SMALL: + return $photo->small_height ?: 0; + case self::VARIANT_SMALL2X: + return $photo->small2x_height ?: 0; + case self::VARIANT_MEDIUM: + return $photo->medium_height ?: 0; + case self::VARIANT_MEDIUM2X: + return $photo->medium2x_height ?: 0; + case self::VARIANT_ORIGINAL: + return $photo->height; + default: + throw new InvalidArgumentException('Invalid size variant: ' . $variant); + } + } + + /** + * @throws InvalidArgumentException + */ + protected function hasSizeVariant(object $photo, int $variantType): bool + { + if ($variantType == self::VARIANT_ORIGINAL || $variantType == self::VARIANT_THUMB) { + return true; + } elseif ($variantType == self::VARIANT_THUMB2X) { + return (bool) ($photo->thumb2x); + } else { + return $this->getWidth($photo, $variantType) != 0; + } + } + + /** + * A helper function that allows to drop an index if exists. + * + * @param Blueprint $table + * @param string $indexName + * + * @throws DBALException + */ + private function dropIndexIfExists(Blueprint $table, string $indexName) + { + $doctrineTable = $this->schemaManager->listTableDetails($table->getTable()); + if ($doctrineTable->hasIndex($indexName)) { + $table->dropIndex($indexName); + } + } + + /** + * A helper function that allows to drop an index if exists. + * + * @param Blueprint $table + * @param string $indexName + * + * @throws DBALException + */ + private function dropUniqueIfExists(Blueprint $table, string $indexName) + { + $doctrineTable = $this->schemaManager->listTableDetails($table->getTable()); + if ($doctrineTable->hasIndex($indexName)) { + $table->dropUnique($indexName); + } + } + + /** + * A helper function that allows to drop an index if exists. + * + * @param Blueprint $table + * @param string $indexName + * + * @throws DBALException + */ + private function dropForeignIfExists(Blueprint $table, string $indexName) + { + if ($this->driverName === 'sqlite') { + return; + } + $doctrineTable = $this->schemaManager->listTableDetails($table->getTable()); + if ($doctrineTable->hasForeignKey($indexName)) { + $table->dropForeign($indexName); + } + } + + private function generateKey(): string + { + // URl-compatible variant of base64 encoding + // `+` and `/` are replaced by `-` and `_`, resp. + // The other characters (a-z, A-Z, 0-9) are legal within an URL. + // As the number of bytes is divisible by 3, no trailing `=` occurs. + return strtr(base64_encode(random_bytes(3 * self::RANDOM_ID_LENGTH / 4)), '+/', '-_'); + } + + /** + * Converts a legacy ID to a Carbon instance. + * + * The method handles 32bit and 64bit integers with second and + * 1/10 millisecond resolution. + * + * @param int $id + * + * @return Carbon + * + * @throws OutOfBoundsException thrown, if `$id` is out of reasonable bounds + */ + private function convertLegacyIdToTime(int $id): Carbon + { + // Typically, the legacy ID should have either + // + // - 10 digits for 32bit platforms, or + // - 14 digits (for 64bit platforms). + // + // On 32bit platforms, the ID indicates the creation date in + // seconds since epoch. + // On 64bit platforms, the ID indicates the creation date in + // 1/10 of microseconds since epoch. + // This means we have four decimal digits of additional precision. + // + // Unfortunately, due to a bug in Lychee at some time, trailing zeros + // were stripped off. + // This means, the 2-digit number 16 might actually indicate + // the timestamp 1600000000 (Sep 13th, 2020) on a 32bit platform. + // Likewise, the 12-digit number 162033368845 might actually indicate + // the timestamp 16203336884500 (May 6h, 2021) on a 64bit platform. + // + // However, in any case we know that the integer part (measured in + // seconds since epoch) must have 10 digits. + // Any other value would not be reasonable, as 999,999,999 is a date + // in 2001 long before the birth of Lychee. + // Also, `self::BIRTH_OF_LYCHEE` is approx. one half of + // `self::MAX_SIGNED_32BIT_INT` (Jan 19th, 2038) which is far in the + // future. + // So, we can multiply/divide the id by ten for numbers which are too + // small/large and be safe that there is at most only a single + // value in the reasonable interval. + // For 32bit platforms we must take care of overflows for the + // multiplication, i.e. we must check for self::MAX_SIGNED_32BIT_INT) { + $id = (float) $id; + while ($id >= self::MAX_SIGNED_32BIT_INT) { + $id = $id / 10.0; + } + } + + if ($id <= self::BIRTH_OF_LYCHEE) { + throw new \OutOfBoundsException('ID-based creation time is out of reasonable bounds'); + } + + return Carbon::createFromTimestampUTC($id); + } + + /** + * Calculates the best creation time of a DB record. + * + * The method takes the (legacy) ID and the alleged creation date + * (as an SQL string) and returns an SQL string of the "best" creation + * time. + * The best creation time is either the converted, legacy ID as it + * provides a higher accuracy, or the original creation time, if the + * time based on the ID and the original creation time differ by more + * than 30 seconds. + * The latter is a safety measure in case someone has internally tweaked + * the IDs or the creation date, or if something is completely wrong + * with the timezone settings. + * + * @param int $legacyID the legacy ID of the record + * @param string $sqlCreatedAt the original creation time of the record (as an SQL string) + * + * @return string the improved creation time of the record (as an SQL string) + */ + private function calculateBestCreatedAt(int $legacyID, string $sqlCreatedAt): string + { + $result = $sqlCreatedAt; + + try { + try { + $originalCreatedAt = Carbon::createFromFormat( + 'Y-m-d H:i:s.u', $sqlCreatedAt, 'UTC' + ); + } catch (InvalidFormatException $e) { + $originalCreatedAt = Carbon::createFromFormat( + 'Y-m-d H:i:s', $sqlCreatedAt, 'UTC' + ); + } + + $idBasesCreatedAt = $this->convertLegacyIdToTime($legacyID); + $diff = $originalCreatedAt->diff($idBasesCreatedAt, true); + + if ($diff->y === 0 || $diff->m === 0 || $diff->d === 0 || $diff->h === 0 || $diff->i === 0 || $diff->s < 30) { + $result = $idBasesCreatedAt->format('Y-m-d H:i:s.u'); + } else { + throw new \RangeException('ID-based creation time and original creation time differ more than 30s'); + } + } catch (\RangeException $e) { + $this->printWarning( + 'Model ID ' . $legacyID . ' - ' . + class_basename($e) . ' - ' . $e->getMessage() + ); + } catch (\Throwable $e) { + $this->printError( + 'Model ID ' . $legacyID . ' - ' . + class_basename($e) . ' - ' . $e->getMessage() + ); + } + + return $result; + } +} + +/** + * Model class specific for this migration. + * + * Migrations are required to be also runnable in the future after the code + * base will have evolved. + * To this end, migrations must not rely on a specific implementation of + * models, because these models may change in the future, but the migration + * must conduct its task with respect to a table layout which was valid at + * the time when the migration was written. + * In conclusion, this implies that migration should not use models but use + * low-level DB queries when necessary. + * Unfortunately, we need the `fixTree()` algorithm and there is no + * implementation which uses low-level DB queries. + */ +class RefactorAlbumModel_AlbumModel extends Model implements Node +{ + use NodeTrait; + + protected $table = 'albums'; + + protected $keyType = 'string'; + + public $timestamps = false; +} diff --git a/database/migrations/2022_01_13_183131_bump_version040500.php b/database/migrations/2022_01_13_183131_bump_version040500.php new file mode 100644 index 00000000000..30c6c88afce --- /dev/null +++ b/database/migrations/2022_01_13_183131_bump_version040500.php @@ -0,0 +1,27 @@ +update(['value' => '040500']); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Configs::where('key', 'version')->update(['value' => '040400']); + } +} diff --git a/phpunit.xml b/phpunit.xml index b58d78d47e9..702f33d502b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -28,6 +28,7 @@ + diff --git a/public/.htaccess b/public/.htaccess index ff90ad3fbab..f1085389ffc 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -27,6 +27,8 @@ Options -Indexes php_value post_max_size 500M php_value upload_max_filesize 500M php_value max_file_uploads 100 + # A proper user agent is required by some web servers, when photos are imported via URL + php_value user_agent "Lychee/4 (https://lycheeorg.github.io/)" # --- diff --git a/public/Lychee-front b/public/Lychee-front index a0713054c5b..4818421284d 160000 --- a/public/Lychee-front +++ b/public/Lychee-front @@ -1 +1 @@ -Subproject commit a0713054c5b1d26ba80a470312d06805cde36343 +Subproject commit 4818421284d651a379641ccdbea2c8b4741898ae diff --git a/public/dist/frame.js b/public/dist/frame.js index b6ab787d782..97538efdddd 100644 --- a/public/dist/frame.js +++ b/public/dist/frame.js @@ -916,8 +916,9 @@ api.isTimeout = function (errorThrown, jqXHR) { return false; }; -api.post = function (fn, params, callback) { +api.post = function (fn, params, successCallback) { var responseProgressCB = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null; + var errorCallback = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : null; loadingBar.show(); @@ -934,17 +935,23 @@ api.post = function (fn, params, callback) { return false; } - callback(data); + if (successCallback) successCallback(data); }; var error = function error(jqXHR, textStatus, errorThrown) { + if (errorCallback) { + var isHandled = errorCallback(jqXHR); + if (isHandled) return; + } + // Call global error handler for unhandled errors api.onError(api.isTimeout(errorThrown, jqXHR) ? "Session timed out." : "Server error or API not found.", params, errorThrown); }; var ajaxParams = { type: "POST", url: api_url, - data: params, + contentType: "application/json", + data: JSON.stringify(params), dataType: "json", success: success, error: error @@ -1125,26 +1132,26 @@ frame.next = function () { frame.refreshPicture = function () { api.post("Photo::getRandom", {}, function (data) { - if (!data.url && (data.sizeVariants === null || data.sizeVariants.medium === null)) { + if (data.size_variants === null || data.size_variants.original === null && data.size_variants.medium === null) { console.log("URL not found"); } - if (data.sizeVariants.thumb === null) console.log("Thumb not found"); + if (data.size_variants.thumb === null) console.log("Thumb not found"); - $("#background").attr("src", data.sizeVariants.thumb.url); + $("#background").attr("src", data.size_variants.thumb.url); var srcset = ""; var src = ""; this.frame.photo = null; - if (data.sizeVariants.medium !== null) { - src = data.sizeVariants.medium.url; + if (data.size_variants.medium !== null) { + src = data.size_variants.medium.url; - if (data.sizeVariants.medium2x !== null) { - srcset = data.sizeVariants.medium.url + " " + data.sizeVariants.medium.width + "w, " + data.sizeVariants.medium2x.url + " " + data.sizeVariants.medium2x.width + "w"; + if (data.size_variants.medium2x !== null) { + srcset = data.size_variants.medium.url + " " + data.size_variants.medium.width + "w, " + data.size_variants.medium2x.url + " " + data.size_variants.medium2x.width + "w"; // We use it in the resize callback. this.frame.photo = data; } } else { - src = data.url; + src = data.size_variants.original.url; } $("#picture").attr("srcset", srcset); @@ -1166,7 +1173,7 @@ frame.set = function (data) { frame.resize = function () { if (this.photo) { - var ratio = this.photo.height > 0 ? this.photo.width / this.photo.height : 1; + var ratio = this.photo.size_variants.original.height > 0 ? this.photo.size_variants.original.width / this.photo.size_variants.original.height : 1; var winWidth = $(window).width(); var winHeight = $(window).height(); diff --git a/public/dist/main.js b/public/dist/main.js index e06392c3c53..a6aac18766c 100644 --- a/public/dist/main.js +++ b/public/dist/main.js @@ -304,7 +304,7 @@ var _templateObject = _taggedTemplateLiteral(["

", " ", "

"], ["

", "

"]), _templateObject8 = _taggedTemplateLiteral(["\n\t
\n\t\t

", "\n\t\t\n\t\t\t\n\t\t\n\t\t
\n\t\t", "\n\t\t

\n\t
"], ["\n\t
\n\t\t

", "\n\t\t\n\t\t\t\n\t\t\n\t\t
\n\t\t", "\n\t\t

\n\t
"]), _templateObject9 = _taggedTemplateLiteral(["\n\t
\n\t\t

"], ["\n\t

\n\t\t

"]), - _templateObject10 = _taggedTemplateLiteral(["\n\t\t\t

\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t

\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t
\n\t\t"], ["\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t

\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t
\n\t\t"]), + _templateObject10 = _taggedTemplateLiteral(["\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t

\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t
\n\t\t"], ["\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t

\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t

", "

\n\t\t\t\t
\n\t\t\t
\n\t\t"]), _templateObject11 = _taggedTemplateLiteral(["
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t
"], ["
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t
"]), _templateObject12 = _taggedTemplateLiteral(["?albumIDs=", ""], ["?albumIDs=", ""]), _templateObject13 = _taggedTemplateLiteral(["

", " '$", "' ", " '$", "'?

"], ["

", " '$", "' ", " '$", "'?

"]), @@ -350,8 +350,8 @@ var _templateObject = _taggedTemplateLiteral(["

", " ", " ", " ", "

"], ["

", " ", " ", "

"]), _templateObject54 = _taggedTemplateLiteral([""], [""]), _templateObject55 = _taggedTemplateLiteral(["

", " ", " ", " ", "

"], ["

", " ", " ", " ", "

"]), - _templateObject56 = _taggedTemplateLiteral(["\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t"], ["\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t"]), - _templateObject57 = _taggedTemplateLiteral(["\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t"], ["\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t"]), + _templateObject56 = _taggedTemplateLiteral(["\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t"], ["\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t"]), + _templateObject57 = _taggedTemplateLiteral(["\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t"], ["\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t\t
\n\t\t\t\n\t\t\t

", "

\n\t\t
\n\t"]), _templateObject58 = _taggedTemplateLiteral(["\n\t\t\t

", "

\n\t\t\t", "\n\t\t\t", "\n\t\t"], ["\n\t\t\t

", "

\n\t\t\t", "\n\t\t\t", "\n\t\t"]), _templateObject59 = _taggedTemplateLiteral(["\n\t\t\t", "\n\t\t\t

", "

\n\t\t\t", "\n\t\t"], ["\n\t\t\t", "\n\t\t\t

", "

\n\t\t\t", "\n\t\t"]), _templateObject60 = _taggedTemplateLiteral(["

", "

"], ["

", "

"]), @@ -376,7 +376,7 @@ var _templateObject = _taggedTemplateLiteral(["

", " \n\t\t\t

$", "\n\t\t\t\t \n\t\t\t\t \n\t\t\t

\n\t\t\t

$", "\n\t\t\t\t \n\t\t\t\t \n\t\t\t\t \n\t\t\t

\n\t\t\t
\n\t\t\t\t\n\t\t\t\t$", "\n\t\t\t
\n\t\t\t
"], ["\n\t\t\t
\n\t\t\t

$", "\n\t\t\t\t \n\t\t\t\t \n\t\t\t

\n\t\t\t

$", "\n\t\t\t\t \n\t\t\t\t \n\t\t\t\t \n\t\t\t

\n\t\t\t
\n\t\t\t\t\n\t\t\t\t$", "\n\t\t\t
\n\t\t\t
"]), - _templateObject82 = _taggedTemplateLiteral(["\n\t\t\t\t
\n\t\t\t\t\t

\n\t\t\t\t\t\t$", "\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t

\n\t\t\t\t\t

\n\t\t\t\t\t\t$", "\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t \t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t \t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t"], ["\n\t\t\t\t
\n\t\t\t\t\t

\n\t\t\t\t\t\t$", "\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t

\n\t\t\t\t\t

\n\t\t\t\t\t\t$", "\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t \t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t \t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t"]), + _templateObject82 = _taggedTemplateLiteral(["\n\t\t\t\t
\n\t\t\t\t\t

\n\t\t\t\t\t\t$", "\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t

\n\t\t\t\t\t

\n\t\t\t\t\t\t$", "\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t \t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t \t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t"], ["\n\t\t\t\t
\n\t\t\t\t\t

\n\t\t\t\t\t\t$", "\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t

\n\t\t\t\t\t

\n\t\t\t\t\t\t$", "\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t \t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t \t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t

\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t$", "\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t"]), _templateObject83 = _taggedTemplateLiteral(["\n\t\t\t
\n\t\t\t\t", "\n\t\t\t
\n\t\t\t"], ["\n\t\t\t
\n\t\t\t\t", "\n\t\t\t
\n\t\t\t"]), _templateObject84 = _taggedTemplateLiteral(["\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t

\n\t\t\t\t", "\n\t\t\t\t

\n\t\t\t\t
\n\t\t\t\t"], ["\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t

\n\t\t\t\t", "\n\t\t\t\t

\n\t\t\t\t
\n\t\t\t\t"]), _templateObject85 = _taggedTemplateLiteral(["\n\t\t\t\t\t\t
\n\t\t\t\t\t\t

\n\t\t\t\t\t\t$", "\n\t\t\t\t\t\t

\n\t\t\t\t\t\t
"], ["\n\t\t\t\t\t\t
\n\t\t\t\t\t\t

\n\t\t\t\t\t\t$", "\n\t\t\t\t\t\t

\n\t\t\t\t\t\t
"]), @@ -413,8 +413,9 @@ api.isTimeout = function (errorThrown, jqXHR) { return false; }; -api.post = function (fn, params, callback) { +api.post = function (fn, params, successCallback) { var responseProgressCB = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null; + var errorCallback = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : null; loadingBar.show(); @@ -431,17 +432,23 @@ api.post = function (fn, params, callback) { return false; } - callback(data); + if (successCallback) successCallback(data); }; var error = function error(jqXHR, textStatus, errorThrown) { + if (errorCallback) { + var isHandled = errorCallback(jqXHR); + if (isHandled) return; + } + // Call global error handler for unhandled errors api.onError(api.isTimeout(errorThrown, jqXHR) ? "Session timed out." : "Server error or API not found.", params, errorThrown); }; var ajaxParams = { type: "POST", url: api_url, - data: params, + contentType: "application/json", + data: JSON.stringify(params), dataType: "json", success: success, error: error @@ -680,35 +687,39 @@ album.isSmartID = function (id) { return id === "unsorted" || id === "starred" || id === "public" || id === "recent"; }; -album.getParent = function () { - if (album.json == null || album.isSmartID(album.json.id) === true || !album.json.parent_id || album.json.parent_id === 0) { - return ""; +album.isModelID = function (id) { + return typeof id === "string" && id.length === 24; +}; + +album.getParentID = function () { + if (album.json == null || album.isSmartID(album.json.id) === true || !album.json.parent_id) { + return null; } return album.json.parent_id; }; +/** + * @return {?string} + */ album.getID = function () { var id = null; // this is a Lambda var isID = function isID(_id) { - if (album.isSmartID(_id)) { - return true; - } - return $.isNumeric(_id); + return album.isSmartID(_id) || album.isModelID(_id); }; - if (_photo.json) id = _photo.json.album;else if (album.json) id = album.json.id;else if (mapview.albumID) id = mapview.albumID; + if (_photo.json) id = _photo.json.album_id;else if (album.json) id = album.json.id;else if (mapview.albumID) id = mapview.albumID; // Search if (isID(id) === false) id = $(".album:hover, .album.active").attr("data-id"); if (isID(id) === false) id = $(".photo:hover, .photo.active").attr("data-album-id"); - if (isID(id) === true) return id;else return false; + if (isID(id) === true) return id;else return null; }; album.isTagAlbum = function () { - return album.json && album.json.tag_album && album.json.tag_album === "1"; + return album.json && album.json.is_tag_album && album.json.is_tag_album === true; }; album.getByID = function (photoID) { @@ -716,19 +727,19 @@ album.getByID = function (photoID) { if (photoID == null || !album.json || !album.json.photos) { lychee.error("Error: Album json not found !"); - return undefined; + return null; } var i = 0; while (i < album.json.photos.length) { - if (parseInt(album.json.photos[i].id) === parseInt(photoID)) { + if (album.json.photos[i].id === photoID) { return album.json.photos[i]; } i++; } lychee.error("Error: photo " + photoID + " not found !"); - return undefined; + return null; }; album.getSubByID = function (albumID) { @@ -741,7 +752,7 @@ album.getSubByID = function (albumID) { var i = 0; while (i < album.json.albums.length) { - if (parseInt(album.json.albums[i].id) === parseInt(albumID)) { + if (album.json.albums[i].id === albumID) { return album.json.albums[i]; } i++; @@ -761,7 +772,7 @@ album.deleteByID = function (photoID) { var deleted = false; $.each(album.json.photos, function (i) { - if (parseInt(album.json.photos[i].id) === parseInt(photoID)) { + if (album.json.photos[i].id === photoID) { album.json.photos.splice(i, 1); deleted = true; return false; @@ -781,7 +792,7 @@ album.deleteSubByID = function (albumID) { var deleted = false; $.each(album.json.albums, function (i) { - if (parseInt(album.json.albums[i].id) === parseInt(albumID)) { + if (album.json.albums[i].id === albumID) { album.json.albums.splice(i, 1); deleted = true; return false; @@ -800,25 +811,6 @@ album.load = function (albumID) { }; var processData = function processData(data) { - if (data === "Warning: Wrong password!") { - // User hit Cancel at the password prompt - return false; - } - - if (data === "Warning: Album private!") { - if (document.location.hash.replace("#", "").split("/")[1] !== undefined) { - // Display photo only - lychee.setMode("view"); - lychee.footer_hide(); - } else { - // Album not public - lychee.content.show(); - lychee.footer_show(); - if (!visible.albums() && !visible.album()) lychee.goto(); - } - return false; - } - album.json = data; if (refresh === false) { @@ -856,7 +848,24 @@ album.load = function (albumID) { }; api.post("Album::get", params, function (data) { - if (data === "Warning: Wrong password!") { + processData(data); + + tabindex.makeFocusable(lychee.content); + + if (lychee.active_focus_on_page_load) { + // Put focus on first element - either album or photo + first_album = $(".album:first"); + if (first_album.length !== 0) { + first_album.focus(); + } else { + first_photo = $(".photo:first"); + if (first_photo.length !== 0) { + first_photo.focus(); + } + } + } + }, null, function (jqXHR) { + if (jqXHR.status === 403) { password.getDialog(albumID, function () { params.password = password.value; @@ -865,24 +874,9 @@ album.load = function (albumID) { processData(_data); }); }); - } else { - processData(data); - - tabindex.makeFocusable(lychee.content); - - if (lychee.active_focus_on_page_load) { - // Put focus on first element - either album or photo - first_album = $(".album:first"); - if (first_album.length !== 0) { - first_album.focus(); - } else { - first_photo = $(".photo:first"); - if (first_photo.length !== 0) { - first_photo.focus(); - } - } - } + return true; } + return false; }); }; @@ -897,8 +891,8 @@ album.add = function () { var action = function action(data) { // let title = data.title; - var isNumber = function isNumber(n) { - return !isNaN(parseInt(n, 10)) && isFinite(n); + var isModelID = function isModelID(albumID) { + return typeof albumID === "string" && albumID.length === 24; }; if (!data.title.trim()) { @@ -910,24 +904,24 @@ album.add = function () { var params = { title: data.title, - parent_id: 0 + parent_id: null }; if (visible.albums() || album.isSmartID(album.json.id)) { - params.parent_id = 0; + params.parent_id = null; } else if (visible.album()) { params.parent_id = album.json.id; } else if (visible.photo()) { - params.parent_id = _photo.json.album; + params.parent_id = _photo.json.album_id; } api.post("Album::add", params, function (_data) { - if (_data !== false && isNumber(_data)) { + if (_data && isModelID(_data.id)) { if (IDs != null && callback != null) { callback(IDs, _data, false); // we do not confirm } else { albums.refresh(); - lychee.goto(_data); + lychee.goto(_data.id); } } else { lychee.error(null, params, _data); @@ -969,12 +963,12 @@ album.addByTags = function () { }; api.post("Album::addByTags", params, function (_data) { - var isNumber = function isNumber(n) { - return !isNaN(parseInt(n, 10)) && isFinite(n); + var isModelID = function isModelID(albumID) { + return typeof albumID === "string" && albumID.length === 24; }; - if (_data !== false && isNumber(_data)) { + if (_data && isModelID(_data.id)) { albums.refresh(); - lychee.goto(_data); + lychee.goto(_data.id); } else { lychee.error(null, params, _data); } @@ -1018,7 +1012,7 @@ album.setShowTags = function (albumID) { }; api.post("Album::setShowTags", params, function (_data) { - if (_data !== true) { + if (_data) { lychee.error(null, params, _data); } else { album.reload(); @@ -1053,7 +1047,7 @@ album.setTitle = function (albumIDs) { if (albumIDs.length === 1) { // Get old title if only one album is selected if (album.json) { - if (parseInt(album.getID()) === parseInt(albumIDs[0])) { + if (album.getID() === albumIDs[0]) { oldTitle = album.json.title; } else oldTitle = album.getSubByID(albumIDs[0]).title; } @@ -1074,7 +1068,7 @@ album.setTitle = function (albumIDs) { var newTitle = data.title; if (visible.album()) { - if (albumIDs.length === 1 && parseInt(album.getID()) === parseInt(albumIDs[0])) { + if (albumIDs.length === 1 && album.getID() === albumIDs[0]) { // Rename only one album album.json.title = newTitle; @@ -1107,7 +1101,7 @@ album.setTitle = function (albumIDs) { }; api.post("Album::setTitle", params, function (_data) { - if (_data !== true) { + if (_data) { lychee.error(null, params, _data); } }); @@ -1133,10 +1127,10 @@ album.setTitle = function (albumIDs) { }; album.setDescription = function (albumID) { - var oldDescription = album.json.description; + var oldDescription = album.json.description ? album.json.description : ""; var action = function action(data) { - var description = data.description; + var description = data.description ? data.description : null; basicModal.close(); @@ -1151,7 +1145,7 @@ album.setDescription = function (albumID) { }; api.post("Album::setDescription", params, function (_data) { - if (_data !== true) { + if (_data) { lychee.error(null, params, _data); } }); @@ -1175,7 +1169,7 @@ album.setDescription = function (albumID) { album.toggleCover = function (photoID) { if (!photoID) return false; - album.json.cover_id = album.json.cover_id === photoID ? "" : photoID; + album.json.cover_id = album.json.cover_id === photoID ? null : photoID; var params = { albumID: album.json.id, @@ -1183,11 +1177,11 @@ album.toggleCover = function (photoID) { }; api.post("Album::setCover", params, function (data) { - if (data !== true) { + if (data) { lychee.error(null, params, data); } else { view.album.content.cover(photoID); - if (!album.getParent()) { + if (!album.getParentID()) { albums.refresh(); } } @@ -1211,7 +1205,7 @@ album.setLicense = function (albumID) { }; api.post("Album::setLicense", params, function (_data) { - if (_data !== true) { + if (_data) { lychee.error(null, params, _data); } else { if (visible.album()) { @@ -1243,34 +1237,30 @@ album.setLicense = function (albumID) { album.setSorting = function (albumID) { var callback = function callback() { $("select#sortingCol").val(album.json.sorting_col); - $("select#sortingOrder").val(album.json.sorting_order); + $("select#sortingOrder").val(album.json.sorting_order === null ? "ASC" : album.json.sorting_order); return false; }; var action = function action(data) { - var typePhotos = data.sortingCol; - var orderPhotos = data.sortingOrder; + var sortingCol = data.sortingCol; + var sortingOrder = data.sortingOrder; basicModal.close(); var params = { albumID: albumID, - typePhotos: typePhotos, - orderPhotos: orderPhotos + sortingCol: sortingCol, + sortingOrder: sortingOrder }; api.post("Album::setSorting", params, function (_data) { - if (_data !== true) { - lychee.error(null, params, _data); - } else { - if (visible.album()) { - album.reload(); - } + if (visible.album()) { + album.reload(); } }); }; - var msg = lychee.html(_templateObject9) + lychee.locale["SORT_PHOTO_BY_1"] + "\n\t\t\n\t\t\t\n\t\t\n\t\t" + lychee.locale["SORT_PHOTO_BY_2"] + "\n\t\t\n\t\t\t\n\t\t\n\t\t" + lychee.locale["SORT_PHOTO_BY_3"] + "\n\t\t

\n\t
"; + var msg = lychee.html(_templateObject9) + lychee.locale["SORT_PHOTO_BY_1"] + "\n\t\t\n\t\t\t\n\t\t\n\t\t" + lychee.locale["SORT_PHOTO_BY_2"] + "\n\t\t\n\t\t\t\n\t\t\n\t\t" + lychee.locale["SORT_PHOTO_BY_3"] + "\n\t\t

\n\t
"; basicModal.show({ body: msg, @@ -1311,30 +1301,30 @@ album.setPublic = function (albumID, e) { } }); - $('.basicModal .switch input[name="public"]').on("click", function () { + $('.basicModal .switch input[name="is_public"]').on("click", function () { if ($(this).prop("checked") === true) { $(".basicModal .choice input").attr("disabled", false); - if (album.json.public === "1") { + if (album.json.is_public) { // Initialize options based on album settings. - if (album.json.full_photo !== null && album.json.full_photo === "1") $('.basicModal .choice input[name="full_photo"]').prop("checked", true); - if (album.json.visible === "0") $('.basicModal .choice input[name="hidden"]').prop("checked", true); - if (album.json.downloadable === "1") $('.basicModal .choice input[name="downloadable"]').prop("checked", true); - if (album.json.share_button_visible === "1") $('.basicModal .choice input[name="share_button_visible"]').prop("checked", true); - if (album.json.password === "1") { - $('.basicModal .choice input[name="password"]').prop("checked", true); + if (album.json.grants_full_photo) $('.basicModal .choice input[name="grants_full_photo"]').prop("checked", true); + if (album.json.requires_link) $('.basicModal .choice input[name="requires_link"]').prop("checked", true); + if (album.json.is_downloadable) $('.basicModal .choice input[name="is_downloadable"]').prop("checked", true); + if (album.json.is_share_button_visible) $('.basicModal .choice input[name="is_share_button_visible"]').prop("checked", true); + if (album.json.has_password) { + $('.basicModal .choice input[name="has_password"]').prop("checked", true); $('.basicModal .choice input[name="passwordtext"]').show(); } } else { // Initialize options based on global settings. - if (lychee.full_photo) { - $('.basicModal .choice input[name="full_photo"]').prop("checked", true); + if (lychee.grants_full_photo) { + $('.basicModal .choice input[name="grants_full_photo"]').prop("checked", true); } - if (lychee.downloadable) { - $('.basicModal .choice input[name="downloadable"]').prop("checked", true); + if (lychee.is_downloadable) { + $('.basicModal .choice input[name="is_downloadable"]').prop("checked", true); } - if (lychee.share_button_visible) { - $('.basicModal .choice input[name="share_button_visible"]').prop("checked", true); + if (lychee.is_share_button_visible) { + $('.basicModal .choice input[name="is_share_button_visible"]').prop("checked", true); } } } else { @@ -1343,19 +1333,19 @@ album.setPublic = function (albumID, e) { } }); - if (album.json.nsfw === "1") { - $('.basicModal .switch input[name="nsfw"]').prop("checked", true); + if (album.json.is_nsfw) { + $('.basicModal .switch input[name="is_nsfw"]').prop("checked", true); } else { - $('.basicModal .switch input[name="nsfw"]').prop("checked", false); + $('.basicModal .switch input[name="is_nsfw"]').prop("checked", false); } - if (album.json.public === "1") { - $('.basicModal .switch input[name="public"]').click(); + if (album.json.is_public) { + $('.basicModal .switch input[name="is_public"]').click(); } else { $(".basicModal .choice input").attr("disabled", true); } - $('.basicModal .choice input[name="password"]').on("change", function () { + $('.basicModal .choice input[name="has_password"]').on("change", function () { if ($(this).prop("checked") === true) $('.basicModal .choice input[name="passwordtext"]').show().focus();else $('.basicModal .choice input[name="passwordtext"]').hide(); }); @@ -1365,55 +1355,31 @@ album.setPublic = function (albumID, e) { albums.refresh(); // Set public - if ($('.basicModal .switch input[name="nsfw"]:checked').length === 1) { - album.json.nsfw = "1"; - } else { - album.json.nsfw = "0"; - } + album.json.is_nsfw = $('.basicModal .switch input[name="is_nsfw"]:checked').length === 1; // Set public - if ($('.basicModal .switch input[name="public"]:checked').length === 1) { - album.json.public = "1"; - } else { - album.json.public = "0"; - } + album.json.is_public = $('.basicModal .switch input[name="is_public"]:checked').length === 1; // Set full photo - if ($('.basicModal .choice input[name="full_photo"]:checked').length === 1) { - album.json.full_photo = "1"; - } else { - album.json.full_photo = "0"; - } + album.json.grants_full_photo = $('.basicModal .choice input[name="grants_full_photo"]:checked').length === 1; // Set visible - if ($('.basicModal .choice input[name="hidden"]:checked').length === 1) { - album.json.visible = "0"; - } else { - album.json.visible = "1"; - } + album.json.requires_link = $('.basicModal .choice input[name="requires_link"]:checked').length === 1; // Set downloadable - if ($('.basicModal .choice input[name="downloadable"]:checked').length === 1) { - album.json.downloadable = "1"; - } else { - album.json.downloadable = "0"; - } + album.json.is_downloadable = $('.basicModal .choice input[name="is_downloadable"]:checked').length === 1; // Set share_button_visible - if ($('.basicModal .choice input[name="share_button_visible"]:checked').length === 1) { - album.json.share_button_visible = "1"; - } else { - album.json.share_button_visible = "0"; - } + album.json.is_share_button_visible = $('.basicModal .choice input[name="is_share_button_visible"]:checked').length === 1; // Set password var oldPassword = album.json.password; - if ($('.basicModal .choice input[name="password"]:checked').length === 1) { + if ($('.basicModal .choice input[name="has_password"]:checked').length === 1) { password = $('.basicModal .choice input[name="passwordtext"]').val(); - album.json.password = "1"; + album.json.has_password = true; } else { password = ""; - album.json.password = "0"; + album.json.has_password = false; } // Modal input has been processed, now it can be closed @@ -1423,7 +1389,7 @@ album.setPublic = function (albumID, e) { if (visible.album()) { view.album.nsfw(); view.album.public(); - view.album.hidden(); + view.album.requiresLink(); view.album.downloadable(); view.album.shareButtonVisible(); view.album.password(); @@ -1431,12 +1397,12 @@ album.setPublic = function (albumID, e) { var params = { albumID: albumID, - full_photo: album.json.full_photo, - public: album.json.public, - nsfw: album.json.nsfw, - visible: album.json.visible, - downloadable: album.json.downloadable, - share_button_visible: album.json.share_button_visible + grants_full_photo: album.json.grants_full_photo, + is_public: album.json.is_public, + is_nsfw: album.json.is_nsfw, + requires_link: album.json.requires_link, + is_downloadable: album.json.is_downloadable, + is_share_button_visible: album.json.is_share_button_visible }; if (oldPassword !== album.json.password || password.length > 0) { // We send the password only if there's been a change; that way the @@ -1444,9 +1410,7 @@ album.setPublic = function (albumID, e) { params.password = password; } - api.post("Album::setPublic", params, function (data) { - if (data !== true) lychee.error(null, params, data); - }); + api.post("Album::setPublic", params, null); }; album.shareUsers = function (albumID, e) { @@ -1544,7 +1508,7 @@ album.shareUsers = function (albumID, e) { }; album.setNSFW = function (albumID, e) { - album.json.nsfw = album.json.nsfw === "0" ? "1" : "0"; + album.json.is_nsfw = !album.json.is_nsfw; view.album.nsfw(); @@ -1553,7 +1517,7 @@ album.setNSFW = function (albumID, e) { }; api.post("Album::setNSFW", params, function (data) { - if (data !== true) { + if (data) { lychee.error(null, params, data); } else { albums.refresh(); @@ -1562,7 +1526,7 @@ album.setNSFW = function (albumID, e) { }; album.share = function (service) { - if (album.json.hasOwnProperty("share_button_visible") && album.json.share_button_visible !== "1") { + if (album.json.hasOwnProperty("is_share_button_visible") && !album.json.is_share_button_visible) { return; } @@ -1591,10 +1555,10 @@ album.buildMessage = function (albumIDs, albumID, op1, op2, ops) { var msg = ""; if (!albumIDs) return false; - if (albumIDs instanceof Array === false) albumIDs = [albumIDs]; + if (!(albumIDs instanceof Array)) albumIDs = [albumIDs]; // Get title of first album - if (parseInt(albumID, 10) === 0) { + if (albumID === null) { title = lychee.locale["ROOT"]; } else { album1 = albums.getByID(albumID); @@ -1630,7 +1594,7 @@ album.delete = function (albumIDs) { var msg = ""; if (!albumIDs) return false; - if (albumIDs instanceof Array === false) albumIDs = [albumIDs]; + if (!(albumIDs instanceof Array)) albumIDs = [albumIDs]; action.fn = function () { basicModal.close(); @@ -1646,18 +1610,22 @@ album.delete = function (albumIDs) { albums.deleteByID(id); }); } else if (visible.album()) { - albums.refresh(); - if (albumIDs.length === 1 && album.getID() == albumIDs[0]) { - lychee.goto(album.getParent()); + if (albumIDs.toString() === "unsorted") { + album.reload(); } else { - albumIDs.forEach(function (id) { - album.deleteSubByID(id); - view.album.content.deleteSub(id); - }); + albums.refresh(); + if (albumIDs.length === 1 && album.getID() == albumIDs[0]) { + lychee.goto(album.getParentID()); + } else { + albumIDs.forEach(function (id) { + album.deleteSubByID(id); + view.album.content.deleteSub(id); + }); + } } } - if (data !== true) lychee.error(null, params, data); + if (typeof data !== "undefined") lychee.error(null, params, data); }); }; @@ -1674,12 +1642,12 @@ album.delete = function (albumIDs) { // Get title if (album.json) { - if (parseInt(album.getID()) === parseInt(albumIDs[0])) { + if (album.getID() === albumIDs[0]) { albumTitle = album.json.title; } else albumTitle = album.getSubByID(albumIDs[0]).title; } if (!albumTitle) { - var _a3 = albums.getByID(albumIDs); + var _a3 = albums.getByID(albumIDs[0]); if (_a3) albumTitle = _a3.title; } @@ -1715,14 +1683,14 @@ album.merge = function (albumIDs, albumID) { var action = function action() { basicModal.close(); - albumIDs.unshift(albumID); var params = { + albumID: albumID, albumIDs: albumIDs.join() }; api.post("Album::merge", params, function (data) { - if (data !== true) { + if (data) { lychee.error(null, params, data); } else { album.reload(); @@ -1755,14 +1723,14 @@ album.setAlbum = function (albumIDs, albumID) { var action = function action() { basicModal.close(); - albumIDs.unshift(albumID); var params = { + albumID: albumID, albumIDs: albumIDs.join() }; api.post("Album::move", params, function (data) { - if (data !== true) { + if (data) { lychee.error(null, params, data); } else { album.reload(); @@ -1808,17 +1776,17 @@ album.isUploadable = function () { if (lychee.admin) { return true; } - if (lychee.publicMode || !lychee.upload) { + if (lychee.publicMode || !lychee.may_upload) { return false; } // For special cases of no album / smart album / etc. we return true. // It's only for regular non-matching albums that we return false. - if (album.json === null || !album.json.owner) { + if (album.json === null || !album.json.owner_name) { return true; } - return album.json.owner === lychee.username; + return album.json.owner_name === lychee.username; }; album.updatePhoto = function (data) { @@ -1834,26 +1802,25 @@ album.updatePhoto = function (data) { if (album.json) { $.each(album.json.photos, function () { if (this.id === data.id) { - this.width = data.width; - this.height = data.height; - this.url = data.url; this.filesize = data.filesize; // Deep copy size variants - this.sizeVariants = { + this.size_variants = { thumb: null, thumb2x: null, small: null, small2x: null, medium: null, - medium2x: null + medium2x: null, + original: null }; - if (data.sizeVariants !== undefined && data.sizeVariants !== null) { - this.sizeVariants.thumb = deepCopySizeVariant(data.sizeVariants.thumb); - this.sizeVariants.thumb2x = deepCopySizeVariant(data.sizeVariants.thumb2x); - this.sizeVariants.small = deepCopySizeVariant(data.sizeVariants.small); - this.sizeVariants.small2x = deepCopySizeVariant(data.sizeVariants.small2x); - this.sizeVariants.medium = deepCopySizeVariant(data.sizeVariants.medium); - this.sizeVariants.medium2x = deepCopySizeVariant(data.sizeVariants.medium2x); + if (data.size_variants !== undefined && data.size_variants !== null) { + this.size_variants.thumb = deepCopySizeVariant(data.size_variants.thumb); + this.size_variants.thumb2x = deepCopySizeVariant(data.size_variants.thumb2x); + this.size_variants.small = deepCopySizeVariant(data.size_variants.small); + this.size_variants.small2x = deepCopySizeVariant(data.size_variants.small2x); + this.size_variants.medium = deepCopySizeVariant(data.size_variants.medium); + this.size_variants.medium2x = deepCopySizeVariant(data.size_variants.medium2x); + this.size_variants.original = deepCopySizeVariant(data.size_variants.original); } view.album.content.updatePhoto(this); albums.refresh(); @@ -1895,7 +1862,7 @@ albums.load = function () { var waitTime = void 0; // Smart Albums - if (data.smartalbums != null) albums._createSmartAlbums(data.smartalbums); + if (data.smart_albums != null) albums._createSmartAlbums(data.smart_albums); albums.json = data; @@ -1960,9 +1927,9 @@ albums.parse = function (album) { if (!album.thumb) { album.thumb = {}; album.thumb.id = ""; - album.thumb.thumb = album.password === "1" ? "img/password.svg" : "img/no_images.svg"; + album.thumb.thumb = album.has_password ? "img/password.svg" : "img/no_images.svg"; album.thumb.type = ""; - album.thumb.thumb2x = ""; + album.thumb.thumb2x = null; } }; @@ -1973,7 +1940,7 @@ albums._createSmartAlbums = function (data) { id: "unsorted", title: lychee.locale["UNSORTED"], created_at: null, - unsorted: "1", + is_unsorted: true, thumb: data.unsorted.thumb }; } @@ -1983,7 +1950,7 @@ albums._createSmartAlbums = function (data) { id: "starred", title: lychee.locale["STARRED"], created_at: null, - star: "1", + is_starred: true, thumb: data.starred.thumb }; } @@ -1993,8 +1960,8 @@ albums._createSmartAlbums = function (data) { id: "public", title: lychee.locale["PUBLIC"], created_at: null, - public: "1", - visible: "0", + is_public: true, + requires_link: true, thumb: data.public.thumb }; } @@ -2004,7 +1971,7 @@ albums._createSmartAlbums = function (data) { id: "recent", title: lychee.locale["RECENT"], created_at: null, - recent: "1", + is_recent: true, thumb: data.recent.thumb }; } @@ -2018,7 +1985,7 @@ albums.isShared = function (albumID) { var found = false; var func = function func() { - if (parseInt(this.id, 10) === parseInt(albumID, 10)) { + if (this.id === albumID) { found = true; return false; // stop the loop } @@ -2042,7 +2009,7 @@ albums.getByID = function (albumID) { var json = undefined; var func = function func() { - if (parseInt(this.id, 10) === parseInt(albumID, 10)) { + if (this.id === albumID) { json = this; return false; // stop the loop } @@ -2055,7 +2022,7 @@ albums.getByID = function (albumID) { if (json === undefined && albums.json.shared_albums !== null) $.each(albums.json.shared_albums, func); - if (json === undefined && albums.json.smartalbums !== null) $.each(albums.json.smartalbums, func); + if (json === undefined && albums.json.smart_albums !== null) $.each(albums.json.smart_albums, func); return json; }; @@ -2072,7 +2039,7 @@ albums.deleteByID = function (albumID) { var deleted = false; $.each(albums.json.albums, function (i) { - if (parseInt(albums.json.albums[i].id) === parseInt(albumID)) { + if (albums.json.albums[i].id === albumID) { albums.json.albums.splice(i, 1); deleted = true; return false; // stop the loop @@ -2082,7 +2049,7 @@ albums.deleteByID = function (albumID) { if (deleted === false) { if (!albums.json.shared_albums) return undefined; $.each(albums.json.shared_albums, function (i) { - if (parseInt(albums.json.shared_albums[i].id) === parseInt(albumID)) { + if (albums.json.shared_albums[i].id === albumID) { albums.json.shared_albums.splice(i, 1); deleted = true; return false; // stop the loop @@ -2091,10 +2058,10 @@ albums.deleteByID = function (albumID) { } if (deleted === false) { - if (!albums.json.smartalbums) return undefined; - $.each(albums.json.smartalbums, function (i) { - if (parseInt(albums.json.smartalbums[i].id) === parseInt(albumID)) { - delete albums.json.smartalbums[i]; + if (!albums.json.smart_albums) return undefined; + $.each(albums.json.smart_albums, function (i) { + if (albums.json.smart_albums[i].id === albumID) { + delete albums.json.smart_albums[i]; deleted = true; return false; // stop the loop } @@ -2165,7 +2132,7 @@ build.getAlbumThumb = function (data) { thumb2x = data.thumb.thumb2x; - return "Photo thumbnail"; + return "Photo thumbnail"; }; build.album = function (data) { @@ -2211,14 +2178,14 @@ build.album = function (data) { } } - var html = lychee.html(_templateObject21, disabled ? "disabled" : "", data.nsfw && data.nsfw === "1" && lychee.nsfw_blur ? "blurred" : "", data.id, data.nsfw && data.nsfw === "1" ? "1" : "0", tabindex.get_next_tab_index(), build.getAlbumThumb(data), build.getAlbumThumb(data), build.getAlbumThumb(data), data.title, data.title, subtitle); + var html = lychee.html(_templateObject21, disabled ? "disabled" : "", data.is_nsfw && lychee.nsfw_blur ? "blurred" : "", data.id, data.is_nsfw ? "1" : "0", tabindex.get_next_tab_index(), build.getAlbumThumb(data), build.getAlbumThumb(data), build.getAlbumThumb(data), data.title, data.title, subtitle); if (album.isUploadable() && !disabled) { var isCover = album.json && album.json.cover_id && data.thumb.id === album.json.cover_id; - html += lychee.html(_templateObject22, data.nsfw === "1" ? "badge--nsfw" : "", build.iconic("warning"), data.star === "1" ? "badge--star" : "", build.iconic("star"), data.recent === "1" ? "badge--visible badge--list" : "", build.iconic("clock"), data.public === "1" ? "badge--visible" : "", data.visible === "1" ? "badge--not--hidden" : "badge--hidden", build.iconic("eye"), data.unsorted === "1" ? "badge--visible" : "", build.iconic("list"), data.password === "1" ? "badge--visible" : "", build.iconic("lock-locked"), data.tag_album === "1" ? "badge--tag" : "", build.iconic("tag"), isCover ? "badge--cover" : "", build.iconic("folder-cover")); + html += lychee.html(_templateObject22, data.is_nsfw ? "badge--nsfw" : "", build.iconic("warning"), data.is_starred ? "badge--star" : "", build.iconic("star"), data.is_recent ? "badge--visible badge--list" : "", build.iconic("clock"), data.is_public ? "badge--visible" : "", data.requires_link ? "badge--hidden" : "badge--not--hidden", build.iconic("eye"), data.is_unsorted ? "badge--visible" : "", build.iconic("list"), data.has_password ? "badge--visible" : "", build.iconic("lock-locked"), data.is_tag_album ? "badge--tag" : "", build.iconic("tag"), isCover ? "badge--cover" : "", build.iconic("folder-cover")); } - if (data.albums && data.albums.length > 0 || data.hasOwnProperty("has_albums") && data.has_albums === "1") { + if (data.albums && data.albums.length > 0 || data.hasOwnProperty("has_albums") && data.has_albums === true) { html += lychee.html(_templateObject23, build.iconic("layers")); } @@ -2237,9 +2204,9 @@ build.photo = function (data) { var isVideo = data.type && data.type.indexOf("video") > -1; var isRaw = data.type && data.type.indexOf("raw") > -1; - var isLivePhoto = data.livePhotoUrl !== "" && data.livePhotoUrl !== null; + var isLivePhoto = data.live_photo_url !== "" && data.live_photo_url !== null; - if (data.sizeVariants.thumb === null) { + if (data.size_variants.thumb === null) { if (isLivePhoto) { thumbnail = "Photo thumbnail"; } @@ -2249,8 +2216,8 @@ build.photo = function (data) { thumbnail = "Photo thumbnail"; } } else if (lychee.layout === "0") { - if (data.sizeVariants.thumb2x !== null) { - thumb2x = data.sizeVariants.thumb2x.url; + if (data.size_variants.thumb2x !== null) { + thumb2x = data.size_variants.thumb2x.url; } if (thumb2x !== "") { @@ -2258,56 +2225,56 @@ build.photo = function (data) { } thumbnail = ""; - thumbnail += "Photo thumbnail"; + thumbnail += "Photo thumbnail"; thumbnail += ""; } else { - if (data.sizeVariants.small !== null) { - if (data.sizeVariants.small2x !== null) { - thumb2x = "data-srcset='" + data.sizeVariants.small.url + " " + data.sizeVariants.small.width + "w, " + data.sizeVariants.small2x.url + " " + data.sizeVariants.small2x.width + "w'"; + if (data.size_variants.small !== null) { + if (data.size_variants.small2x !== null) { + thumb2x = "data-srcset='" + data.size_variants.small.url + " " + data.size_variants.small.width + "w, " + data.size_variants.small2x.url + " " + data.size_variants.small2x.width + "w'"; } thumbnail = ""; - thumbnail += "Photo thumbnail"; + thumbnail += "Photo thumbnail"; thumbnail += ""; - } else if (data.sizeVariants.medium !== null) { - if (data.sizeVariants.medium2x !== null) { - thumb2x = "data-srcset='" + data.sizeVariants.medium.url + " " + data.sizeVariants.medium.width + "w, " + data.sizeVariants.medium2x.url + " " + data.sizeVariants.medium2x.width + "w'"; + } else if (data.size_variants.medium !== null) { + if (data.size_variants.medium2x !== null) { + thumb2x = "data-srcset='" + data.size_variants.medium.url + " " + data.size_variants.medium.width + "w, " + data.size_variants.medium2x.url + " " + data.size_variants.medium2x.width + "w'"; } thumbnail = ""; - thumbnail += "Photo thumbnail"; + thumbnail += "Photo thumbnail"; thumbnail += ""; } else if (!isVideo) { // Fallback for images with no small or medium. thumbnail = ""; - thumbnail += "Photo thumbnail"; + thumbnail += "Photo thumbnail"; thumbnail += ""; } else { // Fallback for videos with no small (the case of no thumb is // handled at the top of this function). - if (data.sizeVariants.thumb2x !== null) { - thumb2x = data.sizeVariants.thumb2x.url; + if (data.size_variants.thumb2x !== null) { + thumb2x = data.size_variants.thumb2x.url; } if (thumb2x !== "") { - thumb2x = "data-srcset='" + data.sizeVariants.thumb.url + " " + data.sizeVariants.thumb.width + "w, " + thumb2x + " " + data.sizeVariants.thumb2x.width + "w'"; + thumb2x = "data-srcset='" + data.size_variants.thumb.url + " " + data.size_variants.thumb.width + "w, " + thumb2x + " " + data.size_variants.thumb2x.width + "w'"; } thumbnail = ""; - thumbnail += "Photo thumbnail"; + thumbnail += "Photo thumbnail"; thumbnail += ""; } } - html += lychee.html(_templateObject24, disabled ? "disabled" : "", data.album, data.id, tabindex.get_next_tab_index(), thumbnail, data.title, data.title); + html += lychee.html(_templateObject24, disabled ? "disabled" : "", data.album_id, data.id, tabindex.get_next_tab_index(), thumbnail, data.title, data.title); if (data.taken_at !== null) html += lychee.html(_templateObject25, build.iconic("camera-slr"), lychee.locale.printDateTime(data.taken_at));else html += lychee.html(_templateObject26, lychee.locale.printDateTime(data.created_at)); html += "
"; if (album.isUploadable()) { - html += lychee.html(_templateObject27, data.star === "1" ? "badge--star" : "", build.iconic("star"), data.public === "1" && album.json.public !== "1" ? "badge--visible badge--hidden" : "", build.iconic("eye"), isCover ? "badge--cover" : "", build.iconic("folder-cover")); + html += lychee.html(_templateObject27, data.is_starred ? "badge--star" : "", build.iconic("star"), data.is_public && !album.json.is_public ? "badge--visible badge--hidden" : "", build.iconic("eye"), isCover ? "badge--cover" : "", build.iconic("folder-cover")); } html += ""; @@ -2372,13 +2339,13 @@ build.imageview = function (data, visibleControls, autoplay) { var thumb = ""; if (data.type.indexOf("video") > -1) { - html += lychee.html(_templateObject29, visibleControls === true ? "" : "full", autoplay ? "autoplay" : "", tabindex.get_next_tab_index(), data.url); - } else if (data.type.indexOf("raw") > -1 && data.sizeVariants.medium === null) { + html += lychee.html(_templateObject29, visibleControls === true ? "" : "full", autoplay ? "autoplay" : "", tabindex.get_next_tab_index(), data.size_variants.original.url); + } else if (data.type.indexOf("raw") > -1 && data.size_variants.medium === null) { html += lychee.html(_templateObject30, visibleControls === true ? "" : "full", tabindex.get_next_tab_index()); } else { var img = ""; - if (data.livePhotoUrl === "" || data.livePhotoUrl === null) { + if (data.live_photo_url === "" || data.live_photo_url === null) { // It's normal photo // See if we have the thumbnail loaded... @@ -2392,25 +2359,25 @@ build.imageview = function (data, visibleControls, autoplay) { } }); - if (data.sizeVariants.medium !== null) { + if (data.size_variants.medium !== null) { var medium = ""; - if (data.sizeVariants.medium2x !== null) { - medium = "srcset='" + data.sizeVariants.medium.url + " " + data.sizeVariants.medium.width + "w, " + data.sizeVariants.medium2x.url + " " + data.sizeVariants.medium2x.width + "w'"; + if (data.size_variants.medium2x !== null) { + medium = "srcset='" + data.size_variants.medium.url + " " + data.size_variants.medium.width + "w, " + data.size_variants.medium2x.url + " " + data.size_variants.medium2x.width + "w'"; } - img = "medium"); + img = "medium"); } else { - img = "big"; + img = "big"; } } else { - if (data.sizeVariants.medium !== null) { - var medium_width = data.sizeVariants.medium.width; - var medium_height = data.sizeVariants.medium.height; + if (data.size_variants.medium !== null) { + var medium_width = data.size_variants.medium.width; + var medium_height = data.size_variants.medium.height; // It's a live photo - img = "
"; + img = "
"; } else { // It's a live photo - img = "
"; + img = "
"; } } @@ -2490,7 +2457,7 @@ build.tags = function (tags) { a_class = a_class + " search"; } - if (tags !== "") { + if (typeof tags === "string" && tags !== "") { tags = tags.split(","); tags.forEach(function (tag, index) { @@ -2550,7 +2517,7 @@ contextMenu.add = function (e) { // For tag albums the context menu is normally not used. items = []; } - if (Number.isInteger(parseInt(albumID)) || albumID === "unsorted") { + if (albumID.length === 24 || albumID === "unsorted") { if (albumID !== "unsorted") { var button_visibility_album = $("#button_visibility_album"); if (button_visibility_album && button_visibility_album.css("display") === "none") { @@ -2581,7 +2548,7 @@ contextMenu.add = function (e) { title: build.iconic("folder") + lychee.locale["MOVE_ALBUM"], visible: lychee.enable_button_move, fn: function fn(event) { - return contextMenu.move([albumID], event, album.setAlbum, "ROOT", album.getParent() !== ""); + return contextMenu.move([albumID], event, album.setAlbum, "ROOT", album.getParentID() !== null); } }); } @@ -2709,7 +2676,7 @@ contextMenu.buildList = function (lists, exclude, action) { var find = function find(excl, id) { for (var _i = 0; _i < excl.length; _i++) { - if (parseInt(excl[_i], 10) === parseInt(id, 10)) return true; + if (excl[_i] === id) return true; } return false; }; @@ -2731,13 +2698,13 @@ contextMenu.buildList = function (lists, exclude, action) { } else { thumb = item.thumb.thumb; } - } else if (item.sizeVariants) { - if (item.sizeVariants.thumb === null) { + } else if (item.size_variants) { + if (item.size_variants.thumb === null) { if (item.type && item.type.indexOf("video") > -1) { thumb = "img/play-icon.png"; } } else { - thumb = item.sizeVariants.thumb.url; + thumb = item.size_variants.thumb.url; } } @@ -2788,7 +2755,7 @@ contextMenu.albumTitle = function (albumID, e) { if (data.shared_albums && data.shared_albums.length > 0) { items = items.concat({}); - items = items.concat(contextMenu.buildList(data.shared_albums, albumID !== false ? [parseInt(albumID, 10)] : [], function (a) { + items = items.concat(contextMenu.buildList(data.shared_albums, albumID !== false ? [albumID] : [], function (a) { return lychee.goto(a.id); })); } @@ -2827,7 +2794,7 @@ contextMenu.photo = function (photoID, e) { title: build.iconic("layers") + lychee.locale["COPY_TO"], fn: function fn() { basicContext.close(); - contextMenu.move([photoID], e, _photo.copyTo, "UNSORTED"); + contextMenu.move([photoID], e, _photo.copyTo); } }, // Notice for 'Move': @@ -2837,7 +2804,7 @@ contextMenu.photo = function (photoID, e) { title: build.iconic("folder") + lychee.locale["MOVE"], fn: function fn() { basicContext.close(); - contextMenu.move([photoID], e, _photo.setAlbum, "UNSORTED"); + contextMenu.move([photoID], e, _photo.setAlbum); } }, { title: build.iconic("trash") + lychee.locale["DELETE"], fn: function fn() { return _photo.delete([photoID]); @@ -2904,13 +2871,13 @@ contextMenu.photoMulti = function (photoIDs, e) { title: build.iconic("layers") + lychee.locale["COPY_ALL_TO"], fn: function fn() { basicContext.close(); - contextMenu.move(photoIDs, e, _photo.copyTo, "UNSORTED"); + contextMenu.move(photoIDs, e, _photo.copyTo); } }, { title: build.iconic("folder") + lychee.locale["MOVE_ALL"], fn: function fn() { basicContext.close(); - contextMenu.move(photoIDs, e, _photo.setAlbum, "UNSORTED"); + contextMenu.move(photoIDs, e, _photo.setAlbum); } }, { title: build.iconic("trash") + lychee.locale["DELETE_ALL"], fn: function fn() { return _photo.delete(photoIDs); @@ -2949,8 +2916,8 @@ contextMenu.photoMore = function (photoID, e) { // a) We are allowed to upload to the album // b) the photo is explicitly marked as downloadable (v4-only) // c) or, the album is explicitly marked as downloadable - var showDownload = album.isUploadable() || (_photo.json.hasOwnProperty("downloadable") ? _photo.json.downloadable === "1" : album.json && album.json.downloadable && album.json.downloadable === "1"); - var showFull = _photo.json.url && _photo.json.url !== ""; + var showDownload = album.isUploadable() || (_photo.json.hasOwnProperty("is_downloadable") ? _photo.json.is_downloadable : album.json && album.json.is_downloadable && album.json.is_downloadable); + var showFull = _photo.json.size_variants.original.url && _photo.json.size_variants.original.url !== ""; var items = [{ title: build.iconic("fullscreen-enter") + lychee.locale["FULL_PHOTO"], visible: !!showFull, fn: function fn() { return window.open(_photo.getDirectLink()); @@ -2990,7 +2957,7 @@ contextMenu.photoMore = function (photoID, e) { }); } /* The condition below is copied from view.photo.header() */ - if (!(_photo.json.type && (_photo.json.type.indexOf("video") === 0 || _photo.json.type === "raw") || _photo.json.livePhotoUrl !== "" && _photo.json.livePhotoUrl !== null)) { + if (!(_photo.json.type && (_photo.json.type.indexOf("video") === 0 || _photo.json.type === "raw") || _photo.json.live_photo_url !== "" && _photo.json.live_photo_url !== null)) { var button_rotate_cwise = $("#button_rotate_cwise"); if (button_rotate_cwise && button_rotate_cwise.css("display") === "none") { items.unshift({ @@ -3018,11 +2985,11 @@ contextMenu.photoMore = function (photoID, e) { }; contextMenu.getSubIDs = function (albums, albumID) { - var ids = [parseInt(albumID, 10)]; + var ids = [albumID]; var a = void 0; for (a = 0; a < albums.length; a++) { - if (parseInt(albums[a].parent_id, 10) === parseInt(albumID, 10)) { + if (albums[a].parent_id === albumID) { ids = ids.concat(contextMenu.getSubIDs(albums, albums[a].id)); } @@ -3059,9 +3026,9 @@ contextMenu.move = function (IDs, e, callback) { if (callback !== album.merge && callback !== _photo.copyTo) { exclude.push(album.getID().toString()); } - if (IDs.length === 1 && IDs[0] === album.getID() && album.getParent() && callback === album.setAlbum) { + if (IDs.length === 1 && IDs[0] === album.getID() && album.getParentID() && callback === album.setAlbum) { // If moving the current album, exclude its parent. - exclude.push(album.getParent().toString()); + exclude.push(album.getParentID().toString()); } } else if (visible.photo()) { exclude.push(_photo.json.album.toString()); @@ -3083,10 +3050,10 @@ contextMenu.move = function (IDs, e, callback) { } // Show Unsorted when unsorted is not the current album - if (display_root && album.getID() !== "0" && !visible.albums()) { + if (display_root && album.getID() !== "unsorted" && !visible.albums()) { items.unshift({}); items.unshift({ title: lychee.locale[kind], fn: function fn() { - return callback(IDs, 0); + return callback(IDs, null); } }); } @@ -3105,7 +3072,7 @@ contextMenu.move = function (IDs, e, callback) { contextMenu.sharePhoto = function (photoID, e) { // v4+ only - if (_photo.json.hasOwnProperty("share_button_visible") && _photo.json.share_button_visible !== "1") { + if (_photo.json.hasOwnProperty("is_share_button_visible") && !_photo.json.is_share_button_visible) { return; } @@ -3128,7 +3095,7 @@ contextMenu.sharePhoto = function (photoID, e) { contextMenu.shareAlbum = function (albumID, e) { // v4+ only - if (album.json.hasOwnProperty("share_button_visible") && album.json.share_button_visible !== "1") { + if (album.json.hasOwnProperty("is_share_button_visible") && !album.json.is_share_button_visible) { return; } @@ -3144,7 +3111,7 @@ contextMenu.shareAlbum = function (albumID, e) { title: build.iconic("link-intact") + lychee.locale["DIRECT_LINK"], fn: function fn() { var url = lychee.getBaseUrl() + "r/" + albumID; - if (album.json.password === "1") { + if (album.json.has_password) { // Copy the url with prefilled password param url += "?password="; } @@ -3170,6 +3137,9 @@ contextMenu.close = function () { contextMenu.config = function (e) { var items = [{ title: build.iconic("cog") + lychee.locale["SETTINGS"], fn: settings.open }]; + if (lychee.new_photos_notification) { + items.push({ title: build.iconic("bell") + lychee.locale["NOTIFICATIONS"], fn: notifications.load }); + } if (lychee.admin) { items.push({ title: build.iconic("person") + lychee.locale["USERS"], fn: users.list }); } @@ -3279,7 +3249,7 @@ header.bind = function () { contextMenu.photoMore(_photo.getID(), e); }); header.dom("#button_move_album").on(eventName, function (e) { - contextMenu.move([album.getID()], e, album.setAlbum, "ROOT", album.getParent() != ""); + contextMenu.move([album.getID()], e, album.setAlbum, "ROOT", album.getParentID() != null); }); header.dom("#button_nsfw_album").on(eventName, function (e) { album.setNSFW(album.getID()); @@ -3312,14 +3282,14 @@ header.bind = function () { if (!album.json.parent_id) { lychee.goto(); } else { - lychee.goto(album.getParent()); + lychee.goto(album.getParentID()); } }); header.dom("#button_back").on(eventName, function () { lychee.goto(album.getID()); }); header.dom("#button_back_map").on(eventName, function () { - lychee.goto(album.getID() || ""); + lychee.goto(album.getID()); }); header.dom("#button_fs_album_enter,#button_fs_enter").on(eventName, lychee.fullscreenEnter); header.dom("#button_fs_album_exit,#button_fs_exit").on(eventName, lychee.fullscreenExit).hide(); @@ -3477,7 +3447,7 @@ header.setMode = function (mode) { // Hide download button when album empty or we are not allowed to // upload to it and it's not explicitly marked as downloadable. - if (!album.json || album.json.photos === false && album.json.albums && album.json.albums.length === 0 || !album.isUploadable() && album.json.downloadable === "0") { + if (!album.json || album.json.photos.length === 0 && album.json.albums && album.json.albums.length === 0 || !album.isUploadable() && !album.json.is_downloadable) { var _e9 = $("#button_archive"); _e9.hide(); tabindex.makeUnfocusable(_e9); @@ -3487,7 +3457,7 @@ header.setMode = function (mode) { tabindex.makeFocusable(_e10); } - if (album.json && album.json.hasOwnProperty("share_button_visible") && album.json.share_button_visible !== "1") { + if (album.json && album.json.hasOwnProperty("is_share_button_visible") && !album.json.is_share_button_visible) { var _e11 = $("#button_share_album"); _e11.hide(); tabindex.makeUnfocusable(_e11); @@ -3618,7 +3588,7 @@ header.setMode = function (mode) { tabindex.makeUnfocusable(_e25); } - if (_photo.json && _photo.json.hasOwnProperty("share_button_visible") && _photo.json.share_button_visible !== "1") { + if (_photo.json && _photo.json.hasOwnProperty("is_share_button_visible") && !_photo.json.is_share_button_visible) { var _e26 = $("#button_share"); _e26.hide(); tabindex.makeUnfocusable(_e26); @@ -3631,7 +3601,7 @@ header.setMode = function (mode) { // Hide More menu if empty (see contextMenu.photoMore) $("#button_more").show(); tabindex.makeFocusable($("#button_more")); - if (!(album.isUploadable() || (_photo.json.hasOwnProperty("downloadable") ? _photo.json.downloadable === "1" : album.json && album.json.downloadable && album.json.downloadable === "1")) && !(_photo.json.url && _photo.json.url !== "")) { + if (!(album.isUploadable() || (_photo.json.hasOwnProperty("is_downloadable") ? _photo.json.is_downloadable : album.json && album.json.is_downloadable)) && !(_photo.json.size_variants.original.url && _photo.json.size_variants.original.url !== "")) { var _e28 = $("#button_more"); _e28.hide(); tabindex.makeUnfocusable(_e28); @@ -3878,7 +3848,7 @@ $(document).ready(function () { }, "keyup"); Mousetrap.bindGlobal(["esc", "command+up"], function () { - if (basicModal.visible() === true) basicModal.cancel();else if (visible.config() || visible.leftMenu()) leftMenu.close();else if (visible.contextMenu()) contextMenu.close();else if (visible.photo()) lychee.goto(album.getID());else if (visible.album() && !album.json.parent_id) lychee.goto();else if (visible.album()) lychee.goto(album.getParent());else if (visible.albums() && search.hash !== null) search.reset();else if (visible.mapview()) mapview.close();else if (visible.albums() && lychee.enable_close_tab_on_esc) { + if (basicModal.visible() === true) basicModal.cancel();else if (visible.config() || visible.leftMenu()) leftMenu.close();else if (visible.contextMenu()) contextMenu.close();else if (visible.photo()) lychee.goto(album.getID());else if (visible.album() && !album.json.parent_id) lychee.goto();else if (visible.album()) lychee.goto(album.getParentID());else if (visible.albums() && search.hash !== null) search.reset();else if (visible.mapview()) mapview.close();else if (visible.albums() && lychee.enable_close_tab_on_esc) { window.open("", "_self").close(); } return false; @@ -4031,6 +4001,8 @@ leftMenu.dom = function (selector) { return leftMenu._dom.find(selector); }; +// Note: on mobile we use a context menu instead; please make sure that +// contextMenu.config is kept in sync with any changes here! leftMenu.build = function () { var html = lychee.html(_templateObject44, lychee.locale["CLOSE"], lychee.locale["SETTINGS"]); if (lychee.new_photos_notification) { @@ -4258,8 +4230,8 @@ var lychee = { api_V2: false, // enable api_V2 sub_albums: false, // enable sub_albums features admin: false, // enable admin mode (multi-user) - upload: false, // enable possibility to upload (multi-user) - lock: false, // locked user (multi-user) + may_upload: false, // enable possibility to upload (multi-user) + is_locked: false, // locked user (multi-user) username: null, layout: "1", // 0: Use default, "square" layout. 1: Use Flickr-like "justified" layout. 2: Use Google-like "unjustified" layout public_search: false, // display Search in publicMode @@ -4357,8 +4329,14 @@ lychee.aboutDialog = function () { if (lychee.checkForUpdates === "1") lychee.getUpdate(); }; +/** + * @param {boolean} isFirstInitialization must be set to `false` if called + * for re-initialization to prevent + * multiple registrations of global + * event handlers + */ lychee.init = function () { - var exitview = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true; + var isFirstInitialization = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true; lychee.adjustContentHeight(); @@ -4476,9 +4454,9 @@ lychee.init = function () { leftMenu.build(); leftMenu.bind(); - lychee.upload = data.admin || data.upload; + lychee.may_upload = data.admin || data.may_upload; lychee.admin = data.admin; - lychee.lock = data.lock; + lychee.is_locked = data.is_locked; lychee.username = data.username; lychee.setMode("logged_in"); @@ -4535,8 +4513,11 @@ lychee.init = function () { // should not happen. } - if (exitview) { - $(window).bind("popstate", lychee.load); + if (isFirstInitialization) { + $(window).on("popstate", function () { + var autoplay = history.state && history.state.hasOwnProperty("autoplay") ? history.state.autoplay : true; + lychee.load(autoplay); + }); lychee.load(); } }); @@ -4561,7 +4542,7 @@ lychee.login = function (data) { }; api.post("Session::login", params, function (_data) { - if (_data === true) { + if (typeof _data === "undefined") { window.location.reload(); } else { // Show error and reactive button @@ -4607,12 +4588,11 @@ lychee.logout = function () { }; lychee.goto = function () { - var url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ""; + var url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; var autoplay = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; - url = "#" + url; - - history.pushState(null, null, url); + url = "#" + (url !== null ? url : ""); + history.pushState({ autoplay: autoplay }, null, url); lychee.load(autoplay); }; @@ -4628,22 +4608,85 @@ lychee.gotoMap = function () { lychee.goto("map/" + albumID, autoplay); }; +/** + * Triggers a reload, if the given IDs are in legacy format. + * + * If any of the IDs is in legacy format, the method first translates the IDs + * into the modern format via an AJAX call to the backend and then triggers + * an asynchronous reloading of the page with the resolved, modern IDs. + * The function returns `true` in this case. + * + * If the IDs are already in modern format (and thus neither a translation + * nor a reloading is required), the function returns `false`. + * In this case this function is basically a no-op. + * + * @param {?string} albumID the album ID + * @param {?string} photoID the photo ID + * @param {boolean} autoplay indicates whether playback should start + * automatically, if the indicated photo is a video + * + * @return {boolean} `true`, if any of the IDs has been in legacy format + * and an asynchronous reloading has been scheduled + */ +lychee.reloadIfLegacyIDs = function (albumID, photoID, autoplay) { + /** @param {?string} id the inspected ID */ + var isLegacyID = function isLegacyID(id) { + // The legacy IDs were pure numeric values. We exclude values which + // have 24 digits, because these could also be modern IDs. + // A modern IDs is a 24 character long, base64 encoded value and thus + // could also match 24 digits by accident. + return id && id.length !== 24 && parseInt(id).toString() === id; + }; + + if (!isLegacyID(albumID) && !isLegacyID(photoID)) { + // this function is a no-op if neither ID is in legacy format + return false; + } + + /** + * Callback to be called asynchronously which executes the actual reloading. + * + * @param {?string} newAlbumID + * @param {?string} newPhotoID + * + * @return void + */ + var reloadWithNewIDs = function reloadWithNewIDs(newAlbumID, newPhotoID) { + var newUrl = ""; + if (newAlbumID) { + newUrl += newAlbumID; + newUrl += newPhotoID ? "/" + newPhotoID : ""; + } + lychee.goto(newUrl, autoplay); + }; + + // We have to deal with three cases: + // 1. the album and photo ID need to be translated + // 2. only the album ID needs to be translated + // 3. only the photo ID needs to be translated + var params = {}; + if (isLegacyID(albumID)) params.albumID = albumID; + if (isLegacyID(photoID)) params.photoID = photoID; + api.post("Legacy::translateLegacyModelIDs", params, function (data) { + reloadWithNewIDs(data.hasOwnProperty("albumID") ? data.albumID : albumID, data.hasOwnProperty("photoID") ? data.photoID : photoID); + }); + + return true; +}; + lychee.load = function () { var autoplay = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true; - var albumID = ""; - var photoID = ""; var hash = document.location.hash.replace("#", "").split("/"); + var albumID = hash[0]; + var photoID = hash[1]; contextMenu.close(); multiselect.close(); tabindex.reset(); - if (hash[0] != null) albumID = hash[0]; - if (hash[1] != null) photoID = hash[1]; - if (albumID && photoID) { - if (albumID == "map") { + if (albumID === "map") { // If map functionality is disabled -> do nothing if (!lychee.map_display) { loadingBar.show("error", lychee.locale["ERROR_MAP_DEACTIVATED"]); @@ -4665,7 +4708,7 @@ lychee.load = function () { } mapview.open(albumID); lychee.footer_hide(); - } else if (albumID == "search") { + } else if (albumID === "search") { // Search has been triggered var search_string = decodeURIComponent(photoID); @@ -4684,6 +4727,10 @@ lychee.load = function () { lychee.footer_show(); } else { + if (lychee.reloadIfLegacyIDs(albumID, photoID, autoplay)) { + return; + } + $(".no_content").remove(); // Show photo @@ -4709,7 +4756,7 @@ lychee.load = function () { lychee.footer_hide(); } } else if (albumID) { - if (albumID == "map") { + if (albumID === "map") { $(".no_content").remove(); // Show map of all albums // If map functionality is disabled -> do nothing @@ -4726,9 +4773,13 @@ lychee.load = function () { if (visible.sidebar()) _sidebar.toggle(false); mapview.open(); lychee.footer_hide(); - } else if (albumID == "search") { + } else if (albumID === "search") { // search string is empty -> do nothing } else { + if (lychee.reloadIfLegacyIDs(albumID, photoID, autoplay)) { + return; + } + $(".no_content").remove(); // Trash data _photo.json = null; @@ -4810,10 +4861,10 @@ lychee.setTitle = function (title, editable) { }; lychee.setMode = function (mode) { - if (lychee.lock) { + if (lychee.is_locked) { $("#button_settings_open").remove(); } - if (!lychee.upload) { + if (!lychee.may_upload) { $("#button_sharing").remove(); $(document).off("click", ".header__title--editable").off("touchend", ".header__title--editable").off("contextmenu", ".photo").off("contextmenu", ".album").off("drop"); @@ -5641,15 +5692,21 @@ lychee.locale = { // want to call `toLocalString` which is fine and don't do any time // arithmetics. // Then we add the original timezone to the string manually. - var splitDateTime = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})([-Z+])(\d{2}:\d{2})?$/.exec(jsonDateTime); - console.assert(splitDateTime.length === 4, "'jsonDateTime' is not formatted acc. to ISO 8601; passed string was: " + jsonDateTime); + var splitDateTime = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}([,.]\d{1,6})?)([-Z+])(\d{2}:\d{2})?$/.exec(jsonDateTime); + // The capturing groups are: + // - 0: the whole string + // - 1: the whole date/time segment incl. fractional seconds + // - 2: the fractional seconds (if present) + // - 3: the timezone separator, i.e. "Z", "-" or "+" (if present) + // - 4: the absolute timezone offset without the sign (if present) + console.assert(splitDateTime.length === 5, "'jsonDateTime' is not formatted acc. to ISO 8601; passed string was: " + jsonDateTime); var locale = "default"; // use the user's browser settings var format = { dateStyle: "medium", timeStyle: "medium" }; var result = new Date(splitDateTime[1]).toLocaleString(locale, format); - if (splitDateTime[2] === "Z" || splitDateTime[3] === "00:00") { + if (splitDateTime[3] === "Z" || splitDateTime[4] === "00:00") { result += " UTC"; } else { - result += " UTC" + splitDateTime[2] + splitDateTime[3]; + result += " UTC" + splitDateTime[3] + splitDateTime[4]; } return result; }, @@ -5845,13 +5902,13 @@ mapview.open = function () { photos.push({ lat: parseFloat(element.latitude), lng: parseFloat(element.longitude), - thumbnail: element.sizeVariants.thumb !== null ? element.sizeVariants.thumb.url : "img/placeholder.png", - thumbnail2x: element.sizeVariants.thumb2x !== null ? element.sizeVariants.thumb2x.url : null, - url: element.sizeVariants.small !== null ? element.sizeVariants.small.url : element.url, - url2x: element.sizeVariants.small2x !== null ? element.sizeVariants.small2x.url : null, + thumbnail: element.size_variants.thumb !== null ? element.size_variants.thumb.url : "img/placeholder.png", + thumbnail2x: element.size_variants.thumb2x !== null ? element.size_variants.thumb2x.url : null, + url: element.size_variants.small !== null ? element.size_variants.small.url : element.url, + url2x: element.size_variants.small2x !== null ? element.size_variants.small2x.url : null, name: element.title, taken_at: element.taken_at, - albumID: element.album, + albumID: element.album_id, photoID: element.id }); @@ -5885,56 +5942,27 @@ mapview.open = function () { var _includeSubAlbums = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; if (_albumID !== "" && _albumID !== null) { - // _ablumID has been to a specific album + // _albumID has been specified var _params = { albumID: _albumID, - includeSubAlbums: _includeSubAlbums, - password: "" + includeSubAlbums: _includeSubAlbums }; api.post("Album::getPositionData", _params, function (data) { - if (data === "Warning: Wrong password!") { - password.getDialog(_albumID, function () { - _params.password = password.value; - - api.post("Album::getPositionData", _params, function (_data) { - addPhotosToMap(_data); - mapview.title(_albumID, _data.title); - }); - }); - } else { - addPhotosToMap(data); - mapview.title(_albumID, data.title); - } + addPhotosToMap(data); + mapview.title(_albumID, data.title); }); } else { // AlbumID is empty -> fetch all photos of all albums - // _ablumID has been to a specific album - var _params2 = { - includeSubAlbums: _includeSubAlbums, - password: "" - }; - - api.post("Albums::getPositionData", _params2, function (data) { - if (data === "Warning: Wrong password!") { - password.getDialog(_albumID, function () { - _params2.password = password.value; - - api.post("Albums::getPositionData", _params2, function (_data) { - addPhotosToMap(_data); - mapview.title(_albumID, _data.title); - }); - }); - } else { - addPhotosToMap(data); - mapview.title(_albumID, data.title); - } + api.post("Albums::getPositionData", {}, function (data) { + addPhotosToMap(data); + mapview.title(_albumID, data.title); }); } }; - // If subalbums not being included and album.json already has all data - // -> we can reuse it + // If sub-albums are not requested and album.json already has all data, + // we reuse it if (lychee.map_include_subalbums === false && album.json !== null && album.json.photos !== null) { addPhotosToMap(album.json); } else { @@ -6362,7 +6390,7 @@ notifications.update = function (params) { } api.post("User::UpdateEmail", params, function (data) { - if (data !== true) { + if (data) { loadingBar.show("error", data.description); lychee.error(null, params, data); } else { @@ -6395,14 +6423,16 @@ password.getDialog = function (albumID, callback) { password: passwd }; - api.post("Album::getPublic", params, function (_data) { - if (_data === true) { - basicModal.close(); - password.value = passwd; - callback(); - } else { + api.post("Album::unlock", params, function (_data) { + basicModal.close(); + password.value = passwd; + callback(); + }, null, function (jqXHR) { + if (jqXHR.status === 403) { basicModal.error("password"); + return true; } + return false; }); }; @@ -6444,7 +6474,7 @@ _photo.getID = function () { if (_photo.json) id = _photo.json.id;else id = $(".photo:hover, .photo.active").attr("data-id"); - if ($.isNumeric(id) === true) return id;else return false; + if (typeof id === "string" && id.length === 24) return id;else return null; }; _photo.load = function (photoID, albumID, autoplay) { @@ -6468,20 +6498,9 @@ _photo.load = function (photoID, albumID, autoplay) { }; api.post("Photo::get", params, function (data) { - if (data === "Warning: Photo private!") { - lychee.content.show(); - lychee.goto(); - return false; - } - - if (data === "Warning: Wrong password!") { - checkPasswd(); - return false; - } - _photo.json = data; - _photo.json.original_album = _photo.json.album; - _photo.json.album = albumID; + _photo.json.original_album_id = _photo.json.album_id; + _photo.json.album_id = albumID; if (!visible.photo()) view.photo.show(); view.photo.init(autoplay); @@ -6512,7 +6531,7 @@ _photo.hasDesc = function () { _photo.isLivePhoto = function () { if (!_photo.json) return false; // In case it's called, but not initialized - return _photo.json.livePhotoUrl && _photo.json.livePhotoUrl !== ""; + return _photo.json.live_photo_url && _photo.json.live_photo_url !== ""; }; _photo.isLivePhotoInitizalized = function () { @@ -6538,8 +6557,8 @@ _photo.cycle_display_overlay = function () { // Preload the next and previous photos for better response time _photo.preloadNextPrev = function (photoID) { if (album.json && album.json.photos && album.getByID(photoID)) { - var previousPhotoID = album.getByID(photoID).previousPhoto; - var nextPhotoID = album.getByID(photoID).nextPhoto; + var previousPhotoID = album.getByID(photoID).previous_photo_id; + var nextPhotoID = album.getByID(photoID).next_photo_id; var imgs = $("img#image"); var isUsing2xCurrently = imgs.length > 0 && imgs[0].currentSrc !== null && imgs[0].currentSrc.includes("@2x."); @@ -6549,12 +6568,12 @@ _photo.preloadNextPrev = function (photoID) { var preloadPhoto = album.getByID(preloadID); var href = ""; - if (preloadPhoto.sizeVariants.medium != null) { - href = preloadPhoto.sizeVariants.medium.url; - if (preloadPhoto.sizeVariants.medium2x != null && isUsing2xCurrently) { + if (preloadPhoto.size_variants.medium != null) { + href = preloadPhoto.size_variants.medium.url; + if (preloadPhoto.size_variants.medium2x != null && isUsing2xCurrently) { // If the currently displayed image uses the 2x variant, // chances are that so will the next one. - href = preloadPhoto.sizeVariants.medium2x.url; + href = preloadPhoto.size_variants.medium2x.url; } } else if (preloadPhoto.type && preloadPhoto.type.indexOf("video") === -1) { // Preload the original size, but only if it's not a video @@ -6591,10 +6610,10 @@ _photo.preloadNextPrev = function (photoID) { } }; - if (nextPhotoID && nextPhotoID !== "") { + if (nextPhotoID) { preload(nextPhotoID); } - if (previousPhotoID && previousPhotoID !== "") { + if (previousPhotoID) { preload(previousPhotoID); } } @@ -6623,7 +6642,7 @@ _photo.updateSizeLivePhotoDuringAnimation = function () { }; _photo.previous = function (animate) { - if (_photo.getID() !== false && album.json && album.getByID(_photo.getID()) && album.getByID(_photo.getID()).previousPhoto !== "") { + if (_photo.getID() !== null && album.json && album.getByID(_photo.getID()) && album.getByID(_photo.getID()).previous_photo_id !== null) { var delay = 0; if (animate === true) { @@ -6638,15 +6657,15 @@ _photo.previous = function (animate) { } setTimeout(function () { - if (_photo.getID() === false) return false; + if (_photo.getID() === null) return false; _photo.LivePhotosObject = null; - lychee.goto(album.getID() + "/" + album.getByID(_photo.getID()).previousPhoto, false); + lychee.goto(album.getID() + "/" + album.getByID(_photo.getID()).previous_photo_id, false); }, delay); } }; _photo.next = function (animate) { - if (_photo.getID() !== false && album.json && album.getByID(_photo.getID()) && album.getByID(_photo.getID()).nextPhoto !== "") { + if (_photo.getID() !== null && album.json && album.getByID(_photo.getID()) && album.getByID(_photo.getID()).next_photo_id !== null) { var delay = 0; if (animate === true) { @@ -6661,9 +6680,9 @@ _photo.next = function (animate) { } setTimeout(function () { - if (_photo.getID() === false) return false; + if (_photo.getID() === null) return false; _photo.LivePhotosObject = null; - lychee.goto(album.getID() + "/" + album.getByID(_photo.getID()).nextPhoto, false); + lychee.goto(album.getID() + "/" + album.getByID(_photo.getID()).next_photo_id, false); }, delay); } }; @@ -6675,33 +6694,34 @@ _photo.delete = function (photoIDs) { var photoTitle = ""; if (!photoIDs) return false; - if (photoIDs instanceof Array === false) photoIDs = [photoIDs]; + if (!(photoIDs instanceof Array)) photoIDs = [photoIDs]; if (photoIDs.length === 1) { // Get title if only one photo is selected - if (visible.photo()) photoTitle = _photo.json.title;else photoTitle = album.getByID(photoIDs).title; + if (visible.photo()) photoTitle = _photo.json.title;else photoTitle = album.getByID(photoIDs[0]).title; // Fallback for photos without a title if (photoTitle === "") photoTitle = lychee.locale["UNTITLED"]; } action.fn = function () { - var nextPhoto = ""; - var previousPhoto = ""; + var nextPhotoID = null; + var previousPhotoID = null; basicModal.close(); photoIDs.forEach(function (id, index) { // Change reference for the next and previous photo - if (album.getByID(id).nextPhoto !== "" || album.getByID(id).previousPhoto !== "") { - nextPhoto = album.getByID(id).nextPhoto; - previousPhoto = album.getByID(id).previousPhoto; + var curPhoto = album.getByID(id); + if (curPhoto.next_photo_id !== null || curPhoto.previous_photo_id !== null) { + nextPhotoID = curPhoto.next_photo_id; + previousPhotoID = curPhoto.previous_photo_id; - if (previousPhoto !== "") { - album.getByID(previousPhoto).nextPhoto = nextPhoto; + if (previousPhotoID !== null) { + album.getByID(previousPhotoID).next_photo_id = nextPhotoID; } - if (nextPhoto !== "") { - album.getByID(nextPhoto).previousPhoto = previousPhoto; + if (nextPhotoID !== null) { + album.getByID(nextPhotoID).previous_photo_id = previousPhotoID; } } @@ -6715,10 +6735,10 @@ _photo.delete = function (photoIDs) { // next photo is not the current one. Also try the previous one. // Show album otherwise. if (visible.photo()) { - if (nextPhoto !== "" && nextPhoto !== _photo.getID()) { - lychee.goto(album.getID() + "/" + nextPhoto); - } else if (previousPhoto !== "" && previousPhoto !== _photo.getID()) { - lychee.goto(album.getID() + "/" + previousPhoto); + if (nextPhotoID !== null && nextPhotoID !== _photo.getID()) { + lychee.goto(album.getID() + "/" + nextPhotoID); + } else if (previousPhotoID !== null && previousPhotoID !== _photo.getID()) { + lychee.goto(album.getID() + "/" + previousPhotoID); } else { lychee.goto(album.getID()); } @@ -6730,9 +6750,7 @@ _photo.delete = function (photoIDs) { photoIDs: photoIDs.join() }; - api.post("Photo::delete", params, function (data) { - if (data !== true) lychee.error(null, params, data); - }); + api.post("Photo::delete", params, null); }; if (photoIDs.length === 1) { @@ -6772,7 +6790,7 @@ _photo.setTitle = function (photoIDs) { if (photoIDs.length === 1) { // Get old title if only one photo is selected - if (_photo.json) oldTitle = _photo.json.title;else if (album.json) oldTitle = album.getByID(photoIDs).title; + if (_photo.json) oldTitle = _photo.json.title;else if (album.json) oldTitle = album.getByID(photoIDs[0]).title; } var action = function action(data) { @@ -6826,9 +6844,15 @@ _photo.setTitle = function (photoIDs) { }); }; +/** + * + * @param {string[]} photoIDs IDs of photos to be copied + * @param {?string} albumID ID of destination album; `null` means root album + * @return {void} + */ _photo.copyTo = function (photoIDs, albumID) { - if (!photoIDs) return false; - if (photoIDs instanceof Array === false) photoIDs = [photoIDs]; + if (!photoIDs) return; + if (!(photoIDs instanceof Array)) photoIDs = [photoIDs]; var params = { photoIDs: photoIDs.join(), @@ -6836,32 +6860,33 @@ _photo.copyTo = function (photoIDs, albumID) { }; api.post("Photo::duplicate", params, function (data) { - if (data !== true) { - lychee.error(null, params, data); - } else { + if (data instanceof Object) { album.reload(); + } else { + lychee.error(null, params, data); } }); }; _photo.setAlbum = function (photoIDs, albumID) { - var nextPhoto = ""; - var previousPhoto = ""; + var nextPhotoID = null; + var previousPhotoID = null; if (!photoIDs) return false; - if (photoIDs instanceof Array === false) photoIDs = [photoIDs]; + if (!(photoIDs instanceof Array)) photoIDs = [photoIDs]; photoIDs.forEach(function (id, index) { // Change reference for the next and previous photo - if (album.getByID(id).nextPhoto !== "" || album.getByID(id).previousPhoto !== "") { - nextPhoto = album.getByID(id).nextPhoto; - previousPhoto = album.getByID(id).previousPhoto; + var curPhoto = album.getByID(id); + if (curPhoto.next_photo_id !== null || curPhoto.previous_photo_id !== null) { + nextPhotoID = curPhoto.next_photo_id; + previousPhotoID = curPhoto.previous_photo_id; - if (previousPhoto !== "") { - album.getByID(previousPhoto).nextPhoto = nextPhoto; + if (previousPhotoID !== null) { + album.getByID(previousPhotoID).next_photo_id = nextPhotoID; } - if (nextPhoto !== "") { - album.getByID(nextPhoto).previousPhoto = previousPhoto; + if (nextPhotoID !== null) { + album.getByID(nextPhotoID).previous_photo_id = previousPhotoID; } } @@ -6875,10 +6900,10 @@ _photo.setAlbum = function (photoIDs, albumID) { // next photo is not the current one. Also try the previous one. // Show album otherwise. if (visible.photo()) { - if (nextPhoto !== "" && nextPhoto !== _photo.getID()) { - lychee.goto(album.getID() + "/" + nextPhoto); - } else if (previousPhoto !== "" && previousPhoto !== _photo.getID()) { - lychee.goto(album.getID() + "/" + previousPhoto); + if (nextPhotoID !== null && nextPhotoID !== _photo.getID()) { + lychee.goto(album.getID() + "/" + nextPhotoID); + } else if (previousPhotoID !== null && previousPhotoID !== _photo.getID()) { + lychee.goto(album.getID() + "/" + previousPhotoID); } else { lychee.goto(album.getID()); } @@ -6908,12 +6933,12 @@ _photo.setStar = function (photoIDs) { if (!photoIDs) return false; if (visible.photo()) { - _photo.json.star = _photo.json.star === "0" ? "1" : "0"; + _photo.json.is_starred = !_photo.json.is_starred; view.photo.star(); } photoIDs.forEach(function (id) { - album.getByID(id).star = album.getByID(id).star === "0" ? "1" : "0"; + album.getByID(id).is_starred = !album.getByID(id).is_starred; view.album.content.star(id); }); @@ -6933,7 +6958,7 @@ _photo.setPublic = function (photoID, e) { var msg_choices = lychee.html(_templateObject57, build.iconic("check"), lychee.locale["PHOTO_FULL"], lychee.locale["PHOTO_FULL_EXPL"], build.iconic("check"), lychee.locale["PHOTO_HIDDEN"], lychee.locale["PHOTO_HIDDEN_EXPL"], build.iconic("check"), lychee.locale["PHOTO_DOWNLOADABLE"], lychee.locale["PHOTO_DOWNLOADABLE_EXPL"], build.iconic("check"), lychee.locale["PHOTO_SHARE_BUTTON_VISIBLE"], lychee.locale["PHOTO_SHARE_BUTTON_VISIBLE_EXPL"], build.iconic("check"), lychee.locale["PHOTO_PASSWORD_PROT"], lychee.locale["PHOTO_PASSWORD_PROT_EXPL"]); - if (_photo.json.public === "2") { + if (_photo.json.is_public == 2) { // Public album. We can't actually change anything but we will // display the current settings. @@ -6949,19 +6974,19 @@ _photo.setPublic = function (photoID, e) { } }); - $('.basicModal .switch input[name="public"]').prop("checked", true); + $('.basicModal .switch input[name="is_public"]').prop("checked", true); if (album.json) { - if (album.json.full_photo !== null && album.json.full_photo === "1") { - $('.basicModal .choice input[name="full_photo"]').prop("checked", true); + if (album.json.grants_full_photo) { + $('.basicModal .choice input[name="grants_full_photo"]').prop("checked", true); } // Photos in public albums are never hidden as such. It's the // album that's hidden. Or is that distinction irrelevant to end // users? - if (album.json.downloadable === "1") { - $('.basicModal .choice input[name="downloadable"]').prop("checked", true); + if (album.json.is_downloadable) { + $('.basicModal .choice input[name="is_downloadable"]').prop("checked", true); } - if (album.json.password === "1") { - $('.basicModal .choice input[name="password"]').prop("checked", true); + if (album.json.has_password) { + $('.basicModal .choice input[name="has_password"]').prop("checked", true); } } @@ -6973,15 +6998,15 @@ _photo.setPublic = function (photoID, e) { var _msg4 = lychee.html(_templateObject59, msg_switch, lychee.locale["PHOTO_EDIT_GLOBAL_SHARING_TEXT"], msg_choices); var action = function action() { - var newPublic = $('.basicModal .switch input[name="public"]:checked').length === 1 ? "1" : "0"; + var newIsPublic = $('.basicModal .switch input[name="is_public"]:checked').length === 1; - if (newPublic !== _photo.json.public) { + if (newIsPublic !== _photo.json.is_public) { if (visible.photo()) { - _photo.json.public = newPublic; + _photo.json.is_public = newIsPublic; view.photo.public(); } - album.getByID(photoID).public = newPublic; + album.getByID(photoID).is_public = newIsPublic; view.album.content.public(photoID); albums.refresh(); @@ -7010,19 +7035,19 @@ _photo.setPublic = function (photoID, e) { } }); - $('.basicModal .switch input[name="public"]').on("click", function () { + $('.basicModal .switch input[name="is_public"]').on("click", function () { if ($(this).prop("checked") === true) { if (lychee.full_photo) { - $('.basicModal .choice input[name="full_photo"]').prop("checked", true); + $('.basicModal .choice input[name="grants_full_photo"]').prop("checked", true); } if (lychee.public_photos_hidden) { - $('.basicModal .choice input[name="hidden"]').prop("checked", true); + $('.basicModal .choice input[name="requires_link"]').prop("checked", true); } if (lychee.downloadable) { - $('.basicModal .choice input[name="downloadable"]').prop("checked", true); + $('.basicModal .choice input[name="is_downloadable"]').prop("checked", true); } if (lychee.share_button_visible) { - $('.basicModal .choice input[name="share_button_visible"]').prop("checked", true); + $('.basicModal .choice input[name="is_share_button_visible"]').prop("checked", true); } // Photos shared individually can't be password-protected. } else { @@ -7030,8 +7055,8 @@ _photo.setPublic = function (photoID, e) { } }); - if (_photo.json.public === "1") { - $('.basicModal .switch input[name="public"]').click(); + if (_photo.json.is_public == 1) { + $('.basicModal .switch input[name="is_public"]').click(); } } @@ -7039,12 +7064,12 @@ _photo.setPublic = function (photoID, e) { }; _photo.setDescription = function (photoID) { - var oldDescription = _photo.json.description; + var oldDescription = _photo.json.description ? _photo.json.description : ""; var action = function action(data) { basicModal.close(); - var description = data.description; + var description = data.description ? data.description : null; if (visible.photo()) { _photo.json.description = description; @@ -7086,7 +7111,7 @@ _photo.editTags = function (photoIDs) { if (photoIDs instanceof Array === false) photoIDs = [photoIDs]; // Get tags - if (visible.photo()) oldTags = _photo.json.tags;else if (visible.album() && photoIDs.length === 1) oldTags = album.getByID(photoIDs).tags;else if (visible.search() && photoIDs.length === 1) oldTags = album.getByID(photoIDs).tags;else if (visible.album() && photoIDs.length > 1) { + if (visible.photo()) oldTags = _photo.json.tags;else if (visible.album() && photoIDs.length === 1) oldTags = album.getByID(photoIDs[0]).tags;else if (visible.search() && photoIDs.length === 1) oldTags = album.getByID(photoIDs[0]).tags;else if (visible.album() && photoIDs.length > 1) { var same = true; photoIDs.forEach(function (id) { same = album.getByID(id).tags === album.getByID(photoIDs[0]).tags && same === true; @@ -7095,7 +7120,11 @@ _photo.editTags = function (photoIDs) { } // Improve tags - oldTags = oldTags.replace(/,/g, ", "); + if (typeof oldTags === "string" && oldTags !== "") { + oldTags = oldTags.replace(/,/g, ", "); + } else { + oldTags = ""; + } var action = function action(data) { basicModal.close(); @@ -7123,11 +7152,11 @@ _photo.editTags = function (photoIDs) { _photo.setTags = function (photoIDs, tags) { if (!photoIDs) return false; - if (photoIDs instanceof Array === false) photoIDs = [photoIDs]; + if (!(photoIDs instanceof Array)) photoIDs = [photoIDs]; // Parse tags - tags = tags.replace(/(\ ,\ )|(\ ,)|(,\ )|(,{1,}\ {0,})|(,$|^,)/g, ","); - tags = tags.replace(/,$|^,|(\ ){0,}$/g, ""); + tags = tags.replace(/( , )|( ,)|(, )|(,+ *)|(,$|^,)/g, ","); + tags = tags.replace(/,$|^,|( )*$/g, ""); if (visible.photo()) { _photo.json.tags = tags; @@ -7146,9 +7175,9 @@ _photo.setTags = function (photoIDs, tags) { api.post("Photo::setTags", params, function (data) { if (data !== true) { lychee.error(null, params, data); - } else if (albums.json && albums.json.smartalbums) { - $.each(Object.entries(albums.json.smartalbums), function () { - if (this.length == 2 && this[1]["tag_album"] === "1") { + } else if (albums.json && albums.json.smart_albums) { + $.each(Object.entries(albums.json.smart_albums), function () { + if (this.length === 2 && this[1]["is_tag_album"] === true) { // If we have any tag albums, force a refresh. albums.refresh(); return false; @@ -7171,7 +7200,7 @@ _photo.deleteTag = function (photoID, index) { }; _photo.share = function (photoID, service) { - if (_photo.json.hasOwnProperty("share_button_visible") && _photo.json.share_button_visible !== "1") { + if (_photo.json.hasOwnProperty("is_share_button_visible") && !_photo.json.is_share_button_visible) { return; } @@ -7212,7 +7241,7 @@ _photo.setLicense = function (photoID) { }; api.post("Photo::setLicense", params, function (_data) { - if (_data !== true) { + if (_data) { lychee.error(null, params, _data); } else { // update the photo JSON and reload the license in the sidebar @@ -7260,29 +7289,29 @@ _photo.getArchive = function (photoIDs) { var _msg5 = lychee.html(_templateObject63); - if (myPhoto.url) { - _msg5 += buildButton("FULL", lychee.locale["PHOTO_FULL"] + " (" + myPhoto.width + "x" + myPhoto.height + ", " + lychee.locale.printFilesizeLocalized(myPhoto.filesize) + ")"); + if (myPhoto.size_variants.original.url) { + _msg5 += buildButton("FULL", lychee.locale["PHOTO_FULL"] + " (" + myPhoto.size_variants.original.width + "x" + myPhoto.size_variants.original.height + ", " + lychee.locale.printFilesizeLocalized(myPhoto.filesize) + ")"); } - if (myPhoto.livePhotoUrl !== null) { + if (myPhoto.live_photo_url !== null) { _msg5 += buildButton("LIVEPHOTOVIDEO", "" + lychee.locale["PHOTO_LIVE_VIDEO"]); } - if (myPhoto.sizeVariants.medium2x !== null) { - _msg5 += buildButton("MEDIUM2X", lychee.locale["PHOTO_MEDIUM_HIDPI"] + " (" + myPhoto.sizeVariants.medium2x.width + "x" + myPhoto.sizeVariants.medium2x.height + ")"); + if (myPhoto.size_variants.medium2x !== null) { + _msg5 += buildButton("MEDIUM2X", lychee.locale["PHOTO_MEDIUM_HIDPI"] + " (" + myPhoto.size_variants.medium2x.width + "x" + myPhoto.size_variants.medium2x.height + ")"); } - if (myPhoto.sizeVariants.medium !== null) { - _msg5 += buildButton("MEDIUM", lychee.locale["PHOTO_MEDIUM"] + " (" + myPhoto.sizeVariants.medium.width + "x" + myPhoto.sizeVariants.medium.height + ")"); + if (myPhoto.size_variants.medium !== null) { + _msg5 += buildButton("MEDIUM", lychee.locale["PHOTO_MEDIUM"] + " (" + myPhoto.size_variants.medium.width + "x" + myPhoto.size_variants.medium.height + ")"); } - if (myPhoto.sizeVariants.small2x !== null) { - _msg5 += buildButton("SMALL2X", lychee.locale["PHOTO_SMALL_HIDPI"] + " (" + myPhoto.sizeVariants.small2x.width + "x" + myPhoto.sizeVariants.small2x.height + ")"); + if (myPhoto.size_variants.small2x !== null) { + _msg5 += buildButton("SMALL2X", lychee.locale["PHOTO_SMALL_HIDPI"] + " (" + myPhoto.size_variants.small2x.width + "x" + myPhoto.size_variants.small2x.height + ")"); } - if (myPhoto.sizeVariants.small !== null) { - _msg5 += buildButton("SMALL", lychee.locale["PHOTO_SMALL"] + " (" + myPhoto.sizeVariants.small.width + "x" + myPhoto.sizeVariants.small.height + ")"); + if (myPhoto.size_variants.small !== null) { + _msg5 += buildButton("SMALL", lychee.locale["PHOTO_SMALL"] + " (" + myPhoto.size_variants.small.width + "x" + myPhoto.size_variants.small.height + ")"); } - if (myPhoto.sizeVariants.thumb2x !== null) { - _msg5 += buildButton("THUMB2X", lychee.locale["PHOTO_THUMB_HIDPI"] + " (" + myPhoto.sizeVariants.thumb2x.width + "x" + myPhoto.sizeVariants.thumb2x.height + ")"); + if (myPhoto.size_variants.thumb2x !== null) { + _msg5 += buildButton("THUMB2X", lychee.locale["PHOTO_THUMB_HIDPI"] + " (" + myPhoto.size_variants.thumb2x.width + "x" + myPhoto.size_variants.thumb2x.height + ")"); } - if (myPhoto.sizeVariants.thumb !== null) { - _msg5 += buildButton("THUMB", lychee.locale["PHOTO_THUMB"] + " (" + myPhoto.sizeVariants.thumb.width + "x" + myPhoto.sizeVariants.thumb.height + ")"); + if (myPhoto.size_variants.thumb !== null) { + _msg5 += buildButton("THUMB", lychee.locale["PHOTO_THUMB"] + " (" + myPhoto.size_variants.thumb.width + "x" + myPhoto.size_variants.thumb.height + ")"); } _msg5 += lychee.html(_templateObject64); @@ -7312,7 +7341,7 @@ _photo.getArchive = function (photoIDs) { _photo.getDirectLink = function () { var url = ""; - if (_photo.json && _photo.json.url && _photo.json.url !== "") url = _photo.json.url; + if (_photo.json && _photo.json.size_variants && _photo.json.size_variants.original && _photo.json.size_variants.original.url && _photo.json.size_variants.original.url !== "") url = _photo.json.size_variants.original.url; return url; }; @@ -7334,29 +7363,29 @@ _photo.showDirectLinks = function (photoID) { var msg = lychee.html(_templateObject67, buildLine(lychee.locale["PHOTO_VIEW"], _photo.getViewLink(photoID)), lychee.locale["PHOTO_DIRECT_LINKS_TO_IMAGES"]); - if (_photo.json.url) { - msg += buildLine(lychee.locale["PHOTO_FULL"] + " (" + _photo.json.width + "x" + _photo.json.height + ")", lychee.getBaseUrl() + _photo.json.url); + if (_photo.json.size_variants.original.url) { + msg += buildLine(lychee.locale["PHOTO_FULL"] + " (" + _photo.json.size_variants.original.width + "x" + _photo.json.size_variants.original.height + ")", lychee.getBaseUrl() + _photo.json.size_variants.original.url); } - if (_photo.json.sizeVariants.medium2x !== null) { - msg += buildLine(lychee.locale["PHOTO_MEDIUM_HIDPI"] + " (" + _photo.json.sizeVariants.medium2x.width + "x" + _photo.json.sizeVariants.medium2x.height + ")", lychee.getBaseUrl() + _photo.json.sizeVariants.medium2x.url); + if (_photo.json.size_variants.medium2x !== null) { + msg += buildLine(lychee.locale["PHOTO_MEDIUM_HIDPI"] + " (" + _photo.json.size_variants.medium2x.width + "x" + _photo.json.size_variants.medium2x.height + ")", lychee.getBaseUrl() + _photo.json.size_variants.medium2x.url); } - if (_photo.json.sizeVariants.medium !== null) { - msg += buildLine(lychee.locale["PHOTO_MEDIUM"] + " (" + _photo.json.sizeVariants.medium.width + "x" + _photo.json.sizeVariants.medium.height + ")", lychee.getBaseUrl() + _photo.json.sizeVariants.medium.url); + if (_photo.json.size_variants.medium !== null) { + msg += buildLine(lychee.locale["PHOTO_MEDIUM"] + " (" + _photo.json.size_variants.medium.width + "x" + _photo.json.size_variants.medium.height + ")", lychee.getBaseUrl() + _photo.json.size_variants.medium.url); } - if (_photo.json.sizeVariants.small2x !== null) { - msg += buildLine(lychee.locale["PHOTO_SMALL_HIDPI"] + " (" + _photo.json.sizeVariants.small2x.width + "x" + _photo.json.sizeVariants.small2x.height + ")", lychee.getBaseUrl() + _photo.json.sizeVariants.small2x.url); + if (_photo.json.size_variants.small2x !== null) { + msg += buildLine(lychee.locale["PHOTO_SMALL_HIDPI"] + " (" + _photo.json.size_variants.small2x.width + "x" + _photo.json.size_variants.small2x.height + ")", lychee.getBaseUrl() + _photo.json.size_variants.small2x.url); } - if (_photo.json.sizeVariants.small !== null) { - msg += buildLine(lychee.locale["PHOTO_SMALL"] + " (" + _photo.json.sizeVariants.small.width + "x" + _photo.json.sizeVariants.small.height + ")", lychee.getBaseUrl() + _photo.json.sizeVariants.small.url); + if (_photo.json.size_variants.small !== null) { + msg += buildLine(lychee.locale["PHOTO_SMALL"] + " (" + _photo.json.size_variants.small.width + "x" + _photo.json.size_variants.small.height + ")", lychee.getBaseUrl() + _photo.json.size_variants.small.url); } - if (_photo.json.sizeVariants.thumb2x !== null) { - msg += buildLine(lychee.locale["PHOTO_THUMB_HIDPI"] + " (" + _photo.json.sizeVariants.thumb2x.width + "x" + _photo.json.sizeVariants.thumb2x.height + ")", lychee.getBaseUrl() + _photo.json.sizeVariants.thumb2x.url); + if (_photo.json.size_variants.thumb2x !== null) { + msg += buildLine(lychee.locale["PHOTO_THUMB_HIDPI"] + " (" + _photo.json.size_variants.thumb2x.width + "x" + _photo.json.size_variants.thumb2x.height + ")", lychee.getBaseUrl() + _photo.json.size_variants.thumb2x.url); } - if (_photo.json.sizeVariants.thumb !== null) { - msg += buildLine(lychee.locale["PHOTO_THUMB"] + " (" + _photo.json.sizeVariants.thumb.width + "x" + _photo.json.sizeVariants.thumb.height + ")", lychee.getBaseUrl() + _photo.json.sizeVariants.thumb.url); + if (_photo.json.size_variants.thumb !== null) { + msg += buildLine(lychee.locale["PHOTO_THUMB"] + " (" + _photo.json.size_variants.thumb.width + "x" + _photo.json.size_variants.thumb.height + ")", lychee.getBaseUrl() + _photo.json.size_variants.thumb.url); } - if (_photo.json.livePhotoUrl !== "") { - msg += buildLine(" " + lychee.locale["PHOTO_LIVE_VIDEO"] + " ", lychee.getBaseUrl() + _photo.json.livePhotoUrl); + if (_photo.json.live_photo_url !== "") { + msg += buildLine(" " + lychee.locale["PHOTO_LIVE_VIDEO"] + " ", lychee.getBaseUrl() + _photo.json.live_photo_url); } msg += lychee.html(_templateObject68); @@ -7401,18 +7430,18 @@ photoeditor.rotate = function (photoID, direction) { lychee.error(null, params, data); } else { _photo.json = data; - _photo.json.original_album = _photo.json.album; + _photo.json.original_album_id = _photo.json.album_id; if (album.json) { - _photo.json.album = album.json.id; + _photo.json.album_id = album.json.id; } var image = $("img#image"); - if (_photo.json.sizeVariants.medium2x !== null) { - image.prop("srcset", _photo.json.sizeVariants.medium.url + " " + _photo.json.sizeVariants.medium.width + "w, " + _photo.json.sizeVariants.medium2x.url + " " + _photo.json.sizeVariants.medium2x.width + "w"); + if (_photo.json.size_variants.medium2x !== null) { + image.prop("srcset", _photo.json.size_variants.medium.url + " " + _photo.json.size_variants.medium.width + "w, " + _photo.json.size_variants.medium2x.url + " " + _photo.json.size_variants.medium2x.width + "w"); } else { image.prop("srcset", ""); } - image.prop("src", _photo.json.sizeVariants.medium !== null ? _photo.json.sizeVariants.medium.url : _photo.json.url); + image.prop("src", _photo.json.size_variants.medium !== null ? _photo.json.size_variants.medium.url : _photo.json.size_variants.original.url); view.photo.onresize(); view.photo.sidebar(); @@ -8036,14 +8065,12 @@ settings.changeCSS = function () { }; settings.save = function (params) { - var exitview = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; - api.post("Settings::saveAll", params, function (data) { if (data === true) { loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_UPDATE"]); view.full_settings.init(); // re-read settings - lychee.init(exitview); + lychee.init(false); } else lychee.error("Check the Logs", params, data); }); }; @@ -8062,7 +8089,7 @@ settings.save_enter = function (e) { cancel.title = lychee.locale["CANCEL"]; action.fn = function () { - settings.save(settings.getValues("#fullSettings"), false); + settings.save(settings.getValues("#fullSettings")); basicModal.close(); }; @@ -8267,13 +8294,13 @@ _sidebar.setSelectable = function () { }; _sidebar.changeAttr = function (attr) { - var value = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : "-"; + var value = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ""; var dangerouslySetInnerHTML = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; if (attr == null || attr === "") return false; // Set a default for the value - if (value == null || value === "") value = "-"; + if (value === null) value = ""; // Escape value if (dangerouslySetInnerHTML === false) value = lychee.escapeHTML(value); @@ -8304,7 +8331,7 @@ _sidebar.createStructure.photo = function (data) { var exifHash = data.taken_at + data.make + data.model + data.shutter + data.aperture + data.focal + data.iso; var locationHash = data.longitude + data.latitude + data.altitude; var structure = {}; - var _public = ""; + var isPublic = ""; var isVideo = data.type && data.type.indexOf("video") > -1; var license = void 0; @@ -8325,35 +8352,39 @@ _sidebar.createStructure.photo = function (data) { } // Set value for public - switch (data.public) { - case "0": - _public = lychee.locale["PHOTO_SHR_NO"]; + switch (data.is_public) { + case 0: + isPublic = lychee.locale["PHOTO_SHR_NO"]; break; - case "1": - _public = lychee.locale["PHOTO_SHR_PHT"]; + case 1: + isPublic = lychee.locale["PHOTO_SHR_PHT"]; break; - case "2": - _public = lychee.locale["PHOTO_SHR_ALB"]; + case 2: + isPublic = lychee.locale["PHOTO_SHR_ALB"]; break; default: - _public = "-"; + isPublic = "-"; break; } structure.basics = { title: lychee.locale["PHOTO_BASICS"], type: _sidebar.types.DEFAULT, - rows: [{ title: lychee.locale["PHOTO_TITLE"], kind: "title", value: data.title, editable: editable }, { title: lychee.locale["PHOTO_UPLOADED"], kind: "uploaded", value: lychee.locale.printDateTime(data.created_at) }, { title: lychee.locale["PHOTO_DESCRIPTION"], kind: "description", value: data.description, editable: editable }] + rows: [{ title: lychee.locale["PHOTO_TITLE"], kind: "title", value: data.title, editable: editable }, { title: lychee.locale["PHOTO_UPLOADED"], kind: "uploaded", value: lychee.locale.printDateTime(data.created_at) }, { title: lychee.locale["PHOTO_DESCRIPTION"], kind: "description", value: data.description ? data.description : "", editable: editable }] }; structure.image = { title: lychee.locale[isVideo ? "PHOTO_VIDEO" : "PHOTO_IMAGE"], type: _sidebar.types.DEFAULT, - rows: [{ title: lychee.locale["PHOTO_SIZE"], kind: "size", value: lychee.locale.printFilesizeLocalized(data.filesize) }, { title: lychee.locale["PHOTO_FORMAT"], kind: "type", value: data.type }, { title: lychee.locale["PHOTO_RESOLUTION"], kind: "resolution", value: data.width + " x " + data.height }] + rows: [{ title: lychee.locale["PHOTO_SIZE"], kind: "size", value: lychee.locale.printFilesizeLocalized(data.filesize) }, { title: lychee.locale["PHOTO_FORMAT"], kind: "type", value: data.type }, { + title: lychee.locale["PHOTO_RESOLUTION"], + kind: "resolution", + value: data.size_variants.original.width + " x " + data.size_variants.original.height + }] }; if (isVideo) { - if (data.width === 0 || data.height === 0) { + if (data.size_variants.original.width === 0 || data.size_variants.original.height === 0) { // Remove the "Resolution" line if we don't have the data. structure.image.rows.splice(-1, 1); } @@ -8393,7 +8424,7 @@ _sidebar.createStructure.photo = function (data) { structure.sharing = { title: lychee.locale["PHOTO_SHARING"], type: _sidebar.types.DEFAULT, - rows: [{ title: lychee.locale["PHOTO_SHR_PLUBLIC"], kind: "public", value: _public }] + rows: [{ title: lychee.locale["PHOTO_SHR_PLUBLIC"], kind: "public", value: isPublic }] }; structure.license = { @@ -8422,12 +8453,12 @@ _sidebar.createStructure.photo = function (data) { value: data.altitude ? (Math.round(parseFloat(data.altitude) * 10) / 10).toString() + "m" : "" }, { title: lychee.locale["PHOTO_LOCATION"], kind: "location", value: data.location ? data.location : "" }] }; - if (data.imgDirection) { + if (data.img_direction) { // No point in display sub-degree precision. structure.location.rows.push({ title: lychee.locale["PHOTO_IMGDIRECTION"], kind: "imgDirection", - value: Math.round(data.imgDirection).toString() + "°" + value: Math.round(data.img_direction).toString() + "°" }); } } else { @@ -8451,79 +8482,14 @@ _sidebar.createStructure.album = function (album) { var editable = album.isUploadable(); var structure = {}; - var _public = ""; - var hidden = ""; - var downloadable = ""; - var share_button_visible = ""; - var password = ""; + var isPublic = data.is_public ? lychee.locale["ALBUM_SHR_YES"] : lychee.locale["ALBUM_SHR_NO"]; + var requiresLink = data.requires_link ? lychee.locale["ALBUM_SHR_YES"] : lychee.locale["ALBUM_SHR_NO"]; + var isDownloadable = data.is_downloadable ? lychee.locale["ALBUM_SHR_YES"] : lychee.locale["ALBUM_SHR_NO"]; + var isShareButtonVisible = data.is_share_button_visible ? lychee.locale["ALBUM_SHR_YES"] : lychee.locale["ALBUM_SHR_NO"]; + var hasPassword = data.has_password ? lychee.locale["ALBUM_SHR_YES"] : lychee.locale["ALBUM_SHR_NO"]; var license = ""; var sorting = ""; - // Set value for public - switch (data.public) { - case "0": - _public = lychee.locale["ALBUM_SHR_NO"]; - break; - case "1": - _public = lychee.locale["ALBUM_SHR_YES"]; - break; - default: - _public = "-"; - break; - } - - // Set value for hidden - switch (data.visible) { - case "0": - hidden = lychee.locale["ALBUM_SHR_YES"]; - break; - case "1": - hidden = lychee.locale["ALBUM_SHR_NO"]; - break; - default: - hidden = "-"; - break; - } - - // Set value for downloadable - switch (data.downloadable) { - case "0": - downloadable = lychee.locale["ALBUM_SHR_NO"]; - break; - case "1": - downloadable = lychee.locale["ALBUM_SHR_YES"]; - break; - default: - downloadable = "-"; - break; - } - - // Set value for share_button_visible - switch (data.share_button_visible) { - case "0": - share_button_visible = lychee.locale["ALBUM_SHR_NO"]; - break; - case "1": - share_button_visible = lychee.locale["ALBUM_SHR_YES"]; - break; - default: - share_button_visible = "-"; - break; - } - - // Set value for password - switch (data.password) { - case "0": - password = lychee.locale["ALBUM_SHR_NO"]; - break; - case "1": - password = lychee.locale["ALBUM_SHR_YES"]; - break; - default: - password = "-"; - break; - } - // Set license string switch (data.license) { case "none": @@ -8537,16 +8503,18 @@ _sidebar.createStructure.album = function (album) { break; } - if (data.sorting_col === "") { - sorting = lychee.locale["DEFAULT"]; - } else { - sorting = data.sorting_col + " " + data.sorting_order; + if (!lychee.publicMode) { + if (data.sorting_col === null) { + sorting = lychee.locale["DEFAULT"]; + } else { + sorting = data.sorting_col + " " + data.sorting_order; + } } structure.basics = { title: lychee.locale["ALBUM_BASICS"], type: _sidebar.types.DEFAULT, - rows: [{ title: lychee.locale["ALBUM_TITLE"], kind: "title", value: data.title, editable: editable }, { title: lychee.locale["ALBUM_DESCRIPTION"], kind: "description", value: data.description, editable: editable }] + rows: [{ title: lychee.locale["ALBUM_TITLE"], kind: "title", value: data.title, editable: editable }, { title: lychee.locale["ALBUM_DESCRIPTION"], kind: "description", value: data.description ? data.description : "", editable: editable }] }; if (album.isTagAlbum()) { @@ -8576,18 +8544,18 @@ _sidebar.createStructure.album = function (album) { structure.album.rows.push({ title: lychee.locale["ALBUM_VIDEOS"], kind: "videos", value: videoCount }); } - if (data.photos) { + if (data.photos && sorting !== "") { structure.album.rows.push({ title: lychee.locale["ALBUM_ORDERING"], kind: "sorting", value: sorting, editable: editable }); } structure.share = { title: lychee.locale["ALBUM_SHARING"], type: _sidebar.types.DEFAULT, - rows: [{ title: lychee.locale["ALBUM_PUBLIC"], kind: "public", value: _public }, { title: lychee.locale["ALBUM_HIDDEN"], kind: "hidden", value: hidden }, { title: lychee.locale["ALBUM_DOWNLOADABLE"], kind: "downloadable", value: downloadable }, { title: lychee.locale["ALBUM_SHARE_BUTTON_VISIBLE"], kind: "share_button_visible", value: share_button_visible }, { title: lychee.locale["ALBUM_PASSWORD"], kind: "password", value: password }] + rows: [{ title: lychee.locale["ALBUM_PUBLIC"], kind: "public", value: isPublic }, { title: lychee.locale["ALBUM_HIDDEN"], kind: "hidden", value: requiresLink }, { title: lychee.locale["ALBUM_DOWNLOADABLE"], kind: "downloadable", value: isDownloadable }, { title: lychee.locale["ALBUM_SHARE_BUTTON_VISIBLE"], kind: "share_button_visible", value: isShareButtonVisible }, { title: lychee.locale["ALBUM_PASSWORD"], kind: "password", value: hasPassword }] }; - if (data.owner != null) { - structure.share.rows.push({ title: lychee.locale["ALBUM_OWNER"], kind: "owner", value: data.owner }); + if (data.owner_name != null) { + structure.share.rows.push({ title: lychee.locale["ALBUM_OWNER"], kind: "owner", value: data.owner_name }); } structure.license = { @@ -9124,11 +9092,14 @@ upload.start = { albums.refresh(); - if (album.getID() === false) lychee.goto();else album.load(albumID); + if (albumID === null) lychee.goto();else album.load(albumID); }; formData.append("function", "Photo::add"); - formData.append("albumID", albumID); + // For form data, a `null` value is indicated by the empty + // string `""`. Form data falsely converts the value `null` to the + // literal string `"null"`. + formData.append("albumID", albumID ? albumID : ""); formData.append(0, files[file_num]); var api_url = "api/" + "Photo::add"; @@ -9139,8 +9110,8 @@ upload.start = { var data = null; var errorText = ""; - var isNumber = function isNumber(n) { - return !isNaN(parseFloat(n)) && isFinite(n); + var isModelID = function isModelID(photoID) { + return typeof photoID === "string" && photoID.length === 24; }; data = xhr.responseText; @@ -9160,7 +9131,7 @@ upload.start = { } // Set status - if (xhr.status === 200 && isNumber(data)) { + if ((xhr.status === 200 || xhr.status === 201) && isModelID(data.id)) { // Success $(nRowStatusSelector(file_num + 1)).html(lychee.locale["UPLOAD_FINISHED"]).addClass("success"); } else { @@ -9256,7 +9227,6 @@ upload.start = { }; if (files.length <= 0) return false; - if (albumID === false || visible.albums() === true) albumID = 0; window.onbeforeunload = function () { return lychee.locale["UPLOAD_IN_PROGRESS"]; @@ -9278,8 +9248,6 @@ upload.start = { _url = typeof _url === "string" ? _url : ""; - if (albumID === false) albumID = 0; - var action = function action(data) { var files = []; @@ -9319,7 +9287,7 @@ upload.start = { albums.refresh(); - if (album.getID() === false) lychee.goto();else album.load(albumID); + if (albumID === null) lychee.goto();else album.load(albumID); }); }); } else basicModal.error("link"); @@ -9342,7 +9310,6 @@ upload.start = { server: function server() { var albumID = album.getID(); - if (albumID === false) albumID = 0; var action = function action(data) { if (!data.path.trim()) { @@ -9474,7 +9441,7 @@ upload.start = { upload.notify(lychee.locale["UPLOAD_IMPORT_COMPLETE"], encounteredProblems ? lychee.locale["UPLOAD_COMPLETE_FAILED"] : null); - if (album.getID() === false) lychee.goto();else album.load(albumID); + if (albumID === null) lychee.goto();else album.load(albumID); if (encounteredProblems) showCloseButton();else basicModal.close(); }, function (event) { @@ -9518,7 +9485,7 @@ upload.start = { albums.refresh(); upload.notify(lychee.locale["UPLOAD_COMPLETE"], lychee.locale["UPLOAD_COMPLETE_FAILED"]); - if (album.getID() === false) lychee.goto();else album.load(albumID); + if (albumID === null) lychee.goto();else album.load(albumID); showCloseButton(); @@ -9578,7 +9545,6 @@ upload.start = { dropbox: function dropbox() { var albumID = album.getID(); - if (albumID === false) albumID = 0; var success = function success(files) { var links = ""; @@ -9623,7 +9589,7 @@ upload.start = { albums.refresh(); - if (album.getID() === false) lychee.goto();else album.load(albumID); + if (albumID === null) lychee.goto();else album.load(albumID); }); }); }; @@ -9674,18 +9640,18 @@ users.update = function (params) { } if ($("#UserData" + params.id + ' .choice input[name="upload"]:checked').length === 1) { - params.upload = "1"; + params.may_upload = true; } else { - params.upload = "0"; + params.may_upload = false; } if ($("#UserData" + params.id + ' .choice input[name="lock"]:checked').length === 1) { - params.lock = "1"; + params.is_locked = true; } else { - params.lock = "0"; + params.is_locked = false; } api.post("User::Save", params, function (data) { - if (data !== true) { + if (data) { loadingBar.show("error", data.description); lychee.error(null, params, data); } else { @@ -9706,36 +9672,26 @@ users.create = function (params) { } if ($('#UserCreate .choice input[name="upload"]:checked').length === 1) { - params.upload = "1"; + params.may_upload = true; } else { - params.upload = "0"; + params.may_upload = false; } if ($('#UserCreate .choice input[name="lock"]:checked').length === 1) { - params.lock = "1"; + params.is_locked = true; } else { - params.lock = "0"; + params.is_locked = false; } - api.post("User::Create", params, function (data) { - if (data !== true) { - loadingBar.show("error", data.description); - lychee.error(null, params, data); - } else { - loadingBar.show("success", "User created!"); - users.list(); // reload user list - } + api.post("User::Create", params, function () { + loadingBar.show("success", "User created!"); + users.list(); // reload user list }); }; users.delete = function (params) { - api.post("User::Delete", params, function (data) { - if (data !== true) { - loadingBar.show("error", data.description); - lychee.error(null, params, data); - } else { - loadingBar.show("success", "User deleted!"); - users.list(); // reload user list - } + api.post("User::Delete", params, function () { + loadingBar.show("success", "User deleted!"); + users.list(); // reload user list }); }; @@ -9779,33 +9735,32 @@ view.albums = { var sharedData = ""; // Smart Albums - if (albums.json.smartalbums != null) { + if (albums.json.smart_albums != null) { if (lychee.publicMode === false) { smartData = build.divider(lychee.locale["SMART_ALBUMS"]); } - if (albums.json.smartalbums.unsorted) { - albums.parse(albums.json.smartalbums.unsorted); - smartData += build.album(albums.json.smartalbums.unsorted); + if (albums.json.smart_albums.unsorted) { + albums.parse(albums.json.smart_albums.unsorted); + smartData += build.album(albums.json.smart_albums.unsorted); } - if (albums.json.smartalbums.public) { - albums.parse(albums.json.smartalbums.public); - smartData += build.album(albums.json.smartalbums.public); + if (albums.json.smart_albums.public) { + albums.parse(albums.json.smart_albums.public); + smartData += build.album(albums.json.smart_albums.public); } - if (albums.json.smartalbums.starred) { - albums.parse(albums.json.smartalbums.starred); - smartData += build.album(albums.json.smartalbums.starred); + if (albums.json.smart_albums.starred) { + albums.parse(albums.json.smart_albums.starred); + smartData += build.album(albums.json.smart_albums.starred); } - if (albums.json.smartalbums.recent) { - albums.parse(albums.json.smartalbums.recent); - smartData += build.album(albums.json.smartalbums.recent); + if (albums.json.smart_albums.recent) { + albums.parse(albums.json.smart_albums.recent); + smartData += build.album(albums.json.smart_albums.recent); } - Object.entries(albums.json.smartalbums).forEach(function (_ref) { + Object.entries(albums.json.smart_albums).forEach(function (_ref) { var _ref2 = _slicedToArray(_ref, 2), - albumName = _ref2[0], albumData = _ref2[1]; - if (albumData["tag_album"] === "1") { + if (albumData["is_tag_album"]) { albums.parse(albumData); smartData += build.album(albumData); } @@ -9815,7 +9770,7 @@ view.albums = { // Albums if (albums.json.albums && albums.json.albums.length !== 0) { $.each(albums.json.albums, function () { - if (!this.parent_id || this.parent_id === 0) { + if (!this.parent_id) { albums.parse(this); albumsData += build.album(this); } @@ -9831,11 +9786,11 @@ view.albums = { if (albums.json.shared_albums && albums.json.shared_albums.length !== 0) { for (i = 0; i < albums.json.shared_albums.length; ++i) { var alb = albums.json.shared_albums[i]; - if (!alb.parent_id || alb.parent_id === 0) { + if (!alb.parent_id) { albums.parse(alb); - if (current_owner !== alb.owner && lychee.publicMode === false) { - sharedData += build.divider(alb.owner); - current_owner = alb.owner; + if (current_owner !== alb.owner_name && lychee.publicMode === false) { + sharedData += build.divider(alb.owner_name); + current_owner = alb.owner_name; } sharedData += build.album(alb, !lychee.admin); } @@ -9923,7 +9878,7 @@ view.album = { return; } - if (album.json.nsfw && album.json.nsfw === "1" && !lychee.nsfw_unlocked_albums.includes(album.json.id)) { + if (album.json.is_nsfw && !lychee.nsfw_unlocked_albums.includes(album.json.id)) { $("#sensitive_warning").show(); } else { $("#sensitive_warning").hide(); @@ -10003,13 +9958,13 @@ view.album = { star: function star(photoID) { var $badge = $('.photo[data-id="' + photoID + '"] .icn-star'); - if (album.getByID(photoID).star === "1") $badge.addClass("badge--star");else $badge.removeClass("badge--star"); + if (album.getByID(photoID).is_starred) $badge.addClass("badge--star");else $badge.removeClass("badge--star"); }, public: function _public(photoID) { var $badge = $('.photo[data-id="' + photoID + '"] .icn-share'); - if (album.getByID(photoID).public === "1") $badge.addClass("badge--visible badge--hidden");else $badge.removeClass("badge--visible badge--hidden"); + if (album.getByID(photoID).is_public == 1) $badge.addClass("badge--visible badge--hidden");else $badge.removeClass("badge--visible badge--hidden"); }, cover: function cover(photoID) { @@ -10037,27 +9992,27 @@ view.album = { // This mimicks the structure of build.photo if (lychee.layout === "0") { - src = data.sizeVariants.thumb.url; - if (data.sizeVariants.thumb2x !== null) { - srcset = data.sizeVariants.thumb2x.url + " 2x"; + src = data.size_variants.thumb.url; + if (data.size_variants.thumb2x !== null) { + srcset = data.size_variants.thumb2x.url + " 2x"; } } else { - if (data.sizeVariants.small !== null) { - src = data.sizeVariants.small.url; - if (data.sizeVariants.small2x !== null) { - srcset = data.sizeVariants.small.url + " " + data.sizeVariants.small.width + "w, " + data.sizeVariants.small2x.url + " " + data.sizeVariants.small2x.width + "w"; + if (data.size_variants.small !== null) { + src = data.size_variants.small.url; + if (data.size_variants.small2x !== null) { + srcset = data.size_variants.small.url + " " + data.size_variants.small.width + "w, " + data.size_variants.small2x.url + " " + data.size_variants.small2x.width + "w"; } - } else if (data.sizeVariants.medium !== null) { - src = data.sizeVariants.medium.url; - if (data.sizeVariants.medium2x !== null) { - srcset = data.sizeVariants.medium.url + " " + data.sizeVariants.medium.width + "w, " + data.sizeVariants.medium2x.url + " " + data.sizeVariants.medium2x.width + "w"; + } else if (data.size_variants.medium !== null) { + src = data.size_variants.medium.url; + if (data.size_variants.medium2x !== null) { + srcset = data.size_variants.medium.url + " " + data.size_variants.medium.width + "w, " + data.size_variants.medium2x.url + " " + data.size_variants.medium2x.width + "w"; } } else if (!data.type || data.type.indexOf("video") !== 0) { - src = data.url; + src = data.size_variants.original.url; } else { - src = data.sizeVariants.thumb.url; - if (data.sizeVariants.thumb2x !== null) { - srcset = data.sizeVariants.thumb.url + " " + data.sizeVariants.thumb.width + "w, " + data.sizeVariants.thumb2x.url + " " + data.sizeVariants.thumb2x.width + "w"; + src = data.size_variants.thumb.url; + if (data.size_variants.thumb2x !== null) { + srcset = data.size_variants.thumb.url + " " + data.size_variants.thumb.width + "w, " + data.size_variants.thumb2x.url + " " + data.size_variants.thumb2x.width + "w"; } } } @@ -10136,11 +10091,14 @@ view.album = { } var ratio = []; $.each(album.json.photos, function (i) { - ratio[i] = this.height > 0 ? this.width / this.height : 1; + var height = this.size_variants.original.height; + var width = this.size_variants.original.width; + ratio[i] = height > 0 ? width / height : 1; + if (this.type && this.type.indexOf("video") > -1) { // Video. If there's no small and medium, we have // to fall back to the square thumb. - if (this.small === "" && this.medium === "") { + if (this.size_variants.small === null && this.size_variants.medium === null) { ratio[i] = 1; } } @@ -10192,11 +10150,11 @@ view.album = { // query is being modified. return false; } - var ratio = album.json.photos[i].height > 0 ? album.json.photos[i].width / album.json.photos[i].height : 1; + var ratio = album.json.photos[i].size_variants.original.height > 0 ? album.json.photos[i].size_variants.original.width / album.json.photos[i].size_variants.original.height : 1; if (album.json.photos[i].type && album.json.photos[i].type.indexOf("video") > -1) { // Video. If there's no small and medium, we have // to fall back to the square thumb. - if (album.json.photos[i].small === "" && album.json.photos[i].medium === "") { + if (album.json.photos[i].size_variants.small === null && album.json.photos[i].size_variants.medium === null) { ratio = 1; } } @@ -10221,7 +10179,7 @@ view.album = { }, description: function description() { - _sidebar.changeAttr("description", album.json.description); + _sidebar.changeAttr("description", album.json.description ? album.json.description : ""); }, show_tags: function show_tags() { @@ -10249,8 +10207,8 @@ view.album = { public: function _public() { $("#button_visibility_album, #button_sharing_album_users").removeClass("active--not-hidden active--hidden"); - if (album.json.public === "1") { - if (album.json.visible === "0") { + if (album.json.is_public) { + if (album.json.requires_link) { $("#button_visibility_album, #button_sharing_album_users").addClass("active--hidden"); } else { $("#button_visibility_album, #button_sharing_album_users").addClass("active--not-hidden"); @@ -10264,12 +10222,12 @@ view.album = { } }, - hidden: function hidden() { - if (album.json.visible === "1") _sidebar.changeAttr("hidden", lychee.locale["ALBUM_SHR_NO"]);else _sidebar.changeAttr("hidden", lychee.locale["ALBUM_SHR_YES"]); + requiresLink: function requiresLink() { + if (album.json.requires_link) _sidebar.changeAttr("hidden", lychee.locale["ALBUM_SHR_YES"]);else _sidebar.changeAttr("hidden", lychee.locale["ALBUM_SHR_NO"]); }, nsfw: function nsfw() { - if (album.json.nsfw === "1") { + if (album.json.is_nsfw) { // Sensitive $("#button_nsfw_album").addClass("active").attr("title", lychee.locale["ALBUM_UNMARK_NSFW"]); } else { @@ -10279,15 +10237,15 @@ view.album = { }, downloadable: function downloadable() { - if (album.json.downloadable === "1") _sidebar.changeAttr("downloadable", lychee.locale["ALBUM_SHR_YES"]);else _sidebar.changeAttr("downloadable", lychee.locale["ALBUM_SHR_NO"]); + if (album.json.is_downloadable) _sidebar.changeAttr("downloadable", lychee.locale["ALBUM_SHR_YES"]);else _sidebar.changeAttr("downloadable", lychee.locale["ALBUM_SHR_NO"]); }, shareButtonVisible: function shareButtonVisible() { - if (album.json.share_button_visible === "1") _sidebar.changeAttr("share_button_visible", lychee.locale["ALBUM_SHR_YES"]);else _sidebar.changeAttr("share_button_visible", lychee.locale["ALBUM_SHR_NO"]); + if (album.json.is_share_button_visible) _sidebar.changeAttr("share_button_visible", lychee.locale["ALBUM_SHR_YES"]);else _sidebar.changeAttr("share_button_visible", lychee.locale["ALBUM_SHR_NO"]); }, password: function password() { - if (album.json.password === "1") _sidebar.changeAttr("password", lychee.locale["ALBUM_SHR_YES"]);else _sidebar.changeAttr("password", lychee.locale["ALBUM_SHR_NO"]); + if (album.json.has_password) _sidebar.changeAttr("password", lychee.locale["ALBUM_SHR_YES"]);else _sidebar.changeAttr("password", lychee.locale["ALBUM_SHR_NO"]); }, sidebar: function sidebar() { @@ -10378,7 +10336,7 @@ view.photo = { }, description: function description() { - if (_photo.json.init) _sidebar.changeAttr("description", _photo.json.description); + if (_photo.json.init) _sidebar.changeAttr("description", _photo.json.description ? _photo.json.description : ""); }, license: function license() { @@ -10402,7 +10360,7 @@ view.photo = { }, star: function star() { - if (_photo.json.star === "1") { + if (_photo.json.is_starred) { // Starred $("#button_star").addClass("active").attr("title", lychee.locale["UNSTAR_PHOTO"]); } else { @@ -10414,9 +10372,9 @@ view.photo = { public: function _public() { $("#button_visibility").removeClass("active--hidden active--not-hidden"); - if (_photo.json.public === "1" || _photo.json.public === "2") { + if (_photo.json.is_public == 1 || _photo.json.is_public == 2) { // Photo public - if (_photo.json.public === "1") { + if (_photo.json.is_public == 1) { $("#button_visibility").addClass("active--hidden"); } else { $("#button_visibility").addClass("active--not-hidden"); @@ -10452,8 +10410,9 @@ view.photo = { var $nextArrow = lychee.imageview.find("a#next"); var $previousArrow = lychee.imageview.find("a#previous"); var photoID = _photo.getID(); - var hasNext = album.json && album.json.photos && album.getByID(photoID) && album.getByID(photoID).nextPhoto != null && album.getByID(photoID).nextPhoto !== ""; - var hasPrevious = album.json && album.json.photos && album.getByID(photoID) && album.getByID(photoID).previousPhoto != null && album.getByID(photoID).previousPhoto !== ""; + var photoInAlbum = album.json && album.json.photos ? album.getByID(photoID) : null; + var hasNext = photoInAlbum !== null && photoInAlbum.hasOwnProperty("next_photo_id") && photoInAlbum.next_photo_id !== null; + var hasPrevious = photoInAlbum !== null && photoInAlbum.hasOwnProperty("previous_photo_id") && photoInAlbum.previous_photo_id !== null; var img = $("img#image"); if (img.length > 0) { @@ -10477,13 +10436,13 @@ view.photo = { if (hasNext === false || lychee.viewMode === true) { $nextArrow.hide(); } else { - var nextPhotoID = album.getByID(photoID).nextPhoto; + var nextPhotoID = photoInAlbum.next_photo_id; var nextPhoto = album.getByID(nextPhotoID); // Check if thumbUrl exists (for videos w/o ffmpeg, we add a play-icon) var thumbUrl = "img/placeholder.png"; - if (nextPhoto.sizeVariants.thumb !== null) { - thumbUrl = nextPhoto.sizeVariants.thumb.url; + if (nextPhoto.size_variants.thumb !== null) { + thumbUrl = nextPhoto.size_variants.thumb.url; } else if (nextPhoto.type.indexOf("video") > -1) { thumbUrl = "img/play-icon.png"; } @@ -10493,13 +10452,13 @@ view.photo = { if (hasPrevious === false || lychee.viewMode === true) { $previousArrow.hide(); } else { - var previousPhotoID = album.getByID(photoID).previousPhoto; + var previousPhotoID = photoInAlbum.previous_photo_id; var previousPhoto = album.getByID(previousPhotoID); // Check if thumbUrl exists (for videos w/o ffmpeg, we add a play-icon) var _thumbUrl = "img/placeholder.png"; - if (previousPhoto.sizeVariants.thumb !== null) { - _thumbUrl = previousPhoto.sizeVariants.thumb.url; + if (previousPhoto.size_variants.thumb !== null) { + _thumbUrl = previousPhoto.size_variants.thumb.url; } else if (previousPhoto.type.indexOf("video") > -1) { _thumbUrl = "img/play-icon.png"; } @@ -10531,7 +10490,7 @@ view.photo = { attribution: map_provider_layer_attribution[lychee.map_provider].attribution }).addTo(mymap); - if (!lychee.map_display_direction || !_photo.json.imgDirection || _photo.json.imgDirection === "") { + if (!lychee.map_display_direction || !_photo.json.img_direction) { // Add Marker to map, direction is not set L.marker([_photo.json.latitude, _photo.json.longitude]).addTo(mymap); } else { @@ -10543,14 +10502,14 @@ view.photo = { iconAnchor: [50, 49] // point of the icon which will correspond to marker's location }); var marker = L.marker([_photo.json.latitude, _photo.json.longitude], { icon: viewDirectionIcon }).addTo(mymap); - marker.setRotationAngle(_photo.json.imgDirection); + marker.setRotationAngle(_photo.json.img_direction); } } }, header: function header() { /* Note: the condition below is duplicated in contextMenu.photoMore() */ - if (_photo.json.type && (_photo.json.type.indexOf("video") === 0 || _photo.json.type === "raw") || _photo.json.livePhotoUrl !== "" && _photo.json.livePhotoUrl !== null) { + if (_photo.json.type && (_photo.json.type.indexOf("video") === 0 || _photo.json.type === "raw") || _photo.json.live_photo_url !== "" && _photo.json.live_photo_url !== null) { $("#button_rotate_cwise, #button_rotate_ccwise").hide(); } else { $("#button_rotate_cwise, #button_rotate_ccwise").show(); @@ -10558,12 +10517,12 @@ view.photo = { }, onresize: function onresize() { - if (!_photo.json || _photo.json.sizeVariants.medium === null || _photo.json.sizeVariants.medium2x === null) return; + if (!_photo.json || _photo.json.size_variants.medium === null || _photo.json.size_variants.medium2x === null) return; // Calculate the width of the image in the current window without // borders and set 'sizes' to it. - var imgWidth = _photo.json.sizeVariants.medium.width; - var imgHeight = _photo.json.sizeVariants.medium.height; + var imgWidth = _photo.json.size_variants.medium.width; + var imgHeight = _photo.json.size_variants.medium.height; var containerWidth = $(window).outerWidth(); var containerHeight = $(window).outerHeight(); @@ -10854,7 +10813,9 @@ view.notifications = { init: function init() { multiselect.clearSelection(); + view.photo.hide(); view.notifications.title(); + header.setMode("config"); view.notifications.content.init(); }, @@ -10918,10 +10879,10 @@ view.users = { $(".users_view").append(build.user(this)); settings.bind("#UserUpdate" + this.id, "#UserData" + this.id, users.update); settings.bind("#UserDelete" + this.id, "#UserData" + this.id, users.delete); - if (this.upload === 1) { + if (this.may_upload) { $("#UserData" + this.id + ' .choice input[name="upload"]').click(); } - if (this.lock === 1) { + if (this.is_locked) { $("#UserData" + this.id + ' .choice input[name="lock"]').click(); } }); @@ -11245,10 +11206,10 @@ view.u2f = { $.each(u2f.json, function () { $(".u2f_view").append(build.u2f(this)); settings.bind("#CredentialDelete" + this.id, "#CredentialData" + this.id, u2f.delete); - // if (this.upload === 1) { + // if (this.may_upload) { // $('#UserData' + this.id + ' .choice input[name="upload"]').click(); // } - // if (this.lock === 1) { + // if (this.is_locked) { // $('#UserData' + this.id + ' .choice input[name="lock"]').click(); // } }); diff --git a/public/dist/view.js b/public/dist/view.js index 8de8adb684f..dd53a99b28f 100644 --- a/public/dist/view.js +++ b/public/dist/view.js @@ -68,8 +68,9 @@ api.isTimeout = function (errorThrown, jqXHR) { return false; }; -api.post = function (fn, params, callback) { +api.post = function (fn, params, successCallback) { var responseProgressCB = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null; + var errorCallback = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : null; loadingBar.show(); @@ -86,17 +87,23 @@ api.post = function (fn, params, callback) { return false; } - callback(data); + if (successCallback) successCallback(data); }; var error = function error(jqXHR, textStatus, errorThrown) { + if (errorCallback) { + var isHandled = errorCallback(jqXHR); + if (isHandled) return; + } + // Call global error handler for unhandled errors api.onError(api.isTimeout(errorThrown, jqXHR) ? "Session timed out." : "Server error or API not found.", params, errorThrown); }; var ajaxParams = { type: "POST", url: api_url, - data: params, + contentType: "application/json", + data: JSON.stringify(params), dataType: "json", success: success, error: error @@ -304,10 +311,10 @@ photo.hide = function () { photo.onresize = function () { // Copy of view.photo.onresize - if (photo.json.sizeVariants.medium === null || photo.json.sizeVariants.medium2x === null) return; + if (photo.json.size_variants.medium === null || photo.json.size_variants.medium2x === null) return; - var imgWidth = photo.json.sizeVariants.medium.width; - var imgHeight = photo.json.sizeVariants.medium.height; + var imgWidth = photo.json.size_variants.medium.width; + var imgHeight = photo.json.size_variants.medium.height; var containerWidth = parseFloat($("#imageview").width(), 10); var containerHeight = parseFloat($("#imageview").height(), 10); @@ -383,12 +390,6 @@ var loadPhotoInfo = function loadPhotoInfo(photoID) { }; api.post("Photo::get", params, function (data) { - if (data === "Warning: Photo private!" || data === "Warning: Wrong password!") { - $("body").append(build.no_content("question-mark")).removeClass("view"); - header.dom().remove(); - return false; - } - photo.json = data; // Set title @@ -488,7 +489,7 @@ build.getAlbumThumb = function (data) { thumb2x = data.thumb.thumb2x; - return "Photo thumbnail"; + return "Photo thumbnail"; }; build.album = function (data) { @@ -534,14 +535,14 @@ build.album = function (data) { } } - var html = lychee.html(_templateObject5, disabled ? "disabled" : "", data.nsfw && data.nsfw === "1" && lychee.nsfw_blur ? "blurred" : "", data.id, data.nsfw && data.nsfw === "1" ? "1" : "0", tabindex.get_next_tab_index(), build.getAlbumThumb(data), build.getAlbumThumb(data), build.getAlbumThumb(data), data.title, data.title, subtitle); + var html = lychee.html(_templateObject5, disabled ? "disabled" : "", data.is_nsfw && lychee.nsfw_blur ? "blurred" : "", data.id, data.is_nsfw ? "1" : "0", tabindex.get_next_tab_index(), build.getAlbumThumb(data), build.getAlbumThumb(data), build.getAlbumThumb(data), data.title, data.title, subtitle); if (album.isUploadable() && !disabled) { var isCover = album.json && album.json.cover_id && data.thumb.id === album.json.cover_id; - html += lychee.html(_templateObject6, data.nsfw === "1" ? "badge--nsfw" : "", build.iconic("warning"), data.star === "1" ? "badge--star" : "", build.iconic("star"), data.recent === "1" ? "badge--visible badge--list" : "", build.iconic("clock"), data.public === "1" ? "badge--visible" : "", data.visible === "1" ? "badge--not--hidden" : "badge--hidden", build.iconic("eye"), data.unsorted === "1" ? "badge--visible" : "", build.iconic("list"), data.password === "1" ? "badge--visible" : "", build.iconic("lock-locked"), data.tag_album === "1" ? "badge--tag" : "", build.iconic("tag"), isCover ? "badge--cover" : "", build.iconic("folder-cover")); + html += lychee.html(_templateObject6, data.is_nsfw ? "badge--nsfw" : "", build.iconic("warning"), data.is_starred ? "badge--star" : "", build.iconic("star"), data.is_recent ? "badge--visible badge--list" : "", build.iconic("clock"), data.is_public ? "badge--visible" : "", data.requires_link ? "badge--hidden" : "badge--not--hidden", build.iconic("eye"), data.is_unsorted ? "badge--visible" : "", build.iconic("list"), data.has_password ? "badge--visible" : "", build.iconic("lock-locked"), data.is_tag_album ? "badge--tag" : "", build.iconic("tag"), isCover ? "badge--cover" : "", build.iconic("folder-cover")); } - if (data.albums && data.albums.length > 0 || data.hasOwnProperty("has_albums") && data.has_albums === "1") { + if (data.albums && data.albums.length > 0 || data.hasOwnProperty("has_albums") && data.has_albums === true) { html += lychee.html(_templateObject7, build.iconic("layers")); } @@ -560,9 +561,9 @@ build.photo = function (data) { var isVideo = data.type && data.type.indexOf("video") > -1; var isRaw = data.type && data.type.indexOf("raw") > -1; - var isLivePhoto = data.livePhotoUrl !== "" && data.livePhotoUrl !== null; + var isLivePhoto = data.live_photo_url !== "" && data.live_photo_url !== null; - if (data.sizeVariants.thumb === null) { + if (data.size_variants.thumb === null) { if (isLivePhoto) { thumbnail = "Photo thumbnail"; } @@ -572,8 +573,8 @@ build.photo = function (data) { thumbnail = "Photo thumbnail"; } } else if (lychee.layout === "0") { - if (data.sizeVariants.thumb2x !== null) { - thumb2x = data.sizeVariants.thumb2x.url; + if (data.size_variants.thumb2x !== null) { + thumb2x = data.size_variants.thumb2x.url; } if (thumb2x !== "") { @@ -581,56 +582,56 @@ build.photo = function (data) { } thumbnail = ""; - thumbnail += "Photo thumbnail"; + thumbnail += "Photo thumbnail"; thumbnail += ""; } else { - if (data.sizeVariants.small !== null) { - if (data.sizeVariants.small2x !== null) { - thumb2x = "data-srcset='" + data.sizeVariants.small.url + " " + data.sizeVariants.small.width + "w, " + data.sizeVariants.small2x.url + " " + data.sizeVariants.small2x.width + "w'"; + if (data.size_variants.small !== null) { + if (data.size_variants.small2x !== null) { + thumb2x = "data-srcset='" + data.size_variants.small.url + " " + data.size_variants.small.width + "w, " + data.size_variants.small2x.url + " " + data.size_variants.small2x.width + "w'"; } thumbnail = ""; - thumbnail += "Photo thumbnail"; + thumbnail += "Photo thumbnail"; thumbnail += ""; - } else if (data.sizeVariants.medium !== null) { - if (data.sizeVariants.medium2x !== null) { - thumb2x = "data-srcset='" + data.sizeVariants.medium.url + " " + data.sizeVariants.medium.width + "w, " + data.sizeVariants.medium2x.url + " " + data.sizeVariants.medium2x.width + "w'"; + } else if (data.size_variants.medium !== null) { + if (data.size_variants.medium2x !== null) { + thumb2x = "data-srcset='" + data.size_variants.medium.url + " " + data.size_variants.medium.width + "w, " + data.size_variants.medium2x.url + " " + data.size_variants.medium2x.width + "w'"; } thumbnail = ""; - thumbnail += "Photo thumbnail"; + thumbnail += "Photo thumbnail"; thumbnail += ""; } else if (!isVideo) { // Fallback for images with no small or medium. thumbnail = ""; - thumbnail += "Photo thumbnail"; + thumbnail += "Photo thumbnail"; thumbnail += ""; } else { // Fallback for videos with no small (the case of no thumb is // handled at the top of this function). - if (data.sizeVariants.thumb2x !== null) { - thumb2x = data.sizeVariants.thumb2x.url; + if (data.size_variants.thumb2x !== null) { + thumb2x = data.size_variants.thumb2x.url; } if (thumb2x !== "") { - thumb2x = "data-srcset='" + data.sizeVariants.thumb.url + " " + data.sizeVariants.thumb.width + "w, " + thumb2x + " " + data.sizeVariants.thumb2x.width + "w'"; + thumb2x = "data-srcset='" + data.size_variants.thumb.url + " " + data.size_variants.thumb.width + "w, " + thumb2x + " " + data.size_variants.thumb2x.width + "w'"; } thumbnail = ""; - thumbnail += "Photo thumbnail"; + thumbnail += "Photo thumbnail"; thumbnail += ""; } } - html += lychee.html(_templateObject8, disabled ? "disabled" : "", data.album, data.id, tabindex.get_next_tab_index(), thumbnail, data.title, data.title); + html += lychee.html(_templateObject8, disabled ? "disabled" : "", data.album_id, data.id, tabindex.get_next_tab_index(), thumbnail, data.title, data.title); if (data.taken_at !== null) html += lychee.html(_templateObject9, build.iconic("camera-slr"), lychee.locale.printDateTime(data.taken_at));else html += lychee.html(_templateObject10, lychee.locale.printDateTime(data.created_at)); html += ""; if (album.isUploadable()) { - html += lychee.html(_templateObject11, data.star === "1" ? "badge--star" : "", build.iconic("star"), data.public === "1" && album.json.public !== "1" ? "badge--visible badge--hidden" : "", build.iconic("eye"), isCover ? "badge--cover" : "", build.iconic("folder-cover")); + html += lychee.html(_templateObject11, data.is_starred ? "badge--star" : "", build.iconic("star"), data.is_public && !album.json.is_public ? "badge--visible badge--hidden" : "", build.iconic("eye"), isCover ? "badge--cover" : "", build.iconic("folder-cover")); } html += ""; @@ -695,13 +696,13 @@ build.imageview = function (data, visibleControls, autoplay) { var thumb = ""; if (data.type.indexOf("video") > -1) { - html += lychee.html(_templateObject13, visibleControls === true ? "" : "full", autoplay ? "autoplay" : "", tabindex.get_next_tab_index(), data.url); - } else if (data.type.indexOf("raw") > -1 && data.sizeVariants.medium === null) { + html += lychee.html(_templateObject13, visibleControls === true ? "" : "full", autoplay ? "autoplay" : "", tabindex.get_next_tab_index(), data.size_variants.original.url); + } else if (data.type.indexOf("raw") > -1 && data.size_variants.medium === null) { html += lychee.html(_templateObject14, visibleControls === true ? "" : "full", tabindex.get_next_tab_index()); } else { var img = ""; - if (data.livePhotoUrl === "" || data.livePhotoUrl === null) { + if (data.live_photo_url === "" || data.live_photo_url === null) { // It's normal photo // See if we have the thumbnail loaded... @@ -715,25 +716,25 @@ build.imageview = function (data, visibleControls, autoplay) { } }); - if (data.sizeVariants.medium !== null) { + if (data.size_variants.medium !== null) { var medium = ""; - if (data.sizeVariants.medium2x !== null) { - medium = "srcset='" + data.sizeVariants.medium.url + " " + data.sizeVariants.medium.width + "w, " + data.sizeVariants.medium2x.url + " " + data.sizeVariants.medium2x.width + "w'"; + if (data.size_variants.medium2x !== null) { + medium = "srcset='" + data.size_variants.medium.url + " " + data.size_variants.medium.width + "w, " + data.size_variants.medium2x.url + " " + data.size_variants.medium2x.width + "w'"; } - img = "medium"); + img = "medium"); } else { - img = "big"; + img = "big"; } } else { - if (data.sizeVariants.medium !== null) { - var medium_width = data.sizeVariants.medium.width; - var medium_height = data.sizeVariants.medium.height; + if (data.size_variants.medium !== null) { + var medium_width = data.size_variants.medium.width; + var medium_height = data.size_variants.medium.height; // It's a live photo - img = "
"; + img = "
"; } else { // It's a live photo - img = "
"; + img = "
"; } } @@ -813,7 +814,7 @@ build.tags = function (tags) { a_class = a_class + " search"; } - if (tags !== "") { + if (typeof tags === "string" && tags !== "") { tags = tags.split(","); tags.forEach(function (tag, index) { @@ -921,7 +922,7 @@ header.bind = function () { contextMenu.photoMore(photo.getID(), e); }); header.dom("#button_move_album").on(eventName, function (e) { - contextMenu.move([album.getID()], e, album.setAlbum, "ROOT", album.getParent() != ""); + contextMenu.move([album.getID()], e, album.setAlbum, "ROOT", album.getParentID() != null); }); header.dom("#button_nsfw_album").on(eventName, function (e) { album.setNSFW(album.getID()); @@ -954,14 +955,14 @@ header.bind = function () { if (!album.json.parent_id) { lychee.goto(); } else { - lychee.goto(album.getParent()); + lychee.goto(album.getParentID()); } }); header.dom("#button_back").on(eventName, function () { lychee.goto(album.getID()); }); header.dom("#button_back_map").on(eventName, function () { - lychee.goto(album.getID() || ""); + lychee.goto(album.getID()); }); header.dom("#button_fs_album_enter,#button_fs_enter").on(eventName, lychee.fullscreenEnter); header.dom("#button_fs_album_exit,#button_fs_exit").on(eventName, lychee.fullscreenExit).hide(); @@ -1119,7 +1120,7 @@ header.setMode = function (mode) { // Hide download button when album empty or we are not allowed to // upload to it and it's not explicitly marked as downloadable. - if (!album.json || album.json.photos === false && album.json.albums && album.json.albums.length === 0 || !album.isUploadable() && album.json.downloadable === "0") { + if (!album.json || album.json.photos.length === 0 && album.json.albums && album.json.albums.length === 0 || !album.isUploadable() && !album.json.is_downloadable) { var _e8 = $("#button_archive"); _e8.hide(); tabindex.makeUnfocusable(_e8); @@ -1129,7 +1130,7 @@ header.setMode = function (mode) { tabindex.makeFocusable(_e9); } - if (album.json && album.json.hasOwnProperty("share_button_visible") && album.json.share_button_visible !== "1") { + if (album.json && album.json.hasOwnProperty("is_share_button_visible") && !album.json.is_share_button_visible) { var _e10 = $("#button_share_album"); _e10.hide(); tabindex.makeUnfocusable(_e10); @@ -1260,7 +1261,7 @@ header.setMode = function (mode) { tabindex.makeUnfocusable(_e24); } - if (photo.json && photo.json.hasOwnProperty("share_button_visible") && photo.json.share_button_visible !== "1") { + if (photo.json && photo.json.hasOwnProperty("is_share_button_visible") && !photo.json.is_share_button_visible) { var _e25 = $("#button_share"); _e25.hide(); tabindex.makeUnfocusable(_e25); @@ -1273,7 +1274,7 @@ header.setMode = function (mode) { // Hide More menu if empty (see contextMenu.photoMore) $("#button_more").show(); tabindex.makeFocusable($("#button_more")); - if (!(album.isUploadable() || (photo.json.hasOwnProperty("downloadable") ? photo.json.downloadable === "1" : album.json && album.json.downloadable && album.json.downloadable === "1")) && !(photo.json.url && photo.json.url !== "")) { + if (!(album.isUploadable() || (photo.json.hasOwnProperty("is_downloadable") ? photo.json.is_downloadable : album.json && album.json.is_downloadable)) && !(photo.json.size_variants.original.url && photo.json.size_variants.original.url !== "")) { var _e27 = $("#button_more"); _e27.hide(); tabindex.makeUnfocusable(_e27); @@ -1520,13 +1521,13 @@ sidebar.setSelectable = function () { }; sidebar.changeAttr = function (attr) { - var value = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : "-"; + var value = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ""; var dangerouslySetInnerHTML = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; if (attr == null || attr === "") return false; // Set a default for the value - if (value == null || value === "") value = "-"; + if (value === null) value = ""; // Escape value if (dangerouslySetInnerHTML === false) value = lychee.escapeHTML(value); @@ -1557,7 +1558,7 @@ sidebar.createStructure.photo = function (data) { var exifHash = data.taken_at + data.make + data.model + data.shutter + data.aperture + data.focal + data.iso; var locationHash = data.longitude + data.latitude + data.altitude; var structure = {}; - var _public = ""; + var isPublic = ""; var isVideo = data.type && data.type.indexOf("video") > -1; var license = void 0; @@ -1578,35 +1579,39 @@ sidebar.createStructure.photo = function (data) { } // Set value for public - switch (data.public) { - case "0": - _public = lychee.locale["PHOTO_SHR_NO"]; + switch (data.is_public) { + case 0: + isPublic = lychee.locale["PHOTO_SHR_NO"]; break; - case "1": - _public = lychee.locale["PHOTO_SHR_PHT"]; + case 1: + isPublic = lychee.locale["PHOTO_SHR_PHT"]; break; - case "2": - _public = lychee.locale["PHOTO_SHR_ALB"]; + case 2: + isPublic = lychee.locale["PHOTO_SHR_ALB"]; break; default: - _public = "-"; + isPublic = "-"; break; } structure.basics = { title: lychee.locale["PHOTO_BASICS"], type: sidebar.types.DEFAULT, - rows: [{ title: lychee.locale["PHOTO_TITLE"], kind: "title", value: data.title, editable: editable }, { title: lychee.locale["PHOTO_UPLOADED"], kind: "uploaded", value: lychee.locale.printDateTime(data.created_at) }, { title: lychee.locale["PHOTO_DESCRIPTION"], kind: "description", value: data.description, editable: editable }] + rows: [{ title: lychee.locale["PHOTO_TITLE"], kind: "title", value: data.title, editable: editable }, { title: lychee.locale["PHOTO_UPLOADED"], kind: "uploaded", value: lychee.locale.printDateTime(data.created_at) }, { title: lychee.locale["PHOTO_DESCRIPTION"], kind: "description", value: data.description ? data.description : "", editable: editable }] }; structure.image = { title: lychee.locale[isVideo ? "PHOTO_VIDEO" : "PHOTO_IMAGE"], type: sidebar.types.DEFAULT, - rows: [{ title: lychee.locale["PHOTO_SIZE"], kind: "size", value: lychee.locale.printFilesizeLocalized(data.filesize) }, { title: lychee.locale["PHOTO_FORMAT"], kind: "type", value: data.type }, { title: lychee.locale["PHOTO_RESOLUTION"], kind: "resolution", value: data.width + " x " + data.height }] + rows: [{ title: lychee.locale["PHOTO_SIZE"], kind: "size", value: lychee.locale.printFilesizeLocalized(data.filesize) }, { title: lychee.locale["PHOTO_FORMAT"], kind: "type", value: data.type }, { + title: lychee.locale["PHOTO_RESOLUTION"], + kind: "resolution", + value: data.size_variants.original.width + " x " + data.size_variants.original.height + }] }; if (isVideo) { - if (data.width === 0 || data.height === 0) { + if (data.size_variants.original.width === 0 || data.size_variants.original.height === 0) { // Remove the "Resolution" line if we don't have the data. structure.image.rows.splice(-1, 1); } @@ -1646,7 +1651,7 @@ sidebar.createStructure.photo = function (data) { structure.sharing = { title: lychee.locale["PHOTO_SHARING"], type: sidebar.types.DEFAULT, - rows: [{ title: lychee.locale["PHOTO_SHR_PLUBLIC"], kind: "public", value: _public }] + rows: [{ title: lychee.locale["PHOTO_SHR_PLUBLIC"], kind: "public", value: isPublic }] }; structure.license = { @@ -1675,12 +1680,12 @@ sidebar.createStructure.photo = function (data) { value: data.altitude ? (Math.round(parseFloat(data.altitude) * 10) / 10).toString() + "m" : "" }, { title: lychee.locale["PHOTO_LOCATION"], kind: "location", value: data.location ? data.location : "" }] }; - if (data.imgDirection) { + if (data.img_direction) { // No point in display sub-degree precision. structure.location.rows.push({ title: lychee.locale["PHOTO_IMGDIRECTION"], kind: "imgDirection", - value: Math.round(data.imgDirection).toString() + "°" + value: Math.round(data.img_direction).toString() + "°" }); } } else { @@ -1704,79 +1709,14 @@ sidebar.createStructure.album = function (album) { var editable = album.isUploadable(); var structure = {}; - var _public = ""; - var hidden = ""; - var downloadable = ""; - var share_button_visible = ""; - var password = ""; + var isPublic = data.is_public ? lychee.locale["ALBUM_SHR_YES"] : lychee.locale["ALBUM_SHR_NO"]; + var requiresLink = data.requires_link ? lychee.locale["ALBUM_SHR_YES"] : lychee.locale["ALBUM_SHR_NO"]; + var isDownloadable = data.is_downloadable ? lychee.locale["ALBUM_SHR_YES"] : lychee.locale["ALBUM_SHR_NO"]; + var isShareButtonVisible = data.is_share_button_visible ? lychee.locale["ALBUM_SHR_YES"] : lychee.locale["ALBUM_SHR_NO"]; + var hasPassword = data.has_password ? lychee.locale["ALBUM_SHR_YES"] : lychee.locale["ALBUM_SHR_NO"]; var license = ""; var sorting = ""; - // Set value for public - switch (data.public) { - case "0": - _public = lychee.locale["ALBUM_SHR_NO"]; - break; - case "1": - _public = lychee.locale["ALBUM_SHR_YES"]; - break; - default: - _public = "-"; - break; - } - - // Set value for hidden - switch (data.visible) { - case "0": - hidden = lychee.locale["ALBUM_SHR_YES"]; - break; - case "1": - hidden = lychee.locale["ALBUM_SHR_NO"]; - break; - default: - hidden = "-"; - break; - } - - // Set value for downloadable - switch (data.downloadable) { - case "0": - downloadable = lychee.locale["ALBUM_SHR_NO"]; - break; - case "1": - downloadable = lychee.locale["ALBUM_SHR_YES"]; - break; - default: - downloadable = "-"; - break; - } - - // Set value for share_button_visible - switch (data.share_button_visible) { - case "0": - share_button_visible = lychee.locale["ALBUM_SHR_NO"]; - break; - case "1": - share_button_visible = lychee.locale["ALBUM_SHR_YES"]; - break; - default: - share_button_visible = "-"; - break; - } - - // Set value for password - switch (data.password) { - case "0": - password = lychee.locale["ALBUM_SHR_NO"]; - break; - case "1": - password = lychee.locale["ALBUM_SHR_YES"]; - break; - default: - password = "-"; - break; - } - // Set license string switch (data.license) { case "none": @@ -1790,16 +1730,18 @@ sidebar.createStructure.album = function (album) { break; } - if (data.sorting_col === "") { - sorting = lychee.locale["DEFAULT"]; - } else { - sorting = data.sorting_col + " " + data.sorting_order; + if (!lychee.publicMode) { + if (data.sorting_col === null) { + sorting = lychee.locale["DEFAULT"]; + } else { + sorting = data.sorting_col + " " + data.sorting_order; + } } structure.basics = { title: lychee.locale["ALBUM_BASICS"], type: sidebar.types.DEFAULT, - rows: [{ title: lychee.locale["ALBUM_TITLE"], kind: "title", value: data.title, editable: editable }, { title: lychee.locale["ALBUM_DESCRIPTION"], kind: "description", value: data.description, editable: editable }] + rows: [{ title: lychee.locale["ALBUM_TITLE"], kind: "title", value: data.title, editable: editable }, { title: lychee.locale["ALBUM_DESCRIPTION"], kind: "description", value: data.description ? data.description : "", editable: editable }] }; if (album.isTagAlbum()) { @@ -1829,18 +1771,18 @@ sidebar.createStructure.album = function (album) { structure.album.rows.push({ title: lychee.locale["ALBUM_VIDEOS"], kind: "videos", value: videoCount }); } - if (data.photos) { + if (data.photos && sorting !== "") { structure.album.rows.push({ title: lychee.locale["ALBUM_ORDERING"], kind: "sorting", value: sorting, editable: editable }); } structure.share = { title: lychee.locale["ALBUM_SHARING"], type: sidebar.types.DEFAULT, - rows: [{ title: lychee.locale["ALBUM_PUBLIC"], kind: "public", value: _public }, { title: lychee.locale["ALBUM_HIDDEN"], kind: "hidden", value: hidden }, { title: lychee.locale["ALBUM_DOWNLOADABLE"], kind: "downloadable", value: downloadable }, { title: lychee.locale["ALBUM_SHARE_BUTTON_VISIBLE"], kind: "share_button_visible", value: share_button_visible }, { title: lychee.locale["ALBUM_PASSWORD"], kind: "password", value: password }] + rows: [{ title: lychee.locale["ALBUM_PUBLIC"], kind: "public", value: isPublic }, { title: lychee.locale["ALBUM_HIDDEN"], kind: "hidden", value: requiresLink }, { title: lychee.locale["ALBUM_DOWNLOADABLE"], kind: "downloadable", value: isDownloadable }, { title: lychee.locale["ALBUM_SHARE_BUTTON_VISIBLE"], kind: "share_button_visible", value: isShareButtonVisible }, { title: lychee.locale["ALBUM_PASSWORD"], kind: "password", value: hasPassword }] }; - if (data.owner != null) { - structure.share.rows.push({ title: lychee.locale["ALBUM_OWNER"], kind: "owner", value: data.owner }); + if (data.owner_name != null) { + structure.share.rows.push({ title: lychee.locale["ALBUM_OWNER"], kind: "owner", value: data.owner_name }); } structure.license = { @@ -2184,13 +2126,13 @@ mapview.open = function () { photos.push({ lat: parseFloat(element.latitude), lng: parseFloat(element.longitude), - thumbnail: element.sizeVariants.thumb !== null ? element.sizeVariants.thumb.url : "img/placeholder.png", - thumbnail2x: element.sizeVariants.thumb2x !== null ? element.sizeVariants.thumb2x.url : null, - url: element.sizeVariants.small !== null ? element.sizeVariants.small.url : element.url, - url2x: element.sizeVariants.small2x !== null ? element.sizeVariants.small2x.url : null, + thumbnail: element.size_variants.thumb !== null ? element.size_variants.thumb.url : "img/placeholder.png", + thumbnail2x: element.size_variants.thumb2x !== null ? element.size_variants.thumb2x.url : null, + url: element.size_variants.small !== null ? element.size_variants.small.url : element.url, + url2x: element.size_variants.small2x !== null ? element.size_variants.small2x.url : null, name: element.title, taken_at: element.taken_at, - albumID: element.album, + albumID: element.album_id, photoID: element.id }); @@ -2224,56 +2166,27 @@ mapview.open = function () { var _includeSubAlbums = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; if (_albumID !== "" && _albumID !== null) { - // _ablumID has been to a specific album + // _albumID has been specified var _params = { albumID: _albumID, - includeSubAlbums: _includeSubAlbums, - password: "" + includeSubAlbums: _includeSubAlbums }; api.post("Album::getPositionData", _params, function (data) { - if (data === "Warning: Wrong password!") { - password.getDialog(_albumID, function () { - _params.password = password.value; - - api.post("Album::getPositionData", _params, function (_data) { - addPhotosToMap(_data); - mapview.title(_albumID, _data.title); - }); - }); - } else { - addPhotosToMap(data); - mapview.title(_albumID, data.title); - } + addPhotosToMap(data); + mapview.title(_albumID, data.title); }); } else { // AlbumID is empty -> fetch all photos of all albums - // _ablumID has been to a specific album - var _params2 = { - includeSubAlbums: _includeSubAlbums, - password: "" - }; - - api.post("Albums::getPositionData", _params2, function (data) { - if (data === "Warning: Wrong password!") { - password.getDialog(_albumID, function () { - _params2.password = password.value; - - api.post("Albums::getPositionData", _params2, function (_data) { - addPhotosToMap(_data); - mapview.title(_albumID, _data.title); - }); - }); - } else { - addPhotosToMap(data); - mapview.title(_albumID, data.title); - } + api.post("Albums::getPositionData", {}, function (data) { + addPhotosToMap(data); + mapview.title(_albumID, data.title); }); } }; - // If subalbums not being included and album.json already has all data - // -> we can reuse it + // If sub-albums are not requested and album.json already has all data, + // we reuse it if (lychee.map_include_subalbums === false && album.json !== null && album.json.photos !== null) { addPhotosToMap(album.json); } else { @@ -2824,15 +2737,21 @@ lychee.locale = { // want to call `toLocalString` which is fine and don't do any time // arithmetics. // Then we add the original timezone to the string manually. - var splitDateTime = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})([-Z+])(\d{2}:\d{2})?$/.exec(jsonDateTime); - console.assert(splitDateTime.length === 4, "'jsonDateTime' is not formatted acc. to ISO 8601; passed string was: " + jsonDateTime); + var splitDateTime = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}([,.]\d{1,6})?)([-Z+])(\d{2}:\d{2})?$/.exec(jsonDateTime); + // The capturing groups are: + // - 0: the whole string + // - 1: the whole date/time segment incl. fractional seconds + // - 2: the fractional seconds (if present) + // - 3: the timezone separator, i.e. "Z", "-" or "+" (if present) + // - 4: the absolute timezone offset without the sign (if present) + console.assert(splitDateTime.length === 5, "'jsonDateTime' is not formatted acc. to ISO 8601; passed string was: " + jsonDateTime); var locale = "default"; // use the user's browser settings var format = { dateStyle: "medium", timeStyle: "medium" }; var result = new Date(splitDateTime[1]).toLocaleString(locale, format); - if (splitDateTime[2] === "Z" || splitDateTime[3] === "00:00") { + if (splitDateTime[3] === "Z" || splitDateTime[4] === "00:00") { result += " UTC"; } else { - result += " UTC" + splitDateTime[2] + splitDateTime[3]; + result += " UTC" + splitDateTime[3] + splitDateTime[4]; } return result; }, diff --git a/routes/web.php b/routes/web.php index 9ab2a93d516..5d7f957d0c3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -37,10 +37,10 @@ * * Other ideas, redirection by album name, photo title... */ -Route::get('/r/{albumid}/{photoid}', [RedirectController::class, 'photo'])->middleware(['installed', 'migrated']); -Route::get('/r/{albumid}', [RedirectController::class, 'album'])->middleware(['installed', 'migrated']); +Route::get('/r/{albumID}/{photoID}', [RedirectController::class, 'photo'])->middleware(['installed', 'migrated']); +Route::get('/r/{albumID}', [RedirectController::class, 'album'])->middleware(['installed', 'migrated']); -Route::get('/view', [ViewController::class, 'view']); +Route::get('/view', [ViewController::class, 'view'])->name('view'); Route::get('/demo', [DemoController::class, 'js']); Route::get('/frame', [FrameController::class, 'init'])->name('frame')->middleware(['installed', 'migrated']); @@ -63,9 +63,9 @@ Route::post('/api/Album::get', [AlbumController::class, 'get'])->middleware('read'); Route::post('/api/Album::getPositionData', [AlbumController::class, 'getPositionData'])->middleware('read'); -Route::post('/api/Album::getPublic', [AlbumController::class, 'getPublic']); +Route::post('/api/Album::unlock', [AlbumController::class, 'unlock']); Route::post('/api/Album::add', [AlbumController::class, 'add'])->middleware('upload'); -Route::post('/api/Album::addByTags', [AlbumController::class, 'addByTags'])->middleware('upload'); +Route::post('/api/Album::addByTags', [AlbumController::class, 'addTagAlbum'])->middleware('upload'); Route::post('/api/Album::setTitle', [AlbumController::class, 'setTitle'])->middleware('upload'); Route::post('/api/Album::setNSFW', [AlbumController::class, 'setNSFW'])->middleware('upload'); Route::post('/api/Album::setDescription', [AlbumController::class, 'setDescription'])->middleware('upload'); @@ -81,6 +81,8 @@ Route::post('/api/Frame::getSettings', [FrameController::class, 'getSettings']); +Route::post('/api/Legacy::translateLegacyModelIDs', [LegacyController::class, 'translateLegacyModelIDs']); + Route::post('/api/Photo::get', [PhotoController::class, 'get'])->middleware('read'); Route::post('/api/Photo::getRandom', [PhotoController::class, 'getRandom']); Route::post('/api/Photo::setTitle', [PhotoController::class, 'setTitle'])->middleware('upload'); diff --git a/tests/Feature/AlbumTest.php b/tests/Feature/AlbumTest.php index ac698a8d70c..1c6156a22b1 100644 --- a/tests/Feature/AlbumTest.php +++ b/tests/Feature/AlbumTest.php @@ -1,10 +1,8 @@ add('0', 'test_album', 'false'); + $albums_tests->add(null, 'test_album', 401); - $albums_tests->get('recent', '', 'true'); - $albums_tests->get('starred', '', 'true'); - $albums_tests->get('public', '', 'true'); - $albums_tests->get('unsorted', '', 'true'); + $albums_tests->get('recent', 403); + $albums_tests->get('starred', 403); + $albums_tests->get('public', 403); + $albums_tests->get('unsorted', 403); } public function testAddReadLogged() { $albums_tests = new AlbumsUnitTest($this); - $session_tests = new SessionUnitTest(); + $session_tests = new SessionUnitTest($this); AccessControl::log_as_id(0); - $albums_tests->get('recent', '', 'true'); - $albums_tests->get('starred', '', 'true'); - $albums_tests->get('public', '', 'true'); - $albums_tests->get('unsorted', '', 'true'); + $albums_tests->get('recent'); + $albums_tests->get('starred'); + $albums_tests->get('public'); + $albums_tests->get('unsorted'); - $albumID = $albums_tests->add('0', 'test_album', 'true'); - $albumID2 = $albums_tests->add('0', 'test_album2', 'true'); - $albumID3 = $albums_tests->add('0', 'test_album3', 'true'); - $albumTagID1 = $albums_tests->addByTags('test_tag_album1', 'test', 'true'); + $albumID = $albums_tests->add(null, 'test_album')->offsetGet('id'); + $albumID2 = $albums_tests->add(null, 'test_album2')->offsetGet('id'); + $albumID3 = $albums_tests->add(null, 'test_album3')->offsetGet('id'); + $albumTagID1 = $albums_tests->addByTags('test_tag_album1', 'test')->offsetGet('id'); - $albums_tests->set_tags($albumTagID1, 'test, coolnewtag, secondnewtag', 'true'); - $response = $albums_tests->get($albumTagID1, '', 'true'); + $albums_tests->set_tags($albumTagID1, 'test, coolnewtag, secondnewtag'); + $response = $albums_tests->get($albumTagID1); $response->assertSee('test, coolnewtag, secondnewtag'); $albums_tests->see_in_albums($albumID); @@ -56,31 +54,31 @@ public function testAddReadLogged() $albums_tests->move($albumTagID1, $albumID3); $albums_tests->move($albumID3, $albumID2); $albums_tests->move($albumID2, $albumID); - $albums_tests->move($albumID3, '0'); + $albums_tests->move($albumID3, null); /* - * try to get a non existing album + * try to get a non-existing album */ - $albums_tests->get('999', '', 'false'); + $albums_tests->get('abcdefghijklmnopqrstuvwx', 404); - $response = $albums_tests->get($albumID, '', 'true'); + $response = $albums_tests->get($albumID); $response->assertJson([ 'id' => $albumID, - 'description' => '', + 'description' => null, 'title' => 'test_album', 'albums' => [['id' => $albumID2]], ]); $albums_tests->set_title($albumID, 'NEW_TEST'); $albums_tests->set_description($albumID, 'new description'); - $albums_tests->set_license($albumID, 'WTFPL', '"Error: License not recognised!'); + $albums_tests->set_license($albumID, 'WTFPL', 422); $albums_tests->set_license($albumID, 'reserved'); $albums_tests->set_sorting($albumID, 'title', 'ASC'); /** * Let's see if the info changed. */ - $response = $albums_tests->get($albumID, '', 'true'); + $response = $albums_tests->get($albumID); $response->assertJson([ 'id' => $albumID, 'description' => 'new description', @@ -97,8 +95,8 @@ public function testAddReadLogged() /* * Let's try to get the info of the album we just created. */ - $albums_tests->get_public($albumID, '', 'false'); - $albums_tests->get($albumID, '', '"Warning: Album private!"'); + $albums_tests->unlock($albumID, '', 422); + $albums_tests->get($albumID, 403); /* * Because we don't know login and password we are just going to assumed we are logged in. @@ -115,19 +113,19 @@ public function testAddReadLogged() */ $albums_tests->dont_see_in_albums($albumID); - $session_tests->logout($this); + $session_tests->logout(); } public function testTrueNegative() { $albums_tests = new AlbumsUnitTest($this); - $session_tests = new SessionUnitTest(); + $session_tests = new SessionUnitTest($this); AccessControl::log_as_id(0); - $albums_tests->set_description('-1', 'new description', 'false'); - $albums_tests->set_public('-1', 1, 1, 1, 0, 1, 1, 'false'); + $albums_tests->set_description('-1', 'new description', 422); + $albums_tests->set_public('-1', true, true, false, false, true, true, 422); - $session_tests->logout($this); + $session_tests->logout(); } } diff --git a/tests/Feature/GeoDataTest.php b/tests/Feature/GeoDataTest.php index 1e16382aac4..df5ad6533b2 100644 --- a/tests/Feature/GeoDataTest.php +++ b/tests/Feature/GeoDataTest.php @@ -2,7 +2,7 @@ namespace Tests\Feature; -use AccessControl; +use App\Facades\AccessControl; use App\Models\Configs; use Carbon\Carbon; use Illuminate\Http\UploadedFile; @@ -23,7 +23,7 @@ public function testGeo() AccessControl::log_as_id(0); /* - * Make a copy of the image because import deletes the file and we want to be + * Make a copy of the image because import deletes the file, and we want to be * able to use the test on a local machine and not just in CI. */ copy('tests/Feature/mongolia.jpeg', 'public/uploads/import/mongolia.jpeg'); @@ -38,7 +38,7 @@ public function testGeo() $id = $photos_tests->upload($file); - $response = $photos_tests->get($id, 'true'); + $response = $photos_tests->get($id); $photos_tests->see_in_unsorted($id); /* * Check some Exif data @@ -57,8 +57,6 @@ public function testGeo() [ 'id' => $id, 'title' => 'mongolia', - 'width' => '1280', - 'height' => '850', 'type' => 'image/jpeg', 'filesize' => 201316, 'iso' => '200', @@ -69,12 +67,12 @@ public function testGeo() 'focal' => '44 mm', 'altitude' => '1633.0000', 'license' => 'none', - 'taken_at' => $taken_at->format(\DateTimeInterface::ATOM), + 'taken_at' => $taken_at->format('Y-m-d\TH:i:s.uP'), 'taken_at_orig_tz' => $taken_at->getTimezone()->getName(), - 'public' => '0', - 'downloadable' => '1', - 'share_button_visible' => '1', - 'sizeVariants' => [ + 'is_public' => 0, + 'is_downloadable' => true, + 'is_share_button_visible' => true, + 'size_variants' => [ 'thumb' => [ 'width' => 200, 'height' => 200, @@ -85,18 +83,21 @@ public function testGeo() ], 'medium' => null, 'medium2x' => null, + 'original' => [ + 'width' => 1280, + 'height' => 850, + ], ], ] ); - $albumID = $albums_tests->add('0', 'test_mongolia'); - $photos_tests->set_album($albumID, $id, 'true'); + $albumID = $albums_tests->add(null, 'test_mongolia')->offsetGet('id'); + $photos_tests->set_album($albumID, $id); $photos_tests->dont_see_in_unsorted($id); - $response = $albums_tests->get($albumID, '', 'true'); - $content = $response->getContent(); - $array_content = json_decode($content); - $this->assertEquals(1, count($array_content->photos)); - $this->assertEquals($id, $array_content->photos[0]->id); + $response = $albums_tests->get($albumID); + $responseObj = json_decode($response->getContent()); + $this->assertCount(1, $responseObj->photos); + $this->assertEquals($id, $responseObj->photos[0]->id); // now we test position Data // save initial value @@ -104,33 +105,33 @@ public function testGeo() // set to 0 Configs::set('map_display', '0'); - $this->assertEquals(Configs::get_value('map_display'), '0'); - $albums_tests->AlbumsGetPositionDataFull(200); // we need to fix this + $this->assertEquals('0', Configs::get_value('map_display')); + $albums_tests->AlbumsGetPositionDataFull(); // we need to fix this // set to 1 Configs::set('map_display', '1'); - $this->assertEquals(Configs::get_value('map_display'), '1'); - $response = $albums_tests->AlbumsGetPositionDataFull(200); - $content = $response->getContent(); - $array_content = json_decode($content); - $this->assertEquals(1, count($array_content->photos)); - $this->assertEquals($id, $array_content->photos[0]->id); + $this->assertEquals('1', Configs::get_value('map_display')); + $response = $albums_tests->AlbumsGetPositionDataFull(); + $responseObj = json_decode($response->getContent()); + $this->assertObjectHasAttribute('photos', $responseObj); + $this->assertCount(1, $responseObj->photos); + $this->assertEquals($id, $responseObj->photos[0]->id); // set to 0 Configs::set('map_display', '0'); - $this->assertEquals(Configs::get_value('map_display'), '0'); - $albums_tests->AlbumGetPositionDataFull($albumID, 200); // we need to fix this + $this->assertEquals('0', Configs::get_value('map_display')); + $albums_tests->AlbumGetPositionDataFull($albumID); // we need to fix this // set to 1 Configs::set('map_display', '1'); - $this->assertEquals(Configs::get_value('map_display'), '1'); - $response = $albums_tests->AlbumGetPositionDataFull($albumID, 200); - $content = $response->getContent(); - $array_content = json_decode($content); - $this->assertEquals(1, count($array_content->photos)); - $this->assertEquals($id, $array_content->photos[0]->id); - - $photos_tests->delete($id, 'true'); + $this->assertEquals('1', Configs::get_value('map_display')); + $response = $albums_tests->AlbumGetPositionDataFull($albumID); + $responseObj = json_decode($response->getContent()); + $this->assertObjectHasAttribute('photos', $responseObj); + $this->assertCount(1, $responseObj->photos); + $this->assertEquals($id, $responseObj->photos[0]->id); + + $photos_tests->delete($id); $albums_tests->delete($albumID); // reset diff --git a/tests/Feature/InstallTest.php b/tests/Feature/InstallTest.php index 064493723dd..5d25d85a846 100644 --- a/tests/Feature/InstallTest.php +++ b/tests/Feature/InstallTest.php @@ -1,10 +1,9 @@ find(0); touch(base_path('.NO_SECURE_KEY')); $response = $this->get('install/'); @@ -37,7 +37,34 @@ public function testInstall() /* * Clearing things up. We could do an Artisan migrate but this is more efficient. */ - $tables = ['sym_links', 'photos', 'configs', 'logs', 'migrations', 'notifications', 'page_contents', 'pages', 'user_album', 'users', 'albums', 'web_authn_credentials']; + + // The order is important: referring tables must be deleted first, referred tables last + $tables = [ + 'sym_links', + 'size_variants', + 'photos', + 'configs', + 'logs', + 'migrations', + 'notifications', + 'page_contents', + 'pages', + 'user_base_album', + 'tag_albums', + 'albums', + 'base_albums', + 'web_authn_credentials', + 'users', + ]; + + if (Schema::connection(null)->getConnection()->getDriverName() !== 'sqlite') { + // We must remove the foreign constraint from `albums` to `photos` to + // break up circular dependencies. + Schema::table('albums', function (Blueprint $table) { + $table->dropForeign('albums_cover_id_foreign'); + }); + } + foreach ($tables as $table) { Schema::dropIfExists($table); } diff --git a/tests/Feature/Lib/AlbumsUnitTest.php b/tests/Feature/Lib/AlbumsUnitTest.php index 8ef50187282..e13c3a9718b 100644 --- a/tests/Feature/Lib/AlbumsUnitTest.php +++ b/tests/Feature/Lib/AlbumsUnitTest.php @@ -2,199 +2,160 @@ namespace Tests\Feature\Lib; -use App\Actions\Albums\Extensions\PublicIds; use Illuminate\Testing\TestResponse; use Tests\TestCase; class AlbumsUnitTest { - private $testCase = null; + private TestCase $testCase; - public function __construct(TestCase &$testCase) + public function __construct(TestCase $testCase) { $this->testCase = $testCase; } - // This is called before any subsequent function call. - // We use it to "refresh" the PublicIds as it is a singleton, - // it stays the same through all the test once initialized. - // This is not the desired behaviour as it is re-initialized per connection. - public function __call($method, $arguments) - { - if (method_exists($this, $method)) { - resolve(PublicIds::class)->refresh(); - // fwrite(STDERR, print_r($method, TRUE) . "\n"); - return call_user_func_array([$this, $method], $arguments); - } - } - /** * Add an album. * - * @param TestCase $testCase - * @param string $parent_id - * @param string $title - * @param array $tags - * @param string $result + * @param string|null $parent_id + * @param string $title + * @param int $expectedStatusCode + * @param string|null $assertSee * - * @return string + * @return TestResponse */ - protected function add( - string $parent_id, + public function add( + ?string $parent_id, string $title, - string $result = 'true' - ) { + int $expectedStatusCode = 201, + ?string $assertSee = null + ): TestResponse { $params = [ 'title' => $title, 'parent_id' => $parent_id, ]; $response = $this->testCase->json('POST', '/api/Album::add', $params); - $response->assertStatus(200); - if ($result == 'true') { - $response->assertDontSee('false'); - } else { - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } - return $response->getContent(); + return $response; } /** * Add an album. * - * @param TestCase $testCase - * @param string $parent_id - * @param string $title - * @param array $tags - * @param string $result + * @param string $title + * @param string $tags + * @param int $expectedStatusCode + * @param string|null $assertSee * - * @return string + * @return TestResponse */ - protected function addByTags( + public function addByTags( string $title, string $tags, - string $result = 'true' - ) { + int $expectedStatusCode = 201, + ?string $assertSee = null + ): TestResponse { $params = [ 'title' => $title, 'tags' => $tags, ]; $response = $this->testCase->json('POST', '/api/Album::addByTags', $params); - $response->assertStatus(200); - if ($result == 'true') { - $response->assertDontSee('false'); - } else { - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } - return $response->getContent(); + return $response; } /** * Move albums. * - * @param TestCase $testCase - * @param string $ids - * @param string $result - * - * @return string + * @param string $ids + * @param string $to + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function move( + public function move( string $ids, - string $to, - string $result = 'true' - ) { + ?string $to, + int $expectedStatusCode = 204, + ?string $assertSee = null + ): void { $response = $this->testCase->json('POST', '/api/Album::move', [ - 'albumIDs' => $to . ',' . $ids, + 'albumID' => $to, + 'albumIDs' => $ids, ]); - $response->assertStatus(200); - if ($result == 'true') { - $response->assertDontSee('false'); - } else { - $response->assertSee($result, false); - } - - return $response->getContent(); - } - - /** - * Get all albums. - * - * @param TestCase $testCase - * @param string $result - * - * @return TestResponse - */ - protected function get_all( - string $result = 'true' - ) { - $response = $this->testCase->json('POST', '/api/Albums::get', []); - $response->assertOk(); - if ($result != 'true') { - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } - - return $response; } /** * Get album by ID. * - * @param TestCase $testCase - * @param string $id - * @param string $password - * @param string $result + * @param string $id + * @param string $password + * @param int $expectedStatusCode + * @param string|null $assertSee * * @return TestResponse */ - protected function get( + public function get( string $id, - string $password = '', - string $result = 'true' - ) { + int $expectedStatusCode = 200, + ?string $assertSee = null + ): TestResponse { $response = $this->testCase->json( 'POST', '/api/Album::get', - ['albumID' => $id, 'password' => $password] + ['albumID' => $id] ); - $response->assertOk(); - if ($result != 'true') { - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } return $response; } /** - * @param TestCase $testCase - * @param string $id - * @param string $password - * @param string $result + * @param string $id + * @param string $password + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function get_public( + public function unlock( string $id, string $password = '', - string $result = 'true' - ) { + int $expectedStatusCode = 200, + ?string $assertSee = null + ): void { $response = $this->testCase->json( 'POST', - '/api/Album::getPublic', + '/api/Album::unlock', ['albumID' => $id, 'password' => $password] ); - $response->assertOk(); - $response->assertSeeText($result); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** * Check if we see id in the list of all visible albums * /!\ results varies depending if logged in or not ! * - * @param TestCase $testCase - * @param string $id + * @param string $id */ - protected function see_in_albums(string $id) + public function see_in_albums(string $id): void { $response = $this->testCase->json('POST', '/api/Albums::get', []); $response->assertOk(); @@ -205,10 +166,9 @@ protected function see_in_albums(string $id) * Check if we don't see id in the list of all visible albums * /!\ results varies depending if logged in or not ! * - * @param TestCase $testCase - * @param string $id + * @param string $id */ - protected function dont_see_in_albums(string $id) + public function dont_see_in_albums(string $id): void { $response = $this->testCase->json('POST', '/api/Albums::get', []); $response->assertOk(); @@ -218,156 +178,170 @@ protected function dont_see_in_albums(string $id) /** * Change title. * - * @param TestCase $testCase - * @param string $id - * @param string $title - * @param string $result + * @param string $id + * @param string $title + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function set_title( + public function set_title( string $id, string $title, - string $result = 'true' - ) { + int $expectedStatusCode = 204, + ?string $assertSee = null + ): void { $response = $this->testCase->json( 'POST', '/api/Album::setTitle', ['albumIDs' => $id, 'title' => $title] ); - $response->assertOk(); - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** * Change description. * - * @param TestCase $testCase - * @param string $id - * @param string $description - * @param string $result + * @param string $id + * @param string $description + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function set_description( + public function set_description( string $id, string $description, - string $result = 'true' - ) { + int $expectedStatusCode = 204, + ?string $assertSee = null + ): void { $response = $this->testCase->json( 'POST', '/api/Album::setDescription', ['albumID' => $id, 'description' => $description] ); - $response->assertOk(); - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** * Set the licence. * - * @param TestCase $testCase - * @param string $id - * @param string $license - * @param string $result + * @param string $id + * @param string $license + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function set_license( + public function set_license( string $id, string $license, - string $result = 'true' - ) { + int $expectedStatusCode = 204, + ?string $assertSee = null + ): void { $response = $this->testCase->json('POST', '/api/Album::setLicense', [ 'albumID' => $id, 'license' => $license, ]); - $response->assertOk(); - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** * Set sorting. * - * @param TestCase $testCase - * @param string $id - * @param string $typePhotos - * @param string $orderPhotos - * @param string $result + * @param string $id + * @param string $sortingCol + * @param string $sortingOrder + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function set_sorting( + public function set_sorting( string $id, - string $typePhotos, - string $orderPhotos, - string $result = 'true' - ) { + string $sortingCol, + string $sortingOrder, + int $expectedStatusCode = 204, + ?string $assertSee = null + ): void { $response = $this->testCase->json('POST', '/api/Album::setSorting', [ 'albumID' => $id, - 'typePhotos' => $typePhotos, - 'orderPhotos' => $orderPhotos, + 'sortingCol' => $sortingCol, + 'sortingOrder' => $sortingOrder, ]); - $response->assertOk(); - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** - * @param TestCase $testCase - * @param string $id - * @param int $full_photo - * @param int $public - * @param int $visible - * @param int $downloadable - * @param int $share_button_visible - * @param string $result + * @param string $id + * @param int $full_photo + * @param int $public + * @param int $requiresLink + * @param int $nsfw + * @param int $downloadable + * @param int $share_button_visible + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function set_public( + public function set_public( string $id, - int $full_photo = 1, - int $public = 1, - int $visible = 1, - int $nsfw = 0, - int $downloadable = 1, - int $share_button_visible = 1, - string $result = 'true' - ) { + bool $full_photo = true, + bool $public = true, + bool $requiresLink = false, + bool $nsfw = false, + bool $downloadable = true, + bool $share_button_visible = true, + int $expectedStatusCode = 204, + ?string $assertSee = null + ): void { $response = $this->testCase->json('POST', '/api/Album::setPublic', [ - 'full_photo' => $full_photo, + 'grants_full_photo' => $full_photo, 'albumID' => $id, - 'public' => $public, - 'visible' => $visible, - 'nsfw' => $nsfw, - 'downloadable' => $downloadable, - 'share_button_visible' => $share_button_visible, + 'is_public' => $public, + 'requires_link' => $requiresLink, + 'is_nsfw' => $nsfw, + 'is_downloadable' => $downloadable, + 'is_share_button_visible' => $share_button_visible, ]); - $response->assertOk(); - $response->assertSee($result); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** - * @param TestCase $testCase - * @param string $id - * @param array $tags - * @param string $result + * @param string $id + * @param string $tags + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function set_tags( + public function set_tags( string $id, string $tags, - string $result = 'true' - ) { + int $expectedStatusCode = 204, + ?string $assertSee = null + ): void { $response = $this->testCase->json('POST', '/api/Album::setShowTags', [ 'albumID' => $id, 'show_tags' => $tags, ]); - - $response->assertOk(); - $response->assertSee($result); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** * We only test for a code 200. * - * @param TestCase $testCase - * @param string $id - * @param string $kind + * @param string $id */ - protected function download( - string $id, - string $kind = 'FULL' - ) { + public function download(string $id): void + { $response = $this->testCase->call('GET', '/api/Album::getArchive', [ 'albumIDs' => $id, ]); @@ -377,30 +351,38 @@ protected function download( /** * Delete. * - * @param TestCase $testCase - * @param string $id - * @param string $result + * @param string $id + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function delete( + public function delete( string $id, - string $result = 'true' - ) { - $response = $this->testCase->json('POST', '/api/Album::delete', ['albumIDs' => $id]); - $response->assertOk(); - $response->assertSee($result, false); + int $expectedStatusCode = 204, + ?string $assertSee = null + ): void { + $response = $this->testCase->postJson('/api/Album::delete', ['albumIDs' => $id]); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** * Test position data (Albums). + * + * @param int $expectedStatusCode + * @param string|null $assertSee + * + * @return TestResponse */ - protected function AlbumsGetPositionDataFull( - int $code = 200, - string $result = 'true' - ) { + public function AlbumsGetPositionDataFull( + int $expectedStatusCode = 200, + ?string $assertSee = null + ): TestResponse { $response = $this->testCase->json('POST', '/api/Albums::getPositionData', []); - $response->assertStatus($code); - if ($result != 'true') { - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } return $response; @@ -408,16 +390,25 @@ protected function AlbumsGetPositionDataFull( /** * Test position data (Album). + * + * @param string $id + * @param int $expectedStatusCode + * @param string|null $assertSee + * + * @return TestResponse */ - protected function AlbumGetPositionDataFull( + public function AlbumGetPositionDataFull( string $id, - int $code = 200, - string $result = 'true' - ) { - $response = $this->testCase->json('POST', '/api/Album::getPositionData', ['albumID' => $id, 'includeSubAlbums' => 'false']); - $response->assertStatus($code); - if ($result != 'true') { - $response->assertSee($result, false); + int $expectedStatusCode = 200, + ?string $assertSee = null + ): TestResponse { + $response = $this->testCase->json('POST', '/api/Album::getPositionData', [ + 'albumID' => $id, + 'includeSubAlbums' => false, + ]); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } return $response; diff --git a/tests/Feature/Lib/PhotosUnitTest.php b/tests/Feature/Lib/PhotosUnitTest.php index 20da235f724..e61eb0ce833 100644 --- a/tests/Feature/Lib/PhotosUnitTest.php +++ b/tests/Feature/Lib/PhotosUnitTest.php @@ -2,45 +2,30 @@ namespace Tests\Feature\Lib; -use App\Actions\Albums\Extensions\PublicIds; +use App\Actions\Photo\Archive; use Illuminate\Http\UploadedFile; use Illuminate\Testing\TestResponse; use Tests\TestCase; class PhotosUnitTest { - private $testCase = null; + private TestCase $testCase; - public function __construct(TestCase &$testCase) + public function __construct(TestCase $testCase) { $this->testCase = $testCase; } - // This is called before any subsequent function call. - // We use it to "refresh" the PublicIds as it is a singleton, - // it stays the same through all the test once initialized. - // This is not the desired behaviour as it is re-initialized per connection. - public function __call($method, $arguments) - { - if (method_exists($this, $method)) { - resolve(PublicIds::class)->refresh(); - // fwrite(STDERR, print_r(__CLASS__ . '\\' . $method, TRUE) . "\n"); - return call_user_func_array([$this, $method], $arguments); - } - } - /** * Try upload a picture. * - * @param TestCase $testcase * @param UploadedFile $file + * @param string|null $albumID * - * @return string (id of the picture) + * @return string the id of the photo */ - public function upload( - UploadedFile &$file, - $albumID = '0' - ) { + public function upload(UploadedFile $file, ?string $albumID = null): string + { $response = $this->testCase->post( '/api/Photo::add', [ @@ -48,93 +33,90 @@ public function upload( '0' => $file, ] ); - $response->assertStatus(200); + + $response->assertSuccessful(); $response->assertDontSee('Error'); - return $response->getContent(); + return $response->offsetGet('id'); } /** - * Try uploading a picture without the file argument (will trigger the validate). - * - * @param TestCase $testcase + * Try uploading a picture without the file argument (will trigger the validation). */ - protected function wrong_upload() + public function wrong_upload(): void { - $response = $this->testCase->post( + $response = $this->testCase->postJson( '/api/Photo::add', [ - 'albumID' => '0', + 'albumID' => null, ] ); - $response->assertStatus(200); - $response->assertSee('"Error: validation failed"', false); + + $response->assertStatus(422); + $response->assertSee('The 0 field is required'); } /** - * Try uploading a picture without the file type (will trigger the hasfile). - * - * @param TestCase $testcase + * Try uploading a picture which is not a file (will trigger the validation). */ - protected function wrong_upload2() + public function wrong_upload2(): void { - $response = $this->testCase->post( + $response = $this->testCase->postJson( '/api/Photo::add', [ - 'albumID' => '0', + 'albumID' => null, '0' => '1', ] ); - $response->assertStatus(200); - $response->assertSee('"Error: missing files"', false); + $response->assertStatus(422); + $response->assertSee('The 0 must be a file'); } /** * Get a photo given a photo id. * - * @param TestCase $testCase - * @param string $photo_id - * @param string $result + * @param string $photo_id + * @param int $expectedStatusCode + * @param string|null $assertSee * * @return TestResponse */ - protected function get( + public function get( string $photo_id, - string $result = 'true' - ) { + int $expectedStatusCode = 200, + ?string $assertSee = null + ): TestResponse { $response = $this->testCase->json('POST', '/api/Photo::get', [ 'photoID' => $photo_id, ]); - $response->assertStatus(200); - if ($result != 'true') { - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } return $response; } /** - * is ID visible in unsorted ? + * is photo with given ID visible in unsorted? * - * @param TestCase $testCase - * @param string $id + * @param string $photoID */ - protected function see_in_unsorted(string $id) + public function see_in_unsorted(string $photoID): void { $response = $this->testCase->json('POST', '/api/Album::get', [ 'albumID' => 'unsorted', ]); $response->assertStatus(200); - $response->assertSee($id, false); + $response->assertSee($photoID, false); } /** - * is ID NOT visible in unsorted ? + * is photo with given ID NOT visible in unsorted? * - * @param TestCase $testCase - * @param string $id + * @param string $id */ - protected function dont_see_in_unsorted(string $id) + public function dont_see_in_unsorted(string $id): void { $response = $this->testCase->json('POST', '/api/Album::get', [ 'albumID' => 'unsorted', @@ -144,12 +126,11 @@ protected function dont_see_in_unsorted(string $id) } /** - * is ID visible in recent ? + * is photo with given ID visible in recent? * - * @param TestCase $testCase - * @param string $id + * @param string $id */ - protected function see_in_recent(string $id) + public function see_in_recent(string $id): void { $response = $this->testCase->json('POST', '/api/Album::get', [ 'albumID' => 'recent', @@ -159,12 +140,11 @@ protected function see_in_recent(string $id) } /** - * is ID NOT visible in recent ? + * is photo with given ID NOT visible in recent? * - * @param TestCase $testCase - * @param string $id + * @param string $id */ - protected function dont_see_in_recent(string $id) + public function dont_see_in_recent(string $id): void { $response = $this->testCase->json('POST', '/api/Album::get', [ 'albumID' => 'recent', @@ -174,12 +154,11 @@ protected function dont_see_in_recent(string $id) } /** - * is ID visible in shared ? + * is photo with given ID visible in shared? * - * @param TestCase $testCase - * @param string $id + * @param string $id */ - protected function see_in_shared(string $id) + public function see_in_shared(string $id): void { $response = $this->testCase->json('POST', '/api/Album::get', [ 'albumID' => 'public', @@ -189,12 +168,11 @@ protected function see_in_shared(string $id) } /** - * is ID NOT visible in shared ? + * is photo with given ID NOT visible in shared? * - * @param TestCase $testCase - * @param string $id + * @param string $id */ - protected function dont_see_in_shared(string $id) + public function dont_see_in_shared(string $id): void { $response = $this->testCase->json('POST', '/api/Album::get', [ 'albumID' => 'public', @@ -204,12 +182,11 @@ protected function dont_see_in_shared(string $id) } /** - * is ID visible in favorite ? + * is photo with given ID visible in favorite? * - * @param TestCase $testCase - * @param string $id + * @param string $id */ - protected function see_in_favorite(string $id) + public function see_in_favorite(string $id): void { $response = $this->testCase->json('POST', '/api/Album::get', [ 'albumID' => 'starred', @@ -219,12 +196,11 @@ protected function see_in_favorite(string $id) } /** - * is ID NOT visible in favorite ? + * is photo with given ID NOT visible in favorite ? * - * @param TestCase $testCase - * @param string $id + * @param string $id */ - protected function dont_see_in_favorite(string $id) + public function dont_see_in_favorite(string $id): void { $response = $this->testCase->json('POST', '/api/Album::get', [ 'albumID' => 'starred', @@ -236,16 +212,17 @@ protected function dont_see_in_favorite(string $id) /** * Set Title. * - * @param TestCase $testCase - * @param string $id - * @param string $title - * @param string $result + * @param string $id + * @param string $title + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function set_title( + public function set_title( string $id, string $title, - string $result = 'true' - ) { + int $expectedStatusCode = 200, + ?string $assertSee = 'true' + ): void { /** * Try to set the title. */ @@ -253,162 +230,195 @@ protected function set_title( 'title' => $title, 'photoIDs' => $id, ]); - $response->assertStatus(200); - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** * Set Description. * - * @param TestCase $testCase - * @param string $id - * @param string $description - * @param string $result + * @param string $id + * @param string $description + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function set_description( + public function set_description( string $id, string $description, - string $result = 'true' - ) { - /** - * Try to set the description. - */ - $response = $this->testCase->json('POST', '/api/Photo::setDescription', [ - 'description' => $description, - 'photoID' => $id, - ]); - $response->assertStatus(200); - $response->assertSee($result, false); + int $expectedStatusCode = 200, + ?string $assertSee = null + ): void { + $response = $this->testCase->postJson( + '/api/Photo::setDescription', [ + 'description' => $description, + 'photoID' => $id, + ] + ); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** * Set Star. * - * @param TestCase $testCase - * @param string $id - * @param string $result + * @param string $id + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function set_star( + public function set_star( string $id, - string $result = 'true' - ) { + int $expectedStatusCode = 200, + ?string $assertSee = 'true' + ): void { $response = $this->testCase->json('POST', '/api/Photo::setStar', [ 'photoIDs' => $id, ]); - $response->assertStatus(200); - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** * Set tags. * - * @param TestCase $testCase - * @param string $id - * @param string $tags - * @param string $result + * @param string $id + * @param string $tags + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function set_tag( + public function set_tag( string $id, string $tags, - string $result = 'true' - ) { + int $expectedStatusCode = 200, + ?string $assertSee = 'true' + ): void { $response = $this->testCase->json('POST', '/api/Photo::setTags', [ 'photoIDs' => $id, 'tags' => $tags, ]); - $response->assertStatus(200); - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** * Set public. * - * @param TestCase $testCase - * @param string $id - * @param string $result + * @param string $id + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function set_public( + public function set_public( string $id, - string $result = 'true' - ) { - $response = $this->testCase->json('POST', '/api/Photo::setPublic', [ - 'photoID' => $id, - ]); - $response->assertStatus(200); - $response->assertSee($result, false); + int $expectedStatusCode = 200, + ?string $assertSee = null + ): void { + $response = $this->testCase->postJson( + '/api/Photo::setPublic', [ + 'photoID' => $id, + ] + ); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** * Set license. * - * @param TestCase $testCase - * @param string $id - * @param string $license - * @param string $result + * @param string $id + * @param string $license + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function set_license( + public function set_license( string $id, string $license, - string $result = 'true' - ) { - $response = $this->testCase->json('POST', '/api/Photo::setLicense', [ - 'photoID' => $id, - 'license' => $license, - ]); - $response->assertStatus(200); - $response->assertSee($result, false); + int $expectedStatusCode = 204, + ?string $assertSee = null + ): void { + $response = $this->testCase->postJson( + '/api/Photo::setLicense', [ + 'photoID' => $id, + 'license' => $license, + ] + ); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** * Set Album. * - * @param TestCase $testCase - * @param string $album_id - * @param string $id - * @param string $result + * @param string $album_id + * @param string $id + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function set_album( + public function set_album( string $album_id, string $id, - string $result = 'true' - ) { - $response = $this->testCase->json('POST', '/api/Photo::setAlbum', [ - 'photoIDs' => $id, - 'albumID' => $album_id, - ]); - $response->assertStatus(200); - $response->assertSee($result, false); + int $expectedStatusCode = 200, + ?string $assertSee = null + ): void { + $response = $this->testCase->postJson( + '/api/Photo::setAlbum', [ + 'photoIDs' => $id, + 'albumID' => $album_id, + ] + ); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** * Duplicate a picture. * - * @param TestCase $testCase - * @param string $id - * @param string $result + * @param string $id + * @param int $expectedStatusCode + * @param string|null $assertSee + * + * @return TestResponse */ - protected function duplicate( + public function duplicate( string $id, - string $result = 'true' - ) { + ?string $targetAlbumID, + int $expectedStatusCode = 201, + ?string $assertSee = null + ): TestResponse { $response = $this->testCase->json('POST', '/api/Photo::duplicate', [ 'photoIDs' => $id, + 'albumID' => $targetAlbumID, ]); - $response->assertStatus(200); - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } + + return $response; } /** * We only test for a code 200. * - * @param TestCase $testCase - * @param string $id - * @param string $kind + * @param string $id + * @param string $kind */ - protected function download( + public function download( string $id, - string $kind = 'FULL' - ) { + string $kind = Archive::FULL + ): void { $response = $this->testCase->call('GET', '/api/Photo::getArchive', [ 'photoIDs' => $id, 'kind' => $kind, @@ -419,46 +429,52 @@ protected function download( /** * Delete a picture. * - * @param TestCase $testCase - * @param string $id - * @param string $result + * @param string $id + * @param int $expectedStatusCode + * @param string|null $assertSee */ - protected function delete( + public function delete( string $id, - string $result = 'true' - ) { + int $expectedStatusCode = 204, + ?string $assertSee = null + ): void { $response = $this->testCase->json('POST', '/api/Photo::delete', [ 'photoIDs' => $id, ]); - $response->assertStatus(200); - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } } /** * Import a picture. * - * @param TestCase $testCase - * @param string $path - * @param string $delete_imported - * @param string $album_id - * @param string $result + * @param string $path + * @param string $delete_imported + * @param string $album_id + * @param int $expectedStatusCode + * @param string|null $assertSee * * @return string */ - protected function import( + public function import( string $path, string $delete_imported = '0', - string $album_id = '0', - string $result = 'true' - ) { + ?string $album_id = null, + int $expectedStatusCode = 200, + ?string $assertSee = null + ): string { $response = $this->testCase->json('POST', '/api/Import::server', [ 'function' => 'Import::server', 'albumID' => $album_id, 'path' => $path, 'delete_imported' => $delete_imported, ]); - $response->assertStatus(200); - $response->assertSee(''); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } return $response->streamedContent(); } @@ -466,26 +482,26 @@ protected function import( /** * Rotate a picture. * - * @param TestCase $testCase - * @param string $id - * @param $direction - * @param string $result + * @param string $id + * @param string $direction + * @param int $expectedStatusCode + * @param string|null $assertSee * * @return TestResponse */ - protected function rotate( + public function rotate( string $id, - $direction, - string $result = 'true', - int $code = 200 - ) { + string $direction, + int $expectedStatusCode = 200, + ?string $assertSee = null + ): TestResponse { $response = $this->testCase->json('POST', '/api/PhotoEditor::rotate', [ 'photoID' => $id, 'direction' => $direction, ]); - $response->assertStatus($code); - if ($code == 200 && $result != 'true') { - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } return $response; diff --git a/tests/Feature/Lib/SessionUnitTest.php b/tests/Feature/Lib/SessionUnitTest.php index 61f3e1b0991..1f946553801 100644 --- a/tests/Feature/Lib/SessionUnitTest.php +++ b/tests/Feature/Lib/SessionUnitTest.php @@ -7,42 +7,55 @@ class SessionUnitTest { + private TestCase $testCase; + + public function __construct(TestCase $testCase) + { + $this->testCase = $testCase; + } + /** * Logging in. * - * @param TestCase $testCase - * @param string $username - * @param string $password - * @param string $result + * @param string $username + * @param string $password + * @param int $expectedStatusCode + * @param string|null $assertSee + * + * @return TestResponse */ public function login( - TestCase &$testCase, string $username, string $password, - string $result = 'true' - ) { - $response = $testCase->json('POST', '/api/Session::login', [ + int $expectedStatusCode = 204, + ?string $assertSee = null + ): TestResponse { + $response = $this->testCase->json('POST', '/api/Session::login', [ 'username' => $username, 'password' => $password, ]); - $response->assertOk(); - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } + + return $response; } /** - * @param TestCase $testCase - * @param string $result + * @param int $expectedStatusCode + * @param string|null $assertSee * * @return TestResponse */ public function init( - TestCase &$testCase, - string $result = 'true' - ) { - $response = $testCase->json('POST', '/api/Session::init', []); - $response->assertStatus(200); - if ($result != 'true') { - $response->assertSee($result, false); + int $expectedStatusCode = 200, + ?string $assertSee = null + ): TestResponse { + $response = $this->testCase->json('POST', '/api/Session::init', []); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } return $response; @@ -51,62 +64,75 @@ public function init( /** * Logging out. * - * @param TestCase $testCase + * @return TestResponse */ - public function logout(TestCase &$testCase) + public function logout(): TestResponse { - $response = $testCase->json('POST', '/api/Session::logout'); - $response->assertOk(); - $response->assertSee('true'); + $response = $this->testCase->json('POST', '/api/Session::logout'); + $response->assertSuccessful(); + + return $response; } /** * Set a new login and password. * - * @param TestCase $testCase - * @param string $login - * @param string $password - * @param string $result + * @param string $login + * @param string $password + * @param int $expectedStatusCode + * @param string|null $assertSee + * + * @return TestResponse */ public function set_new( - TestCase &$testCase, string $login, string $password, - string $result = 'true' - ) { - $response = $testCase->json('POST', '/api/Settings::setLogin', [ + int $expectedStatusCode = 200, + ?string $assertSee = null + ): TestResponse { + $response = $this->testCase->json('POST', '/api/Settings::setLogin', [ 'username' => $login, 'password' => $password, ]); - $response->assertOk(); - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } + + return $response; } /** * Set a new login and password. * - * @param TestCase $testCase - * @param string $login - * @param string $password - * @param string $oldUsername - * @param string $oldPassword - * @param string $result + * @param string $login + * @param string $password + * @param string $oldUsername + * @param string $oldPassword + * @param int $expectedStatusCode + * @param string|null $assertSee + * + * @return TestResponse */ public function set_old( - TestCase &$testCase, string $login, string $password, string $oldUsername, string $oldPassword, - string $result = 'true' - ) { - $response = $testCase->json('POST', '/api/Settings::setLogin', [ + int $expectedStatusCode = 200, + ?string $assertSee = null + ): TestResponse { + $response = $this->testCase->json('POST', '/api/Settings::setLogin', [ 'username' => $login, 'password' => $password, 'oldUsername' => $oldUsername, 'oldPassword' => $oldPassword, ]); - $response->assertOk(); - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } + + return $response; } } diff --git a/tests/Feature/Lib/UsersUnitTest.php b/tests/Feature/Lib/UsersUnitTest.php index 80024eda033..53131319bf9 100644 --- a/tests/Feature/Lib/UsersUnitTest.php +++ b/tests/Feature/Lib/UsersUnitTest.php @@ -7,41 +7,48 @@ class UsersUnitTest { + private TestCase $testCase; + + public function __construct(TestCase $testCase) + { + $this->testCase = $testCase; + } + /** * List users. * - * @param TestCase $testCase - * @param string $result + * @param int $expectedStatusCode + * @param string|null $assertSee * * @return TestResponse */ public function list( - TestCase &$testCase, - string $result = 'true' - ) { - $response = $testCase->json('POST', '/api/User::List', []); - $response->assertStatus(200); - if ($result != 'true') { - $response->assertSee($result, false); + int $expectedStatusCode = 200, + ?string $assertSee = null + ): TestResponse { + $response = $this->testCase->json('POST', '/api/User::List', []); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } return $response; } /** - * @param TestCase $testCase - * @param string $result + * @param int $expectedStatusCode + * @param string|null $assertSee * * @return TestResponse */ public function init( - TestCase &$testCase, - string $result = 'true' - ) { - $response = $testCase->json('POST', '/php/index.php', []); - $response->assertStatus(200); - if ($result != 'true') { - $response->assertSee($result, false); + int $expectedStatusCode = 200, + ?string $assertSee = null + ): TestResponse { + $response = $this->testCase->json('POST', '/php/index.php', []); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } return $response; @@ -50,124 +57,142 @@ public function init( /** * Add a new user. * - * @param TestCase $testCase - * @param string $username - * @param string $password - * @param string $upload - * @param string $lock - * @param string $result + * @param string $username + * @param string $password + * @param bool $mayUpload + * @param bool $isLocked + * @param int $expectedStatusCode + * @param string|null $assertSee + * + * @return TestResponse */ public function add( - TestCase &$testCase, string $username, string $password, - string $upload, - string $lock, - string $result = 'true' - ) { - $response = $testCase->json('POST', '/api/User::Create', [ + bool $mayUpload = true, + bool $isLocked = false, + int $expectedStatusCode = 201, + ?string $assertSee = null + ): TestResponse { + $response = $this->testCase->json('POST', '/api/User::Create', [ 'username' => $username, 'password' => $password, - 'upload' => $upload, - 'lock' => $lock, + 'may_upload' => $mayUpload, + 'is_locked' => $isLocked, ]); - $response->assertStatus(200); - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); + } + + return $response; } /** * Delete a user. * - * @param TestCase $testCase - * @param string $id - * @param string $result + * @param string $id + * @param int $expectedStatusCode + * @param string|null $assertSee + * + * @return TestResponse */ public function delete( - TestCase &$testCase, string $id, - string $result = 'true', - int $code = 200 - ) { - $response = $testCase->json('POST', '/api/User::Delete', [ + int $expectedStatusCode = 204, + ?string $assertSee = null + ): TestResponse { + $response = $this->testCase->json('POST', '/api/User::Delete', [ 'id' => $id, ]); - $response->assertStatus($code); - if ($result == 'true') { - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } + + return $response; } /** * Save modifications to a user. * - * @param TestCase $testCase - * @param string $id - * @param string $username - * @param string $password - * @param string $upload - * @param string $lock - * @param string $result + * @param string $id + * @param string $username + * @param string $password + * @param bool $mayUpload + * @param bool $isLocked + * @param int $expectedStatusCode + * @param string|null $assertSee + * + * @return TestResponse */ public function save( - TestCase &$testCase, string $id, string $username, string $password, - string $upload, - string $lock, - string $result = 'true', - int $code = 200 - ) { - $response = $testCase->json('POST', '/api/User::Save', [ + bool $mayUpload = true, + bool $isLocked = false, + int $expectedStatusCode = 204, + ?string $assertSee = null + ): TestResponse { + $response = $this->testCase->json('POST', '/api/User::Save', [ 'id' => $id, 'username' => $username, 'password' => $password, - 'upload' => $upload, - 'lock' => $lock, + 'may_upload' => $mayUpload, + 'is_locked' => $isLocked, ]); - $response->assertStatus($code); - if ($code == 200) { - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } + + return $response; } /** * Update email on user. * - * @param TestCase $testCase - * @param string $email - * @param string $result + * @param string|null $email + * @param int $expectedStatusCode + * @param string|null $assertSee + * + * @return TestResponse */ public function update_email( - TestCase &$testCase, - string $email, - string $result = 'true', - int $code = 200 - ) { - $response = $testCase->json('POST', '/api/User::UpdateEmail', [ + ?string $email, + int $expectedStatusCode = 204, + ?string $assertSee = null + ): TestResponse { + $response = $this->testCase->json('POST', '/api/User::UpdateEmail', [ 'email' => $email, ]); - $response->assertStatus($code); - if ($code == 200) { - $response->assertSee($result, false); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } + + return $response; } /** * Get the email of a user. * - * @param TestCase $testCase - * @param string $result + * @param int $expectedStatusCode + * @param string|null $assertSee + * + * @return TestResponse */ public function get_email( - TestCase &$testCase, - string $result = 'true', - int $code = 200 - ) { - $response = $testCase->json('POST', '/api/User::GetEmail'); - $response->assertStatus($code); - if ($code == 200) { - $response->assertSee($result, false); + int $expectedStatusCode = 200, + ?string $assertSee = null + ): TestResponse { + $response = $this->testCase->json('POST', '/api/User::GetEmail'); + $response->assertStatus($expectedStatusCode); + if ($assertSee) { + $response->assertSee($assertSee, false); } + + return $response; } } diff --git a/tests/Feature/NotificationTest.php b/tests/Feature/NotificationTest.php index 875f3a980b8..8b779f23d03 100644 --- a/tests/Feature/NotificationTest.php +++ b/tests/Feature/NotificationTest.php @@ -2,7 +2,7 @@ namespace Tests\Feature; -use AccessControl; +use App\Facades\AccessControl; use App\Mail\PhotosAdded; use App\Models\Configs; use Illuminate\Http\UploadedFile; @@ -22,11 +22,11 @@ public function testNotificationSetting() // save initial value $init_config_value = Configs::get_value('new_photos_notification'); - $response = TestCase::json('POST', '/api/Settings::setNewPhotosNotification', [ + $response = $this->json('POST', '/api/Settings::setNewPhotosNotification', [ 'new_photos_notification' => '1', ]); $response->assertStatus(200); - $this->assertEquals(Configs::get_value('new_photos_notification'), '1'); + $this->assertEquals('1', Configs::get_value('new_photos_notification')); // set to initial Configs::set('new_photos_notification', $init_config_value); @@ -34,35 +34,33 @@ public function testNotificationSetting() public function testSetupUserEmail() { - $users_test = new UsersUnitTest(); - $sessions_test = new SessionUnitTest(); - - // save initial value - $init_config_value = Configs::get_value('new_photos_notification'); - Configs::set('new_photos_notification', '1'); + $users_test = new UsersUnitTest($this); + $sessions_test = new SessionUnitTest($this); // add email to admin AccessControl::log_as_id(0); - $users_test->update_email($this, 'test@test.com', 'true'); + $users_test->update_email('test@test.com'); // add new user - $users_test->add($this, 'uploader', 'uploader', '1', '0', 'true'); + $users_test->add('uploader', 'uploader', '1', '0'); - $sessions_test->logout($this); + $sessions_test->logout(); } + /** + * TODO: Figure out if this test even tests anything related to notification; it appears to me as if this test simply uploads a file, but does not even assert that a notification has been sent. + */ public function testUploadAndNotify() { - $users_test = new UsersUnitTest(); - $sessions_test = new SessionUnitTest(); + $sessions_test = new SessionUnitTest($this); $albums_tests = new AlbumsUnitTest($this); $photos_tests = new PhotosUnitTest($this); // login as new user - $sessions_test->login($this, 'uploader', 'uploader'); + $sessions_test->login('uploader', 'uploader'); // add new album - $albumID = $albums_tests->add('0', 'test_album', 'true'); + $albumID = $albums_tests->add(null, 'test_album')->offsetGet('id'); // upload photo to the album copy('tests/Feature/night.jpg', 'public/uploads/import/night.jpg'); @@ -80,11 +78,15 @@ public function testUploadAndNotify() $albums_tests->delete($albumID); // logout - $sessions_test->logout($this); + $sessions_test->logout(); } public function testMailNotifications() { + // save initial value + $init_config_value = Configs::get_value('new_photos_notification'); + Configs::set('new_photos_notification', '1'); + $photos = [ 'album123' => [ 'name' => 'Test Photo', @@ -97,33 +99,35 @@ public function testMailNotifications() ], ]; - Mail::fake('test@test.com')->send(new PhotosAdded($photos)); + Mail::fake()->send(new PhotosAdded($photos)); Mail::assertSent(PhotosAdded::class); + + Configs::set('new_photos_notification', $init_config_value); } public function testClearNotifications() { - $users_test = new UsersUnitTest(); - $sessions_test = new SessionUnitTest(); + $users_test = new UsersUnitTest($this); + $sessions_test = new SessionUnitTest($this); // remove user, email & notifications AccessControl::log_as_id(0); - $users_test->update_email($this, '', 'true'); + $users_test->update_email(''); - $response = $users_test->list($this, 'true'); + $response = $users_test->list(); $t = json_decode($response->getContent()); $user_id = end($t)->id; $response->assertJsonFragment([ 'id' => $user_id, 'username' => 'uploader', - 'upload' => 1, - 'lock' => 0, + 'may_upload' => true, + 'is_locked' => false, ]); - $users_test->delete($this, $user_id, 'true'); + $users_test->delete($user_id); - $sessions_test->logout($this); + $sessions_test->logout(); } } diff --git a/tests/Feature/PhotosRotateTest.php b/tests/Feature/PhotosRotateTest.php index 8e48eb1c64b..ff854e17eb7 100644 --- a/tests/Feature/PhotosRotateTest.php +++ b/tests/Feature/PhotosRotateTest.php @@ -2,7 +2,7 @@ namespace Tests\Feature; -use AccessControl; +use App\Facades\AccessControl; use App\Models\Configs; use Illuminate\Http\UploadedFile; use Tests\Feature\Lib\PhotosUnitTest; @@ -20,7 +20,7 @@ public function testRotate() AccessControl::log_as_id(0); /* - * Make a copy of the image because import deletes the file and we want to be + * Make a copy of the image because import deletes the file, and we want to be * able to use the test on a local machine and not just in CI. */ copy('tests/Feature/night.jpg', 'public/uploads/import/night.jpg'); @@ -35,18 +35,14 @@ public function testRotate() $id = $photos_tests->upload($file); - $photos_tests->get($id, 'true'); - - $response = $photos_tests->get($id, 'true'); + $response = $photos_tests->get($id); /* * Check some Exif data */ $response->assertJson([ - 'height' => 4480, 'id' => $id, 'filesize' => 21104156, - 'width' => 6720, - 'sizeVariants' => [ + 'size_variants' => [ 'small' => [ 'width' => 540, 'height' => 360, @@ -55,33 +51,37 @@ public function testRotate() 'width' => 1620, 'height' => 1080, ], + 'original' => [ + 'width' => 6720, + 'height' => 4480, + ], ], ]); $editor_enabled_value = Configs::get_value('editor_enabled'); Configs::set('editor_enabled', '0'); $response = $this->post('/api/PhotoEditor::rotate', [ - 'photoID' => $id, + // somewhere in the Laravel middleware is a test which checks + // if `photoID` is a string; find where + 'photoID' => (string) $id, 'direction' => 1, ]); - $response->assertStatus(200); - $response->assertSee('false', false); + $response->assertStatus(422); + $response->assertSee('support for rotation disabled by configuration'); Configs::set('editor_enabled', '1'); - $photos_tests->rotate('-1', 1, 'false'); - $photos_tests->rotate($id, 'asdq', 'false', 422); - $photos_tests->rotate($id, '2', 'false'); + $photos_tests->rotate('-1', 1, 422); + $photos_tests->rotate($id, 'asdq', 422, 'The selected direction is invalid'); + $photos_tests->rotate($id, '2', 422, 'The selected direction is invalid'); $response = $photos_tests->rotate($id, 1); /* * Check some Exif data */ $response->assertJson([ - 'height' => 6720, 'id' => $id, // 'filesize' => 21104156, // This changes during the image manipulation sadly. - 'width' => 4480, - 'sizeVariants' => [ + 'size_variants' => [ 'small' => [ 'width' => 240, 'height' => 360, @@ -90,6 +90,10 @@ public function testRotate() 'width' => 720, 'height' => 1080, ], + 'original' => [ + 'width' => 4480, + 'height' => 6720, + ], ], ]); @@ -98,13 +102,11 @@ public function testRotate() /* * Check some Exif data */ - $response = $photos_tests->get($id, 'true'); + $response = $photos_tests->get($id); $response->assertJson([ - 'height' => 4480, 'id' => $id, // 'filesize' => 21104156, // This changes during the image manipulation sadly. - 'width' => 6720, - 'sizeVariants' => [ + 'size_variants' => [ 'small' => [ 'width' => 540, 'height' => 360, @@ -113,10 +115,14 @@ public function testRotate() 'width' => 1620, 'height' => 1080, ], + 'original' => [ + 'width' => 6720, + 'height' => 4480, + ], ], ]); - $photos_tests->delete($id, 'true'); + $photos_tests->delete($id); // reset Configs::set('editor_enabled', $editor_enabled_value); diff --git a/tests/Feature/PhotosTest.php b/tests/Feature/PhotosTest.php index b4458809077..d5099a6bc2c 100644 --- a/tests/Feature/PhotosTest.php +++ b/tests/Feature/PhotosTest.php @@ -2,10 +2,11 @@ namespace Tests\Feature; -use AccessControl; +use App\Facades\AccessControl; use App\Models\Configs; use App\Models\Photo; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection as BaseCollection; use Tests\Feature\Lib\AlbumsUnitTest; @@ -27,7 +28,7 @@ public function testUpload() AccessControl::log_as_id(0); /* - * Make a copy of the image because import deletes the file and we want to be + * Make a copy of the image because import deletes the file, and we want to be * able to use the test on a local machine and not just in CI. */ copy('tests/Feature/night.jpg', 'public/uploads/import/night.jpg'); @@ -42,7 +43,7 @@ public function testUpload() $id = $photos_tests->upload($file); - $photos_tests->get($id, 'true'); + $photos_tests->get($id); $photos_tests->see_in_unsorted($id); $photos_tests->see_in_recent($id); @@ -54,7 +55,7 @@ public function testUpload() $photos_tests->set_star($id); $photos_tests->set_tag($id, 'night'); $photos_tests->set_public($id); - $photos_tests->set_license($id, 'WTFPL', '"Error: License not recognised!"'); + $photos_tests->set_license($id, 'WTFPL', 422, 'The selected license is invalid'); $photos_tests->set_license($id, 'CC0'); $photos_tests->set_license($id, 'CC-BY-1.0'); $photos_tests->set_license($id, 'CC-BY-2.0'); @@ -90,8 +91,8 @@ public function testUpload() $photos_tests->see_in_favorite($id); $photos_tests->see_in_shared($id); - $response = $photos_tests->get($id, 'true'); - $photos_tests->download($id, 'FULL'); + $response = $photos_tests->get($id); + $photos_tests->download($id); /* * Check some Exif data @@ -100,27 +101,26 @@ public function testUpload() 2019, 6, 1, 1, 28, 25, '+02:00' ); $response->assertJson([ + 'album_id' => null, 'aperture' => 'f/2.8', 'description' => 'A night photography', 'focal' => '16 mm', - 'height' => '4480', 'id' => $id, 'iso' => '1250', 'lens' => 'EF16-35mm f/2.8L USM', 'license' => 'reserved', 'make' => 'Canon', 'model' => 'Canon EOS R', - 'public' => '1', + 'is_public' => 1, 'shutter' => '30 s', 'filesize' => 21104156, - 'star' => '1', + 'is_starred' => true, 'tags' => 'night', - 'taken_at' => $taken_at->format(\DateTimeInterface::ATOM), + 'taken_at' => $taken_at->format('Y-m-d\TH:i:s.uP'), 'taken_at_orig_tz' => $taken_at->getTimezone()->getName(), 'title' => "Night in Ploumanac'h", 'type' => 'image/jpeg', - 'width' => 6720, - 'sizeVariants' => [ + 'size_variants' => [ 'small' => [ 'width' => 540, 'height' => 360, @@ -129,6 +129,10 @@ public function testUpload() 'width' => 1620, 'height' => 1080, ], + 'original' => [ + 'width' => 6720, + 'height' => 4480, + ], ], ]); @@ -146,26 +150,68 @@ public function testUpload() /** * We now test interaction with albums. */ - $albumID = $albums_tests->add('0', 'test_album_2'); - $photos_tests->set_album('-1', $id, 'false'); - $photos_tests->set_album($albumID, $id, 'true'); + $albumID = $albums_tests->add(null, 'test_album_2')->offsetGet('id'); + $photos_tests->set_album('-1', $id, 422); + $photos_tests->set_album($albumID, $id); $albums_tests->download($albumID); $photos_tests->dont_see_in_unsorted($id); - $photos_tests->duplicate($id, 'true'); - $album = $this->asObject($albums_tests->get($albumID, '', 'true')); - $this->assertEquals(2, count($album->photos)); + /** + * Test duplication, the duplicate should be completely identical + * except for the IDs. + */ + $response = $photos_tests->duplicate($id, $albumID); + $response->assertJson([ + 'album_id' => $albumID, + 'aperture' => 'f/2.8', + 'description' => 'A night photography', + 'focal' => '16 mm', + 'iso' => '1250', + 'lens' => 'EF16-35mm f/2.8L USM', + 'license' => 'reserved', + 'make' => 'Canon', + 'model' => 'Canon EOS R', + 'is_public' => 1, + 'shutter' => '30 s', + 'filesize' => 21104156, + 'is_starred' => true, + 'tags' => '', + 'taken_at' => $taken_at->format('Y-m-d\TH:i:s.uP'), + 'taken_at_orig_tz' => $taken_at->getTimezone()->getName(), + 'title' => "Night in Ploumanac'h", + 'type' => 'image/jpeg', + 'size_variants' => [ + 'small' => [ + 'width' => 540, + 'height' => 360, + ], + 'medium' => [ + 'width' => 1620, + 'height' => 1080, + ], + 'original' => [ + 'width' => 6720, + 'height' => 4480, + ], + ], + ]); + + /** + * Get album which should contain both photos. + */ + $album = $this->asObject($albums_tests->get($albumID)); + $this->assertCount(2, $album->photos); $ids = []; $ids[0] = $album->photos[0]->id; $ids[1] = $album->photos[1]->id; - $photos_tests->delete($ids[0], 'true'); - $photos_tests->get($id[0], 'false'); + $photos_tests->delete($ids[0]); + $photos_tests->get($ids[0], 404); $photos_tests->dont_see_in_recent($ids[0]); $photos_tests->dont_see_in_unsorted($ids[1]); - $albums_tests->set_public($albumID, 1, 1, 1, 0, 1, 1, 'true'); + $albums_tests->set_public($albumID); /** * Actually try to display the picture. @@ -174,17 +220,17 @@ public function testUpload() $response->assertStatus(200); // delete the picture after displaying it - $photos_tests->delete($ids[1], 'true'); - $photos_tests->get($id[1], 'false'); - $album = $this->asObject($albums_tests->get($albumID, '', 'true')); - $this->assertEquals(0, count($album->photos)); + $photos_tests->delete($ids[1]); + $photos_tests->get($ids[1], 404); + $album = $this->asObject($albums_tests->get($albumID)); + $this->assertCount(0, $album->photos); // save initial value $init_config_value = Configs::get_value('gen_demo_js'); // set to 0 Configs::set('gen_demo_js', '1'); - $this->assertEquals(Configs::get_value('gen_demo_js'), '1'); + $this->assertEquals('1', Configs::get_value('gen_demo_js')); // check redirection $response = $this->get('/demo'); @@ -221,7 +267,7 @@ public function testLivePhotoUpload() if (Configs::hasExiftool()) { /* - * Make a copy of the image because import deletes the file and we want to be + * Make a copy of the image because import deletes the file, and we want to be * able to use the test on a local machine and not just in CI. */ copy('tests/Feature/train.jpg', 'public/uploads/import/train.jpg'); @@ -246,11 +292,11 @@ public function testLivePhotoUpload() $photo_id = $photos_tests->upload($photo_file); $video_id = $photos_tests->upload($video_file); - $photo = $this->asObject($photos_tests->get($photo_id, 'true')); + $photo = $this->asObject($photos_tests->get($photo_id)); $this->assertEquals($photo_id, $video_id); - $this->assertEquals($photo->livePhotoContentID, 'E905E6C6-C747-4805-942F-9904A0281F02'); - $this->assertStringEndsWith('.mov', $photo->livePhotoUrl); + $this->assertEquals('E905E6C6-C747-4805-942F-9904A0281F02', $photo->live_photo_content_id); + $this->assertStringEndsWith('.mov', $photo->live_photo_url); } else { $this->markTestSkipped('Exiftool is not available. Test Skipped.'); } @@ -264,13 +310,13 @@ public function testTrueNegative() AccessControl::log_as_id(0); - $photos_tests->wrong_upload($this); - $photos_tests->wrong_upload2($this); - $photos_tests->get('-1', 'false'); - $photos_tests->set_description('-1', 'test', 'false'); - $photos_tests->set_public('-1', 'false'); - $photos_tests->set_album('-1', '-1', 'false'); - $photos_tests->set_license('-1', 'CC0', 'false'); + $photos_tests->wrong_upload(); + $photos_tests->wrong_upload2(); + $photos_tests->get('-1', 422); + $photos_tests->set_description('-1', 'test', 422); + $photos_tests->set_public('-1', 422); + $photos_tests->set_album('-1', '-1', 422); + $photos_tests->set_license('-1', 'CC0', 422); AccessControl::logout(); } @@ -284,8 +330,8 @@ public function testUpload2() // set to 0 Configs::set('SL_enable', '1'); Configs::set('SL_for_admin', '1'); - $this->assertEquals(Configs::get_value('SL_enable'), '1'); - $this->assertEquals(Configs::get_value('SL_for_admin'), '1'); + $this->assertEquals('1', Configs::get_value('SL_enable')); + $this->assertEquals('1', Configs::get_value('SL_for_admin')); // just redo the test above :'D $this->testUpload(); @@ -307,9 +353,18 @@ public function testImport() // enable import via symlink option Configs::set('import_via_symlink', '1'); - $this->assertEquals(Configs::get_value('import_via_symlink'), '1'); + $this->assertEquals('1', Configs::get_value('import_via_symlink')); - $num_before_import = Photo::recent()->count(); + $strRecent = Carbon::now() + ->subDays(intval(Configs::get_value('recent_age', '1'))) + ->setTimezone('UTC') + ->format('Y-m-d H:i:s'); + $recentFilter = function (Builder $query) use ($strRecent) { + $query->where('created_at', '>=', $strRecent); + }; + + $ids_before_import = Photo::query()->select('id')->where($recentFilter)->pluck('id'); + $num_before_import = $ids_before_import->count(); // upload the photo copy('tests/Feature/night.jpg', 'public/uploads/import/night.jpg'); @@ -318,15 +373,14 @@ public function testImport() // check if the file is still there (without symlinks the photo would have been deleted) $this->assertEquals(true, file_exists('public/uploads/import/night.jpg')); - $response = $albums_tests->get('recent', '', 'true'); - $content = $response->getContent(); - $array_content = json_decode($content); - $photos = new BaseCollection($array_content->photos); - $this->assertEquals(Photo::recent()->count(), $photos->count()); - $ids = $photos->skip($num_before_import)->implode('id', ','); - $photos_tests->delete($ids, 'true'); + $response = $albums_tests->get('recent'); + $responseObj = json_decode($response->getContent()); + $ids_after_import = (new BaseCollection($responseObj->photos))->pluck('id'); + $this->assertEquals(Photo::query()->where($recentFilter)->count(), $ids_after_import->count()); + $ids_to_delete = $ids_after_import->diff($ids_before_import)->implode(','); + $photos_tests->delete($ids_to_delete); - $this->assertEquals($num_before_import, Photo::recent()->count()); + $this->assertEquals($num_before_import, Photo::query()->where($recentFilter)->count()); // set back to initial value Configs::set('import_via_symlink', $init_config_value); diff --git a/tests/Feature/RSSTest.php b/tests/Feature/RSSTest.php index b8a93b46c41..ed9ebc5626f 100644 --- a/tests/Feature/RSSTest.php +++ b/tests/Feature/RSSTest.php @@ -2,7 +2,7 @@ namespace Tests\Feature; -use AccessControl; +use App\Facades\AccessControl; use App\Models\Configs; use Illuminate\Http\UploadedFile; use Tests\Feature\Lib\AlbumsUnitTest; @@ -18,7 +18,7 @@ public function testRSS0() // set to 0 Configs::set('rss_enable', '0'); - $this->assertEquals(Configs::get_value('rss_enable'), '0'); + $this->assertEquals('0', Configs::get_value('rss_enable')); // check redirection $response = $this->get('/feed'); @@ -36,7 +36,7 @@ public function testRSS1() // set to 0 Configs::set('rss_enable', '1'); Configs::set('full_photo', '0'); - $this->assertEquals(Configs::get_value('rss_enable'), '1'); + $this->assertEquals('1', Configs::get_value('rss_enable')); // check redirection $response = $this->get('/feed'); @@ -50,7 +50,7 @@ public function testRSS1() AccessControl::log_as_id(0); // create an album - $albumID = $albums_tests->add('0', 'test_album', 'true'); + $albumID = $albums_tests->add(null, 'test_album')->offsetGet('id'); // upload a picture copy('tests/Feature/night.jpg', 'public/uploads/import/night.jpg'); @@ -74,8 +74,8 @@ public function testRSS1() $photos_tests->set_public($photoID); // move picture to album - $photos_tests->set_album($albumID, $photoID, 'true'); - $albums_tests->set_public($albumID, 1, 1, 1, 0, 1, 1, 'true'); + $photos_tests->set_album($albumID, $photoID); + $albums_tests->set_public($albumID); // try to get the RSS feed. $response = $this->get('/feed'); diff --git a/tests/Feature/RedirectTest.php b/tests/Feature/RedirectTest.php index 00b4ac0310b..b7dc81f66fc 100644 --- a/tests/Feature/RedirectTest.php +++ b/tests/Feature/RedirectTest.php @@ -11,16 +11,16 @@ class RedirectTest extends TestCase * * @return void */ - public function testRedirection() + public function testRedirection(): void { - $response = $this->get('r/12345'); + $response = $this->get('r/aaaaaaaaaaaaaaaaaaaaaaaa'); $response->assertStatus(302); - $response->assertRedirect('gallery#12345'); + $response->assertRedirect('gallery#aaaaaaaaaaaaaaaaaaaaaaaa'); - $response = $this->get('r/12345/67890'); + $response = $this->get('r/aaaaaaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbb'); $response->assertStatus(302); - $response->assertRedirect('gallery#12345/67890'); + $response->assertRedirect('gallery#aaaaaaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbb'); } } diff --git a/tests/Feature/UsersTest.php b/tests/Feature/UsersTest.php index 15f070e781f..55d6c348bbf 100644 --- a/tests/Feature/UsersTest.php +++ b/tests/Feature/UsersTest.php @@ -2,7 +2,7 @@ namespace Tests\Feature; -use AccessControl; +use App\Facades\AccessControl; use App\ModelFunctions\SessionFunctions; use App\Models\Configs; use Tests\Feature\Lib\AlbumsUnitTest; @@ -18,7 +18,7 @@ public function testSetLogin() * because there is no dependency injection in test cases. */ $sessionFunctions = new SessionFunctions(); - $sessions_test = new SessionUnitTest(); + $sessions_test = new SessionUnitTest($this); $clear = false; $configs = Configs::get(); @@ -29,11 +29,11 @@ public function testSetLogin() if ($configs['password'] == '' && $configs['username'] == '') { $clear = true; - $sessions_test->set_new($this, 'lychee', 'password', 'true'); - $sessions_test->logout($this); + $sessions_test->set_new('lychee', 'password'); + $sessions_test->logout(); - $sessions_test->login($this, 'lychee', 'password', 'true'); - $sessions_test->logout($this); + $sessions_test->login('lychee', 'password'); + $sessions_test->logout(); } else { $this->markTestSkipped('Username and Password are set. We do not bother testing further.'); } @@ -43,9 +43,9 @@ public function testSetLogin() */ $this->assertFalse($sessionFunctions->noLogin()); - $sessions_test->login($this, 'foo', 'bar', 'false'); - $sessions_test->login($this, 'lychee', 'bar', 'false'); - $sessions_test->login($this, 'foo', 'password', 'false'); + $sessions_test->login('foo', 'bar', 401); + $sessions_test->login('lychee', 'bar', 401); + $sessions_test->login('foo', 'password', 401); /* * If we did set login and password we clear them @@ -58,12 +58,12 @@ public function testSetLogin() public function testUsers() { - $sessions_test = new SessionUnitTest(); - $users_test = new UsersUnitTest(); + $sessions_test = new SessionUnitTest($this); + $users_test = new UsersUnitTest($this); $album_tests = new AlbumsUnitTest($this); /* - * Scenario is as follow + * Scenario is as follows: * * 1. log as admin * 2. create a user 'test_abcd' @@ -113,10 +113,10 @@ public function testUsers() AccessControl::log_as_id(0); // 2 - $users_test->add($this, 'test_abcd', 'test_abcd', '1', '1', 'true'); + $users_test->add('test_abcd', 'test_abcd', true, true); // 3 - $response = $users_test->list($this, 'true'); + $response = $users_test->list(); // 4 $t = json_decode($response->getContent()); @@ -124,101 +124,108 @@ public function testUsers() $response->assertJsonFragment([ 'id' => $id, 'username' => 'test_abcd', - 'upload' => 1, - 'lock' => 1, + 'may_upload' => true, + 'is_locked' => true, ]); // 5 - $users_test->add($this, 'test_abcd', 'test_abcd', '1', '1', 'Error: username must be unique'); + // TODO: Fix this on the server.side. The expected status code should be '409' (Conflict), not 200 (OK). + $users_test->add('test_abcd', 'test_abcd', true, true, 200, 'Error: username must be unique'); // 6 - $users_test->save($this, $id, 'test_abcde', 'testing', '0', '1', 'true'); + $users_test->save($id, 'test_abcde', 'testing', false, true); // 7 - $users_test->add($this, 'test_abcd2', 'test_abcd', '1', '1', 'true'); - $response = $users_test->list($this, 'true'); + $users_test->add('test_abcd2', 'test_abcd', true, true); + $response = $users_test->list(); $t = json_decode($response->getContent()); $id2 = end($t)->id; // 8 - $users_test->save($this, $id2, 'test_abcde', 'testing', '0', '1', 'Error: username must be unique'); + // TODO: Fix this on the server.side. The expected status code should be '409' (Conflict), not 200 (OK). + $users_test->save($id2, 'test_abcde', 'testing', false, true, 200, 'Error: username must be unique'); // 9 - $sessions_test->logout($this); + $sessions_test->logout(); // 10 - $sessions_test->login($this, 'test_abcde', 'testing'); + $sessions_test->login('test_abcde', 'testing'); // 11 - $users_test->list($this, 'false'); + $users_test->list(403); // 12 - $sessions_test->set_new($this, 'test_abcde', 'testing2', '"Error: Locked account!"'); + // TODO: Fix this on the server.side. The expected status code should be '405' (Method Not Allowed), not 200 (OK). + $sessions_test->set_new('test_abcde', 'testing2', 200, '"Error: Locked account!"'); // 13 - $sessions_test->set_old($this, 'test_abcde', 'testing2', 'test_abcde', 'testing2', '"Error: Locked account!"'); + // TODO: Fix this on the server.side. The expected status code should be '405' (Method Not Allowed), not 200 (OK). + $sessions_test->set_old('test_abcde', 'testing2', 'test_abcde', 'testing2', 200, '"Error: Locked account!"'); // 14 - $sessions_test->logout($this); + $sessions_test->logout(); // 15 AccessControl::log_as_id(0); // 16 - $users_test->save($this, $id, 'test_abcde', 'testing', '0', '0', 'true'); + $users_test->save($id, 'test_abcde', 'testing', false, false); // 17 - $sessions_test->logout($this); + $sessions_test->logout(); // 18 - $sessions_test->login($this, 'test_abcde', 'testing'); - $sessions_test->init($this, 'true'); + $sessions_test->login('test_abcde', 'testing'); + $sessions_test->init(); // 19 - $album_tests->get('public', '', 'true'); + $album_tests->get('public', 403); // 20 - $album_tests->get('starred', '', 'true'); + $album_tests->get('starred', 403); // 21 - $album_tests->get('unsorted', '', 'true'); + $album_tests->get('unsorted', 403); // 22 - $sessions_test->set_new($this, 'test_abcde', 'testing2', '"Error: Old username or password entered incorrectly!"'); + // TODO: Fix this on the server.side. The expected status code should be '401' (Not Authorized), not 200 (OK). + $sessions_test->set_new('test_abcde', 'testing2', 200, '"Error: Old username or password entered incorrectly!"'); // 23 - $sessions_test->set_old($this, 'test_abcde', 'testing2', 'test_abcde', 'testing2', '"Error: Old username or password entered incorrectly!"'); + // TODO: Fix this on the server.side. The expected status code should be '401' (Not Authorized), not 200 (OK). + $sessions_test->set_old('test_abcde', 'testing2', 'test_abcde', 'testing2', 200, '"Error: Old username or password entered incorrectly!"'); // 24 - $sessions_test->set_old($this, 'test_abcd2', 'testing2', 'test_abcde', 'testing2', '"Error: Username already exists."'); + // TODO: Fix this on the server.side. The expected status code should be '409' (Conflict), not 200 (OK). + $sessions_test->set_old('test_abcd2', 'testing2', 'test_abcde', 'testing2', 200, '"Error: Username already exists."'); // 25 - $sessions_test->set_old($this, 'test_abcdef', 'testing2', 'test_abcde', 'testing', 'true'); + $sessions_test->set_old('test_abcdef', 'testing2', 'test_abcde', 'testing'); // 26 - $sessions_test->logout($this); + $sessions_test->logout(); // 27 - $sessions_test->login($this, 'test_abcdef', 'testing2'); + $sessions_test->login('test_abcdef', 'testing2'); // 28 - $sessions_test->logout($this); + $sessions_test->logout(); // 29 AccessControl::log_as_id(0); // 30 - $users_test->delete($this, $id, 'true'); - $users_test->delete($this, $id2, 'true'); + $users_test->delete($id); + $users_test->delete($id2); // those should fail because we do not touch user of ID 0 - $users_test->delete($this, '0', 'false', 422); + $users_test->delete('0', 422); // those should fail because there are no user with id -1 - $users_test->delete($this, '-1', 'false', 422); - $users_test->save($this, '-1', 'toto', 'test', '0', '1', 'false', 422); + $users_test->delete('-1', 422); + $users_test->save('-1', 'toto', 'test', false, true, 422); // 31 - $sessions_test->logout($this); + $sessions_test->logout(); // 32 AccessControl::log_as_id(0); @@ -228,19 +235,19 @@ public function testUsers() Configs::set('new_photos_notification', '1'); // 33 - $users_test->get_email($this, ''); + $users_test->get_email(); // 34 - $users_test->update_email($this, 'test@example.com', 'true'); + $users_test->update_email('test@example.com'); // 35 - $users_test->get_email($this, 'test@example.com'); + $users_test->get_email(); // 36 - $users_test->update_email($this, '', 'true'); + $users_test->update_email(null); // 37 - $sessions_test->logout($this); + $sessions_test->logout(); Configs::set('new_photos_notification', $store_new_photos_notification); } } diff --git a/version.md b/version.md index 64b5ae3938a..a84947d6ffe 100644 --- a/version.md +++ b/version.md @@ -1 +1 @@ -4.4.0 \ No newline at end of file +4.5.0