001/*
002 * (C) Copyright 2017 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 *     Funsho David
018 *
019 */
020
021package org.nuxeo.directory.mongodb;
022
023import static org.nuxeo.directory.mongodb.MongoDBSerializationHelper.MONGODB_ID;
024import static org.nuxeo.directory.mongodb.MongoDBSerializationHelper.MONGODB_SEQ;
025
026import java.io.Serializable;
027import java.util.ArrayList;
028import java.util.Calendar;
029import java.util.Collections;
030import java.util.HashMap;
031import java.util.LinkedList;
032import java.util.List;
033import java.util.Map;
034import java.util.Set;
035import java.util.regex.Pattern;
036import java.util.stream.Collectors;
037
038import org.apache.commons.lang.StringUtils;
039import org.bson.Document;
040import org.bson.conversions.Bson;
041import org.nuxeo.ecm.core.api.DocumentModel;
042import org.nuxeo.ecm.core.api.DocumentModelList;
043import org.nuxeo.ecm.core.api.PropertyException;
044import org.nuxeo.ecm.core.api.impl.DocumentModelListImpl;
045import org.nuxeo.ecm.core.api.model.Property;
046import org.nuxeo.ecm.core.api.security.SecurityConstants;
047import org.nuxeo.ecm.core.schema.types.Field;
048import org.nuxeo.ecm.core.schema.types.Type;
049import org.nuxeo.ecm.core.schema.types.primitives.IntegerType;
050import org.nuxeo.ecm.core.schema.types.primitives.LongType;
051import org.nuxeo.ecm.core.schema.types.primitives.StringType;
052import org.nuxeo.ecm.directory.BaseSession;
053import org.nuxeo.ecm.directory.DirectoryException;
054import org.nuxeo.ecm.directory.PasswordHelper;
055import org.nuxeo.ecm.directory.Reference;
056import org.nuxeo.ecm.directory.Session;
057import org.nuxeo.runtime.api.Framework;
058import org.nuxeo.runtime.mongodb.MongoDBConnectionHelper;
059import org.nuxeo.runtime.mongodb.MongoDBConnectionService;
060
061import com.mongodb.MongoWriteException;
062import com.mongodb.client.FindIterable;
063import com.mongodb.client.MongoCollection;
064import com.mongodb.client.MongoDatabase;
065import com.mongodb.client.model.FindOneAndUpdateOptions;
066import com.mongodb.client.model.ReturnDocument;
067import com.mongodb.client.model.Updates;
068import com.mongodb.client.result.DeleteResult;
069import com.mongodb.client.result.UpdateResult;
070
071/**
072 * MongoDB implementation of a {@link Session}
073 *
074 * @since 9.1
075 */
076public class MongoDBSession extends BaseSession {
077
078    /**
079     * Prefix used to retrieve a MongoDB connection from {@link MongoDBConnectionService}.
080     * <p />
081     * The connection id will be {@code directory/[DIRECTORY_NAME]}.
082     */
083    public static final String DIRECTORY_CONNECTION_PREFIX = "directory/";
084
085    protected final MongoDatabase database;
086
087    protected String countersCollectionName;
088
089    public MongoDBSession(MongoDBDirectory directory) {
090        super(directory, MongoDBReference.class);
091        MongoDBConnectionService mongoService = Framework.getService(MongoDBConnectionService.class);
092        database = mongoService.getDatabase(DIRECTORY_CONNECTION_PREFIX + directory.getDescriptor().name);
093        countersCollectionName = directory.getCountersCollectionName();
094    }
095
096    @Override
097    public MongoDBDirectory getDirectory() {
098        return (MongoDBDirectory) directory;
099    }
100
101    @Override
102    protected DocumentModel createEntryWithoutReferences(Map<String, Object> fieldMap) {
103        // Make a copy of fieldMap to avoid modifying it
104        fieldMap = new HashMap<>(fieldMap);
105
106        // Filter out reference fields for creation as we keep it in a different collection
107        Map<String, Object> newDocMap = fieldMap.entrySet()
108                                                .stream()
109                                                .filter(entry -> getDirectory().getReferences(entry.getKey()) == null)
110                                                .collect(HashMap::new, (m, v) -> m.put(v.getKey(), v.getValue()),
111                                                        HashMap::putAll);
112        Map<String, Field> schemaFieldMap = directory.getSchemaFieldMap();
113        String idFieldName = schemaFieldMap.get(getIdField()).getName().getPrefixedName();
114        String id;
115        if (autoincrementId) {
116            Document filter = MongoDBSerializationHelper.fieldMapToBson(MONGODB_ID, directoryName);
117            Bson update = Updates.inc(MONGODB_SEQ, 1L);
118            FindOneAndUpdateOptions options = new FindOneAndUpdateOptions().returnDocument(ReturnDocument.AFTER);
119            Long longId = getCollection(countersCollectionName).findOneAndUpdate(filter, update, options)
120                                                               .getLong(MONGODB_SEQ);
121            fieldMap.put(idFieldName, longId);
122            newDocMap.put(idFieldName, longId);
123            id = String.valueOf(longId);
124        } else {
125            id = String.valueOf(fieldMap.get(idFieldName));
126            if (hasEntry(id)) {
127                throw new DirectoryException(String.format("Entry with id %s already exists", id));
128            }
129        }
130        try {
131
132            for (Map.Entry<String, Field> entry : schemaFieldMap.entrySet()) {
133                Field field = entry.getValue();
134                if (field != null) {
135                    String fieldName = field.getName().getPrefixedName();
136                    Object value = newDocMap.get(fieldName);
137                    Type type = field.getType();
138                    if (value instanceof String) {
139                        String v = (String) value;
140                        if (type instanceof IntegerType) {
141                            newDocMap.put(fieldName, Integer.valueOf(v));
142                        } else if (type instanceof LongType) {
143                            newDocMap.put(fieldName, Long.valueOf(v));
144                        }
145                    } else if (value instanceof Number) {
146                        if (type instanceof LongType && value instanceof Integer) {
147                            newDocMap.put(fieldName, Long.valueOf((Integer) value));
148                        } else if (type instanceof StringType) {
149                            newDocMap.put(fieldName, value.toString());
150                        }
151                    }
152                    // Load default values if defined and not present in the map
153                    if (!newDocMap.containsKey(fieldName)) {
154                        Object defaultValue = field.getDefaultValue();
155                        if (defaultValue != null) {
156                            newDocMap.put(fieldName, defaultValue);
157                        }
158                    }
159                }
160            }
161            Document bson = MongoDBSerializationHelper.fieldMapToBson(newDocMap);
162            String password = (String) newDocMap.get(getPasswordField());
163            if (password != null && !PasswordHelper.isHashed(password)) {
164                password = PasswordHelper.hashPassword(password, passwordHashAlgorithm);
165                bson.append(getPasswordField(), password);
166            }
167            getCollection().insertOne(bson);
168        } catch (MongoWriteException e) {
169            throw new DirectoryException(e);
170        }
171        return createEntryModel(null, schemaName, id, fieldMap, isReadOnly());
172    }
173
174    @Override
175    protected List<String> updateEntryWithoutReferences(DocumentModel docModel) throws DirectoryException {
176        Map<String, Object> fieldMap = new HashMap<>();
177        List<String> referenceFieldList = new LinkedList<>();
178        Map<String, Field> schemaFieldMap = directory.getSchemaFieldMap();
179        for (String fieldName : schemaFieldMap.keySet()) {
180            if (fieldName.equals(getIdField())) {
181                continue;
182            }
183            Property prop = docModel.getPropertyObject(schemaName, fieldName);
184            if (prop == null || !prop.isDirty()
185                    || (fieldName.equals(getPasswordField()) && StringUtils.isEmpty((String) prop.getValue()))) {
186                continue;
187            }
188            if (getDirectory().isReference(fieldName)) {
189                referenceFieldList.add(fieldName);
190            } else {
191                Serializable value = prop.getValue();
192                if (fieldName.equals(getPasswordField())) {
193                    value = PasswordHelper.hashPassword((String) value, passwordHashAlgorithm);
194                }
195                if (value instanceof Calendar) {
196                    value = ((Calendar) value).getTime();
197                }
198                fieldMap.put(prop.getName(), value);
199            }
200        }
201
202        String idFieldName = schemaFieldMap.get(getIdField()).getName().getPrefixedName();
203        String id = docModel.getId();
204        Document bson = MongoDBSerializationHelper.fieldMapToBson(idFieldName, autoincrementId ? Long.valueOf(id) : id);
205
206        List<Bson> updates = fieldMap.entrySet().stream().map(e -> Updates.set(e.getKey(), e.getValue())).collect(
207                Collectors.toList());
208
209        if (!updates.isEmpty()) {
210            try {
211                UpdateResult result = getCollection().updateOne(bson, Updates.combine(updates));
212                // Throw an error if no document matched the update
213                if (!result.wasAcknowledged()) {
214                    throw new DirectoryException(
215                            "Error while updating the entry, the request has not been acknowledged by the server");
216                }
217                if (result.getMatchedCount() == 0) {
218                    throw new DirectoryException(
219                            String.format("Error while updating the entry, no document was found with the id %s", id));
220                }
221            } catch (MongoWriteException e) {
222                throw new DirectoryException(e);
223            }
224        }
225        return referenceFieldList;
226    }
227
228    @Override
229    public void deleteEntryWithoutReferences(String id) throws DirectoryException {
230        try {
231            String idFieldName = directory.getSchemaFieldMap().get(getIdField()).getName().getPrefixedName();
232            DeleteResult result = getCollection().deleteOne(
233                    MongoDBSerializationHelper.fieldMapToBson(idFieldName, autoincrementId ? Long.valueOf(id) : id));
234            if (!result.wasAcknowledged()) {
235                throw new DirectoryException(
236                        "Error while deleting the entry, the request has not been acknowledged by the server");
237            }
238        } catch (MongoWriteException e) {
239            throw new DirectoryException(e);
240        }
241    }
242
243    @Override
244    public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext, Map<String, String> orderBy,
245            boolean fetchReferences) throws DirectoryException {
246        return query(filter, fulltext, orderBy, fetchReferences, -1, 0);
247    }
248
249    @Override
250    public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext, Map<String, String> orderBy,
251            boolean fetchReferences, int limit, int offset) throws DirectoryException {
252
253        if (!hasPermission(SecurityConstants.READ)) {
254            return new DocumentModelListImpl();
255        }
256
257        // Remove password as it is not possible to do queries with it
258        filter.remove(getPasswordField());
259        Document bson = buildQuery(filter, fulltext);
260
261        DocumentModelList entries = new DocumentModelListImpl();
262
263        FindIterable<Document> results = getCollection().find(bson).skip(offset);
264        if (limit > 0) {
265            results.limit(limit);
266        }
267        for (Document resultDoc : results) {
268
269            // Cast object to document model
270            Map<String, Object> fieldMap = MongoDBSerializationHelper.bsonToFieldMap(resultDoc);
271            // Remove password from results
272            if (!readAllColumns) {
273                fieldMap.remove(getPasswordField());
274            }
275            DocumentModel doc = fieldMapToDocumentModel(fieldMap);
276
277            if (fetchReferences) {
278                Map<String, List<String>> targetIdsMap = new HashMap<>();
279                for (Reference reference : directory.getReferences()) {
280                    List<String> targetIds;
281                    if (reference instanceof MongoDBReference) {
282                        MongoDBReference mongoReference = (MongoDBReference) reference;
283                        targetIds = mongoReference.getTargetIdsForSource(doc.getId(), this);
284                    } else {
285                        targetIds = reference.getTargetIdsForSource(doc.getId());
286                    }
287                    targetIds = new ArrayList<>(targetIds);
288                    Collections.sort(targetIds);
289                    String fieldName = reference.getFieldName();
290                    targetIdsMap.computeIfAbsent(fieldName, key -> new ArrayList<>()).addAll(targetIds);
291                }
292                for (Map.Entry<String, List<String>> entry : targetIdsMap.entrySet()) {
293                    String fieldName = entry.getKey();
294                    List<String> targetIds = entry.getValue();
295                    try {
296                        doc.setProperty(schemaName, fieldName, targetIds);
297                    } catch (PropertyException e) {
298                        throw new DirectoryException(e);
299                    }
300                }
301            }
302            entries.add(doc);
303        }
304
305        if (orderBy != null && !orderBy.isEmpty()) {
306            getDirectory().orderEntries(entries, orderBy);
307        }
308
309        return entries;
310    }
311
312    protected Document buildQuery(Map<String, Serializable> fieldMap, Set<String> fulltext) {
313        Map<String, Field> schemaFieldMap = directory.getSchemaFieldMap();
314        Document bson = new Document();
315        for (Map.Entry<String, Serializable> entry : fieldMap.entrySet()) {
316            Field field = schemaFieldMap.entrySet()
317                                        .stream()
318                                        .filter(e -> e.getValue().getName().getPrefixedName().equals(entry.getKey()))
319                                        .map(Map.Entry::getValue)
320                                        .findFirst()
321                                        .orElse(null);
322
323            Serializable v = entry.getValue();
324            Object value = (field != null) ? MongoDBSerializationHelper.valueToBson(v, field.getType())
325                    : MongoDBSerializationHelper.valueToBson(v);
326            String key = entry.getKey();
327            if (fulltext != null && fulltext.contains(key)) {
328                String val = String.valueOf(value);
329                if (val != null) {
330                    val = val.replaceAll("%+", ".*");
331                }
332                switch (substringMatchType) {
333                case subany:
334                    addField(bson, key, Pattern.compile(val, Pattern.CASE_INSENSITIVE));
335                    break;
336                case subinitial:
337                    addField(bson, key, Pattern.compile('^' + val, Pattern.CASE_INSENSITIVE));
338                    break;
339                case subfinal:
340                    addField(bson, key, Pattern.compile(val + '$', Pattern.CASE_INSENSITIVE));
341                    break;
342                }
343            } else {
344                addField(bson, key, value);
345            }
346        }
347        return bson;
348    }
349
350    protected void addField(Document bson, String key, Object value) {
351        String keyFieldName = key;
352        Field field = directory.getSchemaFieldMap().get(key);
353        if (field != null) {
354            keyFieldName = field.getName().getPrefixedName();
355        }
356        bson.put(keyFieldName, value);
357    }
358
359    @Override
360    public void close() throws DirectoryException {
361        getDirectory().removeSession(this);
362    }
363
364    @Override
365    public boolean authenticate(String username, String password) throws DirectoryException {
366        Document user = getCollection().find(MongoDBSerializationHelper.fieldMapToBson(getIdField(), username)).first();
367        if (user == null) {
368            return false;
369        }
370        String storedPassword = user.getString(getPasswordField());
371        return PasswordHelper.verifyPassword(password, storedPassword);
372    }
373
374    @Override
375    public boolean isAuthenticating() {
376        return directory.getSchemaFieldMap().containsKey(getPasswordField());
377    }
378
379    @Override
380    public boolean hasEntry(String id) {
381        return getCollection().count(MongoDBSerializationHelper.fieldMapToBson(getIdField(), id)) > 0;
382    }
383
384    /**
385     * Retrieve a collection
386     *
387     * @param collection the collection name
388     * @return the MongoDB collection
389     */
390    public MongoCollection<Document> getCollection(String collection) {
391        return database.getCollection(collection);
392    }
393
394    /**
395     * Retrieve the collection associated to this directory
396     *
397     * @return the MongoDB collection
398     */
399    public MongoCollection<Document> getCollection() {
400        return getCollection(directoryName);
401    }
402
403    /**
404     * Check if the MongoDB server has the collection
405     *
406     * @param collection the collection name
407     * @return true if the server has the collection, false otherwise
408     */
409    public boolean hasCollection(String collection) {
410        return MongoDBConnectionHelper.hasCollection(database, collection);
411    }
412
413    protected DocumentModel fieldMapToDocumentModel(Map<String, Object> fieldMap) {
414        String idFieldName = directory.getSchemaFieldMap().get(getIdField()).getName().getPrefixedName();
415        if (!fieldMap.containsKey(idFieldName)) {
416            idFieldName = getIdField();
417        }
418        String id = String.valueOf(fieldMap.get(idFieldName));
419        return createEntryModel(null, schemaName, id, fieldMap, isReadOnly());
420    }
421
422}