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
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,12 @@
use App\H5PLibrary;
use App\Http\Controllers\Controller;
use App\Libraries\H5P\h5p;
use App\Lti\LtiRequest;
use App\Libraries\Hub\HubClient;
use Cerpus\EdlibResourceKit\Lti\Edlib\DeepLinking\EdlibLtiLinkItem;
use Cerpus\EdlibResourceKit\Lti\Lti11\Serializer\DeepLinking\ContentItemsSerializerInterface;
use Cerpus\EdlibResourceKit\Lti\Message\DeepLinking\Image;
use Cerpus\EdlibResourceKit\Lti\Message\DeepLinking\LineItem;
use Cerpus\EdlibResourceKit\Lti\Message\DeepLinking\ScoreConstraints;
use Cerpus\EdlibResourceKit\Oauth1\Credentials;
use Cerpus\EdlibResourceKit\Oauth1\Request as Oauth1Request;
use Cerpus\EdlibResourceKit\Oauth1\SignerInterface;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use H5PContentValidator;
use H5PCore;
Expand All @@ -40,7 +36,7 @@ public function __construct(
private readonly h5p $h5p,
private readonly H5PCore $h5pCore,
private readonly H5PFrameworkInterface $framework,
private readonly SignerInterface $signer,
private readonly HubClient $hubClient,
private readonly ContentItemsSerializerInterface $serializer,
)
{
Expand All @@ -66,15 +62,12 @@ public function index(Request $request): View
if ($request->method() === 'POST' && $request->has('content')) {
$migrated = $this->migrate($fromLibrary, $toLibrary, $request->input('content'));
}

$leafContentIds = $this->getLeafContentIds();

$itemsQuery = H5PContent::select(['h5p_contents.id', 'h5p_contents.title'])
->leftJoin('content_versions', 'content_versions.id', '=', 'h5p_contents.version_id')
->leftJoin('content_versions as cv', 'cv.parent_id', '=', 'content_versions.id')
->where('h5p_contents.library_id', $fromLibrary->id)
->where(function ($query) {
$query
->whereNull('cv.id')
->orWhereNotIn('cv.version_purpose', [ContentVersion::PURPOSE_UPGRADE, ContentVersion::PURPOSE_UPDATE]);
})
->whereIn('h5p_contents.id', $leafContentIds)
->orderBy('h5p_contents.id');

$count = $itemsQuery->count();
Expand Down Expand Up @@ -170,12 +163,51 @@ private function alterParameters(string $parameters): string

private function checkContent(H5PContent $content): void
{
$version = $content->getVersion();
if (!$version->isLeaf()) {
$launchUrl = route('h5p.ltishow', $content->id);
$leafUrls = $this->getLeafLaunchUrls();

if (!in_array($launchUrl, $leafUrls, true)) {
throw new RuntimeException('Content is not latest version');
}
}

/**
* @return int[] Content IDs that are leaf versions in Hub
*/
private function getLeafContentIds(): array
{
$leafUrls = $this->getLeafLaunchUrls();
$routePrefix = route('h5p.ltishow', '') . '/';

return array_values(array_filter(array_map(
function (string $url) use ($routePrefix) {
if (str_starts_with($url, $routePrefix)) {
$id = substr($url, strlen($routePrefix));
return is_numeric($id) ? (int) $id : null;
}
return null;
},
$leafUrls,
)));
}

/**
* @return string[] Leaf version launch URLs from Hub
* @throws GuzzleException|JsonException|RuntimeException
*/
private function getLeafLaunchUrls(): array
{
$decoded = $this->hubClient->post(
'/content-versions/leaves',
['tag' => 'h5p:h5p.ndlathreeimage'],
);

return array_map(
fn (array $item) => $item['lti_launch_url'],
$decoded['data'] ?? [],
);
}

/**
* @throws JsonException
*/
Expand Down Expand Up @@ -229,29 +261,13 @@ private function save(H5pContent $sourceH5p, string $params, H5PLibrary $fromLib
*
* @throws GuzzleException|JsonException|RuntimeException
*/
private function getHubInfo(H5PContent $content)
private function getHubInfo(H5PContent $content): array
{
/** @var LtiRequest $ltiRequest */
$ltiRequest = Session::get('lti_requests.admin');
$requestUrl = $ltiRequest->param('ext_edlib3_content_info_endpoint');

$infoRequest = new Oauth1Request('POST', $requestUrl, [
'lti_launch_url' => route('h5p.ltishow', $content->id),
]);

$infoRequest = $this->signer->sign(
$infoRequest,
new Credentials(config('app.consumer-key'), config('app.consumer-secret')),
$decoded = $this->hubClient->post(
'/content/info',
['lti_launch_url' => route('h5p.ltishow', $content->id)],
);

$client = app(Client::class);
$response = $client->post($requestUrl, [
'form_params' => $infoRequest->toArray(),
])
->getBody()
->getContents();

$decoded = json_decode($response, associative: true, flags: JSON_THROW_ON_ERROR);
if (empty($decoded)) {
throw new RuntimeException('No content info received');
}
Expand Down Expand Up @@ -283,24 +299,12 @@ private function createHubVersion(string $returnUrl, H5PContent $content): void
->withContentType($data->machineName)
->withContentTypeName($data->machineDisplayName);

$returnRequest = new Oauth1Request('POST', $returnUrl, [
$this->hubClient->post($returnUrl, [
'content_items' => json_encode($this->serializer->serialize([$item]), flags: JSON_THROW_ON_ERROR),
'lti_message_type' => 'ContentItemSelection',
'lti_version' => 'LTI-1p0',
'user_id' => Session::get('lti_requests.admin')->param('user_id'),
]);

$returnRequest = $this->signer->sign(
$returnRequest,
new Credentials(config('app.consumer-key'), config('app.consumer-secret')),
);

$client = app(Client::class);
$client->post($returnUrl, [
'form_params' => $returnRequest->toArray(),
])
->getBody()
->getContents();
}

/**
Expand Down
61 changes: 61 additions & 0 deletions sourcecode/apis/contentauthor/app/Libraries/Hub/HubClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace App\Libraries\Hub;

use App\Lti\LtiRequest;
use Cerpus\EdlibResourceKit\Oauth1\Credentials;
use Cerpus\EdlibResourceKit\Oauth1\Request as Oauth1Request;
use Cerpus\EdlibResourceKit\Oauth1\SignerInterface;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Session;

class HubClient
{
private readonly Credentials $credentials;

public function __construct(
private readonly SignerInterface $signer,
private readonly Client $client,
) {
$this->credentials = new Credentials(
config('app.consumer-key'),
config('app.consumer-secret'),
);
}

/**
* Make an OAuth1-signed POST request to Hub.
*
* Relative paths (e.g. '/content/info') are resolved against the base
* URL from the LTI session. Absolute URLs are used as-is.
*
* @throws \GuzzleHttp\Exception\GuzzleException|\JsonException
*/
public function post(string $url, array $params = []): array
{
if (!str_starts_with($url, 'http')) {
$url = $this->baseUrl() . $url;
}

$oauthRequest = new Oauth1Request('POST', $url, $params);
$oauthRequest = $this->signer->sign($oauthRequest, $this->credentials);

$response = $this->client->post($url, [
'form_params' => $oauthRequest->toArray(),
])
->getBody()
->getContents();

return json_decode($response, associative: true, flags: JSON_THROW_ON_ERROR);
}

public function baseUrl(): string
{
/** @var LtiRequest $ltiRequest */
$ltiRequest = Session::get('lti_requests.admin');

return $ltiRequest->param('ext_edlib3_author_endpoint');
}
}
2 changes: 1 addition & 1 deletion sourcecode/apis/contentauthor/config/h5p.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
'showDisplayOptions' => env("H5P_SHOW_DISPLAY_OPTIONS", false),

// one of 'null', 'nynorskroboten', 'nynorobot'
'translator' => env('H5P_TRANSLATOR', env('H5P_NYNORSK_ADAPTER', 'null')),
'translator' => env('H5P_TRANSLATOR', 'null'),
'ckeditor' => [
'textPartLanguages' => env("H5P_CKEDITOR_TEXT_PART_LANGUAGES", 'en,nb,nn'),
],
Expand Down
1 change: 1 addition & 0 deletions sourcecode/apis/contentauthor/phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,6 @@
<server name="H5P_IMAGE_ADAPTER" value="null"/>
<server name="H5P_AUDIO_ADAPTER" value="null"/>
<server name="H5P_DEVELOPMENT_MODE" value="false"/>
<server name="H5P_ADAPTER" value="null"/>
</php>
</phpunit>
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,13 @@ public function testRendersArticleWithBrokenHtml(): void
'content' => '<div>Foo<b></div>bar</b>',
]);

// libxml works in mysterious ways.
// We don't really care that the output looks like this, but it's nice
// libxml works in mysterious ways, and the output differs between
// versions. We don't really care what it looks like, but it's nice
// to know if it suddenly changes after an update or such anyway.
$this->assertSame(
"<div>Foo<b></b></div><p>bar</p>\n",
$article->render(),
);
$this->assertContains($article->render(), [
"<div>Foo<b></b></div><p>bar</p>\n", // older libxml2
"<div>Foo<b></b><p>bar</p></div>\n", // libxml2 2.13+ (PHP 8.4)
]);
}

public function testCreateArticle()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,13 @@ private function createUploadedFiles($files)
if (!is_dir($fileDir)) {
mkdir($fileDir, 0777, true);
}
$pos = strpos($filePath, realpath($this->getTempDirectory()), 0);
$realFilePath = realpath($fileDir) . '/' . basename($filePath);
$pos = strpos($realFilePath, realpath($this->getTempDirectory()), 0);
if ($pos !== 0) {
throw new Exception("Target '$filePath' is not in the tmp space");
throw new Exception("Target '$realFilePath' is not in the tmp space");
}
if (file_put_contents($filePath, $fileContent) === false) {
throw new Exception("Could not write to file '$filePath'");
if (file_put_contents($realFilePath, $fileContent) === false) {
throw new Exception("Could not write to file '$realFilePath'");
}
}
}
Expand Down
32 changes: 32 additions & 0 deletions sourcecode/hub/app/Http/Controllers/ContentAuthorController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,49 @@
use App\Models\Content;
use App\Models\ContentVersion;
use App\Models\LtiTool;
use App\Models\Tag;
use App\Models\User;
use Cerpus\EdlibResourceKit\Lti\Edlib\DeepLinking\EdlibLtiLinkItem;
use Cerpus\EdlibResourceKit\Lti\Lti11\Mapper\DeepLinking\ContentItemsMapperInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class ContentAuthorController extends Controller
{
public function leaves(Request $request, LtiTool $tool): JsonResponse
{
$query = ContentVersion::query()
->where('lti_tool_id', $tool->id)
->whereNotExists(function ($sub) {
$sub->select(DB::raw(1))
->from('content_versions as cv2')
->whereColumn('cv2.previous_version_id', 'content_versions.id');
});

if ($request->has('tag')) {
['prefix' => $prefix, 'name' => $name] = Tag::parse($request->input('tag'));

$query->whereExists(function ($sub) use ($prefix, $name) {
$sub->select(DB::raw(1))
->from('content_version_tag')
->join('tags', 'tags.id', '=', 'content_version_tag.tag_id')
->whereColumn('content_version_tag.content_version_id', 'content_versions.id')
->where('tags.prefix', strtolower($prefix))
->where('tags.name', strtolower($name));
});
}

$launchUrls = $query->pluck('lti_launch_url');

return response()->json([
'data' => $launchUrls->map(fn (string $url) => ['lti_launch_url' => $url])->values(),
]);
}

public function info(ContentInfoRequest $request, LtiTool $tool): JsonResponse
{
$versions = $tool->contentVersions()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public function handle(LaunchItemSelection $event): void
$event->setLaunch(
$event
->getLaunch()
->withClaim('ext_edlib3_content_info_endpoint', route('author.content.info', [$tool->id])),
->withClaim('ext_edlib3_author_endpoint', url('/author/tool/' . $tool->id)),
);
}
}
4 changes: 4 additions & 0 deletions sourcecode/hub/routes/stateless.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
->uses([ContentAuthorController::class, 'info'])
->name('info');

Route::post('/tool/{tool}/content-versions/leaves')
->uses([ContentAuthorController::class, 'leaves'])
->name('leaves');

Route::post('/tool/{tool}/content/{content}/version/{version}/update')
->uses([ContentAuthorController::class, 'update'])
->whereUlid(['tool', 'content', 'version'])
Expand Down
Loading
Loading