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