├── translations └── .gitignore ├── .dockerignore ├── sonar-project.properties ├── config ├── packages │ ├── test │ │ ├── twig.yaml │ │ ├── validator.yaml │ │ ├── framework.yaml │ │ └── monolog.yaml │ ├── routing.yaml │ ├── prod │ │ ├── routing.yaml │ │ ├── deprecations.yaml │ │ └── monolog.yaml │ ├── sensio_framework_extra.yaml │ ├── cache.yaml │ ├── dev │ │ ├── deprecations.yaml │ │ └── monolog.yaml │ ├── translation.yaml │ ├── validator.yaml │ ├── twig.yaml │ ├── framework.yaml │ ├── psr_http_message_bridge.yaml │ ├── nyholm_psr7.yaml │ ├── lti1p3.yaml │ └── security.yaml ├── routes │ ├── dev │ │ └── framework.yaml │ ├── framework.yaml │ ├── health_check.yaml │ └── lti1p3.yaml ├── preload.php ├── keys │ ├── public.key │ └── private.key ├── bundles.php ├── bootstrap.php └── devkit │ └── deep_linking.yaml ├── doc ├── images │ └── logo │ │ └── logo.png ├── api.md └── installation.md ├── .coderabbit.yaml ├── src ├── .preload.php ├── Action │ ├── Api │ │ ├── ApiActionInterface.php │ │ └── Platform │ │ │ ├── Proctoring │ │ │ ├── DeleteAssessmentAction.php │ │ │ ├── GetAssessmentAction.php │ │ │ ├── ListAssessmentsAction.php │ │ │ ├── UpdateAssessmentAction.php │ │ │ └── CreateAssessmentAction.php │ │ │ └── Nrps │ │ │ ├── DeleteMembershipAction.php │ │ │ ├── GetMembershipAction.php │ │ │ ├── ListMembershipsAction.php │ │ │ └── CreateMembershipAction.php │ ├── Util │ │ └── PhpInfoAction.php │ ├── Platform │ │ ├── Proctoring │ │ │ ├── ListAssessmentsAction.php │ │ │ ├── ViewAssessmentAction.php │ │ │ ├── DeleteAssessmentAction.php │ │ │ └── CreateAssessmentAction.php │ │ ├── Ags │ │ │ ├── ListLineItemsAction.php │ │ │ ├── ViewLineItemAction.php │ │ │ └── DeleteLineItemAction.php │ │ ├── BasicOutcome │ │ │ ├── ListBasicOutcomesAction.php │ │ │ └── DeleteBasicOutcomeAction.php │ │ ├── Nrps │ │ │ ├── ListMembershipsAction.php │ │ │ ├── ViewMembershipAction.php │ │ │ └── DeleteMembershipAction.php │ │ ├── Ajax │ │ │ └── RegistrationDefaultLaunchUrlAction.php │ │ └── Message │ │ │ ├── ProctoringReturnAction.php │ │ │ ├── DeepLinkingReturnAction.php │ │ │ └── ProctoringEndAction.php │ ├── DashboardAction.php │ └── Tool │ │ ├── Ajax │ │ ├── NrpsServiceClientAction.php │ │ ├── Ags │ │ │ ├── ListLineItemsServiceClientAction.php │ │ │ └── ListResultsServiceClientAction.php │ │ ├── AcsServiceClientAction.php │ │ └── BasicOutcomeServiceClientAction.php │ │ └── Message │ │ ├── ProctoringResponseAction.php │ │ └── DeepLinkingResponseAction.php ├── Security │ ├── Api │ │ ├── Token │ │ │ └── ApiKeyToken.php │ │ ├── Provider │ │ │ └── ApiKeyProvider.php │ │ └── Firewall │ │ │ └── ApiKeyListener.php │ └── User │ │ └── UserAuthenticator.php ├── DependencyInjection │ ├── Compiler │ │ ├── RedisPass.php │ │ └── ConfigurationPass.php │ └── Security │ │ └── Factory │ │ └── ApiKeyFactory.php ├── Request │ ├── Encoder │ │ └── Base64UrlEncoder.php │ └── ParamConverter │ │ └── AgsLineItemIdentifierConverter.php ├── Factory │ └── ScopeRepositoryFactory.php ├── Form │ ├── Generator │ │ └── FormShareUrlGenerator.php │ └── Platform │ │ └── Proctoring │ │ └── AssessmentType.php ├── Generator │ └── UrlGenerator.php ├── Proctoring │ ├── AcsServiceServerControlProcessor.php │ ├── AssessmentRepository.php │ └── Assessment.php ├── Nrps │ └── DefaultMembershipFactory.php └── Ags │ └── ScoreRepository.php ├── .env.test ├── tests ├── bootstrap.php └── Unit │ └── CoreTraitsTest.php ├── .gitignore ├── bin ├── phpunit └── console ├── php.ini ├── templates ├── tool │ ├── ajax │ │ ├── acs.html.twig │ │ ├── basic-outcome.html.twig │ │ └── ags │ │ │ ├── listResults.html.twig │ │ │ └── viewLineItem.html.twig │ └── message │ │ └── proctoringEnd.html.twig ├── error │ └── error.html.twig ├── notification │ └── flashes.html.twig ├── launch │ ├── blocks │ │ ├── claims.html.twig │ │ ├── platformSecurity.html.twig │ │ ├── message.html.twig │ │ ├── toolSecurity.html.twig │ │ ├── deepLinkingItem.html.twig │ │ ├── proctoringStartAssessment.html.twig │ │ ├── proctoringEndAssessment.html.twig │ │ └── identity.html.twig │ └── modal │ │ └── generatorShareModal.html.twig └── platform │ └── proctoring │ ├── createAssessment.html.twig │ └── editAssessment.html.twig ├── unit.json ├── docker ├── phpfpm │ └── Dockerfile ├── nginx │ └── nginx.conf └── kube │ └── Dockerfile ├── .env ├── cloudbuild.yaml ├── public └── index.php ├── .github └── workflows │ └── sonar.yml ├── phpunit.xml.dist ├── docker-compose.yml ├── README.md └── composer.json /translations/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | vendor/ -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=devkit-lti1p3 2 | -------------------------------------------------------------------------------- /config/packages/test/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | strict_variables: true 3 | -------------------------------------------------------------------------------- /config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | utf8: true 4 | -------------------------------------------------------------------------------- /config/packages/prod/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | strict_requirements: null 4 | -------------------------------------------------------------------------------- /doc/images/logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oat-sa/devkit-lti1p3/HEAD/doc/images/logo/logo.png -------------------------------------------------------------------------------- /config/packages/sensio_framework_extra.yaml: -------------------------------------------------------------------------------- 1 | sensio_framework_extra: 2 | router: 3 | annotations: false 4 | -------------------------------------------------------------------------------- /config/packages/test/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | not_compromised_password: false 4 | -------------------------------------------------------------------------------- /config/packages/test/framework.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | test: true 3 | session: 4 | storage_id: session.storage.mock_file 5 | -------------------------------------------------------------------------------- /config/routes/dev/framework.yaml: -------------------------------------------------------------------------------- 1 | _errors: 2 | resource: '@FrameworkBundle/Resources/config/routing/errors.xml' 3 | prefix: /_error 4 | -------------------------------------------------------------------------------- /.coderabbit.yaml: -------------------------------------------------------------------------------- 1 | remote_config: 2 | url: 'https://raw.githubusercontent.com/oat-sa/tao-code-quality/main/coderabbit/php/common/v1/.coderabbit.yaml' 3 | -------------------------------------------------------------------------------- /config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | app: cache.adapter.redis 4 | default_redis_provider: '%env(resolve:REDIS_CACHE_DSN)%' 5 | -------------------------------------------------------------------------------- /config/routes/framework.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | _errors: 3 | resource: '@FrameworkBundle/Resources/config/routing/errors.xml' 4 | prefix: /_error 5 | -------------------------------------------------------------------------------- /config/packages/dev/deprecations.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | channels: [deprecation] 3 | handlers: 4 | deprecation: 5 | type: "null" 6 | channels: [deprecation] 7 | -------------------------------------------------------------------------------- /config/packages/prod/deprecations.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | channels: [deprecation] 3 | handlers: 4 | deprecation: 5 | type: "null" 6 | channels: [deprecation] 7 | -------------------------------------------------------------------------------- /src/.preload.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 11 | } 12 | -------------------------------------------------------------------------------- /config/packages/test/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | handlers: 3 | main: 4 | type: fingers_crossed 5 | action_level: error 6 | handler: nested 7 | excluded_http_codes: [404, 405] 8 | channels: ["!event"] 9 | nested: 10 | type: stream 11 | path: "%kernel.logs_dir%/%kernel.environment%.log" 12 | level: debug 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ###> IDE ### 2 | /.idea/ 3 | ###> IDE ### 4 | 5 | ###> symfony/framework-bundle ### 6 | /.env.local 7 | /.env.local.php 8 | /.env.*.local 9 | /config/secrets/prod/prod.decrypt.private.php 10 | /public/bundles/ 11 | /var/ 12 | /vendor/ 13 | ###< symfony/framework-bundle ### 14 | 15 | ###> symfony/phpunit-bridge ### 16 | .phpunit 17 | .phpunit.result.cache 18 | /phpunit.xml 19 | ###< symfony/phpunit-bridge ### 20 | -------------------------------------------------------------------------------- /config/routes/lti1p3.yaml: -------------------------------------------------------------------------------- 1 | lti1p3_jwks: 2 | resource: '@Lti1p3Bundle/Resources/config/routing/jwks.yaml' 3 | 4 | lti1p3_message_platform: 5 | resource: '@Lti1p3Bundle/Resources/config/routing/message/platform.yaml' 6 | 7 | lti1p3_message_tool: 8 | resource: '@Lti1p3Bundle/Resources/config/routing/message/tool.yaml' 9 | 10 | lti1p3_service_platform: 11 | resource: '@Lti1p3Bundle/Resources/config/routing/service/platform.yaml' 12 | -------------------------------------------------------------------------------- /bin/phpunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 2 |
3 |
4 |  ACS service response 5 |
6 |
7 |
 8 |             {{ controlResult|json_encode(constant('JSON_PRETTY_PRINT') + constant('JSON_UNESCAPED_SLASHES')) }}
 9 |         
10 |
11 | 14 |
15 | -------------------------------------------------------------------------------- /templates/error/error.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block nav_title %}  Error{% endblock %} 4 | 5 | {% block body %} 6 |
7 | 12 | 13 |  Return to dashboard 14 | 15 |
16 | {% endblock body %} -------------------------------------------------------------------------------- /unit.json: -------------------------------------------------------------------------------- 1 | { 2 | "listeners": { "*:8080": { "pass": "routes/app" } }, 3 | 4 | "routes": { 5 | "app": [ 6 | { "match": { "uri": "/health-check" }, "action": { "return": 200 } }, 7 | { "action": { "pass": "applications/php_app" } } 8 | ] 9 | }, 10 | 11 | "applications": { 12 | "php_app": { 13 | "type": "php", 14 | "root": "/var/www/html", 15 | "script": "public/index.php", 16 | "processes": { "max": 16, "spare": 4 } 17 | } 18 | }, 19 | 20 | "settings": { 21 | "http": { 22 | "header_read_timeout": 10, 23 | "body_read_timeout": 10, 24 | "send_timeout": 10 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 6 | OAT\Bundle\Lti1p3Bundle\Lti1p3Bundle::class => ['all' => true], 7 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 8 | Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], 9 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 10 | OAT\Bundle\HealthCheckBundle\HealthCheckBundle::class => ['all' => true], 11 | Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true], 12 | ]; 13 | -------------------------------------------------------------------------------- /doc/api.md: -------------------------------------------------------------------------------- 1 | # HTTP API documentation 2 | 3 | ## Table of Contents 4 | 5 | - [HTTP API security](#http-api-security) 6 | - [HTTP API endpoints](#http-api-endpoints) 7 | 8 | ## HTTP API security 9 | 10 | Since this development kit can be registered with real LMS production instances, the HTTP API endpoints are **protected by an API key**. 11 | 12 | This API key is configurable on the [.env](../.env) file, in the `APP_API_KEY` environment variable. 13 | 14 | Every HTTP API endpoint request must provide this key as a token bearer via the request header `Authorization: Bearer `. 15 | 16 | ## HTTP API endpoints 17 | 18 | The development kit HTTP endpoints are described in the [openapi documentation](openapi/devkit.yaml). 19 | 20 | -------------------------------------------------------------------------------- /docker/phpfpm/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.4-fpm 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y libpng-dev libjpeg-dev libpq-dev libzip-dev zip unzip sudo wget zlib1g-dev libicu-dev libzstd-dev g++ && \ 5 | rm -rf /var/lib/apt/lists/* 6 | 7 | RUN yes | pecl update-channels 8 | 9 | RUN yes | pecl install igbinary redis 10 | 11 | RUN docker-php-ext-install intl && \ 12 | docker-php-ext-install gd && \ 13 | docker-php-ext-install opcache && \ 14 | docker-php-ext-install zip && \ 15 | docker-php-ext-install calendar && \ 16 | docker-php-ext-install sockets && \ 17 | docker-php-ext-enable igbinary && \ 18 | docker-php-ext-enable redis 19 | 20 | RUN rm -rf /var/www/html \ 21 | && chmod 0777 /tmp/ 22 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | ###> symfony/framework-bundle ### 2 | APP_ENV=dev 3 | APP_HOST=http://devkit-lti1p3.localhost 4 | APP_SECRET=3f33e29c19d9d24f0dccb90a3f84db04 5 | APP_API_KEY=dcf8cb90ac4db043f33e29d2419d93f0 6 | REDIS_CACHE_DSN=redis://devkit_lti1p3_redis:6379 7 | REDIS_CACHE_NAMESPACE=devkit 8 | REDIS_COMMANDER_URL=http://localhost:8081/ 9 | #TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 10 | #TRUSTED_HOSTS='^(localhost|example\.com)$' 11 | ###< symfony/framework-bundle ### 12 | 13 | ###> oat-sa/bundle-lti1p3 ### 14 | LTI1P3_SERVICE_ENCRYPTION_KEY=a725e29c19dabee77hdccb90a3f84db04 15 | ###< oat-sa/bundle-lti1p3 ### 16 | ###> symfony/lock ### 17 | # Choose one of the stores below 18 | # postgresql+advisory://db_user:db_password@localhost/db_name 19 | LOCK_DSN=flock 20 | ###< symfony/lock ### 21 | -------------------------------------------------------------------------------- /cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: "gcr.io/cloud-builders/docker" 3 | args: 4 | [ 5 | "build", 6 | '--cache-from', 7 | 'europe-west1-docker.pkg.dev/tao-artefacts/devkit/lti-devkit:master', 8 | "-t", 9 | "europe-west1-docker.pkg.dev/tao-artefacts/devkit/lti-devkit:$COMMIT_SHA", 10 | "-t", 11 | "europe-west1-docker.pkg.dev/tao-artefacts/devkit/lti-devkit:${_BRANCH_NAME}", 12 | -f, 13 | "./docker/kube/Dockerfile", 14 | ".", 15 | ] 16 | images: 17 | - "europe-west1-docker.pkg.dev/tao-artefacts/devkit/lti-devkit:$COMMIT_SHA" 18 | - 'europe-west1-docker.pkg.dev/tao-artefacts/devkit/lti-devkit:${_BRANCH_NAME}' 19 | 20 | substitutions: 21 | _BRANCH_NAME: ${BRANCH_NAME//\//-} 22 | options: 23 | machineType: E2_HIGHCPU_32 24 | dynamic_substitutions: true 25 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | handle($request); 26 | $response->send(); 27 | $kernel->terminate($request, $response); 28 | -------------------------------------------------------------------------------- /templates/notification/flashes.html.twig: -------------------------------------------------------------------------------- 1 | {% for type, messages in flashes %} 2 | {% for message in messages %} 3 | {% if type == 'error' %} 4 | {% set color = 'danger' %} 5 | {% set icon = 'exclamation-circle' %} 6 | {% elseif type == 'warning' %} 7 | {% set color = 'warning' %} 8 | {% set icon = 'exclamation-triangle' %} 9 | {% else %} 10 | {% set color = 'success' %} 11 | {% set icon = 'check-circle' %} 12 | {% endif %} 13 | 19 | {% endfor %} 20 | {% endfor %} 21 | 22 | -------------------------------------------------------------------------------- /docker/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name devkit_lti1p3.docker.localhost; 4 | root /var/www/html/public; 5 | 6 | sendfile off; 7 | 8 | client_max_body_size 8M; 9 | large_client_header_buffers 4 32k; 10 | 11 | location / { 12 | try_files $uri @rewriteapp; 13 | } 14 | 15 | location @rewriteapp { 16 | rewrite ^(.*)$ /index.php/$1 last; 17 | } 18 | 19 | location ~ ^/index\.php(/|$) { 20 | fastcgi_pass devkit_lti1p3_phpfpm:9000; 21 | fastcgi_split_path_info ^(.+\.php)(/.*)$; 22 | include fastcgi_params; 23 | fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; 24 | fastcgi_param PATH_INFO $fastcgi_path_info; 25 | fastcgi_buffers 64 64k; 26 | fastcgi_buffer_size 64k; 27 | } 28 | 29 | error_log /var/log/nginx/symfony_error.log; 30 | access_log /var/log/nginx/symfony_access.log; 31 | } 32 | -------------------------------------------------------------------------------- /templates/launch/blocks/claims.html.twig: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 |
7 |
 8 |     {{ token.payload.token.claims.all|json_encode(constant('JSON_PRETTY_PRINT') + constant('JSON_UNESCAPED_SLASHES')) }}
 9 | 
10 | 26 | -------------------------------------------------------------------------------- /config/packages/psr_http_message_bridge.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | 6 | Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface: 7 | '@Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory' 8 | 9 | Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface: 10 | '@Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory' 11 | 12 | Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory: null 13 | Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory: null 14 | 15 | # Uncomment the following line to allow controllers to receive a 16 | # PSR-7 server request object instead of an HttpFoundation request 17 | #Symfony\Bridge\PsrHttpMessage\ArgumentValueResolver\PsrServerRequestResolver: null 18 | 19 | # Uncomment the following line to allow controllers to return a 20 | # PSR-7 response object instead of an HttpFoundation response 21 | #Symfony\Bridge\PsrHttpMessage\EventListener\PsrResponseListener: null 22 | -------------------------------------------------------------------------------- /src/Action/Api/ApiActionInterface.php: -------------------------------------------------------------------------------- 1 |   Platform - ACS assessment creation{% endblock %} 4 | 5 | {% block body %} 6 |
7 |
8 |  Create assessment 9 |
10 |
11 | {{ form_start(form) }} 12 | {{ form_errors(form) }} 13 |
14 |
15 | {{ form_row(form.assessment_id) }} 16 |
17 |
18 | {{ form_row(form.assessment_status) }} 19 |
20 |
21 |
22 | 28 | {{ form_end(form) }} 29 |
30 | {% endblock body %} 31 | -------------------------------------------------------------------------------- /config/bootstrap.php: -------------------------------------------------------------------------------- 1 | =1.2) 9 | if (is_array($env = @include dirname(__DIR__).'/.env.local.php') && (!isset($env['APP_ENV']) || ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? $env['APP_ENV']) === $env['APP_ENV'])) { 10 | foreach ($env as $k => $v) { 11 | $_ENV[$k] = $_ENV[$k] ?? (isset($_SERVER[$k]) && 0 !== strpos($k, 'HTTP_') ? $_SERVER[$k] : $v); 12 | } 13 | } elseif (!class_exists(Dotenv::class)) { 14 | throw new RuntimeException('Please run "composer require symfony/dotenv" to load the ".env" files configuring the application.'); 15 | } else { 16 | // load all the .env files 17 | (new Dotenv())->loadEnv(dirname(__DIR__).'/.env'); 18 | } 19 | 20 | $_SERVER += $_ENV; 21 | $_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev'; 22 | $_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV']; 23 | $_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0'; 24 | -------------------------------------------------------------------------------- /src/Security/Api/Token/ApiKeyToken.php: -------------------------------------------------------------------------------- 1 | setAuthenticated(false); 32 | } 33 | 34 | public function getCredentials(): string 35 | { 36 | return ''; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /templates/launch/blocks/platformSecurity.html.twig: -------------------------------------------------------------------------------- 1 | {% if token.payload %} 2 | 6 | {% else %} 7 | 11 | {% endif %} 12 |
13 |
14 |  Token 15 |
16 |
17 |
18 |
JWT
19 |
20 |
21 |
22 | 23 | 24 | 25 |
26 | 27 |
28 |
29 |
30 |
31 |
32 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getParameterOption(['--env', '-e'], null, true)) { 23 | putenv('APP_ENV='.$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $env); 24 | } 25 | 26 | if ($input->hasParameterOption('--no-debug', true)) { 27 | putenv('APP_DEBUG='.$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = '0'); 28 | } 29 | 30 | require dirname(__DIR__).'/config/bootstrap.php'; 31 | 32 | if ($_SERVER['APP_DEBUG']) { 33 | umask(0000); 34 | 35 | if (class_exists(Debug::class)) { 36 | Debug::enable(); 37 | } 38 | } 39 | 40 | $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); 41 | $application = new Application($kernel); 42 | $application->run($input); 43 | -------------------------------------------------------------------------------- /templates/platform/proctoring/editAssessment.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block nav_title %}  Platform - ACS assessment edition{% endblock %} 4 | 5 | {% block body %} 6 |
7 |
8 |  Edit assessment {{ assessment.identifier }} 9 |
10 |
11 | {{ form_start(form) }} 12 | {{ form_errors(form) }} 13 |
14 |
15 | {{ form_row(form.assessment_id) }} 16 |
17 |
18 | {{ form_row(form.assessment_status) }} 19 |
20 |
21 |
22 | 28 | {{ form_end(form) }} 29 |
30 | {% endblock body %} 31 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/RedisPass.php: -------------------------------------------------------------------------------- 1 | getParameter('cache.redis.namespace'); 33 | $container 34 | ->getDefinition('cache.adapter.redis') 35 | ->setArgument('$namespace', $redisCacheNamespace); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | tests 22 | 23 | 24 | 25 | 26 | 27 | src 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 41 | 42 | -------------------------------------------------------------------------------- /src/Request/Encoder/Base64UrlEncoder.php: -------------------------------------------------------------------------------- 1 | 3 |   4 | Valid LTI message 5 | 6 | {% else %} 7 | 11 | {% endif %} 12 |
13 |
14 |
15 |  Message details 16 |
17 |
18 |
19 |
LTI version
20 |
{{ token.payload.version }}
21 |
LTI message type
22 |
{{ token.payload.messageType }}
23 |
24 |
25 |
26 |
27 |
28 |  Message validations 29 |
30 |
31 |
32 |
33 | {% for success in token.validationResult.successes %} 34 |  {{ success }}
35 | {% else %} 36 | n/a 37 | {% endfor %} 38 |
39 |
40 |
41 |
42 |
43 | -------------------------------------------------------------------------------- /src/DependencyInjection/Security/Factory/ApiKeyFactory.php: -------------------------------------------------------------------------------- 1 | setDefinition($providerId, new ChildDefinition(ApiKeyProvider::class)) 35 | ->setArgument(0, new Reference($userProvider)); 36 | 37 | $listenerId = 'security.authentication.listener.api_key.'.$id; 38 | $container->setDefinition($listenerId, new ChildDefinition(ApiKeyListener::class)); 39 | 40 | return [$providerId, $listenerId, $defaultEntryPoint]; 41 | } 42 | 43 | public function addConfiguration(NodeDefinition $node): void 44 | { 45 | return; 46 | } 47 | } -------------------------------------------------------------------------------- /src/Factory/ScopeRepositoryFactory.php: -------------------------------------------------------------------------------- 1 | parameterBag = $parameterBag; 37 | } 38 | 39 | public function create(): ScopeRepository 40 | { 41 | $scopes = []; 42 | 43 | foreach ($this->parameterBag->get('allowed_scopes') as $scope) { 44 | $scopes = new Scope($scope); 45 | } 46 | 47 | return new ScopeRepository($scopes); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /templates/tool/ajax/basic-outcome.html.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |  Basic outcome service response 5 |
6 |
7 |
8 |
9 |
10 |
Score
11 |
{{ response.score|default('n/a') }}
12 |
Language
13 |
{{ response.language|default('n/a') }}
14 |
Description
15 |
{{ response.description|default('n/a') }}
16 |
17 |
18 |
19 |
20 |
Status
21 | {% if response.success %} 22 |
 success
23 | {% else %} 24 |
 failure
25 | {% endif %} 26 |
Ref. identifier
27 |
{{ response.referenceRequestIdentifier|default('n/a') }}
28 |
Ref. operation
29 |
{{ response.referenceRequestType|default('n/a') }}
30 |
31 |
32 |
33 |
34 | 37 |
38 | -------------------------------------------------------------------------------- /config/keys/private.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAyZXlfd5yqChtTH91N76VokquRu2r1EwNDUjA0GAygrPzCpPb 3 | Yokasxzs+60Do/lyTIgd7nRzudAzHnujIPr8GOPIlPlOKT8HuL7xQEN6gmUtz33i 4 | DhK97zK7zOFEmvS8kYPwFAjQ03YKv+3T9b/DbrBZWy2Vx4Wuxf6mZBggKQfwHUuJ 5 | xXDv79NenZarUtC5iFEhJ85ovwjW7yMkcflhUgkf1o/GIR5RKoNPttMXhKYZ4hTl 6 | LglMm1FgRR63pvYoy9Eq644a9x2mbGelO3HnGbkaFo0HxiKbFW1vplHzixYCyjc1 7 | 5pvtBxw/x26p8+lNthuxzaX5HaFMPGs10rRPLwIDAQABAoIBAEFrF7KlQeg4VmAA 8 | 7PVOMBkeyzfRYXXfyEyLU6dL0JiB9Vl1ajir7bI6rN1v5EKObP1RHwWHXRbr7ery 9 | jJnjWXRqV2mhDjBseAEIVhpYy6UNWLSBUZ7njiPV/wQaBr2Stq8ydfLKeZpmME7g 10 | y2xw8uc064qfXIAHbhRq82yAN8+YY7CUKBvJquiiRVDffvbYWhrS4fIIsBNywoBZ 11 | PGoKUhE9RlR5nRyWvQmeKsBmM9bbzEIW4A5GHO5+6Fb2g1cbLx1poCZRta2oE/JJ 12 | qusChTZMXdGmEapT8w+sLqyZYtyPn+W8MuX5N9oZppYWIzUDlxHn6yqQPOj98aKu 13 | rsIu+aECgYEA+x047lp1C3R5dByfk9JwxTayTgfyrnCElV60+KVYxwJkZniMkBk7 14 | ncy0WtcOBQiwNYLCHMpx9IZkSlWbW0C3e2CgGmLahpi8yhBOzPiUinLY/V5xzbxK 15 | ojTkf3pYZo1viJG9MTUI1xEr9anZHM9GmqVnk8gJdi2a64wXGyvXxiUCgYEAzYH5 16 | 8oRuvWgT+4KXql8EUX57YQEDIpeqp4DJHMk5X5OWYZ8by5kyRNDLSvqf5kWnkRpK 17 | VMXuKdpVJXb3OWBvzymzxB/pBfTFFaBybsMQZUKpXfucRSfeQ0T6x1VVLozb6mNH 18 | T7q4nms1LiUidp7M09XtfwJYMlLu/prZ1yN+jcMCgYEA78e4nAiuKNEARDosumdA 19 | nWAcJFx8g8sXHtY/MgoY2nbTTfGgLtyZS5WzReaCEZZ5Q69nETzSUW6eh0h1P05t 20 | pZbfajKofcuMwdmOlTRsCkOeJVmwi4ZXMcoVwhAeJ1a4gIzBPiJpHYvdEQgtM9BM 21 | l9CHNdrWBg2IF5E+YwibUi0CgYBMKtpa2l648LRHSbWwvZq6IajU4S0qSxBDGOZx 22 | Ntt+4xKfh/sjUNpiywgt+An/rN1YWGgoV1vYQ0W/pwImT+ng3FH2ZOVXActyIo+H 23 | IeEXxsdDQBhBQW+Neyl/a88we6CelME/ebMndBC306ecU2sTMHzf0BykOjy+POJ0 24 | bIFhRQKBgBjfK/s6mbif311d4kEzT0aGIPK0STfD5FKDCazdZ3F+/aS8iorEVGou 25 | T7EwvdJvb13N9CpQGvN1nbSUc+rI3MY/Fa0m4FxE1A0FnS3AAVISgY3M4k2pjbIa 26 | 3LfQHtOPDKX0gaYFqf99w1Xwtib4E5PFaRcdUuuc1o8Xv315uh8p 27 | -----END RSA PRIVATE KEY----- -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/ConfigurationPass.php: -------------------------------------------------------------------------------- 1 | processConfiguration( 36 | new Configuration(), 37 | $container->getExtensionConfig(Lti1p3Extension::ALIAS) 38 | ); 39 | 40 | $container->setParameter('lti1p3_resolved_configuration', $configuration); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Form/Generator/FormShareUrlGenerator.php: -------------------------------------------------------------------------------- 1 | generator = $generator; 37 | } 38 | 39 | public function generate(string $url, FormInterface $form): string 40 | { 41 | $queryParams = array_map( 42 | static function ($value) { 43 | return $value instanceof RegistrationInterface 44 | ? $value->getIdentifier() 45 | : $value; 46 | }, 47 | $form->getData() 48 | ); 49 | 50 | return $this->generator->generate($url, $queryParams); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Action/Platform/Proctoring/ListAssessmentsAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 41 | $this->twig = $twig; 42 | } 43 | 44 | public function __invoke(Request $request): Response 45 | { 46 | return new Response( 47 | $this->twig->render( 48 | 'platform/proctoring/listAssessments.html.twig', 49 | [ 50 | 'assessments' => $this->repository->findAll() 51 | ] 52 | ) 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/Unit/CoreTraitsTest.php: -------------------------------------------------------------------------------- 1 | createTestRegistration(); 38 | 39 | $token = $this->buildJwt( 40 | [], 41 | [ 42 | 'some' => 'value' 43 | ], 44 | $registration->getPlatformKeyChain()->getPrivateKey() 45 | ); 46 | 47 | $parsedToken = $this->parseJwt($token->toString()); 48 | 49 | $this->assertTrue($this->verifyJwt($parsedToken, $registration->getPlatformKeyChain()->getPublicKey())); 50 | $this->assertEquals('value', $parsedToken->getClaims()->get('some')); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Request/ParamConverter/AgsLineItemIdentifierConverter.php: -------------------------------------------------------------------------------- 1 | getName(); 35 | 36 | if (!$request->attributes->has($param)) { 37 | return false; 38 | } 39 | 40 | $value = $request->attributes->get($param); 41 | $request->attributes->set($param, Base64UrlEncoder::decode($value)); 42 | 43 | return true; 44 | } 45 | 46 | public function supports(ParamConverter $configuration) 47 | { 48 | return $configuration->getName() === 'lineItemIdentifier'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /templates/tool/ajax/ags/listResults.html.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 | {% if results|length != 0 %} 4 | 7 |
8 | 9 |
10 | 11 |
12 |
13 |
14 |         {{ results|json_encode(constant('JSON_PRETTY_PRINT') + constant('JSON_UNESCAPED_SLASHES')) }}
15 |     
16 | {% else %} 17 | 20 | {% endif %} 21 | 22 | -------------------------------------------------------------------------------- /src/Action/Platform/Ags/ListLineItemsAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 41 | $this->twig = $twig; 42 | } 43 | 44 | public function __invoke(Request $request): Response 45 | { 46 | return new Response( 47 | $this->twig->render( 48 | 'platform/ags/listLineItems.html.twig', 49 | [ 50 | 'lineItems' => $this->repository->findCollection()->all() 51 | ] 52 | ) 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Action/Platform/BasicOutcome/ListBasicOutcomesAction.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 42 | $this->twig = $twig; 43 | } 44 | 45 | public function __invoke(Request $request): Response 46 | { 47 | return new Response( 48 | $this->twig->render( 49 | 'platform/basicOutcome/listBasicOutcomes.html.twig', 50 | [ 51 | 'basicOutcomes' => $this->cache->getItem(BasicOutcomeProcessor::CACHE_KEY)->get() 52 | ] 53 | ) 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /templates/launch/blocks/toolSecurity.html.twig: -------------------------------------------------------------------------------- 1 | {% if token.state %} 2 | 6 | {% else %} 7 | 11 | {% endif %} 12 |
13 |
14 |  Tokens 15 |
16 |
17 |
18 |
Id token
19 |
20 |
21 |
22 | 23 | 24 | 25 |
26 | 27 |
28 |
29 |
OIDC state
30 |
31 |
32 |
33 | 34 | 35 | 36 |
37 | 38 |
39 |
40 |
41 |
42 |
43 | -------------------------------------------------------------------------------- /src/Generator/UrlGenerator.php: -------------------------------------------------------------------------------- 1 | router = $router; 39 | $this->parameterBag = $parameterBag; 40 | } 41 | 42 | public function generate(string $routeName, array $routeParameters = []): string 43 | { 44 | if ($this->parameterBag->has('application_host')) { 45 | return sprintf( 46 | '%s%s', 47 | $this->parameterBag->get('application_host'), 48 | $this->router->generate($routeName, $routeParameters, RouterInterface::ABSOLUTE_PATH) 49 | ); 50 | } 51 | 52 | return $this->router->generate($routeName, $routeParameters, RouterInterface::ABSOLUTE_URL); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /config/devkit/deep_linking.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | deeplinking_resources: 3 | ltiResourceLinkExample: 4 | type: ltiResourceLink 5 | url: "%application_host%/tool/launch" 6 | title: Launch LTI 1.3 DevKit 7 | text: LTI resource link to launch LTI 1.3 DevKit 8 | icon: 9 | url: https://www.taotesting.com/wp-content/uploads/2019/04/favicon-1-300x300.png 10 | thumbnail: 11 | url: https://www.taotesting.com/wp-content/uploads/2019/04/favicon-1-300x300.png 12 | imageExample: 13 | type: image 14 | url: https://www.disneyphile.fr/wp-content/uploads/2020/10/fresque-star-wars.png 15 | title: Star wars (image) 16 | text: Star wars image 17 | icon: 18 | url: https://cdn4.iconfinder.com/data/icons/evil-icons-user-interface/64/picture-20.png 19 | thumbnail: 20 | url: https://cdn4.iconfinder.com/data/icons/evil-icons-user-interface/64/picture-20.png 21 | linkExample: 22 | type: link 23 | url: https://en.wikipedia.org/wiki/Star_Wars 24 | title: Star wars (Wikipedia) 25 | text: Star wars wikipedia page 26 | icon: 27 | url: https://cdn4.iconfinder.com/data/icons/evil-icons-user-interface/64/finger-20.png 28 | thumbnail: 29 | url: https://cdn4.iconfinder.com/data/icons/evil-icons-user-interface/64/finger-20.png 30 | fileExample: 31 | type: file 32 | url: http://rbasra.com/images13/jedi.pdf 33 | title: Jedi path (PDF) 34 | text: How to become a Jedi (PDF guide) 35 | icon: 36 | url: https://cdn4.iconfinder.com/data/icons/48-bubbles/48/12.File-20.png 37 | thumbnail: 38 | url: https://cdn4.iconfinder.com/data/icons/48-bubbles/48/12.File-20.png 39 | htmlFragmentExample: 40 | type: html 41 | html: '
Hello world
' 42 | title: Hello world (HTML) 43 | text: HTML fragment to say hello world 44 | -------------------------------------------------------------------------------- /src/Action/Platform/Nrps/ListMembershipsAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 45 | $this->factory = $factory; 46 | $this->twig = $twig; 47 | } 48 | 49 | public function __invoke(Request $request): Response 50 | { 51 | return new Response( 52 | $this->twig->render( 53 | 'platform/nrps/listMemberships.html.twig', 54 | [ 55 | 'defaultMembership' => $this->factory->create(), 56 | 'memberships' => $this->repository->findAll() 57 | ] 58 | ) 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Security/Api/Provider/ApiKeyProvider.php: -------------------------------------------------------------------------------- 1 | parameterBag = $parameterBag; 37 | } 38 | 39 | public function supports(TokenInterface $token): bool 40 | { 41 | return $token instanceof ApiKeyToken; 42 | } 43 | 44 | public function authenticate(TokenInterface $token): TokenInterface 45 | { 46 | $securedApiKey = $this->parameterBag->get('application_api_key'); 47 | 48 | if ($token->getAttribute('api_key') === $securedApiKey) 49 | { 50 | $token->setAuthenticated(true); 51 | 52 | return $token; 53 | } 54 | 55 | throw new AuthenticationException('Unauthorised api key'); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Action/Api/Platform/Proctoring/DeleteAssessmentAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 39 | } 40 | 41 | public static function getName(): string 42 | { 43 | return 'Delete ACS assessment'; 44 | } 45 | 46 | public function __invoke(Request $request, string $assessmentIdentifier): Response 47 | { 48 | $assessment = $this->repository->find($assessmentIdentifier); 49 | 50 | if (null === $assessment) { 51 | throw new NotFoundHttpException( 52 | sprintf('cannot find assessment with identifier %s', $assessmentIdentifier) 53 | ); 54 | } 55 | 56 | $this->repository->delete($assessment); 57 | 58 | return new Response('', Response::HTTP_NO_CONTENT); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Action/Platform/Ajax/RegistrationDefaultLaunchUrlAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 38 | } 39 | 40 | public function __invoke(Request $request): JsonResponse 41 | { 42 | $registration = $this->repository->find($request->get('registration')); 43 | 44 | switch ($request->get('type')) { 45 | case LtiMessageInterface::LTI_MESSAGE_TYPE_RESOURCE_LINK_REQUEST: 46 | $url = $registration->getTool()->getLaunchUrl(); 47 | break; 48 | case LtiMessageInterface::LTI_MESSAGE_TYPE_DEEP_LINKING_REQUEST: 49 | $url = $registration->getTool()->getDeepLinkingUrl(); 50 | break; 51 | default: 52 | $url = null; 53 | } 54 | 55 | return new JsonResponse(['url' => $url]); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Action/Platform/Proctoring/ViewAssessmentAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 44 | $this->twig = $twig; 45 | } 46 | 47 | public function __invoke(Request $request, string $assessmentIdentifier): Response 48 | { 49 | $assessment = $this->repository->find($assessmentIdentifier); 50 | 51 | if (null === $assessment) { 52 | throw new NotFoundHttpException( 53 | sprintf('Cannot find assessment with id %s', $assessmentIdentifier) 54 | ); 55 | } 56 | 57 | return new Response( 58 | $this->twig->render( 59 | 'platform/proctoring/viewAssessment.html.twig', 60 | [ 61 | 'assessment' => $assessment 62 | ] 63 | ) 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Action/DashboardAction.php: -------------------------------------------------------------------------------- 1 | parameterBag = $parameterBag; 48 | $this->twig = $twig; 49 | $this->collector = $collector; 50 | } 51 | 52 | public function __invoke(Request $request): Response 53 | { 54 | return new Response( 55 | $this->twig->render( 56 | 'dashboard/dashboard.html.twig', 57 | [ 58 | 'configuration' => $this->parameterBag->get('lti1p3_resolved_configuration'), 59 | 'statistics' => $this->collector->collect(), 60 | 'users' => $this->parameterBag->get('users') ?? [] 61 | ] 62 | ) 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Action/Tool/Ajax/NrpsServiceClientAction.php: -------------------------------------------------------------------------------- 1 | twig = $twig; 48 | $this->client = $client; 49 | $this->repository = $repository; 50 | } 51 | 52 | public function __invoke(Request $request): Response 53 | { 54 | $membership = $this->client->getContextMembership( 55 | $this->repository->find($request->get('registration')), 56 | $request->get('url'), 57 | $request->get('role'), 58 | intval($request->get('limit')) 59 | ); 60 | 61 | return new Response( 62 | $this->twig->render('tool/ajax/nrps.html.twig', ['membership' => $membership]) 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /config/packages/lti1p3.yaml: -------------------------------------------------------------------------------- 1 | lti1p3: 2 | scopes: 3 | - 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly' 4 | - 'https://purl.imsglobal.org/spec/lti-bo/scope/basicoutcome' 5 | - 'https://purl.imsglobal.org/spec/lti-ap/scope/control.all' 6 | - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem' 7 | - 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly' 8 | - 'https://purl.imsglobal.org/spec/lti-ags/scope/score' 9 | - 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly' 10 | key_chains: 11 | platformKey: 12 | key_set_name: "platformSet" 13 | public_key: "file://%kernel.project_dir%/config/keys/public.key" 14 | private_key: "file://%kernel.project_dir%/config/keys/private.key" 15 | private_key_passphrase: ~ 16 | toolKey: 17 | key_set_name: "toolSet" 18 | public_key: "file://%kernel.project_dir%/config/keys/public.key" 19 | private_key: "file://%kernel.project_dir%/config/keys/private.key" 20 | private_key_passphrase: ~ 21 | platforms: 22 | devkitPlatform: 23 | name: "LTI 1.3 DevKit (as platform)" 24 | audience: "%application_host%/platform" 25 | oidc_authentication_url: "%application_host%/lti1p3/oidc/authentication" 26 | oauth2_access_token_url: "%application_host%/lti1p3/auth/platformKey/token" 27 | tools: 28 | devkitTool: 29 | name: "LTI 1.3 DevKit (as tool)" 30 | audience: "%application_host%/tool" 31 | oidc_initiation_url: "%application_host%/lti1p3/oidc/initiation" 32 | launch_url: "%application_host%/tool/launch" 33 | deep_linking_url: "%application_host%/tool/launch" 34 | registrations: 35 | devkit: 36 | client_id: "client_id" 37 | platform: "devkitPlatform" 38 | tool: "devkitTool" 39 | deployment_ids: 40 | - "deploymentId1" 41 | - "deploymentId2" 42 | platform_key_chain: "platformKey" 43 | tool_key_chain: "toolKey" 44 | platform_jwks_url: "%application_host%/lti1p3/.well-known/jwks/platformSet.json" 45 | tool_jwks_url: "%application_host%/lti1p3/.well-known/jwks/toolSet.json" 46 | order: 1 47 | -------------------------------------------------------------------------------- /config/packages/security.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | providers: 3 | users_in_memory: { memory: null } 4 | firewalls: 5 | api_area: 6 | pattern: ^/api/ 7 | stateless: true 8 | api_key: true 9 | tool_message_area: 10 | pattern: ^/tool/launch 11 | stateless: true 12 | lti1p3_message_tool: true 13 | platform_message_area: 14 | pattern: ^/platform/message/return 15 | stateless: true 16 | lti1p3_message_platform: true 17 | platform_service_ags_area: 18 | pattern: ^/platform/service/ags/ 19 | stateless: true 20 | lti1p3_service: { 21 | scopes: [ 22 | 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', 23 | 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly', 24 | 'https://purl.imsglobal.org/spec/lti-ags/scope/score', 25 | 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly' 26 | ] 27 | } 28 | platform_service_nrps_area: 29 | pattern: ^/platform/service/nrps 30 | stateless: true 31 | lti1p3_service: { 32 | scopes: [ 33 | 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly' 34 | ] 35 | } 36 | platform_service_acs_area: 37 | pattern: ^/platform/service/acs 38 | stateless: true 39 | lti1p3_service: { 40 | scopes: [ 41 | 'https://purl.imsglobal.org/spec/lti-ap/scope/control.all' 42 | ] 43 | } 44 | platform_service_basic_outcome_area: 45 | pattern: ^/platform/service/basic-outcome 46 | stateless: true 47 | lti1p3_service: { 48 | scopes: [ 49 | 'https://purl.imsglobal.org/spec/lti-bo/scope/basicoutcome' 50 | ] 51 | } 52 | dev: 53 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 54 | security: false 55 | main: 56 | anonymous: lazy 57 | provider: users_in_memory 58 | 59 | access_control: 60 | # - { path: ^/admin, roles: ROLE_ADMIN } 61 | # - { path: ^/profile, roles: ROLE_USER } 62 | -------------------------------------------------------------------------------- /src/Action/Platform/Message/ProctoringReturnAction.php: -------------------------------------------------------------------------------- 1 | flashBag = $flashBag; 49 | $this->twig = $twig; 50 | $this->security = $security; 51 | } 52 | 53 | public function __invoke(Request $request, string $identifier): Response 54 | { 55 | $this->flashBag->add( 56 | 'success', 57 | sprintf( 58 | 'Platform LtiStartAssessment launch success%s', 59 | !empty($identifier) ? sprintf(' (for %s)', $identifier) : '' 60 | ) 61 | ); 62 | 63 | return new Response( 64 | $this->twig->render( 65 | 'platform/message/proctoringReturn.html.twig', 66 | [ 67 | 'token' => $this->security->getToken() 68 | ] 69 | ) 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /templates/launch/blocks/deepLinkingItem.html.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 |  Returned content item(s) 4 |
5 |
6 | {% for resourceIdentifier, resource in resources %} 7 | {% if loop.first %} 8 |
9 | {% endif %} 10 |
11 |
{{ resource.title }}
12 |
13 | 14 |
15 | {% if resource.url is defined %} 16 |
Url
17 |
{{ resource.url }}
18 | {% endif %} 19 | {% if resource.html is defined %} 20 |
HTML
21 |
{{ resource.html }}
22 | {% endif %} 23 |
Description
24 |
{{ resource.text }}
25 | {% if resource.icon is defined and resource.icon.url is defined %} 26 |
Icon
27 |
28 | {% endif %} 29 |
30 |
31 |
32 | 35 |
36 | {% if loop.last %} 37 |
38 | {% endif %} 39 | {% else %} 40 | 43 | {% endfor %} 44 |
45 |
46 | -------------------------------------------------------------------------------- /src/Action/Api/Platform/Nrps/DeleteMembershipAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 40 | } 41 | 42 | public static function getName(): string 43 | { 44 | return 'Delete NRPS membership'; 45 | } 46 | 47 | public function __invoke(Request $request, string $membershipIdentifier): Response 48 | { 49 | if ('default' === $membershipIdentifier) { 50 | throw new AccessDeniedHttpException('the membership with identifier default cannot be deleted'); 51 | } 52 | 53 | $membership = $this->repository->find($membershipIdentifier); 54 | 55 | if (null === $membership) { 56 | throw new NotFoundHttpException( 57 | sprintf('cannot find membership with identifier %s', $membershipIdentifier) 58 | ); 59 | } 60 | 61 | $this->repository->delete($membership); 62 | 63 | return new Response('', Response::HTTP_NO_CONTENT); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /templates/launch/blocks/proctoringStartAssessment.html.twig: -------------------------------------------------------------------------------- 1 | 4 |
5 |
6 |
7 |  Continue to platform 8 |
9 |
10 | 13 |
14 | 15 | 16 |
17 |
18 | 31 |
32 |
33 | -------------------------------------------------------------------------------- /src/Security/User/UserAuthenticator.php: -------------------------------------------------------------------------------- 1 | factory = $factory; 23 | $this->parameterBag = $parameterBag; 24 | } 25 | 26 | public function authenticate(RegistrationInterface $registration, string $loginHint): UserAuthenticationResultInterface 27 | { 28 | $hint = json_decode($loginHint, true); 29 | 30 | if ($hint['type'] === 'list') { 31 | 32 | $userData = $this->parameterBag->get('users')[$hint['user_id']] ?? []; 33 | 34 | return new UserAuthenticationResult( 35 | true, 36 | $this->factory->create( 37 | $hint['user_id'], 38 | $userData['name'] ?? null, 39 | $userData['email'] ?? null, 40 | $userData['givenName'] ?? null, 41 | $userData['familyName'] ?? null, 42 | $userData['middleName'] ?? null, 43 | $userData['locale'] ?? null, 44 | $userData['picture'] ?? null 45 | ) 46 | ); 47 | } 48 | 49 | if ($hint['type'] === 'custom') { 50 | return new UserAuthenticationResult( 51 | true, 52 | $this->factory->create( 53 | $hint['user_id'], 54 | $hint['user_name'] ?? null, 55 | $hint['user_email'] ?? null, 56 | null, 57 | null, 58 | null, 59 | $hint['user_locale'] ?? null 60 | ) 61 | ); 62 | } 63 | 64 | return new UserAuthenticationResult(true); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /templates/launch/modal/generatorShareModal.html.twig: -------------------------------------------------------------------------------- 1 | 29 | 57 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | devkit_lti1p3_traefik: 5 | container_name: devkit_lti1p3_traefik 6 | image: traefik:v2.4 7 | command: 8 | - '--api.insecure=true' 9 | - '--providers.docker=true' 10 | - '--providers.docker.exposedbydefault=false' 11 | - '--entrypoints.web.address=:80' 12 | networks: 13 | - devkit_lti1p3_network 14 | ports: 15 | - 80:80 16 | - 8080:8080 17 | volumes: 18 | - '/var/run/docker.sock:/var/run/docker.sock:ro' 19 | 20 | devkit_lti1p3_nginx: 21 | container_name: devkit_lti1p3_nginx 22 | image: nginx:stable 23 | networks: 24 | devkit_lti1p3_network: 25 | aliases: 26 | - devkit-lti1p3.localhost 27 | labels: 28 | - 'traefik.enable=true' 29 | - 'traefik.http.routers.nginx.rule=Host(`devkit-lti1p3.localhost`)' 30 | - 'traefik.http.routers.nginx.entrypoints=web' 31 | expose: 32 | - 443 33 | volumes: 34 | - .:/var/www/html:cached 35 | - ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:cached 36 | working_dir: /etc/nginx/conf.d 37 | 38 | devkit_lti1p3_phpfpm: 39 | container_name: devkit_lti1p3_phpfpm 40 | build: 41 | context: ./docker/phpfpm 42 | expose: 43 | - 9000 44 | networks: 45 | - devkit_lti1p3_network 46 | volumes: 47 | - .:/var/www/html:cached 48 | working_dir: /var/www/html 49 | 50 | devkit_lti1p3_redis: 51 | container_name: devkit_lti1p3_redis 52 | image: redis:latest 53 | command: ["redis-server", "--appendonly", "yes"] 54 | hostname: devkit_lti1p3_redis 55 | networks: 56 | - devkit_lti1p3_network 57 | ports: 58 | - "6379:6379" 59 | volumes: 60 | - devkit_lti1p3_redis_volume:/data 61 | 62 | devkit_lti1p3_redis_commander: 63 | container_name: devkit_lti1p3_redis_commander 64 | image: rediscommander/redis-commander:latest 65 | hostname: devkit_lti1p3_redis_commander 66 | networks: 67 | - devkit_lti1p3_network 68 | ports: 69 | - "8081:8081" 70 | expose: 71 | - 8081 72 | environment: 73 | - REDIS_HOSTS=local:devkit_lti1p3_redis:6379 74 | 75 | volumes: 76 | devkit_lti1p3_redis_volume: 77 | driver: local 78 | 79 | networks: 80 | devkit_lti1p3_network: 81 | driver: bridge 82 | -------------------------------------------------------------------------------- /templates/tool/ajax/ags/viewLineItem.html.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
Identifier
5 |
{{ lineItem.identifier }}
6 |
Label
7 |
{{ lineItem.label }}
8 |
9 |
10 |
11 |
12 |
13 |
14 |
Score maximum
15 |
16 | 17 | {{ lineItem.scoreMaximum }} 18 | 19 |
20 |
21 |
22 |
23 |
24 |
Tag
25 |
{{ lineItem.tag|default('n/a') }}
26 |
27 |
28 |
29 |
30 |
31 |
32 |
Resource identifier
33 |
{{ lineItem.resourceIdentifier|default('n/a') }}
34 |
35 |
36 |
37 |
38 |
Resource link identifier
39 |
{{ lineItem.resourceLinkIdentifier|default('n/a') }}
40 |
41 |
42 |
43 |
44 |
45 |
46 |
Start date
47 |
{{ lineItem.startDateTime|format('Y-m-d H:i')|default('n/a') }}
48 |
49 |
50 |
51 |
52 |
End date
53 |
{{ lineItem.endDateTime|format('Y-m-d H:i')|default('n/a') }}
54 |
55 |
56 |
57 |
58 |
59 |
60 |
Additional properties
61 |
62 | {% if lineItem.additionalProperties.all|length != 0 %} 63 |
64 |                         {{ lineItem.additionalProperties.all|json_encode(constant('JSON_PRETTY_PRINT') + constant('JSON_UNESCAPED_SLASHES')) }}
65 |                     
66 | {% else %} 67 | n/a 68 | {% endif %} 69 |
70 |
71 |
72 |
73 | 74 | 77 | -------------------------------------------------------------------------------- /src/Action/Platform/Nrps/ViewMembershipAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 49 | $this->twig = $twig; 50 | $this->membershipFactory = $membershipFactory; 51 | } 52 | 53 | public function __invoke(Request $request, string $membershipIdentifier): Response 54 | { 55 | if ($membershipIdentifier === 'default') { 56 | $membership = $this->membershipFactory->create(); 57 | } else { 58 | $membership = $this->repository->find($membershipIdentifier); 59 | } 60 | 61 | if (null === $membership) { 62 | throw new NotFoundHttpException( 63 | sprintf('Cannot find membership with id %s', $membershipIdentifier) 64 | ); 65 | } 66 | 67 | return new Response( 68 | $this->twig->render( 69 | 'platform/nrps/viewMembership.html.twig', 70 | [ 71 | 'membership' => $membership 72 | ] 73 | ) 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Action/Api/Platform/Proctoring/GetAssessmentAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 44 | $this->generator = $generator; 45 | } 46 | 47 | public static function getName(): string 48 | { 49 | return 'Get ACS assessment'; 50 | } 51 | 52 | public function __invoke(Request $request, string $assessmentIdentifier): Response 53 | { 54 | $assessment = $this->repository->find($assessmentIdentifier); 55 | 56 | if (null === $assessment) { 57 | throw new NotFoundHttpException( 58 | sprintf('cannot find assessment with identifier %s', $assessmentIdentifier) 59 | ); 60 | } 61 | 62 | return new JsonResponse( 63 | [ 64 | 'assessment' => $assessment, 65 | 'acs_url' => $this->generator->generate( 66 | 'platform_service_acs', 67 | [ 68 | 'assessmentIdentifier' => $assessment->getIdentifier(), 69 | ] 70 | ) 71 | ] 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Action/Platform/Ags/ViewLineItemAction.php: -------------------------------------------------------------------------------- 1 | lineItemRepository->find($lineItemIdentifier); 47 | 48 | if (null === $lineItem) { 49 | throw new NotFoundHttpException( 50 | sprintf('Cannot find line item with id %s', $lineItemIdentifier) 51 | ); 52 | } 53 | 54 | $scores = $this->scoreRepository->findCollectionByLineItemIdentifier($lineItemIdentifier); 55 | $results = $this->resultRepository->findCollectionByLineItemIdentifier($lineItemIdentifier); 56 | 57 | return new Response( 58 | $this->twig->render( 59 | 'platform/ags/viewLineItem.html.twig', 60 | [ 61 | 'lineItem' => $lineItem, 62 | 'scores' => array_values($scores->all()), 63 | 'results' => array_values($results->all()) 64 | ] 65 | ) 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Action/Platform/Nrps/DeleteMembershipAction.php: -------------------------------------------------------------------------------- 1 | flashBag = $flashBag; 51 | $this->repository = $repository; 52 | $this->router = $router; 53 | } 54 | 55 | public function __invoke(Request $request, string $membershipIdentifier): Response 56 | { 57 | $membership = $this->repository->find($membershipIdentifier); 58 | 59 | if (null === $membership) { 60 | throw new NotFoundHttpException( 61 | sprintf('Cannot find membership with id %s', $membershipIdentifier) 62 | ); 63 | } 64 | 65 | try { 66 | $this->repository->delete($membership); 67 | 68 | $this->flashBag->add('success', sprintf('Membership %s deletion success', $membershipIdentifier)); 69 | } catch (Throwable $exception) { 70 | $this->flashBag->add('error', $exception->getMessage()); 71 | } 72 | 73 | return new RedirectResponse($this->router->generate('platform_nrps_list_memberships')); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Proctoring/AcsServiceServerControlProcessor.php: -------------------------------------------------------------------------------- 1 | requestStack = $requestStack; 44 | $this->repository = $repository; 45 | } 46 | 47 | public function process(RegistrationInterface $registration, AcsControlInterface $control): AcsControlResultInterface 48 | { 49 | $request = $this->requestStack->getCurrentRequest(); 50 | $routeParameters = $request->attributes->get('_route_params'); 51 | 52 | $assessmentIdentifier = $routeParameters['assessmentIdentifier'] ?? null; 53 | 54 | $assessment = $this->repository->find($assessmentIdentifier); 55 | 56 | if (null === $assessment) { 57 | throw new NotFoundHttpException( 58 | sprintf('Assessment with identifier %s cannot be found', $assessmentIdentifier) 59 | ); 60 | } 61 | 62 | $assessment->addControl($control); 63 | 64 | $this->repository->save($assessment); 65 | 66 | return new AcsControlResult($assessment->getStatus(), $control->getExtraTime()); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Action/Platform/Proctoring/DeleteAssessmentAction.php: -------------------------------------------------------------------------------- 1 | flashBag = $flashBag; 51 | $this->repository = $repository; 52 | $this->router = $router; 53 | } 54 | 55 | public function __invoke(Request $request, string $assessmentIdentifier): Response 56 | { 57 | $assessment = $this->repository->find($assessmentIdentifier); 58 | 59 | if (null === $assessment) { 60 | throw new NotFoundHttpException( 61 | sprintf('Cannot find assessment with id %s', $assessmentIdentifier) 62 | ); 63 | } 64 | 65 | try { 66 | $this->repository->delete($assessment); 67 | 68 | $this->flashBag->add('success', sprintf('Assessment %s deletion success', $assessmentIdentifier)); 69 | } catch (Throwable $exception) { 70 | $this->flashBag->add('error', $exception->getMessage()); 71 | } 72 | 73 | return new RedirectResponse($this->router->generate('platform_proctoring_list_assessments')); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Action/Platform/Ags/DeleteLineItemAction.php: -------------------------------------------------------------------------------- 1 | flashBag = $flashBag; 51 | $this->repository = $repository; 52 | $this->router = $router; 53 | } 54 | 55 | public function __invoke(Request $request, string $lineItemIdentifier): Response 56 | { 57 | $lineItem = $this->repository->find($lineItemIdentifier); 58 | 59 | if (null === $lineItem) { 60 | throw new NotFoundHttpException( 61 | sprintf('Cannot find line item with id %s', $lineItemIdentifier) 62 | ); 63 | } 64 | 65 | try { 66 | $this->repository->delete($lineItemIdentifier); 67 | 68 | $this->flashBag->add('success', sprintf('Line item %s deletion success', $lineItemIdentifier)); 69 | } catch (Throwable $exception) { 70 | $this->flashBag->add('error', $exception->getMessage()); 71 | } 72 | 73 | return new RedirectResponse($this->router->generate('platform_ags_list_line_items')); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Action/Tool/Message/ProctoringResponseAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 45 | $this->builder = $builder; 46 | } 47 | 48 | public function __invoke(Request $request): Response 49 | { 50 | $registration = $this->repository->find($request->get('registration')); 51 | 52 | $startAssessmentMessage = $this->builder->buildStartAssessmentLaunchRequest( 53 | new ResourceLinkClaim($request->get('resource-link-id')), 54 | $registration, 55 | $request->get('start-assessment-url'), 56 | $request->get('session-data'), 57 | (int)$request->get('attempt-number'), 58 | null, 59 | [ 60 | new ProctoringVerifiedUserClaim( 61 | [ 62 | 'name' => $request->get('verified-user-name') 63 | ] 64 | ) 65 | ], 66 | $request->get('end-assessment-return') === 'on' 67 | ); 68 | 69 | return new Response($startAssessmentMessage->toHtmlRedirectForm()); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Action/Platform/Message/DeepLinkingReturnAction.php: -------------------------------------------------------------------------------- 1 | flashBag = $flashBag; 55 | $this->factory = $factory; 56 | $this->twig = $twig; 57 | $this->security = $security; 58 | } 59 | 60 | public function __invoke(Request $request): Response 61 | { 62 | /** @var LtiPlatformMessageSecurityToken $token */ 63 | $token = $this->security->getToken(); 64 | 65 | $this->flashBag->add('success', $token->getPayload()->getDeepLinkingMessage()); 66 | 67 | return new Response( 68 | $this->twig->render( 69 | 'platform/message/deepLinkingReturn.html.twig', 70 | [ 71 | 'token' => $this->security->getToken(), 72 | 'resources' => $this->factory->createFromClaim($token->getPayload()->getDeepLinkingContentItems()) 73 | ] 74 | ) 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [TAO](https://www.taotesting.com/) - LTI 1.3 DevKit 2 | 3 | [![Latest Version](https://img.shields.io/github/tag/oat-sa/demo-lti1p3.svg?style=flat&label=release)](https://github.com/oat-sa/demo-lti1p3/tags) 4 | [![License GPL2](http://img.shields.io/badge/licence-GPL%202.0-blue.svg)](http://www.gnu.org/licenses/gpl-2.0.html) 5 | [![IMS Certified](https://img.shields.io/badge/IMS-certified-brightgreen)](https://site.imsglobal.org/certifications/open-assessment-technologies-sa/tao-lti-13-devkit) 6 | 7 | > [IMS certified](https://site.imsglobal.org/certifications/open-assessment-technologies-sa/tao-lti-13-devkit) [Symfony](https://symfony.com/) based development kit for LTI 1.3, to act as [platform and / or tool](http://www.imsglobal.org/spec/lti/v1p3/#platforms-and-tools-0). 8 | 9 | ## Table of Contents 10 | 11 | - [Try it live](#try-it-live) 12 | - [TAO LTI 1.3 PHP framework](#tao-lti-13-php-framework) 13 | - [IMS](#ims) 14 | - [Documentation](#documentation) 15 | - [Installation and configuration](#installation-and-configuration) 16 | - [Available APIs](#available-apis) 17 | 18 | ## Try it live 19 | 20 | To try it live, visit [https://lti-public-devkit.dev.gcp-eu.taocloud.org](https://lti-public-devkit.dev.gcp-eu.taocloud.org). 21 | 22 | ## TAO LTI 1.3 PHP framework 23 | 24 | This development kit is based on the [TAO LTI 1.3 PHP framework](https://oat-sa.github.io/doc-lti1p3/). 25 | 26 | ## IMS 27 | 28 | You can find below [IMS](https://www.imsglobal.org/) related information. 29 | 30 | ### Related certifications 31 | 32 | - [LTI 1.3 advantage complete](https://site.imsglobal.org/certifications/open-assessment-technologies-sa/tao-lti-13-devkit) 33 | - [LTI 1.3 proctoring services](https://site.imsglobal.org/certifications/open-assessment-technologies-sa/tao-lti-13-devkit) 34 | 35 | ### Related specifications 36 | 37 | - [IMS Security](https://www.imsglobal.org/spec/security/v1p0) 38 | - [IMS LTI 1.3 Core](http://www.imsglobal.org/spec/lti/v1p3) 39 | - [IMS LTI 1.3 AGS](https://www.imsglobal.org/spec/lti-ags/v2p0) 40 | - [IMS LTI 1.3 Basic Outcome](https://www.imsglobal.org/spec/lti-bo/v1p1) 41 | - [IMS LTI 1.3 Deep Linking](https://www.imsglobal.org/spec/lti-dl/v2p0) 42 | - [IMS LTI 1.3 NRPS](https://www.imsglobal.org/spec/lti-nrps/v2p0) 43 | - [IMS LTI 1.3 Proctoring](https://www.imsglobal.org/spec/proctoring/v1p0) 44 | - [IMS LTI 1.3 Submission Review](https://www.imsglobal.org/spec/lti-sr/v1p0) 45 | 46 | ## Documentation 47 | 48 | You can find below the development kit documentation, presented by topics. 49 | 50 | ### Installation and configuration 51 | 52 | - how to [install and configure](doc/installation.md) the development kit 53 | 54 | ### Available APIs 55 | 56 | - how to [use the HTTP API](doc/api.md) of the development kit 57 | - how to [use the CLI](doc/cli.md) of the development kit 58 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "OAT LTI 1.3 DevKit", 3 | "type": "project", 4 | "license": "GPL-2.0-only", 5 | "require": { 6 | "php": ">=8.2.0", 7 | "ext-ctype": "*", 8 | "ext-iconv": "*", 9 | "ext-json": "*", 10 | "oat-sa/bundle-health-check": "^2.1", 11 | "oat-sa/bundle-lti1p3": "^7.1", 12 | "oat-sa/lib-lti1p3-ags": "^2.0", 13 | "oat-sa/lib-lti1p3-basic-outcome": "^5.0", 14 | "oat-sa/lib-lti1p3-deep-linking": "^4.1", 15 | "oat-sa/lib-lti1p3-nrps": "^8.0", 16 | "oat-sa/lib-lti1p3-proctoring": "^1.0", 17 | "oat-sa/lib-lti1p3-submission-review": "^1.0", 18 | "ramsey/uuid": "^3.9 || ^4", 19 | "sensio/framework-extra-bundle": "^6.1", 20 | "symfony/console": "^5.3", 21 | "symfony/css-selector": "^5.3", 22 | "symfony/dotenv": "^5.3", 23 | "symfony/expression-language": "^5.3", 24 | "symfony/flex": "^1.3.1", 25 | "symfony/form": "^5.3", 26 | "symfony/framework-bundle": "^5.3", 27 | "symfony/lock": "^5.3", 28 | "symfony/monolog-bundle": "^3.5", 29 | "symfony/stopwatch": "^5.3", 30 | "symfony/twig-bundle": "^5.3", 31 | "symfony/validator": "^5.3", 32 | "symfony/yaml": "^5.3", 33 | "twig/extra-bundle": "^2.12|^3.0", 34 | "twig/twig": "^2.12|^3.0" 35 | }, 36 | "config": { 37 | "preferred-install": { 38 | "*": "dist" 39 | }, 40 | "sort-packages": true, 41 | "allow-plugins": { 42 | "symfony/flex": true 43 | } 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "App\\": "src/" 48 | } 49 | }, 50 | "autoload-dev": { 51 | "psr-4": { 52 | "App\\Tests\\": "tests/", 53 | "OAT\\Library\\Lti1p3Core\\Tests\\": "vendor/oat-sa/lib-lti1p3-core/tests" 54 | } 55 | }, 56 | "replace": { 57 | "paragonie/random_compat": "2.*", 58 | "symfony/polyfill-ctype": "*", 59 | "symfony/polyfill-iconv": "*", 60 | "symfony/polyfill-php72": "*", 61 | "symfony/polyfill-php71": "*", 62 | "symfony/polyfill-php70": "*", 63 | "symfony/polyfill-php56": "*" 64 | }, 65 | "scripts": { 66 | "auto-scripts": { 67 | "cache:clear": "symfony-cmd", 68 | "assets:install %PUBLIC_DIR%": "symfony-cmd" 69 | }, 70 | "post-install-cmd": [ 71 | "@auto-scripts" 72 | ], 73 | "post-update-cmd": [ 74 | "@auto-scripts" 75 | ] 76 | }, 77 | "conflict": { 78 | "symfony/symfony": "*" 79 | }, 80 | "extra": { 81 | "symfony": { 82 | "allow-contrib": false, 83 | "require": "^5.3" 84 | } 85 | }, 86 | "require-dev": { 87 | "symfony/phpunit-bridge": "^5.3" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Action/Api/Platform/Proctoring/ListAssessmentsAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 45 | $this->generator = $generator; 46 | } 47 | 48 | public static function getName(): string 49 | { 50 | return 'List ACS assessments'; 51 | } 52 | 53 | public function __invoke(Request $request): Response 54 | { 55 | $limit = $request->query->has('limit') ? intval($request->query->get('limit')) : null; 56 | $offset = $request->query->has('offset') ? intval($request->query->get('offset')) : null; 57 | 58 | $assessments = $this->repository->findAll(); 59 | 60 | $assessments = array_slice($assessments, $offset ?: 0, $limit); 61 | 62 | $membershipsList = []; 63 | 64 | foreach ($assessments as $assessment) { 65 | $membershipsList[] = [ 66 | 'assessment' => $assessment, 67 | 'acs_url' => $this->generator->generate( 68 | 'platform_service_acs', 69 | [ 70 | 'assessmentIdentifier' => $assessment->getIdentifier(), 71 | ] 72 | ) 73 | ]; 74 | } 75 | 76 | return new JsonResponse( 77 | [ 78 | 'assessments' => $membershipsList 79 | ] 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Action/Platform/BasicOutcome/DeleteBasicOutcomeAction.php: -------------------------------------------------------------------------------- 1 | flashBag = $flashBag; 51 | $this->cache = $cache; 52 | $this->router = $router; 53 | } 54 | 55 | public function __invoke(Request $request, string $basicOutcomeIdentifier): Response 56 | { 57 | try { 58 | $basicOutcomeCache = $this->cache->getItem(BasicOutcomeProcessor::CACHE_KEY); 59 | 60 | $basicOutcomeList = []; 61 | 62 | if ($basicOutcomeCache->isHit()) { 63 | $basicOutcomeList = $basicOutcomeCache->get(); 64 | } 65 | 66 | unset($basicOutcomeList[$basicOutcomeIdentifier]); 67 | 68 | $basicOutcomeCache->set($basicOutcomeList); 69 | 70 | $this->cache->save($basicOutcomeCache); 71 | 72 | $this->flashBag->add('success', sprintf('Basic outcome %s deletion success', $basicOutcomeIdentifier)); 73 | } catch (Throwable $exception) { 74 | $this->flashBag->add('error', $exception->getMessage()); 75 | } 76 | 77 | return new RedirectResponse($this->router->generate('platform_basic_outcome_list')); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Action/Tool/Ajax/Ags/ListLineItemsServiceClientAction.php: -------------------------------------------------------------------------------- 1 | twig = $twig; 49 | $this->client = $client; 50 | $this->repository = $repository; 51 | } 52 | 53 | public function __invoke(Request $request): Response 54 | { 55 | $registration = $this->repository->find($request->get('registration')); 56 | 57 | $lineItemsContainer = $this->client->listLineItems( 58 | $registration, 59 | $request->get('url'), 60 | $request->get('resourceId'), 61 | $request->get('resourceLinkId'), 62 | $request->get('tag'), 63 | (int)$request->get('limit') 64 | ); 65 | 66 | return new Response( 67 | $this->twig->render( 68 | 'tool/ajax/ags/listLineItems.html.twig', 69 | [ 70 | 'registration' => $registration, 71 | 'lineItemsContainer' => $lineItemsContainer, 72 | 'lineItemsContainerUrl' => $request->get('url'), 73 | 'canWriteLineItem' => ScopePermissionVoter::canWriteLineItem(explode(',', $request->get('scopes'))), 74 | 'scopes' => $request->get('scopes') 75 | ] 76 | ) 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Action/Platform/Message/ProctoringEndAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 43 | $this->builder = $builder; 44 | } 45 | 46 | public function __invoke(Request $request): RedirectResponse 47 | { 48 | $registration = $this->repository->find($request->get('registration')); 49 | 50 | $loginHint = [ 51 | 'type' => 'custom', 52 | 'user_id' => $request->get('verified-user-name'), 53 | 'user_name' => $request->get('verified-user-name'), 54 | ]; 55 | 56 | if ($request->get('with-error') === 'on') { 57 | $endAssessmentMessage = $this->builder->buildEndAssessmentLaunchErrorRequest( 58 | $registration, 59 | json_encode($loginHint), 60 | $request->get('error-message'), 61 | $request->get('error-log'), 62 | $registration->getTool()->getLaunchUrl(), 63 | (int)$request->get('attempt-number') 64 | ); 65 | } else { 66 | $endAssessmentMessage = $this->builder->buildEndAssessmentLaunchRequest( 67 | $registration, 68 | json_encode($loginHint), 69 | $registration->getTool()->getLaunchUrl(), 70 | (int)$request->get('attempt-number') 71 | ); 72 | } 73 | 74 | return new RedirectResponse($endAssessmentMessage->toUrl()); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Form/Platform/Proctoring/AssessmentType.php: -------------------------------------------------------------------------------- 1 | setDefaults( 38 | [ 39 | 'edit' => false, 40 | ] 41 | ); 42 | } 43 | 44 | public function buildForm(FormBuilderInterface $builder, array $options) 45 | { 46 | $statuses = array_combine( 47 | AcsControlResultInterface::SUPPORTED_STATUSES, 48 | AcsControlResultInterface::SUPPORTED_STATUSES 49 | ); 50 | 51 | $builder 52 | ->add( 53 | 'assessment_id', 54 | TextType::class, 55 | [ 56 | 'label' => 'Identifier', 57 | 'required' => true, 58 | 'help' => 'Assessment identifier', 59 | 'disabled' => $options['edit'] ?? false 60 | ] 61 | ) 62 | ->add( 63 | 'assessment_status', 64 | ChoiceType::class, 65 | [ 66 | 'label' => 'Status', 67 | 'required' => true, 68 | 'help' => 'Assessment status', 69 | 'choices' => $statuses 70 | ] 71 | ) 72 | ->add( 73 | 'submit', 74 | SubmitType::class, [ 75 | 'label' => ' Save', 76 | 'label_html' => true, 77 | 'attr' => ['class' => 'btn-primary'] 78 | ] 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /docker/kube/Dockerfile: -------------------------------------------------------------------------------- 1 | # ---------- Stage 1: Composer ---------- 2 | FROM composer:2.8 AS composer 3 | WORKDIR /app 4 | COPY . . 5 | RUN set -eux\ 6 | composer install -n --no-dev --prefer-dist --optimize-autoloader; 7 | # composer dump-autoload -n --optimize --classmap-authoritative; \ 8 | # rm -rf .build/ 9 | 10 | # ---------- Stage 2: Runtime (NGINX Unit + PHP 8.4) ---------- 11 | # Tip: adjust tag to whatever Unit PHP 8.4 tag you use in your registry (e.g. unit:1.34.2-php8.4) 12 | FROM unit:php8.4 13 | 14 | ENV MAKEFLAGS="-j8" 15 | 16 | # Pin PECL versions to match your current image 17 | ARG APCU_VERSION=5.1.24 18 | ARG GRPC_VERSION=1.73.0 19 | ARG PROTOBUF_VERSION=4.31.1 20 | ARG REDIS_VERSION=6.2.0 21 | 22 | # System deps for building PHP extensions 23 | RUN set -eux; \ 24 | apt-get update; \ 25 | DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 26 | ca-certificates curl unzip zip git \ 27 | autoconf build-essential pkg-config re2c \ 28 | libicu-dev zlib1g-dev libzip-dev \ 29 | libonig-dev libxml2-dev libpng-dev \ 30 | libpq-dev libssl-dev; \ 31 | rm -rf /var/lib/apt/lists/* 32 | 33 | # Core PHP extensions (docker-php-* helpers are available in Unit’s PHP images) 34 | RUN set -eux; \ 35 | docker-php-ext-configure intl; \ 36 | docker-php-ext-install -j8 \ 37 | intl mbstring opcache zip pcntl sockets \ 38 | sysvmsg sysvsem sysvshm pdo pdo_pgsql calendar 39 | 40 | # PECL extensions (pinned) 41 | RUN set -eux; \ 42 | pecl channel-update pecl.php.net; \ 43 | printf "\n" | pecl install \ 44 | igbinary \ 45 | redis-${REDIS_VERSION} \ 46 | grpc-${GRPC_VERSION} \ 47 | protobuf-${PROTOBUF_VERSION} \ 48 | apcu-${APCU_VERSION}; \ 49 | docker-php-ext-enable igbinary redis grpc protobuf apcu 50 | 51 | # PHP INI (keeps your current tuning, incl. the GRPC stack-size workaround) 52 | RUN set -eux; \ 53 | { \ 54 | echo 'opcache.preload=/var/www/html/config/preload.php'; \ 55 | echo 'opcache.preload_user=www-data'; \ 56 | echo 'opcache.memory_consumption=256'; \ 57 | echo 'opcache.max_accelerated_files=20000'; \ 58 | echo 'opcache.validate_timestamps=0'; \ 59 | echo 'realpath_cache_size=4096K'; \ 60 | echo 'realpath_cache_ttl=600'; \ 61 | } > /usr/local/etc/php/conf.d/zzz-opcache.ini; \ 62 | echo 'zend.max_allowed_stack_size=-1' > /usr/local/etc/php/conf.d/zend.ini 63 | 64 | # App code 65 | WORKDIR /var/www/html 66 | COPY --from=composer /app /var/www/html 67 | 68 | # Unit config: drop this file into /docker-entrypoint.d so it’s auto-applied at start 69 | COPY unit.json /docker-entrypoint.ed/config.json 70 | COPY php.ini /etc/unit-php/php.ini 71 | # RUN test -s /docker-entrypoint.d/config.json \ 72 | # && grep -q '"listeners"' /docker-entrypoint.d/config.json 73 | EXPOSE 8080 74 | # Unit’s official entrypoint will start unitd and apply any configs in /docker-entrypoint.d/ 75 | -------------------------------------------------------------------------------- /src/Proctoring/AssessmentRepository.php: -------------------------------------------------------------------------------- 1 | cache->getItem(self::CACHE_KEY); 40 | 41 | if ($cache->isHit()) { 42 | $assessments = $cache->get(); 43 | 44 | if (array_key_exists($assessmentIdentifier, $assessments)) { 45 | return $assessments[$assessmentIdentifier]; 46 | } 47 | } 48 | 49 | return null; 50 | } 51 | 52 | public function findAll(): array 53 | { 54 | $cache = $this->cache->getItem(self::CACHE_KEY); 55 | 56 | if ($cache->isHit()) { 57 | return $cache->get(); 58 | } 59 | 60 | return []; 61 | } 62 | 63 | public function save(Assessment $assessment): void 64 | { 65 | $lock = $this->lockFactory->createLock(self::CACHE_KEY); 66 | $cache = $this->cache->getItem(self::CACHE_KEY); 67 | $lock->acquire(true); 68 | 69 | $memberships = $cache->get(); 70 | 71 | $memberships[$assessment->getIdentifier()] = $assessment; 72 | 73 | $cache->set($memberships); 74 | 75 | $this->cache->save($cache); 76 | $lock->release(); 77 | } 78 | 79 | public function delete(Assessment $assessment): void 80 | { 81 | $lock = $this->lockFactory->createLock(self::CACHE_KEY); 82 | $lock->acquire(true); 83 | $cache = $this->cache->getItem(self::CACHE_KEY); 84 | 85 | $memberships = $cache->get(); 86 | 87 | unset($memberships[$assessment->getIdentifier()]); 88 | 89 | $cache->set($memberships); 90 | 91 | $this->cache->save($cache); 92 | $lock->release(); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /doc/installation.md: -------------------------------------------------------------------------------- 1 | # Installation and configuration 2 | 3 | ## Table of Contents 4 | 5 | - [Installation](#installation) 6 | - [Usage](#usage) 7 | - [Configuration](#configuration) 8 | 9 | ## Installation 10 | 11 | You need to ensure first you have the following installed: 12 | - [docker](https://docs.docker.com/get-docker/) 13 | - [docker-compose](https://docs.docker.com/compose/install/) 14 | 15 | After cloning this repository, you can build the [provided docker stack](../docker-compose.yml): 16 | ```console 17 | $ docker-compose up -d 18 | ``` 19 | 20 | Then, install required dependencies with [composer](https://hub.docker.com/_/composer): 21 | ```console 22 | $ docker run --rm --interactive --tty \ 23 | --volume $PWD:/app \ 24 | composer install 25 | ``` 26 | 27 | For Windows users: 28 | - you may have to do `--volume %cd%:/app` instead 29 | - with powershell, you may have to do `--volume ${PWD}:/app` instead 30 | 31 | ## Usage 32 | 33 | ### Application 34 | 35 | After installation, the development kit is available on [http://devkit-lti1p3.localhost](http://devkit-lti1p3.localhost) 36 | 37 | ### Services 38 | 39 | After installation, the following docker services are available: 40 | 41 | | Name | Description | 42 | |----------------------------------------|----------------------------------| 43 | | devkit_lti1p3_traefik | application proxy | 44 | | devkit_lti1p3_nginx | application nginx web server | 45 | | devkit_lti1p3_phpfpm | application php-fpm | 46 | | devkit_lti1p3_redis | application cache | 47 | | devkit_lti1p3_redis_commander | application cache administration | 48 | 49 | You can access: 50 | 51 | | Name | URL | 52 | |----------------------------------------|------------------------------------------------------------------| 53 | | devkit_lti1p3_nginx | [http://devkit-lti1p3.localhost](http://devkit-lti1p3.localhost) | 54 | | devkit_lti1p3_traefik | [http://localhost:8080](http://localhost:8080) | 55 | | devkit_lti1p3_redis_commander | [http://localhost:8081](http://localhost:8081) | 56 | 57 | ## Configuration 58 | 59 | ### Platforms, tools and registrations 60 | 61 | Since this development kit application relies on [LTI 1.3 symfony bundle](https://github.com/oat-sa/bundle-lti1p3), you can find [here](https://github.com/oat-sa/bundle-lti1p3/blob/master/doc/quickstart/configuration.md) instructions to configure it. 62 | 63 | ### Customization 64 | 65 | You can find in the [config/devkit](../config/devkit) folder configuration files to customize the development kit: 66 | - [claims.yaml](../config/devkit/claims.yaml): configurable editor claims list 67 | - [deep_linking.yaml](../config/devkit/deep_linking.yaml): configurable deep linking resources list 68 | - [users.yaml](../config/devkit/users.yaml): configurable users list 69 | -------------------------------------------------------------------------------- /src/Nrps/DefaultMembershipFactory.php: -------------------------------------------------------------------------------- 1 | parameterBag = $parameterBag; 45 | $this->factory = $factory; 46 | } 47 | 48 | public function create(): MembershipInterface 49 | { 50 | $members = new MemberCollection(); 51 | 52 | foreach ($this->parameterBag->get('users') ?? [] as $userIdentifier => $userData) { 53 | $userIdentity = $this->factory->create( 54 | $userIdentifier, 55 | $userData['name'] ?? null, 56 | $userData['email'] ?? null, 57 | $userData['givenName'] ?? null, 58 | $userData['familyName'] ?? null, 59 | $userData['middleName'] ?? null, 60 | $userData['locale'] ?? null, 61 | $userData['picture'] ?? null 62 | ); 63 | 64 | $members->add( 65 | new Member( 66 | $userIdentity, 67 | MemberInterface::STATUS_ACTIVE, 68 | $userData['roles'] ?? [], 69 | ['user_id' => $userIdentifier] + $userData 70 | ) 71 | ); 72 | } 73 | 74 | return new Membership( 75 | 'default', 76 | new Context('default', 'Default context label', 'Default context title'), 77 | $members 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Action/Tool/Ajax/Ags/ListResultsServiceClientAction.php: -------------------------------------------------------------------------------- 1 | twig = $twig; 53 | $this->lineItemClient = $lineItemClient; 54 | $this->resultClient = $resultClient; 55 | $this->repository = $repository; 56 | } 57 | 58 | public function __invoke(Request $request, string $lineItemIdentifier): Response 59 | { 60 | $registration = $this->repository->find($request->get('registration')); 61 | 62 | $lineItem = $this->lineItemClient->getLineItem($registration, $lineItemIdentifier); 63 | 64 | $results = $this->resultClient->listResults( 65 | $registration, 66 | $lineItemIdentifier, 67 | $request->get('user'), 68 | (int)$request->get('limit') 69 | ); 70 | 71 | return new Response( 72 | $this->twig->render( 73 | 'tool/ajax/ags/listResults.html.twig', 74 | [ 75 | 'registration' => $registration, 76 | 'lineItem' => $lineItem, 77 | 'results' => array_values($results->getResults()->all()), 78 | 'mode' => $request->get('mode'), 79 | ] 80 | ) 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /templates/tool/message/proctoringEnd.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block nav_title %}  Tool - LtiEndAssessment result{% endblock %} 4 | 5 | {% block body %} 6 |
7 |
8 | 30 |
31 |
32 |
33 |
34 | {% include 'launch/blocks/message.html.twig' with {'token': token} %} 35 |
36 |
37 | {% include 'launch/blocks/toolSecurity.html.twig' with {'token': token} %} 38 |
39 |
40 | {% include 'launch/blocks/registration.html.twig' with {'token': token} %} 41 |
42 |
43 | {% include 'launch/blocks/claims.html.twig' with {'token': token} %} 44 |
45 |
46 |
47 | 50 |
51 | {% endblock body %} 52 | -------------------------------------------------------------------------------- /src/Action/Api/Platform/Nrps/GetMembershipAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 51 | $this->factory = $factory; 52 | $this->generator = $generator; 53 | } 54 | 55 | public static function getName(): string 56 | { 57 | return 'Get NRPS membership'; 58 | } 59 | 60 | public function __invoke(Request $request, string $membershipIdentifier): Response 61 | { 62 | if ('default' === $membershipIdentifier) { 63 | $membership = $this->factory->create(); 64 | } else { 65 | $membership = $this->repository->find($membershipIdentifier); 66 | 67 | if (null === $membership) { 68 | throw new NotFoundHttpException( 69 | sprintf('cannot find membership with identifier %s', $membershipIdentifier) 70 | ); 71 | } 72 | } 73 | 74 | return new JsonResponse( 75 | [ 76 | 'membership' => $membership, 77 | 'nrps_url' => $this->generator->generate( 78 | 'platform_service_nrps', 79 | [ 80 | 'contextIdentifier' => $membership->getContext()->getIdentifier(), 81 | 'membershipIdentifier' => $membership->getIdentifier(), 82 | ] 83 | ) 84 | ] 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Security/Api/Firewall/ApiKeyListener.php: -------------------------------------------------------------------------------- 1 | tokenStorage = $tokenStorage; 51 | $this->authenticationManager = $authenticationManager; 52 | $this->logger = $logger; 53 | } 54 | 55 | public function __invoke(RequestEvent $event): void 56 | { 57 | $request = $event->getRequest(); 58 | 59 | if (!$request->headers->has(static::AUTHORIZATION_HEADER)) { 60 | return; 61 | } 62 | 63 | $apiKey = substr($request->headers->get(static::AUTHORIZATION_HEADER), strlen('Bearer ')); 64 | 65 | $token = new ApiKeyToken(); 66 | $token->setAttribute('api_key', $apiKey); 67 | 68 | try { 69 | $authToken = $this->authenticationManager->authenticate($token); 70 | $this->tokenStorage->setToken($authToken); 71 | 72 | return; 73 | } catch (AuthenticationException $exception) { 74 | $this->logger->error($exception->getMessage()); 75 | 76 | $response = new JsonResponse( 77 | [ 78 | 'error' => $exception->getMessage() 79 | ], 80 | Response::HTTP_UNAUTHORIZED 81 | ); 82 | 83 | $event->setResponse($response); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Action/Api/Platform/Nrps/ListMembershipsAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 50 | $this->factory = $factory; 51 | $this->generator = $generator; 52 | } 53 | 54 | public static function getName(): string 55 | { 56 | return 'List NRPS memberships'; 57 | } 58 | 59 | public function __invoke(Request $request): Response 60 | { 61 | $limit = $request->query->has('limit') ? intval($request->query->get('limit')) : null; 62 | $offset = $request->query->has('offset') ? intval($request->query->get('offset')) : null; 63 | 64 | $memberships = $this->repository->findAll(); 65 | 66 | array_unshift($memberships, $this->factory->create()); 67 | 68 | $memberships = array_slice($memberships, $offset ?: 0, $limit); 69 | 70 | $membershipsList = []; 71 | 72 | foreach ($memberships as $membership) { 73 | $membershipsList[] = [ 74 | 'membership' => $membership, 75 | 'nrps_url' => $this->generator->generate( 76 | 'platform_service_nrps', 77 | [ 78 | 'contextIdentifier' => $membership->getContext()->getIdentifier(), 79 | 'membershipIdentifier' => $membership->getIdentifier(), 80 | ] 81 | ) 82 | ]; 83 | } 84 | 85 | return new JsonResponse( 86 | [ 87 | 'memberships' => $membershipsList 88 | ] 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Proctoring/Assessment.php: -------------------------------------------------------------------------------- 1 | identifier = $identifier; 44 | $this->setStatus($status); 45 | $this->controls = $controls; 46 | } 47 | 48 | public function getIdentifier(): string 49 | { 50 | return $this->identifier; 51 | } 52 | 53 | public function setIdentifier(string $identifier): Assessment 54 | { 55 | $this->identifier = $identifier; 56 | 57 | return $this; 58 | } 59 | 60 | public function getStatus(): string 61 | { 62 | return $this->status; 63 | } 64 | 65 | /** 66 | * @throw InvalidArgumentException 67 | */ 68 | public function setStatus(string $status): Assessment 69 | { 70 | if (!in_array($status, AcsControlResultInterface::SUPPORTED_STATUSES)) { 71 | throw new InvalidArgumentException( 72 | sprintf( 73 | 'Assessment status %s is not supported. Supported statuses: %s', 74 | $status, 75 | implode(', ', AcsControlResultInterface::SUPPORTED_STATUSES) 76 | ) 77 | ); 78 | } 79 | 80 | $this->status = $status; 81 | 82 | return $this; 83 | } 84 | 85 | public function getControls(): array 86 | { 87 | return $this->controls; 88 | } 89 | 90 | public function addControl(AcsControlInterface $control): Assessment 91 | { 92 | $this->controls[] = $control; 93 | 94 | return $this; 95 | } 96 | 97 | public function jsonSerialize(): array 98 | { 99 | return [ 100 | 'id' => $this->identifier, 101 | 'status' => $this->status 102 | ]; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Action/Tool/Ajax/AcsServiceClientAction.php: -------------------------------------------------------------------------------- 1 | twig = $twig; 51 | $this->client = $client; 52 | $this->repository = $repository; 53 | } 54 | 55 | public function __invoke(Request $request): Response 56 | { 57 | $date = !empty($request->get('acsDate')) 58 | ? Carbon::createFromFormat('Y-m-d H:i', $request->get('acsDate')) 59 | : Carbon::now(); 60 | 61 | $acsControl = new AcsControl( 62 | new LtiResourceLink($request->get('acsResourceLink')), 63 | $request->get('acsSub'), 64 | $request->get('acsAction'), 65 | $date, 66 | (int)$request->get('acsAttemptNumber') ?: 1, 67 | $request->get('acsIss'), 68 | $request->get('acsExtraTime') === '' ? null : (int)$request->get('acsExtraTime'), 69 | $request->get('acsSeverity') === '' ? null : (float)$request->get('acsSeverity'), 70 | $request->get('acsReasonCode'), 71 | $request->get('acsReasonMessage') 72 | ); 73 | 74 | $acsControlResult = $this->client->sendControl( 75 | $this->repository->find($request->get('registration')), 76 | $acsControl, 77 | $request->get('acsUrl') 78 | ); 79 | 80 | return new Response( 81 | $this->twig->render( 82 | 'tool/ajax/acs.html.twig', 83 | [ 84 | 'controlResult' => $acsControlResult, 85 | ] 86 | ) 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /templates/launch/blocks/proctoringEndAssessment.html.twig: -------------------------------------------------------------------------------- 1 | 4 | {% if token.payload.proctoringEndAssessmentReturn %} 5 |
6 |
7 |
8 |  Continue to tool 9 |
10 |
11 | 14 |
15 | 16 | 17 |
18 |
19 |
20 |
21 | 22 |
23 |
24 |
25 |
26 | 27 |
28 |
29 |
30 |
31 | 41 |
42 |
43 | {% else %} 44 | 47 | {% endif %} 48 | 57 | -------------------------------------------------------------------------------- /src/Action/Api/Platform/Nrps/CreateMembershipAction.php: -------------------------------------------------------------------------------- 1 | serializer = $serializer; 52 | $this->repository = $repository; 53 | $this->generator = $generator; 54 | } 55 | 56 | public static function getName(): string 57 | { 58 | return 'Create NRPS membership'; 59 | } 60 | 61 | public function __invoke(Request $request): JsonResponse 62 | { 63 | try { 64 | $membership = $this->serializer->deserialize($request->getContent()); 65 | } catch (Throwable $exception) { 66 | throw new BadRequestHttpException($exception->getMessage()); 67 | } 68 | 69 | if (null !== $this->repository->find($membership->getIdentifier()) || 'default' === $membership->getIdentifier()) { 70 | throw new ConflictHttpException( 71 | sprintf('a membership already exists with identifier %s', $membership->getIdentifier()) 72 | ); 73 | } 74 | 75 | $this->repository->save($membership); 76 | 77 | return new JsonResponse( 78 | [ 79 | 'membership' => $membership, 80 | 'nrps_url' => $this->generator->generate( 81 | 'platform_service_nrps', 82 | [ 83 | 'contextIdentifier' => $membership->getContext()->getIdentifier(), 84 | 'membershipIdentifier' => $membership->getIdentifier(), 85 | ] 86 | ) 87 | ] 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Action/Api/Platform/Proctoring/UpdateAssessmentAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 48 | $this->generator = $generator; 49 | } 50 | 51 | public static function getName(): string 52 | { 53 | return 'Update ACS assessment'; 54 | } 55 | 56 | public function __invoke(Request $request, string $assessmentIdentifier): Response 57 | { 58 | $assessment = $this->repository->find($assessmentIdentifier); 59 | 60 | if (null === $assessment) { 61 | throw new NotFoundHttpException( 62 | sprintf('cannot find assessment with identifier %s', $assessment) 63 | ); 64 | } 65 | 66 | $data = json_decode($request->getContent(), true); 67 | 68 | if (JSON_ERROR_NONE !== json_last_error()) { 69 | throw new BadRequestHttpException( 70 | sprintf('invalid request: %s', json_last_error_msg()) 71 | ); 72 | } 73 | 74 | try { 75 | $assessment->setStatus($data['status'] ?? $assessment->getStatus()); 76 | } catch (Throwable $exception) { 77 | throw new BadRequestHttpException($exception->getMessage()); 78 | } 79 | 80 | $this->repository->save($assessment); 81 | 82 | return new JsonResponse( 83 | [ 84 | 'assessment' => $assessment, 85 | 'acs_url' => $this->generator->generate( 86 | 'platform_service_acs', 87 | [ 88 | 'assessmentIdentifier' => $assessment->getIdentifier(), 89 | ] 90 | ) 91 | ] 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Action/Api/Platform/Proctoring/CreateAssessmentAction.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 47 | $this->generator = $generator; 48 | } 49 | 50 | public static function getName(): string 51 | { 52 | return 'Create ACS assessment'; 53 | } 54 | 55 | public function __invoke(Request $request): JsonResponse 56 | { 57 | $data = json_decode($request->getContent(), true); 58 | 59 | if (JSON_ERROR_NONE !== json_last_error()) { 60 | throw new BadRequestHttpException( 61 | sprintf('invalid request: %s', json_last_error_msg()) 62 | ); 63 | } 64 | 65 | try { 66 | $assessment = new Assessment( 67 | $data['id'], 68 | $data['status'] ?? AcsControlResultInterface::STATUS_NONE 69 | ); 70 | } catch (Throwable $exception) { 71 | throw new BadRequestHttpException($exception->getMessage()); 72 | } 73 | 74 | if (null !== $this->repository->find($assessment->getIdentifier())) { 75 | throw new ConflictHttpException( 76 | sprintf('an assessment already exists with identifier %s', $assessment->getIdentifier()) 77 | ); 78 | } 79 | 80 | $this->repository->save($assessment); 81 | 82 | return new JsonResponse( 83 | [ 84 | 'assessment' => $assessment, 85 | 'acs_url' => $this->generator->generate( 86 | 'platform_service_acs', 87 | [ 88 | 'assessmentIdentifier' => $assessment->getIdentifier(), 89 | ] 90 | ) 91 | ] 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /templates/launch/blocks/identity.html.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 |  User 4 |
5 |
6 | {% if token.payload.userIdentity %} 7 |
8 |
9 |
10 |
Identifier
11 |
{{ token.payload.userIdentity.identifier }}
12 |
Name
13 |
{{ token.payload.userIdentity.name|default('n/a') }}
14 |
Email
15 |
{{ token.payload.userIdentity.email|default('n/a') }}
16 |
Given name
17 |
{{ token.payload.userIdentity.givenName|default('n/a') }}
18 |
Family name
19 |
{{ token.payload.userIdentity.familyName|default('n/a') }}
20 |
Middle name
21 |
{{ token.payload.userIdentity.middleName|default('n/a') }}
22 |
Locale
23 |
{{ token.payload.userIdentity.locale|default('n/a') }}
24 |
25 |
26 |
27 |
28 |
Picture
29 |
30 | {% if token.payload.userIdentity.picture %} 31 | {{ token.payload.userIdentity.picture }} 38 | {% else %} 39 | n/a 40 | {% endif %} 41 |
42 |
43 |
44 |
45 | {% else %} 46 | 49 | {% endif %} 50 |
51 |
52 |
53 |
54 |
55 |  Roles (at launch) 56 |
57 |
58 |
59 |
60 | {% if token.payload.roles is not empty %} 61 |
62 | {% for role in token.payload.roles %} 63 |
{{ role }}
64 | {% endfor %} 65 |
66 | {% else %} 67 | 70 | {% endif %} 71 |
72 |
73 |
74 |
75 | 76 | -------------------------------------------------------------------------------- /src/Action/Tool/Message/DeepLinkingResponseAction.php: -------------------------------------------------------------------------------- 1 | factory = $factory; 53 | $this->parameterBag = $parameterBag; 54 | $this->repository = $repository; 55 | $this->builder = $builder; 56 | } 57 | 58 | public function __invoke(Request $request): Response 59 | { 60 | $registration = $this->repository->find($request->get('registration')); 61 | 62 | $availableResources = $this->parameterBag->get('deeplinking_resources'); 63 | $selectedResources = []; 64 | 65 | foreach ($request->get('selected-resources', []) as $resourceIdentifier) { 66 | $selectedResources[] = $availableResources[$resourceIdentifier]; 67 | } 68 | 69 | $resourceCollection = $this->factory->create($selectedResources); 70 | 71 | $deepLinkingResponse = $this->builder->buildDeepLinkingLaunchResponse( 72 | $resourceCollection, 73 | $registration, 74 | $request->get('deep-linking-return-url'), 75 | null, 76 | $request->get('deep-linking-data') 77 | ); 78 | 79 | /*$deepLinkingResponse = $this->builder->buildLaunchErrorResponse( 80 | $registration, 81 | $request->get('deep-linking-return-url'), 82 | null, 83 | $request->get('deep-linking-data'), 84 | 'error message', 85 | 'error log' 86 | );*/ 87 | 88 | return new Response($deepLinkingResponse->toHtmlRedirectForm()); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Action/Tool/Ajax/BasicOutcomeServiceClientAction.php: -------------------------------------------------------------------------------- 1 | twig = $twig; 49 | $this->client = $client; 50 | $this->repository = $repository; 51 | } 52 | 53 | public function __invoke(Request $request): Response 54 | { 55 | switch ($request->get('operation')) { 56 | case BasicOutcomeMessageInterface::TYPE_READ_RESULT: 57 | $basicOutcomeResponse = $this->client->readResult( 58 | $this->repository->find($request->get('registration')), 59 | $request->get('url'), 60 | $request->get('resultSourcedId') 61 | ); 62 | break; 63 | case BasicOutcomeMessageInterface::TYPE_REPLACE_RESULT: 64 | $basicOutcomeResponse = $this->client->replaceResult( 65 | $this->repository->find($request->get('registration')), 66 | $request->get('url'), 67 | $request->get('resultSourcedId'), 68 | (float)$request->get('score'), 69 | $request->get('language') 70 | ); 71 | break; 72 | case BasicOutcomeMessageInterface::TYPE_DELETE_RESULT: 73 | $basicOutcomeResponse = $this->client->deleteResult( 74 | $this->repository->find($request->get('registration')), 75 | $request->get('url'), 76 | $request->get('resultSourcedId') 77 | ); 78 | break; 79 | } 80 | 81 | return new Response( 82 | $this->twig->render( 83 | 'tool/ajax/basic-outcome.html.twig', 84 | [ 85 | 'response' => $basicOutcomeResponse, 86 | ] 87 | ) 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Action/Platform/Proctoring/CreateAssessmentAction.php: -------------------------------------------------------------------------------- 1 | flashBag = $flashBag; 61 | $this->repository = $repository; 62 | $this->twig = $twig; 63 | $this->factory = $factory; 64 | $this->router = $router; 65 | } 66 | 67 | public function __invoke(Request $request): Response 68 | { 69 | $form = $this->factory->create(AssessmentType::class); 70 | 71 | $form->handleRequest($request); 72 | 73 | if ($form->isSubmitted() && $form->isValid()) { 74 | 75 | $formData = $form->getData(); 76 | 77 | $assessment = new Assessment( 78 | $formData['assessment_id'], 79 | $formData['assessment_status'] 80 | ); 81 | 82 | $this->repository->save($assessment); 83 | 84 | $this->flashBag->add('success', sprintf('Assessment %s creation success', $formData['assessment_id'])); 85 | 86 | return new RedirectResponse( 87 | $this->router->generate('platform_proctoring_view_assessment', ['assessmentIdentifier' => $formData['assessment_id']]) 88 | ); 89 | } 90 | 91 | return new Response( 92 | $this->twig->render( 93 | 'platform/proctoring/createAssessment.html.twig', 94 | [ 95 | 'form' => $form->createView(), 96 | ] 97 | ) 98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Ags/ScoreRepository.php: -------------------------------------------------------------------------------- 1 | lockFactory->createLock(self::CACHE_KEY); 48 | $cache = $this->cache->getItem(self::CACHE_KEY); 49 | $lock->acquire(true); 50 | 51 | $scores = $cache->get(); 52 | 53 | $scores[$score->getLineItemIdentifier()][] = $score; 54 | 55 | $cache->set($scores); 56 | 57 | $this->cache->save($cache); 58 | 59 | $result = new Result( 60 | $score->getUserIdentifier(), 61 | $score->getLineItemIdentifier(), 62 | null, 63 | $score->getScoreGiven(), 64 | $score->getScoreMaximum(), 65 | 'Auto generated result by LTI 1.3 DevKit', 66 | $score->getAdditionalProperties()->all() 67 | ); 68 | 69 | $this->resultRepository->save($result); 70 | $lock->release(); 71 | 72 | return $score; 73 | } 74 | 75 | public function findCollectionByLineItemIdentifier(string $lineItemIdentifier): CollectionInterface 76 | { 77 | $cache = $this->cache->getItem(self::CACHE_KEY); 78 | 79 | $scores = $cache->get(); 80 | 81 | return (new Collection)->add($scores[$lineItemIdentifier] ?? []); 82 | } 83 | 84 | public function deleteCollectionByLineItemIdentifier(string $lineItemIdentifier): void 85 | { 86 | $lock = $this->lockFactory->createLock(self::CACHE_KEY); 87 | $cache = $this->cache->getItem(self::CACHE_KEY); 88 | $lock->acquire(true); 89 | 90 | $scores = $cache->get(); 91 | 92 | unset($scores[$lineItemIdentifier]); 93 | 94 | $cache->set($scores); 95 | 96 | $this->cache->save($cache); 97 | $lock->release(); 98 | } 99 | } 100 | --------------------------------------------------------------------------------