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