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 &lt;-&gt; 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}