/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.jooby.mongodb;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoCursor;
import com.mongodb.client.MongoDatabase;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.IndexOptions;
import com.mongodb.client.model.UpdateOptions;
import com.typesafe.config.Config;
import com.typesafe.config.ConfigFactory;
import com.typesafe.config.ConfigValueFactory;
import org.bson.Document;
import org.bson.conversions.Bson;
import org.jooby.Session;
import org.jooby.Session.Builder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javax.inject.Named;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static java.util.Objects.requireNonNull;
/**
* A {@link Session.Store} powered by
* <a href="http://mongodb.github.io/mongo-java-driver/">Mongodb</a>.
*
* <h2>usage</h2>
*
* <pre>
* {
* use(new Mongodb());
*
* session(MongoSessionStore.class);
*
* get("/", req {@literal ->} {
* req.session().set("name", "jooby");
* });
* }
* </pre>
*
* The <code>name</code> attribute and value will be stored in a
* <a href="http://mongodb.github.io/mongo-java-driver/">Mongodb</a>.
*
* <h2>options</h2>
*
* <h3>timeout</h3>
* <p>
* By default, a mongodb session will expire after <code>30 minutes</code>. Changing the default
* timeout is as simple as:
* </p>
*
* <pre>
* # 8 hours
* session.timeout = 8h
*
* # 15 seconds
* session.timeout = 15
*
* # 120 minutes
* session.timeout = 120m
* </pre>
*
* <p>
* It uses MongoDB's TTL collection feature (2.2+) to have <code>mongod</code> automatically remove
* expired sessions.
* </p>
*
* If no timeout is required, use <code>-1</code>.
*
* <h3>session collection</h3>
* <p>
* Default mongodb collection is <code>sessions</code>.
*
* <p>
* It's possible to change the default key setting the <code>mongodb.sesssion.collection</code>
* properties.
* </p>
*
* @author edgar
* @since 0.5.0
*/
public class MongoSessionStore implements Session.Store {
private static final char DOT = '.';
private static final char UDOT = '\uFF0E';
private static final char DOLLAR = '$';
private static final char UDOLLAR = '\uFF04';
private static final String SESSION_IDX = "_sessionIdx_";
/** The logging system. */
private final Logger log = LoggerFactory.getLogger(getClass());
protected final MongoCollection<Document> sessions;
protected final long timeout;
protected final String collection;
private final AtomicBoolean ttlSync = new AtomicBoolean(false);
protected final MongoDatabase db;
public MongoSessionStore(final MongoDatabase db, final String collection,
final long timeoutInSeconds) {
this.db = requireNonNull(db, "Mongo db is required.");
this.collection = requireNonNull(collection, "Collection is required.");
this.sessions = db.getCollection(collection);
this.timeout = timeoutInSeconds;
}
@Inject
public MongoSessionStore(final MongoDatabase db,
final @Named("mongodb.session.collection") String collection,
final @Named("session.timeout") String timeout) {
this(db, collection, seconds(timeout));
}
@SuppressWarnings({"unchecked", "rawtypes"})
@Override
public Session get(final Builder builder) {
return Optional.ofNullable(sessions.find(Filters.eq("_id", builder.sessionId())).first())
.map(doc -> {
Map session = new LinkedHashMap<>(doc);
Date accessedAt = (Date) session.remove("_accessedAt");
Date createdAt = (Date) session.remove("_createdAt");
Date savedAt = (Date) session.remove("_savedAt");
session.remove("_id");
builder
.accessedAt(accessedAt.getTime())
.createdAt(createdAt.getTime())
.savedAt(savedAt.getTime());
session.forEach((k, v) -> builder.set(decode(k.toString()), v.toString()));
return builder.build();
}).orElse(null);
}
@Override
public void save(final Session session) {
syncTtl();
String id = session.id();
Bson filter = Filters.eq("_id", id);
Document doc = new Document()
.append("_id", id)
.append("_accessedAt", new Date(session.accessedAt()))
.append("_createdAt", new Date(session.createdAt()))
.append("_savedAt", new Date(session.savedAt()));
// dump attributes
Map<String, String> attributes = session.attributes();
attributes.forEach((k, v) -> doc.append(encode(k), v));
sessions.updateOne(filter, new Document("$set", doc), new UpdateOptions().upsert(true));
}
@Override
public void create(final Session session) {
save(session);
}
private void syncTtl() {
if (!ttlSync.get()) {
ttlSync.set(true);
if (timeout <= 0) {
return;
}
log.debug("creating session timeout index");
if (existsIdx(SESSION_IDX)) {
Document command = new Document("collMod", collection)
.append("index",
new Document("keyPattern", new Document("_accessedAt", 1))
.append("expireAfterSeconds", timeout));
log.debug("{}", command);
Document result = db.runCommand(command);
log.debug("{}", result);
} else {
sessions.createIndex(
new Document("_accessedAt", 1),
new IndexOptions()
.name(SESSION_IDX)
.expireAfter(timeout, TimeUnit.SECONDS));
}
}
}
@Override
public void delete(final String id) {
sessions.deleteOne(new Document("_id", id));
}
private static long seconds(final String value) {
try {
return Long.parseLong(value);
} catch (NumberFormatException ex) {
Config config = ConfigFactory.empty()
.withValue("timeout", ConfigValueFactory.fromAnyRef(value));
return config.getDuration("timeout", TimeUnit.SECONDS);
}
}
private boolean existsIdx(final String name) {
MongoCursor<Document> iterator = sessions.listIndexes().iterator();
while (iterator.hasNext()) {
Document doc = iterator.next();
if (doc.getString("name").equals(name)) {
return true;
}
}
return false;
}
private String encode(final String key) {
String value = key;
if (value.charAt(0) == DOLLAR) {
value = UDOLLAR + value.substring(1);
}
return value.replace(DOT, UDOT);
}
private String decode(final String key) {
String value = key;
if (value.charAt(0) == UDOLLAR) {
value = DOLLAR + value.substring(1);
}
return value.replace(UDOT, DOT);
}
}