Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions sourcecode/apis/contentauthor/app/H5PContent.php
Original file line number Diff line number Diff line change
Expand Up @@ -357,4 +357,9 @@ protected function getIconUrl(): string
{
return $this->library()->firstOrFail()->getIconUrl();
}

public function getExportFilename(): string
{
return sprintf("%s-%d.h5p", $this->slug, $this->id);
}
}
159 changes: 159 additions & 0 deletions sourcecode/apis/contentauthor/app/Http/Controllers/H5PController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@

namespace App\Http\Controllers;

use App\AuditLog;
use App\CollaboratorContext;
use App\ContentLanguageLink;
use App\ContentVersion;
use App\Events\H5PWasSaved;
use App\H5PCollaborator;
use App\H5PContent;
use App\H5PFile;
use App\H5PLibrary;
use App\H5PResult;
use App\Http\Libraries\License;
use App\Http\Requests\H5PStorageRequest;
use App\Jobs\H5PFilesUpload;
Expand Down Expand Up @@ -45,12 +49,15 @@
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Session;
use Illuminate\View\View;
use Iso639p3;
use MatthiasMullie\Minify\CSS;
use Ramsey\Uuid\Uuid;
use stdClass;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Throwable;

use function app;
use function config;
Expand Down Expand Up @@ -669,4 +676,156 @@ public function getInfo(H5PContent $h5p, H5PInfo $h5pInfo, CacheRepository $cach

return response()->json($information);
}

public function destroy(Request $request)
{
$runId = Uuid::uuid4()->toString(); // A unique id to use in the audit log
$userId = $request->json('user.id');
$userName = $request->json('user.name');
$urls = $request->json('resources');

AuditLog::log(
'Permanent delete',
json_encode([
'runId' => $runId,
'receivedUrls' => $urls,
'status' => 'Request received',
]),
$userId,
$userName
);

$resources = []; // The LTI launch URLs that are connected to the content that will be deleted in Hub
$shared = []; // The LTI launch URLs that are used by other content in the Hub

// Extract the resource id from the LTI launch urls
array_walk($urls['delete'], function ($resource) use (&$resources) {
$route = Route::getRoutes()->match(request()->create($resource, 'POST'));
if ($route && $route->getName() === 'h5p.ltishow') {
$resources[$route->parameter('id')] = $resource;
}
});
array_walk($urls['shared'], function ($resource) use (&$shared) {
$route = Route::getRoutes()->match(request()->create($resource, 'POST'));
if ($route && $route->getName() === 'h5p.ltishow') {
$shared[$route->parameter('id')] = $resource;
}
});
// We want to start with the newest/latest content
krsort($resources, SORT_NUMERIC);

/** @var \Illuminate\Database\Eloquent\Collection<H5PContent> $resourcesContent */
$resourcesContent = H5PContent::find(array_keys($resources));

$deleted = [];
$kept = [];
try {
// Delete resource data from the top-down. If a version has child nodes they will be moved to the parent version.
DB::transaction(function () use ($resourcesContent, $resources, $shared, &$deleted, &$kept) {
$storage = app(H5PCerpusStorage::class);

foreach ($resources as $contentId => $resource) {
Log::info(sprintf('Destroy: Processing content %s', $contentId));
/** @var ?H5PContent $content */
$content = $resourcesContent->find($contentId);
if ($content !== null) {
$versions = ContentVersion::where('content_id', $contentId)->where('content_type', 'h5p')->get();
$isShared = array_key_exists($contentId, $shared);
if (!$isShared && $versions->count() > 1) {
// This content have more than one version, but it was not flagged as shared by Hub.
// It's either an update using the same content id, a new branch, or both. We cannot
// trust our own versioning because Hub does not inform when e.g. creating a copy. If
// we trust Hub versioning, we can delete it. But, then we have to check all child
// content and possibly re-connect, rewriting history for other content.
// We flag content as shared and leave all versions. This will either create content and
// versions that have no connection to anything, or ensure that other content have
// correct history.
Log::info(sprintf('Destroy: Flagging content %s as shared, more than one version found', $contentId), [$versions->pluck('content_id', 'id')->toArray()]);
$isShared = true;
}

if ($isShared) {
Log::info(sprintf('Destroy: Skipping shared content %s', $contentId));
$kept[] = $resource;
} else {
$version = $versions->first();
$children = $version->nextVersions;
if ($children->count() > 0) {
$parent = $version->previousVersion;
foreach ($children as $child) {
Log::info(sprintf('Destroy: Moving child version %s (%s) to parent %s (%s)', $child->id, $child->content_id, $version->parent_id, $parent?->content_id));
$child->parent_id = $version->parent_id;
$child->saveQuietly(); // Don't trigger events, it will connect child to the latest version
}
}
Log::info(sprintf('Destroy: Deleting content %s, version %s, and related data', $contentId, $version->id ?? 'null'));
$content->contentVideos()->delete();
$content->contentUserData()->delete();
$content->metadata()->delete();
$content->contentLibraries()->delete();
$content->language()->delete();

H5PResult::where('content_id', $contentId)->delete();
ContentLanguageLink::where('main_content_id', $content->id)->orWhere('link_content_id', $content->id)->delete();
CollaboratorContext::where('content_id', $content->id)->delete();
DB::table('cerpus_contents_shares')->where('h5p_id', $content->id)->delete();

// The bulk exclution list
if (method_exists($content, 'exclutions')) {
$content->exclutions()->delete();
}

// File operation cannot be rolled back, but it's just a cache
if ($storage->hasExport($content->getExportFilename())) {
$storage->deleteExport($content->getExportFilename());
}

$version->delete();
$content->delete();
$deleted[] = $resource;
}
} else {
Log::info(sprintf('Destroy: Content %s was not found', $contentId));
}
}
});
$success = true;
Log::info('Destroy: Done');
AuditLog::log(
'Permanent delete',
json_encode([
'runId' => $runId,
'success' => true,
'deleted' => $deleted,
'kept' => $kept,
]),
$userId,
$userName
);
} catch (Throwable $e) {
$success = false;
Log::error(sprintf('Destroy: %s exception (%s) %s', get_class($e), $e->getCode(), $e->getMessage()));
AuditLog::log(
'Permanent delete',
json_encode([
'runId' => $runId,
'success' => false,
'error' => [
'type' => get_class($e),
'code' => $e->getCode(),
'message' => $e->getMessage(),
],
]),
$userId,
$userName
);
}

return response()->json([
'resources' => array_values($resources),
'deleted' => $deleted,
'kept' => $kept,
'success' => $success,
]);
}
}
2 changes: 2 additions & 0 deletions sourcecode/apis/contentauthor/app/Http/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Http;

use App\Http\Middleware\AuthPsk;
use App\Http\Middleware\OAuth1BodySigned;
use App\Http\Middleware\RequestId;
use App\Http\Middleware\AdapterMode;
use App\Http\Middleware\APIAuth;
Expand Down Expand Up @@ -69,6 +70,7 @@ class Kernel extends HttpKernel
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'auth.oauth_body' => OAuth1BodySigned::class,

// App middleware
'core.return' => \App\Http\Middleware\StoreLtiRequestInSession::class,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace App\Http\Middleware;

use App\Libraries\OAuth1\BodyHashRequest;
use App\Libraries\OAuth1\BodyHashValidator;
use Cerpus\EdlibResourceKit\Oauth1\CredentialStoreInterface;
use Cerpus\EdlibResourceKit\Oauth1\Exception\ValidationException;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

// Verify that message is signed using OAuth body signing
readonly class OAuth1BodySigned
{
public function __construct(
private BodyHashValidator $validator,
private CredentialStoreInterface $credentialStore,
) {}

public function handle(Request $request, Closure $next)
{
try {
$oauthRequest = new BodyHashRequest(
$request->method(),
$request->url(),
$request->getContent(),
$request->headers,
);

$this->validator->validate(
$oauthRequest,
$this->credentialStore
);
} catch (ValidationException $e) {
Log::info(__METHOD__ . ': Failed to validate OAuth1 message', [$e->getMessage()]);
throw new BadRequestHttpException($e->getMessage());
}

return $next($request);
}
}
Loading
Loading