diff --git a/.gitignore b/.gitignore index cd5cb32211..85eef7182a 100644 --- a/.gitignore +++ b/.gitignore @@ -94,9 +94,11 @@ conversation/src/main/resources/view/ **/backend/**/resources/public/*.* # Ignore HTML files in "backend/view" folders **/backend/**/resources/view/*.html -broker-parent/broker-client +#broker-parent/broker-client .env dependency-reduced-pom.xml .flattened-pom.xml .version.properties .pnpm-store + +? \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile index 8de27bdcb5..a5b15e9f00 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -39,6 +39,13 @@ pipeline { } } } + /* + stage('Build image') { + steps { + sh 'edifice image' + } + } + */ } post { cleanup { diff --git a/admin/pom.xml b/admin/pom.xml index f15b6049fb..030c7c038d 100644 --- a/admin/pom.xml +++ b/admin/pom.xml @@ -19,5 +19,19 @@ ${revision} compile + + fr.wseduc + mod-sms-proxy + 2.0-zookeeper-SNAPSHOT + runtime + fat + + + io.vertx + mod-mongo-persistor + 4.1-zookeeper-SNAPSHOT + runtime + fat + \ No newline at end of file diff --git a/admin/src/main/java/org/entcore/admin/Admin.java b/admin/src/main/java/org/entcore/admin/Admin.java index 37574eeef7..106e7679ca 100644 --- a/admin/src/main/java/org/entcore/admin/Admin.java +++ b/admin/src/main/java/org/entcore/admin/Admin.java @@ -18,7 +18,11 @@ package org.entcore.admin; +import io.vertx.core.Future; import io.vertx.core.Promise; + +import java.util.Map; + import org.entcore.admin.controllers.AdminController; import org.entcore.admin.controllers.BlockProfileTraceController; import org.entcore.admin.controllers.PlatformInfoController; @@ -26,58 +30,67 @@ import org.entcore.admin.services.BlockProfileTraceService; import org.entcore.admin.services.impl.DefaultBlockProfileTraceService; import org.entcore.common.http.BaseServer; + +import fr.wseduc.webutils.collections.SharedDataHelper; import io.vertx.core.AsyncResult; import io.vertx.core.Handler; import io.vertx.core.eventbus.Message; import io.vertx.core.json.JsonObject; -import io.vertx.core.shareddata.LocalMap; import io.vertx.core.eventbus.DeliveryOptions; public class Admin extends BaseServer { - @Override - public void start(final Promise startPromise) throws Exception { - super.start(startPromise); - - addController(new AdminController()); + @Override + public void start(final Promise startPromise) throws Exception { + final Promise promise = Promise.promise(); + super.start(promise); + promise.future() + .compose(init -> SharedDataHelper.getInstance().getLocalMulti("server", "smsProvider", "node", "hidePersonalData")) + .compose(adminConfigMap -> initAdmin(adminConfigMap)) + .onComplete(startPromise); + } + + public Future initAdmin(final Map adminMap) { + addController(new AdminController()); - BlockProfileTraceController blockProfileTraceController = new BlockProfileTraceController("adminv2"); - BlockProfileTraceService blockProfileTraceService = new DefaultBlockProfileTraceService("adminv2"); - blockProfileTraceController.setBlockProfileTraceService(blockProfileTraceService); - addController(blockProfileTraceController); - addController(new ConfigController()); - - final PlatformInfoController platformInfoController = new PlatformInfoController(); + BlockProfileTraceController blockProfileTraceController = new BlockProfileTraceController("adminv2"); + BlockProfileTraceService blockProfileTraceService = new DefaultBlockProfileTraceService("adminv2"); + blockProfileTraceController.setBlockProfileTraceService(blockProfileTraceService); + addController(blockProfileTraceController); + addController(new ConfigController()); + + final PlatformInfoController platformInfoController = new PlatformInfoController(); + platformInfoController.setHidePersonalData((Boolean) adminMap.get("hidePersonalData")); - // check if sms module activated - String smsAddress = ""; - String smsProvider = ""; - LocalMap server = vertx.sharedData().getLocalMap("server"); - if(server != null && server.get("smsProvider") != null) { - smsProvider = (String) server.get("smsProvider"); - final String node = (String) server.get("node"); - smsAddress = (node != null ? node : "") + "entcore.sms"; - } else { - smsAddress = "entcore.sms"; - } + // check if sms module activated + String smsAddress = ""; + String smsProvider = ""; + if(adminMap.get("smsProvider") != null) { + smsProvider = (String) adminMap.get("smsProvider"); + final String node = (String) adminMap.get("node"); + smsAddress = (node != null ? node : "") + "entcore.sms"; + } else { + smsAddress = "entcore.sms"; + } - JsonObject pingAction = new JsonObject() - .put("provider", smsProvider) - .put("action", "ping"); + JsonObject pingAction = new JsonObject() + .put("provider", smsProvider) + .put("action", "ping"); - vertx.eventBus().request(smsAddress, pingAction, new DeliveryOptions().setSendTimeout(5000l), - new Handler>>() { - @Override - public void handle(AsyncResult> res) { - if (res != null && res.succeeded()) { - if ("ok".equals(res.result().body().getString("status"))) { - platformInfoController.setSmsModule(true); - } - } - addController(platformInfoController); - } - } - ); - } + vertx.eventBus().request(smsAddress, pingAction, new DeliveryOptions().setSendTimeout(5000l), + new Handler>>() { + @Override + public void handle(AsyncResult> res) { + if (res != null && res.succeeded()) { + if ("ok".equals(res.result().body().getString("status"))) { + platformInfoController.setSmsModule(true); + } + } + addController(platformInfoController); + } + } + ); + return Future.succeededFuture(); + } } diff --git a/admin/src/main/java/org/entcore/admin/controllers/BlockProfileTraceController.java b/admin/src/main/java/org/entcore/admin/controllers/BlockProfileTraceController.java index 8a7e2f82ee..f5f1955e37 100644 --- a/admin/src/main/java/org/entcore/admin/controllers/BlockProfileTraceController.java +++ b/admin/src/main/java/org/entcore/admin/controllers/BlockProfileTraceController.java @@ -1,7 +1,6 @@ package org.entcore.admin.controllers; import fr.wseduc.bus.BusAddress; -import fr.wseduc.mongodb.MongoDb; import fr.wseduc.rs.ApiDoc; import fr.wseduc.rs.Get; import fr.wseduc.security.ActionType; diff --git a/admin/src/main/java/org/entcore/admin/controllers/PlatformInfoController.java b/admin/src/main/java/org/entcore/admin/controllers/PlatformInfoController.java index f5f3696a9f..99a7c843ae 100644 --- a/admin/src/main/java/org/entcore/admin/controllers/PlatformInfoController.java +++ b/admin/src/main/java/org/entcore/admin/controllers/PlatformInfoController.java @@ -26,7 +26,6 @@ import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; -import io.vertx.core.shareddata.LocalMap; import org.entcore.common.http.filter.AdminFilter; import org.entcore.common.http.filter.ResourceFilter; @@ -40,6 +39,7 @@ public class PlatformInfoController extends BaseController { private boolean smsActivated; private static final List PROFILES = Arrays.asList("Personnel", "Teacher", "Student", "Relative", "Guest"); + private boolean hidePersonalData; @Get("api/platform/module/sms") @SecuredAction(type = ActionType.RESOURCE, value = "") @@ -62,15 +62,13 @@ public void setSmsModule(boolean smsModule) { @ResourceFilter(AdminFilter.class) @MfaProtected() public void readConfig(HttpServerRequest request) { - LocalMap serverMap = vertx.sharedData().getLocalMap("server"); - final JsonObject preDelete = config.getJsonObject("pre-delete"); final JsonObject configuration = new JsonObject() .put("delete-user-delay", config.getLong("delete-user-delay", defaultDeleteUserDelay)) .put("reset-code-delay", config.getLong("resetCodeDelay", 0L)) .put("distributions", config.getJsonArray("distributions", new JsonArray())) .put("hide-adminv1-link", config.getBoolean("hide-adminv1-link", false)) - .put("hide-personal-data", serverMap.get("hidePersonalData")) + .put("hide-personal-data", hidePersonalData) .put("mass-messaging-enabled", config.getBoolean("mass-messaging-enabled")) .put("allow-adml-structure-name-change", config.getBoolean("allow-adml-structure-name-change", true)) .put("enable-manual-group-autolink", config.getBoolean("enable-manual-group-autolink", false)) @@ -95,4 +93,9 @@ public void readConfig(HttpServerRequest request) { renderJson(request, configuration); } + + public void setHidePersonalData(boolean hidePersonalData) { + this.hidePersonalData = hidePersonalData; + } + } diff --git a/admin/src/main/java/org/entcore/admin/services/impl/DefaultBlockProfileTraceService.java b/admin/src/main/java/org/entcore/admin/services/impl/DefaultBlockProfileTraceService.java index 790ea66f2e..b24ab2c4c5 100644 --- a/admin/src/main/java/org/entcore/admin/services/impl/DefaultBlockProfileTraceService.java +++ b/admin/src/main/java/org/entcore/admin/services/impl/DefaultBlockProfileTraceService.java @@ -4,7 +4,6 @@ import fr.wseduc.mongodb.MongoDb; import fr.wseduc.mongodb.MongoQueryBuilder; import fr.wseduc.webutils.Either; -import fr.wseduc.webutils.request.filter.Filter; import io.vertx.core.Handler; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; diff --git a/admin/src/main/resources/i18n/fr.json b/admin/src/main/resources/i18n/fr.json index 4d8ec02c7d..ac3cd6651b 100644 --- a/admin/src/main/resources/i18n/fr.json +++ b/admin/src/main/resources/i18n/fr.json @@ -1296,6 +1296,7 @@ "rss-widget": "RSS", "save": "Enregistrer", "save.modifications": "Enregistrer les modifications", + "screen-time-widget": "Temps d'écran", "scholarshipHolder": "Boursier", "school-widget": "Mon réseau", "screen-time-widget": "Temps d'écran", diff --git a/admin/src/main/ts/package.json b/admin/src/main/ts/package.json index abeba1012b..c298011b7b 100644 --- a/admin/src/main/ts/package.json +++ b/admin/src/main/ts/package.json @@ -34,9 +34,9 @@ "font-awesome": "4.7.0", "jquery": "^3.4.1", "ngx-infinite-scroll": "14.0.1", - "ngx-ode-core": "dev", - "ngx-ode-sijil": "dev", - "ngx-ode-ui": "dev", + "ngx-ode-core": "zookeeper", + "ngx-ode-sijil": "zookeeper", + "ngx-ode-ui": "zookeeper", "ngx-trumbowyg": "^6.0.7", "noty": "2.4.1", "reflect-metadata": "0.1.10", diff --git a/app-registry/pom.xml b/app-registry/pom.xml index 1882978438..5b0c638395 100644 --- a/app-registry/pom.xml +++ b/app-registry/pom.xml @@ -25,5 +25,12 @@ ${revision} test + + io.vertx + mod-mongo-persistor + 4.1-zookeeper-SNAPSHOT + runtime + fat + \ No newline at end of file diff --git a/app-registry/src/main/java/org/entcore/registry/AppRegistry.java b/app-registry/src/main/java/org/entcore/registry/AppRegistry.java index 20765c5fa5..2768806011 100644 --- a/app-registry/src/main/java/org/entcore/registry/AppRegistry.java +++ b/app-registry/src/main/java/org/entcore/registry/AppRegistry.java @@ -19,6 +19,7 @@ package org.entcore.registry; +import io.vertx.core.Future; import io.vertx.core.json.JsonObject; import io.vertx.core.Promise; import org.entcore.broker.api.utils.AddressParameter; @@ -29,33 +30,53 @@ import org.entcore.registry.filters.AppRegistryFilter; import org.entcore.registry.services.impl.NopAppRegistryEventService; +import java.util.ArrayList; +import java.util.List; + public class AppRegistry extends BaseServer { @Override public void start(final Promise startPromise) throws Exception { - super.start(startPromise); - final AppRegistryController appRegistryController = new AppRegistryController(); - addController(appRegistryController); - addController(new ExternalApplicationController(config.getInteger("massAuthorizeBatchSize", 1000))); - addController(new WidgetController()); - addController(new LibraryController(vertx, config())); + final Promise promise = Promise.promise(); + super.start(promise); + promise.future().compose(init -> initAppRegistry()).onComplete(startPromise); + } + + public Future initAppRegistry() { + final List> futures = new ArrayList<>(); + final AppRegistryController appRegistryController = new AppRegistryController(); + futures.add(addController(appRegistryController)); + + futures.add(addController(new ExternalApplicationController(config.getInteger("massAuthorizeBatchSize", 1000)))); + futures.add(addController(new WidgetController())); + try { + futures.add(addController(new LibraryController(vertx, config()))); + } catch (Exception e) { + return Future.failedFuture(e); + } BrokerProxyUtils.addBrokerProxy(appRegistryController, vertx, new AddressParameter("application", "appregistry")); JsonObject eduMalinConf = config.getJsonObject("edumalin-widget-config"); if(eduMalinConf != null) - addController(new EdumalinWidgetController()); + futures.add(addController(new EdumalinWidgetController())); JsonObject webGerestEnabled = config.getJsonObject("webGerest-config"); if(webGerestEnabled != null) { - addController(new WebGerestController()); + futures.add(addController(new WebGerestController())); } JsonObject screenTimeEnabled = config.getJsonObject("screen-time-config"); if(screenTimeEnabled != null) { - addController(new ScreenTimeController()); + futures.add(addController(new ScreenTimeController())); + } + + JsonObject ptitObservatoireConf = config.getJsonObject("ptit-observatoire-widget-config"); + if (ptitObservatoireConf != null) { + addController(new PtitObservatoireController()); } setDefaultResourceFilter(new AppRegistryFilter()); new AppRegistryEventsHandler(vertx, new NopAppRegistryEventService()); vertx.eventBus().publish("app-registry.loaded", new JsonObject()); + return Future.all(futures).mapEmpty(); } } diff --git a/app-registry/src/main/java/org/entcore/registry/controllers/AppRegistryController.java b/app-registry/src/main/java/org/entcore/registry/controllers/AppRegistryController.java index 33dd66ed7c..cdc71396b2 100644 --- a/app-registry/src/main/java/org/entcore/registry/controllers/AppRegistryController.java +++ b/app-registry/src/main/java/org/entcore/registry/controllers/AppRegistryController.java @@ -30,11 +30,13 @@ import fr.wseduc.webutils.Either; import fr.wseduc.webutils.I18n; import fr.wseduc.webutils.Server; +import fr.wseduc.webutils.collections.SharedDataHelper; import fr.wseduc.webutils.http.BaseController; import fr.wseduc.webutils.http.Renders; import io.vertx.core.Future; import io.vertx.core.Promise; +import io.vertx.core.Future; import io.vertx.core.Vertx; import org.apache.commons.lang3.NotImplementedException; import org.entcore.broker.api.appregistry.AppRegistrationRequestDTO; @@ -77,10 +79,13 @@ public class AppRegistryController extends BaseController implements AppRegistry private final AppRegistryService appRegistryService = new DefaultAppRegistryService(); private JsonObject skinLevels; - public void init(Vertx vertx, JsonObject config, RouteMatcher rm, - Map securedActions) { + public Future initAsync(Vertx vertx, JsonObject config, RouteMatcher rm, + Map securedActions) { super.init(vertx, config, rm, securedActions); - this.skinLevels = new JsonObject(vertx.sharedData().getLocalMap("skin-levels")); + return SharedDataHelper.getInstance().getLocal("server", "skin-levels") + .onSuccess(skinLevels -> AppRegistryController.this.skinLevels = skinLevels) + .onFailure(ex -> log.error("Error getting skin-levels", ex)) + .mapEmpty(); } @Get("/admin-console") @@ -532,7 +537,7 @@ public void handle(JsonObject body) { @Put("/structures/:structureId/roles") @SecuredAction(value = "", type = ActionType.RESOURCE) @ResourceFilter(AdminFilter.class) - @MfaProtected() + @MfaProtected() public void authorizeProfiles(final HttpServerRequest request) { bodyToJson(request, new Handler() { @Override diff --git a/app-registry/src/main/java/org/entcore/registry/controllers/PtitObservatoireController.java b/app-registry/src/main/java/org/entcore/registry/controllers/PtitObservatoireController.java new file mode 100644 index 0000000000..6fc71e18c0 --- /dev/null +++ b/app-registry/src/main/java/org/entcore/registry/controllers/PtitObservatoireController.java @@ -0,0 +1,182 @@ +package org.entcore.registry.controllers; + +import fr.wseduc.rs.Get; +import fr.wseduc.rs.Post; +import fr.wseduc.security.ActionType; +import fr.wseduc.security.SecuredAction; +import fr.wseduc.webutils.http.BaseController; +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.*; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import org.entcore.common.user.UserUtils; +import org.vertx.java.core.http.RouteMatcher; + +import java.util.Map; + +public class PtitObservatoireController extends BaseController { + private HttpClient httpClient; + private String ptitObservatoireUrl; + private String ptitObservatoireApiKey; + + @Override + public void init(Vertx vertx, JsonObject config, RouteMatcher rm, Map securedActions) { + super.init(vertx, config, rm, securedActions); + + this.httpClient = vertx.createHttpClient(new HttpClientOptions()); + JsonObject ptitObservatoireConfiguration = config.getJsonObject("ptit-observatoire-widget-config"); + this.ptitObservatoireUrl = ptitObservatoireConfiguration.getString("url", ""); + this.ptitObservatoireApiKey = ptitObservatoireConfiguration.getString("api-key", ""); + } + + // Get the list of students related to a teacher. + @Get("/ptitObservatoire/students") + @SecuredAction(value = "", type = ActionType.AUTHENTICATED) + public void getPtitObservatoireStudents(final HttpServerRequest request) { + getIdLpo(request) + .compose(idLpo -> { + String url = ptitObservatoireUrl + "/students?filter[teacher_id]=" + idLpo; + + RequestOptions options = new RequestOptions() + .setMethod(HttpMethod.GET) + .setAbsoluteURI(url) + .addHeader("X-API-KEY", ptitObservatoireApiKey) + .addHeader("Content-Type", "application/json"); + + return httpClient.request(options) + .compose(HttpClientRequest::send) + .compose(response -> { + Promise promise = Promise.promise(); + response.bodyHandler(body -> { + if (response.statusCode() == 200) { + JsonObject json = body.toJsonObject(); + JsonArray data = json.getJsonArray("data", new JsonArray()); + promise.complete(data); + } else { + promise.fail("Error from API: " + response.statusCode()); + } + }); + return promise.future(); + }); + }) + .onSuccess(students -> renderJson(request, students)) + .onFailure(err -> renderError(request, null, 500, err.getMessage())); + } + + // Get the list of categories related to a teacher. + @Get("/ptitObservatoire/observations/categories") + @SecuredAction(value = "", type = ActionType.AUTHENTICATED) + public void getPtitObservatoireObservationsCategories(HttpServerRequest request) { + getIdLpo(request) + .compose(idLpo -> { + String url = ptitObservatoireUrl + "/observations/categories?filter[teacher_id]=" + idLpo; + + RequestOptions options = new RequestOptions() + .setMethod(HttpMethod.GET) + .setAbsoluteURI(url) + .addHeader("X-API-KEY", ptitObservatoireApiKey) + .addHeader("Content-Type", "application/json"); + + return httpClient.request(options) + .compose(HttpClientRequest::send) + .compose(response -> { + Promise promise = Promise.promise(); + response.bodyHandler(body -> { + if (response.statusCode() == 200) { + JsonObject json = body.toJsonObject(); + JsonArray data = json.getJsonArray("data", new JsonArray()); + promise.complete(data); + } else { + promise.fail("Error from API: " + response.statusCode()); + } + }); + return promise.future(); + }); + }) + .onSuccess(categories -> renderJson(request, categories)) + .onFailure(err -> renderError(request, null, 500, err.getMessage())); + } + + // Create a new observation. + @Post("/ptitObservatoire/observations") + @SecuredAction(value = "", type = ActionType.AUTHENTICATED) + public void createPtitObsevatoireObservation(HttpServerRequest request) { + Future bodyFuture = request.body(); + Future idLpoFuture = getIdLpo(request); + + // Wait for both futures to complete + Future.all(bodyFuture, idLpoFuture) + .compose(cf -> { + Buffer body = cf.resultAt(0); + String idLpo = cf.resultAt(1); + + JsonObject json = body.toJsonObject(); + + // Modify the json object to include the teacher_id in the body + JsonObject attributes = json + .getJsonObject("data", new JsonObject()) + .getJsonObject("attributes", new JsonObject()); + attributes.put("teacher_id", idLpo); + + RequestOptions options = new RequestOptions() + .setMethod(HttpMethod.POST) + .setAbsoluteURI(ptitObservatoireUrl + "/observations") + .addHeader("X-API-KEY", ptitObservatoireApiKey) + .addHeader("Content-Type", "application/json"); + + return httpClient.request(options) + .compose(req -> req.send(json.encode())); + }) + .onSuccess(response -> { + response.bodyHandler(buffer -> { + JsonObject resJson = buffer.toJsonObject(); + if (response.statusCode() == 201) { + renderJson(request, resJson); + } else { + renderError(request, resJson); + } + }); + }) + .onFailure(err -> renderError(request, null, 500, err.getMessage())); + } + + // Get the ID of a teacher in the PtitObservatoire API. + private Future getIdLpo(HttpServerRequest request) { + return UserUtils.getAuthenticatedUserInfos(eb, request) + .compose(userInfos -> { + String userId = userInfos.getUserId(); + + String url = ptitObservatoireUrl + "/users?filter[external_id]=" + userId; + + RequestOptions options = new RequestOptions() + .setMethod(HttpMethod.GET) + .setAbsoluteURI(url) + .addHeader("X-API-KEY", ptitObservatoireApiKey) + .addHeader("Content-Type", "application/json"); + + return httpClient.request(options) + .compose(HttpClientRequest::send) + .compose(response -> { + Promise promise = Promise.promise(); + response.bodyHandler(body -> { + if (response.statusCode() != 200) { + promise.fail("API error: " + response.statusCode()); + return; + } + + JsonArray data = body.toJsonObject().getJsonArray("data"); + if (data == null || data.isEmpty()) { + promise.fail("User not found"); + } else { + String idLpo = data.getJsonObject(0).getString("id"); + promise.complete(idLpo); + } + }); + return promise.future(); + }); + }); + } +} diff --git a/app-registry/src/main/java/org/entcore/registry/services/impl/DefaultLibraryService.java b/app-registry/src/main/java/org/entcore/registry/services/impl/DefaultLibraryService.java index d91fc849ac..2fccd513a2 100644 --- a/app-registry/src/main/java/org/entcore/registry/services/impl/DefaultLibraryService.java +++ b/app-registry/src/main/java/org/entcore/registry/services/impl/DefaultLibraryService.java @@ -37,7 +37,7 @@ public enum MESSAGE {API_URL_NOT_SET, DISABLED, WRONG_TOKEN, LIBRARY_KO, CONTENT private final JsonObject config; private final PdfGenerator pdfGenerator; private final EventBus eb; - private final Storage storage; + private Storage storage; public DefaultLibraryService(Vertx vertx, JsonObject config) throws Exception { this.config = config; @@ -47,7 +47,9 @@ public DefaultLibraryService(Vertx vertx, JsonObject config) throws Exception { .setConnectTimeout(config.getInteger("library-timeout", 45000))); this.pdfGenerator = new PdfFactory(vertx, config).getPdfGenerator(); this.eb = vertx.eventBus(); - this.storage = new StorageFactory(vertx, config).getStorage(); + StorageFactory.build(vertx, config) + .onSuccess(storageFactory -> this.storage = storageFactory.getStorage()) + .onFailure(ex -> log.error("Error building storage factory", ex)); } private Future getArchive(UserInfos user, String locale, String app, String resourceId){ diff --git a/archive/pom.xml b/archive/pom.xml index a33066f5d4..faa49ff141 100644 --- a/archive/pom.xml +++ b/archive/pom.xml @@ -11,6 +11,9 @@ archive + + false + @@ -31,5 +34,19 @@ ${vertxVersion} compile + + fr.wseduc + mod-sms-proxy + 2.0-zookeeper-SNAPSHOT + runtime + fat + + + io.vertx + mod-mongo-persistor + 4.1-zookeeper-SNAPSHOT + runtime + fat + \ No newline at end of file diff --git a/archive/src/main/java/org/entcore/archive/Archive.java b/archive/src/main/java/org/entcore/archive/Archive.java index ec04a7ab70..6c640e036f 100644 --- a/archive/src/main/java/org/entcore/archive/Archive.java +++ b/archive/src/main/java/org/entcore/archive/Archive.java @@ -20,11 +20,14 @@ package org.entcore.archive; import fr.wseduc.cron.CronTrigger; +import fr.wseduc.webutils.collections.SharedDataHelper; import fr.wseduc.webutils.security.RSA; +import io.vertx.core.Future; import io.vertx.core.Promise; import io.vertx.core.json.JsonObject; -import io.vertx.core.shareddata.LocalMap; +import io.vertx.core.shareddata.AsyncMap; +import org.apache.commons.lang3.tuple.Pair; import org.entcore.archive.controllers.ArchiveController; import org.entcore.archive.controllers.ImportController; import org.entcore.archive.controllers.DuplicationController; @@ -51,13 +54,19 @@ public class Archive extends BaseServer { @Override public void start(final Promise startPromise) throws Exception { - setResourceProvider(new ArchiveFilter()); - super.start(startPromise); - - Storage storage = new StorageFactory(vertx, config).getStorage(); + final Promise promise = Promise.promise(); + super.start(promise); + promise.future() + .compose(init -> StorageFactory.build(vertx, config)) + .compose(storageFactory -> SharedDataHelper.getInstance().getLocalAsyncMap("server") + .map(archiveConfigMap -> Pair.of(storageFactory, archiveConfigMap))) + .compose(configPair -> initArchives(configPair.getLeft(), configPair.getRight())) + .onComplete(startPromise); + } - final Map archiveInProgress = MapFactory.getSyncClusterMap(Archive.ARCHIVES, vertx); - final LocalMap serverMap = vertx.sharedData().getLocalMap("server"); + public Future initArchives(final StorageFactory storageFactory, final AsyncMap archivesMap){ + setDefaultResourceFilter(new ArchiveFilter()); + Storage storage = storageFactory.getStorage(); Integer storageTimeout = config.getInteger("import-storage-timeout", 600); String exportPath = config.getString("export-path", System.getProperty("java.io.tmpdir")); @@ -65,15 +74,21 @@ public void start(final Promise startPromise) throws Exception { String privateKeyPath = config.getString("archive-private-key", null); boolean forceEncryption = config.getBoolean("force-encryption", false); //TODO: Set the default to true when it is safe to do so - serverMap.put("archiveConfig", new JsonObject().put("storageTimeout", storageTimeout).encode()); + archivesMap.put("archiveConfig", new JsonObject().put("storageTimeout", storageTimeout).encode()); - PrivateKey signKey = RSA.loadPrivateKey(vertx, privateKeyPath); - PublicKey verifyKey = RSA.loadPublicKey(vertx, privateKeyPath); + PrivateKey signKey; + PublicKey verifyKey; + try { + signKey = RSA.loadPrivateKey(vertx, privateKeyPath); + verifyKey = RSA.loadPublicKey(vertx, privateKeyPath); + } catch (Exception e) { + return Future.failedFuture(e); + } ImportService importService = new DefaultImportService(vertx, config, storage, importPath, null, verifyKey, forceEncryption); - ArchiveController ac = new ArchiveController(storage, archiveInProgress, signKey, forceEncryption); - ImportController ic = new ImportController(importService, storage, archiveInProgress); + ArchiveController ac = new ArchiveController(storage, signKey, forceEncryption); + ImportController ic = new ImportController(importService, storage); DuplicationController dc = new DuplicationController(vertx, storage, importPath, signKey, verifyKey, forceEncryption); addController(ac); @@ -85,7 +100,7 @@ public void start(final Promise startPromise) throws Exception { try { new CronTrigger(vertx, purgeArchivesCron).schedule( new DeleteOldArchives(vertx, - new StorageFactory(vertx, config).getStorage(), + storageFactory.getStorage(), config.getInteger("deleteDelay", 24), exportPath, importService, @@ -127,6 +142,7 @@ public void start(final Promise startPromise) throws Exception { log.error("Invalid cron expression.", e); } } + return Future.succeededFuture(); } } diff --git a/archive/src/main/java/org/entcore/archive/Exporter.java b/archive/src/main/java/org/entcore/archive/Exporter.java index 28c09843f7..e4ff5a8c2e 100644 --- a/archive/src/main/java/org/entcore/archive/Exporter.java +++ b/archive/src/main/java/org/entcore/archive/Exporter.java @@ -30,7 +30,7 @@ public class Exporter extends BusModBase implements Handler> @Override public void start() { super.start(); - vertx.eventBus().localConsumer(config.getString("address", "entcore.exporter"), this); + vertx.eventBus().consumer(config.getString("address", "entcore.exporter"), this); } diff --git a/archive/src/main/java/org/entcore/archive/controllers/ArchiveController.java b/archive/src/main/java/org/entcore/archive/controllers/ArchiveController.java index b32491206e..b242b3704f 100644 --- a/archive/src/main/java/org/entcore/archive/controllers/ArchiveController.java +++ b/archive/src/main/java/org/entcore/archive/controllers/ArchiveController.java @@ -26,18 +26,13 @@ import fr.wseduc.security.ActionType; import fr.wseduc.security.MfaProtected; import fr.wseduc.security.SecuredAction; -import fr.wseduc.webutils.Either; import fr.wseduc.webutils.I18n; import fr.wseduc.webutils.email.EmailSender; import fr.wseduc.webutils.http.BaseController; import fr.wseduc.webutils.http.Renders; import fr.wseduc.webutils.request.RequestUtils; -import io.vertx.core.Promise; +import io.vertx.core.Future; import io.vertx.core.eventbus.MessageConsumer; -import io.vertx.core.http.HttpClientRequest; -import io.vertx.ext.web.client.HttpRequest; -import io.vertx.ext.web.client.HttpResponse; -import io.vertx.ext.web.client.WebClientOptions; import org.entcore.archive.Archive; import org.entcore.archive.services.ExportService; import org.entcore.archive.services.impl.FileSystemExportService; @@ -49,25 +44,22 @@ import org.entcore.common.http.request.JsonHttpServerRequest; import org.entcore.common.notification.TimelineHelper; import org.entcore.common.storage.Storage; -import org.entcore.common.utils.StringUtils; import org.entcore.common.user.UserUtils; import io.vertx.core.AsyncResult; import io.vertx.core.Handler; -import io.vertx.core.buffer.Buffer; import io.vertx.core.Vertx; import io.vertx.core.eventbus.Message; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; -import org.entcore.common.utils.StringUtils; import org.vertx.java.core.http.RouteMatcher; -import io.vertx.ext.web.client.WebClient; import java.security.PrivateKey; import java.util.*; import static fr.wseduc.webutils.Utils.getOrElse; +import static io.vertx.core.Future.succeededFuture; public class ArchiveController extends BaseController { @@ -76,15 +68,13 @@ public class ArchiveController extends BaseController { private ExportService exportService; private EventStore eventStore; private Storage storage; - private Map archiveInProgress; private PrivateKey signKey; private boolean forceEncryption; private enum ArchiveEvent { ACCESS } - public ArchiveController(Storage storage, Map archiveInProgress, PrivateKey signKey, boolean forceEncryption) { + public ArchiveController(Storage storage, PrivateKey signKey, boolean forceEncryption) { this.storage = storage; - this.archiveInProgress = archiveInProgress; this.signKey = signKey; this.forceEncryption = forceEncryption; } @@ -97,12 +87,12 @@ public void init(Vertx vertx, final JsonObject config, RouteMatcher rm, String exportPath = config.getString("export-path", System.getProperty("java.io.tmpdir")); - EmailFactory emailFactory = new EmailFactory(vertx, config); + EmailFactory emailFactory = EmailFactory.getInstance(); EmailSender notification = config.getBoolean("send.export.email", false) ? emailFactory.getSender() : null; exportService = new FileSystemExportService(vertx, vertx.fileSystem(), - eb, exportPath, null, notification, storage, archiveInProgress, new TimelineHelper(vertx, eb, config), + eb, exportPath, null, notification, storage, new TimelineHelper(vertx, eb, config), signKey, forceEncryption); eventStore = EventStoreFactory.getFactory().getEventStore(Archive.class.getSimpleName()); @@ -116,15 +106,17 @@ eb, exportPath, null, notification, storage, archiveInProgress, new TimelineHelp public void handle(Long event) { final long limit = System.currentTimeMillis() - config.getLong("userClearDelay", 3600000l); - Set> entries = new HashSet<>(archiveInProgress.entrySet()); - - for (Map.Entry e : entries) - { - if (e.getValue() == null || e.getValue() < limit) - { - archiveInProgress.remove(e.getKey()); - } - } + exportService.getUserExportInProgress().onFailure(th -> { + log.error("An error occurred while fetching user exports in progress", th); + }).onSuccess(entries -> { + for (Map.Entry e : entries.entrySet()) + { + if (e.getValue() == null || e.getValue() < limit) + { + exportService.removeUserExportInProgress(e.getKey()); + } + } + }); } }); } @@ -267,14 +259,20 @@ public void handle(Void event) { } private void verifyExport(final HttpServerRequest request, final String exportId) { - exportService.setDownloadInProgress(exportId); - storage.fileStats(exportId, ar -> { - if (ar.succeeded() && ar.result().getSizeInBytes() > 0 && request.response().getStatusCode() == 200) { - renderJson(request, new JsonObject().put("status", "ok")); - } else if (!request.response().ended()) { - notFound(request); - } - }); + exportService.setDownloadInProgress(exportId) + .onSuccess(e -> { + storage.fileStats(exportId, ar -> { + if (ar.succeeded() && ar.result().getSizeInBytes() > 0 && request.response().getStatusCode() == 200) { + renderJson(request, new JsonObject().put("status", "ok")); + } else if (!request.response().ended()) { + notFound(request); + } + }); + }) + .onFailure(th -> { + log.error("An error occurred while verifying download in progress", th); + renderError(request); + }); } @Get("/export/:exportId") @@ -299,6 +297,22 @@ else if (!request.response().ended()) }); } + @Get("/export/clear") + @ResourceFilter(SuperAdminFilter.class) + @SecuredAction(value = "", type = ActionType.RESOURCE) + @MfaProtected() + public void clearUserExports(final HttpServerRequest request) + { + exportService.getUserExportInProgress() + .map(Map::keySet) + .onSuccess(keys -> { + for (String key : keys) { + exportService.clearUserExport(key); + } + }); + Renders.ok(request); + } + @Delete("/export/clear/user/:userId") @ResourceFilter(SuperAdminFilter.class) @SecuredAction(value = "", type = ActionType.RESOURCE) @@ -336,57 +350,50 @@ public void export(Message message) UserUtils.getUserInfos(eb, userId, user -> { + final Future future; if(Boolean.TRUE.equals(force)){ - archiveInProgress.remove(userId); - } - exportService.export(user, locale, apps, resourcesIds, exportDocuments.booleanValue(), exportSharedResources.booleanValue(), request, - new Handler>() - { - @Override - public void handle(Either event) - { - if (event.isRight() == true) - { - String exportId = event.right().getValue(); - - if(Boolean.TRUE.equals(synchroniseReply) == false) - { - message.reply( - new JsonObject() - .put("status", "ok") - .put("exportId", exportId) - .put("exportPath", exportId + ".zip") - ); - } - else - { - final String address = exportService.getExportBusAddress(exportId); - - final MessageConsumer consumer = eb.consumer(address); - consumer.handler(new Handler>() - { - @Override - public void handle(Message event) - { - event.reply(new JsonObject().put("status", "ok").put("sendNotifications", false)); - consumer.unregister(); - - message.reply( - new JsonObject() - .put("status", "ok") - .put("exportId", exportId) - .put("exportPath", exportId + ".zip") - ); - } - }); - } - } - else - { - message.reply(new JsonObject().put("status", "error").put("message", event.left().getValue())); - } - } - }); + future = exportService.removeUserExportInProgress(userId); + } else { + future = succeededFuture(); + } + future.onFailure(th -> { + log.error("An error occurred while remove user export in progress: " + userId, th); + message.reply(new JsonObject().put("status", "error").put("message", "internal error")); + }) + .onSuccess(e -> { + exportService.export(user, locale, apps, resourcesIds, exportDocuments.booleanValue(), exportSharedResources.booleanValue(), request, + event -> { + if (event.isRight()) { + String exportId = event.right().getValue(); + + if (!Boolean.TRUE.equals(synchroniseReply)) { + message.reply( + new JsonObject() + .put("status", "ok") + .put("exportId", exportId) + .put("exportPath", exportId + ".zip") + ); + } else { + final String address = exportService.getExportBusAddress(exportId); + + final MessageConsumer consumer = eb.consumer(address); + consumer.handler(event1 -> { + event1.reply(new JsonObject().put("status", "ok").put("sendNotifications", false)); + consumer.unregister(); + + message.reply( + new JsonObject() + .put("status", "ok") + .put("exportId", exportId) + .put("exportPath", exportId + ".zip") + ); + }); + } + } else { + message.reply(new JsonObject().put("status", "error").put("message", event.left().getValue())); + } + }); + }); }); break; case "delete": @@ -401,11 +408,12 @@ public void handle(Message event) } break; case "exported" : - exportService.exported( + exportService.onExportDone( message.body().getString("exportId"), message.body().getString("status"), message.body().getString("locale", "fr"), - message.body().getString("host", config.getString("host", "")) + message.body().getString("host", config.getString("host", "")), + message.body().getString("app") ); break; default: log.error("Archive : invalid action " + action); diff --git a/archive/src/main/java/org/entcore/archive/controllers/DuplicationController.java b/archive/src/main/java/org/entcore/archive/controllers/DuplicationController.java index 353b015350..4bb722a76d 100644 --- a/archive/src/main/java/org/entcore/archive/controllers/DuplicationController.java +++ b/archive/src/main/java/org/entcore/archive/controllers/DuplicationController.java @@ -1,15 +1,14 @@ package org.entcore.archive.controllers; -import fr.wseduc.rs.Get; import fr.wseduc.rs.Post; import fr.wseduc.bus.BusAddress; import fr.wseduc.security.ActionType; import fr.wseduc.security.SecuredAction; import fr.wseduc.webutils.Either; import fr.wseduc.webutils.http.BaseController; -import fr.wseduc.webutils.http.Renders; -import fr.wseduc.webutils.request.RequestUtils; +import io.vertx.core.Future; +import io.vertx.core.Promise; import io.vertx.core.Vertx; import io.vertx.core.Handler; import io.vertx.core.eventbus.EventBus; @@ -26,7 +25,6 @@ import org.entcore.archive.services.DuplicationService; import org.entcore.archive.services.impl.DefaultDuplicationService; -import org.entcore.common.user.UserInfos; import org.entcore.common.user.UserUtils; import org.vertx.java.core.http.RouteMatcher; @@ -51,12 +49,13 @@ public DuplicationController(Vertx vertx, Storage storage, String importPath, Pr } @Override - public void init(Vertx vertx, final JsonObject config, RouteMatcher rm, - Map securedActions) + public Future initAsync(Vertx vertx, final JsonObject config, RouteMatcher rm, + Map securedActions) { super.init(vertx, config, rm, securedActions); - - this.dupService = new DefaultDuplicationService(vertx, config, storage, importPath, signKey, verifyKey, forceEncryption); + final Promise promise = Promise.promise(); + this.dupService = new DefaultDuplicationService(vertx, config, storage, importPath, signKey, verifyKey, forceEncryption, promise); + return promise.future(); } @Post("/duplicate") @@ -81,7 +80,7 @@ public void handle(Buffer buff) } catch(Exception e) { - log.error(e, e.getMessage()); + log.error(e.getMessage(), e); badRequest(request); return; } @@ -125,11 +124,12 @@ public void export(Message message) switch (action) { case "exported" : - this.dupService.exported( + this.dupService.onExportDone( message.body().getString("exportId"), message.body().getString("status"), message.body().getString("locale", "fr"), - message.body().getString("host", config.getString("host", "")) + message.body().getString("host", config.getString("host", "")), + message.body().getString("app") ); break; case "imported" : diff --git a/archive/src/main/java/org/entcore/archive/controllers/ImportController.java b/archive/src/main/java/org/entcore/archive/controllers/ImportController.java index c488485ac0..01b4436b22 100644 --- a/archive/src/main/java/org/entcore/archive/controllers/ImportController.java +++ b/archive/src/main/java/org/entcore/archive/controllers/ImportController.java @@ -7,10 +7,8 @@ import fr.wseduc.security.SecuredAction; import fr.wseduc.webutils.I18n; import fr.wseduc.webutils.http.BaseController; -import fr.wseduc.webutils.http.Renders; import fr.wseduc.webutils.request.RequestUtils; import io.vertx.core.Handler; -import io.vertx.core.Vertx; import io.vertx.core.eventbus.Message; import io.vertx.core.eventbus.MessageConsumer; import io.vertx.core.http.HttpServerRequest; @@ -19,44 +17,52 @@ import io.vertx.core.logging.LoggerFactory; import org.entcore.archive.services.ImportService; -import org.entcore.archive.services.impl.DefaultImportService; import org.entcore.common.storage.Storage; import org.entcore.common.user.UserUtils; -import org.vertx.java.core.http.RouteMatcher; - -import java.io.File; -import java.util.Map; public class ImportController extends BaseController { private static final Logger log = LoggerFactory.getLogger(ImportController.class); private ImportService importService; private Storage storage; - private Map archiveInProgress; - public ImportController(ImportService importService, Storage storage, Map archiveInProgress) { + public ImportController(ImportService importService, Storage storage) { this.importService = importService; this.storage = storage; - this.archiveInProgress = archiveInProgress; + } + + @Get("/import/clear") + public void clear(final HttpServerRequest request) { + importService.clear(); + renderJson(request, new JsonObject().put("ok", true)); } @Post("/import/upload") @SecuredAction(value = "", type = ActionType.RESOURCE) public void upload(final HttpServerRequest request) { UserUtils.getUserInfos(eb, request, user -> { - if (importService.isUserAlreadyImporting(user.getUserId())) { + request.pause(); + importService.isUserAlreadyImporting(user.getUserId()) + .onSuccess(isAlreadyImported -> { + request.resume(); + if (isAlreadyImported) { + renderError(request); + log.error("[upload] User is already importing " + user.getUsername()); + } else { + importService.uploadArchive(request, user, handler -> { + if (handler.isLeft()) { + badRequest(request, handler.left().getValue()); + log.error("[upload] User import failed " + user.getUsername() + " - " + handler.left().getValue()); + } else { + renderJson(request, new JsonObject().put("importId", handler.right().getValue())); + } + }); + } + }) + .onFailure(th -> { + log.error("An error occurred while checking if user import is already running", th); renderError(request); - log.error("[upload] User is already importing " + user.getUsername()); - } else { - importService.uploadArchive(request, user, handler -> { - if (handler.isLeft()) { - badRequest(request, handler.left().getValue()); - log.error("[upload] User import failed " + user.getUsername()+" - "+handler.left().getValue()); - } else { - renderJson(request, new JsonObject().put("importId", handler.right().getValue())); - } - }); - } + }); }); } diff --git a/archive/src/main/java/org/entcore/archive/services/DuplicationService.java b/archive/src/main/java/org/entcore/archive/services/DuplicationService.java index abb80326e7..7b74f3fd8c 100644 --- a/archive/src/main/java/org/entcore/archive/services/DuplicationService.java +++ b/archive/src/main/java/org/entcore/archive/services/DuplicationService.java @@ -14,6 +14,6 @@ public interface DuplicationService void duplicateSingleResource(final UserInfos user, final HttpServerRequest request, JsonArray apps, JsonArray resourcesIds, JsonObject config, Handler> handler); - void exported(final String exportId, String status, final String locale, final String host); + void onExportDone(final String exportId, String status, final String locale, final String host, final String app); void imported(String importId, String app, JsonObject importRapport); } diff --git a/archive/src/main/java/org/entcore/archive/services/ExportService.java b/archive/src/main/java/org/entcore/archive/services/ExportService.java index dad8199e1a..b268b6fc73 100644 --- a/archive/src/main/java/org/entcore/archive/services/ExportService.java +++ b/archive/src/main/java/org/entcore/archive/services/ExportService.java @@ -20,12 +20,14 @@ package org.entcore.archive.services; import fr.wseduc.webutils.Either; +import io.vertx.core.Future; import io.vertx.core.json.JsonArray; -import io.vertx.core.json.JsonObject; import org.entcore.common.user.UserInfos; import io.vertx.core.Handler; import io.vertx.core.http.HttpServerRequest; +import java.util.Map; + public interface ExportService { void export(UserInfos user, String locale, JsonArray apps, JsonArray resourcesIds, boolean exportDocuments, boolean exportSharedResources, @@ -35,21 +37,25 @@ void export(UserInfos user, String locale, JsonArray apps, JsonArray resourcesId void userExportId(UserInfos user, Handler handler); - boolean userExportExists(String exportId); + Future userExportExists(String exportId); void waitingExport(String exportId, Handler handler); void exportPath(String exportId, Handler> handler); - void exported(String exportId, String status, String locale, String host); + void onExportDone(String exportId, String status, String locale, String host, final String app); void deleteExport(String exportId); - void setDownloadInProgress(String exportId); + Future setDownloadInProgress(String exportId); - boolean downloadIsInProgress(String exportId); + Future downloadIsInProgress(String exportId); String getExportBusAddress(String exportId); void clearUserExport(String string); + + Future> getUserExportInProgress(); + + Future removeUserExportInProgress(final String key); } diff --git a/archive/src/main/java/org/entcore/archive/services/ImportService.java b/archive/src/main/java/org/entcore/archive/services/ImportService.java index 41b26214ad..89b68d39cb 100644 --- a/archive/src/main/java/org/entcore/archive/services/ImportService.java +++ b/archive/src/main/java/org/entcore/archive/services/ImportService.java @@ -1,10 +1,9 @@ package org.entcore.archive.services; import fr.wseduc.webutils.Either; +import io.vertx.core.Future; import io.vertx.core.Handler; -import io.vertx.core.buffer.Buffer; import io.vertx.core.http.HttpServerRequest; -import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import org.entcore.common.user.UserInfos; @@ -24,7 +23,9 @@ public interface ImportService { void imported(String importId, String app, JsonObject importRapport); - boolean isUserAlreadyImporting(String userId); + Future isUserAlreadyImporting(String userId); String getImportBusAddress(String exportId); + + void clear(); } diff --git a/archive/src/main/java/org/entcore/archive/services/impl/DefaultDuplicationService.java b/archive/src/main/java/org/entcore/archive/services/impl/DefaultDuplicationService.java index 12785f8f63..8922ad4fc5 100644 --- a/archive/src/main/java/org/entcore/archive/services/impl/DefaultDuplicationService.java +++ b/archive/src/main/java/org/entcore/archive/services/impl/DefaultDuplicationService.java @@ -3,6 +3,7 @@ import fr.wseduc.webutils.Either; import fr.wseduc.webutils.I18n; +import io.vertx.core.Promise; import io.vertx.core.Vertx; import io.vertx.core.eventbus.EventBus; import io.vertx.core.eventbus.Message; @@ -30,7 +31,6 @@ import java.security.PrivateKey; import java.security.PublicKey; import java.util.Map; -import java.util.Optional; import static io.vertx.core.json.JsonObject.mapFrom; import static java.lang.System.currentTimeMillis; @@ -44,19 +44,25 @@ public class DefaultDuplicationService implements DuplicationService private final ExportService exportService; private final ImportService importService; - private final IExplorerPluginCommunication explorerPluginCommunication; + private IExplorerPluginCommunication explorerPluginCommunication; - public DefaultDuplicationService(Vertx vertx, JsonObject config, Storage storage, String importPath, PrivateKey signKey, PublicKey verifyKey, boolean forceEncryption) + public DefaultDuplicationService(Vertx vertx, JsonObject config, Storage storage, String importPath, PrivateKey signKey, + PublicKey verifyKey, boolean forceEncryption, final Promise startPromise) { this.eb = vertx.eventBus(); this.fs = vertx.fileSystem(); String tmpDir = System.getProperty("java.io.tmpdir"); this.exportService = new FileSystemExportService(vertx, vertx.fileSystem(), vertx.eventBus(), tmpDir, "duplicate:export", null, - storage, null, null, signKey, forceEncryption); + storage, null, signKey, forceEncryption); this.importService = new DefaultImportService(vertx, config, storage, importPath, "duplicate:import", verifyKey, forceEncryption); try { - this.explorerPluginCommunication = ExplorerPluginFactory.getCommunication(); + ExplorerPluginFactory.getCommunication() + .onSuccess(communication -> { + this.explorerPluginCommunication = communication; + startPromise.complete(); + }) + .onFailure(startPromise::fail); } catch (Exception e) { throw new IllegalStateException("explorer plugin communication could not be started", e); } @@ -194,9 +200,9 @@ private void moveDuplicatedResourceToOriginalResourceFolder(final JsonArray reso } @Override - public void exported(final String exportId, String status, final String locale, final String host) + public void onExportDone(final String exportId, String status, final String locale, final String host, final String app) { - this.exportService.exported(exportId, status, locale, host); + this.exportService.onExportDone(exportId, status, locale, host, app); } @Override diff --git a/archive/src/main/java/org/entcore/archive/services/impl/DefaultImportService.java b/archive/src/main/java/org/entcore/archive/services/impl/DefaultImportService.java index ed8bca60ce..ee51f5b5b3 100644 --- a/archive/src/main/java/org/entcore/archive/services/impl/DefaultImportService.java +++ b/archive/src/main/java/org/entcore/archive/services/impl/DefaultImportService.java @@ -1,5 +1,7 @@ package org.entcore.archive.services.impl; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; import fr.wseduc.mongodb.MongoDb; import fr.wseduc.webutils.Either; import fr.wseduc.webutils.security.RSA; @@ -12,6 +14,7 @@ import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; +import io.vertx.core.shareddata.AsyncMap; import org.entcore.archive.Archive; import org.entcore.archive.controllers.ArchiveController; @@ -24,33 +27,57 @@ import java.io.File; import java.security.PublicKey; +import java.time.Duration; +import java.time.temporal.ChronoUnit; import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; public class DefaultImportService implements ImportService { - private class UserImport { + private final static long GET_LOCK_IMPORT_TIMEOUT = Duration.of(10, ChronoUnit.MINUTES).toMillis(); + public static class UserImport { private final int expectedImports; - private final AtomicInteger counter; + private int counter; private final JsonObject results; - public UserImport(int expectedImports) { this.expectedImports = expectedImports; - this.counter = new AtomicInteger(0); + this.counter = 0; this.results = new JsonObject(); } + @JsonCreator + public UserImport(@JsonProperty("expectedImports") int expectedImports, + @JsonProperty("results") JsonObject results, + @JsonProperty("counter") int counter) { + this.expectedImports = expectedImports; + this.results = results; + this.counter = counter; + } + + /** + * @param app Application title which sent a new report + * @param importRapport Said report + * @return {@code true} if we have all the reports we expected + */ public boolean addAppResult(String app, JsonObject importRapport) { this.results.put(app, importRapport); - return this.counter.incrementAndGet() == expectedImports; + this.counter = this.counter - 1; + return this.counter <= expectedImports; } public JsonObject getResults() { return results; } + + public int getExpectedImports() { + return expectedImports; + } + + public int getCounter() { + return counter; + } } private static final Logger log = LoggerFactory.getLogger(DefaultImportService.class); @@ -66,7 +93,7 @@ public JsonObject getResults() { private final Neo4j neo = Neo4j.getInstance(); - private final Map userImports; + private AsyncMap userImports; public DefaultImportService(Vertx vertx, final JsonObject config, Storage storage, String importPath, String customHandlerActionName, PublicKey verifyKey, boolean forceEncryption) { @@ -75,15 +102,17 @@ public DefaultImportService(Vertx vertx, final JsonObject config, Storage storag this.importPath = importPath; this.fs = vertx.fileSystem(); this.eb = vertx.eventBus(); - this.userImports = new HashMap<>(); + vertx.sharedData().getAsyncMap("userImports") + .onSuccess(m -> this.userImports = m); this.handlerActionName = customHandlerActionName == null ? "import" : customHandlerActionName; this.verifyKey = verifyKey; this.forceEncryption = forceEncryption; } @Override - public boolean isUserAlreadyImporting(String userId) { - return userImports.keySet().stream().anyMatch(id -> id.endsWith(userId)); + public Future isUserAlreadyImporting(String userId) { + return userImports.keys() + .map(keys -> keys.stream().anyMatch(id -> id.endsWith(userId))); } @Override @@ -189,12 +218,15 @@ public void deleteArchive(String importId) { String filePath = getImportPath(importId); log.debug("[Archive] - Deleting import located at " + filePath); + storage.deleteRecursive(filePath); fs.deleteRecursive(filePath, true, deleted -> { if (deleted.failed()) { log.error("[Archive] - Import could not be deleted - " + deleted.cause().getMessage()); } }); - fs.deleteRecursive(getUnzippedImportPath(importId), true, deleted -> { + String unzippedPath = getUnzippedImportPath(importId); + storage.deleteRecursive(unzippedPath); + fs.deleteRecursive(unzippedPath, true, deleted -> { if (deleted.failed()) { log.error("[Archive] - Import could not be deleted - " + deleted.cause().getMessage()); } @@ -403,23 +435,28 @@ private void getQuota(UserInfos user, JsonObject reply, Handler hand public void launchImport(String userId, String userLogin, String userName, String importId, String locale, String host, JsonObject apps) { - userImports.put(importId, new UserImport(apps.size())); - + userImports.put(importId, JsonObject.mapFrom(new UserImport(apps.size()))); fs.readDir(getUnzippedImportPath(importId), results -> { if (results.succeeded()) { if (results.result().size() == 1) { - JsonObject j = new JsonObject() - .put("action", handlerActionName) - .put("importId", importId) - .put("userId", userId) - .put("userLogin", userLogin) - .put("userName", userName) - .put("locale", locale) - .put("host", host) - .put("apps", apps) - .put("path", results.result().get(0)); - eb.publish("user.repository", j); + final String path = results.result().get(0); + log.debug("Moving unzipped files to storage " + path); + storage.moveFsDirectory(path, path) + .onSuccess(e -> { + JsonObject j = new JsonObject() + .put("action", handlerActionName) + .put("importId", importId) + .put("userId", userId) + .put("userLogin", userLogin) + .put("userName", userName) + .put("locale", locale) + .put("host", host) + .put("apps", apps) + .put("path", path); + eb.publish("user.repository", j); + }) + .onFailure(th -> log.error("[Archive] Error while moving zip to storage", th)); } else { deleteArchive(importId); @@ -474,22 +511,44 @@ public void handle(Either either) @Override public void imported(String importId, String app, JsonObject importRapport) { - UserImport userImport = userImports.get(importId); - if (userImport == null) { - JsonObject jo = new JsonObject() - .put("status", "error"); - eb.request(getImportBusAddress(importId), jo); - deleteArchive(importId); - } else { - final boolean finished = userImport.addAppResult(app, importRapport); - if (finished) { - JsonObject jo = new JsonObject() - .put("status", "ok") - .put("result", userImport.getResults()); - eb.request(getImportBusAddress(importId), jo); + vertx.sharedData().getLockWithTimeout("import_" + importId, GET_LOCK_IMPORT_TIMEOUT) + .onFailure(th -> log.error("An error occurred while getting a lock to treat the import results of import " +importId + " and app " + app, th)) + .onSuccess(lock -> { + userImports.get(importId) + .map(rawImport -> rawImport == null ? null : rawImport.mapTo(UserImport.class)) + .onFailure(th -> { + log.error("An error occurred while treating import " + importId + " for app " + app, th); + lock.release(); + eb.request(getImportBusAddress(importId), new JsonObject() + .put("status", "error")); deleteArchive(importId); - } - } + }) + .onSuccess(userImport -> { + if (userImport == null) { + lock.release(); + JsonObject jo = new JsonObject() + .put("status", "error"); + eb.request(getImportBusAddress(importId), jo); + deleteArchive(importId); + } else { + final boolean finished = userImport.addAppResult(app, importRapport); + userImports.put(importId, JsonObject.mapFrom(userImport)) + .onFailure(th -> { + log.error("Could not update remote import map for import " + importId + " and app " + app, th); + lock.release(); + }).onSuccess(e -> { + lock.release(); + if (finished) { + JsonObject jo = new JsonObject() + .put("status", "ok") + .put("result", userImport.getResults()); + eb.request(getImportBusAddress(importId), jo); + deleteArchive(importId); + } + }); + } + }); + }); } @Override @@ -498,7 +557,12 @@ public String getImportBusAddress(String exportId) return "import." + exportId; } - private Future recursiveSize(String path) { + @Override + public void clear() { + this.userImports.clear(); + } + + private Future recursiveSize(String path) { Promise size = Promise.promise(); fs.props(path, handler -> { if (handler.succeeded()) { diff --git a/archive/src/main/java/org/entcore/archive/services/impl/FileSystemExportService.java b/archive/src/main/java/org/entcore/archive/services/impl/FileSystemExportService.java index 0080867645..487a330994 100644 --- a/archive/src/main/java/org/entcore/archive/services/impl/FileSystemExportService.java +++ b/archive/src/main/java/org/entcore/archive/services/impl/FileSystemExportService.java @@ -46,14 +46,21 @@ import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; -import io.vertx.core.shareddata.LocalMap; +import io.vertx.core.shareddata.AsyncMap; import java.io.File; import java.security.PrivateKey; +import java.time.Duration; +import java.time.temporal.ChronoUnit; import java.util.*; +import java.util.stream.Collectors; import java.util.zip.Deflater; +import static com.google.common.collect.Lists.newArrayList; import static fr.wseduc.webutils.Utils.handlerToAsyncHandler; +import static io.vertx.core.Future.failedFuture; +import static io.vertx.core.Future.succeededFuture; +import static io.vertx.core.json.JsonObject.mapFrom; public class FileSystemExportService implements ExportService { @@ -65,8 +72,8 @@ public class FileSystemExportService implements ExportService { private final EmailSender notification; private final Storage storage; private static final Logger log = LoggerFactory.getLogger(FileSystemExportService.class); - private final Map userExportInProgress; - private final Map userExport; + private AsyncMap userExportInProgress; + private AsyncMap userExport; private final TimelineHelper timeline; private final PrivateKey signKey; private final boolean forceEncryption; @@ -75,8 +82,11 @@ public class FileSystemExportService implements ExportService { private static final long DOWNLOAD_IN_PROGRESS = -2l; private static final long EXPORT_ERROR = -4l; + private static final String EXPORT_LOCK_PREFIX = "EXPORT_LOCK"; + private static final long EXPORT_LOCK_TIMEOUT = Duration.of(10, ChronoUnit.MINUTES).toMillis(); + public FileSystemExportService(Vertx vertx, FileSystem fs, EventBus eb, String exportPath, String customHandlerActionName, - EmailSender notification, Storage storage, Map userExportInProgress, TimelineHelper timeline, + EmailSender notification, Storage storage, TimelineHelper timeline, PrivateKey signKey, boolean forceEncryption) { this.vertx = vertx; this.fs = fs; @@ -85,8 +95,8 @@ public FileSystemExportService(Vertx vertx, FileSystem fs, EventBus eb, String e this.handlerActionName = customHandlerActionName == null ? "export" : customHandlerActionName; this.notification = notification; this.storage = storage; - this.userExportInProgress = userExportInProgress != null ? userExportInProgress : new HashMap(); - this.userExport = new HashMap<>(); + vertx.sharedData().getAsyncMap("userExportInProgress").onSuccess(map -> this.userExportInProgress = map); + vertx.sharedData().getAsyncMap("userExport").onSuccess(map -> this.userExport = map); this.timeline = timeline; this.signKey = signKey; this.forceEncryption = forceEncryption; @@ -110,50 +120,59 @@ public void handle(Boolean event) Archive.ARCHIVES, new JsonObject().put("file_id", exportId) .put("date", MongoDb.now()), res -> { if ("ok".equals(res.body().getString("status"))) { - userExportInProgress.put(user.getUserId(), now); - userExport.put(user.getUserId(), new UserExport(new HashSet<>(apps.getList()), exportId)); - - final String exportDirectory = exportPath + File.separator + exportId; - - fs.mkdirs(exportDirectory, new Handler>() - { - @Override - public void handle(AsyncResult event) - { - if (event.succeeded()) - { - final Set g = (user.getGroupsIds() != null) ? new - HashSet<>(user.getGroupsIds()) : new HashSet(); - User.getOldGroups(user.getUserId(), new Handler() - { - @Override - public void handle(JsonArray objects) - { - g.addAll(objects.getList()); - JsonObject j = new JsonObject() - .put("action", handlerActionName) - .put("exportId", exportId) - .put("userId", user.getUserId()) - .put("groups", new JsonArray(new ArrayList<>(g))) - .put("path", exportDirectory) - .put("locale", locale) - .put("host", request == null || request.headers() == null ? "" : Renders.getScheme(request) + "://" + request.headers().get("Host")) - .put("apps", apps) - .put("exportDocuments", exportDocuments) - .put("exportSharedResources", exportSharedResources) - .put("resourcesIds", resourcesIds); - eb.publish("user.repository", j); - handler.handle(new Either.Right(exportId)); - } - }); - } - else - { - log.error("Create export directory error.", event.cause()); - handler.handle(new Either.Left("export.directory.create.error")); - } - } - }); + final List> futures = newArrayList( + userExportInProgress.put(user.getUserId(), now), + + userExport.put(user.getUserId(), mapFrom(new UserExport(apps.getList(), exportId))) + ); + Future.all(futures) + .onFailure(th -> { + log.error("An error occurred while putting an export in the asyncmap userExport", th); + handler.handle(new Either.Left<>(th.getMessage())); + }) + .onSuccess(e -> { + final String exportDirectory = exportPath + File.separator + exportId; + fs.mkdirs(exportDirectory, new Handler>() + { + @Override + public void handle(AsyncResult event) + { + if (event.succeeded()) + { + final Set g = (user.getGroupsIds() != null) ? new + HashSet<>(user.getGroupsIds()) : new HashSet(); + User.getOldGroups(user.getUserId(), new Handler() + { + @Override + public void handle(JsonArray objects) + { + g.addAll(objects.getList()); + JsonObject j = new JsonObject() + .put("action", handlerActionName) + .put("exportId", exportId) + .put("userId", user.getUserId()) + .put("groups", new JsonArray(new ArrayList<>(g))) + .put("path", exportDirectory) + .put("locale", locale) + .put("host", request == null || request.headers() == null ? "" : Renders.getScheme(request) + "://" + request.headers().get("Host")) + .put("apps", apps) + .put("exportDocuments", exportDocuments) + .put("exportSharedResources", exportSharedResources) + .put("resourcesIds", resourcesIds); + eb.publish("user.repository", j); + handler.handle(new Either.Right(exportId)); + } + }); + } + else + { + log.error("Create export directory error.", event.cause()); + handler.handle(new Either.Left("export.directory.create.error")); + } + } + }); + }); + } else { log.error("Cannot create mongo document in archives"); handler.handle(new Either.Left("export.directory.create.error")); @@ -170,31 +189,48 @@ public void handle(JsonArray objects) @Override public void userExportExists(UserInfos user, final Handler handler) { - handler.handle(userExportInProgress.containsKey(user.getUserId())); + userExportInProgress.keys() + .onSuccess(keys -> handler.handle(keys.contains(user.getUserId()))) + .onFailure(th -> { + log.error("An error occurred while fetching userExportInProgress by id for user " + user.getUserId(), th); + handler.handle(null); + }); } @Override public void userExportId(UserInfos user, Handler handler) { String userId = user.getUserId(); - if (userExportInProgress.containsKey(userId) - && userExportInProgress.get(userId) != DOWNLOAD_READY) { - UserExport ue = userExport.get(user.getUserId()); - handler.handle(ue != null ? ue.getExportId() : null); - } else { - handler.handle(null); - } + userExportInProgress.get(userId) + .compose(progress -> { + if (progress != null && progress != DOWNLOAD_READY) { + return userExport.get(user.getUserId()); + } + return succeededFuture(null); + }) + .map(UserExport::fromJson) + .onSuccess(ue -> handler.handle(ue == null ? null : ue.getExportId())) + .onFailure(th -> { + log.error("An error occurred while fetching userExport by id for user " + user.getUserId(), th); + handler.handle(null); + }); } @Override - public boolean userExportExists(String exportId) { - return userExportInProgress.containsKey(getUserId(exportId)); + public Future userExportExists(String exportId) { + return userExportInProgress.get(getUserId(exportId)) + .map(Objects::nonNull); } @Override public void waitingExport(String exportId, final Handler handler) { - Long v = userExportInProgress.get(getUserId(exportId)); - handler.handle(v != null && v > 0); + userExportInProgress.get(getUserId(exportId)) + .onSuccess(v -> handler.handle(v != null && v > 0)) + .onFailure(th -> { + log.error("An error occurred while fetching userExportInProgress by id for " + exportId, th); + handler.handle(false); + }) + ; } @Override @@ -218,8 +254,7 @@ public void handle(AsyncResult event) { } @Override - public void exported(final String exportId, String status, final String locale, final String host) - { + public void onExportDone(final String exportId, String status, final String locale, final String host, final String app) { log.debug("Exported method"); if (exportId == null) { log.error("Export receive event without exportId "); @@ -227,146 +262,179 @@ public void exported(final String exportId, String status, final String locale, } final String exportDirectory = exportPath + File.separator + exportId; final String userId = getUserId(exportId); - if (!userExportInProgress.containsKey(userId)) { - return; - } - final UserExport export = userExport.get(userId); - final int counter = export.incrementAndGetCounter(); - final boolean isFinished = counter == export.getExpectedExport().size(); - if(isFinished) { - log.debug("Export " + exportId + " finished for user " + userId); - } else { - log.debug("Received " + counter + " parts out of " + export.getExpectedExport().size() + " for export " + exportId + " of user " + userId); - } - if (!"ok".equals(status)) { - export.setProgress(EXPORT_ERROR); - } - if (isFinished && export.getProgress().longValue() == EXPORT_ERROR) { - log.error("Error in export " + exportId); - JsonObject j = new JsonObject() - .put("status", "error") - .put("message", "export.error"); - eb.publish(getExportBusAddress(exportId), j); - userExportInProgress.remove(userId); - fs.deleteRecursive(exportDirectory, true, new Handler>() { - @Override - public void handle(AsyncResult event) { - if (event.failed()) { - log.error("Error deleting directory : " + exportDirectory, event.cause()); - } - } - }); - if (notification != null) { - sendExportEmail(exportId, locale, status, host); - } - return; - } - if (isFinished) { - log.debug("Export " + exportId + " is finished and OK", exportId); - addManifestToExport(exportId, exportDirectory, locale, event -> { - log.debug("Manifest added for export " + exportId); - signExport(exportId, exportDirectory, new Handler>() - { - @Override - public void handle(AsyncResult signed) - { - log.debug("Zipping export " + exportId); - Zip.getInstance().zipFolder(exportDirectory, exportDirectory + ".zip", true, - Deflater.NO_COMPRESSION, new Handler>() - { - @Override - public void handle(final Message event) - { - if (!"ok".equals(event.body().getString("status")) || signed.failed() == true) - { - log.error("Zip export " + exportId + " error : " - + (signed.failed() == true ? "Could not sign the archive" : event.body().getString("message"))); - event.body().put("message", "zip.export.error"); - userExportInProgress.remove(userId); - fs.deleteRecursive(exportDirectory, true, new Handler>() { - @Override - public void handle(AsyncResult event) { - if (event.failed()) { - log.error("Error deleting directory : " + exportDirectory, - event.cause()); - } - } - }); - publish(event); - } else { - log.debug("Storing export zip in file storage"); - storeZip(event); - } - } - - private void storeZip(final Message event) - { - log.debug("Starting to upload exported archive " + exportId + " to fs....."); - storage.writeFsFile(exportId, exportDirectory + ".zip", new Handler() { - @Override - public void handle(JsonObject res) { - if (!"ok".equals(res.getString("status"))) { - log.error("Zip storage " + exportId + " error : " - + res.getString("message")); - event.body().put("message", "zip.saving.error"); - userExportInProgress.remove(userId); - publish(event); - } else { - log.debug("Exported archive " + exportId + " uploaded"); - userExportInProgress.put(userId,DOWNLOAD_READY); - publish(event); - } - deleteTempZip(exportId); - } - }); - } - - public void deleteTempZip(final String exportId1) { - final String path = exportPath + File.separator + exportId1 + ".zip"; - log.debug("Deleting temp exported archive " + path); - fs.delete(path, new Handler>() { - @Override - public void handle(AsyncResult event) { - if (event.failed()) { - log.error("Error deleting temp zip export " + exportId1, event.cause()); - } else { - log.debug("Temp archive " + path + " deleted"); - } - } - }); - } - - private void publish(final Message event) { - final String address = getExportBusAddress(exportId); - log.debug("Notifying that export " + exportId + " is done with body " +event.body().encodePrettily()); - eb.request(address, event.body(), new DeliveryOptions().setSendTimeout(5000l), - new Handler>>() { - @Override - public void handle(AsyncResult> res) { - if ((!res.succeeded() && userExportExists(exportId) - && !downloadIsInProgress(exportId)) - || (res.succeeded() - && res.result().body().getBoolean("sendNotifications", false) - .booleanValue())) { - if (notification != null) { - sendExportEmail(exportId, locale, - event.body().getString("status"), host); - } else { - notifyOnTimeline(exportId, locale, - event.body().getString("status")); - } - } - } - }); - } + this.vertx.sharedData().getLockWithTimeout(EXPORT_LOCK_PREFIX + "_" + exportId, EXPORT_LOCK_TIMEOUT) + .onFailure(th -> log.error("An error occurred while getting a log for export " + exportId + " of user " + userId)) + .onSuccess(lock -> { + userExport.get(userId) + .onFailure(th -> { + log.error("Cannot get userExport " + exportId + " of user " + userId, th); + lock.release(); + }) + .map(UserExport::fromJson) + .flatMap(export -> { + if (export == null) { + log.warn("Received a notification about a finished export (" + exportId + ") but this export could not be found"); + lock.release(); + return succeededFuture(); + } + final Map states = export.getStateByModule(); + states.put(app, true); + if (export.isFinished()) { + log.debug("Export " + exportId + " finished for user " + userId); + } else { + final String stillProcessingApps = states.entrySet().stream() + .filter(exported -> !exported.getValue()) + .map(e -> e.getKey()) + .collect(Collectors.joining(",")); + log.info("Still waiting for apps [" + stillProcessingApps + "] for export " + exportId + " of user " + userId); + } + if (!"ok".equals(status)) { + export.setProgress(EXPORT_ERROR); + } + return userExport.put(userId, mapFrom(export)).map(export); + }) + .onFailure(th -> { + log.error("Cannot update userExport " + exportId + " of user " + userId, th); + lock.release(); + }) + .onSuccess(export -> { + lock.release(); + final boolean isFinished = export.isFinished(); + if (isFinished && export.getProgress() == EXPORT_ERROR) { + log.error("Error in export " + exportId); + JsonObject errorPayload = new JsonObject() + .put("status", "error") + .put("message", "export.error"); + eb.publish(getExportBusAddress(exportId), errorPayload); + userExportInProgress.remove(userId); + storage.deleteRecursive(exportDirectory) + .onComplete(e -> log.debug("Deletion of " + exportDirectory + " is ok ? " + e.succeeded())); + fs.deleteRecursive(exportDirectory, true, event -> { + if (event.failed()) { + log.error("Error deleting directory : " + exportDirectory, event.cause()); + } + }); + if (notification != null) { + sendExportEmail(exportId, locale, status, host); + } + return; + } + if (isFinished) { + log.debug("Export " + exportId + " is finished and OK", exportId); + // Copy what the different modules produced to this service's file system (because if the modules exported to S3) + // then archive cannot access the export files. + storage.moveDirectoryToFs(exportDirectory, exportDirectory) + .onFailure(th -> { + JsonObject errorPayload = new JsonObject() + .put("status", "error") + .put("message", "export.error"); + eb.publish(getExportBusAddress(exportId), errorPayload); + userExportInProgress.remove(userId); + storage.deleteRecursive(exportDirectory) + .onComplete(e -> log.debug("Deletion of " + exportDirectory + " is ok ? " + e.succeeded())); + fs.deleteRecursive(exportDirectory, true, event -> { + if (event.failed()) { + log.error("Error deleting directory : " + exportDirectory, event.cause()); + } + }); + log.error("Error while retrieving exported files", th); // TODO jber purge download; + }) + .onSuccess(e -> + addManifestToExport(exportId, exportDirectory, locale, event -> { + log.debug("Manifest added for export " + exportId); + signExport(exportId, exportDirectory, signed -> { + log.debug("Zipping export " + exportId); + Zip.getInstance().zipFolder(exportDirectory, exportDirectory + ".zip", true, + Deflater.NO_COMPRESSION, zipResult -> { + if (!"ok".equals(zipResult.body().getString("status")) || signed.failed()) { + log.error("Zip export " + exportId + " error : " + + (signed.failed() ? "Could not sign the archive" : zipResult.body().getString("message"))); + zipResult.body().put("message", "zip.export.error"); + userExport.remove(userId); + userExportInProgress.remove(userId); + fs.deleteRecursive(exportDirectory, true, event2 -> { + if (event2.failed()) { + log.error("Error deleting directory : " + exportDirectory, + event2.cause()); + } + }); + publish(zipResult, exportId, locale, host); + } else { + log.debug("Storing export zip in file storage"); + storeZip(zipResult, exportId, exportDirectory, userId, locale, host); + } + }); + }); + })); + } + }); }); + } + + // TODO check if that should not be conditional when using S3 + private void storeZip(final Message event, final String exportId, final String exportDirectory, final String userId, final String locale, final String host) { + log.debug("Starting to upload exported archive " + exportId + " to fs....."); + storage.writeFsFile(exportId, exportDirectory + ".zip", new Handler() { + @Override + public void handle(JsonObject res) { + if (!"ok".equals(res.getString("status"))) { + log.error("Zip storage " + exportId + " error : " + + res.getString("message")); + event.body().put("message", "zip.saving.error"); + userExportInProgress.remove(userId); + publish(event, exportId, locale, host); + } else { + log.debug("Exported archive " + exportId + " uploaded"); + userExportInProgress.put(userId, DOWNLOAD_READY); + publish(event, exportId, locale, host); + } + deleteTempZip(exportId); + } + }); + } + + public void deleteTempZip(final String exportId1) { + final String path = exportPath + File.separator + exportId1 + ".zip"; + log.debug("Deleting temp exported archive " + path); + fs.delete(path, new Handler>() { + @Override + public void handle(AsyncResult event) { + if (event.failed()) { + log.error("Error deleting temp zip export " + exportId1, event.cause()); + } else { + log.debug("Temp archive " + path + " deleted"); + } + } + }); + } + + private void publish(final Message event, final String exportId, final String locale, final String host) { + final String address = getExportBusAddress(exportId); + log.debug("Notifying that export " + exportId + " is done with body " + event.body().encodePrettily()); + eb.request(address, event.body(), new DeliveryOptions().setSendTimeout(5000l), + (Handler>>) res -> { + final List> futures = newArrayList( + userExportExists(exportId), + downloadIsInProgress(exportId) + ); + Future.all(futures).onSuccess(checks -> { + if ((!res.succeeded() && checks.resultAt(0) + && !checks.resultAt(1)) + || (res.succeeded() + && res.result().body().getBoolean("sendNotifications", false))) { + if (notification != null) { + sendExportEmail(exportId, locale, + event.body().getString("status"), host); + } else { + notifyOnTimeline(exportId, locale, + event.body().getString("status")); + } } }); }); - } - } + } - @Override + @Override public void deleteExport(final String exportId) { storage.removeFile(exportId, new Handler() { @Override @@ -378,6 +446,7 @@ public void handle(JsonObject event) { }); MongoDb.getInstance().delete(Archive.ARCHIVES, new JsonObject().put("file_id", exportId)); String userId = getUserId(exportId); + userExport.remove(userId); userExportInProgress.remove(userId); } @@ -387,60 +456,83 @@ public void clearUserExport(String userId) userExportInProgress.remove(userId); } - @Override - public void setDownloadInProgress(String exportId) { - String userId = getUserId(exportId); - if (userExportInProgress.containsKey(userId)) { - userExportInProgress.put(userId,DOWNLOAD_IN_PROGRESS); - } + @Override + public Future> getUserExportInProgress() { + return userExportInProgress.entries(); + } + + @Override + public Future removeUserExportInProgress(String key) { + return userExportInProgress.remove(key).mapEmpty(); + } + + @Override + public Future setDownloadInProgress(String exportId) { + return this.userExportExists(exportId).compose(userExport -> { + if(userExport != null) { + final String userId = getUserId(exportId); + return userExportInProgress.put(userId,DOWNLOAD_IN_PROGRESS); + } + return succeededFuture(); + }); } @Override - public boolean downloadIsInProgress(String exportId) { - Long v = userExportInProgress.get(getUserId(exportId)); - return v != null && v == DOWNLOAD_IN_PROGRESS; + public Future downloadIsInProgress(String exportId) { + return userExportInProgress.get(getUserId(exportId)) + .map(v -> v != null && v == DOWNLOAD_IN_PROGRESS); } private void addManifestToExport(String exportId, String exportDirectory, String locale, Handler> handler) { - LocalMap versionMap = vertx.sharedData().getLocalMap("versions"); JsonObject manifest = new JsonObject(); - Set expectedExport = this.userExport.get(getUserId(exportId)).getExpectedExport(); - - this.vertx.eventBus().request("portal", new JsonObject().put("action","getI18n").put("acceptLanguage",locale), json -> - { - JsonObject i18n = (JsonObject)(json.result().body()); - versionMap.forEach((k, v) -> - { - String[] s = k.split("\\."); - // Removing of "-" for scrapbook - String app = (s[s.length - 1]).replaceAll("-", ""); - - if (expectedExport.contains(app)) - { - String i = i18n.getString(app); - manifest.put(k, new JsonObject().put("version",v) - .put("folder", StringUtils.stripAccents(i == null ? app : i))); - } - }); + this.userExport.get(getUserId(exportId)) + .onFailure(th -> { + log.error("An error occurred while getting userUserport for " + exportId, th); + handler.handle(failedFuture(th)); + }) + .map(UserExport::fromJson) + .onSuccess(e -> { + final Set expectedExport = e.getStateByModule().keySet(); + this.vertx.eventBus().request("portal", new JsonObject().put("action", "getI18n").put("acceptLanguage", locale), json -> { + vertx.sharedData().getAsyncMap("versions") + .compose(AsyncMap::entries).onSuccess(versionMap -> { + JsonObject i18n = (JsonObject) (json.result().body()); + versionMap.forEach((k, v) -> + { + String[] s = k.split("\\."); + // Removing of "-" for scrapbook + String app = (s[s.length - 1]).replaceAll("-", ""); + + if (expectedExport.contains(app)) { + String i = i18n.getString(app); + manifest.put(k, new JsonObject().put("version", v) + .put("folder", StringUtils.stripAccents(i == null ? app : i))); + } + }); - String path = exportDirectory + File.separator + "Manifest.json"; - fs.writeFile(path, Buffer.buffer(manifest.encodePrettily()), handler); - }); + String path = exportDirectory + File.separator + "Manifest.json"; + fs.writeFile(path, Buffer.buffer(manifest.encodePrettily()), handler); + }).onFailure(ex -> { + log.error("Error getting versions map to add export manifest", ex); + handler.handle(failedFuture(ex)); + }); + }); + }); } private void signExport(String exportId, String exportDirectory, Handler> handler) { if(this.signKey == null) { - if(this.forceEncryption == true) + if(this.forceEncryption) { log.error("No signing key for export " + exportId); - handler.handle(Future.failedFuture("No signing key")); + handler.handle(failedFuture("No signing key")); } else { - handler.handle(Future.succeededFuture()); + handler.handle(succeededFuture()); } return; } @@ -457,13 +549,13 @@ private void signExport(String exportId, String exportDirectory, Handler fs.writeFile(exportDirectory + File.separator + ArchiveController.SIGNATURE_NAME, signContents.toBuffer(), handler)) - .onFailure(th -> handler.handle(Future.failedFuture(th))); + .onFailure(th -> handler.handle(failedFuture(th))); } private String getUserId(String exportId) { @@ -538,4 +630,5 @@ public String getExportBusAddress(String exportId) } + } diff --git a/archive/src/main/java/org/entcore/archive/services/impl/UserExport.java b/archive/src/main/java/org/entcore/archive/services/impl/UserExport.java index 8074586183..b6e0491cf8 100644 --- a/archive/src/main/java/org/entcore/archive/services/impl/UserExport.java +++ b/archive/src/main/java/org/entcore/archive/services/impl/UserExport.java @@ -1,30 +1,62 @@ package org.entcore.archive.services.impl; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.vertx.core.json.JsonObject; import io.vertx.core.shareddata.Shareable; +import java.beans.Transient; import java.io.Serializable; -import java.util.Collections; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.*; import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static java.lang.System.currentTimeMillis; public class UserExport implements Shareable, Serializable { private static final long serialVersionUID = 42L; - private AtomicLong progress; - private AtomicInteger counter; - private final Set expectedExport; + private long start; + private final AtomicLong progress; + /** {@code true} or {@code} false for each apps that have to be exported.*/ + private final Map stateByModule; private final String exportId; - public UserExport(Set expectedExport, String exportId) { - this.progress = new AtomicLong(System.currentTimeMillis()); - this.counter = new AtomicInteger(0); - this.expectedExport = Collections.unmodifiableSet(expectedExport); + public UserExport(Map stateByModule, String exportId) { + this.start = currentTimeMillis(); + this.progress = new AtomicLong(this.start); + this.stateByModule = new HashMap<>(stateByModule); this.exportId = exportId; } - public Long getProgress() { + public UserExport(Collection apps, String exportId) { + this.start = currentTimeMillis(); + this.progress = new AtomicLong(this.start); + this.stateByModule = apps.stream().collect(Collectors.toMap(e -> e, e -> false)); + this.exportId = exportId; + } + + @JsonCreator + public UserExport(@JsonProperty("progress") final long progress, + @JsonProperty("stateByModule") final Map stateByModule, + @JsonProperty("exportId") final String exportId, + @JsonProperty("start") final long start) { + this.progress = new AtomicLong(progress); + this.stateByModule = stateByModule; + this.exportId = exportId; + this.start = start; + } + + public static UserExport fromJson(final JsonObject jsonObject) { + return jsonObject == null ? null : jsonObject.mapTo(UserExport.class); + } + + public long getStart() { + return start; + } + + public Long getProgress() { return this.progress.get(); } @@ -32,15 +64,16 @@ public void setProgress(long progress) { this.progress.set(progress); } - public int incrementAndGetCounter() { - return this.counter.incrementAndGet(); - } - - public Set getExpectedExport() { - return this.expectedExport; - } - public String getExportId() { return exportId; } + + public Map getStateByModule() { + return stateByModule; + } + + @Transient + public boolean isFinished() { + return stateByModule.values().stream().allMatch(exported -> exported); + } } diff --git a/audience/pom.xml b/audience/pom.xml index 8466d606e6..e9532d4bd0 100644 --- a/audience/pom.xml +++ b/audience/pom.xml @@ -30,5 +30,12 @@ commons-collections4 4.4 + + fr.wseduc + mod-postgresql + 2.0-zookeeper-SNAPSHOT + runtime + fat + \ No newline at end of file diff --git a/audience/src/main/java/org/entcore/audience/Audience.java b/audience/src/main/java/org/entcore/audience/Audience.java index a6fcec1ee1..9da46302ff 100644 --- a/audience/src/main/java/org/entcore/audience/Audience.java +++ b/audience/src/main/java/org/entcore/audience/Audience.java @@ -1,6 +1,8 @@ package org.entcore.audience; +import io.vertx.core.Future; import io.vertx.core.Promise; +import org.apache.commons.lang3.tuple.Pair; import org.entcore.audience.controllers.AudienceController; import org.entcore.audience.reaction.dao.ReactionDao; import org.entcore.audience.reaction.dao.impl.ReactionDaoImpl; @@ -21,27 +23,34 @@ import java.util.stream.Collectors; public class Audience extends BaseServer { - private AudienceController audienceController; - - @Override - public void start(final Promise startPromise) throws Exception { - super.start(startPromise); - final ISql isql = Sql.getInstance(); - final ReactionDao reactionDao = new ReactionDaoImpl(isql); - final ReactionService reactionService = new ReactionServiceImpl(vertx.eventBus(), reactionDao); - final ViewDao viewDao = new ViewDaoImpl(isql); - final ViewService viewService = new ViewServiceImpl(viewDao); - final AudienceService audienceService = new AudienceServiceImpl(reactionService, viewService); - final Set validReactionTypes = config.getJsonObject("publicConf").getJsonArray("reaction-types").stream().map(Object::toString).collect(Collectors.toSet()); - audienceController = new AudienceController(vertx, config(), reactionService, viewService, audienceService, validReactionTypes); - addController(audienceController); - setRepositoryEvents(new AudienceRepositoryEvents(audienceService)); - startPromise.tryComplete(); - } - - @Override - public void stop(Promise stopPromise) throws Exception { - super.stop(stopPromise); - audienceController.stopResourceDeletionListener(); - } + + private AudienceController audienceController; + + @Override + public void start(final Promise startPromise) throws Exception { + final Promise promise = Promise.promise(); + super.start(promise); + promise.future().compose(init -> initAudience()).onComplete(startPromise); + } + + public Future initAudience() { + + final ISql isql = Sql.getInstance(); + final ReactionDao reactionDao = new ReactionDaoImpl(isql); + final ReactionService reactionService = new ReactionServiceImpl(vertx.eventBus(), reactionDao); + final ViewDao viewDao = new ViewDaoImpl(isql); + final ViewService viewService = new ViewServiceImpl(viewDao); + final AudienceService audienceService = new AudienceServiceImpl(reactionService, viewService); + final Set validReactionTypes = config.getJsonObject("publicConf").getJsonArray("reaction-types").stream().map(Object::toString).collect(Collectors.toSet()); + audienceController = new AudienceController(vertx, config(), reactionService, viewService, audienceService, validReactionTypes); + addController(audienceController); + setRepositoryEvents(new AudienceRepositoryEvents(audienceService)); + return Future.succeededFuture(); + } + + @Override + public void stop(Promise stopPromise) throws Exception { + super.stop(stopPromise); + audienceController.stopResourceDeletionListener(); + } } diff --git a/auth/pom.xml b/auth/pom.xml index e209cab539..c7945f0f50 100644 --- a/auth/pom.xml +++ b/auth/pom.xml @@ -56,10 +56,18 @@ test - org.apache.commons - commons-lang3 - ${commonsLangVersion} - test + fr.wseduc + mod-sms-proxy + 2.0-zookeeper-SNAPSHOT + runtime + fat + + + io.vertx + mod-mongo-persistor + 4.1-zookeeper-SNAPSHOT + runtime + fat \ No newline at end of file diff --git a/auth/src/main/java/org/entcore/auth/Auth.java b/auth/src/main/java/org/entcore/auth/Auth.java index 2371a94857..5b6b5fb262 100644 --- a/auth/src/main/java/org/entcore/auth/Auth.java +++ b/auth/src/main/java/org/entcore/auth/Auth.java @@ -20,17 +20,16 @@ package org.entcore.auth; import fr.wseduc.cron.CronTrigger; +import fr.wseduc.webutils.collections.SharedDataHelper; import fr.wseduc.webutils.security.JWT; -import io.vertx.core.AsyncResult; -import io.vertx.core.DeploymentOptions; -import io.vertx.core.Handler; -import io.vertx.core.Promise; +import io.vertx.core.*; import io.vertx.core.eventbus.EventBus; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; -import io.vertx.core.shareddata.LocalMap; +import io.vertx.core.shareddata.AsyncMap; import jp.eisbahn.oauth2.server.data.DataHandler; +import org.apache.commons.lang3.tuple.Pair; import org.entcore.auth.controllers.*; import org.entcore.auth.controllers.AuthController.AuthEvent; import org.entcore.auth.oauth.HttpServerRequestAdapter; @@ -55,9 +54,11 @@ import org.entcore.common.http.BaseServer; import org.entcore.common.neo4j.Neo; import org.entcore.common.sms.SmsSenderFactory; -import org.opensaml.xml.ConfigurationException; +import java.security.InvalidKeyException; +import java.text.ParseException; import java.util.List; +import java.util.Map; import java.util.UUID; import static fr.wseduc.webutils.Utils.getOrElse; @@ -67,19 +68,36 @@ public class Auth extends BaseServer { @Override public void start(final Promise startPromise) throws Exception { + final Promise promise = Promise.promise(); + super.start(promise); + promise.future() + .compose(init -> SharedDataHelper.getInstance().getLocalMulti( + "server", "signKey", "smsProvider", "node", "emailValidationConfig", "skins", "event-store")) + .compose(authMap -> SharedDataHelper.getInstance().getLocalAsyncMap("server") + .map(asyncServerMap -> Pair.of(authMap, asyncServerMap))) + .compose(configPair -> initAuth(configPair.getLeft(), configPair.getRight())) + .onComplete(startPromise); + } + + public Future initAuth(final Map authMap, final AsyncMap asyncAuthMap) { final EventBus eb = getEventBus(vertx); - super.start(startPromise); setDefaultResourceFilter(new AuthResourcesProvider(new Neo(vertx, eb, null))); final String JWT_PERIOD_CRON = "jwt-bearer-authorization-periodic"; final String JWT_PERIOD = "jwt-bearer-authorization"; final EventStore eventStore = EventStoreFactory.getFactory().getEventStore(Auth.class.getSimpleName()); - final UserAuthAccount userAuthAccount = new DefaultUserAuthAccount(vertx, config, eventStore); - SafeRedirectionService.getInstance().init(vertx, config.getJsonObject("safeRedirect", new JsonObject())); + final UserAuthAccount userAuthAccount = new DefaultUserAuthAccount(vertx, config, eventStore, authMap); + SafeRedirectionService.getInstance().init(vertx, config.getJsonObject("safeRedirect", new JsonObject()), + (JsonObject) authMap.get("skins")); SmsSenderFactory.getInstance().init(vertx, config); UserValidationFactory.getFactory().setEventStore(eventStore, AuthEvent.SMS.name()); - final MfaService mfaService = new DefaultMfaService(vertx, config).setEventStore(eventStore); + final MfaService mfaService; + try { + mfaService = new DefaultMfaService(vertx, config, authMap).setEventStore(eventStore); + } catch (InvalidKeyException e) { + return Future.failedFuture(e); + } final JsonObject oic = config.getJsonObject("openid-connect"); final OpenIdConnectService openIdConnectService = (oic != null) @@ -92,7 +110,7 @@ public void start(final Promise startPromise) throws Exception { config.getJsonArray("oauth2-pw-client-enable-saml2"), eventStore, config.getBoolean("otp-disabled", false), config.getInteger("oauth2-token-expiration-time-seconds", 3600)); - AuthController authController = new AuthController(); + AuthController authController = new AuthController(authMap); authController.setEventStore(eventStore); authController.setUserAuthAccount(userAuthAccount); authController.setOauthDataFactory(oauthDataFactory); @@ -112,7 +130,7 @@ public void start(final Promise startPromise) throws Exception { } final String customTokenEncryptKey = config.getString("custom-token-encrypt-key", UUID.randomUUID().toString()); - final String signKey = (String) vertx.sharedData().getLocalMap("server").get("signKey"); + final String signKey = (String) authMap.get("signKey"); CustomTokenHelper.setEncryptKey(customTokenEncryptKey); CustomTokenHelper.setSignKey(signKey); @@ -130,7 +148,7 @@ public void handle(AsyncResult> event) { ); oauthDataFactory.setSamlHelper(samlHelper); - SamlController samlController = new SamlController(); + SamlController samlController = new SamlController((JsonObject) authMap.get("skins")); JsonObject conf = config; vertx.deployVerticle(SamlValidator.class, @@ -142,22 +160,24 @@ public void handle(AsyncResult> event) { samlController.setSamlWayfParams(config.getJsonObject("saml-wayf")); samlController.setIgnoreCallBackPattern(config.getString("ignoreCallBackPattern")); addController(samlController); - LocalMap server = vertx.sharedData().getLocalMap("server"); - if (server != null) { + if (asyncAuthMap != null) { String loginUri = config.getString("loginUri"); String callbackParam = config.getString("callbackParam"); if (loginUri != null && !loginUri.trim().isEmpty()) { - server.putIfAbsent("loginUri", loginUri); + asyncAuthMap.putIfAbsent("loginUri", loginUri) + .onFailure(ex -> log.error("Error when put loginUri", ex)); } if (callbackParam != null && !callbackParam.trim().isEmpty()) { - server.putIfAbsent("callbackParam", callbackParam); + asyncAuthMap.putIfAbsent("callbackParam", callbackParam) + .onFailure(ex -> log.error("Error when put callbackParam", ex)); } final JsonObject authLocations = config.getJsonObject("authLocations"); if (authLocations != null && authLocations.size() > 0) { - server.putIfAbsent("authLocations", authLocations.encode()); + asyncAuthMap.putIfAbsent("authLocations", authLocations.encode()) + .onFailure(ex -> log.error("Error when put authLocations", ex)); } } - } catch (ConfigurationException e) { + } catch (Exception e) { log.error("Saml loading error.", e); } } @@ -206,7 +226,7 @@ public void handle(JsonObject certs) { String cron = NDWConf.getString("cron"); if(cron != null) { - EmailFactory emailFactory = new EmailFactory(vertx, config); + EmailFactory emailFactory = EmailFactory.getInstance(); boolean warnADMC = NDWConf.getBoolean("warn-admc", false); boolean warnADML = NDWConf.getBoolean("warn-adml", false); boolean warnUsers = NDWConf.getBoolean("warn-users", false); @@ -214,8 +234,13 @@ public void handle(JsonObject certs) { int batchLimit = NDWConf.getInteger("batch-limit", 4000).intValue(); String processInterval = NDWConf.getString("process-interval"); NDWTask = new NewDeviceWarningTask(vertx, config, emailFactory.getSender(), config.getString("email"), - warnADMC, warnADML, warnUsers, scoreThreshold, batchLimit, processInterval); - new CronTrigger(vertx, cron).schedule(NDWTask); + warnADMC, warnADML, warnUsers, scoreThreshold, batchLimit, processInterval, + (String) authMap.get("event-store")); + try { + new CronTrigger(vertx, cron).schedule(NDWTask); + } catch (ParseException e) { + return Future.failedFuture(e); + } } } @@ -240,6 +265,7 @@ public void handle(Long event) { } }); } + return Future.succeededFuture(); } } diff --git a/auth/src/main/java/org/entcore/auth/controllers/AuthController.java b/auth/src/main/java/org/entcore/auth/controllers/AuthController.java index 21913ab9a6..f47d385698 100644 --- a/auth/src/main/java/org/entcore/auth/controllers/AuthController.java +++ b/auth/src/main/java/org/entcore/auth/controllers/AuthController.java @@ -43,7 +43,6 @@ import io.vertx.core.json.Json; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; -import io.vertx.core.shareddata.LocalMap; import jp.eisbahn.oauth2.server.async.Handler; import jp.eisbahn.oauth2.server.data.DataHandler; import jp.eisbahn.oauth2.server.data.DataHandlerFactory; @@ -134,6 +133,11 @@ public enum AuthEvent { private List internalAddress; private boolean checkFederatedLogin = false; private long jwtTtlSeconds; + private final Map server; + + public AuthController(Map server) { + this.server = server; + } @Override public void init(Vertx vertx, JsonObject config, RouteMatcher rm, @@ -150,7 +154,6 @@ public void init(Vertx vertx, JsonObject config, RouteMatcher rm, protectedResource.setDataHandlerFactory(oauthDataFactory); protectedResource.setAccessTokenFetcherProvider(accessTokenFetcherProvider); passwordPattern = Pattern.compile(config.getString("passwordRegex", ".{8}.*")); - LocalMap server = vertx.sharedData().getLocalMap("server"); JsonArray authorizedSessions = getOrElse(config.getJsonArray("authorize-mobile-session"), new JsonArray()); authorizedSessions.forEach(session -> clientIdsAuthorized.add((String) session)); if (server != null && server.get("smsProvider") != null) @@ -202,15 +205,6 @@ public void init(Vertx vertx, JsonObject config, RouteMatcher rm, } ipAllowedByPassLimit = getOrElse(config.getJsonArray("ip-allowed-by-pass-limit"), new JsonArray()); -// if (server != null) { -// Boolean cluster = (Boolean) server.get("cluster"); -// if (Boolean.TRUE.equals(cluster)) { -// ClusterManager cm = ((VertxInternal) vertx).clusterManager(); -// invalidEmails = cm.getSyncMap("invalidEmails"); -// } else { -// invalidEmails = vertx.sharedData().getMap("invalidEmails"); -// } -// } else { invalidEmails = MapFactory.getSyncClusterMap("invalidEmails", vertx); internalAddress = config.getJsonArray("internalAddress", new JsonArray().add("localhost").add("127.0.0.1")).getList(); @@ -406,7 +400,7 @@ private JsonObject createQueryParamToken(final HttpServerRequest request, String .put("token_type", "QueryParam"); try { return result - .put("access_token", UserUtils.createJWTForQueryParam(vertx, userId, clientId, ttlInSeconds, request)) + .put("access_token", UserUtils.createJWTForQueryParam(vertx, userId, clientId, ttlInSeconds, request, (String) server.get("signKey"))) .put("expires_in", ttlInSeconds); } catch(Exception e) { return result.put("expires_in", 0).putNull( "access_token" ); @@ -421,6 +415,11 @@ public void token(final HttpServerRequest request) { @Override public void handle(Void v) { final Request req = new HttpServerRequestAdapter(request); + String clientId = req.getParameter("client_id"); + String redirectUri = req.getParameter("redirect_uri"); + log.info(getIp(request) + + " - Initialisation de connexion OAuth2 - ClientID " + clientId + + " - RedirectUri " + redirectUri); token.handleRequest(req, new Handler() { @Override @@ -638,14 +637,14 @@ public void context(final HttpServerRequest request) { pwdResetFormatByLang.put(lang, i18n.translate("password.rules.reset", Renders.getHost(request), lang)); } catch (Exception e) { pwdResetFormatByLang.put(lang, ""); - log.error("error when translating password.rules.reset in {0} : {1}", lang, e); + log.error(String.format("error when translating password.rules.reset in %s : ", lang), e); } try { pwdActivationFormatByLang.put(lang, i18n.translate("password.rules.activation", Renders.getHost(request), lang)); } catch (Exception e) { pwdActivationFormatByLang.put(lang, ""); - log.error("error when translating password.rules.activation in {0} : {1}", lang, e); + log.error(String.format("error when translating password.rules.activation in %s : ", lang), e); } } }); diff --git a/auth/src/main/java/org/entcore/auth/controllers/SamlController.java b/auth/src/main/java/org/entcore/auth/controllers/SamlController.java index de3e1bf143..a9ec9fd60e 100644 --- a/auth/src/main/java/org/entcore/auth/controllers/SamlController.java +++ b/auth/src/main/java/org/entcore/auth/controllers/SamlController.java @@ -95,6 +95,12 @@ public class SamlController extends AbstractFederateController { private static final String SESSIONS_COLLECTION = "sessions"; + private JsonObject skins; + + public SamlController(JsonObject skins) { + this.skins = skins; + } + @Override public void init(Vertx vertx, JsonObject config, RouteMatcher rm, Map securedActions) { @@ -217,7 +223,6 @@ else if(attr.startsWith("other")) } // get theme for logo image src - JsonObject skins = new JsonObject(vertx.sharedData().getLocalMap("skins")); String skin = skins.getString(getHost(request)); if (swmf != null) { swmf.put("childTheme", (skin != null && !skin.trim().isEmpty()) ? skin : "raw"); diff --git a/auth/src/main/java/org/entcore/auth/security/SamlValidator.java b/auth/src/main/java/org/entcore/auth/security/SamlValidator.java index de14ddbb52..aa17bde8d4 100644 --- a/auth/src/main/java/org/entcore/auth/security/SamlValidator.java +++ b/auth/src/main/java/org/entcore/auth/security/SamlValidator.java @@ -163,7 +163,7 @@ public void start() { loadSignatureTrustEngine(f); } loadPrivateKey(config.getString("saml-private-key")); - vertx.eventBus().localConsumer("saml", this); + vertx.eventBus().consumer("saml", this); } catch (ConfigurationException | MetadataProviderException | InvalidKeySpecException | NoSuchAlgorithmException e) { logger.error("Error loading SamlValidator.", e); diff --git a/auth/src/main/java/org/entcore/auth/services/SafeRedirectionService.java b/auth/src/main/java/org/entcore/auth/services/SafeRedirectionService.java index 2502fdae21..78ed09e837 100644 --- a/auth/src/main/java/org/entcore/auth/services/SafeRedirectionService.java +++ b/auth/src/main/java/org/entcore/auth/services/SafeRedirectionService.java @@ -36,7 +36,7 @@ static SafeRedirectionService getInstance() { return SafeRedirectionServiceHolder.instance; } - void init(Vertx vertx, JsonObject config); + void init(Vertx vertx, JsonObject config, JsonObject skins); void canRedirectTo(String uri, Handler handler); diff --git a/auth/src/main/java/org/entcore/auth/services/impl/DefaultMfaService.java b/auth/src/main/java/org/entcore/auth/services/impl/DefaultMfaService.java index 949c85bec9..88fce3b004 100644 --- a/auth/src/main/java/org/entcore/auth/services/impl/DefaultMfaService.java +++ b/auth/src/main/java/org/entcore/auth/services/impl/DefaultMfaService.java @@ -10,7 +10,6 @@ import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; -import io.vertx.core.shareddata.LocalMap; import org.entcore.auth.services.MfaService; import org.entcore.common.datavalidation.UserValidation; @@ -229,10 +228,10 @@ public Future sendWarningMessage(HttpServerRequest request, Map server) + throws InvalidKeyException { io.vertx.core.json.JsonObject params = config.getJsonObject("emailValidationConfig"); if (params == null ) { - LocalMap server = vertx.sharedData().getLocalMap("server"); String s = (String) server.get("emailValidationConfig"); params = (s != null) ? new JsonObject(s) : new JsonObject(); } diff --git a/auth/src/main/java/org/entcore/auth/services/impl/DefaultSafeRedirectionService.java b/auth/src/main/java/org/entcore/auth/services/impl/DefaultSafeRedirectionService.java index 59c6f23b0e..c72f12b625 100644 --- a/auth/src/main/java/org/entcore/auth/services/impl/DefaultSafeRedirectionService.java +++ b/auth/src/main/java/org/entcore/auth/services/impl/DefaultSafeRedirectionService.java @@ -33,12 +33,11 @@ public class DefaultSafeRedirectionService implements SafeRedirectionService { private final Neo4j neo = Neo4j.getInstance(); private boolean inited = false; - public void init(Vertx vertx, JsonObject config) { + public void init(Vertx vertx, JsonObject config, JsonObject skins) { if (inited) return; final long delay = config.getLong("delayInMinutes", 30l); - final Map skins = vertx.sharedData().getLocalMap("skins"); - internalHosts.addAll(skins.keySet()); + internalHosts.addAll(skins.fieldNames()); defaultDomainsWhiteList.addAll(config.getJsonArray("defaultDomains", new JsonArray()).stream() .map(String.class::cast).collect(Collectors.toSet())); loadDomains(); diff --git a/auth/src/main/java/org/entcore/auth/services/impl/SSOGoogle.java b/auth/src/main/java/org/entcore/auth/services/impl/SSOGoogle.java new file mode 100644 index 0000000000..6c5593f336 --- /dev/null +++ b/auth/src/main/java/org/entcore/auth/services/impl/SSOGoogle.java @@ -0,0 +1,87 @@ +package org.entcore.auth.services.impl; + +import fr.wseduc.webutils.Either; +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.Handler; +import io.vertx.core.eventbus.EventBus; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; +import org.entcore.common.neo4j.Neo4j; +import org.entcore.common.neo4j.Neo4jResult; +import org.opensaml.saml2.core.Assertion; + +public class SSOGoogle extends AbstractSSOProvider { + private static final Logger log = LoggerFactory.getLogger(SSOGoogle.class); + private static final String EMAIL = "email"; + private static final String USERID = "userId"; + + @Override + public void generate(EventBus eb, String userId, String host, String serviceProviderEntityId, + Handler> handler) { + getEmail(userId) + .compose(this::fillResult) + .onSuccess(result -> handler.handle(new Either.Right<>(result))) + .onFailure(err -> { + log.error("[Auth@SSOGoogle::generate] Failed to generate response for Google: " + err.getMessage()); + handler.handle(new Either.Left<>(err.getMessage())); + }); + } + + private Future getEmail(String userId) { + Promise promise = Promise.promise(); + + String query = "MATCH (u:User {id:{userId}})\n" + + "OPTIONAL MATCH (u)-[:IN]->(:Group)-[:AUTHORIZED]->(:Role)-[:AUTHORIZE]->(:Action)<-[:PROVIDE]-(a:Application)\n" + + "WITH u, COLLECT(a) AS applications\n" + + "RETURN u.email AS email"; + + Neo4j.getInstance() + .execute(query, new JsonObject().put(USERID, userId), Neo4jResult.validUniqueResultHandler(event -> { + if (event.isLeft()) { + String err = event.left().getValue(); + log.error("[Auth@SSOGoogle::getEmail] Neo4j error for user " + userId + " : " + err); + promise.fail(err); + return; + } + + JsonObject row = event.right().getValue(); + String email = row != null ? row.getString(EMAIL) : null; + if (email != null) { + email = email.trim(); + } + + if (email == null || email.isEmpty()) { + String msg = "User email not found"; + log.warn("[Auth@SSOGoogle::getEmail] " + msg + " for user: " + userId); + promise.fail(msg); + return; + } + + promise.complete(email); + })); + + return promise.future(); + } + + private Future fillResult(String email) { + Promise promise = Promise.promise(); + try { + JsonArray result = new JsonArray().add(new JsonObject().put(EMAIL, email)); + promise.complete(result); + } catch (Exception e) { + log.error("[Auth@SSOGoogle::fillResult] Failed to build result: " + e.getMessage()); + promise.fail(e.getMessage()); + } + return promise.future(); + } + + @Override + public void execute(Assertion assertion, Handler> handler) { + String errorMessage = "execute function not available on SSO Google implementation"; + log.error("[Auth@SSOGoogle::execute] " + errorMessage); + handler.handle(new Either.Left<>(errorMessage)); + } +} diff --git a/auth/src/main/java/org/entcore/auth/users/DefaultUserAuthAccount.java b/auth/src/main/java/org/entcore/auth/users/DefaultUserAuthAccount.java index 453b153a1a..3cd2d490e4 100644 --- a/auth/src/main/java/org/entcore/auth/users/DefaultUserAuthAccount.java +++ b/auth/src/main/java/org/entcore/auth/users/DefaultUserAuthAccount.java @@ -38,7 +38,6 @@ import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; -import io.vertx.core.shareddata.LocalMap; import org.apache.commons.collections4.CollectionUtils; import org.entcore.auth.pojo.SendPasswordDestination; import org.entcore.common.email.EmailFactory; @@ -94,16 +93,15 @@ public class DefaultUserAuthAccount extends TemplatedEmailRenders implements Use private final boolean sendForgotPasswordEmailWithResetCode; - public DefaultUserAuthAccount(Vertx vertx, JsonObject config, EventStore eventStore) { + public DefaultUserAuthAccount(Vertx vertx, JsonObject config, EventStore eventStore, Map server) { super(vertx, config); this.eb = Server.getEventBus(vertx); this.neo = new Neo(vertx, eb, null); this.vertx = vertx; this.config = config; - EmailFactory emailFactory = new EmailFactory(vertx, config); + EmailFactory emailFactory = EmailFactory.getInstance(); notification = emailFactory.getSender(); render = new Renders(vertx, config); - LocalMap server = vertx.sharedData().getLocalMap("server"); if(server != null && server.get("smsProvider") != null) { smsProvider = (String) server.get("smsProvider"); final String node = (String) server.get("node"); diff --git a/auth/src/main/java/org/entcore/auth/users/NewDeviceWarningTask.java b/auth/src/main/java/org/entcore/auth/users/NewDeviceWarningTask.java index a56ccf58da..411a0d8f95 100644 --- a/auth/src/main/java/org/entcore/auth/users/NewDeviceWarningTask.java +++ b/auth/src/main/java/org/entcore/auth/users/NewDeviceWarningTask.java @@ -103,10 +103,9 @@ public class NewDeviceWarningTask extends TemplatedEmailRenders implements Handl private static final Logger log = LoggerFactory.getLogger(NewDeviceWarningTask.class); - public NewDeviceWarningTask(Vertx vertx, JsonObject config, EmailSender sender, String mailFrom, boolean includeADMC, boolean includeADML, boolean includeUsers, int scoreThreshold, int batchLimit, String processInterval) + public NewDeviceWarningTask(Vertx vertx, JsonObject config, EmailSender sender, String mailFrom, boolean includeADMC, boolean includeADML, boolean includeUsers, int scoreThreshold, int batchLimit, String processInterval, String eventStoreConf) { super(vertx, config); - final String eventStoreConf = (String) vertx.sharedData().getLocalMap("server").get("event-store"); if (eventStoreConf != null) { final JsonObject eventStoreConfig = new JsonObject(eventStoreConf); diff --git a/auth/src/test/java/org/entcore/auth/HostTest.java b/auth/src/test/java/org/entcore/auth/HostTest.java index 9e239942f0..f9b63500cb 100644 --- a/auth/src/test/java/org/entcore/auth/HostTest.java +++ b/auth/src/test/java/org/entcore/auth/HostTest.java @@ -74,16 +74,16 @@ public void testHostHeaderInjection(TestContext context) { assertEquals(HOST, host); } - @Test - public void testNotificationGetHost() { - final EmailSender notification = new EmailFactory(vertx).getSender(); - assertEquals("http://" + HOST, notification.getHost(HTTP_REQUEST)); - } + // @Test + // public void testNotificationGetHost() { + // final EmailSender notification = EmailFactory.build(vertx).getSender(); + // assertEquals("http://" + HOST, notification.getHost(HTTP_REQUEST)); + // } - @Test - public void testNotificationHostHeaderInjection() { - final EmailSender notification = new EmailFactory(vertx).getSender(); - assertEquals("http://" + HOST, notification.getHost(ATTACK_HTTP_REQUEST)); - } + // @Test + // public void testNotificationHostHeaderInjection() { + // final EmailSender notification = new EmailFactory(vertx).getSender(); + // assertEquals("http://" + HOST, notification.getHost(ATTACK_HTTP_REQUEST)); + // } } diff --git a/auth/src/test/java/org/entcore/auth/SafeRedirectionTest.java b/auth/src/test/java/org/entcore/auth/SafeRedirectionTest.java index 13ba22e3a3..fdf5764404 100644 --- a/auth/src/test/java/org/entcore/auth/SafeRedirectionTest.java +++ b/auth/src/test/java/org/entcore/auth/SafeRedirectionTest.java @@ -68,7 +68,7 @@ public static void setUp(TestContext context) throws Exception { final Async async = context.async(); CompositeFuture.all(futures).onComplete(res -> { context.assertTrue(res.succeeded()); - redirectionService.init(test.vertx(), redirectConfig); + redirectionService.init(test.vertx(), redirectConfig, new JsonObject().put(entUri.getHost(), "default")); async.complete(); }); diff --git a/auth/src/test/java/org/entcore/auth/UserAuthAccountTest.java b/auth/src/test/java/org/entcore/auth/UserAuthAccountTest.java index 9c1004b854..672b8b66e9 100644 --- a/auth/src/test/java/org/entcore/auth/UserAuthAccountTest.java +++ b/auth/src/test/java/org/entcore/auth/UserAuthAccountTest.java @@ -1,5 +1,7 @@ package org.entcore.auth; +import java.util.HashMap; + import org.entcore.auth.users.DefaultUserAuthAccount; import org.entcore.auth.users.UserAuthAccount; import org.entcore.common.events.EventStore; @@ -32,7 +34,7 @@ public class UserAuthAccountTest { public static void setUp(TestContext context) throws Exception { EventStoreFactory.getFactory().setVertx(test.vertx()); eStore = EventStoreFactory.getFactory().getEventStore(Auth.class.getSimpleName()); - authAccount = new DefaultUserAuthAccount(test.vertx(), authAccountConfig, eStore); + authAccount = new DefaultUserAuthAccount(test.vertx(), authAccountConfig, eStore, new HashMap<>()); test.database().initNeo4j(context, neo4jContainer); } diff --git a/broker-parent/.gitignore b/broker-parent/.gitignore new file mode 100644 index 0000000000..b000391a05 --- /dev/null +++ b/broker-parent/.gitignore @@ -0,0 +1,2 @@ +broker-client/nest +broker-client/quarkus \ No newline at end of file diff --git a/broker-parent/broker-client/node/.gitignore b/broker-parent/broker-client/node/.gitignore new file mode 100644 index 0000000000..46a13b1ad0 --- /dev/null +++ b/broker-parent/broker-client/node/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.pnpm-store +.DS_Store +.env +.idea +.vscode \ No newline at end of file diff --git a/broker-parent/broker-client/node/.npmrc b/broker-parent/broker-client/node/.npmrc new file mode 100644 index 0000000000..c19217d167 --- /dev/null +++ b/broker-parent/broker-client/node/.npmrc @@ -0,0 +1,3 @@ +@edifice.io:registry=https://registry.npmjs.org/ +//registry.npmjs.org/:_authToken=${NPM_TOKEN} +auto-install-peers=true \ No newline at end of file diff --git a/broker-parent/broker-client/node/package.json b/broker-parent/broker-client/node/package.json new file mode 100644 index 0000000000..5a854d48d3 --- /dev/null +++ b/broker-parent/broker-client/node/package.json @@ -0,0 +1,55 @@ +{ + "name": "@edifice.io/edifice-ent-client-node", + "version": "6.10.0-zookeeper.1764714139056", + "description": "Clients to interact with ent-nats-service", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "build:types": "tsc --build", + "preview": "vite preview", + "prepare": "husky", + "clean": "rm -Rf dist", + "format": "pnpm run format:write && pnpm run format:check", + "format:check": "npx prettier --check \"src/**/*.ts\"", + "format:write": "npx prettier --write \"src/**/*.ts\"", + "pre-commit": "pnpm run format" + }, + "keywords": [], + "author": "Edifice", + "license": "AGPL-3.0", + "packageManager": "pnpm@8.6.6", + "engines": { + "node": "22" + }, + "engineStrict": true, + "type": "module", + "exports": { + ".": { + "require": "./dist/index.umd.cjs", + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "dist/index.umd.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "dependencies": { + "@nats-io/transport-node": "3.2.0", + "@nats-io/nats-core": "3.2.0" + }, + "devDependencies": { + "husky": "^9.0.11", + "prettier": "^3.2.5", + "semantic-release": "^23.0.2", + "typescript": "^5.2.2", + "vite": "^5.1.4", + "vite-plugin-dts": "^4.3.0", + "rxjs": "^7.8.1" + }, + "lint-staged": { + "*.{js,ts,jsx,tsx,json,css,md}": "pnpm format" + } +} diff --git a/broker-parent/broker-client/node/prettier.config.cjs b/broker-parent/broker-client/node/prettier.config.cjs new file mode 100644 index 0000000000..042a2533b6 --- /dev/null +++ b/broker-parent/broker-client/node/prettier.config.cjs @@ -0,0 +1,9 @@ +module.exports = { + "tabWidth": 2, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 80, + "useTabs": false, + "endOfLine": "auto" + } \ No newline at end of file diff --git a/broker-parent/broker-client/node/src/index.ts b/broker-parent/broker-client/node/src/index.ts new file mode 100644 index 0000000000..11597434d4 --- /dev/null +++ b/broker-parent/broker-client/node/src/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright © "Edifice" + * + * This program is published by "Edifice". + * You must indicate the name of the software and the company in any production /contribution + * using the software and indicate on the home page of the software industry in question, + * "powered by Edifice" with a reference to the website: https://edifice.io/. + * + * This program is free software, licensed under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, version 3 of the License. + * + * You can redistribute this application and/or modify it since you respect the terms of the GNU Affero General Public License. + * If you modify the source code and then use this modified source code in your creation, you must make available the source code of your modifications. + * + * You should have received a copy of the GNU Affero General Public License along with the software. + * If not, please see : . Full compliance requires reading the terms of this license and following its directives. + */ + +export * from "./nats/types"; +export * from "./nats/client"; \ No newline at end of file diff --git a/broker-parent/broker-client/node/src/nats/client.ts b/broker-parent/broker-client/node/src/nats/client.ts new file mode 100644 index 0000000000..0da9c026f6 --- /dev/null +++ b/broker-parent/broker-client/node/src/nats/client.ts @@ -0,0 +1,501 @@ +/* + * Copyright © "Edifice" + * + * This program is published by "Edifice". + * You must indicate the name of the software and the company in any production /contribution + * using the software and indicate on the home page of the software industry in question, + * "powered by Edifice" with a reference to the website: https://edifice.io/. + * + * This program is free software, licensed under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, version 3 of the License. + * + * You can redistribute this application and/or modify it since you respect the terms of the GNU Affero General Public License. + * If you modify the source code and then use this modified source code in your creation, you must make available the source code of your modifications. + * + * You should have received a copy of the GNU Affero General Public License along with the software. + * If not, please see : . Full compliance requires reading the terms of this license and following its directives. + */ + +import type { NatsConnection, Msg } from "@nats-io/nats-core"; + +import type { AddCommunicationLinksRequestDTO } from './types'; + +import type { AddCommunicationLinksResponseDTO } from './types'; + +import type { AddGroupMemberRequestDTO } from './types'; + +import type { AddGroupMemberResponseDTO } from './types'; + +import type { AddLinkBetweenGroupsRequestDTO } from './types'; + +import type { AddLinkBetweenGroupsResponseDTO } from './types'; + +import type { AppRegistrationRequestDTO } from './types'; + +import type { AppRegistrationResponseDTO } from './types'; + +import type { CheckResourceAccessRequestDTO } from './types'; + +import type { CheckResourceAccessResponseDTO } from './types'; + +import type { CreateEventRequestDTO } from './types'; + +import type { CreateEventResponseDTO } from './types'; + +import type { CreateGroupRequestDTO } from './types'; + +import type { CreateGroupResponseDTO } from './types'; + +import type { DeleteGroupRequestDTO } from './types'; + +import type { DeleteGroupResponseDTO } from './types'; + +import type { DummyResponseDTO } from './types'; + +import type { FetchTranslationsRequestDTO } from './types'; + +import type { FetchTranslationsResponseDTO } from './types'; + +import type { FindGroupByExternalIdRequestDTO } from './types'; + +import type { FindGroupByExternalIdResponseDTO } from './types'; + +import type { FindSessionRequestDTO } from './types'; + +import type { FindSessionResponseDTO } from './types'; + +import type { GetResourcesRequestDTO } from './types'; + +import type { GetResourcesResponseDTO } from './types'; + +import type { GetUserDisplayNamesRequestDTO } from './types'; + +import type { GetUserDisplayNamesResponseDTO } from './types'; + +import type { GetUsersByIdsRequestDTO } from './types'; + +import type { GetUsersByIdsResponseDTO } from './types'; + +import type { ListenAndAnswerDTO } from './types'; + +import type { ListenOnlyDTO } from './types'; + +import type { LoadTestRequestDTO } from './types'; + +import type { LoadTestResponseDTO } from './types'; + +import type { RecreateCommunicationLinksRequestDTO } from './types'; + +import type { RecreateCommunicationLinksResponseDTO } from './types'; + +import type { RefreshSessionRequestDTO } from './types'; + +import type { RefreshSessionResponseDTO } from './types'; + +import type { RegisterNotificationBatchRequestDTO } from './types'; + +import type { RegisterNotificationRequestDTO } from './types'; + +import type { RegisterNotificationResponseDTO } from './types'; + +import type { RegisterTranslationFilesRequestDTO } from './types'; + +import type { RegisterTranslationFilesResponseDTO } from './types'; + +import type { RemoveCommunicationLinksRequestDTO } from './types'; + +import type { RemoveCommunicationLinksResponseDTO } from './types'; + +import type { RemoveGroupMemberRequestDTO } from './types'; + +import type { RemoveGroupMemberResponseDTO } from './types'; + +import type { RemoveGroupSharesRequestDTO } from './types'; + +import type { RemoveGroupSharesResponseDTO } from './types'; + +import type { SendNotificationRequestDTO } from './types'; + +import type { SendNotificationResponseDTO } from './types'; + +import type { UpdateGroupRequestDTO } from './types'; + +import type { UpdateGroupResponseDTO } from './types'; + +import type { UpsertGroupSharesRequestDTO } from './types'; + +import type { UpsertGroupSharesResponseDTO } from './types'; + + +export class EntNatsServiceClient { + + constructor(private readonly natsConnection: NatsConnection) { + console.log('Creating service EntNatsServiceClient') + } + + + + async addCommunicationLinks(request: AddCommunicationLinksRequestDTO): Promise { + const eventAddress = "communication.link.users.add"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as AddCommunicationLinksResponseDTO; + } + + + + async addGroupMember(request: AddGroupMemberRequestDTO): Promise { + const eventAddress = "directory.group.member.add"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as AddGroupMemberResponseDTO; + } + + + + async addLinkBetweenGroups(request: AddLinkBetweenGroupsRequestDTO): Promise { + const eventAddress = "communication.link.groups.add"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as AddLinkBetweenGroupsResponseDTO; + } + + + + async checkResourceAccess(request: CheckResourceAccessRequestDTO, module: string, resourceType: string): Promise { + const eventAddress = "audience.check.right." + module + "." + resourceType + ""; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as CheckResourceAccessResponseDTO; + } + + + + async createAndStoreEvent(request: CreateEventRequestDTO): Promise { + const eventAddress = "event.store.create"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as CreateEventResponseDTO; + } + + + + async createManualGroup(request: CreateGroupRequestDTO): Promise { + const eventAddress = "directory.group.manual.create"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as CreateGroupResponseDTO; + } + + + + async deleteManualGroup(request: DeleteGroupRequestDTO): Promise { + const eventAddress = "directory.group.manual.delete"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as DeleteGroupResponseDTO; + } + + + + async fetchTranslations(request: FetchTranslationsRequestDTO, application: string): Promise { + const eventAddress = "i18n." + application + ".fetch"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as FetchTranslationsResponseDTO; + } + + + + async findGroupByExternalId(request: FindGroupByExternalIdRequestDTO): Promise { + const eventAddress = "directory.group.find.byexternalid"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as FindGroupByExternalIdResponseDTO; + } + + + + async findSession(request: FindSessionRequestDTO): Promise { + const eventAddress = "session.find"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as FindSessionResponseDTO; + } + + + + async getResources(request: GetResourcesRequestDTO, application: string): Promise { + const eventAddress = "resource.get." + application + ""; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as GetResourcesResponseDTO; + } + + + + async getUserDisplayNames(request: GetUserDisplayNamesRequestDTO): Promise { + const eventAddress = "directory.users.get.displaynames"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as GetUserDisplayNamesResponseDTO; + } + + + + async getUsersByIds(request: GetUsersByIdsRequestDTO): Promise { + const eventAddress = "directory.users.get.byids"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as GetUsersByIdsResponseDTO; + } + + + + async listenAndReplyExample(request: ListenAndAnswerDTO): Promise { + const eventAddress = "ent.test.listen.reply"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as DummyResponseDTO; + } + + + + async listenOnlyExample(request: ListenOnlyDTO) { + const eventAddress = "ent.test.listen"; + console.debug("Publishing to NATS subject", {messageAddress: eventAddress}); + this.natsConnection.publish(eventAddress, JSON.stringify(request)); + } + + + + async loadTest(request: LoadTestRequestDTO): Promise { + const eventAddress = "ent.loadtest"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as LoadTestResponseDTO; + } + + + + async recreateCommunicationLinks(request: RecreateCommunicationLinksRequestDTO): Promise { + const eventAddress = "communication.link.users.recreate"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as RecreateCommunicationLinksResponseDTO; + } + + + + async refreshSession(request: RefreshSessionRequestDTO): Promise { + const eventAddress = "session.refresh"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as RefreshSessionResponseDTO; + } + + + + async registerApp(request: AppRegistrationRequestDTO): Promise { + const eventAddress = "ent.appregistry.app.register"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as AppRegistrationResponseDTO; + } + + + + async registerI18nFiles(request: RegisterTranslationFilesRequestDTO, application: string): Promise { + const eventAddress = "i18n." + application + ".register"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as RegisterTranslationFilesResponseDTO; + } + + + + async registerNotification(request: RegisterNotificationRequestDTO): Promise { + const eventAddress = "timeline.notification.register"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as RegisterNotificationResponseDTO; + } + + + + async registerNotifications(request: RegisterNotificationBatchRequestDTO): Promise { + const eventAddress = "timeline.notification.register.batch"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as RegisterNotificationResponseDTO; + } + + + + async removeCommunicationLinks(request: RemoveCommunicationLinksRequestDTO): Promise { + const eventAddress = "communication.link.users.remove"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as RemoveCommunicationLinksResponseDTO; + } + + + + async removeGroupMember(request: RemoveGroupMemberRequestDTO): Promise { + const eventAddress = "directory.group.member.delete"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as RemoveGroupMemberResponseDTO; + } + + + + async removeGroupShares(request: RemoveGroupSharesRequestDTO, application: string): Promise { + const eventAddress = "share.group.remove." + application + ""; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as RemoveGroupSharesResponseDTO; + } + + + + async sendNotification(request: SendNotificationRequestDTO): Promise { + const eventAddress = "timeline.notification.send"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as SendNotificationResponseDTO; + } + + + + async updateManualGroup(request: UpdateGroupRequestDTO): Promise { + const eventAddress = "directory.group.manual.update"; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as UpdateGroupResponseDTO; + } + + + + async upsertGroupShares(request: UpsertGroupSharesRequestDTO, application: string): Promise { + const eventAddress = "share.group.upsert." + application + ""; + console.debug("Sending request to NATS subject", {messageAddress: eventAddress}); + const reply: Msg = await this.natsConnection.request(eventAddress, JSON.stringify(request)); + if(!reply) { + console.warn("No reply received for subject", {messageAddress: eventAddress}); + throw new Error('No reply received'); + } + return this.extractResponse(reply.json()) as UpsertGroupSharesResponseDTO; + } + + + + private extractResponse(replyData: any): any { + return replyData.response || replyData; + } + +} diff --git a/broker-parent/broker-client/node/src/nats/types.ts b/broker-parent/broker-client/node/src/nats/types.ts new file mode 100644 index 0000000000..515547b970 --- /dev/null +++ b/broker-parent/broker-client/node/src/nats/types.ts @@ -0,0 +1,446 @@ +export interface ActionDto { + displayName?: string; + name?: string; + type?: string; +} + +export interface AddCommunicationLinksRequestDTO { + direction?: string; + groupId?: string; +} + +export interface AddCommunicationLinksResponseDTO { + added?: boolean; +} + +export interface AddGroupMemberRequestDTO { + groupExternalId?: string; + groupId?: string; + userId?: string; +} + +export interface AddGroupMemberResponseDTO { + added?: boolean; +} + +export interface AddLinkBetweenGroupsRequestDTO { + endGroupId?: string; + startGroupId?: string; +} + +export interface AddLinkBetweenGroupsResponseDTO { + created?: boolean; +} + +export interface AppRegistrationDTO { + address?: string; + appType?: string; + customProperties?: { [key: string]: object }; + display?: boolean; + displayName?: string; + icon?: string; + name?: string; + prefix?: string; +} + +export interface AppRegistrationRequestDTO { + actions?: SecuredActionDTO[]; + application?: AppRegistrationDTO; +} + +export interface AppRegistrationResponseDTO { + message?: string; + success?: boolean; +} + +export interface BaseCalendar { + ACCUMULATED_DAYS_IN_MONTH?: number[]; + ACCUMULATED_DAYS_IN_MONTH_LEAP?: number[]; + APRIL?: number; + AUGUST?: number; + BASE_YEAR?: number; + DAYS_IN_MONTH?: number[]; + DECEMBER?: number; + FEBRUARY?: number; + FIXED_DATES?: number[]; + FRIDAY?: number; + JANUARY?: number; + JULY?: number; + JUNE?: number; + MARCH?: number; + MAY?: number; + MONDAY?: number; + NOVEMBER?: number; + OCTOBER?: number; + SATURDAY?: number; + SEPTEMBER?: number; + SUNDAY?: number; + THURSDAY?: number; + TUESDAY?: number; + WEDNESDAY?: number; +} + +export interface CheckResourceAccessRequestDTO { + groupIds?: string[]; + module?: string; + resourceIds?: string[]; + resourceType?: string; + userId?: string; +} + +export interface CheckResourceAccessResponseDTO { + access?: boolean; + errorMsg?: string; + success?: boolean; +} + +export interface ClassDto { + id?: string; + name?: string; +} + +export interface CreateEventRequestDTO { + clientId?: string; + customAttributes?: JsonObject; + eventType?: string; + headers?: { [key: string]: string }; + ip?: string; + login?: string; + module?: string; + path?: string; + userAgent?: string; + userId?: string; +} + +export interface CreateEventResponseDTO { + eventId?: string; +} + +export interface CreateGroupRequestDTO { + classId?: string; + externalId?: string; + labels?: string[]; + name?: string; + structureId?: string; +} + +export interface CreateGroupResponseDTO { + id?: string; +} + +export interface Date { + cdate?: Date; + defaultCenturyStart?: number; + fastTime?: number; + gcal?: BaseCalendar; + jcal?: BaseCalendar; + serialVersionUID?: number; + ttb?: number[]; + wtb?: string[]; +} + +export interface DeleteGroupRequestDTO { + externalId?: string; + id?: string; +} + +export interface DeleteGroupResponseDTO { + deleted?: boolean; +} + +export interface DummyResponseDTO { + jobId?: string; + success?: boolean; + userId?: string; +} + +export interface FetchTranslationsRequestDTO { + application?: string; + headers?: { [key: string]: string }; + langAndDomain?: LangAndDomain; +} + +export interface FetchTranslationsResponseDTO { + translations?: { [key: string]: string }; +} + +export interface FindGroupByExternalIdRequestDTO { + externalId?: string; +} + +export interface FindGroupByExternalIdResponseDTO { + group?: GroupDTO; +} + +export interface FindSessionRequestDTO { + cookies?: string; + headers?: { [key: string]: string }; + params?: { [key: string]: string }; + path?: string; + pathPrefix?: string; + sessionId?: string; +} + +export interface FindSessionResponseDTO { + session?: SessionDto; +} + +export interface GetResourcesRequestDTO { + resourceIds?: string[]; +} + +export interface GetResourcesResponseDTO { + resources?: ResourceInfoDTO[]; +} + +export interface GetUserDisplayNamesRequestDTO { + userIds?: string[]; +} + +export interface GetUserDisplayNamesResponseDTO { + userDisplayNames?: { [key: string]: string }; +} + +export interface GetUsersByIdsRequestDTO { + userIds?: string[]; +} + +export interface GetUsersByIdsResponseDTO { + users?: UserDTO[]; +} + +export interface GroupDTO { + id?: string; + name?: string; +} + +export interface GroupDto { + id?: string; + name?: string; +} + +export interface JsonObject { + map?: { [key: string]: object }; +} + +export interface LangAndDomain { + domain?: string; + lang?: string; +} + +export interface ListenAndAnswerDTO { + jobId?: string; + userId?: string; +} + +export interface ListenOnlyDTO { + timestamp?: number; + userId?: string; +} + +export interface LoadTestRequestDTO { + delay?: number; + payload?: string; + responseSize?: number; +} + +export interface LoadTestResponseDTO { + elapsedTime?: number; + payload?: string; + startProcessingAt?: number; + startWaitingAt?: number; +} + +export interface NotificationPreviewDTO { + images?: string[]; + text?: string; +} + +export interface PushNotifParamsDTO { + body?: string; + title?: string; +} + +export interface RecreateCommunicationLinksRequestDTO { + direction?: string; + groupId?: string; +} + +export interface RecreateCommunicationLinksResponseDTO { + recreated?: boolean; +} + +export interface RefreshSessionRequestDTO { + refreshOnly?: boolean; + sessionId?: string; + userId?: string; +} + +export interface RefreshSessionResponseDTO { + sessionId?: string; +} + +export interface RegisterNotificationBatchRequestDTO { + notifications?: RegisterNotificationRequestDTO[]; +} + +export interface RegisterNotificationRequestDTO { + appAddress?: string; + defaultFrequency?: 'NEVER' | 'IMMEDIATE' | 'DAILY' | 'WEEKLY'; + eventType?: string; + pushNotif?: boolean; + restriction?: 'INTERNAL' | 'EXTERNAL' | 'NONE' | 'HIDDEN'; + template?: string; + type?: string; +} + +export interface RegisterNotificationResponseDTO { + count?: number; +} + +export interface RegisterTranslationFilesRequestDTO { + application?: string; + translationsByLanguage?: { [key: string]: { [key: string]: string } }; +} + +export interface RegisterTranslationFilesResponseDTO { + application?: string; + languagesCount?: number; + translationsCount?: number; +} + +export interface RemoveCommunicationLinksRequestDTO { + direction?: string; + groupId?: string; +} + +export interface RemoveCommunicationLinksResponseDTO { + removed?: boolean; +} + +export interface RemoveGroupMemberRequestDTO { + groupExternalId?: string; + groupId?: string; + userId?: string; +} + +export interface RemoveGroupMemberResponseDTO { + removed?: boolean; +} + +export interface RemoveGroupSharesRequestDTO { + application?: string; + currentUserId?: string; + groupExternalId?: string; + groupId?: string; + resourceId?: string; +} + +export interface RemoveGroupSharesResponseDTO { + shares?: SharesResponseDTO[]; +} + +export interface ResourceInfoDTO { + authorId?: string; + authorName?: string; + creationDate?: Date; + description?: string; + id?: string; + modificationDate?: Date; + thumbnail?: string; + title?: string; +} + +export interface SecuredActionDTO { + displayName?: string; + name?: string; + type?: string; +} + +export interface SendNotificationRequestDTO { + disableAntiFlood?: boolean; + disableMailNotification?: boolean; + headers?: { [key: string]: string }; + notificationName?: string; + params?: { [key: string]: string }; + preview?: NotificationPreviewDTO; + publishDate?: number; + pushNotif?: PushNotifParamsDTO; + recipientIds?: string[]; + resourceId?: string; + resourceName?: string; + resourceUri?: string; + senderId?: string; + senderName?: string; + subResourceId?: string; +} + +export interface SendNotificationResponseDTO { + recipientCount?: number; +} + +export interface SessionDto { + authorizedActions?: ActionDto[]; + birthDate?: string; + classes?: ClassDto[]; + email?: string; + externalId?: string; + firstName?: string; + functions?: UserFunctionDto[]; + groups?: GroupDto[]; + lastName?: string; + level?: string; + login?: string; + mobile?: string; + sessionId?: string; + structures?: StructureDto[]; + superAdmin?: boolean; + type?: string; + userId?: string; + username?: string; +} + +export interface SharesResponseDTO { + id?: string; + kind?: 'Group' | 'User'; + permissions?: string[]; +} + +export interface StructureDto { + id?: string; + name?: string; +} + +export interface UpdateGroupRequestDTO { + externalId?: string; + id?: string; + name?: string; +} + +export interface UpdateGroupResponseDTO { + updated?: boolean; +} + +export interface UpsertGroupSharesRequestDTO { + application?: string; + currentUserId?: string; + groupId?: string; + permissions?: string[]; + resourceId?: string; +} + +export interface UpsertGroupSharesResponseDTO { + shares?: SharesResponseDTO[]; +} + +export interface UserDTO { + displayName?: string; + functions?: { [key: string]: string[] }; + id?: string; + profile?: string; +} + +export interface UserFunctionDto { + code?: string; + scope?: string[]; +} \ No newline at end of file diff --git a/broker-parent/broker-client/node/tsconfig.json b/broker-parent/broker-client/node/tsconfig.json new file mode 100644 index 0000000000..9609244471 --- /dev/null +++ b/broker-parent/broker-client/node/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2023", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "outDir": "dist/src", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/broker-parent/broker-client/node/vite.config.ts b/broker-parent/broker-client/node/vite.config.ts new file mode 100644 index 0000000000..cb743e3ffd --- /dev/null +++ b/broker-parent/broker-client/node/vite.config.ts @@ -0,0 +1,58 @@ +/* + * Copyright © "Edifice" + * + * This program is published by "Edifice". + * You must indicate the name of the software and the company in any production /contribution + * using the software and indicate on the home page of the software industry in question, + * "powered by Edifice" with a reference to the website: https://edifice.io/. + * + * This program is free software, licensed under the terms of the GNU Affero General Public License + * as published by the Free Software Foundation, version 3 of the License. + * + * You can redistribute this application and/or modify it since you respect the terms of the GNU Affero General Public License. + * If you modify the source code and then use this modified source code in your creation, you must make available the source code of your modifications. + * + * You should have received a copy of the GNU Affero General Public License along with the software. + * If not, please see : . Full compliance requires reading the terms of this license and following its directives. + */ + +// vite.config.ts +import { resolve } from 'path' +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: '@edifice.io/edifice-ent-client-node', + fileName: 'index', + }, + rollupOptions: { + // theses are external dependencies that should not be bundled + external: [ + '@nestjs/common', + '@nestjs/microservices', + 'rxjs', + 'util', + 'node:util' + ], + output: { + // Provide global variables to use in the UMD build + globals: { + '@nestjs/common': 'NestjsCommon', + '@nestjs/microservices': 'NestjsMicroservices', + 'rxjs': 'rxjs', + 'util': 'util', + 'node:util': 'util' + } + } + } + }, + plugins: [ + dts({ + insertTypesEntry: true, // Ensures a types entry is added to package.json + outDir: 'dist', // Directory for type definitions + }), + ], +}) \ No newline at end of file diff --git a/build.sh b/build.sh index 9edaf0c8df..f632900ff0 100755 --- a/build.sh +++ b/build.sh @@ -385,6 +385,9 @@ do buildBackend) buildBackend ;; + buildMvn) + install + ;; install) buildFrontend && buildBackend && buildBroker ;; diff --git a/cas/pom.xml b/cas/pom.xml index ca2befa0e3..64732b98b0 100644 --- a/cas/pom.xml +++ b/cas/pom.xml @@ -31,5 +31,12 @@ ${revision} test + + io.vertx + mod-mongo-persistor + 4.1-zookeeper-SNAPSHOT + runtime + fat + \ No newline at end of file diff --git a/cas/src/main/java/org/entcore/cas/Cas.java b/cas/src/main/java/org/entcore/cas/Cas.java index 03784de049..17ceb3231c 100644 --- a/cas/src/main/java/org/entcore/cas/Cas.java +++ b/cas/src/main/java/org/entcore/cas/Cas.java @@ -20,6 +20,7 @@ package org.entcore.cas; import fr.wseduc.cas.endpoint.CredentialResponse; +import io.vertx.core.Future; import io.vertx.core.Promise; import org.entcore.cas.controllers.*; import org.entcore.cas.data.EntCoreDataHandlerFactory; @@ -39,7 +40,12 @@ public class Cas extends BaseServer { @Override public void start(final Promise startPromise) throws Exception { - super.start(startPromise); + final Promise promise = Promise.promise(); + super.start(promise); + promise.future().compose(init -> initCas()).onComplete(startPromise); + } + + public Future initCas(){ MappingService.getInstance().configure(config()); EntCoreDataHandlerFactory dataHandlerFactory = new EntCoreDataHandlerFactory(getEventBus(vertx), config); @@ -82,7 +88,7 @@ public void handle(Long event) { configurationController.loadPatterns(); } }); - + return Future.succeededFuture(); } } diff --git a/cas/src/main/java/org/entcore/cas/data/EntCoreDataHandler.java b/cas/src/main/java/org/entcore/cas/data/EntCoreDataHandler.java index 989db3caec..248e0bbb3a 100644 --- a/cas/src/main/java/org/entcore/cas/data/EntCoreDataHandler.java +++ b/cas/src/main/java/org/entcore/cas/data/EntCoreDataHandler.java @@ -54,7 +54,6 @@ import fr.wseduc.mongodb.MongoDb; import static com.mongodb.client.model.Filters.eq; -import static fr.wseduc.webutils.Utils.getOrElse; public class EntCoreDataHandler extends DataHandler { diff --git a/common/pom.xml b/common/pom.xml index 70cadccc9c..ffca074e20 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -85,6 +85,13 @@ ${project.version} compile + + com.opendigitaleducation + mod-json-schema-validator + 2.0-zookeeper-SNAPSHOT + runtime + fat + org.mockito mockito-inline diff --git a/common/src/main/java/org/entcore/common/appregistry/AppRegistryEventsHandler.java b/common/src/main/java/org/entcore/common/appregistry/AppRegistryEventsHandler.java index e9d008a94c..fdf338769b 100644 --- a/common/src/main/java/org/entcore/common/appregistry/AppRegistryEventsHandler.java +++ b/common/src/main/java/org/entcore/common/appregistry/AppRegistryEventsHandler.java @@ -32,7 +32,7 @@ public final class AppRegistryEventsHandler implements Handler create(Vertx vertx, JsonObject config){ if(Redis.getClient() != null){ final Integer db = config.getInteger("redis-db"); if(db != null){ - return new RedisCacheService(Redis.createClientForDb(vertx, db).getClient()); + return Redis.createClientForDb(vertx, db).map(redisApi -> new RedisCacheService(redisApi.getClient())); }else{ - return new RedisCacheService(Redis.getClient().getClient()); + return succeededFuture(new RedisCacheService(Redis.getClient().getClient())); } } else{ throw new IllegalStateException("CacheService.create : could not create cache because it is not initialized"); diff --git a/common/src/main/java/org/entcore/common/datavalidation/impl/DefaultUserValidationService.java b/common/src/main/java/org/entcore/common/datavalidation/impl/DefaultUserValidationService.java index e5f92a3ad1..cd8ce91c6e 100644 --- a/common/src/main/java/org/entcore/common/datavalidation/impl/DefaultUserValidationService.java +++ b/common/src/main/java/org/entcore/common/datavalidation/impl/DefaultUserValidationService.java @@ -15,6 +15,7 @@ import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; import org.entcore.common.datavalidation.EmailValidation; +import org.entcore.common.datavalidation.MobileValidation; import org.entcore.common.datavalidation.UserValidationService; import org.entcore.common.datavalidation.metrics.DataValidationMetricsFactory; import org.entcore.common.datavalidation.utils.DataStateUtils; @@ -54,7 +55,7 @@ private class MobileField extends AbstractDataValidationService { MobileField(io.vertx.core.Vertx vertx, io.vertx.core.json.JsonObject config, io.vertx.core.json.JsonObject params) { super("mobile", "mobileState", vertx, config, params); - emailSender = new EmailFactory(this.vertx, config).getSenderWithPriority(EmailFactory.PRIORITY_HIGH); + emailSender = EmailFactory.getInstance().getSenderWithPriority(EmailFactory.PRIORITY_HIGH); } @Override @@ -132,7 +133,7 @@ private class EmailField extends AbstractDataValidationService { EmailField(io.vertx.core.Vertx vertx, io.vertx.core.json.JsonObject config, io.vertx.core.json.JsonObject params) { super("email", "emailState", vertx, config, params); - emailSender = new EmailFactory(this.vertx, config).getSenderWithPriority(EmailFactory.PRIORITY_HIGH); + emailSender = EmailFactory.getInstance().getSenderWithPriority(EmailFactory.PRIORITY_HIGH); } @Override diff --git a/common/src/main/java/org/entcore/common/datavalidation/metrics/DataValidationMetricsFactory.java b/common/src/main/java/org/entcore/common/datavalidation/metrics/DataValidationMetricsFactory.java index 65f7476e19..67cbdc6bae 100644 --- a/common/src/main/java/org/entcore/common/datavalidation/metrics/DataValidationMetricsFactory.java +++ b/common/src/main/java/org/entcore/common/datavalidation/metrics/DataValidationMetricsFactory.java @@ -2,6 +2,8 @@ import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; import io.vertx.core.metrics.MetricsOptions; import io.vertx.micrometer.MicrometerMetricsOptions; @@ -14,6 +16,9 @@ * configured then it creates a dummy recorder that records nothing. */ public class DataValidationMetricsFactory { + + private static final Logger log = LoggerFactory.getLogger(DataValidationMetricsFactory.class); + private static MetricsOptions metricsOptions; private static DataValidationMetricsRecorder metricsRecorder; private static JsonObject config; @@ -21,12 +26,19 @@ public class DataValidationMetricsFactory { public static void init(final Vertx vertx, final JsonObject config){ DataValidationMetricsFactory.config = config; if(config.getJsonObject("metricsOptions") == null) { - final String metricsOptions = (String) vertx.sharedData().getLocalMap("server").get("metricsOptions"); - if(metricsOptions == null){ + vertx.sharedData().getLocalAsyncMap("server") + .compose(serverMap -> serverMap.get("metricsOptions")) + .onSuccess(metricsOptions -> { + if(metricsOptions == null){ + DataValidationMetricsFactory.metricsOptions = new MetricsOptions().setEnabled(false); + }else{ + DataValidationMetricsFactory.metricsOptions = new MetricsOptions(new JsonObject(metricsOptions)); + } + }) + .onFailure(ex -> { + log.error("Error get metricsOptions in server map.", ex); DataValidationMetricsFactory.metricsOptions = new MetricsOptions().setEnabled(false); - }else{ - DataValidationMetricsFactory.metricsOptions = new MetricsOptions(new JsonObject(metricsOptions)); - } + }); } else { metricsOptions = new MetricsOptions(config.getJsonObject("metricsOptions")); } diff --git a/common/src/main/java/org/entcore/common/datavalidation/utils/UserValidationFactory.java b/common/src/main/java/org/entcore/common/datavalidation/utils/UserValidationFactory.java index 7f527f2e58..cd32d682eb 100644 --- a/common/src/main/java/org/entcore/common/datavalidation/utils/UserValidationFactory.java +++ b/common/src/main/java/org/entcore/common/datavalidation/utils/UserValidationFactory.java @@ -24,13 +24,19 @@ import org.entcore.common.datavalidation.metrics.DataValidationMetricsFactory; import org.entcore.common.events.EventStore; +import fr.wseduc.webutils.collections.SharedDataHelper; +import io.vertx.core.Future; +import io.vertx.core.Promise; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; -import io.vertx.core.shareddata.LocalMap; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; import java.security.InvalidKeyException; public class UserValidationFactory { + + private static final Logger log = LoggerFactory.getLogger(UserValidationFactory.class); private Vertx vertx; private JsonObject config; private JsonObject moduleConfig; @@ -52,30 +58,53 @@ public static UserValidationFactory getFactory() { return UserValidationFactoryHolder.instance; } - public UserValidationFactory init(Vertx vertx, JsonObject moduleConfig) throws InvalidKeyException { + public UserValidationFactory init(Vertx vertx, JsonObject moduleConfig, Promise initPromise) { this.vertx = vertx; this.moduleConfig = moduleConfig; DataValidationMetricsFactory.init(vertx, moduleConfig); config = moduleConfig.getJsonObject("emailValidationConfig"); if (config == null ) { - LocalMap server = vertx.sharedData().getLocalMap("server"); - String s = (String) server.get("emailValidationConfig"); - config = (s != null) ? new JsonObject(s) : new JsonObject(); + final SharedDataHelper sharedDataHelper = SharedDataHelper.getInstance(); + sharedDataHelper.init(vertx); + sharedDataHelper.getLocal("server", "emailValidationConfig").onSuccess(s -> { + config = (s != null) ? new JsonObject(s) : new JsonObject(); + initInternal(initPromise); + }).onFailure(ex -> { + log.error("Error when init UserValidationFactory from async map server", ex); + initPromise.fail(ex); + }); + } else { + initInternal(initPromise); } + return this; + } - // The encryptKey parameter must be defined correctly. - String encryptKey = config.getString("encryptKey", null); - if( encryptKey != null - && (encryptKey.length()!=16 && encryptKey.length()!=24 && encryptKey.length()!=32) ) { - // An AES key has to be 16, 24 or 32 bytes long. - throw new InvalidKeyException("The \"encryptKey\" parameter must be 16, 24 or 32 bytes long."); + private void initInternal(Promise initPromise) { + try { + // The encryptKey parameter must be defined correctly. + String encryptKey = config.getString("encryptKey", null); + if( encryptKey != null + && (encryptKey.length()!=16 && encryptKey.length()!=24 && encryptKey.length()!=32) ) { + // An AES key has to be 16, 24 or 32 bytes long. + throw new InvalidKeyException("The \"encryptKey\" parameter must be 16, 24 or 32 bytes long."); + } + + final Boolean emailValidationActive = config.getBoolean("active", true); + final boolean emailValidationRelativeActive = config.getBoolean("emailValidationRelativeActive", false); + deactivateValidationAfterLogin = Boolean.FALSE.equals(emailValidationActive); + activateValidationRelative = Boolean.TRUE.equals(emailValidationRelativeActive); + initPromise.complete(this); + } catch (InvalidKeyException e) { + log.error("Error with key format when init UserValidationFactory", e); + initPromise.fail(e); } + } - final Boolean emailValidationActive = config.getBoolean("active", true); - final boolean emailValidationRelativeActive = config.getBoolean("emailValidationRelativeActive", false); - deactivateValidationAfterLogin = Boolean.FALSE.equals(emailValidationActive); - activateValidationRelative = Boolean.TRUE.equals(emailValidationRelativeActive); - return this; + public static Future build(Vertx vertx, JsonObject config) { + final Promise promise = Promise.promise(); + final UserValidationFactory userValidationFactory = getFactory(); + userValidationFactory.init(vertx, config, promise); + return promise.future(); } public UserValidationFactory setEventStore(EventStore eventStore, String eventType) { diff --git a/common/src/main/java/org/entcore/common/email/EmailFactory.java b/common/src/main/java/org/entcore/common/email/EmailFactory.java index 948a734dfb..696ce4b321 100644 --- a/common/src/main/java/org/entcore/common/email/EmailFactory.java +++ b/common/src/main/java/org/entcore/common/email/EmailFactory.java @@ -19,16 +19,19 @@ package org.entcore.common.email; +import fr.wseduc.webutils.collections.SharedDataHelper; import fr.wseduc.webutils.email.EmailSender; import fr.wseduc.webutils.email.SMTPSender; import fr.wseduc.webutils.email.SendInBlueSender; import fr.wseduc.webutils.email.GoMailSender; import fr.wseduc.webutils.exception.InvalidConfigurationException; +import io.vertx.core.Future; +import io.vertx.core.Promise; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; -import io.vertx.core.shareddata.LocalMap; + import org.entcore.common.email.impl.PostgresEmailSender; import java.net.URISyntaxException; @@ -39,33 +42,61 @@ public class EmailFactory { public static final int PRIORITY_NORMAL = 0; public static final int PRIORITY_HIGH = 1; public static final int PRIORITY_VERY_HIGH = 2; - private final Vertx vertx; - private final JsonObject config; - private final JsonObject moduleConfig; + private Vertx vertx; + private JsonObject config; + private JsonObject moduleConfig; private final Logger log = LoggerFactory.getLogger(EmailFactory.class); - public EmailFactory(Vertx vertx) { - this(vertx, null); + private static class EmailFactoryHolder { + private static final EmailFactory instance = new EmailFactory(); + } + + private EmailFactory() {} + + public void init(Vertx vertx, Promise initPromise) { + init(vertx, null, initPromise); } - public EmailFactory(Vertx vertx, JsonObject config) { + public void init(Vertx vertx, JsonObject config, Promise initPromise) { this.vertx = vertx; if (config != null && config.getJsonObject("emailConfig") != null) { this.config = config.getJsonObject("emailConfig"); this.moduleConfig = config; + initPromise.complete(this); } else { - LocalMap server = vertx.sharedData().getLocalMap("server"); - String s = (String) server.get("emailConfig"); - if (s != null) { - this.config = new JsonObject(s); - this.moduleConfig = this.config; - } else { - this.config = null; - this.moduleConfig = null; - } + final SharedDataHelper sharedDataHelper = SharedDataHelper.getInstance(); + sharedDataHelper.init(vertx); + sharedDataHelper.getLocal("server", "emailConfig").onSuccess(s -> { + if (s != null) { + this.config = new JsonObject(s); + this.moduleConfig = this.config; + } else { + this.config = null; + this.moduleConfig = null; + } + initPromise.complete(this); + }).onFailure(ex -> { + log.error("Error when init UserValidationFactory from async map server", ex); + initPromise.fail(ex); + }); } } + public static Future build(Vertx vertx) { + return build(vertx, null); + } + + public static Future build(Vertx vertx, JsonObject config) { + final Promise promise = Promise.promise(); + final EmailFactory emailFactory = getInstance(); + emailFactory.init(vertx, config, promise); + return promise.future(); + } + + public static EmailFactory getInstance() { + return EmailFactoryHolder.instance; + } + public EmailSender getSender() { return getSenderWithPriority(PRIORITY_NORMAL); } diff --git a/common/src/main/java/org/entcore/common/email/impl/PostgresEmailSender.java b/common/src/main/java/org/entcore/common/email/impl/PostgresEmailSender.java index 0fe92054d8..7a0c068f00 100644 --- a/common/src/main/java/org/entcore/common/email/impl/PostgresEmailSender.java +++ b/common/src/main/java/org/entcore/common/email/impl/PostgresEmailSender.java @@ -31,7 +31,7 @@ public class PostgresEmailSender implements EmailSender { private final Renders renders; private final EventBus eventBus; private final int maxSize; - private final String platformId; + private String platformId; private final PostgresEmailHelper helper; private final int priority; private final String senderEmail; @@ -45,13 +45,19 @@ public PostgresEmailSender(EmailSender aMailSender, Vertx vertx, JsonObject modu this.renders = new Renders(vertx, moduleConfig); maxSize = pgConfig.getInteger("max-size", -1); this.helper = PostgresEmailHelper.create(vertx, pgConfig); - final String eventStoreConf = (String) vertx.sharedData().getLocalMap("server").get("event-store"); - if (eventStoreConf != null) { - final JsonObject eventStoreConfig = new JsonObject(eventStoreConf); - platformId = eventStoreConfig.getString("platform"); - } else { - platformId = null; - } + + vertx.sharedData().getLocalAsyncMap("server") + .compose(serverMap -> serverMap.get("event-store")) + .onSuccess(eventStoreConf -> { + if (eventStoreConf != null) { + final JsonObject eventStoreConfig = new JsonObject(eventStoreConf); + platformId = eventStoreConfig.getString("platform"); + } else { + platformId = null; + } + }) + .onFailure(ex ->logger.error("Error when get platformId in event-store server map", ex)); + // final String defaultMail = emailConfig.getString("email", "noreply@one1d.fr"); final String defaultHost = emailConfig.getString("host", "http://localhost:8009"); diff --git a/common/src/main/java/org/entcore/common/events/EventStoreFactory.java b/common/src/main/java/org/entcore/common/events/EventStoreFactory.java index 928cf11f49..9d8721b2b5 100644 --- a/common/src/main/java/org/entcore/common/events/EventStoreFactory.java +++ b/common/src/main/java/org/entcore/common/events/EventStoreFactory.java @@ -30,7 +30,7 @@ import java.util.ServiceLoader; public abstract class EventStoreFactory { - static final Logger logger = LoggerFactory.getLogger(EventStoreFactory.class); + protected static final Logger logger = LoggerFactory.getLogger(EventStoreFactory.class); protected Vertx vertx; diff --git a/common/src/main/java/org/entcore/common/events/impl/MongoDbEventStoreFactory.java b/common/src/main/java/org/entcore/common/events/impl/MongoDbEventStoreFactory.java index 393a965abe..a6bea7cba0 100644 --- a/common/src/main/java/org/entcore/common/events/impl/MongoDbEventStoreFactory.java +++ b/common/src/main/java/org/entcore/common/events/impl/MongoDbEventStoreFactory.java @@ -33,18 +33,24 @@ public EventStore getEventStore(String module) { eventStore.setEventBus(Server.getEventBus(vertx)); eventStore.setModule(module); eventStore.setVertx(vertx); - final String eventStoreConf = (String) vertx.sharedData().getLocalMap("server").get("event-store"); - if (eventStoreConf != null) { - final JsonObject eventStoreConfig = new JsonObject(eventStoreConf); - if (eventStoreConfig.containsKey("postgresql")) { - final PostgresqlEventStore pgEventStore = new PostgresqlEventStore(); - pgEventStore.setEventBus(Server.getEventBus(vertx)); - pgEventStore.setModule(module); - pgEventStore.setVertx(vertx); - pgEventStore.init(); - eventStore.setPostgresqlEventStore(pgEventStore); - } - } + + vertx.sharedData().getLocalAsyncMap("server") + .compose(serverMap -> serverMap.get("event-store")) + .onSuccess(eventStoreConf -> { + if (eventStoreConf != null) { + final JsonObject eventStoreConfig = new JsonObject(eventStoreConf); + if (eventStoreConfig.containsKey("postgresql")) { + final PostgresqlEventStore pgEventStore = new PostgresqlEventStore(); + pgEventStore.setEventBus(Server.getEventBus(vertx)); + pgEventStore.setModule(module); + pgEventStore.setVertx(vertx); + pgEventStore.init(); + eventStore.setPostgresqlEventStore(pgEventStore); + } + } + }) + .onFailure(ex -> logger.error("Error when set pg event-store", ex)); + return eventStore; } diff --git a/common/src/main/java/org/entcore/common/events/impl/PostgresqlEventStore.java b/common/src/main/java/org/entcore/common/events/impl/PostgresqlEventStore.java index 7232ec6af4..5114d5db32 100644 --- a/common/src/main/java/org/entcore/common/events/impl/PostgresqlEventStore.java +++ b/common/src/main/java/org/entcore/common/events/impl/PostgresqlEventStore.java @@ -81,57 +81,64 @@ public void init(boolean reinit, Handler> handler) { handler.handle(Future.succeededFuture()); return; } - final String eventStoreConf = (String) vertx.sharedData().getLocalMap("server").get("event-store"); - if (eventStoreConf != null) { - final JsonObject eventStoreConfig = new JsonObject(eventStoreConf); - platform = eventStoreConfig.getString("platform"); - final JsonObject eventStorePGConfig = eventStoreConfig.getJsonObject("postgresql"); - if (eventStorePGConfig != null) { - final SslMode sslMode = SslMode.valueOf(eventStorePGConfig.getString("ssl-mode", "DISABLE")); - final PgConnectOptions options = new PgConnectOptions() - .setPort(eventStorePGConfig.getInteger("port", 5432)) - .setHost(eventStorePGConfig.getString("host")) - .setDatabase(eventStorePGConfig.getString("database")) - .setUser(eventStorePGConfig.getString("user")) - .setPassword(eventStorePGConfig.getString("password")) - .setIdleTimeout(eventStorePGConfig.getInteger("idle-timeout", 300)); // unit seconds - final PoolOptions poolOptions = new PoolOptions() - .setMaxSize(eventStorePGConfig.getInteger("pool-size", 5)); - if (!SslMode.DISABLE.equals(sslMode)) { - options - .setSslMode(sslMode) - .setTrustAll(SslMode.ALLOW.equals(sslMode) || SslMode.PREFER.equals(sslMode) || SslMode.REQUIRE.equals(sslMode)); - } - if (reinit && pgClient != null) { - pgClient.close(); - } - pgClient = PgPool.pool(vertx, options, poolOptions); - countNoAckReceive.set(0); + vertx.sharedData().getLocalAsyncMap("server") + .compose(serverMap -> serverMap.get("event-store")) + .onSuccess(eventStoreConf -> { + if (eventStoreConf != null) { + final JsonObject eventStoreConfig = new JsonObject(eventStoreConf); + platform = eventStoreConfig.getString("platform"); + final JsonObject eventStorePGConfig = eventStoreConfig.getJsonObject("postgresql"); + if (eventStorePGConfig != null) { + final SslMode sslMode = SslMode.valueOf(eventStorePGConfig.getString("ssl-mode", "DISABLE")); + final PgConnectOptions options = new PgConnectOptions() + .setPort(eventStorePGConfig.getInteger("port", 5432)) + .setHost(eventStorePGConfig.getString("host")) + .setDatabase(eventStorePGConfig.getString("database")) + .setUser(eventStorePGConfig.getString("user")) + .setPassword(eventStorePGConfig.getString("password")) + .setIdleTimeout(eventStorePGConfig.getInteger("idle-timeout", 300)); // unit seconds + final PoolOptions poolOptions = new PoolOptions() + .setMaxSize(eventStorePGConfig.getInteger("pool-size", 5)); + if (!SslMode.DISABLE.equals(sslMode)) { + options + .setSslMode(sslMode) + .setTrustAll(SslMode.ALLOW.equals(sslMode) || SslMode.PREFER.equals(sslMode) || SslMode.REQUIRE.equals(sslMode)); + } + if (reinit && pgClient != null) { + pgClient.close(); + } + pgClient = PgPool.pool(vertx, options, poolOptions); + countNoAckReceive.set(0); - final JsonArray allowedEventsConf = eventStorePGConfig.getJsonArray("allowed-events"); - if (allowedEventsConf != null && !allowedEventsConf.isEmpty()) { - allowedEventsConf.stream().forEach(x -> allowedEvents.add(x.toString())); - } - final Integer limitNumberNoAck = eventStorePGConfig.getInteger("limit-number-no-ack"); - if (limitNumberNoAck != null) { // set 0 to disable - maxNumberNoAck = limitNumberNoAck; - } - listKnownEvents(ar -> { - if (ar.succeeded()) { - knownEvents = ar.result(); - handler.handle(Future.succeededFuture()); + final JsonArray allowedEventsConf = eventStorePGConfig.getJsonArray("allowed-events"); + if (allowedEventsConf != null && !allowedEventsConf.isEmpty()) { + allowedEventsConf.stream().forEach(x -> allowedEvents.add(x.toString())); + } + final Integer limitNumberNoAck = eventStorePGConfig.getInteger("limit-number-no-ack"); + if (limitNumberNoAck != null) { // set 0 to disable + maxNumberNoAck = limitNumberNoAck; + } + listKnownEvents(ar -> { + if (ar.succeeded()) { + knownEvents = ar.result(); + handler.handle(Future.succeededFuture()); + } else { + logger.error("Error listing known events", ar.cause()); + handler.handle(Future.failedFuture(ar.cause())); + } + }); + enablePersistTimer = eventStorePGConfig.getBoolean("enable-persist-fallback-timer", false); } else { - logger.error("Error listing known events", ar.cause()); - handler.handle(Future.failedFuture(ar.cause())); + handler.handle(Future.failedFuture(new ValidationException("Missing postgresql config."))); } - }); - enablePersistTimer = eventStorePGConfig.getBoolean("enable-persist-fallback-timer", false); - } else { - handler.handle(Future.failedFuture(new ValidationException("Missing postgresql config."))); - } - } else { - handler.handle(Future.failedFuture(new ValidationException("Missing event store config."))); - } + } else { + handler.handle(Future.failedFuture(new ValidationException("Missing event store config."))); + } + }) + .onFailure(ex -> { + logger.error("Error when get platformId in event-store server map", ex); + handler.handle(Future.failedFuture(ex)); + }); } private void listKnownEvents(Handler>> handler) { diff --git a/common/src/main/java/org/entcore/common/explorer/ExplorerPluginFactory.java b/common/src/main/java/org/entcore/common/explorer/ExplorerPluginFactory.java index bc0802a5be..4deebcaf9b 100644 --- a/common/src/main/java/org/entcore/common/explorer/ExplorerPluginFactory.java +++ b/common/src/main/java/org/entcore/common/explorer/ExplorerPluginFactory.java @@ -1,5 +1,6 @@ package org.entcore.common.explorer; +import io.vertx.core.Future; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import io.vertx.ext.mongo.MongoClient; @@ -9,8 +10,13 @@ import org.entcore.common.postgres.IPostgresClient; import org.entcore.common.redis.RedisClient; +import java.util.List; import java.util.function.Function; +import static io.vertx.core.Future.failedFuture; +import static com.google.common.collect.Lists.newArrayList; +import static io.vertx.core.Future.succeededFuture; + public class ExplorerPluginFactory { private static Vertx vertxInstance; private static JsonObject explorerConfig; @@ -50,7 +56,7 @@ public static JsonObject getRedisConfig() throws Exception { return globalConfig; } - public static JsonObject getPostgresConfig() throws Exception { + public static JsonObject getPostgresConfig() throws Exception{ final JsonObject explorerConfig = getExplorerConfig(); if(explorerConfig.containsKey("postgresConfig")){ return explorerConfig; @@ -58,35 +64,77 @@ public static JsonObject getPostgresConfig() throws Exception { return globalConfig; } - public static IExplorerPluginCommunication getCommunication() throws Exception { + public static Future getCommunication() { if(explorerConfig == null){ - throw new Exception("Explorer config not initialized"); + return failedFuture("Explorer config not initialized"); } - if(explorerConfig.getBoolean("postgres", false)){ + try { + if (explorerConfig.getBoolean("postgres", false)) { final IExplorerPluginMetricsRecorder metricsRecorder = ExplorerPluginMetricsFactory.getExplorerPluginMetricsRecorder("postgres"); - final IPostgresClient postgresClient = IPostgresClient.create(vertxInstance, getPostgresConfig(), false, true); - final IExplorerPluginCommunication communication = new ExplorerPluginCommunicationPostgres(vertxInstance, postgresClient, metricsRecorder).setEnabled(isEnabled()); - return communication; - }else { + return IPostgresClient.create(vertxInstance, getPostgresConfig(), false, true) + .flatMap(postgresClient -> { + try { + final IExplorerPluginCommunication communication = new ExplorerPluginCommunicationPostgres(vertxInstance, postgresClient, metricsRecorder).setEnabled(isEnabled()); + return succeededFuture(communication); + } catch (Exception e) { + return failedFuture(e); + } + }); + } else { final IExplorerPluginMetricsRecorder metricsRecorder = ExplorerPluginMetricsFactory.getExplorerPluginMetricsRecorder("redis"); - final RedisClient redisClient = RedisClient.create(vertxInstance, getRedisConfig()); - final IExplorerPluginCommunication communication = new ExplorerPluginCommunicationRedis(vertxInstance, redisClient, metricsRecorder).setEnabled(isEnabled()); - return communication; + return RedisClient.create(vertxInstance, getRedisConfig()) + .map(redisClient -> { + try { + return new ExplorerPluginCommunicationRedis(vertxInstance, redisClient, metricsRecorder).setEnabled(isEnabled()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + } catch (Exception e) { + return failedFuture(e); } } - public static IExplorerPlugin createMongoPlugin(final Function, IExplorerPlugin> instance) throws Exception { - final IExplorerPluginCommunication communication = getCommunication(); - final MongoClient mongoClient = MongoClientFactory.create(vertxInstance, globalConfig); - final ExplorerFactoryParams params = new ExplorerFactoryParams(mongoClient,communication); - return instance.apply(params).setConfig(getExplorerConfig()); + public static Future createMongoPlugin(final Function, IExplorerPlugin> instance) { + final List> futures = newArrayList( + getCommunication(), + MongoClientFactory.create(vertxInstance, globalConfig) + ); + return Future.all(futures).flatMap(res -> { + final IExplorerPluginCommunication communication = res.resultAt(0); + final MongoClient mongoClient = res.resultAt(1); + try { + final ExplorerFactoryParams params = new ExplorerFactoryParams(mongoClient, communication); + return succeededFuture(instance.apply(params).setConfig(getExplorerConfig())); + } catch (Exception e) { + return failedFuture(new RuntimeException("Error while initializing explorer mongo plugin", e)); + } + }); } - public static IExplorerPlugin createPostgresPlugin(final Function, IExplorerPlugin> instance) throws Exception { - final IExplorerPluginCommunication communication = getCommunication(); - final IPostgresClient postgresClient = IPostgresClient.create(vertxInstance, globalConfig, false, true); - final ExplorerFactoryParams params = new ExplorerFactoryParams(postgresClient,communication); - return instance.apply(params).setConfig(getExplorerConfig()); + public static Future createPostgresPlugin(final Function, IExplorerPlugin> instance) { + try { + return getCommunication().flatMap(communication -> { + try { + return IPostgresClient.create(vertxInstance, globalConfig, false, true) + .flatMap(postgresClient -> { + final ExplorerFactoryParams params = new ExplorerFactoryParams(postgresClient, communication); + Future future; + try { + future = succeededFuture(instance.apply(params).setConfig(getExplorerConfig())); + } catch (Exception e) { + future = failedFuture(e); + } + return future; + }); + } catch (Exception e) { + throw new RuntimeException("Error while initializing explorer postgres plugin", e); + } + }); + } catch (Exception e) { + throw new RuntimeException("Error while creating postgres plugin", e); + } } public static class ExplorerFactoryParams{ diff --git a/common/src/main/java/org/entcore/common/explorer/impl/ExplorerRepositoryEvents.java b/common/src/main/java/org/entcore/common/explorer/impl/ExplorerRepositoryEvents.java index 2acc081cfd..f79406c645 100644 --- a/common/src/main/java/org/entcore/common/explorer/impl/ExplorerRepositoryEvents.java +++ b/common/src/main/java/org/entcore/common/explorer/impl/ExplorerRepositoryEvents.java @@ -22,7 +22,6 @@ import io.vertx.core.AsyncResult; import io.vertx.core.Future; import io.vertx.core.Handler; -import io.vertx.core.Promise; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; @@ -31,6 +30,7 @@ import org.entcore.common.explorer.to.ExplorerReindexResourcesRequest; import org.entcore.common.user.RepositoryEvents; import org.entcore.common.user.UserInfos; +import org.entcore.common.user.ExportResourceResult; import java.util.*; import java.util.stream.Collectors; @@ -91,13 +91,13 @@ public ExplorerRepositoryEvents setOnReindex(Handler handler) { + String locale, String host, Handler handler) { realRepositoryEvents.exportResources(exportDocuments, exportSharedResources, exportId, userId, groups, exportPath, locale, host, handler); } @Override public void exportResources(JsonArray resourcesIds, boolean exportDocuments, boolean exportSharedResources, String exportId, String userId, - JsonArray groups, String exportPath, String locale, String host, Handler handler) { + JsonArray groups, String exportPath, String locale, String host, Handler handler) { realRepositoryEvents.exportResources(resourcesIds, exportDocuments, exportSharedResources, exportId, userId, groups, exportPath, locale, host, handler); } diff --git a/common/src/main/java/org/entcore/common/folders/FolderImporter.java b/common/src/main/java/org/entcore/common/folders/FolderImporter.java index 69f9569ee2..7f763eddfa 100644 --- a/common/src/main/java/org/entcore/common/folders/FolderImporter.java +++ b/common/src/main/java/org/entcore/common/folders/FolderImporter.java @@ -1,5 +1,7 @@ package org.entcore.common.folders; +import static org.apache.commons.lang3.ObjectUtils.isNotEmpty; + import java.io.File; import java.util.Map; import java.util.HashMap; @@ -137,16 +139,18 @@ public FolderImporter(Vertx vertx, FileSystem fs, EventBus eb, boolean throwErro this.fs = fs; this.eb = eb; this.throwErrors = throwErrors; - try{ - final LocalMap serverMap = vertx.sharedData().getLocalMap("server"); - if(serverMap.containsKey("archiveConfig")){ - final String archiveConfig = serverMap.get("archiveConfig").toString(); - final JsonObject archiveConfigJson = new JsonObject(archiveConfig); - this.busTimeoutSec = archiveConfigJson.getInteger("storageTimeout", 600); - } - }catch(Exception e){ - log.error("Could not read archive config:", e); - } + vertx.sharedData().getLocalAsyncMap("server") + .compose(serverMap -> serverMap.get("archiveConfig")) + .onSuccess(archiveConfig -> { + try { + if (isNotEmpty(archiveConfig)){ + final JsonObject archiveConfigJson = new JsonObject(archiveConfig); + this.busTimeoutSec = archiveConfigJson.getInteger("storageTimeout", 600); + } + }catch(Exception e){ + log.error("Could not read archive config:", e); + } + }).onFailure(ex -> log.error("Error when get FolderImporter config", ex)); } public void setBusTimeoutSec(Integer busTimeoutSec) { @@ -236,7 +240,9 @@ private void bufferToStorage(FolderImporterContext context, JsonObject document, .put("oldFileId", fileId) .put("filePath", filePath) .put("userId", context.userId); - final DeliveryOptions options = new DeliveryOptions().setSendTimeout(this.busTimeoutSec * 1000); + final DeliveryOptions options = new DeliveryOptions() + .setSendTimeout(this.busTimeoutSec * 1000) + .setLocalOnly(true); this.eb.request("org.entcore.workspace", importParams, options, new Handler>>() { @Override @@ -376,17 +382,20 @@ public void handle(AsyncResult> result) { if(result.succeeded() == false) { + log.error("An error occurred while reading " + context.basePath, result.cause()); context.addError(null, null, "Failed to read document folder", result.cause().getMessage()); throw new RuntimeException(result.cause()); } else { List filesInDir = result.result(); + log.debug("Read files from " + context.basePath + " : " + filesInDir); LinkedList futures = new LinkedList(); fileFor: for(String filePath : filesInDir) { + log.debug("Reading file " + filePath); Promise future = Promise.promise(); futures.add(future.future()); @@ -399,6 +408,7 @@ public void handle(AsyncResult> result) if(m.find() == false) { String error = "Filename " + fileTrunc + "does not contain the file id"; + log.debug(error); context.addError(null, null, error, null); future.fail(new RuntimeException(error)); diff --git a/common/src/main/java/org/entcore/common/folders/impl/FolderImporterZip.java b/common/src/main/java/org/entcore/common/folders/impl/FolderImporterZip.java index 5962016c1f..87c7b3b0d0 100644 --- a/common/src/main/java/org/entcore/common/folders/impl/FolderImporterZip.java +++ b/common/src/main/java/org/entcore/common/folders/impl/FolderImporterZip.java @@ -2,6 +2,7 @@ import fr.wseduc.webutils.DefaultAsyncResult; import fr.wseduc.webutils.I18n; +import fr.wseduc.webutils.collections.SharedDataHelper; import fr.wseduc.webutils.http.Renders; import io.vertx.core.CompositeFuture; import io.vertx.core.Future; @@ -15,7 +16,6 @@ import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; -import io.vertx.core.shareddata.LocalMap; import io.vertx.core.streams.Pump; import io.vertx.core.streams.ReadStream; import org.entcore.common.folders.FolderManager; @@ -34,34 +34,40 @@ import java.util.stream.Collectors; import static org.apache.commons.lang3.ObjectUtils.isEmpty; +import static org.apache.commons.lang3.ObjectUtils.isNotEmpty; public class FolderImporterZip { static final Logger logger = LoggerFactory.getLogger(FolderImporterZip.class); private final Vertx vertx; private final FolderManager manager; private final List encodings = new ArrayList<>(); - private final Optional antivirusClient; - private final Optional fileValidator; + private Optional antivirusClient; + private Optional fileValidator = Optional.empty(); public FolderImporterZip(final Vertx v, final FolderManager aManager) { this.vertx = v; this.manager = aManager; - this.fileValidator = FileValidator.create(v); - this.antivirusClient = AntivirusClient.create(v); - try { - encodings.add("UTF-8"); - final String encodingList = (String) v.sharedData().getLocalMap("server").get("encoding-available"); - if (encodingList != null) { - final JsonArray encodingJson = new JsonArray(encodingList); - for (final Object o : encodingJson) { - if (!encodings.contains(o.toString())) { - encodings.add(o.toString()); + FileValidator.create(v) + .onSuccess(fValidator -> fileValidator = fValidator) + .onFailure(ex -> logger.error("Error creating fileValidator", ex)); + AntivirusClient.create(v) + .onSuccess(antivirus -> this.antivirusClient = antivirus) + .onFailure(ex -> logger.error("Error creating antivirus", ex)); + encodings.add("UTF-8"); + SharedDataHelper.getInstance().getLocal("server", "encoding-available").onSuccess(encodingList -> { + try { + if (encodingList != null) { + final JsonArray encodingJson = new JsonArray(encodingList); + for (final Object o : encodingJson) { + if (!encodings.contains(o.toString())) { + encodings.add(o.toString()); + } } } + } catch (Exception e) { + logger.warn("An error occurred while initializing importer", e); } - } catch (Exception e) { - logger.warn("An error occurred while initializing importer", e); - } + }).onFailure(ex -> logger.error("Error adding encodings from server map", ex)); } public Future> getGuessedEncoding(FolderImporterZipContext context) { @@ -96,27 +102,28 @@ public static Future createContext(Vertx vertx, UserIn public static Future createContext(Vertx vertx, UserInfos user, ReadStream buffer, String invalidMessage) { final Promise future = Promise.promise(); final String name = UUID.randomUUID() + ".zip"; - final String importPath = getImportPath(vertx); - final String zipPath = Paths.get(importPath, name).normalize().toString(); buffer.pause(); - vertx.fileSystem().open(zipPath, new OpenOptions().setTruncateExisting(true).setCreate(true).setWrite(true), fileRes -> { - if (fileRes.succeeded()) { - final AsyncFile file = fileRes.result(); - final Pump pump = Pump.pump(buffer, file); - buffer.endHandler(r -> { - file.end(); - future.complete(new FolderImporterZipContext(zipPath, importPath, user, invalidMessage)); - }); - buffer.exceptionHandler(e -> { - file.end(); - future.fail(e); - }); - pump.start(); - buffer.resume(); - } else { - future.fail(fileRes.cause()); - } - }); + getImportPath(vertx).onSuccess(importPath -> { + final String zipPath = Paths.get(importPath, name).normalize().toString(); + vertx.fileSystem().open(zipPath, new OpenOptions().setTruncateExisting(true).setCreate(true).setWrite(true), fileRes -> { + if (fileRes.succeeded()) { + final AsyncFile file = fileRes.result(); + final Pump pump = Pump.pump(buffer, file); + buffer.endHandler(r -> { + file.end(); + future.complete(new FolderImporterZipContext(zipPath, importPath, user, invalidMessage)); + }); + buffer.exceptionHandler(e -> { + file.end(); + future.fail(e); + }); + pump.start(); + buffer.resume(); + } else { + future.fail(fileRes.cause()); + } + }); + }).onFailure(ex -> future.fail(ex)); return future.future(); } @@ -128,13 +135,21 @@ public static Future createContext(Vertx vertx, UserIn * @param vertx Vertx instance of the called * @return The path to import the zip file */ - private static String getImportPath(Vertx vertx) { + private static Future getImportPath(Vertx vertx) { + final Promise promise = Promise.promise(); String importPath = vertx.getOrCreateContext().config().getString("import-path"); if(org.apache.commons.lang3.StringUtils.isEmpty(importPath)) { - final LocalMap localMap = vertx.sharedData().getLocalMap("server"); - importPath = (String)localMap.getOrDefault("import-path", System.getProperty("java.io.tmpdir")); + vertx.sharedData().getLocalAsyncMap("server").compose(serverMap -> serverMap.get("import-path")) + .onSuccess(iPath -> + promise.complete(isNotEmpty(iPath) ? iPath : System.getProperty("java.io.tmpdir")) + ).onFailure(ex -> { + logger.error("Error getting import-path", ex); + promise.complete(System.getProperty("java.io.tmpdir")); + }); + } else { + promise.complete(importPath); } - return importPath; + return promise.future(); } public Future doPrepare(final FolderImporterZipContext context) { diff --git a/common/src/main/java/org/entcore/common/folders/impl/FolderManagerMongoImpl.java b/common/src/main/java/org/entcore/common/folders/impl/FolderManagerMongoImpl.java index 3aff51b6d4..edf3be7848 100644 --- a/common/src/main/java/org/entcore/common/folders/impl/FolderManagerMongoImpl.java +++ b/common/src/main/java/org/entcore/common/folders/impl/FolderManagerMongoImpl.java @@ -16,7 +16,6 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; -import org.entcore.broker.api.dto.resources.ResourcesDeletedDTO; import org.entcore.broker.api.dto.resources.ResourcesTrashedDTO; import org.entcore.broker.api.publisher.BrokerPublisherFactory; import org.entcore.broker.api.utils.AddressParameter; @@ -1204,7 +1203,7 @@ public void handle(AsyncResult> result) } } - future.fail(new RuntimeException("Failed to send a request to the image resizer", result.cause())); + future.fail(new RuntimeException(" Failed to send a request to the image resizer", result.cause())); handler.handle(future.future()); } }); diff --git a/common/src/main/java/org/entcore/common/http/BaseServer.java b/common/src/main/java/org/entcore/common/http/BaseServer.java index ed791890ea..52f158ede4 100644 --- a/common/src/main/java/org/entcore/common/http/BaseServer.java +++ b/common/src/main/java/org/entcore/common/http/BaseServer.java @@ -22,6 +22,7 @@ import fr.wseduc.mongodb.MongoDb; import fr.wseduc.webutils.I18n; import fr.wseduc.webutils.Server; +import fr.wseduc.webutils.collections.SharedDataHelper; import fr.wseduc.webutils.data.FileResolver; import fr.wseduc.webutils.http.BaseController; import fr.wseduc.webutils.http.Renders; @@ -32,14 +33,9 @@ import fr.wseduc.webutils.request.filter.UserAuthFilter; import fr.wseduc.webutils.security.SecureHttpServerRequest; import fr.wseduc.webutils.validation.JsonSchemaValidator; -import io.vertx.core.AsyncResult; -import io.vertx.core.Future; -import io.vertx.core.Handler; -import io.vertx.core.Promise; -import io.vertx.core.buffer.Buffer; -import io.vertx.core.http.HttpServerRequest; -import io.vertx.core.json.JsonObject; -import io.vertx.core.shareddata.LocalMap; + +import io.vertx.core.*; +import io.vertx.core.json.JsonArray; import org.entcore.broker.api.dto.applications.ApplicationStatusDTO; import org.entcore.broker.api.publisher.BrokerPublisherFactory; @@ -51,6 +47,7 @@ import org.entcore.common.controller.RightsController; import org.entcore.common.datavalidation.utils.UserValidationFactory; import org.entcore.common.elasticsearch.ElasticSearch; +import org.entcore.common.email.EmailFactory; import org.entcore.common.events.EventStoreFactory; import org.entcore.common.explorer.ExplorerPluginFactory; import org.entcore.common.http.filter.*; @@ -64,6 +61,8 @@ import org.entcore.common.search.SearchingHandler; import org.entcore.common.sql.DB; import org.entcore.common.sql.Sql; +import org.entcore.common.storage.Storage; +import org.entcore.common.storage.StorageFactory; import org.entcore.common.trace.TraceFilter; import org.entcore.common.user.RepositoryEvents; import org.entcore.common.user.RepositoryHandler; @@ -72,6 +71,13 @@ import org.entcore.common.utils.Mfa; import org.entcore.common.utils.Zip; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.json.JsonObject; + +import static fr.wseduc.webutils.Utils.getOrElse; +import static fr.wseduc.webutils.Utils.isNotEmpty; + import java.io.File; import java.util.*; @@ -94,94 +100,131 @@ public static String getModuleName() { @Override public void start(final Promise startPromise) throws Exception { - moduleName = getClass().getSimpleName(); - this.statusPublisher = BrokerPublisherFactory.create( - ApplicationStatusBrokerPublisher.class, - vertx, - new AddressParameter("application", moduleName.toLowerCase()) - ); - if (resourceProvider == null) { - setResourceProvider(new ResourceProviderFilter()); - } - super.start(startPromise); - accessLogger = new EntAccessLogger(getEventBus(vertx)); - - EventStoreFactory eventStoreFactory = EventStoreFactory.getFactory(); - eventStoreFactory.setVertx(vertx); - - Mfa.Factory.getFactory().init(vertx, config); - - initFilters(); - - String node = (String) vertx.sharedData().getLocalMap("server").get("node"); - this.nodeName = node; - contentSecurityPolicy = (String) vertx.sharedData().getLocalMap("server").get("contentSecurityPolicy"); - - repositoryHandler = new RepositoryHandler(getEventBus(vertx)); - searchingHandler = new SearchingHandler(getEventBus(vertx)); - i18nHandler = new I18nHandler(); - - Config.getInstance().setConfig(config); - - if (node != null) { - initModulesHelpers(node); - } - - if (config.getBoolean("csrf-token", false)) { - addFilter(new CsrfFilter(getEventBus(vertx), securedUriBinding)); - } - if (config.getBoolean("block-route-filter", false)) { - addFilter(new BlockRouteFilter(vertx, getEventBus(vertx), Server.getPathPrefix(config), - config.getLong("block-route-filter-refresh-period", 5 * 60 * 1000L), - config.getBoolean("block-route-filter-redirect-if-mobile", true), - config.getInteger("block-route-filter-error-status-code", 401) - )); - } - UserValidationFactory userValidationFactory = UserValidationFactory.getFactory(); - userValidationFactory.init(vertx, config); - - final LocalMap server = vertx.sharedData().getLocalMap("server"); - final Boolean cacheEnabled = (Boolean) server.getOrDefault("cache-filter", false); - if(Boolean.TRUE.equals(cacheEnabled)){ - final CacheService cacheService = CacheService.create(vertx); - addFilter(new CacheFilter(getEventBus(vertx),securedUriBinding, cacheService)); - } - - addFilter(new MandatoryUserValidationFilter(mfaProtectedBinding, getEventBus(vertx))); - - if (config.getString("integration-mode","BUS").equals("HTTP")) { - addFilter(new HttpActionFilter(securedUriBinding, config, vertx, resourceProvider)); - } else { - addFilter(new ActionFilter(securedUriBinding, vertx, resourceProvider)); - } - vertx.eventBus().localConsumer("user.repository", repositoryHandler); - vertx.eventBus().localConsumer("search.searching", this.searchingHandler); - vertx.eventBus().localConsumer(moduleName.toLowerCase()+".i18n", this.i18nHandler); - - loadI18nAssetsFiles(); - - addController(new RightsController()); - addController(new ConfController()); - SecurityHandler.setVertx(vertx); - - final Map skins = vertx.sharedData().getLocalMap("skins"); - Renders.getAllowedHosts().addAll(skins.keySet()); - //listen for i18n deploy - vertx.eventBus().consumer(ONDEPLOY_I18N, message ->{ - log.info("Received "+ONDEPLOY_I18N+" update i18n override"); - this.loadI18nAssetsFiles(); - }); - // notify started on broker - startPromise.future().onComplete(result -> { - // Wait for broker module to be deployed - final long brokerDelay = config.getLong("broker-start-delay", 60_000L); // 60 sec - vertx.setTimer(brokerDelay, time -> { - statusPublisher.notifyStarted(ApplicationStatusDTO.withBasicInfo(moduleName, nodeName)); - log.info("Sent started status to broker for application " + moduleName); - }); - }); + moduleName = getClass().getSimpleName(); + this.statusPublisher = BrokerPublisherFactory.create( + ApplicationStatusBrokerPublisher.class, + vertx, + new AddressParameter("application", moduleName.toLowerCase()) + ); + if (resourceProvider == null) { + setResourceProvider(new ResourceProviderFilter()); + } + Future.future(p ->{ + try { + super.start(p); + } catch (Exception e) { + startPromise.fail(e); + return; + }} + ).compose(x -> { + accessLogger = new EntAccessLogger(getEventBus(vertx)); + EventStoreFactory eventStoreFactory = EventStoreFactory.getFactory(); + eventStoreFactory.setVertx(vertx); + return loadInfra(); + }) + .compose(x -> Mfa.Factory.getFactory().init(vertx, config)) + .compose(x -> + SharedDataHelper.getInstance().getLocalMulti("server", + "node", "contentSecurityPolicy", "cache-filter", "skins", "oauthCache", + "neo4jConfig", "elasticsearchConfig", "redisConfig", "explorerConfig") + ) + .compose(this::initBaseServer) + .onFailure(e -> { + log.error("Error when initialing BaseServer on module " + moduleName, e); + if (vertx.isClustered()) { + try { + Promise stopPromise = Promise.promise(); + super.stop(stopPromise); + stopPromise.future().onComplete(r -> vertx.close()); + } catch (Exception e1) { + log.error("Error when stop module " + moduleName, e1); + } + } + }) + .onSuccess(result -> { + // Wait for broker module to be deployed + final long brokerDelay = config.getLong("broker-start-delay", 60_000L); // 60 sec + vertx.setTimer(brokerDelay, time -> { + statusPublisher.notifyStarted(ApplicationStatusDTO.withBasicInfo(moduleName, nodeName)); + log.info("Sent started status to broker for application " + moduleName); + }); + }) + .onComplete(startPromise); } + public Future initBaseServer(final Map baseServerMap) { + initFilters(baseServerMap); + + final String node = (String) baseServerMap.get("node"); + this.nodeName = node; + contentSecurityPolicy = (String) baseServerMap.get("contentSecurityPolicy"); + return Future.future(p -> { + StorageFactory.build(vertx, config) + .onFailure(p::fail) + .onSuccess(factory -> { + final Storage storage = factory.getStorage(); + repositoryHandler = new RepositoryHandler(getEventBus(vertx), storage); + searchingHandler = new SearchingHandler(getEventBus(vertx)); + i18nHandler = new I18nHandler(); + + Config.getInstance().setConfig(config); + + EmailFactory.build(vertx, config) + .onFailure(p::fail) + .onSuccess(f -> { + UserValidationFactory.build(vertx, config); + if (node != null) { + initModulesHelpers(node, baseServerMap); + } + + if (config.getBoolean("csrf-token", false)) { + addFilter(new CsrfFilter(getEventBus(vertx), securedUriBinding)); + } + if (config.getBoolean("block-route-filter", false)) { + addFilter(new BlockRouteFilter(vertx, getEventBus(vertx), Server.getPathPrefix(config), + config.getLong("block-route-filter-refresh-period", 5 * 60 * 1000L), + config.getBoolean("block-route-filter-redirect-if-mobile", true), + config.getInteger("block-route-filter-error-status-code", 401) + )); + } + + final List> futures = new ArrayList<>(); + + final Boolean cacheEnabled = (Boolean) baseServerMap.getOrDefault("cache-filter", false); + if(Boolean.TRUE.equals(cacheEnabled)){ + final CacheService cacheService = CacheService.create(vertx); + addFilter(new CacheFilter(getEventBus(vertx),securedUriBinding, cacheService)); + } + + addFilter(new MandatoryUserValidationFilter(mfaProtectedBinding, getEventBus(vertx))); + + if (config.getString("integration-mode","BUS").equals("HTTP")) { + addFilter(new HttpActionFilter(securedUriBinding, config, vertx, resourceProvider)); + } else { + addFilter(new ActionFilter(securedUriBinding, vertx, resourceProvider)); + } + vertx.eventBus().consumer("user.repository", repositoryHandler); + vertx.eventBus().consumer("search.searching", this.searchingHandler); + vertx.eventBus().consumer(moduleName.toLowerCase()+".i18n", this.i18nHandler); + + final Map skins = getOrElse((JsonObject) baseServerMap.get("skins"), new JsonObject()).getMap(); + loadI18nAssetsFiles(skins); + + futures.add(addController(new RightsController())); + futures.add(addController(new ConfController())); + SecurityHandler.setVertx(vertx); + + Renders.getAllowedHosts().addAll(skins.keySet()); + //listen for i18n deploy + vertx.eventBus().consumer(ONDEPLOY_I18N, message ->{ + log.info("Received "+ONDEPLOY_I18N+" update i18n override"); + this.loadI18nAssetsFiles(skins); + }); + Future.all(futures).onComplete(res -> p.tryComplete()); + }); + }); + }); + } @Override public void stop(final Promise promise) throws Exception { @@ -190,9 +233,9 @@ public void stop(final Promise promise) throws Exception { statusPublisher.notifyStopped(ApplicationStatusDTO.withBasicInfo(moduleName, nodeName)); } - protected void initFilters() { + protected void initFilters(final Map baseServerMap) { //prepare cache if needed - final LocalMap server = vertx.sharedData().getLocalMap("server"); + final Map server = baseServerMap; final Optional oauthCache = Optional.ofNullable(server.get("oauthCache")); final Optional oauthConfigJson = oauthCache.map(e-> new JsonObject((String)e)); final Optional oauthTtl = oauthConfigJson.map( e -> e.getInteger("ttlSeconds")); @@ -221,15 +264,16 @@ protected void initFilters() { } @Override - protected Server addController(BaseController controller) { + protected Future addController(BaseController controller) { controller.setAccessLogger(accessLogger); - super.addController(controller); - if (config.getJsonObject("override-theme") != null) { - controller.addHookRenderProcess(new OverrideThemeHookRender(getEventBus(vertx), config.getJsonObject("override-theme"))); - } - controller.addHookRenderProcess(new SecurityHookRender(getEventBus(vertx), - true, contentSecurityPolicy)); - return this; + return super.addController(controller).map(e -> { + if (config.getJsonObject("override-theme") != null) { + controller.addHookRenderProcess(new OverrideThemeHookRender(getEventBus(vertx), config.getJsonObject("override-theme"))); + } + controller.addHookRenderProcess(new SecurityHookRender(getEventBus(vertx), + true, contentSecurityPolicy)); + return this; + }); } @Override @@ -249,14 +293,14 @@ public void handle(JsonObject session) { } } - protected void initModulesHelpers(String node) { + protected void initModulesHelpers(String node, final Map baseServerMap) { if (config.getBoolean("neo4j", true)) { if (config.getJsonObject("neo4jConfig") != null) { final JsonObject neo4jConfigJson = config.getJsonObject("neo4jConfig").copy(); final JsonObject neo4jConfigOverride = config.getJsonObject("neo4jConfigOverride", new JsonObject()); Neo4j.getInstance().init(vertx, neo4jConfigJson.mergeIn(neo4jConfigOverride)); } else { - final String neo4jConfig = (String) vertx.sharedData().getLocalMap("server").get("neo4jConfig"); + final String neo4jConfig = (String) baseServerMap.get("neo4jConfig"); final JsonObject neo4jConfigJson = new JsonObject(neo4jConfig); final JsonObject neo4jConfigOverride = config.getJsonObject("neo4jConfigOverride", new JsonObject()); Neo4j.getInstance().init(vertx, neo4jConfigJson.mergeIn(neo4jConfigOverride)); @@ -291,7 +335,7 @@ protected void initModulesHelpers(String node) { if (config.getJsonObject("elasticsearchConfig") != null) { ElasticSearch.getInstance().init(vertx, config.getJsonObject("elasticsearchConfig")); } else { - String elasticsearchConfig = (String) vertx.sharedData().getLocalMap("server").get("elasticsearchConfig"); + String elasticsearchConfig = (String) baseServerMap.get("elasticsearchConfig"); ElasticSearch.getInstance().init(vertx, new JsonObject(elasticsearchConfig)); } } @@ -299,16 +343,26 @@ protected void initModulesHelpers(String node) { if (config.getJsonObject("redisConfig") != null) { Redis.getInstance().init(vertx, config.getJsonObject("redisConfig")); }else{ - final String redisConf = (String) vertx.sharedData().getLocalMap("server").get("redisConfig"); + final String redisConf = (String) baseServerMap.get("redisConfig"); if(redisConf!=null){ Redis.getInstance().init(vertx, new JsonObject(redisConf)); } } } if (config.getBoolean("explorer", true)) { - ExplorerPluginFactory.init(vertx, config); + JsonObject explorerConfig = config.getJsonObject("explorerConfig"); + if (explorerConfig == null) { + final String explorerConf = (String) baseServerMap.get("explorerConfig"); + if (explorerConf != null) { + explorerConfig = new JsonObject(explorerConf); + } + } + if (explorerConfig != null) { + ExplorerPluginFactory.init(vertx, new JsonObject().put("explorerConfig", explorerConfig)); + } } - // + + // TODO add mongoConfig & postgresConfig JsonSchemaValidator validator = JsonSchemaValidator.getInstance(); validator.setEventBus(getEventBus(vertx)); @@ -316,22 +370,21 @@ protected void initModulesHelpers(String node) { validator.loadJsonSchema(getPathPrefix(config), vertx); } - private void loadI18nAssetsFiles() { + private void loadI18nAssetsFiles(Map skins) { final String assetsDirectory = config.getString("assets-path", "../..") + File.separator + "assets"; final String className = this.getClass().getSimpleName(); readI18n(I18n.DEFAULT_DOMAIN, assetsDirectory + File.separator + "i18n" + File.separator + className, v -> { - this.loadI18nThemesFiles(); + this.loadI18nThemesFiles(skins); }); } - private void loadI18nThemesFiles(){ + private void loadI18nThemesFiles(Map skins){ final String className = this.getClass().getSimpleName(); final String assetsDirectory = config.getString("assets-path", "../..") + File.separator + "assets"; final String themesDirectory = assetsDirectory + File.separator + "themes"; - final Map skins = vertx.sharedData().getLocalMap("skins"); final Map reverseSkins = new HashMap<>(); - for (Map.Entry e: skins.entrySet()) { - reverseSkins.put(e.getValue(), e.getKey()); + for (Map.Entry e: skins.entrySet()) { + reverseSkins.put((String) e.getValue(), e.getKey()); } vertx.fileSystem().exists(themesDirectory, event -> { if (event.succeeded() && event.result()) { @@ -420,8 +473,9 @@ protected BaseServer setRepositoryEvents(RepositoryEvents repositoryEvents) { protected BaseServer setSearchingEvents(final SearchingEvents searchingEvents) { searchingHandler.setSearchingEvents(searchingEvents); - final LocalMap set = vertx.sharedData().getLocalMap(SearchingHandler.class.getName()); - set.putIfAbsent(searchingEvents.getClass().getSimpleName(), ""); + vertx.sharedData().getAsyncMap(SearchingHandler.class.getName()) + .compose(set -> set.putIfAbsent(searchingEvents.getClass().getSimpleName(), "")) + .onFailure(ex -> log.error("Error putting searching events", ex)); return this; } @@ -436,7 +490,7 @@ public String getSchema() { return schema; } - /** + /** * An overridable hook allowing additional non-sql tasks to be done * after all SQL migration scripts have been applied. * @return a future @@ -445,4 +499,75 @@ protected Future postSqlScripts() { return Future.succeededFuture(); } + + private Future loadInfra() { + log.info("loading infra for module " + moduleName); + Promise returnPromise = Promise.promise(); + try { + final Map serverMap = new HashMap<>(); + String random = config.getBoolean("key-random", true) ? String.valueOf(Math.random()) : ""; + serverMap.put("signKey", config.getString("key", "zbxgKWuzfxaYzbXcHnK3WnWK" + random)); + + serverMap.put("sameSiteValue", config.getString("sameSiteValue", "Strict")); + serverMap.put("hidePersonalData", config.getBoolean("hidePersonalData", false)); + + //JWT need signKey + SecurityHandler.setVertx(vertx); + //encoding + final JsonArray encodings = config.getJsonArray("encoding-available", new JsonArray()); + final JsonArray safeEncodings = new JsonArray(); + for(final Object o : encodings){ + safeEncodings.add(o.toString()); + } + serverMap.put("encoding-available", safeEncodings.encode()); + // + CookieHelper.getInstance().init((String) serverMap.get("signKey"), + (String) serverMap.get("sameSiteValue"), + log); + + final String[] keys = new String[]{"swift", "s3", "emailConfig", "emailValidationConfig", "mfaConfig", + "webviewConfig", "file-system", "neo4jConfig", "mongoConfig", "postgresConfig", "explorerConfig", + "redisConfig", "oauthCache", "node-pdf-generator", "event-store", "metricsOptions", "content-transformer"}; + for(final String key : keys) { + JsonObject value = config.getJsonObject(key); + if (value != null) { + serverMap.put(key, value.encode()); + } + } + serverMap.put("cache-enabled", config.getBoolean("cache-enabled", false)); + serverMap.put("gridfsAddress", config.getString("gridfs-address", "wse.gridfs.persistor")); + final String csp = config.getString("content-security-policy"); + if (isNotEmpty(csp)) { + serverMap.put("contentSecurityPolicy", csp); + } + final String staticHost = config.getString("static-host"); + if(staticHost != null) { + serverMap.put("static-host", staticHost); + } + /* sharedConf sub-object */ + JsonObject sharedConf = config.getJsonObject("sharedConf", new JsonObject()); + for(String field : sharedConf.fieldNames()){ + serverMap.put(field, sharedConf.getValue(field)); + } + + serverMap.put("skins", config.getJsonObject("skins", new JsonObject())); + + log.info("config skin-levels = " + config.getJsonObject("skin-levels", new JsonObject())); + serverMap.put("skin-levels", config.getJsonObject("skin-levels", new JsonObject())); + + vertx.sharedData().getLocalAsyncMap("server").onSuccess(asyncServerMap -> { + final List> futures = new ArrayList<>(); + serverMap.entrySet().stream().forEach(entry -> futures.add(asyncServerMap.put(entry.getKey(), entry.getValue()))); + Future.all(futures) + .onSuccess(a -> returnPromise.tryComplete()) + .onFailure(ex -> { + log.error("Error putting values in config server map", ex); + returnPromise.tryFail(ex); + }); + }).onFailure(ex -> log.error("Error getting server map", ex)); + } catch (Exception ex) { + returnPromise.tryFail(ex); + } + return returnPromise.future(); + } } diff --git a/common/src/main/java/org/entcore/common/http/EntAccessLogger.java b/common/src/main/java/org/entcore/common/http/EntAccessLogger.java index 1f17a27ddb..4ca1adcc93 100644 --- a/common/src/main/java/org/entcore/common/http/EntAccessLogger.java +++ b/common/src/main/java/org/entcore/common/http/EntAccessLogger.java @@ -49,9 +49,9 @@ public void handle(JsonObject session) { secureRequest = new SecureHttpServerRequest(request); secureRequest.setSession(session); } - log.trace(formatLog(secureRequest) + " - " + session.getString("userId")); + log.info(formatLog(secureRequest, session.getString("userId"))); } else { - log.trace(formatLog(request)); + log.info(formatLog(request, null)); } request.resume(); handler.handle(null); diff --git a/common/src/main/java/org/entcore/common/http/filter/WebviewFilter.java b/common/src/main/java/org/entcore/common/http/filter/WebviewFilter.java index baa8998837..3b675d6309 100644 --- a/common/src/main/java/org/entcore/common/http/filter/WebviewFilter.java +++ b/common/src/main/java/org/entcore/common/http/filter/WebviewFilter.java @@ -1,5 +1,6 @@ package org.entcore.common.http.filter; +import fr.wseduc.webutils.Utils; import fr.wseduc.webutils.http.Binding; import fr.wseduc.webutils.http.Renders; import fr.wseduc.webutils.request.CookieHelper; @@ -12,7 +13,7 @@ import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; -import io.vertx.core.shareddata.LocalMap; + import org.entcore.common.utils.StringUtils; public class WebviewFilter implements Filter { @@ -26,17 +27,20 @@ public class WebviewFilter implements Filter { private static final Logger log = LoggerFactory.getLogger(CsrfFilter.class); private final EventBus eb; private final Vertx vertx; - private final JsonObject config; + private JsonObject config; + public WebviewFilter(Vertx vertx, EventBus eb) { this.eb = eb; this.vertx = vertx; - final LocalMap server = vertx.sharedData().getLocalMap("server"); - final String webviewConfig = (String) server.get("webviewConfig"); - if (webviewConfig != null) { - this.config = new JsonObject(webviewConfig); - }else{ - this.config = new JsonObject(); - } + vertx.sharedData().getLocalAsyncMap("server") + .compose(serverMap -> serverMap.get("webviewConfig")) + .onSuccess(webviewConfig -> { + if (webviewConfig != null) { + this.config = new JsonObject(webviewConfig); + }else{ + this.config = new JsonObject(); + } + }).onFailure(ex -> log.error("Error when get config of WebviewFilter", ex)); } protected String getIllegalWebpage(){ diff --git a/common/src/main/java/org/entcore/common/http/health/MongoProbe.java b/common/src/main/java/org/entcore/common/http/health/MongoProbe.java new file mode 100644 index 0000000000..dc2b623c94 --- /dev/null +++ b/common/src/main/java/org/entcore/common/http/health/MongoProbe.java @@ -0,0 +1,44 @@ +package org.entcore.common.http.health; + +import fr.wseduc.mongodb.MongoDb; +import fr.wseduc.webutils.metrics.HealthCheckProbe; +import fr.wseduc.webutils.metrics.HealthCheckProbeResult; +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; + +import static io.vertx.core.Future.succeededFuture; + +/** + * Checks that MongoDB is reachable and that a simple read query can be performed. + */ +public class MongoProbe implements HealthCheckProbe { + private Vertx vertx; + + @Override + public Future init(final Vertx vertx, final JsonObject config) { + this.vertx = vertx; + return succeededFuture(); + } + + @Override + public String getName() { + return "mongo"; + } + + @Override + public Vertx getVertx() { + return vertx; + } + + @Override + public Future probe() { + final Promise promise = Promise.promise(); + MongoDb.getInstance().command("{ \"dbStats\": 1 }",res -> { + boolean ok = "ok".equals(res.body().getString("status")); + promise.tryComplete(new HealthCheckProbeResult(getName(), ok, null)); + }); + return promise.future(); + } +} diff --git a/common/src/main/java/org/entcore/common/http/health/Neo4jProbe.java b/common/src/main/java/org/entcore/common/http/health/Neo4jProbe.java new file mode 100644 index 0000000000..e308d22969 --- /dev/null +++ b/common/src/main/java/org/entcore/common/http/health/Neo4jProbe.java @@ -0,0 +1,44 @@ +package org.entcore.common.http.health; + +import fr.wseduc.webutils.metrics.HealthCheckProbe; +import fr.wseduc.webutils.metrics.HealthCheckProbeResult; +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import org.entcore.common.neo4j.Neo4j; + +import static io.vertx.core.Future.succeededFuture; + +/** + * Checks that Neo4j is reachable and that a simple read query can be executed. + */ +public class Neo4jProbe implements HealthCheckProbe { + private Vertx vertx; + + @Override + public Future init(final Vertx vertx, final JsonObject config) { + this.vertx = vertx; + return succeededFuture(); + } + + @Override + public String getName() { + return "neo4j"; + } + + @Override + public Vertx getVertx() { + return vertx; + } + + @Override + public Future probe() { + final Promise promise = Promise.promise(); + Neo4j.getInstance().execute("MATCH (:Structure) RETURN count(*)", (JsonObject) null, res -> { + boolean ok = "ok".equals(res.body().getString("status")); + promise.tryComplete(new HealthCheckProbeResult(getName(), ok, null)); + }); + return promise.future(); + } +} diff --git a/common/src/main/java/org/entcore/common/http/health/PostgresProbe.java b/common/src/main/java/org/entcore/common/http/health/PostgresProbe.java new file mode 100644 index 0000000000..50046ee0aa --- /dev/null +++ b/common/src/main/java/org/entcore/common/http/health/PostgresProbe.java @@ -0,0 +1,44 @@ +package org.entcore.common.http.health; + +import fr.wseduc.webutils.metrics.HealthCheckProbe; +import fr.wseduc.webutils.metrics.HealthCheckProbeResult; +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import org.entcore.common.sql.Sql; + +import static io.vertx.core.Future.succeededFuture; + +/** + * Check that Postgresql is reachable and that a simple read query can be performed. + */ +public class PostgresProbe implements HealthCheckProbe { + private Vertx vertx; + + @Override + public Future init(final Vertx vertx, final JsonObject config) { + this.vertx = vertx; + return succeededFuture(); + } + + @Override + public String getName() { + return "postgres"; + } + + @Override + public Vertx getVertx() { + return vertx; + } + + @Override + public Future probe() { + final Promise promise = Promise.promise(); + Sql.getInstance().raw("SELECT count(*) FROM information_schema.tables", res -> { + boolean ok = "ok".equals(res.body().getString("status")); + promise.tryComplete(new HealthCheckProbeResult(getName(), ok, null)); + }); + return promise.future(); + } +} diff --git a/common/src/main/java/org/entcore/common/http/health/RedisProbe.java b/common/src/main/java/org/entcore/common/http/health/RedisProbe.java new file mode 100644 index 0000000000..fc06eb1bc5 --- /dev/null +++ b/common/src/main/java/org/entcore/common/http/health/RedisProbe.java @@ -0,0 +1,46 @@ +package org.entcore.common.http.health; + +import fr.wseduc.webutils.metrics.HealthCheckProbe; +import fr.wseduc.webutils.metrics.HealthCheckProbeResult; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import io.vertx.redis.client.RedisAPI; +import org.entcore.common.redis.Redis; + +import java.util.UUID; + +import static io.vertx.core.Future.succeededFuture; +import static java.util.Arrays.asList; + +/** + * Check that Redis is reachable and that a simple read query can be performed. + */ +public class RedisProbe implements HealthCheckProbe { + private Vertx vertx; + + @Override + public Future init(final Vertx vertx, final JsonObject config) { + this.vertx = vertx; + return succeededFuture(); + } + + @Override + public String getName() { + return "redis"; + } + + @Override + public Vertx getVertx() { + return vertx; + } + + @Override + public Future probe() { + final RedisAPI client = Redis.getClient().getClient(); + final String key = "probe-ent-" + UUID.randomUUID(); + return client.set(asList(key, "healthcheck", "EX", "1")) + .map(e -> new HealthCheckProbeResult(getName(), true, null)) + .otherwise(th -> new HealthCheckProbeResult(getName(), false, new JsonObject().put("error", th.getMessage()))); + } +} diff --git a/common/src/main/java/org/entcore/common/http/response/DefaultResponseHandler.java b/common/src/main/java/org/entcore/common/http/response/DefaultResponseHandler.java index c1d37282d0..bf5079212d 100644 --- a/common/src/main/java/org/entcore/common/http/response/DefaultResponseHandler.java +++ b/common/src/main/java/org/entcore/common/http/response/DefaultResponseHandler.java @@ -32,6 +32,8 @@ import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import static org.entcore.common.http.filter.AppOAuthResourceProvider.getTokenHeader; + +import org.entcore.common.storage.Storage; import org.entcore.common.user.UserInfos; import org.entcore.common.user.UserUtils; import static org.entcore.common.user.UserUtils.getSessionIdOrTokenId; @@ -287,8 +289,7 @@ public void handle(Either event) { }; } - public static Handler> reportResponseHandler(final Vertx vertx, final String path, - final HttpServerRequest request) { + public static Handler> reportResponseHandler(final Vertx vertx, Storage storage, final String path, final HttpServerRequest request) { return new Handler>() { @Override public void handle(Either event) { @@ -298,7 +299,7 @@ public void handle(Either event) { JsonObject error = new JsonObject().put("errors", event.left().getValue()); Renders.renderJson(request, error, 400); } - deleteImportPath(vertx, path); + deleteImportPath(vertx, storage, path); } }; } diff --git a/common/src/main/java/org/entcore/common/messaging/IMessagingClientFactory.java b/common/src/main/java/org/entcore/common/messaging/IMessagingClientFactory.java index d3c53cac78..39d5a7dea4 100644 --- a/common/src/main/java/org/entcore/common/messaging/IMessagingClientFactory.java +++ b/common/src/main/java/org/entcore/common/messaging/IMessagingClientFactory.java @@ -1,5 +1,7 @@ package org.entcore.common.messaging; +import io.vertx.core.Future; + /** * Service that creates a messaging client. */ @@ -7,5 +9,5 @@ public interface IMessagingClientFactory { /** * @return A messaging client */ - IMessagingClient create(); + Future create(); } diff --git a/common/src/main/java/org/entcore/common/messaging/RedisStreamClientFactory.java b/common/src/main/java/org/entcore/common/messaging/RedisStreamClientFactory.java index 3b780c4d02..e6d532ec0d 100644 --- a/common/src/main/java/org/entcore/common/messaging/RedisStreamClientFactory.java +++ b/common/src/main/java/org/entcore/common/messaging/RedisStreamClientFactory.java @@ -1,5 +1,6 @@ package org.entcore.common.messaging; +import io.vertx.core.Future; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import static org.apache.commons.lang3.StringUtils.isEmpty; @@ -63,20 +64,21 @@ public RedisStreamClientFactory(final Vertx vertx, final JsonObject config){ } @Override - public IMessagingClient create() { - final RedisClient redis; + public Future create() { try { - redis = RedisClient.create(vertx, config); + return RedisClient.create(vertx, config) + .map(redis -> { + final String stream = config.getString("stream"); + if(isEmpty(stream)) { + throw new MessagingException("missing stream name in redis stream configuration"); + } + final int consumerBlockMs = config.getInteger("consumer-block-ms", DEFAULT_BLOCK_MS); + final String consumerName = config.getString("consumer-name"); + final String consumerGroup = config.getString("consumer-group"); + return new RedisMessagingClient(vertx, redis, stream, consumerGroup, consumerName, consumerBlockMs); + }); } catch (Exception e) { throw new MessagingException("redis.client.creation.error", e); } - final String stream = config.getString("stream"); - if(isEmpty(stream)) { - throw new MessagingException("missing stream name in redis stream configuration"); - } - final int consumerBlockMs = config.getInteger("consumer-block-ms", DEFAULT_BLOCK_MS); - final String consumerName = config.getString("consumer-name"); - final String consumerGroup = config.getString("consumer-group"); - return new RedisMessagingClient(vertx, redis, stream, consumerGroup, consumerName, consumerBlockMs); } } diff --git a/common/src/main/java/org/entcore/common/messaging/impl/MonitoredMessagingClientFactory.java b/common/src/main/java/org/entcore/common/messaging/impl/MonitoredMessagingClientFactory.java index bdbae70c75..440b6d3d8a 100644 --- a/common/src/main/java/org/entcore/common/messaging/impl/MonitoredMessagingClientFactory.java +++ b/common/src/main/java/org/entcore/common/messaging/impl/MonitoredMessagingClientFactory.java @@ -1,5 +1,6 @@ package org.entcore.common.messaging.impl; +import io.vertx.core.Future; import io.vertx.core.Vertx; import org.entcore.common.messaging.IMessagingClient; import org.entcore.common.messaging.IMessagingClientFactory; @@ -25,7 +26,7 @@ public MonitoredMessagingClientFactory(final IMessagingClientFactory factory, fi } @Override - public IMessagingClient create() { - return new MessagingClientWithMetrics(factory.create(), metricsRecorder); + public Future create() { + return factory.create().map(client -> new MessagingClientWithMetrics(client, metricsRecorder)); } } diff --git a/common/src/main/java/org/entcore/common/messaging/impl/NoopMessagingClientFactory.java b/common/src/main/java/org/entcore/common/messaging/impl/NoopMessagingClientFactory.java index 542065fac9..344e885dda 100644 --- a/common/src/main/java/org/entcore/common/messaging/impl/NoopMessagingClientFactory.java +++ b/common/src/main/java/org/entcore/common/messaging/impl/NoopMessagingClientFactory.java @@ -1,13 +1,16 @@ package org.entcore.common.messaging.impl; +import io.vertx.core.Future; import org.entcore.common.messaging.IMessagingClient; import org.entcore.common.messaging.IMessagingClientFactory; +import static io.vertx.core.Future.succeededFuture; + public class NoopMessagingClientFactory implements IMessagingClientFactory { public static final NoopMessagingClientFactory instance = new NoopMessagingClientFactory(); @Override - public IMessagingClient create() { - return IMessagingClient.noop; + public Future create() { + return succeededFuture(IMessagingClient.noop); } private NoopMessagingClientFactory() {} } diff --git a/common/src/main/java/org/entcore/common/mongodb/MongoClientFactory.java b/common/src/main/java/org/entcore/common/mongodb/MongoClientFactory.java index 9f50618407..f7151f566a 100644 --- a/common/src/main/java/org/entcore/common/mongodb/MongoClientFactory.java +++ b/common/src/main/java/org/entcore/common/mongodb/MongoClientFactory.java @@ -1,23 +1,32 @@ package org.entcore.common.mongodb; +import io.vertx.core.Future; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import io.vertx.ext.mongo.MongoClient; +import static io.vertx.core.Future.failedFuture; +import static io.vertx.core.Future.succeededFuture; + public class MongoClientFactory { - public static MongoClient create(final Vertx vertx, final JsonObject config) throws Exception{ + public static Future create(final Vertx vertx, final JsonObject config) { if (config.getJsonObject("mongoConfig") != null) { final JsonObject mongoConfig = config.getJsonObject("mongoConfig"); final MongoClient mongoClient = MongoClient.create(vertx, mongoConfig); - return mongoClient; + return succeededFuture(mongoClient); }else{ - final String mongoConfig = (String) vertx.sharedData().getLocalMap("server").get("mongoConfig"); - if(mongoConfig!=null){ + return vertx.sharedData().getLocalAsyncMap("server") + .flatMap(map -> map.get("mongoConfig")) + .flatMap(mongoConfig -> { + final Future future; + if (mongoConfig != null) { final MongoClient mongoClient = MongoClient.create(vertx, new JsonObject(mongoConfig)); - return mongoClient; - }else{ - throw new Exception("Missing mongoConfig config"); - } + future = succeededFuture(mongoClient); + } else { + future = failedFuture("Missing mongoConfig config"); + } + return future; + }); } } } diff --git a/common/src/main/java/org/entcore/common/neo4j/Neo.java b/common/src/main/java/org/entcore/common/neo4j/Neo.java index 47b9986bbf..e03c4b7d22 100644 --- a/common/src/main/java/org/entcore/common/neo4j/Neo.java +++ b/common/src/main/java/org/entcore/common/neo4j/Neo.java @@ -35,7 +35,7 @@ public class Neo { private Neo4j neo4j; - public Neo (Vertx vertx, EventBus eb, Logger log) { + public Neo (Vertx vertx, EventBus eb, Object log) { neo4j = Neo4j.getInstance(); } diff --git a/common/src/main/java/org/entcore/common/neo4j/Neo4j.java b/common/src/main/java/org/entcore/common/neo4j/Neo4j.java index 3bec27fa8c..08287b3a28 100644 --- a/common/src/main/java/org/entcore/common/neo4j/Neo4j.java +++ b/common/src/main/java/org/entcore/common/neo4j/Neo4j.java @@ -59,6 +59,7 @@ public static Neo4j getSpecificInstance() { } public void init(Vertx vertx, JsonObject config) { + log.info("Neo4j config : " + config.encode()); this.eb = Server.getEventBus(vertx); JsonArray serverUris = config.getJsonArray("server-uris"); String serverUri = config.getString("server-uri"); @@ -78,7 +79,7 @@ public void init(Vertx vertx, JsonObject config) { config.getBoolean("keepAlive", true), config); } catch (Exception e) { - log.error(e.getMessage(), e); + log.error("An error occurred while initializing Neo4j", e); } } else { log.error("Invalid Neo4j URI"); diff --git a/common/src/main/java/org/entcore/common/neo4j/Neo4jRestClientCheckNotifier.java b/common/src/main/java/org/entcore/common/neo4j/Neo4jRestClientCheckNotifier.java index cd7cb0cad4..4cb0e22530 100644 --- a/common/src/main/java/org/entcore/common/neo4j/Neo4jRestClientCheckNotifier.java +++ b/common/src/main/java/org/entcore/common/neo4j/Neo4jRestClientCheckNotifier.java @@ -40,7 +40,7 @@ public class Neo4jRestClientCheckNotifier implements Neo4jRestClientCheck{ final EventStoreFactory eventFac = EventStoreFactory.getFactory(); eventFac.setVertx(vertx); eventStore = eventFac.getEventStore(BaseServer.getModuleName()); - emailSender = new EmailFactory(vertx).getSenderWithPriority(EmailFactory.PRIORITY_VERY_HIGH); + emailSender = EmailFactory.getInstance().getSenderWithPriority(EmailFactory.PRIORITY_VERY_HIGH); //mails this.emailAlertSubject = neo4jConfig.getString("email-alerts-subject", "[NEO4J] Noeuds down: "); this.emailAlertMinDown = neo4jConfig.getInteger("email-alerts-mindown", 2); diff --git a/common/src/main/java/org/entcore/common/notification/TimelineHelper.java b/common/src/main/java/org/entcore/common/notification/TimelineHelper.java index ec6ae39dfa..e5993dbb69 100644 --- a/common/src/main/java/org/entcore/common/notification/TimelineHelper.java +++ b/common/src/main/java/org/entcore/common/notification/TimelineHelper.java @@ -25,6 +25,7 @@ import io.vertx.core.*; import io.vertx.core.shareddata.LocalMap; +import io.vertx.core.shareddata.AsyncMap; import org.entcore.common.http.request.JsonHttpServerRequest; import org.entcore.common.user.UserInfos; import org.entcore.common.utils.StringUtils; @@ -49,6 +50,7 @@ public class TimelineHelper { + private static final int MAX_RETRY = 10; private static final String TIMELINE_ADDRESS = "wse.timeline"; private final static String messagesDir = FileResolver.absolutePath("i18n/timeline"); private final EventBus eb; @@ -295,24 +297,32 @@ public void handle(AsyncResult> asyncResult) { } private void appendTimelineEventsI18n(Map i18ns) { - LocalMap eventsI18n = vertx.sharedData().getLocalMap("timelineEventsI18n"); - for (Map.Entry e: i18ns.entrySet()) { - String json = e.getValue().encode(); - if (StringUtils.isEmpty(json) || "{}".equals(StringUtils.stripSpaces(json))) continue; - String j = json.substring(1, json.length() - 1) + ","; - String resJson = j; - String oldJson = eventsI18n.putIfAbsent(e.getKey(), j); - if (oldJson != null && !oldJson.equals(j)) { - resJson += oldJson; - boolean appended = eventsI18n.replace(e.getKey(), oldJson, resJson); - while (!appended) { - oldJson = eventsI18n.get(e.getKey()); - resJson = j; - resJson += oldJson; - appended = eventsI18n.replace(e.getKey(), oldJson, resJson); - } + vertx.sharedData().getAsyncMap("timelineEventsI18n").onSuccess(eventsI18n-> { + for (Map.Entry e: i18ns.entrySet()) { + String json = e.getValue().encode(); + if (StringUtils.isEmpty(json) || "{}".equals(StringUtils.stripSpaces(json))) continue; + final String j = json.substring(1, json.length() - 1) + ","; + eventsI18n.putIfAbsent(e.getKey(), j) + .onSuccess(oldJson -> replaceEventsI18n(eventsI18n, e.getKey(), oldJson, j, 0)) + .onFailure(ex -> log.error("Error when try put eventsI18n on key " + e.getKey(), ex)); } + }); + } + + private void replaceEventsI18n(AsyncMap eventsI18n, String key, String old, String append, int retry) { + if (old == null || old.equals(append) || retry > MAX_RETRY) { + if (retry > MAX_RETRY) { + log.warn("Replace eventi18n not updated after max retries : " + retry); + } + return; } + eventsI18n.replaceIfPresent(key, old, (old + append)).onSuccess(updated -> { + if (!updated) { + eventsI18n.get(key) + .onSuccess(old2 -> replaceEventsI18n(eventsI18n, key, old2, append, retry + 1)) + .onFailure(ex -> log.error("Error when update eventsI18n on key " + key, ex)); + } + }); } } diff --git a/common/src/main/java/org/entcore/common/pdf/NodePdfClient.java b/common/src/main/java/org/entcore/common/pdf/NodePdfClient.java index 7dc06d397d..b7845ce647 100644 --- a/common/src/main/java/org/entcore/common/pdf/NodePdfClient.java +++ b/common/src/main/java/org/entcore/common/pdf/NodePdfClient.java @@ -47,13 +47,14 @@ public class NodePdfClient implements PdfGenerator { private static final Logger log = LoggerFactory.getLogger(NodePdfClient.class); - private final Vertx vertx; - private final HttpClient client; - private final String authHeader; - private final String clientId; - private final PdfMetricsRecorder metricsRecorder; + private Vertx vertx; + private HttpClient client; + private String authHeader; + private String clientId; + private PdfMetricsRecorder metricsRecorder; + private String signKey; - public NodePdfClient(Vertx vertx, JsonObject conf) throws URISyntaxException { + public void init(Vertx vertx, JsonObject conf, String signKey, String metricsOptions) throws URISyntaxException { this.vertx = vertx; this.authHeader = "Basic " + conf.getString("auth"); this.clientId = conf.getString("pdf-connector-id"); @@ -67,8 +68,9 @@ public NodePdfClient(Vertx vertx, JsonObject conf) throws URISyntaxException { .setHttp2KeepAliveTimeout(conf.getInteger("pdf-keepalive-timeout", HttpClientOptions.DEFAULT_HTTP2_KEEP_ALIVE_TIMEOUT)) .setDefaultHost(uri.getHost()).setDefaultPort(uri.getPort()).setSsl("https".equals(uri.getScheme())); this.client = vertx.createHttpClient(options); - PdfMetricsRecorderFactory.init(vertx, conf); + PdfMetricsRecorderFactory.init(vertx, conf, metricsOptions); this.metricsRecorder = PdfMetricsRecorderFactory.getPdfMetricsRecorder(); + this.signKey = signKey; } @Override @@ -230,7 +232,7 @@ private Buffer multipartBody(String name, String token, String content, String b @Override public String createToken(UserInfos user) throws Exception { - final String token = UserUtils.createJWTToken(vertx, user, clientId, null); + final String token = UserUtils.createJWTToken(vertx, user, clientId, null, signKey); if (isEmpty(token)) { throw new PdfException("invalid.token"); } diff --git a/common/src/main/java/org/entcore/common/pdf/PdfFactory.java b/common/src/main/java/org/entcore/common/pdf/PdfFactory.java index 0ae0518b7a..67204032d1 100644 --- a/common/src/main/java/org/entcore/common/pdf/PdfFactory.java +++ b/common/src/main/java/org/entcore/common/pdf/PdfFactory.java @@ -21,39 +21,46 @@ import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; -import io.vertx.core.shareddata.LocalMap; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; +import fr.wseduc.webutils.collections.SharedDataHelper; + +import java.net.URISyntaxException; + import org.entcore.common.pdf.metrics.PdfMetricsRecorderFactory; public class PdfFactory { - private final Vertx vertx; - private JsonObject node; + private static final Logger log = LoggerFactory.getLogger(PdfFactory.class); + private final PdfGenerator pdfGenerator; public PdfFactory(Vertx vertx) { this(vertx, null); } public PdfFactory(Vertx vertx, JsonObject config) { - this.vertx = vertx; - LocalMap server = vertx.sharedData().getLocalMap("server"); - String s = (String) server.get("node-pdf-generator"); - if (s != null) { - this.node = new JsonObject(s); - } - - if (config != null && config.getJsonObject("node-pdf-generator") != null) { - this.node = config.getJsonObject("node-pdf-generator"); - } - PdfMetricsRecorderFactory.init(vertx, config); + this.pdfGenerator = new NodePdfClient(); + SharedDataHelper.getInstance().getLocalMulti("server", "node-pdf-generator", "signKey", "metricsOptions").onSuccess(serverMap -> { + JsonObject node = null; + final String nodePdfConfig = serverMap.get("node-pdf-generator"); + final String signKey = serverMap.get("signKey"); + if (nodePdfConfig != null) { + node = new JsonObject(nodePdfConfig); + } + + if (config != null && config.getJsonObject("node-pdf-generator") != null) { + node = config.getJsonObject("node-pdf-generator"); + } + try { + ((NodePdfClient) pdfGenerator).init(vertx, node, signKey, serverMap.get("metricsOptions")); + } catch (URISyntaxException e) { + log.error("Error when init node pdf generator client", e); + } + PdfMetricsRecorderFactory.init(vertx, config, serverMap.get("metricsOptions")); + }).onFailure(ex -> log.error("Error getting node-pdf-generator config in server map", ex)); } - public PdfGenerator getPdfGenerator() throws Exception { - PdfGenerator pdfGenerator = null; - if (node != null) { - pdfGenerator = new NodePdfClient(vertx, node); - } else { - throw new PdfException("no.pdf.generator.found"); - } + public PdfGenerator getPdfGenerator() { return pdfGenerator; } diff --git a/common/src/main/java/org/entcore/common/pdf/metrics/PdfMetricsRecorderFactory.java b/common/src/main/java/org/entcore/common/pdf/metrics/PdfMetricsRecorderFactory.java index cb43d2bb18..c494ba9d7b 100644 --- a/common/src/main/java/org/entcore/common/pdf/metrics/PdfMetricsRecorderFactory.java +++ b/common/src/main/java/org/entcore/common/pdf/metrics/PdfMetricsRecorderFactory.java @@ -2,6 +2,8 @@ import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; import io.vertx.core.metrics.MetricsOptions; /** * Creates the singleton that will record metrics of the pdf generator client. @@ -9,11 +11,14 @@ * configured then it creates a dummy recorder that records nothing. */ public class PdfMetricsRecorderFactory { + + private static final Logger log = LoggerFactory.getLogger(PdfMetricsRecorderFactory.class); + private static Vertx vertx; private static MetricsOptions metricsOptions; private static PdfMetricsRecorder ingestJobMetricsRecorder; private static JsonObject config; - public static void init(final Vertx vertx, final JsonObject config){ + public static void init(final Vertx vertx, final JsonObject config, String metricsOptions){ if(PdfMetricsRecorderFactory.vertx != null){ // already init return; @@ -21,14 +26,13 @@ public static void init(final Vertx vertx, final JsonObject config){ PdfMetricsRecorderFactory.vertx = vertx; PdfMetricsRecorderFactory.config = config == null? new JsonObject() : config; if(PdfMetricsRecorderFactory.config.getJsonObject("metricsOptions") == null) { - final String metricsOptions = (String) vertx.sharedData().getLocalMap("server").get("metricsOptions"); if(metricsOptions == null){ PdfMetricsRecorderFactory.metricsOptions = new MetricsOptions().setEnabled(false); }else{ PdfMetricsRecorderFactory.metricsOptions = new MetricsOptions(new JsonObject(metricsOptions)); } } else { - metricsOptions = new MetricsOptions(PdfMetricsRecorderFactory.config.getJsonObject("metricsOptions")); + PdfMetricsRecorderFactory.metricsOptions = new MetricsOptions(PdfMetricsRecorderFactory.config.getJsonObject("metricsOptions")); } } diff --git a/common/src/main/java/org/entcore/common/postgres/IPostgresClient.java b/common/src/main/java/org/entcore/common/postgres/IPostgresClient.java index 2177ecd81c..43a96c8441 100644 --- a/common/src/main/java/org/entcore/common/postgres/IPostgresClient.java +++ b/common/src/main/java/org/entcore/common/postgres/IPostgresClient.java @@ -1,6 +1,5 @@ package org.entcore.common.postgres; -import fr.wseduc.webutils.security.Md5; import io.vertx.codegen.annotations.Nullable; import io.vertx.core.Future; import io.vertx.core.Promise; @@ -13,6 +12,9 @@ import java.util.function.Function; +import static io.vertx.core.Future.failedFuture; +import static io.vertx.core.Future.succeededFuture; + public interface IPostgresClient { Future> preparedQuery(String query, Tuple tuple); @@ -22,32 +24,44 @@ public interface IPostgresClient { PostgresClientChannel getClientChannel(); - static IPostgresClient create(final Vertx vertx, final JsonObject config, final boolean worker, final boolean pool) throws Exception{ - final JsonObject postgresConfig = getPostgresConfig(vertx, config); - final PostgresClient baseClient = new PostgresClient(vertx, postgresConfig); - final IPostgresClient postgresClient = pool? baseClient.getClientPool(): baseClient; - return postgresClient; + static Future create(final Vertx vertx, final JsonObject config, final boolean worker, final boolean pool) { + try { + return getPostgresConfig(vertx, config) + .map(postgresConfig -> { + final PostgresClient baseClient = new PostgresClient(vertx, postgresConfig); + return pool ? baseClient.getClientPool() : baseClient; + }); + } catch (Exception e) { + return failedFuture(e); + } } - static JsonObject getPostgresConfig(final Vertx vertx, final JsonObject config) throws Exception{ + static Future getPostgresConfig(final Vertx vertx, final JsonObject config) throws Exception{ if (config.getJsonObject("postgresConfig") != null) { final JsonObject postgresqlConfig = config.getJsonObject("postgresConfig"); - return postgresqlConfig; - }else{ - final String postgresConfig = (String) vertx.sharedData().getLocalMap("server").get("postgresConfig"); - if(postgresConfig!=null){ - return new JsonObject(postgresConfig); - }else{ - throw new Exception("Missing postgresConfig config"); - } + return succeededFuture(postgresqlConfig); + } else { + return vertx.sharedData().getLocalAsyncMap("server") + .flatMap(m -> m.get("postgresConfig")) + .flatMap(postgresConfig -> { + final Future pgConf; + if (postgresConfig != null) { + pgConf = succeededFuture(new JsonObject(postgresConfig)); + } else { + pgConf = failedFuture("Missing postgresConfig config"); + } + return pgConf; + }); } } - static PostgresClientChannel createChannel(final Vertx vertx, final JsonObject config) throws Exception { - final JsonObject realConfig = getPostgresConfig(vertx, config); - final PgSubscriber pgSubscriber = PgSubscriber.subscriber(vertx, IPostgresClient.getConnectOption(realConfig)); - return new PostgresClientChannel(pgSubscriber, config); + static Future createChannel(final Vertx vertx, final JsonObject config) throws Exception { + return getPostgresConfig(vertx, config) + .map(realConfig -> { + final PgSubscriber pgSubscriber = PgSubscriber.subscriber(vertx, IPostgresClient.getConnectOption(realConfig)); + return new PostgresClientChannel(pgSubscriber, config); + }); } static PgConnectOptions getConnectOption(final JsonObject config){ diff --git a/common/src/main/java/org/entcore/common/redis/Redis.java b/common/src/main/java/org/entcore/common/redis/Redis.java index 6c6c858780..dac43b4449 100644 --- a/common/src/main/java/org/entcore/common/redis/Redis.java +++ b/common/src/main/java/org/entcore/common/redis/Redis.java @@ -19,9 +19,12 @@ package org.entcore.common.redis; +import io.vertx.core.Future; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; +import static io.vertx.core.Future.succeededFuture; + public class Redis { private RedisClient redisClient; @@ -39,9 +42,12 @@ public static Redis getInstance() { return RedisHolder.instance; } - public void init(Vertx vertx, JsonObject redisConfig) { - this.redisClient = RedisClient.create(vertx, new JsonObject().put("redisConfig", redisConfig)); - RedisHolder.redisConfig = redisConfig; + public Future init(Vertx vertx, JsonObject redisConfig) { + return RedisClient.create(vertx, new JsonObject().put("redisConfig", redisConfig)) + .onSuccess(redisClient -> { + this.redisClient = redisClient; + RedisHolder.redisConfig = redisConfig; + }).mapEmpty(); } public RedisClient getRedisClient() { @@ -52,9 +58,9 @@ public static RedisClient getClient() { return getInstance().getRedisClient(); } - public static RedisClient createClientForDb(Vertx vertx, Integer db) { + public static Future createClientForDb(Vertx vertx, Integer db) { if(RedisHolder.redisConfig.getInteger("select", 0).equals(db)) { - return getInstance().getRedisClient(); + return succeededFuture(getInstance().getRedisClient()); } final JsonObject newRedisConfig = RedisHolder.redisConfig.copy(); newRedisConfig.put("select", db); diff --git a/common/src/main/java/org/entcore/common/redis/RedisClient.java b/common/src/main/java/org/entcore/common/redis/RedisClient.java index bff3c7f0e9..482e84510a 100644 --- a/common/src/main/java/org/entcore/common/redis/RedisClient.java +++ b/common/src/main/java/org/entcore/common/redis/RedisClient.java @@ -1,5 +1,6 @@ package org.entcore.common.redis; +import fr.wseduc.webutils.collections.SharedDataHelper; import io.vertx.core.*; import io.vertx.redis.client.*; import io.vertx.core.json.JsonObject; @@ -36,19 +37,21 @@ public RedisClient(final RedisAPI redis, final RedisOptions redisOptions) { * @param config An object containing a field redisConfig which holds redis configuration * @return A client to call Redis */ - public static RedisClient create(final Vertx vertx, final JsonObject config) { + public static Future create(final Vertx vertx, final JsonObject config) { if (config.getJsonObject("redisConfig") != null) { final JsonObject redisConfig = config.getJsonObject("redisConfig"); final RedisClient redisClient = new RedisClient(vertx, redisConfig); - return redisClient; + return Future.succeededFuture(redisClient); }else{ - final String redisConfig = (String) vertx.sharedData().getLocalMap("server").get("redisConfig"); - if(redisConfig!=null){ - final RedisClient redisClient = new RedisClient(vertx, new JsonObject(redisConfig)); - return redisClient; - }else{ + return SharedDataHelper.getInstance().getLocalMulti("server", "redisConfig") + .map(map -> map.get("redisConfig")) + .map(redisConfig -> { + if (redisConfig != null) { + return new RedisClient(vertx, new JsonObject(redisConfig)); + } else { throw new InvalidParameterException("Missing redisConfig config"); - } + } + }); } } diff --git a/common/src/main/java/org/entcore/common/resources/ResourceBrokerRepositoryEvents.java b/common/src/main/java/org/entcore/common/resources/ResourceBrokerRepositoryEvents.java index 5b0e39a6ee..4845464ecb 100644 --- a/common/src/main/java/org/entcore/common/resources/ResourceBrokerRepositoryEvents.java +++ b/common/src/main/java/org/entcore/common/resources/ResourceBrokerRepositoryEvents.java @@ -1,6 +1,5 @@ package org.entcore.common.resources; -import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.Vertx; import io.vertx.core.json.JsonArray; @@ -13,6 +12,7 @@ import org.entcore.broker.api.utils.AddressParameter; import org.entcore.broker.proxy.ResourceBrokerPublisher; import org.entcore.common.user.RepositoryEvents; +import org.entcore.common.user.ExportResourceResult; import java.util.ArrayList; import java.util.List; @@ -152,13 +152,12 @@ private void notifyResourceDeletion(Set deletedResourceIds, String trigg @Override public void exportResources(boolean exportDocuments, boolean exportSharedResources, String exportId, String userId, JsonArray groups, String exportPath, - String locale, String host, Handler handler) { + String locale, String host, Handler handler) { delegateEvents.exportResources(exportDocuments, exportSharedResources, exportId, userId, groups, exportPath, locale, host, handler); } @Override - public void exportResources(JsonArray resourcesIds, boolean exportDocuments, boolean exportSharedResources, String exportId, String userId, - JsonArray groups, String exportPath, String locale, String host, Handler handler) { + public void exportResources(JsonArray resourcesIds, boolean exportDocuments, boolean exportSharedResources, String exportId, String userId, JsonArray groups, String exportPath, String locale, String host, Handler handler) { delegateEvents.exportResources(resourcesIds, exportDocuments, exportSharedResources, exportId, userId, groups, exportPath, locale, host, handler); } diff --git a/common/src/main/java/org/entcore/common/s3/S3Client.java b/common/src/main/java/org/entcore/common/s3/S3Client.java index 52771d4351..5890d17cce 100644 --- a/common/src/main/java/org/entcore/common/s3/S3Client.java +++ b/common/src/main/java/org/entcore/common/s3/S3Client.java @@ -16,6 +16,14 @@ package org.entcore.common.s3; +import io.vertx.core.*; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.file.OpenOptions; +import io.vertx.core.http.*; +import io.vertx.core.json.JsonObject; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; +import io.vertx.core.streams.ReadStream; import org.apache.commons.codec.DecoderException; import org.apache.commons.codec.net.QuotedPrintableCodec; import org.entcore.common.s3.exception.SignatureException; @@ -32,22 +40,12 @@ import org.w3c.dom.NodeList; import org.xml.sax.SAXException; -import io.vertx.core.file.OpenOptions; - -import io.vertx.core.*; -import io.vertx.core.buffer.Buffer; -import io.vertx.core.http.*; -import io.vertx.core.json.JsonObject; -import io.vertx.core.logging.Logger; -import io.vertx.core.logging.LoggerFactory; -import io.vertx.core.streams.ReadStream; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.UnsupportedEncodingException; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.*; import java.net.URI; +import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.channels.ClosedChannelException; import java.nio.charset.StandardCharsets; @@ -56,19 +54,10 @@ import java.nio.file.Paths; import java.text.ParseException; import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.TimeZone; -import java.util.UUID; +import java.util.*; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; - public class S3Client { private static final Logger log = LoggerFactory.getLogger(S3Client.class); @@ -550,13 +539,19 @@ public void deleteFile(String id, final Handler> handler) { deleteFile(id, defaultBucket, handler); } - public void deleteFile(String id, String bucket, final Handler> handler) { - id = getPath(id); + public void deleteFile(String id, String bucket, final Handler> handler) { + deleteFileWithPath(getPath(id), bucket, handler); + } + + public void deleteFileWithPath(String path, final Handler> handler) { + deleteFileWithPath(path, defaultBucket, handler); + } + public void deleteFileWithPath(String path, String bucket, final Handler> handler) { RequestOptions requestOptions = new RequestOptions() .setMethod(HttpMethod.DELETE) .setHost(host) - .setURI("/" + bucket + "/" + id); + .setURI("/" + bucket + "/" + encodeUrlPath(path)); httpClient.request(requestOptions) .flatMap(req -> { @@ -626,12 +621,21 @@ public void writeToFileSystem(String id, String destination, Handler> handler) { - final String fileId = getPath(id); + final String fileId = getPath(id); + writeToFileSystemWithId(fileId, destination, bucket, handler); + } + + public void writeToFileSystemWithId(String id, final String destination, + final Handler> handler) { + writeToFileSystemWithId(id, destination, defaultBucket, handler); + } + public void writeToFileSystemWithId(String id, final String destination, String bucket, + final Handler> handler) { RequestOptions requestOptions = new RequestOptions() .setMethod(HttpMethod.GET) .setHost(host) - .setURI("/" + bucket + "/" + fileId); + .setURI("/" + bucket + "/" + encodeUrlPath(id)); httpClient.request(requestOptions) .flatMap(req -> { @@ -648,22 +652,27 @@ public void writeToFileSystem(String id, final String destination, String bucket .onSuccess(response -> { response.pause(); if (response.statusCode() == 200) { - vertx.fileSystem().open(destination, new OpenOptions(), ar -> { - if (ar.succeeded()) { - response.pipeTo(ar.result(), aVoid -> { - if(aVoid.succeeded()) { - log.info(id + " file successfully downloaded from S3"); - handler.handle(new DefaultAsyncResult<>(destination)); - } else { - final String message = "An error occurred while piping " + id + " to " + destination; - log.error(message, aVoid.cause()); - handler.handle(new DefaultAsyncResult<>(new StorageException(message, aVoid.cause()))); - } - }); - } else { - handler.handle(new DefaultAsyncResult<>(ar.cause())); - } - }); + final String dest = decodePath(destination); + createParentsIfNeeded(dest) + .onSuccess(e -> { + vertx.fileSystem().open(dest, new OpenOptions(), ar -> { + if (ar.succeeded()) { + response.pipeTo(ar.result(), aVoid -> { + if (aVoid.succeeded()) { + log.info(id + " file successfully downloaded from S3 to " + dest); + handler.handle(new DefaultAsyncResult<>(dest)); + } else { + final String message = "An error occurred while piping " + id + " to " + dest; + log.error(message, aVoid.cause()); + handler.handle(new DefaultAsyncResult<>(new StorageException(message, aVoid.cause()))); + } + }); + } else { + handler.handle(new DefaultAsyncResult<>(ar.cause())); + } + }); + }) + .onFailure(th -> handler.handle(new DefaultAsyncResult<>(th))); } else { handler.handle(new DefaultAsyncResult<>(new StorageException(response.statusMessage()))); } @@ -673,7 +682,13 @@ public void writeToFileSystem(String id, final String destination, String bucket }); } - public void writeFromFileSystem(final String id, String path, final Handler handler) { + private Future createParentsIfNeeded(final String destination) { + final File file = new File(destination); + final File parent = file.getParentFile(); + return vertx.fileSystem().mkdirs(parent.getAbsolutePath()); + } + + public void writeFromFileSystem(final String id, String path, final Handler handler) { writeFromFileSystem(id, path, defaultBucket, handler); } @@ -695,6 +710,54 @@ public void writeFromFileSystem(final String id, String path, final String bucke }); } + public Future writeFromFileSystem(final String s3Path, String fsPath) { + final Promise promise = Promise.promise(); + MultipartUpload multipartUpload = new MultipartUpload(vertx, httpClient, host, accessKey, secretKey, region, defaultBucket, ssec); + final String id = encodeUrlPath(s3Path); + multipartUpload.upload(fsPath, id, result -> promise.complete(new JsonObject().put("_id", id).put("status", result.getString("status")).put("message", result.getValue("message")))); + return promise.future(); + } + + + public static String encodeUrlPath(String path) { + String[] segments = path.split("/"); + StringBuilder result = new StringBuilder(); + + for (int i = 0; i < segments.length; i++) { + if (i > 0) { + result.append("/"); + } + // Encode each segment separately + try { + result.append(URLEncoder.encode(segments[i], StandardCharsets.UTF_8.name())); + } catch (UnsupportedEncodingException e) { + result.append(URLEncoder.encode(segments[i])); + } + } + + return result.toString(); + } + + + public static String decodePath(String path) { + String[] segments = path.split("/"); + StringBuilder result = new StringBuilder(); + + for (int i = 0; i < segments.length; i++) { + if (i > 0) { + result.append("/"); + } + // Encode each segment separately + try { + result.append(URLDecoder.decode(segments[i], StandardCharsets.UTF_8.name())); + } catch (UnsupportedEncodingException e) { + result.append(URLDecoder.decode(segments[i])); + } + } + + return result.toString(); + } + public void writeBufferStream(final String id, ReadStream bufferReadStream, String contentType, String filename, Handler> handler) { bufferReadStream.pause(); final String idPrefixed = getPath(id); @@ -938,4 +1001,138 @@ public static String getUuid(final String path) { return (separatorIndex < 0) ? path : path.substring(separatorIndex+1); } + /** + * + * @param prefix + * @return + */ + public Future> listFilesByPrefix(final String prefix) { + Promise> promise = Promise.promise(); + List allObjects = new ArrayList<>(); + + listBucketRecursive(prefix, null, allObjects, promise); + + return promise.future(); + } + + private void listBucketRecursive(final String prefix, String continuationToken, + List allObjects, Promise> promise) { + + final StringBuilder url = new StringBuilder().append('/').append(defaultBucket).append("/?list-type=2"); + if (prefix != null) { + try { + url.append("&prefix=").append(URLEncoder.encode(prefix, StandardCharsets.UTF_8.toString())); + } catch (UnsupportedEncodingException e) { + promise.fail(new StorageException("Error encoding prefix in listBucketRecursive method")); + return; + } + } + if (continuationToken != null) { + try { + url.append("&continuation-token=").append(URLEncoder.encode(continuationToken, StandardCharsets.UTF_8.toString())); + } catch (UnsupportedEncodingException e) { + promise.fail(new StorageException("Error encoding continuation token in listBucketRecursive method")); + return; + } + } + + RequestOptions requestOptions = new RequestOptions() + .setMethod(HttpMethod.GET) + .setHost(host) + .setURI(url.toString()); + + httpClient.request(requestOptions) + .flatMap(req -> { + AwsUtils.setSSEC(req, ssec); + try { + AwsUtils.sign(req, accessKey, secretKey, region); + } catch (SignatureException e) { + log.error("S3Client listFilesByPrefix, signature failed: " + e.getMessage(), e); + return Future.failedFuture("S3Client listFilesByPrefix, signature failed"); + } + return req.send(); + }) + .onSuccess(response -> { + response.pause(); + if (response.statusCode() == 200) { + response.bodyHandler(body -> { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document document = builder.parse(new ByteArrayInputStream(body.getBytes())); + + NodeList contents = document.getElementsByTagName("Contents"); + for (int i = 0; i < contents.getLength(); i++) { + Node content = contents.item(i); + NodeList children = content.getChildNodes(); + for (int j = 0; j < children.getLength(); j++) { + Node child = children.item(j); + if ("Key".equals(child.getNodeName())) { + String key = child.getTextContent(); + if (key != null && !key.trim().isEmpty()) { + allObjects.add(new S3FileInfo(getUuid(key), key)); + } + } + } + } + + // Check if there are more objects to retrieve + NodeList isTruncatedNodes = document.getElementsByTagName("IsTruncated"); + boolean isTruncated = false; + if (isTruncatedNodes.getLength() > 0) { + isTruncated = "true".equals(isTruncatedNodes.item(0).getTextContent()); + } + + if (isTruncated) { + NodeList nextContinuationTokenNodes = document.getElementsByTagName("NextContinuationToken"); + if (nextContinuationTokenNodes.getLength() > 0) { + String nextToken = nextContinuationTokenNodes.item(0).getTextContent(); + // Recursive call to get the next batch + listBucketRecursive(prefix, nextToken, allObjects, promise); + } else { + promise.complete(allObjects); + } + } else { + promise.complete(allObjects); + } + + } catch (ParserConfigurationException | SAXException | IOException e) { + promise.fail(new StorageException("Error parsing response: " + e.getMessage())); + } + }); + } else { + response.bodyHandler(bodyBuffer -> + log.error("An error occurred while listing files by prefix : HTTP code=" + response.statusCode() + " body=" + bodyBuffer.toString())); + promise.fail(new StorageException(response.statusCode() + " - " + response.statusMessage())); + } + response.resume(); + }) + .onFailure(exception -> { + promise.fail(new StorageException(exception.getMessage())); + }); + } + + public String getDefaultBucket() { + return defaultBucket; + } + + public static class S3FileInfo { + private final String id; + private final String path; + + public S3FileInfo(String id, String path) { + this.id = id; + this.path = path; + } + + public String getId() { + return id; + } + + public String getPath() { + return path; + } + + } + } diff --git a/common/src/main/java/org/entcore/common/s3/utils/AwsUtils.java b/common/src/main/java/org/entcore/common/s3/utils/AwsUtils.java index 4adbcf1639..d3a127307e 100644 --- a/common/src/main/java/org/entcore/common/s3/utils/AwsUtils.java +++ b/common/src/main/java/org/entcore/common/s3/utils/AwsUtils.java @@ -9,6 +9,8 @@ import org.entcore.common.s3.exception.SignatureException; import org.entcore.common.s3.storage.StorageObject; +import static fr.wseduc.webutils.Utils.isNotEmpty; + import java.io.IOException; import java.io.UnsupportedEncodingException; import java.nio.file.Files; @@ -21,6 +23,7 @@ import java.util.HashMap; import java.util.Map; +import static fr.wseduc.webutils.Utils.isNotEmpty; public class AwsUtils { private static final Logger log = LoggerFactory.getLogger(AwsUtils.class); @@ -100,9 +103,11 @@ private static String getOrComputeSSECKeyMD5Sum(final String ssecKey) { } public static void setSSEC(HttpClientRequest request, String ssec) { - request.putHeader("x-amz-server-side-encryption-customer-algorithm", "AES256"); - request.putHeader("x-amz-server-side-encryption-customer-key", ssec); - request.putHeader("x-amz-server-side-encryption-customer-key-MD5", getOrComputeSSECKeyMD5Sum(ssec)); + if (isNotEmpty(ssec)) { + request.putHeader("x-amz-server-side-encryption-customer-algorithm", "AES256"); + request.putHeader("x-amz-server-side-encryption-customer-key", ssec); + request.putHeader("x-amz-server-side-encryption-customer-key-MD5", getOrComputeSSECKeyMD5Sum(ssec)); + } } public static void setSSECCopy(HttpClientRequest request, String ssec) { diff --git a/common/src/main/java/org/entcore/common/s3/utils/MultipartUpload.java b/common/src/main/java/org/entcore/common/s3/utils/MultipartUpload.java index 8d251f3ff5..74d378f959 100644 --- a/common/src/main/java/org/entcore/common/s3/utils/MultipartUpload.java +++ b/common/src/main/java/org/entcore/common/s3/utils/MultipartUpload.java @@ -17,6 +17,7 @@ import org.apache.commons.codec.EncoderException; import org.apache.commons.codec.net.QuotedPrintableCodec; +import org.entcore.common.s3.S3Client; import org.entcore.common.s3.dataclasses.CompleteMultipartUpload; import org.entcore.common.s3.dataclasses.CompletePart; import org.entcore.common.s3.dataclasses.InitiateMultipartUploadResult; @@ -58,7 +59,8 @@ public MultipartUpload(final Vertx vertx, final ResilientHttpClient httpClient, this.ssec = ssec; } - public void upload(final String filepath, final String id, final Handler handler) { + + public void upload(final String filepath, final String id, final Handler handler) { init(id, Paths.get(filepath).getFileName().toString(), AwsUtils.getContentType(filepath), uploadId -> { if (uploadId == null) { handler.handle( @@ -103,7 +105,7 @@ public void init(final String id, final String filename, final String contentTyp RequestOptions requestOptions = new RequestOptions() .setMethod(HttpMethod.POST) .setHost(endPoint) - .setURI("/" + bucket + "/" + id + "?uploads="); + .setURI("/" + bucket + "/" + S3Client.encodeUrlPath(id) + "?uploads="); httpClient.request(requestOptions) .flatMap(req -> { @@ -138,20 +140,24 @@ public void init(final String id, final String filename, final String contentTyp initiateMultipartUploadResult = (InitiateMultipartUploadResult) unmarshaller.unmarshal(stringReader); } catch (JAXBException e) { + log.error("An error occurred while deserializing the response body of the upload of file id="+id + " filename=" + filename + ":" + bodyBuffer); handler.handle(null); return; } if (initiateMultipartUploadResult.getUploadId() == null) { + log.error("No uploadId received for the upload of file id="+id + " filename=" + filename + ":" + bodyBuffer); handler.handle(null); return; } handler.handle(initiateMultipartUploadResult.getUploadId()); }); - } - else { - handler.handle(null); + } else { + response.bodyHandler(bodyBuffer -> { + log.error("An error occurred while upload file id=" + id + " filename=" + filename + " HTTP code=" + response.statusCode() + " body=" + bodyBuffer.toString()); + }); + handler.handle(null); } }) .onFailure(exception -> { @@ -228,7 +234,7 @@ public void uploadPart(final String id, final String uploadId, final Chunk chunk RequestOptions requestOptions = new RequestOptions() .setMethod(HttpMethod.PUT) .setHost(endPoint) - .setURI("/" + bucket + "/" + id + "?partNumber=" + chunk.getChunkNumber() + "&uploadId=" + uploadId); + .setURI("/" + bucket + "/" + S3Client.encodeUrlPath(id) + "?partNumber=" + chunk.getChunkNumber() + "&uploadId=" + uploadId); httpClient.request(requestOptions) .flatMap(req -> { @@ -278,7 +284,7 @@ public void complete(final String id, final String uploadId, final List RequestOptions requestOptions = new RequestOptions() .setMethod(HttpMethod.POST) .setHost(endPoint) - .setURI("/" + bucket + "/" + id + "?uploadId=" + uploadId); + .setURI("/" + bucket + "/" + S3Client.encodeUrlPath(id) + "?uploadId=" + uploadId); httpClient.request(requestOptions) .flatMap(req -> { @@ -325,7 +331,7 @@ public void cancel(final String id, final String uploadId) { RequestOptions requestOptions = new RequestOptions() .setMethod(HttpMethod.DELETE) .setHost(endPoint) - .setURI("/" + bucket + "/" + id + "?uploadId=" + uploadId); + .setURI("/" + bucket + "/" + S3Client.encodeUrlPath(id) + "?uploadId=" + uploadId); httpClient.request(requestOptions).flatMap(req -> { if (!sign(req, null)) { diff --git a/common/src/main/java/org/entcore/common/service/impl/AbstractRepositoryEvents.java b/common/src/main/java/org/entcore/common/service/impl/AbstractRepositoryEvents.java index 8649a3d790..b4fb89f538 100644 --- a/common/src/main/java/org/entcore/common/service/impl/AbstractRepositoryEvents.java +++ b/common/src/main/java/org/entcore/common/service/impl/AbstractRepositoryEvents.java @@ -35,7 +35,7 @@ public abstract class AbstractRepositoryEvents implements RepositoryEvents { protected final FileSystem fs; protected final EventBus eb; protected final String title; - protected final FolderExporter exporter; + protected FolderExporter exporter; protected final MongoDb mongo = MongoDb.getInstance(); private static final Pattern uuidPattern = Pattern.compile(StringUtils.UUID_REGEX); @@ -49,7 +49,9 @@ protected AbstractRepositoryEvents(Vertx vertx) { this.fs = vertx.fileSystem(); this.eb = vertx.eventBus(); - this.exporter = new FolderExporter(new StorageFactory(vertx).getStorage(), this.fs); + StorageFactory.build(vertx) + .onSuccess(storageFactory -> this.exporter = new FolderExporter(storageFactory.getStorage(), this.fs)) + .onFailure(ex -> log.error("Error building storage factory", ex)); } else { @@ -64,7 +66,7 @@ protected void createExportDirectory(String exportPath, String locale, final Han if (json.succeeded()) { final String path = exportPath + File.separator + StringUtils.stripAccents(((JsonObject)json.result().body()).getString(title.toLowerCase())); - vertx.fileSystem().mkdir(path, event -> { + vertx.fileSystem().mkdirs(path, event -> { if (event.succeeded()) { handler.handle(path); } else { diff --git a/common/src/main/java/org/entcore/common/service/impl/MongoDbRepositoryEvents.java b/common/src/main/java/org/entcore/common/service/impl/MongoDbRepositoryEvents.java index bd282b1783..2add355ff3 100644 --- a/common/src/main/java/org/entcore/common/service/impl/MongoDbRepositoryEvents.java +++ b/common/src/main/java/org/entcore/common/service/impl/MongoDbRepositoryEvents.java @@ -38,6 +38,7 @@ import io.vertx.core.buffer.Buffer; import io.vertx.core.file.FileProps; +import org.entcore.common.user.ExportResourceResult; import org.entcore.common.utils.FileUtils; import org.entcore.common.utils.StringUtils; import org.entcore.common.folders.FolderImporter; @@ -335,7 +336,7 @@ public void handle(AsyncResult event) { exportFiles(results, exportPath, usedFileName, exported, handler); } else { log.error(title + " : Could not write file " + filePath, event.cause()); - handler.handle(exported.get()); + handler.handle(false); } } }); @@ -353,7 +354,7 @@ protected Future exportResourcesFilter(final JsonArray resources, Str @Override public void exportResources(JsonArray resourcesIds, boolean exportDocuments, boolean exportSharedResources, String exportId, String userId, - JsonArray g, String exportPath, String locale, String host, Handler handler) { + JsonArray g, String exportPath, String locale, String host, Handler handler) { Bson findByAuthor = Filters.eq("author.userId", userId); Bson findByOwner = Filters.eq("owner.userId", userId); Bson findByAuthorOrOwner = Filters.or(findByAuthor, findByOwner); @@ -389,33 +390,30 @@ public void exportResources(JsonArray resourcesIds, boolean exportDocuments, boo } createExportDirectory(exportPath, locale, path -> { if (path != null) { - Handler finish = new Handler() { - @Override - public void handle(Boolean bool) { - if (bool) { - exportFiles(results, path, new HashSet(), exported, handler); - } else { - // Should never happen, export doesn't fail if docs export fail. - handler.handle(exported.get()); - } - } - }; + Handler finish = bool -> { + if (bool) { + exportFiles(results, path, new HashSet<>(), exported, e -> handler.handle(new ExportResourceResult(e, path))); + } else { + // Should never happen, export doesn't fail if docs export fail. + handler.handle(new ExportResourceResult(exported.get(), path)); + } + }; if(exportDocuments == true) exportDocumentsDependancies(results, path, finish); else finish.handle(Boolean.TRUE); } else { - handler.handle(exported.get()); + handler.handle(new ExportResourceResult(exported.get(), exportPath) ); } }); } else { log.error(title + " : Could not proceed query " + query.encode(), event.body().getString("message")); - handler.handle(exported.get()); + handler.handle(new ExportResourceResult(exported.get(), exportPath)); } }).onFailure(error -> { log.error(title + " : Could not filter resources ", error); - handler.handle(exported.get()); + handler.handle(new ExportResourceResult(exported.get(), exportPath)); }); }); } @@ -525,52 +523,56 @@ public void handle(Void result) { promise.complete(fileMap); } }; - - for(String filePath : filesInDir) { - self.fs.props(filePath, new Handler>() { - @Override - public void handle(AsyncResult propsResult) { - if(propsResult.succeeded() == false) - promise.fail(propsResult.cause()); - else { - if(propsResult.result().isDirectory() == true) { - FolderImporterContext ctx = new FolderImporterContext(filePath, userId, userName); - self.fileImporter.importFoldersFlatFormat(ctx, new Handler() { - @Override - public void handle(JsonObject rapport) { - int ix = unprocessed.decrementAndGet(); - - nbErrors.addAndGet(Integer.parseInt(rapport.getString("errorsNumber", "1"))); - contexts.add(ctx); - - if(ix == 0) - finaliseRead.handle(null); - } - }); - } else { - self.fs.readFile(filePath, new Handler>() { - @Override - public void handle(AsyncResult fileResult) { - if(fileResult.succeeded() == false) - promise.fail(fileResult.cause()); - else { - int ix = unprocessed.decrementAndGet(); - - if(filterMongoDocumentFile(filePath, fileResult.result()) == true) { - mongoDocs.set(ix, fileResult.result().toJsonObject()); - mongoDocsFileNames.set(ix, FileUtils.getFilename(filePath)); - } - - if(ix == 0) - finaliseRead.handle(null); - } - } - }); - } - } - } - }); - } + if(nbFiles <= 0) { + log.info("No files to read from import in " + dirPath + " of user " + userId); + finaliseRead.handle(null); + } else { + for (String filePath : filesInDir) { + self.fs.props(filePath, new Handler>() { + @Override + public void handle(AsyncResult propsResult) { + if (propsResult.succeeded() == false) + promise.fail(propsResult.cause()); + else { + if (propsResult.result().isDirectory() == true) { + FolderImporterContext ctx = new FolderImporterContext(filePath, userId, userName); + self.fileImporter.importFoldersFlatFormat(ctx, new Handler() { + @Override + public void handle(JsonObject rapport) { + int ix = unprocessed.decrementAndGet(); + + nbErrors.addAndGet(Integer.parseInt(rapport.getString("errorsNumber", "1"))); + contexts.add(ctx); + + if (ix == 0) + finaliseRead.handle(null); + } + }); + } else { + self.fs.readFile(filePath, new Handler>() { + @Override + public void handle(AsyncResult fileResult) { + if (fileResult.succeeded() == false) + promise.fail(fileResult.cause()); + else { + int ix = unprocessed.decrementAndGet(); + + if (filterMongoDocumentFile(filePath, fileResult.result()) == true) { + mongoDocs.set(ix, fileResult.result().toJsonObject()); + mongoDocsFileNames.set(ix, FileUtils.getFilename(filePath)); + } + + if (ix == 0) + finaliseRead.handle(null); + } + } + }); + } + } + } + }); + } + } } } }); diff --git a/common/src/main/java/org/entcore/common/storage/AntivirusClient.java b/common/src/main/java/org/entcore/common/storage/AntivirusClient.java index eed65d6738..8d5bf60a13 100644 --- a/common/src/main/java/org/entcore/common/storage/AntivirusClient.java +++ b/common/src/main/java/org/entcore/common/storage/AntivirusClient.java @@ -21,13 +21,17 @@ import io.vertx.core.AsyncResult; +import io.vertx.core.Future; import io.vertx.core.Handler; +import io.vertx.core.Promise; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import io.vertx.core.logging.LoggerFactory; -import io.vertx.core.shareddata.LocalMap; + import org.entcore.common.storage.impl.HttpAntivirusClient; +import fr.wseduc.webutils.Utils; + import java.util.Optional; import static fr.wseduc.webutils.Utils.isNotEmpty; @@ -42,12 +46,19 @@ public interface AntivirusClient { void scanS3(String id, String bucket, Handler> handler); - static Optional create(Vertx vertx){ + static Future> create(Vertx vertx){ + final Promise> promise = Promise.promise(); + vertx.sharedData().getLocalAsyncMap("server") + .compose(serverMap -> serverMap.get("file-system")) + .onSuccess(s -> + promise.complete(create(vertx, Utils.isNotEmpty(s) ? new JsonObject(s) : new JsonObject())) + ).onFailure(promise::fail); + return promise.future(); + } + + static Optional create(Vertx vertx, JsonObject fs){ try{ - final LocalMap server = vertx.sharedData().getLocalMap("server"); - final String s = (String) server.get("file-system"); - if (s != null) { - final JsonObject fs = new JsonObject(s); + if (fs != null && !fs.isEmpty()) { final JsonObject antivirus = fs.getJsonObject("antivirus"); if (antivirus != null) { final String h = antivirus.getString("host"); diff --git a/common/src/main/java/org/entcore/common/storage/Storage.java b/common/src/main/java/org/entcore/common/storage/Storage.java index 6fef815665..bcc3dcf342 100644 --- a/common/src/main/java/org/entcore/common/storage/Storage.java +++ b/common/src/main/java/org/entcore/common/storage/Storage.java @@ -153,7 +153,37 @@ static String getFilePath(String file, final String bucket, boolean flat) throws throw new FileNotFoundException("Invalid file : " + file); } - class FileInfo{ + /** + * @return {@code true} if the underlying storage uses the local fs + */ + default boolean isLocal() { return false; } + + /** + * Moves a directory from the underlying storage to the filesystem of the application. + * @param srcDir Path in the underlying storage + * @param targetDir Path to the filesystem target directory + * @return A future that completes when the move has been completed + */ + Future moveDirectoryToFs(final String srcDir, final String targetDir); + /** + * Copy recursively everything from {@code srcPath} from the storage to {@code destPath} on the file system. + * @param srcDir Absolute path from the storage + * @param targetDir Absolute path to the filesystem + * @return A future that completes when the copy has been completed + */ + Future copyDirectoryToFs(final String srcDir, final String targetDir); + + /** + * Move recursively everything from {@code srcPath} on the filesystem to {@code destPath} in the target storage. + * @param srcPath Absolute path from the filesystem + * @param destPath Absolute path to the target storage + * @return A future that completes when the move has been completed + */ + Future moveFsDirectory(String srcPath, String destPath); + + Future deleteRecursive(String exportDirectory); + + class FileInfo{ public final String path; public final FileProps props; public final boolean deleted; diff --git a/common/src/main/java/org/entcore/common/storage/StorageFactory.java b/common/src/main/java/org/entcore/common/storage/StorageFactory.java index c0ed0e674f..46c1e13b8c 100644 --- a/common/src/main/java/org/entcore/common/storage/StorageFactory.java +++ b/common/src/main/java/org/entcore/common/storage/StorageFactory.java @@ -20,20 +20,24 @@ package org.entcore.common.storage; import fr.wseduc.webutils.Server; +import fr.wseduc.webutils.collections.SharedDataHelper; + import static fr.wseduc.webutils.Utils.isNotEmpty; + +import io.vertx.core.Future; +import io.vertx.core.Promise; import io.vertx.core.Vertx; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; -import io.vertx.core.shareddata.LocalMap; import org.entcore.common.messaging.IMessagingClient; import org.entcore.common.messaging.MessagingClientFactoryProvider; import org.entcore.common.storage.impl.*; +import static io.vertx.core.Future.succeededFuture; import static org.entcore.common.storage.impl.StorageFileAnalyzer.Configuration.DEFAULT_CONTENT; import org.entcore.common.validation.ExtensionValidator; import org.entcore.common.validation.FileValidator; -import org.entcore.common.validation.QuotaFileSizeValidation; import java.net.URI; import java.net.URISyntaxException; @@ -41,68 +45,93 @@ public class StorageFactory { private final Vertx vertx; - private final IMessagingClient messagingClient; + private IMessagingClient messagingClient; private JsonObject fs; private JsonObject s3; private String gridfsAddress; - private final StorageFileAnalyzer.Configuration storageFileAnalyzerConfiguration; + private StorageFileAnalyzer.Configuration storageFileAnalyzerConfiguration; - public StorageFactory(Vertx vertx) { - this(vertx, null); + public StorageFactory(Vertx vertx, Promise initPromise) { + this(vertx, null, initPromise); } - public StorageFactory(Vertx vertx, JsonObject config) { - this(vertx, config, null); + public StorageFactory(Vertx vertx, JsonObject config, Promise initPromise) { + this(vertx, config, null, initPromise); } - public StorageFactory(Vertx vertx, JsonObject config, AbstractApplicationStorage applicationStorage) { + public StorageFactory(Vertx vertx, JsonObject config, AbstractApplicationStorage applicationStorage, Promise initPromise) { this.vertx = vertx; - LocalMap server = vertx.sharedData().getLocalMap("server"); - String s = (String) server.get("s3"); - if (s != null) { - this.s3 = new JsonObject(s); - } - s = (String) server.get("file-system"); - if (s != null) { - this.fs = new JsonObject(s); - } - this.gridfsAddress = (String) server.get("gridfsAddress"); - if (config != null && config.getJsonObject("s3") != null) { - this.s3 = config.getJsonObject("s3"); - } else if (config != null && config.getJsonObject("file-system") != null) { - this.fs = config.getJsonObject("file-system"); - } else if (config != null && config.getString("gridfs-address") != null) { - this.gridfsAddress = config.getString("gridfs-address"); - } + final SharedDataHelper sharedDataHelper = SharedDataHelper.getInstance(); + sharedDataHelper.init(vertx); + sharedDataHelper.getLocalMulti("server", "s3", "file-system", "gridfsAddress").onSuccess(server -> { + String s = (String) server.get("s3"); + if (s != null) { + this.s3 = new JsonObject(s); + } + s = (String) server.get("file-system"); + if (s != null) { + this.fs = new JsonObject(s); + } + this.gridfsAddress = (String) server.get("gridfsAddress"); + if (config != null && config.getJsonObject("s3") != null) { + this.s3 = config.getJsonObject("s3"); + } else if (config != null && config.getJsonObject("file-system") != null) { + this.fs = config.getJsonObject("file-system"); + } else if (config != null && config.getString("gridfs-address") != null) { + this.gridfsAddress = config.getString("gridfs-address"); + } - if (applicationStorage != null) { - applicationStorage.setVertx(vertx); - vertx.eventBus().localConsumer("storage", applicationStorage); - } + if (applicationStorage != null) { + applicationStorage.setVertx(vertx); + vertx.eventBus().consumer("storage", applicationStorage); + } - if(config == null) { - this.messagingClient = IMessagingClient.noop; - this.storageFileAnalyzerConfiguration = new StorageFileAnalyzer.Configuration(); - } else { - final IMessagingClient messagingClient; - final JsonObject fileAnalyzerConfiguration = config.getJsonObject("fileAnalyzer"); - if (fileAnalyzerConfiguration != null && fileAnalyzerConfiguration.getBoolean("enabled", false)) { - MessagingClientFactoryProvider.init(vertx); - this.messagingClient = MessagingClientFactoryProvider.getFactory(fileAnalyzerConfiguration.getJsonObject("messaging")).create(); - if(this.messagingClient.canListen()) { - this.storageFileAnalyzerConfiguration = new StorageFileAnalyzer.Configuration( - fileAnalyzerConfiguration.getJsonArray("mime-types", new JsonArray()).getList(), - fileAnalyzerConfiguration.getInteger("max-size", -1), - fileAnalyzerConfiguration.getString("replacement-content", DEFAULT_CONTENT) - ); + Future future = Future.succeededFuture(); + if(config == null) { + this.messagingClient = IMessagingClient.noop; + this.storageFileAnalyzerConfiguration = new StorageFileAnalyzer.Configuration(); + } else { + final JsonObject fileAnalyzerConfiguration = config.getJsonObject("fileAnalyzer"); + if (fileAnalyzerConfiguration != null && fileAnalyzerConfiguration.getBoolean("enabled", false)) { + MessagingClientFactoryProvider.init(vertx); + final Promise promise = Promise.promise(); + MessagingClientFactoryProvider.getFactory(fileAnalyzerConfiguration.getJsonObject("messaging")).create().onSuccess(messagingClient -> { + this.messagingClient = messagingClient; + if(this.messagingClient.canListen()) { + this.storageFileAnalyzerConfiguration = new StorageFileAnalyzer.Configuration( + fileAnalyzerConfiguration.getJsonArray("mime-types", new JsonArray()).getList(), + fileAnalyzerConfiguration.getInteger("max-size", -1), + fileAnalyzerConfiguration.getString("replacement-content", DEFAULT_CONTENT) + ); + } else { + this.storageFileAnalyzerConfiguration = new StorageFileAnalyzer.Configuration(); + } + promise.complete(); + }).onFailure(promise::fail); + future = promise.future(); } else { + this.messagingClient = IMessagingClient.noop; this.storageFileAnalyzerConfiguration = new StorageFileAnalyzer.Configuration(); } - } else { - this.messagingClient = IMessagingClient.noop; - this.storageFileAnalyzerConfiguration = new StorageFileAnalyzer.Configuration(); } - } + future + .onSuccess(e -> initPromise.complete(this)) + .onFailure(initPromise::fail); + }).onFailure(initPromise::fail); + } + + public static Future build(Vertx vertx) { + return build(vertx, null, null); + } + + public static Future build(Vertx vertx, JsonObject config) { + return build(vertx, config, null); + } + + public static Future build(Vertx vertx, JsonObject config, AbstractApplicationStorage applicationStorage) { + final Promise promise = Promise.promise(); + new StorageFactory(vertx, config, applicationStorage, promise); + return promise.future(); } public Storage getStorage() { @@ -136,12 +165,7 @@ public Storage getStorage() { } } - FileValidator fileValidator = new QuotaFileSizeValidation(); - JsonArray blockedExtensions = s3.getJsonArray("blockedExtensions"); - if (blockedExtensions != null && blockedExtensions.size() > 0) { - fileValidator.setNext(new ExtensionValidator(blockedExtensions)); - } - ((S3Storage) storage).setValidator(fileValidator); + ((S3Storage) storage).setValidator(FileValidator.createNullable(s3)); JsonObject s3fallbacks3s3 = s3.getJsonObject("s3fallbacks3s3"); JsonObject s3fallback = s3.getJsonObject("s3fallback"); @@ -168,12 +192,7 @@ else if (s3fallback != null) { ((FileStorage) storage).setAntivirus(av); } } - FileValidator fileValidator = new QuotaFileSizeValidation(); - JsonArray blockedExtensions = fs.getJsonArray("blockedExtensions"); - if (blockedExtensions != null && blockedExtensions.size() > 0) { - fileValidator.setNext(new ExtensionValidator(blockedExtensions)); - } - ((FileStorage) storage).setValidator(fileValidator); + ((FileStorage) storage).setValidator(FileValidator.createNullable(fs)); JsonObject s3fallback = fs.getJsonObject("s3fallback"); JsonObject s3fallbacks3fs = fs.getJsonObject("s3fallbacks3fs"); diff --git a/common/src/main/java/org/entcore/common/storage/impl/FileStorage.java b/common/src/main/java/org/entcore/common/storage/impl/FileStorage.java index 2ab107f769..8c112db388 100644 --- a/common/src/main/java/org/entcore/common/storage/impl/FileStorage.java +++ b/common/src/main/java/org/entcore/common/storage/impl/FileStorage.java @@ -51,14 +51,12 @@ import java.io.File; import java.io.FileNotFoundException; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; +import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Stream; +import java.util.stream.Collectors; public class FileStorage implements Storage { @@ -961,4 +959,64 @@ public void setFallbackStorage(FallbackStorage fallbackStorage) { this.fallbackStorage = fallbackStorage; } + + @Override + public boolean isLocal() { + return true; + } + + + @Override + public Future copyDirectoryToFs(String srcDir, String targetDir) { + return moveOrCopyDirectoryToFs(srcDir, targetDir, false); + } + + @Override + public Future moveDirectoryToFs(String srcDir, String targetDir) { + return moveOrCopyDirectoryToFs(srcDir, targetDir, true); + } + + private Future moveOrCopyDirectoryToFs(final String srcDir, final String targetDir, final boolean deleteAfterMove) { + final Future future; + if(srcDir.equals(targetDir)) { + // Nothing to do + future = Future.succeededFuture(); + } else { + future = fs.mkdirs(targetDir) + .compose(e -> Future.all(fs.readDir(srcDir), fs.readDir(targetDir))) + .compose(dirEntries -> { + final List srcEntries = dirEntries.resultAt(0); + final Set destEntries = new HashSet<>((List) dirEntries.resultAt(1)).stream() + .map(p -> p.replace(targetDir + File.separator, "")) + .collect(Collectors.toSet()); + final List> futures = new ArrayList<>(); + for (final String srcEntry : srcEntries) { + final String srcEntryName = srcEntry.replace(srcDir + File.separator, ""); + if (!destEntries.contains(srcEntryName) && !srcEntry.equals(targetDir)) { + final String destPath = targetDir + File.separator + srcEntryName; + final Future onDone; + if(deleteAfterMove) { + onDone = fs.move(srcEntry, destPath); + } else { + onDone = fs.copy(srcEntry, destPath); + } + futures.add(onDone); + } + } + return Future.all(futures); + }) + .mapEmpty(); + } + return future; + } + + @Override + public Future moveFsDirectory(String srcPath, String destPath) { + return moveDirectoryToFs(srcPath, destPath); + } + + @Override + public Future deleteRecursive(final String srcDir) { + return fs.deleteRecursive(srcDir, true); + } } diff --git a/common/src/main/java/org/entcore/common/storage/impl/GridfsStorage.java b/common/src/main/java/org/entcore/common/storage/impl/GridfsStorage.java index e37a335743..6897b63243 100644 --- a/common/src/main/java/org/entcore/common/storage/impl/GridfsStorage.java +++ b/common/src/main/java/org/entcore/common/storage/impl/GridfsStorage.java @@ -88,7 +88,27 @@ public Future readFileToMemory(final UploadedFileMessage uploadedFileMes throw new NotImplementedException(); } - public GridfsStorage(Vertx vertx, EventBus eb, String gridfsAddress) { + @Override + public Future moveDirectoryToFs(String srcDir, String targetDir) { + return Future.failedFuture(new NotImplementedException(this.getClass().getCanonicalName() + " did not implement Future moveDirectoryToFs(String srcDir, String targetDir)")); + } + + @Override + public Future copyDirectoryToFs(String srcDir, String targetDir) { + return Future.failedFuture(new NotImplementedException(this.getClass().getCanonicalName() + " did not implement Future copyDirectoryToFs(String srcDir, String targetDir)")); + } + + @Override + public Future moveFsDirectory(String srcPath, String destPath) { + return Future.failedFuture(new NotImplementedException(this.getClass().getCanonicalName() + " did not implement Future copyFsDirectory(String srcDir, String destPath)")); + } + + @Override + public Future deleteRecursive(String exportDirectory) { + return Future.failedFuture(new NotImplementedException(this.getClass().getCanonicalName() + " did not implement Future deleteRecursive(String srcDir)")); + } + + public GridfsStorage(Vertx vertx, EventBus eb, String gridfsAddress) { this(vertx, eb, gridfsAddress, "fs"); } diff --git a/common/src/main/java/org/entcore/common/storage/impl/HttpAntivirusClient.java b/common/src/main/java/org/entcore/common/storage/impl/HttpAntivirusClient.java index 8335a503be..9776c4e1b4 100644 --- a/common/src/main/java/org/entcore/common/storage/impl/HttpAntivirusClient.java +++ b/common/src/main/java/org/entcore/common/storage/impl/HttpAntivirusClient.java @@ -51,13 +51,17 @@ public HttpAntivirusClient(Vertx vertx, String host, String cretential, int port this.httpClient = vertx.createHttpClient(options); this.credential = cretential; - final String eventStoreConf = (String) vertx.sharedData().getLocalMap("server").get("event-store"); - if (eventStoreConf != null) { - final JsonObject eventStoreConfig = new JsonObject(eventStoreConf); - this.platformId = eventStoreConfig.getString("platform"); - } else { - this.platformId = null; - } + vertx.sharedData().getLocalAsyncMap("server") + .compose(serverMap -> serverMap.get("event-store")) + .onSuccess(eventStoreConf -> { + if (eventStoreConf != null) { + final JsonObject eventStoreConfig = new JsonObject(eventStoreConf); + platformId = eventStoreConfig.getString("platform"); + } else { + platformId = null; + } + }) + .onFailure(ex ->log.error("Error when get platformId in event-store server map (HttpAntivirusClient)", ex)); } @Override diff --git a/common/src/main/java/org/entcore/common/storage/impl/S3Storage.java b/common/src/main/java/org/entcore/common/storage/impl/S3Storage.java index 4593685701..483e44b666 100644 --- a/common/src/main/java/org/entcore/common/storage/impl/S3Storage.java +++ b/common/src/main/java/org/entcore/common/storage/impl/S3Storage.java @@ -19,12 +19,14 @@ package org.entcore.common.storage.impl; +import com.google.common.collect.Lists; import io.vertx.core.AsyncResult; import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.Promise; import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; +import io.vertx.core.file.FileSystem; import io.vertx.core.http.HttpClientResponse; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonArray; @@ -45,24 +47,33 @@ import java.io.File; import java.net.URI; +import java.nio.file.Paths; +import java.util.List; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; - +import java.util.stream.Collectors; + +import static org.entcore.common.s3.S3Client.encodeUrlPath; + public class S3Storage implements Storage { private final S3Client s3Client; private final String bucket; + private final FileSystem fs; private AntivirusClient antivirus; private FileValidator validator; private FallbackStorage fallbackStorage; + private static final int DOWNLOAD_TO_FS_BATCH_SIZE = 10; + private static final Logger log = LoggerFactory.getLogger(S3Storage.class); public S3Storage(Vertx vertx, URI uri, String accessKey, String secretKey, String region, String bucket, String ssec, boolean keepAlive, int timeout, int threshold, long openDelay, int poolSize) { this.bucket = bucket; this.s3Client = new S3Client(vertx, uri, accessKey, secretKey, region, bucket, ssec, keepAlive, timeout, threshold, openDelay, poolSize); + this.fs = vertx.fileSystem(); } @Override @@ -287,8 +298,87 @@ public Future readFileToMemory(final UploadedFileMessage uploadedFileMes return onFileRead.future(); } - + + @Override + public Future deleteRecursive(String srcDir) { + return s3Client.listFilesByPrefix(srcDir.charAt(0) == '/' ? srcDir.replaceFirst("/", "") : srcDir) + .compose(s3FilePaths -> deleteFromS3(s3FilePaths, 0)); + } + + private Future deleteFromS3(List s3FilePaths, int index) { + if(index < 0 || index >= s3FilePaths.size()) { + return Future.succeededFuture(); + } else { + final S3Client.S3FileInfo path = s3FilePaths.get(index); + return Future.future(p -> { + s3Client.deleteFileWithPath(path.getPath(), s3Client.getDefaultBucket(), e -> { + deleteFromS3(s3FilePaths, index + 1).onComplete(p); + }); + }); + } + } + + @Override + public Future moveDirectoryToFs(String srcDir, String targetDir) { + return this.fs.mkdirs(targetDir) + .compose(e -> s3Client.listFilesByPrefix(srcDir.charAt(0) == '/' ? srcDir.replaceFirst("/", "") : srcDir)) + .compose(s3FilePaths -> downloadToFs(s3FilePaths, srcDir, targetDir, true)); + } + @Override + public Future copyDirectoryToFs(String srcDir, String targetDir) { + return this.fs.mkdirs(targetDir) + .compose(e -> s3Client.listFilesByPrefix(encodeUrlPath(srcDir.charAt(0) == '/' ? srcDir.replaceFirst("/", "") : srcDir))) + .compose(s3FilePaths -> downloadToFs(s3FilePaths, srcDir, targetDir, false)); + } + + private Future downloadToFs(final List s3FilePaths, String srcDir, String targetDir, final boolean deleteAfterMove) { + final List> batches = Lists.partition(s3FilePaths, DOWNLOAD_TO_FS_BATCH_SIZE); + return downloadBatchToFs(srcDir, targetDir, batches, 0, deleteAfterMove); + } + + private Future downloadBatchToFs(final String srcDir, + final String targetDir, + final List> batches, + final int batchIndex, + final boolean deleteAfterMove) { + if(batchIndex >= batches.size()) { + return Future.succeededFuture(); + } + // Download all files of a batch then move on to the next + final List> futures = batches.get(batchIndex).stream().map(fileInfo -> { + final Promise promise = Promise.promise(); + final String path = fileInfo.getPath(); + this.s3Client.writeToFileSystemWithId(path, getPathInTargetDirectory(File.separatorChar + fileInfo.getPath(), srcDir, targetDir), e -> { + if(e.succeeded()) { + log.debug("Successfully downloaded file " + path); + if(deleteAfterMove) { + this.s3Client.deleteFileWithPath(path, x -> { + if(x.succeeded()) { + log.debug("Successfully deleted file " + path); + } else { + log.warn("Could not delete file " + path); + } + }); + } + promise.complete(); + } else { + log.error("could not download file " + path + ": ", e.cause()); + promise.fail(e.cause()); + } + }); + return promise.future(); + }).collect(Collectors.toList()); + return Future.all(futures) + .flatMap(e -> downloadBatchToFs(srcDir, targetDir, batches, batchIndex + 1, deleteAfterMove)); + } + + private String getPathInTargetDirectory(final String path, final String srcDir, final String targetDir) { + final String src = srcDir.charAt(0) == '/' ? srcDir : '/' + srcDir; + return path.replaceFirst(src, targetDir); + } + + @Override public void copyFile(String id, final Handler handler) { s3Client.copyFile(id, new Handler>() { @Override @@ -391,8 +481,51 @@ public void handle(AsyncResult event) { } } - public void setFallbackStorage(FallbackStorage fallbackStorage) { + public void setFallbackStorage(FallbackStorage fallbackStorage) { this.fallbackStorage = fallbackStorage; } + + @Override + public Future moveFsDirectory(final String srcPath, final String destPath) { + log.debug("Copying from " + srcPath + " to " + destPath); + return fs.readDir(srcPath) + .compose(children -> { + final Promise promise = Promise.promise(); + final String s3Path = destPath.charAt(0) == '/' ? destPath.substring(1) : destPath; + moveFsEntriesToS3(children, s3Path, 0, promise); + return promise.future(); + }) + .compose(e -> fs.deleteRecursive(srcPath, true)) + .mapEmpty(); + } + + private void moveFsEntriesToS3(final List children, final String prefix, int childIndex, final Promise promise) { + if(childIndex >= children.size()) { + promise.complete(); + } else { + final String childPathOnFs = children.get(childIndex); + final String child = Paths.get(childPathOnFs).getFileName().toString(); + fs.props(childPathOnFs) + .compose(props -> { + final Promise onChildCopied = Promise.promise(); + final String s3Path = prefix + File.separatorChar + child; + if(props.isDirectory()) { + moveFsDirectory(childPathOnFs, s3Path).onComplete(onChildCopied); + } else { + log.debug("Copying " + childPathOnFs + " to s3 " + s3Path); + s3Client.writeFromFileSystem(s3Path, childPathOnFs).onSuccess(e -> { + if("ok".equals(e.getString("status"))) { + onChildCopied.complete(); + } else { + onChildCopied.fail(e.getString("message")); + } + }).onFailure(onChildCopied::fail); + } + return onChildCopied.future(); + }) + .onSuccess(e -> moveFsEntriesToS3(children, prefix, childIndex + 1, promise)) + .onFailure(promise:: fail); + } + } } \ No newline at end of file diff --git a/common/src/main/java/org/entcore/common/user/ExportResourceResult.java b/common/src/main/java/org/entcore/common/user/ExportResourceResult.java new file mode 100644 index 0000000000..11d9badb37 --- /dev/null +++ b/common/src/main/java/org/entcore/common/user/ExportResourceResult.java @@ -0,0 +1,20 @@ +package org.entcore.common.user; + +public class ExportResourceResult { + private final boolean ok; + private final String exportPath; + public static final ExportResourceResult KO = new ExportResourceResult(false, null); + + public ExportResourceResult(boolean ok, String exportPath) { + this.ok = ok; + this.exportPath = exportPath; + } + + public boolean isOk() { + return ok; + } + + public String getExportPath() { + return exportPath; + } +} diff --git a/common/src/main/java/org/entcore/common/user/LogRepositoryEvents.java b/common/src/main/java/org/entcore/common/user/LogRepositoryEvents.java index 19cc0af843..2959ee5be2 100644 --- a/common/src/main/java/org/entcore/common/user/LogRepositoryEvents.java +++ b/common/src/main/java/org/entcore/common/user/LogRepositoryEvents.java @@ -29,7 +29,7 @@ public class LogRepositoryEvents implements RepositoryEvents { @Override public void exportResources(JsonArray resourcesIds, boolean exportDocuments, boolean exportSharedResources, String exportId, String userId, - JsonArray groups, String exportPath, String locale, String host, Handler handler) { + JsonArray groups, String exportPath, String locale, String host, Handler handler) { log.info("Export " + userId + " resources on path " + exportPath); } diff --git a/common/src/main/java/org/entcore/common/user/RepositoryEvents.java b/common/src/main/java/org/entcore/common/user/RepositoryEvents.java index 1a3d9d4007..87228a6015 100644 --- a/common/src/main/java/org/entcore/common/user/RepositoryEvents.java +++ b/common/src/main/java/org/entcore/common/user/RepositoryEvents.java @@ -30,12 +30,12 @@ public interface RepositoryEvents { default void exportResources(boolean exportDocuments, boolean exportSharedResources, String exportId, String userId, JsonArray groups, String exportPath, - String locale, String host, Handler handler) { + String locale, String host, Handler handler) { exportResources(null,exportDocuments,exportSharedResources,exportId,userId,groups,exportPath,locale,host,handler); }; default void exportResources(JsonArray resourcesIds, boolean exportDocuments, boolean exportSharedResources, String exportId, String userId, - JsonArray groups, String exportPath, String locale, String host, Handler handler) {} + JsonArray groups, String exportPath, String locale, String host, Handler handler) {} default void importResources(String importId, String userId, String userLogin, String userName, String importPath, String locale, String host, boolean forceImportAsDuplication, Handler handler) {} diff --git a/common/src/main/java/org/entcore/common/user/RepositoryHandler.java b/common/src/main/java/org/entcore/common/user/RepositoryHandler.java index d185378eb6..43f4d8da1e 100644 --- a/common/src/main/java/org/entcore/common/user/RepositoryHandler.java +++ b/common/src/main/java/org/entcore/common/user/RepositoryHandler.java @@ -21,30 +21,43 @@ import fr.wseduc.webutils.Server; import fr.wseduc.webutils.Utils; +import fr.wseduc.webutils.collections.SharedDataHelper; +import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.eventbus.EventBus; import io.vertx.core.eventbus.Message; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; +import org.entcore.common.storage.Storage; import org.entcore.common.utils.Config; import java.io.File; +import static io.vertx.core.Future.succeededFuture; + public class RepositoryHandler implements Handler> { private RepositoryEvents repositoryEvents; private final EventBus eb; + private final Storage storage; + private static final Logger log = LoggerFactory.getLogger(RepositoryHandler.class); + private final long LOCK_RELEASE_TIMEOUT = 500L; + private final long LOCK_RELEASE_DELAY = 2 * LOCK_RELEASE_TIMEOUT; - public RepositoryHandler(EventBus eb) { + public RepositoryHandler(EventBus eb, Storage storage) { this.eb = eb; - this.repositoryEvents = new LogRepositoryEvents(); + this.storage = storage; + this.repositoryEvents = new LogRepositoryEvents(); } - public RepositoryHandler(RepositoryEvents repositoryEvents, EventBus eb) { + public RepositoryHandler(RepositoryEvents repositoryEvents, EventBus eb, Storage storage) { this.eb = eb; this.repositoryEvents = repositoryEvents; - } + this.storage = storage; + } @Override public void handle(Message message) @@ -65,35 +78,64 @@ public void handle(Message message) final JsonArray resourcesIds = message.body().getJsonArray("resourcesIds"); final Boolean exportDocuments = message.body().getBoolean("exportDocuments", true); final Boolean exportSharedResources = message.body().getBoolean("exportSharedResources", true); - String title = Server.getPathPrefix(Config.getConf()); + String pathPrefix = Server.getPathPrefix(Config.getConf()); - if (!Utils.isEmpty(title) && exportApps.contains(title.substring(1))) + if (!Utils.isEmpty(pathPrefix) && exportApps.contains(pathPrefix.substring(1))) { - final String exportId = message.body().getString("exportId", ""); - String userId = message.body().getString("userId", ""); - String path = message.body().getString("path", ""); - final String locale = message.body().getString("locale", "fr"); - final String host = message.body().getString("host", ""); - JsonArray groupIds = message.body().getJsonArray("groups", new fr.wseduc.webutils.collections.JsonArray()); - - String finalBusAddress = exportedBusAddress; - repositoryEvents.exportResources(resourcesIds, exportDocuments.booleanValue(), exportSharedResources.booleanValue(), - exportId, userId, groupIds, path, locale, host, - new Handler() - { - @Override - public void handle(Boolean isExported) - { - JsonObject exported = new JsonObject() - .put("action", "exported") - .put("status", (isExported ? "ok" : "error")) - .put("exportId", exportId) - .put("locale", locale) - .put("host", host); - eb.publish(finalBusAddress, exported); - } - }); - } + final String exportId = message.body().getString("exportId", ""); + final String userId = message.body().getString("userId", ""); + final String finalBusAddress = exportedBusAddress; + final SharedDataHelper sharedData = SharedDataHelper.getInstance(); + final String lockName = "export_" + exportId + "_" + pathPrefix; + sharedData.getLock(lockName, LOCK_RELEASE_TIMEOUT) + .onFailure(th -> log.debug("We could not get the export lock " + lockName+ " so it means that someone else is already treating the export", th)) + .onSuccess(lock -> { + String path = message.body().getString("path", ""); + final String locale = message.body().getString("locale", "fr"); + final String host = message.body().getString("host", ""); + final JsonArray groupIds = message.body().getJsonArray("groups", new fr.wseduc.webutils.collections.JsonArray()); + final String appTitle = pathPrefix.replaceFirst("/", ""); + try { + log.info("We got a lock to process export " + exportId + " for user " + userId + " for app " + pathPrefix); + + repositoryEvents.exportResources(resourcesIds, exportDocuments.booleanValue(), exportSharedResources.booleanValue(), + exportId, userId, groupIds, path, locale, host, + isExported -> { + final boolean ok = isExported.isOk(); + final Future future; + if (ok) { + final String finalPath = isExported.getExportPath(); + future = storage.moveFsDirectory(finalPath, finalPath); + } else { + future = succeededFuture(); + } + future.onComplete(res -> { + sharedData.releaseLockAfterDelay(lock, LOCK_RELEASE_DELAY); + final boolean exported = ok && res.succeeded(); + JsonObject responsePayload = new JsonObject() + .put("action", "exported") + .put("app", appTitle) + .put("status", (exported ? "ok" : "error")) + .put("exportId", exportId) + .put("locale", locale) + .put("host", host); + eb.send(finalBusAddress, responsePayload); + }); + }); + } catch (Exception e) { + sharedData.releaseLockAfterDelay(lock, LOCK_RELEASE_DELAY); + log.error("An error occurred while treating an export " + message.body().encode(), e); + JsonObject responsePayload = new JsonObject() + .put("action", "exported") + .put("app", appTitle) + .put("status", "error") + .put("exportId", exportId) + .put("locale", locale) + .put("host", host); + eb.send(finalBusAddress, responsePayload); + } + }); + } break; case "duplicate:import" : importedBusAddress = "entcore.duplicate"; @@ -109,24 +151,55 @@ public void handle(Boolean isExported) if (!Utils.isEmpty(appTitle) && importApps.containsKey(appTitle.substring(1))) { - final String importId = message.body().getString("importId", ""); - String userId = message.body().getString("userId", ""); - String userLogin = message.body().getString("userLogin", ""); - String userName = message.body().getString("userName", ""); - String path = message.body().getString("path", ""); - String locale = message.body().getString("locale", "fr"); - String folderPath = path + File.separator + importApps.getJsonObject(appTitle.substring(1)).getString("folder"); - String host = message.body().getString("host", ""); - - String finalBusAddress = importedBusAddress; - repositoryEvents.importResources(importId, userId, userLogin, userName, folderPath, locale, host, forceImportAsDuplication, success -> { - JsonObject imported = new JsonObject() - .put("action", "imported") - .put("importId", importId) - .put("app", appTitle.substring(1)) - .put("rapport", success); - eb.publish(finalBusAddress, imported); - }); + final JsonObject body = message.body(); + final String importId = body.getString("importId", ""); + final String userId = body.getString("userId", ""); + final boolean force = forceImportAsDuplication; + final String finalBusAddress = importedBusAddress; + final SharedDataHelper sharedData = SharedDataHelper.getInstance(); + sharedData.getLock("import_" + importId + "_" + appTitle, LOCK_RELEASE_TIMEOUT) + .onFailure(th -> log.info("We could not get the lock so it means that someone else is already treating the import", th)) + .onSuccess(lock -> { + log.info("We got a lock to process import " + importId + " for user " + userId + " for app " + appTitle); + try { + final String userLogin = body.getString("userLogin", ""); + final String userName = body.getString("userName", ""); + final String path = body.getString("path", ""); + final String locale = body.getString("locale", "fr"); + final String folderPath = path + File.separator + importApps.getJsonObject(appTitle.substring(1)).getString("folder"); + final String host = body.getString("host", ""); + storage.copyDirectoryToFs(folderPath, folderPath) + .onSuccess(e -> { + repositoryEvents.importResources(importId, userId, userLogin, userName, folderPath, locale, host, force, success -> { + sharedData.releaseLockAfterDelay(lock, LOCK_RELEASE_DELAY); + JsonObject imported = new JsonObject() + .put("action", "imported") + .put("importId", importId) + .put("app", appTitle.substring(1)) + .put("rapport", success); + eb.send(finalBusAddress, imported); + }); + }).onFailure(th -> { + sharedData.releaseLockAfterDelay(lock, LOCK_RELEASE_DELAY); + log.error("Error while copying from FS", th); + final JsonObject imported = new JsonObject() + .put("action", "imported") + .put("importId", importId) + .put("app", appTitle.substring(1)) + .put("rapport", new JsonObject().put("status", "error")); + eb.send(finalBusAddress, imported); + }); + } catch (Exception e) { + log.error("Error while processing the import", e); + sharedData.releaseLockAfterDelay(lock, LOCK_RELEASE_DELAY); + final JsonObject imported = new JsonObject() + .put("action", "imported") + .put("importId", importId) + .put("app", appTitle.substring(1)) + .put("rapport", new JsonObject().put("status", "error")); + eb.send(finalBusAddress, imported); + } + }); } break; case "delete-groups" : diff --git a/common/src/main/java/org/entcore/common/user/UserUtils.java b/common/src/main/java/org/entcore/common/user/UserUtils.java index c93010b3dd..3d419b9b6d 100644 --- a/common/src/main/java/org/entcore/common/user/UserUtils.java +++ b/common/src/main/java/org/entcore/common/user/UserUtils.java @@ -38,8 +38,8 @@ import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; -import io.vertx.core.shareddata.LocalMap; import org.entcore.common.neo4j.Neo4j; +import io.vertx.core.shareddata.LocalMap; import org.entcore.common.session.SessionRecreationRequest; import org.entcore.common.utils.HostUtils; import org.entcore.common.utils.StringUtils; @@ -242,6 +242,13 @@ public static Future findVisibles(EventBus eb, String userId, String } m.put("reverseUnion", reverseUnion); m.put("userId", userId); + // LocalMap serverConfig = vertx.sharedData().getLocalMap("server"); + // final int timeout; + // if (serverConfig != null) { + // timeout = (int) serverConfig.getOrDefault("findVisiblesTimeout", DEFAULT_VISIBLES_TIMEOUT); + // } else { + // timeout = DEFAULT_VISIBLES_TIMEOUT; + // } Promise promise = Promise.promise(); eb.request(COMMUNICATION_USERS, m, new DeliveryOptions().setSendTimeout(getFindVisiblesTimeout()), new Handler>>() { @@ -1059,6 +1066,7 @@ public void handle(AsyncResult> res) { if (res.succeeded() && "ok".equals(res.result().body().getString("status"))) { handler.handle(res.result().body().getString("sessionId")); } else { + log.error("An error occurred while creating session for user " + userId, res.cause()); handler.handle(null); } } @@ -1223,8 +1231,9 @@ public void handle(AsyncResult> res) { }); } - public static String createJWTToken(Vertx vertx, UserInfos user, String clientId, HttpServerRequest request) throws Exception { - final JWT jwt = new JWT(vertx, (String) vertx.sharedData().getLocalMap("server").get("signKey"), null); + public static String createJWTToken(Vertx vertx, UserInfos user, String clientId, + HttpServerRequest request, String signKey) throws Exception { + final JWT jwt = new JWT(vertx, signKey, null); final JsonObject payload = createJWTClaim( user.getUserId(), clientId, JWT_TOKEN_EXPIRATION_TIME, (request != null) ? Renders.getHost(request) : null @@ -1245,9 +1254,10 @@ public static String createJWTToken(Vertx vertx, UserInfos user, String clientId * @throws Exception */ public static String createJWTForQueryParam( - Vertx vertx, String userId, String clientId, long ttlInSeconds, HttpServerRequest request + Vertx vertx, String userId, String clientId, long ttlInSeconds, + HttpServerRequest request, String signKey ) throws Exception { - final JWT jwt = new JWT(vertx, (String) vertx.sharedData().getLocalMap("server").get("signKey"), null); + final JWT jwt = new JWT(vertx, signKey, null); final JsonObject payload = createJWTClaim(userId, clientId, (0>=ttlInSeconds || ttlInSeconds>JWT_TOKEN_EXPIRATION_TIME) ? JWT_TOKEN_EXPIRATION_TIME : ttlInSeconds, (request != null) ? Renders.getHost(request) : null diff --git a/common/src/main/java/org/entcore/common/utils/FileUtils.java b/common/src/main/java/org/entcore/common/utils/FileUtils.java index 7011fb21c4..1c5987ab6b 100644 --- a/common/src/main/java/org/entcore/common/utils/FileUtils.java +++ b/common/src/main/java/org/entcore/common/utils/FileUtils.java @@ -29,6 +29,7 @@ import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; +import org.entcore.common.storage.Storage; import java.io.File; import java.io.IOException; @@ -80,8 +81,8 @@ public static JsonObject metadata(HttpServerFileUpload upload) { return metadata; } - public static void deleteImportPath(final Vertx vertx, final String path) { - deleteImportPath(vertx, path, new Handler>() { + public static void deleteImportPath(final Vertx vertx, Storage storage, final String path) { + deleteImportPath(vertx, storage, path, new Handler>() { @Override public void handle(AsyncResult event) { if (event.failed()) { @@ -91,7 +92,27 @@ public void handle(AsyncResult event) { }); } - public static void deleteImportPath(final Vertx vertx, final String path, final Handler> handler) { + public static void deleteImportPath(final Vertx vertx, Storage storage, final String path, final Handler> handler) { + storage.deleteRecursive(path).onComplete(event -> { + if (event.failed()) { + log.error("Error deleting import files in storage at : " + path, event.cause()); + } + deleteFSImportPath(vertx, path, handler); + }); + } + + public static void deleteFSImportPath(final Vertx vertx, final String path) { + deleteFSImportPath(vertx, path, new Handler>() { + @Override + public void handle(AsyncResult event) { + if (event.failed()) { + log.error("Error deleting import files in filesystem at : " + path, event.cause()); + } + } + }); + } + + public static void deleteFSImportPath(Vertx vertx, String path, Handler> handler) { vertx.fileSystem().exists(path, new Handler>() { @Override public void handle(AsyncResult event) { @@ -116,7 +137,7 @@ private static FileSystem createZipFileSystem(String zipFilename, Optional env = new HashMap<>(); + Map env = new HashMap<>(); if(encoding.isPresent()){ env.put("encoding", encoding.get()); } diff --git a/common/src/main/java/org/entcore/common/utils/MapFactory.java b/common/src/main/java/org/entcore/common/utils/MapFactory.java index b48fedadc2..b760def83f 100644 --- a/common/src/main/java/org/entcore/common/utils/MapFactory.java +++ b/common/src/main/java/org/entcore/common/utils/MapFactory.java @@ -54,7 +54,7 @@ public static AsyncMap getAsyncMap(AsyncMap asyncMap) { public static void getClusterMap(String name, Vertx vertx, Handler> handler) { LocalMap server = vertx.sharedData().getLocalMap("server"); - Boolean cluster = (Boolean) server.get("cluster"); + Boolean cluster = vertx.isClustered(); if (Boolean.TRUE.equals(cluster)) { vertx.sharedData().getClusterWideMap(name, new Handler>>() { @Override @@ -80,7 +80,7 @@ public static Map getSyncClusterMap(String name, Vertx vertx) { @Deprecated public static Map getSyncClusterMap(String name, Vertx vertx, boolean elseLocalMap) { LocalMap server = vertx.sharedData().getLocalMap("server"); - Boolean cluster = (Boolean) server.get("cluster"); + Boolean cluster = vertx.isClustered(); final Map map; if (Boolean.TRUE.equals(cluster)) { ClusterManager cm = ((VertxInternal) vertx).getClusterManager(); diff --git a/common/src/main/java/org/entcore/common/utils/Mfa.java b/common/src/main/java/org/entcore/common/utils/Mfa.java index 96f91da111..70c82bbf94 100644 --- a/common/src/main/java/org/entcore/common/utils/Mfa.java +++ b/common/src/main/java/org/entcore/common/utils/Mfa.java @@ -21,10 +21,12 @@ import org.entcore.common.user.UserInfos; +import fr.wseduc.webutils.collections.SharedDataHelper; +import io.vertx.core.Future; +import io.vertx.core.Promise; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import io.vertx.core.json.JsonArray; -import io.vertx.core.shareddata.LocalMap; public class Mfa { static public final String TYPE_SMS = "sms"; @@ -46,16 +48,25 @@ public static Factory getFactory() { return FactoryHolder.instance; } - public void init(Vertx vertx, JsonObject config) { + public Future init(Vertx vertx, JsonObject config) { + final Promise initPromise = Promise.promise(); this.vertx = vertx; this.config = (config!=null) ? config.getJsonObject("mfaConfig") : null; if( this.config == null ) { - LocalMap server = vertx.sharedData().getLocalMap("server"); - String s = (String) server.get("mfaConfig"); - this.config = (s != null) ? new JsonObject(s) : new JsonObject(); + SharedDataHelper sharedDataHelper = SharedDataHelper.getInstance(); + sharedDataHelper.init(vertx); + sharedDataHelper.getLocal("server", "mfaConfig").onSuccess(mfaConfig -> { + this.config = (mfaConfig != null) ? new JsonObject(mfaConfig) : new JsonObject(); + // TODO extraire la liste réelle des URLs sensibles + mfaProtectedUrls = this.config.getJsonArray("protectedUrls", new JsonArray()); + initPromise.complete(); + }); + } else { + // TODO extraire la liste réelle des URLs sensibles + mfaProtectedUrls = this.config.getJsonArray("protectedUrls", new JsonArray()); + initPromise.complete(); } - // TODO extraire la liste réelle des URLs sensibles - mfaProtectedUrls = this.config.getJsonArray("protectedUrls", new JsonArray()); + return initPromise.future(); } public static Mfa getInstance() { diff --git a/common/src/main/java/org/entcore/common/utils/Zip.java b/common/src/main/java/org/entcore/common/utils/Zip.java index 8644539f7b..80a79a8960 100644 --- a/common/src/main/java/org/entcore/common/utils/Zip.java +++ b/common/src/main/java/org/entcore/common/utils/Zip.java @@ -57,12 +57,14 @@ public void zipFolder(String path, String zipPath, boolean deletePath, Handler> handler) { - JsonObject j = new JsonObject() + final JsonObject j = new JsonObject() .put("path", path) .put("zipFile", zipPath) .put("deletePath", deletePath) .put("level", level); - eb.request(address, j, new DeliveryOptions().setSendTimeout(900000l), handlerToAsyncHandler(handler)); + final DeliveryOptions deliveryOptions = new DeliveryOptions() + .setSendTimeout(900000l); + eb.request(address, j, deliveryOptions, handlerToAsyncHandler(handler)); } } diff --git a/common/src/main/java/org/entcore/common/validation/ExtensionValidator.java b/common/src/main/java/org/entcore/common/validation/ExtensionValidator.java index d0d6ebb721..732a564256 100644 --- a/common/src/main/java/org/entcore/common/validation/ExtensionValidator.java +++ b/common/src/main/java/org/entcore/common/validation/ExtensionValidator.java @@ -29,7 +29,7 @@ public class ExtensionValidator extends FileValidator { private final JsonArray blockedExtension; - public ExtensionValidator(JsonArray blockedExtension) { + protected ExtensionValidator(JsonArray blockedExtension) { this.blockedExtension = blockedExtension; } diff --git a/common/src/main/java/org/entcore/common/validation/FileValidator.java b/common/src/main/java/org/entcore/common/validation/FileValidator.java index 1d7658e68b..1aa74df46e 100644 --- a/common/src/main/java/org/entcore/common/validation/FileValidator.java +++ b/common/src/main/java/org/entcore/common/validation/FileValidator.java @@ -19,33 +19,48 @@ package org.entcore.common.validation; +import io.vertx.core.Future; +import io.vertx.core.Promise; import io.vertx.core.Vertx; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.core.logging.LoggerFactory; -import io.vertx.core.shareddata.LocalMap; import org.entcore.common.storage.AntivirusClient; +import fr.wseduc.webutils.Utils; + import java.util.Optional; public abstract class FileValidator extends AbstractValidator { - public static Optional create(Vertx vertx) { + public static FileValidator createNullable(JsonObject fs) { try { - final LocalMap server = vertx.sharedData().getLocalMap("server"); - final String s = (String) server.get("file-system"); - if (s != null) { - final JsonObject fs = new JsonObject(s); + if (fs != null && !fs.isEmpty()) { final FileValidator fileValidator = new QuotaFileSizeValidation(); final JsonArray blockedExtensions = fs.getJsonArray("blockedExtensions"); if (blockedExtensions != null && blockedExtensions.size() > 0) { fileValidator.setNext(new ExtensionValidator(blockedExtensions)); } - return Optional.of(fileValidator); + return fileValidator; } } catch (Exception e) { - LoggerFactory.getLogger(AntivirusClient.class).warn("Could not create file validator: ", e); + LoggerFactory.getLogger(FileValidator.class).warn("Could not create file validator: ", e); } - return Optional.empty(); + return null; + } + + public static Optional create(JsonObject fs) { + return Optional.ofNullable(createNullable(fs)); } + + public static Future> create(Vertx vertx) { + final Promise> promise = Promise.promise(); + vertx.sharedData().getLocalAsyncMap("server") + .compose(serverMap -> serverMap.get("file-system")) + .onSuccess(s -> + promise.complete(create(Utils.isNotEmpty(s) ? new JsonObject(s) : new JsonObject())) + ).onFailure(promise::fail); + return promise.future(); + } + } diff --git a/common/src/main/java/org/entcore/common/validation/QuotaFileSizeValidation.java b/common/src/main/java/org/entcore/common/validation/QuotaFileSizeValidation.java index 439741a069..9fb119e891 100644 --- a/common/src/main/java/org/entcore/common/validation/QuotaFileSizeValidation.java +++ b/common/src/main/java/org/entcore/common/validation/QuotaFileSizeValidation.java @@ -26,6 +26,8 @@ public class QuotaFileSizeValidation extends FileValidator { + protected QuotaFileSizeValidation() {} + @Override protected void validate(JsonObject metadata, JsonObject context, Handler> handler) { Long maxSize = context.getLong("maxSize"); diff --git a/common/src/test/java/org/entcore/common/events/PostgresqlEventStoreTest.java b/common/src/test/java/org/entcore/common/events/PostgresqlEventStoreTest.java index 918827abf5..2222f5bf7b 100644 --- a/common/src/test/java/org/entcore/common/events/PostgresqlEventStoreTest.java +++ b/common/src/test/java/org/entcore/common/events/PostgresqlEventStoreTest.java @@ -86,7 +86,7 @@ public void setUp(TestContext context) { final LocalMap serverMap = vertx.sharedData().getLocalMap("server"); serverMap.put("event-store", eventStoreTestConfigJson.encode()); this.vertx = vertx; - vertx.eventBus().localConsumer("event.blacklist", ar -> { + vertx.eventBus().consumer("event.blacklist", ar -> { ar.reply(new JsonArray()); }); } diff --git a/common/src/test/java/org/entcore/common/explorer/ExplorerRepositoryEventsTest.java b/common/src/test/java/org/entcore/common/explorer/ExplorerRepositoryEventsTest.java index 3976ba6a83..9deeec65cc 100644 --- a/common/src/test/java/org/entcore/common/explorer/ExplorerRepositoryEventsTest.java +++ b/common/src/test/java/org/entcore/common/explorer/ExplorerRepositoryEventsTest.java @@ -11,6 +11,7 @@ import org.entcore.common.explorer.to.ExplorerReindexResourcesRequest; import org.entcore.common.user.RepositoryEvents; import org.entcore.common.user.UserInfos; +import org.entcore.common.user.ExportResourceResult; import org.junit.Test; import org.junit.runner.RunWith; @@ -165,13 +166,13 @@ public RepositoryEventsWithSuppliedImportReport(final JsonObject report) { this.report = report; } @Override - public void exportResources(final boolean exportDocuments, final boolean exportSharedResources, final String exportId, final String userId, final JsonArray groups, final String exportPath, final String locale, final String host, final Handler handler) { - handler.handle(true); + public void exportResources(final boolean exportDocuments, final boolean exportSharedResources, final String exportId, final String userId, final JsonArray groups, final String exportPath, final String locale, final String host, final Handler handler) { + handler.handle(new ExportResourceResult(true, null)); } @Override - public void exportResources(final JsonArray resourcesIds, final boolean exportDocuments, final boolean exportSharedResources, final String exportId, final String userId, final JsonArray groups, final String exportPath, final String locale, final String host, final Handler handler) { - handler.handle(true); + public void exportResources(final JsonArray resourcesIds, final boolean exportDocuments, final boolean exportSharedResources, final String exportId, final String userId, final JsonArray groups, final String exportPath, final String locale, final String host, final Handler handler) { + handler.handle(new ExportResourceResult(true, null)); } @Override diff --git a/communication/src/main/java/org/entcore/communication/Communication.java b/communication/src/main/java/org/entcore/communication/Communication.java index 2e4aeedaa5..60f177cc85 100644 --- a/communication/src/main/java/org/entcore/communication/Communication.java +++ b/communication/src/main/java/org/entcore/communication/Communication.java @@ -19,8 +19,8 @@ package org.entcore.communication; +import io.vertx.core.Future; import io.vertx.core.Promise; -import io.vertx.core.json.JsonArray; import org.entcore.broker.api.utils.BrokerProxyUtils; import org.entcore.common.http.BaseServer; import org.entcore.common.notification.TimelineHelper; @@ -34,16 +34,21 @@ public class Communication extends BaseServer { @Override public void start(final Promise startPromise) throws Exception { - super.start(startPromise); - TimelineHelper helper = new TimelineHelper(vertx, vertx.eventBus(), config); - CommunicationController communicationController = new CommunicationController(); - final CommunicationService service = new DefaultCommunicationService(vertx, helper, config); - communicationController.setCommunicationService(service); + final Promise promise = Promise.promise(); + super.start(promise); + promise.future().compose(init -> initCommunication()).onComplete(startPromise); + } + + public Future initCommunication() { + final TimelineHelper helper = new TimelineHelper(vertx, vertx.eventBus(), config); + final CommunicationController communicationController = new CommunicationController(); + final CommunicationService service = new DefaultCommunicationService(vertx, helper, config); + communicationController.setCommunicationService(service); addController(communicationController); setDefaultResourceFilter(new CommunicationFilter()); - - BrokerProxyUtils.addBrokerProxy(new CommunicationBrokerListenerImpl(service), vertx); + BrokerProxyUtils.addBrokerProxy(new CommunicationBrokerListenerImpl(service), vertx); + return Future.succeededFuture(); } } diff --git a/communication/src/test/java/org/entcore/communication/test/integration/java/CommunicationTest.java b/communication/src/test/java/org/entcore/communication/test/integration/java/CommunicationTest.java index c87ccefe5f..f83f2ff2a7 100644 --- a/communication/src/test/java/org/entcore/communication/test/integration/java/CommunicationTest.java +++ b/communication/src/test/java/org/entcore/communication/test/integration/java/CommunicationTest.java @@ -136,13 +136,13 @@ // config.put("initDefaultCommunicationRules", new JsonObject(json)); // communicationController = new CommunicationController(); // communicationController.init(vertx, container, null, null); -// vertx.eventBus().localConsumer(ENTCORE_COMMUNICATION, new Handler>() { +// vertx.eventBus().consumer(ENTCORE_COMMUNICATION, new Handler>() { // @Override // public void handle(Message message) { // communicationController.communicationEventBusHandler(message); // } // }); -// vertx.eventBus().localConsumer(ENTCORE_COMMUNICATION_USERS, new Handler>() { +// vertx.eventBus().consumer(ENTCORE_COMMUNICATION_USERS, new Handler>() { // @Override // public void handle(Message message) { // communicationController.visibleUsers(message); diff --git a/conversation/backend/pom.xml b/conversation/backend/pom.xml index ddd6c33b1d..4eca784f11 100644 --- a/conversation/backend/pom.xml +++ b/conversation/backend/pom.xml @@ -32,5 +32,12 @@ ${revision} test + + fr.wseduc + mod-postgresql + 2.0-zookeeper-SNAPSHOT + runtime + fat + \ No newline at end of file diff --git a/conversation/backend/src/main/java/org/entcore/conversation/Conversation.java b/conversation/backend/src/main/java/org/entcore/conversation/Conversation.java index 69c0522975..ff6bd9bc97 100644 --- a/conversation/backend/src/main/java/org/entcore/conversation/Conversation.java +++ b/conversation/backend/src/main/java/org/entcore/conversation/Conversation.java @@ -22,6 +22,8 @@ import java.text.ParseException; import static org.entcore.common.editor.ContentTransformerConfig.getContentTransformerConfig; + +import io.vertx.core.Future; import org.entcore.common.editor.ContentTransformerEventRecorderFactory; import org.entcore.common.editor.IContentTransformerEventRecorder; import org.entcore.common.http.BaseServer; @@ -55,9 +57,16 @@ public class Conversation extends BaseServer { @Override public void start(final Promise startPromise) throws Exception { - super.start(startPromise); + final Promise promise = Promise.promise(); + super.start(promise); + promise.future() + .compose(init -> StorageFactory.build(vertx, config, new ConversationStorage())) + .compose(this::initConversation) + .onComplete(startPromise); + } - final Storage storage = new StorageFactory(vertx, config, new ConversationStorage()).getStorage(); + public Future initConversation(StorageFactory storageFactory) { + final Storage storage = storageFactory.getStorage(); ContentTransformerFactoryProvider.init(vertx); final JsonObject contentTransformerConfig = getContentTransformerConfig(vertx).orElse(null); @@ -91,6 +100,7 @@ public void start(final Promise startPromise) throws Exception { log.error("Invalid cron expression.", e); } } + return Future.succeededFuture(); } } diff --git a/conversation/backend/src/main/java/org/entcore/conversation/filters/FoldersFilter.java b/conversation/backend/src/main/java/org/entcore/conversation/filters/FoldersFilter.java index 0822c42be8..577541d54d 100644 --- a/conversation/backend/src/main/java/org/entcore/conversation/filters/FoldersFilter.java +++ b/conversation/backend/src/main/java/org/entcore/conversation/filters/FoldersFilter.java @@ -19,9 +19,6 @@ package org.entcore.conversation.filters; -import java.util.List; - -import fr.wseduc.webutils.request.RequestUtils; import org.entcore.common.http.filter.ResourcesProvider; import org.entcore.common.sql.Sql; import org.entcore.common.sql.SqlResult; diff --git a/conversation/backend/src/main/java/org/entcore/conversation/service/impl/ConversationRepositoryEvents.java b/conversation/backend/src/main/java/org/entcore/conversation/service/impl/ConversationRepositoryEvents.java index 813cd04553..5a4c7bed6d 100644 --- a/conversation/backend/src/main/java/org/entcore/conversation/service/impl/ConversationRepositoryEvents.java +++ b/conversation/backend/src/main/java/org/entcore/conversation/service/impl/ConversationRepositoryEvents.java @@ -27,7 +27,6 @@ import org.entcore.common.storage.Storage; import org.entcore.common.service.impl.SqlRepositoryEvents; import io.vertx.core.Vertx; -import fr.wseduc.webutils.Either; import io.vertx.core.AsyncResult; import io.vertx.core.Handler; import io.vertx.core.eventbus.Message; @@ -39,7 +38,7 @@ import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; -import fr.wseduc.webutils.Either; +import org.entcore.common.user.ExportResourceResult; import org.entcore.common.utils.StringUtils; public class ConversationRepositoryEvents extends SqlRepositoryEvents { @@ -135,7 +134,7 @@ public void handle(JsonObject event) { @Override public void exportResources(JsonArray resourcesIds, boolean exportDocuments, boolean exportSharedResources, String exportId, String userId, - JsonArray groups, String exportPath, String locale, String host, Handler handler) { + JsonArray groups, String exportPath, String locale, String host, Handler handler) { final HashMap queries = new HashMap(); @@ -200,11 +199,11 @@ public void handle(String path) { exportAttachments(path, attachments, new Handler() { @Override public void handle(Boolean event) { - exportTables(queries, new JsonArray(), null, exportDocuments, path, exported, handler); + exportTables(queries, new JsonArray(), null, exportDocuments, path, exported, e -> handler.handle(new ExportResourceResult(e, path))); } }); } else { - handler.handle(exported.get()); + handler.handle(new ExportResourceResult(exported.get(), exportPath)); } } }); diff --git a/conversation/frontend/build.sh b/conversation/frontend/build.sh index c056348422..9e1bd374d7 100755 --- a/conversation/frontend/build.sh +++ b/conversation/frontend/build.sh @@ -55,6 +55,10 @@ init () { echo "[init] Get branch name from git..." BRANCH_NAME=`git branch | sed -n -e "s/^\* \(.*\)/\1/p"` fi + if [ ! -z "$FRONT_TAG" ]; then + echo "[buildNode] Get tag name from jenkins param... $FRONT_TAG" + BRANCH_NAME="$FRONT_TAG" + fi echo "[init] Generate package.json from package.json.template..." NPM_VERSION_SUFFIX=`date +"%Y%m%d%H%M"` diff --git a/directory/pom.xml b/directory/pom.xml index 4179a625d1..535fface61 100644 --- a/directory/pom.xml +++ b/directory/pom.xml @@ -31,5 +31,12 @@ ${revision} test + + io.vertx + mod-mongo-persistor + 4.1-zookeeper-SNAPSHOT + runtime + fat + \ No newline at end of file diff --git a/directory/src/main/java/org/entcore/directory/Directory.java b/directory/src/main/java/org/entcore/directory/Directory.java index 040798f202..6b78f46ae2 100644 --- a/directory/src/main/java/org/entcore/directory/Directory.java +++ b/directory/src/main/java/org/entcore/directory/Directory.java @@ -19,7 +19,9 @@ package org.entcore.directory; +import fr.wseduc.webutils.collections.SharedDataHelper; import fr.wseduc.webutils.email.EmailSender; +import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.Promise; import io.vertx.core.eventbus.EventBus; @@ -30,7 +32,9 @@ import java.net.URI; import java.net.URISyntaxException; +import java.util.Map; +import org.apache.commons.lang3.tuple.Pair; import org.entcore.broker.api.utils.BrokerProxyUtils; import org.entcore.common.bus.WorkspaceHelper; import org.entcore.common.email.EmailFactory; @@ -63,15 +67,25 @@ public class Directory extends BaseServer { public static final String SLOTPROFILE_COLLECTION = "slotprofile"; @Override - protected void initFilters() { - super.initFilters(); + protected void initFilters(Map baseServerMap) { + super.initFilters(baseServerMap); addFilter(new UserbookCsrfFilter(getEventBus(vertx), securedUriBinding)); } @Override public void start(final Promise startPromise) throws Exception { + final Promise promise = Promise.promise(); + super.start(promise); + promise.future() + .compose(init -> StorageFactory.build(vertx, config, new MongoDBApplicationStorage("documents", Directory.class.getSimpleName()))) + .compose(storageFactory -> SharedDataHelper.getInstance().getLocalMulti("server", "skins", "assetPath", "hidePersonalData") + .map(directoryConfigMap -> Pair.of(storageFactory, directoryConfigMap))) + .compose(configPair -> initDirectory(configPair.getLeft(), configPair.getRight())) + .onComplete(startPromise); + } + + public Future initDirectory(final StorageFactory storageFactory, final Map serverMap) { final EventBus eb = getEventBus(vertx); - super.start(startPromise); MongoDbConf.getInstance().setCollection(SLOTPROFILE_COLLECTION); setDefaultResourceFilter(new DirectoryResourcesProvider()); @@ -81,8 +95,6 @@ public void handle(HttpServerRequest request) { i18nMessages(request); } }); - final StorageFactory storageFactory = new StorageFactory(vertx, config, - new MongoDBApplicationStorage("documents", Directory.class.getSimpleName())); Storage storageAvatar = null; if (config != null && config.getJsonObject("s3avatars") != null) { @@ -114,7 +126,7 @@ public void handle(HttpServerRequest request) { final Storage defaulStorage = storageFactory.getStorage(); WorkspaceHelper wsHelper = new WorkspaceHelper(vertx.eventBus(), defaulStorage); - EmailFactory emailFactory = new EmailFactory(vertx, config); + EmailFactory emailFactory = EmailFactory.getInstance(); EmailSender emailSender = emailFactory.getSender(); SmsSenderFactory.getInstance().init(vertx, config); final JsonObject userBookData = config.getJsonObject("user-book-data"); @@ -125,6 +137,8 @@ public void handle(HttpServerRequest request) { SchoolService schoolService = new DefaultSchoolService(eb).setListUserMode(config.getString("listUserMode", "multi")); GroupService groupService = new DefaultGroupService(eb); SubjectService subjectService = new DefaultSubjectService(eb); + ImportService importService = new DefaultImportService(vertx, eb, defaulStorage, config); + MassMessagingService massMessagingService = new DefaultMassMessagingService(vertx, eb, defaulStorage); final JsonObject emptyJsonObject = new JsonObject(); UserPositionService userPositionService = new DefaultUserPositionService(eb, config .getJsonObject("publicConf", emptyJsonObject) @@ -139,21 +153,23 @@ public void handle(HttpServerRequest request) { directoryController.setUserService(userService); directoryController.setGroupService(groupService); directoryController.setSlotProfileService(new DefaultSlotProfileService(SLOTPROFILE_COLLECTION)); - addController(directoryController); - vertx.setTimer(5000l, event -> directoryController.createSuperAdmin()); + addController(directoryController) + .onSuccess(e -> vertx.setTimer(5000l, event -> directoryController.createSuperAdmin())); - UserBookController userBookController = new UserBookController(); + UserBookController userBookController = new UserBookController(serverMap); userBookController.setSchoolService(schoolService); userBookController.setUserBookService(userBookService); userBookController.setUserPositionService(userPositionService); userBookController.setConversationNotification(conversationNotification); addController(userBookController); - StructureController structureController = new StructureController(); + StructureController structureController = new StructureController( + (JsonObject) serverMap.get("skins"), (String) serverMap.get("assetPath")); structureController.setStructureService(schoolService); structureController.setNotifHelper(emailSender); - structureController.setMassMailService(new DefaultMassMailService(vertx,eb,emailSender,config)); + structureController.setMassMailService(new DefaultMassMailService( + vertx,eb,emailSender,config, (String) serverMap.get("node"))); addController(structureController); ClassController classController = new ClassController(); @@ -181,17 +197,13 @@ public void handle(HttpServerRequest request) { tenantController.setTenantService(new DefaultTenantService(eb)); addController(tenantController); - ImportController importController = new ImportController(); - importController.setImportService(new DefaultImportService(vertx, eb)); - importController.setSchoolService(schoolService); + ImportController importController = new ImportController(importService, schoolService, defaulStorage); addController(importController); - MassMessagingController massMessagingController = new MassMessagingController(); - massMessagingController.setMassMesssagingService(new DefaultMassMessagingService(vertx, eb)); + MassMessagingController massMessagingController = new MassMessagingController(massMessagingService, importService, defaulStorage); addController(massMessagingController); - TimetableController timetableController = new TimetableController(); - timetableController.setTimetableService(new DefaultTimetableService(eb)); + TimetableController timetableController = new TimetableController(new DefaultTimetableService(eb), defaulStorage); addController(timetableController); ShareBookmarkController shareBookmarkController = new ShareBookmarkController(); @@ -211,8 +223,8 @@ public void handle(HttpServerRequest request) { UserPositionController userPositionController = new UserPositionController(userPositionService); addController(userPositionController); - vertx.eventBus().localConsumer("user.repository", - new RepositoryHandler(new UserbookRepositoryEvents(userBookService), eb)); + vertx.eventBus().consumer("user.repository", + new RepositoryHandler(new UserbookRepositoryEvents(userBookService), eb, storageFactory.getStorage())); MessageConsumer consumer = eb.consumer(DIRECTORY_ADDRESS); consumer.handler(message -> { @@ -232,8 +244,13 @@ public void handle(HttpServerRequest request) { final JsonObject remoteNodes = config.getJsonObject("remote-nodes"); if (remoteNodes != null) { - final RemoteClientCluster remoteClientCluster = new RemoteClientCluster(vertx, remoteNodes); - final RemoteUserService remoteUserService = new DefaultRemoteUserService(emailSender); + final RemoteClientCluster remoteClientCluster; + try { + remoteClientCluster = new RemoteClientCluster(vertx, remoteNodes); + } catch (URISyntaxException e) { + return Future.failedFuture(e); + } + final RemoteUserService remoteUserService = new DefaultRemoteUserService(emailSender); ((DefaultRemoteUserService) remoteUserService).setRemoteClientCluster(remoteClientCluster); final RemoteUserController remoteUserController = new RemoteUserController(); remoteUserController.setRemoteUserService(remoteUserService); @@ -242,6 +259,7 @@ public void handle(HttpServerRequest request) { // add the directory broker listener BrokerProxyUtils.addBrokerProxy(new DirectoryBrokerListenerImpl(vertx, userService), vertx); BrokerProxyUtils.addBrokerProxy(new LoadTestProxyImpl(vertx), vertx); + return Future.succeededFuture(); } } diff --git a/directory/src/main/java/org/entcore/directory/controllers/DirectoryController.java b/directory/src/main/java/org/entcore/directory/controllers/DirectoryController.java index 67c57c0072..35a223b5a3 100644 --- a/directory/src/main/java/org/entcore/directory/controllers/DirectoryController.java +++ b/directory/src/main/java/org/entcore/directory/controllers/DirectoryController.java @@ -26,9 +26,11 @@ import fr.wseduc.security.MfaProtected; import fr.wseduc.security.SecuredAction; import fr.wseduc.webutils.Either; +import fr.wseduc.webutils.data.FileResolver; import fr.wseduc.webutils.http.BaseController; import fr.wseduc.webutils.http.Renders; import fr.wseduc.webutils.security.BCrypt; +import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.Vertx; import io.vertx.core.eventbus.DeliveryOptions; @@ -44,7 +46,6 @@ import org.entcore.common.http.filter.ResourceFilter; import org.entcore.common.http.filter.SuperAdminFilter; import org.entcore.common.neo4j.Neo; -import org.entcore.common.user.position.UserPositionService; import org.entcore.directory.security.AdmlOfStructuresByExternalId; import org.entcore.directory.services.*; import org.vertx.java.core.http.RouteMatcher; @@ -74,13 +75,16 @@ public class DirectoryController extends BaseController { private SlotProfileService slotProfileService; private EventStore eventStore; - public void init(Vertx vertx, JsonObject config, RouteMatcher rm, - Map securedActions) { + public Future initAsync(Vertx vertx, JsonObject config, RouteMatcher rm, + Map securedActions) { super.init(vertx, config, rm, securedActions); this.neo = new Neo(vertx, eb,log); this.config = config; - this.admin = new JsonObject(vertx.fileSystem().readFileBlocking("super-admin.json").toString()); - eventStore = EventStoreFactory.getFactory().getEventStore(UserBookController.ANNUAIRE_MODULE); + eventStore = EventStoreFactory.getFactory().getEventStore(UserBookController.ANNUAIRE_MODULE); + return vertx.fileSystem().readFile(FileResolver.absolutePath("super-admin.json")) + .onSuccess(buffer -> { + this.admin = buffer.toJsonObject(); + }).mapEmpty(); } @Get("/admin-console") diff --git a/directory/src/main/java/org/entcore/directory/controllers/ImportController.java b/directory/src/main/java/org/entcore/directory/controllers/ImportController.java index de3a40cdf9..7c51d25681 100644 --- a/directory/src/main/java/org/entcore/directory/controllers/ImportController.java +++ b/directory/src/main/java/org/entcore/directory/controllers/ImportController.java @@ -38,6 +38,7 @@ import io.vertx.core.json.JsonObject; import org.entcore.common.http.filter.AdminFilter; import org.entcore.common.http.filter.ResourceFilter; +import org.entcore.common.storage.Storage; import org.entcore.common.user.DefaultFunctions; import org.entcore.common.user.UserInfos; import org.entcore.common.user.UserUtils; @@ -60,6 +61,13 @@ public class ImportController extends BaseController { private ImportService importService; private SchoolService schoolService; + private Storage storage; + + public ImportController(ImportService importService, SchoolService schoolService, Storage storage) { + this.importService = importService; + this.schoolService = schoolService; + this.storage = storage; + } @Get("/wizard") @ResourceFilter(AdminFilter.class) @@ -74,11 +82,11 @@ public void view(HttpServerRequest request) { @SecuredAction(value = "", type = ActionType.RESOURCE) @MfaProtected() public void columnsMapping(final HttpServerRequest request) { - uploadImport(request, new Handler>() { + importService.uploadImport(request, new Handler>() { @Override public void handle(AsyncResult event) { if (event.succeeded()) { - importService.columnsMapping(event.result(), reportResponseHandler(vertx, event.result().getPath(), request)); + importService.columnsMapping(event.result(), reportResponseHandler(vertx, storage, event.result().getPath(), request)); } else { badRequest(request, event.cause().getMessage()); } @@ -91,11 +99,11 @@ public void handle(AsyncResult event) { @SecuredAction(value = "", type = ActionType.RESOURCE) @MfaProtected() public void classesMapping(final HttpServerRequest request) { - uploadImport(request, new Handler>() { + importService.uploadImport(request, new Handler>() { @Override public void handle(AsyncResult event) { if (event.succeeded()) { - importService.classesMapping(event.result(), reportResponseHandler(vertx, event.result().getPath(), request)); + importService.classesMapping(event.result(), reportResponseHandler(vertx, storage, event.result().getPath(), request)); } else { badRequest(request, event.cause().getMessage()); } @@ -109,14 +117,14 @@ public void handle(AsyncResult event) { @SecuredAction(value = "", type = ActionType.RESOURCE) @MfaProtected() public void validateImport(final HttpServerRequest request) { - uploadImport(request, new Handler>() { + importService.uploadImport(request, new Handler>() { @Override public void handle(AsyncResult event) { if (event.succeeded()) { UserUtils.getUserInfos(eb, request, user -> { if (user != null) { importService.validate(event.result(), user, - reportResponseHandler(vertx, event.result().getPath(), request)); + reportResponseHandler(vertx, storage, event.result().getPath(), request)); } else { unauthorized(request, "invalid.user"); } @@ -136,7 +144,7 @@ public void validateWithId(final HttpServerRequest request) { String importId = request.params().get("id"); UserUtils.getUserInfos(eb, request, user -> { if (user != null) { - importService.validate(importId, user, reportResponseHandler(vertx, + importService.validate(importId, user, reportResponseHandler(vertx, storage, config.getString("wizard-path", "/tmp") + File.separator + importId, request)); } else { unauthorized(request, "invalid.user"); @@ -144,122 +152,6 @@ public void validateWithId(final HttpServerRequest request) { }); } - private void uploadImport(final HttpServerRequest request, final Handler> handler) { - request.pause(); - final String importId = UUID.randomUUID().toString(); - final String path = config.getString("wizard-path", "/tmp") + File.separator + importId; - request.setExpectMultipart(true); - request.endHandler(new Handler() { - @Override - public void handle(Void v) { - final ImportInfos importInfos = new ImportInfos(); - importInfos.setId(importId); - importInfos.setPath(path); - importInfos.setStructureId(request.formAttributes().get("structureId")); - importInfos.setStructureExternalId(request.formAttributes().get("structureExternalId")); - importInfos.setPreDelete(paramToBoolean(request.formAttributes().get("predelete"))); - importInfos.setTransition(paramToBoolean(request.formAttributes().get("transition"))); - importInfos.setStructureName(request.formAttributes().get("structureName")); - importInfos.setUAI(request.formAttributes().get("UAI")); - importInfos.setLanguage(I18n.acceptLanguage(request)); - if (isNotEmpty(request.formAttributes().get("classExternalId"))) { - importInfos.setOverrideClass(request.formAttributes().get("classExternalId")); - } - - if (isNotEmpty(request.formAttributes().get("columnsMapping")) || - isNotEmpty(request.formAttributes().get("classesMapping"))) { - try { - if (isNotEmpty(request.formAttributes().get("columnsMapping"))) { - importInfos.setMappings(new JsonObject(request.formAttributes().get("columnsMapping"))); - } - if (isNotEmpty(request.formAttributes().get("classesMapping"))) { - importInfos.setClassesMapping(new JsonObject(request.formAttributes().get("classesMapping"))); - } - } catch (DecodeException e) { - handler.handle(new DefaultAsyncResult(new ImportException("invalid.columns.mapping", e))); - deleteImportPath(vertx, path); - deleteImportPath(vertx, path); - return; - } - } - try { - importInfos.setFeeder(request.formAttributes().get("type")); - } catch (IllegalArgumentException | NullPointerException e) { - handler.handle(new DefaultAsyncResult(new ImportException("invalid.import.type", e))); - deleteImportPath(vertx, path); - return; - } - UserUtils.getUserInfos(eb, request, new Handler() { - @Override - public void handle(UserInfos user) { - if (user == null) { - handler.handle(new DefaultAsyncResult(new ImportException("invalid.admin"))); - deleteImportPath(vertx, path); - return; - } - importInfos.validate(user.getFunctions() != null && user.getFunctions() - .containsKey(DefaultFunctions.SUPER_ADMIN), vertx, new Handler>() { - @Override - public void handle(AsyncResult validate) { - if (validate.succeeded()) { - if (validate.result() == null) { - handler.handle(new DefaultAsyncResult<>(importInfos)); - } else { - handler.handle(new DefaultAsyncResult(new ImportException(validate.result()))); - deleteImportPath(vertx, path); - } - } else { - handler.handle(new DefaultAsyncResult(validate.cause())); - log.error("Validate error", validate.cause()); - deleteImportPath(vertx, path); - } - } - }); - } - }); - } - }); - request.exceptionHandler(new Handler() { - @Override - public void handle(Throwable event) { - handler.handle(new DefaultAsyncResult(event)); - deleteImportPath(vertx, path); - } - }); - request.uploadHandler(new Handler() { - @Override - public void handle(final HttpServerFileUpload upload) { - if (!upload.filename().toLowerCase().endsWith(".csv")) { - handler.handle(new DefaultAsyncResult( - new ImportException("invalid.file.extension"))); - return; - } - final String filename = path + File.separator + upload.name(); - upload.streamToFileSystem(filename) - .onSuccess(event -> log.info("File " + upload.filename() + " uploaded as " + upload.name())) - .onFailure(th -> log.error("Cannot import " + upload.filename(), th)); - request.resume(); - } - }); - deleteImportPath(vertx, path,res->{ - vertx.fileSystem().mkdir(path, new Handler>() { - @Override - public void handle(AsyncResult event) { - if (event.succeeded()) { - request.resume(); - } else { - handler.handle(new DefaultAsyncResult( - new ImportException("mkdir.error", event.cause()))); - } - } - }); - }); - } - - private boolean paramToBoolean(String param) { - return "true".equalsIgnoreCase(param); - } - @Get("/wizard/import/:id") @ResourceFilter(AdminFilter.class) @SecuredAction(value = "", type = ActionType.RESOURCE) @@ -273,11 +165,11 @@ public void findImportDraft(final HttpServerRequest request) { @SecuredAction(value = "", type = ActionType.RESOURCE) @MfaProtected() public void doImport(final HttpServerRequest request) { - uploadImport(request, new Handler>() { + importService.uploadImport(request, new Handler>() { @Override public void handle(final AsyncResult event) { if (event.succeeded()) { - importService.doImport(event.result(), reportResponseHandler(vertx, event.result().getPath(), request)); + importService.doImport(event.result(), reportResponseHandler(vertx, storage, event.result().getPath(), request)); } else { badRequest(request, event.cause().getMessage()); } @@ -310,11 +202,11 @@ public void handle(Either s) { request.formAttributes().add("UAI", structure.getString("UAI")); request.formAttributes().add("type", "CSV"); request.resume(); - uploadImport(request, new Handler>() { + importService.uploadImport(request, new Handler>() { @Override public void handle(final AsyncResult event) { if (event.succeeded()) { - importService.doImport(event.result(), reportResponseHandler(vertx, event.result().getPath(), request)); + importService.doImport(event.result(), reportResponseHandler(vertx, storage, event.result().getPath(), request)); } else { badRequest(request, event.cause().getMessage()); } @@ -333,7 +225,7 @@ public void handle(final AsyncResult event) { @MfaProtected() public void launchImport(final HttpServerRequest request) { String importId = request.params().get("id"); - importService.doImport(importId, reportResponseHandler(vertx, + importService.doImport(importId, reportResponseHandler(vertx, storage, config.getString("wizard-path", "/tmp") + File.separator + importId, request)); } diff --git a/directory/src/main/java/org/entcore/directory/controllers/MassMessagingController.java b/directory/src/main/java/org/entcore/directory/controllers/MassMessagingController.java index 14e5c5c0d4..619bd8a0b3 100644 --- a/directory/src/main/java/org/entcore/directory/controllers/MassMessagingController.java +++ b/directory/src/main/java/org/entcore/directory/controllers/MassMessagingController.java @@ -38,11 +38,13 @@ import io.vertx.core.json.JsonObject; import org.entcore.common.http.filter.AdminFilter; import org.entcore.common.http.filter.ResourceFilter; +import org.entcore.common.storage.Storage; import org.entcore.common.user.DefaultFunctions; import org.entcore.common.user.UserInfos; import org.entcore.common.user.UserUtils; import org.entcore.directory.exceptions.ImportException; import org.entcore.directory.pojo.ImportInfos; +import org.entcore.directory.services.ImportService; import org.entcore.directory.services.MassMessagingService; import java.io.File; @@ -56,6 +58,14 @@ public class MassMessagingController extends BaseController { private MassMessagingService massMessagingService; + private ImportService importService; + private Storage storage; + + public MassMessagingController(MassMessagingService massMessagingService, ImportService importService, Storage storage) { + this.massMessagingService = massMessagingService; + this.importService = importService; + this.storage = storage; + } @Get("") @ResourceFilter(AdminFilter.class) @@ -72,11 +82,11 @@ public void view(HttpServerRequest request) { @SecuredAction(value = "", type = ActionType.RESOURCE) @MfaProtected() public void csvColumnsMapping(final HttpServerRequest request) { - uploadImport(request, new Handler>() { + importService.uploadImport(request, new Handler>() { @Override public void handle(AsyncResult event) { if (event.succeeded()) { - massMessagingService.csvColumnsMapping(event.result(), reportResponseHandler(vertx, event.result().getPath(), request)); + massMessagingService.csvColumnsMapping(event.result(), reportResponseHandler(vertx, storage, event.result().getPath(), request)); } else { badRequest(request, event.cause().getMessage()); } @@ -146,123 +156,6 @@ public void handle(Either event) { }); } - - private void uploadImport(final HttpServerRequest request, final Handler> handler) { - request.pause(); - final String importId = UUID.randomUUID().toString(); - final String path = config.getString("wizard-path", "/tmp") + File.separator + importId; - request.setExpectMultipart(true); - request.endHandler(new Handler() { - @Override - public void handle(Void v) { - final ImportInfos importInfos = new ImportInfos(); - importInfos.setId(importId); - importInfos.setPath(path); - importInfos.setStructureId(request.formAttributes().get("structureId")); - importInfos.setStructureExternalId(request.formAttributes().get("structureExternalId")); - importInfos.setPreDelete(paramToBoolean(request.formAttributes().get("predelete"))); - importInfos.setTransition(paramToBoolean(request.formAttributes().get("transition"))); - importInfos.setStructureName(request.formAttributes().get("structureName")); - importInfos.setUAI(request.formAttributes().get("UAI")); - importInfos.setLanguage(I18n.acceptLanguage(request)); - if (isNotEmpty(request.formAttributes().get("classExternalId"))) { - importInfos.setOverrideClass(request.formAttributes().get("classExternalId")); - } - - if (isNotEmpty(request.formAttributes().get("columnsMapping")) || - isNotEmpty(request.formAttributes().get("classesMapping"))) { - try { - if (isNotEmpty(request.formAttributes().get("columnsMapping"))) { - importInfos.setMappings(new JsonObject(request.formAttributes().get("columnsMapping"))); - } - if (isNotEmpty(request.formAttributes().get("classesMapping"))) { - importInfos.setClassesMapping(new JsonObject(request.formAttributes().get("classesMapping"))); - } - } catch (DecodeException e) { - handler.handle(new DefaultAsyncResult(new ImportException("invalid.columns.mapping", e))); - deleteImportPath(vertx, path); - deleteImportPath(vertx, path); - return; - } - } - try { - importInfos.setFeeder(request.formAttributes().get("type")); - } catch (IllegalArgumentException | NullPointerException e) { - handler.handle(new DefaultAsyncResult(new ImportException("invalid.import.type", e))); - deleteImportPath(vertx, path); - return; - } - UserUtils.getUserInfos(eb, request, new Handler() { - @Override - public void handle(UserInfos user) { - if (user == null) { - handler.handle(new DefaultAsyncResult(new ImportException("invalid.admin"))); - deleteImportPath(vertx, path); - return; - } - importInfos.validate(user.getFunctions() != null && user.getFunctions() - .containsKey(DefaultFunctions.SUPER_ADMIN), vertx, new Handler>() { - @Override - public void handle(AsyncResult validate) { - if (validate.succeeded()) { - if (validate.result() == null) { - handler.handle(new DefaultAsyncResult<>(importInfos)); - } else { - handler.handle(new DefaultAsyncResult(new ImportException(validate.result()))); - deleteImportPath(vertx, path); - } - } else { - handler.handle(new DefaultAsyncResult(validate.cause())); - log.error("Validate error", validate.cause()); - deleteImportPath(vertx, path); - } - } - }); - } - }); - } - }); - request.exceptionHandler(new Handler() { - @Override - public void handle(Throwable event) { - handler.handle(new DefaultAsyncResult(event)); - deleteImportPath(vertx, path); - } - }); - request.uploadHandler(new Handler() { - @Override - public void handle(final HttpServerFileUpload upload) { - if (!upload.filename().toLowerCase().endsWith(".csv")) { - handler.handle(new DefaultAsyncResult( - new ImportException("invalid.file.extension"))); - return; - } - final String filename = path + File.separator + upload.name(); - upload.endHandler(new Handler() { - @Override - public void handle(Void event) { - log.info("File " + upload.filename() + " uploaded as " + upload.name()); - } - }); - upload.streamToFileSystem(filename); - request.resume(); - } - }); - deleteImportPath(vertx, path,res->{ - vertx.fileSystem().mkdir(path, new Handler>() { - @Override - public void handle(AsyncResult event) { - if (event.succeeded()) { - request.resume(); - } else { - handler.handle(new DefaultAsyncResult( - new ImportException("mkdir.error", event.cause()))); - } - } - }); - }); - } - private boolean paramToBoolean(String param) { return "true".equalsIgnoreCase(param); } diff --git a/directory/src/main/java/org/entcore/directory/controllers/StructureController.java b/directory/src/main/java/org/entcore/directory/controllers/StructureController.java index e800dfa795..8c38f7db43 100644 --- a/directory/src/main/java/org/entcore/directory/controllers/StructureController.java +++ b/directory/src/main/java/org/entcore/directory/controllers/StructureController.java @@ -84,11 +84,16 @@ public class StructureController extends BaseController { private MassMailService massMailService; private SchoolService structureService; private EmailSender notifHelper; - private String assetsPath = "../.."; - private Map skins = new HashMap<>(); + private final String assetsPath; + private final JsonObject skins; private static final Logger log = LoggerFactory.getLogger(StructureController.class); + public StructureController(JsonObject skins, String assetsPath) { + this.skins = skins; + this.assetsPath = assetsPath; + } + @Override public void init(Vertx vertx, JsonObject config, RouteMatcher rm, Map securedActions) { super.init(vertx, config, rm, securedActions); @@ -374,14 +379,11 @@ public void handle(UserInfos infos) { public void getMassMessageTemplate(final HttpServerRequest request) { FileSystem fs = vertx.fileSystem(); - this.assetsPath = (String) vertx.sharedData().getLocalMap("server").get("assetPath"); - this.skins = vertx.sharedData().getLocalMap("skins"); - getSkin(request, res -> { final String skin; if (res.isLeft() || res.right().getValue() == null) { - skin = this.skins.get(Renders.getHost(request)); + skin = this.skins.getString(Renders.getHost(request)); } else { skin = res.right().getValue(); } @@ -422,49 +424,54 @@ public void performMassmailUser(final HttpServerRequest request){ return; } - this.assetsPath = (String) vertx.sharedData().getLocalMap("server").get("assetPath"); - this.skins = vertx.sharedData().getLocalMap("skins"); + getSkin(request, result -> { - final String skin = this.skins.get(Renders.getHost(request)); + final String skin; + if (result.isLeft() || result.right().getValue() == null) { + skin = this.skins.getString(Renders.getHost(request)); + } else { + skin = result.right().getValue(); + } - final String assetsPath = this.assetsPath + "/assets/themes/" + skin; - final String templatePath = assetsPath + "/template/directory/"; - final String baseUrl = getScheme(request) + "://" + Renders.getHost(request) + "/assets/themes/" + skin + "/img/"; + final String assetsPath = this.assetsPath + "/assets/themes/" + skin; + final String templatePath = assetsPath + "/template/directory/"; + final String baseUrl = getScheme(request) + "://" + Renders.getHost(request) + "/assets/themes/" + skin + "/img/"; + + UserUtils.getUserInfos(eb, request, new Handler() { + public void handle(final UserInfos infos) { + + //PDF + if("pdf".equals(type)){ + massMailService.massMailUser(userId, infos, new Handler>() { + public void handle(Either result) { + if(result.isLeft()){ + forbidden(request); + return; + } - UserUtils.getUserInfos(eb, request, new Handler() { - public void handle(final UserInfos infos) { - - //PDF - if("pdf".equals(type)){ - massMailService.massMailUser(userId, infos, new Handler>() { - public void handle(Either result) { - if(result.isLeft()){ - forbidden(request); - return; - } + massMailService.massMailTypePdf(infos, request, templatePath, baseUrl, filename, "pdf", result.right().getValue()); - massMailService.massMailTypePdf(infos, request, templatePath, baseUrl, filename, "pdf", result.right().getValue()); + } + }); + } + //Mail + else if("mail".equals(type)){ + massMailService.massMailUser(userId, infos, new Handler>() { + public void handle(final Either result) { + if(result.isLeft()){ + forbidden(request); + return; + } - } - }); - } - //Mail - else if("mail".equals(type)){ - massMailService.massMailUser(userId, infos, new Handler>() { - public void handle(final Either result) { - if(result.isLeft()){ - forbidden(request); - return; + massMailService.massMailTypeMail(infos, request, templatePath, result.right().getValue()); } + }); + } else { + badRequest(request); + } - massMailService.massMailTypeMail(infos, request, templatePath, result.right().getValue()); - } - }); - } else { - badRequest(request); } - - } + }); }); } @@ -499,56 +506,59 @@ public void performMassmail(final HttpServerRequest request){ filter.put("date", request.params().get("date")); } - // If query parameter b does not exist, then do nothing. - if(request.params().contains("b")){ - // Keep unblocked users iif b has an explicit value of "false". - // Keep blocked users in any other case. - final boolean keepUnblockedUsers = "false".equalsIgnoreCase(request.params().get("b")); - filter.put("blocked", !keepUnblockedUsers); - } - - this.assetsPath = (String) vertx.sharedData().getLocalMap("server").get("assetPath"); - this.skins = vertx.sharedData().getLocalMap("skins"); - - final String skin = this.skins.get(Renders.getHost(request)); + getSkin(request, result -> { + // If query parameter b does not exist, then do nothing. + if(request.params().contains("b")){ + // Keep unblocked users iif b has an explicit value of "false". + // Keep blocked users in any other case. + final boolean keepUnblockedUsers = "false".equalsIgnoreCase(request.params().get("b")); + filter.put("blocked", !keepUnblockedUsers); + } + final String skin; + if (result.isLeft() || result.right().getValue() == null) { + skin = this.skins.getString(Renders.getHost(request)); + } else { + skin = result.right().getValue(); + } - final String assetsPath = this.assetsPath + "/assets/themes/" + skin; - final String templatePath = assetsPath + "/template/directory/"; - final String baseUrl = getScheme(request) + "://" + Renders.getHost(request) + "/assets/themes/" + skin + "/img/"; + final String assetsPath = this.assetsPath + "/assets/themes/" + skin; + final String templatePath = assetsPath + "/template/directory/"; + final String baseUrl = getScheme(request) + "://" + Renders.getHost(request) + "/assets/themes/" + skin + "/img/"; + + UserUtils.getUserInfos(eb, request, new Handler() { + public void handle(final UserInfos infos) { + + //PDF + if("pdf".equals(type) || "newPdf".equals(type) || "simplePdf".equals(type)){ + massMailService.massmailUsers(structureId, filter, filterMail, true, infos, new Handler>() { + public void handle(Either result) { + if(result.isLeft()){ + forbidden(request); + return; + } - UserUtils.getUserInfos(eb, request, new Handler() { - public void handle(final UserInfos infos) { - - //PDF - if("pdf".equals(type) || "newPdf".equals(type) || "simplePdf".equals(type)){ - massMailService.massmailUsers(structureId, filter, filterMail, true, infos, new Handler>() { - public void handle(Either result) { - if(result.isLeft()){ - forbidden(request); - return; + massMailService.massMailTypePdf(infos, request, templatePath, baseUrl, filename, type, result.right().getValue()); } + }); + } + //Mail + else if("mail".equals(type)){ + massMailService.massmailUsers(structureId, filter, filterMail, true, infos, new Handler>() { + public void handle(final Either result) { + if(result.isLeft()){ + forbidden(request); + return; + } - massMailService.massMailTypePdf(infos, request, templatePath, baseUrl, filename, type, result.right().getValue()); - } - }); - } - //Mail - else if("mail".equals(type)){ - massMailService.massmailUsers(structureId, filter, filterMail, true, infos, new Handler>() { - public void handle(final Either result) { - if(result.isLeft()){ - forbidden(request); - return; + massMailService.massMailTypeMail(infos, request, templatePath, result.right().getValue()); } + }); + } else { + badRequest(request); + } - massMailService.massMailTypeMail(infos, request, templatePath, result.right().getValue()); - } - }); - } else { - badRequest(request); } - - } + }); }); } @@ -568,18 +578,17 @@ public void handle(JsonObject body) { } final String host = Renders.getHost(request); - final Map skins = vertx.sharedData().getLocalMap("skins"); getSkin(request, result -> { final String skin; if (result.isLeft() || result.right().getValue() == null) { - skin = skins.get(host); + skin = skins.getString(host); } else { skin = result.right().getValue(); } - final String assetsPath = (String) vertx.sharedData().getLocalMap("server").get("assetPath") + + final String assetsPath = StructureController.this.assetsPath + "/assets/themes/" + skin; final String templatePath = assetsPath + "/template/directory/"; final String baseUrl = getScheme(request) + "://" + host + "/assets/themes/" + skin + "/img/"; diff --git a/directory/src/main/java/org/entcore/directory/controllers/TimetableController.java b/directory/src/main/java/org/entcore/directory/controllers/TimetableController.java index 6fe5613eb8..12245689ea 100644 --- a/directory/src/main/java/org/entcore/directory/controllers/TimetableController.java +++ b/directory/src/main/java/org/entcore/directory/controllers/TimetableController.java @@ -37,6 +37,7 @@ import org.entcore.common.http.filter.AdmlOfStructure; import org.entcore.common.http.filter.ResourceFilter; import org.entcore.common.http.filter.SuperAdminFilter; +import org.entcore.common.storage.Storage; import org.entcore.common.utils.MapFactory; import org.entcore.common.utils.StringUtils; import org.entcore.directory.security.UserInStructure; @@ -69,6 +70,12 @@ public class TimetableController extends BaseController { private TimetableService timetableService; private Map importInProgress; + private Storage storage; + + public TimetableController(TimetableService timetableService, Storage storage) { + this.timetableService = timetableService; + this.storage = storage; + } @Override public void init(Vertx vertx, JsonObject config, RouteMatcher rm, Map securedActions) { @@ -283,7 +290,7 @@ private void receiveTimetableFile(final HttpServerRequest request, String struct public void handle(Throwable event) { importInProgress.remove(structureIdentifier); badRequest(request, event.getMessage()); - deleteImportPath(vertx, path); + deleteImportPath(vertx, storage, path); } }); request.uploadHandler(upload -> { @@ -291,7 +298,7 @@ public void handle(Throwable event) { upload.streamToFileSystem(filename).onComplete(event -> { Handler> hnd = result -> { importInProgress.remove(structureIdentifier); - reportResponseHandler(vertx, path, request).handle(result); + reportResponseHandler(vertx, storage, path, request).handle(result); }; if (feederImport != true) { diff --git a/directory/src/main/java/org/entcore/directory/controllers/UserBookController.java b/directory/src/main/java/org/entcore/directory/controllers/UserBookController.java index c7f5445f3f..83f236781e 100644 --- a/directory/src/main/java/org/entcore/directory/controllers/UserBookController.java +++ b/directory/src/main/java/org/entcore/directory/controllers/UserBookController.java @@ -36,7 +36,6 @@ import com.google.common.collect.Lists; import fr.wseduc.webutils.http.Renders; import fr.wseduc.webutils.request.CookieHelper; -import io.vertx.core.shareddata.LocalMap; import org.entcore.common.events.EventStore; import org.entcore.common.events.EventStoreFactory; import org.entcore.common.http.request.JsonHttpServerRequest; @@ -99,6 +98,12 @@ public void setUserPositionService(UserPositionService userPositionService) { this.userPositionService = userPositionService; } + private final Map serverMap; + + public UserBookController(Map serverMap) { + this.serverMap = serverMap; + } + @Override public void init(final Vertx vertx, JsonObject config, RouteMatcher rm, Map securedActions) { @@ -117,11 +122,11 @@ public void init(final Vertx vertx, JsonObject config, RouteMatcher rm, eventStore = EventStoreFactory.getFactory().getEventStore(ANNUAIRE_MODULE); if (config.getBoolean("activation-welcome-message", false)) { activationWelcomeMessage = new HashMap<>(); - String assetsPath = (String) vertx.sharedData().getLocalMap("server").get("assetPath"); - Map skins = vertx.sharedData().getLocalMap("skins"); + String assetsPath = (String) serverMap.get("assetPath"); + Map skins = getOrElse((JsonObject) serverMap.get("skins"), new JsonObject()).getMap(); if (skins != null) { activationWelcomeMessage = new HashMap<>(); - for (final Map.Entry e: skins.entrySet()) { + for (final Map.Entry e: skins.entrySet()) { String path = assetsPath + "/assets/themes/" + e.getValue() + "/template/directory/welcome/"; vertx.fileSystem().readDir(path, new Handler>>() { @Override @@ -155,7 +160,6 @@ public void handle(AsyncResult event) { @Get("/mon-compte") @SecuredAction(value = "userbook.authent", type = ActionType.AUTHENTICATED) public void monCompte(HttpServerRequest request) { - LocalMap serverMap = vertx.sharedData().getLocalMap("server"); JsonObject configuration = new JsonObject().put("hidePersonalData", serverMap.get("hidePersonalData")); renderView(request, configuration, "mon-compte.html", null); eventStore.createAndStoreEvent(DirectoryEvent.ACCESS.name(), request, new JsonObject().put("override-module", "MyAccount")); diff --git a/directory/src/main/java/org/entcore/directory/pojo/ImportInfos.java b/directory/src/main/java/org/entcore/directory/pojo/ImportInfos.java index 66ee4dbdcf..c148b0df60 100644 --- a/directory/src/main/java/org/entcore/directory/pojo/ImportInfos.java +++ b/directory/src/main/java/org/entcore/directory/pojo/ImportInfos.java @@ -27,6 +27,7 @@ import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; +import org.entcore.common.storage.Storage; import java.io.File; import java.util.*; @@ -156,7 +157,7 @@ public void setClassesMapping(JsonObject classesMapping) { this.classesMapping = (classesMapping != null) ? classesMapping.getMap() : null; } - public void validate(final boolean isAdmc, final Vertx vertx, final Handler> handler) { + public void validate(final boolean isAdmc, final Vertx vertx, Storage storage, final Handler> handler) { if (isAdmc && isEmpty(structureExternalId)) { structureExternalId = UUID.randomUUID().toString(); } @@ -178,7 +179,7 @@ public void handle(AsyncResult> list) { CompositeFuture.all(futures).onComplete(ar -> { if (ar.succeeded()) { if (ar.result().list().stream().allMatch(size -> ((Long) size) < MAX_FILE_SIZE)) { - moveFiles(list.result(), fs, handler); + moveFiles(path, storage, handler); } else { handler.handle(new DefaultAsyncResult<>("csv.file.too.long")); } @@ -212,35 +213,13 @@ private Future getFileSize(Vertx vertx, String p) { return future.future(); } - private void moveFiles(final List l, final FileSystem fs, final Handler> handler) { - final String p = path + File.separator + structureName + - (isNotEmpty(structureExternalId) ? "@" + structureExternalId: "") + "_" + - (isNotEmpty(UAI) ? UAI : "") + "_" + (isNotEmpty(overrideClass) ? overrideClass : ""); - fs.mkdir(p, new Handler>() { - @Override - public void handle(AsyncResult event) { - if (event.succeeded()) { - final AtomicInteger count = new AtomicInteger(l.size()); - for (String f: l) { - fs.move(f, p + File.separator + f.substring(path.length() + 1), new Handler>() { - @Override - public void handle(AsyncResult event2) { - if (event2.succeeded()) { - if (count.decrementAndGet() == 0) { - handler.handle(new DefaultAsyncResult<>((String) null)); - } - } else { - count.set(-1); - handler.handle(new DefaultAsyncResult(event2.cause())); - } - } - }); - } - } else { - handler.handle(new DefaultAsyncResult(event.cause())); - } - } - }); + private void moveFiles(final String directoryToMove, final Storage storage, final Handler> handler) { + final String targetPath = path + File.separator + structureName + + (isNotEmpty(structureExternalId) ? "@" + structureExternalId: "") + + (isNotEmpty(UAI) ? "_" + UAI : "") + (isNotEmpty(overrideClass) ? "_" + overrideClass : ""); + storage.moveFsDirectory(directoryToMove, targetPath) + .onSuccess(event -> handler.handle(new DefaultAsyncResult<>((String) null))) + .onFailure(th -> handler.handle(new DefaultAsyncResult<>(th.getCause()))); } } diff --git a/directory/src/main/java/org/entcore/directory/services/ImportService.java b/directory/src/main/java/org/entcore/directory/services/ImportService.java index 6e0df4c31d..7cf5ce154f 100644 --- a/directory/src/main/java/org/entcore/directory/services/ImportService.java +++ b/directory/src/main/java/org/entcore/directory/services/ImportService.java @@ -20,7 +20,9 @@ package org.entcore.directory.services; import fr.wseduc.webutils.Either; +import io.vertx.core.AsyncResult; import io.vertx.core.Handler; +import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonObject; import org.entcore.common.user.UserInfos; import org.entcore.directory.pojo.ImportInfos; @@ -46,4 +48,7 @@ public interface ImportService { void deleteLine(String importId, String profile, Integer line, Handler> handler); void findById(String importId, Handler> handler); -} + + void uploadImport(final HttpServerRequest request, final Handler> handler); + + } diff --git a/directory/src/main/java/org/entcore/directory/services/impl/DefaultImportService.java b/directory/src/main/java/org/entcore/directory/services/impl/DefaultImportService.java index 8089f8813c..024af4d22c 100644 --- a/directory/src/main/java/org/entcore/directory/services/impl/DefaultImportService.java +++ b/directory/src/main/java/org/entcore/directory/services/impl/DefaultImportService.java @@ -22,27 +22,38 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import fr.wseduc.mongodb.MongoDb; +import fr.wseduc.webutils.DefaultAsyncResult; import fr.wseduc.webutils.Either; -import io.vertx.core.Handler; -import io.vertx.core.Vertx; +import fr.wseduc.webutils.I18n; +import io.vertx.core.*; import io.vertx.core.eventbus.DeliveryOptions; import io.vertx.core.eventbus.EventBus; import io.vertx.core.eventbus.Message; +import io.vertx.core.http.HttpServerFileUpload; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.json.DecodeException; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; import org.entcore.common.mongodb.MongoDbResult; +import org.entcore.common.storage.Storage; +import org.entcore.common.user.DefaultFunctions; import org.entcore.common.user.UserInfos; +import org.entcore.common.user.UserUtils; import org.entcore.directory.Directory; +import org.entcore.directory.exceptions.ImportException; import org.entcore.directory.pojo.ImportInfos; import org.entcore.directory.services.ImportService; +import java.io.File; import java.util.Map; +import java.util.UUID; import static fr.wseduc.webutils.Utils.*; import static org.entcore.common.user.DefaultFunctions.ADMIN_LOCAL; import static org.entcore.common.user.DefaultFunctions.SUPER_ADMIN; +import static org.entcore.common.utils.FileUtils.deleteImportPath; public class DefaultImportService implements ImportService { @@ -53,9 +64,13 @@ public class DefaultImportService implements ImportService { private static final ObjectMapper mapper = new ObjectMapper(); private final MongoDb mongo = MongoDb.getInstance(); private static final String IMPORTS = "imports"; - public DefaultImportService(Vertx vertx, EventBus eb) { + private final Storage storage; + private final JsonObject config; + public DefaultImportService(Vertx vertx, EventBus eb, Storage storage, JsonObject config) { this.eb = eb; this.vertx = vertx; + this.storage = storage; + this.config = config; } @Override @@ -239,6 +254,126 @@ public void handle(Message event) { })); } + @Override + public void uploadImport(final HttpServerRequest request, final Handler> handler) { + request.pause(); + final String importId = UUID.randomUUID().toString(); + String path = config.getString("wizard-path", "/tmp") + File.separator + importId; + request.setExpectMultipart(true); + request.endHandler(new Handler() { + @Override + public void handle(Void v) { + final ImportInfos importInfos = new ImportInfos(); + importInfos.setId(importId); + importInfos.setPath(path); + importInfos.setStructureId(request.formAttributes().get("structureId")); + importInfos.setStructureExternalId(request.formAttributes().get("structureExternalId")); + importInfos.setPreDelete(paramToBoolean(request.formAttributes().get("predelete"))); + importInfos.setTransition(paramToBoolean(request.formAttributes().get("transition"))); + importInfos.setStructureName(request.formAttributes().get("structureName")); + importInfos.setUAI("null".equals(request.formAttributes().get("UAI")) ? null : request.formAttributes().get("UAI")); + importInfos.setLanguage(I18n.acceptLanguage(request)); + if (isNotEmpty(request.formAttributes().get("classExternalId"))) { + importInfos.setOverrideClass(request.formAttributes().get("classExternalId")); + } + + if (isNotEmpty(request.formAttributes().get("columnsMapping")) || + isNotEmpty(request.formAttributes().get("classesMapping"))) { + try { + if (isNotEmpty(request.formAttributes().get("columnsMapping"))) { + importInfos.setMappings(new JsonObject(request.formAttributes().get("columnsMapping"))); + } + if (isNotEmpty(request.formAttributes().get("classesMapping"))) { + importInfos.setClassesMapping(new JsonObject(request.formAttributes().get("classesMapping"))); + } + } catch (DecodeException e) { + handler.handle(new DefaultAsyncResult(new ImportException("invalid.columns.mapping", e))); + deleteImportPath(vertx, storage, path); + return; + } + } + try { + importInfos.setFeeder(request.formAttributes().get("type")); + } catch (IllegalArgumentException | NullPointerException e) { + handler.handle(new DefaultAsyncResult(new ImportException("invalid.import.type", e))); + deleteImportPath(vertx, storage, path); + return; + } + UserUtils.getUserInfos(eb, request, new Handler() { + @Override + public void handle(UserInfos user) { + if (user == null) { + handler.handle(new DefaultAsyncResult(new ImportException("invalid.admin"))); + deleteImportPath(vertx, storage, path); + return; + } + importInfos.validate( + user.getFunctions() != null && user.getFunctions().containsKey(DefaultFunctions.SUPER_ADMIN), + vertx, + storage, + new Handler>() { + @Override + public void handle(AsyncResult validate) { + if (validate.succeeded()) { + if (validate.result() == null) { + handler.handle(new DefaultAsyncResult<>(importInfos)); + } else { + handler.handle(new DefaultAsyncResult(new ImportException(validate.result()))); + deleteImportPath(vertx, storage, path); + } + } else { + handler.handle(new DefaultAsyncResult(validate.cause())); + log.error("Validate error", validate.cause()); + deleteImportPath(vertx, storage, path); + } + } + }); + } + }); + } + }); + request.exceptionHandler(new Handler() { + @Override + public void handle(Throwable event) { + handler.handle(new DefaultAsyncResult(event)); + deleteImportPath(vertx, storage, path); + } + }); + request.uploadHandler(new Handler() { + @Override + public void handle(final HttpServerFileUpload upload) { + if (!upload.filename().toLowerCase().endsWith(".csv")) { + handler.handle(new DefaultAsyncResult( + new ImportException("invalid.file.extension"))); + return; + } + final String filename = path + File.separator + upload.name(); + upload.streamToFileSystem(filename) + .onSuccess(event -> log.info("File " + upload.filename() + " uploaded as " + upload.name())) + .onFailure(th -> log.error("Cannot import " + upload.filename(), th)); + request.resume(); + } + }); + + deleteImportPath(vertx, storage, path,res->{ + vertx.fileSystem().mkdir(path, new Handler>() { + @Override + public void handle(AsyncResult event) { + if (event.succeeded()) { + request.resume(); + } else { + handler.handle(new DefaultAsyncResult( + new ImportException("mkdir.error", event.cause()))); + } + } + }); + }); + } + + private boolean paramToBoolean(String param) { + return "true".equalsIgnoreCase(param); + } + private class AdmlValidate { private boolean myResult; private UserInfos user; diff --git a/directory/src/main/java/org/entcore/directory/services/impl/DefaultMassMailService.java b/directory/src/main/java/org/entcore/directory/services/impl/DefaultMassMailService.java index 6d0e5d68e7..ca329c2b51 100644 --- a/directory/src/main/java/org/entcore/directory/services/impl/DefaultMassMailService.java +++ b/directory/src/main/java/org/entcore/directory/services/impl/DefaultMassMailService.java @@ -39,13 +39,12 @@ public class DefaultMassMailService extends Renders implements MassMailService { private final String node; private final Neo4j neo = Neo4j.getInstance(); - public DefaultMassMailService(Vertx vertx, EventBus eb, EmailSender notifHelper, JsonObject config) { + public DefaultMassMailService(Vertx vertx, EventBus eb, EmailSender notifHelper, JsonObject config, String node) { super(vertx, config); this.notifHelper = notifHelper; this.vertx = vertx; this.eb = eb; - String n = (String) vertx.sharedData().getLocalMap("server").get("node"); - this.node = n == null ? "" : n; + this.node = node == null ? "" : node; } public void massMailTypePdf(UserInfos userInfos, final HttpServerRequest request, final String templatePath, final String baseUrl, final String filename, final String type, final JsonArray users) { diff --git a/directory/src/main/java/org/entcore/directory/services/impl/DefaultMassMessagingService.java b/directory/src/main/java/org/entcore/directory/services/impl/DefaultMassMessagingService.java index 18530bba3b..a15258b804 100644 --- a/directory/src/main/java/org/entcore/directory/services/impl/DefaultMassMessagingService.java +++ b/directory/src/main/java/org/entcore/directory/services/impl/DefaultMassMessagingService.java @@ -33,8 +33,8 @@ import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; -import org.checkerframework.checker.units.qual.s; import org.entcore.common.neo4j.Neo4j; +import org.entcore.common.storage.Storage; import org.entcore.directory.pojo.ImportInfos; import org.entcore.directory.services.MassMessagingService; @@ -56,51 +56,54 @@ public class DefaultMassMessagingService implements MassMessagingService { private final EventBus eb; private final Vertx vertx; private final Neo4j neo4j = Neo4j.getInstance(); + private final Storage storage; - public DefaultMassMessagingService(Vertx vertx, EventBus eb) { + public DefaultMassMessagingService(Vertx vertx, EventBus eb, Storage storage) { this.eb = eb; this.vertx = vertx; + this.storage = storage; } @Override public void csvColumnsMapping(ImportInfos importInfos, final Handler> handler) { - - String path = importInfos.getPath(); - vertx.fileSystem().readDir(path, new Handler>>() { - @Override - public void handle(AsyncResult> event) { - if (event.succeeded() && event.result().size() == 1) { - final String path = event.result().get(0); - vertx.fileSystem().readDir(path, new Handler>>() { - @Override - public void handle(AsyncResult> event) { - final List importFiles = event.result(); - - - List> records = new ArrayList>(); - try (CSVReader csvReader = new CSVReader(new FileReader(importFiles.get(0)));) { - String[] values = null; - while ((values = csvReader.readNext()) != null) { - List row = Arrays.asList(values); - if (!row.isEmpty() && row.stream().anyMatch(value -> value != null && !value.trim().isEmpty())) { - records.add(Arrays.asList(values)); + String path = importInfos.getPath(); + storage.copyDirectoryToFs(path, path).onSuccess(event -> { + vertx.fileSystem().readDir(path, new Handler>>() { + @Override + public void handle(AsyncResult> event) { + if (event.succeeded() && event.result().size() == 1) { + final String path = event.result().get(0); + vertx.fileSystem().readDir(path, new Handler>>() { + @Override + public void handle(AsyncResult> event) { + final List importFiles = event.result(); + + + List> records = new ArrayList>(); + try (CSVReader csvReader = new CSVReader(new FileReader(importFiles.get(0)));) { + String[] values = null; + while ((values = csvReader.readNext()) != null) { + List row = Arrays.asList(values); + if (!row.isEmpty() && row.stream().anyMatch(value -> value != null && !value.trim().isEmpty())) { + records.add(Arrays.asList(values)); + } } + } catch (FileNotFoundException e) { + handler.handle(new Either.Left<>(new JsonObject().put("error", "File not found"))); + } catch (IOException e) { + handler.handle(new Either.Left<>(new JsonObject().put("error", "io exception"))); } - } catch (FileNotFoundException e) { - handler.handle(new Either.Left<>(new JsonObject().put("error", "File not found"))); - } catch (IOException e) { - handler.handle(new Either.Left<>(new JsonObject().put("error", "io exception"))); + handler.handle(new Either.Right<>(new JsonObject().put("asmRecords", records))); } - handler.handle(new Either.Right<>(new JsonObject().put("asmRecords", records))); - } - }); - } else { - handler.handle(new Either.Left<>(new JsonObject().put("error", "Failed reading from Path"))); + }); + } else { + handler.handle(new Either.Left<>(new JsonObject().put("error", "Failed reading from Path"))); + } } - } - }); + }); + }).onFailure(th -> handler.handle(new Either.Left<>(new JsonObject().put("error", "Failed to copy import files from storage to FS")))); } diff --git a/directory/src/main/java/org/entcore/directory/services/impl/UserbookRepositoryEvents.java b/directory/src/main/java/org/entcore/directory/services/impl/UserbookRepositoryEvents.java index 753cb16d8d..558499c695 100644 --- a/directory/src/main/java/org/entcore/directory/services/impl/UserbookRepositoryEvents.java +++ b/directory/src/main/java/org/entcore/directory/services/impl/UserbookRepositoryEvents.java @@ -26,6 +26,7 @@ import org.entcore.common.neo4j.Neo4j; import org.entcore.common.neo4j.StatementsBuilder; import org.entcore.common.user.RepositoryEvents; +import org.entcore.common.user.ExportResourceResult; import org.entcore.common.utils.StringUtils; import org.entcore.directory.services.UserBookService; @@ -53,7 +54,7 @@ public void mergeUsers(String keepedUserId, String deletedUserId) { @Override public void exportResources(JsonArray resourcesIds, boolean exportDocuments, boolean exportSharedResources, String exportId, String userId, - JsonArray groups, String exportPath, String locale, String host, Handler handler) { + JsonArray groups, String exportPath, String locale, String host, Handler handler) { } diff --git a/directory/src/test/java/org/entcore/directory/ValidationTest.java b/directory/src/test/java/org/entcore/directory/ValidationTest.java index d1d784febd..fc429e54bc 100644 --- a/directory/src/test/java/org/entcore/directory/ValidationTest.java +++ b/directory/src/test/java/org/entcore/directory/ValidationTest.java @@ -38,16 +38,15 @@ public static void setUp(TestContext context) throws Exception { JsonObject validationConfig = test.file().jsonFromResource("config/validations.json"); // Setup validations factory - UserValidationFactory userValidationFactory = UserValidationFactory.getFactory(); - userValidationFactory.init(test.vertx(), validationConfig); + UserValidationFactory.build(test.vertx(), validationConfig).onComplete(ar -> { + final Async async = context.async(); - final Async async = context.async(); - - test.directory().createActiveUser("login", "password", "email@test.com") - .onComplete(res -> { - context.assertTrue(res.succeeded()); - userId = res.result(); - async.complete(); + test.directory().createActiveUser("login", "password", "email@test.com") + .onComplete(res -> { + context.assertTrue(res.succeeded()); + userId = res.result(); + async.complete(); + }); }); } diff --git a/docker-compose.yml b/docker-compose.yml index b04be8d5eb..487d478608 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,7 +47,6 @@ services: image: opendigitaleducation/node:22-alpine-pnpm working_dir: /home/node/app volumes: - - ./broker-parent/broker-client/nest:/home/node/app - ~/.npm:/.npm - ../recette:/home/node/recette # TODO : rendre générique pour appliquer à tous les springboards environment: diff --git a/feeder/pom.xml b/feeder/pom.xml index 7b02f69cb2..ad47d32a4a 100644 --- a/feeder/pom.xml +++ b/feeder/pom.xml @@ -49,5 +49,12 @@ ${revision} test + + io.vertx + mod-mongo-persistor + 4.1-zookeeper-SNAPSHOT + runtime + fat + \ No newline at end of file diff --git a/feeder/src/main/java/org/entcore/feeder/Feeder.java b/feeder/src/main/java/org/entcore/feeder/Feeder.java index bd63976821..e4da09eb64 100644 --- a/feeder/src/main/java/org/entcore/feeder/Feeder.java +++ b/feeder/src/main/java/org/entcore/feeder/Feeder.java @@ -22,12 +22,17 @@ import fr.wseduc.cron.CronTrigger; import fr.wseduc.mongodb.MongoDb; import fr.wseduc.webutils.I18n; +import fr.wseduc.webutils.collections.SharedDataHelper; import io.vertx.core.AsyncResult; +import io.vertx.core.Future; import io.vertx.core.Handler; +import io.vertx.core.Promise; import io.vertx.core.eventbus.Message; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; +import org.apache.commons.lang3.tuple.Pair; import org.entcore.common.bus.MessageReplyNotifier; +import org.entcore.common.email.EmailFactory; import org.entcore.common.events.EventStoreFactory; import org.entcore.common.neo4j.Neo4j; import org.entcore.common.notification.TimelineHelper; @@ -102,19 +107,29 @@ public enum FeederEvent { private EDTUtils edtUtils; private ValidatorFactory validatorFactory; - @Override - public void start() { + public void start(Promise startPromise) { super.start(); - storage = new StorageFactory(vertx, config).getStorage(); + final SharedDataHelper sharedDataHelper = SharedDataHelper.getInstance(); + sharedDataHelper.init(vertx); + sharedDataHelper.getLocalMulti("server", "neo4jConfig", "node") + .compose(feederConfigMap -> StorageFactory.build(vertx, config) + .map(storageFactory -> Pair.of(feederConfigMap, storageFactory))) + .compose(configPair -> initFeeder(configPair.getLeft(), configPair.getRight())) + .onComplete(startPromise); + } + + public Future initFeeder(Map feederMap, StorageFactory storageFactory) { + storage = storageFactory.getStorage(); + EmailFactory.build(vertx, config); FeederLogger.init(config); - String node = (String) vertx.sharedData().getLocalMap("server").get("node"); + String node = (String) feederMap.get("node"); if (node == null) { node = ""; } - String neo4jConfig = (String) vertx.sharedData().getLocalMap("server").get("neo4jConfig"); + JsonObject neo4jConfig = config.getJsonObject("neo4jConfig"); if (neo4jConfig != null) { neo4j = Neo4j.getInstance(); - neo4j.init(vertx, new JsonObject(neo4jConfig).put("ignore-empty-statements-error", config.getBoolean("ignore-empty-statements-error", false))); + neo4j.init(vertx, neo4jConfig.put("ignore-empty-statements-error", config.getBoolean("ignore-empty-statements-error", false))); } MongoDb.getInstance().init(vertx.eventBus(), node + "wse.mongodb.persistor"); TransactionManager.getInstance().setNeo4j(neo4j); @@ -174,7 +189,7 @@ public void start() { } catch (ParseException e) { logger.fatal(e.getMessage(), e); vertx.close(); - return; + return Future.failedFuture(e); } final String reinitLoginCron = config.getString("reinit-login-cron", null); Validator.initLogin(neo4j, vertx); @@ -250,7 +265,8 @@ public void handle(Long l) } } I18n.getInstance().init(vertx); - validatorFactory = new ValidatorFactory(vertx); + validatorFactory = new ValidatorFactory(vertx, storage); + return Future.succeededFuture(); } private void setupImportCron(JsonObject cronConf, ImportsLauncher launcher) @@ -516,7 +532,7 @@ public void handle(AsyncResult event) { } private void csvClassesMapping(final Message message) { - final CsvValidator v = new CsvValidator(vertx, message.body().getString("langage"),message.body()); + final CsvValidator v = new CsvValidator(vertx, message.body().getString("langage"),message.body(), storage); String path = message.body().getString("path"); v.classesMapping(path, new Handler() { @Override @@ -590,7 +606,7 @@ public void handle(JsonObject event2) { private void csvColumnMapping(final Message message) { final String acceptLanguage = message.body().getString("language", "fr"); final CsvValidator v = new CsvValidator(vertx, acceptLanguage, - this.config.getJsonObject("csvMappings", new JsonObject())); + this.config.getJsonObject("csvMappings", new JsonObject()), storage); String path = message.body().getString("path"); v.columnsMapping(path, new Handler() { @Override @@ -613,7 +629,7 @@ private void launchImportValidation(final Message message, final Han final ImportValidator v; switch (source) { case "CSV": - v = new CsvValidator(vertx, acceptLanguage, message.body()); + v = new CsvValidator(vertx, acceptLanguage, message.body(), storage); break; case "AAF": case "AAF1D": diff --git a/feeder/src/main/java/org/entcore/feeder/ValidatorFactory.java b/feeder/src/main/java/org/entcore/feeder/ValidatorFactory.java index a99e0ad301..ff0154c8bf 100644 --- a/feeder/src/main/java/org/entcore/feeder/ValidatorFactory.java +++ b/feeder/src/main/java/org/entcore/feeder/ValidatorFactory.java @@ -21,6 +21,7 @@ import fr.wseduc.mongodb.MongoDb; import fr.wseduc.webutils.DefaultAsyncResult; +import org.entcore.common.storage.Storage; import org.entcore.feeder.csv.CsvValidator; import org.entcore.feeder.exceptions.ValidationException; import io.vertx.core.AsyncResult; @@ -32,9 +33,11 @@ public class ValidatorFactory { private final Vertx vertx; + private final Storage storage; - public ValidatorFactory(Vertx vertx) { + public ValidatorFactory(Vertx vertx, Storage storage) { this.vertx = vertx; + this.storage = storage; } public void validator(String importId, final Handler> handler) { @@ -45,7 +48,7 @@ public void handle(Message event) { if ("ok".equals(event.body().getString("status")) && result != null) { switch (result.getString("source")) { case "CSV" : - handler.handle(new DefaultAsyncResult(new CsvValidator(vertx, event.body().getString("language", "fr"), result))); + handler.handle(new DefaultAsyncResult(new CsvValidator(vertx, event.body().getString("language", "fr"), result, storage))); break; default: handler.handle(new DefaultAsyncResult( diff --git a/feeder/src/main/java/org/entcore/feeder/csv/CsvReport.java b/feeder/src/main/java/org/entcore/feeder/csv/CsvReport.java index ed6a1c38c4..9c9b0f4cf6 100644 --- a/feeder/src/main/java/org/entcore/feeder/csv/CsvReport.java +++ b/feeder/src/main/java/org/entcore/feeder/csv/CsvReport.java @@ -27,6 +27,7 @@ import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.core.file.FileSystem; +import org.entcore.common.storage.Storage; import org.entcore.common.utils.FileUtils; import org.entcore.feeder.exceptions.ValidationException; import org.entcore.feeder.utils.CSVUtil; @@ -46,9 +47,10 @@ public class CsvReport extends Report { private static final String CLASSES_MAPPING = "classesMapping"; private static final String HEADERS = "headers"; private final Vertx vertx; + private final Storage storage; protected final ProfileColumnsMapper columnsMapper; - public CsvReport(Vertx vertx, JsonObject importInfos) { + public CsvReport(Vertx vertx, Storage storage, JsonObject importInfos) { super(importInfos.getString("language", "fr")); final String importId = importInfos.getString("id"); if (isNotEmpty(importId)) { @@ -60,6 +62,7 @@ public CsvReport(Vertx vertx, JsonObject importInfos) { uncleanKeys(); } this.vertx = vertx; + this.storage = storage; this.columnsMapper = new ProfileColumnsMapper(getMappings()); } @@ -126,7 +129,7 @@ public void exportFiles(final Handler> handler) final String UAI = result.getString("UAI"); //clean directory if exists - FileUtils.deleteImportPath(vertx, originalPath, resDel -> + FileUtils.deleteImportPath(vertx, storage, originalPath, resDel -> { String basePath; diff --git a/feeder/src/main/java/org/entcore/feeder/csv/CsvValidator.java b/feeder/src/main/java/org/entcore/feeder/csv/CsvValidator.java index b6e4fee2c6..13e53d6deb 100644 --- a/feeder/src/main/java/org/entcore/feeder/csv/CsvValidator.java +++ b/feeder/src/main/java/org/entcore/feeder/csv/CsvValidator.java @@ -23,6 +23,7 @@ import io.vertx.core.Future; import org.entcore.common.neo4j.Neo4j; import org.entcore.common.neo4j.TransactionHelper; +import org.entcore.common.storage.Storage; import org.entcore.feeder.exceptions.TransactionException; import org.entcore.feeder.utils.*; import org.entcore.feeder.ImportValidator; @@ -65,6 +66,7 @@ private enum CsvValidationProcessType { VALIDATE, COLUMN_MAPPING,CLASSES_MAPPING public static final Map profiles; private final Map studentExternalIdMapping = new HashMap<>(); private final long defaultStudentSeed; + private final Storage storage; static { Map p = new HashMap<>(); @@ -76,11 +78,12 @@ private enum CsvValidationProcessType { VALIDATE, COLUMN_MAPPING,CLASSES_MAPPING profiles = Collections.unmodifiableMap(p); } - public CsvValidator(Vertx vertx, String acceptLanguage, JsonObject importInfos) { - super(vertx, importInfos); + public CsvValidator(Vertx vertx, String acceptLanguage, JsonObject importInfos, Storage storage) { + super(vertx, storage, importInfos); this.mappingFinder = new MappingFinder(vertx); this.vertx = vertx; defaultStudentSeed = getSeed(); + this.storage = storage; } public void columnsMapping(final String p, final Handler handler) { @@ -209,11 +212,9 @@ public void handle(AsyncResult event) { } } - private void process(final String p, final CsvValidationProcessType processType, - List admlStructures, final Handler handler) { - vertx.fileSystem().readDir(p, new Handler>>() { - @Override - public void handle(AsyncResult> event) { + private void process(final String p, final CsvValidationProcessType processType, List admlStructures, final Handler handler) { + storage.copyDirectoryToFs(p, p).onSuccess(filesCopiedToFS -> { + vertx.fileSystem().readDir(p, event -> { if (event.succeeded() && event.result().size() == 1) { final String path = event.result().get(0); String fileName = path.replaceAll("/$", "").substring(path.lastIndexOf("/") + 1); @@ -226,30 +227,30 @@ public void handle(AsyncResult> event) { handler.handle(result); return; } - vertx.fileSystem().readDir(path, new Handler>>() { - @Override - public void handle(AsyncResult> event) { - final List importFiles = event.result(); - Collections.sort(importFiles, Collections.reverseOrder()); - if (event.succeeded() && importFiles.size() > 0) { - if (processType == CsvValidationProcessType.VALIDATE && importFiles.stream().anyMatch(f -> f.endsWith("Relative"))) { - loadStudentExternalIdMapping(structureId, h -> { - processFiles(importFiles, handler, processType, path, admlStructures); - }); - } else { + vertx.fileSystem().readDir(path, event1 -> { + final List importFiles = event1.result(); + Collections.sort(importFiles, Collections.reverseOrder()); + if (event1.succeeded() && importFiles.size() > 0) { + if (processType == CsvValidationProcessType.VALIDATE && importFiles.stream().anyMatch(f -> f.endsWith("Relative"))) { + loadStudentExternalIdMapping(structureId, h -> { processFiles(importFiles, handler, processType, path, admlStructures); - } - } else { - addError("error.list.files"); - handler.handle(result); + }); + } else { + processFiles(importFiles, handler, processType, path, admlStructures); } + } else { + addError("error.list.files"); + handler.handle(result); } }); } else { addError("error.list.files"); handler.handle(result); } - } + }); + }).onFailure(th -> { + addError("error.copy.files.from.storage.to.fs"); + handler.handle(result); }); } diff --git a/feeder/src/main/java/org/entcore/feeder/dictionary/structures/User.java b/feeder/src/main/java/org/entcore/feeder/dictionary/structures/User.java index 0b5deca79e..b135fa8a85 100644 --- a/feeder/src/main/java/org/entcore/feeder/dictionary/structures/User.java +++ b/feeder/src/main/java/org/entcore/feeder/dictionary/structures/User.java @@ -975,7 +975,7 @@ public static void searchUserFromOldPlatform(Vertx vertx) { MongoDb.getInstance().find(OLD_PLATFORM_USERS, new JsonObject().put(UserDataSync.STATUS_FIELD, UserDataSync.SyncState.UNPROCESSED), null, keys, m -> { if ("ok".equals(m.body().getString("status"))) { final JsonArray res = m.body().getJsonArray("results"); - EmailSender emailSender = new EmailFactory(vertx).getSender(); + EmailSender emailSender = EmailFactory.getInstance().getSender(); HttpServerRequest forged = new JsonHttpServerRequest(new JsonObject()); if (res != null) { for (Object o : res) { diff --git a/feeder/src/main/java/org/entcore/feeder/utils/Report.java b/feeder/src/main/java/org/entcore/feeder/utils/Report.java index febea75b44..5adb9ff720 100644 --- a/feeder/src/main/java/org/entcore/feeder/utils/Report.java +++ b/feeder/src/main/java/org/entcore/feeder/utils/Report.java @@ -19,11 +19,6 @@ package org.entcore.feeder.utils; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; import java.util.stream.Collectors; import com.fasterxml.jackson.annotation.JsonValue; @@ -386,7 +381,7 @@ public void sendEmails(final Vertx vertx, final JsonObject config, String source return; } int count = sendReport.size(); - EmailFactory emailFactory = new EmailFactory(vertx, config); + EmailFactory emailFactory = EmailFactory.getInstance(); for (Object o : sendReport) { JsonObject currentSendReport = (JsonObject) o; if (currentSendReport.getJsonArray("to") == null // diff --git a/infra/pom.xml b/infra/pom.xml index 62588224a6..3cfa3a932c 100644 --- a/infra/pom.xml +++ b/infra/pom.xml @@ -31,5 +31,12 @@ ${revision} test + + io.vertx + mod-mongo-persistor + 4.1-zookeeper-SNAPSHOT + runtime + fat + \ No newline at end of file diff --git a/infra/src/main/java/org/entcore/infra/Starter.java b/infra/src/main/java/org/entcore/infra/Starter.java index b96ca7156a..8ca3f50714 100644 --- a/infra/src/main/java/org/entcore/infra/Starter.java +++ b/infra/src/main/java/org/entcore/infra/Starter.java @@ -21,16 +21,17 @@ import fr.wseduc.cron.CronTrigger; import fr.wseduc.mongodb.MongoDb; +import fr.wseduc.webutils.collections.SharedDataHelper; import fr.wseduc.webutils.http.Renders; import fr.wseduc.webutils.request.CookieHelper; import fr.wseduc.webutils.request.filter.SecurityHandler; import io.vertx.core.DeploymentOptions; +import io.vertx.core.Future; import io.vertx.core.Promise; import io.vertx.core.eventbus.MessageConsumer; import io.vertx.core.json.JsonArray; import io.vertx.core.metrics.MetricsOptions; import io.vertx.core.shareddata.AsyncMap; -import io.vertx.core.shareddata.LocalMap; import org.entcore.common.email.EmailFactory; import org.entcore.common.http.BaseServer; import org.entcore.common.notification.TimelineHelper; @@ -52,7 +53,10 @@ import java.io.File; import java.text.ParseException; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import static fr.wseduc.webutils.Utils.handlerToAsyncHandler; import static fr.wseduc.webutils.Utils.isNotEmpty; @@ -60,136 +64,32 @@ public class Starter extends BaseServer { @Override - public void start(Promise startPromise) { - try { - super.start(startPromise); - final LocalMap serverMap = vertx.sharedData().getLocalMap("server"); - serverMap.put("signKey", config.getString("key", "zbxgKWuzfxaYzbXcHnK3WnWK" + Math.random())); - - serverMap.put("sameSiteValue", config.getString("sameSiteValue", "Strict")); - serverMap.put("hidePersonalData", config.getBoolean("hidePersonalData", false)); - - //JWT need signKey - SecurityHandler.setVertx(vertx); - //encoding - final JsonArray encondings = config.getJsonArray("encoding-available", new JsonArray()); - final JsonArray safeEncondigs = new JsonArray(); - for(final Object o : encondings){ - safeEncondigs.add(o.toString()); - } - serverMap.put("encoding-available", safeEncondigs.encode()); - // - CookieHelper.getInstance().init((String) vertx - .sharedData().getLocalMap("server").get("signKey"), - (String) vertx.sharedData().getLocalMap("server").get("sameSiteValue"), - log); - - JsonObject swift = config.getJsonObject("swift"); - if (swift != null) { - serverMap.put("swift", swift.encode()); - } - JsonObject s3 = config.getJsonObject("s3"); - if (s3 != null) { - serverMap.put("s3", s3.encode()); - } - JsonObject emailConfig = config.getJsonObject("emailConfig"); - if (emailConfig != null) { - serverMap.put("emailConfig", emailConfig.encode()); - if(emailConfig.containsKey("postgresql")){ - addController(new MailController(vertx, emailConfig)); - } - } - JsonObject dataValidationConfig = config.getJsonObject("emailValidationConfig"); - if (dataValidationConfig != null) { - serverMap.put("emailValidationConfig", dataValidationConfig.encode()); - } - JsonObject mfaConfig = config.getJsonObject("mfaConfig"); - if (mfaConfig != null) { - serverMap.put("mfaConfig", mfaConfig.encode()); - } - final JsonObject webviewConfig = config.getJsonObject("webviewConfig"); - if (webviewConfig != null) { - serverMap.put("webviewConfig", webviewConfig.encode()); - } - JsonObject filesystem = config.getJsonObject("file-system"); - if (filesystem != null) { - serverMap.put("file-system", filesystem.encode()); - } - JsonObject neo4jConfig = config.getJsonObject("neo4jConfig"); - if (neo4jConfig != null) { - serverMap.put("neo4jConfig", neo4jConfig.encode()); - } - JsonObject mongoConfig = config.getJsonObject("mongoConfig"); - if (mongoConfig != null) { - serverMap.put("mongoConfig", mongoConfig.encode()); - } - JsonObject postgresConfig = config.getJsonObject("postgresConfig"); - if (postgresConfig != null) { - serverMap.put("postgresConfig", postgresConfig.encode()); - } - JsonObject explorerConfig = config.getJsonObject("explorerConfig"); - if (explorerConfig != null) { - serverMap.put("explorerConfig", explorerConfig.encode()); - } - JsonObject redisConfig = config.getJsonObject("redisConfig"); - if (redisConfig != null) { - serverMap.put("redisConfig", redisConfig.encode()); - } - JsonObject oauthCache = config.getJsonObject("oauthCache"); - if (oauthCache != null) { - serverMap.put("oauthCache", oauthCache.encode()); - } - serverMap.put("cache-enabled", config.getBoolean("cache-enabled", false)); - final String csp = config.getString("content-security-policy"); - if (isNotEmpty(csp)) { - serverMap.put("contentSecurityPolicy", csp); - } - JsonObject nodePdfGenerator = config.getJsonObject("node-pdf-generator"); - if (nodePdfGenerator != null) { - serverMap.put("node-pdf-generator", nodePdfGenerator.encode()); - } - final String staticHost = config.getString("static-host"); - if(staticHost != null) - { - serverMap.put("static-host", staticHost); - } - JsonObject eventStoreConfig = config.getJsonObject("event-store"); - if (eventStoreConfig != null) { - serverMap.put("event-store", eventStoreConfig.encode()); - } - serverMap.put("gridfsAddress", config.getString("gridfs-address", "wse.gridfs.persistor")); - final JsonObject metricsOptions = config.getJsonObject("metricsOptions"); - if(metricsOptions != null) { - serverMap.put("metricsOptions", metricsOptions.encode()); - } - final JsonObject contentTransformer = config.getJsonObject("content-transformer"); - if (contentTransformer != null) { - serverMap.put("content-transformer", contentTransformer.encode()); - } - //initModulesHelpers(node); - - /* sharedConf sub-object */ - JsonObject sharedConf = config.getJsonObject("sharedConf", new JsonObject()); - for(String field : sharedConf.fieldNames()){ - serverMap.put(field, sharedConf.getValue(field)); - } - - vertx.sharedData().getLocalMap("skins").putAll(config.getJsonObject("skins", new JsonObject()).getMap()); - - log.info("config skin-levels = " + config.getJsonObject("skin-levels", new JsonObject())); - - vertx.sharedData().getLocalMap("skin-levels").putAll(config.getJsonObject("skin-levels", new JsonObject()).getMap()); + public void start(Promise startPromise) throws Exception { + final Promise initInfraPromise = Promise.promise(); + super.start(initInfraPromise); + initInfraPromise.future().compose(init -> initInfra()).onComplete(startPromise); + } - log.info("localMap skin-levels = " + vertx.sharedData().getLocalMap("skin-levels")); + public Future initInfra() { + Promise returnPromise = Promise.promise(); + try { + vertx.sharedData().getLocalAsyncMap("server").onSuccess(asyncServerMap -> { + asyncServerMap.get("emailConfig") + .map(config -> (String) config) + .compose(emailConfigStr -> { + if (isNotEmpty(emailConfigStr)) { + JsonObject emailConfig = new JsonObject(emailConfigStr); + if(emailConfig.containsKey("postgresql")) { + addController(new MailController(vertx, emailConfig)); + } + } + return Future.succeededFuture(); + }); + }).onFailure(th -> log.error("Error getting server map", th)); - final MessageConsumer messageConsumer = vertx.eventBus().localConsumer("app-registry.loaded"); + final MessageConsumer messageConsumer = vertx.eventBus().consumer("app-registry.loaded"); messageConsumer.handler(message -> { -// JsonSchemaValidator validator = JsonSchemaValidator.getInstance(); -// validator.setEventBus(getEventBus(vertx)); -// validator.setAddress(node + "json.schema.validator"); -// validator.loadJsonSchema(getPathPrefix(config), vertx); - registerGlobalWidgets(config.getString("widgets-path", config.getString("assets-path", ".") + "/assets/widgets")); - loadInvalidEmails(); + loadInvalidEmails(); // TODO change map loadding if needed messageConsumer.unregister(); }); } catch (Exception ex) { @@ -232,20 +132,17 @@ public void start(Promise startPromise) { ); vertx.setPeriodic(checkMonitoringEvents.getLong("period", 300000L), monitoringEventsChecker); } - final boolean metricsActivated; if(config.getJsonObject("metricsOptions") == null) { - final String metricsOptions = (String) vertx.sharedData().getLocalMap("server").get("metricsOptions"); - if(metricsOptions == null){ - metricsActivated = false; - }else{ - metricsActivated = new MetricsOptions(new JsonObject(metricsOptions)).isEnabled(); - } - } else { - metricsActivated = new MetricsOptions(config.getJsonObject("metricsOptions")).isEnabled(); - } - if(metricsActivated) { + SharedDataHelper.getInstance().getLocal("server", "metricsOptions").onSuccess(metricsOptions -> { + if(isNotEmpty(metricsOptions) && new MetricsOptions(new JsonObject(metricsOptions)).isEnabled()){ + new MicrometerInfraMetricsRecorder(vertx); + } + }).onFailure(ex -> log.error("Error getting metrics options", ex)); + } else if (new MetricsOptions(config.getJsonObject("metricsOptions")).isEnabled()) { new MicrometerInfraMetricsRecorder(vertx); } + returnPromise.complete(); + return returnPromise.future(); } private void loadInvalidEmails() { @@ -282,7 +179,7 @@ public void handle(AsyncResult event) { } }); } - EmailFactory emailFactory = new EmailFactory(vertx, config); + EmailFactory emailFactory = EmailFactory.getInstance(); try { new CronTrigger(vertx, config.getString("hard-bounces-cron", "0 0 7 * * ? *")) .schedule(new HardBounceTask(emailFactory.getSender(), config.getInteger("hard-bounces-day", -1), @@ -295,53 +192,4 @@ public void handle(AsyncResult event) { }); } - private void registerWidget(final String widgetPath){ - final String widgetName = new File(widgetPath).getName(); - JsonObject widget = new JsonObject() - .put("name", widgetName) - .put("js", "/assets/widgets/"+widgetName+"/"+widgetName+".js") - .put("path", "/assets/widgets/"+widgetName+"/"+widgetName+".html"); - - if(vertx.fileSystem().existsBlocking(widgetPath + "/i18n")){ - widget.put("i18n", "/assets/widgets/"+widgetName+"/i18n"); - } - - JsonObject message = new JsonObject() - .put("widget", widget); - vertx.eventBus().request("wse.app.registry.widgets", message, handlerToAsyncHandler(new Handler>() { - public void handle(Message event) { - if("error".equals(event.body().getString("status"))){ - log.error("Error while registering widget "+widgetName+". "+event.body().getJsonArray("errors")); - return; - } - log.info("Successfully registered widget "+widgetName); - } - })); - } - - private void registerGlobalWidgets(String widgetsPath) { - vertx.fileSystem().readDir(widgetsPath, new Handler>>() { - public void handle(AsyncResult> asyn) { - if(asyn.failed()){ - log.error("Error while registering global widgets.", asyn.cause()); - return; - } - final List paths = asyn.result(); - for(final String path: paths){ - vertx.fileSystem().props(path, new Handler>() { - public void handle(AsyncResult asyn) { - if(asyn.failed()){ - log.error("Error while registering global widget " + path, asyn.cause()); - return; - } - if(asyn.result().isDirectory()){ - registerWidget(path); - } - } - }); - } - } - }); - } - } diff --git a/infra/src/main/java/org/entcore/infra/controllers/MonitoringController.java b/infra/src/main/java/org/entcore/infra/controllers/MonitoringController.java index 171d29566b..4879cc3058 100644 --- a/infra/src/main/java/org/entcore/infra/controllers/MonitoringController.java +++ b/infra/src/main/java/org/entcore/infra/controllers/MonitoringController.java @@ -32,7 +32,8 @@ import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; -import io.vertx.core.shareddata.LocalMap; +import io.vertx.core.shareddata.AsyncMap; + import org.entcore.common.http.filter.AdminFilter; import org.entcore.common.http.filter.IgnoreCsrf; import org.entcore.common.http.filter.ResourceFilter; @@ -111,11 +112,13 @@ public void checkDbNeo4j(final HttpServerRequest request) { @ResourceFilter(AdminFilter.class) public void checkVersionsAll(final HttpServerRequest request) { final JsonArray versions = new JsonArray(); - LocalMap versionMap = vertx.sharedData().getLocalMap("modsInfoMap"); - for (Map.Entry entry : versionMap.entrySet()) { - versions.add(new JsonObject().put(entry.getKey(), entry.getValue())); - } - Renders.renderJson(request, versions); + vertx.sharedData().getAsyncMap("modsInfoMap") + .compose(AsyncMap::entries).onSuccess(versionMap -> { + for (Map.Entry entry : versionMap.entrySet()) { + versions.add(new JsonObject().put(entry.getKey(), entry.getValue())); + } + Renders.renderJson(request, versions); + }).onFailure(ex -> renderError(request, new JsonObject().put("message", ex.getMessage()))); } @Get("/monitoring/versions") @@ -123,11 +126,14 @@ public void checkVersionsAll(final HttpServerRequest request) { @ResourceFilter(AdminFilter.class) public void checkVersions(final HttpServerRequest request) { final JsonArray versions = new JsonArray(); - LocalMap versionMap = vertx.sharedData().getLocalMap("versions"); - for (Map.Entry entry : versionMap.entrySet()) { - versions.add(new JsonObject().put(entry.getKey(), entry.getValue())); - } - Renders.renderJson(request, versions); + vertx.sharedData().getAsyncMap("versions") + .compose(AsyncMap::entries).onSuccess(versionMap -> { + for (Map.Entry entry : versionMap.entrySet()) { + versions.add(new JsonObject().put(entry.getKey(), entry.getValue())); + } + Renders.renderJson(request, versions); + }).onFailure(ex -> renderError(request, new JsonObject().put("message", ex.getMessage()))); + } @Get("/monitoring/detailedVersions") @@ -135,11 +141,13 @@ public void checkVersions(final HttpServerRequest request) { @ResourceFilter(AdminFilter.class) public void checkDetailedVersions(final HttpServerRequest request) { final JsonArray versions = new JsonArray(); - LocalMap versionMap = vertx.sharedData().getLocalMap("detailedVersions"); - for (Map.Entry entry : versionMap.entrySet()) { - versions.add(new JsonObject().put(entry.getKey(), entry.getValue())); - } - Renders.renderJson(request, versions); + vertx.sharedData().getAsyncMap("detailedVersions") + .compose(AsyncMap::entries).onSuccess(map -> { + for (Map.Entry entry : map.entrySet()) { + versions.add(new JsonObject().put(entry.getKey(), entry.getValue())); + } + Renders.renderJson(request, versions); + }).onFailure(ex -> renderError(request, new JsonObject().put("message", ex.getMessage()))); } private Handler> getResponseHandler(final String module, final long timerId, diff --git a/infra/src/main/java/org/entcore/infra/metrics/MicrometerInfraMetricsRecorder.java b/infra/src/main/java/org/entcore/infra/metrics/MicrometerInfraMetricsRecorder.java index 19f0e3d424..38c682baaf 100644 --- a/infra/src/main/java/org/entcore/infra/metrics/MicrometerInfraMetricsRecorder.java +++ b/infra/src/main/java/org/entcore/infra/metrics/MicrometerInfraMetricsRecorder.java @@ -2,10 +2,11 @@ import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; +import io.vertx.core.Future; +import io.vertx.core.Promise; import io.vertx.core.Vertx; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; -import io.vertx.core.shareddata.LocalMap; import io.vertx.micrometer.backends.BackendRegistries; import org.apache.commons.lang3.StringUtils; @@ -29,27 +30,33 @@ public MicrometerInfraMetricsRecorder(final Vertx vertx) { if(registry == null) { throw new IllegalStateException("micrometer.registries.empty"); } - String literalVersion; - final AtomicLong version = new AtomicLong(); - try { - literalVersion = getLiteralVersion(vertx); - version.set(getEntVersion(literalVersion)); - } catch (Exception e) { - log.error("An error occurred while creating the metrics to expose ent version"); - literalVersion = "error"; - version.set(-1); - } - Gauge.builder("ent.version", version::get) - .tag("ent-version", literalVersion).register(registry); + + getLiteralVersion(vertx).onSuccess(lVersion -> { + String literalVersion = lVersion; + final AtomicLong version = new AtomicLong(); + try { + version.set(getEntVersion(literalVersion)); + } catch (Exception e) { + log.error("An error occurred while creating the metrics to expose ent version"); + literalVersion = "error"; + version.set(-1); + } + Gauge.builder("ent.version", version::get) + .tag("ent-version", literalVersion).register(registry); + }); } - private String getLiteralVersion(Vertx vertx) { + private Future getLiteralVersion(Vertx vertx) { + final Promise promise = Promise.promise(); String entVersion = vertx.getOrCreateContext().config().getString("ent-version"); if(org.apache.commons.lang3.StringUtils.isEmpty(entVersion)) { - final LocalMap localMap = vertx.sharedData().getLocalMap("server"); - entVersion = (String)localMap.get("ent-version"); + vertx.sharedData().getLocalAsyncMap("server").compose(server -> server.get("ent-version")).onSuccess(version -> + promise.complete(StringUtils.isEmpty(entVersion) ? "na" : entVersion) + ).onFailure(ex -> promise.fail(ex)); + } else { + promise.complete(entVersion); } - return StringUtils.isEmpty(entVersion) ? "na" : entVersion; + return promise.future(); } private Long getEntVersion(final String entVersion) { diff --git a/infra/src/main/java/org/entcore/infra/services/impl/AbstractAntivirusService.java b/infra/src/main/java/org/entcore/infra/services/impl/AbstractAntivirusService.java index 3e92706537..6657d354e3 100644 --- a/infra/src/main/java/org/entcore/infra/services/impl/AbstractAntivirusService.java +++ b/infra/src/main/java/org/entcore/infra/services/impl/AbstractAntivirusService.java @@ -57,8 +57,10 @@ public abstract class AbstractAntivirusService implements AntivirusService, Hand public void init() { this.queue = new HashMap<>(); - this.storage = new StorageFactory(vertx).getStorage(); - vertx.eventBus().localConsumer("antivirus", this); + StorageFactory.build(vertx) + .onSuccess(storageFactory -> this.storage = storageFactory.getStorage()) + .onFailure(ex -> log.error("Error building storage factory", ex)); + vertx.eventBus().consumer("antivirus", this); } protected abstract void parseScanReport(String path, Handler>> handler); diff --git a/infra/src/main/java/org/entcore/infra/services/impl/ExecCommandWorker.java b/infra/src/main/java/org/entcore/infra/services/impl/ExecCommandWorker.java index 9231c21a59..cbbb72d862 100644 --- a/infra/src/main/java/org/entcore/infra/services/impl/ExecCommandWorker.java +++ b/infra/src/main/java/org/entcore/infra/services/impl/ExecCommandWorker.java @@ -35,7 +35,7 @@ public class ExecCommandWorker extends BusModBase implements HandlergetLocalAsyncMap("server") + .compose(serverMap -> serverMap.get("event-store")) + .onSuccess(eventStoreConf -> { + if (eventStoreConf != null) { + final JsonObject eventStoreConfig = new JsonObject(eventStoreConf); + if (eventStoreConfig.containsKey("postgresql")) { + pgEventStore = new PostgresqlEventStore(); + pgEventStore.setEventBus(vertx.eventBus()); + pgEventStore.setModule("infra"); + pgEventStore.setVertx(vertx); + pgEventStore.init(); + } + eventsBatchSize = eventStoreConfig.getInteger("events-batch-size", DEFAULT_EVENT_BATCH_SIZE); + } else { + eventsBatchSize = DEFAULT_EVENT_BATCH_SIZE; + } + }).onFailure(ex -> log.error("Error when get event-store conf in server map", ex)); } @Override diff --git a/infra/src/test/java/org/entcore/infra/EventStoreTest.java b/infra/src/test/java/org/entcore/infra/EventStoreTest.java index a49b7f3270..3584bebff6 100644 --- a/infra/src/test/java/org/entcore/infra/EventStoreTest.java +++ b/infra/src/test/java/org/entcore/infra/EventStoreTest.java @@ -63,7 +63,7 @@ public static void setUp(TestContext context) throws Exception { final EventStoreFactory fac = new PostgresqlEventStoreFactory(); fac.setVertx(test.vertx()); eventStore = fac.getEventStore("test"); - test.vertx().eventBus().localConsumer("event.store", new Handler>() { + test.vertx().eventBus().consumer("event.store", new Handler>() { @Override public void handle(Message event) { final JsonObject json = event.body(); diff --git a/infra/src/test/java/org/entcore/infra/EventWorkerForTest.java b/infra/src/test/java/org/entcore/infra/EventWorkerForTest.java index 443f1408d0..ef72d413ee 100644 --- a/infra/src/test/java/org/entcore/infra/EventWorkerForTest.java +++ b/infra/src/test/java/org/entcore/infra/EventWorkerForTest.java @@ -19,7 +19,7 @@ public class EventWorkerForTest extends BusModBase implements Handler io.edifice app-parent - 1.0 + 1.1-zookeeper-SNAPSHOT org.entcore @@ -57,13 +57,13 @@ - 6.12-SNAPSHOT + 6.11-zookeeper-SNAPSHOT 4.13.2 1.19.3 - 2.0-SNAPSHOT - 4.0-SNAPSHOT - 3.2-SNAPSHOT - 3.0-SNAPSHOT + 2.0-zookeeper-SNAPSHOT + 4.1-zookeeper-SNAPSHOT + 3.2-zookeeper-SNAPSHOT + 3.0-zookeeper-SNAPSHOT 2.9.4 2.1 1.11.4 @@ -172,6 +172,34 @@ + + com.opendigitaleducation + mod-json-schema-validator + 2.0-zookeeper-SNAPSHOT + runtime + fat + + + fr.wseduc + mod-image-resizer + 3.1-zookeeper-SNAPSHOT + runtime + fat + + + fr.wseduc + mod-zip + 3.1-zookeeper-SNAPSHOT + runtime + fat + + + fr.wseduc + mod-pdf-generator + 2.0-zookeeper-SNAPSHOT + runtime + fat + diff --git a/portal/backend/src/main/java/org/entcore/portal/Portal.java b/portal/backend/src/main/java/org/entcore/portal/Portal.java index 8e6ec4220c..b9d8486ab3 100644 --- a/portal/backend/src/main/java/org/entcore/portal/Portal.java +++ b/portal/backend/src/main/java/org/entcore/portal/Portal.java @@ -19,8 +19,19 @@ package org.entcore.portal; +import io.vertx.core.AsyncResult; +import io.vertx.core.Future; +import io.vertx.core.Handler; import io.vertx.core.Promise; +import io.vertx.core.eventbus.Message; +import io.vertx.core.file.FileProps; +import io.vertx.core.json.JsonObject; + +import static fr.wseduc.webutils.Utils.handlerToAsyncHandler; + import java.io.File; +import java.util.List; + import org.entcore.broker.api.utils.AddressParameter; import org.entcore.broker.api.utils.BrokerProxyUtils; import org.entcore.common.cache.CacheService; @@ -29,17 +40,85 @@ import org.entcore.portal.listeners.I18nBrokerListenerImpl; import org.entcore.common.events.EventBrokerListenerImpl; +import fr.wseduc.webutils.collections.SharedDataHelper; + public class Portal extends BaseServer { @Override public void start(final Promise startPromise) throws Exception { - super.start(startPromise); - final String assetPath = config.getString("assets-path", "../..")+ File.separator + "assets"; - final AddressParameter parameter = new AddressParameter("application", "portal"); - final CacheService cacheService = CacheService.create(vertx); - BrokerProxyUtils.addBrokerProxy(new I18nBrokerListenerImpl(vertx, assetPath, cacheService), vertx, parameter); - BrokerProxyUtils.addBrokerProxy(new EventBrokerListenerImpl(), vertx); - addController(new PortalController()); + final Promise promise = Promise.promise(); + super.start(promise); + promise.future().compose(x -> + SharedDataHelper.getInstance().getLocal("server", "skins") + ) + .compose(this::initPortal) + .onComplete(startPromise); + } + + public Future initPortal(final JsonObject skins) { + return Future.future(p -> { + try { + final String assetPath = config.getString("assets-path", "../.."); + final AddressParameter parameter = new AddressParameter("application", "portal"); + final CacheService cacheService = CacheService.create(vertx); + BrokerProxyUtils.addBrokerProxy(new I18nBrokerListenerImpl(vertx, assetPath, cacheService), vertx, parameter); + BrokerProxyUtils.addBrokerProxy(new EventBrokerListenerImpl(), vertx); + addController(new PortalController(skins)); + registerGlobalWidgets(config.getString("widgets-path", config.getString("assets-path", ".") + "/assets/widgets")); + p.complete(); + } catch (Exception e) { + p.fail(e); + } + }); + } + + private void registerWidget(final String widgetPath){ + final String widgetName = new File(widgetPath).getName(); + JsonObject widget = new JsonObject() + .put("name", widgetName) + .put("js", "/assets/widgets/"+widgetName+"/"+widgetName+".js") + .put("path", "/assets/widgets/"+widgetName+"/"+widgetName+".html"); + + if(vertx.fileSystem().existsBlocking(widgetPath + "/i18n")){ + widget.put("i18n", "/assets/widgets/"+widgetName+"/i18n"); + } + + JsonObject message = new JsonObject() + .put("widget", widget); + vertx.eventBus().request("wse.app.registry.widgets", message, handlerToAsyncHandler(new Handler>() { + public void handle(Message event) { + if("error".equals(event.body().getString("status"))){ + log.error("Error while registering widget "+widgetName+". "+event.body().getJsonArray("errors")); + return; + } + log.info("Successfully registered widget "+widgetName); + } + })); + } + + private void registerGlobalWidgets(String widgetsPath) { + vertx.fileSystem().readDir(widgetsPath, new Handler>>() { + public void handle(AsyncResult> asyn) { + if(asyn.failed()){ + log.error("Error while registering global widgets.", asyn.cause()); + return; + } + final List paths = asyn.result(); + for(final String path: paths){ + vertx.fileSystem().props(path, new Handler>() { + public void handle(AsyncResult asyn) { + if(asyn.failed()){ + log.error("Error while registering global widget " + path, asyn.cause()); + return; + } + if(asyn.result().isDirectory()){ + registerWidget(path); + } + } + }); + } + } + }); } } diff --git a/portal/backend/src/main/java/org/entcore/portal/controllers/PortalController.java b/portal/backend/src/main/java/org/entcore/portal/controllers/PortalController.java index 6d3a105e52..a6794698c8 100644 --- a/portal/backend/src/main/java/org/entcore/portal/controllers/PortalController.java +++ b/portal/backend/src/main/java/org/entcore/portal/controllers/PortalController.java @@ -23,13 +23,13 @@ import fr.wseduc.rs.Get; import fr.wseduc.rs.Put; import fr.wseduc.webutils.I18n; +import fr.wseduc.webutils.collections.SharedDataHelper; import fr.wseduc.webutils.http.BaseController; import fr.wseduc.webutils.http.StaticResource; import fr.wseduc.webutils.request.CookieHelper; import fr.wseduc.webutils.request.RequestUtils; -import fr.wseduc.webutils.security.SecureHttpServerRequest; +import io.vertx.core.Future; import io.vertx.core.file.FileSystemException; -import io.vertx.core.shareddata.LocalMap; import org.entcore.common.events.EventHelper; import org.entcore.common.events.EventStore; @@ -55,18 +55,18 @@ import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; import io.vertx.core.eventbus.Message; -import io.vertx.core.file.FileProps; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import org.vertx.java.core.http.RouteMatcher; import static fr.wseduc.webutils.Utils.isNotEmpty; +import static io.vertx.core.Future.succeededFuture; import static org.entcore.common.user.SessionAttributes.*; public class PortalController extends BaseController { - private LocalMap staticRessources; + private Map staticRessources; private Map fixResources = new HashMap<>(); private boolean dev; private Map> themes; @@ -78,59 +78,70 @@ private enum PortalEvent { ACCESS_ADAPTER, ACCESS } private String defaultSkin; private JsonObject defaultTracker; private EventHelper eventHelper; + private JsonObject skins; + + public PortalController(JsonObject skins) { + this.skins = skins; + } @Override - public void init(final Vertx vertx, JsonObject config, RouteMatcher rm, - Map securedActions) { + public Future initAsync(final Vertx vertx, JsonObject config, RouteMatcher rm, + Map securedActions) { super.init(vertx, config, rm, securedActions); - this.staticRessources = vertx.sharedData().getLocalMap("staticRessources"); - dev = "dev".equals(config.getString("mode")); - assetsPath = config.getString("assets-path", "."); - JsonObject skins = new JsonObject(vertx.sharedData().getLocalMap("skins")); - defaultSkin = config.getString("skin", "raw"); - themes = new HashMap<>(); - themesDetails = new HashMap<>(); - this.hostSkin = new HashMap<>(); - for (final String domain: skins.fieldNames()) { - final String skin = skins.getString(domain); - this.hostSkin.put(domain, skin); - ThemeUtils.availableThemes(vertx, assetsPath + "/assets/themes/" + skin + "/skins", false, new Handler>() { - @Override - public void handle(List event) { - themes.put(skin, event); - JsonArray a = new JsonArray(); - for (final String s : event) { - String path = assetsPath + "/assets/themes/" + skin + "/skins/" + s + "/"; - final JsonObject j = new JsonObject() - .put("_id", s) - .put("path", path.substring(assetsPath.length())); - if ("default".equals(s)) { - vertx.fileSystem().readFile(path + "/details.json", new Handler>() { - @Override - public void handle(AsyncResult event) { - if (event.succeeded()) { - JsonObject d = new JsonObject(event.result().toString()); - j.put("displayName", d.getString("displayName")); - } else { - j.put("displayName", s); - } - } - }); - } else { - j.put("displayName", s); - } - a.add(j); - } - themesDetails.put(skin, a); - } - }); - } - defaultTracker = config.getJsonObject( "tracker", new JsonObject().put("type", "none") ); - eventStore = EventStoreFactory.getFactory().getEventStore(Portal.class.getSimpleName()); - vertx.sharedData().getLocalMap("server").put("assetPath", assetsPath); - - final EventStore eventStore = EventStoreFactory.getFactory().getEventStore(Portal.class.getSimpleName()); - this.eventHelper = new EventHelper(eventStore); + return vertx.sharedData().getAsyncMap("staticRessources") + .compose(map -> map.entries()) + .compose(resources -> { + this.staticRessources = resources; + dev = "dev".equals(config.getString("mode")); + assetsPath = config.getString("assets-path", "."); + defaultSkin = config.getString("skin", "raw"); + themes = new HashMap<>(); + themesDetails = new HashMap<>(); + this.hostSkin = new HashMap<>(); + for (final String domain: skins.fieldNames()) { + final String skin = skins.getString(domain); + this.hostSkin.put(domain, skin); + ThemeUtils.availableThemes(vertx, assetsPath + "/assets/themes/" + skin + "/skins", false, new Handler>() { + @Override + public void handle(List event) { + themes.put(skin, event); + JsonArray a = new JsonArray(); + for (final String s : event) { + String path = assetsPath + "/assets/themes/" + skin + "/skins/" + s + "/"; + final JsonObject j = new JsonObject() + .put("_id", s) + .put("path", path.substring(assetsPath.length())); + if ("default".equals(s)) { + vertx.fileSystem().readFile(path + "/details.json", new Handler>() { + @Override + public void handle(AsyncResult event) { + if (event.succeeded()) { + JsonObject d = new JsonObject(event.result().toString()); + j.put("displayName", d.getString("displayName")); + } else { + j.put("displayName", s); + } + } + }); + } else { + j.put("displayName", s); + } + a.add(j); + } + themesDetails.put(skin, a); + } + }); + } + defaultTracker = config.getJsonObject( "tracker", new JsonObject().put("type", "none") ); + eventStore = EventStoreFactory.getFactory().getEventStore(Portal.class.getSimpleName()); + SharedDataHelper.getInstance().getLocalAsyncMap("server") + .compose(serverMap -> serverMap.put("assetPath", assetsPath)) + .onFailure(ex -> log.error("Error when put assetPath", ex)); + + final EventStore eventStore = EventStoreFactory.getFactory().getEventStore(Portal.class.getSimpleName()); + this.eventHelper = new EventHelper(eventStore); + return succeededFuture(); + }); } @Get("/welcome") diff --git a/portal/frontend/build.sh b/portal/frontend/build.sh index 373d45edb5..c93c5e47ff 100755 --- a/portal/frontend/build.sh +++ b/portal/frontend/build.sh @@ -55,6 +55,10 @@ init () { echo "[init] Get branch name from git..." BRANCH_NAME=`git branch | sed -n -e "s/^\* \(.*\)/\1/p"` fi + if [ ! -z "$FRONT_TAG" ]; then + echo "[buildNode] Get tag name from jenkins param... $FRONT_TAG" + BRANCH_NAME="$FRONT_TAG" + fi echo "[init] Generate package.json from package.json.template..." NPM_VERSION_SUFFIX=`date +"%Y%m%d%H%M"` diff --git a/session/pom.xml b/session/pom.xml index 06ee3bffa6..f2358853e9 100644 --- a/session/pom.xml +++ b/session/pom.xml @@ -25,5 +25,12 @@ ${revision} test + + io.vertx + mod-mongo-persistor + 4.1-zookeeper-SNAPSHOT + runtime + fat + \ No newline at end of file diff --git a/session/src/main/java/org/entcore/session/AuthManager.java b/session/src/main/java/org/entcore/session/AuthManager.java index 1da507adab..726f756305 100644 --- a/session/src/main/java/org/entcore/session/AuthManager.java +++ b/session/src/main/java/org/entcore/session/AuthManager.java @@ -22,6 +22,8 @@ import fr.wseduc.mongodb.MongoDb; import fr.wseduc.mongodb.MongoUpdateBuilder; import fr.wseduc.webutils.Either; +import fr.wseduc.webutils.collections.SharedDataHelper; + import static fr.wseduc.webutils.Utils.getOrElse; import io.vertx.core.AsyncResult; import io.vertx.core.CompositeFuture; @@ -31,7 +33,10 @@ import io.vertx.core.eventbus.Message; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; -import io.vertx.core.shareddata.LocalMap; + +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; +import io.vertx.core.shareddata.SharedData; import org.apache.commons.lang3.tuple.Pair; import org.entcore.broker.api.utils.BrokerProxyUtils; import org.entcore.common.cache.CacheService; @@ -44,6 +49,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -62,32 +68,35 @@ public class AuthManager extends BusModBase implements Handler startPromise) { super.start(); - BrokerProxyUtils.addBrokerProxy(new SessionBrokerListenerImpl(this), vertx); + final SharedDataHelper sharedDataHelper = SharedDataHelper.getInstance(); + sharedDataHelper.init(vertx); - LocalMap server = vertx.sharedData().getLocalMap("server"); + initSession(config.getMap()) + .onComplete(startPromise); + } - String neo4jConfig = (String) server.get("neo4jConfig"); + public Future initSession(Map sessionMap) { + BrokerProxyUtils.addBrokerProxy(new SessionBrokerListenerImpl(this), vertx); + final JsonObject neo4jConfig = (JsonObject) sessionMap.get("neo4jConfig"); neo4j = Neo4j.getInstance(); - neo4j.init(vertx, new JsonObject(neo4jConfig)); + neo4j.init(vertx, neo4jConfig); - cluster = (Boolean) server.get("cluster"); - String node = (String) server.get("node"); + cluster = vertx.isClustered(); + String node = (String) sessionMap.get("node"); mongo = MongoDb.getInstance(); mongo.init(vertx.eventBus(), node + config.getString("mongo-address", "wse.mongodb.persistor")); - sessionStore = new MapSessionStore(vertx, cluster, config); - this.xsrfOnAuth = config.getBoolean("xsrfOnAuth", true); try { - Object oauthCacheConf = server.get("oauthCache"); + JsonObject oauthCacheConf = (JsonObject) sessionMap.get("oauthCache"); if(oauthCacheConf != null) { - JsonObject redisConfig = new JsonObject((String) server.get("redisConfig")); - if(new JsonObject((String)oauthCacheConf).getBoolean("enabled", false) == true) + JsonObject redisConfig = (JsonObject)sessionMap.get("redisConfig"); + if(oauthCacheConf.getBoolean("enabled", false) == true) { Redis.getInstance().init(vertx, redisConfig); this.OAuthCacheService = CacheService.create(vertx); @@ -99,8 +108,12 @@ public void start() { logger.error("Failed to create OAuthCacheService: " + e.getMessage()); } - final String address = getOptionalStringConfig("address", "wse.session"); - eb.localConsumer(address, this); + if (AuthManager.class.getName().equals(this.getClass().getName())) { + sessionStore = new MapSessionStore(vertx, cluster, config); + final String address = getOptionalStringConfig("address", "wse.session"); + eb.consumer(address, this); + } + return Future.succeededFuture(); } @Override diff --git a/session/src/main/java/org/entcore/session/MapSessionStore.java b/session/src/main/java/org/entcore/session/MapSessionStore.java index e180e1d824..a002f8063b 100644 --- a/session/src/main/java/org/entcore/session/MapSessionStore.java +++ b/session/src/main/java/org/entcore/session/MapSessionStore.java @@ -175,7 +175,7 @@ public void putSession(String userId, String sessionId, JsonObject infos, boolea addLoginInfo(userId, timerId, sessionId); handler.handle(Future.succeededFuture()); } catch (Exception e) { - logger.error("Error putting session in hazelcast map"); + logger.error("Error putting session in hazelcast map", e); handler.handle(Future.failedFuture(new SessionException("Error putting session in hazelcast map"))); } } diff --git a/stop.sh b/stop.sh index b616f5f390..8669a1ed3d 100755 --- a/stop.sh +++ b/stop.sh @@ -1,4 +1,4 @@ #!/bin/sh -docker-compose stop neo4j +docker compose stop neo4j PID_ENT=$(ps -ef | grep vertx | grep -v grep | sed 's/\s\+/ /g' | cut -d' ' -f2) kill $PID_ENT diff --git a/test/pom.xml b/test/pom.xml index dfc2d6247b..0bfbe20d30 100644 --- a/test/pom.xml +++ b/test/pom.xml @@ -13,6 +13,11 @@ test + + com.google.guava + guava + 31.0.1-jre + org.entcore common diff --git a/test/src/main/java/org/entcore/test/DatabaseTestHelper.java b/test/src/main/java/org/entcore/test/DatabaseTestHelper.java index 886696451c..005ff4ed17 100644 --- a/test/src/main/java/org/entcore/test/DatabaseTestHelper.java +++ b/test/src/main/java/org/entcore/test/DatabaseTestHelper.java @@ -39,6 +39,8 @@ public class DatabaseTestHelper { + private JsonObject neo4jConfig; + private class PostgreSQLContainerWithParams extends PostgreSQLContainer { public PostgreSQLContainerWithParams(String dockerImageName) { @@ -197,8 +199,15 @@ public void initNeo4j(TestContext context, Neo4jContainer neo4jContainer) { final JsonObject config = new JsonObject().put("server-uri", base).put("poolSize", 1); final Neo4j neo4j = Neo4j.getInstance(); neo4j.init(vertx, config); + this.neo4jConfig = config; vertx.sharedData().getLocalMap("server").put("neo4jConfig", config.encode()); + } + public JsonObject addNeo4jConfig(final JsonObject conf) { + if(conf != null && neo4jConfig != null) { + conf.put("neo4jConfig", neo4jConfig); + } + return conf; } /** @return a new docker-based PostgreSQL 9.5 container. */ diff --git a/tests/pom.xml b/tests/pom.xml index 085885d682..a7657f5a97 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -12,6 +12,10 @@ tests + + false + + io.gatling.highcharts diff --git a/timeline/pom.xml b/timeline/pom.xml index 6638ad0bc3..a628b9c977 100644 --- a/timeline/pom.xml +++ b/timeline/pom.xml @@ -31,5 +31,19 @@ ${revision} test + + io.vertx + mod-mongo-persistor + 4.1-zookeeper-SNAPSHOT + runtime + fat + + + fr.wseduc + mod-postgresql + 2.0-zookeeper-SNAPSHOT + runtime + fat + \ No newline at end of file diff --git a/timeline/src/main/java/org/entcore/timeline/Timeline.java b/timeline/src/main/java/org/entcore/timeline/Timeline.java index 760d8272db..c0297f612a 100644 --- a/timeline/src/main/java/org/entcore/timeline/Timeline.java +++ b/timeline/src/main/java/org/entcore/timeline/Timeline.java @@ -28,14 +28,15 @@ import java.util.List; import java.util.Map; +import io.vertx.core.Future; import io.vertx.core.Promise; -import io.vertx.core.shareddata.LocalMap; +import io.vertx.core.shareddata.AsyncMap; +import fr.wseduc.webutils.collections.SharedDataHelper; import fr.wseduc.webutils.http.oauth.OAuth2Client; import org.entcore.broker.api.publisher.BrokerPublisherFactory; import org.entcore.broker.api.utils.BrokerProxyUtils; import org.entcore.broker.proxy.ApplicationStatusBrokerPublisher; import org.entcore.common.http.BaseServer; -import org.entcore.common.utils.MapFactory; import org.entcore.timeline.controllers.helper.NotificationHelper; import org.entcore.timeline.listeners.TimelineBrokerListenerImpl; import org.entcore.timeline.services.FlashMsgService; @@ -53,33 +54,48 @@ public class Timeline extends BaseServer { + private static final long DELAY_REFRESH_NOTIFICATION_CACHE = 60_000L; + @Override public void start(final Promise startPromise) throws Exception { - super.start(startPromise); + final Promise promise = Promise.promise(); + super.start(promise); + promise.future() + .compose(init -> SharedDataHelper.getInstance().getLocalMulti("server", "skins", "skin-levels")) + .compose(timelineConfigMap -> initTimeline(timelineConfigMap)) + .onComplete(startPromise); + } + + public Future initTimeline(final Map timelineMap) { + final Map registeredNotificationsCache = new HashMap<>(); + final Map eventsI18n = new HashMap<>(); + updateRegisteredNotificationsCache(registeredNotificationsCache); + updateEventsI18nCache(eventsI18n); + vertx.setPeriodic(DELAY_REFRESH_NOTIFICATION_CACHE, h -> { + updateRegisteredNotificationsCache(registeredNotificationsCache); + updateEventsI18nCache(eventsI18n); + }); - final Map registeredNotifications = MapFactory.getSyncClusterMap("notificationsMap", vertx); - final LocalMap eventsI18n = vertx.sharedData().getLocalMap("timelineEventsI18n"); final HashMap lazyEventsI18n = new HashMap<>(); final DefaultTimelineConfigService configService = new DefaultTimelineConfigService("timeline.config"); - configService.setRegisteredNotifications(registeredNotifications); + configService.setRegisteredNotifications(registeredNotificationsCache); final DefaultTimelineMailerService mailerService = new DefaultTimelineMailerService(vertx, config); mailerService.setConfigService(configService); - mailerService.setRegisteredNotifications(registeredNotifications); + mailerService.setRegisteredNotifications(registeredNotificationsCache); mailerService.setEventsI18n(eventsI18n); mailerService.setLazyEventsI18n(lazyEventsI18n); final NotificationHelper notificationHelper = new NotificationHelper(vertx, configService); notificationHelper.setMailerService(mailerService); - final TimelineController timelineController = new TimelineController(); + final TimelineController timelineController = new TimelineController(timelineMap); timelineController.setConfigService(configService); timelineController.setMailerService(mailerService); - timelineController.setRegisteredNotifications(registeredNotifications); + timelineController.setRegisteredNotifications(registeredNotificationsCache); timelineController.setEventsI18n(eventsI18n); timelineController.setLazyEventsI18n(lazyEventsI18n); - final List pushNotifServices = startPushNotifServices( eventsI18n,configService, config.getBoolean("log-push-notifs", false), @@ -90,7 +106,8 @@ public void start(final Promise startPromise) throws Exception { timelineController.setNotificationHelper(notificationHelper); final FlashMsgService flashMsgService = new FlashMsgServiceSqlImpl("flashmsg", "messages"); - final FlashMsgController flashMsgController = new FlashMsgController(); + final FlashMsgController flashMsgController = new FlashMsgController( + (JsonObject) timelineMap.get("skins"), (JsonObject) timelineMap.get("skin-levels")); flashMsgController.setFlashMessagesService(flashMsgService); addController(flashMsgController); @@ -127,6 +144,21 @@ public void start(final Promise startPromise) throws Exception { log.error("Invalid cron expression.", e); } } + return Future.succeededFuture(); + } + + private void updateRegisteredNotificationsCache(final Map registeredNotificationsCache) { + SharedDataHelper.getInstance().getAsyncMap("notificationsMap") + .compose(AsyncMap::entries) + .onSuccess(registeredNotificationsCache::putAll) + .onFailure(ex -> log.error("Error when update registeredNotifications", ex)); + } + + private void updateEventsI18nCache(final Map eventsI18n) { + SharedDataHelper.getInstance().getAsyncMap("timelineEventsI18n") + .compose(AsyncMap::entries) + .onSuccess(eventsI18n::putAll) + .onFailure(ex -> log.error("Error when update eventsI18n", ex)); } /** @@ -135,7 +167,7 @@ public void start(final Promise startPromise) throws Exception { * @see pushNotifServiceFactory() below */ protected List startPushNotifServices( - final LocalMap eventsI18n, + final Map eventsI18n, final TimelineConfigService configService, final boolean logPushNotifs, final boolean removeTokenIf404 @@ -194,7 +226,7 @@ protected List startPushNotifServices( */ protected TimelinePushNotifService pushNotifServiceFactory( final JsonObject pushNotif, - final LocalMap eventsI18n, + final Map eventsI18n, final TimelineConfigService configService, final boolean logPushNotifs, final boolean removeTokenIf404 diff --git a/timeline/src/main/java/org/entcore/timeline/controllers/FlashMsgController.java b/timeline/src/main/java/org/entcore/timeline/controllers/FlashMsgController.java index 18d2282863..0278bd91b8 100644 --- a/timeline/src/main/java/org/entcore/timeline/controllers/FlashMsgController.java +++ b/timeline/src/main/java/org/entcore/timeline/controllers/FlashMsgController.java @@ -62,17 +62,20 @@ public class FlashMsgController extends BaseController { private FlashMsgService service; private TimelineHelper notification; private final EventHelper eventHelper; + private final JsonObject skins; - public FlashMsgController(){ + public FlashMsgController(JsonObject skins, JsonObject skinLevels) { final EventStore eventStore = EventStoreFactory.getFactory().getEventStore(Timeline.class.getSimpleName()); this.eventHelper = new EventHelper(eventStore); + this.skins = skins; + this.skinLevels = skinLevels; } // TEMPORARY to handle both timeline and timeline2 view private String defaultSkin; private Map hostSkin; private JsonObject skinLevels; - + public void init(Vertx vertx, JsonObject config, RouteMatcher rm, Map securedActions) { super.init(vertx, config, rm, securedActions); @@ -81,11 +84,9 @@ public void init(Vertx vertx, JsonObject config, RouteMatcher rm, // TEMPORARY to handle both timeline and timeline2 view this.defaultSkin = config.getString("skin", "raw"); this.hostSkin = new HashMap<>(); - JsonObject skins = new JsonObject(vertx.sharedData().getLocalMap("skins")); for (final String domain: skins.fieldNames()) { this.hostSkin.put(domain, skins.getString(domain)); } - this.skinLevels = new JsonObject(vertx.sharedData().getLocalMap("skin-levels")); } /* User part */ diff --git a/timeline/src/main/java/org/entcore/timeline/controllers/TimelineController.java b/timeline/src/main/java/org/entcore/timeline/controllers/TimelineController.java index 9d16e95daa..493896d9cf 100644 --- a/timeline/src/main/java/org/entcore/timeline/controllers/TimelineController.java +++ b/timeline/src/main/java/org/entcore/timeline/controllers/TimelineController.java @@ -36,7 +36,6 @@ import fr.wseduc.webutils.collections.TTLSet; import fr.wseduc.webutils.http.BaseController; import fr.wseduc.webutils.request.RequestUtils; -import io.vertx.core.shareddata.LocalMap; import org.entcore.common.cache.CacheService; import org.entcore.common.events.EventHelper; import org.entcore.common.events.EventStore; @@ -91,7 +90,7 @@ public class TimelineController extends BaseController { private TimelineConfigService configService; private TimelineMailerService mailerService; private Map registeredNotifications; - private LocalMap eventsI18n; + private Map eventsI18n; private HashMap lazyEventsI18n; private Set antiFlood; @@ -110,7 +109,13 @@ public class TimelineController extends BaseController { private EventHelper eventHelper; - public void init(Vertx vertx, JsonObject config, RouteMatcher rm, + private final Map serverMap; + + public TimelineController(Map serverMap) { + this.serverMap = serverMap; + } + + public Future initAsync(Vertx vertx, JsonObject config, RouteMatcher rm, Map securedActions) { super.init(vertx, config, rm, securedActions); store = new DefaultTimelineEventStore(); @@ -126,23 +131,28 @@ public void init(Vertx vertx, JsonObject config, RouteMatcher rm, antiFlood = new TTLSet<>(config.getLong("antiFloodDelay", 3000l), vertx, config.getLong("antiFloodClear", 3600 * 1000l)); refreshTypesCache = config.getBoolean("refreshTypesCache", false); + Future future = Future.succeededFuture(); if(config.getBoolean("cache", false)){ - final CacheService cacheService = CacheService.create(vertx, config); - final Integer cacheLen = config.getInteger("cache-size", PAGELIMIT); - store = new CachedTimelineEventStore(store, cacheService, cacheLen, configService, registeredNotifications); + final Promise promise = Promise.promise(); + CacheService.create(vertx, config).onSuccess(cacheService -> { + final Integer cacheLen = config.getInteger("cache-size", PAGELIMIT); + store = new CachedTimelineEventStore(store, cacheService, cacheLen, configService, registeredNotifications); + }).onFailure(promise::fail); + future = promise.future(); } // TEMPORARY to handle both timeline and timeline2 view this.defaultSkin = config.getString("skin", "raw"); this.hostSkin = new HashMap<>(); - JsonObject skins = new JsonObject(vertx.sharedData().getLocalMap("skins")); + JsonObject skins = (JsonObject) serverMap.get("skins"); for (final String domain: skins.fieldNames()) { this.hostSkin.put(domain, skins.getString(domain)); } - this.skinLevels = new JsonObject(vertx.sharedData().getLocalMap("skin-levels")); + this.skinLevels = (JsonObject) serverMap.get("skin-levels"); final EventStore eventStore = EventStoreFactory.getFactory().getEventStore(Timeline.class.getSimpleName()); this.eventHelper = new EventHelper(eventStore); + return future; } /* Override i18n to use additional timeline translations and nested templates */ @@ -1001,11 +1011,56 @@ public void setNotificationHelper(NotificationHelper notificationHelper) { this.notificationHelper = notificationHelper; } - public void setEventsI18n(LocalMap eventsI18n) { + public void setEventsI18n(Map eventsI18n) { this.eventsI18n = eventsI18n; } public void setLazyEventsI18n(HashMap lazyEventsI18n) { this.lazyEventsI18n = lazyEventsI18n; } + + + @Post("/send/notification") + @SecuredAction(value= "", type = ActionType.RESOURCE) + @ResourceFilter(AdminFilter.class) + public void externalNotifications(final HttpServerRequest request) { + RequestUtils.bodyToJson(request, + new Handler() { + @Override + public void handle(JsonObject json) { + final JsonArray recipientsId = json.getJsonArray("recipients", new JsonArray()); + + if (recipientsId == null || recipientsId.isEmpty()) { + badRequest(request, "Invalid sender or recipients"); + return; + } + + final String subject = json.getString("subject", ""); + final String body = json.getString("body", ""); + + final boolean pushMobile = json.getBoolean("pushMobile", false); + + final String mobileTitle = json.getString("mobileTitle", ""); + final String mobileBody = json.getString("mobileBody", ""); + + + JsonObject params = new JsonObject() + .put("subject", subject) + .put("body", body); + + if(pushMobile && !mobileTitle.isEmpty()) { + JsonObject pushNotif = new JsonObject() + .put("title", mobileTitle) + .put("body", mobileBody); + + params.put("pushNotif", pushNotif); + } + + timelineHelper.notifyTimeline(request, "timeline.external_notification", null, recipientsId.getList(), + System.currentTimeMillis() + "external_notification", params); + + ok(request); + } + }); + } } diff --git a/timeline/src/main/java/org/entcore/timeline/controllers/TimelineLambda.java b/timeline/src/main/java/org/entcore/timeline/controllers/TimelineLambda.java index 598d1a3542..0419386e7a 100644 --- a/timeline/src/main/java/org/entcore/timeline/controllers/TimelineLambda.java +++ b/timeline/src/main/java/org/entcore/timeline/controllers/TimelineLambda.java @@ -32,14 +32,12 @@ import io.vertx.core.json.JsonObject; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; -import io.vertx.core.shareddata.LocalMap; import java.io.IOException; import java.io.Writer; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.ConcurrentMap; public final class TimelineLambda { @@ -49,7 +47,7 @@ public final class TimelineLambda { private TimelineLambda() {} public static void setLambdaTemplateRequest(final HttpServerRequest request, final TemplateProcessor processor, - final LocalMap eventsI18n, final Map lazyEventsI18n) { + final Map eventsI18n, final Map lazyEventsI18n) { processor.setLambda("i18n", new Mustache.Lambda() { diff --git a/timeline/src/main/java/org/entcore/timeline/services/impl/DefaultPushNotifService.java b/timeline/src/main/java/org/entcore/timeline/services/impl/DefaultPushNotifService.java index 9dbb0e669d..6ddbd3874c 100644 --- a/timeline/src/main/java/org/entcore/timeline/services/impl/DefaultPushNotifService.java +++ b/timeline/src/main/java/org/entcore/timeline/services/impl/DefaultPushNotifService.java @@ -22,7 +22,6 @@ import fr.wseduc.webutils.Server; import fr.wseduc.webutils.http.Renders; import io.vertx.core.logging.LoggerFactory; -import io.vertx.core.shareddata.LocalMap; import org.entcore.common.notification.NotificationUtils; import org.entcore.common.notification.TimelineNotificationsLoader; import org.entcore.timeline.services.TimelineConfigService; @@ -51,7 +50,7 @@ public class DefaultPushNotifService extends Renders implements TimelinePushNoti private TimelineConfigService configService; private final EventBus eb; private final OssFcm ossFcm; - private LocalMap eventsI18n; + private Map eventsI18n; private Map cacheI18N = new HashMap<>(); public DefaultPushNotifService(Vertx vertx, JsonObject config, OssFcm ossFcm) { @@ -246,7 +245,7 @@ public void setConfigService(TimelineConfigService configService) { this.configService = configService; } - public void setEventsI18n(LocalMap eventsI18n) { + public void setEventsI18n(Map eventsI18n) { this.eventsI18n = eventsI18n; } diff --git a/timeline/src/main/java/org/entcore/timeline/services/impl/DefaultTimelineMailerService.java b/timeline/src/main/java/org/entcore/timeline/services/impl/DefaultTimelineMailerService.java index f9b80496e8..e604ac5286 100644 --- a/timeline/src/main/java/org/entcore/timeline/services/impl/DefaultTimelineMailerService.java +++ b/timeline/src/main/java/org/entcore/timeline/services/impl/DefaultTimelineMailerService.java @@ -29,7 +29,6 @@ import fr.wseduc.webutils.http.Renders; import io.vertx.core.AsyncResult; import io.vertx.core.eventbus.DeliveryOptions; -import io.vertx.core.shareddata.LocalMap; import org.entcore.common.email.EmailFactory; import org.entcore.common.http.request.JsonHttpServerRequest; import org.entcore.common.neo4j.Neo4j; @@ -68,7 +67,7 @@ public class DefaultTimelineMailerService extends Renders implements TimelineMai private final EventBus eb; private Map registeredNotifications; private TimelineConfigService configService; - private LocalMap eventsI18n; + private Map eventsI18n; private HashMap lazyEventsI18n; private final EmailSender emailSender; private final int USERS_LIMIT; @@ -79,7 +78,7 @@ public class DefaultTimelineMailerService extends Renders implements TimelineMai public DefaultTimelineMailerService(Vertx vertx, JsonObject config) { super(vertx, config); eb = Server.getEventBus(vertx); - EmailFactory emailFactory = new EmailFactory(this.vertx, config); + EmailFactory emailFactory = EmailFactory.getInstance(); emailSender = emailFactory.getSenderWithPriority(EmailFactory.PRIORITY_VERY_LOW); USERS_LIMIT = config.getInteger("users-loop-limit", 25); QUERY_TIMEOUT = config.getLong("query-timeout", 300000L); @@ -916,7 +915,7 @@ public void setRegisteredNotifications(Map registeredNotificatio this.registeredNotifications = registeredNotifications; } - public void setEventsI18n(LocalMap eventsI18n) { + public void setEventsI18n(Map eventsI18n) { this.eventsI18n = eventsI18n; } diff --git a/timeline/src/main/java/org/entcore/timeline/services/impl/FlashMsgRepositoryEventsSql.java b/timeline/src/main/java/org/entcore/timeline/services/impl/FlashMsgRepositoryEventsSql.java index e1e37651b6..6200c9ee0e 100644 --- a/timeline/src/main/java/org/entcore/timeline/services/impl/FlashMsgRepositoryEventsSql.java +++ b/timeline/src/main/java/org/entcore/timeline/services/impl/FlashMsgRepositoryEventsSql.java @@ -28,6 +28,7 @@ import io.vertx.core.logging.LoggerFactory; import fr.wseduc.webutils.Either; +import org.entcore.common.user.ExportResourceResult; public class FlashMsgRepositoryEventsSql implements RepositoryEvents { @@ -37,7 +38,7 @@ public class FlashMsgRepositoryEventsSql implements RepositoryEvents { @Override public void exportResources(JsonArray resourcesIds, boolean exportDocuments, boolean exportSharedResources, String exportId, String userId, JsonArray groups, String exportPath, String locale, String host, - Handler handler) {} + Handler handler) {} @Override public void removeShareGroups(JsonArray oldGroups) { diff --git a/timeline/src/main/resources/view-src/notify/external_notification.html b/timeline/src/main/resources/view-src/notify/external_notification.html new file mode 100644 index 0000000000..3f1aa20eee --- /dev/null +++ b/timeline/src/main/resources/view-src/notify/external_notification.html @@ -0,0 +1,8 @@ + + {{#subjet}} +

{{subjet}}

+ {{/subjet}} + {{#body}} + {{body}} + {{/body}} +
\ No newline at end of file diff --git a/timeline/src/main/resources/view-src/notify/external_notification.json b/timeline/src/main/resources/view-src/notify/external_notification.json new file mode 100644 index 0000000000..8fd7aa8173 --- /dev/null +++ b/timeline/src/main/resources/view-src/notify/external_notification.json @@ -0,0 +1,4 @@ +{ + "default-frequency": "IMMEDIATE", + "push-notif" : true +} \ No newline at end of file diff --git a/workspace/pom.xml b/workspace/pom.xml index d0c54f3582..12c5746876 100644 --- a/workspace/pom.xml +++ b/workspace/pom.xml @@ -31,6 +31,13 @@ ${lamejbVersion} compile
+ + io.vertx + mod-mongo-persistor + 4.1-zookeeper-SNAPSHOT + runtime + fat + org.entcore test diff --git a/workspace/src/main/java/org/entcore/workspace/Workspace.java b/workspace/src/main/java/org/entcore/workspace/Workspace.java index b41de8b822..18839cc0b6 100644 --- a/workspace/src/main/java/org/entcore/workspace/Workspace.java +++ b/workspace/src/main/java/org/entcore/workspace/Workspace.java @@ -20,8 +20,11 @@ package org.entcore.workspace; import java.util.HashMap; +import java.util.Map; +import io.vertx.core.Future; import io.vertx.core.Promise; +import org.apache.commons.lang3.tuple.Pair; import org.entcore.broker.api.utils.AddressParameter; import org.entcore.broker.api.utils.BrokerProxyUtils; import org.entcore.common.folders.FolderManager; @@ -46,6 +49,7 @@ import io.vertx.core.logging.LoggerFactory; import fr.wseduc.mongodb.MongoDb; +import fr.wseduc.webutils.collections.SharedDataHelper; import io.vertx.core.DeploymentOptions; import io.vertx.core.http.HttpServerOptions; @@ -53,20 +57,31 @@ public class Workspace extends BaseServer { public static final String REVISIONS_COLLECTION = "documentsRevisions"; private static final Logger log = LoggerFactory.getLogger(Workspace.class); + @Override public void start(final Promise startPromise) throws Exception { + final Promise promise = Promise.promise(); + super.start(promise); + promise.future() + .compose(init -> SharedDataHelper.getInstance().getLocalMulti("server", "node")) + .compose(workspaceConfigMap -> + StorageFactory.build(vertx, config, new MongoDBApplicationStorage(DocumentDao.DOCUMENTS_COLLECTION, Workspace.class.getSimpleName())) + .map(storageFactory -> Pair.of(workspaceConfigMap, storageFactory))) + .compose(configPair -> initWorkspace(configPair.getLeft(), configPair.getRight())) + .onComplete(startPromise); + } + + public Future initWorkspace(final Map workspaceMap, StorageFactory storageFactory) { WorkspaceResourcesProvider resourceProvider = new WorkspaceResourcesProvider(); - setResourceProvider(resourceProvider); - super.start(startPromise); + setDefaultResourceFilter(resourceProvider); - Storage storage = new StorageFactory(vertx, config, - new MongoDBApplicationStorage(DocumentDao.DOCUMENTS_COLLECTION, Workspace.class.getSimpleName())).getStorage(); + Storage storage = storageFactory.getStorage(); final boolean neo4jPlugin = config.getBoolean("neo4jPlugin", false); final QuotaService quotaService = new DefaultQuotaService(neo4jPlugin, new TimelineHelper(vertx, vertx.eventBus(), config)); - String node = (String) vertx.sharedData().getLocalMap("server").get("node"); + String node = (String) workspaceMap.get("node"); if (node == null) { node = ""; } @@ -134,6 +149,7 @@ public void start(final Promise startPromise) throws Exception { // add broker listener for workspace resources BrokerProxyUtils.addBrokerProxy(new ResourceBrokerListenerImpl(), vertx, new AddressParameter("application", "workspace")); + return Future.succeededFuture(); } } diff --git a/workspace/src/main/java/org/entcore/workspace/controllers/WorkspaceController.java b/workspace/src/main/java/org/entcore/workspace/controllers/WorkspaceController.java index 0dfb07f729..2076d38871 100644 --- a/workspace/src/main/java/org/entcore/workspace/controllers/WorkspaceController.java +++ b/workspace/src/main/java/org/entcore/workspace/controllers/WorkspaceController.java @@ -1781,6 +1781,7 @@ public void view(final HttpServerRequest request) { context.put("enableScratch", config.getBoolean("enable-scratch", false)); context.put("enableGeogebra", config.getBoolean("enable-geogebra", false)); context.put("enableNextcloud", config.getBoolean("enable-nextcloud", false)); + context.put("useNextcloudSniplet", config.getBoolean("use-nextcloud-sniplet", true)); context.put("lazyMode", config.getJsonObject("publicConf", new JsonObject()).getBoolean("lazy-mode", false)); context.put("cacheDocTTl", config.getJsonObject("publicConf", new JsonObject()).getInteger("ttl-documents", -1)); context.put("cacheFolderTtl", config.getJsonObject("publicConf", new JsonObject()).getInteger("ttl-folders", -1)); diff --git a/workspace/src/main/java/org/entcore/workspace/service/impl/AudioRecorderWorker.java b/workspace/src/main/java/org/entcore/workspace/service/impl/AudioRecorderWorker.java index 6b4cc45f44..2d0f3caefc 100644 --- a/workspace/src/main/java/org/entcore/workspace/service/impl/AudioRecorderWorker.java +++ b/workspace/src/main/java/org/entcore/workspace/service/impl/AudioRecorderWorker.java @@ -70,9 +70,11 @@ public class AudioRecorderWorker extends BusModBase implements Handler this.storage = storageFactory.getStorage()) + .onFailure(ex -> logger.error("Error building storage factory", ex)); workspaceHelper = new WorkspaceHelper(vertx.eventBus(), storage); - vertx.eventBus().localConsumer(AudioRecorderWorker.class.getSimpleName(), this); + vertx.eventBus().consumer(AudioRecorderWorker.class.getSimpleName(), this); } @Override @@ -194,7 +196,7 @@ public void handle(Throwable event) { } } }; - MessageConsumer consumer = vertx.eventBus().localConsumer(AudioRecorderWorker.class.getSimpleName() + id, handler); + MessageConsumer consumer = vertx.eventBus().consumer(AudioRecorderWorker.class.getSimpleName() + id, handler); consumers.put(id, consumer); sendOK(message); } diff --git a/workspace/src/main/java/org/entcore/workspace/service/impl/WorkspaceRepositoryEvents.java b/workspace/src/main/java/org/entcore/workspace/service/impl/WorkspaceRepositoryEvents.java index 28398aed87..0bae4069cb 100644 --- a/workspace/src/main/java/org/entcore/workspace/service/impl/WorkspaceRepositoryEvents.java +++ b/workspace/src/main/java/org/entcore/workspace/service/impl/WorkspaceRepositoryEvents.java @@ -42,6 +42,7 @@ import org.entcore.common.folders.FolderManager; import org.entcore.common.storage.Storage; import org.entcore.common.user.RepositoryEvents; +import org.entcore.common.user.ExportResourceResult; import org.entcore.common.utils.StringUtils; import org.entcore.workspace.controllers.WorkspaceController; import org.entcore.workspace.dao.DocumentDao; @@ -90,7 +91,7 @@ public WorkspaceRepositoryEvents(Vertx vertx, Storage storage, boolean shareOldG @SuppressWarnings("unchecked") @Override public void exportResources(final JsonArray resourcesIds, boolean exportDocuments, boolean exportSharedResources, final String exportId, final String userId, - JsonArray groupIds, final String exportPathOrig, final String locale, String host, final Handler handler) { + JsonArray groupIds, final String exportPathOrig, final String locale, String host, final Handler handler) { Bson findByOwner = Filters.eq("owner", userId); Bson findByShared = Filters.or(Filters.eq("inheritedShares.userId", userId), @@ -155,16 +156,16 @@ public void handle(Boolean bool) { public void handle(AsyncResult event) { if (event.succeeded()) { log.info("Documents exported successfully to : " + finalExportPath); - handler.handle(true); + handler.handle(new ExportResourceResult(true, finalExportPath)); } else { log.error("Documents : Failed to export documents to " + finalExportPath + " - " + event.cause()); - handler.handle(false); + handler.handle(new ExportResourceResult(false, finalExportPath)); } } }); } else { log.error("Documents : Failed to export documents to " + finalExportPath); - handler.handle(false); + handler.handle(new ExportResourceResult(false, finalExportPath)); } } }); diff --git a/workspace/src/main/resources/public/template/copy/index.html b/workspace/src/main/resources/public/template/copy/index.html index af611e8f00..e9481ee191 100644 --- a/workspace/src/main/resources/public/template/copy/index.html +++ b/workspace/src/main/resources/public/template/copy/index.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/workspace/src/main/resources/public/template/directives/sync-document-viewer.html b/workspace/src/main/resources/public/template/directives/sync-document-viewer.html new file mode 100644 index 0000000000..e8a9e7f290 --- /dev/null +++ b/workspace/src/main/resources/public/template/directives/sync-document-viewer.html @@ -0,0 +1,43 @@ +
+
+
+

+
+

[[ngModel.name]]

+ owner[[ngModel.ownerDisplayName]] +
+
+
+ + +
+
+
+ + + + +
+
+
+
+
+ + + + + + + + +
+ +
+
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/content/views/icons.html b/workspace/src/main/resources/public/template/nextcloud/content/views/icons.html new file mode 100644 index 0000000000..4f1daf488f --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/content/views/icons.html @@ -0,0 +1,39 @@ + + \ No newline at end of file diff --git a/workspace/src/main/resources/public/template/nextcloud/content/views/list.html b/workspace/src/main/resources/public/template/nextcloud/content/views/list.html new file mode 100644 index 0000000000..14027902e1 --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/content/views/list.html @@ -0,0 +1,84 @@ + +
+

+ workspace.loading  +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/workspace/src/main/resources/public/template/nextcloud/content/views/viewer.html b/workspace/src/main/resources/public/template/nextcloud/content/views/viewer.html new file mode 100644 index 0000000000..7a7e23d6e0 --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/content/views/viewer.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/workspace/src/main/resources/public/template/nextcloud/content/workspace-nextcloud-content.html b/workspace/src/main/resources/public/template/nextcloud/content/workspace-nextcloud-content.html new file mode 100644 index 0000000000..e4c8fad2bd --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/content/workspace-nextcloud-content.html @@ -0,0 +1,91 @@ +
+
+
+ nextcloud.url.connect : + [[nextcloudUrl]] +
+ +
+ + +
+ + +
+
+
+ + + + + + + + + +
+

+ empty.workspace.subfolder.title +

+ + + + +
+ + +
+ + +
+ +
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/content/workspace-nextcloud-upload-file.html b/workspace/src/main/resources/public/template/nextcloud/content/workspace-nextcloud-upload-file.html new file mode 100644 index 0000000000..9a0962c94f --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/content/workspace-nextcloud-upload-file.html @@ -0,0 +1,55 @@ + +
+

+ medialibrary.title +

+ +
+ + +
+
    +
  • + +
    + +
    + + + +
    [[doc.name || doc.title]]
    + + +
    + + [[upload.getSize(doc.size)]] + + +
    + + +
  • +
+
+
+ + +
+ + + + + +
+
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/folder/empty-trash.html b/workspace/src/main/resources/public/template/nextcloud/folder/empty-trash.html new file mode 100644 index 0000000000..c123e3ffc3 --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/folder/empty-trash.html @@ -0,0 +1,14 @@ + +

+
+ +
+
+ +
diff --git a/workspace/src/main/resources/public/template/nextcloud/folder/folder-creation.html b/workspace/src/main/resources/public/template/nextcloud/folder/folder-creation.html new file mode 100644 index 0000000000..a3ff5c284e --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/folder/folder-creation.html @@ -0,0 +1,15 @@ + +
+

+ folder.new.title +

+
+ + +
+ +
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/folder/workspace-nextcloud-folder.html b/workspace/src/main/resources/public/template/nextcloud/folder/workspace-nextcloud-folder.html new file mode 100644 index 0000000000..4a8a8c1c4f --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/folder/workspace-nextcloud-folder.html @@ -0,0 +1,59 @@ +
+ + + + + +
+ + + +
+

+ nextcloud.quota +

+ + nextcloud.unlimited.space +
+
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/import/nextcloud-import.html b/workspace/src/main/resources/public/template/nextcloud/import/nextcloud-import.html new file mode 100644 index 0000000000..65e55aec0e --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/import/nextcloud-import.html @@ -0,0 +1,64 @@ + +

+ medialibrary.title +

+
+
+
+ +
+ medialibrary.drop.help2 +
+
+
+
+
+ +
+ +
+ +
+ medialibrary.drop.help +
+
+
+ +
+ workspace.import.warning
workspace.import.solution
+
+
+ +
+
+
+
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share-documents-options.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share-documents-options.html new file mode 100644 index 0000000000..653f6b392a --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share-documents-options.html @@ -0,0 +1,45 @@ +
+
+ +

+ + +
+ +
+
+ + +
+ + +
+
+
+
+

+ +
+
+
+
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share.html new file mode 100644 index 0000000000..2696c1bc1a --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/share.html @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/share/workspace-nextcloud-toolbar-share.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/workspace-nextcloud-toolbar-share.html new file mode 100644 index 0000000000..ac1dc389be --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/share/workspace-nextcloud-toolbar-share.html @@ -0,0 +1,6 @@ + + + diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-copy.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-copy.html new file mode 100644 index 0000000000..abfe36eb42 --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-copy.html @@ -0,0 +1,3 @@ + + + diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-delete.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-delete.html new file mode 100644 index 0000000000..a11f8b3a0f --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-delete.html @@ -0,0 +1,37 @@ + +
+ +

+ workspace.delete +

+ + +
+ nextcloud.documents.confirm.deletion +
+
+ + + +
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-properties.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-properties.html new file mode 100644 index 0000000000..64c82684b8 --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-properties.html @@ -0,0 +1,35 @@ + + +

+ workspace.properties +

+ + +
+ + +
+ + + +
diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-trash.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-trash.html new file mode 100644 index 0000000000..c6ab00028a --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar-trash.html @@ -0,0 +1,37 @@ + +
+ +

+ nextcloud.documents.trash +

+ + +
+ nextcloud.documents.confirm.trash +
+
+ + + +
+
diff --git a/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar.html b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar.html new file mode 100644 index 0000000000..655d45145d --- /dev/null +++ b/workspace/src/main/resources/public/template/nextcloud/toolbar/workspace-nextcloud-toolbar.html @@ -0,0 +1,131 @@ +
+
+ +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+
+ + +
+ +
+ +
+ + +
+ + + +
+
+
+
+
diff --git a/workspace/src/main/resources/public/ts/app.ts b/workspace/src/main/resources/public/ts/app.ts index e2c3aaa28e..82c0c56a21 100644 --- a/workspace/src/main/resources/public/ts/app.ts +++ b/workspace/src/main/resources/public/ts/app.ts @@ -1,48 +1,82 @@ -import { ng, routes } from 'entcore'; -import { workspaceController } from './controller'; -import { importFiles } from './directives/import'; -import { fileViewer } from './directives/fileViewer'; -import { pdfViewer } from './directives/pdfViewer'; -import { cssTransitionEnd } from './directives/cssTransitions'; -import { dropzoneOverlay } from './directives/dropzoneOverlay'; -import { lazyLoadImg } from './directives/lazyLoad'; -import { csvViewer } from './directives/csvViewer'; -import { txtViewer } from './directives/txtViewer'; +import { ng, routes } from "entcore"; +import { workspaceController } from "./controller"; +import { cssTransitionEnd } from "./directives/cssTransitions"; +import { csvViewer } from "./directives/csvViewer"; +import { dropzoneOverlay } from "./directives/dropzoneOverlay"; +import { fileViewer } from "./directives/fileViewer"; +import { folderPicker2 } from "./directives/folderPicker2"; +import { folderTree2, folderTreeInner2 } from "./directives/folderTree2"; +import { importFiles } from "./directives/import"; +import { lazyLoadImg } from "./directives/lazyLoad"; +import { + workspaceNextcloudContent, + workspaceNextcloudContentController, +} from "./directives/nextcloud/components/content/contentViewer.component"; +import { + workspaceNextcloudFolder, + workspaceNextcloudFolderController, +} from "./directives/nextcloud/nextcloudFolder.directive"; +import { NextcloudService } from "./directives/nextcloud/services/nextcloud.service"; +import { NextcloudEventService } from "./directives/nextcloud/services/nextcloudEvent.service"; +import { NextcloudUserService } from "./directives/nextcloud/services/nextcloudUser.service"; +import { pdfViewer } from "./directives/pdfViewer"; +import { syncDocumentViewer } from "./directives/syncDocumentViewer"; +import { txtViewer } from "./directives/txtViewer"; routes.define(function ($routeProvider) { - $routeProvider - .when('/', { - action: 'openOwn' - }) - .when('/folder/:folderId', { - action: 'viewFolder' - }) - .when('/shared/folder/:folderId', { - action: 'viewSharedFolder' - }) - .when('/shared', { - action: 'openShared' - }) - .when('/trash', { - action: 'openTrash' - }) - .when('/apps', { - action: 'openApps' - }) - .when('/external', { - action: 'openExternal' - }) - .otherwise({ - redirectTo: '/' - }) + $routeProvider + .when("/", { + action: "openOwn", + }) + .when("/folder/:folderId", { + action: "viewFolder", + }) + .when("/shared/folder/:folderId", { + action: "viewSharedFolder", + }) + .when("/shared", { + action: "openShared", + }) + .when("/trash", { + action: "openTrash", + }) + .when("/apps", { + action: "openApps", + }) + .when("/external", { + action: "openExternal", + }) + .otherwise({ + redirectTo: "/", + }); }); ng.controllers.push(workspaceController); ng.directives.push(importFiles); ng.directives.push(fileViewer); +ng.directives.push(syncDocumentViewer); ng.directives.push(pdfViewer); ng.directives.push(cssTransitionEnd); -ng.directives.push(dropzoneOverlay) -ng.directives.push(lazyLoadImg) -ng.directives.push(csvViewer) -ng.directives.push(txtViewer) +ng.directives.push(dropzoneOverlay); +ng.directives.push(lazyLoadImg); +ng.directives.push(csvViewer); +ng.directives.push(txtViewer); + +// Nextcloud +// Services +ng.services.push(NextcloudService); +ng.services.push(NextcloudUserService); +ng.services.push(NextcloudEventService); + +// Folder +ng.directives.push(workspaceNextcloudFolder); +ng.controllers.push(workspaceNextcloudFolderController); + +// Folder Picker +ng.directives.push(folderTree2); +ng.directives.push(folderTreeInner2); +ng.directives.push(folderPicker2); + +// Content +ng.directives.push(workspaceNextcloudContent); +ng.controllers.push(workspaceNextcloudContentController); diff --git a/workspace/src/main/resources/public/ts/controller.ts b/workspace/src/main/resources/public/ts/controller.ts index 5f8bdaa74f..e34b735d72 100644 --- a/workspace/src/main/resources/public/ts/controller.ts +++ b/workspace/src/main/resources/public/ts/controller.ts @@ -15,18 +15,27 @@ // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. -import { ng, template, idiom as lang, notify, idiom, moment, workspace } from 'entcore'; -import { NavigationDelegateScope, NavigationDelegate } from './delegates/navigation'; -import { ActionDelegate, ActionDelegateScope } from './delegates/actions'; -import { TreeDelegate, TreeDelegateScope } from './delegates/tree'; -import { CommentDelegate, CommentDelegateScope } from './delegates/comments'; -import { DragDelegate, DragDelegateScope } from './delegates/drag'; -import { SearchDelegate, SearchDelegateScope } from './delegates/search'; -import { RevisionDelegateScope, RevisionDelegate } from './delegates/revisions'; -import { KeyboardDelegate, KeyboardDelegateScope } from './delegates/keyboard'; -import { LoolDelegateScope, LoolDelegate } from './delegates/lool'; -import { models, workspaceService, DocumentCursor, Document, DocumentCursorParams, CursorUpdate } from "./services"; -import { DocumentActionType } from 'entcore/types/src/ts/workspace/services'; +import { idiom as lang, idiom, moment, ng, notify, template } from "entcore"; +import { + NavigationDelegate, + NavigationDelegateScope, +} from "./delegates/navigation"; +import { ActionDelegate, ActionDelegateScope } from "./delegates/actions"; +import { TreeDelegate, TreeDelegateScope } from "./delegates/tree"; +import { CommentDelegate, CommentDelegateScope } from "./delegates/comments"; +import { DragDelegate, DragDelegateScope } from "./delegates/drag"; +import { SearchDelegate, SearchDelegateScope } from "./delegates/search"; +import { RevisionDelegate, RevisionDelegateScope } from "./delegates/revisions"; +import { KeyboardDelegate, KeyboardDelegateScope } from "./delegates/keyboard"; +import { LoolDelegate, LoolDelegateScope } from "./delegates/lool"; +import { + CursorUpdate, + DocumentCursor, + DocumentCursorParams, + models, + workspaceService, +} from "./services"; +import { DocumentActionType } from "entcore/types/src/ts/workspace/services"; import {ScratchDelegate, ScratchDelegateScope} from "./delegates/scratch"; import {GeogebraDelegate, GeogebraDelegateScope} from "./delegates/geogebra"; @@ -34,6 +43,7 @@ import {GeogebraDelegate, GeogebraDelegateScope} from "./delegates/geogebra"; declare var ENABLE_LOOL: boolean; declare var ENABLE_SCRATCH: boolean; declare var ENABLE_NEXTCLOUD: boolean; +declare var USE_NEXTCLOUD_SNIPLET: boolean; declare var ENABLE_GGB: boolean; declare var DISABLE_FULL_TEXT_SEARCH: boolean; export interface WorkspaceScope extends RevisionDelegateScope, NavigationDelegateScope, TreeDelegateScope, ActionDelegateScope, CommentDelegateScope, DragDelegateScope, SearchDelegateScope, KeyboardDelegateScope, LoolDelegateScope, ScratchDelegateScope, GeogebraDelegateScope { @@ -41,6 +51,7 @@ export interface WorkspaceScope extends RevisionDelegateScope, NavigationDelegat ENABLE_SCRATCH: boolean; ENABLE_GGB: boolean; ENABLE_NEXTCLOUD: boolean; + USE_NEXTCLOUD_SNIPLET: boolean; DISABLE_FULL_TEXT_SEARCH: boolean; documentList:models.DocumentsListModel; documentListSorted:models.DocumentsListModel; @@ -169,6 +180,7 @@ export let workspaceController = ng.controller('Workspace', ['$scope', '$rootSco $scope.ENABLE_SCRATCH = ENABLE_SCRATCH; $scope.ENABLE_GGB = ENABLE_GGB; $scope.ENABLE_NEXTCLOUD = ENABLE_NEXTCLOUD; + $scope.USE_NEXTCLOUD_SNIPLET = USE_NEXTCLOUD_SNIPLET; $scope.DISABLE_FULL_TEXT_SEARCH = DISABLE_FULL_TEXT_SEARCH; /** @@ -184,87 +196,171 @@ export let workspaceController = ng.controller('Workspace', ['$scope', '$rootSco const shouldCache = workspaceService.isLazyMode(); $scope.documentList = new models.DocumentsListModel($filter).watch($scope,{documents:'openedFolder.documents'}); $scope.documentListSorted = new models.DocumentsListModel($filter).watch($scope,{documents:'openedFolder.sortedDocuments'}); - $scope.trees = [new models.ElementTree(shouldCache,{ - name: lang.translate('documents'), - filter: 'owner', - hierarchical: true, - hidden: false, - children: [], - buttons: [ - { text: lang.translate('workspace.add.document'), action: () => $scope.display.importFiles = true, icon: true, workflow: 'workspace.create', disabled() { return false } } - ], - contextualButtons: [ - { text: lang.translate('workspace.move'), action: $scope.openMoveView, right: "manager", allow: allowAction("move") }, - { text: lang.translate('workspace.copy'), action: $scope.openCopyView, right: "read", allow: allowAction("copy") }, - { text: lang.translate('workspace.move.trash'), action: $scope.toTrashConfirm, right: "manager" } - ] - }), new models.ElementTree(shouldCache,{ - name: lang.translate('shared_tree'), - filter: 'shared', - hierarchical: true, - hidden: false, - buttons: [ - { - text: lang.translate('workspace.add.document'), action: () => $scope.display.importFiles = true, icon: true, workflow: 'workspace.create', disabled() { - if($scope.currentTree.filter == "shared" && $scope.currentTree===$scope.openedFolder.folder){ - return false; - } - let isFolder = ($scope.openedFolder.folder instanceof models.Element); - return isFolder && !$scope.openedFolder.folder.canWriteOnFolder - } - } - ], - children: [], - contextualButtons: [ - { text: lang.translate('workspace.move'), action: $scope.openMoveView, right: "manager", allow: allowAction("move") }, - { text: lang.translate('workspace.copy'), action: $scope.openCopyView, right: "read", allow: allowAction("copy") }, - { text: lang.translate('workspace.move.trash'), action: $scope.toTrashConfirm, right: "manager" } - ] - }),new models.ElementTree(shouldCache,{ - name: lang.translate('externalDocs'), - filter: 'external', - get hidden() { - const tree = $scope.trees.find(e => e.filter == "external"); - return !tree || tree.children.length == 0; - }, - buttons: [], - hierarchical: true, - children: [], - contextualButtons: [ - { - text: lang.translate('workspace.move.trash'), action: $scope.toTrashConfirm, allow() { - //trash only files - return $scope.selectedFolders().length == 0; - } - } - ] - }), new models.ElementTree(shouldCache,{ - name: lang.translate('appDocuments'), - filter: 'protected', - hidden: false, - buttons: [ - { text: lang.translate('workspace.add.document'), action: () => { }, icon: true, workflow: 'workspace.create', disabled() { return true } } - ], - hierarchical: true, - children: [], - contextualButtons: [ - { text: lang.translate('workspace.copy'), action: $scope.openCopyView, right: "read", allow: allowAction("copy") }, - { text: lang.translate('workspace.move.trash'), action: $scope.toTrashConfirm, right: "manager" } - ] - }), new models.ElementTree(shouldCache,{ - name: lang.translate('trash'), - hidden: false, - buttons: [ - { text: lang.translate('workspace.add.document'), action: () => { }, icon: true, workflow: 'workspace.create', disabled() { return true } } - ], - filter: 'trash', - hierarchical: true, - children: [], - contextualButtons: [ - { text: lang.translate('workspace.trash.restore'), action: $scope.restore, right: "manager" }, - { text: lang.translate('workspace.move.trash'), action: $scope.deleteConfirm, right: "manager" } - ] - })]; + $scope.trees = [ + new models.ElementTree(shouldCache, { + name: lang.translate("documents"), + filter: "owner", + hierarchical: true, + hidden: false, + children: [], + buttons: [ + { + text: lang.translate("workspace.add.document"), + action: () => ($scope.display.importFiles = true), + icon: true, + workflow: "workspace.create", + disabled() { + return false; + }, + }, + ], + contextualButtons: [ + { + text: lang.translate("workspace.move"), + action: $scope.openMoveView, + right: "manager", + allow: allowAction("move"), + }, + { + text: lang.translate("workspace.copy"), + action: $scope.openCopyView, + right: "read", + allow: allowAction("copy"), + }, + { + text: lang.translate("workspace.move.trash"), + action: $scope.toTrashConfirm, + right: "manager", + }, + ], + }), + new models.ElementTree(shouldCache, { + name: lang.translate("shared_tree"), + filter: "shared", + hierarchical: true, + hidden: false, + buttons: [ + { + text: lang.translate("workspace.add.document"), + action: () => ($scope.display.importFiles = true), + icon: true, + workflow: "workspace.create", + disabled() { + if ( + $scope.currentTree.filter == "shared" && + $scope.currentTree === $scope.openedFolder.folder + ) { + return false; + } + let isFolder = $scope.openedFolder.folder instanceof models.Element; + return isFolder && !$scope.openedFolder.folder.canWriteOnFolder; + }, + }, + ], + children: [], + contextualButtons: [ + { + text: lang.translate("workspace.move"), + action: $scope.openMoveView, + right: "manager", + allow: allowAction("move"), + }, + { + text: lang.translate("workspace.copy"), + action: $scope.openCopyView, + right: "read", + allow: allowAction("copy"), + }, + { + text: lang.translate("workspace.move.trash"), + action: $scope.toTrashConfirm, + right: "manager", + }, + ], + }), + new models.ElementTree(shouldCache, { + name: lang.translate("externalDocs"), + filter: "external", + get hidden() { + const tree = $scope.trees.find((e) => e.filter == "external"); + return !tree || tree.children.length == 0; + }, + buttons: [], + hierarchical: true, + children: [], + contextualButtons: [ + { + text: lang.translate("workspace.move.trash"), + action: $scope.toTrashConfirm, + allow() { + //trash only files + return $scope.selectedFolders().length == 0; + }, + }, + ], + }), + new models.ElementTree(shouldCache, { + name: lang.translate("appDocuments"), + filter: "protected", + hidden: false, + buttons: [ + { + text: lang.translate("workspace.add.document"), + action: () => {}, + icon: true, + workflow: "workspace.create", + disabled() { + return true; + }, + }, + ], + hierarchical: true, + children: [], + contextualButtons: [ + { + text: lang.translate("workspace.copy"), + action: $scope.openCopyView, + right: "read", + allow: allowAction("copy"), + }, + { + text: lang.translate("workspace.move.trash"), + action: $scope.toTrashConfirm, + right: "manager", + }, + ], + }), + new models.ElementTree(shouldCache, { + name: lang.translate("trash"), + hidden: false, + buttons: [ + { + text: lang.translate("workspace.add.document"), + action: () => {}, + icon: true, + workflow: "workspace.create", + disabled() { + return true; + }, + }, + ], + filter: "trash", + hierarchical: true, + children: [], + contextualButtons: [ + { + text: lang.translate("workspace.trash.restore"), + action: $scope.restore, + right: "manager", + }, + { + text: lang.translate("workspace.move.trash"), + action: $scope.deleteConfirm, + right: "manager", + }, + ], + }), + ]; $scope.display = { nbFiles: 50 }; @@ -335,4 +431,4 @@ export let workspaceController = ng.controller('Workspace', ['$scope', '$rootSco return moment().format('L'); } -}]); \ No newline at end of file +}]); diff --git a/workspace/src/main/resources/public/ts/delegates/actions/copy.ts b/workspace/src/main/resources/public/ts/delegates/actions/copy.ts index 428c9328fa..439efb8326 100644 --- a/workspace/src/main/resources/public/ts/delegates/actions/copy.ts +++ b/workspace/src/main/resources/public/ts/delegates/actions/copy.ts @@ -1,7 +1,6 @@ import { model, template, FolderPickerProps, FolderPickerSourceFile, notify } from "entcore"; import { models, workspaceService } from "../../services"; - export interface ActionCopyDelegateScope { copyProps: FolderPickerProps isMovingElementsMine(): boolean @@ -24,7 +23,6 @@ export interface ActionCopyDelegateScope { selectedItems(): models.Element[] safeApply(a?) setHighlightTree(els: { folder: models.Node, count: number }[]); - } export function ActionCopyDelegate($scope: ActionCopyDelegateScope) { diff --git a/workspace/src/main/resources/public/ts/delegates/actions/trash.ts b/workspace/src/main/resources/public/ts/delegates/actions/trash.ts index 2981bba2fc..fe0540ea00 100644 --- a/workspace/src/main/resources/public/ts/delegates/actions/trash.ts +++ b/workspace/src/main/resources/public/ts/delegates/actions/trash.ts @@ -109,4 +109,4 @@ export function ActionTrashDelegate($scope: ActionTrashScope) { }, 300) }; }; -} \ No newline at end of file +} diff --git a/workspace/src/main/resources/public/ts/directives/folderPicker2.ts b/workspace/src/main/resources/public/ts/directives/folderPicker2.ts new file mode 100644 index 0000000000..9906586a49 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/folderPicker2.ts @@ -0,0 +1,298 @@ +/* + * ⚠️ WARNING ⚠️ + * This component is almost an exact copy of the folderPicker component + * with a few adaptations to make it work with nextcloud. + */ + +import { ng } from "entcore"; +import { models, workspaceService } from "../services"; +import { FolderTreeProps } from "./folderTree2"; +import { SyncDocument } from "./nextcloud/models/nextcloudFolder.model"; + +export interface FolderPickerScope { + treeProps: FolderTreeProps; + nextcloudTreeProps: FolderTreeProps; + folderProps: FolderPickerProps; + + // Tree data + trees: models.Tree[]; + nextcloudTrees: SyncDocument[]; + + // UI state + selectedFolder: models.Element | SyncDocument; + newFolder: models.Element; + search: { + value: string; + }; + + // Utility methods + safeApply(fn?: () => void): void; + + // State check methods + isStateNormal(): boolean; + isStateLoading(): boolean; + isStateLoaded(): boolean; + isStateEmpty(): boolean; + + // New folder operations + openEditView(): void; + canOpenEditView(): boolean; + isEditVew(): boolean; + submitNewFolder(): void; + + // Search operations + canResetSearch(): boolean; + resetSearch(): void; + searchKeyUp(event: KeyboardEvent): void; + + // Action methods + onError(error?: any): void; + onCancel(): void; + onSubmit(): void; + cannotSubmit(): boolean; +} + +export interface FolderPickerSource { + action: "create-from-blob" | "copy-from-file" | "move-from-file"; +} + +export interface FolderPickerSourceFile extends FolderPickerSource { + fileId: string; +} + +export interface FolderPickerSourceBlob extends FolderPickerSource { + title?: string; + content: Blob; +} + +export interface FolderPickerProps { + i18: { + title: string; + actionTitle: string; + actionProcessing: string; + actionFinished: string; + actionEmpty?: string; + info: string; + }; + sources: FolderPickerSource[]; + treeProvider?(): Promise; + nextcloudTreeProvider?(): Promise; + manageSubmit?(folder: models.Element | SyncDocument): boolean; + submit?(folder: models.Element | SyncDocument): Promise | void; + onCancel(): void; + onError?(error: any): void; + onSubmitSuccess?(dest: models.Element | SyncDocument, count: number): void; +} + +export const folderPicker2 = ng.directive("folderPicker2", [ + "$timeout", + () => { + return { + restrict: "E", + scope: { + folderProps: "=", + }, + template: ` +
+

+
+
+
+
+
+ +
+ +
+ +
+ +
+
+ +
+
+
+ `, + link: async (scope: FolderPickerScope) => { + scope.search = { value: "" }; + scope.newFolder = new models.Element(); + + scope.safeApply = function (fn) { + const phase = this.$root.$$phase; + if (phase == "$apply" || phase == "$digest") { + if (fn && typeof fn === "function") { + fn(); + } + } else { + this.$apply(fn); + } + }; + + scope.selectedFolder = null; + + scope.trees = []; + scope.nextcloudTrees = []; + + const canSelect = function (folder: models.Element | SyncDocument) { + if (folder instanceof models.Element) { + if ((folder as models.Tree).filter) { + return (folder as models.Tree).filter == "owner"; + } else { + return true; + } + } else if (folder instanceof SyncDocument) { + return folder.isFolder; + } + return false; + }; + + let selectedWorkspaceFolder: models.Element = null; + let openedWorkspaceFolder: models.Element = null; + + scope.treeProps = { + cssTree: "maxheight-half-vh", + get trees() { + return scope.trees; + }, + isDisabled(folder: models.Element) { + return !canSelect(folder); + }, + isOpenedFolder(folder: models.Element) { + if (openedWorkspaceFolder === folder) { + return true; + } else if ((folder as models.Tree).filter) { + if (!workspaceService.isLazyMode()) { + return true; + } + } + return ( + openedWorkspaceFolder && + workspaceService.findFolderInTreeByRefOrId( + folder, + openedWorkspaceFolder, + ) + ); + }, + isSelectedFolder(folder: models.Element) { + return selectedWorkspaceFolder === folder; + }, + openFolder(folder: models.Element) { + selectedNextcloudFolder = null; + openedNextcloudFolders = []; + + if (canSelect(folder)) { + openedWorkspaceFolder = selectedWorkspaceFolder = folder; + scope.selectedFolder = folder; + } else { + openedWorkspaceFolder = folder; + } + }, + }; + + // Nextcloud tree props (reuse workspaceNextcloudFolder logic) + let selectedNextcloudFolder: SyncDocument = null; + let openedNextcloudFolders: SyncDocument[] = []; + + scope.nextcloudTreeProps = { + cssTree: "maxheight-half-vh", + get trees() { + return scope.nextcloudTrees; + }, + isDisabled(folder: SyncDocument) { + return false; + }, + isOpenedFolder(folder: SyncDocument) { + return openedNextcloudFolders.some( + (openFolder) => openFolder === folder, + ); + }, + isSelectedFolder(folder: SyncDocument) { + return selectedNextcloudFolder === folder; + }, + openFolder(folder: SyncDocument) { + // Clear workspace selection + selectedWorkspaceFolder = null; + openedWorkspaceFolder = null; + + selectedNextcloudFolder = folder; + scope.selectedFolder = folder; + + // Add to opened folders if not already there + if (!openedNextcloudFolders.includes(folder)) { + openedNextcloudFolders.push(folder); + } + }, + }; + + // Load trees + const loadTrees = async () => { + try { + // Load workspace trees + if (scope.folderProps.treeProvider) { + const workspaceTrees = await scope.folderProps.treeProvider(); + if (workspaceTrees && workspaceTrees.length > 0) { + scope.trees.length = 0; + workspaceTrees.forEach((tree) => scope.trees.push(tree)); + } + } + + // Load Nextcloud trees + if (scope.folderProps.nextcloudTreeProvider) { + const nextcloudTrees = + await scope.folderProps.nextcloudTreeProvider(); + if (nextcloudTrees && nextcloudTrees.length > 0) { + scope.nextcloudTrees.length = 0; + nextcloudTrees.forEach((tree) => + scope.nextcloudTrees.push(tree), + ); + } + } + + scope.safeApply(); + } catch (e) { + console.error("Error loading trees:", e); + if (scope.folderProps.onError) { + scope.folderProps.onError(e); + } + } + }; + + // Load trees + await loadTrees(); + + // Submission handling + scope.cannotSubmit = () => !scope.selectedFolder; + + scope.onSubmit = async () => { + if (scope.cannotSubmit()) return; + + // If custom submit handling is provided, use that + if ( + scope.folderProps.manageSubmit && + scope.folderProps.manageSubmit(scope.selectedFolder) + ) { + return; + } + + try { + if (scope.folderProps.submit) { + await scope.folderProps.submit(scope.selectedFolder); + scope.safeApply(); + } + } catch (e) { + scope.folderProps.onError(e); + scope.safeApply(); + } + }; + + scope.onCancel = () => { + scope.folderProps.onCancel(); + }; + }, + }; + }, +]); diff --git a/workspace/src/main/resources/public/ts/directives/folderTree2.ts b/workspace/src/main/resources/public/ts/directives/folderTree2.ts new file mode 100644 index 0000000000..6f3282d1fa --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/folderTree2.ts @@ -0,0 +1,200 @@ +/* + * ⚠️ WARNING ⚠️ + * This component is almost an exact copy of the folderTree component + * with a few adaptations to make it work with nextcloud. + */ + +import { model, ng } from "entcore"; +import { models, workspaceService } from "../services"; +import angular = require("angular"); +import { SyncDocument } from "./nextcloud/models/nextcloudFolder.model"; +import { nextcloudService } from "./nextcloud/services/nextcloud.service"; + +//function to compile template recursively +function compileRecursive($compile, element, link) { + // Normalize the link parameter + if (angular.isFunction(link)) { + link = { post: link }; + } + // Break the recursion loop by removing the contents + const contents = element.contents().remove(); + let compiledContents; + return { + pre: link && link.pre ? link.pre : null, + /** + * Compiles and re-adds the contents + */ + post: function (scope, element) { + // Compile the contents + if (!compiledContents) { + compiledContents = $compile(contents); + } + // Re-add the compiled contents to the element + compiledContents(scope, function (clone) { + element.append(clone); + }); + + // Call the post-linking function, if any + if (link && link.post) { + link.post.apply(null, arguments); + } + }, + }; +} +export interface FolderTreeProps { + cssTree?: string; + trees: T[]; + isDisabled(folder: T): boolean; + isSelectedFolder(folder: T): boolean; + isOpenedFolder(folder: T): boolean; + openFolder(folder: T): void; +} + +export interface FolderTreeInnerScope { + folder: T; + treeProps: FolderTreeProps; + translate(); + canExpendTree(); + isSelectedFolder(): boolean; + isOpenedFolder(): boolean; + openFolder(); + safeApply(a?: any); + isDisabled(): boolean; +} + +export interface FolderTreeScope { + treeProps: FolderTreeProps; + trees(): T[]; +} + +export const folderTreeInner2 = ng.directive("folderTreeInner2", [ + "$compile", + ($compile) => { + return { + restrict: "E", + scope: { + treeProps: "=", + folder: "=", + }, + template: ` + + [[translate()]] + +
    +
  • + +
  • +
`, + compile: function (element) { + // Use the compile function from the RecursionHelper, + // And return the linking function(s) which it returns + return compileRecursive( + $compile, + element, + (scope: FolderTreeInnerScope) => { + scope.safeApply = function (fn) { + const phase = this.$root.$$phase; + if (phase == "$apply" || phase == "$digest") { + if (fn && typeof fn === "function") { + fn(); + } + } else { + this.$apply(fn); + } + }; + scope.canExpendTree = function () { + if (workspaceService.isLazyMode()) { + return ( + scope.folder.children.length > 0 || + (scope.folder as models.Element).cacheChildren.isEmpty + ); + } + return scope.folder.children.length > 0; + }; + scope.isSelectedFolder = function () { + return scope.treeProps.isSelectedFolder(scope.folder); + }; + scope.isOpenedFolder = function () { + return scope.treeProps.isOpenedFolder(scope.folder); + }; + scope.openFolder = async function () { + if (scope.folder instanceof SyncDocument) { + if ( + !scope.folder.children || + scope.folder.children.length === 0 + ) { + const children = await nextcloudService.listDocument( + model.me.userId, + scope.folder.path, + ); + + (scope.folder as SyncDocument).children = children.filter( + (child) => + child.isFolder && + child.path !== "/" && + child.path !== scope.folder.path, + ); + + scope.safeApply(); + } + } else if (workspaceService.isLazyMode()) { + if (scope.folder instanceof models.ElementTree) { + const temp = scope.folder as models.ElementTree; + await workspaceService.fetchChildrenForRoot( + temp, + { filter: temp.filter, hierarchical: false }, + null, + { onlyFolders: true }, + ); + } else { + await workspaceService.fetchChildren( + scope.folder as models.Element, + { filter: "all", hierarchical: false }, + null, + { onlyFolders: true }, + ); + } + } + const ret = scope.treeProps.openFolder(scope.folder); + scope.safeApply(); + return ret; + }; + scope.translate = function () { + return scope.folder.name; + }; + scope.isDisabled = function () { + return scope.treeProps.isDisabled(scope.folder); + }; + }, + ); + }, + }; + }, +]); + +export const folderTree2 = ng.directive("folderTree2", [ + "$templateCache", + ($templateCache) => { + return { + restrict: "E", + scope: { + treeProps: "=", + }, + template: ` + + `, + link: async (scope: FolderTreeScope) => { + scope.trees = function () { + return scope.treeProps ? scope.treeProps.trees : []; + }; + }, + }; + }, +]); diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/contentViewer.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/contentViewer.component.ts new file mode 100644 index 0000000000..841afa724e --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/contentViewer.component.ts @@ -0,0 +1,544 @@ +import { AxiosError, AxiosResponse } from "axios"; +import { angular, Me, model, ng, template, workspace } from "entcore"; +import { Subscription } from "rxjs"; +import { ViewMode } from "../../enums/viewMode.enum"; +import { Draggable } from "../../models/nextcloudDraggable.model"; +import { SyncDocument } from "../../models/nextcloudFolder.model"; +import { INextcloudEventService } from "../../services/nextcloudEvent.service"; +import { nextcloudUserService } from "../../services/nextcloudUser.service"; +import { + NextcloudPreference, + Preference, +} from "../../services/nextcloud.preferences"; +import { INextcloudService } from "../../services/nextcloud.service"; +import { safeApply } from "../../utils/safeApply.utils"; +import { UploadFileSnipletViewModel } from "./fileUpload.component"; +import { NextcloudViewIcons } from "./iconView.component"; +import { INextcloudViewList, NextcloudViewList } from "./listView.component"; +import { ToolbarSnipletViewModel } from "./toolbar.component"; +import models = workspace.v2.models; + +declare let window: any; + +const nextcloudTree: string = "nextcloud-folder-tree"; + +export interface IWorkspaceNextcloudContent { + safeApply(): void; + initDraggable(): void; + onSelectContent(document: SyncDocument): void; + onSelectAll(): void; + onOpenContent(document: SyncDocument): void; + getFile(document: SyncDocument): string; + nextcloudUrl: string; + isNextcloudUrlHidden: boolean; + draggable: Draggable; + lockDropzone: boolean; + parentDocument: SyncDocument; + documents: Array; + selectedDocuments: Array; + checkboxSelectAll: boolean; + // drag & drop action + moveDocument(element: any, document: SyncDocument): Promise; + // dropzone + isDropzoneEnabled(): boolean; + canDropOnFolder(): boolean; + onCannotDropFile(): void; + isViewMode(mode: ViewMode): boolean; + changeViewMode(mode: ViewMode): Promise; + + isLoaded: boolean; + viewIcons: NextcloudViewIcons; + viewList: INextcloudViewList; + toolbar: ToolbarSnipletViewModel; + upload: UploadFileSnipletViewModel; + updateTree(): void; + getNextcloudTreeController(): any; + + isTrashMode(): boolean; + + openDocument(document?: SyncDocument): any; + closeViewFile(): void; +} + +export const workspaceNextcloudContentController = ng.controller( + "NextcloudContentController", + [ + "$scope", + "NextcloudService", + "NextcloudEventService", + ( + $scope: IWorkspaceNextcloudContent, + nextcloudService: INextcloudService, + nextcloudEventService: INextcloudEventService, + ) => { + $scope.isLoaded = false; + $scope.documents = []; + $scope.parentDocument = null; + $scope.nextcloudUrl = null; + $scope.selectedDocuments = new Array(); + // fetch nextcloud url hidden state in order to hide or show the nextcloud url + $scope.isNextcloudUrlHidden = false; + + let nextcloudPreference = new Preference(); + let orderDesc: boolean = false; + let orderField: string = null; + let subscription = new Subscription(); + + $scope.getNextcloudTreeController = function () { + return angular.element(document.getElementById(nextcloudTree)).scope(); + }; + + $scope.isTrashMode = function (): boolean { + const treeController = $scope.getNextcloudTreeController(); + return treeController?.isTrashbinOpen; + }; + + nextcloudService + .getIsNextcloudUrlHidden() + .then((isHidden) => ($scope.isNextcloudUrlHidden = isHidden)) + .catch((err: AxiosError) => { + const message: string = + "Error while attempting to fetch nextcloud url hidden state"; + console.error(message + err.message); + $scope.isNextcloudUrlHidden = false; + }); + + // on init we first sync its main folder content + Promise.all([ + initDocumentsContent(nextcloudService, $scope), + nextcloudService.getNextcloudUrl(), + nextcloudPreference.init(), + ]) + .then(([_, url]) => { + $scope.changeViewMode(nextcloudPreference.viewMode); + $scope.nextcloudUrl = url; + $scope.viewList = new NextcloudViewList($scope); + $scope.viewIcons = new NextcloudViewIcons($scope); + $scope.toolbar = new ToolbarSnipletViewModel($scope); + $scope.upload = new UploadFileSnipletViewModel($scope); + $scope.isLoaded = true; + safeApply($scope); + }) + .catch((err: AxiosError) => { + const message: string = + "Error while attempting to init or fetch nextcloud url: "; + console.error(message + err.message); + $scope.isLoaded = true; + safeApply($scope); + }); + + // on receive documents from folder-tree sniplet + subscription.add( + nextcloudEventService + .getDocumentsState() + .subscribe( + (res: { + parentDocument: SyncDocument; + documents: Array; + }) => { + if (res.documents && res.documents.length > 0) { + $scope.parentDocument = res.parentDocument; + + // if we are in trash mode, we do not need to filter documents + if ($scope.isTrashMode()) { + $scope.documents = res.documents.sort(sortDocumentsByFolder); + } else { + $scope.documents = res.documents + .filter( + (syncDocument: SyncDocument) => + syncDocument.name != model.me.userId, + ) + .sort(sortDocumentsByFolder); + orderDesc = false; + orderField = ""; + } + } else { + $scope.parentDocument = res.parentDocument; + $scope.documents = []; + } + $scope.isLoaded = true; + safeApply($scope); + }, + ), + ); + + initDraggable(); + + async function initDocumentsContent( + nextcloudService: INextcloudService, + scope: IWorkspaceNextcloudContent, + ): Promise { + // if we are in trash mode, we do not need to fetch documents + if ($scope.isTrashMode()) { + return; + } + + let selectedFolderFromNextcloudTree: SyncDocument = + $scope.getNextcloudTreeController()["selectedFolder"]; + return nextcloudService + .listDocument(model.me.userId, selectedFolderFromNextcloudTree.path) + .then((documents: Array) => { + // will be called first time while constructor initializing + // since it will syncing at the same time observable will receive its events, we check its length at the end + if (!scope.documents.length) { + scope.documents = documents + .filter( + (syncDocument: SyncDocument) => + syncDocument.path != selectedFolderFromNextcloudTree.path, + ) + .filter( + (syncDocument: SyncDocument) => + syncDocument.name != model.me.userId, + ) + .sort(sortDocumentsByFolder); + scope.parentDocument = new SyncDocument().initParent(); + } + safeApply(scope); + }) + .catch((err: AxiosError) => { + const message: string = + "Error while attempting to fetch documents children from content"; + console.error(message + err.message); + return []; + }); + } + + function updateTree(): void { + const selectedFolderFromNextcloudTree: SyncDocument = + $scope.getNextcloudTreeController()["selectedFolder"]; + updateFolderDocument(selectedFolderFromNextcloudTree); + safeApply($scope); + } + + function initDraggable(): void { + // use this const to make it accessible to its folderTree inner context + const viewModel = $scope; + $scope.draggable = { + dragConditionHandler(event: DragEvent, content?: any): boolean { + return false; + }, + dragDropHandler(event: DragEvent, content?: any): void {}, + async dragEndHandler(event: DragEvent, content?: any): Promise { + await viewModel.moveDocument( + document.elementFromPoint(event.x, event.y), + content, + ); + viewModel.lockDropzone = false; + safeApply($scope); + }, + dragStartHandler(event: DragEvent, content?: any): void { + viewModel.lockDropzone = true; + try { + event.dataTransfer.setData( + "application/json", + JSON.stringify(content), + ); + } catch (e) { + event.dataTransfer.setData("Text", JSON.stringify(content)); + } + nextcloudEventService.setContentContext(content); + }, + dropConditionHandler(event: DragEvent, content?: any): boolean { + return true; + }, + }; + } + + function sortDocumentsByFolder( + syncDocumentA: SyncDocument, + syncDocumentB: SyncDocument, + ): number { + if (syncDocumentA.type === "folder" && syncDocumentB.type === "file") + return -1; + if (syncDocumentA.type === "file" && syncDocumentB.type === "folder") + return 1; + return 0; + } + + $scope.moveDocument = async function ( + element: any, + document: SyncDocument, + ): Promise { + let selectedFolderFromNextcloudTree: SyncDocument = + $scope.getNextcloudTreeController()["selectedFolder"]; + if (!selectedFolderFromNextcloudTree) { + selectedFolderFromNextcloudTree = $scope.parentDocument; + } + let folderContent: any = angular.element(element).scope(); + // if interacted into trees(workspace or nextcloud) + if (folderContent && folderContent.folder) { + processMoveTree( + folderContent, + document, + selectedFolderFromNextcloudTree, + ); + } + if ( + folderContent && + folderContent.content instanceof SyncDocument && + folderContent.content.isFolder + ) { + // if interacted into nextcloud + processMoveToNextcloud( + document, + folderContent.content, + selectedFolderFromNextcloudTree, + ); + } + }; + + function processMoveTree( + folderContent: any, + document: SyncDocument, + selectedFolderFromNextcloudTree: SyncDocument, + ): void { + if (folderContent.folder instanceof models.Element) { + const nextcloudController: any = $scope.getNextcloudTreeController(); + const filesToMove: Set = new Set( + $scope.selectedDocuments, + ).add(document); + const filesPath: Array = Array.from(filesToMove).map( + (file: SyncDocument) => file.path, + ); + if (filesPath.length) { + nextcloudService + .moveDocumentNextcloudToWorkspace( + model.me.userId, + filesPath, + folderContent.folder._id, + ) + .then(() => nextcloudUserService.getUserInfo(model.me.userId)) + .then((userInfos) => { + nextcloudController.userInfo = userInfos; + return nextcloudService.listDocument( + model.me.userId, + selectedFolderFromNextcloudTree.path + ? selectedFolderFromNextcloudTree.path + : null, + ); + }) + .then((syncedDocument: Array) => { + $scope.documents = syncedDocument + .filter( + (syncDocument: SyncDocument) => + syncDocument.path != selectedFolderFromNextcloudTree.path, + ) + .filter( + (syncDocument: SyncDocument) => + syncDocument.name != model.me.userId, + ); + updateFolderDocument(selectedFolderFromNextcloudTree); + safeApply($scope); + }) + .catch((err: AxiosError) => { + const message: string = + "Error while attempting to move nextcloud document to workspace " + + "or update nextcloud list"; + console.error(message + err.message); + }); + } + } else { + processMoveToNextcloud( + document, + folderContent.folder, + selectedFolderFromNextcloudTree, + ); + } + } + + async function moveAllDocuments( + document: SyncDocument, + target: SyncDocument, + ): Promise { + const promises: Array> = []; + $scope.selectedDocuments.push(document); + const selectedSet: Set = new Set( + $scope.selectedDocuments, + ); + selectedSet.forEach((doc: SyncDocument) => { + if (doc.path != target.path) { + promises.push( + nextcloudService.moveDocument( + model.me.userId, + doc.path, + (target.path != null ? target.path : "") + encodeURI(doc.name), + ), + ); + } + }); + return await Promise.all(promises); + } + + function updateDocList( + selectedFolderFromNextcloudTree: SyncDocument, + ): void { + $scope.selectedDocuments = []; + nextcloudService + .listDocument( + model.me.userId, + selectedFolderFromNextcloudTree.path + ? selectedFolderFromNextcloudTree.path + : null, + ) + .then((syncedDocument: Array) => { + $scope.documents = syncedDocument + .filter( + (syncDocument: SyncDocument) => + syncDocument.path != selectedFolderFromNextcloudTree.path, + ) + .filter( + (syncDocument: SyncDocument) => + syncDocument.name != model.me.userId, + ); + updateFolderDocument(selectedFolderFromNextcloudTree); + safeApply($scope); + }) + .catch((err: AxiosError) => { + const message: string = "Error while updating documents list"; + console.error(message + err.message); + }); + } + + function deleteDocuments( + document: SyncDocument, + ): Promise { + $scope.selectedDocuments.push(document); + const selectedSet: Set = new Set( + $scope.selectedDocuments, + ); + + const paths: Array = Array.from(selectedSet).map( + (doc: SyncDocument) => doc.path + ); + + return nextcloudService.deleteDocuments(model.me.userId, paths); + } + + function processMoveToNextcloud( + document: SyncDocument, + target: SyncDocument, + selectedFolderFromNextcloudTree: SyncDocument, + ): void { + if(target.isStaticFolder && target.staticFolderType === "trashbin") { + deleteDocuments(document) + .then(() => { + updateDocList(selectedFolderFromNextcloudTree); + }) + .catch((err: AxiosError) => { + updateDocList(selectedFolderFromNextcloudTree); + const message: string = + "Error while attempting to delete nextcloud document"; + console.error(message + err.message); + }) + return; + } + + moveAllDocuments(document, target) + .then(() => updateDocList(selectedFolderFromNextcloudTree)) + .catch((err: AxiosError) => { + updateDocList(selectedFolderFromNextcloudTree); + const message: string = + "Error while attempting to move nextcloud document to workspace " + + "or update nextcloud list"; + console.error(message + err.message); + }); + } + + function updateFolderDocument( + selectedFolderFromNextcloudTree: SyncDocument, + ): void { + nextcloudEventService.setContentContext(null); + nextcloudEventService.sendOpenFolderDocument( + selectedFolderFromNextcloudTree, + ); + } + + $scope.onSelectContent = function (content: SyncDocument): void { + $scope.selectedDocuments = $scope.documents.filter( + (document: SyncDocument) => document.selected, + ); + }; + + $scope.onSelectAll = function (): void { + $scope.checkboxSelectAll = !$scope.checkboxSelectAll; + $scope.documents.map(document => document.selected = $scope.checkboxSelectAll); + + $scope.selectedDocuments = $scope.documents.filter( + (document: SyncDocument) => document.selected, + ); + }; + + $scope.isViewMode = function (mode: ViewMode): boolean { + const pathTemplate = `nextcloud/content/views/${mode}`; + return template.contains("documents-content", pathTemplate); + }; + + $scope.changeViewMode = async function (mode: ViewMode): Promise { + let preference: NextcloudPreference = Me.preferences["nextcloud"]; + preference.viewMode = mode; + await nextcloudPreference.updatePreference(preference); + const pathTemplate = `nextcloud/content/views/${mode}`; + $scope.documents.forEach((document) => (document.selected = false)); + $scope.selectedDocuments = []; + template.open("documents-content", pathTemplate); + + safeApply($scope); + }; + + $scope.openDocument = function(document?: SyncDocument): any { + const pathTemplate: string = `nextcloud/content/views/viewer`; + + this.viewFile = document ? document : $scope.selectedDocuments[0]; + template.open("documents-content", pathTemplate); + $scope.selectedDocuments = []; + } + + $scope.closeViewFile = function(): any { + let preference: NextcloudPreference = Me.preferences["nextcloud"]; + this.viewFile = null; + $scope.changeViewMode(preference.viewMode); + } + + $scope.onOpenContent = function (document: SyncDocument): void { + if (document.isFolder) { + nextcloudEventService.sendOpenFolderDocument(document); + // reset all selected documents switch we switch folder + $scope.selectedDocuments = []; + } else { + $scope.openDocument(document); + } + }; + + $scope.getFile = function (document: SyncDocument): string { + return nextcloudService.getFile( + model.me.userId, + document.name, + document.path, + document.contentType, + ); + }; + + $scope.isDropzoneEnabled = function (): boolean { + return !$scope.lockDropzone; + }; + + $scope.canDropOnFolder = function (): boolean { + return true; + }; + + $scope.onCannotDropFile = function (): void {}; + }, + ], +); + +export const workspaceNextcloudContent = ng.directive( + "workspaceNextcloudContent", + () => { + return { + restrict: "E", + templateUrl: + "/workspace/public/template/nextcloud/content/workspace-nextcloud-content.html", + controller: "NextcloudContentController", + }; + }, +); diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/fileUpload.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/fileUpload.component.ts new file mode 100644 index 0000000000..935000ed32 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/fileUpload.component.ts @@ -0,0 +1,140 @@ +import { AxiosError } from "axios"; +import { idiom as lang, model, notify } from "entcore"; +import { SyncDocument } from "../../models/nextcloudFolder.model"; +import { nextcloudUserService } from "../../services/nextcloudUser.service"; +import { nextcloudService } from "../../services/nextcloud.service"; +import { safeApply } from "../../utils/safeApply.utils"; +import { IWorkspaceNextcloudContent } from "./contentViewer.component"; + +declare let window: any; + +interface ILightboxViewModel { + uploadFile: boolean; + importFiles: boolean; +} + +interface IViewModel { + lightbox: ILightboxViewModel; + uploadedDocuments: Array; + files: FileList; + + toggleUploadFilesView(state: boolean): void; + toggleImportFilesView(state: boolean): void; + startImportFlow(): void; + onFilesSelectedFromImport(): void; + onImportFiles(files: FileList): void; + onValidImportFiles(files: FileList): void; + + // document util + getSize(size: number): string; + abortFile(doc: File): void; +} + +export class UploadFileSnipletViewModel implements IViewModel { + private vm: any; + + lightbox: ILightboxViewModel; + uploadedDocuments: Array; + files: null; + + constructor(scope: IWorkspaceNextcloudContent) { + this.vm = scope; + this.lightbox = { + importFiles: false, + uploadFile: false, + }; + this.uploadedDocuments = []; + } + + toggleImportFilesView(state: boolean): void { + this.lightbox.importFiles = state; + this.vm.safeApply(); + } + + startImportFlow(): void { + this.toggleImportFilesView(true); + } + + onFilesSelectedFromImport(): void { + this.toggleImportFilesView(false); + this.onImportFiles(this.files); + } + + toggleUploadFilesView(state: boolean): void { + this.lightbox.uploadFile = state; + if (!state) { + this.uploadedDocuments = []; + this.lightbox.importFiles = false; + } + } + + onImportFiles(files: FileList): void { + this.toggleUploadFilesView(true); + + for (let i = 0; i < files.length; i++) { + this.uploadedDocuments.push(files[i]); + } + } + + onValidImportFiles(): void { + let selectedFolderFromNextcloudTree: SyncDocument = + this.vm.getNextcloudTreeController()["selectedFolder"]; + const nextcloudController: any = this.vm.getNextcloudTreeController(); + nextcloudService + .uploadDocuments( + model.me.userId, + this.uploadedDocuments, + selectedFolderFromNextcloudTree.path, + ) + .then(() => nextcloudUserService.getUserInfo(model.me.userId)) + .then((userInfos) => { + nextcloudController.userInfo = userInfos; + return nextcloudService.listDocument( + model.me.userId, + this.vm.parentDocument.path ? this.vm.parentDocument.path : null, + ); + }) + .then((syncDocuments: Array) => { + this.vm.documents = syncDocuments + .filter( + (syncDocument: SyncDocument) => + syncDocument.path != this.vm.parentDocument.path, + ) + .filter( + (syncDocument: SyncDocument) => + syncDocument.name != model.me.userId, + ); + safeApply(this.vm); + }) + .catch((err: AxiosError) => { + const message: string = "Error while uploading files to nextcloud: "; + console.error( + `${message}${err.message}: ${this.vm.toolbar.getErrorMessage(err)}`, + ); + if (err.message.includes("413") || err.message.includes("507")) { + notify.error(lang.translate("file.too.large.upload")); + } else { + notify.error(lang.translate("nextcloud.fail.upload")); + } + }); + this.toggleUploadFilesView(false); + safeApply(this.vm); + } + + getSize(size: number): string { + const koSize = size / 1024; + if (koSize > 1024) { + return parseInt(String((koSize / 1024) * 10)) / 10 + " Mo"; + } + return Math.ceil(koSize) + " Ko"; + } + + abortFile(doc: File): void { + const index: number = this.uploadedDocuments.indexOf(doc); + this.uploadedDocuments.splice(index, 1); + + if (this.uploadedDocuments.length === 0) { + this.toggleUploadFilesView(false); + } + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/iconView.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/iconView.component.ts new file mode 100644 index 0000000000..ab5e1d604f --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/iconView.component.ts @@ -0,0 +1,11 @@ +interface INextcloudViewIcons {} + +export class NextcloudViewIcons implements INextcloudViewIcons { + private vm: any; + private scope: any; + + constructor(scope) { + this.scope = scope; + this.vm = scope.vm; + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/listView.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/listView.component.ts new file mode 100644 index 0000000000..dddce569be --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/listView.component.ts @@ -0,0 +1,66 @@ +import { SyncDocument } from "../../models/nextcloudFolder.model"; +import { DateUtils } from "../../utils/date.utils"; + +export interface INextcloudViewList { + orderByField(fieldName: string, desc?: boolean): void; + isOrderedDesc(fieldName: string): boolean; + isOrderedAsc(fieldName: string): boolean; + displayLastModified(document: SyncDocument): string; + + orderField: string; + orderDesc: boolean; +} + +export class NextcloudViewList implements INextcloudViewList { + private vm: any; + + orderField: string; + orderDesc: boolean; + + constructor(vm) { + this.vm = vm; + this.orderField = null; + this.orderDesc = false; + } + + isOrderedAsc(fieldName: string): boolean { + return this.orderField === fieldName && !this.orderDesc; + } + + isOrderedDesc(fieldName: string): boolean { + return this.orderField === fieldName && this.orderDesc; + } + + orderByField(fieldName: string, desc?: boolean): void { + if (fieldName === this.orderField) { + this.orderDesc = !this.orderDesc; + } else { + this.orderDesc = typeof desc === "boolean" ? desc : false; + } + this.orderField = fieldName; + this.vm.documents = this.vm.documents.sort( + (a: SyncDocument, b: SyncDocument) => { + if (this.orderField === "lastModified") { + return this.orderDesc + ? new Date(a.lastModified).getTime() - + new Date(b.lastModified).getTime() + : new Date(b.lastModified).getTime() - + new Date(a.lastModified).getTime(); + } else if (this.orderField === "size") { + if (a.size > b.size) return this.orderDesc ? 1 : -1; + if (a.size < b.size) return this.orderDesc ? -1 : 1; + } else if (typeof a[fieldName] === "string") { + return this.orderDesc + ? a[fieldName].localeCompare(b[fieldName]) + : b[fieldName].localeCompare(a[fieldName]); + } + return 0; + }, + ); + } + + displayLastModified(document: SyncDocument): string { + if (!document.lastModified) return ""; + return DateUtils.format(document.lastModified, "DD/MM/YYYY HH:mm:ss"); + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbar.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbar.component.ts new file mode 100644 index 0000000000..23e279f7ae --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbar.component.ts @@ -0,0 +1,551 @@ +import { AxiosError } from "axios"; +import { + angular, + FolderPickerProps, + FolderPickerSourceFile, + model, + toasts, +} from "entcore"; +import { WorkspaceScope } from "../../../../controller"; +import { models } from "../../../../services"; +import { SyncDocument } from "../../models/nextcloudFolder.model"; +import { INextcloudFolderScope } from "../../nextcloudFolder.directive"; +import { nextcloudUserService } from "../../services/nextcloudUser.service"; +import { nextcloudService } from "../../services/nextcloud.service"; +import { safeApply } from "../../utils/safeApply.utils"; +import { IWorkspaceNextcloudContent } from "./contentViewer.component"; +import { ToolbarShareSnipletViewModel } from "./toolbarShare.components"; + +declare let window: any; + +interface ILightbox { + properties: boolean; + delete: boolean; + share: boolean; + copy: boolean; +} + +export interface IViewModel { + lightbox: ILightbox; + currentDocument: SyncDocument; + + // Document selection + hasOneDocumentSelected(selectedDocuments: Array): boolean; + isSelectedEditable(selectedDocuments: Array): boolean; + + // Actions + downloadFiles(selectedDocuments: Array): void; + openDocument(): void; + viewFile: SyncDocument; + + editDocument(): void; + + // Properties/Rename + toggleRenameView( + state: boolean, + selectedDocuments?: Array, + ): void; + renameDocument(): void; + + // Delete + toggleDeleteView(state: boolean): void; + deleteDocuments(): void; + restoreDocuments(): void; + + // Copy/Move + toggleCopyView(state: boolean, selectedDocuments?: Array): void; + + // Share + share: any; +} + +/** + * ViewModel for toolbar actions in the Nextcloud content view + */ +export class ToolbarSnipletViewModel implements IViewModel { + private vm: IWorkspaceNextcloudContent; + private treeController: INextcloudFolderScope; + private workspaceScope: WorkspaceScope; + + public lightbox: ILightbox; + public currentDocument: SyncDocument; + public viewFile: SyncDocument; + + public share: ToolbarShareSnipletViewModel; + + public copyProps: FolderPickerProps; + + constructor(scope: IWorkspaceNextcloudContent) { + this.vm = scope; + this.treeController = this.vm.getNextcloudTreeController(); + + // Get workspace scope for accessing workspace trees + this.workspaceScope = angular + .element(document.querySelector('[data-ng-controller="Workspace"]')) + .scope(); + + // Initialize UI state + this.lightbox = { + properties: false, + delete: false, + share: false, + copy: false, + }; + this.currentDocument = null; + + this.share = new ToolbarShareSnipletViewModel(scope, this.lightbox); + + // Initialize empty copy props + this.copyProps = { + i18: null, + sources: [], + treeProvider: null, + nextcloudTreeProvider: null, + onCancel: () => this.closeCopyView(), + onError: () => this.closeCopyView(), + }; + } + + /*** Selection State ***/ + + public isSelectedEditable(selectedDocuments: Array): boolean { + return selectedDocuments.length > 0 && selectedDocuments[0].editable; + } + + public hasOneDocumentSelected( + selectedDocuments: Array, + ): boolean { + const total: number = selectedDocuments ? selectedDocuments.length : 0; + return total === 1; + } + + /*** Document Actions ***/ + + public openDocument(): void { + if (this.vm.selectedDocuments.length === 0) return; + this.vm.openDocument(); + } + + public editDocument(): void { + if (this.vm.selectedDocuments.length > 0) { + nextcloudService.openNextcloudEditLink( + this.vm.selectedDocuments[0], + this.vm.nextcloudUrl, + ); + } + } + + public downloadFiles(selectedDocuments: Array): void { + if (selectedDocuments.length === 1) { + this.downloadSingleFile(selectedDocuments[0]); + } else { + this.downloadMultipleFiles(selectedDocuments); + } + } + + private downloadSingleFile(document: SyncDocument): void { + window.open( + nextcloudService.getFile( + model.me.userId, + document.name, + document.path, + document.contentType, + document.isFolder, + ), + ); + } + + private downloadMultipleFiles(documents: Array): void { + const selectedDocumentsName: Array = documents.map( + (doc: SyncDocument) => doc.name, + ); + const getPathParent: string = this.vm.parentDocument.path || "/"; + + window.open( + nextcloudService.getFiles( + model.me.userId, + getPathParent, + selectedDocumentsName, + ), + ); + } + + /*** Rename Operations ***/ + + public toggleRenameView( + state: boolean, + selectedDocuments?: Array, + ): void { + this.lightbox.properties = state; + if (state && selectedDocuments) { + this.currentDocument = Object.assign({}, selectedDocuments[0]); + } else { + this.currentDocument = null; + } + } + + public renameDocument(): void { + const oldDocumentToRename: SyncDocument = this.vm.selectedDocuments[0]; + if (!oldDocumentToRename) return; + + const parentPath = this.vm.parentDocument.path || ""; + const targetDocument: string = + parentPath + "/" + encodeURIComponent(this.currentDocument.name); + + nextcloudService + .moveDocument(model.me.userId, oldDocumentToRename.path, targetDocument) + .then(() => this.refreshDocuments()) + .then(() => { + this.toggleRenameView(false); + this.vm.selectedDocuments = []; + safeApply(this.vm); + }) + .catch((err: AxiosError) => { + this.handleError( + err, + "Error while attempting to rename document from content", + ); + this.toggleRenameView(false); + this.vm.selectedDocuments = []; + safeApply(this.vm); + }); + } + + /*** Delete Operations ***/ + + public toggleDeleteView(state: boolean): void { + this.lightbox.delete = state; + } + + // This method moves documents to the trashbin + public deleteDocuments(): void { + const paths: Array = this.vm.selectedDocuments.map( + (selectedDocument: SyncDocument) => selectedDocument.path, + ); + + nextcloudService + .deleteDocuments(model.me.userId, paths) + .then(() => nextcloudUserService.getUserInfo(model.me.userId)) + .then((userInfos) => { + this.treeController.userInfo = userInfos; + toasts.info("nextcloud.documents.trash.confirmation"); + return this.refreshDocuments(); + }) + .then(() => { + this.toggleDeleteView(false); + this.vm.selectedDocuments = []; + safeApply(this.vm); + }) + .catch((err: AxiosError) => { + this.handleError( + err, + "Error while attempting to delete documents from content", + ); + this.toggleDeleteView(false); + this.vm.selectedDocuments = []; + safeApply(this.vm); + }); + } + + public deleteDocumentsPermanently() { + const paths: Array = this.vm.selectedDocuments.map( + (selectedDocument: SyncDocument) => selectedDocument.path, + ); + + nextcloudService + .deleteTrashDocuments(model.me.userId, paths) + .then(() => nextcloudUserService.getUserInfo(model.me.userId)) + .then((userInfos) => { + this.treeController.userInfo = userInfos; + toasts.info("nextcloud.documents.deletion.confirmation"); + return this.refreshTrashbin(); + }) + .then(() => { + this.toggleDeleteView(false); + this.vm.selectedDocuments = []; + safeApply(this.vm); + }) + .catch((err: AxiosError) => { + this.handleError( + err, + "Error while attempting to delete documents from content", + ); + this.toggleDeleteView(false); + this.vm.selectedDocuments = []; + safeApply(this.vm); + }); + } + + public restoreDocuments(): void { + const paths: Array = this.vm.selectedDocuments.map( + (selectedDocument: SyncDocument) => selectedDocument.path, + ); + + nextcloudService + .restoreDocument(model.me.userId, paths) + .then(() => nextcloudUserService.getUserInfo(model.me.userId)) + .then((userInfos) => { + this.treeController.userInfo = userInfos; + toasts.info("nextcloud.documents.restore.confirmation"); + return this.refreshTrashbin(); + }) + .then(() => { + this.vm.selectedDocuments = []; + safeApply(this.vm); + }) + .catch((err: AxiosError) => { + this.handleError( + err, + "Error while attempting to restore documents from content", + ); + this.vm.selectedDocuments = []; + safeApply(this.vm); + }); + } + + /*** Copy Operations ***/ + public toggleCopyView( + state: boolean, + selectedDocuments?: Array, + ): void { + if (state && selectedDocuments) { + this.setupCopyProps(selectedDocuments, "copy"); + } + this.lightbox.copy = state; + } + + private setupCopyProps( + selectedDocuments: Array, + type: "move" | "copy", + ): void { + this.copyProps = { + i18: { + title: + type === "copy" + ? "workspace.copy.window.title" + : "workspace.move.window.title", + actionTitle: + type === "copy" + ? "workspace.copy.window.action" + : "workspace.move.window.action", + actionProcessing: + type === "copy" ? "workspace.copying" : "workspace.moving", + actionFinished: + type === "copy" + ? "workspace.copy.finished" + : "workspace.move.finished", + info: + type === "copy" + ? "workspace.copy.window.info" + : "workspace.move.window.info", + }, + sources: selectedDocuments.map( + (document: SyncDocument) => + ({ + action: type === "copy" ? "copy-from-file" : "move-from-file", + fileId: document.path, + }) as FolderPickerSourceFile, + ), + treeProvider: async () => { + if (this.workspaceScope && this.workspaceScope.trees) { + // Make sure we're returning an array of trees + const trees = this.workspaceScope.trees.filter( + (tree) => tree.filter === "owner", + ); + return trees; + } + return []; + }, + + // If copying, we don't need to load Nextcloud folders + nextcloudTreeProvider: + type === "copy" + ? null + : async () => { + try { + const rootFolder = new SyncDocument().initParent(); + const documents = await nextcloudService.listDocument( + model.me.userId, + ); + rootFolder.children = documents.filter( + (doc) => doc.isFolder && doc.path !== "/", + ); + return [rootFolder]; + } catch (e) { + console.error("Error loading Nextcloud folders", e); + return []; + } + }, + + submit: (selectedFolder: models.Element | SyncDocument) => { + if (selectedFolder instanceof models.Element) { + this.handleSubmitToWorkspace(selectedFolder, selectedDocuments, type); + } else if (selectedFolder instanceof SyncDocument) { + this.handleSubmitToNextcloud(selectedFolder, selectedDocuments, type); + } + }, + onCancel: () => this.closeCopyView(), + onError: () => this.closeCopyView(), + }; + } + + private async handleSubmitToWorkspace( + destFolder: models.Element, + sourceDocuments: Array, + type: "copy" | "move", + ): Promise { + try { + const paths = sourceDocuments.map((doc) => doc.path); + const parentId = destFolder._id; + + let results; + if (type === "copy") { + // Copy from Nextcloud to workspace + results = await nextcloudService.copyDocumentToWorkspace( + model.me.userId, + paths, + parentId, + ); + } else { + // Move from Nextcloud to workspace + results = await nextcloudService.moveDocumentNextcloudToWorkspace( + model.me.userId, + paths, + parentId, + ); + } + + if (results && results.length > 0) { + // Refresh the workspace folder if we're viewing it + if ( + this.workspaceScope && + this.workspaceScope.openedFolder && + this.workspaceScope.openedFolder.folder && + this.workspaceScope.openedFolder.folder._id === parentId + ) { + this.workspaceScope.reloadFolderContent(); + } + } + + // Clear selection and refresh Nextcloud view + this.vm.selectedDocuments = []; + await this.refreshDocuments(); + + this.closeCopyView(); + this.vm.safeApply(); + } catch (err) { + this.handleError( + err, + `Error ${type === "copy" ? "copying" : "moving"} to workspace`, + ); + toasts.warning(`workspace.${type}.error`); + this.closeCopyView(); + this.vm.safeApply(); + } + } + + /** + * Handle submission when a Nextcloud folder is selected + */ + private async handleSubmitToNextcloud( + destFolder: SyncDocument, + sourceDocuments: Array, + type: "move" | "copy", + ): Promise { + try { + const destPath = destFolder.path || "/"; + + if (type === "move") { + for (const doc of sourceDocuments) { + await nextcloudService.moveDocument( + model.me.userId, + doc.path, + `${destPath}/${doc.name}`, + ); + } + // Refresh views + await this.refreshDocuments(); + } else { + // Implement Nextcloud copy functionality here + } + + this.closeCopyView(); + this.vm.safeApply(); + } catch (err) { + this.handleError( + err, + `Error ${type === "copy" ? "copying" : "moving"} within Nextcloud`, + ); + toasts.warning(`nextcloud.${type}.error`); + this.closeCopyView(); + this.vm.safeApply(); + } + } + + public closeCopyView(): void { + this.lightbox.copy = false; + + if (this.copyProps && this.copyProps.sources) { + this.copyProps.sources = []; + } + + if (this.vm && this.vm.safeApply) { + this.vm.safeApply(); + } + } + + /*** Move Operations ***/ + public toggleMoveView( + state: boolean, + selectedDocuments?: Array, + ): void { + if (state && selectedDocuments) { + this.setupCopyProps(selectedDocuments, "move"); + } + this.lightbox.copy = state; + } + + private async moveSubmit(selectedFolder: SyncDocument): Promise { + this.vm.selectedDocuments.forEach((document) => { + nextcloudService.moveDocument( + model.me.userId, + document.path, + selectedFolder.path + "/" + document.name, + ); + }); + this.vm.selectedDocuments = []; + await this.refreshDocuments(); + this.closeCopyView(); + this.vm.safeApply(); + } + + /*** Utility Methods ***/ + + private async refreshDocuments(): Promise> { + const parentPath = this.vm.parentDocument.path; + const syncDocuments = await nextcloudService.listDocument( + model.me.userId, + parentPath || null, + ); + this.vm.documents = syncDocuments + .filter((doc) => doc.path !== this.vm.parentDocument.path) + .filter((doc_1) => doc_1.name !== model.me.userId); + return syncDocuments; + } + + private async refreshTrashbin(): Promise { + const syncDocuments = await nextcloudService.listTrash(model.me.userId); + this.vm.documents = syncDocuments; + } + + private handleError(err: AxiosError, message: string): void { + console.error( + `${message}: ${err.message}${this.getErrorMessage(err) ? `: ${this.getErrorMessage(err)}` : ""}`, + ); + this.closeCopyView(); + } + + private getErrorMessage(err: AxiosError): string { + return err?.response?.data?.message || ""; + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbarShare.components.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbarShare.components.ts new file mode 100644 index 0000000000..692ef6e591 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/content/toolbarShare.components.ts @@ -0,0 +1,96 @@ +import { setTimeout } from "core-js"; +import { + idiom as lang, + model, + notify, + SharePayload, + template, + workspace, +} from "entcore"; +import { SyncDocument } from "../../models/nextcloudFolder.model"; +import { nextcloudService } from "../../services/nextcloud.service"; +import { WorkspaceEntcoreUtils } from "../../utils/workspaceEntcore.utils"; +import models = workspace.v2.models; + +interface IViewModel { + copyingForShare: boolean; + + sharedElement: Array; + toggleShareView( + state: boolean, + selectedDocuments?: Array, + ): void; + onShareAndCopy(): void; + + onSubmitSharedElements(share: SharePayload): Promise; + onCancelShareElements(): Promise; +} + +export class ToolbarShareSnipletViewModel implements IViewModel { + private vm: any; + private lightbox: any; + + copyingForShare: boolean; + sharedElement: Array; + + constructor(scopeParent: any, lightbox: any) { + this.vm = scopeParent; + this.lightbox = lightbox; + this.sharedElement = []; + } + + toggleShareView( + state: boolean, + selectedDocuments?: Array, + ): void { + this.lightbox.share = state; + if (state && selectedDocuments) { + this.copyingForShare = false; + const pathTemplate: string = `nextcloud/toolbar/share/share-documents-options`; + template.open("workspace-nextcloud-toolbar-share", pathTemplate); + } else { + template.close("workspace-nextcloud-toolbar-share"); + this.copyingForShare = true; + } + } + + onShareAndCopy(): void { + const paths: Array = this.vm.selectedDocuments.map( + (document: SyncDocument) => document.path, + ); + nextcloudService + .copyDocumentToWorkspace(model.me.userId, paths) + .then((workspaceDocuments: Array) => { + this.sharedElement = workspaceDocuments; + const pathTemplate: string = `nextcloud/toolbar/share/share`; + template.open("workspace-nextcloud-toolbar-share", pathTemplate); + }); + } + + async onSubmitSharedElements(share: SharePayload): Promise { + this.toggleShareView(false); + setTimeout(() => { + WorkspaceEntcoreUtils.toggleWorkspaceContentDisplay(false); + this.vm.safeApply(); + }, 500); + this.sharedElement = []; + } + + async onCancelShareElements(): Promise { + if (this.sharedElement.length) { + try { + await nextcloudService.moveDocumentWorkspaceToCloud( + model.me.userId, + this.sharedElement.map((doc) => doc._id), + this.vm.parentDocument.path, + ); + this.vm.updateTree(); + this.vm.safeApply(); + } catch (e) { + console.error("Error while canceling share: " + e); + } + this.sharedElement = []; + } + this.toggleShareView(false); + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/folder/emptyTrash.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/folder/emptyTrash.component.ts new file mode 100644 index 0000000000..5f116eb475 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/folder/emptyTrash.component.ts @@ -0,0 +1,46 @@ +import { model } from "entcore"; +import { INextcloudFolderScope } from "../../nextcloudFolder.directive"; +import { nextcloudEventService } from "../../services/nextcloudEvent.service"; +import { nextcloudService } from "../../services/nextcloud.service"; +import { safeApply } from "../../utils/safeApply.utils"; + +interface ILightboxViewModel { + emptyTrash: boolean; +} + +interface IViewModel { + lightbox: ILightboxViewModel; +} + +export class EmptyTrashModel implements IViewModel { + private vm: INextcloudFolderScope; + + lightbox: ILightboxViewModel; + + constructor(scope: INextcloudFolderScope) { + this.vm = scope; + this.lightbox = { + emptyTrash: false, + }; + } + + public emptyTrashbin(): void { + nextcloudService + .deleteTrash(model.me.userId) + .then(() => { + return nextcloudService.listTrash(model.me.userId); + }) + .then((syncDocuments) => { + nextcloudEventService.sendDocuments({ + parentDocument: null, + documents: syncDocuments, + }); + this.lightbox.emptyTrash = false; + safeApply(this.vm); + }) + .catch((err: Error) => { + const message: string = "Error while attempting to empty trashbin "; + console.error(message + err.message); + }); + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/components/folder/folderManager.component.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/components/folder/folderManager.component.ts new file mode 100644 index 0000000000..02acaf6178 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/components/folder/folderManager.component.ts @@ -0,0 +1,64 @@ +import { AxiosError } from "axios"; +import { model, workspace } from "entcore"; +import { SyncDocument } from "../../models/nextcloudFolder.model"; +import { nextcloudService } from "../../services/nextcloud.service"; +import { nextcloudEventService } from "../../services/nextcloudEvent.service"; +import { safeApply } from "../../utils/safeApply.utils"; +import models = workspace.v2.models; + +interface ILightboxViewModel { + folder: boolean; +} + +interface IViewModel { + lightbox: ILightboxViewModel; + currentDocument: SyncDocument; + toggleCreateFolder(state: boolean, folderCreate: models.Element): void; + createFolder(folderCreate: models.Element): void; +} + +export class FolderCreationModel implements IViewModel { + private vm: any; + private scope: any; + + lightbox: ILightboxViewModel; + currentDocument: SyncDocument; + + constructor(scope) { + this.vm = scope; + this.lightbox = { + folder: false, + }; + this.currentDocument = null; + } + + public toggleCreateFolder( + state: boolean, + folderCreate: models.Element, + ): void { + if (folderCreate) { + folderCreate.name = ""; + } + this.lightbox.folder = state; + } + + public createFolder(folderCreate: models.Element): void { + const folder: SyncDocument = this.vm.selectedFolder; + nextcloudService + .createFolder( + model.me.userId, + (folder.path != null ? folder.path + "/" : "") + + encodeURI(folderCreate.name), + ) + .then(() => { + folderCreate.name = ""; + this.toggleCreateFolder(false, folderCreate); + nextcloudEventService.sendOpenFolderDocument(this.vm.selectedFolder); + safeApply(this.scope); + }) + .catch((err: AxiosError) => { + const message: string = "Error while attempting folder creation."; + console.error(message + err.message); + }); + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/constants/dateFormats.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/constants/dateFormats.ts new file mode 100644 index 0000000000..3c22570037 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/constants/dateFormats.ts @@ -0,0 +1,7 @@ +export const DATE_FORMAT = { + formattedDate: "YYYY-MM-DD", + formattedTime: "HH:mm:ss", +}; + +export const START_DAY_TIME = "00:00:00"; +export const END_DAY_TIME = "23:59:59"; diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/constants/rootPaths.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/constants/rootPaths.ts new file mode 100644 index 0000000000..9b071ce321 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/constants/rootPaths.ts @@ -0,0 +1,4 @@ +export const RootsConst = { + directive: "workspace/public/ts/directives", + template: "workspace/public/template", +}; diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/enums/documentRole.enum.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/enums/documentRole.enum.ts new file mode 100644 index 0000000000..20f4292cf8 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/enums/documentRole.enum.ts @@ -0,0 +1,11 @@ +export enum DocumentRole { + XLS = 'spreadsheet', + PPT = 'presentation', + VIDEO = 'video', + IMG = 'image', + AUDIO = 'audio', + DOC = 'doc', + PDF = 'pdf', + UNKNOWN = 'unknown', + FOLDER = 'folder' +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/enums/documentsType.enum.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/enums/documentsType.enum.ts new file mode 100644 index 0000000000..0550a9d9cd --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/enums/documentsType.enum.ts @@ -0,0 +1,4 @@ +export enum DocumentsType { + FILE = "file", + FOLDER = "folder", +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/enums/viewMode.enum.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/enums/viewMode.enum.ts new file mode 100644 index 0000000000..6d0f5348ae --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/enums/viewMode.enum.ts @@ -0,0 +1,4 @@ +export enum ViewMode { + ICONS = "icons", + LIST = "list", +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudDraggable.model.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudDraggable.model.ts new file mode 100644 index 0000000000..70c74b4a37 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudDraggable.model.ts @@ -0,0 +1,7 @@ +export interface Draggable { + dragStartHandler(event: DragEvent, content?: any): void; + dragDropHandler(event: DragEvent, content?: any): void; + dragConditionHandler(event: DragEvent, content?: any): boolean; + dropConditionHandler(event: DragEvent, content?: any): boolean; + dragEndHandler(event: DragEvent, content?: any): void; +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudFolder.model.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudFolder.model.ts new file mode 100644 index 0000000000..b4bf484a7e --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudFolder.model.ts @@ -0,0 +1,173 @@ +import { idiom as lang, model, workspace } from "entcore"; +import { DocumentRole } from "../enums/documentRole.enum"; +import { DocumentsType } from "../enums/documentsType.enum"; +import { NextcloudDocumentsUtils } from "../utils/nextcloudDocuments.utils"; +import models = workspace.v2.models; + +export interface IDocumentResponse { + path: string; + displayname: string; + ownerDisplayName: string; + contentType: string; + size: number; + favorite: number; + etag: string; + fileId: number; + isFolder: boolean; + lastModified: string; +} + +export class SyncDocument { + path: string; + name: string; + ownerDisplayName: string; + contentType: string; + role: DocumentRole; + size: number; + favorite: number; + editable: boolean; + etag: string; + extension: string; + fileId: number; + isFolder: boolean; + lastModified: string; + type: DocumentsType; + children: Array; + cacheChildren: models.CacheList; + cacheDocument: models.CacheList; + + // custom field bound by other entity/model + selected?: boolean; + isNextcloudParent?: boolean; + + // properties used for static folder + isStaticFolder?: boolean; + staticFolderType?: "trashbin"; + + build(data: IDocumentResponse): SyncDocument { + this.name = decodeURIComponent(data.displayname); + this.ownerDisplayName = data.ownerDisplayName; + this.path = data.path.split(model.me.userId).pop(); + this.contentType = data.contentType; + this.size = data.size; + this.favorite = data.favorite; + this.etag = data.etag; + this.fileId = data.fileId; + this.isFolder = data.isFolder; + this.lastModified = data.lastModified; + this.type = this.determineType(); + this.role = this.determineRole(); + this.editable = this.isEditable(); + this.children = []; + this.cacheChildren = new models.CacheList( + 0, + () => false, + () => false, + ); + this.cacheChildren.setData([]); + this.cacheChildren.disableCache(); + this.cacheDocument = new models.CacheList( + 0, + () => false, + () => false, + ); + this.cacheDocument.setData([]); + this.cacheDocument.disableCache(); + + return this; + } + + determineRole(): DocumentRole { + if (this.isFolder) { + return DocumentRole.FOLDER; + } else if (this.contentType) { + return NextcloudDocumentsUtils.determineRole(this.contentType); + } else { + return DocumentRole.UNKNOWN; + } + } + + determineType(): DocumentsType { + if (this.isFolder) { + return DocumentsType.FOLDER; + } else { + return DocumentsType.FILE; + } + } + + isEditable(): boolean { + return ([ + DocumentRole.DOC, + DocumentRole.PDF, + DocumentRole.XLS, + DocumentRole.PPT, + ]).includes(this.role); + } + + // create a folder with only one content (synchronized document) and its children all sync documents + initParent(): SyncDocument { + const parentNextcloudFolder: SyncDocument = new SyncDocument(); + parentNextcloudFolder.path = null; + parentNextcloudFolder.name = lang.translate("nextcloud.documents"); + parentNextcloudFolder.ownerDisplayName = model.me.login; + parentNextcloudFolder.contentType = null; + parentNextcloudFolder.size = null; + parentNextcloudFolder.favorite = null; + parentNextcloudFolder.etag = null; + parentNextcloudFolder.fileId = null; + parentNextcloudFolder.isFolder = true; + parentNextcloudFolder.lastModified = new Date().toISOString(); + parentNextcloudFolder.children = []; + parentNextcloudFolder.cacheChildren = new models.CacheList( + 0, + () => false, + () => false, + ); + parentNextcloudFolder.cacheChildren.setData([]); + parentNextcloudFolder.cacheChildren.disableCache(); + parentNextcloudFolder.cacheDocument = new models.CacheList( + 0, + () => false, + () => false, + ); + parentNextcloudFolder.cacheDocument.setData([]); + parentNextcloudFolder.cacheDocument.disableCache(); + + parentNextcloudFolder.isNextcloudParent = true; + return parentNextcloudFolder; + } + + // static folders are like the parent folder, but they can have specific name and behavior (e.g. trashbin) + static createStaticFolder(type: "trashbin"): SyncDocument { + const staticFolder = new SyncDocument(); + staticFolder.path = `/__static__/${type}`; + staticFolder.name = lang.translate(`nextcloud.static.${type}`); + staticFolder.ownerDisplayName = model.me.login; + staticFolder.contentType = null; + staticFolder.size = null; + staticFolder.favorite = null; + staticFolder.etag = null; + staticFolder.fileId = null; + staticFolder.isFolder = true; + staticFolder.lastModified = new Date().toISOString(); + staticFolder.children = []; + staticFolder.isStaticFolder = true; + staticFolder.staticFolderType = type; + staticFolder.cacheChildren = new models.CacheList( + 0, + () => false, + () => false, + ); + staticFolder.cacheChildren.setData([]); + staticFolder.cacheChildren.disableCache(); + staticFolder.cacheDocument = new models.CacheList( + 0, + () => false, + () => false, + ); + staticFolder.cacheDocument.setData([]); + staticFolder.cacheDocument.disableCache(); + + return staticFolder; + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudUser.model.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudUser.model.ts new file mode 100644 index 0000000000..cd929f9c95 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/models/nextcloudUser.model.ts @@ -0,0 +1,54 @@ +export interface IUserResponse { + id: string; + displayname: number; + email: string; + phone: string; + itemsperpage: string; + quota: { + free: string; + used: string; + total: string; + relative: number; + quota: number; + }; +} + +export class UserNextcloud { + id: string; + displayName: number; + email: string; + phone: string; + itemsPerPage: string; + quota: Quota; + + build(data: IUserResponse): UserNextcloud { + this.id = data.id; + this.displayName = data.displayname; + this.email = data.email; + this.phone = data.phone; + this.itemsPerPage = data.itemsperpage; + this.quota = new Quota().build(data.quota); + return this; + } +} + +export class Quota { + used: number; + total: number; + unit: string; + + build(data: any): Quota { + this.used = data.used / (1024 * 1024); + this.total = data.quota / (1024 * 1024); + if (this.total > 2000) { + this.total = Math.round((this.total / 1024) * 100) / 100; + this.used = Math.round((this.used / 1024) * 100) / 100; + this.unit = "Go"; + } else { + this.total = Math.round(this.total); + this.used = Math.round(this.used); + this.unit = "Mo"; + } + return this; + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/nextcloudFolder.directive.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/nextcloudFolder.directive.ts new file mode 100644 index 0000000000..f8f15271ba --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/nextcloudFolder.directive.ts @@ -0,0 +1,587 @@ +import { angular, Document, FolderTreeProps, model, ng, template, } from "entcore"; +import { Tree } from "entcore/types/src/ts/workspace/model"; +import { Subscription } from "rxjs"; +import { models } from "../../services"; +import { EmptyTrashModel } from "./components/folder/emptyTrash.component"; +import { FolderCreationModel } from "./components/folder/folderManager.component"; +import { DocumentsType } from "./enums/documentsType.enum"; +import { Draggable } from "./models/nextcloudDraggable.model"; +import { SyncDocument } from "./models/nextcloudFolder.model"; +import { UserNextcloud } from "./models/nextcloudUser.model"; +import { INextcloudEventService } from "./services/nextcloudEvent.service"; +import { INextcloudUserService } from "./services/nextcloudUser.service"; +import { INextcloudService } from "./services/nextcloud.service"; +import { NextcloudDocumentsUtils } from "./utils/nextcloudDocuments.utils"; +import { safeApply } from "./utils/safeApply.utils"; +import { WorkspaceEntcoreUtils } from "./utils/workspaceEntcore.utils"; + +export interface INextcloudFolderScope { + documents: Array; + userInfo: UserNextcloud; + folderTree: FolderTreeProps; + selectedFolder: models.Element; + openedFolder: Array; + droppable: Draggable; + dragOverEventListeners: Map; + dragLeaveEventListeners: Map; + + initTree(folder: Array): void; + watchFolderState(): void; + openDocument(folder: any): Promise; + setSwitchDisplayHandler(): void; + // drag & drop actions + initDraggable(): void; + resolveDragTarget(event: DragEvent): Promise; + removeSelectedDocuments(): void; + addDragEventListeners(): void; + removeDragEventListeners(): void; + addDragOverlays(): void; + removeDragOverlays(): void; + addDragFeedback(): void; + removeDragFeedback(): void; + folderCreation: FolderCreationModel; + + isTrashbinOpen: boolean; + emptyTrashbin: EmptyTrashModel; +} + +export const workspaceNextcloudFolderController = ng.controller( + "NextcloudFolderController", + [ + "$scope", + "NextcloudService", + "NextcloudUserService", + "NextcloudEventService", + ( + $scope: INextcloudFolderScope, + nextcloudService: INextcloudService, + nextcloudUserService: INextcloudUserService, + nextcloudEventService: INextcloudEventService, + ) => { + $scope.userInfo = null; + $scope.documents = []; + $scope.folderTree = {}; + $scope.selectedFolder = null; + $scope.openedFolder = []; + $scope.dragOverEventListeners = new Map(); + $scope.dragLeaveEventListeners = new Map(); + $scope.folderCreation = new FolderCreationModel($scope); + $scope.emptyTrashbin = new EmptyTrashModel($scope); + $scope.isTrashbinOpen = false; + + let subscriptions: Subscription = new Subscription(); + + // Resolve user has the following actions: + // 1. Fetch user info + // 1.a) If user exists, fetch user info + // 1.b) If user does not exist, it will create its nextcloud user + nextcloudUserService + .resolveUser(model.me.userId) + .then((user) => { + nextcloudUserService + .getUserInfo(model.me.userId) + .then(async (nextcloudUserInfo: UserNextcloud) => { + $scope.userInfo = nextcloudUserInfo; + $scope.documents = [new SyncDocument().initParent()]; + $scope.initTree($scope.documents); + $scope.initDraggable(); + + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get("folder") === "synced") { + await $scope.openDocument($scope.documents[0]); + await $scope.folderTree.openFolder($scope.documents[0]); + $scope.setSwitchDisplayHandler(); + template.open("documents", "nextcloud/content/workspace-nextcloud-content"); + WorkspaceEntcoreUtils.toggleWorkspaceContentDisplay(false); + } + + safeApply($scope); + }) + .catch((err: Error) => { + const message: string = + "Error while attempting to fetch user info"; + console.error(message + err.message); + }); + }) + .catch((err: Error) => { + const message: string = "Error while attempting to resolve user"; + console.error(message + err.message); + }); + + // on receive openFolder event + subscriptions.add( + nextcloudEventService + .getOpenedFolderDocument() + .subscribe((document: SyncDocument) => { + let getFolderContext: SyncDocument = $scope.folderTree.trees.find( + (f) => f.fileId === document.fileId, + ); + $scope.folderTree.openFolder( + getFolderContext ? getFolderContext : document, + ); + }), + ); + + $scope.initTree = (folder: Array): void => { + // use this const to make it accessible to its folderTree inner context + const viewModel: INextcloudFolderScope = $scope; + + // move nextcloud tree under workspace tree + const nextcloudElement: HTMLElement = document.querySelector( + '[application="nextcloud"]', + ).parentElement; + if (nextcloudElement) { + nextcloudElement.parentNode.appendChild(nextcloudElement); + } + + // we create all the static folders + const staticFolders: Array = [ + SyncDocument.createStaticFolder("trashbin"), + ]; + + // then we add them to the folder tree + folder.push(...staticFolders); + + $scope.folderTree = { + cssTree: "folders-tree", + get trees(): any | Array { + return folder; + }, + isDisabled(folder: models.Element): boolean { + return false; + }, + isOpenedFolder(folder: models.Element): boolean { + return viewModel.openedFolder.some( + (openFolder: models.Element) => openFolder === folder, + ); + }, + isSelectedFolder(folder: models.Element): boolean { + return viewModel.selectedFolder === folder; + }, + async openFolder(folder: models.Element): Promise { + viewModel.selectedFolder = folder; + viewModel.setSwitchDisplayHandler(); + // create handler in case icon are only clicked + viewModel.watchFolderState(); + + if ( + !viewModel.openedFolder.some( + (openFolder: models.Element) => openFolder === folder, + ) + ) { + viewModel.openedFolder = viewModel.openedFolder.filter( + (e: models.Element) => (e).path != (folder).path, + ); + viewModel.openedFolder.push(folder); + } + + await viewModel.openDocument(folder); + + // reset drag feedback by security + viewModel.removeDragFeedback(); + // init drag over + viewModel.addDragFeedback(); + }, + }; + }; + + $scope.initDraggable = (): void => { + const viewModel: INextcloudFolderScope = $scope; + $scope.droppable = { + dragConditionHandler(event: DragEvent, content?: any): boolean { + return false; + }, + async dragDropHandler(event: DragEvent): Promise { + await viewModel.resolveDragTarget(event); + }, + dragEndHandler(event: DragEvent, content?: any): void { + }, + dragStartHandler(event: DragEvent, content?: any): void { + }, + dropConditionHandler(event: DragEvent, content?: any): boolean { + return false; + }, + }; + }; + + function removeDropTarget(event: DragEvent) { + const target: HTMLElement = event.target as HTMLElement; + const droppableElement: HTMLElement = target.closest('.folder-list-item') || target; + if (droppableElement) { + droppableElement.classList.remove("droptarget"); + } + } + + $scope.resolveDragTarget = async (event: DragEvent): Promise => { + removeDropTarget(event); + // case drop concerns nextcloud + if (nextcloudEventService.getContentContext()) { + //nextcloud context + } else { + // case drop concerns workspace but we need extra check + const document: any = JSON.parse( + event.dataTransfer.getData("application/json"), + ); + // check if it s a workspace document with its identifier and format file to proceed move to nextcloud + if ( + document && + ((document._id && document.eType === DocumentsType.FILE) || + document.eType === DocumentsType.FOLDER) + ) { + if ( + angular.element(event.target).scope().folder instanceof + SyncDocument + ) { + const syncedDocument: SyncDocument = angular + .element(event.target) + .scope().folder; + let selectedDocuments: Array = + WorkspaceEntcoreUtils.workspaceScope()["documentList"][ + "_documents" + ]; + selectedDocuments = selectedDocuments.concat( + WorkspaceEntcoreUtils.workspaceScope()["currentTree"][ + "children" + ], + ); + let documentToUpdate: Set = new Set( + selectedDocuments + .filter((file: Document) => file.selected) + .map((file: Document) => file._id), + ); + documentToUpdate.add(document._id); + nextcloudService + .moveDocumentWorkspaceToCloud( + model.me.userId, + Array.from(documentToUpdate), + syncedDocument.path, + ) + .then((_: any) => { + WorkspaceEntcoreUtils.updateWorkspaceDocuments( + WorkspaceEntcoreUtils.workspaceScope()["openedFolder"][ + "folder" + ], + ); + nextcloudEventService.sendOpenFolderDocument( + angular.element(event.target).scope().folder, + ); + $scope.selectedFolder = null; + angular + .element(event.target) + .scope() + .folder.classList.remove("selected"); + }) + .catch((err: Error) => { + const message: string = + "Error while attempting to fetch documents children "; + console.error(message + err.message); + }); + } + } + } + }; + + $scope.watchFolderState = (): void => { + // Get all folder tree arrow icons using vanilla JS + const folderArrows = document.querySelectorAll( + "#nextcloud-folder-tree i", + ); + + // Remove existing event listeners + folderArrows.forEach((element) => { + element.removeEventListener("click", onClickFolder($scope)); + }); + + // Use this const to make it accessible to its callback + const viewModel: INextcloudFolderScope = $scope; + + // Add new click event listeners to each folder arrow + folderArrows.forEach((element) => { + element.addEventListener("click", onClickFolder(viewModel)); + }); + }; + + $scope.openDocument = async (document: any): Promise => { + if ((document).isStaticFolder) { + const staticType: string = (document).staticFolderType; + let staticDocuments: Array = []; + + switch (staticType) { + case "trashbin": + $scope.isTrashbinOpen = true; + const trashList = await nextcloudService + .listTrash(model.me.userId) + .catch((err: Error) => { + const message: string = "Error while attempting to fetch "; + console.error(message + err.message); + return []; + }); + staticDocuments = trashList; + } + + $scope.documents = staticDocuments; + nextcloudEventService.sendDocuments({ + parentDocument: document, + documents: staticDocuments, + }); + safeApply($scope); + return; + } + + $scope.isTrashbinOpen = false; + + let syncDocuments: Array = await nextcloudService + .listDocument(model.me.userId, document.path ? document.path : null) + .catch((err: Error) => { + const message: string = + "Error while attempting to fetch documents children "; + console.error(message + err.message); + return []; + }); + // first filter applies only when we happen to fetch its own folder and the second applies on document only + document.children = syncDocuments + .filter(NextcloudDocumentsUtils.filterRemoveOwnDocument(document)) + .filter(NextcloudDocumentsUtils.filterDocumentOnly()); + safeApply($scope); + nextcloudEventService.sendDocuments({ + parentDocument: document.path + ? document + : new SyncDocument().initParent(), + documents: syncDocuments.filter( + NextcloudDocumentsUtils.filterRemoveOwnDocument(document), + ), + }); + }; + + $scope.setSwitchDisplayHandler = (): void => { + const viewModel: INextcloudFolderScope = $scope; + + // case nextcloud folder tree is interacted + // checking if listener does not exist in order to create one + const nextcloudFolder = document.querySelector( + "#nextcloud-folder-tree", + ); + if (nextcloudFolder) { + // Remove old event listener if exists + const oldHandler = nextcloudFolder["workspaceNextcloudHandler"]; + if (oldHandler) { + nextcloudFolder.removeEventListener("click", oldHandler); + } + + // Create and store new handler + const newHandler = switchWorkspaceTreeHandler(); + nextcloudFolder["workspaceNextcloudHandler"] = newHandler; + nextcloudFolder.addEventListener("click", newHandler); + } + + // case entcore workspace folder tree is interacted + // we unbind its handler and rebind it in order to keep our list of workspace updated + const workspaceTree = document.querySelector( + WorkspaceEntcoreUtils.$ENTCORE_WORKSPACE, + ); + if (workspaceTree) { + // Remove old event listener if exists + const oldHandler = workspaceTree["nextcloudHandler"]; + if (oldHandler) { + workspaceTree.removeEventListener("click", oldHandler); + } + + // Create and store new handler + const newHandler = switchNextcloudTreeHandler(viewModel); + workspaceTree["nextcloudHandler"] = newHandler; + workspaceTree.addEventListener("click", newHandler); + } + }; + + $scope.removeSelectedDocuments = (): void => { + let selectedDocuments: Array = + WorkspaceEntcoreUtils.workspaceScope()["openedFolder"]["documents"]; + let folders: Array = + WorkspaceEntcoreUtils.workspaceScope()["openedFolder"]["folders"]; + if (selectedDocuments != null && folders != null) { + selectedDocuments.forEach((doc: Document) => (doc.selected = false)); + folders.forEach((fol: Document) => (fol.selected = false)); + } + }; + + $scope.addDragEventListeners = (): void => { + const folders: HTMLElement[] = Array.from( + document.getElementsByTagName("folder-tree-inner"), + ) as HTMLElement[]; + folders.forEach((element: HTMLElement) => { + element.addEventListener("dragover", onDragOver(element)); + element.addEventListener("dragleave", onDragLeave(element)); + + $scope.dragOverEventListeners.set(element, onDragOver(element)); + $scope.dragLeaveEventListeners.set(element, onDragLeave(element)); + }); + }; + + $scope.addDragOverlays = (): void => { + const folders: HTMLElement[] = Array.from( + document.getElementsByTagName("folder-tree-inner"), + ) as HTMLElement[]; + folders.forEach((element: HTMLElement, i: number) => { + const span: HTMLElement = document.createElement("span"); + span.id = "droptarget-" + i; + span.className = "highlight-title highlight-title-border ng-scope"; + const subSpan: HTMLElement = document.createElement("span"); + subSpan.className = "count-badge ng-binding"; + span.appendChild(subSpan); + + const ul: Element = element.lastElementChild; + if (ul.tagName === "UL") { + element.insertBefore(span, ul); + } else { + element.appendChild(span); + } + element.style.position = "relative"; + element.style.display = "block"; + }); + }; + + $scope.removeDragOverlays = (): void => { + const spans: HTMLElement[] = Array.from( + document.querySelectorAll(`[id^="droptarget-"]`), + ) as HTMLElement[]; + spans.forEach((element: HTMLElement) => { + element.remove(); + }); + }; + + $scope.removeDragEventListeners = (): void => { + $scope.dragOverEventListeners.forEach( + (listener: EventListener, element: HTMLElement) => + element.removeEventListener("dragover", onDragOver(element)), + ); + $scope.dragOverEventListeners.clear(); + $scope.dragLeaveEventListeners.forEach( + (listener: EventListener, element: HTMLElement) => + element.removeEventListener("dragleave", onDragLeave(element)), + ); + $scope.dragLeaveEventListeners.clear(); + }; + + $scope.addDragFeedback = (): void => { + $scope.addDragOverlays(); + $scope.addDragEventListeners(); + }; + + $scope.removeDragFeedback = (): void => { + $scope.removeDragOverlays(); + $scope.removeDragEventListeners(); + }; + + function onDragLeave(element: HTMLElement): EventListener { + return function (event: Event): void { + event.preventDefault(); + event.stopPropagation(); + element.firstElementChild.classList.remove("droptarget"); + }; + } + + function onDragOver(element: HTMLElement): EventListener { + return function (event: Event): void { + event.preventDefault(); + event.stopPropagation(); + element.firstElementChild.classList.add("droptarget"); + }; + } + + function switchWorkspaceTreeHandler() { + const viewModel: INextcloudFolderScope = $scope; + return function (): void { + if (!viewModel.selectedFolder) { + viewModel.folderTree.openFolder(viewModel.documents[0]); + } + + const workspaceFolderTree = document.querySelectorAll( + WorkspaceEntcoreUtils.$ENTCORE_WORKSPACE + " li a", + ); + // using nextcloud content display + template.open( + "documents", + `nextcloud/content/workspace-nextcloud-content`, + ); + + viewModel.removeSelectedDocuments(); + + // clear all potential "selected" class workspace folder tree + workspaceFolderTree.forEach((element: Element): void => { + element.classList.remove("selected"); + }); + + // hide workspace contents (search bar, menu, list of folder/files...) interactions + WorkspaceEntcoreUtils.toggleWorkspaceContentDisplay(false); + }; + } + + function switchNextcloudTreeHandler(viewModel: INextcloudFolderScope) { + return function (): void { + let element: Element = arguments[0].target; + let target: Element; + if (element && element.tagName === "A") { + target = element; + } else if ( + element && + element.parentElement && + element.parentElement.tagName === "A" + ) { + target = element.parentElement; + } + + if (target && viewModel.selectedFolder) { + // go back to workspace content display + // clear nextCloudTree interaction + viewModel.selectedFolder = null; + target.classList.add("selected"); + // update workspace folder content + WorkspaceEntcoreUtils.updateWorkspaceDocuments( + angular.element(target).scope().folder, + ); + //set the right openedFolder + WorkspaceEntcoreUtils.workspaceScope()["openedFolder"]["folder"] = + angular.element(target).scope().folder; + // display workspace contents (search bar, menu, list of folder/files...) interactions + WorkspaceEntcoreUtils.toggleWorkspaceContentDisplay(true); + // remove any content context cache + nextcloudEventService.setContentContext(null); + template.open("documents", `icons`); + } + }; + } + + function onClickFolder(viewModel: INextcloudFolderScope) { + return function () { + event.stopPropagation(); + const scope: any = angular.element(arguments[0].target).scope(); + const folder: models.Element = scope.folder; + if ( + viewModel.openedFolder.some( + (openFolder: models.Element) => openFolder === folder, + ) + ) { + viewModel.openedFolder = viewModel.openedFolder.filter( + (openedFolder: models.Element) => openedFolder !== folder, + ); + } else { + viewModel.openedFolder.push(folder); + } + safeApply(scope); + }; + } + }, + ], +); + +export const workspaceNextcloudFolder = ng.directive( + "workspaceNextcloudFolder", + () => { + return { + restrict: "E", + templateUrl: + "/workspace/public/template/nextcloud/folder/workspace-nextcloud-folder.html", + controller: "NextcloudFolderController", + }; + }, +); diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloud.preferences.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloud.preferences.ts new file mode 100644 index 0000000000..279149e23a --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloud.preferences.ts @@ -0,0 +1,55 @@ +import { Me, notify } from "entcore"; +import { ViewMode } from "../enums/viewMode.enum"; + +export type NextcloudPreference = { + viewMode: ViewMode; +}; + +export class Preference { + private _viewMode: ViewMode; + + get viewMode(): ViewMode { + return this._viewMode; + } + + set viewMode(viewMode: ViewMode) { + this._viewMode = viewMode; + } + + async init(): Promise { + try { + // fetch nextcloud preference from Me + let preference = await Me.preference("nextcloud"); + // Check on nextcloud preferences AND nextcloud viewMode preferences + if (this.isEmpty(preference) || !preference.viewMode) { + // If no view mode, we set it with default value + preference.viewMode = ViewMode.ICONS; + // persist for the first time my nextcloud preference + await this.updatePreference(preference); + } + this.setProperties(preference); + } catch (e) { + notify.error("nextcloud.preferences.init.error"); + throw e; + } + } + + async updatePreference(preference: NextcloudPreference): Promise { + Me.preferences.nextcloud = preference; + try { + await Me.savePreference("nextcloud"); + this.setProperties(preference); + } catch (e) { + notify.error("nextcloud.preferences.updatepreference.error"); + throw e; + } + } + + private setProperties(preference: NextcloudPreference): void { + this._viewMode = preference.viewMode; + } + + private isEmpty(preference: NextcloudPreference): boolean { + return !preference || !Object.keys(preference).length; + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloud.service.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloud.service.ts new file mode 100644 index 0000000000..2819adb936 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloud.service.ts @@ -0,0 +1,307 @@ +import http, { AxiosResponse } from "axios"; +import { ng, workspace } from "entcore"; +import { + IDocumentResponse, + SyncDocument, +} from "../models/nextcloudFolder.model"; +import models = workspace.v2.models; + +export interface INextcloudService { + openNextcloudLink(document: SyncDocument, nextcloudUrl: string): void; + + openNextcloudEditLink(document: SyncDocument, nextcloudUrl: string): void; + + getNextcloudUrl(): Promise; + + getIsNextcloudUrlHidden(): Promise; + + listDocument(userid: string, path?: string): Promise>; + + uploadDocuments( + userid: string, + files: Array, + path?: string, + ): Promise; + + moveDocument( + userid: string, + path: string, + destPath: string, + ): Promise; + + moveDocumentNextcloudToWorkspace( + userid: string, + paths: Array, + parentId?: string, + ): Promise; + + moveDocumentWorkspaceToCloud( + userid: string, + ids: Array, + cloudDocumentName?: string, + ): Promise; + + copyDocumentToWorkspace( + userid: string, + paths: Array, + parentId?: string, + ): Promise>; + + deleteDocuments(userid: string, path: Array): Promise; + + deleteTrashDocuments( + userid: string, + path: Array, + ): Promise; + + restoreDocument(userid: string, paths: Array): Promise; + + deleteTrash(userid: string): Promise; + + getFile( + userid: string, + fileName: string, + path: string, + contentType: string, + isFolder?: boolean, + ): string; + + getFiles(userid: string, path: string, files: Array): string; + + createFolder(userid: string, folderPath: String): Promise; + + listTrash(userid: string): Promise>; +} + +export const nextcloudService: INextcloudService = { + openNextcloudLink: (document: SyncDocument, nextcloudUrl: string): void => { + const url: string = document.path.includes("/") + ? document.path.substring(0, document.path.lastIndexOf("/")) + : ""; + const dir: string = url ? url : "/"; + window.open( + `${nextcloudUrl}/index.php/apps/files?dir=${dir}&openfile=${document.fileId}`, + ); + }, + + openNextcloudEditLink: ( + document: SyncDocument, + nextcloudUrl: string, + ): void => { + const url: string = `${nextcloudUrl}/index.php/apps/onlyoffice/${document.fileId}`; + window.open(url); + }, + + getNextcloudUrl: async (): Promise => { + return http + .get(`/nextcloud/config/url`) + .then((res: AxiosResponse) => res.data.url); + }, + + getIsNextcloudUrlHidden: async (): Promise => { + return http + .get(`/nextcloud/config/isNextcloudUrlHidden`) + .then((res: AxiosResponse) => res.data.isNextcloudUrlHidden); + }, + + createFolder: async ( + userid: string, + folderPath: String, + ): Promise => { + const urlParam: string = folderPath ? `?path=${folderPath}` : ""; + return http.post( + `/nextcloud/files/user/${userid}/create/folder${urlParam}`, + ); + }, + + listDocument: async ( + userid: string, + path?: string, + ): Promise> => { + const urlParam: string = path ? `?path=${path}` : ""; + return http + .get(`/nextcloud/files/user/${userid}${urlParam}`) + .then((res: AxiosResponse) => + res.data.data.map((document: IDocumentResponse) => + new SyncDocument().build(document), + ), + ); + }, + + uploadDocuments( + userid: string, + files: Array, + path?: string, + ): Promise { + const urlParam: string = path ? `?path=${path}` : ""; + const formData: FormData = new FormData(); + const headers = { + headers: { + "Content-type": "multipart/form-data", + "File-Count": files.length, + }, + }; + files.forEach((file) => { + formData.append("fileToUpload[]", file); + }); + // @ts-ignore + return http.put( + `/nextcloud/files/user/${userid}/upload${urlParam}`, + formData, + headers, + ); + }, + + moveDocument: ( + userid: string, + path: string, + destPath: string, + ): Promise => { + const urlParam: string = `?path=${path}&destPath=${destPath}`; + // @ts-ignore + return http.put(`/nextcloud/files/user/${userid}/move${urlParam}`); + }, + + moveDocumentNextcloudToWorkspace: ( + userid: string, + paths: Array, + parentId?: string, + ): Promise => { + let urlParams: URLSearchParams = new URLSearchParams(); + paths.forEach((path: string) => urlParams.append("path", path)); + const parentIdParam: string = parentId ? `&parentId=${parentId}` : ""; + // @ts-ignore + return http + .put( + `/nextcloud/files/user/${userid}/move/workspace?${urlParams}${parentIdParam}`, + ) + .then((res: AxiosResponse) => + res.data.data + .filter((document) => document._id) + .map((document) => new models.Element(document)), + ); + }, + + moveDocumentWorkspaceToCloud: ( + userid: string, + ids: Array, + cloudDocumentName?: string, + ): Promise => { + let urlParams: URLSearchParams = new URLSearchParams(); + ids.forEach((path: string) => urlParams.append("id", path)); + const parentDocumentNameParam: string = cloudDocumentName + ? `&parentName=${cloudDocumentName}` + : ""; + // @ts-ignore + return http.put( + `/nextcloud/files/user/${userid}/workspace/move/cloud?${urlParams}${parentDocumentNameParam}`, + ); + }, + + copyDocumentToWorkspace( + userid: string, + paths: Array, + parentId?: string, + ): Promise> { + let urlParams: URLSearchParams = new URLSearchParams(); + paths.forEach((path: string) => urlParams.append("path", path)); + const parentIdParam: string = parentId ? `&parentId=${parentId}` : ""; + // @ts-ignore + return http + .put( + `/nextcloud/files/user/${userid}/copy/workspace?${urlParams}${parentIdParam}`, + ) + .then((res: AxiosResponse) => + res.data.data + .filter((document) => document._id) + .map((document) => new models.Element(document)), + ); + }, + + deleteDocuments( + userid: string, + paths: Array, + ): Promise { + let urlParams: URLSearchParams = new URLSearchParams(); + paths.forEach((path: string) => { + urlParams.append("path", path); + }); + // @ts-ignore + return http.delete(`/nextcloud/files/user/${userid}/delete?${urlParams}`); + }, + + restoreDocument: ( + userid: string, + paths: Array, + ): Promise => { + let urlParams: URLSearchParams = new URLSearchParams(); + paths.forEach((path: string) => { + urlParams.append("path", path); + }); + // @ts-ignore + return http.put(`/nextcloud/files/user/${userid}/restore?${urlParams}`); + }, + + listTrash: async (userid: string): Promise> => { + return http + .get(`/nextcloud/files/user/${userid}/trash`) + .then((res: AxiosResponse) => + res.data.map((document: IDocumentResponse) => + new SyncDocument().build(document), + ), + ); + }, + + deleteTrash(userid: string): Promise { + // @ts-ignore + return http.delete(`/nextcloud/files/user/${userid}/trash/delete`); + }, + + deleteTrashDocuments( + userid: string, + paths: Array, + ): Promise { + let urlParams: URLSearchParams = new URLSearchParams(); + paths.forEach((path: string) => { + urlParams.append("path", path); + }); + // @ts-ignore + return http.delete( + `/nextcloud/files/user/${userid}/trash/delete-documents?${urlParams}`, + ); + }, + + getFile: ( + userid: string, + fileName: string, + path: string, + contentType: string, + isFolder: boolean = false, + ): string => { + const pathParam: string = path ? `?path=${path}` : ""; + const contentTypeParam: string = + path && contentType ? `&contentType=${contentType}` : ""; + const isFolderParam: string = pathParam + ? `&isFolder=${isFolder}` + : `?isFolder=${isFolder}`; + const urlParam: string = `${pathParam}${contentTypeParam}${isFolderParam}`; + return `/nextcloud/files/user/${userid}/file/${encodeURI( + fileName, + )}/download${urlParam}`; + }, + + getFiles: (userid: string, path: string, files: Array): string => { + const pathParam: string = `?path=${path}`; + let filesParam: string = ""; + files.forEach((file: string) => { + filesParam += `&file=${file}`; + }); + const urlParam: string = `${pathParam}${filesParam}`; + return `/nextcloud/files/user/${userid}/multiple/download${urlParam}`; + }, +}; + +export const NextcloudService = ng.service( + "NextcloudService", + (): INextcloudService => nextcloudService, +); diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloudEvent.service.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloudEvent.service.ts new file mode 100644 index 0000000000..59930b0045 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloudEvent.service.ts @@ -0,0 +1,56 @@ +import { ng } from "entcore"; +import { Observable, Subject } from "rxjs"; +import { SyncDocument } from "../models/nextcloudFolder.model"; + +export interface INextcloudEventService { + sendDocuments(documents: { + parentDocument: SyncDocument; + documents: Array; + }): void; + getDocumentsState(): Observable<{ + parentDocument: SyncDocument; + documents: Array; + }>; + sendOpenFolderDocument(document: SyncDocument): void; + getOpenedFolderDocument(): Observable; + getContentContext(): SyncDocument; + setContentContext(content: SyncDocument): void; +} + +const openFolderSubject = new Subject(); +const documentSubject = new Subject<{ + parentDocument: SyncDocument; + documents: Array; +}>(); +let contentContext: SyncDocument = null; + +export const nextcloudEventService: INextcloudEventService = { + sendDocuments: (documents) => { + documentSubject.next(documents); + }, + + getDocumentsState: () => { + return documentSubject.asObservable(); + }, + + sendOpenFolderDocument: (document) => { + openFolderSubject.next(document); + }, + + getOpenedFolderDocument: () => { + return openFolderSubject.asObservable(); + }, + + getContentContext: () => { + return contentContext; + }, + + setContentContext: (content) => { + contentContext = content; + }, +}; + +export const NextcloudEventService = ng.service( + "NextcloudEventService", + (): INextcloudEventService => nextcloudEventService, +); diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloudUser.service.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloudUser.service.ts new file mode 100644 index 0000000000..d73529f410 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/services/nextcloudUser.service.ts @@ -0,0 +1,28 @@ +import http, { AxiosResponse } from "axios"; +import { ng } from "entcore"; +import { UserNextcloud } from "../models/nextcloudUser.model"; + +export interface INextcloudUserService { + resolveUser(userid: string): Promise; + + getUserInfo(userid: string): Promise; +} + +export const nextcloudUserService: INextcloudUserService = { + resolveUser: async (userid: string): Promise => { + return http.get(decodeURI(`/nextcloud/user/${userid}/provide/token`)); + }, + + getUserInfo: async (userid: string): Promise => { + return http + .get(`/nextcloud/user/${userid}`) + .then((response: AxiosResponse) => + new UserNextcloud().build(response.data), + ); + }, +}; + +export const NextcloudUserService = ng.service( + "NextcloudUserService", + (): INextcloudUserService => nextcloudUserService, +); diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/utils/date.utils.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/date.utils.ts new file mode 100644 index 0000000000..ea685e9823 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/date.utils.ts @@ -0,0 +1,12 @@ +import { moment } from "entcore"; + +export class DateUtils { + /** + * Format date based on given format using moment + * @param date date to format + * @param format format + */ + static format(date: any, format: string) { + return moment(date).format(format); + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/utils/nextcloudDocuments.utils.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/nextcloudDocuments.utils.ts new file mode 100644 index 0000000000..d364d279bf --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/nextcloudDocuments.utils.ts @@ -0,0 +1,40 @@ +import { model } from "entcore"; +import { DocumentRole } from "../enums/documentRole.enum"; +import { SyncDocument } from "../models/nextcloudFolder.model"; + +export class NextcloudDocumentsUtils { + static determineRole(contentType: string): DocumentRole { + for (let role in DocumentRole) { + if (contentType.includes(DocumentRole[role])) { + return DocumentRole[role]; + } + } + return DocumentRole.UNKNOWN; + } + + static filterRemoveNameFile(): (syncDocument: SyncDocument) => boolean { + return (syncDocument: SyncDocument) => + syncDocument.name !== model.me.userId; + } + + static filterDocumentOnly(): (syncDocument: SyncDocument) => boolean { + return (syncDocument: SyncDocument) => + syncDocument.isFolder && syncDocument.name != model.me.userId; + } + + static filterFilesOnly(): (syncDocument: SyncDocument) => boolean { + return (syncDocument: SyncDocument) => + !syncDocument.isFolder && syncDocument.name != model.me.userId; + } + + static filterRemoveOwnDocument( + document: SyncDocument, + ): (syncDocument: SyncDocument) => boolean { + return (syncDocument: SyncDocument) => syncDocument.path !== document.path; + } + + static getExtension(filename: string): string { + let words: Array = filename.split("."); + return words[words.length - 1]; + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/utils/safeApply.utils.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/safeApply.utils.ts new file mode 100644 index 0000000000..cb7799f52c --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/safeApply.utils.ts @@ -0,0 +1,6 @@ +export function safeApply($scope: any) { + let phase = $scope.$root.$$phase; + if (phase !== "$apply" && phase !== "$digest") { + $scope.$apply(); + } +} diff --git a/workspace/src/main/resources/public/ts/directives/nextcloud/utils/workspaceEntcore.utils.ts b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/workspaceEntcore.utils.ts new file mode 100644 index 0000000000..5f01c89eeb --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/nextcloud/utils/workspaceEntcore.utils.ts @@ -0,0 +1,114 @@ +import { angular, Document, model, workspace } from "entcore"; +import { WorkspaceEvent } from "entcore/types/src/ts/workspace/services"; +import { models } from "../../../services"; +import { SyncDocument } from "../models/nextcloudFolder.model"; +import { NextcloudDocumentsUtils } from "./nextcloudDocuments.utils"; +import ng = require("angular"); + +export class WorkspaceEntcoreUtils { + static $ENTCORE_WORKSPACE: string = `div[data-ng-include="'folder-content'"]`; + + /** + * Will fetch Element type component and its div to toggle hide or show depending on state + * Format date based on given format using moment + * @param state boolean determine display default or none + */ + static toggleProgressBarDisplay(state: boolean): void { + const htmlQuery: string = ".mobile-navigation > div.row"; + (document.querySelector(htmlQuery)).style.display = state + ? "block" + : "none"; + } + + /** + * Will fetch all buttons in workspace folder its div to toggle hide or show depending on state + * @param state boolean determine display default or none + */ + static toggleWorkspaceButtonsDisplay(state: boolean): void { + const htmlQuery: string = `.mobile-navigation > a, .zero-mobile > div, sniplet[application="lool"`; + Array.from(document.querySelectorAll(htmlQuery)).forEach( + (elem: Element) => + ((elem).style.display = state ? "block" : "none"), + ); + } + + /** + * Will fetch all buttons in workspace folder its div to toggle hide or show depending on state + * @param state boolean determine display default or none + */ + static toggleWorkspaceContentDisplay(state: boolean): void { + const searchImportViewQuery: string = + "section .margin-four > h3, section .margin-four > nav > div.row"; + Array.from(document.querySelectorAll(searchImportViewQuery)).forEach( + (elem: Element) => + ((elem).style.display = state ? "block" : "none"), + ); + + const contentEmptyScreenQuery: string = + "div .toggle-buttons-spacer .emptyscreen"; + Array.from(document.querySelectorAll(contentEmptyScreenQuery)).forEach( + (elem: Element) => + ((elem).style.display = state ? "flex" : "none"), + ); + + const rightMagnetQuery: string = "app-title.twelve div.right-magnet"; + Array.from(document.querySelectorAll(rightMagnetQuery)).forEach( + (elem: Element) => + ((elem).style.display = state ? "block" : "none"), + ); + } + + /** + * Fetch workspace controller scope + */ + static workspaceScope(): ng.IScope { + return angular + .element(document.getElementsByClassName("workspace-app")) + .scope(); + } + + /** + * Update fetch folder content via workspace controller + * @param folder folder from workspace controller + */ + static updateWorkspaceDocuments(folder: any | models.Element): void { + if (folder && folder instanceof models.Element) { + //The root folder is treated differently because it contains all the tree of files and folders, and we can't apply + // the same treatment as if it was a classic folder. + //As it is not a folder, it does not contain the eType attribute, so we have to simulate it by adding the eType attribute, + // and make the refresh happen on this root folder. + if ("tree" in folder) { + folder.eType = "folder"; + } + const event: WorkspaceEvent = { + action: "tree-change", + elements: [folder], + }; + workspace.v2.service.onChange.next(event); + } + } + + static toDocuments(syncDocuments: Array): Array { + let formattedDocuments: Array = []; + syncDocuments.forEach((syncDoc: SyncDocument) => { + let elementObj: any = { + name: syncDoc.name, + comments: "", + metadata: { + "content-type": syncDoc.contentType, + role: syncDoc.role, + extension: NextcloudDocumentsUtils.getExtension(syncDoc.name), + filename: syncDoc.name, + size: syncDoc.size, + }, + owner: model.me.userId, + ownerName: syncDoc.ownerDisplayName, + path: syncDoc.path, + }; + let newElement: Document = new Document(elementObj); + newElement.application = "nextcloud"; + formattedDocuments.push(newElement); + }); + return formattedDocuments; + } +} diff --git a/workspace/src/main/resources/public/ts/directives/syncDocumentViewer.ts b/workspace/src/main/resources/public/ts/directives/syncDocumentViewer.ts new file mode 100644 index 0000000000..609107dd87 --- /dev/null +++ b/workspace/src/main/resources/public/ts/directives/syncDocumentViewer.ts @@ -0,0 +1,358 @@ +import { ng, model } from "entcore"; +import { CsvDelegate, CsvFile, CsvController } from "./csvViewer"; +import { SyncDocument } from "./nextcloud/models/nextcloudFolder.model"; +import { nextcloudService } from "./nextcloud/services/nextcloud.service"; +import { TxtDelegate, TxtController, TxtFile } from "./txtViewer"; +import http from "axios"; + +interface SyncDocumentViewerScope { + contentType: string; + ngModel: SyncDocument; + isFullscreen: boolean; + csvDelegate: CsvDelegate; + txtDelegate: TxtDelegate; + isTxt(): boolean; + isStreamable(): boolean; + download(): void; + isOfficePdf(): boolean; + isOfficeExcelOrCsv(): boolean; + editImage(): void; + fullscreen(allow: boolean): void; + render?: () => void; + previewUrl(): string; + closeViewFile(): void; + canEditInLool(): boolean; + canEditInScratch(): boolean; + openOnLool(): void; + openOnScratch(): void; + openOnGeogebra(): void; + getFileUrl(): string; + canEditImage(): boolean; + hasStreamingCapability(): boolean; + canDownload(): boolean; + $parent: { + display: { + editedImage: any; + editImage: boolean; + }; + }; + onBack: any; + htmlContent: string; + $apply: any; +} + +function getContentTypeFromSyncDocument(syncDoc: SyncDocument): string { + if (!syncDoc.contentType) { + return "unknown"; + } + + const contentType = syncDoc.contentType.toLowerCase(); + const extension = syncDoc.extension || syncDoc.name.split(".").pop()?.toLowerCase(); + + // Map content types to viewer types + if (contentType.startsWith("image/")) return "img"; + if (contentType.startsWith("video/")) return "video"; + if (contentType.startsWith("audio/")) return "audio"; + if (contentType === "application/pdf") return "pdf"; + if (contentType === "text/html") return "html"; + if (contentType === "text/csv" || extension === "csv") return "csv"; + if (contentType === "text/plain" || extension === "txt") return "txt"; + if (contentType === "application/zip") return "zip"; + + // Check by extension for office documents + if (extension) { + switch (extension) { + case "doc": + case "docx": + return "doc"; + case "ppt": + case "pptx": + return "ppt"; + case "xls": + case "xlsx": + return "xls"; + default: + return "unknown"; + } + } + + return "unknown"; +} + +function createElement(html: string): HTMLElement { + const template = document.createElement("template"); + template.innerHTML = html.trim(); + return template.content.firstChild as HTMLElement; +} + +function fadeIn(element: HTMLElement, duration: number = 400): Promise { + return new Promise((resolve) => { + element.style.opacity = "0"; + element.style.display = "block"; + element.style.transition = `opacity ${duration}ms ease-in-out`; + element.offsetHeight; + element.style.opacity = "1"; + + setTimeout(() => { + element.style.transition = ""; + resolve(); + }, duration); + }); +} + +function fadeOut(element: HTMLElement, duration: number = 400): Promise { + return new Promise((resolve) => { + element.style.transition = `opacity ${duration}ms ease-in-out`; + element.style.opacity = "0"; + + setTimeout(() => { + element.remove(); + resolve(); + }, duration); + }); +} + +class SyncCsvProviderFromText implements CsvFile { + private _cache: Promise; + constructor(private model: SyncDocument) {} + get id() { + return this.model.fileId?.toString() || this.model.path; + } + get content() { + if (this._cache) return this._cache; + this._cache = new Promise(async (resolve, reject) => { + try { + const downloadUrl = nextcloudService.getFile( + model.me.userId, + this.model.name, + this.model.path, + this.model.contentType, + false, + ); + const response = await http.get(downloadUrl); + resolve(response.data); + } catch (error) { + reject(error); + } + }); + return this._cache; + } +} + +class SyncCsvProviderFromExcel implements CsvFile { + private _cache: Promise; + constructor(private model: SyncDocument) {} + get id() { + return this.model.fileId?.toString() || this.model.path; + } + get content() { + if (this._cache) return this._cache; + this._cache = new Promise(async (resolve, reject) => { + try { + const downloadUrl = nextcloudService.getFile( + model.me.userId, + this.model.name, + this.model.path, + this.model.contentType, + false, + ); + const response = await http.get(downloadUrl); + resolve(response.data); + } catch (error) { + reject(error); + } + }); + return this._cache; + } +} + +export const syncDocumentViewer = ng.directive("syncDocumentViewer", [ + "$sce", + ($sce) => { + return { + restrict: "E", + scope: { + ngModel: "=", + onBack: "&", + }, + templateUrl: "/workspace/public/template/directives/sync-document-viewer.html", + link: function (scope: SyncDocumentViewerScope, element, attributes) { + const _csvCache: { [key: string]: CsvFile } = {}; + const _txtCache: { [key: string]: TxtFile } = {}; + let _csvController: CsvController = null; + let _txtDelegate: TxtController = null; + + scope.csvDelegate = { + onInit(ctrl) { + _csvController = ctrl; + _csvController.setContent(getCsvContent()); + }, + }; + scope.txtDelegate = { + onInit(ctrl) { + _txtDelegate = ctrl; + _txtDelegate.setContent(getTxtContent()); + }, + }; + + const getCsvContent = () => { + const cacheKey = scope.ngModel.fileId?.toString() || scope.ngModel.path; + if (_csvCache[cacheKey]) { + return _csvCache[cacheKey]; + } + if (scope.contentType == "csv") { + _csvCache[cacheKey] = new SyncCsvProviderFromText(scope.ngModel); + } else { + _csvCache[cacheKey] = new SyncCsvProviderFromExcel(scope.ngModel); + } + return _csvCache[cacheKey]; + }; + + const getTxtContent = () => { + const cacheKey = scope.ngModel.fileId?.toString() || scope.ngModel.path; + if (_txtCache[cacheKey]) { + return _txtCache[cacheKey]; + } + _txtCache[cacheKey] = new SyncCsvProviderFromText(scope.ngModel); + return _txtCache[cacheKey]; + }; + + scope.contentType = getContentTypeFromSyncDocument(scope.ngModel); + + if (scope.contentType == "html") { + const call = async () => { + try { + const downloadUrl = nextcloudService.getFile( + model.me.userId, + scope.ngModel.name, + scope.ngModel.path, + scope.ngModel.contentType, + false, + ); + const response = await http.get(downloadUrl); + scope.htmlContent = $sce.trustAsHtml(response.data) as string; + scope.$apply(); + } catch (error) { + console.error("Error loading HTML content:", error); + } + }; + call(); + } + + scope.isFullscreen = false; + + scope.download = function () { + const downloadUrl = nextcloudService.getFile( + model.me.userId, + scope.ngModel.name, + scope.ngModel.path, + scope.ngModel.contentType, + false, + ); + window.open(downloadUrl, "_blank"); + }; + + let renderElement: HTMLElement; + let renderParent: HTMLElement; + + scope.canDownload = () => { + return true; + }; + + scope.isOfficePdf = () => { + const ext = ["doc", "ppt"]; + return ext.includes(scope.contentType); + }; + + scope.isOfficeExcelOrCsv = () => { + const ext = ["xls", "csv"]; + return ext.includes(scope.contentType); + }; + + scope.isTxt = () => { + const ext = ["txt"]; + return ext.includes(scope.contentType); + }; + + scope.isStreamable = () => { + return scope.contentType === "video" && scope.ngModel.contentType?.startsWith("video/"); + }; + + scope.closeViewFile = () => { + scope.onBack(); + }; + + scope.previewUrl = () => { + return nextcloudService.getFile( + model.me.userId, + scope.ngModel.name, + scope.ngModel.path, + scope.ngModel.contentType, + false, + ); + }; + + scope.getFileUrl = () => { + return nextcloudService.getFile( + model.me.userId, + scope.ngModel.name, + scope.ngModel.path, + scope.ngModel.contentType, + false, + ); + }; + + scope.hasStreamingCapability = () => { + return false; + }; + + scope.fullscreen = (allow) => { + scope.isFullscreen = allow; + if (allow) { + const container = createElement('
'); + container.style.display = "none"; + + container.addEventListener("click", function (e) { + const target = e.target as HTMLElement; + if (!target.classList.contains("render")) { + scope.fullscreen(false); + scope.$apply("isFullscreen"); + } + }); + + const embeddedViewer = element[0].querySelector(".embedded-viewer"); + renderElement = element[0].querySelector(".render") as HTMLElement; + renderParent = renderElement.parentElement; + + embeddedViewer?.classList.add("fullscreen"); + renderElement.classList.add("fullscreen"); + + container.appendChild(renderElement); + document.body.appendChild(container); + + fadeIn(container); + + if (typeof scope.render === "function") { + scope.render(); + } + } else { + renderElement.classList.remove("fullscreen"); + renderParent.appendChild(renderElement); + + const embeddedViewer = element[0].querySelector(".embedded-viewer"); + embeddedViewer?.classList.remove("fullscreen"); + + const fullscreenViewer = document.querySelector(".fullscreen-viewer") as HTMLElement; + if (fullscreenViewer) { + fadeOut(fullscreenViewer); + } + + if (typeof scope.render === "function") { + scope.render(); + } + } + }; + }, + }; + }, +]); diff --git a/workspace/src/main/resources/view-src/workspace.html b/workspace/src/main/resources/view-src/workspace.html index 50e643d935..2a5954fea9 100644 --- a/workspace/src/main/resources/view-src/workspace.html +++ b/workspace/src/main/resources/view-src/workspace.html @@ -16,6 +16,7 @@ var ENABLE_SCRATCH= {{enableScratch}}; var ENABLE_GGB= {{enableGeogebra}}; var ENABLE_NEXTCLOUD= {{enableNextcloud}}; + var USE_NEXTCLOUD_SNIPLET = {{useNextcloudSniplet}}; var LAZY_MODE = {{lazyMode}} var CACHE_DOC_TTL_SEC = {{cacheDocTTl}} var CACHE_FOLDER_TTL_SEC = {{cacheFolderTtl}} @@ -109,10 +110,6 @@

- -
- -
folder.new folder.new.shared @@ -123,6 +120,22 @@

+
+
+ + + + + +
+
diff --git a/workspace/src/test/java/org/entcore/workspace/DocumentTest.java b/workspace/src/test/java/org/entcore/workspace/DocumentTest.java index eb2bba6d2a..dd1cb46091 100644 --- a/workspace/src/test/java/org/entcore/workspace/DocumentTest.java +++ b/workspace/src/test/java/org/entcore/workspace/DocumentTest.java @@ -76,30 +76,33 @@ public static void setUp(TestContext context) throws Exception { test.database().initNeo4j(context, neo4jContainer); final ShareService shareService = test.share().createMongoShareService(context, DocumentDao.DOCUMENTS_COLLECTION); - final Storage storage = new StorageFactory(test.vertx(), new JsonObject().put("file-system", new JsonObject().put("path", "/tmp")), + StorageFactory.build(test.vertx(), new JsonObject().put("file-system", new JsonObject().put("path", "/tmp")), new MongoDBApplicationStorage(DocumentDao.DOCUMENTS_COLLECTION, Workspace.class.getSimpleName())) - .getStorage(); - final String imageResizerAddress = "wse.image.resizer"; - final FolderManager folderManager = FolderManager.mongoManager(DocumentDao.DOCUMENTS_COLLECTION, storage, - test.vertx(), shareService, imageResizerAddress, false); - final boolean neo4jPlugin = false; - final QuotaService quotaService = new DefaultQuotaService(neo4jPlugin, - new TimelineHelper(test.vertx(), test.vertx().eventBus(), new JsonObject())); - final int threshold = 80; - workspaceService = new DefaultWorkspaceService(storage, MongoDb.getInstance(), threshold, imageResizerAddress, - quotaService, folderManager, test.vertx(), shareService, false); - test.database().initMongo(context, mongoContainer); - final Async async = context.async(); - test.directory().createActiveUser("user1", "password", "email").onComplete(res -> { - context.assertTrue(res.succeeded()); - userid = res.result(); - async.complete(); - }); - final Async async2 = context.async(); - test.directory().createActiveUser(test.http().sessionUser()).onComplete(r -> { - context.assertTrue(r.succeeded()); - async2.complete(); - }); + .onSuccess(storageFactory -> { + Storage storage = storageFactory.getStorage(); + final String imageResizerAddress = "wse.image.resizer"; + final FolderManager folderManager = FolderManager.mongoManager(DocumentDao.DOCUMENTS_COLLECTION, storage, + test.vertx(), shareService, imageResizerAddress, false); + final boolean neo4jPlugin = false; + final QuotaService quotaService = new DefaultQuotaService(neo4jPlugin, + new TimelineHelper(test.vertx(), test.vertx().eventBus(), new JsonObject())); + final int threshold = 80; + workspaceService = new DefaultWorkspaceService(storage, MongoDb.getInstance(), threshold, imageResizerAddress, + quotaService, folderManager, test.vertx(), shareService, false); + test.database().initMongo(context, mongoContainer); + final Async async = context.async(); + test.directory().createActiveUser("user1", "password", "email").onComplete(res -> { + context.assertTrue(res.succeeded()); + userid = res.result(); + async.complete(); + }); + final Async async2 = context.async(); + test.directory().createActiveUser(test.http().sessionUser()).onComplete(r -> { + context.assertTrue(r.succeeded()); + async2.complete(); + }); + }) + .onFailure(ex -> context.fail(ex)); } private ElementShareOperations readWrite(UserInfos user) { diff --git a/workspace/src/test/java/org/entcore/workspace/FolderTest.java b/workspace/src/test/java/org/entcore/workspace/FolderTest.java index 842b110fea..beb4a3cb37 100644 --- a/workspace/src/test/java/org/entcore/workspace/FolderTest.java +++ b/workspace/src/test/java/org/entcore/workspace/FolderTest.java @@ -72,19 +72,21 @@ public static void setUp(TestContext context) throws Exception { test.database().initNeo4j(context, neo4jContainer); final ShareService shareService = test.share().createMongoShareService(context, DocumentDao.DOCUMENTS_COLLECTION); - final Storage storage = new StorageFactory(test.vertx(), new JsonObject(), + StorageFactory.build(test.vertx(), new JsonObject().put("file-system", new JsonObject().put("path", "/tmp")), new MongoDBApplicationStorage(DocumentDao.DOCUMENTS_COLLECTION, Workspace.class.getSimpleName())) - .getStorage(); - final String imageResizerAddress = "wse.image.resizer"; - final FolderManager folderManager = FolderManager.mongoManager(DocumentDao.DOCUMENTS_COLLECTION, storage, - test.vertx(), shareService, imageResizerAddress, false); - final boolean neo4jPlugin = false; - final QuotaService quotaService = new DefaultQuotaService(neo4jPlugin, - new TimelineHelper(test.vertx(), test.vertx().eventBus(), new JsonObject())); - final int threshold = 80; - workspaceService = new DefaultWorkspaceService(storage, MongoDb.getInstance(), threshold, imageResizerAddress, - quotaService, folderManager, test.vertx(), shareService, false); - test.database().initMongo(context, mongoContainer); + .onSuccess(storageFactory -> { + final Storage storage = storageFactory.getStorage(); + final String imageResizerAddress = "wse.image.resizer"; + final FolderManager folderManager = FolderManager.mongoManager(DocumentDao.DOCUMENTS_COLLECTION, storage, + test.vertx(), shareService, imageResizerAddress, false); + final boolean neo4jPlugin = false; + final QuotaService quotaService = new DefaultQuotaService(neo4jPlugin, + new TimelineHelper(test.vertx(), test.vertx().eventBus(), new JsonObject())); + final int threshold = 80; + workspaceService = new DefaultWorkspaceService(storage, MongoDb.getInstance(), threshold, imageResizerAddress, + quotaService, folderManager, test.vertx(), shareService, false); + test.database().initMongo(context, mongoContainer); + }).onFailure(ex -> context.fail(ex)); } @Before