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.Context;
036import javax.naming.NamingEnumeration;
037import javax.naming.NamingException;
038import javax.naming.directory.Attribute;
039import javax.naming.directory.SearchControls;
040import javax.naming.directory.SearchResult;
041
042import org.apache.avalon.framework.component.Component;
043import org.apache.avalon.framework.service.ServiceException;
044import org.apache.avalon.framework.service.ServiceManager;
045import org.apache.cocoon.ProcessingException;
046import org.slf4j.Logger;
047
048import org.ametys.cms.contenttype.ContentType;
049import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
050import org.ametys.core.util.ldap.AbstractLDAPConnector;
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    private int _nbError;
076    private boolean _hasGlobalError;
077    
078    @Override
079    public void service(ServiceManager serviceManager) throws ServiceException
080    {
081        _contentTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE); 
082        super.service(serviceManager);
083    }
084    
085    @Override
086    public void _delayedInitialize(String dataSourceId) throws Exception
087    {
088        super._delayedInitialize(dataSourceId);
089    }
090    
091    /**
092     * Search over the LDAP the data from the filter.
093     * After calling this method, call the methods {@link #getNbErrors()} and {@link #hasGlobalError()} to know about error which occured.
094     * @param collectionId The id of the collection being synchronized
095     * @param pageSize The page size for the search
096     * @param relativeDN the name of the context or object to search
097     * @param filter the filter expression to use for the search
098     * @param searchScope The search scope
099     * @param offset Begin of the search
100     * @param limit Number of results
101     * @param mapping The mapping for retrieving the remote values (keys are metadata paths)
102     * @param idKey The key where to search the id value of the content
103     * @param logger The logger
104     * @return A map containing the content ids (keys) to import with their remote values (key is attribute, value is the remote value).
105     */
106    public Map<String, Map<String, Object>> search(String collectionId, int pageSize, String relativeDN, String filter, String searchScope, int offset, int limit, Map<String, List<String>> mapping, String idKey, Logger logger)
107    {
108        Map<String, Map<String, Object>> result = new LinkedHashMap<>();
109        _nbError = 0;
110        _hasGlobalError = false;
111        
112        try
113        {
114            for (SearchResult searchResult : _search(pageSize, relativeDN, filter, null, _getSearchControls(mapping, searchScope), offset, limit))
115            {
116                String idValue = (String) _getIdValue(idKey, searchResult, logger);
117                if (idValue == null)
118                {
119                    _nbError++;
120                    logger.warn("The id value '{}' for '{}' was null ", idKey, searchResult.getName());
121                }
122                else if (!result.keySet().contains(idValue))
123                {
124                    try
125                    {
126                        Map<String, Object> values = new HashMap<>();
127                        NamingEnumeration<? extends Attribute> attributes = searchResult.getAttributes().getAll();
128                        while (attributes.hasMoreElements())
129                        {
130                            Attribute attribute = attributes.nextElement();
131                            values.put(attribute.getID(), _getLDAPValues(attribute));
132                        }
133                        result.put(idValue, values);
134                    }
135                    catch (Exception e)
136                    {
137                        _nbError++;
138                        logger.warn("Failed to import the content '{}'", idValue, e);
139                    }
140                }
141                else
142                {
143                    logger.warn("Cannot import '{}' because its id value '{}={}' is already an id value for another content", searchResult.getName(), idKey, idValue);
144                }
145            }
146        }
147        catch (Exception e)
148        {
149            _hasGlobalError = true;
150            _nbError++;
151            logger.error("Failed to populate contents from synchronizable collection of id '{}'", collectionId, e);
152        }
153        
154        return result;
155    }
156
157    private List<Object> _getLDAPValues(Attribute attribute) throws NamingException
158    {
159        List<Object> ldapValues = new ArrayList<>();
160        
161        NamingEnumeration< ? > values = attribute.getAll();
162        while (values.hasMoreElements())
163        {
164            ldapValues.add(values.nextElement());
165        }
166        
167        return ldapValues;
168    }
169    
170    /**
171     * Returns the number of errors which occured {@link #search(String, int, String, String, String, int, int, Map, String, Logger)}
172     * @return the number of errors which occured {@link #search(String, int, String, String, String, int, int, Map, String, Logger)}
173     */
174    public int getNbErrors()
175    {
176        return _nbError;
177    }
178    
179    /**
180     * Returns true if the a global error occured during {@link #search(String, int, String, String, String, int, int, Map, String, Logger)}
181     * @return true if the a global error occured during {@link #search(String, int, String, String, String, int, int, Map, String, Logger)}
182     */
183    public boolean hasGlobalError()
184    {
185        return _hasGlobalError;
186    }
187    
188    /**
189     * Get the LDAP search controls.
190     * @param mapping The mapping
191     * @param searchScope The search scope
192     * @return the search controls.
193     * @throws ProcessingException if the scope is not valid
194     */
195    protected SearchControls _getSearchControls(Map<String, List<String>> mapping, String searchScope) throws ProcessingException
196    {
197        SearchControls controls = new SearchControls();
198        
199        List<String> attributes = new ArrayList<>();
200        for (List<String> attribute : mapping.values())
201        {
202            attributes.addAll(attribute);
203        }
204        String[] attrArray = attributes.toArray(new String[attributes.size()]);
205        
206        controls.setReturningAttributes(attrArray);
207        
208        controls.setSearchScope(_getScope(searchScope));
209        
210        return controls;
211    }
212    
213    /**
214     * Get the scope as an integer (handlable by the SearchControls) from the scope string.
215     * @param scopeStr the scope string.
216     * @return the scope as an integer.
217     * @throws ProcessingException if the given scope is not valid
218     */
219    protected int _getScope(String scopeStr) throws ProcessingException
220    {
221        try
222        {
223            return ScopeEnumerator.parseScope(scopeStr);
224        }
225        catch (IllegalArgumentException e)
226        {
227            throw new ProcessingException("Unable to parse scope", e);
228        }
229    }
230    
231    /**
232     * Gets id value from a ldap entry
233     * @param idKey The key where to search the id value
234     * @param entry The ldap entry
235     * @param logger The logger
236     * @return The attribute value
237     * @throws NamingException if a ldap query error occurred
238     */
239    protected Object _getIdValue(String idKey, SearchResult entry, Logger logger) throws NamingException
240    {
241        Attribute ldapAttr = entry.getAttributes().get(idKey);
242        
243        if (ldapAttr == null)
244        {
245            logger.warn("LDAP attribute not found: '{}'", idKey);
246        }
247        return ldapAttr != null ? ldapAttr.get() : null;
248    }
249    
250    /**
251     * Clean a connection to a ldap server.
252     * 
253     * @param context The connection to the database to close.
254     * @param result The result to close.
255     * @param logger The logger
256     */
257    protected void _cleanup(Context context, NamingEnumeration result, Logger logger)
258    {
259        if (result != null)
260        {
261            try
262            {
263                result.close();
264            }
265            catch (NamingException e)
266            {
267                _nbError++;
268                logger.error("Error while closing ldap result", e);
269            }
270        }
271        if (context != null)
272        {
273            try
274            {
275                context.close();
276            }
277            catch (NamingException e)
278            {
279                _nbError++;
280                logger.error("Error while closing ldap connection", e);
281            }
282        }
283    }
284    
285    /**
286     * Transform date and datetime attributes on each result line from timestamp to LocalDate (date) or ZonedDateTime (datetime)
287     * @param results The results from LDAP source
288     * @param contentTypeId Content type ID from which attributes come from
289     * @param allAttributes All mapped attributes
290     */
291    public void transformTypedAttributes(Map<String, Map<String, List<Object>>> results, String contentTypeId, Set<String> allAttributes)
292    {
293        // Define date and datetime attributes
294        Set<String> dateAttributes = new HashSet<>();
295        Set<String> datetimeAttributes = new HashSet<>();
296        
297        ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
298        for (String attributePath : allAttributes)
299        {
300            // Some synchronized attributes are not necessarily defined in the model
301            if (contentType.hasModelItem(attributePath))
302            {
303                String attributeType = contentType.getModelItem(attributePath).getType().getId();
304                switch (attributeType)
305                {
306                    case ModelItemTypeConstants.DATE_TYPE_ID:
307                        dateAttributes.add(attributePath);
308                        break;
309                    case ModelItemTypeConstants.DATETIME_TYPE_ID:
310                        datetimeAttributes.add(attributePath);
311                        break;
312                    default:
313                        // Nothing to do
314                        break;
315                }
316            }
317        }
318        
319        // Transform values to typed values
320        if (!dateAttributes.isEmpty() || !datetimeAttributes.isEmpty())
321        {
322            for (Map<String, List<Object>> resultLine : results.values())
323            {
324                _transformValuesAsTypedValues(resultLine, dateAttributes, s -> LocalDate.parse(s, __BASIC_DATE_TIME));
325                _transformValuesAsTypedValues(resultLine, datetimeAttributes, s -> ZonedDateTime.parse(s, __BASIC_DATE_TIME));
326            }
327        }
328    }
329    
330    private <R> void _transformValuesAsTypedValues(Map<String, List<Object>> resultLine, Set<String> attributeNames, Function<String, R> typedFunction)
331    {
332        for (String attributeName : attributeNames)
333        {
334            _transformValueAsTypedValue(resultLine, attributeName, typedFunction);
335        }
336    }
337    
338    private <R> void _transformValueAsTypedValue(Map<String, List<Object>> resultLine, String attributeName, Function<String, R> typedFunction)
339    {
340        List<Object> newValues = Optional.of(attributeName)
341                // Get the date attribute for the current result line
342                .map(resultLine::get)
343                // Stream the list
344                .map(List::stream)
345                .orElseGet(Stream::empty)
346                // Transform each element of the list to a String
347                .map(Object::toString)
348                // Transform it to LocalDate
349                .map(typedFunction)
350                // Collect
351                .collect(Collectors.toList());
352
353        // If there are values, update the line
354        if (!newValues.isEmpty())
355        {
356            resultLine.put(attributeName, newValues);
357        }
358    }
359}