From 60b4b523fe33293ba6e4f78e81d99032cc50d289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Witold=20Wi=C5=9Bniewski?= <58150098+daVitekPL@users.noreply.github.com> Date: Mon, 15 Apr 2024 14:56:28 +0200 Subject: [PATCH] S3 (#203) --- composer.json | 4 +- config/hh5p.php | 4 +- src/Commands/StorageH5PCopyStorageCommand.php | 41 ++++ src/Commands/StorageH5PLinkCommand.php | 21 +-- src/HeadlessH5PServiceProvider.php | 10 +- src/Helpers/Helpers.php | 30 ++- src/Http/Controllers/ContentApiController.php | 10 +- src/Repositories/H5PContentRepository.php | 13 +- .../H5PEditorStorageRepository.php | 5 +- src/Repositories/H5PFileStorageRepository.php | 176 ++++++++++++++++-- src/Repositories/H5PRepository.php | 5 +- src/Services/H5PCoreService.php | 134 +++++++++++++ src/Services/H5PExportService.php | 173 +++++++++++++++++ src/Services/HeadlessH5PService.php | 32 ++-- tests/Api/ContentApiTest.php | 9 +- tests/Api/LibraryApiTest.php | 3 +- .../H5PEditorStorageRepositoryTest.php | 6 +- 17 files changed, 598 insertions(+), 78 deletions(-) create mode 100644 src/Commands/StorageH5PCopyStorageCommand.php create mode 100644 src/Services/H5PCoreService.php create mode 100644 src/Services/H5PExportService.php diff --git a/composer.json b/composer.json index e20ee04a..b3c2e718 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "type": "package", "require": { "php": ">=7.4", - "laravel/framework": ">=8.0", + "laravel/framework": ">=9.0", "h5p/h5p-core": "1.24.*|dev-master", "h5p/h5p-editor": "1.24.*|dev-master", "escolalms/core": "^1", @@ -13,7 +13,7 @@ }, "require-dev": { "phpunit/phpunit": "^9.0", - "orchestra/testbench": "^6.0", + "orchestra/testbench": "^7.0", "laravel/legacy-factories": "^1.0.4", "guzzlehttp/guzzle": "^7" }, diff --git a/config/hh5p.php b/config/hh5p.php index 03993214..4ce6885b 100644 --- a/config/hh5p.php +++ b/config/hh5p.php @@ -38,7 +38,7 @@ 'guzzle' => [], - 'h5p_storage_path' => 'app/h5p', - 'h5p_content_storage_path' => 'app/h5p/content/', + 'h5p_storage_path' => 'h5p', + 'h5p_content_storage_path' => 'h5p/content/', 'h5p_library_url' => 'h5p/libraries' ]; diff --git a/src/Commands/StorageH5PCopyStorageCommand.php b/src/Commands/StorageH5PCopyStorageCommand.php new file mode 100644 index 00000000..dc0d8592 --- /dev/null +++ b/src/Commands/StorageH5PCopyStorageCommand.php @@ -0,0 +1,41 @@ + env('AWS_URL')])->copyVendorFiles($target, $link); + Helpers::deleteFileTreeLocal($target); + + $this->info("The files [$target] have been copied to [$link]."); + } +} diff --git a/src/Commands/StorageH5PLinkCommand.php b/src/Commands/StorageH5PLinkCommand.php index 177f793a..44b47961 100644 --- a/src/Commands/StorageH5PLinkCommand.php +++ b/src/Commands/StorageH5PLinkCommand.php @@ -2,7 +2,9 @@ namespace EscolaLms\HeadlessH5P\Commands; +use EscolaLms\HeadlessH5P\Repositories\H5PFileStorageRepository; use Illuminate\Console\Command; +use Illuminate\Support\Facades\Storage; class StorageH5PLinkCommand extends Command { @@ -18,7 +20,7 @@ class StorageH5PLinkCommand extends Command * * @var string */ - protected $description = 'Create the symbolic links for H%p configured for the application'; + protected $description = 'Create the symbolic links for H5P configured for the application'; /** * Execute the console command. @@ -32,20 +34,12 @@ public function handle() $links = $this->links(); foreach ($links as $link => $target) { - if (file_exists($link)) { + if (Storage::fileExists($link)) { $this->error("The [$link] link already exists."); continue; } - if (is_link($link)) { - $this->laravel->make('files')->delete($link); - } - - if ($relative) { - $this->laravel->make('files')->relativeLink($target, $link); - } else { - $this->laravel->make('files')->link($target, $link); - } + app(H5PFileStorageRepository::class, ['path' => env('AWS_URL')])->copyVendorFiles($target, $link); $this->info("The [$link] link has been connected to [$target]."); } @@ -61,9 +55,8 @@ public function handle() protected function links() { return[ - public_path('h5p') => storage_path('app/h5p'), - public_path('h5p-core') => base_path().'/vendor/h5p/h5p-core', - public_path('h5p-editor') => base_path().'/vendor/h5p/h5p-editor', + Storage::path('h5p-core') => base_path().'/vendor/h5p/h5p-core', + Storage::path('h5p-editor') => base_path().'/vendor/h5p/h5p-editor', ]; } } diff --git a/src/HeadlessH5PServiceProvider.php b/src/HeadlessH5PServiceProvider.php index 0de5c742..f1b8bf5a 100644 --- a/src/HeadlessH5PServiceProvider.php +++ b/src/HeadlessH5PServiceProvider.php @@ -3,6 +3,7 @@ namespace EscolaLms\HeadlessH5P; use EscolaLms\HeadlessH5P\Commands\H5PSeedCommand; +use EscolaLms\HeadlessH5P\Commands\StorageH5PCopyStorageCommand; use EscolaLms\HeadlessH5P\Commands\StorageH5PLinkCommand; use EscolaLms\HeadlessH5P\Enums\ConfigEnum; use EscolaLms\HeadlessH5P\Providers\SettingsServiceProvider; @@ -15,12 +16,13 @@ use EscolaLms\HeadlessH5P\Repositories\H5PLibraryLanguageRepository; use EscolaLms\HeadlessH5P\Repositories\H5PRepository; use EscolaLms\HeadlessH5P\Services\Contracts\HeadlessH5PServiceContract; +use EscolaLms\HeadlessH5P\Services\H5PCoreService; use EscolaLms\HeadlessH5P\Services\HeadlessH5PService; use H5PContentValidator; -use H5PCore; use H5peditor; use H5PStorage; use H5PValidator; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\ServiceProvider; /** @@ -35,7 +37,7 @@ class HeadlessH5PServiceProvider extends ServiceProvider public function register(): void { - $this->commands([H5PSeedCommand::class, StorageH5PLinkCommand::class]); + $this->commands([H5PSeedCommand::class, StorageH5PLinkCommand::class, StorageH5PCopyStorageCommand::class]); $this->bindH5P(); $this->app->register(AuthServiceProvider::class); $this->app->register(SettingsServiceProvider::class); @@ -46,8 +48,8 @@ private function bindH5P(): void $this->app->singleton(HeadlessH5PServiceContract::class, function ($app) { $languageRepository = new H5PLibraryLanguageRepository(); $repository = new H5PRepository($languageRepository); - $fileStorage = new H5PFileStorageRepository(storage_path('app/h5p')); - $core = new H5PCore($repository, $fileStorage, url('h5p'), config('hh5p.language'), config('hh5p.h5p_export')); + $fileStorage = new H5PFileStorageRepository(config('filesystems.default') === 's3' ? Storage::path('/') . '/h5p' : storage_path('app/h5p')); + $core = new H5PCoreService($repository, $fileStorage, Storage::url('h5p'), config('hh5p.language'), config('hh5p.h5p_export')); $core->aggregateAssets = true; $validator = new H5PValidator($repository, $core); $storage = new H5PStorage($repository, $core); diff --git a/src/Helpers/Helpers.php b/src/Helpers/Helpers.php index 031bc37b..6de293cd 100644 --- a/src/Helpers/Helpers.php +++ b/src/Helpers/Helpers.php @@ -2,6 +2,8 @@ namespace EscolaLms\HeadlessH5P\Helpers; +use Illuminate\Support\Facades\Storage; + class Helpers { /** @@ -44,16 +46,39 @@ public static function fixCaseKeysArray($keys, $array) * Indicates if the directory existed. */ public static function deleteFileTree($dir) + { + if (!Storage::directoryExists($dir)) { + return false; + } + if (is_link($dir)) { + // Do not traverse and delete linked content, simply unlink. + unlink($dir); + return; + } + + foreach (Storage::directories($dir) as $directory) { + self::deleteFileTree($directory); + } + foreach (Storage::files($dir) as $file) { + Storage::delete($file); + } + + return Storage::deleteDirectory($dir); + } + + public static function deleteFileTreeLocal($dir) { if (!is_dir($dir)) { return false; } + if (is_link($dir)) { // Do not traverse and delete linked content, simply unlink. unlink($dir); return; } - $files = array_diff(scandir($dir), array('.','..')); + + $files = array_diff(scandir($dir), array('.', '..')); foreach ($files as $file) { $filepath = "$dir/$file"; // Note that links may resolve as directories @@ -62,9 +87,10 @@ public static function deleteFileTree($dir) unlink($filepath); } else { // Traverse subdir and delete files - self::deleteFileTree($filepath); + self::deleteFileTreeLocal($filepath); } } + return rmdir($dir); } } diff --git a/src/Http/Controllers/ContentApiController.php b/src/Http/Controllers/ContentApiController.php index a5326205..a8b7a7cb 100644 --- a/src/Http/Controllers/ContentApiController.php +++ b/src/Http/Controllers/ContentApiController.php @@ -19,7 +19,9 @@ use EscolaLms\HeadlessH5P\Services\Contracts\HeadlessH5PServiceContract; use Exception; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\StreamedResponse; class ContentApiController extends EscolaLmsBaseController implements ContentApiSwagger { @@ -125,15 +127,11 @@ public function upload(LibraryStoreRequest $request): JsonResponse return $this->sendResponseForResource(ContentResource::make($content)); } - public function download(AdminContentReadRequest $request, $id): BinaryFileResponse + public function download(AdminContentReadRequest $request, $id): StreamedResponse { $filepath = $this->contentRepository->download($id); - return response() - ->download($filepath, '', [ - 'Content-Type' => 'application/zip', - 'Cache-Control' => 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0', - ]); + return Storage::download($filepath); } public function deleteUnused(): JsonResponse diff --git a/src/Repositories/H5PContentRepository.php b/src/Repositories/H5PContentRepository.php index 7feb8cd5..8b70464a 100644 --- a/src/Repositories/H5PContentRepository.php +++ b/src/Repositories/H5PContentRepository.php @@ -17,6 +17,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -107,7 +108,7 @@ public function edit(int $id, string $library, string $params, string $nonce): i private function moveTmpFilesToContentFolders($nonce, $contentId): bool { - $storage_path = storage_path(config('hh5p.h5p_storage_path')); + $storage_path = Storage::path(config('hh5p.h5p_storage_path')); $files = H5PTempFile::where(['nonce' => $nonce])->get(); @@ -115,11 +116,7 @@ private function moveTmpFilesToContentFolders($nonce, $contentId): bool $old_path = $storage_path . $file->path; if (strpos($file->path, '/editor') !== false) { $new_path = $storage_path . str_replace('/editor', '/content/' . $contentId, $file->path); - $dir_path = dirname($new_path); - if (!is_dir($dir_path)) { - mkdir($dir_path, 0777, true); - } - rename($old_path, $new_path); + Storage::move($old_path, $new_path); } $file->delete(); @@ -178,7 +175,7 @@ public function delete(int $id): int $content = H5PContent::findOrFail($id); $content->delete(); - $storage_path = storage_path(config('hh5p.h5p_content_storage_path') . $id); + $storage_path = config('hh5p.h5p_content_storage_path') . $id; Helpers::deleteFileTree($storage_path); @@ -228,7 +225,7 @@ public function download($id): string $filename = $this->hh5pService->getRepository()->getDownloadFile($id); - return storage_path('app/h5p/exports/' . $filename); + return 'h5p/exports/' . $filename; } public function getLibraryById(int $id): H5PLibrary diff --git a/src/Repositories/H5PEditorStorageRepository.php b/src/Repositories/H5PEditorStorageRepository.php index dff19cfa..3ed70213 100644 --- a/src/Repositories/H5PEditorStorageRepository.php +++ b/src/Repositories/H5PEditorStorageRepository.php @@ -9,6 +9,7 @@ use EscolaLms\HeadlessH5P\Models\H5PContent; use EscolaLms\HeadlessH5P\Models\H5PTempFile; use EscolaLms\HeadlessH5P\Helpers\Helpers; +use Illuminate\Support\Facades\Storage; /** @@ -188,11 +189,11 @@ public static function markFileForCleanup($file, $nonce) */ public static function removeTemporarilySavedFiles($filePath) { - if (is_dir($filePath)) { + if (Storage::directoryExists($filePath)) { Helpers::deleteFileTree($filePath); } else { - unlink($filePath); + Storage::delete($filePath); } } } diff --git a/src/Repositories/H5PFileStorageRepository.php b/src/Repositories/H5PFileStorageRepository.php index a592f035..9e7da288 100644 --- a/src/Repositories/H5PFileStorageRepository.php +++ b/src/Repositories/H5PFileStorageRepository.php @@ -3,8 +3,13 @@ namespace EscolaLms\HeadlessH5P\Repositories; use Exception; +use H5PCore; +use H5peditorFile; use H5PFileStorage; use H5PDefaultStorage; +use Illuminate\Http\File; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class H5PFileStorageRepository extends H5PDefaultStorage implements H5PFileStorage { @@ -22,7 +27,9 @@ public function saveLibrary($library) { $dest = $this->path . '/libraries/' . $this->libraryToFolderName($library); - $this->copyFiles($library['uploadDirectory'], $dest); + Str::startsWith($library['uploadDirectory'], storage_path()) + ? $this->copyVendorFiles($library['uploadDirectory'], $dest) + : $this->copyFiles($library['uploadDirectory'], $dest); } private static function libraryToFolderName($library) { @@ -42,19 +49,40 @@ private function copyFiles($source, $destination) { $ignoredFiles = $this->ignoredFilesProvider("{$source}/.h5pignore"); + if (Storage::directoryExists($source)) { + foreach (Storage::directories($source) as $directory) { + $dir = Str::afterLast($directory, '/'); + $this->copyFiles("{$source}/{$dir}", "{$destination}/{$dir}"); + } + foreach (Storage::files($source) as $file) { + if (($file != '.') && ($file != '..') && $file != '.git' && $file != '.gitignore' && !in_array($file, $ignoredFiles)) { + $folder = Str::after($destination, env('AWS_URL', '/')); + Storage::copy($file, $folder . '/' . Str::afterLast($file, '/')); + } + } + } + } + + public function copyVendorFiles($source, $destination) { + if (!$this->isDirReady($destination)) { + throw new Exception('unabletocopy'); + } + $dir = opendir($source); if ($dir === false) { trigger_error('Unable to open directory ' . $source, E_USER_WARNING); throw new Exception('unabletocopy'); } + $ignoredFiles = $this->ignoredFilesProvider("{$source}/.h5pignore"); + while (($file = readdir($dir)) !== false) { if (($file != '.') && ($file != '..') && $file != '.git' && $file != '.gitignore' && !in_array($file, $ignoredFiles)) { if (is_dir("{$source}/{$file}")) { - $this->copyFiles("{$source}/{$file}", "{$destination}/{$file}"); - } - else { - copy("{$source}/{$file}", "{$destination}/{$file}"); + $this->copyVendorFiles("{$source}/{$file}", "{$destination}/{$file}"); + } else { + $folder = Str::after($destination, env('AWS_URL', '')); + Storage::putFileAs($folder, new File("{$source}/{$file}"), $file); } } } @@ -78,25 +106,147 @@ private function ignoredFilesProvider($file) private function isDirReady($path): bool { - if (!file_exists($path)) { + if (!Storage::exists($path)) { $parent = preg_replace("/\/[^\/]+\/?$/", '', $path); - if (!$this->isDirReady($parent)) { + if ($parent !== $path && $parent !== '/' && $parent !== '' && !$this->isDirReady($parent)) { return false; } - mkdir($path, 0777, true); + $path = Str::after($path, Storage::path('/')); + Storage::makeDirectory($path); } - if (!is_dir($path)) { + if (!Storage::directoryExists($path)) { trigger_error('Path is not a directory ' . $path, E_USER_WARNING); - return false; - } - if (!is_writable($path)) { - trigger_error('Unable to write to ' . $path . ' – check directory permissions –', E_USER_WARNING); return false; } return true; } + + public function cacheAssets(&$files, $key) + { + foreach ($files as $type => $assets) { + if (empty($assets)) { + continue; // Skip no assets + } + + $content = ''; + foreach ($assets as $asset) { + // Get content from asset file + $assetContent = Storage::get(config('hh5p.url') . $asset->path); + + $cssRelPath = ltrim(preg_replace('/[^\/]+$/', '', $asset->path), '/'); + + // Get file content and concatenate + if ($type === 'scripts') { + $content .= $assetContent . ";\n"; + } + else { + // Rewrite relative URLs used inside stylesheets + $content .= preg_replace_callback( + '/url\([\'"]?([^"\')]+)[\'"]?\)/i', + function ($matches) use ($cssRelPath) { + if (preg_match("/^(data:|([a-z0-9]+:)?\/)/i", $matches[1]) === 1) { + return $matches[0]; // Not relative, skip + } + return 'url("../' . $cssRelPath . $matches[1] . '")'; + }, + $assetContent) . "\n"; + } + } + + $this->isDirReady("{$this->path}/cachedassets"); + $ext = ($type === 'scripts' ? 'js' : 'css'); + $outputfile = "/cachedassets/{$key}.{$ext}"; + Storage::put(config('hh5p.url') . $outputfile, $content); + + $files[$type] = array((object) array( + 'path' => $outputfile, + 'version' => '' + )); + } + } + + public function saveFile($file, $contentId): H5peditorFile + { + // Prepare directory + if (empty($contentId)) { + // Should be in editor tmp folder + $path = $this->getEditorPath(); + } + else { + // Should be in content folder + $path = $this->path . '/content/' . $contentId; + } + $path .= '/' . $file->getType() . 's'; + $this->isDirReady($path); + + Storage::putFileAs(Str::after($path, env('AWS_URL')), $_FILES['file']['tmp_name'], $file->getName()); + + return $file; + } + + private function getEditorPath() + { + return ($this->altEditorPath !== NULL ? $this->altEditorPath : "{$this->path}/editor"); + } + + public function saveContent($source, $content): void + { + $dest = "{$this->path}/content/{$content['id']}"; + + // Remove any old content + H5PCore::deleteFileTree($dest); + + $this->copyFiles($source, $dest); + } + + public function getTmpPath() + { + $temp = "{$this->path}/temp"; + $this->isDirReady($temp); + return "{$temp}/" . uniqid('h5p-'); + } + + public function exportContent($id, $target) + { + $source = "{$this->path}/content/{$id}"; + if (file_exists($source)) { + // Copy content folder if it exists + $this->copyFiles($source, $target); + } + else { + // No contnet folder, create emty dir for content.json + $this->isDirReady($target); + } + } + + public function exportLibrary($library, $target, $developmentPath = NULL) + { + $folder = \H5PCore::libraryToString($library, TRUE); + $srcPath = ($developmentPath === NULL ? "/libraries/{$folder}" : $developmentPath); + $this->copyFiles("{$this->path}{$srcPath}", "{$target}/{$folder}"); + } + + public function saveExport($source, $filename) + { + $this->deleteExport($filename); + + if (!$this->isDirReady("{$this->path}/exports")) { + throw new Exception("Unable to create directory for H5P export file."); + } + + if (!Storage::copy('/h5p/temp/' . Str::afterLast($source, '/'), "/h5p/exports/{$filename}")) { + throw new Exception("Unable to save H5P export file."); + } + } + + public function deleteExport($filename) { + $target = "{$this->path}/exports/{$filename}"; + if (Storage::exists($target)) { + Storage::delete($target); + } + } } diff --git a/src/Repositories/H5PRepository.php b/src/Repositories/H5PRepository.php index 39dcf41e..6a65911e 100644 --- a/src/Repositories/H5PRepository.php +++ b/src/Repositories/H5PRepository.php @@ -19,6 +19,7 @@ use DateTime; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Storage; class H5PRepository implements H5PFrameworkInterface @@ -213,7 +214,7 @@ public function getUploadedH5pFolderPath() { static $dir; // such a stupid way to have singleton .... if (is_null($dir)) { - $dir = storage_path('app/h5p/temp/temp/') . uniqid('h5p-'); + $dir = storage_path('app/h5p/temp/') . uniqid('h5p-'); @mkdir(dirname($dir), 0777, true); } @@ -230,7 +231,7 @@ public function getUploadedH5pPath() { static $path; // such a stupid way to have singleton .... if (is_null($path)) { - $path = storage_path('app/h5p/temp/temp/') . uniqid('h5p-') . '.h5p'; + $path = storage_path('app/h5p/temp/') . uniqid('h5p-') . '.h5p'; @mkdir(dirname($path), 0777, true); } diff --git a/src/Services/H5PCoreService.php b/src/Services/H5PCoreService.php new file mode 100644 index 00000000..019da38b --- /dev/null +++ b/src/Services/H5PCoreService.php @@ -0,0 +1,134 @@ +exportEnabled = $export; + } + + public function filterParameters(&$content) + { + if (!empty($content['filtered']) && + (!$this->exportEnabled || + ($content['slug'] && + $this->fs->hasExport($content['slug'] . '-' . $content['id'] . '.h5p')))) { + return $content['filtered']; + } + + if (!(isset($content['library']) && isset($content['params']))) { + return NULL; + } + + // Validate and filter against main library semantics. + $validator = new H5PContentValidator($this->h5pF, $this); + $params = (object) array( + 'library' => H5PCore::libraryToString($content['library']), + 'params' => json_decode($content['params']) + ); + if (!$params->params) { + return NULL; + } + $validator->validateLibrary($params, (object) array('options' => array($params->library))); + + // Handle addons: + $addons = $this->h5pF->loadAddons(); + foreach ($addons as $addon) { + $add_to = json_decode($addon['addTo']); + + if (isset($add_to->content->types)) { + foreach($add_to->content->types as $type) { + + if (isset($type->text->regex) && + $this->textAddonMatches($params->params, $type->text->regex)) { + $validator->addon($addon); + + // An addon shall only be added once + break; + } + } + } + } + + $params = json_encode($params->params); + + // Update content dependencies. + $content['dependencies'] = $validator->getDependencies(); + + // Sometimes the parameters are filtered before content has been created + if ($content['id']) { + $this->h5pF->deleteLibraryUsage($content['id']); + $this->h5pF->saveLibraryUsage($content['id'], $content['dependencies']); + + if (!$content['slug']) { + $content['slug'] = $this->generateContentSlug($content); + + // Remove old export file + $this->fs->deleteExport($content['id'] . '.h5p'); + } + + if ($this->exportEnabled) { + // Recreate export file + $exporter = new H5PExportService($this->h5pF, $this); + $content['filtered'] = $params; + $exporter->createExportFile($content); + } + + // Cache. + $this->h5pF->updateContentFields($content['id'], array( + 'filtered' => $params, + 'slug' => $content['slug'] + )); + } + return $params; + } + + private function textAddonMatches($params, $pattern, $found = false) { + $type = gettype($params); + if ($type === 'string') { + if (preg_match($pattern, $params) === 1) { + return true; + } + } + elseif ($type === 'array' || $type === 'object') { + foreach ($params as $value) { + $found = $this->textAddonMatches($value, $pattern, $found); + if ($found === true) { + return true; + } + } + } + return false; + } + + private function generateContentSlug($content) { + $slug = H5PCore::slugify($content['title']); + + $available = NULL; + while (!$available) { + if ($available === FALSE) { + // If not available, add number suffix. + $matches = array(); + if (preg_match('/(.+-)([0-9]+)$/', $slug, $matches)) { + $slug = $matches[1] . (intval($matches[2]) + 1); + } + else { + $slug .= '-2'; + } + } + $available = $this->h5pF->isContentSlugAvailable($slug); + } + + return $slug; + } +} diff --git a/src/Services/H5PExportService.php b/src/Services/H5PExportService.php new file mode 100644 index 00000000..777d1442 --- /dev/null +++ b/src/Services/H5PExportService.php @@ -0,0 +1,173 @@ +h5pC->fs->getTmpPath(); + Storage::createDirectory($tmpPath); + + try { + // Create content folder and populate with files + $this->h5pC->fs->exportContent($content['id'], "{$tmpPath}/content"); + } + catch (Exception $e) { + $this->h5pF->setErrorMessage($this->h5pF->t($e->getMessage()), 'failed-creating-export-file'); + H5PCore::deleteFileTree($tmpPath); + return FALSE; + } + + // Update content.json with content from database + Storage::put("{$tmpPath}/content/content.json", $content['filtered']); + + // Make embedType into an array + $embedTypes = explode(', ', $content['embedType']); + + // Build h5p.json, the en-/de-coding will ensure proper escaping + $h5pJson = array ( + 'title' => self::revertH5PEditorTextEscape($content['title']), + 'language' => (isset($content['language']) && strlen(trim($content['language'])) !== 0) ? $content['language'] : 'und', + 'mainLibrary' => $content['library']['name'], + 'embedTypes' => $embedTypes + ); + + foreach(array('authors', 'source', 'license', 'licenseVersion', 'licenseExtras' ,'yearFrom', 'yearTo', 'changes', 'authorComments', 'defaultLanguage') as $field) { + if (isset($content['metadata'][$field]) && $content['metadata'][$field] !== '') { + if (($field !== 'authors' && $field !== 'changes') || (count($content['metadata'][$field]) > 0)) { + $h5pJson[$field] = json_decode(json_encode($content['metadata'][$field], TRUE)); + } + } + } + + // Remove all values that are not set + foreach ($h5pJson as $key => $value) { + if (!isset($value)) { + unset($h5pJson[$key]); + } + } + + // Add dependencies to h5p + foreach ($content['dependencies'] as $dependency) { + $library = $dependency['library']; + + try { + $exportFolder = NULL; + + // Determine path of export library + if (isset($this->h5pC) && isset($this->h5pC->h5pD)) { + + // Tries to find library in development folder + $isDevLibrary = $this->h5pC->h5pD->getLibrary( + $library['machineName'], + $library['majorVersion'], + $library['minorVersion'] + ); + + if ($isDevLibrary !== NULL && isset($library['path'])) { + $exportFolder = "/" . $library['path']; + } + } + + // Export required libraries + $this->h5pC->fs->exportLibrary($library, $tmpPath, $exportFolder); + } + catch (Exception $e) { + $this->h5pF->setErrorMessage($this->h5pF->t($e->getMessage()), 'failed-creating-export-file'); + H5PCore::deleteFileTree($tmpPath); + return FALSE; + } + + // Do not add editor dependencies to h5p json. + if ($dependency['type'] === 'editor') { + continue; + } + + // Add to h5p.json dependencies + $h5pJson[$dependency['type'] . 'Dependencies'][] = array( + 'machineName' => $library['machineName'], + 'majorVersion' => $library['majorVersion'], + 'minorVersion' => $library['minorVersion'] + ); + } + + // Save h5p.json + $results = print_r(json_encode($h5pJson), true); + Storage::put("{$tmpPath}/h5p.json", $results); + + // Get a complete file list from our tmp dir + $files = array(); + self::populateFileList($tmpPath, $files); + + // Get path to temporary export target file + $tmpFile = $this->h5pC->fs->getTmpPath(); + $zipPath = storage_path('app' . $tmpFile); + + // Create new zip instance. + $zip = new ZipArchive(); + $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); + + // Add all the files from the tmp dir. + foreach ($files as $file) { + // Please note that the zip format has no concept of folders, we must + // use forward slashes to separate our directories. + if (Storage::exists($file->absolutePath)) { + $zip->addFromString($file->relativePath, Storage::get($file->absolutePath)); + } + } + + // Close zip and remove tmp dir + $zip->close(); + Storage::putFileAs('h5p/temp', new File($zipPath), Str::afterLast($tmpFile, '/')); + unlink($zipPath); + H5PCore::deleteFileTree($tmpPath); + + $filename = $content['slug'] . '-' . $content['id'] . '.h5p'; + try { + // Save export + $this->h5pC->fs->saveExport($tmpFile, $filename); + } + catch (Exception $e) { + $this->h5pF->setErrorMessage($this->h5pF->t($e->getMessage()), 'failed-creating-export-file'); + return false; + } + + Storage::delete($tmpFile); + Storage::deleteDirectory($tmpPath); + $this->h5pF->afterExportCreated($content, $filename); + + return true; + } + + private static function populateFileList($dir, &$files, $relative = '') { + $strip = strlen($dir); + $contents = Storage::allFiles($dir); + if (!empty($contents)) { + foreach ($contents as $file) { + $rel = $relative . substr($file, $strip); + if (is_dir($file)) { + self::populateFileList($file, $files, $rel . '/'); + } + else { + $files[] = (object) array( + 'absolutePath' => $file, + 'relativePath' => $rel + ); + } + } + } + } + + private static function revertH5PEditorTextEscape($value) { + return str_replace('<', '<', str_replace('>', '>', str_replace(''', "'", str_replace('"', '"', $value)))); + } +} diff --git a/src/Services/HeadlessH5PService.php b/src/Services/HeadlessH5PService.php index 91b0aee4..118151f5 100644 --- a/src/Services/HeadlessH5PService.php +++ b/src/Services/HeadlessH5PService.php @@ -15,7 +15,6 @@ use H5peditorFile; use H5peditorStorage; use H5PFileStorage; -use H5PMetadata; use H5PStorage; use H5PValidator; use H5PPermission; @@ -23,6 +22,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\Auth; use JsonSerializable; @@ -152,15 +152,15 @@ public function getConfig(): array { if (!isset($this->config)) { $config = (array)config('hh5p'); - $config['url'] = asset($config['url']); + $config['url'] = Storage::url($config['url']); $config['ajaxPath'] = route($config['ajaxPath']) . '/'; - $config['libraryUrl'] = url($config['libraryUrl']) . '/'; - $config['get_laravelh5p_url'] = url($config['get_laravelh5p_url']); - $config['get_h5peditor_url'] = url($config['get_h5peditor_url']) . '/'; - $config['get_h5pcore_url'] = url($config['get_h5pcore_url']); + $config['libraryUrl'] = Storage::url($config['libraryUrl']); + $config['get_laravelh5p_url'] = Storage::url($config['get_laravelh5p_url']); + $config['get_h5peditor_url'] = Storage::url($config['get_h5peditor_url']) . '/'; + $config['get_h5pcore_url'] = Storage::url($config['get_h5pcore_url']); $config['getCopyrightSemantics'] = $this->getContentValidator()->getCopyrightSemantics(); $config['getMetadataSemantics'] = $this->getContentValidator()->getMetadataSemantics(); - $config['filesPath'] = url('h5p/editor'); // TODO: diffrernt name + $config['filesPath'] = Storage::url('h5p/editor'); // TODO: diffrernt name $this->config = $config; } @@ -173,7 +173,7 @@ public function getConfig(): array public function getLibraries(string $machineName = null, string $major_version = null, string $minor_version = null) { $lang = config('hh5p.language'); - $libraries_url = url(config('hh5p.h5p_library_url')); + $libraries_url = Storage::url(config('hh5p.h5p_library_url')); if ($machineName) { $defaultLang = $this->getEditor()->getLibraryLanguage($machineName, $major_version, $minor_version, $lang); @@ -189,9 +189,9 @@ public function getLibraries(string $machineName = null, string $major_version = $library ->append('contentsCount') ->append('requiredLibrariesCount'); - } + } - return $libraries; + return $libraries; } private function addMoreHtmlTags($semantics) { @@ -258,7 +258,7 @@ public function getEditorSettings($content = null): array $settings['core']['scripts'][] = $config['get_h5peditor_url'] . '/language/'. $lang .'.js'; $settings['editor'] = [ - 'filesPath' => isset($content) ? url("h5p/content/$content") : url('h5p/editor'), + 'filesPath' => isset($content) ? Storage::url("h5p/content/$content") : Storage::url('h5p/editor'), 'fileIcon' => [ 'path' => $config['fileIcon'], 'width' => 50, @@ -295,13 +295,13 @@ public function getEditorSettings($content = null): array // add editor styles foreach (H5peditor::$styles as $style) { - $settings['editor']['assets']['css'][] = $config['get_h5peditor_url'] . ('/' . $style); + $settings['editor']['assets']['css'][] = $config['get_h5peditor_url'] . $style; } // Add editor JavaScript foreach (H5peditor::$scripts as $script) { // We do not want the creator of the iframe inside the iframe if ($script !== 'scripts/h5peditor-editor.js') { - $settings['editor']['assets']['js'][] = $config['get_h5peditor_url'] . ('/' . $script); + $settings['editor']['assets']['js'][] = $config['get_h5peditor_url'] . '/' . $script; } } @@ -436,7 +436,7 @@ public function getContentSettings($id, ?string $token = null): array [$h5pEditorDir, $h5pCoreDir] = $this->getH5pEditorDir(); $language_script = $this->getEditorLangScript($lang, $h5pEditorDir); - $settings['editor']['assets']['js'][] = $config['get_h5peditor_url'] . ($language_script); + $settings['editor']['assets']['js'][] = $config['get_h5peditor_url'] . trim($language_script, '/'); $settings['core']['scripts'] = $this->margeFileList( $settings['core']['scripts'], 'js', @@ -832,10 +832,10 @@ private function getEditorLangScript(string $lang, string $h5pEditorDir): string private function getH5pEditorDir(): array { $h5pEditorDir = file_exists(__DIR__ . '/../../vendor/h5p/h5p-editor') ? __DIR__ . '/../../vendor/h5p/h5p-editor' - : __DIR__ . '/../../../../../vendor/h5p/h5p-editor'; + : __DIR__ . '/../../../../vendor/h5p/h5p-editor'; $h5pCoreDir = file_exists(__DIR__ . '/../../vendor/h5p/h5p-core') ? __DIR__ . '/../../vendor/h5p/h5p-core' - : __DIR__ . '/../../../../../vendor/h5p/h5p-core'; + : __DIR__ . '/../../../../vendor/h5p/h5p-core'; return [$h5pEditorDir, $h5pCoreDir]; } diff --git a/tests/Api/ContentApiTest.php b/tests/Api/ContentApiTest.php index 3a0c8794..14f347fe 100644 --- a/tests/Api/ContentApiTest.php +++ b/tests/Api/ContentApiTest.php @@ -10,7 +10,7 @@ use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Testing\TestResponse; @@ -762,6 +762,9 @@ public function testShouldRemoveUnusedH5PWithFiles() 'value' => $h5pFirstId, ]); + $this->assertTrue(Storage::directoryExists('/h5p/content/' . $h5pFirstId)); + $this->assertTrue(Storage::directoryExists('/h5p/content/' . $h5pSecondId)); + $response = $this->delete('/api/admin/hh5p/unused'); $response->assertOk(); @@ -773,8 +776,8 @@ public function testShouldRemoveUnusedH5PWithFiles() 'id' => $h5pSecondId ]); - $this->assertTrue(File::exists(storage_path('app/h5p/content/' . $h5pFirstId))); - $this->assertFalse(File::exists(storage_path('app/h5p/content/' . $h5pSecondId))); + $this->assertTrue(Storage::directoryExists('/h5p/content/' . $h5pFirstId)); + $this->assertFalse(Storage::directoryExists('/h5p/content/' . $h5pSecondId)); } public function testShouldCreateUuidWhenIsEmptyAndWhenFetchContent(): void diff --git a/tests/Api/LibraryApiTest.php b/tests/Api/LibraryApiTest.php index 0408c701..4e1ffd89 100644 --- a/tests/Api/LibraryApiTest.php +++ b/tests/Api/LibraryApiTest.php @@ -246,11 +246,12 @@ public function test_reinstall_library_dependencies() $h5pFile = $this->getH5PFile(); $this->uploadH5PLibrary($h5pFile); + $library = H5PLibrary::latest()->first(); - $library = H5PLibrary::first(); $libraryDependencies = H5PLibraryDependency::where('library_id', $library->getKey()); $libraryDependenciesCount = $libraryDependencies->count(); + $libraryDependencies->first()->delete(); $this->assertNotEquals($libraryDependenciesCount, $libraryDependencies->count()); diff --git a/tests/Repositories/H5PEditorStorageRepositoryTest.php b/tests/Repositories/H5PEditorStorageRepositoryTest.php index 0579179e..26eada6e 100644 --- a/tests/Repositories/H5PEditorStorageRepositoryTest.php +++ b/tests/Repositories/H5PEditorStorageRepositoryTest.php @@ -177,7 +177,7 @@ public function testRemoveTemporarySavedFiles(): void Storage::assertExists('/contents/123/audio.mp3'); - $this->repository::removeTemporarilySavedFiles(Storage::path('contents/123/audio.mp3')); + $this->repository::removeTemporarilySavedFiles('contents/123/audio.mp3'); Storage::assertMissing('/contents/123/audio.mp3'); } @@ -193,7 +193,7 @@ public function testShouldCleanMultipleFiles(): void Storage::assertExists('/contents/123/audio2.mp3'); Storage::assertExists('/contents/123/audio3.mp3'); - $this->repository::removeTemporarilySavedFiles(Storage::path('contents/123')); + $this->repository::removeTemporarilySavedFiles('contents/123'); Storage::assertMissing('/contents/123/audio1.mp3'); Storage::assertMissing('/contents/123/audio2.mp3'); @@ -211,7 +211,7 @@ public function testShouldCleanFileTree(): void Storage::assertExists('/contents/123/video/mp4/video.mp4'); Storage::assertExists('/contents/123/text.txt'); - $this->repository::removeTemporarilySavedFiles(Storage::path('contents/123')); + $this->repository::removeTemporarilySavedFiles('contents/123'); Storage::assertMissing('/contents/123/audio/audio.mp3'); Storage::assertMissing('/contents/123/video/mp4/video.mp4');