diff --git a/src/Content/FileItem.php b/src/Content/FileItem.php index 95a99f9..b3deae5 100644 --- a/src/Content/FileItem.php +++ b/src/Content/FileItem.php @@ -3,6 +3,8 @@ namespace ceLTIc\LTI\Content; +use ceLTIc\LTI\Util; + /** * Class to represent a file content-item object * @@ -100,19 +102,27 @@ public function toJsonObject(): object * * @param object $item A JSON object representing a file content-item * - * @return void + * @return bool True if the item is valid */ - protected function fromJsonObject(object $item): void + protected function fromJsonObject(object $item): bool { - parent::fromJsonObject($item); - foreach (get_object_vars($item) as $name => $value) { - switch ($name) { - case 'copyAdvice': - case 'expiresAt': - $this->{$name} = $item->{$name}; - break; + $ok = parent::fromJsonObject($item); + if ($ok) { + foreach (get_object_vars($item) as $name => $value) { + switch ($name) { + case 'copyAdvice': + $this->copyAdvice = Util::checkBoolean($item, 'FileItem/copyAdvice'); + $ok = $ok && !is_null($this->copyAdvice); + break; + case 'expiresAt': + $this->expiresAt = Util::checkBoolean($item, 'FileItem/expiresAt'); + $ok = $ok && !is_null($this->expiresAt); + break; + } } } + + return $ok; } } diff --git a/src/Content/Image.php b/src/Content/Image.php index ead4441..145448f 100644 --- a/src/Content/Image.php +++ b/src/Content/Image.php @@ -3,6 +3,8 @@ namespace ceLTIc\LTI\Content; +use ceLTIc\LTI\Util; + /** * Class to represent a content-item image object * @@ -89,12 +91,13 @@ public function toJsonObject(): object /** * Generate an Image object from its JSON or JSON-LD representation. * - * @param object $item A JSON or JSON-LD object representing a content-item + * @param object|string $item A JSON or JSON-LD object representing an image or an image URL * * @return Image|null The Image object */ - public static function fromJsonObject(object $item): ?Image + public static function fromJsonObject(object|string $item): ?Image { + $ok = true; $obj = null; $width = null; $height = null; @@ -104,20 +107,26 @@ public static function fromJsonObject(object $item): ?Image switch ($name) { case '@id': case 'url': - $url = $item->{$name}; + $url = Util::checkString($item, "Image/{$name}", false, false, '', false, null); break; case 'width': - $width = $item->width; + $width = Util::checkInteger($item, "Image/width", false, 0, true); + if (is_null($width)) { + $ok = false; + } break; case 'height': - $height = $item->height; + $height = Util::checkInteger($item, "Image/height", false, 0, true); + if (is_null($height)) { + $ok = false; + } break; } } } else { $url = $item; } - if ($url) { + if ($ok && $url) { $obj = new Image($url, $width, $height); } diff --git a/src/Content/Item.php b/src/Content/Item.php index 9255f8d..13eaf19 100644 --- a/src/Content/Item.php +++ b/src/Content/Item.php @@ -142,7 +142,7 @@ class Item function __construct(string $type, array|Placement|null $placementAdvices = null, ?string $id = null) { $this->type = $type; - if (!empty($placementAdvices)) { + if (!is_null($placementAdvices)) { if (!is_array($placementAdvices)) { $placementAdvices = [$placementAdvices]; } @@ -218,13 +218,16 @@ public function setHtml(?string $html): void * * @param Placement|null $placementAdvice Placement advice object * - * @return void + * @return bool True if a placement was added */ - public function addPlacementAdvice(?Placement $placementAdvice): void + public function addPlacementAdvice(?Placement $placementAdvice): bool { - if (!empty($placementAdvice)) { + $ok = !is_null($placementAdvice); + if ($ok) { $this->placements[$placementAdvice->documentTarget] = $placementAdvice; } + + return $ok; } /** @@ -290,30 +293,32 @@ public static function toJson(array|Item $items, LtiVersion $ltiVersion = LtiVer } } - return json_encode($obj); + return json_encode($obj, JSON_UNESCAPED_SLASHES); } /** * Generate an array of Item objects from their JSON representation. * - * @param object $items A JSON object representing Content-Items + * @param object|array $items A JSON object or array representing Content-Items * * @return array Array of Item objects */ - public static function fromJson(object $items): array + public static function fromJson(object|array $items): array { - $isJsonLd = isset($items->{'@graph'}); - if ($isJsonLd) { - $items = $items->{'@graph'}; - } - if (!is_array($items)) { - $items = [$items]; - } $objs = []; - foreach ($items as $item) { - $obj = self::fromJsonItem($item); - if (!empty($obj)) { - $objs[] = $obj; + if (is_object($items)) { + if (isset($items->{'@graph'})) { + $items = $items->{'@graph'}; + } + } + if (is_array($items)) { + foreach ($items as $item) { + if (is_object($item)) { + $obj = self::fromJsonItem($item); + if (!is_null($obj)) { + $objs[] = $obj; + } + } } } @@ -328,10 +333,10 @@ public static function fromJson(object $items): array protected function toJsonldObject(): object { $item = new \stdClass(); - if (!empty($this->id)) { + if (!is_null($this->id)) { $item->{'@id'} = $this->id; } - if (!empty($this->type)) { + if (!is_null($this->type)) { if (($this->type === self::TYPE_LTI_LINK) || ($this->type === self::TYPE_LTI_ASSIGNMENT)) { $item->{'@type'} = 'LtiLinkItem'; } elseif ($this->type === self::TYPE_FILE) { @@ -342,43 +347,43 @@ protected function toJsonldObject(): object } else { $item->{'@type'} = 'ContentItem'; } - if (!empty($this->title)) { + if (!is_null($this->title)) { $item->title = $this->title; } - if (!empty($this->text)) { + if (!is_null($this->text)) { $item->text = $this->text; - } elseif (!empty($this->html)) { + } elseif (!is_null($this->html)) { $item->text = $this->html; } - if (!empty($this->url)) { + if (!is_null($this->url)) { $item->url = $this->url; } - if (!empty($this->mediaType)) { + if (!is_null($this->mediaType)) { $item->mediaType = $this->mediaType; } - if (!empty($this->placements)) { + if (!is_null($this->placements)) { $placementAdvice = new \stdClass(); $placementAdvices = []; foreach ($this->placements as $placement) { $obj = $placement->toJsonldObject(); - if (!empty($obj)) { - if (!empty($placement->documentTarget)) { + if (!is_null($obj)) { + if (!is_null($placement->documentTarget)) { $placementAdvices[] = $placement->documentTarget; } $placementAdvice = (object) array_merge((array) $placementAdvice, (array) $obj); } } - if (!empty($placementAdvice)) { + if (!is_null($placementAdvice)) { $item->placementAdvice = $placementAdvice; - if (!empty($placementAdvices)) { + if (!is_null($placementAdvices)) { $item->placementAdvice->presentationDocumentTarget = implode(',', $placementAdvices); } } } - if (!empty($this->icon)) { + if (!is_null($this->icon)) { $item->icon = $this->icon->toJsonldObject(); } - if (!empty($this->thumbnail)) { + if (!is_null($this->thumbnail)) { $item->thumbnail = $this->thumbnail->toJsonldObject(); } if (!is_null($this->hideOnCreate)) { @@ -404,9 +409,9 @@ protected function toJsonObject(): object $item->type = self::TYPE_FILE; break; case 'ContentItem': - if (empty($this->url)) { + if (is_null($this->url)) { $item->type = self::TYPE_HTML; - } elseif (!empty($this->mediaType) && str_starts_with($this->mediaType, 'image')) { + } elseif (!is_null($this->mediaType) && str_starts_with($this->mediaType, 'image')) { $item->type = self::TYPE_IMAGE; } else { $item->type = self::TYPE_LINK; @@ -416,16 +421,16 @@ protected function toJsonObject(): object $item->type = $this->type; break; } - if (!empty($this->title)) { + if (!is_null($this->title)) { $item->title = $this->title; } - if (!empty($this->text)) { + if (!is_null($this->text)) { $item->text = Util::stripHtml($this->text); } - if (!empty($this->html)) { + if (!is_null($this->html)) { $item->html = $this->html; } - if (!empty($this->url)) { + if (!is_null($this->url)) { $item->url = $this->url; } foreach ($this->placements as $type => $placement) { @@ -436,14 +441,14 @@ protected function toJsonObject(): object Placement::TYPE_FRAME => $placement->toJsonObject(), default => null }; - if (!empty($obj)) { + if (!is_null($obj)) { $item->{$type} = $obj; } } - if (!empty($this->icon)) { + if (!is_null($this->icon)) { $item->icon = $this->icon->toJsonObject(); } - if (!empty($this->thumbnail)) { + if (!is_null($this->thumbnail)) { $item->thumbnail = $this->thumbnail->toJsonObject(); } if (!is_null($this->hideOnCreate)) { @@ -458,46 +463,58 @@ protected function toJsonObject(): object * * @param object $item A JSON or JSON-LD object representing a content-item * - * @return Item|LtiLinkItem|FileItem The content-item object + * @return Item|LtiLinkItem|FileItem|null The content-item object */ - public static function fromJsonItem(object $item): Item|LtiLinkItem|FileItem + public static function fromJsonItem(object $item): Item|LtiLinkItem|FileItem|null { $obj = null; $placement = null; - if (isset($item->{'@type'})) { - if (isset($item->presentationDocumentTarget)) { + $type = Util::checkString($item, 'Item/@type', false, true, + ['ContentItem', 'LtiLinkItem', 'AssignmentLinkItem', 'FileItem'], false, null); + if (!is_null($type)) { + if (isset($item->presentationDocumentTarget) && is_object($item->presentationDocumentTarget)) { $placement = Placement::fromJsonObject($item, $item->presentationDocumentTarget); } - $obj = match ($item->{'@type'}) { + $obj = match ($type) { 'ContentItem' => new Item('ContentItem', $placement), 'LtiLinkItem' => new LtiLinkItem($placement), - 'FileItem' => new FileItem($placement) + 'FileItem' => new FileItem($placement), + default => new Item($type, $placement), }; - } elseif (isset($item->type)) { - $placements = []; - $placement = Placement::fromJsonObject($item, 'embed'); - if (!empty($placement)) { - $placements[] = $placement; - } - $placement = Placement::fromJsonObject($item, 'iframe'); - if (!empty($placement)) { - $placements[] = $placement; - } - $placement = Placement::fromJsonObject($item, 'window'); - if (!empty($placement)) { - $placements[] = $placement; + } else { + $type = Util::checkString($item, 'Item/type', true, true, '', false, null); + if (!is_null($type)) { + if (!in_array($type, [self::TYPE_LINK, self::TYPE_LTI_LINK, self::TYPE_FILE, self::TYPE_HTML, self::TYPE_IMAGE])) { + Util::setMessage(false, "Value of the 'Item/type' element not recognised ('{$type}' found)"); + } + $placements = []; + $placement = Placement::fromJsonObject($item, 'embed'); + if (!is_null($placement)) { + $placements[] = $placement; + } + $placement = Placement::fromJsonObject($item, 'iframe'); + if (!is_null($placement)) { + $placements[] = $placement; + } + $placement = Placement::fromJsonObject($item, 'window'); + if (!is_null($placement)) { + $placements[] = $placement; + } + $obj = match ($type) { + self::TYPE_LINK, + self::TYPE_HTML, + self::TYPE_IMAGE => new Item($type, $placements), + self::TYPE_LTI_LINK => new LtiLinkItem($placements), + self::TYPE_LTI_ASSIGNMENT => new LtiAssignmentItem($placements), + self::TYPE_FILE => new FileItem($placements), + default => new Item($type, $placements), + }; } - $obj = match ($item->type) { - self::TYPE_LINK, - self::TYPE_HTML, - self::TYPE_IMAGE => new Item($item->type, $placements), - self::TYPE_LTI_LINK => new LtiLinkItem($placements), - self::TYPE_LTI_ASSIGNMENT => new LtiAssignmentItem($placements), - self::TYPE_FILE => new FileItem($placements) - }; } - if (!empty($obj)) { - $obj->fromJsonObject($item); + if (!is_null($obj)) { + if (!$obj->fromJsonObject($item)) { + $obj = null; + } } return $obj; @@ -508,13 +525,12 @@ public static function fromJsonItem(object $item): Item|LtiLinkItem|FileItem * * @param object $item A JSON object representing a content-item * - * @return void + * @return bool True if the item is valid */ - protected function fromJsonObject(object $item): void + protected function fromJsonObject(object $item): bool { - if (isset($item->{'@id'})) { - $this->id = $item->{'@id'}; - } + $ok = true; + $this->id = Util::checkString($item, 'Item/@id', false, true, '', false, $this->id); foreach (get_object_vars($item) as $name => $value) { switch ($name) { case 'title': @@ -522,23 +538,48 @@ protected function fromJsonObject(object $item): void case 'html': case 'url': case 'mediaType': + $this->{$name} = Util::checkString($item, "Item/{$name}", false, false, '', false, null); + if (is_null($this->{$name})) { + $ok = false; + } + break; case 'hideOnCreate': - $this->{$name} = $item->{$name}; + $this->hideOnCreate = Util::checkBoolean($item, 'Item/hideOnCreate'); + if (is_null($this->hideOnCreate)) { + $ok = false; + } break; case 'placementAdvice': - $this->addPlacementAdvice(Placement::fromJsonObject($item)); + $placements = Util::checkString($value, 'Item/placementAdvice/presentationDocumentTarget', false, true, '', + false, null); + if (!is_null($placements)) { + $placements = explode(',', $placements); + foreach ($placements as $placement) { + $ok = $ok && $this->addPlacementAdvice(Placement::fromJsonObject($item, $placement)); + } + } break; case 'embed': case 'window': case 'iframe': - $this->addPlacementAdvice(Placement::fromJsonObject($item, $name)); + $ok = $ok && $this->addPlacementAdvice(Placement::fromJsonObject($item, $name)); break; case 'icon': case 'thumbnail': - $this->{$name} = Image::fromJsonObject($item->{$name}); + if (is_object($value) || is_string($value)) { + $this->{$name} = Image::fromJsonObject($value); + if (is_null($this->{$name})) { + $ok = false; + } + } else { + $ok = false; + Util::setMessage(true, "The {$name} element must be a simple object or string"); + } break; } } + + return $ok; } } diff --git a/src/Content/LineItem.php b/src/Content/LineItem.php index 514624f..f626101 100644 --- a/src/Content/LineItem.php +++ b/src/Content/LineItem.php @@ -4,6 +4,7 @@ namespace ceLTIc\LTI\Content; use ceLTIc\LTI\SubmissionReview; +use ceLTIc\LTI\Util; /** * Class to represent a line-item object @@ -81,14 +82,14 @@ public function toJsonldObject(): object $lineItem->{'@type'} = 'LineItem'; $lineItem->label = $this->label; $lineItem->reportingMethod = 'http://purl.imsglobal.org/ctx/lis/v2p1/Result#normalScore'; - if (!empty($this->resourceId)) { + if (!is_null($this->resourceId)) { $lineItem->assignedActivity = (object) ['activityId' => $this->resourceId]; } $lineItem->scoreConstraints = (object) [ '@type' => 'NumericLimits', 'normalMaximum' => $this->scoreMaximum ]; - if (!empty($this->submissionReview)) { + if (!is_null($this->submissionReview)) { $lineItem->submissionReview = $this->submissionReview->toJsonObject(); } @@ -106,13 +107,13 @@ public function toJsonObject(): object $lineItem->label = $this->label; $lineItem->scoreMaximum = $this->scoreMaximum; - if (!empty($this->resourceId)) { + if (!is_null($this->resourceId)) { $lineItem->resourceId = $this->resourceId; } - if (!empty($this->tag)) { + if (!is_null($this->tag)) { $lineItem->tag = $this->tag; } - if (!empty($this->submissionReview)) { + if (!is_null($this->submissionReview)) { $lineItem->submissionReview = $this->submissionReview->toJsonObject(); } @@ -135,33 +136,42 @@ public static function fromJsonObject(object $item): ?LineItem $activityId = null; $tag = null; $submissionReview = null; + $hasLabel = false; + $hasScoreMaximum = false; foreach (get_object_vars($item) as $name => $value) { switch ($name) { case 'label': - $label = $item->label; + $hasLabel = true; + $label = Util::checkString($item, 'LineItem/label', false, true, '', false, $label); break; case 'reportingMethod': - $reportingMethod = $item->reportingMethod; + $reportingMethod = Util::checkString($item, 'LineItem/reportingMethod', false, true, '', false, $reportingMethod); break; case 'scoreConstraints': - $scoreConstraints = $item->scoreConstraints; + if (is_object($value)) { + $scoreConstraints = $value; + } break; case 'scoreMaximum': - $scoreMaximum = $item->scoreMaximum; + $hasScoreMaximum = true; + $scoreMaximum = Util::checkNumber($item, 'LineItem/scoreMaximum', false, 0, true); break; case 'assignedActivity': - if (isset($item->assignedActivity->activityId)) { - $activityId = $item->assignedActivity->activityId; + if (isset($item->assignedActivity)) { + $activityId = Util::checkString($item->assignedActivity, 'LineItem/assignedActivity/activityId', false, + true, '', false, $activityId); } break; case 'resourceId': - $activityId = $item->resourceId; + $activityId = Util::checkString($item, 'LineItem/resourceId', false, true, '', false, $activityId); break; case 'tag': - $tag = $item->tag; + $tag = Util::checkString($item, 'LineItem/tag', false, true, '', false, $tag); break; case 'submissionReview': - $submissionReview = SubmissionReview::fromJsonObject($item->submissionReview); + if (is_object($item->submissionReview)) { + $submissionReview = SubmissionReview::fromJsonObject($item->submissionReview); + } break; } } @@ -169,13 +179,20 @@ public static function fromJsonObject(object $item): ?LineItem foreach (get_object_vars($scoreConstraints) as $name => $value) { $method = str_replace('Maximum', 'Score', $name); if (str_ends_with($reportingMethod, $method)) { - $scoreMaximum = $value; + $scoreMaximum = Util::checkNumber($scoreConstraints, "LineItem/scoreConstraints/{$name}", false, 0, true); break; } } } - if (!is_null($scoreMaximum)) { + if (!is_null($label) && !is_null($scoreMaximum)) { $obj = new LineItem($label, $scoreMaximum, $activityId, $tag, $submissionReview); + } else { + if (!$hasLabel) { + Util::setMessage(true, 'A line item must have a label'); + } + if (!$hasScoreMaximum) { + Util::setMessage(true, 'A line item must have a maximum score'); + } } return $obj; diff --git a/src/Content/LtiLinkItem.php b/src/Content/LtiLinkItem.php index a06948c..67265b0 100644 --- a/src/Content/LtiLinkItem.php +++ b/src/Content/LtiLinkItem.php @@ -3,6 +3,8 @@ namespace ceLTIc\LTI\Content; +use ceLTIc\LTI\Util; + /** * Class to represent an LTI link content-item object * @@ -70,11 +72,11 @@ function __construct(array|Placement|null $placementAdvices = null, ?string $id */ public function addCustom(string $name, ?string $value = null): void { - if (!empty($name)) { - if (!empty($value)) { + if (!is_null($name) && (strlen($name) > 0)) { + if (is_string($value)) { $this->custom[$name] = $value; } else { - reset($this->custom[$name]); + unset($this->custom[$name]); } } } @@ -82,11 +84,11 @@ public function addCustom(string $name, ?string $value = null): void /** * Set a line-item for the content-item. * - * @param LineItem $lineItem Line-item + * @param LineItem|null $lineItem Line-item * * @return void */ - public function setLineItem(LineItem $lineItem): void + public function setLineItem(?LineItem $lineItem): void { $this->lineItem = $lineItem; } @@ -135,7 +137,7 @@ public function setNoUpdate(?bool $noUpdate): void public function toJsonldObject(): object { $item = parent::toJsonldObject(); - if (!empty($this->lineItem)) { + if (!is_null($this->lineItem)) { $item->lineItem = $this->lineItem->toJsonldObject(); } if (!is_null($this->noUpdate)) { @@ -162,7 +164,7 @@ public function toJsonldObject(): object public function toJsonObject(): object { $item = parent::toJsonObject(); - if (!empty($this->lineItem)) { + if (!is_null($this->lineItem)) { $item->lineItem = $this->lineItem->toJsonObject(); } if (!is_null($this->noUpdate)) { @@ -186,32 +188,62 @@ public function toJsonObject(): object * * @param object $item A JSON object representing an LTI link content-item * - * @return void + * @return bool True if the item is valid */ - protected function fromJsonObject(object $item): void + protected function fromJsonObject(object $item): bool { - parent::fromJsonObject($item); + $ok = parent::fromJsonObject($item); foreach (get_object_vars($item) as $name => $value) { switch ($name) { case 'custom': - foreach ($item->custom as $paramName => $paramValue) { - $this->addCustom($paramName, $paramValue); + $obj = Util::checkObject($item, 'LtiLink/custom', false, true); + if (!is_null($obj)) { + foreach (\get_object_vars($obj) as $elementName => $elementValue) { + $this->addCustom($elementName, $elementValue); + } + } else { + $ok = false; } break; case 'lineItem': - $this->setLineItem(LineItem::fromJsonObject($item->lineItem)); + if (is_object($item->lineItem)) { + $lineItem = LineItem::fromJsonObject($item->lineItem); + if (!is_null($lineItem)) { + $this->setLineItem($lineItem); + } else { + $ok = false; + } + } elseif (isset($item->lineItem)) { + $ok = false; + Util::setMessage(true, 'The lineItem element must be a simple object'); + } break; case 'available': - $this->setAvailable(TimePeriod::fromJsonObject($item->available)); + if (is_object($item->available)) { + $this->setAvailable(TimePeriod::fromJsonObject($item->available)); + } elseif (isset($item->available)) { + $ok = false; + Util::setMessage(true, 'The available element must be a simple object'); + } break; case 'submission': - $this->setSubmission(TimePeriod::fromJsonObject($item->submission)); + if (is_object($item->submission)) { + $this->setSubmission(TimePeriod::fromJsonObject($item->submission)); + } elseif (isset($item->submission)) { + $ok = false; + Util::setMessage(true, 'The submission element must be a simple object'); + } break; case 'noUpdate': - $this->noUpdate = $item->noUpdate; + $this->noUpdate = Util::checkBoolean($item, 'LtiLink/noUpdate'); + if (is_null($this->noUpdate) && isset($this->noUpdate)) { + $ok = false; + } break; } } + + return $ok; } } diff --git a/src/Content/Placement.php b/src/Content/Placement.php index 1b82803..22e4d6c 100644 --- a/src/Content/Placement.php +++ b/src/Content/Placement.php @@ -3,6 +3,8 @@ namespace ceLTIc\LTI\Content; +use ceLTIc\LTI\Util; + /** * Class to represent a content-item placement object * @@ -199,6 +201,7 @@ public function toJsonObject(): ?object */ public static function fromJsonObject(object $item, ?string $documentTarget = null): ?Placement { + $ok = true; $obj = null; $displayWidth = null; $displayHeight = null; @@ -208,23 +211,22 @@ public static function fromJsonObject(object $item, ?string $documentTarget = nu $html = null; if (isset($item->{'@type'})) { // Version 1 if (empty($documentTarget) && isset($item->placementAdvice)) { - if (isset($item->placementAdvice->presentationDocumentTarget)) { - $documentTarget = $item->placementAdvice->presentationDocumentTarget; - } + $documentTarget = Util::checkString($item->placementAdvice, 'Item/placementAdvice/presentationDocumentTarget', + false, true, ['embed', 'frame', 'iframe', 'none', 'overlay', 'popup', 'window'], false, null); + $ok = $ok && (!is_null($documentTarget) || isset($item->placementAdvice->presentationDocumentTarget)); } - if (!empty($documentTarget) && isset($item->placementAdvice)) { - if (isset($item->placementAdvice->displayWidth)) { - $displayWidth = $item->placementAdvice->displayWidth; - } - if (isset($item->placementAdvice->displayHeight)) { - $displayHeight = $item->placementAdvice->displayHeight; - } - if (isset($item->placementAdvice->windowTarget)) { - $windowTarget = $item->placementAdvice->windowTarget; - } + if (!empty($documentTarget) && isset($item->placementAdvice) && is_object($item->placementAdvice)) { + $displayWidth = Util::checkInteger($item->placementAdvice, 'Item/placementAdvice/displayWidth', false, 0, true); + $ok = $ok && (!is_null($displayWidth) || !isset($item->placementAdvice->displayWidth)); + $displayHeight = Util::checkInteger($item->placementAdvice, 'Item/placementAdvice/displayHeight', false, 0, true); + $ok = $ok && (!is_null($displayHeight) || !isset($item->placementAdvice->displayHeight)); + $windowTarget = Util::checkString($item->placementAdvice, 'Item/placementAdvice/windowTarget', false, true, '', + false, null); + $ok = $ok && (!is_null($windowTarget) || !isset($item->placementAdvice->windowTarget)); } if (isset($item->url)) { - $url = $item->url; + $url = Util::checkString($item, 'url', false, true, '', false, null); + $ok = $ok && !is_null($url); } } else { // Version 2 if (empty($documentTarget)) { @@ -239,27 +241,23 @@ public static function fromJsonObject(object $item, ?string $documentTarget = nu $documentTarget = null; } if (!empty($documentTarget)) { - if (isset($item->{$documentTarget}->width)) { - $displayWidth = $item->{$documentTarget}->width; - } - if (isset($item->{$documentTarget}->height)) { - $displayHeight = $item->{$documentTarget}->height; - } - if (isset($item->{$documentTarget}->targetName)) { - $windowTarget = $item->{$documentTarget}->targetName; - } - if (isset($item->{$documentTarget}->windowFeatures)) { - $windowFeatures = $item->{$documentTarget}->windowFeatures; - } - if (isset($item->{$documentTarget}->src)) { - $url = $item->{$documentTarget}->src; - } - if (isset($item->{$documentTarget}->html)) { - $html = $item->{$documentTarget}->html; - } + $displayWidth = Util::checkInteger($item->{$documentTarget}, "Item/{$documentTarget}/width", false, 0, true); + $ok = $ok && (!is_null($displayWidth) || !isset($item->{$documentTarget}->width)); + $displayHeight = Util::checkInteger($item->{$documentTarget}, "Item/{$documentTarget}/height", false, 0, true); + $ok = $ok && (!is_null($displayHeight) || !isset($item->{$documentTarget}->height)); + $windowTarget = Util::checkString($item->{$documentTarget}, "Item/{$documentTarget}/targetName", false, true, '', + false, null); + $ok = $ok && (!is_null($windowTarget) || !isset($item->{$documentTarget}->targetName)); + $windowFeatures = Util::checkString($item->{$documentTarget}, "Item/{$documentTarget}/windowFeatures", false, true, + '', false, null); + $ok = $ok && (!is_null($windowFeatures) || !isset($item->{$documentTarget}->windowFeatures)); + $url = Util::checkString($item->{$documentTarget}, "Item/{$documentTarget}/src", false, true, '', false, $url); + $ok = $ok && (!is_null($url) || !isset($item->{$documentTarget}->src)); + $html = Util::checkString($item->{$documentTarget}, "Item/{$documentTarget}/html", false, true, '', false, $html); + $ok = $ok && (!is_null($html) || !isset($item->{$documentTarget}->html)); } } - if (!empty($documentTarget)) { + if ($ok && !empty($documentTarget)) { $obj = new Placement($documentTarget, $displayWidth, $displayHeight, $windowTarget, $windowFeatures, $url, $html); } diff --git a/src/Content/TimePeriod.php b/src/Content/TimePeriod.php index 2b20ba5..1cd9b08 100644 --- a/src/Content/TimePeriod.php +++ b/src/Content/TimePeriod.php @@ -3,6 +3,8 @@ namespace ceLTIc\LTI\Content; +use ceLTIc\LTI\Util; + /** * Class to represent a time period object * @@ -88,12 +90,24 @@ public static function fromJsonObject(object $item): ?TimePeriod $startDateTime = null; $endDateTime = null; foreach (get_object_vars($item) as $name => $value) { + if (!Util::$strictMode) { + $value = Util::valToString($value); + } + if (!is_string($value)) { + continue; + } switch ($name) { case 'startDateTime': - $startDateTime = strtotime($item->startDateTime); + $startDateTime = strtotime($value); + if ($startDateTime === false) { + $startDateTime = null; + } break; case 'endDateTime': - $endDateTime = strtotime($item->endDateTime); + $endDateTime = strtotime($value); + if ($endDateTime === false) { + $endDateTime = null; + } break; } } diff --git a/src/LineItem.php b/src/LineItem.php index 61776e4..b4fd788 100644 --- a/src/LineItem.php +++ b/src/LineItem.php @@ -195,7 +195,9 @@ public function deleteOutcome(User $user): bool */ public static function fromEndpoint(Platform $platform, string $endpoint): LineItem|bool { - return Service\LineItem::getLineItem($platform, $endpoint); + $lineItemService = new Service\LineItem($platform, $endpoint); + + return lineItemService->get(); } } diff --git a/src/OAuth/OAuthUtil.php b/src/OAuth/OAuthUtil.php index 2d34e75..712419a 100644 --- a/src/OAuth/OAuthUtil.php +++ b/src/OAuth/OAuthUtil.php @@ -203,6 +203,14 @@ public static function build_http_query(?array $params): string return implode('&', $pairs); } + /** + * Recursively merge two arrays. + * + * @param array $array1 First array + * @param array $array2 Second array + * + * @return array + */ public static function array_merge_recursive(array $array1, array $array2): array { $array = []; diff --git a/src/OAuthDataStore.php b/src/OAuthDataStore.php index bc831e8..f179683 100644 --- a/src/OAuthDataStore.php +++ b/src/OAuthDataStore.php @@ -101,7 +101,7 @@ function lookup_nonce(OAuthConsumer $consumer, OAuthToken $token, string $value, $ok = $nonce->save(); } if (!$ok) { - $this->system->reason = 'Invalid nonce.'; + $this->system->setReason('Invalid nonce'); } return !$ok; diff --git a/src/Platform.php b/src/Platform.php index bff679c..10260a9 100644 --- a/src/Platform.php +++ b/src/Platform.php @@ -9,6 +9,7 @@ use ceLTIc\LTI\Enum\IdScope; use ceLTIc\LTI\Enum\LogLevel; use ceLTIc\LTI\ApiHook\ApiHook; +use ceLTIc\LTI\Content\Item; /** * Class to represent a platform @@ -429,7 +430,7 @@ public function hasAccessTokenService(): bool public function getMessageParameters(): ?array { if ($this->ok && is_null($this->messageParameters)) { - $this->parseMessage(true, true, false); + $this->parseMessage(true, false); } return $this->messageParameters; @@ -438,9 +439,11 @@ public function getMessageParameters(): ?array /** * Process an incoming request * + * @param bool $generateWarnings True if warning messages should be generated (optional, default is false) + * * @return void */ - public function handleRequest(): void + public function handleRequest(bool $generateWarnings = false): void { $parameters = Util::getRequestParameters(); if ($this->debugMode) { @@ -453,7 +456,10 @@ public function handleRequest(): void } else { // LTI message $this->getMessageParameters(); Util::logRequest(); - if ($this->ok && $this->authenticate()) { + if ($this->ok) { + $this->authenticate($generateWarnings); + } + if ($this->ok) { $this->doCallback(); } } @@ -760,7 +766,7 @@ protected function onAuthenticate(): void */ protected function onContentItem(): void { - $this->reason = 'No onContentItem method found for platform'; + $this->setReason('No onContentItem method found for platform'); $this->onError(); } @@ -771,7 +777,7 @@ protected function onContentItem(): void */ protected function onLtiStartAssessment(): void { - $this->reason = 'No onLtiStartAssessment method found for platform'; + $this->setReason('No onLtiStartAssessment method found for platform'); $this->onError(); } @@ -794,16 +800,36 @@ protected function onError(): void * * The platform, resource link and user objects will be initialised if the request is valid. * - * @return bool True if the request has been successfully validated. + * @param bool $generateWarnings True if warning messages should be generated (optional, default is false) + * + * @return void */ - private function authenticate(): bool + private function authenticate(bool $generateWarnings = false): void { - $this->ok = $this->checkMessage(); + $this->checkMessage($generateWarnings); + if (($this->ok || $generateWarnings) && !empty($this->messageParameters['lti_message_type'])) { + if ($this->messageParameters['lti_message_type'] === 'ContentItemSelection') { + if (isset($this->messageParameters['content_items'])) { + $value = Util::jsonDecode($this->messageParameters['content_items']); + if (is_null($value)) { + $this->setReason('Invalid JSON in \'content_items\' parameter'); + } elseif (empty($this->jwt) || !$this->jwt->hasJwt()) { + if (is_object($value)) { + Item::fromJson($value); + } else { + $this->setReason('\'content_items\' parameter must be an object'); + } + } elseif (is_array($value)) { + Item::fromJson($value); + } else { + $this->setReason('\'content_items\' parameter must be an array'); + } + } + } + } if ($this->ok) { - $this->ok = $this->verifySignature(); + $this->verifySignature(); } - - return $this->ok; } /** diff --git a/src/ResourceLink.php b/src/ResourceLink.php index ceb85e1..cba1ec1 100644 --- a/src/ResourceLink.php +++ b/src/ResourceLink.php @@ -683,15 +683,15 @@ public function doOutcomesService(ServiceAction $action, Outcome $ltiOutcome, Us } } if (!empty($resultDataType)) { - $xml = <<< EOF + $xml = <<< EOD <{$resultDataType}>{$comment} -EOF; +EOD; } } - $xml = <<< EOF + $xml = <<< EOD @@ -699,16 +699,16 @@ public function doOutcomesService(ServiceAction $action, Outcome $ltiOutcome, Us {$outcome} {$xml} -EOF; +EOD; } $sourcedId = htmlentities($sourcedId); - $xml = << {$sourcedId} {$xml} -EOF; +EOD; if ($this->doLTI11Service($do, $urlLTI11, $xml)) { switch ($action) { case ServiceAction::Read: @@ -1459,15 +1459,17 @@ private function doService(string $type, string $url, array $params, string $sco $this->extResponseHeaders = $http->responseHeaders; try { $this->extDoc = new DOMDocument(); - $this->extDoc->loadXML($http->response); - $this->extNodes = $this->domnodeToArray($this->extDoc->documentElement); - if (isset($this->extNodes['statusinfo']['codemajor']) && ($this->extNodes['statusinfo']['codemajor'] === 'Success')) { - $ok = true; + @$this->extDoc->loadXML($http->response); + if ($this->extDoc->documentElement) { + $this->extNodes = $this->domnodeToArray($this->extDoc->documentElement); + if (isset($this->extNodes['statusinfo']['codemajor']) && ($this->extNodes['statusinfo']['codemajor'] === 'Success')) { + $ok = true; + } + } else { + Util::setMessage(true, 'Invalid XML in service response'); } } catch (\Exception $e) { - } catch (\TypeError $e) { - } } $retry = $retry && !$newToken && !$ok; @@ -1620,16 +1622,18 @@ private function doLTI11Service(string $type, string $url, string $xml): bool $this->extResponseHeaders = $http->responseHeaders; try { $this->extDoc = new DOMDocument(); - $this->extDoc->loadXML($http->response); - $this->extNodes = $this->domnodeToArray($this->extDoc->documentElement); - if (isset($this->extNodes['imsx_POXHeader']['imsx_POXResponseHeaderInfo']['imsx_statusInfo']['imsx_codeMajor']) && - ($this->extNodes['imsx_POXHeader']['imsx_POXResponseHeaderInfo']['imsx_statusInfo']['imsx_codeMajor'] === 'success')) { - $ok = true; + @$this->extDoc->loadXML($http->response); + if ($this->extDoc->documentElement) { + $this->extNodes = $this->domnodeToArray($this->extDoc->documentElement); + if (isset($this->extNodes['imsx_POXHeader']['imsx_POXResponseHeaderInfo']['imsx_statusInfo']['imsx_codeMajor']) && + ($this->extNodes['imsx_POXHeader']['imsx_POXResponseHeaderInfo']['imsx_statusInfo']['imsx_codeMajor'] === 'success')) { + $ok = true; + } + } else { + Util::setMessage(true, 'Invalid XML in service response'); } } catch (\Exception $e) { - } catch (\TypeError $e) { - } } $retry = $retry && !$newToken && !$ok; diff --git a/src/Service/Groups.php b/src/Service/Groups.php index 9062cad..4952861 100644 --- a/src/Service/Groups.php +++ b/src/Service/Groups.php @@ -5,6 +5,7 @@ use ceLTIc\LTI\Context; use ceLTIc\LTI\User; +use ceLTIc\LTI\Util; /** * Class to implement the Course Groups service @@ -153,24 +154,31 @@ public function getGroupSets(?int $limit = null): bool $endpoint = $this->endpoint; do { $http = $this->send('GET', $parameters); - $ok = !empty($http) && $http->ok; + $ok = $http->ok; $url = ''; if ($ok) { - if (isset($http->responseJson->sets)) { - foreach ($http->responseJson->sets as $set) { - $groupSets[$set->id] = [ - 'title' => $set->name, - 'hidden' => false, + $sets = Util::checkArray($http->responseJson, 'sets', true); + foreach ($sets as $set) { + if (!is_object($set)) { + Util::setMessage(true, + 'The \'sets\' element must comprise an array of objects (' . gettype($set) . ' found)'); + continue; + } + $id = Util::checkString($set, 'id', true, true); + $name = Util::checkString($set, 'name', true, true); + $tag = Util::checkString($set, 'tag', false, true); + $hidden = Util::checkBoolean($set, 'hidden', false, false); + if (!empty($id) && !empty($name)) { + $groupSets[$id] = [ + 'title' => $name, + 'hidden' => $hidden, 'groups' => [], 'num_members' => 0, 'num_staff' => 0, 'num_learners' => 0 ]; - if (isset($set->tag)) { - $groupSets[$set->id]['tag'] = $set->tag; - } - if (isset($set->hidden)) { - $groupSets[$set->id]['hidden'] = $set->hidden; + if (!empty($tag)) { + $groupSets[$id]['tag'] = $tag; } } } @@ -231,30 +239,44 @@ public function getGroups(bool $allowNonSets = false, ?User $user = null, ?int $ $endpoint = $this->endpoint; do { $http = $this->send('GET', $parameters); - $ok = !empty($http) && $http->ok; + $ok = $http->ok; $url = ''; if ($ok) { - if (isset($http->responseJson->groups)) { - foreach ($http->responseJson->groups as $agroup) { - if (!$allowNonSets && empty($agroup->set_ids)) { + $jsongroups = Util::checkArray($http->responseJson, 'groups', true); + foreach ($jsongroups as $agroup) { + if (!is_object($agroup)) { + Util::setMessage(true, + 'The \'groups\' element must comprise an array of objects (' . gettype($agroup) . ' found)'); + continue; + } + $id = Util::checkString($agroup, 'groups/id', true, true); + $name = Util::checkString($agroup, 'groups/name', true, true); + $tag = Util::checkString($agroup, 'groups/tag'); + $hidden = Util::checkBoolean($agroup, 'hidden', false, false); + $setids = Util::checkArray($agroup, 'groups/set_ids'); + if (!empty($id) && !empty($name)) { + if (!$allowNonSets && empty($setids)) { continue; } $group = [ - 'title' => $agroup->name, - 'hidden' => false + 'title' => $name, + 'hidden' => $hidden ]; - if (!empty($agroup->set_ids) && is_array(($agroup->set_ids))) { - foreach ($agroup->set_ids as $set_id) { - if (!array_key_exists($set_id, $groupSets)) { - $groupSets[$set_id] = [ - 'title' => "Set {$set_id}", + if (!empty($tag)) { + $group['tag'] = $tag; + } + if (!empty($setids)) { + foreach ($setids as $setid) { + if (!array_key_exists($setid, $groupSets)) { + $groupSets[$setid] = [ + 'title' => "Set {$setid}", 'groups' => [], 'num_members' => 0, 'num_staff' => 0, 'num_learners' => 0 ]; } - $groupSets[$set_id]['groups'][] = $agroup->id; + $groupSets[$setid]['groups'][] = $id; if (!isset($group['set'])) { $group['set'] = $setid; } elseif (!is_array($group['set'])) { @@ -264,13 +286,7 @@ public function getGroups(bool $allowNonSets = false, ?User $user = null, ?int $ } } } - if (!empty($agroup->tag)) { - $group['tag'] = $agroup->tag; - } - if (isset($agroup->hidden)) { - $group['hidden'] = $agroup->hidden; - } - $groups[$agroup->id] = $group; + $groups[$id] = $group; } } if (!$this->pagingMode && $http->hasRelativeLink('next')) { diff --git a/src/Service/LineItem.php b/src/Service/LineItem.php index 07d7da4..8319fda 100644 --- a/src/Service/LineItem.php +++ b/src/Service/LineItem.php @@ -6,6 +6,7 @@ use ceLTIc\LTI; use ceLTIc\LTI\Platform; use ceLTIc\LTI\SubmissionReview; +use ceLTIc\LTI\Util; /** * Class to implement the Line-item service @@ -118,11 +119,18 @@ public function getAll(?string $ltiResourceLinkId = null, ?string $resourceId = $this->mediaType = self::MEDIA_TYPE_LINE_ITEMS; $http = $this->send('GET', $params); $this->scope = self::$SCOPE; + $ok = $http->ok && !empty($http->responseJson); $url = ''; - if ($http->ok) { - if (!empty($http->responseJson)) { - foreach ($http->responseJson as $lineItem) { - $lineItems[] = self::toLineItem($this->getPlatform(), $lineItem); + if ($ok) { + $items = Util::checkArray($http, 'responseJson'); + foreach ($items as $lineItemJson) { + if (!is_object($lineItemJson)) { + Util::setMessage(true, 'The array must comprise an array of objects (' . gettype($lineItemJson) . ' found)'); + } else { + $lineItem = $this->toLineItem($this->getPlatform(), $lineItemJson); + if ($lineItem) { + $lineItems[] = $lineItem; + } } } if (!$this->pagingMode && $http->hasRelativeLink('next')) { @@ -153,7 +161,7 @@ public function createLineItem(LTI\LineItem $lineItem): bool $http = $this->send('POST', null, self::toJson($lineItem)); $ok = $http->ok && !empty($http->responseJson); if ($ok) { - $newLineItem = self::toLineItem($this->getPlatform(), $http->responseJson); + $newLineItem = $this->toLineItem($this->getPlatform(), $http->responseJson); if ($newLineItem) { foreach (get_object_vars($newLineItem) as $key => $value) { $lineItem->$key = $value; @@ -177,11 +185,13 @@ public function saveLineItem(LTI\LineItem $lineItem): bool $http = $this->send('PUT', null, self::toJson($lineItem)); $ok = $http->ok; if ($ok && !empty($http->responseJson)) { - $savedLineItem = self::toLineItem($this->getPlatform(), $http->responseJson); + $savedLineItem = $this->toLineItem($this->getPlatform(), $http->responseJson); + if ($savedLineItem) { foreach (get_object_vars($savedLineItem) as $key => $value) { $lineItem->$key = $value; } } + } return $ok; } @@ -204,20 +214,22 @@ public function deleteLineItem(LTI\LineItem $lineItem): bool /** * Retrieve a line-item. * - * @param Platform $platform Platform object for this service request - * @param string $endpoint Line-item endpoint - * * @return LTI\\LineItem|bool LineItem object, or false on error */ - public static function getLineItem(Platform $platform, string $endpoint): LTI\LineItem|bool + public function get(): LTI\LineItem|bool { - $service = new self($platform, $endpoint); - $service->scope = self::$SCOPE_READONLY; - $service->mediaType = self::MEDIA_TYPE_LINE_ITEM; - $http = $service->send('GET'); - $service->scope = self::$SCOPE; + $this->scope = self::$SCOPE_READONLY; + $this->mediaType = self::MEDIA_TYPE_LINE_ITEM; + $http = $this->send('GET'); if ($http->ok && !empty($http->responseJson)) { - $lineItem = self::toLineItem($platform, $http->responseJson); + if (!is_object($http->responseJson)) { + Util::setMessage(true, 'The response must be an object (' . gettype($http->responseJson) . ' found)'); + } else { + $lineItem = $this->toLineItem($this->getPlatform(), $http->responseJson); + if (empty($lineItem)) { + $lineItem = false; + } + } } else { $lineItem = false; } @@ -225,6 +237,21 @@ public static function getLineItem(Platform $platform, string $endpoint): LTI\Li return $lineItem; } + /** + * Retrieve a line-item. + * + * @deprecated Use LineItem::fromEndpoint() or get() instead + * + * @param Platform $platform Platform object for this service request + * @param string $endpoint Line-item endpoint + * + * @return LTI\\LineItem|bool LineItem object, or false on error + */ + public static function getLineItem(Platform $platform, string $endpoint): LTI\LineItem|bool + { + return LTI\LineItem::fromEndpoint($platform, $endpoint); + } + ### ### PRIVATE METHODS ### @@ -237,30 +264,45 @@ public static function getLineItem(Platform $platform, string $endpoint): LTI\Li * * @return LTI\\LineItem|null LineItem object, or null on error */ - private static function toLineItem(Platform $platform, object $json): ?LTI\LineItem + private function toLineItem(Platform $platform, object $json): ?LTI\LineItem { - if (!empty($json->id) && !empty($json->label) && !empty($json->scoreMaximum)) { - $lineItem = new LTI\LineItem($platform, $json->label, $json->scoreMaximum); - if (!empty($json->id)) { - $lineItem->endpoint = $json->id; + $id = Util::checkString($json, 'id', true, true); + $scoreMaximum = Util::checkNumber($json, 'scoreMaximum', true, 0, true); + $label = Util::checkString($json, 'label', true, true); + $resourceId = Util::checkString($json, 'resourceId'); + $tag = Util::checkString($json, 'tag'); + $startDateTime = Util::checkDateTime($json, 'startDateTime'); + $endDateTime = Util::checkDateTIme($json, 'endDateTime'); + $resourceLinkId = Util::checkString($json, 'resourceLinkId'); + if (!empty($id) && !empty($label) && + (!is_null($scoreMaximum) || (!empty($json->gradingScheme) && is_object($json->gradingScheme)))) { + $lineItem = new LTI\LineItem($platform, $label, $scoreMaximum); + if (!empty($json->gradingScheme)) { + $lineItem->gradingScheme = GradingScheme::fromJsonObject($json->gradingScheme); } - if (!empty($json->resourceLinkId)) { - $lineItem->ltiResourceLinkId = $json->resourceLinkId; + $lineItem->endpoint = $json->id; + if (!empty($resourceLinkId)) { + $lineItem->ltiResourceLinkId = $resourceLinkId; } - if (!empty($json->resourceId)) { - $lineItem->resourceId = $json->resourceId; + if (!empty($resourceId)) { + $lineItem->resourceId = $resourceId; } - if (!empty($json->tag)) { - $lineItem->tag = $json->tag; + if (!empty($tag)) { + $lineItem->tag = $tag; } - if (!empty($json->startDateTime)) { - $lineItem->submitFrom = strtotime($json->startDateTime); + if (!empty($startDateTime)) { + $lineItem->submitFrom = $startDateTime; } - if (!empty($json->endDateTime)) { - $lineItem->submitUntil = strtotime($json->endDateTime); + if (!empty($endDateTime)) { + $lineItem->submitUntil = $endDateTime; } if (!empty($json->submissionReview)) { + if (is_object($json->submissionReview)) { $lineItem->submissionReview = SubmissionReview::fromJsonObject($json->submissionReview); + } else { + Util::setMessage(true, + 'The \'submissionReview\' element must be an object (' . gettype($json->submissionReviewJson) . ' found)'); + } } } else { $lineItem = null; diff --git a/src/Service/Membership.php b/src/Service/Membership.php index 3628cd7..e4eb060 100644 --- a/src/Service/Membership.php +++ b/src/Service/Membership.php @@ -8,6 +8,7 @@ use ceLTIc\LTI\ResourceLink; use ceLTIc\LTI\Enum\IdScope; use ceLTIc\LTI\Enum\LtiVersion; +use ceLTIc\LTI\Util; /** * Class to implement the Membership service @@ -151,210 +152,400 @@ private function getMembers(bool $withGroups, ?string $role = null, ?int $limit $this->source->getGroups(); $parameters['groups'] = 'true'; } + $ok = true; $userResults = []; $memberships = []; $endpoint = $this->endpoint; do { $http = $this->send('GET', $parameters); + $ok = $http->ok; $url = ''; - if (!empty($http) && $http->ok) { - $isjsonld = false; - if (isset($http->responseJson->pageOf) && isset($http->responseJson->pageOf->membershipSubject) && - isset($http->responseJson->pageOf->membershipSubject->membership)) { - $isjsonld = true; - $memberships = array_merge($memberships, $http->responseJson->pageOf->membershipSubject->membership); - if (!empty($http->responseJson->nextPage) && !empty($http->responseJson->pageOf->membershipSubject->membership)) { + if ($ok) { + $members = []; + if ($this->mediaType === self::MEDIA_TYPE_MEMBERSHIPS_V1) { + if (empty(Util::checkString($http->responseJson, '@type', true, true, 'Page', false))) { + $ok = $ok && !Util::$strictMode; + } + if (!isset($http->responseJson->pageOf)) { + $ok = false; + Util::setMessage(true, 'The pageOf element is missing'); + } else { + if (empty(Util::checkString($http->responseJson->pageOf, 'pageOf/@type', true, true, + 'LISMembershipContainer', false))) { + $ok = $ok && !Util::$strictMode; + } + if (isset($http->responseJson->pageOf->membershipSubject)) { + if (empty(Util::checkString($http->responseJson->pageOf->membershipSubject, + 'pageOf/membershipSubject/@type', true, true, 'Context', false))) { + $ok = $ok && !Util::$strictMode; + } + if (empty(Util::checkString($http->responseJson->pageOf->membershipSubject, + 'pageOf/membershipSubject/contextId'))) { + $ok = $ok && !Util::$strictMode; + } + $members = Util::checkArray($http->responseJson->pageOf->membershipSubject, + 'pageOf/membershipSubject/membership'); + } + } + if (!empty($http->responseJson->nextPage) && !empty($members)) { $http->relativeLinks['next'] = $http->responseJson->nextPage; } - } elseif (isset($http->responseJson->members) && is_array($http->responseJson->members)) { - $memberships = array_merge($memberships, $http->responseJson->members); - } - if (!$this->pagingMode && $http->hasRelativeLink('next')) { - $url = $http->getRelativeLink('next'); - $this->endpoint = $url; - $parameters = []; + } else { // Version 2 (NRPS) + if (!isset($http->responseJson->context)) { + if (Util::$strictMode) { + $ok = false; + Util::setMessage(true, 'The context element is missing'); + } else { + Util::setMessage(false, 'The context element should be present'); + } + } elseif (empty(Util::checkString($http->responseJson->context, 'context/id'))) { + $ok = $ok && !Util::$strictMode; + } + $members = Util::checkArray($http->responseJson, 'members'); + if ($ok && !$this->pagingMode && $http->hasRelativeLink('next')) { + $url = $http->getRelativeLink('next'); + $this->endpoint = $url; + $parameters = []; + } } - } else { - $userResults = false; + $memberships = array_merge($memberships, $members); } } while ($url); $this->endpoint = $endpoint; - if ($userResults !== false) { + if ($ok) { if ($isLink) { $oldUsers = $this->source->getUserResultSourcedIDs(true, IdScope::Resource); } + if ($this->mediaType === self::MEDIA_TYPE_MEMBERSHIPS_V1) { + if (!empty($http->responseJson->{'@context'})) { + $contexts = $http->responseJson->{'@context'}; + if (is_string($contexts)) { + $contexts = [$contexts]; + } elseif (!is_array($contexts)) { + $contexts = []; + } + } else { + $contexts = []; + } + } foreach ($memberships as $membership) { - if ($isjsonld) { - $member = $membership->member; - if ($isLink) { - $userResult = LTI\UserResult::fromResourceLink($this->source, $member->userId); + if ($this->mediaType === self::MEDIA_TYPE_MEMBERSHIPS_V1) { + if (isset($membership->member)) { + $member = $membership->member; } else { - $userResult = new LTI\UserResult(); - $userResult->ltiUserId = $member->userId; + Util::setMessage(true, 'The membership/member element is missing'); + $member = null; } + if (empty(Util::checkArray($membership, 'membership/role', true, true)) && Util::$strictMode) { + $member = null; + } + if ($isLink) { + $messages = Util::checkArray($membership, 'membership/message'); + } + if (!empty($member)) { + $userid = null; + if (!empty($member->userId)) { + $userid = $member->userId; + } elseif (!empty($member->{'@id'})) { + $userid = $member->{'@id'}; + } + if (empty($userid)) { + Util::setMessage(true, 'The membership/member/userid or @id element is missing'); + } elseif (!is_string($userid)) { + if (Util::$strictMode) { + Util::setMessage(true, 'The membership/member/userid or @id element must have a string '); + $userid = null; + } else { + Util::setMessage(false, 'The membership/member/userid or @id element should have a string '); + $userid = LTI\Util::valToString($userid); + } + } + $roles = []; + $stringroles = Util::checkArray($membership, 'membership/role', true, true); + foreach ($stringroles as $role) { + if (!is_string($role)) { + if (Util::$strictMode) { + Util::setMessage(true, 'The membership/role element must only comprise string values'); + $userid = null; + } else { + Util::setMessage(false, 'The membership/role element should only comprise string values'); + } + } else { + $roles[] = $role; + } + } + if (!empty($userid)) { + if ($isLink) { + $userResult = LTI\UserResult::fromResourceLink($this->source, $userid); + } else { + $userResult = new LTI\UserResult(); + $userResult->ltiUserId = $userid; + } // Set the user name - $firstname = $member->givenName ?? ''; - $middlename = $member->middleName ?? ''; - $lastname = $member->familyName ?? ''; - $fullname = $member->name ?? ''; - $userResult->setNames($firstname, $lastname, $fullname); + $firstname = Util::checkString($member, 'membership/member/givenName'); + $middlename = Util::checkString($member, 'membership/member/middleName'); + $lastname = Util::checkString($member, 'membership/member/familyName'); + $fullname = Util::checkString($member, 'membership/member/name'); + $userResult->setNames($firstname, $lastname, $fullname, $middlename); // Set the sourcedId - if (isset($member->sourcedId)) { - $userResult->sourcedId = $member->sourcedId; - } - -// Set the username - if (isset($member->ext_username)) { - $userResult->username = $member->ext_username; - } elseif (isset($member->ext_user_username)) { - $userResult->username = $member->ext_user_username; - } elseif (isset($member->custom_username)) { - $userResult->username = $member->custom_username; - } elseif (isset($member->custom_user_username)) { - $userResult->username = $member->custom_user_username; - } + if (isset($member->sourcedId)) { + $userResult->sourcedId = Util::checkString($member, 'membership/member/sourcedId'); + } // Set the user email - $email = $member->email ?? ''; - $userResult->setEmail($email, $this->source->getPlatform()->defaultEmail); + $email = Util::checkString($member, 'membership/member/email'); + $userResult->setEmail($email, $this->source->getPlatform()->defaultEmail); // Set the user roles - if (isset($membership->role)) { - $roles = $this->parseContextsInArray($http->responseJson->{'@context'}, $membership->role); - $ltiVersion = $this->getPlatform()->ltiVersion; - if (empty($ltiVersion)) { - $ltiVersion = LtiVersion::V1; - } - $userResult->roles = LTI\Tool::parseRoles($roles, $ltiVersion); - } + if (!empty($roles)) { + $roles = $this->parseContextsInArray($contexts, $roles); + $ltiVersion = $this->getPlatform()->ltiVersion; + if (empty($ltiVersion)) { + $ltiVersion = LtiVersion::V1; + } + $userResult->roles = LTI\Tool::parseRoles($roles, $ltiVersion); + } // If a result sourcedid is provided save the user - if ($isLink) { - $doSave = false; - if (isset($membership->message)) { - foreach ($membership->message as $message) { - if (isset($message->message_type) && (($message->message_type === 'basic-lti-launch-request') || ($message->message_type) === 'LtiResourceLinkRequest')) { - if (isset($message->lis_result_sourcedid)) { - if (empty($userResult->ltiResultSourcedId) || ($userResult->ltiResultSourcedId !== $message->lis_result_sourcedid)) { - $userResult->ltiResultSourcedId = $message->lis_result_sourcedid; - $doSave = true; - } - } elseif ($userResult->isLearner() && empty($userResult->created)) { // Ensure all learners are recorded in case Assignment and Grade services are used - $userResult->ltiResultSourcedId = ''; - $doSave = true; - } - if (isset($message->ext)) { - if (empty($userResult->username)) { - if (!empty($message->ext->username)) { - $userResult->username = $message->ext->username; - } elseif (!empty($message->ext->user_username)) { - $userResult->username = $message->ext->user_username; + if ($isLink) { + $doSave = false; + if (is_array($messages)) { + foreach ($messages as $message) { + if (!is_object($message)) { + Util::setMessage(true, + 'The membership/message element must comprise an array of objects (' . gettype($message) . ' found)'); + continue; + } else { + if (isset($message->ext)) { + if (!is_object($message->ext)) { + Util::setMessage(true, + 'The membership/message/ext element must be an object (' . gettype($message->ext) . ' found)'); + } + } + if (isset($message->custom)) { + if (!is_object($message->custom)) { + Util::setMessage(true, + 'The membership/message/custom element must be an object (' . gettype($message->custom) . ' found)'); + } } } - } - if (isset($message->custom)) { - if (empty($userResult->username)) { - if (!empty($message->custom->username)) { - $userResult->username = $message->custom->username; - } elseif (!empty($message->custom->user_username)) { - $userResult->username = $message->custom->user_username; + if (!isset($message->message_type)) { + Util::setMessage(true, + 'The membership/message elements must include a \'message_type\' property'); + } elseif (($message->message_type === 'basic-lti-launch-request') || ($message->message_type === 'LtiResourceLinkRequest')) { + if (isset($message->lis_result_sourcedid)) { + $sourcedid = Util::checkString($message, 'membership/message/lis_result_sourcedid'); + if (empty($userResult->ltiResultSourcedId) || ($userResult->ltiResultSourcedId !== $sourcedid)) { + $userResult->ltiResultSourcedId = $sourcedid; + $doSave = true; + } + } elseif ($userResult->isLearner() && empty($userResult->created)) { // Ensure all learners are recorded in case Assignment and Grade services are used + $userResult->ltiResultSourcedId = ''; + $doSave = true; + } + $username = null; + if (is_object($message->ext)) { + if (!empty($message->ext->username)) { + $username = Util::checkString($message->ext, 'membership/message/ext/username'); + } elseif (!empty($message->ext->user_username)) { + $username = Util::checkString($message->ext, + 'membership/message/ext/user_username'); + } } + if (empty($username) && is_object($message->custom)) { + if (!empty($message->custom->username)) { + $username = Util::checkString($message->custom, + 'membership/message/custom/username'); + } elseif (!empty($message->custom->user_username)) { + $username = Util::checkString($message->custom, + 'membership/message/custom/user_username'); + } + } + if (!empty($username)) { + $userResult->username = $username; + } + break; } } - break; + } elseif ($userResult->isLearner() && empty($userResult->created)) { // Ensure all learners are recorded in case Assignment and Grade services are used + $userResult->ltiResultSourcedId = ''; + $doSave = true; + } + if (!$doSave && isset($member->resultSourcedId)) { + $userResult->setResourceLinkId($this->source->getId()); + $userResult->ltiResultSourcedId = Util::checkString($member, 'membership/member/resultSourcedId'); + $doSave = true; + } + if ($doSave) { + $userResult->save(); } } - } elseif ($userResult->isLearner() && empty($userResult->created)) { // Ensure all learners are recorded in case Assignment and Grade services are used - $userResult->ltiResultSourcedId = ''; - $doSave = true; - } - if (!$doSave && isset($member->resultSourcedId)) { - $userResult->setResourceLinkId($this->source->getId()); - $userResult->ltiResultSourcedId = $member->resultSourcedId; - $doSave = true; - } - if ($doSave) { - $userResult->save(); - } - } - $userResults[] = $userResult; + $userResults[] = $userResult; // Remove old user (if it exists) - if ($isLink) { - unset($oldUsers[$userResult->getId(IdScope::Resource)]); + if ($isLink) { + unset($oldUsers[$userResult->getId(IdScope::Resource)]); + } + } } - } else { // Version 2 + } else { // Version 2 (NRPS) $member = $membership; + $userid = null; + $userid = Util::checkString($member, 'members/user_id', true); + $roles = []; + $stringroles = Util::checkArray($member, 'members/roles', true, true); + foreach ($stringroles as $role) { + if (!is_string($role)) { + if (Util::$strictMode) { + Util::setMessage(true, 'The members/roles element must only comprise string values'); + $userid = null; + } else { + Util::setMessage(false, 'The members/roles element should only comprise string values'); + } + } else { + $roles[] = $role; + } + } + } + if ($isLink) { + if (isset($member->message)) { + $messages = $member->message; + if (!is_array($messages)) { + if (Util::$strictMode) { + $userid = null; + Util::setMessage(true, + 'The members/message element must have an array value (' . gettype($member->message) . ' found)'); + $messages = []; + } else { + Util::setMessage(false, + 'The members/message element should have an array value (' . gettype($membership->message) . ' found)'); + if (is_object($messages)) { + $messages = (array) $messages; + } else { + $messages = []; + } + } + } + } else { + $messages = []; + } + } + if (!empty($userid)) { if ($isLink) { - $userResult = LTI\UserResult::fromResourceLink($this->source, $member->user_id); + $userResult = LTI\UserResult::fromResourceLink($this->source, $userid); } else { $userResult = new LTI\UserResult(); - $userResult->ltiUserId = $member->user_id; + $userResult->ltiUserId = $userid; } // Set the user name - $firstname = $member->given_name ?? ''; - $middlename = $member->middle_name ?? ''; - $lastname = $member->family_name ?? ''; - $fullname = $member->name ?? ''; - $userResult->setNames($firstname, $lastname, $fullname); + $firstname = Util::checkString($member, 'members/given_mame'); + $middlename = Util::checkString($member, 'members/middle_name'); + $lastname = Util::checkString($member, 'members/family_name'); + $fullname = Util::checkString($member, 'members/name'); + $userResult->setNames($firstname, $lastname, $fullname, $middlename); // Set the sourcedId if (isset($member->lis_person_sourcedid)) { - $userResult->sourcedId = $member->lis_person_sourcedid; + $userResult->sourcedId = Util::checkString($member, 'members/lis_person_sourcedid'); } // Set the user email - $email = $member->email ?? ''; + $email = Util::checkString($member, 'email'); $userResult->setEmail($email, $this->source->getPlatform()->defaultEmail); // Set the user roles - if (isset($member->roles)) { + if (!empty($roles)) { $ltiVersion = $this->getPlatform()->ltiVersion; if (empty($ltiVersion)) { $ltiVersion = LtiVersion::V1; } - $userResult->roles = LTI\Tool::parseRoles($member->roles, $ltiVersion); + $userResult->roles = LTI\Tool::parseRoles($roles, $ltiVersion); } // If a result sourcedid is provided save the user + $groupenrollments = []; if ($isLink) { $doSave = false; - if (isset($member->message)) { - $messages = $member->message; - if (!is_array($messages)) { - $messages = [$member->message]; - } + if (is_array($messages)) { foreach ($messages as $message) { - if (isset($message->{'https://purl.imsglobal.org/spec/lti/claim/message_type'}) && (($message->{'https://purl.imsglobal.org/spec/lti/claim/message_type'} === 'basic-lti-launch-request') || ($message->{'https://purl.imsglobal.org/spec/lti/claim/message_type'}) === 'LtiResourceLinkRequest')) { + if (!is_object($message)) { + Util::setMessage(true, + 'The members/message element must comprise an array of objects (' . gettype($message) . ' found)'); + continue; + } else { + if (isset($message->{'https://purl.imsglobal.org/spec/lti/claim/ext'})) { + if (!is_object($message->{'https://purl.imsglobal.org/spec/lti/claim/ext'})) { + Util::setMessage(true, + 'The members/message/https://purl.imsglobal.org/spec/lti/claim/ext element must be an object (' . gettype($message->{'https://purl.imsglobal.org/spec/lti/claim/ext'}) . ' found)'); + } + } + if (isset($message->{'https://purl.imsglobal.org/spec/lti/claim/custom'})) { + if (!is_object($message->{'https://purl.imsglobal.org/spec/lti/claim/custom'})) { + Util::setMessage(true, + 'The members/message/https://purl.imsglobal.org/spec/lti/claim/custom element must be an object (' . gettype($message->{'https://purl.imsglobal.org/spec/lti/claim/custom'}) . ' found)'); + } + } + if (isset($member->group_enrollments)) { + if (!is_array($member->group_enrollments)) { + if (Util::$strictMode) { + Util::setMessage(true, + 'The members/message/group_enrollments element must be an array (' . gettype($member->group_enrollments) . ' found)'); + } else { + Util::setMessage(false, + 'The members/message/group_enrollments element should be an array (' . gettype($member->group_enrollments) . ' found)'); + if (is_object($member->group_enrollments)) { + $groupenrollments = (array) $member->group_enrollments; + } + } + } else { + $groupenrollments = $member->group_enrollments; + } + } + } + if (!isset($message->{'https://purl.imsglobal.org/spec/lti/claim/message_type'})) { + Util::setMessage(true, + 'The members/message elements must include a \'https://purl.imsglobal.org/spec/lti/claim/message_type\' property'); + } elseif (($message->{'https://purl.imsglobal.org/spec/lti/claim/message_type'} === 'basic-lti-launch-request') || + ($message->{'https://purl.imsglobal.org/spec/lti/claim/message_type'} === 'LtiResourceLinkRequest')) { if (isset($message->{'https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome'}) && isset($message->{'https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome'}->lis_result_sourcedid)) { - $userResult->ltiResultSourcedId = $message->{'https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome'}->lis_result_sourcedid; - $doSave = true; + $sourcedid = Util::checkString($message->{'https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome'}, + 'members/message/https://purl.imsglobal.org/spec/lti-bo/claim/basicoutcome/lis_result_sourcedid'); + if (empty($userResult->ltiResultSourcedId) || ($userResult->ltiResultSourcedId !== $sourcedid)) { + $userResult->ltiResultSourcedId = $sourcedid; + $doSave = true; + } } elseif ($userResult->isLearner() && empty($userResult->created)) { // Ensure all learners are recorded in case Assignment and Grade services are used $userResult->ltiResultSourcedId = ''; $doSave = true; } - if (isset($message->{'https://purl.imsglobal.org/spec/lti/claim/ext'})) { - if (empty($userResult->username)) { - if (!empty($message->{'https://purl.imsglobal.org/spec/lti/claim/ext'}->username)) { - $userResult->username = $message->{'https://purl.imsglobal.org/spec/lti/claim/ext'}->username; - } elseif (!empty($message->{'https://purl.imsglobal.org/spec/lti/claim/ext'}->user_username)) { - $userResult->username = $message->{'https://purl.imsglobal.org/spec/lti/claim/ext'}->user_username; - } + $username = null; + if (isset($message->{'https://purl.imsglobal.org/spec/lti/claim/ext'}) && + is_object($message->{'https://purl.imsglobal.org/spec/lti/claim/ext'})) { + if (!empty($message->{'https://purl.imsglobal.org/spec/lti/claim/ext'}->username)) { + $username = Util::checkString($message->{'https://purl.imsglobal.org/spec/lti/claim/ext'}, + 'members/message/https://purl.imsglobal.org/spec/lti/claim/ext/username'); + } elseif (!empty($message->{'https://purl.imsglobal.org/spec/lti/claim/ext'}->user_username)) { + $username = Util::checkString($message->{'https://purl.imsglobal.org/spec/lti/claim/ext'}, + 'members/message/https://purl.imsglobal.org/spec/lti/claim/ext/user_username'); } } - if (isset($message->{'https://purl.imsglobal.org/spec/lti/claim/custom'})) { - if (empty($userResult->username)) { - if (!empty($message->{'https://purl.imsglobal.org/spec/lti/claim/custom'}->username)) { - $userResult->username = $message->{'https://purl.imsglobal.org/spec/lti/claim/custom'}->username; - } elseif (!empty($message->{'https://purl.imsglobal.org/spec/lti/claim/custom'}->user_username)) { - $userResult->username = $message->{'https://purl.imsglobal.org/spec/lti/claim/custom'}->user_username; - } + if (empty($username) && isset($message->{'https://purl.imsglobal.org/spec/lti/claim/custom'}) && + is_object($message->{'https://purl.imsglobal.org/spec/lti/claim/custom'})) { + if (!empty($message->{'https://purl.imsglobal.org/spec/lti/claim/custom'}->username)) { + $username = Util::checkString($message->{'https://purl.imsglobal.org/spec/lti/claim/custom'}, + 'members/message/https://purl.imsglobal.org/spec/lti/claim/custom/username'); + } elseif (!empty($message->{'https://purl.imsglobal.org/spec/lti/claim/custom'}->user_username)) { + $username = Util::checkString($message->{'https://purl.imsglobal.org/spec/lti/claim/custom'}, + 'members/message/https://purl.imsglobal.org/spec/lti/claim/custom/user_username'); } } + if (!empty($username)) { + $userResult->username = $username; + } break; } } @@ -367,25 +558,36 @@ private function getMembers(bool $withGroups, ?string $role = null, ?int $limit } } $userResults[] = $userResult; - if (isset($member->group_enrollments)) { + if (is_array($groupenrollments)) { $userResult->groups = []; - foreach ($member->group_enrollments as $group) { - $groupId = $group->group_id; - if (empty($this->source->groups) || !array_key_exists($groupId, $this->source->groups)) { - $this->source->groups[$groupId] = [ - 'title' => "Group {$groupId}" - ]; - } - if (!empty($this->source->groups[$groupId]['set'])) { - $this->source->groupSets[$this->source->groups[$groupId]['set']]['num_members']++; - if ($userResult->isStaff()) { - $this->source->groupSets[$this->source->groups[$groupId]['set']]['num_staff']++; - } - if ($userResult->isLearner()) { - $this->source->groupSets[$this->source->groups[$groupId]['set']]['num_learners']++; + foreach ($groupenrollments as $group) { + if (!is_object($group)) { + Util::setMessage(true, + 'The members/group_enrollments element must comprise an array of objects (' . gettype($group) . ' found)'); + continue; + } elseif (!isset($group->group_id)) { + Util::setMessage(true, 'The members/group_enrollments objects must have a \'group_id\' property'); + continue; + } else { + $groupId = Util::checkString($group, 'members/group_enrollments/group_id'); + if (!empty($groupId)) { + if (empty($this->source->groups) || !array_key_exists($groupId, $this->source->groups)) { + $this->source->groups[$groupId] = [ + 'title' => "Group {$groupId}" + ]; + } + if (!empty($this->source->groups[$groupId]['set'])) { + $this->source->groupSets[$this->source->groups[$groupId]['set']]['num_members']++; + if ($userResult->isStaff()) { + $this->source->groupSets[$this->source->groups[$groupId]['set']]['num_staff']++; + } + if ($userResult->isLearner()) { + $this->source->groupSets[$this->source->groups[$groupId]['set']]['num_learners']++; + } + } + $userResult->groups[] = $groupId; } } - $userResult->groups[] = $groupId; } } @@ -402,6 +604,8 @@ private function getMembers(bool $withGroups, ?string $role = null, ?int $limit $userResult->delete(); } } + } else { + $userResults = false; } return $userResults; diff --git a/src/Service/Result.php b/src/Service/Result.php index b67f302..09fe765 100644 --- a/src/Service/Result.php +++ b/src/Service/Result.php @@ -6,6 +6,7 @@ use ceLTIc\LTI\Platform; use ceLTIc\LTI\User; use ceLTIc\LTI\Outcome; +use ceLTIc\LTI\Util; /** * Class to implement the Result service @@ -90,10 +91,21 @@ public function getAll(?int $limit = null): array|bool do { $http = $this->send('GET', $params); $url = ''; + if ($http->ok) { + $http->ok = empty($http->responseJson) || is_array($http->responseJson); + } if ($http->ok) { if (!empty($http->responseJson)) { foreach ($http->responseJson as $outcome) { - $outcomes[] = self::getOutcome($outcome); + if (!is_object($outcome)) { + $http->ok = false; + break; + } else { + $obj = $this->getOutcome($outcome); + if (!is_null($obj)) { + $outcomes[] = $obj; + } + } } } if (!$this->pagingMode && $http->hasRelativeLink('next')) { @@ -101,11 +113,12 @@ public function getAll(?int $limit = null): array|bool $this->endpoint = $url; $params = []; } - } else { - $outcomes = false; } } while ($url); $this->endpoint = $endpoint; + if (!$http->ok) { + $outcomes = false; + } return $outcomes; } @@ -123,9 +136,17 @@ public function get(User $user): Outcome|null|bool 'user_id' => $user->ltiUserId ]; $http = $this->send('GET', $params); + if ($http->ok) { + $http->ok = empty($http->responseJson) || is_array($http->responseJson); + } if ($http->ok) { if (!empty($http->responseJson)) { - $outcome = self::getOutcome(reset($http->responseJson)); + $obj = reset($http->responseJson); + if (is_object($obj)) { + $outcome = $this->getOutcome($obj); + } else { + $outcome = false; + } } else { $outcome = null; } @@ -144,20 +165,29 @@ public function get(User $user): Outcome|null|bool * * @param object $json JSON representation of an outcome * - * @return Outcome Outcome object + * @return Outcome|null Outcome object */ - private static function getOutcome(object $json): Outcome + private function getOutcome(object $json): ?Outcome { - $outcome = new Outcome(); - $outcome->ltiUserId = $json->userId; - if (isset($json->resultScore)) { - $outcome->setValue($json->resultScore); - } - if (isset($json->resultMaximum)) { - $outcome->setPointsPossible($json->resultMaximum); - } - if (isset($json->comment)) { - $outcome->comment = $json->comment; + $outcome = null; + $id = Util::checkString($json, 'id', true); + $scoreOf = Util::checkString($json, 'scoreOf', true); + $userId = Util::checkString($json, 'userId', true); + $resultScore = Util::checkNumber($json, 'resultScore'); + $resultMaximum = Util::checkNumber($json, 'resultMaximum', false, 0, true); + $comment = Util::checkString($json, 'comment'); + if (!empty($userId)) { + $outcome = new Outcome(); + $outcome->ltiUserId = $userId; + if (!is_null($resultScore)) { + $outcome->setValue($resultScore); + } + if (!is_null($resultMaximum)) { + $outcome->setPointsPossible($resultMaximum); + } + if (!empty($comment)) { + $outcome->comment = $comment; + } } return $outcome; diff --git a/src/Service/ToolSettings.php b/src/Service/ToolSettings.php index ffa3ca2..85ee072 100644 --- a/src/Service/ToolSettings.php +++ b/src/Service/ToolSettings.php @@ -85,21 +85,55 @@ public function __construct(Platform|Context|ResourceLink $source, string $endpo */ public function get(?ToolSettingsMode $mode = null): array|bool { + $response = false; $parameter = []; if (!empty($mode)) { $parameter['bubble'] = $mode->value; } $http = $this->send('GET', $parameter); - if (!$http->ok) { - $response = false; - } elseif ($this->simple) { - $response = Util::jsonDecode($http->response, true); - } elseif (isset($http->responseJson->{'@graph'})) { - $response = []; - foreach ($http->responseJson->{'@graph'} as $level) { - $settings = Util::jsonDecode(json_encode($level->custom), true); - unset($settings['@id']); - $response[self::$LEVEL_NAMES[$level->{'@type'}]] = $settings; + if ($http->ok) { + if ($this->simple) { + if (!is_object($http->responseJson)) { + Util::setMessage(true, 'The response must be an object'); + $response = false; + } else { + $response = Util::jsonDecode(json_encode($http->responseJson), true); + if (is_array($response)) { + $response = $this->checkSettings($response); + } else { + Util::setMessage(true, 'The response must be a simple object'); + $response = false; + } + } + } else { + $graph = Util::checkArray($http->responseJson, '@graph', true, true); + if (!empty($graph)) { + $response = []; + foreach ($graph as $level) { + $settings = []; + if (isset($level->custom)) { + if (!is_object($level->custom)) { + Util::setMessage(true, 'The custom element must be an object'); + $response = false; + } else { + $settings = Util::jsonDecode(json_encode($level->custom), true); + if (is_array($settings)) { + unset($settings['@id']); + $settings = $this->checkSettings($settings); + if ($settings === false) { + $response = false; + } + } else { + Util::setMessage(true, 'The custom element must be a simple object'); + $response = false; + } + } + } + if ($response !== false) { + $response[self::$LEVEL_NAMES[$level->{'@type'}]] = $settings; + } + } + } } } @@ -142,4 +176,38 @@ public function set(array $settings): bool return $response->ok; } +### +### PRIVATE METHODS +### + + /** + * Check the tool setting values. + * + * @param array $settings An associative array of settings + * + * @return array|false Array of settings, or false if an invalid value is found + */ + private function checkSettings(array $settings): array|false + { + $response = []; + foreach ($settings as $key => $value) { + if (is_string($value)) { + if ($response !== false) { + $response[$key] = $value; + } + } elseif (!Util::$strictMode) { + Util::setMessage(false, + 'Properties of the custom element should have a string value (' . gettype($value) . ' found)'); + if ($response !== false) { + $response[$key] = Util::valToString($value); + } + } else { + Util::setMessage(true, 'Properties of the custom element must have a string value (' . gettype($value) . ' found)'); + $response = false; + } + } + + return $response; + } + } diff --git a/src/SubmissionReview.php b/src/SubmissionReview.php index 0080480..dcdb52f 100644 --- a/src/SubmissionReview.php +++ b/src/SubmissionReview.php @@ -3,6 +3,8 @@ namespace ceLTIc\LTI; +use ceLTIc\LTI\Util; + /** * Class to represent a submission review * @@ -53,27 +55,27 @@ public function __construct(?string $label = null, ?string $endpoint = null, ?ar * * @param object $json A JSON object representing a submission review * - * @return SubmissionReview The SubmissionReview object + * @return SubmissionReview|null The SubmissionReview object */ - public static function fromJsonObject(object $json): SubmissionReview + public static function fromJsonObject(object $json): ?SubmissionReview { - if (!empty($json->label)) { - $label = $json->label; + $ok = true; + $obj = null; + $label = Util::checkString($json, 'SubmissionReview/label', false, true, '', false, null); + $ok = $ok && (!is_null($label) || !isset($json->label)); + $endpoint = Util::checkString($json, 'SubmissionReview/url', false, true, '', false, null); + $ok = $ok && (!is_null($endpoint) || !isset($json->url)); + $custom = Util::checkObject($json, 'SubmissionReview/custom', false, true); + if (!is_null($custom)) { + $custom = (array) $custom; } else { - $label = null; + $ok = $ok && !isset($json->custom); } - if (!empty($json->url)) { - $endpoint = $json->url; - } else { - $endpoint = null; - } - if (!empty($json->custom)) { - $custom = $json->custom; - } else { - $custom = null; + if ($ok) { + $obj = new SubmissionReview($label, $endpoint, $custom); } - return new SubmissionReview($label, $endpoint, $custom); + return $obj; } /** diff --git a/src/System.php b/src/System.php index 5a253f8..ae69282 100644 --- a/src/System.php +++ b/src/System.php @@ -122,6 +122,8 @@ trait System /** * Warnings relating to last request processed. * + * @deprecated Use Util::getMessages() instead + * * @var array $warnings */ public array $warnings = []; @@ -479,6 +481,7 @@ public function getMessageClaims(bool $fullyQualified = false): ?array $messageClaims = []; if (!empty($messageParameters['oauth_consumer_key'])) { $messageClaims['aud'] = [$messageParameters['oauth_consumer_key']]; + $messageClaims['azp'] = $messageParameters['oauth_consumer_key']; } foreach ($messageParameters as $key => $value) { $ok = true; @@ -489,12 +492,17 @@ public function getMessageClaims(bool $fullyQualified = false): ?array } else { $mapping = Util::JWT_CLAIM_MAPPING[$key]; } - if (isset($mapping['isObject']) && $mapping['isObject']) { - $value = Util::jsonDecode($value); - } elseif (isset($mapping['isArray']) && $mapping['isArray']) { + if (isset($mapping['isArray']) && $mapping['isArray']) { $value = array_map('trim', explode(',', $value)); $value = array_filter($value); sort($value); + } elseif (isset($mapping['isContentItemSelection']) && $mapping['isContentItemSelection']) { + $value = Util::jsonDecode($value); + if (is_object($value) && isset($value->{'@graph'}) && is_array($value->{'@graph'})) { + $value = $value->{'@graph'}; + } else if (!is_array($value)) { + $value = null; + } } elseif (isset($mapping['isBoolean']) && $mapping['isBoolean']) { $value = (is_bool($value)) ? $value : $value === 'true'; } elseif (isset($mapping['isInteger']) && $mapping['isInteger']) { @@ -1075,61 +1083,40 @@ public function getBaseString(): ?string /** * Verify the required properties of an LTI message. * + * @param bool $generateWarnings True if warning messages should be generated + * * @return bool True if it is a valid LTI message */ - public function checkMessage(): bool + public function checkMessage(bool $generateWarnings = false): bool { - $ok = $_SERVER['REQUEST_METHOD'] === 'POST'; - if (!$ok) { - $this->reason = 'LTI messages must use HTTP POST'; - } elseif (!empty($this->jwt) && !empty($this->jwt->hasJwt())) { - $ok = false; - $context = $this->jwt->getClaim('https://purl.imsglobal.org/spec/lti/claim/context'); - $resourceLink = $this->jwt->getClaim('https://purl.imsglobal.org/spec/lti/claim/resource_link'); - if (is_null($this->messageParameters['oauth_consumer_key']) || (strlen($this->messageParameters['oauth_consumer_key']) <= 0)) { - $this->reason = 'Missing iss claim'; - } elseif (empty($this->jwt->getClaim('iat', ''))) { - $this->reason = 'Missing iat claim'; - } elseif (empty($this->jwt->getClaim('exp', ''))) { - $this->reason = 'Missing exp claim'; - } elseif (intval($this->jwt->getClaim('iat')) > intval($this->jwt->getClaim('exp'))) { - $this->reason = 'iat claim must not have a value greater than exp claim'; - } elseif (empty($this->jwt->getClaim('nonce', ''))) { - $this->reason = 'Missing nonce claim'; - } elseif (!empty($context) && property_exists($context, 'id') && (empty($context->id) || !is_string($context->id))) { - $this->reason = 'Invalid value for id property in https://purl.imsglobal.org/spec/lti/claim/context claim'; - } elseif (!empty($resourceLink) && property_exists($resourceLink, 'id') && (empty($resourceLink->id) || !is_string($resourceLink->id))) { - $this->reason = 'Invalid value for id property in https://purl.imsglobal.org/spec/lti/claim/resource_link claim'; - } else { - $ok = true; - } - } + $this->ok = $_SERVER['REQUEST_METHOD'] === 'POST'; + if (!$this->ok) { + $this->setReason('LTI messages must use HTTP POST'); + } else { // Set signature method from request - if (isset($this->messageParameters['oauth_signature_method'])) { - $this->signatureMethod = $this->messageParameters['oauth_signature_method']; - if (($this instanceof Tool) && !empty($this->platform)) { - $this->platform->signatureMethod = $this->signatureMethod; + if (isset($this->messageParameters['oauth_signature_method'])) { + $this->signatureMethod = $this->messageParameters['oauth_signature_method']; + if (($this instanceof Tool) && !empty($this->platform)) { + $this->platform->signatureMethod = $this->signatureMethod; + } } - } // Check all required launch parameters - if ($ok) { - $ok = isset($this->messageParameters['lti_message_type']); - if (!$ok) { - $this->reason = 'Missing lti_message_type parameter.'; - } - } - if ($ok) { - $ok = isset($this->messageParameters['lti_version']); - if ($ok) { - $this->ltiVersion = LtiVersion::tryFrom($this->messageParameters['lti_version']); - $ok = !empty($this->ltiVersion); + if ($this->ok || $generateWarnings) { + if (!isset($this->messageParameters['lti_message_type'])) { + $this->setReason('Missing \'lti_message_type\' parameter'); + } } - if (!$ok) { - $this->reason = 'Invalid or missing lti_version parameter.'; + if ($this->ok || $generateWarnings) { + if (isset($this->messageParameters['lti_version'])) { + $this->ltiVersion = LtiVersion::tryFrom($this->messageParameters['lti_version']); + } + if (empty($this->ltiVersion)) { + $this->setReason('Invalid or missing \'lti_version\' parameter'); + } } } - return $ok; + return $this->ok; } /** @@ -1139,7 +1126,6 @@ public function checkMessage(): bool */ public function verifySignature(): bool { - $ok = false; $key = $this->key; if (!empty($key)) { $secret = $this->secret; @@ -1183,17 +1169,15 @@ public function verifySignature(): bool $request->unset_parameter('_new_window'); } $server->verify_request($request); - $ok = true; } catch (\Exception $e) { + $this->ok = false; if (empty($this->reason)) { $oauthConsumer = new OAuth\OAuthConsumer($key, $secret); $signature = $request->build_signature($method, $oauthConsumer, null); if ($this->debugMode) { - $this->reason = $e->getMessage(); - } - if (empty($this->reason)) { - $this->reason = 'OAuth signature check failed - perhaps an incorrect secret or timestamp.'; + $this->setReason($e->getMessage()); } + $this->setReason('OAuth signature check failed - perhaps an incorrect secret or timestamp'); $this->details[] = "Shared secret: '{$secret}'"; $this->details[] = 'Current timestamp: ' . time(); $this->details[] = "Expected signature: {$signature}"; @@ -1201,25 +1185,42 @@ public function verifySignature(): bool } } } else { // JWT-signed message - $nonce = new PlatformNonce($platform, $this->jwt->getClaim('nonce')); + $nonce = new PlatformNonce($platform, Util::valToString($this->jwt->getClaim('nonce'))); $ok = !$nonce->load(); if ($ok) { $ok = $nonce->save(); } if (!$ok) { - $this->reason = 'Invalid nonce.'; + $this->setReason('Invalid nonce'); } elseif (!empty($publicKey) || !empty($jku) || Jwt::$allowJkuHeader) { $ok = $this->jwt->verify($publicKey, $jku); if (!$ok) { - $this->reason = 'JWT signature check failed - perhaps an invalid public key or timestamp'; + $this->setReason('JWT signature check failed - perhaps an invalid public key or timestamp'); } } else { - $ok = false; - $this->reason = 'Unable to verify JWT signature as neither a public key nor a JSON Web Key URL is specified'; + $this->setReason('Unable to verify JWT signature as neither a public key nor a JSON Web Key URL is specified'); } } - return $ok; + return $this->ok; + } + + /** + * Set the error reason. + * + * @param string $reason Reason value + * + * @return bool Returns false + */ + public function setReason(string $reason): bool + { + $this->ok = false; + Util::setMessage(true, $reason); + if (empty($this->reason)) { + $this->reason = $reason; + } + + return false; } ### @@ -1229,185 +1230,184 @@ public function verifySignature(): bool /** * Parse the message. * - * @param bool $strictMode True if full compliance with the LTI specification is required * @param bool $disableCookieCheck True if no cookie check should be made * @param bool $generateWarnings True if warning messages should be generated * * @return void */ - private function parseMessage(bool $strictMode, bool $disableCookieCheck, bool $generateWarnings): void + private function parseMessage(bool $disableCookieCheck, bool $generateWarnings): void { - if (is_null($this->messageParameters)) { - $this->getRawParameters(); - if (isset($this->rawParameters['id_token']) || isset($this->rawParameters['JWT'])) { // JWT-signed message - try { - $this->jwt = Jwt::getJwtClient(); - if (isset($this->rawParameters['id_token'])) { - $this->ok = $this->jwt->load($this->rawParameters['id_token'], $this->rsaKey); - } else { - $this->ok = $this->jwt->load($this->rawParameters['JWT'], $this->rsaKey); + $this->getRawParameters(); + if (isset($this->rawParameters['id_token']) || isset($this->rawParameters['JWT'])) { // JWT-signed message + try { + $this->jwt = Jwt::getJwtClient(); + if (isset($this->rawParameters['id_token'])) { + $this->ok = $this->jwt->load($this->rawParameters['id_token'], $this->rsaKey); + } else { + $this->ok = $this->jwt->load($this->rawParameters['JWT'], $this->rsaKey); + } + if (!$this->ok) { + $this->setReason('Message does not contain a valid JWT'); + } else { + if ($this->ok || $generateWarnings) { + $iat = $this->getClaimInteger('iat', true, $generateWarnings); } - if (!$this->ok) { - $this->reason = 'Message does not contain a valid JWT'; - } else { - $this->ok = $this->jwt->hasClaim('iss') && $this->jwt->hasClaim('aud') && $this->jwt->hasClaim('nonce') && - $this->jwt->hasClaim(Util::JWT_CLAIM_PREFIX . '/claim/deployment_id'); - if ($this->ok) { - $iss = $this->jwt->getClaim('iss'); - $aud = $this->jwt->getClaim('aud'); - $deploymentId = $this->jwt->getClaim(Util::JWT_CLAIM_PREFIX . '/claim/deployment_id'); - $this->ok = !empty($iss) && !empty($aud) && !empty($deploymentId); - if (!$this->ok) { - $this->reason = 'iss, aud and/or deployment_id claim is empty'; - } elseif (is_array($aud)) { - if ($this->jwt->hasClaim('azp')) { - $this->ok = !empty($this->jwt->getClaim('azp')); - if (!$this->ok) { - $this->reason = 'azp claim is empty'; - } else { - $this->ok = in_array($this->jwt->getClaim('azp'), $aud); - if ($this->ok) { - $aud = $this->jwt->getClaim('azp'); - } else { - $this->reason = 'azp claim value is not included in aud claim'; - } - } + if ($this->ok || $generateWarnings) { + $exp = $this->getClaimInteger('exp', true, $generateWarnings); + } + if (($this->ok || $generateWarnings) && !is_null($iat) && !is_null($exp) && ($iat > $exp)) { + $this->setReason('\'iat\' claim must not have a value greater than \'exp\' claim'); + } + if ($this->ok || $generateWarnings) { + $nonce = $this->getClaimString('nonce', true, true, $generateWarnings); + } + if ($this->ok || $generateWarnings) { + $iss = $this->getClaimString('iss', true, true, $generateWarnings); + } + if ($this->ok || $generateWarnings) { + $azp = $this->getClaimString('azp', false, true, $generateWarnings); + } + if ($this->ok || $generateWarnings) { + $aud = $this->jwt->getClaim('aud'); + if (is_string($aud)) { + $aud = [$aud]; + } + $aud = $this->checkClaimArray('aud', $aud, true, true, $generateWarnings); + if (!empty($aud)) { + if (!empty($azp)) { + if (in_array($azp, $aud)) { + $aud = $azp; } else { - $aud = $aud[0]; - $this->ok = !empty($aud); - if (!$this->ok) { - $this->reason = 'First element of aud claim is empty'; - } + $this->setReason('\'azp\' claim value is not included in \'aud\' claim'); } - } elseif ($this->jwt->hasClaim('azp')) { - $this->ok = $this->jwt->getClaim('azp') === $aud; - if (!$this->ok) { - $this->reason = 'aud claim does not match the azp claim'; + } else { + $aud = $aud[0]; + if (empty($aud)) { + $this->setReason('First element of \'aud\' claim is empty'); } } - if ($this->ok) { - if ($this instanceof Tool) { - $this->platform = Platform::fromPlatformId($iss, $aud, $deploymentId, $this->dataConnector); - $this->platform->platformId = $iss; - if (isset($this->rawParameters['id_token'])) { - $this->ok = !empty($this->rawParameters['state']); - if ($this->ok) { - $state = $this->rawParameters['state']; - $parts = explode('.', $state); - if (!empty(session_id()) && (count($parts) > 1) && (session_id() !== $parts[1]) && - ($parts[1] !== 'platformStorage')) { // Reset to original session - session_abort(); - session_id($parts[1]); - session_start(); - $this->onResetSessionId(); - } - $usePlatformStorage = (str_ends_with($state, '.platformStorage')); - if ($usePlatformStorage) { - $state = substr($state, 0, -16); - } - $this->onAuthenticate($state, $this->jwt->getClaim('nonce'), $usePlatformStorage); - if (!$disableCookieCheck) { - if (empty($_COOKIE) && !isset($_POST['_new_window'])) { // Reopen in a new window - Util::setTestCookie(); - $_POST['_new_window'] = ''; - echo Util::sendForm($_SERVER['REQUEST_URI'], $_POST, '_blank'); - exit; - } - Util::setTestCookie(true); - } - } else { - $this->reason = 'state parameter is missing'; - } - if ($this->ok) { - $nonce = new PlatformNonce($this->platform, $state); - $this->ok = $nonce->load(); - if (!$this->ok) { - $platform = Platform::fromPlatformId($iss, $aud, null, $this->dataConnector); - $nonce = new PlatformNonce($platform, $state); - $this->ok = $nonce->load(); - } - if (!$this->ok) { - $platform = Platform::fromPlatformId($iss, null, null, $this->dataConnector); - $nonce = new PlatformNonce($platform, $state); - $this->ok = $nonce->load(); - } - if ($this->ok) { - $this->ok = $nonce->delete(); - } - if (!$this->ok) { - $this->reason = 'state parameter is invalid or has expired'; - } + } + } + if ($this->ok || $generateWarnings) { + $deploymentId = $this->getClaimString(Util::JWT_CLAIM_PREFIX . '/claim/deployment_id', true, true, + $generateWarnings); + } + if ($this->ok) { + if ($this instanceof Tool) { + $this->platform = Platform::fromPlatformId($iss, $aud, $deploymentId, $this->dataConnector); + $this->platform->platformId = $iss; + if (isset($this->rawParameters['id_token'])) { + $this->ok = !empty($this->rawParameters['state']); + if ($this->ok) { + $state = $this->rawParameters['state']; + $parts = explode('.', $state); + if (!empty(session_id()) && (count($parts) > 1) && (session_id() !== $parts[1]) && + ($parts[1] !== 'platformStorage')) { // Reset to original session + session_abort(); + session_id($parts[1]); + session_start(); + $this->onResetSessionId(); + } + $usePlatformStorage = (str_ends_with($state, '.platformStorage')); + if ($usePlatformStorage) { + $state = substr($state, 0, -16); + } + $this->onAuthenticate($state, $nonce, $usePlatformStorage); + if (!$disableCookieCheck) { + if (empty($_COOKIE) && !isset($_POST['_new_window'])) { // Reopen in a new window + Util::setTestCookie(); + $_POST['_new_window'] = ''; + echo Util::sendForm($_SERVER['REQUEST_URI'], $_POST, '_blank'); + exit; } + Util::setTestCookie(true); } + } else { + $this->setReason('\'state\' parameter is missing'); } - $this->messageParameters = []; if ($this->ok) { - $this->messageParameters['oauth_consumer_key'] = $aud; - $this->messageParameters['oauth_signature_method'] = $this->jwt->getHeader('alg'); - $this->parseClaims($strictMode, $generateWarnings); + $nonce = new PlatformNonce($this->platform, $state); + $this->ok = $nonce->load(); + if (!$this->ok) { + $platform = Platform::fromPlatformId($iss, $aud, null, $this->dataConnector); + $nonce = new PlatformNonce($platform, $state); + $this->ok = $nonce->load(); + } + if (!$this->ok) { + $platform = Platform::fromPlatformId($iss, null, null, $this->dataConnector); + $nonce = new PlatformNonce($platform, $state); + $this->ok = $nonce->load(); + } + if ($this->ok) { + $this->ok = $nonce->delete(); + } + if (!$this->ok) { + $this->setReason('\'state\' parameter is invalid or has expired'); + } } } - } else { - $this->reason = 'iss, aud, deployment_id and/or nonce claim not found'; + } + $this->messageParameters = []; + if ($this->ok) { + $this->messageParameters['oauth_consumer_key'] = $aud; + $this->messageParameters['oauth_signature_method'] = $this->jwt->getHeader('alg'); + $this->parseClaims($generateWarnings); } } - } catch (\Exception $e) { - $this->ok = false; - $this->reason = 'Message does not contain a valid JWT'; } - } elseif (isset($this->rawParameters['error'])) { // Error with JWT-signed message - $this->ok = false; - $this->reason = $this->rawParameters['error']; - if (!empty($this->rawParameters['error_description'])) { - $this->reason .= ": {$this->rawParameters['error_description']}"; + } catch (\Exception $e) { + $this->setReason('Message does not contain a valid JWT'); + } + } elseif (isset($this->rawParameters['error'])) { // Error with JWT-signed message + $reason = $this->rawParameters['error']; + if (!empty($this->rawParameters['error_description'])) { + $reason .= ": {$this->rawParameters['error_description']}"; + } + $this->setReason($reason); + } else { // OAuth + if ($this instanceof Tool) { + if (isset($this->rawParameters['oauth_consumer_key'])) { + $this->platform = Platform::fromConsumerKey($this->rawParameters['oauth_consumer_key'], $this->dataConnector); } - } else { // OAuth - if ($this instanceof Tool) { - if (isset($this->rawParameters['oauth_consumer_key'])) { - $this->platform = Platform::fromConsumerKey($this->rawParameters['oauth_consumer_key'], $this->dataConnector); - } - if (isset($this->rawParameters['tool_state'])) { // Relaunch? - $state = $this->rawParameters['tool_state']; - if (!$disableCookieCheck) { - $parts = explode('.', $state); - if (empty($_COOKIE) && !isset($_POST['_new_window'])) { // Reopen in a new window - Util::setTestCookie(); - $_POST['_new_window'] = ''; - echo Util::sendForm($_SERVER['REQUEST_URI'], $_POST, '_blank'); - exit; - } elseif (!empty(session_id()) && (count($parts) > 1) && (session_id() !== $parts[1])) { // Reset to original session - session_abort(); - session_id($parts[1]); - session_start(); - $this->onResetSessionId(); - } - unset($this->rawParameters['_new_window']); - Util::setTestCookie(true); - } - $nonce = new PlatformNonce($this->platform, $state); - $this->ok = $nonce->load(); - if (!$this->ok) { - $this->reason = "Invalid tool_state parameter: '{$state}'"; + if (isset($this->rawParameters['tool_state'])) { // Relaunch? + $state = $this->rawParameters['tool_state']; + if (!$disableCookieCheck) { + $parts = explode('.', $state); + if (empty($_COOKIE) && !isset($_POST['_new_window'])) { // Reopen in a new window + Util::setTestCookie(); + $_POST['_new_window'] = ''; + echo Util::sendForm($_SERVER['REQUEST_URI'], $_POST, '_blank'); + exit; + } elseif (!empty(session_id()) && (count($parts) > 1) && (session_id() !== $parts[1])) { // Reset to original session + session_abort(); + session_id($parts[1]); + session_start(); + $this->onResetSessionId(); } + unset($this->rawParameters['_new_window']); + Util::setTestCookie(true); + } + $nonce = new PlatformNonce($this->platform, $state); + $this->ok = $nonce->load(); + if (!$this->ok) { + $this->setReason("Invalid tool_state parameter: '{$state}'"); } } - $this->messageParameters = $this->rawParameters; } + $this->messageParameters = $this->rawParameters; } } /** * Parse the claims * - * @param bool $strictMode True if full compliance with the LTI specification is required * @param bool $generateWarnings True if warning messages should be generated * * @return void */ - private function parseClaims(bool $strictMode, bool $generateWarnings): void + private function parseClaims(bool $generateWarnings): void { $payload = Util::cloneObject($this->jwt->getPayload()); - $errors = []; foreach (Util::JWT_CLAIM_MAPPING as $key => $mapping) { $claim = Util::JWT_CLAIM_PREFIX; if (!empty($mapping['suffix'])) { @@ -1431,34 +1431,39 @@ private function parseClaims(bool $strictMode, bool $generateWarnings): void if (is_array($group) && array_key_exists($mapping['claim'], $group)) { unset($payload->{$claim}[$mapping['claim']]); $value = $group[$mapping['claim']]; + $claim .= "[{$mapping['claim']}]"; } elseif (is_object($group) && isset($group->{$mapping['claim']})) { unset($payload->{$claim}->{$mapping['claim']}); $value = $group->{$mapping['claim']}; + $claim .= "/{$mapping['claim']}"; } } if (!is_null($value)) { if (isset($mapping['isArray']) && $mapping['isArray']) { if (!is_array($value)) { - $errors[] = "'{$claim}' claim must be an array"; - } else { + $value = $this->checkClaimArray($claim, $value, false, false, $generateWarnings); + } + if (is_array($value)) { $value = implode(',', $value); } - } elseif (isset($mapping['isObject']) && $mapping['isObject']) { - $value = json_encode($value); - } elseif (isset($mapping['isBoolean']) && $mapping['isBoolean']) { - $value = $value ? 'true' : 'false'; - } elseif (isset($mapping['isInteger']) && $mapping['isInteger']) { - $value = strval($value); - } elseif (!is_string($value)) { - if ($generateWarnings) { - $this->warnings[] = "Value of claim '{$claim}' is not a string: '{$value}'"; + } elseif (isset($mapping['isContentItemSelection']) && $mapping['isContentItemSelection']) { + $value = $this->checkClaimArray($claim, $value, false); + if (is_array($value)) { + $value = json_encode($value, JSON_UNESCAPED_SLASHES); } - if (!$strictMode) { - $value = strval($value); + } elseif (isset($mapping['isBoolean']) && $mapping['isBoolean']) { + $value = $this->checkClaimBoolean($claim, $value, false, $generateWarnings); + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; } + } elseif (isset($mapping['isInteger']) && $mapping['isInteger']) { + $value = $this->checkClaimInteger($claim, $value, false, $generateWarnings); + $value = Util::valToString($value); + } else { + $value = $this->checkClaimString($claim, $value, false, false, $generateWarnings); } } - if (!is_null($value) && is_string($value)) { + if (is_string($value)) { $this->messageParameters[$key] = $value; } } @@ -1494,41 +1499,67 @@ private function parseClaims(bool $strictMode, bool $generateWarnings): void $claim = Util::JWT_CLAIM_PREFIX . '/claim/custom'; if ($this->jwt->hasClaim($claim)) { unset($payload->{$claim}); - $custom = $this->jwt->getClaim($claim); - if (!is_array($custom) && !is_object($custom)) { - $errors[] = "'{$claim}' claim must be an object"; - } else { + $custom = $this->getClaimObject($claim, false); + if (is_object($custom)) { foreach ($custom as $key => $value) { - $this->messageParameters["custom_{$key}"] = $value; + if (!is_string($value)) { + if (Util::$strictMode) { + $this->setReason("Properties of the '{$claim}' claim object must have string values (" . gettype($value) . ' found)'); + } else { + Util::setMessage(false, + "Properties of the '{$claim}' claim object should have string values (" . gettype($value) . ' found)'); + $value = Util::valToString($value); + } + } + if (is_string($value)) { + $this->messageParameters["custom_{$key}"] = $value; + } } } } $claim = Util::JWT_CLAIM_PREFIX . '/claim/ext'; if ($this->jwt->hasClaim($claim)) { unset($payload->{$claim}); - $ext = $this->jwt->getClaim($claim); - if (!is_array($ext) && !is_object($ext)) { - $errors[] = "'{$claim}' claim must be an object"; - } else { + $ext = $this->getClaimObject($claim, false); + if (is_object($ext)) { foreach ($ext as $key => $value) { - $this->messageParameters["ext_{$key}"] = $value; + if (!is_string($value)) { + if (Util::$strictMode) { + $this->setReason("Properties of the '{$claim}' claim object must have string values (" . gettype($value) . ' found)'); + } else { + Util::setMessage(false, + "Properties of the '{$claim}' claim object should have string values (" . gettype($value) . ' found)'); + $value = Util::valToString($value); + } + } + if (is_string($value)) { + $this->messageParameters["ext_{$key}"] = $value; + } } } } $claim = Util::JWT_CLAIM_PREFIX . '/claim/lti1p1'; if ($this->jwt->hasClaim($claim)) { unset($payload->{$claim}); - $lti1p1 = $this->jwt->getClaim($claim); - if (!is_array($lti1p1) && !is_object($lti1p1)) { - $errors[] = "'{$claim}' claim must be an object"; - } else { + $lti1p1 = $this->getClaimObject($claim, false); + if (is_array($lti1p1)) { foreach ($lti1p1 as $key => $value) { if (is_null($value)) { $value = ''; } elseif (is_object($value)) { $value = json_encode($value); + } elseif (!is_string($value)) { + if (Util::$strictMode) { + $this->setReason("Properties of the '{$claim}' claim object must have string or object values (" . gettype($value) . ' found)'); + } else { + Util::setMessage(false, + "Properties of the '{$claim}' claim object should have string or object values (" . gettype($value) . ' found)'); + $value = Util::valToString($value); + } + } + if (is_string($value)) { + $this->messageParameters["lti1p1_{$key}"] = $value; } - $this->messageParameters["lti1p1_{$key}"] = $value; } } } @@ -1537,12 +1568,12 @@ private function parseClaims(bool $strictMode, bool $generateWarnings): void $d2l = $this->jwt->getClaim($claim); if (is_array($d2l)) { if (!empty($d2l['username'])) { - $this->messageParameters['ext_d2l_username'] = $d2l['username']; + $this->messageParameters['ext_d2l_username'] = Util::valToString($d2l['username']); unset($payload->{$claim}['username']); } } elseif (isset($ext) && is_object($ext)) { if (!empty($d2l->username)) { - $this->messageParameters['ext_d2l_username'] = $d2l->username; + $this->messageParameters['ext_d2l_username'] = Util::valToString($d2l->username); unset($payload->{$claim}->username); } } @@ -1556,10 +1587,6 @@ private function parseClaims(bool $strictMode, bool $generateWarnings): void } $this->messageParameters['unmapped_claims'] = json_encode($payload); } - if (!empty($errors)) { - $this->ok = false; - $this->reason = 'Invalid JWT: ' . implode(', ', $errors); - } } /** @@ -1579,8 +1606,7 @@ private function doCallback(): void if (method_exists($this, $callback)) { $this->$callback(); } elseif ($this->ok) { - $this->ok = false; - $this->reason = "Message type not supported: {$this->messageParameters['lti_message_type']}"; + $this->setReason("Message type not supported: {$this->messageParameters['lti_message_type']}"); } } @@ -1862,4 +1888,256 @@ private static function fullyQualifyClaim(string $claim, mixed $value): array return $claims; } + /** + * Check a JWT claim value is a string. + * + * @param string $name Name of claim + * @param mixed $value Value to check + * @param bool $required True if the claim must be present (optional, default is false) + * @param bool $notEmpty True if the claim must not be empty (optional, default is false) + * @param bool $generateWarnings True if warning messages should be generated (optional, default is false) + * + * @return string|null String value (or null if not valid) + */ + private function checkClaimString(string $name, mixed $value, bool $required = false, bool $notEmpty = false, + bool $generateWarnings = false): ?string + { + if (!is_null($value)) { + $type = gettype($value); + if (!is_string($value) && !Util::$strictMode) { + if ($generateWarnings) { + $this->warnings[] = "Value of claim '{$name}' is not a string: '{$value}'"; + Util::setMessage(false, "The '{$name}' claim should have a string value ({$type} found)"); + } + $value = Util::valToString($value, null); + } + if (!is_string($value)) { + $this->setReason("'{$name}' claim must have a string value ({$type} found)"); + $value = null; + } else { + $value = trim($value); + if ($notEmpty && empty($value)) { + $this->setReason("'{$name}' claim must not be empty"); + $value = null; + } + } + } elseif ($required) { + $this->setReason("Missing '{$name}' claim"); + } + + return $value; + } + + /** + * Get the named string claim from JWT. + * + * @param string $name Name of claim + * @param bool $required True if the claim must be present (optional, default is false) + * @param bool $notEmpty True if the claim must not be empty (optional, default is false) + * @param bool $generateWarnings True if warning messages should be generated (optional, default is false) + * + * @return string|null Value of element (or null if not found or valid) + */ + private function getClaimString(string $name, bool $required = false, bool $notEmpty = false, bool $generateWarnings = false): ?string + { + $value = $this->jwt->getClaim($name); + + return $this->checkClaimString($name, $value, $required, $notEmpty, $generateWarnings); + } + + /** + * Check a JWT claim value is an integer. + * + * @param string $name Name of claim + * @param mixed $value Value to check + * @param bool $required True if the claim must be present (optional, default is false) + * @param bool $generateWarnings True if warning messages should be generated (optional, default is false) + * + * @return int|null Value of element (or null if not found or valid) + */ + private function checkClaimInteger(string $name, mixed $value, bool $required = false, bool $generateWarnings = false): ?int + { + if (!is_null($value)) { + $type = gettype($value); + if (!is_int($value) && !Util::$strictMode) { + if ($generateWarnings) { + Util::setMessage(false, "The '{$name}' claim should have an integer value ({$type} found)"); + } + $value = Util::valToNumber($value); + if (is_float($value)) { + $value = intval($value); + } + } + if (!is_int($value)) { + $this->setReason("'{$name}' claim must have an integer value ({$type} found)"); + $value = null; + } + } elseif ($required) { + $this->setReason("Missing '{$name}' claim"); + } + + return $value; + } + + /** + * Get the named integer claim from JWT. + * + * @param string $name Name of claim + * @param bool $required True if the claim must be present (optional, default is false) + * @param bool $notEmpty True if the claim must not be empty (optional, default is false) + * @param bool $generateWarnings True if warning messages should be generated (optional, default is false) + * + * @return int|null Value of element (or null if not found or valid) + */ + private function getClaimInteger(string $name, bool $required = false, bool $generateWarnings = false): ?int + { + $value = $this->jwt->getClaim($name); + + return $this->checkClaimInteger($name, $value, $required, $generateWarnings); + } + + /** + * Check a JWT claim value is a boolean. + * + * @param string $name Name of claim + * @param mixed $value Value to check + * @param bool $required True if the claim must be present (optional, default is false) + * @param bool $generateWarnings True if warning messages should be generated (optional, default is false) + * + * @return bool|null Value of element (or null if not found or valid) + */ + private function checkClaimBoolean(string $name, mixed $value, bool $required = false, bool $generateWarnings = false): ?bool + { + if (!is_null($value)) { + $type = gettype($value); + if (!is_bool($value) && !Util::$strictMode) { + if ($generateWarnings) { + Util::setMessage(false, "The '{$name}' claim should have a boolean value ({$type} found)"); + } + $value = Util::valToBoolean($value); + } + if (!is_bool($value)) { + $this->setReason("'{$name}' claim must have a boolean value ({$type} found)"); + $value = null; + } + } elseif ($required) { + $this->setReason("Missing '{$name}' claim"); + } + + return $value; + } + + /** + * Get the named boolean claim from JWT. + * + * @param string $name Name of claim + * @param bool $required True if the claim must be present (optional, default is false) + * @param bool $notEmpty True if the claim must not be empty (optional, default is false) + * @param bool $generateWarnings True if warning messages should be generated (optional, default is false) + * + * @return bool|null Value of element (or null if not found or valid) + */ + private function getClaimBoolean(string $name, bool $required = false, bool $generateWarnings = false): ?bool + { + $value = $this->jwt->getClaim($name); + + return $this->checkClaimBoolean($name, $value, $required, $generateWarnings); + } + + /** + * Check a JWT claim value is an integer. + * + * @param string $name Name of claim + * @param mixed $value Value to check + * @param bool $required True if the claim must be present (optional, default is false) + * @param bool $notEmpty True if the claim must not be empty (optional, default is false) + * @param bool $generateWarnings True if warning messages should be generated (optional, default is false) + * + * @return array|null Value of element (or null if not found or valid) + */ + private function checkClaimArray(string $name, mixed $value, bool $required = false, bool $notEmpty = false, + bool $generateWarnings = false): ?array + { + if (!is_null($value)) { + $type = gettype($value); + if (!is_array($value) && !Util::$strictMode) { + if ($generateWarnings) { + Util::setMessage(false, "The '{$name}' claim should have an array value ({$type} found)"); + } + $value = Util::valToString($value, null); + if (is_string($value)) { + $value = [$value]; + } else { + $value = null; + } + } + if (!is_array($value)) { + $this->setReason("'{$name}' claim must have an array value ({$type} found)"); + $value = null; + } elseif ($notEmpty && empty($value)) { + $this->setReason("'{$name}' claim must not be empty"); + $value = null; + } + } elseif ($required) { + $this->setReason("Missing '{$name}' claim"); + } + + return $value; + } + + /** + * Get the named array claim from JWT. + * + * @param string $name Name of claim + * @param bool $required True if the claim must be present (optional, default is false) + * @param bool $notEmpty True if the claim must not be empty (optional, default is false) + * @param bool $generateWarnings True if warning messages should be generated (optional, default is false) + * + * @return array|null Value of element (or null if not found or valid) + */ + private function getClaimArray(string $name, bool $required = false, bool $notEmpty = false, bool $generateWarnings = false): ?array + { + $value = $this->jwt->getClaim($name); + + return $this->checkClaimArray($name, $value, $required, $notEmpty, $generateWarnings); + } + + /** + * Check a JWT claim value is an object. + * + * @param string $name Name of claim + * @param mixed $value Value to check + * @param bool $required True if the claim must be present (optional, default is false) + * + * @return object|null Value of element (or null if not found or valid) + */ + private function checkClaimObject(string $name, mixed $value, bool $required = false): ?object + { + if (!is_null($value)) { + if (!is_object($value)) { + $this->setReason("'{$name}' claim must be an object (" . gettype($value) . ' found)'); + $value = null; + } + } elseif ($required) { + $this->setReason("Missing '{$name}' claim"); + } + + return $value; + } + + /** + * Get the named object claim from JWT. + * + * @param string $name Name of claim + * @param bool $required True if the claim must be present (optional, default is false) + * + * @return object|null Value of element (or null if not found or valid) + */ + private function getClaimObject(string $name, bool $required = false): ?object + { + $value = $this->jwt->getClaim($name); + + return $this->checkClaimObject($name, $value, $required); + } + } diff --git a/src/Tool.php b/src/Tool.php index 11e3dfc..5bd8a8c 100644 --- a/src/Tool.php +++ b/src/Tool.php @@ -405,16 +405,22 @@ public function delete(): bool /** * Get the message parameters. * - * @param bool $strictMode True if full compliance with the LTI specification is required (optional, default is false) + * @param bool|null $strictMode True if full compliance with the LTI specification is required (optional, default is the Util::$strictMode setting) * @param bool $disableCookieCheck True if no cookie check should be made (optional, default is false) * @param bool $generateWarnings True if warning messages should be generated (optional, default is false) * * @return array|null The message parameter array */ - public function getMessageParameters(bool $strictMode = false, bool $disableCookieCheck = false, bool $generateWarnings = false): ?array + public function getMessageParameters(?bool $strictMode = null, bool $disableCookieCheck = false, bool $generateWarnings = false): ?array { - if (is_null($this->messageParameters)) { - $this->parseMessage($strictMode, $disableCookieCheck, $generateWarnings); + $currentStrictMode = Util::$strictMode; + if (!is_null($strictMode)) { + Util::$strictMode = $strictMode; + } + if ($this->ok && is_null($this->messageParameters)) { + $this->parseMessage($disableCookieCheck, $generateWarnings); + } + if ($this->ok && is_null($this->messageParameters)) { // Set debug mode if (!Util::$logLevel->logDebug()) { $this->debugMode = (isset($this->messageParameters['custom_debug']) && @@ -433,6 +439,7 @@ public function getMessageParameters(bool $strictMode = false, bool $disableCook $this->returnUrl = $this->messageParameters['launch_presentation_return_url']; } } + Util::$strictMode = $currentStrictMode; return $this->messageParameters; } @@ -440,12 +447,16 @@ public function getMessageParameters(bool $strictMode = false, bool $disableCook /** * Process an incoming request * - * @param bool $strictMode True if full compliance with the LTI specification is required (optional, default is false) + * @param bool|null $strictMode True if full compliance with the LTI specification is required (optional, default is the Util::$strictMode setting) * @param bool $disableCookieCheck True if no cookie check should be made (optional, default is false) * @param bool $generateWarnings True if warning messages should be generated (optional, default is false) */ - public function handleRequest(bool $strictMode = false, bool $disableCookieCheck = false, bool $generateWarnings = false): void + public function handleRequest(bool $strictMode = null, bool $disableCookieCheck = false, bool $generateWarnings = false): void { + $currentStrictMode = Util::$strictMode; + if (!is_null($strictMode)) { + Util::$strictMode = $strictMode; + } $parameters = Util::getRequestParameters(); if ($this->debugMode) { Util::$logLevel = LogLevel::Debug; @@ -455,26 +466,26 @@ public function handleRequest(bool $strictMode = false, bool $disableCookieCheck } elseif (isset($parameters['iss']) && (strlen($parameters['iss']) > 0)) { // Initiate login request Util::logRequest(); if (!isset($parameters['login_hint']) || (strlen($parameters['login_hint']) <= 0)) { - $this->ok = false; - $this->reason = 'Missing login_hint parameter.'; + $this->setReason('Missing \'login_hint\' parameter'); } elseif (!isset($parameters['target_link_uri']) || (strlen($parameters['target_link_uri']) <= 0)) { - $this->ok = false; - $this->reason = 'Missing target_link_uri parameter.'; + $this->setReason('Missing \'target_link_uri\' parameter'); } else { + Util::$strictMode = $currentStrictMode; $this->ok = $this->sendAuthenticationRequest($parameters, $disableCookieCheck); } } elseif (isset($parameters['openid_configuration']) && (strlen($parameters['openid_configuration']) > 0)) { // Dynamic registration request Util::logRequest(); $this->onRegistration(); } else { // LTI message - $this->getMessageParameters($strictMode, $disableCookieCheck, $generateWarnings); Util::logRequest(); - if ($this->ok && $this->authenticate($strictMode, $disableCookieCheck, $generateWarnings)) { - if (empty($this->output)) { - $this->doCallback(); - if ($this->ok && ($this->messageParameters['lti_message_type'] === 'ToolProxyRegistrationRequest')) { - $this->platform->save(); - } + $this->getMessageParameters($strictMode, $disableCookieCheck, $generateWarnings); + if (($this->ok || $generateWarnings) && !is_null($this->messageParameters)) { + $this->authenticate($disableCookieCheck, $generateWarnings); + } + if ($this->ok && empty($this->output)) { + $this->doCallback(); + if ($this->ok && ($this->messageParameters['lti_message_type'] === 'ToolProxyRegistrationRequest')) { + $this->platform->save(); } } } @@ -488,6 +499,7 @@ public function handleRequest(bool $strictMode = false, bool $disableCookieCheck } Util::logError($errorMessage); } + Util::$strictMode = $currentStrictMode; $this->result(); } @@ -595,7 +607,7 @@ public function doToolProxyService(): bool */ protected function onLaunch(): void { - $this->reason = 'No onLaunch method found for tool.'; + $this->setReason('No onLaunch method found for tool'); $this->onError(); } @@ -606,7 +618,7 @@ protected function onLaunch(): void */ protected function onConfigure(): void { - $this->reason = 'No onConfigure method found for tool.'; + $this->setReason('No onConfigure method found for tool'); $this->onError(); } @@ -617,7 +629,7 @@ protected function onConfigure(): void */ protected function onDashboard(): void { - $this->reason = 'No onDashboard method found for tool.'; + $this->setReason('No onDashboard method found for tool'); $this->onError(); } @@ -628,7 +640,7 @@ protected function onDashboard(): void */ protected function onContentItem(): void { - $this->reason = 'No onContentItem method found for tool.'; + $this->setReason('No onContentItem method found for tool'); $this->onError(); } @@ -639,7 +651,7 @@ protected function onContentItem(): void */ protected function onContentItemUpdate(): void { - $this->reason = 'No onContentItemUpdate method found for tool.'; + $this->setReason('No onContentItemUpdate method found for tool'); $this->onError(); } @@ -650,7 +662,7 @@ protected function onContentItemUpdate(): void */ protected function onSubmissionReview(): void { - $this->reason = 'No onSubmissionReview method found for tool.'; + $this->setReason('No onSubmissionReview method found for tool'); $this->onError(); } @@ -678,7 +690,7 @@ protected function onRegistration(): void */ protected function onLtiStartProctoring(): void { - $this->reason = 'No onLtiStartProctoring method found for tool.'; + $this->setReason('No onLtiStartProctoring method found for tool'); $this->onError(); } @@ -689,7 +701,7 @@ protected function onLtiStartProctoring(): void */ protected function onLtiEndAssessment(): void { - $this->reason = 'No onLtiEndAssessment method found for tool.'; + $this->setReason('No onLtiEndAssessment method found for tool'); $this->onError(); } @@ -742,12 +754,10 @@ protected function onAuthenticate(string $state, string $nonce, bool $usePlatfor $state = $parts[0]; $parts = explode('.', $this->rawParameters['_storage_check']); if ((count($parts) !== 2) || ($parts[0] !== $state) || ($parts[1] !== $nonce)) { - $this->ok = false; - $this->reason = 'Invalid state and/or nonce values'; + $this->setReason('Invalid \'state\' and/or \'nonce\' values'); } } else { - $this->ok = false; - $this->reason = 'Error accessing platform storage'; + $this->setReason('Error accessing platform storage'); } } elseif (isset($_SESSION['ceLTIc_lti_authentication_request'])) { $auth = $_SESSION['ceLTIc_lti_authentication_request']; @@ -755,8 +765,7 @@ protected function onAuthenticate(string $state, string $nonce, bool $usePlatfor $state = substr($state, 0, -16); } if (($state !== $auth['state']) || ($nonce !== $auth['nonce'])) { - $this->ok = false; - $this->reason = 'Invalid state parameter value and/or nonce claim value'; + $this->setReason('Invalid \'state\' parameter value and/or \'nonce\' claim value'); } unset($_SESSION['ceLTIc_lti_authentication_request']); } @@ -803,10 +812,10 @@ protected function getPlatformConfiguration(): ?array $this->ok = !empty($platformConfig); } if (!$this->ok) { - $this->reason = 'Unable to access platform configuration details.'; + $this->setReason('Unable to access platform configuration details'); } } else { - $this->reason = 'Invalid registration request: missing openid_configuration parameter.'; + $this->setReason('Invalid registration request: missing \'openid_configuration\ parameter'); } if ($this->ok) { $this->ok = !empty($platformConfig['registration_endpoint']) && !empty($platformConfig['jwks_uri']) && !empty($platformConfig['authorization_endpoint']) && @@ -817,7 +826,7 @@ protected function getPlatformConfiguration(): ?array !empty($platformConfig['https://purl.imsglobal.org/spec/lti-platform-configuration']['version']) && !empty($platformConfig['https://purl.imsglobal.org/spec/lti-platform-configuration']['messages_supported']); if (!$this->ok) { - $this->reason = 'Invalid platform configuration details.'; + $this->setReason('Invalid platform configuration details'); } } if ($this->ok) { @@ -828,7 +837,7 @@ protected function getPlatformConfiguration(): ?array if ($this->ok) { rsort($platformConfig['id_token_signing_alg_values_supported']); } else { - $this->reason = 'None of the signature algorithms offered by the platform is supported.'; + $this->setReason('None of the signature algorithms offered by the platform is supported'); } } } @@ -976,7 +985,7 @@ protected function sendRegistration(array $platformConfig, array $toolConfig): ? $this->ok = !empty($registrationConfig); } if (!$this->ok) { - $this->reason = 'Unable to register with platform.'; + $this->setReason('Unable to register with platform'); } } if (!$this->ok) { @@ -1019,7 +1028,7 @@ protected function getPlatformToRegister(array $platformConfig, array $registrat if ($doSave) { $this->ok = $this->platform->save(); if (!$this->ok) { - $this->reason = 'Sorry, an error occurred when saving the platform details.'; + $this->setReason('Sorry, an error occurred when saving the platform details'); } } @@ -1278,42 +1287,45 @@ private function result(): void * * The platform, resource link and user objects will be initialised if the request is valid. * - * @param bool $strictMode True if full compliance with the LTI specification is required * @param bool $disableCookieCheck True if no cookie check should be made * @param bool $generateWarnings True if warning messages should be generated * - * @return bool True if the request has been successfully validated. + * @return void */ - private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $generateWarnings): bool + private function authenticate(bool $disableCookieCheck, bool $generateWarnings): void { $doSavePlatform = false; - $this->ok = $this->checkMessage(); - if (($this->ok || $generateWarnings) && !empty($this->jwt) && !empty($this->jwt->hasJwt())) { - if ($this->jwt->hasClaim('sub') && (strlen($this->jwt->getClaim('sub')) <= 0)) { - $this->setError('Empty sub claim', $strictMode, $generateWarnings); + $this->checkMessage($generateWarnings); + if (!empty($this->jwt) && !empty($this->jwt->hasJwt())) { + if ($this->ok || $generateWarnings) { + $sub = $this->getClaimString('sub', true, true, $generateWarnings); } - if (!empty($this->jwt->getClaim('https://purl.imsglobal.org/spec/lti/claim/context', '')) && - empty($this->messageParameters['context_id'])) { - $this->setError('Missing id property in https://purl.imsglobal.org/spec/lti/claim/context claim', $strictMode, - $generateWarnings); - } elseif (!empty($this->jwt->getClaim('https://purl.imsglobal.org/spec/lti/claim/tool_platform', '')) && - empty($this->messageParameters['tool_consumer_instance_guid'])) { - $this->setError('Missing guid property in https://purl.imsglobal.org/spec/lti/claim/tool_platform claim', - $strictMode, $generateWarnings); + if ($this->ok || $generateWarnings) { + if (!empty($this->jwt->getClaim('https://purl.imsglobal.org/spec/lti/claim/context', '')) && + empty($this->messageParameters['context_id'])) { + $this->setError('Missing \'id\' property in \'https://purl.imsglobal.org/spec/lti/claim/context\' claim', + Util::$strictMode, $generateWarnings); + } + } + if ($this->ok || $generateWarnings) { + if (!empty($this->jwt->getClaim('https://purl.imsglobal.org/spec/lti/claim/tool_platform', '')) && + empty($this->messageParameters['tool_consumer_instance_guid'])) { + $this->setError('Missing \'guid\' property in \'https://purl.imsglobal.org/spec/lti/claim/tool_platform\' claim', + Util::$strictMode, $generateWarnings); + } } } if (($this->ok || $generateWarnings) && !empty($this->messageParameters['lti_message_type'])) { if ($this->messageParameters['lti_message_type'] === 'basic-lti-launch-request') { if ($this->ok && (!isset($this->messageParameters['resource_link_id']) || (strlen(trim($this->messageParameters['resource_link_id'])) <= 0))) { - $this->ok = false; - $this->reason = 'Missing resource link ID.'; + $this->setReason('Missing resource link ID', true, $generateWarnings); } if ($this->ltiVersion === LtiVersion::V1P3) { if (!isset($this->messageParameters['roles'])) { - $this->setError('Missing roles parameter.', $strictMode, $generateWarnings); + $this->setError('Missing roles parameter', Util::$strictMode, $generateWarnings); } elseif (!empty($this->messageParameters['roles']) && empty(array_intersect(self::parseRoles($this->messageParameters['roles'], LtiVersion::V1P3), User::PRINCIPAL_ROLES))) { - $this->setError('No principal role found in roles parameter.', $strictMode, $generateWarnings); + $this->setError('No principal role found in roles parameter', Util::$strictMode, $generateWarnings); } } } elseif (($this->messageParameters['lti_message_type'] === 'ContentItemSelectionRequest') || @@ -1329,18 +1341,18 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ $mediaTypes = array_unique($mediaTypes); } if ((count($mediaTypes) <= 0) && ($this->ltiVersion === LtiVersion::V1P3)) { - $this->setError('Missing or empty accept_media_types parameter.', $strictMode, $generateWarnings); + $this->setError('Missing or empty accept_media_types parameter', Util::$strictMode, $generateWarnings); } if ($isUpdate) { if ($this->ltiVersion === LtiVersion::V1P3) { if (!$this->checkValue($this->messageParameters['accept_media_types'], [Item::LTI_LINK_MEDIA_TYPE, Item::LTI_ASSIGNMENT_MEDIA_TYPE], - 'Invalid value in accept_media_types parameter: \'%s\'.', $strictMode, $generateWarnings, true)) { + 'Invalid value in accept_media_types parameter: \'%s\'', $generateWarnings, true)) { $this->ok = false; } } elseif (!$this->checkValue($this->messageParameters['accept_types'], - [Item::TYPE_LTI_LINK, Item::TYPE_LTI_ASSIGNMENT], 'Invalid value in accept_types parameter: \'%s\'.', - $strictMode, $generateWarnings, true)) { + [Item::TYPE_LTI_LINK, Item::TYPE_LTI_ASSIGNMENT], 'Invalid value in accept_types parameter: \'%s\'', + $generateWarnings, true)) { $this->ok = false; } } @@ -1372,7 +1384,7 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ $documentTargets = array_filter($documentTargets); $documentTargets = array_unique($documentTargets); if (count($documentTargets) <= 0) { - $this->setError('Missing or empty accept_presentation_document_targets parameter.', $strictMode, + $this->setError('Missing or empty accept_presentation_document_targets parameter', Util::$strictMode, $generateWarnings); } elseif (!empty($documentTargets)) { if (empty($this->jwt) || !$this->jwt->hasJwt()) { @@ -1382,18 +1394,18 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ } foreach ($documentTargets as $documentTarget) { if (!$this->checkValue($documentTarget, $permittedTargets, - 'Invalid value in accept_presentation_document_targets parameter: \'%s\'.', $strictMode, - $generateWarnings, true)) { + 'Invalid value in accept_presentation_document_targets parameter: \'%s\'', $generateWarnings, + true)) { $this->ok = false; } } } } else { - $this->setError('No accept_presentation_document_targets parameter found.', $strictMode, $generateWarnings); + $this->setError('No accept_presentation_document_targets parameter found', Util::$strictMode, $generateWarnings); } if ($this->ok || $generateWarnings) { if (empty($this->messageParameters['content_item_return_url'])) { - $this->setError('Missing content_item_return_url parameter.', true, $generateWarnings); + $this->setError('Missing content_item_return_url parameter', true, $generateWarnings); } } if ($this->ok) { @@ -1404,19 +1416,19 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ } } elseif ($this->messageParameters['lti_message_type'] === 'LtiSubmissionReviewRequest') { if (!isset($this->messageParameters['custom_lineitem_url']) || (strlen(trim($this->messageParameters['custom_lineitem_url'])) <= 0)) { - $this->setError('Missing LineItem service URL.', true, $generateWarnings); + $this->setError('Missing LineItem service URL', true, $generateWarnings); } if (!isset($this->messageParameters['for_user_id']) || (strlen(trim($this->messageParameters['for_user_id'])) <= 0)) { $this->setError('Missing ID of \'for user\'', true, $generateWarnings); } if (($this->ok || $generateWarnings) && ($this->ltiVersion === LtiVersion::V1P3)) { if (!isset($this->messageParameters['roles'])) { - $this->setError('Missing roles parameter.', $strictMode, $generateWarnings); + $this->setError('Missing roles parameter', Util::$strictMode, $generateWarnings); } } } elseif ($this->messageParameters['lti_message_type'] === 'ToolProxyRegistrationRequest') { if (!method_exists($this, 'onRegister')) { - $this->setError('No onRegister method found for tool.', true, $generateWarnings); + $this->setError('No onRegister method found for tool', true, $generateWarnings); } elseif ((!isset($this->messageParameters['reg_key']) || (strlen(trim($this->messageParameters['reg_key'])) <= 0)) || (!isset($this->messageParameters['reg_password']) || @@ -1425,18 +1437,18 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ (strlen(trim($this->messageParameters['tc_profile_url'])) <= 0)) || (!isset($this->messageParameters['launch_presentation_return_url']) || (strlen(trim($this->messageParameters['launch_presentation_return_url'])) <= 0))) { - $this->setError('Missing message parameters.', true, $generateWarnings); + $this->setError('Missing message parameters', true, $generateWarnings); } } elseif ($this->messageParameters['lti_message_type'] === 'LtiStartProctoring') { if (!isset($this->messageParameters['resource_link_id']) || (strlen(trim($this->messageParameters['resource_link_id'])) <= 0)) { - $this->setError('Missing resource link ID.', true, $generateWarnings); + $this->setError('Missing resource link ID', true, $generateWarnings); } if (!isset($this->messageParameters['custom_ap_attempt_number']) || (strlen(trim($this->messageParameters['custom_ap_attempt_number'])) <= 0) || !is_numeric($this->messageParameters['custom_ap_attempt_number'])) { - $this->setError('Missing or invalid value for attempt number.', true, $generateWarnings); + $this->setError('Missing or invalid value for attempt number', true, $generateWarnings); } if (!isset($this->messageParameters['user_id']) || (strlen(trim($this->messageParameters['user_id'])) <= 0)) { - $this->setError('Empty user ID.', true, $generateWarnings); + $this->setError('Empty user ID', true, $generateWarnings); } } } @@ -1444,7 +1456,7 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ if (isset($this->messageParameters['role_scope_mentor'])) { if (!isset($this->messageParameters['roles']) || !in_array('urn:lti:role:ims/lis/Mentor', self::parseRoles($this->messageParameters['roles']))) { - $this->setError('Found role_scope_mentor parameter without a Mentor role.', $strictMode, $generateWarnings); + $this->setError('Found role_scope_mentor parameter without a Mentor role', Util::$strictMode, $generateWarnings); } } } @@ -1453,13 +1465,13 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ ($this->messageParameters['lti_message_type'] !== 'ToolProxyRegistrationRequest')) { $now = time(); if (!isset($this->messageParameters['oauth_consumer_key'])) { - $this->setError('Missing consumer key.', true, $generateWarnings); + $this->setError('Missing consumer key', true, $generateWarnings); } if (is_null($this->platform->created)) { if (empty($this->jwt) || !$this->jwt->hasJwt()) { - $reason = "Consumer key not recognised: '{$this->messageParameters['oauth_consumer_key']}'."; + $reason = "Consumer key not recognised: '{$this->messageParameters['oauth_consumer_key']}'"; } else { - $reason = "Platform not recognised (Platform ID | Client ID | Deployment ID): '{$this->messageParameters['platform_id']}' | '{$this->messageParameters['oauth_consumer_key']}' | '{$this->messageParameters['deployment_id']}'."; + $reason = "Platform not recognised (Platform ID | Client ID | Deployment ID): '{$this->messageParameters['platform_id']}' | '{$this->messageParameters['oauth_consumer_key']}' | '{$this->messageParameters['deployment_id']}'"; } $this->setError($reason, true, $generateWarnings); } @@ -1476,26 +1488,26 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ $doSavePlatform = $doSavePlatform || ($last !== $today); } $this->platform->lastAccess = $now; - $this->ok = $this->verifySignature(); + $this->verifySignature(); } if ($this->ok) { if ($this->platform->protected) { if (!is_null($this->platform->consumerGuid)) { $this->ok = empty($this->messageParameters['tool_consumer_instance_guid']) || ($this->platform->consumerGuid === $this->messageParameters['tool_consumer_instance_guid']); if (!$this->ok) { - $this->reason = 'Request is from an invalid platform.'; + $this->setReason('Request is from an invalid platform'); } } else { $this->ok = isset($this->messageParameters['tool_consumer_instance_guid']); if (!$this->ok) { - $this->reason = 'A platform GUID must be included in the launch request as this configuration is protected.'; + $this->setReason('A platform GUID must be included in the launch request as this configuration is protected'); } } } if ($this->ok) { $this->ok = $this->platform->enabled; if (!$this->ok) { - $this->reason = 'Platform has not been enabled by the tool.'; + $this->setReason('Platform has not been enabled by the tool'); } } if ($this->ok) { @@ -1503,10 +1515,10 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ if ($this->ok) { $this->ok = is_null($this->platform->enableUntil) || ($this->platform->enableUntil > $now); if (!$this->ok) { - $this->reason = 'Platform access has expired.'; + $this->setReason('Platform access has expired'); } } else { - $this->reason = 'Platform access is not yet available.'; + $this->setReason('Platform access is not yet available'); } } } @@ -1528,8 +1540,8 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ } } if (!$found) { - $this->setError(sprintf('No valid value found in context_type parameter: \'%s\'.', - $this->messageParameters['context_type']), $strictMode, $generateWarnings); + $this->setError(sprintf('No valid value found in context_type parameter: \'%s\'', + $this->messageParameters['context_type']), Util::$strictMode, $generateWarnings); } } if (($this->ok || $generateWarnings) && !empty($this->messageParameters['lti_message_type']) && @@ -1537,55 +1549,54 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ $isUpdate = ($this->messageParameters['lti_message_type'] === 'ContentItemUpdateRequest'); if (isset($this->messageParameters['accept_unsigned']) && !$this->checkValue($this->messageParameters['accept_unsigned'], ['true', 'false'], - 'Invalid value for accept_unsigned parameter: \'%s\'.', $strictMode, $generateWarnings)) { + 'Invalid value for accept_unsigned parameter: \'%s\'', $generateWarnings)) { $this->ok = false; } if (isset($this->messageParameters['accept_multiple'])) { if (!$isUpdate) { if (!$this->checkValue($this->messageParameters['accept_multiple'], ['true', 'false'], - 'Invalid value for accept_multiple parameter: \'%s\'.', $strictMode, $generateWarnings)) { + 'Invalid value for accept_multiple parameter: \'%s\'', $generateWarnings)) { $this->ok = false; } } elseif (!$this->checkValue($this->messageParameters['accept_multiple'], ['false'], - 'Invalid value for accept_multiple parameter: \'%s\'.', $strictMode, $generateWarnings)) { + 'Invalid value for accept_multiple parameter: \'%s\'', $generateWarnings)) { $this->ok = false; } } if (isset($this->messageParameters['accept_copy_advice'])) { if (!$isUpdate) { if (!$this->checkValue($this->messageParameters['accept_copy_advice'], ['true', 'false'], - 'Invalid value for accept_copy_advice parameter: \'%s\'.', $strictMode, $generateWarnings)) { + 'Invalid value for accept_copy_advice parameter: \'%s\'', $generateWarnings)) { $this->ok = false; } } elseif (!$this->checkValue($this->messageParameters['accept_copy_advice'], ['false'], - 'Invalid value for accept_copy_advice parameter: \'%s\'.', $strictMode, $generateWarnings)) { + 'Invalid value for accept_copy_advice parameter: \'%s\'', $generateWarnings)) { $this->ok = false; } } if (isset($this->messageParameters['auto_create']) && !$this->checkValue($this->messageParameters['auto_create'], ['true', 'false'], - 'Invalid value for auto_create parameter: \'%s\'.', $strictMode, $generateWarnings)) { + 'Invalid value for auto_create parameter: \'%s\'', $generateWarnings)) { $this->ok = false; } if (isset($this->messageParameters['can_confirm']) && !$this->checkValue($this->messageParameters['can_confirm'], ['true', 'false'], - 'Invalid value for can_confirm parameter: \'%s\'.', $strictMode, $generateWarnings)) { + 'Invalid value for can_confirm parameter: \'%s\'', $generateWarnings)) { $this->ok = false; } } if (isset($this->messageParameters['launch_presentation_document_target'])) { if (!$this->checkValue($this->messageParameters['launch_presentation_document_target'], ['embed', 'frame', 'iframe', 'window', 'popup', 'overlay'], - 'Invalid value for launch_presentation_document_target parameter: \'%s\'.', $strictMode, - $generateWarnings, true)) { + 'Invalid value for launch_presentation_document_target parameter: \'%s\'', $generateWarnings, true)) { $this->ok = false; } if (($this->messageParameters['lti_message_type'] === 'LtiStartProctoring') && ($this->messageParameters['launch_presentation_document_target'] !== 'window')) { if (isset($this->messageParameters['launch_presentation_height']) || isset($this->messageParameters['launch_presentation_width'])) { - $this->setError('Height and width parameters must only be included for the window document target.', - $strictMode, $generateWarnings); + $this->setError('Height and width parameters must only be included for the window document target', + Util::$strictMode, $generateWarnings); } } } @@ -1597,7 +1608,7 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ } if (!empty($errors)) { $this->setError(sprintf('Custom parameters must have string values: \'%s\'', implode('\', \'', $errors)), - $strictMode, $generateWarnings); + Util::$strictMode, $generateWarnings); } } } @@ -1605,7 +1616,7 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ if ($this->ok && ($this->messageParameters['lti_message_type'] === 'ToolProxyRegistrationRequest')) { $this->ok = $this->ltiVersion === LtiVersion::V2; if (!$this->ok) { - $this->reason = 'Invalid lti_version parameter.'; + $this->setReason('Invalid \'lti_version\' parameter'); } if ($this->ok) { $url = $this->messageParameters['tc_profile_url']; @@ -1618,12 +1629,12 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ $http = new HttpMessage($url, 'GET', null, 'Accept: application/vnd.ims.lti.v2.toolconsumerprofile+json'); $this->ok = $http->send(); if (!$this->ok) { - $this->reason = 'Platform profile not accessible.'; + $this->setReason('Platform profile not accessible'); } else { $tcProfile = Util::jsonDecode($http->response); $this->ok = !is_null($tcProfile); if (!$this->ok) { - $this->reason = 'Invalid JSON in platform profile.'; + $this->setReason('Invalid JSON in platform profile'); } } } @@ -1650,8 +1661,7 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ } if (!empty($missing)) { ksort($missing); - $this->reason = 'Required capability not offered - \'' . implode('\', \'', array_keys($missing)) . '\'.'; - $this->ok = false; + $this->setReason('Required capability not offered - \'' . implode('\', \'', array_keys($missing)) . '\''); } } // Check for required services @@ -1660,15 +1670,18 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ foreach ($service->formats as $format) { if (!$this->findService($format, $service->actions)) { if ($this->ok) { - $this->reason = 'Required service(s) not offered - '; + $reason = 'Required service(s) not offered - '; $this->ok = false; } else { - $this->reason .= ', '; + $reason .= ', '; } - $this->reason .= "'{$format}' [" . implode(', ', $service->actions) . '].'; + $reason .= "'{$format}' [" . implode(', ', $service->actions) . ']'; } } } + if (!$this->ok) { + $this->setReason($reason); + } } if ($this->ok) { if ($this->messageParameters['lti_message_type'] === 'ToolProxyRegistrationRequest') { @@ -1707,8 +1720,7 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ if (isset($this->messageParameters['relaunch_url'])) { Util::logRequest(); if (empty($this->messageParameters['platform_state'])) { - $this->ok = false; - $this->reason = 'Missing or empty platform_state parameter.'; + $this->setReason('Missing or empty \'platform_state\' parameter'); } else { $this->ok = $this->sendRelaunchRequest($disableCookieCheck); } @@ -1733,10 +1745,7 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ } } if (count($invalidParameters) > 0) { - $this->ok = false; - if (empty($this->reason)) { - $this->reason = 'Invalid parameter(s): ' . implode(', ', $invalidParameters) . '.'; - } + $this->setReason('Invalid parameter(s): ' . implode(', ', $invalidParameters)); } if ($this->ok) { @@ -1986,23 +1995,20 @@ private function authenticate(bool $strictMode, bool $disableCookieCheck, bool $ } // Check if a share arrangement is in place for this resource link - $this->ok = $this->checkForShare(); + $this->checkForShare(); } } } } - - return $this->ok; } /** * Check if a share arrangement is in place. * - * @return bool True if no error is reported + * @return void */ - private function checkForShare(): bool + private function checkForShare(): void { - $ok = true; $doSaveResourceLink = true; $id = $this->resourceLink->primaryResourceLinkId; @@ -2010,20 +2016,19 @@ private function checkForShare(): bool $shareRequest = isset($this->messageParameters['custom_share_key']) && !empty($this->messageParameters['custom_share_key']); if ($shareRequest) { if (!$this->allowSharing) { - $ok = false; - $this->reason = 'Your sharing request has been refused because sharing is not being permitted.'; + $this->setReason('Your sharing request has been refused because sharing is not being permitted'); } else { // Check if this is a new share key $shareKey = new ResourceLinkShareKey($this->resourceLink, $this->messageParameters['custom_share_key']); if (!is_null($shareKey->resourceLinkId)) { // Update resource link with sharing primary resource link details $id = $shareKey->resourceLinkId; - $ok = ($id !== $this->resourceLink->getRecordId()); - if ($ok) { + $this->ok = ($id !== $this->resourceLink->getRecordId()); + if ($this->ok) { $this->resourceLink->primaryResourceLinkId = $id; $this->resourceLink->shareApproved = $shareKey->autoApprove; - $ok = $this->resourceLink->save(); - if ($ok) { + $this->ok = $this->resourceLink->save(); + if ($this->ok) { $doSaveResourceLink = false; $this->userResult->getResourceLink()->primaryResourceLinkId = $id; $this->userResult->getResourceLink()->shareApproved = $shareKey->autoApprove; @@ -2031,47 +2036,45 @@ private function checkForShare(): bool // Remove share key $shareKey->delete(); } else { - $this->reason = 'An error occurred initialising your share arrangement.'; + $this->setReason('An error occurred initialising your share arrangement'); } } else { - $this->reason = 'It is not possible to share your resource link with yourself.'; + $this->setReason('It is not possible to share your resource link with yourself'); } } - if ($ok) { - $ok = !is_null($id); - if (!$ok) { - $this->reason = 'You have requested to share a resource link but none is available.'; + if ($this->ok) { + $this->ok = !is_null($id); + if (!$this->ok) { + $this->setReason('You have requested to share a resource link but none is available'); } else { - $ok = (!is_null($this->userResult->getResourceLink()->shareApproved) && $this->userResult->getResourceLink()->shareApproved); - if (!$ok) { - $this->reason = 'Your share request is waiting to be approved.'; + $this->ok = (!is_null($this->userResult->getResourceLink()->shareApproved) && $this->userResult->getResourceLink()->shareApproved); + if (!$this->ok) { + $this->setReason('Your share request is waiting to be approved'); } } } } } else { // Check no share is in place - $ok = is_null($id); - if (!$ok) { - $this->reason = 'You have not requested to share a resource link but an arrangement is currently in place.'; + $this->ok = is_null($id); + if (!$this->ok) { + $this->setReason('You have not requested to share a resource link but an arrangement is currently in place'); } } // Look up primary resource link - if ($ok && !is_null($id)) { + if ($this->ok && !is_null($id)) { $resourceLink = ResourceLink::fromRecordId($id, $this->dataConnector); - $ok = !is_null($resourceLink->created); - if ($ok) { + $this->ok = !is_null($resourceLink->created); + if ($this->ok) { if ($doSaveResourceLink) { $this->resourceLink->save(); } $this->resourceLink = $resourceLink; } else { - $this->reason = 'Unable to load resource link being shared.'; + $this->setReason('Unable to load resource link being shared'); } } - - return $ok; } /** @@ -2101,7 +2104,7 @@ private function sendAuthenticationRequest(array $parameters, bool $disableCooki } $ok = !is_null($this->platform) && !empty($this->platform->authenticationUrl); if (!$ok) { - $this->reason = 'Platform not found or no platform authentication request URL.'; + $this->setReason('Platform not found or no platform authentication request URL'); } else { $oauthRequest = OAuth\OAuthRequest::from_request(); $usePlatformStorage = !empty($oauthRequest->get_parameter('lti_storage_target')); @@ -2182,7 +2185,7 @@ private function sendAuthenticationRequest(array $parameters, bool $disableCooki Util::redirect($this->platform->authenticationUrl, $params, '', $javascript); } } else { - $this->reason = 'Unable to generate a state value.'; + $this->setReason('Unable to generate a state value'); } } @@ -2226,7 +2229,7 @@ private function sendRelaunchRequest(bool $disableCookieCheck): bool $params = $this->platform->addSignature($this->messageParameters['relaunch_url'], $params); $this->output = Util::sendForm($this->messageParameters['relaunch_url'], $params); } else { - $this->reason = 'Unable to generate a state value.'; + $this->setReason('Unable to generate a state value'); } return $this->ok; @@ -2238,34 +2241,37 @@ private function sendRelaunchRequest(bool $disableCookieCheck): bool * @param string $value Value to be checked * @param array $values Array of permitted values * @param string $reason Reason to generate when the value is not permitted - * @param bool $strictMode True if full compliance with the LTI specification is required * @param bool $generateWarnings True if warning messages should be generated * @param bool $ignoreInvalid True if invalid values are to be ignored (optional default is false) * * @return bool True if value is valid */ - private function checkValue(string &$value, array $values, string $reason, bool $strictMode, bool $generateWarnings, - bool $ignoreInvalid = false): bool + private function checkValue(?string &$value, array $values, string $reason, bool $generateWarnings, bool $ignoreInvalid = false): bool { - $lookupValue = $value; - if (!$strictMode) { - $lookupValue = strtolower($value); - } - $ok = in_array($lookupValue, $values); - if (!$ok) { - if ($this->ok && $strictMode) { - $this->reason = sprintf($reason, $value); - } else { - $ok = true; + $ok = true; + if (!empty($value)) { + $lookupValue = $value; + if (!Util::$strictMode) { + $lookupValue = strtolower($value); + } + $ok = in_array($lookupValue, $values); + if (!$ok) { + if ($this->ok && Util::$strictMode) { + $this->setReason(sprintf($reason, $value)); + } else { + $ok = true; + if ($generateWarnings) { + $this->warnings[] = sprintf($reason, $value); + Util::setMessage(false, sprintf($reason, $value)); + } + } + } elseif ($lookupValue !== $value) { if ($generateWarnings) { - $this->warnings[] = sprintf($reason, $value); + $this->warnings[] = sprintf($reason, $value) . " [Changed to '{$lookupValue}']"; + Util::setMessage(false, sprintf($reason, $value) . " [Changed to '{$lookupValue}']"); } + $value = $lookupValue; } - } elseif ($lookupValue !== $value) { - if ($generateWarnings) { - $this->warnings[] = sprintf($reason, $value) . " [Changed to '{$lookupValue}']"; - } - $value = $lookupValue; } return $ok; @@ -2282,11 +2288,11 @@ private function checkValue(string &$value, array $values, string $reason, bool */ private function setError(string $reason, bool $strictMode, bool $generateWarnings): void { - if ($strictMode && $this->ok) { - $this->ok = false; - $this->reason = $reason; + if ($strictMode) { + $this->setReason($reason); } elseif ($generateWarnings) { $this->warnings[] = $reason; + Util::setMessage(false, $reason); } } diff --git a/src/Util.php b/src/Util.php index a8a3e9d..af3cc44 100644 --- a/src/Util.php +++ b/src/Util.php @@ -5,6 +5,7 @@ use ceLTIc\LTI\OAuth; use ceLTIc\LTI\Enum\LogLevel; +use ceLTIc\LTI\Jwt\ClientInterface; /** * Class to implement utility methods @@ -44,7 +45,7 @@ final class Util 'auto_create' => ['suffix' => 'dl', 'group' => 'deep_linking_settings', 'claim' => 'auto_create', 'isBoolean' => true], 'can_confirm' => ['suffix' => 'dl', 'group' => 'deep_linking_settings', 'claim' => 'can_confirm'], 'content_item_return_url' => ['suffix' => 'dl', 'group' => 'deep_linking_settings', 'claim' => 'deep_link_return_url'], - 'content_items' => ['suffix' => 'dl', 'group' => '', 'claim' => 'content_items', 'isObject' => true], + 'content_items' => ['suffix' => 'dl', 'group' => '', 'claim' => 'content_items', 'isContentItemSelection' => true], 'data' => ['suffix' => 'dl', 'group' => 'deep_linking_settings', 'claim' => 'data'], 'data.LtiDeepLinkingResponse' => ['suffix' => 'dl', 'group' => '', 'claim' => 'data'], 'text' => ['suffix' => 'dl', 'group' => 'deep_linking_settings', 'claim' => 'text'], @@ -77,6 +78,8 @@ final class Util 'role_scope_mentor' => ['suffix' => '', 'group' => '', 'claim' => 'role_scope_mentor', 'isArray' => true], 'platform_id' => ['suffix' => '', 'group' => null, 'claim' => 'iss'], 'deployment_id' => ['suffix' => '', 'group' => '', 'claim' => 'deployment_id'], + 'oauth_nonce' => ['suffix' => '', 'group' => null, 'claim' => 'nonce'], + 'oauth_timestamp' => ['suffix' => '', 'group' => null, 'claim' => 'iat', 'isInteger' => true], 'lti_message_type' => ['suffix' => '', 'group' => '', 'claim' => 'message_type'], 'lti_version' => ['suffix' => '', 'group' => '', 'claim' => 'version'], 'resource_link_description' => ['suffix' => '', 'group' => 'resource_link', 'claim' => 'description'], @@ -163,6 +166,13 @@ final class Util */ public static LogLevel $logLevel = LogLevel::None; + /** + * Whether full compliance with the LTI specification is required. + * + * @var bool $strictMode + */ + public static bool $strictMode = false; + /** * Delay (in seconds) before a manual button is displayed in case a browser is blocking a form submission. * @@ -170,6 +180,13 @@ final class Util */ public static int $formSubmissionTimeout = 2; + /** + * Messages relating to service request. + * + * @var array $messages + */ + private static array $messages = [true => [], false => []]; + /** * Check whether the request received could be an LTI message. * @@ -329,6 +346,33 @@ public static function log(string $message, bool $showSource = false): void error_log($message . $source); } + /** + * Set an error or warning message. + * + * @param bool $isError True if the message represents an error + * @param string $message Message + * + * @return void + */ + public static function setMessage(bool $isError, string $message): void + { + if (!in_array($message, self::$messages[$isError])) { + self::$messages[$isError][] = $message; + } + } + + /** + * Get the system error or warning messages. + * + * @param bool $isError True if error messages are to be returned + * + * @return array Array of messages + */ + public static function getMessages(bool $isError): array + { + return self::$messages[$isError]; + } + /** * Generate a web page containing an auto-submitted form of parameters. * @@ -540,17 +584,20 @@ public static function urlEncode(?string $val): string /** * Convert a value to a string. * - * @param mixed $val The value to be converted + * @param mixed $val The value to be converted + * @param string|null $default Value to return when a conversion is not possible (optional, default is an empty string) * * @return string */ - public static function valToString(mixed $val): string + public static function valToString(mixed $val, ?string $default = ''): string { if (!is_string($val)) { - if (is_scalar($val)) { + if (is_bool($val)) { + $val = ($val) ? 'true' : 'false'; + } elseif (is_scalar($val)) { $val = strval($val); } else { - $val = ''; + $val = $default; } } @@ -589,16 +636,309 @@ public static function valToNumber(mixed $val): int|float|null public static function valToBoolean(mixed $val): bool { if (!is_bool($val)) { - if ($val !== 'true') { - $val = false; - } else { + if (($val === 'true' || $val === 1)) { $val = true; + } else { + $val = false; } } return $val; } + /** + * Get the named object element from object. + * + * @param object $obj Object containing the element + * @param string $fullname Name of element + * @param bool $required True if the element must be present + * @param bool $stringValues True if the values must be strings + * + * @return object|null Value of element (or null if not found) + */ + public static function checkObject(object $obj, string $fullname, bool $required = false, bool $stringValues = false): ?object + { + $element = null; + $name = $fullname; + $pos = strrpos($name, '/'); + if ($pos !== false) { + $name = substr($name, $pos + 1); + } + if (isset($obj->{$name})) { + if (is_object($obj->{$name})) { + $element = $obj->{$name}; + if ($stringValues) { + foreach (get_object_vars($element) as $elementName => $elementValue) { + if (!is_string($elementValue)) { + if (!self::$strictMode) { + self::setMessage(false, + "Properties of the {$fullname} element should have a string value (" . gettype($elementValue) . ' found)'); + $element->{$elementName} = self::valToString($elementValue); + } else { + $element = null; + self::setMessage(true, + "Properties of the {$fullname} element must have a string value (" . gettype($elementValue) . ' found)'); + break; + } + } + } + } + } else { + self::setMessage(false, "The '{$fullname}' element must be an object (" . gettype($obj->{$name}) . ' found)'); + } + } elseif ($required) { + self::setMessage(true, "The '{$fullname}' element is missing"); + } + + return $element; + } + + /** + * Get the named array element from object. + * + * @param object $obj Object containing the element + * @param string $fullname Name of element + * @param bool $required True if the element must be present + * @param bool $notEmpty True if the element must not have an empty value + * + * @return array Value of element (or empty string if not found) + */ + public static function checkArray(object $obj, string $fullname, bool $required = false, bool $notEmpty = false): array + { + $arr = []; + $name = $fullname; + $pos = strrpos($name, '/'); + if ($pos !== false) { + $name = substr($name, $pos + 1); + } + if (isset($obj->{$name})) { + if (is_array($obj->{$name})) { + $arr = $obj->{$name}; + if ($notEmpty && empty($arr)) { + self::setMessage(true, "The '{$fullname}' element must not be empty"); + } + } elseif (self::$strictMode) { + self::setMessage(false, "The '{$fullname}' element must have an array value (" . gettype($obj->{$name}) . ' found)'); + } else { + self::setMessage(false, + "The '{$fullname}' element should have an array value (" . gettype($obj->{$name}) . ' found)'); + if (is_object($obj->{$name})) { + $arr = (array) $obj->{$name}; + } + } + } elseif ($required) { + self::setMessage(true, "The '{$fullname}' element is missing"); + } + + return $arr; + } + + /** + * Get the named string element from object. + * + * @param object $obj Object containing the element + * @param string $fullname Name of element (may include a path) + * @param bool $required True if the element must be present + * @param bool $notEmpty True if the element must not have an empty value + * @param string $fixedValue Required value of element (empty string if none) + * @param bool $overrideStrictMode Ignore strict mode setting + * @param string|null $default Value to return when a conversion is not possible (optional, default is an empty string) + * + * @return string Value of element (or default value if not found or valid) + */ + public static function checkString(object $obj, string $fullname, bool $required = false, bool $notEmpty = false, + string|array $fixedValue = '', bool $overrideStrictMode = false, ?string $default = ''): ?string + { + $value = $default; + $name = $fullname; + $pos = strrpos($name, '/'); + if ($pos !== false) { + $name = substr($name, $pos + 1); + } + if (isset($obj->{$name})) { + if (is_string($obj->{$name})) { + if (!empty($fixedValue) && is_string($fixedValue) && ($obj->{$name} !== $fixedValue)) { + if (self::$strictMode || $overrideStrictMode) { + self::setMessage(true, + "The '{$fullname}' element must have a value of '{$fixedValue}' ('{$obj->{$name}}' found)"); + } else { + self::setMessage(false, + "The '{$fullname}' element should have a value of '{$fixedValue}' ('{$obj->{$name}}' found)"); + } + } elseif (!empty($fixedValue) && is_array($fixedValue) && !in_array($obj->{$name}, $fixedValue)) { + self::setMessage(self::$strictMode || $overrideStrictMode, + "Value of the '{$fullname}' element not recognised ('{$obj->{$name}}' found)"); + } elseif ($notEmpty && empty($obj->{$name})) { + if (self::$strictMode || $overrideStrictMode) { + self::setMessage(true, "The '{$fullname}' element must not be empty"); + } else { + self::setMessage(false, "The '{$fullname}' element should not be empty"); + } + } else { + $value = $obj->{$name}; + } + } elseif ($required || !is_null($obj->{$name})) { + if (self::$strictMode || $overrideStrictMode) { + self::setMessage(true, + "The '{$fullname}' element must have a string value (" . gettype($obj->{$name}) . ' found)'); + } else { + self::setMessage(false, + "The '{$fullname}' element should have a string value (" . gettype($obj->{$name}) . ' found)'); + $value = self::valToString($obj->{$name}, $default); + } + } + } elseif ($required) { + self::setMessage(true, "The '{$fullname}' element is missing"); + } + + return $value; + } + + /** + * Get the named number element from object. + * + * @param object $obj Object containing the element + * @param string $fullname Name of element (may include a path) + * @param bool $required True if the element must be present + * @param int|false $minimum Minimum value (or false is none) + * @param bool $minimumExclusive True if value must exceed the minimum + * @param bool $overrideStrictMode Ignore strict mode setting + * + * @return int|float|null Value of element (or null if not found or valid) + */ + public static function checkNumber(object $obj, string $fullname, bool $required = false, int|false $minimum = false, + bool $minimumExclusive = false, bool $overrideStrictMode = false): int|float|null + { + $value = null; + $name = $fullname; + $pos = strrpos($name, '/'); + if ($pos !== false) { + $name = substr($name, $pos + 1); + } + if (isset($obj->{$name})) { + if (is_int($obj->{$name}) || is_float($obj->{$name})) { + if (($minimum !== false) && !$minimumExclusive && ($obj->{$name} < $minimum)) { + self::setMessage(true, "The '{$fullname}' element must have a numeric value of at least {$minimum}"); + } elseif (($minimum !== false) && $minimumExclusive && ($obj->{$name} <= $minimum)) { + self::setMessage(true, "The '{$fullname}' element must have a numeric value greater than {$minimum}"); + } else { + $value = $obj->{$name}; + } + } elseif ($required || !is_null($obj->{$name})) { + if (self::$strictMode || $overrideStrictMode) { + self::setMessage(true, + "The '{$fullname}' element must have a numeric value (" . gettype($obj->{$name}) . ' found)'); + } else { + self::setMessage(false, + "The '{$fullname}' element should have a numeric value (" . gettype($obj->{$name}) . ' found)'); + $value = self::valToNumber($obj->{$name}); + } + } + } elseif ($required) { + self::setMessage(true, "The '{$fullname}' element is missing"); + } + + return $value; + } + + /** + * Get the named integer element from object. + * + * @param object $obj Object containing the element + * @param string $fullname Name of element (may include a path) + * @param bool $required True if the element must be present + * @param int|false $minimum Minimum value (or false is none) + * @param bool $minimumExclusive True if value must exceed the minimum + * @param bool $overrideStrictMode Ignore strict mode setting + * + * @return int|null Value of element (or null if not found or valid) + */ + public static function checkInteger(object $obj, string $fullname, bool $required = false, int|false $minimum = false, + bool $minimumExclusive = false, bool $overrideStrictMode = false): int|null + { + $value = self::checkNumber($obj, $fullname, $required, $minimum, $minimumExclusive, $overrideStrictMode); + if (is_float($value)) { + if (self::$strictMode) { + self::setMessage(true, "The '{$fullname}' element must have an integer value"); + $value = null; + } else { + self::setMessage(false, "The '{$fullname}' element should have an integer value"); + $value = intval($value); + } + } + + return $value; + } + + /** + * Get the named boolean element from object. + * + * @param object $obj Object containing the element + * @param string $fullname Name of element (may include a path) + * @param bool $required True if the element must be present + * @param string|null $default Value to return when a conversion is not possible (optional, default is an empty string) + * + * @return bool|null Value of element (or null if not found or valid) + */ + public static function checkBoolean(object $obj, string $fullname, bool $required = false, ?bool $default = null): ?bool + { + $value = $default; + $name = $fullname; + $pos = strrpos($name, '/'); + if ($pos !== false) { + $name = substr($name, $pos + 1); + } + if (isset($obj->{$name})) { + if (is_bool($obj->{$name})) { + $value = $obj->{$name}; + } elseif (!self::$strictMode) { + self::setMessage(false, + "The '{$fullname}' element should have a boolean value (" . gettype($obj->{$name}) . ' found)'); + $value = self::valToBoolean($obj->{$name}); + } else { + self::setMessage(true, "The '{$fullname}' element must have a boolean value (" . gettype($obj->{$name}) . ' found)'); + } + } elseif ($required) { + self::setMessage(true, "The '{$fullname}' element is missing"); + } + + return $value; + } + + /** + * Get the named number element from object. + * + * @param object $obj Object containing the element + * @param string $fullname Name of element (may include a path) + * @param bool $required True if the element must be present + * + * @return int Value of element (or 0 if not found or valid) + */ + public static function checkDateTime(object $obj, string $fullname, bool $required = false): int + { + $value = 0; + $name = $fullname; + $pos = strrpos($name, '/'); + if ($pos !== false) { + $name = substr($name, $pos + 1); + } + if (isset($obj->{$name})) { + if (is_string($obj->{$name})) { + $value = strtotime($obj->{$name}); + if ($value === false) { + self::setMessage(true, "The '{$fullname}' element must have a datetime value"); + $value = 0; + } + } else { + self::setMessage(true, "The '{$fullname}' element must have a string value (" . gettype($obj->{$name}) . ' found)'); + } + } elseif ($required) { + self::setMessage(true, "The '{$fullname}' element is missing"); + } + + return $value; + } + /** * Decode a JSON string. *