This document describes an extension for H5P that adds multi-user capabilities to the H5P core and can be used in conjunction with existing open-source H5P implementations.
PHP, which is used by the H5P core and by all common integrations (WordPress, Moodle, Drupal), isn't really suitable for live interaction applications: Most of them keep connection alive only on a per-request basis and these systems aren't made for Websocket usage. In contrast, NodeJS has excellent Websocket support and a huge library ecosystem, which greatly speeds up development. That's why a NodeJS stack was chosen for the implementation of the multi-user state system.
The system leverages several existing technologies:
In essence, the shared-state server is a ShareDB server that keeps a central "document" (in ShareDB terms) in memory and in a database that can be modified by clients through operations ("ops"). ShareDB keeps an in-memory "snapshot" of the current state on the server and makes sure that the clients have the same snapshot (by transmitting the ops through Websockets to all clients). Conflicts in ops (e.g. users have changed the same properties) are either automatically resolved or reverted. The H5P multi-user state system puts some essential functionality on top:
This is necessary as the client cannot be trusted - it could be a malicious client manipulating the state to take over other user accounts (XSS), make it invalid (breaks user experience) or cheat.
As the information needed to make many of these decisions (e.g. "Is User X allowed to do operation Y on content Z?") must be retrieved from the host system, which manages users and content objects (e.g. WordPress or Moodle), the multi-user state system can access this information from the host system. It also has to share authentication with the host system.
There are several cases in which the multi-user state system needs to retrieve data from the H5P host system (e.g. WordPress):
The H5P host system has to notify the multi-user state system of certain events:
There are several ways how this can or could be achieved:
Content type authors must specify validators to prevent abuse from malicious clients. There are two types of validators and each type is applied to ops and snapshots (in this order):
The system validates received ops and the resulting snapshot against JSON schema validators (JSON Schema draft 2020-12). It passes in the batch op list (a client can submit multiple ops at the same time). If the validation fails, the ops are rejected. It is important that content type authors are as strict as possible in the JSON schemas, so that they make it impossible for undesired operations to pass through.
Must be put into opSchema.json
in the base directory of the content type. The validator checks the op batches it receives from the client. Example:
[{
"p": ["a", "path", "in", "the", "state"],
"oi": "insert this string into the object referenced by the path"
}]
Possible ops can be found in the JSON0 documentation. The op schema must be written in a way that all possible op batches supported by the content types pass.
Must be put into snapshotSchema.json
in the base directory of the content type. The validator checks the snapshot that is the result of the applied ops. The state is fully custom and defined by the content type author.
After the schema validation was successful, the system performs "logic checks" against the ops and snapshot. The logic checks for ops must be in opLogicCheck.json
and the ones for snapshots in snapshotLogicCheck.json
in the base directory of the content type. Logic checks can be used to check logical connections between data. Examples:
$.context.permission
and other operation data)Logic checks are custom declarative operations that are inspired by the MongoDB query language (but not exactly the same). They combine Mongo-style queries with JSON Path queries. Every logic check consists of a object with either an operator as a property or a JSON Path.
Check if the result of $.context.permission
(= JSON Path expression) equals privileged
(string literal):
{ "$.context.permission": "privileged" }
Check if the result of the JSON Path expression $.create
is defined (= the object exists):
{
"$defined": {
"$query": "$.create"
}
}
Check if one of several checks are successful (logical or):
{
"$or": [
{ "$.op[0].p": ["votesUp", 0] },
{ "$.op[0].p": ["votesDown", 0] }
]
}
Check if the result of the JSON Path evaluation of $.snapshot.answers.length
is less than or equal the result of the JSON Path evaluation of $.params.questions.params.choices.length
:
{
"$.snapshot.answers.length": {
"$lte": {
"$query": "$.params.questions.params.choices.length"
}
}
}
A logic check file contains an array of logic checks. All logic checks must apply for the validation to be successful. Example:
[
{
"$or": [
{
"$defined": {
"$query": "$.create"
}
},
{
"$and": [
{
"$or": [
{ "$.op[0].p": ["votesUp", 0] },
{ "$.op[0].p": ["votesDown", 0] }
]
},
{
"$.op[0].li": {
"$query": "$.context.user.id"
}
}
]
},
{
"$and": [
{ "$.op[0].p": ["votesDown"] },
{ "$.op[1].p": ["votesUp"] },
{ "$.op[0].oi": [] },
{ "$.op[1].oi": [] },
{ "$.context.permission": "privileged" }
]
}
]
}
]
The op logic check receives an object of the following structure:
{
op: [
{
"p": ["path", "..."],
"operation": "value"
},
// more ops ...
],
create: any, // the create object (see ShareDB docs)
params: any, // the parameters of the h5p content object
context: {
user: {
email: string; // the user's email address (if available)
id: string; // user id (can be anything)
name: string; // display name
},
permission: "privileged" | "user",
},
snapshot: any // the resulting snapshot of the op
}
The snapshot logic check receives an object of the following structure:
{
snapshot: any, // the new snapshot
params: any, // the parameters of the h5p content object
context: {
user: {
email: string; // the user's email address (if available)
id: string; // user id (can be anything)
name: string; // display name
},
permission: "privileged" | "user",
}
}
Currently the keywords below are supported. You can use any of them in the top-level list of a logic check file.
Syntax:
{
"$keyword": "literal"
}
// or
{
"left": {
"$query": "$.json.path"
}
}
Available keywords:
Comparison operators can take either literals as their right expression or a query, which is executed before the comparison is performed. Valid literals are: number
, string
, boolean
and arrays
of these literals.
Syntax:
{
"$operator": [
// ... comparisons, queries or logical operators
]
}
// or
{
"$operator": {
// comparison, query or logical operator
}
}
Available keywords:
JSON Path expressions can either be put into the left side of a comparison operator:
{
"$.a.json.path[1].expression.*~": {
// comparison operator
}
}
If you need a JSON Path expression as the argument of a comparison operator or a logical operator, you have to use a query object (to keep a JSON Path expression apart from a string literal):
{
"$defined": {
"$query": "$.a.json.path[1].expression.*~"
}
}
Notes: