Proyectos de Subversion Moodle

Rev

Autoría | Ultima modificación | Ver Log |

<?php declare(strict_types=1);

namespace EduSharingApiClient;

use Exception;
use JsonException;

/**
 * Class EduSharingNodeHelper
 *
 * @author Torsten Simon  <simon@edu-sharing.net>
 * @author Marian Ziegler <ziegler@edu-sharing.net>
 */
class EduSharingNodeHelper extends EduSharingHelperAbstract
{
    private EduSharingNodeHelperConfig $config;

    public function __construct(EduSharingHelperBase $base, EduSharingNodeHelperConfig $config) {
        parent::__construct($base);
        $this->config = $config;
    }

    /**
     * creates a usage for a given node
     * The given usage can later be used to fetch this node REGARDLESS of the actual user
     * The usage gives permanent access to this node and acts similar to a license
     * In order to be able to create an usage for a node, the current user (provided via the ticket)
     * MUST have CC_PUBLISH permissions on the given node id
     * @param string $ticket
     * A ticket with the user session who is creating this usage
     * @param string $containerId
     * A unique page / course id this usage refers to inside your system (e.g. a database id of the page you include the usage)
     * @param string $resourceId
     * The individual resource id on the current page or course this object refers to
     * (you may enumerate or use unique UUID's)
     * @param string $nodeId
     * The edu-sharing node id the usage shall be created for
     * @param string|null $nodeVersion
     * Optional: The fixed version this usage should refer to
     * If you leave it empty, the usage will always refer to the latest version of the node
     * @return Usage
     * An usage element you can use with @getNodeByUsage
     * Keep all data of this object stored inside your system!
     * @throws JsonException
     * @throws Exception
     */
    public function createUsage(string $ticket, string $containerId, string $resourceId, string $nodeId, string $nodeVersion = null): Usage {
        $headers   = $this->getSignatureHeaders($ticket);
        $headers[] = $this->getRESTAuthenticationHeader($ticket);
        $curl      = $this->base->handleCurlRequest($this->base->baseUrl . '/rest/usage/v1/usages/repository/-home-', [
            CURLOPT_FAILONERROR    => false,
            CURLOPT_POST           => 1,
            CURLOPT_POSTFIELDS     => json_encode([
                'appId'       => $this->base->appId,
                'courseId'    => $containerId,
                'resourceId'  => $resourceId,
                'nodeId'      => $nodeId,
                'nodeVersion' => $nodeVersion,
            ], 512, JSON_THROW_ON_ERROR),
            CURLOPT_RETURNTRANSFER => 1,
            CURLOPT_HTTPHEADER     => $headers
        ]);
        $data = json_decode($curl->content, true, 512, JSON_THROW_ON_ERROR);
        if ((int)$curl->info['http_code'] === 403 || (!empty($data['error']) && $data['message'] === 'NO_CCPUBLISH_PERMISSION')) {
            throw new MissingRightsException("User missing publish rights.");
        }
        if (empty($data['parentNodeId']) || empty($data['nodeId'])) {
            error_log('Creating usage failed for node: ' . $nodeId . '. Returned content: ' . $curl->content);
            throw new Exception('creating usage failed: ' . $nodeId);
        }
        if ($curl->error === 0 && $curl->info['http_code'] ?? 0 === 200 && empty($data['error'])) {
            return new Usage($data['parentNodeId'], $nodeVersion, $containerId, $resourceId, $data['nodeId']);
        }
        throw new Exception('creating usage failed ' . $curl->info['http_code'] . ': ' . $data['error'] . ' ' . $data['message']);
    }

    /**
     * @DEPRECATED
     * Function getUsageIdByParameters
     *
     * Returns the id of an usage object for a given node, container & resource id of that usage
     * This is only relevant for legacy plugins which do not store the usage id and need to fetch it in order to delete an usage
     * @param string $ticket
     * A ticket with the user session who is creating this usage
     * @param string $containerId
     * A unique page / course id this usage refers to inside your system (e.g. a database id of the page you include the usage)
     * @param string $resourceId
     * The individual resource id on the current page or course this object refers to
     * (you may enumerate or use unique UUID's)
     * @return string|null
     * The id of the usage, or NULL if no usage with the given data was found
     * @throws Exception
     */
    public function getUsageIdByParameters(string $ticket, string $nodeId, string $containerId, string $resourceId): ?string {
        $headers   = $this->getSignatureHeaders($ticket);
        $headers[] = $this->getRESTAuthenticationHeader($ticket);
        $curl      = $this->base->handleCurlRequest($this->base->baseUrl . '/rest/usage/v1/usages/node/' . rawurlencode($nodeId), [
            CURLOPT_FAILONERROR    => false,
            CURLOPT_RETURNTRANSFER => 1,
            CURLOPT_HTTPHEADER     => $headers
        ]);
        $data      = json_decode($curl->content, true, 512, JSON_THROW_ON_ERROR);
        if ($curl->error === 0 && $curl->info['http_code'] ?? 0 === 200 && isset($data['usages'])) {
            foreach ($data['usages'] as $usage) {
                if ((string)$usage['appId'] === $this->base->appId && (string)$usage['courseId'] === $containerId && (string)$usage['resourceId'] === $resourceId) {
                    return isset($usage['nodeId']) ? (string)$usage['nodeId'] : null;
                }
            }
            return null;
        }
        throw new Exception('fetching usage list for course failed '
            . ($curl->info['http_code'] ?? 'unknown') . ': ' . ($data['error'] ?? 'unknown') . ' ' . ($data['message'] ?? 'unknown'));
    }

    /**
     * Function getNodeByUsage
     *
     * Loads the edu-sharing node referred by a given usage
     * @param Usage $usage
     * The usage, as previously returned by @createUsage
     * @param string $displayMode
     * The displayMode
     * This will ONLY change the content representation inside the "detailsSnippet" return value
     * @param array|null $renderingParams
     * @param string|null $userId
     * The userId can be included for tracking and statistics purposes
     * @return array
     * Returns an object containing a "detailsSnippet" representation
     * as well as the full node as provided by the REST API
     * Please refer to the edu-sharing REST documentation for more details
     * @throws JsonException
     * @throws NodeDeletedException
     * @throws UsageDeletedException
     * @throws Exception
     */
    public function getNodeByUsage(Usage $usage, string $displayMode = DisplayMode::INLINE, ?array $renderingParams = null, ?string $userId = null): array {
        $url = $this->base->baseUrl . '/rest/rendering/v1/details/-home-/' . rawurlencode($usage->nodeId);
        $url .= '?displayMode=' . rawurlencode($displayMode);
        if ($usage->nodeVersion) {
            $url .= '&version=' . rawurlencode($usage->nodeVersion);
        }
        $headers = $this->getUsageSignatureHeaders($usage, $userId);
        $curl    = $this->base->handleCurlRequest($url, [
            CURLOPT_FAILONERROR    => false,
            CURLOPT_POST           => 1,
            CURLOPT_POSTFIELDS     => json_encode($renderingParams),
            CURLOPT_RETURNTRANSFER => 1,
            CURLOPT_HTTPHEADER     => $headers
        ]);
        $data    = json_decode($curl->content, true, 512, JSON_THROW_ON_ERROR);
        $this->handleURLMapping($data, $usage);
        if ($curl->error === 0 && (int)($curl->info['http_code'] ?? 0) === 200) {
            return $data;
        }
        if ((int)($curl->info['http_code'] ?? 0) === 403) {
            throw new UsageDeletedException('the given usage is deleted and the requested node is not public');
        } else if ((int)($curl->info['http_code'] ?? 0) === 404) {
            throw new NodeDeletedException('the given node is already deleted ' . $curl->info['http_code'] . ': ' . $data['error'] . ' ' . $data['message']);
        } else {
            throw new Exception('fetching node by usage failed ' . $curl->info['http_code'] . ': ' . $data['error'] . ' ' . $data['message']);
        }
    }

    /**
     * Function deleteUsage
     *
     * Deletes the given usage
     * We trust that you've validated if the current user in your context is allowed to do so
     * There is no restriction in deleting usages even from foreign users, as long as they were generated by your app
     * Thus, this endpoint does not require any user ticket
     * @param string $nodeId
     * The edu-sharing node id this usage belongs to
     * @param string $usageId
     * The usage id
     * @throws UsageDeletedException
     * @throws Exception
     */
    public function deleteUsage(string $nodeId, string $usageId): void {
        $headers = $this->getSignatureHeaders($nodeId . $usageId);
        $curl    = $this->base->handleCurlRequest($this->base->baseUrl . '/rest/usage/v1/usages/node/' . rawurlencode($nodeId) . '/' . rawurlencode($usageId), [
            CURLOPT_FAILONERROR    => false,
            CURLOPT_CUSTOMREQUEST  => 'DELETE',
            CURLOPT_RETURNTRANSFER => 1,
            CURLOPT_HTTPHEADER     => $headers
        ]);
        if ($curl->error === 0 && (int)($curl->info['http_code'] ?? 0) === 200) {
            return;
        }
        if ((int)($curl->info['http_code'] ?? 0) === 404) {
            throw new UsageDeletedException('the given usage is already deleted or does not exist');
        } else {
            throw new Exception('deleting usage failed with curl error ' . $curl->error);
        }
    }

    /**
     * Function handleURLMapping
     *
     * @param $data
     * @param Usage $usage
     */
    private function handleURLMapping(&$data, Usage $usage): void {
        if (!$this->config->urlHandling->enabled) {
            return;
        }
        if (isset($data['node'])) {
            $params = '&usageId=' . urlencode($usage->usageId) . '&nodeId=' . urlencode($usage->nodeId) . '&resourceId=' . urlencode($usage->resourceId) . '&containerId=' . urlencode($usage->containerId);
            if ($usage->nodeVersion) {
                $params .= '&nodeVersion=' . urlencode($usage->nodeVersion);
            }
            $endpointBase           = $this->config->urlHandling->endpointURL . (str_contains($this->config->urlHandling->endpointURL, '?') ? '&' : '?');
            $contentUrl             = $endpointBase . 'mode=content' . $params;
            $data['url']            = [
                'content'  => $contentUrl,
                'download' => $endpointBase . 'mode=download' . $params
            ];
            $data['detailsSnippet'] = str_replace('{{{LMS_INLINE_HELPER_SCRIPT}}}', $contentUrl, $data['detailsSnippet']);
            $data['detailsSnippet'] = str_replace('{{{TICKET}}}', '', $data['detailsSnippet']);
        }
    }

    /**
     * Function getRedirectUrl
     *
     * @param string $mode
     * @param Usage $usage
     * @param array $additionalParams
     * Additional query params that shall be passed to the repository url (as key=>value structure)
     * @param string|null $userId
     * The user id. Note: Due to the current behaviour, this userId will currently NOT obeyed for the tracking results
     * of this method, the statistics/tracking when going into the full view will always be anonymous
     * @return string
     * @throws JsonException
     * @throws NodeDeletedException
     * @throws UsageDeletedException
     * @throws Exception
     */
    public function getRedirectUrl(string $mode, Usage $usage, array $additionalParams = [], ?string $userId = null): string {
        $headers = $this->getUsageSignatureHeaders($usage);
        // DisplayMode::PRERENDER is used in order to differentiate for tracking and statistics
        $node    = $this->getNodeByUsage($usage, DisplayMode::PRERENDER, null, $userId);
        $params  = '';
        foreach ($headers as $header) {
            if (!str_starts_with($header, 'X-')) {
                continue;
            }
            $header = explode(': ', $header);
            $params .= '&' . $header[0] . '=' . urlencode($header[1]);
        }
        foreach($additionalParams as $key => $value) {
            foreach ($headers as $header) {
                if($header[0] === $key) {
                    continue(2);
                }
            }
            $params .= '&' . $key . '=' . urlencode($value);
        }
        if ($mode === 'content') {
            $url    = $node['node']['content']['url'] ?? '';
            $params .= '&closeOnBack=true';
        } else if ($mode === 'download') {
            $url = $node['node']['downloadUrl'] ?? '';
        } else {
            throw new Exception('Unknown parameter for mode: ' . $mode);
        }
        return $url . (str_contains($url, '?') ? '' : '?') . $params;
    }

    /**
     * Function getUsageSignatureHeaders
     *
     * @param Usage $usage
     * @param string|null $userId
     * @return array
     */
    private function getUsageSignatureHeaders(Usage $usage, ?string $userId = null): array {
        $headers   = $this->getSignatureHeaders($usage->usageId);
        $headers[] = 'X-Edu-Usage-Node-Id: ' . $usage->nodeId;
        $headers[] = 'X-Edu-Usage-Course-Id: ' . $usage->containerId;
        $headers[] = 'X-Edu-Usage-Resource-Id: ' . $usage->resourceId;
        if ($userId !== null) {
            $headers[] = 'X-Edu-User-Id: ' . $userId;
        }
        return $headers;
    }

    /**
     * Function getPreview
     *
     * @param Usage $usage
     * @return CurlResult
     */
    public function getPreview(Usage $usage): CurlResult {
        $url = $this->base->baseUrl . '/preview?nodeId=' . rawurlencode($usage->nodeId) . '&maxWidth=400&maxHeight=400&crop=true';
        if ($usage->nodeVersion) {
            $url .= '&version=' . rawurlencode($usage->nodeVersion);
        }
        $headers = $this->getUsageSignatureHeaders($usage);
        return $this->base->handleCurlRequest($url, [
            CURLOPT_FAILONERROR    => false,
            CURLOPT_RETURNTRANSFER => 1,
            CURLOPT_HTTPHEADER     => $headers
        ]);
    }
}