diff --git a/README.md b/README.md index 2172015a..85ffffee 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ Docs is an open source, lightweight document management system. Docs is written in Java, and may be run on any operating system with Java support. +Demo +---- + +A demo is available at [demo.sismicsdocs.com](https://demo.sismicsdocs.com) +- Guest login is enabled with read access on all documents +- "admin" login with "admin" password +- "demo" login with "password" password + Features -------- diff --git a/docs-core/pom.xml b/docs-core/pom.xml index 85e0267d..cafafa76 100644 --- a/docs-core/pom.xml +++ b/docs-core/pom.xml @@ -51,7 +51,22 @@ commons-lang commons-lang + + + org.apache.commons + commons-email + + + org.freemarker + freemarker + + + + org.glassfish + javax.json + + log4j log4j diff --git a/docs-core/src/main/java/com/sismics/docs/core/constant/Constants.java b/docs-core/src/main/java/com/sismics/docs/core/constant/Constants.java index aba7c82d..a9274e41 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/constant/Constants.java +++ b/docs-core/src/main/java/com/sismics/docs/core/constant/Constants.java @@ -44,4 +44,37 @@ public class Constants { * Supported document languages. */ public static final List SUPPORTED_LANGUAGES = Lists.newArrayList("eng", "fra", "ita", "deu", "spa", "por", "pol", "rus", "ukr", "ara", "hin", "chi_sim", "chi_tra", "jpn", "tha", "kor"); + + /** + * Base URL environment variable. + */ + public static final String BASE_URL_ENV = "DOCS_BASE_URL"; + + /** + * Default language environment variable. + */ + public static final String DEFAULT_LANGUAGE_ENV = "DOCS_DEFAULT_LANGUAGE"; + + /** + * SMTP configuration environment variables. + */ + public static final String SMTP_HOSTNAME_ENV = "DOCS_SMTP_HOSTNAME"; + public static final String SMTP_PORT_ENV = "DOCS_SMTP_PORT"; + public static final String SMTP_USERNAME_ENV = "DOCS_SMTP_USERNAME"; + public static final String SMTP_PASSWORD_ENV = "DOCS_SMTP_PASSWORD"; + + /** + * Global quota environment variable. + */ + public static final String GLOBAL_QUOTA_ENV = "DOCS_GLOBAL_QUOTA"; + + /** + * Expiration time of the password recovery in hours. + */ + public static final int PASSWORD_RECOVERY_EXPIRATION_HOUR = 2; + + /** + * Email template for password recovery. + */ + public static final String EMAIL_TEMPLATE_PASSWORD_RECOVERY = "password_recovery"; } diff --git a/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/DocumentDao.java b/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/DocumentDao.java index 403f2ee2..24b99c35 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/DocumentDao.java +++ b/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/DocumentDao.java @@ -1,18 +1,5 @@ package com.sismics.docs.core.dao.jpa; -import java.sql.Timestamp; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; - -import javax.persistence.EntityManager; -import javax.persistence.NoResultException; -import javax.persistence.Query; - import com.google.common.base.Joiner; import com.google.common.base.Strings; import com.sismics.docs.core.constant.AuditLogType; @@ -28,6 +15,12 @@ import com.sismics.docs.core.util.jpa.QueryParam; import com.sismics.docs.core.util.jpa.SortCriteria; import com.sismics.util.context.ThreadLocalContext; +import javax.persistence.EntityManager; +import javax.persistence.NoResultException; +import javax.persistence.Query; +import java.sql.Timestamp; +import java.util.*; + /** * Document DAO. * @@ -322,4 +315,15 @@ public class DocumentDao { return documentFromDb; } + + /** + * Returns the number of documents. + * + * @return Number of documents + */ + public long getDocumentCount() { + EntityManager em = ThreadLocalContext.get().getEntityManager(); + Query query = em.createNativeQuery("select count(d.DOC_ID_C) from T_DOCUMENT d where d.DOC_DELETEDATE_D is null"); + return ((Number) query.getSingleResult()).longValue(); + } } diff --git a/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/PasswordRecoveryDao.java b/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/PasswordRecoveryDao.java new file mode 100644 index 00000000..1eb5bec0 --- /dev/null +++ b/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/PasswordRecoveryDao.java @@ -0,0 +1,68 @@ +package com.sismics.docs.core.dao.jpa; + +import com.sismics.docs.core.constant.Constants; +import com.sismics.docs.core.model.jpa.PasswordRecovery; +import com.sismics.util.context.ThreadLocalContext; +import org.joda.time.DateTime; +import org.joda.time.DurationFieldType; + +import javax.persistence.EntityManager; +import javax.persistence.NoResultException; +import javax.persistence.Query; +import java.util.Date; +import java.util.UUID; + +/** + * Password recovery DAO. + * + * @author jtremeaux + */ +public class PasswordRecoveryDao { + /** + * Create a new password recovery request. + * + * @param passwordRecovery Password recovery + * @return Unique identifier + */ + public String create(PasswordRecovery passwordRecovery) { + passwordRecovery.setId(UUID.randomUUID().toString()); + passwordRecovery.setCreateDate(new Date()); + + EntityManager em = ThreadLocalContext.get().getEntityManager(); + em.persist(passwordRecovery); + + return passwordRecovery.getId(); + } + + /** + * Search an active password recovery by unique identifier. + * + * @param id Unique identifier + * @return Password recovery + */ + public PasswordRecovery getActiveById(String id) { + EntityManager em = ThreadLocalContext.get().getEntityManager(); + try { + Query q = em.createQuery("select r from PasswordRecovery r where r.id = :id and r.createDate > :createDateMin and r.deleteDate is null"); + q.setParameter("id", id); + q.setParameter("createDateMin", new DateTime().withFieldAdded(DurationFieldType.hours(), -1 * Constants.PASSWORD_RECOVERY_EXPIRATION_HOUR).toDate()); + return (PasswordRecovery) q.getSingleResult(); + } catch (NoResultException e) { + return null; + } + } + + /** + * Deletes active password recovery by username. + * + * @param username Username + */ + public void deleteActiveByLogin(String username) { + EntityManager em = ThreadLocalContext.get().getEntityManager(); + Query q = em.createQuery("update PasswordRecovery r set r.deleteDate = :deleteDate where r.username = :username and r.createDate > :createDateMin and r.deleteDate is null"); + q.setParameter("username", username); + q.setParameter("deleteDate", new Date()); + q.setParameter("createDateMin", new DateTime().withFieldAdded(DurationFieldType.hours(), -1 * Constants.PASSWORD_RECOVERY_EXPIRATION_HOUR).toDate()); + q.executeUpdate(); + } +} diff --git a/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/UserDao.java b/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/UserDao.java index 209fa97c..87166a59 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/UserDao.java +++ b/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/UserDao.java @@ -1,19 +1,5 @@ package com.sismics.docs.core.dao.jpa; -import java.sql.Timestamp; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import javax.persistence.EntityManager; -import javax.persistence.NoResultException; -import javax.persistence.Query; - -import org.mindrot.jbcrypt.BCrypt; - import com.google.common.base.Joiner; import com.sismics.docs.core.constant.AuditLogType; import com.sismics.docs.core.dao.jpa.criteria.UserCriteria; @@ -24,6 +10,14 @@ import com.sismics.docs.core.util.jpa.QueryParam; import com.sismics.docs.core.util.jpa.QueryUtil; import com.sismics.docs.core.util.jpa.SortCriteria; import com.sismics.util.context.ThreadLocalContext; +import org.joda.time.DateTime; +import org.mindrot.jbcrypt.BCrypt; + +import javax.persistence.EntityManager; +import javax.persistence.NoResultException; +import javax.persistence.Query; +import java.sql.Timestamp; +import java.util.*; /** * User DAO. @@ -44,7 +38,7 @@ public class UserDao { q.setParameter("username", username); try { User user = (User) q.getSingleResult(); - if (!BCrypt.checkpw(password, user.getPassword())) { + if (!BCrypt.checkpw(password, user.getPassword()) || user.getDisableDate() != null) { return null; } return user; @@ -59,7 +53,7 @@ public class UserDao { * @param user User to create * @param userId User ID * @return User ID - * @throws Exception + * @throws Exception e */ public String create(User user, String userId) throws Exception { // Create the user UUID @@ -105,7 +99,8 @@ public class UserDao { userFromDb.setStorageQuota(user.getStorageQuota()); userFromDb.setStorageCurrent(user.getStorageCurrent()); userFromDb.setTotpKey(user.getTotpKey()); - + userFromDb.setDisableDate(user.getDisableDate()); + // Create audit log AuditLogUtil.create(userFromDb, AuditLogType.UPDATE, userId); @@ -116,9 +111,8 @@ public class UserDao { * Updates a user's quota. * * @param user User to update - * @return Updated user */ - public User updateQuota(User user) { + public void updateQuota(User user) { EntityManager em = ThreadLocalContext.get().getEntityManager(); // Get the user @@ -128,8 +122,6 @@ public class UserDao { // Update the user userFromDb.setStorageQuota(user.getStorageQuota()); - - return user; } /** @@ -256,7 +248,7 @@ public class UserDao { Map parameterMap = new HashMap<>(); List criteriaList = new ArrayList<>(); - StringBuilder sb = new StringBuilder("select u.USE_ID_C as c0, u.USE_USERNAME_C as c1, u.USE_EMAIL_C as c2, u.USE_CREATEDATE_D as c3, u.USE_STORAGECURRENT_N as c4, u.USE_STORAGEQUOTA_N as c5, u.USE_TOTPKEY_C as c6"); + StringBuilder sb = new StringBuilder("select u.USE_ID_C as c0, u.USE_USERNAME_C as c1, u.USE_EMAIL_C as c2, u.USE_CREATEDATE_D as c3, u.USE_STORAGECURRENT_N as c4, u.USE_STORAGEQUOTA_N as c5, u.USE_TOTPKEY_C as c6, u.USE_DISABLEDATE_D as c7"); sb.append(" from T_USER u "); // Add search criterias @@ -293,9 +285,38 @@ public class UserDao { userDto.setCreateTimestamp(((Timestamp) o[i++]).getTime()); userDto.setStorageCurrent(((Number) o[i++]).longValue()); userDto.setStorageQuota(((Number) o[i++]).longValue()); - userDto.setTotpKey((String) o[i]); + userDto.setTotpKey((String) o[i++]); + if (o[i] != null) { + userDto.setDisableTimestamp(((Timestamp) o[i]).getTime()); + } userDtoList.add(userDto); } return userDtoList; } + + /** + * Returns the global storage used by all users. + * + * @return Current global storage + */ + public long getGlobalStorageCurrent() { + EntityManager em = ThreadLocalContext.get().getEntityManager(); + Query query = em.createNativeQuery("select sum(u.USE_STORAGECURRENT_N) from T_USER u where u.USE_DELETEDATE_D is null"); + return ((Number) query.getSingleResult()).longValue(); + } + + /** + * Returns the number of active users. + * + * @return Number of active users + */ + public long getActiveUserCount() { + EntityManager em = ThreadLocalContext.get().getEntityManager(); + Query query = em.createNativeQuery("select count(u.USE_ID_C) from T_USER u where u.USE_DELETEDATE_D is null and (u.USE_DISABLEDATE_D is null or u.USE_DISABLEDATE_D >= :fromDate and u.USE_DISABLEDATE_D < :toDate)"); + DateTime fromDate = DateTime.now().minusMonths(1).dayOfMonth().withMinimumValue().withTimeAtStartOfDay(); + DateTime toDate = fromDate.plusMonths(1); + query.setParameter("fromDate", fromDate.toDate()); + query.setParameter("toDate", toDate.toDate()); + return ((Number) query.getSingleResult()).longValue(); + } } diff --git a/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/dto/UserDto.java b/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/dto/UserDto.java index 081e3a5b..9085523c 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/dto/UserDto.java +++ b/docs-core/src/main/java/com/sismics/docs/core/dao/jpa/dto/UserDto.java @@ -26,6 +26,11 @@ public class UserDto { */ private Long createTimestamp; + /** + * Disable date of this user. + */ + private Long disableTimestamp; + /** * Storage quota. */ @@ -72,7 +77,16 @@ public class UserDto { public void setCreateTimestamp(Long createTimestamp) { this.createTimestamp = createTimestamp; } - + + public Long getDisableTimestamp() { + return disableTimestamp; + } + + public UserDto setDisableTimestamp(Long disableTimestamp) { + this.disableTimestamp = disableTimestamp; + return this; + } + public Long getStorageQuota() { return storageQuota; } diff --git a/docs-core/src/main/java/com/sismics/docs/core/event/PasswordLostEvent.java b/docs-core/src/main/java/com/sismics/docs/core/event/PasswordLostEvent.java new file mode 100644 index 00000000..78f39d73 --- /dev/null +++ b/docs-core/src/main/java/com/sismics/docs/core/event/PasswordLostEvent.java @@ -0,0 +1,46 @@ +package com.sismics.docs.core.event; + +import com.google.common.base.MoreObjects; +import com.sismics.docs.core.model.jpa.PasswordRecovery; +import com.sismics.docs.core.model.jpa.User; + +/** + * Event fired on user's password lost event. + * + * @author jtremeaux + */ +public class PasswordLostEvent { + /** + * User. + */ + private User user; + + /** + * Password recovery request. + */ + private PasswordRecovery passwordRecovery; + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public PasswordRecovery getPasswordRecovery() { + return passwordRecovery; + } + + public void setPasswordRecovery(PasswordRecovery passwordRecovery) { + this.passwordRecovery = passwordRecovery; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("user", user) + .add("passwordRecovery", "**hidden**") + .toString(); + } +} diff --git a/docs-core/src/main/java/com/sismics/docs/core/listener/async/PasswordLostAsyncListener.java b/docs-core/src/main/java/com/sismics/docs/core/listener/async/PasswordLostAsyncListener.java new file mode 100644 index 00000000..8c4aa48b --- /dev/null +++ b/docs-core/src/main/java/com/sismics/docs/core/listener/async/PasswordLostAsyncListener.java @@ -0,0 +1,53 @@ +package com.sismics.docs.core.listener.async; + +import com.google.common.eventbus.Subscribe; +import com.sismics.docs.core.constant.Constants; +import com.sismics.docs.core.event.PasswordLostEvent; +import com.sismics.docs.core.model.jpa.PasswordRecovery; +import com.sismics.docs.core.model.jpa.User; +import com.sismics.docs.core.util.TransactionUtil; +import com.sismics.util.EmailUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; + +/** + * Listener for password recovery requests. + * + * @author jtremeaux + */ +public class PasswordLostAsyncListener { + /** + * Logger. + */ + private static final Logger log = LoggerFactory.getLogger(PasswordLostAsyncListener.class); + + /** + * Handle events. + * + * @param passwordLostEvent Event + */ + @Subscribe + public void onPasswordLost(final PasswordLostEvent passwordLostEvent) { + if (log.isInfoEnabled()) { + log.info("Password lost event: " + passwordLostEvent.toString()); + } + + TransactionUtil.handle(new Runnable() { + @Override + public void run() { + final User user = passwordLostEvent.getUser(); + final PasswordRecovery passwordRecovery = passwordLostEvent.getPasswordRecovery(); + + // Send the password recovery email + Map paramRootMap = new HashMap<>(); + paramRootMap.put("user_name", user.getUsername()); + paramRootMap.put("password_recovery_key", passwordRecovery.getId()); + + EmailUtil.sendEmail(Constants.EMAIL_TEMPLATE_PASSWORD_RECOVERY, user, paramRootMap); + } + }); + } +} diff --git a/docs-core/src/main/java/com/sismics/docs/core/model/context/AppContext.java b/docs-core/src/main/java/com/sismics/docs/core/model/context/AppContext.java index 78a7cb45..b869eee6 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/model/context/AppContext.java +++ b/docs-core/src/main/java/com/sismics/docs/core/model/context/AppContext.java @@ -1,5 +1,16 @@ package com.sismics.docs.core.model.context; +import com.google.common.eventbus.AsyncEventBus; +import com.google.common.eventbus.EventBus; +import com.sismics.docs.core.constant.ConfigType; +import com.sismics.docs.core.dao.jpa.ConfigDao; +import com.sismics.docs.core.listener.async.*; +import com.sismics.docs.core.listener.sync.DeadEventListener; +import com.sismics.docs.core.model.jpa.Config; +import com.sismics.docs.core.service.IndexingService; +import com.sismics.docs.core.util.PdfUtil; +import com.sismics.util.EnvironmentUtil; + import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; @@ -7,19 +18,6 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import com.google.common.eventbus.AsyncEventBus; -import com.google.common.eventbus.EventBus; -import com.lowagie.text.FontFactory; -import com.sismics.docs.core.constant.ConfigType; -import com.sismics.docs.core.dao.jpa.ConfigDao; -import com.sismics.docs.core.event.TemporaryFileCleanupAsyncEvent; -import com.sismics.docs.core.listener.async.*; -import com.sismics.docs.core.listener.sync.DeadEventListener; -import com.sismics.docs.core.model.jpa.Config; -import com.sismics.docs.core.service.IndexingService; -import com.sismics.docs.core.util.PdfUtil; -import com.sismics.util.EnvironmentUtil; - /** * Global application context. * @@ -41,6 +39,11 @@ public class AppContext { */ private EventBus asyncEventBus; + /** + * Asynchronous bus for email sending. + */ + private EventBus mailEventBus; + /** * Indexing service. */ @@ -83,6 +86,9 @@ public class AppContext { asyncEventBus.register(new DocumentDeletedAsyncListener()); asyncEventBus.register(new RebuildIndexAsyncListener()); asyncEventBus.register(new TemporaryFileCleanupAsyncListener()); + + mailEventBus = newAsyncEventBus(); + mailEventBus.register(new PasswordLostAsyncListener()); } /** @@ -138,29 +144,18 @@ public class AppContext { } } - /** - * Getter of eventBus. - * - * @return eventBus - */ public EventBus getEventBus() { return eventBus; } - /** - * Getter of asyncEventBus. - * - * @return asyncEventBus - */ public EventBus getAsyncEventBus() { return asyncEventBus; } - /** - * Getter of indexingService. - * - * @return indexingService - */ + public EventBus getMailEventBus() { + return mailEventBus; + } + public IndexingService getIndexingService() { return indexingService; } diff --git a/docs-core/src/main/java/com/sismics/docs/core/model/jpa/File.java b/docs-core/src/main/java/com/sismics/docs/core/model/jpa/File.java index 243e0386..3db2ab7b 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/model/jpa/File.java +++ b/docs-core/src/main/java/com/sismics/docs/core/model/jpa/File.java @@ -171,7 +171,7 @@ public class File implements Loggable { @Override public String toMessage() { // Attached document ID and name concatenated - return documentId + name; + return (documentId == null ? Strings.repeat(" ", 36) : documentId) + name; } /** diff --git a/docs-core/src/main/java/com/sismics/docs/core/model/jpa/PasswordRecovery.java b/docs-core/src/main/java/com/sismics/docs/core/model/jpa/PasswordRecovery.java new file mode 100644 index 00000000..6306c807 --- /dev/null +++ b/docs-core/src/main/java/com/sismics/docs/core/model/jpa/PasswordRecovery.java @@ -0,0 +1,82 @@ +package com.sismics.docs.core.model.jpa; + +import com.google.common.base.MoreObjects; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; +import java.util.Date; + +/** + * Password recovery entity. + * + * @author jtremeaux + */ +@Entity +@Table(name = "T_PASSWORD_RECOVERY") +public class PasswordRecovery { + /** + * Identifier. + */ + @Id + @Column(name = "PWR_ID_C", length = 36) + private String id; + + /** + * Username. + */ + @Column(name = "PWR_USERNAME_C", nullable = false, length = 50) + private String username; + + /** + * Creation date. + */ + @Column(name = "PWR_CREATEDATE_D", nullable = false) + private Date createDate; + + /** + * Delete date. + */ + @Column(name = "PWR_DELETEDATE_D") + private Date deleteDate; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Date getCreateDate() { + return createDate; + } + + public void setCreateDate(Date createDate) { + this.createDate = createDate; + } + + public Date getDeleteDate() { + return deleteDate; + } + + public void setDeleteDate(Date deleteDate) { + this.deleteDate = deleteDate; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .toString(); + } +} diff --git a/docs-core/src/main/java/com/sismics/docs/core/model/jpa/User.java b/docs-core/src/main/java/com/sismics/docs/core/model/jpa/User.java index 0f37b50c..b06d6d60 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/model/jpa/User.java +++ b/docs-core/src/main/java/com/sismics/docs/core/model/jpa/User.java @@ -1,13 +1,12 @@ package com.sismics.docs.core.model.jpa; -import java.util.Date; +import com.google.common.base.MoreObjects; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.Id; import javax.persistence.Table; - -import com.google.common.base.MoreObjects; +import java.util.Date; /** * User entity. @@ -84,6 +83,12 @@ public class User implements Loggable { @Column(name = "USE_DELETEDATE_D") private Date deleteDate; + /** + * Disable date. + */ + @Column(name = "USE_DISABLEDATE_D") + private Date disableDate; + public String getId() { return id; } @@ -147,7 +152,16 @@ public class User implements Loggable { this.deleteDate = deleteDate; return this; } - + + public Date getDisableDate() { + return disableDate; + } + + public User setDisableDate(Date disableDate) { + this.disableDate = disableDate; + return this; + } + public String getPrivateKey() { return privateKey; } diff --git a/docs-core/src/main/java/com/sismics/docs/core/util/AuditLogUtil.java b/docs-core/src/main/java/com/sismics/docs/core/util/AuditLogUtil.java index d2d0913c..60b04820 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/util/AuditLogUtil.java +++ b/docs-core/src/main/java/com/sismics/docs/core/util/AuditLogUtil.java @@ -22,6 +22,10 @@ public class AuditLogUtil { * @param userId User ID */ public static void create(Loggable loggable, AuditLogType type, String userId) { + if (userId == null) { + userId = "admin"; + } + // Get the entity ID EntityManager em = ThreadLocalContext.get().getEntityManager(); String entityId = (String) em.getEntityManagerFactory().getPersistenceUnitUtil().getIdentifier(loggable); diff --git a/docs-core/src/main/java/com/sismics/docs/core/util/FileUtil.java b/docs-core/src/main/java/com/sismics/docs/core/util/FileUtil.java index 6a511bf0..3d67a6a7 100644 --- a/docs-core/src/main/java/com/sismics/docs/core/util/FileUtil.java +++ b/docs-core/src/main/java/com/sismics/docs/core/util/FileUtil.java @@ -129,8 +129,8 @@ public class FileUtil { if (image != null) { // Generate thumbnails from image - BufferedImage web = Scalr.resize(image, Scalr.Method.AUTOMATIC, Scalr.Mode.AUTOMATIC, 1280, Scalr.OP_ANTIALIAS); - BufferedImage thumbnail = Scalr.resize(image, Scalr.Method.AUTOMATIC, Scalr.Mode.AUTOMATIC, 256, Scalr.OP_ANTIALIAS); + BufferedImage web = Scalr.resize(image, Scalr.Method.ULTRA_QUALITY, Scalr.Mode.AUTOMATIC, 1280); + BufferedImage thumbnail = Scalr.resize(image, Scalr.Method.ULTRA_QUALITY, Scalr.Mode.AUTOMATIC, 256); image.flush(); // Write "web" encrypted image diff --git a/docs-core/src/main/java/com/sismics/docs/core/util/UserUtil.java b/docs-core/src/main/java/com/sismics/docs/core/util/UserUtil.java deleted file mode 100644 index 7f19c0d1..00000000 --- a/docs-core/src/main/java/com/sismics/docs/core/util/UserUtil.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.sismics.docs.core.util; - -import com.sismics.docs.core.model.jpa.User; - -/** - * Utilitaires sur les utilisateurs. - * - * @author jtremeaux - */ -public class UserUtil { - /** - * Retourne the user's username. - * - * @param user User - * @return User name - */ - public static String getUserName(User user) { - return user.getUsername(); - } -} diff --git a/docs-core/src/main/java/com/sismics/util/EmailUtil.java b/docs-core/src/main/java/com/sismics/util/EmailUtil.java new file mode 100644 index 00000000..88269e0d --- /dev/null +++ b/docs-core/src/main/java/com/sismics/util/EmailUtil.java @@ -0,0 +1,162 @@ +package com.sismics.util; + +import com.google.common.base.Strings; +import com.sismics.docs.core.constant.ConfigType; +import com.sismics.docs.core.constant.Constants; +import com.sismics.docs.core.dao.jpa.ConfigDao; +import com.sismics.docs.core.model.jpa.Config; +import com.sismics.docs.core.model.jpa.User; +import com.sismics.docs.core.util.ConfigUtil; +import freemarker.template.Configuration; +import freemarker.template.DefaultObjectWrapperBuilder; +import freemarker.template.Template; +import org.apache.commons.mail.HtmlEmail; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.json.Json; +import javax.json.JsonObject; +import javax.json.JsonReader; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.Locale; +import java.util.Map; + +/** + * Emails utilities. + * + * @author jtremeaux + */ +public class EmailUtil { + /** + * Logger. + */ + private static final Logger log = LoggerFactory.getLogger(EmailUtil.class); + + /** + * Returns an email content as string. + * The content is formatted from the given Freemarker template and parameters. + * + * @param templateName Template name + * @param paramRootMap Map of Freemarker parameters + * @param locale Locale + * @return Template as string + * @throws Exception e + */ + private static String getFormattedHtml(String templateName, Map paramRootMap, Locale locale) throws Exception { + Configuration cfg = new Configuration(Configuration.VERSION_2_3_23); + cfg.setClassForTemplateLoading(EmailUtil.class, "/email_template"); + cfg.setObjectWrapper(new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_23).build()); + Template template = cfg.getTemplate(templateName + "/template.ftl"); + paramRootMap.put("messages", new ResourceBundleModel(MessageUtil.getMessage(locale), + new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_23).build())); + StringWriter sw = new StringWriter(); + template.process(paramRootMap, sw); + + return sw.toString(); + } + + /** + * Sending an email to a user. + * + * @param templateName Template name + * @param recipientUser Recipient user + * @param subject Email subject + * @param paramMap Email parameters + */ + public static void sendEmail(String templateName, User recipientUser, String subject, Map paramMap) { + if (log.isInfoEnabled()) { + log.info("Sending email from template=" + templateName + " to user " + recipientUser); + } + + try { + // Build email headers + HtmlEmail email = new HtmlEmail(); + email.setCharset("UTF-8"); + ConfigDao configDao = new ConfigDao(); + + // Hostname + String envHostname = System.getenv(Constants.SMTP_HOSTNAME_ENV); + if (envHostname == null) { + email.setHostName(ConfigUtil.getConfigStringValue(ConfigType.SMTP_HOSTNAME)); + } else { + email.setHostName(envHostname); + } + + // Port + String envPort = System.getenv(Constants.SMTP_PORT_ENV); + if (envPort == null) { + email.setSmtpPort(ConfigUtil.getConfigIntegerValue(ConfigType.SMTP_PORT)); + } else { + email.setSmtpPort(Integer.valueOf(envPort)); + } + + // Username and password + String envUsername = System.getenv(Constants.SMTP_USERNAME_ENV); + String envPassword = System.getenv(Constants.SMTP_PASSWORD_ENV); + if (envUsername == null || envPassword == null) { + Config usernameConfig = configDao.getById(ConfigType.SMTP_USERNAME); + Config passwordConfig = configDao.getById(ConfigType.SMTP_PASSWORD); + if (usernameConfig != null && passwordConfig != null) { + email.setAuthentication(usernameConfig.getValue(), passwordConfig.getValue()); + } + } else { + email.setAuthentication(envUsername, envPassword); + } + + // Recipient + email.addTo(recipientUser.getEmail(), recipientUser.getUsername()); + + // Application name + Config themeConfig = configDao.getById(ConfigType.THEME); + String appName = "Sismics Docs"; + if (themeConfig != null) { + try (JsonReader reader = Json.createReader(new StringReader(themeConfig.getValue()))) { + JsonObject themeJson = reader.readObject(); + appName = themeJson.getString("name", "Sismics Docs"); + } + } + + // From email address (defined only by configuration value in the database) + email.setFrom(ConfigUtil.getConfigStringValue(ConfigType.SMTP_FROM), appName); + + // Locale (defined only by environment variable) + java.util.Locale userLocale = LocaleUtil.getLocale(System.getenv(Constants.DEFAULT_LANGUAGE_ENV)); + + // Subject and content + email.setSubject(appName + " - " + subject); + email.setTextMsg(MessageUtil.getMessage(userLocale, "email.no_html.error")); + + // Add automatic parameters + String baseUrl = System.getenv(Constants.BASE_URL_ENV); + if (Strings.isNullOrEmpty(baseUrl)) { + log.error("DOCS_BASE_URL environnement variable needs to be set for proper email links"); + baseUrl = ""; // At least the mail will be sent... + } + paramMap.put("base_url", baseUrl); + paramMap.put("app_name", appName); + + // Build HTML content from Freemarker template + String htmlEmailTemplate = getFormattedHtml(templateName, paramMap, userLocale); + email.setHtmlMsg(htmlEmailTemplate); + + // Send the email + email.send(); + } catch (Exception e) { + log.error("Error sending email with template=" + templateName + " to user " + recipientUser, e); + } + } + + /** + * Sending an email to a user. + * + * @param templateName Template name + * @param recipientUser Recipient user + * @param paramMap Email parameters + */ + public static void sendEmail(String templateName, User recipientUser, Map paramMap) { + java.util.Locale userLocale = LocaleUtil.getLocale(System.getenv(Constants.DEFAULT_LANGUAGE_ENV)); + String subject = MessageUtil.getMessage(userLocale, "email.template." + templateName + ".subject"); + sendEmail(templateName, recipientUser, subject, paramMap); + } +} diff --git a/docs-core/src/main/java/com/sismics/util/LocaleUtil.java b/docs-core/src/main/java/com/sismics/util/LocaleUtil.java new file mode 100644 index 00000000..11ab9c7b --- /dev/null +++ b/docs-core/src/main/java/com/sismics/util/LocaleUtil.java @@ -0,0 +1,36 @@ +package com.sismics.util; + +import com.google.common.base.Strings; + +import java.util.Locale; + +/** + * Locale utilities. + * + * @author jtremeaux + */ +public class LocaleUtil { + /** + * Returns a locale from the language / country / variation code (ex: fr_FR). + * + * @param localeCode Locale code + * @return Locale instance + */ + public static Locale getLocale(String localeCode) { + if (Strings.isNullOrEmpty(localeCode)) { + return Locale.ENGLISH; + } + + String[] localeCodeArray = localeCode.split("_"); + String language = localeCodeArray[0]; + String country = ""; + String variant = ""; + if (localeCodeArray.length >= 2) { + country = localeCodeArray[1]; + } + if (localeCodeArray.length >= 3) { + variant = localeCodeArray[2]; + } + return new Locale(language, country, variant); + } +} diff --git a/docs-core/src/main/java/com/sismics/util/MessageUtil.java b/docs-core/src/main/java/com/sismics/util/MessageUtil.java new file mode 100644 index 00000000..a2898bd0 --- /dev/null +++ b/docs-core/src/main/java/com/sismics/util/MessageUtil.java @@ -0,0 +1,43 @@ +package com.sismics.util; + +import java.text.MessageFormat; +import java.util.Locale; +import java.util.MissingResourceException; +import java.util.ResourceBundle; + +/** + * Messages utilities. + * + * @author jtremeaux + */ +public class MessageUtil { + /** + * Returns a localized message in the specified language. + * Returns **key** if no message exists for this key. + * + * @param locale Locale + * @param key Message key + * @param args Arguments to format + * @return Formatted message + */ + public static String getMessage(Locale locale, String key, Object... args) { + ResourceBundle resources = ResourceBundle.getBundle("messages", locale); + String message; + try { + message = resources.getString(key); + } catch (MissingResourceException e) { + message = "**" + key + "**"; + } + return MessageFormat.format(message, args); + } + + /** + * Returns the resource bundle corresponding to the specified language. + * + * @param locale Locale + * @return Resource bundle + */ + public static ResourceBundle getMessage(Locale locale) { + return ResourceBundle.getBundle("messages", locale); + } +} diff --git a/docs-core/src/main/java/com/sismics/util/ResourceBundleModel.java b/docs-core/src/main/java/com/sismics/util/ResourceBundleModel.java new file mode 100644 index 00000000..cff2a583 --- /dev/null +++ b/docs-core/src/main/java/com/sismics/util/ResourceBundleModel.java @@ -0,0 +1,55 @@ +package com.sismics.util; + +import freemarker.ext.beans.BeansWrapper; +import freemarker.ext.beans.StringModel; +import freemarker.template.TemplateModel; +import freemarker.template.TemplateModelException; + +import java.util.Iterator; +import java.util.List; +import java.util.MissingResourceException; +import java.util.ResourceBundle; + +/** + * Override of {@link freemarker.ext.beans.ResourceBundleModel} + * to threat single quotes uniformely. + * + * @author bgamard + */ +public class ResourceBundleModel extends freemarker.ext.beans.ResourceBundleModel { + + /** + * Default constructor. + * + * @param bundle Resource bundle + * @param wrapper Beans wrapper + */ + public ResourceBundleModel(ResourceBundle bundle, BeansWrapper wrapper) { + super(bundle, wrapper); + } + + @SuppressWarnings("rawtypes") + @Override + public Object exec(List arguments) throws TemplateModelException { + // Must have at least one argument - the key + if (arguments.size() < 1) + throw new TemplateModelException("No message key was specified"); + // Read it + Iterator it = arguments.iterator(); + String key = unwrap((TemplateModel) it.next()).toString(); + try { + // Copy remaining arguments into an Object[] + int args = arguments.size() - 1; + Object[] params = new Object[args]; + for (int i = 0; i < args; ++i) + params[i] = unwrap((TemplateModel) it.next()); + + // Invoke format + return new StringModel(format(key, params), wrapper); + } catch (MissingResourceException e) { + throw new TemplateModelException("No such key: " + key); + } catch (Exception e) { + throw new TemplateModelException(e.getMessage()); + } + } +} diff --git a/docs-core/src/main/resources/META-INF/persistence.xml b/docs-core/src/main/resources/META-INF/persistence.xml index 7c40e310..b62e7679 100644 --- a/docs-core/src/main/resources/META-INF/persistence.xml +++ b/docs-core/src/main/resources/META-INF/persistence.xml @@ -4,6 +4,6 @@ xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd" version="2.0"> - org.hibernate.ejb.HibernatePersistence + org.hibernate.jpa.HibernatePersistenceProvider \ No newline at end of file diff --git a/docs-core/src/main/resources/config.properties b/docs-core/src/main/resources/config.properties index a56b42a4..f1624659 100644 --- a/docs-core/src/main/resources/config.properties +++ b/docs-core/src/main/resources/config.properties @@ -1 +1 @@ -db.version=12 \ No newline at end of file +db.version=14 \ No newline at end of file diff --git a/docs-core/src/main/resources/db/update/dbupdate-013-0.sql b/docs-core/src/main/resources/db/update/dbupdate-013-0.sql new file mode 100644 index 00000000..1407d548 --- /dev/null +++ b/docs-core/src/main/resources/db/update/dbupdate-013-0.sql @@ -0,0 +1,2 @@ +create cached table T_PASSWORD_RECOVERY ( PWR_ID_C varchar(36) not null, PWR_USERNAME_C varchar(50) not null, PWR_CREATEDATE_D datetime, PWR_DELETEDATE_D datetime, primary key (PWR_ID_C) ); +update T_CONFIG set CFG_VALUE_C = '13' where CFG_ID_C = 'DB_VERSION'; diff --git a/docs-core/src/main/resources/db/update/dbupdate-014-0.sql b/docs-core/src/main/resources/db/update/dbupdate-014-0.sql new file mode 100644 index 00000000..b4bdb01d --- /dev/null +++ b/docs-core/src/main/resources/db/update/dbupdate-014-0.sql @@ -0,0 +1,2 @@ +alter table T_USER add column USE_DISABLEDATE_D datetime; +update T_CONFIG set CFG_VALUE_C = '14' where CFG_ID_C = 'DB_VERSION'; diff --git a/docs-core/src/main/resources/email_template/layout.ftl b/docs-core/src/main/resources/email_template/layout.ftl new file mode 100644 index 00000000..0ceae478 --- /dev/null +++ b/docs-core/src/main/resources/email_template/layout.ftl @@ -0,0 +1,16 @@ +<#macro email> + + + + + + + +
+ ${app_name} +
+
+ <#nested> +
+
+ \ No newline at end of file diff --git a/docs-core/src/main/resources/email_template/password_recovery/template.ftl b/docs-core/src/main/resources/email_template/password_recovery/template.ftl new file mode 100644 index 00000000..84c371fa --- /dev/null +++ b/docs-core/src/main/resources/email_template/password_recovery/template.ftl @@ -0,0 +1,8 @@ +<#import "../layout.ftl" as layout> +<@layout.email> +

${app_name} - ${messages['email.template.password_recovery.subject']}

+

${messages('email.template.password_recovery.hello', user_name)}

+

${messages['email.template.password_recovery.instruction1']}

+

${messages['email.template.password_recovery.instruction2']}

+ ${messages['email.template.password_recovery.click_here']} + \ No newline at end of file diff --git a/docs-core/src/main/resources/messages.properties b/docs-core/src/main/resources/messages.properties new file mode 100644 index 00000000..06a43b5a --- /dev/null +++ b/docs-core/src/main/resources/messages.properties @@ -0,0 +1,6 @@ +email.template.password_recovery.subject=Please reset your password +email.template.password_recovery.hello=Hello {0}. +email.template.password_recovery.instruction1=We have received a request to reset your password.
If you did not request help, then feel free to ignore this email. +email.template.password_recovery.instruction2=To reset your password, please visit the link below: +email.template.password_recovery.click_here=Click here to reset your password +email.no_html.error=Your email client does not support HTML messages \ No newline at end of file diff --git a/docs-core/src/main/resources/messages_fr.properties b/docs-core/src/main/resources/messages_fr.properties new file mode 100644 index 00000000..1262dd0b --- /dev/null +++ b/docs-core/src/main/resources/messages_fr.properties @@ -0,0 +1,6 @@ +email.template.password_recovery.subject=Réinitialiser votre mot de passe +email.template.password_recovery.hello=Bonjour {0}. +email.template.password_recovery.instruction1=Nous avons reçu une demande de réinitialisation de mot de passe.
Si vous n'avez rien demandé, vous pouvez ignorer cet mail. +email.template.password_recovery.instruction2=Pour réinitialiser votre mot de passe, cliquez sur le lien ci-dessous : +email.template.password_recovery.click_here=Cliquez ici pour réinitialiser votre mot de passe. +email.no_html.error=Votre client mail ne supporte pas les messages au format HTML \ No newline at end of file diff --git a/docs-core/src/test/resources/log4j.properties b/docs-core/src/test/resources/log4j.properties index 0a16867a..b13ac1dc 100644 --- a/docs-core/src/test/resources/log4j.properties +++ b/docs-core/src/test/resources/log4j.properties @@ -5,4 +5,5 @@ log4j.appender.CONSOLE.layout.ConversionPattern=%d{DATE} %p %l %m %n log4j.appender.MEMORY=com.sismics.util.log4j.MemoryAppender log4j.appender.MEMORY.size=1000 -log4j.logger.com.sismics=DEBUG +log4j.logger.com.sismics=INFO +log4j.logger.org.hibernate=ERROR \ No newline at end of file diff --git a/docs-web-common/pom.xml b/docs-web-common/pom.xml index 5a7e8a30..efa9aced 100644 --- a/docs-web-common/pom.xml +++ b/docs-web-common/pom.xml @@ -93,7 +93,13 @@ jersey-container-grizzly2-servlet test
- + + + org.subethamail + subethasmtp-wiser + test + + diff --git a/docs-web-common/src/main/java/com/sismics/util/filter/SecurityFilter.java b/docs-web-common/src/main/java/com/sismics/util/filter/SecurityFilter.java index b4bad6e1..c4bb86a8 100644 --- a/docs-web-common/src/main/java/com/sismics/util/filter/SecurityFilter.java +++ b/docs-web-common/src/main/java/com/sismics/util/filter/SecurityFilter.java @@ -57,7 +57,7 @@ public abstract class SecurityFilter implements Filter { */ private void injectUser(HttpServletRequest request, User user) { // Check if the user is still valid - if (user != null && user.getDeleteDate() == null) { + if (user != null && user.getDeleteDate() == null && user.getDisableDate() == null) { injectAuthenticatedUser(request, user); } else { injectAnonymousUser(request); diff --git a/docs-web-common/src/test/java/com/sismics/docs/rest/BaseJerseyTest.java b/docs-web-common/src/test/java/com/sismics/docs/rest/BaseJerseyTest.java index a344443d..24d90082 100644 --- a/docs-web-common/src/test/java/com/sismics/docs/rest/BaseJerseyTest.java +++ b/docs-web-common/src/test/java/com/sismics/docs/rest/BaseJerseyTest.java @@ -1,11 +1,9 @@ package com.sismics.docs.rest; -import java.net.URI; - -import javax.ws.rs.core.Application; -import javax.ws.rs.core.UriBuilder; - +import com.sismics.docs.rest.util.ClientUtil; import com.sismics.util.filter.HeaderBasedSecurityFilter; +import com.sismics.util.filter.RequestContextFilter; +import com.sismics.util.filter.TokenBasedSecurityFilter; import org.glassfish.grizzly.http.server.HttpServer; import org.glassfish.grizzly.servlet.ServletRegistration; import org.glassfish.grizzly.servlet.WebappContext; @@ -17,10 +15,17 @@ import org.glassfish.jersey.test.spi.TestContainerException; import org.glassfish.jersey.test.spi.TestContainerFactory; import org.junit.After; import org.junit.Before; +import org.subethamail.wiser.Wiser; +import org.subethamail.wiser.WiserMessage; -import com.sismics.docs.rest.util.ClientUtil; -import com.sismics.util.filter.RequestContextFilter; -import com.sismics.util.filter.TokenBasedSecurityFilter; +import javax.mail.MessagingException; +import javax.mail.internet.MimeMessage; +import javax.ws.rs.core.Application; +import javax.ws.rs.core.UriBuilder; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.util.List; /** * Base class of integration tests with Jersey. @@ -37,6 +42,11 @@ public abstract class BaseJerseyTest extends JerseyTest { * Utility class for the REST client. */ protected ClientUtil clientUtil; + + /** + * Test mail server. + */ + private Wiser wiser; @Override protected TestContainerFactory getTestContainerFactory() throws TestContainerException { @@ -45,10 +55,10 @@ public abstract class BaseJerseyTest extends JerseyTest { @Override protected Application configure() { - enable(TestProperties.LOG_TRAFFIC); String travisEnv = System.getenv("TRAVIS"); if (travisEnv == null || !travisEnv.equals("true")) { - // Travis don't like entity dumped in the logs + // Travis doesn't like big logs + enable(TestProperties.LOG_TRAFFIC); enable(TestProperties.DUMP_ENTITY); } return new Application(); @@ -66,7 +76,7 @@ public abstract class BaseJerseyTest extends JerseyTest { System.setProperty("docs.header_authentication", "true"); clientUtil = new ClientUtil(target()); - + httpServer = HttpServer.createSimpleServer(getClass().getResource("/").getFile(), "localhost", getPort()); WebappContext context = new WebappContext("GrizzlyContext", "/docs"); context.addFilter("requestContextFilter", RequestContextFilter.class) @@ -84,12 +94,39 @@ public abstract class BaseJerseyTest extends JerseyTest { reg.setAsyncSupported(true); context.deploy(httpServer); httpServer.start(); + + wiser = new Wiser(); + wiser.setPort(2500); + wiser.start(); + } + + /** + * Extract an email from the list and consume it. + * + * @return Email content + * @throws MessagingException e + * @throws IOException e + */ + protected String popEmail() throws MessagingException, IOException { + List wiserMessageList = wiser.getMessages(); + if (wiserMessageList.isEmpty()) { + return null; + } + WiserMessage wiserMessage = wiserMessageList.get(wiserMessageList.size() - 1); + wiserMessageList.remove(wiserMessageList.size() - 1); + MimeMessage message = wiserMessage.getMimeMessage(); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + message.writeTo(os); + return os.toString(); } @Override @After public void tearDown() throws Exception { super.tearDown(); + if (wiser != null) { + wiser.stop(); + } if (httpServer != null) { httpServer.shutdownNow(); } diff --git a/docs-web/pom.xml b/docs-web/pom.xml index 3573646e..4d239548 100644 --- a/docs-web/pom.xml +++ b/docs-web/pom.xml @@ -121,6 +121,12 @@ jersey-container-grizzly2-servlet test + + + org.subethamail + subethasmtp-wiser + test + diff --git a/docs-web/src/dev/resources/config.properties b/docs-web/src/dev/resources/config.properties index 17c44aa4..743723d1 100644 --- a/docs-web/src/dev/resources/config.properties +++ b/docs-web/src/dev/resources/config.properties @@ -1,3 +1,3 @@ api.current_version=${project.version} api.min_version=1.0 -db.version=12 \ No newline at end of file +db.version=14 \ No newline at end of file diff --git a/docs-web/src/main/java/com/sismics/docs/rest/resource/AppResource.java b/docs-web/src/main/java/com/sismics/docs/rest/resource/AppResource.java index 0586d6bd..558761b4 100644 --- a/docs-web/src/main/java/com/sismics/docs/rest/resource/AppResource.java +++ b/docs-web/src/main/java/com/sismics/docs/rest/resource/AppResource.java @@ -1,32 +1,14 @@ package com.sismics.docs.rest.resource; -import java.io.IOException; -import java.nio.file.DirectoryStream; -import java.nio.file.Files; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.ResourceBundle; - -import javax.json.Json; -import javax.json.JsonArrayBuilder; -import javax.json.JsonObjectBuilder; -import javax.persistence.EntityManager; -import javax.persistence.Query; -import javax.ws.rs.*; -import javax.ws.rs.core.Response; - +import com.google.common.base.Strings; import com.sismics.docs.core.constant.ConfigType; -import com.sismics.docs.core.dao.jpa.*; +import com.sismics.docs.core.constant.Constants; +import com.sismics.docs.core.dao.jpa.ConfigDao; +import com.sismics.docs.core.dao.jpa.DocumentDao; +import com.sismics.docs.core.dao.jpa.FileDao; +import com.sismics.docs.core.dao.jpa.UserDao; import com.sismics.docs.core.event.RebuildIndexAsyncEvent; -import com.sismics.rest.util.ValidationUtil; -import org.apache.commons.lang.StringUtils; -import org.apache.log4j.Appender; -import org.apache.log4j.Level; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - +import com.sismics.docs.core.model.jpa.Config; import com.sismics.docs.core.model.jpa.File; import com.sismics.docs.core.model.jpa.User; import com.sismics.docs.core.util.ConfigUtil; @@ -36,10 +18,28 @@ import com.sismics.docs.core.util.jpa.PaginatedLists; import com.sismics.docs.rest.constant.BaseFunction; import com.sismics.rest.exception.ForbiddenClientException; import com.sismics.rest.exception.ServerException; +import com.sismics.rest.util.ValidationUtil; import com.sismics.util.context.ThreadLocalContext; import com.sismics.util.log4j.LogCriteria; import com.sismics.util.log4j.LogEntry; import com.sismics.util.log4j.MemoryAppender; +import org.apache.commons.lang.StringUtils; +import org.apache.log4j.Appender; +import org.apache.log4j.Level; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.json.Json; +import javax.json.JsonArrayBuilder; +import javax.json.JsonObjectBuilder; +import javax.persistence.EntityManager; +import javax.persistence.Query; +import javax.ws.rs.*; +import javax.ws.rs.core.Response; +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.util.*; /** * General app REST resource. @@ -64,6 +64,10 @@ public class AppResource extends BaseResource { * @apiSuccess {Boolean} guest_login True if guest login is enabled * @apiSuccess {String} total_memory Allocated JVM memory (in bytes) * @apiSuccess {String} free_memory Free JVM memory (in bytes) + * @apiSuccess {String} document_count Number of documents + * @apiSuccess {String} active_user_count Number of active users + * @apiSuccess {String} global_storage_current Global storage currently used (in bytes) + * @apiSuccess {String} global_storage_quota Maximum global storage (in bytes) * @apiPermission none * @apiVersion 1.5.0 * @@ -75,14 +79,27 @@ public class AppResource extends BaseResource { String currentVersion = configBundle.getString("api.current_version"); String minVersion = configBundle.getString("api.min_version"); Boolean guestLogin = ConfigUtil.getConfigBooleanValue(ConfigType.GUEST_LOGIN); + UserDao userDao = new UserDao(); + DocumentDao documentDao = new DocumentDao(); + String globalQuotaStr = System.getenv(Constants.GLOBAL_QUOTA_ENV); + long globalQuota = 0; + if (!Strings.isNullOrEmpty(globalQuotaStr)) { + globalQuota = Long.valueOf(globalQuotaStr); + } JsonObjectBuilder response = Json.createObjectBuilder() .add("current_version", currentVersion.replace("-SNAPSHOT", "")) .add("min_version", minVersion) .add("guest_login", guestLogin) .add("total_memory", Runtime.getRuntime().totalMemory()) - .add("free_memory", Runtime.getRuntime().freeMemory()); - + .add("free_memory", Runtime.getRuntime().freeMemory()) + .add("document_count", documentDao.getDocumentCount()) + .add("active_user_count", userDao.getActiveUserCount()) + .add("global_storage_current", userDao.getGlobalStorageCurrent()); + if (globalQuota > 0) { + response.add("global_storage_quota", globalQuota); + } + return Response.ok().entity(response.build()).build(); } @@ -113,6 +130,75 @@ public class AppResource extends BaseResource { return Response.ok().build(); } + + /** + * Get the SMTP server configuration. + * + * @api {get} /app/config_smtp Get the SMTP server configuration + * @apiName GetAppConfigSmtp + * @apiGroup App + * @apiSuccess {String} hostname SMTP hostname + * @apiSuccess {String} port + * @apiSuccess {String} username + * @apiSuccess {String} password + * @apiSuccess {String} from + * @apiError (client) ForbiddenError Access denied + * @apiPermission admin + * @apiVersion 1.5.0 + * + * @return Response + */ + @GET + @Path("config_smtp") + public Response getConfigSmtp() { + if (!authenticate()) { + throw new ForbiddenClientException(); + } + checkBaseFunction(BaseFunction.ADMIN); + + ConfigDao configDao = new ConfigDao(); + Config hostnameConfig = configDao.getById(ConfigType.SMTP_HOSTNAME); + Config portConfig = configDao.getById(ConfigType.SMTP_PORT); + Config usernameConfig = configDao.getById(ConfigType.SMTP_USERNAME); + Config passwordConfig = configDao.getById(ConfigType.SMTP_PASSWORD); + Config fromConfig = configDao.getById(ConfigType.SMTP_FROM); + JsonObjectBuilder response = Json.createObjectBuilder(); + if (System.getenv(Constants.SMTP_HOSTNAME_ENV) == null) { + if (hostnameConfig == null) { + response.addNull("hostname"); + } else { + response.add("hostname", hostnameConfig.getValue()); + } + } + if (System.getenv(Constants.SMTP_PORT_ENV) == null) { + if (portConfig == null) { + response.addNull("port"); + } else { + response.add("port", Integer.valueOf(portConfig.getValue())); + } + } + if (System.getenv(Constants.SMTP_USERNAME_ENV) == null) { + if (usernameConfig == null) { + response.addNull("username"); + } else { + response.add("username", usernameConfig.getValue()); + } + } + if (System.getenv(Constants.SMTP_PASSWORD_ENV) == null) { + if (passwordConfig == null) { + response.addNull("password"); + } else { + response.add("password", passwordConfig.getValue()); + } + } + if (fromConfig == null) { + response.addNull("from"); + } else { + response.add("from", fromConfig.getValue()); + } + + return Response.ok().entity(response.build()).build(); + } /** * Configure the SMTP server. @@ -122,45 +208,53 @@ public class AppResource extends BaseResource { * @apiGroup App * @apiParam {String} hostname SMTP hostname * @apiParam {Integer} port SMTP port - * @apiParam {String} from From address * @apiParam {String} username SMTP username * @apiParam {String} password SMTP password + * @apiParam {String} from From address * @apiError (client) ForbiddenError Access denied + * @apiError (client) ValidationError Validation error * @apiPermission admin * @apiVersion 1.5.0 * * @param hostname SMTP hostname * @param portStr SMTP port - * @param from From address * @param username SMTP username * @param password SMTP password + * @param from From address * @return Response */ @POST @Path("config_smtp") public Response configSmtp(@FormParam("hostname") String hostname, @FormParam("port") String portStr, - @FormParam("from") String from, @FormParam("username") String username, - @FormParam("password") String password) { + @FormParam("password") String password, + @FormParam("from") String from) { if (!authenticate()) { throw new ForbiddenClientException(); } checkBaseFunction(BaseFunction.ADMIN); - ValidationUtil.validateRequired(hostname, "hostname"); - ValidationUtil.validateInteger(portStr, "port"); - ValidationUtil.validateRequired(from, "from"); + if (!Strings.isNullOrEmpty(portStr)) { + ValidationUtil.validateInteger(portStr, "port"); + } + // Just update the changed configuration ConfigDao configDao = new ConfigDao(); - configDao.update(ConfigType.SMTP_HOSTNAME, hostname); - configDao.update(ConfigType.SMTP_PORT, portStr); - configDao.update(ConfigType.SMTP_FROM, from); - if (username != null) { + if (!Strings.isNullOrEmpty(hostname)) { + configDao.update(ConfigType.SMTP_HOSTNAME, hostname); + } + if (!Strings.isNullOrEmpty(portStr)) { + configDao.update(ConfigType.SMTP_PORT, portStr); + } + if (!Strings.isNullOrEmpty(username)) { configDao.update(ConfigType.SMTP_USERNAME, username); } - if (password != null) { + if (!Strings.isNullOrEmpty(password)) { configDao.update(ConfigType.SMTP_PASSWORD, password); } + if (!Strings.isNullOrEmpty(from)) { + configDao.update(ConfigType.SMTP_FROM, from); + } return Response.ok().build(); } diff --git a/docs-web/src/main/java/com/sismics/docs/rest/resource/FileResource.java b/docs-web/src/main/java/com/sismics/docs/rest/resource/FileResource.java index 6d99e965..56096aaa 100644 --- a/docs-web/src/main/java/com/sismics/docs/rest/resource/FileResource.java +++ b/docs-web/src/main/java/com/sismics/docs/rest/resource/FileResource.java @@ -3,6 +3,7 @@ package com.sismics.docs.rest.resource; import com.google.common.base.Strings; import com.google.common.collect.Lists; import com.google.common.io.ByteStreams; +import com.sismics.docs.core.constant.Constants; import com.sismics.docs.core.constant.PermType; import com.sismics.docs.core.dao.jpa.AclDao; import com.sismics.docs.core.dao.jpa.DocumentDao; @@ -135,11 +136,21 @@ public class FileResource extends BaseResource { throw new ServerException("ErrorGuessMime", "Error guessing mime type", e); } - // Validate quota + // Validate user quota if (user.getStorageCurrent() + fileSize > user.getStorageQuota()) { throw new ClientException("QuotaReached", "Quota limit reached"); } - + + // Validate global quota + String globalStorageQuotaStr = System.getenv(Constants.GLOBAL_QUOTA_ENV); + if (!Strings.isNullOrEmpty(globalStorageQuotaStr)) { + long globalStorageQuota = Long.valueOf(globalStorageQuotaStr); + long globalStorageCurrent = userDao.getGlobalStorageCurrent(); + if (globalStorageCurrent + fileSize > globalStorageQuota) { + throw new ClientException("QuotaReached", "Global quota limit reached"); + } + } + try { // Get files of this document FileDao fileDao = new FileDao(); diff --git a/docs-web/src/main/java/com/sismics/docs/rest/resource/UserResource.java b/docs-web/src/main/java/com/sismics/docs/rest/resource/UserResource.java index aaca4646..ae092942 100644 --- a/docs-web/src/main/java/com/sismics/docs/rest/resource/UserResource.java +++ b/docs-web/src/main/java/com/sismics/docs/rest/resource/UserResource.java @@ -11,6 +11,8 @@ import com.sismics.docs.core.dao.jpa.dto.GroupDto; import com.sismics.docs.core.dao.jpa.dto.UserDto; import com.sismics.docs.core.event.DocumentDeletedAsyncEvent; import com.sismics.docs.core.event.FileDeletedAsyncEvent; +import com.sismics.docs.core.event.PasswordLostEvent; +import com.sismics.docs.core.model.context.AppContext; import com.sismics.docs.core.model.jpa.*; import com.sismics.docs.core.util.ConfigUtil; import com.sismics.docs.core.util.EncryptionUtil; @@ -184,6 +186,7 @@ public class UserResource extends BaseResource { * @apiParam {String{8..50}} password Password * @apiParam {String{1..100}} email E-mail * @apiParam {Number} storage_quota Storage quota (in bytes) + * @apiParam {Boolean} disabled Disabled status * @apiSuccess {String} status Status OK * @apiError (client) ForbiddenError Access denied * @apiError (client) ValidationError Validation error @@ -202,7 +205,8 @@ public class UserResource extends BaseResource { @PathParam("username") String username, @FormParam("password") String password, @FormParam("email") String email, - @FormParam("storage_quota") String storageQuotaStr) { + @FormParam("storage_quota") String storageQuotaStr, + @FormParam("disabled") Boolean disabled) { if (!authenticate()) { throw new ForbiddenClientException(); } @@ -216,7 +220,7 @@ public class UserResource extends BaseResource { UserDao userDao = new UserDao(); User user = userDao.getActiveByUsername(username); if (user == null) { - throw new ClientException("UserNotFound", "The user doesn't exist"); + throw new ClientException("UserNotFound", "The user does not exist"); } // Update the user @@ -227,6 +231,22 @@ public class UserResource extends BaseResource { Long storageQuota = ValidationUtil.validateLong(storageQuotaStr, "storage_quota"); user.setStorageQuota(storageQuota); } + if (disabled != null) { + // Cannot disable the admin user or the guest user + RoleBaseFunctionDao userBaseFuction = new RoleBaseFunctionDao(); + Set baseFunctionSet = userBaseFuction.findByRoleId(Sets.newHashSet(user.getRoleId())); + if (Constants.GUEST_USER_ID.equals(username) || baseFunctionSet.contains(BaseFunction.ADMIN.name())) { + disabled = false; + } + + if (disabled && user.getDisableDate() == null) { + // Recording the disabled date + user.setDisableDate(new Date()); + } else if (!disabled && user.getDisableDate() != null) { + // Emptying the disabled date + user.setDisableDate(null); + } + } user = userDao.update(user, principal.getId()); // Change the password @@ -629,6 +649,7 @@ public class UserResource extends BaseResource { * @apiSuccess {Number} storage_quota Storage quota (in bytes) * @apiSuccess {Number} storage_current Quota used (in bytes) * @apiSuccess {String[]} groups Groups + * @apiSuccess {Boolean} disabled True if the user is disabled * @apiError (client) ForbiddenError Access denied * @apiError (client) UserNotFound The user does not exist * @apiPermission user @@ -666,7 +687,8 @@ public class UserResource extends BaseResource { .add("groups", groups) .add("email", user.getEmail()) .add("storage_quota", user.getStorageQuota()) - .add("storage_current", user.getStorageCurrent()); + .add("storage_current", user.getStorageCurrent()) + .add("disabled", user.getDisableDate() != null); return Response.ok().entity(response.build()).build(); } @@ -686,6 +708,7 @@ public class UserResource extends BaseResource { * @apiSuccess {Number} users.storage_quota Storage quota (in bytes) * @apiSuccess {Number} users.storage_current Quota used (in bytes) * @apiSuccess {Number} users.create_date Create date (timestamp) + * @apiSuccess {Number} users.disabled True if the user is disabled * @apiError (client) ForbiddenError Access denied * @apiPermission user * @apiVersion 1.5.0 @@ -728,7 +751,8 @@ public class UserResource extends BaseResource { .add("email", userDto.getEmail()) .add("storage_quota", userDto.getStorageQuota()) .add("storage_current", userDto.getStorageCurrent()) - .add("create_date", userDto.getCreateTimestamp())); + .add("create_date", userDto.getCreateTimestamp()) + .add("disabled", userDto.getDisableTimestamp() != null)); } JsonObjectBuilder response = Json.createObjectBuilder() @@ -901,7 +925,110 @@ public class UserResource extends BaseResource { .add("status", "ok"); return Response.ok().entity(response.build()).build(); } - + + /** + * Create a key to reset a password and send it by email. + * + * @api {post} /user/password_lost Create a key to reset a password and send it by email + * @apiName PostUserPasswordLost + * @apiGroup User + * @apiParam {String} username Username + * @apiSuccess {String} status Status OK + * @apiError (client) UserNotFound The user is not found + * @apiError (client) ValidationError Validation error + * @apiPermission none + * @apiVersion 1.5.0 + * + * @param username Username + * @return Response + */ + @POST + @Path("password_lost") + @Produces(MediaType.APPLICATION_JSON) + public Response passwordLost(@FormParam("username") String username) { + authenticate(); + + // Validate input data + ValidationUtil.validateStringNotBlank("username", username); + + // Check for user existence + UserDao userDao = new UserDao(); + User user = userDao.getActiveByUsername(username); + if (user == null) { + throw new ClientException("UserNotFound", "User not found: " + username); + } + + // Create the password recovery key + PasswordRecoveryDao passwordRecoveryDao = new PasswordRecoveryDao(); + PasswordRecovery passwordRecovery = new PasswordRecovery(); + passwordRecovery.setUsername(user.getUsername()); + passwordRecoveryDao.create(passwordRecovery); + + // Fire a password lost event + PasswordLostEvent passwordLostEvent = new PasswordLostEvent(); + passwordLostEvent.setUser(user); + passwordLostEvent.setPasswordRecovery(passwordRecovery); + AppContext.getInstance().getMailEventBus().post(passwordLostEvent); + + // Always return OK + JsonObjectBuilder response = Json.createObjectBuilder() + .add("status", "ok"); + return Response.ok().entity(response.build()).build(); + } + + /** + * Reset the user's password. + * + * @api {post} /user/password_reset Reset the user's password + * @apiName PostUserPasswordReset + * @apiGroup User + * @apiParam {String} key Password recovery key + * @apiParam {String} password New password + * @apiSuccess {String} status Status OK + * @apiError (client) KeyNotFound Password recovery key not found + * @apiError (client) ValidationError Validation error + * @apiPermission none + * @apiVersion 1.5.0 + * + * @param passwordResetKey Password reset key + * @param password New password + * @return Response + */ + @POST + @Path("password_reset") + @Produces(MediaType.APPLICATION_JSON) + public Response passwordReset( + @FormParam("key") String passwordResetKey, + @FormParam("password") String password) { + authenticate(); + + // Validate input data + ValidationUtil.validateRequired("key", passwordResetKey); + password = ValidationUtil.validateLength(password, "password", 8, 50, true); + + // Load the password recovery key + PasswordRecoveryDao passwordRecoveryDao = new PasswordRecoveryDao(); + PasswordRecovery passwordRecovery = passwordRecoveryDao.getActiveById(passwordResetKey); + if (passwordRecovery == null) { + throw new ClientException("KeyNotFound", "Password recovery key not found"); + } + + UserDao userDao = new UserDao(); + User user = userDao.getActiveByUsername(passwordRecovery.getUsername()); + + // Change the password + user.setPassword(password); + user = userDao.updatePassword(user, principal.getId()); + + // Deletes password recovery requests + passwordRecoveryDao.deleteActiveByLogin(user.getUsername()); + + // Always return OK + JsonObjectBuilder response = Json.createObjectBuilder() + .add("status", "ok"); + return Response.ok().entity(response.build()).build(); + } + /** * Returns the authentication token value. * diff --git a/docs-web/src/main/webapp/src/app/docs/app.js b/docs-web/src/main/webapp/src/app/docs/app.js index 707bef6a..b7cc62c3 100644 --- a/docs-web/src/main/webapp/src/app/docs/app.js +++ b/docs-web/src/main/webapp/src/app/docs/app.js @@ -13,7 +13,7 @@ angular.module('docs', /** * Configuring modules. */ -.config(function($locationProvider, $urlRouterProvider, $stateProvider, $httpProvider, +.config(function($locationProvider, $urlRouterProvider, $stateProvider, $httpProvider, $qProvider, RestangularProvider, $translateProvider, timeAgoSettings, tmhDynamicLocaleProvider) { $locationProvider.hashPrefix(''); @@ -28,6 +28,15 @@ angular.module('docs', } } }) + .state('passwordreset', { + url: '/passwordreset/:key', + views: { + 'page': { + templateUrl: 'partial/docs/passwordreset.html', + controller: 'PasswordReset' + } + } + }) .state('tag', { url: '/tag', abstract: true, @@ -408,6 +417,9 @@ angular.module('docs', return angular.isObject(data) && String(data) !== '[object File]' ? param(data) : data; }]; + + // Silence unhandled rejections + $qProvider.errorOnUnhandledRejections(false); }) /** diff --git a/docs-web/src/main/webapp/src/app/docs/controller/Login.js b/docs-web/src/main/webapp/src/app/docs/controller/Login.js index 7b008144..d48a5f57 100644 --- a/docs-web/src/main/webapp/src/app/docs/controller/Login.js +++ b/docs-web/src/main/webapp/src/app/docs/controller/Login.js @@ -45,23 +45,23 @@ angular.module('docs').controller('Login', function(Restangular, $scope, $rootSc $scope.openPasswordLost = function () { $uibModal.open({ templateUrl: 'partial/docs/passwordlost.html', - controller: 'LoginModalPasswordLost' - }).result.then(function (email) { - if (name === null) { + controller: 'ModalPasswordLost' + }).result.then(function (username) { + if (username === null) { return; } // Send a password lost email - Restangular.one('user').post('passwordLost', { - email: email + Restangular.one('user').post('password_lost', { + username: username }).then(function () { var title = $translate.instant('login.password_lost_sent_title'); - var msg = $translate.instant('login.password_lost_sent_message', { email: email }); + var msg = $translate.instant('login.password_lost_sent_message', { username: username }); var btns = [{result: 'ok', label: $translate.instant('ok'), cssClass: 'btn-primary'}]; $dialog.messageBox(title, msg, btns); }, function () { var title = $translate.instant('login.password_lost_error_title'); - var msg = $translate.instant('login.password_lost_error_message', { email: email }); + var msg = $translate.instant('login.password_lost_error_message'); var btns = [{result: 'ok', label: $translate.instant('ok'), cssClass: 'btn-primary'}]; $dialog.messageBox(title, msg, btns); }); diff --git a/docs-web/src/main/webapp/src/app/docs/controller/LoginModalPasswordLost.js b/docs-web/src/main/webapp/src/app/docs/controller/LoginModalPasswordLost.js deleted file mode 100644 index 534b9f46..00000000 --- a/docs-web/src/main/webapp/src/app/docs/controller/LoginModalPasswordLost.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -/** - * Login modal password lost controller. - */ -angular.module('docs').controller('LoginModalPasswordLost', function ($scope, $uibModalInstance) { - $scope.email = ''; - $scope.close = function(name) { - $uibModalInstance.close(name); - } -}); \ No newline at end of file diff --git a/docs-web/src/main/webapp/src/app/docs/controller/ModalFeedback.js b/docs-web/src/main/webapp/src/app/docs/controller/ModalFeedback.js new file mode 100644 index 00000000..f55705d3 --- /dev/null +++ b/docs-web/src/main/webapp/src/app/docs/controller/ModalFeedback.js @@ -0,0 +1,11 @@ +'use strict'; + +/** + * Modal feedback controller. + */ +angular.module('docs').controller('ModalFeedback', function ($scope, $uibModalInstance) { + $scope.content = ''; + $scope.close = function(content) { + $uibModalInstance.close(content); + } +}); \ No newline at end of file diff --git a/docs-web/src/main/webapp/src/app/docs/controller/ModalPasswordLost.js b/docs-web/src/main/webapp/src/app/docs/controller/ModalPasswordLost.js new file mode 100644 index 00000000..59dec913 --- /dev/null +++ b/docs-web/src/main/webapp/src/app/docs/controller/ModalPasswordLost.js @@ -0,0 +1,11 @@ +'use strict'; + +/** + * Modal password lost controller. + */ +angular.module('docs').controller('ModalPasswordLost', function ($scope, $uibModalInstance) { + $scope.username = ''; + $scope.close = function(username) { + $uibModalInstance.close(username); + } +}); \ No newline at end of file diff --git a/docs-web/src/main/webapp/src/app/docs/controller/PasswordReset.js b/docs-web/src/main/webapp/src/app/docs/controller/PasswordReset.js new file mode 100644 index 00000000..f525ac92 --- /dev/null +++ b/docs-web/src/main/webapp/src/app/docs/controller/PasswordReset.js @@ -0,0 +1,20 @@ +'use strict'; + +/** + * Password reset controller. + */ +angular.module('docs').controller('PasswordReset', function($scope, Restangular, $state, $stateParams, $translate, $dialog) { + $scope.submit = function () { + Restangular.one('user').post('password_reset', { + key: $stateParams.key, + password: $scope.password + }).then(function () { + $state.go('login'); + }, function () { + var title = $translate.instant('passwordreset.error_title'); + var msg = $translate.instant('passwordreset.error_message'); + var btns = [{result: 'ok', label: $translate.instant('ok'), cssClass: 'btn-primary'}]; + $dialog.messageBox(title, msg, btns); + }); + }; +}); \ No newline at end of file diff --git a/docs-web/src/main/webapp/src/app/docs/controller/document/DocumentDefault.js b/docs-web/src/main/webapp/src/app/docs/controller/document/DocumentDefault.js index fd0e998b..20997c39 100644 --- a/docs-web/src/main/webapp/src/app/docs/controller/document/DocumentDefault.js +++ b/docs-web/src/main/webapp/src/app/docs/controller/document/DocumentDefault.js @@ -3,16 +3,14 @@ /** * Document default controller. */ -angular.module('docs').controller('DocumentDefault', function($scope, $rootScope, $state, Restangular, Upload, $translate) { +angular.module('docs').controller('DocumentDefault', function ($scope, $rootScope, $state, Restangular, Upload, $translate, $uibModal, $dialog) { // Load user audit log - Restangular.one('auditlog').get().then(function(data) { + Restangular.one('auditlog').get().then(function (data) { $scope.logs = data.logs; }); - /** - * Load unlinked files. - */ - $scope.loadFiles = function() { + // Load unlinked files + $scope.loadFiles = function () { Restangular.one('file/list').get().then(function (data) { $scope.files = data.files; // TODO Keep currently uploading files @@ -20,15 +18,12 @@ angular.module('docs').controller('DocumentDefault', function($scope, $rootScope }; $scope.loadFiles(); - /** - * File has been drag & dropped. - * @param files - */ - $scope.fileDropped = function(files) { + // File has been drag & dropped + $scope.fileDropped = function (files) { if (files && files.length) { // Adding files to the UI var newfiles = []; - _.each(files, function(file) { + _.each(files, function (file) { var newfile = { progress: 0, name: file.name, @@ -42,7 +37,7 @@ angular.module('docs').controller('DocumentDefault', function($scope, $rootScope // Uploading files sequentially var key = 0; - var then = function() { + var then = function () { if (files[key]) { $scope.uploadFile(files[key], newfiles[key++]).then(then); } @@ -51,12 +46,8 @@ angular.module('docs').controller('DocumentDefault', function($scope, $rootScope } }; - /** - * Upload a file. - * @param file - * @param newfile - */ - $scope.uploadFile = function(file, newfile) { + // Upload a file + $scope.uploadFile = function (file, newfile) { // Upload the file newfile.status = $translate.instant('document.default.upload_progress'); return Upload.upload({ @@ -77,26 +68,22 @@ angular.module('docs').controller('DocumentDefault', function($scope, $rootScope }) .error(function (data) { newfile.status = $translate.instant('document.default.upload_error'); - if (data.type == 'QuotaReached') { + if (data.type === 'QuotaReached') { newfile.status += ' - ' + $translate.instant('document.default.upload_error_quota'); } }); }; - /** - * Navigate to the selected file. - */ + //Navigate to the selected file $scope.openFile = function (file) { $state.go('document.default.file', { fileId: file.id }) }; - /** - * Delete a file. - */ + // Delete a file $scope.deleteFile = function ($event, file) { $event.stopPropagation(); - Restangular.one('file', file.id).remove().then(function() { + Restangular.one('file', file.id).remove().then(function () { // File deleted, decrease used quota $rootScope.userInfo.storage_current -= file.size; @@ -106,17 +93,36 @@ angular.module('docs').controller('DocumentDefault', function($scope, $rootScope return false; }; - /** - * Returns checked files. - */ - $scope.checkedFiles = function() { + // Returns checked files + $scope.checkedFiles = function () { return _.where($scope.files, { checked: true }); }; - /** - * Add a document with checked files. - */ - $scope.addDocument = function() { + // Add a document with checked files + $scope.addDocument = function () { $state.go('document.add', { files: _.pluck($scope.checkedFiles(), 'id') }); }; + + // Open the feedback modal + $scope.openFeedback = function () { + $uibModal.open({ + templateUrl: 'partial/docs/feedback.html', + controller: 'ModalFeedback' + }).result.then(function (content) { + if (content === null) { + return; + } + + Restangular.withConfig(function (RestangularConfigurer) { + RestangularConfigurer.setBaseUrl('https://api.sismicsdocs.com'); + }).one('api').post('feedback', { + content: content + }).then(function () { + var title = $translate.instant('feedback.sent_title'); + var msg = $translate.instant('feedback.sent_message'); + var btns = [{result: 'ok', label: $translate.instant('ok'), cssClass: 'btn-primary'}]; + $dialog.messageBox(title, msg, btns); + }); + }); + }; }); \ No newline at end of file diff --git a/docs-web/src/main/webapp/src/app/docs/controller/document/FileView.js b/docs-web/src/main/webapp/src/app/docs/controller/document/FileView.js index d625314a..e6407e6e 100644 --- a/docs-web/src/main/webapp/src/app/docs/controller/document/FileView.js +++ b/docs-web/src/main/webapp/src/app/docs/controller/document/FileView.js @@ -20,7 +20,7 @@ angular.module('docs').controller('FileView', function($uibModal, $state, $state $timeout(function () { // After all router transitions are passed, // if we are still on the file route, go back to the document - if ($state.current.name === 'document.view.content.file') { + if ($state.current.name === 'document.view.content.file' || $state.current.name === 'document.default.file') { $state.go('^', {id: $stateParams.id}); } }); diff --git a/docs-web/src/main/webapp/src/app/docs/controller/settings/SettingsConfig.js b/docs-web/src/main/webapp/src/app/docs/controller/settings/SettingsConfig.js index 7efaac1d..932b41d0 100644 --- a/docs-web/src/main/webapp/src/app/docs/controller/settings/SettingsConfig.js +++ b/docs-web/src/main/webapp/src/app/docs/controller/settings/SettingsConfig.js @@ -5,29 +5,29 @@ */ angular.module('docs').controller('SettingsConfig', function($scope, $rootScope, Restangular) { // Get the app configuration - Restangular.one('app').get().then(function(data) { + Restangular.one('app').get().then(function (data) { $scope.app = data; }); // Enable/disable guest login - $scope.changeGuestLogin = function(enabled) { + $scope.changeGuestLogin = function (enabled) { Restangular.one('app').post('guest_login', { enabled: enabled - }).then(function() { + }).then(function () { $scope.app.guest_login = enabled; }); }; // Fetch the current theme configuration - Restangular.one('theme').get().then(function(data) { + Restangular.one('theme').get().then(function (data) { $scope.theme = data; $rootScope.appName = $scope.theme.name; }); // Update the theme - $scope.update = function() { + $scope.update = function () { $scope.theme.name = $scope.theme.name.length === 0 ? 'Sismics Docs' : $scope.theme.name; - Restangular.one('theme').post('', $scope.theme).then(function() { + Restangular.one('theme').post('', $scope.theme).then(function () { var stylesheet = $('#theme-stylesheet')[0]; stylesheet.href = stylesheet.href.replace(/\?.*|$/, '?' + new Date().getTime()); $rootScope.appName = $scope.theme.name; @@ -36,7 +36,7 @@ angular.module('docs').controller('SettingsConfig', function($scope, $rootScope, // Send an image $scope.sendingImage = false; - $scope.sendImage = function(type, image) { + $scope.sendImage = function (type, image) { // Build the payload var formData = new FormData(); formData.append('image', image); @@ -64,4 +64,14 @@ angular.module('docs').controller('SettingsConfig', function($scope, $rootScope, } }); }; + + // Load SMTP config + Restangular.one('app/config_smtp').get().then(function (data) { + $scope.smtp = data; + }); + + // Edit SMTP config + $scope.editSmtpConfig = function () { + Restangular.one('app').post('config_smtp', $scope.smtp); + }; }); \ No newline at end of file diff --git a/docs-web/src/main/webapp/src/app/share/controller/FileView.js b/docs-web/src/main/webapp/src/app/share/controller/FileView.js index 800a7ceb..7b431a5b 100644 --- a/docs-web/src/main/webapp/src/app/share/controller/FileView.js +++ b/docs-web/src/main/webapp/src/app/share/controller/FileView.js @@ -15,7 +15,7 @@ angular.module('share').controller('FileView', function($uibModal, $state, $stat modal.closed = false; modal.result.then(function() { modal.closed = true; - },function() { + }, function() { modal.closed = true; $timeout(function () { // After all router transitions are passed, diff --git a/docs-web/src/main/webapp/src/index.html b/docs-web/src/main/webapp/src/index.html index fa1ac830..86796c0b 100644 --- a/docs-web/src/main/webapp/src/index.html +++ b/docs-web/src/main/webapp/src/index.html @@ -48,7 +48,9 @@ - + + + @@ -156,6 +158,10 @@
-
\ No newline at end of file + + + \ No newline at end of file diff --git a/docs-web/src/main/webapp/src/partial/docs/document.html b/docs-web/src/main/webapp/src/partial/docs/document.html index 7b0d2722..e98d9bd8 100644 --- a/docs-web/src/main/webapp/src/partial/docs/document.html +++ b/docs-web/src/main/webapp/src/partial/docs/document.html @@ -159,7 +159,7 @@ next-text="{{ 'pagination.next' | translate }}" first-text="{{ 'pagination.first' | translate }}" last-text="{{ 'pagination.last' | translate }}" - total-items="totalDocuments" items-per-page="limit" max-size="5" ng-model="$parent.currentPage"> + total-items="totalDocuments" items-per-page="$parent.limit" max-size="5" ng-model="$parent.currentPage"> +

+ + + \ No newline at end of file diff --git a/docs-web/src/main/webapp/src/partial/docs/passwordlost.html b/docs-web/src/main/webapp/src/partial/docs/passwordlost.html index 73d042c0..2bb1baac 100644 --- a/docs-web/src/main/webapp/src/partial/docs/passwordlost.html +++ b/docs-web/src/main/webapp/src/partial/docs/passwordlost.html @@ -5,11 +5,11 @@ + + +

+
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ {{ 'validation.email' | translate }} +
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/docs-web/src/main/webapp/src/partial/docs/settings.group.edit.html b/docs-web/src/main/webapp/src/partial/docs/settings.group.edit.html index 9deaec3b..ca6432ae 100644 --- a/docs-web/src/main/webapp/src/partial/docs/settings.group.edit.html +++ b/docs-web/src/main/webapp/src/partial/docs/settings.group.edit.html @@ -33,7 +33,7 @@
+
+
+
+ +
+
+
+
-
diff --git a/docs-web/src/main/webapp/src/partial/docs/settings.user.html b/docs-web/src/main/webapp/src/partial/docs/settings.user.html index 6cc614c8..d0dae4ad 100644 --- a/docs-web/src/main/webapp/src/partial/docs/settings.user.html +++ b/docs-web/src/main/webapp/src/partial/docs/settings.user.html @@ -16,7 +16,8 @@ - {{ user.username }} + {{ user.username }} + {{ user.username }} {{ user.create_date | date: dateFormat }} diff --git a/docs-web/src/main/webapp/src/style/main.less b/docs-web/src/main/webapp/src/style/main.less index fdae4870..0a582242 100644 --- a/docs-web/src/main/webapp/src/style/main.less +++ b/docs-web/src/main/webapp/src/style/main.less @@ -310,6 +310,35 @@ input[readonly].share-link { padding-bottom: 0; } +// Feedback +.feedback { + display: block; + position: fixed; + right: 0; + top: 200px; + transform: rotate(-90deg) translateY(-100%); + background: #ccc; + padding: 8px; + color: #fff; + font-weight: bold; + transform-origin: 100% 0; + + &:hover { + background: #aaa; + text-decoration: none; + color: #fff; + } + + &:active { + background: #444; + } + + &:active, &:focus { + text-decoration: none; + color: #fff; + } +} + // Vertical alignment .vertical-center { min-height: 100vh; diff --git a/docs-web/src/prod/resources/config.properties b/docs-web/src/prod/resources/config.properties index 17c44aa4..743723d1 100644 --- a/docs-web/src/prod/resources/config.properties +++ b/docs-web/src/prod/resources/config.properties @@ -1,3 +1,3 @@ api.current_version=${project.version} api.min_version=1.0 -db.version=12 \ No newline at end of file +db.version=14 \ No newline at end of file diff --git a/docs-web/src/stress/resources/config.properties b/docs-web/src/stress/resources/config.properties index 17c44aa4..743723d1 100644 --- a/docs-web/src/stress/resources/config.properties +++ b/docs-web/src/stress/resources/config.properties @@ -1,3 +1,3 @@ api.current_version=${project.version} api.min_version=1.0 -db.version=12 \ No newline at end of file +db.version=14 \ No newline at end of file diff --git a/docs-web/src/test/java/com/sismics/docs/rest/TestAppResource.java b/docs-web/src/test/java/com/sismics/docs/rest/TestAppResource.java index 0434148b..dab82572 100644 --- a/docs-web/src/test/java/com/sismics/docs/rest/TestAppResource.java +++ b/docs-web/src/test/java/com/sismics/docs/rest/TestAppResource.java @@ -36,6 +36,8 @@ public class TestAppResource extends BaseJerseyTest { Long totalMemory = json.getJsonNumber("total_memory").longValue(); Assert.assertTrue(totalMemory > 0 && totalMemory > freeMemory); Assert.assertFalse(json.getBoolean("guest_login")); + Assert.assertTrue(json.containsKey("global_storage_current")); + Assert.assertTrue(json.getJsonNumber("active_user_count").longValue() > 0); // Rebuild Lucene index Response response = target().path("/app/batch/reindex").request() @@ -163,14 +165,34 @@ public class TestAppResource extends BaseJerseyTest { // Login admin String adminToken = clientUtil.login("admin", "admin", false); + // Get SMTP configuration + JsonObject json = target().path("/app/config_smtp").request() + .cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken) + .get(JsonObject.class); + Assert.assertTrue(json.isNull("hostname")); + Assert.assertTrue(json.isNull("port")); + Assert.assertTrue(json.isNull("username")); + Assert.assertTrue(json.isNull("password")); + Assert.assertTrue(json.isNull("from")); + // Change SMTP configuration target().path("/app/config_smtp").request() .cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken) .post(Entity.form(new Form() .param("hostname", "smtp.sismics.com") .param("port", "1234") - .param("from", "contact@sismics.com") .param("username", "sismics") + .param("from", "contact@sismics.com") ), JsonObject.class); + + // Get SMTP configuration + json = target().path("/app/config_smtp").request() + .cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken) + .get(JsonObject.class); + Assert.assertEquals("smtp.sismics.com", json.getString("hostname")); + Assert.assertEquals(1234, json.getInt("port")); + Assert.assertEquals("sismics", json.getString("username")); + Assert.assertTrue(json.isNull("password")); + Assert.assertEquals("contact@sismics.com", json.getString("from")); } } \ No newline at end of file diff --git a/docs-web/src/test/java/com/sismics/docs/rest/TestUserResource.java b/docs-web/src/test/java/com/sismics/docs/rest/TestUserResource.java index 4d5bdfa7..e9fe73a5 100644 --- a/docs-web/src/test/java/com/sismics/docs/rest/TestUserResource.java +++ b/docs-web/src/test/java/com/sismics/docs/rest/TestUserResource.java @@ -1,5 +1,6 @@ package com.sismics.docs.rest; +import com.sismics.docs.core.model.context.AppContext; import com.sismics.util.filter.TokenBasedSecurityFilter; import com.sismics.util.totp.GoogleAuthenticator; import org.junit.Assert; @@ -13,6 +14,8 @@ import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import java.util.Date; import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Exhaustive test of the user resource. @@ -54,6 +57,7 @@ public class TestUserResource extends BaseJerseyTest { Assert.assertNotNull(user.getJsonNumber("storage_current")); Assert.assertNotNull(user.getJsonNumber("create_date")); Assert.assertFalse(user.getBoolean("totp_enabled")); + Assert.assertFalse(user.getBoolean("disabled")); // Create a user KO (login length validation) Response response = target().path("/user").request() @@ -259,7 +263,7 @@ public class TestUserResource extends BaseJerseyTest { Assert.assertEquals("newadminemail@docs.com", json.getString("email")); // User admin update admin_user1 information - json = target().path("/user").request() + json = target().path("/user/admin_user1").request() .cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken) .post(Entity.form(new Form() .param("email", " alice2@docs.com ")), JsonObject.class); @@ -273,6 +277,36 @@ public class TestUserResource extends BaseJerseyTest { json = response.readEntity(JsonObject.class); Assert.assertEquals("ForbiddenError", json.getString("type")); + // User admin disable admin_user1 + json = target().path("/user/admin_user1").request() + .cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken) + .post(Entity.form(new Form() + .param("disabled", "true")), JsonObject.class); + Assert.assertEquals("ok", json.getString("status")); + + // User admin_user1 tries to authenticate + response = target().path("/user/login").request() + .post(Entity.form(new Form() + .param("username", "admin_user1") + .param("password", "12345678") + .param("remember", "false"))); + Assert.assertEquals(Status.FORBIDDEN.getStatusCode(), response.getStatus()); + + // User admin enable admin_user1 + json = target().path("/user/admin_user1").request() + .cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken) + .post(Entity.form(new Form() + .param("disabled", "false")), JsonObject.class); + Assert.assertEquals("ok", json.getString("status")); + + // User admin_user1 tries to authenticate + response = target().path("/user/login").request() + .post(Entity.form(new Form() + .param("username", "admin_user1") + .param("password", "12345678") + .param("remember", "false"))); + Assert.assertEquals(Status.OK.getStatusCode(), response.getStatus()); + // User admin deletes user admin_user1 json = target().path("/user/admin_user1").request() .cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken) @@ -354,4 +388,79 @@ public class TestUserResource extends BaseJerseyTest { .get(JsonObject.class); Assert.assertFalse(json.getBoolean("totp_enabled")); } + + @Test + public void testResetPassword() throws Exception { + // Login admin + String adminToken = clientUtil.login("admin", "admin", false); + + // Change SMTP configuration to target Wiser + target().path("/app/config_smtp").request() + .cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken) + .post(Entity.form(new Form() + .param("hostname", "localhost") + .param("port", "2500") + .param("from", "contact@sismicsdocs.com") + ), JsonObject.class); + + // Create absent_minded who lost his password + clientUtil.createUser("absent_minded"); + + // User no_such_user try to recovery its password: invalid user + Response response = target().path("/user/password_lost").request() + .post(Entity.form(new Form() + .param("username", "no_such_user"))); + Assert.assertEquals(Response.Status.BAD_REQUEST, Response.Status.fromStatusCode(response.getStatus())); + JsonObject json = response.readEntity(JsonObject.class); + Assert.assertEquals("UserNotFound", json.getString("type")); + + // User absent_minded try to recovery its password: OK + json = target().path("/user/password_lost").request() + .post(Entity.form(new Form() + .param("username", "absent_minded")), JsonObject.class); + Assert.assertEquals("ok", json.getString("status")); + AppContext.getInstance().waitForAsync(); + String emailBody = popEmail(); + Assert.assertNotNull("No email to consume", emailBody); + Assert.assertTrue(emailBody.contains("Please reset your password")); + Pattern keyPattern = Pattern.compile("/passwordreset/(.+?)\""); + Matcher keyMatcher = keyPattern.matcher(emailBody); + Assert.assertTrue("Token not found", keyMatcher.find()); + String key = keyMatcher.group(1).replaceAll("=", ""); + + // User absent_minded resets its password: invalid key + response = target().path("/user/password_reset").request() + .post(Entity.form(new Form() + .param("key", "no_such_key") + .param("password", "87654321"))); + Assert.assertEquals(Response.Status.BAD_REQUEST, Response.Status.fromStatusCode(response.getStatus())); + json = response.readEntity(JsonObject.class); + Assert.assertEquals("KeyNotFound", json.getString("type")); + + // User absent_minded resets its password: password invalid + response = target().path("/user/password_reset").request() + .post(Entity.form(new Form() + .param("key", key) + .param("password", " 1 "))); + Assert.assertEquals(Response.Status.BAD_REQUEST, Response.Status.fromStatusCode(response.getStatus())); + json = response.readEntity(JsonObject.class); + Assert.assertEquals("ValidationError", json.getString("type")); + Assert.assertTrue(json.getString("message"), json.getString("message").contains("password")); + + // User absent_minded resets its password: OK + json = target().path("/user/password_reset").request() + .post(Entity.form(new Form() + .param("key", key) + .param("password", "87654321")), JsonObject.class); + Assert.assertEquals("ok", json.getString("status")); + + // User absent_minded resets its password: expired key + response = target().path("/user/password_reset").request() + .post(Entity.form(new Form() + .param("key", key) + .param("password", "87654321"))); + Assert.assertEquals(Response.Status.BAD_REQUEST, Response.Status.fromStatusCode(response.getStatus())); + json = response.readEntity(JsonObject.class); + Assert.assertEquals("KeyNotFound", json.getString("type")); + } } \ No newline at end of file diff --git a/docs-web/src/test/resources/log4j.properties b/docs-web/src/test/resources/log4j.properties index 1d786f52..85def7fe 100644 --- a/docs-web/src/test/resources/log4j.properties +++ b/docs-web/src/test/resources/log4j.properties @@ -6,5 +6,7 @@ log4j.appender.MEMORY=com.sismics.util.log4j.MemoryAppender log4j.appender.MEMORY.size=1000 log4j.logger.com.sismics=INFO -log4j.logger.org.hibernate=INFO -log4j.logger.org.apache.pdfbox=INFO \ No newline at end of file +log4j.logger.com.sismics.util.jpa=ERROR +log4j.logger.org.hibernate=ERROR +log4j.logger.org.apache.pdfbox=INFO +log4j.logger.com.mchange=ERROR diff --git a/pom.xml b/pom.xml index b3cadb17..64cb10a4 100644 --- a/pom.xml +++ b/pom.xml @@ -19,6 +19,8 @@ 1.10 2.6 2.4 + 1.5 + 2.3.23 1.4 19.0 1.2.16 @@ -27,6 +29,7 @@ 4.12 1.4.191 2.22.2 + 1.0.4 0.3m 5.5.0 4.2 @@ -40,6 +43,7 @@ 3.2.1 1.6.5 1.3.1 + 1.2 9.2.13.v20150730 9.2.13.v20150730 @@ -109,6 +113,10 @@ org.apache.maven.plugins maven-surefire-plugin ${org.apache.maven.plugins.maven-surefire-plugin.version} + + 1 + false + @@ -193,6 +201,12 @@ commons-io ${commons-io.commons-io.version} + + + org.apache.commons + commons-email + ${org.apache.commons.commons-email.version} + com.google.guava @@ -284,7 +298,13 @@ jersey-container-grizzly2-servlet ${org.glassfish.jersey.version} - + + + org.glassfish + javax.json + ${org.glassfish.javax.json.version} + + com.h2database h2 @@ -314,7 +334,13 @@ commons-dbcp ${commons-dbcp.version} - + + + org.freemarker + freemarker + ${org.freemarker.freemarker.version} + + joda-time joda-time @@ -375,6 +401,12 @@ ${fr.opensagres.xdocreport.version} + + org.subethamail + subethasmtp-wiser + ${org.subethamail.subethasmtp-wiser.version} + + com.twelvemonkeys.servlet