001/*
002 *  Copyright 2016 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.contentio.synchronize.impl;
017
018import java.time.LocalDate;
019import java.time.ZonedDateTime;
020import java.time.format.DateTimeFormatter;
021import java.time.format.DateTimeFormatterBuilder;
022import java.time.temporal.ChronoField;
023import java.util.ArrayList;
024import java.util.HashMap;
025import java.util.HashSet;
026import java.util.LinkedHashMap;
027import java.util.List;
028import java.util.Map;
029import java.util.Optional;
030import java.util.Set;
031import java.util.function.Function;
032import java.util.stream.Collectors;
033import java.util.stream.Stream;
034
035import javax.naming.NamingEnumeration;
036import javax.naming.NamingException;
037import javax.naming.directory.Attribute;
038import javax.naming.directory.SearchControls;
039import javax.naming.directory.SearchResult;
040
041import org.apache.avalon.framework.component.Component;
042import org.apache.avalon.framework.service.ServiceException;
043import org.apache.avalon.framework.service.ServiceManager;
044import org.apache.cocoon.ProcessingException;
045import org.slf4j.Logger;
046
047import org.ametys.cms.contenttype.ContentType;
048import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
049import org.ametys.core.util.ldap.AbstractLDAPConnector;
050import org.ametys.core.util.ldap.ScopeEnumerator;
051import org.ametys.plugins.contentio.synchronize.SynchronizableContentsCollection;
052import org.ametys.runtime.model.type.ModelItemTypeConstants;
053
054/**
055 * Helper component for {@link SynchronizableContentsCollection}s which need to access a LDAP
056 */
057public class LDAPCollectionHelper extends AbstractLDAPConnector implements Component
058{
059    /** Avalon Role */
060    public static final String ROLE = LDAPCollectionHelper.class.getName();
061    
062    private static final DateTimeFormatter __BASIC_DATE_TIME = new DateTimeFormatterBuilder()
063            .append(DateTimeFormatter.BASIC_ISO_DATE)
064            .appendValue(ChronoField.HOUR_OF_DAY, 2)
065            .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
066            .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
067            .appendFraction(ChronoField.MILLI_OF_SECOND, 1, 3, true)
068            .appendOffsetId()
069            .toFormatter();
070
071    /** The content type extension point */
072    protected ContentTypeExtensionPoint _contentTypeEP;
073        
074    @Override
075    public void service(ServiceManager serviceManager) throws ServiceException
076    {
077        _contentTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE); 
078        super.service(serviceManager);
079    }
080        
081    /**
082     * Search over the LDAP the data from the filter.
083     * @param collectionId The id of the collection being synchronized
084     * @param pageSize The page size for the search
085     * @param relativeDN the name of the context or object to search
086     * @param filter the filter expression to use for the search
087     * @param searchScope The search scope
088     * @param offset Begin of the search
089     * @param limit Number of results
090     * @param mapping The mapping for retrieving the remote values (keys are metadata paths)
091     * @param idKey The key where to search the id value of the content
092     * @param logger The logger
093     * @param dataSourceId the datasource id
094     * @return A map containing the content ids (keys) to import with their remote values (key is attribute, value is the remote value).
095     */
096    public synchronized LDAPCollectionHelperSearchResult search(String collectionId, int pageSize, String relativeDN, String filter, String searchScope, int offset, int limit, Map<String, List<String>> mapping, String idKey, Logger logger, String dataSourceId)
097    {
098        Map<String, Map<String, Object>> searchResults = new LinkedHashMap<>();
099        int nbErrors = 0;
100        boolean hasGlobalError = false;
101        
102        try
103        {
104            _delayedInitialize(dataSourceId);
105            for (SearchResult searchResult : _search(pageSize, relativeDN, filter, null, _getSearchControls(mapping, searchScope), offset, limit, false))
106            {
107                String idValue = (String) _getIdValue(idKey, searchResult, logger);
108                if (idValue == null)
109                {
110                    nbErrors++;
111                    logger.warn("The id value '{}' for '{}' was null ", idKey, searchResult.getName());
112                }
113                else if (!searchResults.keySet().contains(idValue))
114                {
115                    try
116                    {
117                        Map<String, Object> values = new HashMap<>();
118                        NamingEnumeration<? extends Attribute> attributes = searchResult.getAttributes().getAll();
119                        while (attributes.hasMoreElements())
120                        {
121                            Attribute attribute = attributes.nextElement();
122                            values.put(attribute.getID(), _getLDAPValues(attribute));
123                        }
124                        searchResults.put(idValue, values);
125                    }
126                    catch (Exception e)
127                    {
128                        nbErrors++;
129                        logger.warn("Failed to import the content '{}'", idValue, e);
130                    }
131                }
132                else
133                {
134                    logger.warn("Cannot import '{}' because its id value '{}={}' is already an id value for another content", searchResult.getName(), idKey, idValue);
135                }
136            }
137        }
138        catch (Exception e)
139        {
140            hasGlobalError = true;
141            nbErrors++;
142            logger.error("Failed to populate contents from synchronizable collection of id '{}'", collectionId, e);
143        }
144        
145        return new LDAPCollectionHelperSearchResult(searchResults, hasGlobalError, nbErrors);
146    }
147
148    /**
149     * Results of {@link LDAPCollectionHelper#search(String, int, String, String, String, int, int, Map, String, Logger, String)}
150     * @param searchResults the search result
151     * @param hasGlobalError the number of errors which occured during search
152     * @param nbErrors true if the a global error occured during search
153     */
154    public record LDAPCollectionHelperSearchResult(Map<String, Map<String, Object>> searchResults, boolean hasGlobalError, int nbErrors) { /* empty */ }
155    
156    private List<Object> _getLDAPValues(Attribute attribute) throws NamingException
157    {
158        List<Object> ldapValues = new ArrayList<>();
159        
160        NamingEnumeration< ? > values = attribute.getAll();
161        while (values.hasMoreElements())
162        {
163            ldapValues.add(values.nextElement());
164        }
165        
166        return ldapValues;
167    }
168    
169    /**
170     * Get the LDAP search controls.
171     * @param mapping The mapping
172     * @param searchScope The search scope
173     * @return the search controls.
174     * @throws ProcessingException if the scope is not valid
175     */
176    protected SearchControls _getSearchControls(Map<String, List<String>> mapping, String searchScope) throws ProcessingException
177    {
178        SearchControls controls = new SearchControls();
179        
180        List<String> attributes = new ArrayList<>();
181        for (List<String> attribute : mapping.values())
182        {
183            attributes.addAll(attribute);
184        }
185        String[] attrArray = attributes.toArray(new String[attributes.size()]);
186        
187        controls.setReturningAttributes(attrArray);
188        
189        controls.setSearchScope(_getScope(searchScope));
190        
191        return controls;
192    }
193    
194    /**
195     * Get the scope as an integer (handlable by the SearchControls) from the scope string.
196     * @param scopeStr the scope string.
197     * @return the scope as an integer.
198     * @throws ProcessingException if the given scope is not valid
199     */
200    protected int _getScope(String scopeStr) throws ProcessingException
201    {
202        try
203        {
204            return ScopeEnumerator.parseScope(scopeStr);
205        }
206        catch (IllegalArgumentException e)
207        {
208            throw new ProcessingException("Unable to parse scope", e);
209        }
210    }
211    
212    /**
213     * Gets id value from a ldap entry
214     * @param idKey The key where to search the id value
215     * @param entry The ldap entry
216     * @param logger The logger
217     * @return The attribute value
218     * @throws NamingException if a ldap query error occurred
219     */
220    protected Object _getIdValue(String idKey, SearchResult entry, Logger logger) throws NamingException
221    {
222        Attribute ldapAttr = entry.getAttributes().get(idKey);
223        
224        if (ldapAttr == null)
225        {
226            logger.warn("LDAP attribute not found: '{}'", idKey);
227        }
228        return ldapAttr != null ? ldapAttr.get() : null;
229    }
230    
231    /**
232     * Transform date and datetime attributes on each result line from timestamp to LocalDate (date) or ZonedDateTime (datetime)
233     * @param results The results from LDAP source
234     * @param contentTypeId Content type ID from which attributes come from
235     * @param allAttributes All mapped attributes
236     */
237    public void transformTypedAttributes(Map<String, Map<String, List<Object>>> results, String contentTypeId, Set<String> allAttributes)
238    {
239        // Define date and datetime attributes
240        Set<String> dateAttributes = new HashSet<>();
241        Set<String> datetimeAttributes = new HashSet<>();
242        
243        ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
244        for (String attributePath : allAttributes)
245        {
246            // Some synchronized attributes are not necessarily defined in the model
247            if (contentType.hasModelItem(attributePath))
248            {
249                String attributeType = contentType.getModelItem(attributePath).getType().getId();
250                switch (attributeType)
251                {
252                    case ModelItemTypeConstants.DATE_TYPE_ID:
253                        dateAttributes.add(attributePath);
254                        break;
255                    case ModelItemTypeConstants.DATETIME_TYPE_ID:
256                        datetimeAttributes.add(attributePath);
257                        break;
258                    default:
259                        // Nothing to do
260                        break;
261                }
262            }
263        }
264        
265        // Transform values to typed values
266        if (!dateAttributes.isEmpty() || !datetimeAttributes.isEmpty())
267        {
268            for (Map<String, List<Object>> resultLine : results.values())
269            {
270                _transformValuesAsTypedValues(resultLine, dateAttributes, s -> LocalDate.parse(s, __BASIC_DATE_TIME));
271                _transformValuesAsTypedValues(resultLine, datetimeAttributes, s -> ZonedDateTime.parse(s, __BASIC_DATE_TIME));
272            }
273        }
274    }
275    
276    private <R> void _transformValuesAsTypedValues(Map<String, List<Object>> resultLine, Set<String> attributeNames, Function<String, R> typedFunction)
277    {
278        for (String attributeName : attributeNames)
279        {
280            _transformValueAsTypedValue(resultLine, attributeName, typedFunction);
281        }
282    }
283    
284    private <R> void _transformValueAsTypedValue(Map<String, List<Object>> resultLine, String attributeName, Function<String, R> typedFunction)
285    {
286        List<Object> newValues = Optional.of(attributeName)
287                // Get the date attribute for the current result line
288                .map(resultLine::get)
289                // Stream the list
290                .map(List::stream)
291                .orElseGet(Stream::empty)
292                // Transform each element of the list to a String
293                .map(Object::toString)
294                // Transform it to LocalDate
295                .map(typedFunction)
296                // Collect
297                .collect(Collectors.toList());
298
299        // If there are values, update the line
300        if (!newValues.isEmpty())
301        {
302            resultLine.put(attributeName, newValues);
303        }
304    }
305}