001/* 002 * Copyright 2024 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.plugins.rocket.chat; 017 018import java.io.ByteArrayInputStream; 019import java.io.IOException; 020import java.io.InputStream; 021import java.nio.charset.StandardCharsets; 022import java.time.Duration; 023import java.time.ZonedDateTime; 024import java.util.ArrayList; 025import java.util.Base64; 026import java.util.Base64.Decoder; 027import java.util.Base64.Encoder; 028import java.util.Collection; 029import java.util.Collections; 030import java.util.HashMap; 031import java.util.HashSet; 032import java.util.LinkedHashMap; 033import java.util.List; 034import java.util.Map; 035import java.util.Map.Entry; 036import java.util.Objects; 037import java.util.Optional; 038import java.util.Set; 039import java.util.stream.Collectors; 040 041import org.apache.avalon.framework.activity.Initializable; 042import org.apache.avalon.framework.component.Component; 043import org.apache.avalon.framework.context.Context; 044import org.apache.avalon.framework.context.ContextException; 045import org.apache.avalon.framework.context.Contextualizable; 046import org.apache.avalon.framework.service.ServiceException; 047import org.apache.avalon.framework.service.ServiceManager; 048import org.apache.avalon.framework.service.Serviceable; 049import org.apache.cocoon.components.ContextHelper; 050import org.apache.cocoon.environment.Request; 051import org.apache.commons.codec.digest.Sha2Crypt; 052import org.apache.commons.lang3.StringUtils; 053import org.apache.commons.lang3.tuple.Pair; 054import org.apache.excalibur.source.Source; 055import org.apache.excalibur.source.SourceResolver; 056import org.apache.http.Consts; 057import org.apache.http.HttpEntity; 058import org.apache.http.client.methods.CloseableHttpResponse; 059import org.apache.http.client.methods.HttpGet; 060import org.apache.http.client.methods.HttpPost; 061import org.apache.http.client.methods.HttpUriRequest; 062import org.apache.http.entity.ContentType; 063import org.apache.http.entity.StringEntity; 064import org.apache.http.entity.mime.HttpMultipartMode; 065import org.apache.http.entity.mime.MultipartEntityBuilder; 066import org.apache.http.impl.client.CloseableHttpClient; 067import org.apache.http.impl.client.HttpClients; 068import org.apache.http.util.EntityUtils; 069import org.apache.poi.util.IOUtils; 070import org.apache.tika.Tika; 071 072import org.ametys.core.authentication.AuthenticateAction; 073import org.ametys.core.cache.AbstractCacheManager; 074import org.ametys.core.cache.Cache; 075import org.ametys.core.ui.Callable; 076import org.ametys.core.user.CurrentUserProvider; 077import org.ametys.core.user.User; 078import org.ametys.core.user.UserIdentity; 079import org.ametys.core.user.UserManager; 080import org.ametys.core.user.population.PopulationContextHelper; 081import org.ametys.core.userpref.UserPreferencesException; 082import org.ametys.core.userpref.UserPreferencesManager; 083import org.ametys.core.util.CryptoHelper; 084import org.ametys.core.util.DateUtils; 085import org.ametys.core.util.JSONUtils; 086import org.ametys.core.util.LambdaUtils; 087import org.ametys.core.util.URIUtils; 088import org.ametys.runtime.config.Config; 089import org.ametys.runtime.i18n.I18nizableText; 090import org.ametys.runtime.plugin.component.AbstractLogEnabled; 091import org.ametys.web.WebHelper; 092import org.ametys.web.transformation.xslt.AmetysXSLTHelper; 093 094/** 095 * Helper for the rocket.chat link 096 */ 097public class RocketChatHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable, Initializable 098{ 099 /** The Avalon role */ 100 public static final String ROLE = RocketChatHelper.class.getName(); 101 102 private static final String __USERPREF_PREF_PASSWORD = "rocket.chat-connector-password"; 103 private static final String __USERPREF_PREF_TOKEN = "rocket.chat-connector-token"; 104 private static final String __USERPREF_PREF_ID = "rocket.chat-connector-id"; 105 private static final String __USERPREF_CONTEXT = "/rocket.chat-connector"; 106 107 private static final String __CONFIG_ADMIN_ID = "rocket.chat.rocket.admin.id"; 108 private static final String __CONFIG_ADMIN_TOKEN = "rocket.chat.rocket.admin.token"; 109 private static final String __CONFIG_URL = "rocket.chat.rocket.url"; 110 111 private static final String __CACHE_STATUS = RocketChatHelper.class.getName() + "$status"; 112 private static final int __CACHE_STATUS_DURATION = 120; 113 private static final String __CACHE_UPDATES = RocketChatHelper.class.getName() + "$updates"; 114 private static final int __CACHE_UPDATES_DURATION = 120; 115 116 private static final Encoder __BASE64_ENCODER = Base64.getUrlEncoder().withoutPadding(); 117 private static final Decoder __BASE64_DECODER = Base64.getUrlDecoder(); 118 119 /** JSON Utils */ 120 protected JSONUtils _jsonUtils; 121 122 /** User Manager */ 123 protected UserManager _userManager; 124 125 /** User Preferences */ 126 protected UserPreferencesManager _userPreferencesManager; 127 128 /** Cryptography */ 129 protected CryptoHelper _cryptoHelper; 130 131 /** Current user provider */ 132 protected CurrentUserProvider _currentUserProvider; 133 134 private SourceResolver _sourceResolver; 135 136 private Context _context; 137 138 private AbstractCacheManager _cacheManager; 139 140 private PopulationContextHelper _populationContextHelper; 141 142 public void contextualize(Context context) throws ContextException 143 { 144 _context = context; 145 } 146 147 public void service(ServiceManager manager) throws ServiceException 148 { 149 _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE); 150 _userManager = (UserManager) manager.lookup(UserManager.ROLE); 151 _userPreferencesManager = (UserPreferencesManager) manager.lookup(UserPreferencesManager.ROLE); 152 _cryptoHelper = (CryptoHelper) manager.lookup("org.ametys.plugins.rocket.chat.cryptoHelper"); 153 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 154 _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE); 155 _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE); 156 _populationContextHelper = (PopulationContextHelper) manager.lookup(PopulationContextHelper.ROLE); 157 } 158 159 public void initialize() throws Exception 160 { 161 _createCaches(); 162 } 163 164 /** 165 * Creates the caches 166 */ 167 protected void _createCaches() 168 { 169 _cacheManager.createMemoryCache(__CACHE_STATUS, 170 new I18nizableText("plugin.rocket.chat", "PLUGINS_ROCKETCHAT_HELPER_STATUS_CACHE_LABEL"), 171 new I18nizableText("plugin.rocket.chat", "PLUGINS_ROCKETCHAT_HELPER_STATUS_CACHE_DESC"), 172 true, 173 Duration.ofSeconds(__CACHE_STATUS_DURATION)); 174 _cacheManager.createMemoryCache(__CACHE_UPDATES, 175 new I18nizableText("plugin.rocket.chat", "PLUGINS_ROCKETCHAT_HELPER_STATUS_UPDATES_LABEL"), 176 new I18nizableText("plugin.rocket.chat", "PLUGINS_ROCKETCHAT_HELPER_STATUS_UPDATES_DESC"), 177 true, 178 Duration.ofSeconds(__CACHE_UPDATES_DURATION)); 179 } 180 181 private Cache<UserIdentity, String> _getStatusCache() 182 { 183 return _cacheManager.get(__CACHE_STATUS); 184 } 185 186 private Cache<UserIdentity, Boolean> _getUpdatesCache() 187 { 188 return _cacheManager.get(__CACHE_UPDATES); 189 } 190 191 private Map<String, Object> _doGet(String api, Map<String, String> parameters) throws IOException 192 { 193 return _doGet(api, parameters, Config.getInstance().getValue(__CONFIG_ADMIN_TOKEN), Config.getInstance().getValue(__CONFIG_ADMIN_ID)); 194 } 195 196 private Map<String, Object> _doGet(String api, Map<String, String> parameters, String authToken, String userId) throws IOException 197 { 198 String path = Config.getInstance().getValue(__CONFIG_URL) + "/api/" + api; 199 200 String uri = URIUtils.encodeURI(path, parameters); 201 HttpGet request = new HttpGet(uri); 202 request.setHeader("Content-Type", "application/json"); 203 204 return _execRequest(request, authToken, userId); 205 } 206 207 private Map<String, Object> _doPOST(String api, Map<String, Object> parameters) throws IOException 208 { 209 return _doPOST(api, parameters, Config.getInstance().getValue(__CONFIG_ADMIN_TOKEN), Config.getInstance().getValue(__CONFIG_ADMIN_ID)); 210 } 211 212 private Map<String, Object> _doPOST(String api, Map<String, Object> parameters, String authToken, String userId) throws IOException 213 { 214 String path = Config.getInstance().getValue(__CONFIG_URL) + "/api/" + api; 215 216 HttpPost request = new HttpPost(path); 217 218 String json = _jsonUtils.convertObjectToJson(parameters); 219 request.setEntity(new StringEntity(json, ContentType.create("application/json", StandardCharsets.UTF_8))); 220 request.setHeader("Content-Type", "application/json"); 221 222 return _execRequest(request, authToken, userId); 223 } 224 225 private Map<String, Object> _doMultipartPOST(String api, Map<String, Object> parameters) throws IOException 226 { 227 return _doMultipartPOST(api, parameters, Config.getInstance().getValue(__CONFIG_ADMIN_TOKEN), Config.getInstance().getValue(__CONFIG_ADMIN_ID)); 228 } 229 230 private Map<String, Object> _doMultipartPOST(String api, Map<String, Object> parameters, String authToken, String userId) throws IOException 231 { 232 String path = Config.getInstance().getValue(__CONFIG_URL) + "/api/" + api; 233 234 HttpPost request = new HttpPost(path); 235 236 MultipartEntityBuilder builder = MultipartEntityBuilder.create(); 237 builder.setMode(HttpMultipartMode.RFC6532); 238 239 for (Entry<String, Object> p : parameters.entrySet()) 240 { 241 if (p.getValue() instanceof String) 242 { 243 builder.addTextBody(p.getKey(), (String) p.getValue(), ContentType.create("text/plain", Consts.UTF_8)); 244 } 245 else if (p.getValue() instanceof InputStream is) 246 { 247 byte[] imageAsBytes = IOUtils.toByteArray(is); 248 ByteArrayInputStream bis = new ByteArrayInputStream(imageAsBytes); 249 Tika tika = new Tika(); 250 String mimeType = tika.detect(imageAsBytes); 251 252 builder.addBinaryBody(p.getKey(), bis, ContentType.create(mimeType), p.getKey()); 253 } 254 else 255 { 256 throw new UnsupportedOperationException("Cannot post the type " + p.getValue().getClass().getName() + " for parameter " + p.getKey()); 257 } 258 } 259 260 HttpEntity multipart = builder.build(); 261 request.setEntity(multipart); 262 263 return _execRequest(request, authToken, userId); 264 } 265 266 private Map<String, Object> _execRequest(HttpUriRequest request, String authToken, String userId) throws IOException 267 { 268 getLogger().debug("Request to Rocket.Chat server {}", request.getURI()); 269 270 request.setHeader("X-Auth-Token", authToken); 271 request.setHeader("X-User-Id", userId); 272 273 try (CloseableHttpClient httpClient = HttpClients.createDefault(); 274 CloseableHttpResponse response = httpClient.execute(request)) 275 { 276 Map<String, Object> convertJsonToMap = _jsonUtils.convertJsonToMap(EntityUtils.toString(response.getEntity())); 277 return convertJsonToMap; 278 } 279 } 280 281 private String _getError(Map<String, Object> info) 282 { 283 if (info.containsKey("error")) 284 { 285 return (String) info.get("error"); 286 } 287 else if (info.containsKey("message")) 288 { 289 return (String) info.get("message"); 290 } 291 else 292 { 293 return ""; 294 } 295 } 296 297 /** 298 * Get (or create) a new user. 299 * @param userIdentity the user that will be mirrored into chat 300 * @param updateIfNotNew If the user was already existing, should it be updated (except password)? 301 * @return the user info or null if user does not exist in chat and create was not required 302 * @throws IOException something went wrong 303 * @throws UserPreferencesException error while reading the user preferences 304 * @throws InterruptedException error while reading the user preferences 305 */ 306 @SuppressWarnings("unchecked") 307 public Map<String, Object> getUser(UserIdentity userIdentity, boolean updateIfNotNew) throws IOException, UserPreferencesException, InterruptedException 308 { 309 User user = _userManager.getUser(userIdentity); 310 if (user == null) 311 { 312 throw new IllegalStateException("Cannot create user in Rocket.Chat for unexisting user " + UserIdentity.userIdentityToString(userIdentity)); 313 } 314 315 Map<String, Object> userInfo = _doGet("v1/users.info", Map.of("username", _userIdentitytoUserName(userIdentity))); 316 if (!_isOperationSuccessful(userInfo)) 317 { 318 Map<String, String> ametysUserInfo = getAmetysUserInfo(user, true, 64); 319 String userName = ametysUserInfo.get("userName"); 320 String userEmail = ametysUserInfo.get("userEmail"); 321 322 userInfo = _doPOST("v1/users.create", Map.of("username", _userIdentitytoUserName(userIdentity), 323 "email", userEmail, 324 "name", userName, 325 "verified", true, 326 "password", _getUserPassword(userIdentity))); 327 if (!_isOperationSuccessful(userInfo)) 328 { 329 throw new IllegalStateException("Cannot create user in Rocket.Chat for " + UserIdentity.userIdentityToString(userIdentity) + ": " + _getError(userInfo)); 330 } 331 332 getLogger().debug("User " + UserIdentity.userIdentityToString(userIdentity) + " created on the chat server"); 333 334 _updateAvatar(userIdentity); 335 _getUpdatesCache().put(userIdentity, true); 336 } 337 else if (_getUpdatesCache().get(userIdentity) == null) // Lets avoid calling updateUserInfos too often since there is an unavoidable 60 seconds rate limit ROCKETCHAT-4 338 { 339 Map<String, Object> userMap = (Map<String, Object>) userInfo.get("user"); 340 _userPreferencesManager.addUserPreference(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_ID, (String) userMap.get("_id")); 341 342 updateUserInfos(userIdentity, false); 343 _getUpdatesCache().put(userIdentity, true); 344 345 return userMap; 346 } 347 348 Map<String, Object> userMap = (Map<String, Object>) userInfo.get("user"); 349 _userPreferencesManager.addUserPreference(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_ID, (String) userMap.get("_id")); 350 351 return userMap; 352 } 353 354 /** 355 * Get the user info 356 * @param user The user 357 * @param externalUrl Is the image url external? 358 * @param imageSize The size of the avatar 359 * @return The name, email and avatar 360 */ 361 public Map<String, String> getAmetysUserInfo(User user, boolean externalUrl, int imageSize) 362 { 363 return Map.of("userName", user.getFullName(), 364 "userSortableName", user.getSortableName(), 365 "userEmail", user.getEmail(), 366 "userAvatar", externalUrl ? AmetysXSLTHelper.uriPrefix() + "/_plugins/core-ui/user/" + user.getIdentity().getPopulationId() + "/" + URIUtils.encodePath(user.getIdentity().getLogin()) + "/image_" + imageSize + "?lang=en" // TODO get the user pref of language? The purpose is to always get the same avatar in every languages since it is shared in RC 367 : "cocoon://_plugins/core-ui/user/" + user.getIdentity().getPopulationId() + "/" + user.getIdentity().getLogin() + "/image_" + imageSize 368 ); 369 } 370 371 /** 372 * Update user name, email, avatar... on chat server 373 * @param userIdentity The user to update 374 * @param changePassword Update the user password (slow) 375 * @throws UserPreferencesException If an error occurred while getting user infos 376 * @throws IOException If an error occurred while updating 377 * @throws InterruptedException If an error occurred while updating 378 */ 379 public void updateUserInfos(UserIdentity userIdentity, boolean changePassword) throws IOException, UserPreferencesException, InterruptedException 380 { 381 User user = _userManager.getUser(userIdentity); 382 if (user == null) 383 { 384 throw new IllegalStateException("Cannot update user in Rocket.Chat for unexisting user " + UserIdentity.userIdentityToString(userIdentity)); 385 } 386 387 Map<String, String> ametysUserInfo = getAmetysUserInfo(user, true, 64); 388 String userName = ametysUserInfo.get("userName"); 389 String userEmail = ametysUserInfo.get("userEmail"); 390 391 Map<String, String> data = new HashMap<>(); 392 data.put("email", userEmail); 393 data.put("name", userName); 394 if (changePassword) 395 { 396 data.put("password", _getUserPassword(userIdentity)); 397 } 398 399 Map<String, Object> updateInfos = _doPOST("v1/users.update", Map.of("userId", _getUserId(userIdentity), 400 "data", data)); 401 if (!_isOperationSuccessful(updateInfos)) 402 { 403 throw new IOException("Cannot update user " + UserIdentity.userIdentityToString(userIdentity) + " on chat server: " + _getError(updateInfos)); 404 } 405 406 if (changePassword) 407 { 408 // When changing password, it unlogs people and this takes time 409 Thread.sleep(1000); 410 } 411 412 _updateAvatar(userIdentity); 413 } 414 415 private void _updateAvatar(UserIdentity user) 416 { 417 ContextHelper.getRequest(_context).setAttribute(AuthenticateAction.REQUEST_ATTRIBUTE_INTERNAL_ALLOWED, true); 418 419 Source src = null; 420 try 421 { 422 User u = _userManager.getUser(user); 423 src = _sourceResolver.resolveURI(getAmetysUserInfo(u, false, 64).get("userAvatar")); 424 try (InputStream is = src.getInputStream()) 425 { 426 Map<String, Object> avatarInfo = _doMultipartPOST("v1/users.setAvatar", Map.of("username", _userIdentitytoUserName(user), 427 "image", is)); 428 if (!_isOperationSuccessful(avatarInfo)) 429 { 430 getLogger().warn("Fail to update avatar for user " + UserIdentity.userIdentityToString(user) + ": " + _getError(avatarInfo)); 431 } 432 } 433 } 434 catch (Exception e) 435 { 436 getLogger().warn("Fail to update avatar for user " + UserIdentity.userIdentityToString(user), e); 437 } 438 finally 439 { 440 _sourceResolver.release(src); 441 } 442 443 } 444 445 private String _userIdentitytoUserName(UserIdentity userIdentity) 446 { 447 return UserIdentity.userIdentityToString(userIdentity).replaceAll("[^a-zA-Z-.]", "_") 448 + "." + new String(__BASE64_ENCODER.encode(UserIdentity.userIdentityToString(userIdentity).getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8); 449 } 450 451 private String _getUserPassword(UserIdentity userIdentity) throws UserPreferencesException 452 { 453 String cryptedPassword = _userPreferencesManager.getUserPreferenceAsString(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_PASSWORD); 454 if (!StringUtils.isBlank(cryptedPassword)) 455 { 456 try 457 { 458 return _cryptoHelper.decrypt(cryptedPassword); 459 } 460 catch (CryptoHelper.WrongKeyException e) 461 { 462 getLogger().warn("Password of user {} cannot be decrypted, and thus will be reset", UserIdentity.userIdentityToString(userIdentity), e); 463 } 464 } 465 466 return _generateAndStorePassword(userIdentity); 467 } 468 469 // renewIfNecessary Set to true, if there is a serious possibility that the token is no more valid 470 // as it cost, do not do it, if there was a valid request a few minutes before 471 private String _getUserAuthToken(UserIdentity userIdentity, boolean renewIfNecessary) throws UserPreferencesException, IOException, InterruptedException 472 { 473 String cryptedToken = _userPreferencesManager.getUserPreferenceAsString(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_TOKEN); 474 if (!StringUtils.isBlank(cryptedToken)) 475 { 476 try 477 { 478 String token = _cryptoHelper.decrypt(cryptedToken); 479 if (renewIfNecessary) 480 { 481 token = _renewTokenIfNecessary(userIdentity, token, _getUserId(userIdentity)); 482 } 483 return token; 484 } 485 catch (CryptoHelper.WrongKeyException e) 486 { 487 getLogger().warn("Token of user {} cannot be decrypted, and thus will be reset", UserIdentity.userIdentityToString(userIdentity), e); 488 } 489 } 490 return _generateAndStoreAuthToken(userIdentity, true); 491 } 492 493 494 private String _getUserId(UserIdentity userIdentity) throws UserPreferencesException 495 { 496 String userId = _userPreferencesManager.getUserPreferenceAsString(userIdentity, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_ID); 497 return userId; 498 } 499 500 private String _generateAndStorePassword(UserIdentity user) throws UserPreferencesException 501 { 502 Double random = Math.random(); 503 byte[] randoms = {random.byteValue()}; 504 String randomPassword = Sha2Crypt.sha256Crypt(randoms); 505 506 String cryptedPassword = _cryptoHelper.encrypt(randomPassword); 507 _userPreferencesManager.addUserPreference(user, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_PASSWORD, cryptedPassword); 508 509 return randomPassword; 510 } 511 512 513 @SuppressWarnings("unchecked") 514 private String _generateAndStoreAuthToken(UserIdentity user, boolean tryToChangePassword) throws IOException, UserPreferencesException, InterruptedException 515 { 516 Map<String, Object> loginInfo = _doPOST("v1/login", Map.of("user", _userIdentitytoUserName(user), 517 "password", _getUserPassword(user))); 518 if (_isOperationSuccessful(loginInfo)) 519 { 520 String authToken = (String) ((Map<String, Object>) loginInfo.get("data")).get("authToken"); 521 String cryptedAuthToken = _cryptoHelper.encrypt(authToken); 522 _userPreferencesManager.addUserPreference(user, __USERPREF_CONTEXT, Collections.emptyMap(), __USERPREF_PREF_TOKEN, cryptedAuthToken); 523 524 return authToken; 525 } 526 else if (tryToChangePassword) 527 { 528 this.updateUserInfos(user, true); 529 530 return _generateAndStoreAuthToken(user, false); 531 } 532 else 533 { 534 throw new IOException("Could not log user " + UserIdentity.userIdentityToString(user) + " into chat " + _getError(loginInfo)); 535 } 536 } 537 538 /** 539 * Read the JSON result to test for success 540 * @param result the JSON result of a rest call 541 * @return true if success 542 */ 543 protected boolean _isOperationSuccessful(Map<String, Object> result) 544 { 545 Boolean success = false; 546 if (result != null) 547 { 548 Object successObj = result.get("success"); 549 if (successObj instanceof Boolean) 550 { 551 success = (Boolean) successObj; 552 } 553 else if (successObj instanceof String) 554 { 555 success = "true".equalsIgnoreCase((String) successObj); 556 } 557 else 558 { 559 Object statusObj = result.get("status"); 560 if (statusObj instanceof String) 561 { 562 success = "success".equalsIgnoreCase((String) statusObj); 563 } 564 } 565 } 566 return success; 567 } 568 569 private String _renewTokenIfNecessary(UserIdentity userIdentity, String authToken, String userId) throws IOException, UserPreferencesException, InterruptedException 570 { 571 String newToken = authToken; 572 573 // Ensure token validity by getting status on chat server 574 String status = _computeStatus(userIdentity, authToken, userId, null); // No cache here on purpose, since we need to test the authToken 575 if (status == null) 576 { 577 // If we cannot get status, this is probably because the auth token has expired or the user was recreated. Try a new one 578 newToken = _generateAndStoreAuthToken(userIdentity, true); 579 580 status = _computeStatus(userIdentity, authToken, userId, "offline"); 581 } 582 _getStatusCache().put(userIdentity, status); 583 584 return newToken; 585 } 586 587 /** 588 * Login the current user 589 * @return The info about the user 590 * @throws UserPreferencesException If the user password stored in prefs has an issue 591 * @throws IOException If an error occurred 592 * @throws InterruptedException If an error occurred 593 */ 594 @Callable(rights = Callable.NO_CHECK_REQUIRED, allowAnonymous = true) 595 public Map<String, Object> login() throws IOException, UserPreferencesException, InterruptedException 596 { 597 // Get current user in Ametys 598 UserIdentity userIdentity = _currentUserProvider.getUser(); 599 if (!_isPartOfAnAuthorizedPopulation(userIdentity)) 600 { 601 return null; // Avoid 403 if the user is not connected (login screen...) 602 } 603 604 User user = _userManager.getUser(userIdentity); 605 if (user == null) 606 { 607 return null; 608 } 609 610 boolean notLoggedRecently = _getUpdatesCache().get(userIdentity) == null; 611 if (notLoggedRecently) // Do not call this too often to accelerate the loading of the page 612 { 613 // Ensure user exists on chat server 614 getUser(userIdentity, true); 615 } 616 617 // Get the login info of the user 618 String authToken = _getUserAuthToken(userIdentity, notLoggedRecently); 619 String userId = _getUserId(userIdentity); 620 621 return Map.of( 622 "authToken", authToken, 623 "userId", userId, 624 "userName", _userIdentitytoUserName(userIdentity), 625 "status", _computeStatusWithCache(userIdentity, authToken, userId), 626 "canCreate", !_isPartOfALimitedPopulation(userIdentity), 627 "url", Config.getInstance().getValue("rocket.chat.rocket.url"), 628 "video", StringUtils.isNotBlank(Config.getInstance().getValue("rocket.chat.video.url", false, "")) 629 ); 630 } 631 632 /** 633 * Get the current cache status 634 * @return The association login#populationId <-> status (online, offline...) 635 * @throws InterruptedException If an error occurred 636 * @throws IOException If an error occurred 637 * @throws UserPreferencesException If an error occurred 638 */ 639 @Callable(rights = Callable.NO_CHECK_REQUIRED, allowAnonymous = true) 640 public Map<String, String> getStatusCache() throws UserPreferencesException, IOException, InterruptedException 641 { 642 // Get current user in Ametys 643 UserIdentity userIdentity = _currentUserProvider.getUser(); 644 if (!_isPartOfAnAuthorizedPopulation(userIdentity)) 645 { 646 return null; // Avoid 403 if the user is not connected (login screen...) 647 } 648 649 Request request = ContextHelper.getRequest(_context); 650 651 String sitename = WebHelper.getSiteName(request); 652 Set<String> populations = _populationContextHelper.getUserPopulationsOnContexts(Set.of("/sites/" + sitename, "/sites-fo/" + sitename), false, true); 653 654 return _getStatusCache().asMap().entrySet().stream() 655 .filter(e -> populations.contains(e.getKey().getPopulationId())) 656 .map(e -> Pair.of(UserIdentity.userIdentityToString(e.getKey()), e.getValue())) 657 .collect(Collectors.toMap(Pair::getKey, Pair::getValue)); 658 } 659 660 /** 661 * Set the current user new status 662 * @param newStatus The new status between online, offline, busy or away 663 * @throws InterruptedException If an error occurred 664 * @throws IOException If an error occurred 665 * @throws UserPreferencesException If an error occurred 666 */ 667 @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION) 668 public void setStatus(String newStatus) throws UserPreferencesException, IOException, InterruptedException 669 { 670 // Get current user in Ametys 671 UserIdentity userIdentity = _currentUserProvider.getUser(); 672 if (!_isPartOfAnAuthorizedPopulation(userIdentity)) 673 { 674 return; 675 } 676 677 // Get the login info of the user 678 String authToken = _getUserAuthToken(userIdentity, false); 679 String userId = _getUserId(userIdentity); 680 681 Map<String, Object> response = _doPOST("v1/users.setStatus", Map.of("message", "-", "status", newStatus), authToken, userId); 682 if (!_isOperationSuccessful(response)) 683 { 684 getLogger().error("Cannot set status of " + userIdentity + " because: " + response.get("error")); 685 } 686 else 687 { 688 _getStatusCache().put(userIdentity, newStatus); 689 } 690 } 691 692 /** 693 * Get the last messages of the current user 694 * @return The messages 695 * @throws IOException something went wrong 696 * @throws UserPreferencesException something went wrong 697 * @throws InterruptedException something went wrong 698 */ 699 @SuppressWarnings("unchecked") 700 @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION) 701 public Collection<Map<String, Object>> getLastMessages() throws IOException, UserPreferencesException, InterruptedException 702 { 703 Map<String, Map<String, Object>> responses = new LinkedHashMap<>(); 704 705 // Get current user in Ametys 706 UserIdentity user = _currentUserProvider.getUser(); 707 if (!_isPartOfAnAuthorizedPopulation(user)) 708 { 709 return null; 710 } 711 712 // Get the login info of the user 713 String authToken = _getUserAuthToken(user, false); 714 String userId = _getUserId(user); 715 716 Map<String, Object> statusInfo = _doGet("v1/im.list", Map.of("sort", "{ \"_updatedAt\": -1 }"), authToken, userId); 717 if (_isOperationSuccessful(statusInfo)) 718 { 719 List<Map<String, Object>> ims = (List<Map<String, Object>>) statusInfo.get("ims"); 720 for (Map<String, Object> im : ims) 721 { 722 List<User> users = _getUsers((List<String>) im.get("_USERNAMES"), UserIdentity.userIdentityToString(user).toString()); 723 724 if (users.size() > 0) 725 { 726 Map<String, Object> response = new HashMap<>(); 727 response.putAll(Map.of( 728 "id", im.get("_id"), 729 "authors", users.stream().filter(Objects::nonNull).map(u -> Map.of( 730 "identity", UserIdentity.userIdentityToString(u.getIdentity()), 731 "fullname", u.getFullName(), 732 "avatar", getAmetysUserInfo(u, true, 76).get("userAvatar"), 733 "status", users.size() == 1 ? _computeStatusWithCache(u.getIdentity(), authToken, userId) : "")).toList(), 734 "lastDate", im.get("_updatedAt"), 735 "lastMessage", _getLastMessage((Map<String, Object>) im.get("lastMessage")) 736 )); 737 738 responses.put((String) im.get("_id"), response); 739 } 740 } 741 742 Map<String, Object> unreadInfo = _doGet("v1/subscriptions.get", Map.of(), authToken, userId); 743 if (unreadInfo != null) 744 { 745 List<Map<String, Object>> updates = (List<Map<String, Object>>) unreadInfo.get("update"); 746 for (Map<String, Object> update : updates) 747 { 748 String id = (String) update.get("rid"); 749 Map<String, Object> response = responses.get(id); 750 if (response != null) 751 { 752 response.put("unread", (int) update.get("unread")); 753 response.put("mentions", ((int) update.get("userMentions")) > 0 754 || ((int) update.get("groupMentions")) > 0); 755 } 756 } 757 } 758 return responses.values(); 759 } 760 else 761 { 762 getLogger().error("Cannot get last messages of " + user + " because: " + statusInfo.get("error")); 763 return null; 764 } 765 766 } 767 768 private String _computeStatusWithCache(UserIdentity user, String authToken, String userId) 769 { 770 return _getStatusCache().get(user, __ -> _computeStatus(user, authToken, userId, "offline")); 771 } 772 773 private String _computeStatus(UserIdentity user, String authToken, String userId, String defaultValue) 774 { 775 try 776 { 777 Map<String, Object> statusInfo = _doGet("v1/users.getStatus", Map.of("username", _userIdentitytoUserName(user)), authToken, userId); 778 if (!_isOperationSuccessful(statusInfo)) 779 { 780 getLogger().error("Cannot get status of user " + UserIdentity.userIdentityToString(user) + " because Rocket.Chat returned: " + statusInfo.get("error")); 781 return defaultValue; 782 } 783 else 784 { 785 return (String) statusInfo.get("status"); 786 } 787 } 788 catch (IOException e) 789 { 790 throw new RuntimeException(e); 791 } 792 } 793 794 private String _usernameToUserIdentity(String username) 795 { 796 return Optional.ofNullable(username) 797 .filter(u -> u.contains(".")) 798 .map(u -> StringUtils.substringAfterLast(u, ".")) 799 .map(b64 -> new String(__BASE64_DECODER.decode(b64.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8)) 800 .orElse(""); 801 } 802 803 private List<User> _getUsers(List<String> usernames, String avoid) 804 { 805 return Optional.ofNullable(usernames) 806 .map(us -> us.stream() 807 .map(u -> _usernameToUserIdentity(u)) 808 .filter(u -> StringUtils.isNotBlank(u) && !StringUtils.equals(u, avoid)) 809 .map(UserIdentity::stringToUserIdentity) 810 .map(ud -> _userManager.getUser(ud)) 811 .toList()) 812 .orElse(List.of()); 813 } 814 815 @SuppressWarnings("unchecked") 816 private Map<String, Object> _getLastMessage(Map<String, Object> lastMessage) 817 { 818 if (lastMessage == null) 819 { 820 return Map.of(); 821 } 822 823 return Map.of( 824 "author", _usernameToUserIdentity(((Map<String, String>) lastMessage.get("u")).get("username")), 825 "message", _getMessageText(lastMessage) 826 ); 827 } 828 829 private String _getMessageText(Map<String, Object> message) 830 { 831 String simpleText = (String) message.get("msg"); 832 833 @SuppressWarnings("unchecked") 834 List<Map<String, Object>> attachments = (List<Map<String, Object>>) message.get("attachments"); 835 836 if (StringUtils.isNotBlank(simpleText)) 837 { 838 return simpleText.replaceAll("\\[([^\\]]+)\\]\\([^)]+\\)", "$1") // Link with description text: [Ametys](https://www.ametys.org) => Ametys 839 .replaceAll("\\[[^\\]]*\\]\\(([^)]+)\\)", "$1") // Link with NO description text: [](https://www.ametys.org) => https://www.ametys.org 840 .replaceAll("\\*([^\\s][^\\n]*)\\*", "$1") // Bold text: *test* => test 841 .replaceAll("_([^\\s][^\\n]*)_", "$1") // Underline text: _test_ => test 842 .replaceAll("~([^\s][^\n]*)~", "$1") // Stroke text: ~test~ => test 843 .replaceAll("```", "") // Multiline code: `test` => test (same as rocket.chat) do 844 .replaceAll("`([^\n]*)`", "$1"); // Inline code: `test` => test 845 846 } 847 else if (attachments != null && attachments.size() > 0) 848 { 849 return StringUtils.defaultIfBlank((String) attachments.get(0).get("description"), (String) attachments.get(0).get("title")); 850 } 851 else 852 { 853 return ""; 854 } 855 } 856 857 /** 858 * Creates a new chat with the current user and the given users 859 * @param users The parteners 860 * @return The chat id 861 * @throws InterruptedException If an error occurred 862 * @throws IOException If an error occurred 863 * @throws UserPreferencesException If an error occurred 864 */ 865 @SuppressWarnings("unchecked") 866 @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION) 867 public String createChat(List<String> users) throws UserPreferencesException, IOException, InterruptedException 868 { 869 // Get current user in Ametys 870 UserIdentity user = _currentUserProvider.getUser(); 871 if (_isPartOfALimitedPopulation(user)) 872 { 873 getLogger().error("User {} is not authorized to create a new chat", UserIdentity.userIdentityToString(user)); 874 return null; 875 } 876 877 // Get the login info of the user 878 String authToken = _getUserAuthToken(user, false); 879 String userId = _getUserId(user); 880 881 // Creates users if not existing 882 List<String> rcUsers = users.stream() 883 .map(UserIdentity::stringToUserIdentity) 884 .map(LambdaUtils.wrap(ud -> getUser(ud, false))) 885 .map(m -> (String) m.get("username")) 886 .toList(); 887 888 List<String> finalRcUsers = new ArrayList<>(rcUsers); 889 // finalRcUsers.addFirst(userIdentitytoUserName(user)); 890 891 Map<String, Object> statusInfo = _doPOST("v1/im.create", Map.of("usernames", StringUtils.join(finalRcUsers, ", ")), authToken, userId); 892 if (!_isOperationSuccessful(statusInfo)) 893 { 894 getLogger().error("Cannot create the new chat: " + statusInfo.get("message")); 895 return null; 896 } 897 898 return (String) ((Map<String, Object>) statusInfo.get("room")).get("rid"); 899 } 900 901 /** 902 * List all the users with messages in the given time window 903 * @param since The date since the messages should be considered 904 * @return The list of users 905 * @throws IOException If an error occurred 906 */ 907 public Set<UserIdentity> getUsersWithRecentMessages(ZonedDateTime since) throws IOException 908 { 909 Set<UserIdentity> users = new HashSet<>(); 910 911 int offset = 0; 912 final int count = 50; 913 int total = 1; 914 915 while (total > offset) 916 { 917 Map<String, Object> dmEveryone = _doGet("v1/im.list.everyone", Map.of( 918 "sort", "{ \"_updatedAt\": -1 }", 919 "offset", Integer.toString(offset), 920 "count", Integer.toString(count) 921 )); 922 923 if (!_isOperationSuccessful(dmEveryone)) 924 { 925 throw new IOException("Cannot get the DM: " + dmEveryone.get("message")); 926 } 927 928 total = (int) dmEveryone.get("total"); 929 930 @SuppressWarnings("unchecked") 931 List<Map<String, Object>> ims = (List<Map<String, Object>>) dmEveryone.get("ims"); 932 for (Map<String, Object> im : ims) 933 { 934 String updatedAtString = (String) im.get("_updatedAt"); 935 ZonedDateTime updatedAt = DateUtils.parseZonedDateTime(updatedAtString); 936 937 if (updatedAt.compareTo(since) < 0) 938 { 939 offset = total; 940 break; 941 } 942 943 @SuppressWarnings("unchecked") 944 Map<String, Object> lastMessage = (Map<String, Object>) im.get("lastMessage"); 945 if (lastMessage != null) 946 { 947 ZonedDateTime ts = DateUtils.parseZonedDateTime((String) lastMessage.get("ts")); 948 949 if (ts.compareTo(since) >= 0) 950 { 951 @SuppressWarnings("unchecked") 952 List<String> usernames = (List<String>) im.get("usernames"); 953 for (String username : usernames) 954 { 955 UserIdentity user = UserIdentity.stringToUserIdentity(_usernameToUserIdentity(username)); 956 users.add(user); 957 } 958 } 959 } 960 } 961 962 offset += count; 963 } 964 965 return users; 966 } 967 968 /** 969 * List the unread messages of a user in the given time window 970 * @param user The user to consider 971 * @param since The date to consider 972 * @return The ids of the room containing unread messages 973 * @throws IOException If an error occurred 974 * @throws UserPreferencesException If an error occurred 975 * @throws InterruptedException If an error occurred 976 */ 977 public Set<RoomInfo> getThreadsWithUnreadMessages(UserIdentity user, ZonedDateTime since) throws IOException, UserPreferencesException, InterruptedException 978 { 979 Set<RoomInfo> roomsInfos = new HashSet<>(); 980 981 // Ensure user exists on chat server 982 getUser(user, false); 983 984 // Get the login info of the user 985 String authToken = _getUserAuthToken(user, true); 986 String userId = _getUserId(user); 987 988 Map<String, Object> messages = _doGet("v1/subscriptions.get", Map.of( 989 "updatedSince", DateUtils.zonedDateTimeToString(since) 990 ), authToken, userId); 991 992 if (!_isOperationSuccessful(messages)) 993 { 994 throw new IOException("Cannot get the messages of " + UserIdentity.userIdentityToString(user) + ": " + messages.get("message")); 995 } 996 997 @SuppressWarnings("unchecked") 998 List<Map<String, Object>> updates = (List<Map<String, Object>>) messages.get("update"); 999 for (Map<String, Object> update : updates) 1000 { 1001 int unread = (int) update.get("unread"); 1002 if (unread > 0) 1003 { 1004 String roomId = (String) update.get("rid"); 1005 String roomLabel = (String) update.get("fname"); 1006 1007 roomsInfos.add(new RoomInfo(roomId, roomLabel, unread)); 1008 } 1009 } 1010 1011 return roomsInfos; 1012 } 1013 1014 /** 1015 * Get the n last messages of the user in the room 1016 * @param user The user to consider 1017 * @param roomId The room id to consider 1018 * @param count The number of messages to retrieve 1019 * @param since Since the max date 1020 * @return The message 1021 * @throws IOException If an error occurred 1022 * @throws UserPreferencesException If an error occurred 1023 * @throws InterruptedException If an error occurred 1024 */ 1025 public List<Message> getLastMessages(UserIdentity user, String roomId, int count, ZonedDateTime since) throws IOException, UserPreferencesException, InterruptedException 1026 { 1027 List<Message> messagesReceived = new ArrayList<>(); 1028 1029 // Get the login info of the user 1030 String authToken = _getUserAuthToken(user, false); 1031 String userId = _getUserId(user); 1032 1033 Map<String, Object> messagesInfo = _doGet("v1/im.messages", Map.of( 1034 "sort", "{ \"_updatedAt\": -1 }", 1035 "roomId", roomId, 1036 "count", Integer.toString(count) 1037 ), authToken, userId); 1038 1039 if (!_isOperationSuccessful(messagesInfo)) 1040 { 1041 if ("[invalid-channel]".equals(messagesInfo.get("error"))) 1042 { 1043 // can happen when destroying/recreating users 1044 return List.of(); 1045 } 1046 throw new IOException("Cannot get the messages of " + UserIdentity.userIdentityToString(user) + ": " + messagesInfo.get("error")); 1047 } 1048 1049 @SuppressWarnings("unchecked") 1050 List<Map<String, Object>> messages = (List<Map<String, Object>>) messagesInfo.get("messages"); 1051 for (Map<String, Object> message : messages) 1052 { 1053 ZonedDateTime ts = DateUtils.parseZonedDateTime((String) message.get("ts")); 1054 1055 if (ts.compareTo(since) >= 0) 1056 { 1057 String text = _getMessageText(message); 1058 @SuppressWarnings("unchecked") 1059 UserIdentity author = UserIdentity.stringToUserIdentity(_usernameToUserIdentity((String) ((Map<String, Object>) message.get("u")).get("username"))); 1060 ZonedDateTime date = ts; 1061 1062 messagesReceived.add(new Message(author, date, text)); 1063 } 1064 } 1065 1066 messagesReceived.sort((d1, d2) -> d1.date().compareTo(d2.date())); 1067 1068 return messagesReceived; 1069 } 1070 1071 private boolean _isPartOfAnAuthorizedPopulation(UserIdentity userIdentity) 1072 { 1073 if (userIdentity == null) 1074 { 1075 return false; 1076 } 1077 1078 String populationId = userIdentity.getPopulationId(); 1079 if (StringUtils.isBlank(populationId)) 1080 { 1081 return false; 1082 } 1083 1084 for (String forbiddenPopulationId : StringUtils.split(Config.getInstance().getValue("rocket.chat.rocket.population.forbidden"), ",")) 1085 { 1086 if (forbiddenPopulationId.trim().equals(populationId)) 1087 { 1088 return false; 1089 } 1090 } 1091 1092 return true; 1093 } 1094 1095 private boolean _isPartOfALimitedPopulation(UserIdentity userIdentity) 1096 { 1097 if (userIdentity == null) 1098 { 1099 return true; 1100 } 1101 1102 String populationId = userIdentity.getPopulationId(); 1103 if (StringUtils.isBlank(populationId)) 1104 { 1105 return true; 1106 } 1107 1108 for (String forbiddenPopulationId : StringUtils.split(Config.getInstance().getValue("rocket.chat.rocket.population.limited"), ",")) 1109 { 1110 if (forbiddenPopulationId.trim().equals(populationId)) 1111 { 1112 return true; 1113 } 1114 } 1115 1116 return false; 1117 } 1118 1119 /** 1120 * A Rocket.Chat message 1121 * @param author The author of the message 1122 * @param date The date of the message 1123 * @param text The text of the message 1124 */ 1125 public record Message(UserIdentity author, ZonedDateTime date, String text) { /* empty */ } 1126 /** 1127 * A Rocket.Chat room info for a user 1128 * @param roomId The room id 1129 * @param roomLabel The room name 1130 * @param unread The number of unread items for the user 1131 */ 1132 public record RoomInfo(String roomId, String roomLabel, int unread) { /* empty */ } 1133}