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