001/*
002 *  Copyright 2017 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.workspaces.project;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.Collection;
023import java.util.Date;
024import java.util.HashMap;
025import java.util.List;
026import java.util.Map;
027import java.util.Map.Entry;
028import java.util.Objects;
029import java.util.Optional;
030import java.util.Set;
031import java.util.function.Function;
032import java.util.function.Predicate;
033import java.util.stream.Collectors;
034
035import javax.jcr.Node;
036import javax.jcr.RepositoryException;
037import javax.jcr.Session;
038import javax.mail.MessagingException;
039
040import org.apache.avalon.framework.component.Component;
041import org.apache.avalon.framework.configuration.Configuration;
042import org.apache.avalon.framework.configuration.ConfigurationException;
043import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
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.logger.AbstractLogEnabled;
048import org.apache.avalon.framework.service.ServiceException;
049import org.apache.avalon.framework.service.ServiceManager;
050import org.apache.avalon.framework.service.Serviceable;
051import org.apache.cocoon.components.ContextHelper;
052import org.apache.cocoon.environment.Request;
053import org.apache.cocoon.servlet.multipart.Part;
054import org.apache.cocoon.servlet.multipart.PartOnDisk;
055import org.apache.commons.lang.ArrayUtils;
056import org.apache.commons.lang.StringUtils;
057import org.apache.excalibur.source.Source;
058import org.apache.excalibur.source.SourceResolver;
059import org.xml.sax.SAXException;
060
061import org.ametys.cms.languages.LanguagesManager;
062import org.ametys.cms.transformation.xslt.ResolveURIComponent;
063import org.ametys.core.group.GroupDirectoryContextHelper;
064import org.ametys.core.observation.Event;
065import org.ametys.core.observation.ObservationManager;
066import org.ametys.core.right.ProfileAssignmentStorageExtensionPoint;
067import org.ametys.core.right.RightManager;
068import org.ametys.core.right.RightManager.RightResult;
069import org.ametys.core.ui.Callable;
070import org.ametys.core.user.CurrentUserProvider;
071import org.ametys.core.user.User;
072import org.ametys.core.user.UserIdentity;
073import org.ametys.core.user.UserManager;
074import org.ametys.core.user.population.PopulationContextHelper;
075import org.ametys.core.util.I18nUtils;
076import org.ametys.core.util.URLEncoder;
077import org.ametys.core.util.mail.SendMailHelper;
078import org.ametys.plugins.repository.AmetysObject;
079import org.ametys.plugins.repository.AmetysObjectIterable;
080import org.ametys.plugins.repository.AmetysObjectResolver;
081import org.ametys.plugins.repository.AmetysRepositoryException;
082import org.ametys.plugins.repository.ModifiableAmetysObject;
083import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
084import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
085import org.ametys.plugins.repository.jcr.SimpleAmetysObject;
086import org.ametys.plugins.workspaces.members.ProjectMemberManager;
087import org.ametys.plugins.workspaces.project.modules.WorkspaceModuleExtensionPoint;
088import org.ametys.plugins.workspaces.project.objects.Project;
089import org.ametys.plugins.workspaces.project.objects.Project.InscriptionStatus;
090import org.ametys.plugins.workspaces.project.objects.ProjectCategory;
091import org.ametys.plugins.workspaces.project.rights.ProjectRightHelper;
092import org.ametys.runtime.config.Config;
093import org.ametys.runtime.i18n.I18nizableText;
094import org.ametys.web.ObservationConstants;
095import org.ametys.web.repository.page.Page;
096import org.ametys.web.repository.page.ZoneItem;
097import org.ametys.web.repository.site.Site;
098import org.ametys.web.repository.site.SiteDAO;
099import org.ametys.web.repository.sitemap.Sitemap;
100import org.ametys.web.site.SiteConfigurationExtensionPoint;
101import org.ametys.web.site.SiteParameter;
102
103import com.google.common.collect.Iterables;
104
105/**
106 * Manager for the Projects Catalogue service
107 */
108public class ProjectsCatalogueManager extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
109{
110    /** Avalon Role */
111    public static final String ROLE = ProjectsCatalogueManager.class.getName();
112
113    private static final String __RIGHT_PROJECT_CREATE = "Plugins_Workspaces_Rights_Project_Create";
114    private static final String __RIGHT_PROJECT_EDIT = "Plugins_Workspaces_Rights_Project_Edit";
115    private static final String __RIGHT_PROJECT_DELETE = "Plugins_Workspaces_Rights_Project_Delete";
116
117    private static final String __RIGHT_PROJECT_FO_CREATE = "Plugins_Workspaces_Rights_Project_FO_Create";
118    private static final String __RIGHT_PROJECT_FO_EDIT = "Plugins_Workspaces_Rights_Project_FO_Edit";
119    private static final String __RIGHT_PROJECT_FO_DELETE = "Plugins_Workspaces_Rights_Project_FO_Delete";
120    
121    /** List of allowed field received from the front */
122    private static final String[] __ALLOWED_FORM_DATA = {"description", "emailList", "inscriptionStatus", "defaultProfile", "tags", "categoryTags", "keywords"};
123
124    /** Ametys Object Resolver */
125    protected AmetysObjectResolver _resolver;
126    /** Current user provider */
127    protected CurrentUserProvider _currentUserProvider;
128    /** The project members' manager */
129    protected ProjectMemberManager _projectMemberManager;
130    /** The right manager */
131    protected RightManager _rightManager;
132    /** The project manager */
133    protected ProjectManager _projectManager;
134    /** Helper for project's rights */
135    protected ProjectRightHelper _projectRightHelper;
136    /** The language manager */
137    protected LanguagesManager _languagesManager;
138    /** The source resolver */
139    protected SourceResolver _sourceResolver;
140    /** The site dao */
141    protected SiteDAO _siteDAO;
142    /** The site's configuration handler */
143    protected SiteConfigurationExtensionPoint _siteConfigurationEP;
144    /** Helper for user population */
145    protected PopulationContextHelper _populationContextHelper;
146    /** The extension point for workspace's modules */
147    protected WorkspaceModuleExtensionPoint _moduleManagerEP;
148    /** The extension point for profiles' storage */
149    protected ProfileAssignmentStorageExtensionPoint _profileAssignmentStorageExtensionPoint;
150    /** The user manager */
151    protected UserManager _userManager;
152    /** Utils for i18n */
153    protected I18nUtils _i18nUtils;
154    /** The observation manager */
155    protected ObservationManager _observationManager;
156    /** Helper for group directory's context */
157    protected GroupDirectoryContextHelper _groupDirectoryContextHelper;
158    
159    /** The avalon context */
160    protected Context _context;
161    
162    public void service(ServiceManager manager) throws ServiceException
163    {
164        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
165        _projectManager = (ProjectManager) manager.lookup(ProjectManager.ROLE);
166        _projectMemberManager = (ProjectMemberManager) manager.lookup(ProjectMemberManager.ROLE);
167        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
168        _projectRightHelper = (ProjectRightHelper) manager.lookup(ProjectRightHelper.ROLE);
169        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
170        _languagesManager = (LanguagesManager) manager.lookup(LanguagesManager.ROLE);
171        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
172        _siteConfigurationEP = (SiteConfigurationExtensionPoint) manager.lookup(SiteConfigurationExtensionPoint.ROLE);
173        _siteDAO = (SiteDAO) manager.lookup(SiteDAO.ROLE);
174        _populationContextHelper = (PopulationContextHelper) manager.lookup(PopulationContextHelper.ROLE);
175        _moduleManagerEP = (WorkspaceModuleExtensionPoint) manager.lookup(WorkspaceModuleExtensionPoint.ROLE);
176        _profileAssignmentStorageExtensionPoint = (ProfileAssignmentStorageExtensionPoint) manager.lookup(ProfileAssignmentStorageExtensionPoint.ROLE);
177        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
178        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
179        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
180        _groupDirectoryContextHelper = (GroupDirectoryContextHelper) manager.lookup(GroupDirectoryContextHelper.ROLE);
181    }
182    
183    public void contextualize(Context context) throws ContextException
184    {
185        _context = context;
186    }
187    
188    /**
189     * Get the rights to create, edit or delete projects, for the current user
190     * @return The map of rights for the current user
191     */
192    @Callable
193    public Map<String, Object> getRights()
194    {
195        Map<String, Object> rights = new HashMap<>();
196
197        rights.put("create", _rightManager.currentUserHasRight(__RIGHT_PROJECT_CREATE, null) == RightResult.RIGHT_ALLOW
198                          || _rightManager.currentUserHasRight(__RIGHT_PROJECT_FO_CREATE, null) == RightResult.RIGHT_ALLOW);
199        rights.put("edit", _rightManager.currentUserHasRight(__RIGHT_PROJECT_EDIT, null) == RightResult.RIGHT_ALLOW);
200        rights.put("delete", _rightManager.currentUserHasRight(__RIGHT_PROJECT_DELETE, null) == RightResult.RIGHT_ALLOW);
201
202        return rights;
203    }
204    
205    /**
206     * Get the categories tree
207     * @param id The tree root. Can be null
208     * @return The tree data
209     */
210    @Callable
211    public List<Map<String, Object>> getCategoriesTree(String id)
212    {
213        return _projectManager.getProjectTree(id, 0, false, false, null);
214    }
215
216    /**
217     * Get the node information of a specific project or category from the project tree
218     * @param id The category or project id
219     * @param includeProjects False to only get the categories tree
220     * @return The node informations
221     */
222    @Callable
223    public List<Map<String, Object>> getProjectTreeNode(String id, boolean includeProjects)
224    {
225        return getProjectTreeNode(id, includeProjects, false);
226    }
227    
228    /**
229     * Get the node information of a specific project or category from the project tree
230     * @param id The category or project id
231     * @param zoneItemId The zone item of the project catalogue
232     * @param includeProjects False to only get the categories tree
233     * @return The node informations
234     */
235    @Callable
236    public List<Map<String, Object>> getProjectTreeNode(String id, String zoneItemId, boolean includeProjects)
237    {
238        ZoneItem zoneItem = _resolver.resolveById(zoneItemId);
239        ModelAwareDataHolder serviceParameters = zoneItem.getServiceParameters();
240
241        String[] filterCategoriesArray = serviceParameters.getValue("filterCategories", false, new String[0]);
242        List<String> filterCategories = filterCategoriesArray.length > 0 ? Arrays.asList(filterCategoriesArray) : null;
243
244        return getProjectTreeNode(id, includeProjects, serviceParameters.getValue("memberOnly", false, true), filterCategories);
245    }
246    
247    /**
248     * Get the node information of a specific project or category from the project tree
249     * @param id The category or project id
250     * @param includeProjects False to only get the categories tree
251     * @param memberOnly Only return projects for which the current user is a member
252     * @return The node informations
253     */
254    @Callable
255    public List<Map<String, Object>> getProjectTreeNode(String id, boolean includeProjects, boolean memberOnly)
256    {
257        return getProjectTreeNode(id, includeProjects, memberOnly, null);
258    }
259    
260    /**
261     * Get the node information of a specific project or category from the project tree
262     * @param id The category or project id
263     * @param includeProjects False to only get the categories tree
264     * @param memberOnly Only return projects for which the current user is a member
265     * @param filterCategories The list of categories to filter. Can be null to ignore
266     * @return The node informations
267     */
268    @Callable
269    public List<Map<String, Object>> getProjectTreeNode(String id, boolean includeProjects, boolean memberOnly, List<String> filterCategories)
270    {
271        return _projectManager.getProjectTreeNodes(id, 1, 0, includeProjects, memberOnly, filterCategories).stream()
272            .map(node -> this._node2Json(node, includeProjects, filterCategories))
273            .filter(Objects::nonNull)
274            .collect(Collectors.toList());
275    }
276
277    private Map<String, Object> _node2Json(Object treeNode, boolean includeProjects, List<String> filterCategories)
278    {
279        if (treeNode instanceof Project)
280        {
281            Project project = (Project) treeNode;
282            Map<String, Object> projectProperties = _projectManager.getProjectProperties(project);
283            boolean hasAccess = _projectMemberManager.isProjectMember(project, _currentUserProvider.getUser());
284            projectProperties.put("hasAccess", hasAccess);
285            
286            Map<String, Boolean> rights = new HashMap<>();
287            rights.put("edit", _rightManager.currentUserHasRight(__RIGHT_PROJECT_FO_EDIT, project) == RightResult.RIGHT_ALLOW);
288            rights.put("delete", _rightManager.currentUserHasRight(__RIGHT_PROJECT_FO_DELETE, project) == RightResult.RIGHT_ALLOW);
289            projectProperties.put("rights", rights);
290
291            InscriptionStatus inscriptionStatus = project.getInscriptionStatus();
292            projectProperties.put("inscriptionStatus", inscriptionStatus.toString());
293            UserIdentity userIdentity = _currentUserProvider.getUser();
294            if (!hasAccess && !inscriptionStatus.equals(InscriptionStatus.PRIVATE) && userIdentity != null)
295            {
296                String siteName = Iterables.getFirst(_projectManager.getProjectNames(project), null);
297                boolean inPopulations = siteName != null && (_populationContextHelper.getUserPopulationsOnContext("/sites/" + siteName, false).contains(userIdentity.getPopulationId())
298                    || _populationContextHelper.getUserPopulationsOnContext("/sites-fo/" + siteName, false).contains(userIdentity.getPopulationId()));
299                projectProperties.put("inPopulations", inPopulations);
300            }
301
302            return projectProperties;
303        }
304        else if (treeNode instanceof Map)
305        {
306            Map categoryTreeNode = (Map) treeNode;
307            if (categoryTreeNode.containsKey("category"))
308            {
309                ProjectCategory projectCategory = (ProjectCategory) categoryTreeNode.get("category");
310                Map<String, Object> categoryProperties = _projectManager.getCategoryProperties(projectCategory);
311                boolean hasChildren = projectCategory.getChildren()
312                                                     .stream()
313                                                     .filter(node -> (includeProjects || node instanceof ProjectCategory) 
314                                                                     && (filterCategories == null 
315                                                                         || filterCategories.contains(projectCategory.getId())
316                                                                         || _projectManager.isCategoryInFilters(node.getId(), filterCategories, includeProjects)))
317                                                     .count() > 0;
318                categoryProperties.put("hasChild", hasChildren);
319                
320                return categoryProperties;
321            }
322        }
323
324        return null;
325    }
326
327    /**
328     * Retrieve the data required to create a new project
329     * @param zoneItemId The zone item of the project catalogue
330     * @return The new project data
331     */
332    @Callable
333    public Map<String, Object> getNewProjectData(String zoneItemId)
334    {
335        return getProjectData(null, zoneItemId);
336    }
337
338    /**
339     * Retrieve the data of a project to edit, or the data for a new project
340     * @param projectId The project id. Can be null.
341     * @param zoneItemId The zone item of the project catalog
342     * @return The project data
343     */
344    @Callable
345    public Map<String, Object> getProjectData(String projectId, String zoneItemId)
346    {
347        Map<String, Object> result = new HashMap<>();
348        
349        ZoneItem zoneItem = _resolver.resolveById(zoneItemId);
350        ModelAwareDataHolder serviceParameters = zoneItem.getServiceParameters();
351        
352        String[] availableLanguages = serviceParameters.getValue("availableLanguages", false, new String[0]);
353        List languages = Arrays.stream(availableLanguages).map(code -> 
354        {
355            Map<String, Object> data = new HashMap<>();
356            data.put("id", code);
357            data.put("label", _languagesManager.getLanguage(code).getLabel());
358            return data;
359        }).collect(Collectors.toList());
360        result.put("availableLanguages", languages);
361        
362        result.putAll(_projectRightHelper.getProjectRightsData(null));
363        
364        boolean memberOnly = serviceParameters.getValue("memberOnly", false, true);
365        result.put("categories", _projectManager.getProjectTree(null, 0, false, memberOnly, null));
366        
367        result.put("project", _getProjectParameters(projectId, serviceParameters));
368        
369        return result;
370    }
371
372    /**
373     * Retrieves the parameters of the given project
374     * @param projectId Identifier of the project
375     * @param serviceDataHolder data holder of the project catalog service 
376     * @return a Map containing the parameters of the project
377     */
378    protected Map<String, Object> _getProjectParameters(String projectId, ModelAwareDataHolder serviceDataHolder)
379    {
380        Map<String, Object> projectParameters = new HashMap<>();
381        
382        if (projectId != null)
383        {
384            Project project = _resolver.resolveById(projectId);
385            
386            Collection<Site> sites = project.getSites();
387            if (sites.size() > 0)
388            {
389                Site site = sites.iterator().next();
390                
391                String titlePrefix = serviceDataHolder.getValue("titlePrefix", false, "");
392                String urlPrefix = serviceDataHolder.getValue("urlPrefix", false, "");
393                
394                String title = site.getTitle();
395                String url = site.getUrl();
396                        
397                if (title != null)
398                {
399                    projectParameters.put("title", title.startsWith(titlePrefix) ? title.substring(titlePrefix.length()) : title);
400                }
401                if (url != null)
402                {
403                    projectParameters.put("url", url.startsWith(urlPrefix) ? url.substring(urlPrefix.length()) : url);
404                }
405                projectParameters.put("languages", site.getSitemaps().stream().map(Sitemap::getName).collect(Collectors.toList()));
406                
407                AmetysObject parent = project.getParent();
408                if (parent instanceof ProjectCategory)
409                {
410                    projectParameters.put("category", parent.getId());
411                }
412                 
413                projectParameters.put("description", project.getDescription());
414                if (site.getIllustration() != null)
415                {
416                    String illustrationURI = ResolveURIComponent.resolveBoundedImage("site-metadata", site.getName() + ";illustration", 120, 80, false, true);
417                    projectParameters.put("illustration", illustrationURI);
418                }
419                if (project.getCoverImage() != null)
420                {
421                    String coverImageURI = ResolveURIComponent.resolveBoundedImage("project-metadata", project.getName() + ";coverImage", 120, 550, false, true);
422                    projectParameters.put("coverImage", coverImageURI);
423                }
424                projectParameters.put("emailList", project.getMailingList());
425                projectParameters.put("inscriptionStatus", project.getInscriptionStatus().toString());
426                projectParameters.put("defaultProfile", project.getDefaultProfile());
427                UserIdentity[] managers = project.getManagers();
428                projectParameters.put("projectManagers", Arrays.stream(managers)
429                        .map(UserIdentity::userIdentityToString)
430                        .collect(Collectors.toList()));
431                if (managers.length > 0)
432                {
433                    // get first manager profile as default suggested profile affected to managers
434                    Set<String> managerProfiles = _profileAssignmentStorageExtensionPoint.getAllowedProfilesForUser(project, managers[0]);
435                    managerProfiles.remove(RightManager.READER_PROFILE_ID);
436                    projectParameters.put("profile", managerProfiles.size() > 0 ? managerProfiles.iterator().next() : null);
437                }
438                
439                projectParameters.put("emailSender", site.getValue("site-mail-from"));
440                projectParameters.put("modules", _moduleManagerEP.getModules().stream()
441                                                                 .collect(Collectors.toMap(module -> module.getId(), module -> _projectManager.isModuleActivated(project, module.getId()))));
442                
443            }
444        }
445        
446        return projectParameters;
447    }
448    
449    /**
450     * Create a new project
451     * @param formData The project data
452     * @param zoneItemId The zone item of the project catalogue
453     * @return The result
454     * @throws IllegalAccessException If an error occurred
455     * @throws IOException If an error occurred
456     * @throws AmetysRepositoryException If an error occurred 
457     */
458    @Callable
459    public Map<String, Object> createProject(Map<String, Object> formData, String zoneItemId) throws IllegalAccessException, AmetysRepositoryException, IOException
460    {
461        return createProject(null, null, formData, zoneItemId);
462    }
463    
464    
465    /**
466     * Create a new project
467     * @param image An image, can be either the illustration or the coverImage, which can be identify through formData
468     * @param formData The project data
469     * @param zoneItemId The zone item of the project catalogue
470     * @return The result
471     * @throws IllegalAccessException If an error occurred
472     * @throws IOException If an error occurred
473     * @throws AmetysRepositoryException If an error occurred 
474     */
475    @Callable
476    public Map<String, Object> createProject(Part image, Map<String, Object> formData, String zoneItemId) throws IllegalAccessException, AmetysRepositoryException, IOException
477    {
478        if (image != null)
479        {
480            if ((boolean) formData.getOrDefault("illustrationUpdated", false))
481            {
482                return createProject(image, null, formData, zoneItemId);
483            }
484            else if ((boolean) formData.getOrDefault("coverImageUpdated", false))
485            {
486                return createProject(null, image, formData, zoneItemId);
487            }
488        }
489        return createProject(null, null, formData, zoneItemId);
490    }
491
492    /**
493     * Create a new project
494     * @param illustration The project illustration file. Can be null
495     * @param coverImage The cover image. Can be null
496     * @param formData The project data
497     * @param zoneItemId The zone item of the project catalogue
498     * @return The result
499     * @throws IllegalAccessException If an error occurred
500     * @throws IOException If an error occurred
501     * @throws AmetysRepositoryException If an error occurred
502     */
503    @Callable
504    public Map<String, Object> createProject(Part illustration, Part coverImage, Map<String, Object> formData, String zoneItemId) throws IllegalAccessException, AmetysRepositoryException, IOException
505    {
506        Map<String, Object> result = new HashMap<>();
507        
508        if (_rightManager.currentUserHasRight(__RIGHT_PROJECT_CREATE, null) != RightResult.RIGHT_ALLOW 
509                && _rightManager.currentUserHasRight(__RIGHT_PROJECT_FO_CREATE, null) != RightResult.RIGHT_ALLOW)
510        {
511            throw new IllegalAccessException("User '" + _currentUserProvider.getUser() + "' tried to create a project without sufficient rights");
512        }
513        
514        _validateImageUpload("Unable to create the project", illustration, coverImage);
515        
516        ZoneItem zoneItem = _resolver.resolveById(zoneItemId);
517        ModelAwareDataHolder serviceDataHolder = zoneItem.getServiceParameters();
518        
519        Map<String, Object> siteConfiguration = _getNewSiteConfiguration(formData, serviceDataHolder);
520        
521        String projectCategory = (String) formData.getOrDefault("category", null);
522        
523        List<String> errors = new ArrayList<>();
524        Project project = _createProject(projectCategory, formData, serviceDataHolder, siteConfiguration, errors);
525        
526        if (project != null)
527        {
528            _assignPopulationsAndManager(project, formData);
529            _setProjectIllustration(project, formData, illustration);
530            _setProjectCoverImage(project, formData, coverImage);
531            project.saveChanges();
532        }
533        
534        result.put("success", errors.size() == 0);
535        if (errors.size() > 0)
536        {   
537            result.put("error", errors);
538        }
539        
540        return result;
541    }
542
543    private void _validateImageUpload(String error, Part illustration, Part coverImage)
544    {
545        if (illustration != null && !(illustration instanceof PartOnDisk && illustration.getMimeType() != null && illustration.getMimeType().startsWith("image/")))
546        {
547            throw new IllegalArgumentException(error + ", upload failed or invalid upload type '" + illustration.getMimeType() + "' for the illustration");
548        }
549        
550        if (coverImage != null && !(coverImage instanceof PartOnDisk && coverImage.getMimeType() != null && coverImage.getMimeType().startsWith("image/")))
551        {
552            throw new IllegalArgumentException(error + ", upload failed or invalid upload type '" + coverImage.getMimeType() + "' for the cover image");
553        }
554    }
555
556
557    private Map<String, Object> _getNewSiteConfiguration(Map<String, Object> formData, ModelAwareDataHolder serviceDataHolder)
558    {
559        Map<String, Object> siteConfiguration = new HashMap<>();
560        
561        try
562        {
563            Source src = _sourceResolver.resolveURI("context://WEB-INF/param/workspace-default-config.xml");
564            
565            if (src.exists())
566            {
567                try (InputStream is = src.getInputStream())
568                {
569                    if (getLogger().isDebugEnabled())
570                    {
571                        getLogger().debug("ProjectsCatalogueManager : WEB-INF/param/workspace-default-config.xml imported");
572                    }
573                    
574                    Configuration workspaceDefaultConfig = new DefaultConfigurationBuilder().build(is);
575                    for (Configuration configuration : workspaceDefaultConfig.getChildren())
576                    {
577                        siteConfiguration.put(configuration.getName(), configuration.getValue());
578                    }
579                }
580            }
581        }
582        catch (IOException | ConfigurationException | SAXException e)
583        {
584            // Config does not exist, ignore
585        }
586        
587        siteConfiguration.put("skin", serviceDataHolder.getValue("skin"));
588        siteConfiguration.put("force-accept-cookies", serviceDataHolder.getValue("force_accept_cookies"));
589        siteConfiguration.put("display-restricted-pages", serviceDataHolder.getValue("display_restricted_pages")); 
590        siteConfiguration.put("ping_activated", serviceDataHolder.getValue("ping_activated"));
591        siteConfiguration.put("site-mail-from", formData.getOrDefault("emailSender", null));
592        
593        return siteConfiguration;
594    }
595
596    @SuppressWarnings("unchecked")
597    private Project _createProject(String parentId, Map<String, Object> formData, ModelAwareDataHolder serviceDataHolder, Map<String, Object> siteConfiguration, List<String> errors)
598    {
599        String title = serviceDataHolder.getValue("titlePrefix", false, "") + formData.get("title");
600        String url = serviceDataHolder.getValue("urlPrefix", false, "") + formData.get("url");
601        
602        if (url.endsWith("/"))
603        {
604            url = StringUtils.substring(url, 0, -1);
605        }
606        String name = StringUtils.substring(url, url.lastIndexOf('/') + 1);
607        if (name.contains(":"))
608        {
609            name = StringUtils.substring(name, 0, name.indexOf(":"));
610        }
611        
612        String[] availableLanguages = serviceDataHolder.getValue("availableLanguages");
613        List<String> languages = ((List<String>) formData.get("languages")).stream()
614                .filter(lang -> ArrayUtils.contains(availableLanguages, lang))
615                .collect(Collectors.toList());
616        
617        if (languages.size() == 0)
618        {
619            throw new IllegalArgumentException("Error while creating a new workspace : invalid argument languages");
620        }
621        siteConfiguration.put("lang", languages);
622        siteConfiguration.put("title", title);
623        siteConfiguration.put("url", url);
624        
625        Map<String, Boolean> modules = (Map<String, Boolean>) formData.getOrDefault("modules", null);
626        Set<String> modulesToActivate = modules != null ? modules.entrySet().stream()
627                .filter(e -> e.getValue())
628                .map(e -> e.getKey())
629                .collect(Collectors.toSet()) : null;
630                
631        // Filter formData values to transmit, to prevent form injection with invalid keys
632        Predicate<String> isFormKeyAllowed = key -> ArrayUtils.contains(getAllowedFormData(), key);
633        
634        Map<String, Object> filteredFormData = new HashMap<>();
635        formData.keySet().stream()
636                .filter(isFormKeyAllowed)
637                .forEach(key -> filteredFormData.put(key, formData.get(key)));
638        
639        String projectId = _projectManager.createProject(parentId, name, title, filteredFormData, modulesToActivate, errors);
640        
641        if (errors.size() == 0)
642        {
643            Project project = (Project) _resolver.resolveById(projectId);
644            
645            return _postProjectCreation(project, filteredFormData, serviceDataHolder, siteConfiguration, errors);
646        }
647        
648        return null;
649    }
650    
651    /**
652     * some post action when the project is created in the repo
653     * @param project the project
654     * @param formData the forms data
655     * @param serviceDataHolder service data holder
656     * @param siteConfiguration site configuration
657     * @param errors list of errors
658     * @return the project
659     */
660    protected Project _postProjectCreation(Project project, Map<String, Object> formData, ModelAwareDataHolder serviceDataHolder, Map<String, Object> siteConfiguration, List<String> errors)
661    {
662        for (Site site : project.getSites())
663        {
664            try
665            {
666                // Add default values for missing values
667                _siteConfigurationEP.getParameters(site.getName()).values().stream()
668                    .filter(param -> param.getDefaultValue() != null && !siteConfiguration.containsKey(param.getName()))
669                    .forEach(param -> siteConfiguration.put(param.getName(), param.getType().valueToJSONForClient(param.getDefaultValue())));
670                
671                _siteDAO.configureSite(site.getName(), siteConfiguration);
672            }
673            catch (Exception e)
674            {
675                errors.add("invalid-site-configuration");
676                getLogger().error("Unable to configure the site when creating a new project", e);
677            }
678        }
679        
680        return project;
681    }
682    
683    private void _assignPopulationsAndManager(Project project, Map<String, Object> formData)
684    {
685        Request request = ContextHelper.getRequest(_context);
686        String siteName = (String) request.getAttribute("siteName");
687        
688        Set<String> populations = _populationContextHelper.getUserPopulationsOnContext("/sites/" + siteName, false);
689        Set<String> frontPopulations = _populationContextHelper.getUserPopulationsOnContext("/sites-fo/" + siteName, false);
690        
691        Set<String> groupDirectories = _groupDirectoryContextHelper.getGroupDirectoriesOnContext("/sites/" + siteName);
692        Set<String> frontGroupDirectories = _groupDirectoryContextHelper.getGroupDirectoriesOnContext("/sites-fo/" + siteName);
693        
694        for (Site site : project.getSites())
695        {
696            _populationContextHelper.link("/sites/" + site.getName(), populations);
697            _populationContextHelper.link("/sites-fo/" + site.getName(), frontPopulations);
698            _groupDirectoryContextHelper.link("/sites/" + site.getName(), new ArrayList<>(groupDirectories));
699            _groupDirectoryContextHelper.link("/sites-fo/" + site.getName(), new ArrayList<>(frontGroupDirectories));
700        }
701        
702        @SuppressWarnings("unchecked")
703        List<String> projectManagers = (List<String>) formData.get("projectManagers");
704        
705        Predicate<UserIdentity> isInPopulations = user -> populations.contains(user.getPopulationId()) || frontPopulations.contains(user.getPopulationId());
706        
707        List<UserIdentity> managers = projectManagers.stream()
708                .map(UserIdentity::stringToUserIdentity)
709                .filter(Objects::nonNull)
710                .filter(isInPopulations)
711                .collect(Collectors.toList());
712        
713        String profile = (String) formData.get("profile");
714        _projectMemberManager.setProjectManager(project.getName(), profile, managers);
715    }
716
717
718    /**
719     * Edit a project
720     * @param projectId the project id
721     * @param formData The project data
722     * @param zoneItemId The zone item of the project catalogue
723     * @return The result
724     * @throws IllegalAccessException If an error occurred
725     * @throws RepositoryException If an error occurred
726     * @throws AmetysRepositoryException If an error occurred
727     * @throws IOException If an error occurred
728     */
729    @Callable
730    public Map<String, Object> editProject(String projectId, Map<String, Object> formData, String zoneItemId) throws IllegalAccessException, AmetysRepositoryException, RepositoryException, IOException
731    {
732        return editProject(null, null, projectId, formData, zoneItemId);
733    }
734    
735
736    /**
737     * Edit a project
738     * @param image An image, can be either the illustration or the coverImage, which can be identify through formData
739     * @param projectId the project id
740     * @param formData The project data
741     * @param zoneItemId The zone item of the project catalogue
742     * @return The result
743     * @throws IllegalAccessException If an error occurred
744     * @throws RepositoryException If an error occurred
745     * @throws AmetysRepositoryException If an error occurred
746     * @throws IOException If an error occurred
747     */
748    @Callable
749    public Map<String, Object> editProject(Part image, String projectId, Map<String, Object> formData, String zoneItemId) throws IllegalAccessException, AmetysRepositoryException, RepositoryException, IOException
750    {
751        if (image != null)
752        {
753            if ((boolean) formData.getOrDefault("illustrationUpdated", false))
754            {
755                return editProject(image, null, projectId, formData, zoneItemId);
756            }
757            else if ((boolean) formData.getOrDefault("coverImageUpdated", false))
758            {
759                return editProject(null, image, projectId, formData, zoneItemId);
760            }
761        }
762        return editProject(null, null, projectId, formData, zoneItemId);
763    }
764    
765    /**
766     * edit a project
767     * @param illustration The project illustration file. Can be null
768     * @param coverImage The cover image. Can be null
769     * @param projectId the project id
770     * @param formData The project data
771     * @param zoneItemId The zone item of the project catalogue
772     * @return The result
773     * @throws IllegalAccessException If an error occurred
774     * @throws RepositoryException If an error occurred
775     * @throws AmetysRepositoryException If an error occurred
776     * @throws IOException If an error occurred
777     */
778    @Callable
779    public Map<String, Object> editProject(Part illustration, Part coverImage, String projectId, Map<String, Object> formData, String zoneItemId) throws IllegalAccessException, AmetysRepositoryException, RepositoryException, IOException
780    {
781        Map<String, Object> result = new HashMap<>();
782        List<String> errors = new ArrayList<>();
783
784        Project project = _resolver.resolveById(projectId);
785        
786        if (project == null)
787        {
788            throw new IllegalArgumentException("Unable to edit a project, invalid project id received '" + projectId + "'");
789        }
790        
791        _validateImageUpload("Unable to edit a project", illustration, coverImage);
792        
793        if (_rightManager.currentUserHasRight(__RIGHT_PROJECT_EDIT, null) != RightResult.RIGHT_ALLOW
794                && _rightManager.currentUserHasRight(__RIGHT_PROJECT_FO_EDIT, project) != RightResult.RIGHT_ALLOW)
795        {
796            throw new IllegalAccessException("User '" + _currentUserProvider.getUser() + "' tried to edit the project '" + projectId + "' without sufficient rights");
797        }
798        
799        ZoneItem zoneItem = _resolver.resolveById(zoneItemId);
800        ModelAwareDataHolder serviceDataHolder = zoneItem.getServiceParameters();
801
802        // update category first, as the move needs to have no pending changes, for the live to be successfully updated
803        _updateCategory(project, formData);
804        
805        _updateProject(project, formData, serviceDataHolder);
806        _updateSiteConfiguration(project, formData, serviceDataHolder, errors);
807        _updateModules(project, formData);
808        _setProjectIllustration(project, formData, illustration);
809        _setProjectCoverImage(project, formData, coverImage);
810        
811        if (project.needsSave())
812        {
813            project.saveChanges();
814        }
815        
816        // Notify observers
817        Map<String, Object> eventParams = new HashMap<>();
818        eventParams.put(org.ametys.plugins.workspaces.ObservationConstants.ARGS_PROJECT, project);
819        _observationManager.notify(new Event(org.ametys.plugins.workspaces.ObservationConstants.EVENT_PROJECT_UPDATED, _currentUserProvider.getUser(), eventParams));
820        
821        result.put("success", errors.size() == 0);
822        if (errors.size() > 0)
823        {
824            result.put("error", errors);
825        }
826        
827        return result;
828    }
829
830    /**
831     * Update a project
832     * @param project the project to update
833     * @param formData form data
834     * @param serviceDataHolder service parameters
835     */
836    protected void _updateProject(Project project, Map<String, Object> formData, ModelAwareDataHolder serviceDataHolder)
837    {
838        String title = serviceDataHolder.getValue("titlePrefix", false, "") + formData.get("title");
839        String description = (String) formData.getOrDefault("description", null);
840        String mailingList = (String) formData.getOrDefault("emailList", null);
841        String inscriptionStatus = (String) formData.getOrDefault("inscriptionStatus", null);
842        String defaultProfile = (String) formData.getOrDefault("defaultProfile", null);
843        
844        if (!title.equals(project.getTitle()))
845        {
846            project.setTitle(title);
847        }
848        
849        String projectDescription = project.getDescription();
850        if (projectDescription != null ? !projectDescription.equals(description) : description != null)
851        {
852            project.setDescription(description);
853        }
854        
855        String projectMailingList = project.getMailingList();
856        if (projectMailingList != null ? !projectMailingList.equals(mailingList) : mailingList != null)
857        {
858            project.setMailingList(mailingList);
859        }
860        
861        String projectInscriptionStatus = project.getInscriptionStatus().toString();
862        if (projectInscriptionStatus != null ? !projectInscriptionStatus.equals(inscriptionStatus) : inscriptionStatus != null)
863        {
864            project.setInscriptionStatus(inscriptionStatus);
865        }
866        
867        String projectDefaultProfile = project.getDefaultProfile();
868        if (projectDefaultProfile != null ? !projectDefaultProfile.equals(defaultProfile) : defaultProfile != null)
869        {
870            project.setDefaultProfile(defaultProfile);
871        }
872    }
873
874    @SuppressWarnings("unchecked")
875    private void _updateSiteConfiguration(Project project, Map<String, Object> formData, ModelAwareDataHolder serviceDataHolder, List<String> errors)
876    {
877        String title = serviceDataHolder.getValue("titlePrefix", false, "") + formData.get("title");
878        String url = serviceDataHolder.getValue("urlPrefix", false, "") + formData.get("url");
879
880        String[] availableLanguages = serviceDataHolder.getValue("availableLanguages");
881        List<String> languages = ((List<String>) formData.get("languages")).stream()
882                .filter(lang -> ArrayUtils.contains(availableLanguages, lang))
883                .collect(Collectors.toList());
884        
885        if (languages.size() == 0)
886        {
887            throw new IllegalArgumentException("Error while creating a new workspace : invalid argument languages");
888        }
889        for (Site site : project.getSites())
890        {
891            Map<String, Object> siteConfiguration = _getSiteConfiguration(site);
892            siteConfiguration.put("lang", languages);
893            siteConfiguration.put("title", title);
894            siteConfiguration.put("url", url);
895            siteConfiguration.put("site-mail-from", formData.get("emailSender"));
896            
897            try
898            {
899                _siteDAO.configureSite(site.getName(), siteConfiguration);
900            }
901            catch (Exception e)
902            {
903                errors.add("invalid-site-configuration");
904                getLogger().error("Unable to configure the site when creating a new project", e);
905            }
906            
907            if (site.needsSave())
908            {
909                site.saveChanges();
910            }
911        }
912    }
913    
914    private Map<String, Object> _getSiteConfiguration(Site site)
915    {
916        Map<String, Object> values = new HashMap<>();
917        String siteName = site.getName();
918        Map<String, SiteParameter> parameters = _siteConfigurationEP.getParameters(siteName);
919        for (String name : parameters.keySet())
920        {
921            Object siteParameterValue = site.getValue(name);
922            if (siteParameterValue != null)
923            {
924                values.put(name, siteParameterValue);
925            }
926        }
927        
928        // Add default values for missing values
929        parameters.values().stream()
930            .filter(param -> param.getDefaultValue() != null && !values.containsKey(param.getName()))
931            .forEach(param -> values.put(param.getName(), param.getType().valueToJSONForClient(param.getDefaultValue())));
932        
933        return values;
934    }
935
936    private void _updateCategory(Project project, Map<String, Object> formData) throws AmetysRepositoryException, RepositoryException
937    {
938        String projectCategory = (String) formData.getOrDefault("category", null);
939        ModifiableTraversableAmetysObject newParent;
940        if (projectCategory == null)
941        {
942            newParent = _projectManager.getProjectsRoot();
943        }
944        else
945        {
946            newParent = _resolver.resolveById(projectCategory);
947        }
948        
949        ModifiableAmetysObject oldParent = project.getParent();
950        if (!oldParent.equals(newParent))
951        {
952            String newName = project.getName();
953            int index = 1;
954            while (newParent.hasChild(newName))
955            {
956                newName = project.getName() + "-" + ++index;
957            }
958            
959            Node projectNode = project.getNode();
960            Node parentNode = ((SimpleAmetysObject) newParent).getNode();
961            Session session = projectNode.getSession();
962            session.move(projectNode.getPath(), parentNode.getPath() + "/" + newName);
963            session.save();
964        }
965    }
966
967    @SuppressWarnings("unchecked")
968    private void _updateModules(Project project, Map<String, Object> formData)
969    {
970        String[] oldModules = project.getModules();
971        
972        Map<String, Boolean> modules = (Map<String, Boolean>) formData.getOrDefault("modules", null);
973        if (modules != null)
974        {
975            Predicate<Entry<String, Boolean>> isModuleActivated = Entry::getValue;
976            Function<Entry<String, Boolean>, String> getModule = Entry::getKey;
977            
978            Set<String> activeModules = modules.entrySet().stream()
979                              .filter(isModuleActivated)
980                              .map(getModule)
981                              .collect(Collectors.toSet());
982            
983            Set<String> inactiveModules = modules.entrySet().stream()
984                    .filter(isModuleActivated.negate())
985                    .map(getModule)
986                    .collect(Collectors.toSet());
987            
988            _projectManager.activateModules(project, activeModules);
989            _projectManager.deactivateModules(project, inactiveModules);
990        }
991        else
992        {
993            _projectManager.deactivateModules(project, Arrays.stream(oldModules).collect(Collectors.toSet()));
994        }
995        
996        String profile = (String) formData.get("profile");
997        List<String> projectManagers = (List<String>) formData.get("projectManagers");
998        
999        List<UserIdentity> managers = projectManagers.stream()
1000                .map(UserIdentity::stringToUserIdentity)
1001                .filter(Objects::nonNull)
1002                .collect(Collectors.toList());
1003        
1004        _projectMemberManager.setProjectManager(project.getName(), profile, managers);
1005    }
1006    
1007
1008    private void _setProjectIllustration(Project project, Map<String, Object> formData, Part illustration) throws AmetysRepositoryException, IOException
1009    {
1010        if ((Boolean) formData.getOrDefault("illustrationUpdated", false))
1011        {
1012            for (Site site : project.getSites())
1013            {
1014                if (illustration == null)
1015                {
1016                    site.setIllustration(null, null, null, null);
1017                }
1018                else
1019                {
1020                    try (InputStream is = illustration.getInputStream())
1021                    {
1022                        site.setIllustration(is, illustration.getMimeType(), illustration.getFileName(), new Date());
1023                    }
1024                }
1025                
1026                if (site.needsSave())
1027                {
1028                    site.saveChanges();
1029                    
1030                    Map<String, Object> eventParams = new HashMap<>();
1031                    eventParams.put(ObservationConstants.ARGS_SITE, site);
1032                    _observationManager.notify(new Event(ObservationConstants.EVENT_SITE_UPDATED, _currentUserProvider.getUser(), eventParams));
1033                }
1034            }
1035        }
1036    }
1037    
1038
1039    private void _setProjectCoverImage(Project project, Map<String, Object> formData, Part coverImage) throws AmetysRepositoryException, IOException
1040    {
1041        if ((Boolean) formData.getOrDefault("coverImageUpdated", false))
1042        {
1043            if (coverImage == null)
1044            {
1045                project.setCoverImage(null, null, null, null);
1046            }
1047            else
1048            {
1049                try (InputStream is = coverImage.getInputStream())
1050                {
1051                    project.setCoverImage(is, coverImage.getMimeType(), coverImage.getFileName(), new Date());
1052                }
1053            }
1054        }
1055    }
1056    
1057    /**
1058     * Delete a project
1059     * @param projectId The project id
1060     * @return The result
1061     * @throws IllegalAccessException  if an error occurred
1062     */
1063    @Callable
1064    public Map<String, Object> deleteProject(String projectId) throws IllegalAccessException
1065    {
1066        Project project = _resolver.resolveById(projectId);
1067        
1068        if (project == null)
1069        {
1070            throw new IllegalArgumentException("Unable to delete a project, invalid project id received '" + projectId + "'");
1071        }
1072        
1073        if (_rightManager.currentUserHasRight(__RIGHT_PROJECT_DELETE, null) != RightResult.RIGHT_ALLOW
1074                && _rightManager.currentUserHasRight(__RIGHT_PROJECT_FO_DELETE, project) != RightResult.RIGHT_ALLOW)
1075        {
1076            throw new IllegalAccessException("User '" + _currentUserProvider.getUser() + "' tried to delete the project '" + projectId + "' without sufficient rights");
1077        }
1078        
1079        Map<String, Object> result = new HashMap<>();
1080        result.put("sites", _projectManager.deleteProject(project));
1081        result.put("success", true);
1082        return result;
1083    }
1084    
1085    /**
1086     * Add the current user to the project, if the project's inscriptions are opened
1087     * @param projectId The project id
1088     * @return The result
1089     * @throws MessagingException If an error occurred sending a notification mail to the project manager
1090     */
1091    @Callable
1092    public Map<String, Object> joinProject(String projectId) throws MessagingException
1093    {
1094        Map<String, Object> result = new HashMap<>();
1095        Project project = _resolver.resolveById(projectId);
1096        
1097        if (project == null)
1098        {
1099            throw new IllegalArgumentException("Unable to join a project, invalid project id received '" + projectId + "'");
1100        }
1101
1102        UserIdentity currentUser = _currentUserProvider.getUser();
1103        
1104        Optional<Site> projectSite = project.getSites().stream().filter(site -> StringUtils.isNotEmpty(site.getUrl())).findFirst();
1105        String siteName = projectSite.isPresent() ? projectSite.get().getName() : null;
1106        if (siteName == null || (!_populationContextHelper.getUserPopulationsOnContext("/sites/" + siteName, false).contains(currentUser.getPopulationId())
1107                && !_populationContextHelper.getUserPopulationsOnContext("/sites-fo/" + siteName, false).contains(currentUser.getPopulationId())))
1108        {
1109            //  User is not in the project site populations, cannot be added
1110            result.put("success", false);
1111            return result;
1112        }
1113
1114        
1115        boolean success = _projectMemberManager.addProjectMember(project, currentUser);
1116        
1117        result.put("success", success);
1118        if (success)
1119        {
1120            String url = null;
1121            if (projectSite.isPresent())
1122            {
1123                url = projectSite.get().getUrl();
1124                result.put("url", url);
1125            }
1126            
1127            String mailFrom = Config.getInstance().getValue("smtp.mail.from");
1128            
1129            List<String> managersEmails = Arrays.stream(project.getManagers())
1130                    .map(manager -> _userManager.getUser(manager))
1131                    .filter(Objects::nonNull)
1132                    .map(User::getEmail)
1133                    .filter(StringUtils::isNotEmpty)
1134                    .collect(Collectors.toList());
1135            
1136            if (managersEmails.size() > 0 && mailFrom != null)
1137            {
1138                Map<String, I18nizableText> params = new HashMap<>();
1139                User current = _userManager.getUser(currentUser);
1140                params.put("user", new I18nizableText(current != null ? current.getFullName() : currentUser.getLogin()));
1141                params.put("project", new I18nizableText(project.getTitle()));
1142                params.put("url", new I18nizableText(url != null ? url : ""));
1143                String subject = _i18nUtils.translate(new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_SERVICE_PROJECTS_CATALOGUE_PROJECT_JOIN_MAIL_TITLE", params));
1144                String textBody = _i18nUtils.translate(new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_SERVICE_PROJECTS_CATALOGUE_PROJECT_JOIN_MAIL_BODY", params));
1145                
1146                for (String managerMail : managersEmails)
1147                {
1148                    SendMailHelper.sendMail(subject, null, textBody, managerMail, mailFrom);
1149                }
1150            }
1151        }
1152        return result;
1153    }
1154    
1155    /**
1156     * Send a demand to join a project to the project's manager, if the project's inscriptions are moderated 
1157     * @param projectId The project to join
1158     * @param message A message to send to the project's manager.
1159     * @return The result
1160     * @throws MessagingException If an error occurred sending the email to the project's manager
1161     */
1162    @Callable
1163    public Map<String, Object> askToJoinProject(String projectId, String message) throws MessagingException
1164    {
1165        Map<String, Object> result = new HashMap<>();
1166
1167        Project project = _resolver.resolveById(projectId);
1168        
1169        if (project == null)
1170        {
1171            throw new IllegalArgumentException("Unable to join a project, invalid project id received '" + projectId + "'");
1172        }
1173
1174        UserIdentity currentUser = _currentUserProvider.getUser();
1175        
1176        String siteName = Iterables.getFirst(_projectManager.getProjectNames(project), null);
1177        if (siteName == null || (!_populationContextHelper.getUserPopulationsOnContext("/sites/" + siteName, false).contains(currentUser.getPopulationId())
1178                && !_populationContextHelper.getUserPopulationsOnContext("/sites-fo/" + siteName, false).contains(currentUser.getPopulationId())))
1179        {
1180            //  User is not in the project site populations, cannot be added
1181            result.put("success", false);
1182            return result;
1183        }
1184        
1185        _sendAskToJoinMail(message, project, currentUser);
1186        result.put("success", true);
1187        result.put("added-notification", Config.getInstance().getValue("workspaces.member.added.send.notification"));
1188        return result;
1189    }
1190
1191    private void _sendAskToJoinMail(String message, Project project, UserIdentity joiningUser) throws MessagingException
1192    {
1193        String url = getAddUserUrl(project, joiningUser);
1194        
1195        String mailFrom = Config.getInstance().getValue("smtp.mail.from");
1196        
1197        List<String> managersEmails = Arrays.stream(project.getManagers())
1198                .map(manager -> _userManager.getUser(manager))
1199                .filter(Objects::nonNull)
1200                .map(User::getEmail)
1201                .filter(StringUtils::isNotEmpty)
1202                .collect(Collectors.toList());
1203        
1204        if (managersEmails.size() > 0 && mailFrom != null)
1205        {
1206            String escapedMessage = StringUtils.isEmpty(message) ? null : message.replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\n", "<br/>");
1207            
1208            Map<String, I18nizableText> params = new HashMap<>();
1209            User current = _userManager.getUser(joiningUser);
1210            params.put("user", new I18nizableText(current != null ? current.getFullName() : joiningUser.getLogin()));
1211            params.put("project", new I18nizableText(project.getTitle()));
1212            params.put("url", new I18nizableText(url));
1213            params.put("message", new I18nizableText(escapedMessage != null ? escapedMessage : ""));
1214            String subject = _i18nUtils.translate(new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_SERVICE_PROJECTS_CATALOGUE_PROJECT_ASKTOJOIN_MAIL_TITLE", params));
1215            String htmlBody = _i18nUtils.translate(new I18nizableText("plugin.workspaces", escapedMessage != null ? "PLUGINS_WORKSPACES_SERVICE_PROJECTS_CATALOGUE_PROJECT_ASKTOJOIN_MAIL_BODY" : "PLUGINS_WORKSPACES_SERVICE_PROJECTS_CATALOGUE_PROJECT_ASKTOJOIN_MAIL_BODY_EMPTY" , params));
1216            
1217            for (String managerMail : managersEmails)
1218            {
1219                SendMailHelper.sendMail(subject, htmlBody, null, managerMail, mailFrom);
1220            }
1221        }
1222    }
1223    
1224    /**
1225     * Get the absolute url to add a user to a project
1226     * @param project The project
1227     * @param user the identity of user to add
1228     * @return the absolute page url
1229     */
1230    protected String getAddUserUrl(Project project, UserIdentity user)
1231    {
1232        AmetysObjectIterable<Page> projectDashboardPages = _projectManager.getProjectDashboardPage(project, null);
1233        if (projectDashboardPages.getSize() > 0)
1234        {
1235            return ResolveURIComponent.resolve("page", projectDashboardPages.iterator().next().getId(), false, true) + "?askToJoin=" + URLEncoder.encodeParameter(UserIdentity.userIdentityToString(user));
1236        }
1237        
1238        return "";
1239    }
1240    
1241    /**
1242     * Get the list of allowed data in the form
1243     * @return the list of allowed data in the form
1244     */
1245    protected String[] getAllowedFormData()
1246    {
1247        return __ALLOWED_FORM_DATA;
1248    }
1249
1250}