001/*
002 * (C) Copyright 2013-2018 Nuxeo (http://nuxeo.com/) and others.
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 *
016 * Contributors:
017 *     <a href="mailto:grenard@nuxeo.com">Guillaume</a>
018 */
019package org.nuxeo.ecm.automation.core.operations.users;
020
021import java.io.IOException;
022import java.io.Serializable;
023import java.util.ArrayList;
024import java.util.Arrays;
025import java.util.Collections;
026import java.util.LinkedHashMap;
027import java.util.List;
028import java.util.Locale;
029import java.util.Map;
030import java.util.stream.Collectors;
031
032import org.apache.commons.lang3.StringUtils;
033import org.apache.commons.logging.Log;
034import org.apache.commons.logging.LogFactory;
035import org.nuxeo.common.utils.i18n.I18NUtils;
036import org.nuxeo.ecm.automation.OperationContext;
037import org.nuxeo.ecm.automation.core.Constants;
038import org.nuxeo.ecm.automation.core.annotations.Context;
039import org.nuxeo.ecm.automation.core.annotations.Operation;
040import org.nuxeo.ecm.automation.core.annotations.OperationMethod;
041import org.nuxeo.ecm.automation.core.annotations.Param;
042import org.nuxeo.ecm.automation.features.SuggestConstants;
043import org.nuxeo.ecm.core.api.Blob;
044import org.nuxeo.ecm.core.api.Blobs;
045import org.nuxeo.ecm.core.api.DocumentModel;
046import org.nuxeo.ecm.core.api.DocumentModelList;
047import org.nuxeo.ecm.core.api.NuxeoGroup;
048import org.nuxeo.ecm.core.api.NuxeoPrincipal;
049import org.nuxeo.ecm.core.io.registry.MarshallingConstants;
050import org.nuxeo.ecm.core.query.sql.model.MultiExpression;
051import org.nuxeo.ecm.core.query.sql.model.Operator;
052import org.nuxeo.ecm.core.query.sql.model.OrderByExprs;
053import org.nuxeo.ecm.core.query.sql.model.Predicate;
054import org.nuxeo.ecm.core.query.sql.model.Predicates;
055import org.nuxeo.ecm.core.query.sql.model.QueryBuilder;
056import org.nuxeo.ecm.core.schema.SchemaManager;
057import org.nuxeo.ecm.core.schema.types.Field;
058import org.nuxeo.ecm.core.schema.types.QName;
059import org.nuxeo.ecm.core.schema.types.Schema;
060import org.nuxeo.ecm.directory.Directory;
061import org.nuxeo.ecm.directory.SizeLimitExceededException;
062import org.nuxeo.ecm.directory.api.DirectoryService;
063import org.nuxeo.ecm.platform.usermanager.UserAdapter;
064import org.nuxeo.ecm.platform.usermanager.UserManager;
065import org.nuxeo.ecm.platform.usermanager.io.NuxeoGroupJsonWriter;
066import org.nuxeo.ecm.platform.usermanager.io.NuxeoPrincipalJsonWriter;
067
068/**
069 * SuggestUser Operation.
070 *
071 * @since 5.7.3
072 */
073@Operation(id = SuggestUserEntries.ID, category = Constants.CAT_SERVICES, label = "Get user/group suggestion", description = "Get the user/group list of the running instance. This is returning a blob containing a serialized JSON array..", addToStudio = false)
074public class SuggestUserEntries {
075
076    @SuppressWarnings("unused")
077    private static final Log log = LogFactory.getLog(SuggestUserEntries.class);
078
079    public static final String ID = "UserGroup.Suggestion";
080
081    public static final String POWERUSERS = "powerusers";
082
083    @Context
084    protected OperationContext ctx;
085
086    @Context
087    protected SchemaManager schemaManager;
088
089    @Param(name = "searchTerm", alias = "prefix", required = false)
090    protected String prefix;
091
092    @Param(name = "searchType", required = false)
093    protected String searchType;
094
095    @Param(name = "groupRestriction", required = false, description = "Enter the id of a group to suggest only user from this group.")
096    protected String groupRestriction;
097
098    /**
099     * @since 7.10
100     */
101    @Param(name = "hideAdminGroups", required = false, description = "If set, remove all administrator groups from the suggestions")
102    protected boolean hideAdminGroups;
103
104    /**
105     * @since 8.3
106     */
107    @Param(name = "hidePowerUsersGroup", required = false, description = "If set, remove power users group from the suggestions")
108    protected boolean hidePowerUsersGroup;
109
110    @Param(name = "userSuggestionMaxSearchResults", required = false)
111    protected Integer userSuggestionMaxSearchResults;
112
113    @Param(name = "firstLabelField", required = false)
114    protected String firstLabelField;
115
116    @Param(name = "secondLabelField", required = false)
117    protected String secondLabelField;
118
119    @Param(name = "thirdLabelField", required = false)
120    protected String thirdLabelField;
121
122    @Param(name = "hideFirstLabel", required = false)
123    protected boolean hideFirstLabel = false;
124
125    @Param(name = "hideSecondLabel", required = false)
126    protected boolean hideSecondLabel = false;
127
128    @Param(name = "hideThirdLabel", required = false)
129    protected boolean hideThirdLabel;
130
131    @Param(name = "displayEmailInSuggestion", required = false)
132    protected boolean displayEmailInSuggestion;
133
134    @Param(name = "hideIcon", required = false)
135    protected boolean hideIcon;
136
137    @Context
138    protected UserManager userManager;
139
140    @Context
141    protected DirectoryService directoryService;
142
143    @Param(name = "lang", required = false)
144    protected String lang;
145
146    @OperationMethod
147    public Blob run() throws IOException {
148        List<Map<String, Object>> result = new ArrayList<>();
149        boolean isGroupRestriction = !StringUtils.isBlank(groupRestriction);
150        boolean groupOnly = false;
151        boolean userOnly = isGroupRestriction;
152
153        if (!isGroupRestriction && searchType != null && !searchType.isEmpty()) {
154            if (searchType.equals(SuggestConstants.USER_TYPE)) {
155                userOnly = true;
156            } else if (searchType.equals(SuggestConstants.GROUP_TYPE)) {
157                groupOnly = true;
158            }
159        }
160        int limit = userSuggestionMaxSearchResults == null ? 0 : userSuggestionMaxSearchResults.intValue();
161        try {
162            int userSize = 0;
163            int groupSize = 0;
164            if (!groupOnly) {
165                if (limit > 0 && isGroupRestriction) {
166                    // we may have to iterate several times, while increasing the limit,
167                    // because the group restrictions may truncate our results
168                    long currentLimit = limit;
169                    int prevUserListSize = -1;
170                    for (;;) {
171                        DocumentModelList userList = searchUsers(currentLimit);
172                        result = usersToMapWithGroupRestrictions(userList);
173                        int userListSize = userList.size();
174                        if (userListSize == prevUserListSize || result.size() > limit) {
175                            // stop if the search didn't return more results
176                            // or if we are beyond the limit anyway
177                            break;
178                        }
179                        prevUserListSize = userListSize;
180                        currentLimit *= 2;
181                        if (currentLimit > Integer.MAX_VALUE) {
182                            break;
183                        }
184                    }
185                } else {
186                    DocumentModelList userList = searchUsers(limit);
187                    result = usersToMapWithGroupRestrictions(userList);
188                }
189                userSize = result.size();
190            }
191            if (!userOnly) {
192                Schema schema = schemaManager.getSchema(userManager.getGroupSchemaName());
193                DocumentModelList groupList = userManager.searchGroups(prefix);
194                List<String> admins = new ArrayList<>();
195                if (hideAdminGroups) {
196                    admins = userManager.getAdministratorsGroups();
197                }
198                groupLoop: for (DocumentModel group : groupList) {
199                    if (hideAdminGroups) {
200                        for (String adminGroupName : admins) {
201                            if (adminGroupName.equals(group.getId())) {
202                                break groupLoop;
203                            }
204                        }
205                    }
206                    if (hidePowerUsersGroup && POWERUSERS.equals(group.getId())) {
207                        break groupLoop;
208                    }
209                    Map<String, Object> obj = new LinkedHashMap<>();
210                    for (Field field : schema.getFields()) {
211                        QName fieldName = field.getName();
212                        String key = fieldName.getLocalName();
213                        Serializable value = group.getPropertyValue(fieldName.getPrefixedName());
214                        obj.put(key, value);
215                    }
216                    String groupId = group.getId();
217                    obj.put(SuggestConstants.ID, groupId);
218                    obj.put(MarshallingConstants.ENTITY_FIELD_NAME, NuxeoGroupJsonWriter.ENTITY_TYPE);
219                    // If the group hasn't an label, let's put the groupid
220                    SuggestConstants.computeGroupLabel(obj, groupId, userManager.getGroupLabelField(), hideFirstLabel);
221                    obj.put(SuggestConstants.TYPE_KEY_NAME, SuggestConstants.GROUP_TYPE);
222                    obj.put(SuggestConstants.PREFIXED_ID_KEY_NAME, NuxeoGroup.PREFIX + groupId);
223                    SuggestConstants.computeUserGroupIcon(obj, hideIcon);
224                    result.add(obj);
225                }
226                groupSize = result.size() - userSize;
227            }
228
229            // Limit size results.
230            if (limit > 0 && (userSize > limit || groupSize > limit || userSize + groupSize > limit)) {
231                throw new SizeLimitExceededException();
232            }
233
234        } catch (SizeLimitExceededException e) {
235            return searchOverflowMessage();
236        }
237
238        return Blobs.createJSONBlobFromValue(result);
239    }
240
241    /**
242     * Applies group restrictions, and returns Map objects.
243     */
244    protected List<Map<String, Object>> usersToMapWithGroupRestrictions(DocumentModelList userList) {
245        List<Map<String, Object>> result = new ArrayList<>();
246        Schema schema = schemaManager.getSchema(userManager.getUserSchemaName());
247        Directory userDir = directoryService.getDirectory(userManager.getUserDirectoryName());
248        for (DocumentModel user : userList) {
249            Map<String, Object> obj = new LinkedHashMap<>();
250            for (Field field : schema.getFields()) {
251                QName fieldName = field.getName();
252                String key = fieldName.getLocalName();
253                Serializable value = user.getPropertyValue(fieldName.getPrefixedName());
254                if (key.equals(userDir.getPasswordField())) {
255                    continue;
256                }
257                obj.put(key, value);
258            }
259            String userId = user.getId();
260            obj.put(SuggestConstants.ID, userId);
261            obj.put(MarshallingConstants.ENTITY_FIELD_NAME, NuxeoPrincipalJsonWriter.ENTITY_TYPE);
262            obj.put(SuggestConstants.TYPE_KEY_NAME, SuggestConstants.USER_TYPE);
263            obj.put(SuggestConstants.PREFIXED_ID_KEY_NAME, NuxeoPrincipal.PREFIX + userId);
264            SuggestConstants.computeUserLabel(obj, firstLabelField, secondLabelField, thirdLabelField,
265                    hideFirstLabel, hideSecondLabel, hideThirdLabel, displayEmailInSuggestion, userId);
266            SuggestConstants.computeUserGroupIcon(obj, hideIcon);
267            if (!StringUtils.isBlank(groupRestriction)) {
268                // We need to load all data about the user particularly its groups.
269                user = userManager.getUserModel(userId);
270                UserAdapter userAdapter = user.getAdapter(UserAdapter.class);
271                List<String> groups = userAdapter.getGroups();
272                if (groups != null && groups.contains(groupRestriction)) {
273                    result.add(obj);
274                }
275            } else {
276                result.add(obj);
277            }
278        }
279        return result;
280    }
281
282    /**
283     * Performs a full name user search, e.g. typing "John Do" returns the user with first name "John" and last name
284     * "Doe". Typing "John" returns the "John Doe" user and possibly other users such as "John Foo". Respectively,
285     * typing "Do" returns the "John Doe" user and possibly other users such as "Jack Donald".
286     */
287    protected DocumentModelList searchUsers(long limit) {
288        if (StringUtils.isBlank(prefix)) {
289            // empty search term
290            return userManager.searchUsers(prefix);
291        }
292        // split search term around whitespace, e.g. "John Do" -> ["John", "Do"]
293        String[] searchTerms = prefix.trim().split("\\s", 2);
294        List<Predicate> predicates = Arrays.stream(searchTerms)
295                                           .map(this::getUserSearchPredicate)
296                                           .collect(Collectors.toList());
297        // intersection between all search results to handle full name
298        DocumentModelList users = searchUsers(new MultiExpression(Operator.AND, predicates), limit);
299        if (users.isEmpty()) {
300            // search on whole term to handle a whitespace within the first or last name
301            users = searchUsers(getUserSearchPredicate(prefix), limit);
302        }
303        return users;
304    }
305
306    protected DocumentModelList searchUsers(MultiExpression multiExpression, long limit) {
307        QueryBuilder queryBuilder = new QueryBuilder();
308        queryBuilder.filter(multiExpression);
309        if (limit > 0) {
310            // no need to search more than that because we throw SizeLimitExceededException is we reach it
311            queryBuilder.limit(limit + 1);
312        }
313        String sortField = StringUtils.defaultString(userManager.getUserSortField(), userManager.getUserIdField());
314        queryBuilder.order(OrderByExprs.asc(sortField));
315        return userManager.searchUsers(queryBuilder);
316    }
317
318    protected MultiExpression getUserSearchPredicate(String prefix) {
319        String pattern = prefix.trim() + '%';
320        List<Predicate> predicates = userManager.getUserSearchFields()
321                          .stream()
322                          .map(key -> Predicates.ilike(key, pattern))
323                          .collect(Collectors.toList());
324        return new MultiExpression(Operator.OR, predicates);
325    }
326
327    /**
328     * @return searchOverflowMessage
329     * @since 5.7.3
330     */
331    private Blob searchOverflowMessage() throws IOException {
332        String label = I18NUtils.getMessageString("messages", "label.security.searchOverFlow", new Object[0],
333                getLocale());
334        Map<String, Object> obj = Collections.singletonMap(SuggestConstants.LABEL, label);
335        return Blobs.createJSONBlobFromValue(Collections.singletonList(obj));
336    }
337
338    protected String getLang() {
339        if (lang == null) {
340            lang = (String) ctx.get("lang");
341            if (lang == null) {
342                lang = SuggestConstants.DEFAULT_LANG;
343            }
344        }
345        return lang;
346    }
347
348    protected Locale getLocale() {
349        return new Locale(getLang());
350    }
351
352}