diff --git a/app/Exceptions/Internal/ZipExtractionException.php b/app/Exceptions/Internal/ZipExtractionException.php new file mode 100644 index 00000000000..66b492efff0 --- /dev/null +++ b/app/Exceptions/Internal/ZipExtractionException.php @@ -0,0 +1,11 @@ +close(); // End of work-around + if (Configs::getValueAsBool('extract_zip_on_upload') && + \Str::endsWith($processableFile->getPath(), '.zip')) { + ExtractZip::dispatch($processableFile, $album?->id, $file_last_modified_time); + $meta->stage = FileStatus::DONE; + + return $meta; + } + if (Configs::getValueAsBool('use_job_queues')) { ProcessImageJob::dispatch($processableFile, $album, $file_last_modified_time); $meta->stage = FileStatus::READY; diff --git a/app/Image/Files/ExtractedJobFile.php b/app/Image/Files/ExtractedJobFile.php new file mode 100644 index 00000000000..dbb74ff418d --- /dev/null +++ b/app/Image/Files/ExtractedJobFile.php @@ -0,0 +1,28 @@ +path; + } + + public function getOriginalBasename(): string + { + return $this->baseName; + } +} diff --git a/app/Image/Files/ProcessableJobFile.php b/app/Image/Files/ProcessableJobFile.php index ef58a212ab0..d68c3504bd4 100644 --- a/app/Image/Files/ProcessableJobFile.php +++ b/app/Image/Files/ProcessableJobFile.php @@ -7,7 +7,7 @@ use function Safe\mkdir; /** - * Class TemporaryJobFile. + * Class ProcessableJobFile. * * Represents a local file with an automatically chosen, unique name intended * to be used temporarily before being processed in a Job. diff --git a/app/Jobs/CleanUpExtraction.php b/app/Jobs/CleanUpExtraction.php new file mode 100644 index 00000000000..e89124221f6 --- /dev/null +++ b/app/Jobs/CleanUpExtraction.php @@ -0,0 +1,79 @@ +folderPath = $folderPath; + $this->userId = Auth::user()->id; + + // Set up our new history record. + $this->history = new JobHistory(); + $this->history->owner_id = $this->userId; + $this->history->job = Str::limit('Removing ' . basename($this->folderPath), 200); + $this->history->status = JobStatus::READY; + + $this->history->save(); + } + + /** + * Execute the job. + */ + public function handle(): void + { + // $this->history->status = JobStatus::STARTED; + // $this->history->save(); + + rmdir($this->folderPath); + + $this->history->status = JobStatus::SUCCESS; + $this->history->save(); + } + + /** + * Catch failures. + * + * @param \Throwable $th + * + * @return void + */ + public function failed(\Throwable $th): void + { + $this->history->status = JobStatus::FAILURE; + $this->history->save(); + + if ($th->getCode() === 999) { + $this->release(); + } else { + Log::error(__LINE__ . ':' . __FILE__ . ' ' . $th->getMessage(), $th->getTrace()); + } + } +} diff --git a/app/Jobs/ExtractZip.php b/app/Jobs/ExtractZip.php new file mode 100644 index 00000000000..a2737c78285 --- /dev/null +++ b/app/Jobs/ExtractZip.php @@ -0,0 +1,184 @@ +filePath = $file->getPath(); + $this->originalBaseName = $file->getOriginalBasename(); + $this->albumID = is_string($albumID) ? $albumID : $albumID?->id; + $this->userId = Auth::user()->id; + $this->fileLastModifiedTime = $fileLastModifiedTime; + + // Set up our new history record. + $this->history = new JobHistory(); + $this->history->owner_id = $this->userId; + $this->history->job = Str::limit('Extracting: ' . $this->originalBaseName, 200); + $this->history->status = JobStatus::READY; + + $this->history->save(); + } + + /** + * Execute the job. + */ + public function handle(): void + { + $this->history->status = JobStatus::STARTED; + $this->history->save(); + + $extractedFolderName = $this->getExtractFolderName(); + + $pathExtracted = Storage::disk('extract-jobs')->path(date('Ymd') . $extractedFolderName); + $zip = new \ZipArchive(); + if ($zip->open($this->filePath) === true) { + $zip->extractTo($pathExtracted); + $zip->close(); + + // clean up the zip file + unlink($this->filePath); + + $this->history->status = JobStatus::SUCCESS; + $this->history->save(); + } else { + throw new ZipExtractionException($this->filePath, $pathExtracted); + } + + $newAlbum = $this->createAlbum($extractedFolderName, $this->albumID); + $jobs = []; + foreach (new \DirectoryIterator($pathExtracted) as $fileInfo) { + if ($fileInfo->isDot() || $fileInfo->isDir()) { + continue; + } + + $extractedFile = new ExtractedJobFile($fileInfo->getRealPath(), $fileInfo->getFilename()); + $jobs[] = new ProcessImageJob($extractedFile, $newAlbum, $fileInfo->getMTime()); + } + + $jobs[] = new CleanUpExtraction($pathExtracted); + foreach ($jobs as $job) { + dispatch($job); + } + } + + /** + * Catch failures. + * + * @param \Throwable $th + * + * @return void + */ + public function failed(\Throwable $th): void + { + $this->history->status = JobStatus::FAILURE; + $this->history->save(); + + if ($th->getCode() === 999) { + $this->release(); + } else { + Log::error(__LINE__ . ':' . __FILE__ . ' ' . $th->getMessage(), $th->getTrace()); + } + } + + /** + * Given a name and parent we create it. + * + * @param string $newAlbumName + * @param string|null $parentID + * + * @return Album new album + */ + private function createAlbum(string $newAlbumName, ?string $parentID): Album + { + if (SmartAlbumType::tryFrom($parentID) !== null) { + $parentID = null; + } + + /** @var Album $parentAlbum */ + $parentAlbum = $parentID !== null ? Album::query()->findOrFail($parentID) : null; // in case no ID provided -> import to root folder + $createAlbum = new Create($this->userId); + + return $createAlbum->create($this->prepareAlbumName($newAlbumName), $parentAlbum); + } + + /** + * Todo Later: add renamer module. + * + * @param string $albumNameCandidate + * + * @return string + */ + private function prepareAlbumName(string $albumNameCandidate): string + { + return trim(str_replace('_', ' ', $albumNameCandidate)); + } + + /** + * Returns a folder name where: + * - spaces are replaced by `_` + * - if folder already exists (with date prefix) then we pad with _(xx) where xx is the next available number. + * + * @return string + */ + private function getExtractFolderName(): string + { + $baseNameWithoutExtension = substr($this->originalBaseName, 0, -4); + + // Save that one (is default if no existing folder found). + $orignalName = str_replace(' ', '_', $baseNameWithoutExtension); + + // Iterate on that one. + $candidateName = $orignalName; + + // count + $i = 0; + while (Storage::disk('extract-jobs')->exists(date('Ymd') . $candidateName)) { + $candidateName = $orignalName . '_(' . $i . ')'; + $i++; + } + + return $candidateName; + } +} diff --git a/app/Jobs/ProcessImageJob.php b/app/Jobs/ProcessImageJob.php index cf14d0e7d15..fb286357122 100644 --- a/app/Jobs/ProcessImageJob.php +++ b/app/Jobs/ProcessImageJob.php @@ -7,6 +7,7 @@ use App\DTO\ImportMode; use App\Enum\JobStatus; use App\Factories\AlbumFactory; +use App\Image\Files\ExtractedJobFile; use App\Image\Files\ProcessableJobFile; use App\Image\Files\TemporaryJobFile; use App\Models\Configs; @@ -44,7 +45,7 @@ class ProcessImageJob implements ShouldQueue * Create a new job instance. */ public function __construct( - ProcessableJobFile $file, + ProcessableJobFile|ExtractedJobFile $file, string|AbstractAlbum|null $album, ?int $fileLastModifiedTime, ) { diff --git a/database/migrations/2024_10_14_104644_show_nsfw_in_smart_albums.php b/database/migrations/2024_10_14_104644_show_nsfw_in_smart_albums.php index 996415ae1ec..d4d7de2b2c7 100644 --- a/database/migrations/2024_10_14_104644_show_nsfw_in_smart_albums.php +++ b/database/migrations/2024_10_14_104644_show_nsfw_in_smart_albums.php @@ -3,8 +3,6 @@ use App\Models\Extensions\BaseConfigMigration; return new class() extends BaseConfigMigration { - public const OAUTH = 'OAuth & SSO'; - public function getConfigs(): array { return [ diff --git a/database/migrations/2024_10_17_064538_extract_zip_on_upload.php b/database/migrations/2024_10_17_064538_extract_zip_on_upload.php new file mode 100644 index 00000000000..0b595355ed7 --- /dev/null +++ b/database/migrations/2024_10_17_064538_extract_zip_on_upload.php @@ -0,0 +1,23 @@ + 'extract_zip_on_upload', + 'value' => '0', + 'cat' => self::PROCESSING, + 'type_range' => self::BOOL, + 'description' => 'Extract uploaded zip file and import content.', + 'details' => 'Zip file will stay on your server unless it is properly extracted without faults (after which it is removed).', + 'is_secret' => false, + 'level' => 1, // Only for SE. + ], + ]; + } +};