diff --git a/.env b/.env
index 25331ca93..26e468c96 100755
--- a/.env
+++ b/.env
@@ -33,6 +33,7 @@ BOOL_SEND_EMAILS=false
#URLS_STATIC=
#URLS_ENGINES=
+#URLS_H5P=
#BOOL_ADMIN_UPLOADER_ENABLE=true
#ASSET_STORAGE_DRIVER=file
#ASSET_STORAGE_S3_REGION=us-east-1
@@ -88,3 +89,8 @@ LTI_KEY="materia-production-lti-key"
#BOOL_LTI_USE_LAUNCH_ROLES=true
#BOOL_LTI_GRACEFUL_CONFIG_FALLBACK=true
#BOOL_LTI_LOG_FOR_DEBUGGING=false
+
+# THIRD PARTY OAUTH ===================
+
+OAUTH_KEY="materia-third-party-oauth-key"
+OAUTH_SECRET="third-party-oauth-secret"
\ No newline at end of file
diff --git a/docker/config/nginx/nginx-dev.conf b/docker/config/nginx/nginx-dev.conf
index cb866e2bd..4096eb91e 100644
--- a/docker/config/nginx/nginx-dev.conf
+++ b/docker/config/nginx/nginx-dev.conf
@@ -164,4 +164,17 @@ http {
}
+ upstream h5p {
+ server h5p:3333;
+ }
+
+ server {
+ listen *:3000 ssl;
+ listen [::]:3000 ssl;
+
+ location / {
+ proxy_pass https://h5p$request_uri;
+ }
+ }
+
}
diff --git a/docker/docker-compose.override.yml b/docker/docker-compose.override.yml
index a705ea366..c639c9e82 100644
--- a/docker/docker-compose.override.yml
+++ b/docker/docker-compose.override.yml
@@ -30,6 +30,11 @@ services:
fakes3:
volumes:
- uploaded_media:/s3mnt/fakes3_root/fakes3_uploads/media/
+
+ h5p:
+ volumes:
+ - ./config/nginx/key.pem:/etc/nginx/conf.d/key.pem:ro
+ - ./config/nginx/cert.pem:/etc/nginx/conf.d/cert.pem:ro
volumes:
# static_files: {} # compiled js/css and uploaded widgets
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index e44f90704..8f65f3f80 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -1,4 +1,4 @@
-version: '3.5'
+version: "3.5"
services:
webserver:
@@ -10,6 +10,7 @@ services:
- "80:80" # main materia
- "443:443" # main materia
- "8008:8008" # static files (simulates a different domain sandbox & cdn)
+ - "3000:3000" # h5p
networks:
- frontend
depends_on:
@@ -45,6 +46,7 @@ services:
- THEME_PACKAGE=materia-theme-ucf
- URLS_ENGINES=https://localhost:8008/widget/
- URLS_STATIC=https://localhost:8008/
+ - URLS_H5P=https://localhost:3000
- USER_INSTRUCTOR_PASSWORD=${DEV_ONLY_USER_PASSWORD}
- USER_STUDENT_PASSWORD=${DEV_ONLY_USER_PASSWORD}
- USER_SYSTEM_PASSWORD=${DEV_ONLY_USER_PASSWORD}
@@ -81,9 +83,22 @@ services:
- frontend
- backend
+ h5p:
+ build:
+ context: ../
+ dockerfile: ./h5p-server/materia-h5p.Dockerfile
+ args:
+ ENVIRONMENT: dev
+ ports:
+ - "3333:3333" # port should match what's being provided in URLS_H5P above
+ networks:
+ - frontend
+ environment:
+ - MATERIA_WEBSERVER_NETWORK=webserver
+ - MATERIA_URL=https://localhost:8008 # should be same as static URL above
+
networks:
frontend:
name: materia_frontend
backend:
name: materia_backend
-
diff --git a/docker/run_first.sh b/docker/run_first.sh
index ed52d00c5..3f87acf8f 100755
--- a/docker/run_first.sh
+++ b/docker/run_first.sh
@@ -44,6 +44,10 @@ source run_build_assets.sh
# create a dev user based on your current shell user (password will be 'kogneato') MATERIA_DEV_PASS=whatever can be used to set a custom pw
source run_create_me.sh
+# run setup for the h5p server
+cd ../h5p-server/
+source setup.sh
+
echo -e "Materia will be hosted on \033[32m$DOCKER_IP\033[0m"
echo -e "\033[1mRun an oil comand:\033[0m ./run.sh php oil r widget:show_engines"
echo -e "\033[1mRun the web app:\033[0m docker-compose up"
diff --git a/fuel/app/classes/controller/media.php b/fuel/app/classes/controller/media.php
index ed3506046..f61b5e346 100644
--- a/fuel/app/classes/controller/media.php
+++ b/fuel/app/classes/controller/media.php
@@ -5,6 +5,7 @@
*/
use \Materia\Widget_Asset_Manager;
use \Materia\Widget_Asset;
+use \Thirdparty\Oauth;
class Controller_Media extends Controller
{
@@ -64,8 +65,11 @@ public function get_import()
// This currently assumes a single uploaded file at a time
public function action_upload()
{
- // Validate Logged in
- if (\Service_User::verify_session() !== true) throw new HttpNotFoundException;
+ // Either Validate Logged in
+ // or validate a third party server thru Oauth
+ if (\Service_User::verify_session() !== true)
+ if (Oauth::validate_post() !== true)
+ throw new HttpNotFoundException;
$res = new Response();
// Make sure file is not cached (as it happens for example on iOS devices)
diff --git a/fuel/app/classes/thirdparty/oauth.php b/fuel/app/classes/thirdparty/oauth.php
new file mode 100644
index 000000000..146b36444
--- /dev/null
+++ b/fuel/app/classes/thirdparty/oauth.php
@@ -0,0 +1,39 @@
+getMessage(), \Uri::current(), print_r(\Input::post(), 1)], 'lti-error-dump');
+ }
+
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/fuel/app/classes/trait/commoncontrollertemplate.php b/fuel/app/classes/trait/commoncontrollertemplate.php
index c32f090a6..7f8e8ec95 100644
--- a/fuel/app/classes/trait/commoncontrollertemplate.php
+++ b/fuel/app/classes/trait/commoncontrollertemplate.php
@@ -50,6 +50,7 @@ public function inject_common_js_constants()
'BASE_URL' => Uri::base(),
'WIDGET_URL' => Config::get('materia.urls.engines'),
'MEDIA_URL' => Config::get('materia.urls.media'),
+ 'H5P_URL' => Config::get('materia.urls.h5p'),
'MEDIA_UPLOAD_URL' => Config::get('materia.urls.media_upload'),
'STATIC_CROSSDOMAIN' => Config::get('materia.urls.static'),
];
diff --git a/fuel/app/config/config.php b/fuel/app/config/config.php
index a50151d06..6e88b5549 100644
--- a/fuel/app/config/config.php
+++ b/fuel/app/config/config.php
@@ -333,13 +333,13 @@
],
[
'id' => 2,
- 'package' => 'https://github.com/ucfopen/hangman-materia-widget/releases/latest/download/hangman.wigt',
- 'checksum' => 'https://github.com/ucfopen/hangman-materia-widget/releases/latest/download/hangman-build-info.yml',
+ 'package' => 'https://github.com/ucfopen/guess-the-phrase-materia-widget/releases/latest/download/guess-the-phrase.wigt',
+ 'checksum' => 'https://github.com/ucfopen/guess-the-phrase-materia-widget/releases/latest/download/guess-the-phrase-build-info.yml',
],
[
'id' => 3,
- 'package' => 'https://github.com/ucfopen/matching-materia-widget/releases/latest/download/matching.wigt',
- 'checksum' => 'https://github.com/ucfopen/matching-materia-widget/releases/latest/download/matching-build-info.yml',
+ 'package' => 'https://github.com/ucfopen/guess-the-phrase-materia-widget/releases/latest/download/guess-the-phrase.wigt',
+ 'checksum' => 'https://github.com/ucfopen/guess-the-phrase-materia-widget/releases/latest/download/guess-the-phrase-build-info.yml',
],
[
'id' => 4,
diff --git a/fuel/app/config/materia.php b/fuel/app/config/materia.php
index ed7fd531c..9f19700ae 100644
--- a/fuel/app/config/materia.php
+++ b/fuel/app/config/materia.php
@@ -26,7 +26,8 @@
'embed' => \Uri::create('embed/'), // game embed urls http://siteurl.com/embed/3434
'preview' => \Uri::create('preview/'), // game preview urls http://siteurl.com/preview/3443
'static' => $_ENV['URLS_STATIC'] ?? \Uri::create(), // allows you to host another domain for static assets http://static.siteurl.com/
- 'engines' => $_ENV['URLS_ENGINES'] ?? \Uri::create('widget/'), // widget file locations
+ 'engines' => $_ENV['URLS_ENGINES'] ?? \Uri::create('widget/'), // widget file locations,
+ 'h5p' => $_ENV['URLS_H5P'] ?? null
],
diff --git a/h5p-server/.dockerignore b/h5p-server/.dockerignore
new file mode 100644
index 000000000..5171c5408
--- /dev/null
+++ b/h5p-server/.dockerignore
@@ -0,0 +1,2 @@
+node_modules
+npm-debug.log
\ No newline at end of file
diff --git a/h5p-server/.gitignore b/h5p-server/.gitignore
new file mode 100644
index 000000000..927d27ca1
--- /dev/null
+++ b/h5p-server/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+h5p/libraries/*
+h5p/temporary_storage/*
+config/*.pem
+.env*
\ No newline at end of file
diff --git a/h5p-server/Dockerfile b/h5p-server/Dockerfile
new file mode 100644
index 000000000..d101699ac
--- /dev/null
+++ b/h5p-server/Dockerfile
@@ -0,0 +1,31 @@
+# This dockerfile is meant to spin up the h5p-server alone
+
+FROM node:12
+
+# Create app directory
+WORKDIR /usr/src/app
+
+# Install app dependencies
+# A wildcard is used to ensure both package.json AND package-lock.json are copied
+# where available (npm@5+)
+COPY package*.json ./
+
+RUN yarn install
+
+# Bundle app source
+COPY . .
+
+# for local development, don't copy existing h5p info into
+# fresh docker container
+# RUN rm -r h5p/core h5p/editor h5p/libraries h5p/temporary-storage
+# RUN mkdir h5p/core h5p/editor h5p/libraries h5p/temporary-storage
+
+RUN ./setup.sh
+
+EXPOSE 3333
+
+# connect to the localhost of our machine from inside the docker container
+# used to make requests to local materia server
+ENV MATERIA_WEBSERVER_NETWORK=host.docker.internal
+
+CMD ["yarn", "start"]
\ No newline at end of file
diff --git a/h5p-server/README.md b/h5p-server/README.md
new file mode 100644
index 000000000..6f9b8fa3f
--- /dev/null
+++ b/h5p-server/README.md
@@ -0,0 +1,183 @@
+# H5P in Materia
+
+This is a node server built to serve [H5P](h5p.org) widgets specifically for integration with [Materia](https://github.com/ucfopen/Materia). It is based on LumiEducation’s open source [H5P NodeJS Library](https://github.com/Lumieducation/H5P-Nodejs-library) implementation.
+
+Although the server is set up to handle any number of h5p widgets, we currently support the following:
+
+- [Interactive Video](https://h5p.org/interactive-video)
+- [Multiple Choice](https://h5p.org/multichoice)
+- [Quiz (Question Set)](https://h5p.org/question-set)
+- [Advanced Fill in the Blanks](https://h5p.org/advanced-fill-the-blanks)
+- [Mark the Words](https://h5p.org/mark-the-words)
+- [Drag the Words](https://h5p.org/drag-the-words)
+
+## Installation
+
+Note: This project is under active development. If you're making code changes, we advise running it manually with the yarn commands below. Running it with docker will require destroying the h5p containers and images each time you want code changes to take effect.
+
+### Docker
+
+```
+docker build --tag h5p-server:1.0 .
+docker run --env ENVIRONMENT=mwdk --publish 3000:3000 --name h5p h5p-server:1.0
+```
+
+The server is currently configured to run at localhost:3000 in the browser. The publish flag exposes it to your local machine’s port 3000.
+
+### Manual (No Docker)
+
+Install the modules required:
+`yarn install && ./setup.sh`
+Start the server with
+`yarn start:{mwdk/dev/prod}`
+The server will be running at localhost:3000. If not in prod, view the h5p hub client at localhost:3000/admin.
+
+### Configure .env
+
+If you are running this server in the Materia stack, it will pull the .env file from there. However if you are running it alone with the steps above, you will have to create a `.env.local` file at the root directory, and add the following contents:
+
+```
+OAUTH_KEY="materia-third-party-oauth-key"
+OAUTH_SECRET="third-party-oauth-secret"
+MATERIA_URL="https://localhost:8008" # whatever your local static materia url is set to
+```
+
+Where the `key` and `secret` values have to match those on your local materia server. These will allow you to upload any media when creating an H5P widget.
+
+## Development
+
+### Selecting a Materia Environment Variable
+
+This server utilizes postMessages to facilitate communication between H5P and Materia. It is configured to use the specified environment variable to determine which domain to send and receive postMessages.
+
+| Env Var | Description |
+| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `mwdk` | runs with the [Materia Widget Development Kit](https://ucfopen.github.io/Materia-Docs/develop/materia-widget-development-kit.html), a local server spun up quickly for developing individual Materia widgets |
+| `dev` | runs with your local instance of [Materia](https://github.com/ucfopen/Materia). An h5p widget must be built with the mwdk and exported to your local instance of Materia in order to use this. |
+| `prod` | Used in production, points to your local instance of Materia, unless otherwise specified by the environment variable MATERIA_WEBSERVER_NETWORK. This env variable is used when integrated to the Materia docker stack to point to the Materia webserver directly (see docker-compose.yml in the Materia repo). Additionally, prod sets the env variable NODE_ENV=production which optimizes performance. |
+
+### Porting New H5P Widgets
+
+We would suggest using a simple H5P widget such as [Multiple Choice](https://github.com/ucfcdl/h5p-multichoice-materia-widget) as a reference point to port a new one. See the [Materia widget developer guide](https://ucfopen.github.io/Materia-Docs/develop/widget-developer-guide.html) for an in-depth understanding of Materia widgets.
+
+1. Navigate to the /admin page and install the new library you want to port
+2. Make a copy of the h5p multichoice repo as a template for your new widget repo.
+ 1. Rename all instances of `multichoice` in the repo to an appropriate name for your new widget
+ 2. In creator.js: the `paramsToQset()` function, remove code that modifies `qset.items` as this is content-specific to multichoice and will cause errors when you try to save an instance of your new widget.
+ 3. In player.js: update the `/play/1` url to the number created in step 3.b. below.
+3. Update the h5p server to be able to handle loading this new widget:
+ 1. First you have to find the main library name/version for the widget you just installed. There are a couple ways to do so, one is to go into your h5p/libraries directory and look for the corresponding name. (For example if you just installed the Crossword widget, the library would look like `H5P.Crossword 0.4`).
+ 2. Next, in the h5p/content directory, we have an incrementing list of folders that correspond to h5p widget config files. It’s suggested to copy/paste the multichoice one as a template, increment the directory number, then
+ 1. rename the values as appropriate (`title`, `mainLibrary`, etc) .
+ 2. replace the `preloadedDependency` for `H5P.Multichoice` with the values for your main library. (For example for Crossword, replace `machineName` with `H5P.Crossword`, `majorVersion: 0`, `minorVersion: 4`).
+ 3. In `renderers/editor.js`: find the switch case that sets the selected library in `rendererInit()`. Add a case for your new library. For example in the case of crossword, the new case would look like
+ ```
+ case “h5p-crossword”:
+ window.selectedLibrary = “H5P.Crossword 0.4”;
+ break;
+ ```
+4. At this point you should be able to run `yarn start` in your new repo and see your new widget running at `localhost:8118`.
+5. The next step would be to implement scoring for the widget (see [Implementing Scoring](#implementing-scoring) below)
+6. You’ll want to create a demo for your widget, which can be used by users that want to try out functionality in Materia. Simply copy a working qset that you’d like to use into `demo.json`
+7. Finally, the last step would be to port a compiled version of the widget from your MWDK (which you have been working in) to your local Materia installation.
+ 1. At the top right corner of your MWDK page, click `Download Package`. This will bring up a prompt where you can either
+ - Download a .wigt file and manually install it to your local Materia
+ - Click Install to Docker Materia where the MWDK will attempt to upload the widget automatically. Wait a few seconds to see a success or failure message pop up.
+
+If you’re porting a widget that uses libraries not currently installed by one of the other widgets, you may need to add them yourself. While Interactive Video covers a large amount of the libraries available, it’s still possible you are porting a widget that requires others.
+In development, this can be done by navigating to localhost:3000/admin and clicking `Install` on the widget you are porting. (same as step 1)
+However if you want this widget to be supported by default when installing H5P in the future, you will need to update the `setup.sh` script. Use the only list of available library download links we currently know of [here](https://h5p.org/comment/18115#comment-18115) to grab a download link and copy an existing `curl` command to add its required libraries to the ones that get installed by default.
+
+### Implementing Scoring
+
+H5P widgets in Materia implement scoring using the data captured from H5P events (see [Integration with Materia](#integration-with-materia) below). Because each H5P widget is created differently, a standard scoring mechanism cannot be guaranteed for every H5P content type.
+In your H5P widget’s `player.js`, you can start by printing out the events received to the console to see at which interactions you receive scoring data. In some cases it might be after every interaction, or it could not be until the very end of play. Typically, the best way to monitor for scoring events is to filter for events with the `event.data.result` property present.
+Once you find when and where to get your score data, you can choose to utilize
+
+- Materia’s default scoring module, which is written in php and found in `src/_score`. An example of this can be found in H5P widget repositories such as Interactive Video or Quiz Question Set.
+- Write your own custom score module in javascript. An example of custom scoring can be found in quite a few H5P widget repositories such as Drag the Words.
+
+To use a custom score module with the widget, you will have to add it to the `install.yaml` file config. Under `score:`, add the name of your custom score screen html file like so:
+
+```
+score:
+ is_scorable: Yes
+ score_module: H5PMultiChoice
+ score_screen: scoreScreen.html
+```
+
+### H5P Server Customization
+
+The behavior of h5p can be customized in a few different ways, see Lumi docs on [Customization](https://docs.lumi.education/advanced-usage/customization) for more details. When writing your own customization, remember to append a reference/URL of your script to the options parameter of the H5PEditor object in `express.js`, as defined in the docs.
+We have implemented a few scripts that customize behavior in the `custom/` directory:
+
+| Script | Description | How it Works |
+| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `Fullscreen.js` | Forces the h5p editor to be fullscreen within the iframe of the Materia widget, and removes a button that is unnecessary for Materia users. | Overrides h5p/editor/scripts/h5peditor-fullscreen-bar.js to grab a reference to the fullscreen html button and call `.click()` on it while the page is loading. |
+| `interactiveVideoUpload.js` | Prevents users from uploading videos specifically for the H5P Interactive Video Widget, since Materia cannot currently support video uploads. | Overrides h5p/editor/scripts/h5peditor-av.js to specifically remove the video file upload html that is inserted in the `C.createAdd()` function |
+| `Hooks.js` | This hook is used to prevent users from uploading videos to any other widgets besides Interactive Video, since Materia currently can’t support video uploads. | utilizing [this](https://docs.lumi.education/advanced-usage/customization#changing-the-semantics-of-individual-libraries) provided `alterLibrarySemanticsHook` to intercept libraries as they are being loaded for a widget, removing the `H5P.Video` library if found. |
+
+Though not the cleanest solution, the most effective way we’ve found to customize behavior is copying the scripts that power the editor (mostly found in h5p/editor/scripts/) to a custom script, and overwriting the parts you need to.
+
+### Image Uploading to Materia
+
+By default, the H5P server saves uploaded media to a file on the server in the `h5p/temporary-storage` directory. This implementation is built to be able to be overridden with a custom interface, which is what we have done in order to pass all media uploaded directly to the Materia server. The following resources were referenced to create our custom interface
+
+- some sparse documentation of an implementation written for S3 upload [here](https://docs.lumi.education/npm-packages/h5p-mongos3/s3-temporary-file-storage)
+- The full file for that S3 implementation [here](https://github.com/Lumieducation/H5P-Nodejs-library/blob/master/packages/h5p-mongos3/src/S3TemporaryFileStorage.ts)
+- the actual implementation of the class from the original server found [here](https://github.com/Lumieducation/H5P-Nodejs-library/blob/master/packages/h5p-server/src/implementation/fs/DirectoryTemporaryFileStorage.ts)
+
+You can find this custom built interface in `interfaces/MateriaTempFileStorage.js`. The high-level overview of this media uploading flow is
+
+1. Create the file in a temporary directory on the server
+2. Create a readstream using the temporary file
+3. Upload this readstream directly to the Materia server with a POST request, which requires some OAuth steps.
+4. If the upload was successful, Materia will send back a 5 digit hash that represents the ID of the file on the Materia server. We rename the temporary file with this 5 digit hash so that it matches up, and can be inserted into the qset when the user saves the widget they’re editing.
+
+There’s also a cron job that can be found in `express.js` that is used to clean out this temporary directory daily at 3:00am.
+
+## Setup and Docker Install Overview
+
+The setup script
+
+- Downloads the [H5P Core](https://github.com/h5p/h5p-php-library/archive/1.24.0.zip) and [H5P Editor](https://github.com/h5p/h5p-editor-php-library/archive/1.24.0.zip) files and unzips them into their requisite directories: h5p/core and h5p/editor respectively.
+- It then downloads the library files needed for the h5p widgets we currently offer and unzips them into h5p/libraries. This is done for two reasons:
+ - The install is automated and they do not have to be manually installed from the h5p hub client.
+ - In prod, we do not have a reason to expose the `/admin` page, so no auth needs to be built into the server.
+
+The Dockerfile that builds the server is incredibly simple, it
+
+1. Copies the package.json to the container and installs the necessary dependencies
+2. Copies the application source code to the container
+3. Optionally can remove h5p/ directories in the container to ensure a fresh install of the server for development purposes
+4. Runs the setup script
+5. Exposes the port to your local machine
+6. Starts the server
+
+### Integrating into the Materia Stack
+
+When integrated into the Materia Stack, this server sits in its own directory called `h5p-server/`. It is spun up with the rest of the Materia stack in Materia’s docker-compose file. There, a few configs need to be specified in order for the integration to be successful:
+
+- An `arg` is added to the build config to specify what [Materia environment variable](#selecting-a-materia-environment-variable) we want to use. Don’t forget to update this to `prod` once you are ready to deploy to production alongside Materia!
+- The correct ports are exposed between your local machine and the container (3000 for both, by default)
+- The `frontend` network is specified, so that this container is capable of communicating with Materia’s `webserver` container, which is on the same network.
+- The environment variable `MATERIA_WEBSERVER_NETWORK` is set to the name of Materia’s server container (`webserver`) so that h5p can send the upload requests to the corresponding URL
+
+## Architecture and Integration
+
+### H5P Server
+
+Lumi provides a short overview of the architecture for their template server [here](https://docs.lumi.education/usage/architecture). There they provide the following visual diagram:
+![H5P Node Server Architevture](docs/H5P_node_architecture.svg)
+This application implements our own `Express Adapter` and `Temporary File Storage`, as well as customizations on various pieces described above, such as the `editor client` and `LibraryManager`. The `player client` is not shown but works similarly to the editor, just more simply with fewer endpoints.
+
+### Integration with Materia
+
+![Materia H5P Integration Architecture Diagram](docs/Materia_H5P_Architecture_Diagram.png)
+The above figure demonstrates the relationship between the Materia server, iFrame, and the H5P Server. H5P only talks to Materia to upload images that users upload in the creator.
+The iFrames send postMessages back and forth to communicate events with each other:
+| H5P iFrame to Materia Parent iFrame | Materia iFrame to H5P child iFrame |
+| ----------------------------------- | ---------------------------------- |
+| `ready_for_qset`: when editing or playing an existing widget, H5P requests the qset from Materia | `params_send`: response to `ready_for_qset`. When editing or playing an existing widget, Materia provides H5P the qset to load. |
+| `save`: response to `widget_save`. Sends the library and h5p widget parameters to Materia to store in the qset when saving a widget | `widget_save`: Tells H5P that the widget is ready to be created. H5P will respond with `save` postMessage along with params to be encoded in the qset. |
+| H5P xAPI Events: when H5P dispatches its own events from the user interacting with a widget, we capture them and send them to the Materia iFrame in case they are useful for scoring, etc. See H5P xAPI Docs (https://h5p.org/documentation/x-api) for light documentation. |
diff --git a/h5p-server/config/h5p-config.json b/h5p-server/config/h5p-config.json
new file mode 100644
index 000000000..d0ffce76e
--- /dev/null
+++ b/h5p-server/config/h5p-config.json
@@ -0,0 +1,12 @@
+{
+ "fetchingDisabled": 0,
+ "uuid": "8de62c47-f335-42f6-909d-2d8f4b7fb7f5",
+ "siteType": "local",
+ "sendUsageStatistics": false,
+ "hubRegistrationEndpoint": "https://api.h5p.org/v1/sites",
+ "hubContentTypesEndpoint": "https://api.h5p.org/v1/content-types/",
+ "contentTypeCacheRefreshInterval": 86400000,
+ "enableLrsContentTypes": true,
+ "ajaxUrl": "/ajax?action=",
+ "baseUrl": "/h5p"
+}
diff --git a/h5p-server/custom/fullscreen.js b/h5p-server/custom/fullscreen.js
new file mode 100644
index 000000000..649ce844f
--- /dev/null
+++ b/h5p-server/custom/fullscreen.js
@@ -0,0 +1,111 @@
+H5PEditor.FullscreenBar = (function($) {
+ function FullscreenBar($mainForm, library) {
+ const title = H5PEditor.libraryCache[library]
+ ? H5PEditor.libraryCache[library].title
+ : library;
+ const iconId = library
+ .split(" ")[0]
+ .split(".")[1]
+ .toLowerCase();
+
+ let isInFullscreen = false;
+ let exitSemiFullscreen;
+
+ $mainForm.addClass("h5peditor-form-manager");
+
+ // Add fullscreen bar
+ const $bar = ns.$("
", {
+ class: "h5peditor-form-manager-head"
+ });
+
+ const $breadcrumb = ns.$("", {
+ class: "h5peditor-form-manager-breadcrumb",
+ appendTo: $bar
+ });
+
+ const $title = ns.$("", {
+ class: "h5peditor-form-manager-title " + iconId,
+ text: title,
+ appendTo: $breadcrumb
+ });
+
+ const fullscreenButton = createButton(
+ "fullscreen",
+ "",
+ function() {
+ if (isInFullscreen) {
+ // Trigger semi-fullscreen exit
+ exitSemiFullscreen();
+ } else {
+ // Trigger semi-fullscreen enter
+ exitSemiFullscreen = H5PEditor.semiFullscreen(
+ $mainForm,
+ function() {
+ fullscreenButton.setAttribute(
+ "aria-label",
+ H5PEditor.t("core", "exitFullscreenButtonLabel")
+ );
+ isInFullscreen = true;
+ },
+ function() {
+ fullscreenButton.setAttribute(
+ "aria-label",
+ H5PEditor.t("core", "enterFullscreenButtonLabel")
+ );
+ isInFullscreen = false;
+ }
+ );
+ }
+ },
+ H5PEditor.t("core", "enterFullscreenButtonLabel")
+ );
+
+ // Create 'Proceed to save' button
+ // const proceedButton = createButton(
+ // "proceed",
+ // H5PEditor.t("core", "proceedButtonLabel"),
+ // function() {
+ // exitSemiFullscreen();
+ // }
+ // );
+
+ // $bar.append(proceedButton);
+ $bar.append(fullscreenButton);
+ $mainForm.prepend($bar);
+ fullscreenButton.click();
+ }
+
+ /**
+ * Helper for creating buttons.
+ *
+ * @private
+ * @param {string} id
+ * @param {string} text
+ * @param {function} clickHandler
+ * @param {string} ariaLabel
+ * @return {Element}
+ */
+ const createButton = function(id, text, clickHandler, ariaLabel) {
+ if (ariaLabel === undefined) {
+ ariaLabel = text;
+ }
+
+ const button = document.createElement("button");
+ button.setAttribute("type", "button");
+ button.classList.add("h5peditor-form-manager-button");
+ button.classList.add("h5peditor-form-manager-" + id);
+ button.setAttribute("aria-label", ariaLabel);
+ button.addEventListener("click", clickHandler);
+
+ // Create special inner filler to avoid focus from pointer devices.
+ const content = document.createElement("span");
+ content.classList.add("h5peditor-form-manager-button-inner");
+ content.innerText = text;
+ content.tabIndex = -1;
+ button.appendChild(content);
+
+ return button;
+ };
+
+ return FullscreenBar;
+})(ns.jQuery);
diff --git a/h5p-server/custom/hooks.js b/h5p-server/custom/hooks.js
new file mode 100644
index 000000000..a776852ab
--- /dev/null
+++ b/h5p-server/custom/hooks.js
@@ -0,0 +1,33 @@
+module.exports = {
+ /**
+ * This hook is called when the editor retrieves the semantics of a
+ * library.
+ * Note: This function should be immutable, so it shouldn't change the
+ * semantics parameter but return a clone!
+ * @param library the library that is currently being loaded
+ * @param semantics the original semantic structure
+ * @returns the changed semantic structure
+ */
+ alterLibrarySemanticsHook: (library, semantics) => {
+ // Updating the Library.fields.options array to remove
+ // "H5P.Video" from the allowed options, so users cannot upload
+ // videos to any h5p widgets, as materia cannot support video files
+ if (semantics[0].hasOwnProperty("fields")) {
+ if (semantics[0].fields[0].hasOwnProperty("options")) {
+ semantics[0].fields[0].options = semantics[0].fields[0].options.filter(
+ library => {
+ // messy but have to check if it is a string array
+ // because of course all h5p widgets have to be different
+ if (typeof library == "string") {
+ return !library.startsWith("H5P.Video");
+ }
+ // if not a string array then just return `true` to the filter
+ // to not remove anything
+ return true;
+ }
+ );
+ }
+ }
+ return semantics;
+ }
+};
diff --git a/h5p-server/custom/interactiveVideoUpload.js b/h5p-server/custom/interactiveVideoUpload.js
new file mode 100644
index 000000000..8c6f08fe5
--- /dev/null
+++ b/h5p-server/custom/interactiveVideoUpload.js
@@ -0,0 +1,842 @@
+/*
+this custom script overrides h5p/editor/scripts/h5peditor-av.js to specifically
+remove the video file upload html that is inserted in the C.createAdd() function
+*/
+
+/* global ns */
+/**
+ * Audio/Video module.
+ * Makes it possible to add audio or video through file uploads and urls.
+ *
+ */
+H5PEditor.widgets.video = H5PEditor.widgets.audio = H5PEditor.AV = (function(
+ $
+) {
+ /**
+ * Constructor.
+ *
+ * @param {mixed} parent
+ * @param {object} field
+ * @param {mixed} params
+ * @param {function} setValue
+ * @returns {_L3.C}
+ */
+ function C(parent, field, params, setValue) {
+ var self = this;
+
+ // Initialize inheritance
+ H5PEditor.FileUploader.call(self, field);
+
+ this.parent = parent;
+ this.field = field;
+ this.params = params;
+ this.setValue = setValue;
+ this.changes = [];
+
+ if (params !== undefined && params[0] !== undefined) {
+ this.setCopyright(params[0].copyright);
+ }
+
+ // When uploading starts
+ self.on("upload", function() {
+ // Insert throbber
+ self.$uploading = $(
+ '
' +
+ H5PEditor.t("core", "uploading") +
+ "
"
+ ).insertAfter(self.$add.hide());
+
+ // Clear old error messages
+ self.$errors.html("");
+
+ // Close dialog
+ self.closeDialog();
+ });
+
+ // Monitor upload progress
+ self.on("uploadProgress", function(e) {
+ self.$uploading.html(
+ H5PEditor.t("core", "uploading") + " " + Math.round(e.data * 100) + " %"
+ );
+ });
+
+ // Handle upload complete
+ self.on("uploadComplete", function(event) {
+ var result = event.data;
+
+ // Clear out add dialog
+ this.$addDialog.find(".h5p-file-url").val("");
+
+ try {
+ if (result.error) {
+ throw result.error;
+ }
+
+ // Set params if none is set
+ if (self.params === undefined) {
+ self.params = [];
+ self.setValue(self.field, self.params);
+ }
+
+ // Add a new file/source
+ var file = {
+ path: result.data.path,
+ mime: result.data.mime,
+ copyright: self.copyright
+ };
+ var index =
+ self.updateIndex !== undefined
+ ? self.updateIndex
+ : self.params.length;
+ self.params[index] = file;
+ self.addFile(index);
+
+ // Trigger change callbacks (old event system)
+ for (var i = 0; i < self.changes.length; i++) {
+ self.changes[i](file);
+ }
+ } catch (error) {
+ // Display errors
+ self.$errors.append(H5PEditor.createError(error));
+ }
+
+ if (self.$uploading !== undefined && self.$uploading.length !== 0) {
+ // Hide throbber and show add button
+ self.$uploading.remove();
+ self.$add.show();
+ }
+ });
+ }
+
+ C.prototype = Object.create(ns.FileUploader.prototype);
+ C.prototype.constructor = C;
+
+ /**
+ * Append widget to given wrapper.
+ *
+ * @param {jQuery} $wrapper
+ */
+ C.prototype.appendTo = function($wrapper) {
+ var self = this;
+ const id = ns.getNextFieldId(this.field);
+
+ var imageHtml =
+ '
",
+ false,
+ id,
+ hasDescription
+ );
+ };
+
+ /**
+ * Providers incase mime type is unknown.
+ * @public
+ */
+ C.providers = [
+ {
+ name: "YouTube",
+ regexp: /(?:https?:\/\/)?(?:www\.)?(?:(?:youtube.com\/(?:attribution_link\?(?:\S+))?(?:v\/|embed\/|watch\/|(?:user\/(?:\S+)\/)?watch(?:\S+)v\=))|(?:youtu.be\/|y2u.be\/))([A-Za-z0-9_-]{11})/i,
+ aspectRatio: "16:9"
+ }
+ ];
+
+ // Avoid ID attribute collisions
+ let idCounter = 0;
+
+ /**
+ * Grab the next available ID to avoid collisions on the page.
+ * @public
+ */
+ C.getNextId = function() {
+ return idCounter++;
+ };
+
+ return C;
+})(H5P.jQuery);
diff --git a/h5p-server/docs/H5P_node_architecture.svg b/h5p-server/docs/H5P_node_architecture.svg
new file mode 100644
index 000000000..37f88cdb5
--- /dev/null
+++ b/h5p-server/docs/H5P_node_architecture.svg
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/h5p-server/docs/Materia_H5P_Architecture_Diagram.png b/h5p-server/docs/Materia_H5P_Architecture_Diagram.png
new file mode 100644
index 000000000..04cd7c59a
Binary files /dev/null and b/h5p-server/docs/Materia_H5P_Architecture_Diagram.png differ
diff --git a/h5p-server/express.js b/h5p-server/express.js
new file mode 100644
index 000000000..7f8f4db3e
--- /dev/null
+++ b/h5p-server/express.js
@@ -0,0 +1,295 @@
+const express = require("express");
+const https = require("https");
+const fs = require("fs");
+const path = require("path");
+const bodyParser = require("body-parser");
+const upload = require("express-fileupload");
+const cors = require("cors");
+const H5PServer = require("@lumieducation/h5p-server");
+const { h5pAjaxExpressRouter } = require("@lumieducation/h5p-express");
+const dotenv = require("dotenv");
+const cron = require("node-cron");
+const morgan = require("morgan");
+
+const { editorRenderer } = require("./renderers/editor");
+const { playerRenderer } = require("./renderers/player");
+const { adminRenderer } = require("./renderers/admin");
+const hooks = require("./custom/hooks");
+import MateriaTempFileStorage from "./interfaces/MateriaTempFileStorage.js";
+
+const app = express();
+
+const port = 3333;
+
+// if in development use '.env.local' file, else use '.env' file
+const envFile = process.env.ENVIRONMENT == "prod" ? `.env` : ".env.local";
+dotenv.config({ path: path.resolve(envFile) });
+
+function User() {
+ this.id = "1";
+ this.name = "Firstname Surname";
+ this.canInstallRecommended = true;
+ this.canUpdateAndInstallLibraries = true;
+ this.canCreateRestricted = true;
+ this.type = "local";
+ this.email = "test@example.com";
+}
+
+let h5pEditor = {};
+let h5pPlayer = {};
+
+const setupConfig = (resolve, reject) => {
+ new H5PServer.H5PConfig(
+ new H5PServer.fsImplementations.JsonStorage(
+ path.resolve("config/h5p-config.json")
+ )
+ )
+ .load()
+ .then(config => {
+ resolve(config);
+ });
+};
+
+const setupPlayerAndEditor = config => {
+ // using cached library storage to cache most common calls to the libraries, improving performance
+ // also using custom tempfilestorage to automate uploading media to materia from h5p server
+ const editor = new H5PServer.H5PEditor(
+ new H5PServer.fsImplementations.InMemoryStorage(),
+ config,
+ // new H5PServer.fsImplementations.FileLibraryStorage(
+ // path.resolve("h5p/libraries")
+ // ),
+ new H5PServer.cacheImplementations.CachedLibraryStorage(
+ new H5PServer.fsImplementations.FileLibraryStorage(
+ path.resolve("h5p/libraries")
+ )
+ ),
+ new H5PServer.fsImplementations.FileContentStorage(
+ path.resolve("h5p/content")
+ ),
+ // new H5PServer.fsImplementations.DirectoryTemporaryFileStorage(
+ // path.resolve("h5p/temporary-storage")
+ // )
+ new MateriaTempFileStorage(),
+ undefined,
+ undefined,
+ {
+ customization: {
+ global: {
+ scripts: [
+ "/custom/fullscreen.js",
+ "/custom/interactiveVideoUpload.js"
+ ]
+ },
+ alterLibrarySemantics: hooks.alterLibrarySemanticsHook
+ }
+ }
+ );
+
+ const player = new H5PServer.H5PPlayer(
+ editor.libraryStorage,
+ editor.contentStorage,
+ config
+ );
+
+ return [editor, player];
+};
+
+const setupServer = ([editor, player]) => {
+ h5pEditor = editor;
+ h5pPlayer = player;
+
+ const setupServerPromise = (resolve, reject) => {
+ // TODO replace whitelist with hosts from context env var
+
+ const whitelist =
+ process.env.ENVIRONMENT == "prod" || process.env.ENVIRONMENT == "dev"
+ ? [process.env.MATERIA_URL]
+ : ["http://localhost:8118"];
+
+ app.use(
+ cors({
+ origin: whitelist
+ })
+ );
+
+ app.use(morgan("combined"));
+
+ app.use(bodyParser.json({ limit: "500mb" }));
+ app.use(
+ bodyParser.urlencoded({
+ extended: true
+ })
+ );
+
+ app.use(
+ upload({
+ limits: { fileSize: h5pEditor.config.maxFileSize }
+ })
+ );
+
+ // inject user data into request
+ // TODO don't just make an arbitrary user object!
+ app.use((req, res, next) => {
+ req.user = new User();
+ next();
+ });
+
+ // load custom styles and js
+ app.use("/styles", express.static("styles"));
+ app.use("/custom", express.static("custom"));
+
+ // RENDERER OVERRIDES
+ // ASSUMING DIRECT CONTROL
+ h5pEditor.setRenderer(editorRenderer);
+ h5pPlayer.setRenderer(playerRenderer);
+
+ app.use(
+ h5pEditor.config.baseUrl,
+ h5pAjaxExpressRouter(
+ h5pEditor,
+ path.resolve("h5p/core"), // the path on the local disc where the files of the JavaScript client of the player are stored
+ path.resolve("h5p/editor") // the path on the local disc where the files of the JavaScript client of the editor are stored
+ // undefined,
+ // "auto" // You can change the language of the editor here by setting
+ // the language code you need here. 'auto' means the route will try
+ // to use the language detected by the i18next language detector.
+ )
+ );
+
+ // cron job runs once daily at 3:00am to delete temporary files that have expired.
+ // expired time being 24 hours after initial upload
+ cron.schedule("0 3 * * *", () => {
+ h5pEditor.temporaryFileManager.cleanUp();
+ });
+
+ resolve();
+ };
+ return new Promise(setupServerPromise);
+};
+
+new Promise(setupConfig)
+ .then(setupPlayerAndEditor)
+ .then(setupServer)
+ .then(page => {
+ app.get("/status", (req, res) => {
+ res.status(200).end();
+ });
+
+ // only expose /admin route if in development
+ if (process.env.ENVIRONMENT != "prod") {
+ app.get("/admin", (req, res) => {
+ h5pEditor.setRenderer(adminRenderer);
+ h5pEditor
+ .render(undefined, "en", req.user)
+ .then(page => {
+ res.send(page);
+ res.status(200).end();
+ })
+ .catch(error => {
+ console.error("Error GET /admin:");
+ console.error(error);
+ res.status(500).end();
+ });
+ });
+ }
+
+ // create new h5p content of a given type
+ app.get("/new/:type", (req, res) => {
+ h5pEditor.setRenderer(editorRenderer);
+
+ h5pEditor
+ .render(undefined, "en", req.user)
+ .then(page => {
+ res.send(page);
+ res.status(200).end();
+ })
+ .catch(error => {
+ console.error("Error GET /new/:type :");
+ console.error(error);
+ res.status(500).end();
+ });
+ });
+
+ app.get("/edit/:type", (req, res) => {
+ h5pEditor.setRenderer(editorRenderer);
+
+ h5pEditor
+ .render(undefined, "en", req.user)
+ .then(page => {
+ res.send(page);
+ res.status(200).end();
+ })
+ .catch(error => {
+ console.error("Error GET /edit/:type :");
+ console.error(error);
+ res.status(500).end();
+ });
+ });
+
+ app.get(`${h5pEditor.config.playUrl}/:type`, (req, res) => {
+ // TODO provide h5pPlayer with content id depending on h5P | materia toggle? Is that needed?
+ // otherwise, we're looking for req.params.contentId
+ if (req.params.type == "undefined") {
+ // TODO: probly need to display a 404 for this
+ console.error("Error GET /play/:type : type undefined");
+ res.status(404).end();
+ }
+
+ // type directs to render configs located at h5p/content/:type
+ h5pPlayer
+ .render(req.params.type, "en", req.user)
+ .then(h5pPage => {
+ res.send(h5pPage);
+ res.status(200).end();
+ })
+ .catch(error => {
+ console.error("Error GET /play/:type :");
+ console.error(error);
+ res.status(500).end();
+ });
+ });
+
+ // return a new h5p widget
+ // used for materia to check for specific library
+ // should be deprecated as there is no need to save this information on the server
+ app.post("/new/:type", (req, res) => {
+ h5pEditor
+ .saveOrUpdateContent(
+ undefined,
+ req.body.params.params,
+ req.body.params.metadata,
+ req.body.library,
+ req.user
+ )
+ .then(contentId => {
+ //returns contentID of widget for materia to put in the qset
+ res.send(JSON.stringify({ contentId }));
+ res.status(200).end();
+ })
+ .catch(error => {
+ console.error("Error POST /new/:type :");
+ console.error(error);
+ res.status(500).end();
+ });
+ });
+
+ var key = fs.readFileSync("config/key.pem");
+ var cert = fs.readFileSync("config/cert.pem");
+ var options = {
+ key: key,
+ cert: cert
+ };
+
+ const server = https.createServer(options, app);
+
+ server.listen(port, () => {
+ console.log(
+ `Server is running at http(s)://localhost:${port}. Current Materia context: ${process.env.ENVIRONMENT}.`
+ );
+ });
+ })
+ .catch(error => {
+ console.error("Error setting up the h5p node express server:");
+ console.error(error);
+ });
diff --git a/h5p-server/h5p/content/0/content.json b/h5p-server/h5p/content/0/content.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/h5p-server/h5p/content/0/content.json
@@ -0,0 +1 @@
+{}
diff --git a/h5p-server/h5p/content/0/h5p.json b/h5p-server/h5p/content/0/h5p.json
new file mode 100644
index 000000000..97b4dbef6
--- /dev/null
+++ b/h5p-server/h5p/content/0/h5p.json
@@ -0,0 +1,70 @@
+{
+ "embedTypes": ["iframe"],
+ "language": "en",
+ "license": "U",
+ "extraTitle": "Materia h5p question set",
+ "title": "Materia h5p question set",
+ "mainLibrary": "H5P.QuestionSet",
+ "preloadedDependencies": [
+ {
+ "machineName": "H5P.Image",
+ "majorVersion": 1,
+ "minorVersion": 1
+ },
+ {
+ "machineName": "FontAwesome",
+ "majorVersion": 4,
+ "minorVersion": 5
+ },
+ {
+ "machineName": "EmbeddedJS",
+ "majorVersion": 1,
+ "minorVersion": 0
+ },
+ {
+ "machineName": "H5P.JoubelUI",
+ "majorVersion": 1,
+ "minorVersion": 3
+ },
+ {
+ "machineName": "H5P.Question",
+ "majorVersion": 1,
+ "minorVersion": 4
+ },
+ {
+ "machineName": "H5P.MultiChoice",
+ "majorVersion": 1,
+ "minorVersion": 14
+ },
+ {
+ "machineName": "H5P.QuestionSet",
+ "majorVersion": 1,
+ "minorVersion": 17
+ },
+ {
+ "machineName": "H5P.DragQuestion",
+ "majorVersion": 1,
+ "minorVersion": 13
+ },
+ {
+ "machineName": "H5P.TrueFalse",
+ "majorVersion": 1,
+ "minorVersion": 6
+ },
+ {
+ "machineName": "H5P.Blanks",
+ "majorVersion": 1,
+ "minorVersion": 12
+ },
+ {
+ "machineName": "H5P.MarkTheWords",
+ "majorVersion": 1,
+ "minorVersion": 9
+ },
+ {
+ "machineName": "H5P.DragText",
+ "majorVersion": 1,
+ "minorVersion": 8
+ }
+ ]
+}
diff --git a/h5p-server/h5p/content/1/content.json b/h5p-server/h5p/content/1/content.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/h5p-server/h5p/content/1/content.json
@@ -0,0 +1 @@
+{}
diff --git a/h5p-server/h5p/content/1/h5p.json b/h5p-server/h5p/content/1/h5p.json
new file mode 100644
index 000000000..db73060c8
--- /dev/null
+++ b/h5p-server/h5p/content/1/h5p.json
@@ -0,0 +1,40 @@
+{
+ "embedTypes": ["iframe"],
+ "language": "en",
+ "license": "U",
+ "extraTitle": "Materia h5p multiple choice",
+ "title": "Materia h5p multiple choice",
+ "mainLibrary": "H5P.MultiChoice",
+ "preloadedDependencies": [
+ {
+ "machineName": "H5P.Image",
+ "majorVersion": 1,
+ "minorVersion": 1
+ },
+ {
+ "machineName": "FontAwesome",
+ "majorVersion": 4,
+ "minorVersion": 5
+ },
+ {
+ "machineName": "EmbeddedJS",
+ "majorVersion": 1,
+ "minorVersion": 0
+ },
+ {
+ "machineName": "H5P.JoubelUI",
+ "majorVersion": 1,
+ "minorVersion": 3
+ },
+ {
+ "machineName": "H5P.MultiChoice",
+ "majorVersion": 1,
+ "minorVersion": 14
+ },
+ {
+ "machineName": "H5P.Video",
+ "majorVersion": 1,
+ "minorVersion": 5
+ }
+ ]
+}
diff --git a/h5p-server/h5p/content/2/content.json b/h5p-server/h5p/content/2/content.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/h5p-server/h5p/content/2/content.json
@@ -0,0 +1 @@
+{}
diff --git a/h5p-server/h5p/content/2/h5p.json b/h5p-server/h5p/content/2/h5p.json
new file mode 100644
index 000000000..18a24353b
--- /dev/null
+++ b/h5p-server/h5p/content/2/h5p.json
@@ -0,0 +1,40 @@
+{
+ "embedTypes": ["iframe"],
+ "language": "en",
+ "license": "U",
+ "extraTitle": "Materia h5p mark the words",
+ "title": "Materia h5p mark the words",
+ "mainLibrary": "H5P.MarkTheWords",
+ "preloadedDependencies": [
+ {
+ "machineName": "H5P.Image",
+ "majorVersion": 1,
+ "minorVersion": 1
+ },
+ {
+ "machineName": "FontAwesome",
+ "majorVersion": 4,
+ "minorVersion": 5
+ },
+ {
+ "machineName": "EmbeddedJS",
+ "majorVersion": 1,
+ "minorVersion": 0
+ },
+ {
+ "machineName": "H5P.JoubelUI",
+ "majorVersion": 1,
+ "minorVersion": 3
+ },
+ {
+ "machineName": "H5P.MarkTheWords",
+ "majorVersion": 1,
+ "minorVersion": 9
+ },
+ {
+ "machineName": "H5P.Video",
+ "majorVersion": 1,
+ "minorVersion": 5
+ }
+ ]
+}
diff --git a/h5p-server/h5p/content/3/content.json b/h5p-server/h5p/content/3/content.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/h5p-server/h5p/content/3/content.json
@@ -0,0 +1 @@
+{}
diff --git a/h5p-server/h5p/content/3/h5p.json b/h5p-server/h5p/content/3/h5p.json
new file mode 100644
index 000000000..16629491d
--- /dev/null
+++ b/h5p-server/h5p/content/3/h5p.json
@@ -0,0 +1,40 @@
+{
+ "embedTypes": ["iframe"],
+ "language": "en",
+ "license": "U",
+ "extraTitle": "Materia h5p advanced blanks",
+ "title": "Materia h5p advanced blanks",
+ "mainLibrary": "H5P.Blanks",
+ "preloadedDependencies": [
+ {
+ "machineName": "H5P.Image",
+ "majorVersion": 1,
+ "minorVersion": 1
+ },
+ {
+ "machineName": "FontAwesome",
+ "majorVersion": 4,
+ "minorVersion": 5
+ },
+ {
+ "machineName": "EmbeddedJS",
+ "majorVersion": 1,
+ "minorVersion": 0
+ },
+ {
+ "machineName": "H5P.JoubelUI",
+ "majorVersion": 1,
+ "minorVersion": 3
+ },
+ {
+ "machineName": "H5P.Blanks",
+ "majorVersion": 1,
+ "minorVersion": 12
+ },
+ {
+ "machineName": "H5P.Video",
+ "majorVersion": 1,
+ "minorVersion": 5
+ }
+ ]
+}
diff --git a/h5p-server/h5p/content/4/content.json b/h5p-server/h5p/content/4/content.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/h5p-server/h5p/content/4/content.json
@@ -0,0 +1 @@
+{}
diff --git a/h5p-server/h5p/content/4/h5p.json b/h5p-server/h5p/content/4/h5p.json
new file mode 100644
index 000000000..97f8940d7
--- /dev/null
+++ b/h5p-server/h5p/content/4/h5p.json
@@ -0,0 +1,40 @@
+{
+ "embedTypes": ["iframe"],
+ "language": "en",
+ "license": "U",
+ "extraTitle": "Materia h5p drag the words",
+ "title": "Materia h5p drag the words",
+ "mainLibrary": "H5P.DragText",
+ "preloadedDependencies": [
+ {
+ "machineName": "H5P.Image",
+ "majorVersion": 1,
+ "minorVersion": 1
+ },
+ {
+ "machineName": "FontAwesome",
+ "majorVersion": 4,
+ "minorVersion": 5
+ },
+ {
+ "machineName": "EmbeddedJS",
+ "majorVersion": 1,
+ "minorVersion": 0
+ },
+ {
+ "machineName": "H5P.JoubelUI",
+ "majorVersion": 1,
+ "minorVersion": 3
+ },
+ {
+ "machineName": "H5P.DragText",
+ "majorVersion": 1,
+ "minorVersion": 8
+ },
+ {
+ "machineName": "H5P.Video",
+ "majorVersion": 1,
+ "minorVersion": 5
+ }
+ ]
+}
diff --git a/h5p-server/h5p/content/5/content.json b/h5p-server/h5p/content/5/content.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/h5p-server/h5p/content/5/content.json
@@ -0,0 +1 @@
+{}
diff --git a/h5p-server/h5p/content/5/h5p.json b/h5p-server/h5p/content/5/h5p.json
new file mode 100644
index 000000000..f92942d22
--- /dev/null
+++ b/h5p-server/h5p/content/5/h5p.json
@@ -0,0 +1,110 @@
+{
+ "embedTypes": ["iframe"],
+ "language": "en",
+ "license": "U",
+ "extraTitle": "Materia h5p interactive video",
+ "title": "Materia h5p interactive video",
+ "mainLibrary": "H5P.InteractiveVideo",
+ "preloadedDependencies": [
+ {
+ "machineName": "H5P.Image",
+ "majorVersion": 1,
+ "minorVersion": 1
+ },
+ {
+ "machineName": "FontAwesome",
+ "majorVersion": 4,
+ "minorVersion": 5
+ },
+ {
+ "machineName": "EmbeddedJS",
+ "majorVersion": 1,
+ "minorVersion": 0
+ },
+ {
+ "machineName": "H5P.JoubelUI",
+ "majorVersion": 1,
+ "minorVersion": 3
+ },
+ {
+ "machineName": "H5P.GoToQuestion",
+ "majorVersion": 1,
+ "minorVersion": 3
+ },
+ {
+ "machineName": "H5P.Table",
+ "majorVersion": 1,
+ "minorVersion": 1
+ },
+ {
+ "machineName": "H5P.Link",
+ "majorVersion": 1,
+ "minorVersion": 3
+ },
+ {
+ "machineName": "H5P.SingleChoiceSet",
+ "majorVersion": 1,
+ "minorVersion": 11
+ },
+ {
+ "machineName": "H5P.IVHotspot",
+ "majorVersion": 1,
+ "minorVersion": 2
+ },
+ {
+ "machineName": "H5P.DragText",
+ "majorVersion": 1,
+ "minorVersion": 8
+ },
+ {
+ "machineName": "H5P.InteractiveVideo",
+ "majorVersion": 1,
+ "minorVersion": 22
+ },
+ {
+ "machineName": "H5P.Blanks",
+ "majorVersion": 1,
+ "minorVersion": 12
+ },
+ {
+ "machineName": "H5P.MarkTheWords",
+ "majorVersion": 1,
+ "minorVersion": 9
+ },
+ {
+ "machineName": "H5P.DragQuestion",
+ "majorVersion": 1,
+ "minorVersion": 13
+ },
+ {
+ "machineName": "H5P.MultiChoice",
+ "majorVersion": 1,
+ "minorVersion": 14
+ },
+ {
+ "machineName": "H5P.Summary",
+ "majorVersion": 1,
+ "minorVersion": 10
+ },
+ {
+ "machineName": "H5P.TrueFalse",
+ "majorVersion": 1,
+ "minorVersion": 6
+ },
+ {
+ "machineName": "H5P.Video",
+ "majorVersion": 1,
+ "minorVersion": 5
+ },
+ {
+ "machineName": "H5P.Text",
+ "majorVersion": 1,
+ "minorVersion": 1
+ },
+ {
+ "machineName": "H5P.FreeTextQuestion",
+ "majorVersion": 1,
+ "minorVersion": 0
+ }
+ ]
+}
diff --git a/h5p-server/h5p/content/999999999/content.json b/h5p-server/h5p/content/999999999/content.json
new file mode 100644
index 000000000..9e26dfeeb
--- /dev/null
+++ b/h5p-server/h5p/content/999999999/content.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/h5p-server/h5p/content/999999999/h5p.json b/h5p-server/h5p/content/999999999/h5p.json
new file mode 100644
index 000000000..338aa933f
--- /dev/null
+++ b/h5p-server/h5p/content/999999999/h5p.json
@@ -0,0 +1,50 @@
+{
+ "embedTypes": ["iframe"],
+ "language": "en",
+ "license": "U",
+ "extraTitle": "MATERIA DUMMY H5P FILE",
+ "title": "MATERIA DUMMY H5P FILE",
+ "mainLibrary": "H5P.QuestionSet",
+ "preloadedDependencies": [
+ {
+ "machineName": "H5P.Image",
+ "majorVersion": 1,
+ "minorVersion": 1
+ },
+ {
+ "machineName": "FontAwesome",
+ "majorVersion": 4,
+ "minorVersion": 5
+ },
+ {
+ "machineName": "EmbeddedJS",
+ "majorVersion": 1,
+ "minorVersion": 0
+ },
+ {
+ "machineName": "H5P.JoubelUI",
+ "majorVersion": 1,
+ "minorVersion": 3
+ },
+ {
+ "machineName": "H5P.Question",
+ "majorVersion": 1,
+ "minorVersion": 4
+ },
+ {
+ "machineName": "H5P.MultiChoice",
+ "majorVersion": 1,
+ "minorVersion": 14
+ },
+ {
+ "machineName": "H5P.QuestionSet",
+ "majorVersion": 1,
+ "minorVersion": 17
+ },
+ {
+ "machineName": "H5P.TrueFalse",
+ "majorVersion": 1,
+ "minorVersion": 6
+ }
+ ]
+}
diff --git a/h5p-server/h5p/core/.gitignore b/h5p-server/h5p/core/.gitignore
new file mode 100644
index 000000000..b303012e5
--- /dev/null
+++ b/h5p-server/h5p/core/.gitignore
@@ -0,0 +1,6 @@
+# Ignore everything in this directory
+*
+# Except this file
+!.gitignore
+
+# required to push empty directory to github
\ No newline at end of file
diff --git a/h5p-server/h5p/editor/.gitignore b/h5p-server/h5p/editor/.gitignore
new file mode 100644
index 000000000..b303012e5
--- /dev/null
+++ b/h5p-server/h5p/editor/.gitignore
@@ -0,0 +1,6 @@
+# Ignore everything in this directory
+*
+# Except this file
+!.gitignore
+
+# required to push empty directory to github
\ No newline at end of file
diff --git a/h5p-server/h5p/libraries/.gitignore b/h5p-server/h5p/libraries/.gitignore
new file mode 100644
index 000000000..86d0cb272
--- /dev/null
+++ b/h5p-server/h5p/libraries/.gitignore
@@ -0,0 +1,4 @@
+# Ignore everything in this directory
+*
+# Except this file
+!.gitignore
\ No newline at end of file
diff --git a/h5p-server/h5p/temporary-storage/.gitignore b/h5p-server/h5p/temporary-storage/.gitignore
new file mode 100644
index 000000000..86d0cb272
--- /dev/null
+++ b/h5p-server/h5p/temporary-storage/.gitignore
@@ -0,0 +1,4 @@
+# Ignore everything in this directory
+*
+# Except this file
+!.gitignore
\ No newline at end of file
diff --git a/h5p-server/interfaces/MateriaTempFileStorage.js b/h5p-server/interfaces/MateriaTempFileStorage.js
new file mode 100644
index 000000000..1ee7f4161
--- /dev/null
+++ b/h5p-server/interfaces/MateriaTempFileStorage.js
@@ -0,0 +1,275 @@
+import { H5pError, Logger } from "@lumieducation/h5p-server";
+
+const fsextra = require("fs-extra");
+const fs = require("fs");
+const request = require("request-promise");
+const promisepipe = require("promisepipe");
+const path = require("path");
+const crypto = require("crypto");
+
+const log = new Logger("MateriaTempFileStorage");
+
+// instructs node to allow untrusted certificates
+// when uploading images to materia
+// only for use in development
+if (process.env.ENVIRONMENT != "prod") {
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
+}
+
+export default class MateriaTempFileStorage {
+ constructor() {}
+
+ /**
+ * this gets called when a user uploads a new media file in an h5p widget creator
+ *
+ * Saves the file to temp directory then uploads to Materia
+ * @param filename: string auto generated by h5p
+ * @param dataStream: PassThrough (Read/Write stream) generated by h5p. not compatible with materia
+ * @param user: h5p user object, user.id for materia should always be 1
+ * @param expiration time: set for an hour after upload
+ */
+ async saveFile(filename, dataStream, user, expirationTime) {
+ if (!filename) {
+ log.error(`Filename empty!`);
+ throw new H5pError("illegal-filename", {}, 400);
+ }
+
+ // create the file in a temporary directory
+ // use this file to upload to materia
+ await fsextra.ensureDir(this.getAbsoluteUserDirectoryPath(user.id));
+ const filePath = this.getAbsoluteFilePath(user.id, filename);
+ await fsextra.ensureDir(path.dirname(filePath));
+ const writeStream = fsextra.createWriteStream(filePath);
+ await promisepipe(dataStream, writeStream);
+ await fsextra.writeJSON(`${filePath}.metadata`, {
+ expiresAt: expirationTime.getTime()
+ });
+
+ // upload to materia process
+ try {
+ // create a readstream with the temp file
+ const stream = fs.createReadStream(filePath);
+
+ //oauth token
+ const timestamp = Math.floor(new Date().getTime() / 1000).toString();
+ const nonce = crypto.randomBytes(16).toString("hex");
+ const token = crypto
+ .createHmac("sha256", process.env.OAUTH_SECRET + timestamp + nonce)
+ .update(process.env.OAUTH_KEY)
+ .digest("hex");
+ const materiaHost = process.env.MATERIA_WEBSERVER_NETWORK || "localhost";
+
+ // set up post request with multiform-data
+ var options = {
+ method: "POST",
+ url: `https://${materiaHost}/media/upload`,
+ formData: {
+ "Content-Type": "image/png", //todo get actual content type
+ file: {
+ value: stream,
+ options: {
+ filename: filename,
+ contentType: null
+ }
+ },
+ name: filename,
+ oauth_consumer_key: process.env.OAUTH_KEY,
+ oauth_timestamp: timestamp,
+ oauth_nonce: nonce,
+ oauth_signature: token
+ }
+ };
+
+ // create a filename using the materia media ID that is
+ // returned to us from the upload request
+ var materiaFilename = "";
+
+ await request(options, function(error, response) {
+ if (error) throw new Error(error);
+ if (response.statusCode == 404)
+ throw new Error("Unable to upload file to Materia");
+ // response should look like {"success": "true", "id": "5-digit-random-hash"}
+ response.body = JSON.parse(response.body);
+
+ // rename the temp file to materia ID hash so
+ // we can insert it in the materia qset later
+ const dir = filename.match(/[^\/]*\//); // something like "images/"
+ const filetype = filename.match(/\.[0-9a-z]+$/i); // something like ".png"
+ materiaFilename = dir + response.body.id + filetype;
+ fs.rename(
+ path.join("./h5p/temporary-storage/", user.id, filename),
+ path.join("./h5p/temporary-storage/", user.id, materiaFilename),
+ error => {
+ if (error) {
+ console.log("error renaming files");
+ console.log(error);
+ } else {
+ // also need to rename the metadata file so
+ // h5p knows where to find the file stats
+ fs.rename(
+ path.join(
+ "./h5p/temporary-storage/",
+ user.id,
+ filename + ".metadata"
+ ),
+ path.join(
+ "./h5p/temporary-storage/",
+ user.id,
+ materiaFilename + ".metadata"
+ ),
+ error => {
+ if (error) {
+ console.log("error renaming metadata files");
+ console.log(error);
+ }
+ }
+ );
+ }
+ }
+ );
+ });
+
+ // return the file object so h5p creator can retrieve it
+ return {
+ expiresAt: expirationTime,
+ filename: materiaFilename,
+ ownedByUserId: user.id
+ };
+ } catch (error) {
+ log.error(
+ `Error while uploading file "${filename}" to Materia storage: ${error.message}`
+ );
+ throw new H5pError(
+ `temporary-storage:materia-upload-error`,
+ { filename },
+ 500
+ );
+ }
+ }
+
+ // helper function simply generates temp file path
+ // for h5p creator to find media
+ getAbsoluteFilePath(userId, filename) {
+ return path.join("./h5p/temporary-storage/", userId, filename);
+ }
+
+ // generate users directory for temp file storage
+ // in materia's case userId will always be 1
+ getAbsoluteUserDirectoryPath(userId) {
+ return path.join("./h5p/temporary-storage/", userId);
+ }
+
+ // returns an object representing a file using metadata to get
+ // its expire time
+ async getTempFileInfo(userId, filename) {
+ const metadata = await fsextra.readJSON(
+ `${this.getAbsoluteFilePath(userId, filename)}.metadata`
+ );
+ return {
+ expiresAt: new Date(metadata.expiresAt),
+ filename,
+ ownedByUserId: userId
+ };
+ }
+
+ /**
+ * Checks if a file exists in temporary storage.
+ * @param filename the file to check
+ * @param user the user who wants to access the file
+ */
+ async fileExists(filename, user) {
+ const filePath = this.getAbsoluteFilePath(user.id, filename);
+ return fsextra.pathExists(filePath);
+ }
+
+ /**
+ * Removes a file from temporary storage. Will silently do nothing if the file does not
+ * exist or is not accessible.
+ * @param filename
+ * @param user
+ */
+ async deleteFile(filename, userId) {
+ const filePath = this.getAbsoluteFilePath(userId, filename);
+ fsextra.remove(filePath);
+ fsextra.remove(`${filePath}.metadata`);
+ }
+
+ /**
+ * List all files in temporary storage, from both the images/ and videos/ directories,
+ * and append them together. removes each corresponding .metadata file from the list
+ * @returns an object for each file of the form
+ * {
+ * expiresAt: DateTime,
+ * filename,
+ * ownedByUserId: userID
+ * }
+ */
+ async listFiles() {
+ // get list of files from images/ directory
+ let result = await fsextra.readdir("./h5p/temporary-storage/1/images");
+ // prepend images/ to each file
+ result = result.map(file => "images/" + file);
+
+ // get list of files from /videos direcotry
+ let videoResults = await fsextra.readdir(
+ "./h5p/temporary-storage/1/videos"
+ );
+ // prepend videos/ to each file
+ videoResults = videoResults.map(file => "videos/" + file);
+
+ // concatenate the two arrays to get all temporary files in one array
+ result = result.concat(videoResults);
+
+ // remove all metadata files from the array
+ result = result.filter(file => !file.endsWith(".metadata"));
+ // create object for each file, which includes expire time and user owner
+ result = await Promise.all(
+ result.map(file => this.getTempFileInfo("1", file))
+ );
+ return result;
+ }
+
+ /**
+ * Returns a information about a temporary file.
+ * Throws an exception if the file does not exist.
+ * @param filename the relative path inside the library
+ * @param user the user who wants to access the file
+ * @returns the file stats
+ */
+ async getFileStats(filename, user) {
+ if (!(await this.fileExists(filename, user))) {
+ throw new H5pError(
+ "storage-file-implementations:temporary-file-not-found",
+ {
+ filename,
+ userId: user.id
+ },
+ 404
+ );
+ }
+ const filePath = this.getAbsoluteFilePath(user.id, filename);
+ return fsextra.stat(filePath);
+ }
+
+ /**
+ * Returns a readable for a file.
+ *
+ * Note: Make sure to handle the 'error' event of the Readable! This method
+ * does not check if the file exists in storage to avoid the extra request.
+ * However, this means that there will be an error when piping the Readable
+ * to the response if the file doesn't exist!
+ * @param filename
+ * @param user
+ */
+ async getFileStream(filename, user) {
+ const filePath = this.getAbsoluteFilePath(user.id, filename);
+ if (!(await fsextra.pathExists(filePath))) {
+ throw new H5pError(
+ "storage-file-implementations:temporary-file-not-found",
+ { filename, userId: user.id },
+ 404
+ );
+ }
+ return fsextra.createReadStream(filePath);
+ }
+}
diff --git a/h5p-server/materia-h5p.Dockerfile b/h5p-server/materia-h5p.Dockerfile
new file mode 100644
index 000000000..3fdf8068c
--- /dev/null
+++ b/h5p-server/materia-h5p.Dockerfile
@@ -0,0 +1,37 @@
+# This dockerfile is meant to be run by ../docker/docker-compose.yml file,
+# which spins up the entire Materia stack
+
+# To run the h5p-server alone, use ./Dockerfile
+
+FROM node:12
+
+# Create app directory
+WORKDIR /usr/src/app
+
+# Install app dependencies
+# A wildcard is used to ensure both package.json AND package-lock.json are copied
+# where available (npm@5+)
+COPY ./h5p-server/package*.json ./
+
+# Copy Materia's env file(s) to the current working directory
+COPY .env* ./
+
+RUN yarn install
+
+# Bundle app source
+COPY ./h5p-server .
+
+# for local development, don't copy existing h5p info into
+# fresh docker container
+# RUN rm -r h5p/core h5p/editor h5p/libraries h5p/temporary-storage
+# RUN mkdir h5p/core h5p/editor h5p/libraries h5p/temporary-storage
+
+RUN ./setup.sh
+
+EXPOSE 3000
+
+# set in docker-compose
+ARG ENVIRONMENT
+ENV ENVIRONMENT $ENVIRONMENT
+
+CMD yarn start:${ENVIRONMENT}
\ No newline at end of file
diff --git a/h5p-server/materia-h5p.Dockerfile.dockerignore b/h5p-server/materia-h5p.Dockerfile.dockerignore
new file mode 100644
index 000000000..5171c5408
--- /dev/null
+++ b/h5p-server/materia-h5p.Dockerfile.dockerignore
@@ -0,0 +1,2 @@
+node_modules
+npm-debug.log
\ No newline at end of file
diff --git a/h5p-server/package.json b/h5p-server/package.json
new file mode 100644
index 000000000..988b7c6c9
--- /dev/null
+++ b/h5p-server/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "materia-h5p-server",
+ "license": "GPL-3.0-or-later",
+ "main": "express.js",
+ "scripts": {
+ "start": "node -r esm express.js",
+ "start:dev": "ENVIRONMENT=dev node -r esm express.js",
+ "start:mwdk": "ENVIRONMENT=mwdk node -r esm express.js",
+ "start:prod": "NODE_ENV=production ENVIRONMENT=prod node -r esm express.js"
+ },
+ "dependencies": {
+ "@lumieducation/h5p-express": "^9.0.3",
+ "@lumieducation/h5p-server": "^9.0.3",
+ "body-parser": "^1.19.0",
+ "cors": "^2.8.5",
+ "dotenv": "^10.0.0",
+ "esm": "^3.2.25",
+ "express": "^4.17.1",
+ "express-fileupload": "^1.1.7-alpha.4",
+ "fs-extra": "^10.0.0",
+ "got": "^11.3.0",
+ "morgan": "^1.10.0",
+ "node-cron": "^3.0.0",
+ "request": "^2.88.2",
+ "request-promise": "^4.2.6"
+ }
+}
diff --git a/h5p-server/renderers/admin.js b/h5p-server/renderers/admin.js
new file mode 100644
index 000000000..caf888e65
--- /dev/null
+++ b/h5p-server/renderers/admin.js
@@ -0,0 +1,133 @@
+import { H5PEditor } from "@lumieducation/h5p-server";
+
+export function adminRenderer(model) {
+ function rendererInit() {
+ var ns = H5PEditor;
+
+ (function($) {
+ H5PEditor.init = function() {
+ H5PEditor.$ = H5P.jQuery;
+ H5PEditor.basePath = H5PIntegration.editor.libraryUrl;
+ H5PEditor.fileIcon = H5PIntegration.editor.fileIcon;
+ H5PEditor.ajaxPath = H5PIntegration.editor.ajaxPath;
+ H5PEditor.filesPath = H5PIntegration.editor.filesPath;
+ H5PEditor.apiVersion = H5PIntegration.editor.apiVersion;
+ H5PEditor.contentLanguage = H5PIntegration.editor.language;
+
+ // Semantics describing what copyright information can be stored for media.
+ H5PEditor.copyrightSemantics = H5PIntegration.editor.copyrightSemantics;
+ H5PEditor.metadataSemantics = H5PIntegration.editor.metadataSemantics;
+
+ // Required styles and scripts for the editor
+ H5PEditor.assets = H5PIntegration.editor.assets;
+
+ // Required for assets
+ H5PEditor.baseUrl = "";
+
+ var $editorElement = $(".h5p-editor");
+ var $type = $('input[name="action"]');
+ var $upload = $(".h5p-upload");
+ var $create = $(".h5p-create").hide();
+ var $editor = $(".h5p-editor");
+ var $library = $('input[name="library"]');
+ var $params = $('input[name="parameters"]');
+ var library = $library.val();
+
+ var $goBackElement = $(".go-back-warning").hide();
+ $("#go-back").click(function(event) {
+ $create.html('');
+
+ H5PEditor.init();
+ $goBackElement.hide();
+ $create.show();
+ });
+
+ var h5peditor = new ns.Editor(undefined, undefined, $editorElement[0]);
+ $create.show();
+
+ H5P.externalDispatcher.on("editorloaded", function(event) {
+ $create.hide();
+ $goBackElement.show();
+ });
+ };
+
+ H5PEditor.getAjaxUrl = function(action, parameters) {
+ var url = H5PIntegration.editor.ajaxPath + action;
+
+ if (parameters !== undefined) {
+ for (var property in parameters) {
+ if (parameters.hasOwnProperty(property)) {
+ url += "&" + property + "=" + parameters[property];
+ }
+ }
+ }
+
+ url += window.location.search.replace(/\\?/g, "&");
+ return url;
+ };
+
+ $(document).ready(H5PEditor.init);
+ })(H5P.jQuery);
+ }
+
+ var initAsString = new String(rendererInit);
+
+ return `
+
+
+
+ ${model.styles
+ .map(style => ``)
+ .join("\n ")}
+ ${model.scripts
+ .map(script => ``)
+ .join("\n ")}
+
+
+
+
+
+
+
Sorry! Picking H5P Libraries won't work here. Go back.
+
+
+
+
+
+
+
+ `;
+}
diff --git a/h5p-server/renderers/editor.js b/h5p-server/renderers/editor.js
new file mode 100644
index 000000000..44809f9c3
--- /dev/null
+++ b/h5p-server/renderers/editor.js
@@ -0,0 +1,282 @@
+export function editorRenderer(model) {
+ // TODO conditionally apply when visiting an h5p library - e.g., selectedLibrary != undefined
+ // currently obscures the h5p-hub when you need to visit the picker
+ // uncomment this if you need to visit the h5p hub - at least for the moment
+ model.integration.editor.assets.css.push("/styles/creator.css");
+
+ // TODO override styles from model:
+ /*
+ styles: [
+ '/h5p/core/styles/h5p.css',
+ '/h5p/core/styles/h5p-confirmation-dialog.css',
+ '/h5p/core/styles/h5p-core-button.css',
+ '/h5p/editor/libs/darkroom.css',
+ '/h5p/editor/styles/css/h5p-hub-client.css',
+ '/h5p/editor/styles/css/fonts.css',
+ '/h5p/editor/styles/css/application.css',
+ '/h5p/editor/styles/css/libs/zebra_datepicker.min.css'
+ ],
+ */
+ // placeholder to prevent errors at runtime
+ var modelParams = "";
+ var context = "";
+
+ // this function is defined here to properly allow for syntax highlighting in editors
+ // as opposed to being entirely defined within the return string
+ // we use some seriously hackish js magic to convert the entire function to a string and insert it into the return string down below
+ function rendererInit() {
+ // this switch case looks at the URL to determine which editor to load
+ // it will have to eventually include every H5P library we want to support
+ // TODO see if there's a better way to do this
+ // TODO determine if it's possible to use the library string directly instead of a clean name, or transform it into a safe string
+ // this URL will be provided by the Materia widget
+ window.selectedLibrary = undefined;
+
+ const pattern = /^\/(new|edit){1}\/([A-Za-z0-9\-]+)$/;
+ let editTypeMatch = window.location.pathname.match(pattern)[1]; // check if url is "new" or "edit"
+ let libraryMatch = window.location.pathname.match(pattern)[2]; // grab library title that follows
+
+ if (libraryMatch) {
+ switch (libraryMatch) {
+ case "h5p-multichoice":
+ window.selectedLibrary = "H5P.MultiChoice 1.14";
+ break;
+ case "h5p-questionset":
+ window.selectedLibrary = "H5P.QuestionSet 1.17";
+ break;
+ case "h5p-advancedblanks":
+ window.selectedLibrary = "H5P.Blanks 1.12";
+ break;
+ case "h5p-markthewords":
+ window.selectedLibrary = "H5P.MarkTheWords 1.9";
+ break;
+ case "h5p-dragtext":
+ window.selectedLibrary = "H5P.DragText 1.8";
+ break;
+ case "h5p-interactivevideo":
+ window.selectedLibrary = "H5P.InteractiveVideo 1.22";
+ break;
+ default:
+ window.selectedLibrary = undefined;
+ }
+ }
+
+ let materiaPath = "";
+ switch (context) {
+ case "prod":
+ case "dev":
+ materiaPath = materiaUrl;
+ break;
+ case "mwdk":
+ default:
+ materiaPath = "http://localhost:8118"; // this is the default mwdk URL
+ break;
+ }
+
+ var ns = H5PEditor;
+
+ (function($) {
+ H5PEditor.init = function() {
+ H5PEditor.$ = H5P.jQuery;
+ H5PEditor.basePath = H5PIntegration.editor.libraryUrl;
+ H5PEditor.fileIcon = H5PIntegration.editor.fileIcon;
+ H5PEditor.ajaxPath = H5PIntegration.editor.ajaxPath;
+ H5PEditor.filesPath = H5PIntegration.editor.filesPath;
+ H5PEditor.apiVersion = H5PIntegration.editor.apiVersion;
+ H5PEditor.contentLanguage = H5PIntegration.editor.language;
+
+ // Semantics describing what copyright information can be stored for media.
+ H5PEditor.copyrightSemantics = H5PIntegration.editor.copyrightSemantics;
+ H5PEditor.metadataSemantics = H5PIntegration.editor.metadataSemantics;
+
+ // Required styles and scripts for the editor
+ H5PEditor.assets = H5PIntegration.editor.assets;
+
+ // Required for assets
+ H5PEditor.baseUrl = "";
+
+ if (H5PIntegration.editor.nodeVersionId !== undefined) {
+ H5PEditor.contentId = H5PIntegration.editor.nodeVersionId;
+ }
+
+ var h5peditor;
+ var $type = $('input[name="action"]');
+ var $upload = $(".h5p-upload");
+ var $create = $(".h5p-create").hide();
+ var $editor = $(".h5p-editor");
+ var $library = $('input[name="library"]');
+ var $params = $('input[name="parameters"]');
+ var library = $library.val();
+
+ $upload.hide();
+
+ if (h5peditor === undefined) {
+ // contentId is present in search query (existing content)
+ if (editTypeMatch == "edit") {
+ window.parent.postMessage(
+ { message: "ready_for_qset" },
+ materiaPath
+ ); // TODO add this url to a config somewhere?
+ }
+ // no contentId passed in, initialize empty editor
+ else if (editTypeMatch == "new") {
+ h5peditor = new ns.Editor(
+ window.selectedLibrary,
+ undefined,
+ $editor[0]
+ );
+ $create.show();
+ } else {
+ console.error("H5P URL malformed!");
+ }
+ } else {
+ $create.show();
+ }
+
+ // this is for uploading H5P content - which we won't support?
+ // if ($type.filter(':checked').val() === 'upload') {
+ // $type.change();
+ // } else {
+ // $type
+ // .filter('input[value="create"]')
+ // .attr('checked', true)
+ // .change();
+ // }
+
+ // Adds listener to talk to the widget frame above us
+ window.addEventListener("message", receiveMessage, false);
+
+ // postMessage handler for talking to Materia
+ function receiveMessage(event) {
+ switch (event.data.message) {
+ // widget wants to save, send it the params
+ case "widget_save":
+ handlePublish();
+ break;
+ // widget has sent params to initialize existing content
+ case "params_send":
+ h5peditor = new ns.Editor(
+ window.selectedLibrary,
+ JSON.stringify(event.data.params),
+ $editor[0]
+ );
+ $create.show();
+ break;
+ default:
+ return false;
+ }
+
+ return event.preventDefault();
+ }
+
+ function handlePublish() {
+ var params = h5peditor.getParams();
+
+ if (params.params !== undefined) {
+ // Validate mandatory main title. Prevent submitting if that's not set.
+ // Deliberately doing it after getParams(), so that any other validation
+ // problems are also revealed
+
+ if (!h5peditor.isMainTitleSet()) {
+ console.warn("Main title must be set in order to publish!");
+ // TODO send postMessage to alert Materia that main title is not set
+ // This is in-line with what other widgets do
+ return false;
+ }
+
+ // Set main library
+ $library.val(h5peditor.getLibrary());
+
+ // Set params
+ $params.val(JSON.stringify(params));
+
+ window.parent.postMessage(
+ {
+ message: "save",
+ library: h5peditor.getLibrary(),
+ params
+ },
+ materiaPath
+ );
+
+ return event.preventDefault();
+ // TODO - Calculate & set max score
+ // $maxscore.val(h5peditor.getMaxScore(params.params));
+ }
+ }
+
+ // Title label
+ var $title = $("#h5p-content-form #title");
+ var $label = $title.prev();
+ $title
+ .focus(function() {
+ $label.addClass("screen-reader-text");
+ })
+ .blur(function() {
+ if ($title.val() === "") {
+ $label.removeClass("screen-reader-text");
+ }
+ })
+ .focus();
+
+ // Delete confirm
+ $(".submitdelete").click(function() {
+ return confirm(H5PIntegration.editor.deleteMessage);
+ });
+ };
+
+ H5PEditor.getAjaxUrl = function(action, parameters) {
+ var url = H5PIntegration.editor.ajaxPath + action;
+
+ if (parameters !== undefined) {
+ for (var property in parameters) {
+ if (parameters.hasOwnProperty(property)) {
+ url += "&" + property + "=" + parameters[property];
+ }
+ }
+ }
+
+ // url += window.location.search.replace(/\\?/g, '&');
+ return url;
+ };
+
+ $(document).ready(H5PEditor.init);
+ })(H5P.jQuery);
+ }
+
+ // converts the entire function into a string - allows us to inject it as part of the returned page
+ // instead of writing js within the returned string directly
+ var initAsString = new String(rendererInit);
+
+ // TODO redo all this with something cleaner
+ return `
+
+
+
+
+ ${model.styles
+ .map(style => ``)
+ .join("\n ")}
+ ${model.scripts
+ .map(script => ``)
+ .join("\n ")}
+
+
+
+
+
+
+
+ `;
+}
diff --git a/h5p-server/renderers/player.js b/h5p-server/renderers/player.js
new file mode 100644
index 000000000..806d846eb
--- /dev/null
+++ b/h5p-server/renderers/player.js
@@ -0,0 +1,70 @@
+export function playerRenderer(model) {
+ var context = "";
+
+ function playerInit(contentId = "") {
+ H5P.preventInit = true;
+
+ if (H5PIntegration) {
+ let materiaPath = "";
+ switch (context) {
+ case "prod":
+ case "dev":
+ materiaPath = materiaUrl;
+ break;
+ case "mwdk":
+ default:
+ materiaPath = "http://localhost:8118"; // this is the default mwdk url
+ break;
+ }
+
+ // perform a postMessage to the widget to ask for the qset
+ window.parent.postMessage({ message: "ready_for_qset" }, materiaPath);
+
+ // Adds listener to talk to the widget frame above us
+ window.addEventListener("message", receiveMessage, false);
+
+ // postMessage handler for talking to Materia
+ function receiveMessage(event) {
+ let params = event.data.params.params;
+ H5PIntegration.contents[
+ `cid-${contentId}`
+ ].jsonContent = JSON.stringify(params);
+
+ // this actually inits the player when we're ready
+ H5P.init(document.getElementById("h5p-player"));
+ return event.preventDefault();
+ }
+
+ H5P.externalDispatcher.on("xAPI", function(event) {
+ window.parent.postMessage(event.data.statement, materiaPath);
+ });
+ }
+ }
+
+ var initAsString = new String(playerInit);
+
+ return `
+
+
+
+ ${model.scripts
+ .map(script => ``)
+ .join("\n ")}
+ ${model.styles
+ .map(style => ``)
+ .join("\n ")}
+
+
+