Skip to content

Commit

Permalink
feat(h5p-server): added content user state (closes #1014) (#1886)
Browse files Browse the repository at this point in the history
* test(jest): run jest tests in watch mode via test:watch

* chore(scripts): add start:rest script

* feat(contentUserData): add parameters to contentUserDataUrl config

* feat(contentUserData): read saveFreq from config

* feat(contentUserData): add the contentUserData into the H5PIntegration

* test(contentUserDataGET): add example for GET issue

Lumieducation/H5P-Nodejs-library#1014 (comment)

* test(contentUserData): revert example

* chore(scripts): add h5p-server build script

* test(launch): add DEBUG to vscode launch

* refactor(script): rename start:rest to start:rest:server

Lumieducation/H5P-Nodejs-library#1886 (comment)

* refactor(saveFreq): rename saveFreq to contentUserStateSaveInterval

Lumieducation/H5P-Nodejs-library#1886 (comment)

* feat(contentUserData): delete contentUserData when content is deleted

* test(contentUserData): use mock for H5PPlayer.render test

* refactor(h5p-examples): add mock implementation to pass build

* feat(contentUserData): build contentUserDataIntegration in Manager

* feat(contentUserData): update interfaces

* test(contentUserDataManager): add tests

* refactor(h5p-examples): remove test/example conentUserDataStorage

* refactor(saveContentUserData): add invalidate and preload parameters

* feat(h5p-express): add ContentUserDataController

* fix(contentManager): delete contentUserDataStorage after content

Lumieducation/H5P-Nodejs-library#1886 (comment)

* refactor(ContentUserDataManager): remove unnecessary code

* refactor(ContentUserDataManager): use boolean instead of number

Lumieducation/H5P-Nodejs-library#1886 (comment)

* refactor(ContentManager): remove unnecessary code

* refactor(ContentUserDataManager): use boolean instead of number

Lumieducation/H5P-Nodejs-library#1886 (comment)

* fix(ContentUserDataManager): sanitize userState before saving

* feat(h5pexpress): add ContentUserDataRouter

* refactor(h5p-server): add deleteAllContentUserDataforContentId method

* refactor(h5p-examples): add reference implementation to h5p-example

* docs(ContentUserDataStorage): add contentUserDataStorage docs

* feat(contentUserData): add setFinished

* fix(ContentUserDataManager): throw H5pError instead of regular error

* fix(contentUserStateSaveInterval): change from seconds to milliseconds

Lumieducation/H5P-Nodejs-library#1886 (comment)

* refactor(code): remove redundant return statements

* refactor(code): reorder import statements

* refactor(log): change to debug from info

* refactor(code): remove typos, reorder imports, remove redundant code

* refactor(h5p-examples): don't use singleton

* fix(H5PPlayer): use contentUserStateSaveInterval in milliseconds

* refactor(code): fix typos

* fix(contentUserDataManager): saveContentUserData: check arguments

* fix(h5p-examples): correct json number declaration (#1991)

* feat(listContentUserDataByUserId): add listContentUserDataByUserId

* style(jsdocs): add divergence

* refactor: formatting and minor issues

* refactor: fixed imports

* test: corrected test

* refactor: decreased content user state save interval

* test: fix test

* fix(contentUserDataManager): remove userData when invalidate is true

Lumieducation/H5P-Nodejs-library#1886 (comment)

* fix(contentUserData): generate integration only when preload is true

Lumieducation/H5P-Nodejs-library#1886 (comment)

* feat(FileContentUserDataStorage): add FileContentUserDataStorage

* feat(contentUserData): include contentUserData in rest-example

* feat(contentUserData): include in rest example

* test(contentUserDataRouter): fix test

* chore(contentUserDataStorage): add json to gitignore

* build(h5p-shared-state-server): build h5p-shared-state-server on install

* fix(contentUserData): delete invalid contentUserData if content changes

Lumieducation/H5P-Nodejs-library#1886 (comment)

* refactor(contentUserData): rename saveContentUserData

saveContentUserData-method is renamed to createOrUpdateContentUserData

* feat(mongos3): add MongoContentUserDataStorage

* refactor: made namings more consistent

* refactor: minor cleanup

* fix: examples work

* test: use snapshot instead of inline HTML

* feat: corrected and extended MongoContentUserDataStorage

* fix: corrected FileContentUserDataStorage signature

* test: added more tests

* test: more tests

* feat: delete finished data when content is deleted

* feat: reimplemented FileContentUserDataStorage to work with directories

* test: added tests for FileContentUserDataStorage

* test: renamed generalized test file

* fix: missing user data doesn't return 404

* fix: corrected CSFR token generation + own route for setFinished

* fix: protected FileContentUserDataStorage against attacks

* test: corrected tests

* fix: corrected config to load falsy settings

* fix: improved filename validation

* feat: routes are closed when feature disabled in config

* feat: added CSRF tokens to rest example & fixed bugs

* fix: fileContentUserDataStorage saves more than one entry

* test: corrected test

* feat: improved  CSRF

* refactor: correct shared state example

* refactor: cleanup

* docs: added docs

Co-authored-by: Oliver Tacke <[email protected]>
Co-authored-by: Sebastian Rettig <[email protected]>
  • Loading branch information
3 people authored Jun 10, 2022
1 parent ca13bb8 commit f6545e6
Show file tree
Hide file tree
Showing 21 changed files with 2,659 additions and 649 deletions.
35 changes: 31 additions & 4 deletions src/ContentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ContentParameters,
IContentMetadata,
IContentStorage,
IContentUserDataStorage,
IUser,
Permission
} from './types';
Expand All @@ -21,10 +22,13 @@ const log = new Logger('ContentManager');
export default class ContentManager {
/**
* @param contentStorage The storage object
* @param contentUserDataStorage The contentUserDataStorage to delete contentUserData for content when it is deleted
*/
constructor(public contentStorage: IContentStorage) {
constructor(
public contentStorage: IContentStorage,
public contentUserDataStorage?: IContentUserDataStorage
) {
log.info('initialize');
this.contentStorage = contentStorage;
}

/**
Expand Down Expand Up @@ -90,15 +94,38 @@ export default class ContentManager {
}

/**
* Deletes a piece of content and all files dependent on it.
* Deletes a piece of content, the corresponding contentUserData and all files dependent on it.
* @param contentId the piece of content to delete
* @param user the user who wants to delete it
*/
public async deleteContent(
contentId: ContentId,
user: IUser
): Promise<void> {
return this.contentStorage.deleteContent(contentId, user);
await this.contentStorage.deleteContent(contentId, user);

if (this.contentUserDataStorage) {
try {
await this.contentUserDataStorage.deleteAllContentUserDataByContentId(
contentId
);
} catch (error) {
log.error(
`Could not delete content user data with contentId ${contentId}`
);
log.error(error);
}
try {
await this.contentUserDataStorage.deleteFinishedDataByContentId(
contentId
);
} catch (error) {
log.error(
`Could not finished data with contentId ${contentId}`
);
log.error(error);
}
}
}

/**
Expand Down
227 changes: 227 additions & 0 deletions src/ContentUserDataManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import {
ContentId,
ISerializedContentUserData,
IUser,
IContentUserDataStorage,
IContentUserData
} from './types';
import Logger from './helpers/Logger';

const log = new Logger('ContentUserDataManager');

/**
* The ContentUserDataManager takes care of saving user data and states. It only
* contains storage-agnostic functionality and depends on a
* ContentUserDataStorage object to do the actual persistence.
*/
export default class ContentUserDataManager {
/**
* @param contentUserDataStorage The storage object
*/
constructor(private contentUserDataStorage: IContentUserDataStorage) {
log.info('initialize');
}

/**
* Deletes a contentUserData object for given contentId and userId. Throws
* errors if something goes wrong.
* @param user the user for which the contentUserData object should be
* deleted
*/
public async deleteAllContentUserDataByUser(user: IUser): Promise<void> {
if (this.contentUserDataStorage) {
log.debug(`deleting contentUserData for userId ${user.id}`);
return this.contentUserDataStorage.deleteAllContentUserDataByUser(
user
);
}
}

public async deleteInvalidatedContentUserDataByContentId(
contentId: ContentId
): Promise<void> {
if (this.contentUserDataStorage && contentId) {
log.debug(
`deleting invalidated contentUserData for contentId ${contentId}`
);
return this.contentUserDataStorage.deleteInvalidatedContentUserData(
contentId
);
}
}

public async deleteAllContentUserDataByContentId(
contentId: ContentId
): Promise<void> {
if (this.contentUserDataStorage) {
log.debug(
`deleting all content user data for contentId ${contentId}`
);
return this.contentUserDataStorage.deleteAllContentUserDataByContentId(
contentId
);
}
}

/**
* Loads the contentUserData for given contentId, dataType and subContentId
* @param contentId The id of the content to load user data from
* @param dataType Used by the h5p.js client
* @param subContentId The id provided by the h5p.js client call
* @param user The user who is accessing the h5p
* @returns the saved state as string or undefined when not found
*/
public async getContentUserData(
contentId: ContentId,
dataType: string,
subContentId: string,
user: IUser
): Promise<IContentUserData> {
if (!this.contentUserDataStorage) {
return undefined;
}

log.debug(
`loading contentUserData for user with id ${user.id}, contentId ${contentId}, subContentId ${subContentId}, dataType ${dataType}`
);

return this.contentUserDataStorage.getContentUserData(
contentId,
dataType,
subContentId,
user
);
}

/**
* Loads the content user data for given contentId and user. The returned data
* is an array of IContentUserData where the position in the array
* corresponds with the subContentId or undefined if there is no
* content user data.
*
* @param contentId The id of the content to load user data from
* @param user The user who is accessing the h5p
* @returns an array of IContentUserData or undefined if no content user data
* is found.
*/
public async generateContentUserDataIntegration(
contentId: ContentId,
user: IUser
): Promise<ISerializedContentUserData[]> {
log.debug(
`generating contentUserDataIntegration for user with id ${user.id} and contentId ${contentId}`
);

if (!this.contentUserDataStorage) {
return undefined;
}

let states =
await this.contentUserDataStorage.getContentUserDataByContentIdAndUser(
contentId,
user
);

if (!states) {
return undefined;
}

states = states.filter((s) => s.preload === true);

const sortedStates = states.sort(
(a, b) => Number(a.subContentId) - Number(b.subContentId)
);

const mappedStates = sortedStates
// filter removes states where preload is set to false
.filter((state) => state.preload)
// maps the state to an object where the key is the dataType and the userState is the value
.map((state) => ({
[state.dataType]: state.userState
}));

return mappedStates;
}

/**
* Saves data when a user completes content.
* @param contentId The content id to delete.
* @param score the score the user reached as an integer
* @param maxScore the maximum score of the content
* @param openedTimestamp the time the user opened the content as UNIX time
* @param finishedTimestamp the time the user finished the content as UNIX
* time
* @param completionTime the time the user needed to complete the content
* (as integer)
* @param user The user who triggers this method via /setFinished
*/
public async setFinished(
contentId: ContentId,
score: number,
maxScore: number,
openedTimestamp: number,
finishedTimestamp: number,
completionTime: number,
user: IUser
): Promise<void> {
log.debug(
`saving finished data for ${user.id} and contentId ${contentId}`
);

if (!this.contentUserDataStorage) {
return undefined;
}

await this.contentUserDataStorage.createOrUpdateFinishedData({
contentId,
score,
maxScore,
openedTimestamp,
finishedTimestamp,
completionTime,
userId: user.id
});
}

/**
* Saves the contentUserData for given contentId, dataType and subContentId
* @param contentId The id of the content to load user data from
* @param dataType Used by the h5p.js client
* @param subContentId The id provided by the h5p.js client call
* @param userState The userState as string
* @param user The user who owns this object
* @returns the saved state as string
*/
public async createOrUpdateContentUserData(
contentId: ContentId,
dataType: string,
subContentId: string,
userState: string,
invalidate: boolean,
preload: boolean,
user: IUser
): Promise<void> {
log.debug(
`saving contentUserData for user with id ${user.id} and contentId ${contentId}`
);

if (typeof invalidate !== 'boolean' || typeof preload !== 'boolean') {
log.error(`invalid arguments passed for contentId ${contentId}`);
throw new Error(
"createOrUpdateContentUserData received invalid arguments: invalidate or preload weren't boolean"
);
}

if (this.contentUserDataStorage) {
return this.contentUserDataStorage.createOrUpdateContentUserData({
contentId,
dataType,
subContentId,
userState,
invalidate,
preload,
userId: user.id
});
}
}
}
28 changes: 24 additions & 4 deletions src/H5PEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import defaultRenderer from './renderers/default';
import supportedLanguageList from '../assets/editorLanguages.json';
import variantEquivalents from '../assets/variantEquivalents.json';

import ContentUserDataManager from './ContentUserDataManager';

import ContentManager from './ContentManager';
import { ContentMetadata } from './ContentMetadata';
import ContentStorer from './ContentStorer';
Expand All @@ -37,6 +39,7 @@ import {
IAssets,
IContentMetadata,
IContentStorage,
IContentUserDataStorage,
IEditorModel,
IH5PConfig,
IH5PEditorOptions,
Expand Down Expand Up @@ -98,7 +101,8 @@ export default class H5PEditor {
'copyright-semantics': defaultCopyrightSemanticsLanguageFile
}).t,
private urlGenerator: IUrlGenerator = new UrlGenerator(config),
private options?: IH5PEditorOptions
private options?: IH5PEditorOptions,
public contentUserDataStorage?: IContentUserDataStorage
) {
log.info('initialize');

Expand All @@ -122,7 +126,10 @@ export default class H5PEditor {
this.options?.lockProvider,
this.config
);
this.contentManager = new ContentManager(contentStorage);
this.contentManager = new ContentManager(
contentStorage,
contentUserDataStorage
);
this.contentTypeRepository = new ContentTypeInformationRepository(
this.contentTypeCache,
this.libraryManager,
Expand All @@ -133,6 +140,9 @@ export default class H5PEditor {
temporaryStorage,
this.config
);
this.contentUserDataManager = new ContentUserDataManager(
contentUserDataStorage
);
this.contentStorer = new ContentStorer(
this.contentManager,
this.libraryManager,
Expand Down Expand Up @@ -185,6 +195,7 @@ export default class H5PEditor {
public contentManager: ContentManager;
public contentTypeCache: ContentTypeCache;
public contentTypeRepository: ContentTypeInformationRepository;
public contentUserDataManager: ContentUserDataManager;
public libraryManager: LibraryManager;
public packageImporter: PackageImporter;
public temporaryFileManager: TemporaryFileManager;
Expand Down Expand Up @@ -716,6 +727,9 @@ export default class H5PEditor {
mainLibraryUbername: string,
user: IUser
): Promise<ContentId> {
await this.contentUserDataManager.deleteInvalidatedContentUserDataByContentId(
contentId
);
return (
await this.saveOrUpdateContentReturnMetaData(
contentId,
Expand Down Expand Up @@ -1139,8 +1153,14 @@ export default class H5PEditor {
)
},
libraryConfig: this.config.libraryConfig,
postUserStatistics: false,
saveFreq: false,
postUserStatistics: this.config.setFinishedEnabled,
saveFreq:
this.config.contentUserStateSaveInterval !== false
? Math.round(
Number(this.config.contentUserStateSaveInterval) /
1000
) || 1
: false,
libraryUrl: this.urlGenerator.coreFiles(),
pluginCacheBuster: this.cacheBusterGenerator(),
url: this.urlGenerator.baseUrl(),
Expand Down
Loading

0 comments on commit f6545e6

Please sign in to comment.