Merge remote-tracking branch 'origin/master'

This commit is contained in:
bgamard 2017-11-23 15:32:31 +01:00
commit 4fc434a222
68 changed files with 1626 additions and 250 deletions

View File

@ -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. 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 Features
-------- --------

View File

@ -52,6 +52,21 @@
<artifactId>commons-lang</artifactId> <artifactId>commons-lang</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-email</artifactId>
</dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
</dependency>
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>javax.json</artifactId>
</dependency>
<dependency> <dependency>
<groupId>log4j</groupId> <groupId>log4j</groupId>
<artifactId>log4j</artifactId> <artifactId>log4j</artifactId>

View File

@ -44,4 +44,37 @@ public class Constants {
* Supported document languages. * Supported document languages.
*/ */
public static final List<String> SUPPORTED_LANGUAGES = Lists.newArrayList("eng", "fra", "ita", "deu", "spa", "por", "pol", "rus", "ukr", "ara", "hin", "chi_sim", "chi_tra", "jpn", "tha", "kor"); public static final List<String> 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";
} }

View File

@ -1,18 +1,5 @@
package com.sismics.docs.core.dao.jpa; 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.Joiner;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import com.sismics.docs.core.constant.AuditLogType; 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.docs.core.util.jpa.SortCriteria;
import com.sismics.util.context.ThreadLocalContext; 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. * Document DAO.
* *
@ -322,4 +315,15 @@ public class DocumentDao {
return documentFromDb; 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();
}
} }

View File

@ -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();
}
}

View File

@ -1,19 +1,5 @@
package com.sismics.docs.core.dao.jpa; 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.google.common.base.Joiner;
import com.sismics.docs.core.constant.AuditLogType; import com.sismics.docs.core.constant.AuditLogType;
import com.sismics.docs.core.dao.jpa.criteria.UserCriteria; 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.QueryUtil;
import com.sismics.docs.core.util.jpa.SortCriteria; import com.sismics.docs.core.util.jpa.SortCriteria;
import com.sismics.util.context.ThreadLocalContext; 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. * User DAO.
@ -44,7 +38,7 @@ public class UserDao {
q.setParameter("username", username); q.setParameter("username", username);
try { try {
User user = (User) q.getSingleResult(); User user = (User) q.getSingleResult();
if (!BCrypt.checkpw(password, user.getPassword())) { if (!BCrypt.checkpw(password, user.getPassword()) || user.getDisableDate() != null) {
return null; return null;
} }
return user; return user;
@ -59,7 +53,7 @@ public class UserDao {
* @param user User to create * @param user User to create
* @param userId User ID * @param userId User ID
* @return User ID * @return User ID
* @throws Exception * @throws Exception e
*/ */
public String create(User user, String userId) throws Exception { public String create(User user, String userId) throws Exception {
// Create the user UUID // Create the user UUID
@ -105,6 +99,7 @@ public class UserDao {
userFromDb.setStorageQuota(user.getStorageQuota()); userFromDb.setStorageQuota(user.getStorageQuota());
userFromDb.setStorageCurrent(user.getStorageCurrent()); userFromDb.setStorageCurrent(user.getStorageCurrent());
userFromDb.setTotpKey(user.getTotpKey()); userFromDb.setTotpKey(user.getTotpKey());
userFromDb.setDisableDate(user.getDisableDate());
// Create audit log // Create audit log
AuditLogUtil.create(userFromDb, AuditLogType.UPDATE, userId); AuditLogUtil.create(userFromDb, AuditLogType.UPDATE, userId);
@ -116,9 +111,8 @@ public class UserDao {
* Updates a user's quota. * Updates a user's quota.
* *
* @param user User to update * @param user User to update
* @return Updated user
*/ */
public User updateQuota(User user) { public void updateQuota(User user) {
EntityManager em = ThreadLocalContext.get().getEntityManager(); EntityManager em = ThreadLocalContext.get().getEntityManager();
// Get the user // Get the user
@ -128,8 +122,6 @@ public class UserDao {
// Update the user // Update the user
userFromDb.setStorageQuota(user.getStorageQuota()); userFromDb.setStorageQuota(user.getStorageQuota());
return user;
} }
/** /**
@ -256,7 +248,7 @@ public class UserDao {
Map<String, Object> parameterMap = new HashMap<>(); Map<String, Object> parameterMap = new HashMap<>();
List<String> criteriaList = new ArrayList<>(); List<String> 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 "); sb.append(" from T_USER u ");
// Add search criterias // Add search criterias
@ -293,9 +285,38 @@ public class UserDao {
userDto.setCreateTimestamp(((Timestamp) o[i++]).getTime()); userDto.setCreateTimestamp(((Timestamp) o[i++]).getTime());
userDto.setStorageCurrent(((Number) o[i++]).longValue()); userDto.setStorageCurrent(((Number) o[i++]).longValue());
userDto.setStorageQuota(((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); userDtoList.add(userDto);
} }
return userDtoList; 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();
}
} }

View File

@ -26,6 +26,11 @@ public class UserDto {
*/ */
private Long createTimestamp; private Long createTimestamp;
/**
* Disable date of this user.
*/
private Long disableTimestamp;
/** /**
* Storage quota. * Storage quota.
*/ */
@ -73,6 +78,15 @@ public class UserDto {
this.createTimestamp = createTimestamp; this.createTimestamp = createTimestamp;
} }
public Long getDisableTimestamp() {
return disableTimestamp;
}
public UserDto setDisableTimestamp(Long disableTimestamp) {
this.disableTimestamp = disableTimestamp;
return this;
}
public Long getStorageQuota() { public Long getStorageQuota() {
return storageQuota; return storageQuota;
} }

View File

@ -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();
}
}

View File

@ -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<String, Object> 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);
}
});
}
}

View File

@ -1,5 +1,16 @@
package com.sismics.docs.core.model.context; 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.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@ -7,19 +18,6 @@ import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit; 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. * Global application context.
* *
@ -41,6 +39,11 @@ public class AppContext {
*/ */
private EventBus asyncEventBus; private EventBus asyncEventBus;
/**
* Asynchronous bus for email sending.
*/
private EventBus mailEventBus;
/** /**
* Indexing service. * Indexing service.
*/ */
@ -83,6 +86,9 @@ public class AppContext {
asyncEventBus.register(new DocumentDeletedAsyncListener()); asyncEventBus.register(new DocumentDeletedAsyncListener());
asyncEventBus.register(new RebuildIndexAsyncListener()); asyncEventBus.register(new RebuildIndexAsyncListener());
asyncEventBus.register(new TemporaryFileCleanupAsyncListener()); 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() { public EventBus getEventBus() {
return eventBus; return eventBus;
} }
/**
* Getter of asyncEventBus.
*
* @return asyncEventBus
*/
public EventBus getAsyncEventBus() { public EventBus getAsyncEventBus() {
return asyncEventBus; return asyncEventBus;
} }
/** public EventBus getMailEventBus() {
* Getter of indexingService. return mailEventBus;
* }
* @return indexingService
*/
public IndexingService getIndexingService() { public IndexingService getIndexingService() {
return indexingService; return indexingService;
} }

View File

@ -171,7 +171,7 @@ public class File implements Loggable {
@Override @Override
public String toMessage() { public String toMessage() {
// Attached document ID and name concatenated // Attached document ID and name concatenated
return documentId + name; return (documentId == null ? Strings.repeat(" ", 36) : documentId) + name;
} }
/** /**

View File

@ -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();
}
}

View File

@ -1,13 +1,12 @@
package com.sismics.docs.core.model.jpa; package com.sismics.docs.core.model.jpa;
import java.util.Date; import com.google.common.base.MoreObjects;
import javax.persistence.Column; import javax.persistence.Column;
import javax.persistence.Entity; import javax.persistence.Entity;
import javax.persistence.Id; import javax.persistence.Id;
import javax.persistence.Table; import javax.persistence.Table;
import java.util.Date;
import com.google.common.base.MoreObjects;
/** /**
* User entity. * User entity.
@ -84,6 +83,12 @@ public class User implements Loggable {
@Column(name = "USE_DELETEDATE_D") @Column(name = "USE_DELETEDATE_D")
private Date deleteDate; private Date deleteDate;
/**
* Disable date.
*/
@Column(name = "USE_DISABLEDATE_D")
private Date disableDate;
public String getId() { public String getId() {
return id; return id;
} }
@ -148,6 +153,15 @@ public class User implements Loggable {
return this; return this;
} }
public Date getDisableDate() {
return disableDate;
}
public User setDisableDate(Date disableDate) {
this.disableDate = disableDate;
return this;
}
public String getPrivateKey() { public String getPrivateKey() {
return privateKey; return privateKey;
} }

View File

@ -22,6 +22,10 @@ public class AuditLogUtil {
* @param userId User ID * @param userId User ID
*/ */
public static void create(Loggable loggable, AuditLogType type, String userId) { public static void create(Loggable loggable, AuditLogType type, String userId) {
if (userId == null) {
userId = "admin";
}
// Get the entity ID // Get the entity ID
EntityManager em = ThreadLocalContext.get().getEntityManager(); EntityManager em = ThreadLocalContext.get().getEntityManager();
String entityId = (String) em.getEntityManagerFactory().getPersistenceUnitUtil().getIdentifier(loggable); String entityId = (String) em.getEntityManagerFactory().getPersistenceUnitUtil().getIdentifier(loggable);

View File

@ -129,8 +129,8 @@ public class FileUtil {
if (image != null) { if (image != null) {
// Generate thumbnails from image // Generate thumbnails from image
BufferedImage web = Scalr.resize(image, Scalr.Method.AUTOMATIC, Scalr.Mode.AUTOMATIC, 1280, Scalr.OP_ANTIALIAS); BufferedImage web = Scalr.resize(image, Scalr.Method.ULTRA_QUALITY, Scalr.Mode.AUTOMATIC, 1280);
BufferedImage thumbnail = Scalr.resize(image, Scalr.Method.AUTOMATIC, Scalr.Mode.AUTOMATIC, 256, Scalr.OP_ANTIALIAS); BufferedImage thumbnail = Scalr.resize(image, Scalr.Method.ULTRA_QUALITY, Scalr.Mode.AUTOMATIC, 256);
image.flush(); image.flush();
// Write "web" encrypted image // Write "web" encrypted image

View File

@ -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();
}
}

View File

@ -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<String, Object> 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<String, Object> 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<String, Object> 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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}
}

View File

@ -4,6 +4,6 @@
xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd" xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd"
version="2.0"> version="2.0">
<persistence-unit name="transactions-optional" transaction-type="RESOURCE_LOCAL"> <persistence-unit name="transactions-optional" transaction-type="RESOURCE_LOCAL">
<provider>org.hibernate.ejb.HibernatePersistence</provider> <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
</persistence-unit> </persistence-unit>
</persistence> </persistence>

View File

@ -1 +1 @@
db.version=12 db.version=14

View File

@ -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';

View File

@ -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';

View File

@ -0,0 +1,16 @@
<#macro email>
<table style="width: 100%; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';">
<tr style="background: #242424; color: #fff;">
<td style="padding: 12px; font-size: 16px; font-weight: bold;">
${app_name}
</td>
</tr>
<tr>
<td style="padding-bottom: 10px; padding-top: 10px;">
<div style="border: 1px solid #ddd; padding: 10px;">
<#nested>
</div>
</td>
</tr>
</table>
</#macro>

View File

@ -0,0 +1,8 @@
<#import "../layout.ftl" as layout>
<@layout.email>
<h2>${app_name} - ${messages['email.template.password_recovery.subject']}</h2>
<p>${messages('email.template.password_recovery.hello', user_name)}</p>
<p>${messages['email.template.password_recovery.instruction1']}</p>
<p>${messages['email.template.password_recovery.instruction2']}</p>
<a href="${base_url}/#/passwordreset/${password_recovery_key}">${messages['email.template.password_recovery.click_here']}</a>
</@layout.email>

View File

@ -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.<br/>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

View File

@ -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.<br/>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

View File

@ -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=com.sismics.util.log4j.MemoryAppender
log4j.appender.MEMORY.size=1000 log4j.appender.MEMORY.size=1000
log4j.logger.com.sismics=DEBUG log4j.logger.com.sismics=INFO
log4j.logger.org.hibernate=ERROR

View File

@ -94,6 +94,12 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.subethamail</groupId>
<artifactId>subethasmtp-wiser</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<!-- Install test jar --> <!-- Install test jar -->

View File

@ -57,7 +57,7 @@ public abstract class SecurityFilter implements Filter {
*/ */
private void injectUser(HttpServletRequest request, User user) { private void injectUser(HttpServletRequest request, User user) {
// Check if the user is still valid // 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); injectAuthenticatedUser(request, user);
} else { } else {
injectAnonymousUser(request); injectAnonymousUser(request);

View File

@ -1,11 +1,9 @@
package com.sismics.docs.rest; package com.sismics.docs.rest;
import java.net.URI; import com.sismics.docs.rest.util.ClientUtil;
import javax.ws.rs.core.Application;
import javax.ws.rs.core.UriBuilder;
import com.sismics.util.filter.HeaderBasedSecurityFilter; 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.http.server.HttpServer;
import org.glassfish.grizzly.servlet.ServletRegistration; import org.glassfish.grizzly.servlet.ServletRegistration;
import org.glassfish.grizzly.servlet.WebappContext; 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.glassfish.jersey.test.spi.TestContainerFactory;
import org.junit.After; import org.junit.After;
import org.junit.Before; import org.junit.Before;
import org.subethamail.wiser.Wiser;
import org.subethamail.wiser.WiserMessage;
import com.sismics.docs.rest.util.ClientUtil; import javax.mail.MessagingException;
import com.sismics.util.filter.RequestContextFilter; import javax.mail.internet.MimeMessage;
import com.sismics.util.filter.TokenBasedSecurityFilter; 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. * Base class of integration tests with Jersey.
@ -38,6 +43,11 @@ public abstract class BaseJerseyTest extends JerseyTest {
*/ */
protected ClientUtil clientUtil; protected ClientUtil clientUtil;
/**
* Test mail server.
*/
private Wiser wiser;
@Override @Override
protected TestContainerFactory getTestContainerFactory() throws TestContainerException { protected TestContainerFactory getTestContainerFactory() throws TestContainerException {
return new ExternalTestContainerFactory(); return new ExternalTestContainerFactory();
@ -45,10 +55,10 @@ public abstract class BaseJerseyTest extends JerseyTest {
@Override @Override
protected Application configure() { protected Application configure() {
enable(TestProperties.LOG_TRAFFIC);
String travisEnv = System.getenv("TRAVIS"); String travisEnv = System.getenv("TRAVIS");
if (travisEnv == null || !travisEnv.equals("true")) { 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); enable(TestProperties.DUMP_ENTITY);
} }
return new Application(); return new Application();
@ -84,12 +94,39 @@ public abstract class BaseJerseyTest extends JerseyTest {
reg.setAsyncSupported(true); reg.setAsyncSupported(true);
context.deploy(httpServer); context.deploy(httpServer);
httpServer.start(); 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<WiserMessage> 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 @Override
@After @After
public void tearDown() throws Exception { public void tearDown() throws Exception {
super.tearDown(); super.tearDown();
if (wiser != null) {
wiser.stop();
}
if (httpServer != null) { if (httpServer != null) {
httpServer.shutdownNow(); httpServer.shutdownNow();
} }

View File

@ -122,6 +122,12 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.subethamail</groupId>
<artifactId>subethasmtp-wiser</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@ -1,3 +1,3 @@
api.current_version=${project.version} api.current_version=${project.version}
api.min_version=1.0 api.min_version=1.0
db.version=12 db.version=14

View File

@ -1,32 +1,14 @@
package com.sismics.docs.rest.resource; package com.sismics.docs.rest.resource;
import java.io.IOException; import com.google.common.base.Strings;
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.sismics.docs.core.constant.ConfigType; 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.docs.core.event.RebuildIndexAsyncEvent;
import com.sismics.rest.util.ValidationUtil; import com.sismics.docs.core.model.jpa.Config;
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.File; import com.sismics.docs.core.model.jpa.File;
import com.sismics.docs.core.model.jpa.User; import com.sismics.docs.core.model.jpa.User;
import com.sismics.docs.core.util.ConfigUtil; 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.docs.rest.constant.BaseFunction;
import com.sismics.rest.exception.ForbiddenClientException; import com.sismics.rest.exception.ForbiddenClientException;
import com.sismics.rest.exception.ServerException; import com.sismics.rest.exception.ServerException;
import com.sismics.rest.util.ValidationUtil;
import com.sismics.util.context.ThreadLocalContext; import com.sismics.util.context.ThreadLocalContext;
import com.sismics.util.log4j.LogCriteria; import com.sismics.util.log4j.LogCriteria;
import com.sismics.util.log4j.LogEntry; import com.sismics.util.log4j.LogEntry;
import com.sismics.util.log4j.MemoryAppender; 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. * General app REST resource.
@ -64,6 +64,10 @@ public class AppResource extends BaseResource {
* @apiSuccess {Boolean} guest_login True if guest login is enabled * @apiSuccess {Boolean} guest_login True if guest login is enabled
* @apiSuccess {String} total_memory Allocated JVM memory (in bytes) * @apiSuccess {String} total_memory Allocated JVM memory (in bytes)
* @apiSuccess {String} free_memory Free 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 * @apiPermission none
* @apiVersion 1.5.0 * @apiVersion 1.5.0
* *
@ -75,13 +79,26 @@ public class AppResource extends BaseResource {
String currentVersion = configBundle.getString("api.current_version"); String currentVersion = configBundle.getString("api.current_version");
String minVersion = configBundle.getString("api.min_version"); String minVersion = configBundle.getString("api.min_version");
Boolean guestLogin = ConfigUtil.getConfigBooleanValue(ConfigType.GUEST_LOGIN); 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() JsonObjectBuilder response = Json.createObjectBuilder()
.add("current_version", currentVersion.replace("-SNAPSHOT", "")) .add("current_version", currentVersion.replace("-SNAPSHOT", ""))
.add("min_version", minVersion) .add("min_version", minVersion)
.add("guest_login", guestLogin) .add("guest_login", guestLogin)
.add("total_memory", Runtime.getRuntime().totalMemory()) .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(); return Response.ok().entity(response.build()).build();
} }
@ -114,6 +131,75 @@ public class AppResource extends BaseResource {
return Response.ok().build(); 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. * Configure the SMTP server.
* *
@ -122,45 +208,53 @@ public class AppResource extends BaseResource {
* @apiGroup App * @apiGroup App
* @apiParam {String} hostname SMTP hostname * @apiParam {String} hostname SMTP hostname
* @apiParam {Integer} port SMTP port * @apiParam {Integer} port SMTP port
* @apiParam {String} from From address
* @apiParam {String} username SMTP username * @apiParam {String} username SMTP username
* @apiParam {String} password SMTP password * @apiParam {String} password SMTP password
* @apiParam {String} from From address
* @apiError (client) ForbiddenError Access denied * @apiError (client) ForbiddenError Access denied
* @apiError (client) ValidationError Validation error
* @apiPermission admin * @apiPermission admin
* @apiVersion 1.5.0 * @apiVersion 1.5.0
* *
* @param hostname SMTP hostname * @param hostname SMTP hostname
* @param portStr SMTP port * @param portStr SMTP port
* @param from From address
* @param username SMTP username * @param username SMTP username
* @param password SMTP password * @param password SMTP password
* @param from From address
* @return Response * @return Response
*/ */
@POST @POST
@Path("config_smtp") @Path("config_smtp")
public Response configSmtp(@FormParam("hostname") String hostname, public Response configSmtp(@FormParam("hostname") String hostname,
@FormParam("port") String portStr, @FormParam("port") String portStr,
@FormParam("from") String from,
@FormParam("username") String username, @FormParam("username") String username,
@FormParam("password") String password) { @FormParam("password") String password,
@FormParam("from") String from) {
if (!authenticate()) { if (!authenticate()) {
throw new ForbiddenClientException(); throw new ForbiddenClientException();
} }
checkBaseFunction(BaseFunction.ADMIN); checkBaseFunction(BaseFunction.ADMIN);
ValidationUtil.validateRequired(hostname, "hostname"); if (!Strings.isNullOrEmpty(portStr)) {
ValidationUtil.validateInteger(portStr, "port"); ValidationUtil.validateInteger(portStr, "port");
ValidationUtil.validateRequired(from, "from"); }
// Just update the changed configuration
ConfigDao configDao = new ConfigDao(); ConfigDao configDao = new ConfigDao();
if (!Strings.isNullOrEmpty(hostname)) {
configDao.update(ConfigType.SMTP_HOSTNAME, hostname); configDao.update(ConfigType.SMTP_HOSTNAME, hostname);
}
if (!Strings.isNullOrEmpty(portStr)) {
configDao.update(ConfigType.SMTP_PORT, portStr); configDao.update(ConfigType.SMTP_PORT, portStr);
configDao.update(ConfigType.SMTP_FROM, from); }
if (username != null) { if (!Strings.isNullOrEmpty(username)) {
configDao.update(ConfigType.SMTP_USERNAME, username); configDao.update(ConfigType.SMTP_USERNAME, username);
} }
if (password != null) { if (!Strings.isNullOrEmpty(password)) {
configDao.update(ConfigType.SMTP_PASSWORD, password); configDao.update(ConfigType.SMTP_PASSWORD, password);
} }
if (!Strings.isNullOrEmpty(from)) {
configDao.update(ConfigType.SMTP_FROM, from);
}
return Response.ok().build(); return Response.ok().build();
} }

View File

@ -3,6 +3,7 @@ package com.sismics.docs.rest.resource;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import com.google.common.io.ByteStreams; 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.constant.PermType;
import com.sismics.docs.core.dao.jpa.AclDao; import com.sismics.docs.core.dao.jpa.AclDao;
import com.sismics.docs.core.dao.jpa.DocumentDao; 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); throw new ServerException("ErrorGuessMime", "Error guessing mime type", e);
} }
// Validate quota // Validate user quota
if (user.getStorageCurrent() + fileSize > user.getStorageQuota()) { if (user.getStorageCurrent() + fileSize > user.getStorageQuota()) {
throw new ClientException("QuotaReached", "Quota limit reached"); 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 { try {
// Get files of this document // Get files of this document
FileDao fileDao = new FileDao(); FileDao fileDao = new FileDao();

View File

@ -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.dao.jpa.dto.UserDto;
import com.sismics.docs.core.event.DocumentDeletedAsyncEvent; import com.sismics.docs.core.event.DocumentDeletedAsyncEvent;
import com.sismics.docs.core.event.FileDeletedAsyncEvent; 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.model.jpa.*;
import com.sismics.docs.core.util.ConfigUtil; import com.sismics.docs.core.util.ConfigUtil;
import com.sismics.docs.core.util.EncryptionUtil; import com.sismics.docs.core.util.EncryptionUtil;
@ -184,6 +186,7 @@ public class UserResource extends BaseResource {
* @apiParam {String{8..50}} password Password * @apiParam {String{8..50}} password Password
* @apiParam {String{1..100}} email E-mail * @apiParam {String{1..100}} email E-mail
* @apiParam {Number} storage_quota Storage quota (in bytes) * @apiParam {Number} storage_quota Storage quota (in bytes)
* @apiParam {Boolean} disabled Disabled status
* @apiSuccess {String} status Status OK * @apiSuccess {String} status Status OK
* @apiError (client) ForbiddenError Access denied * @apiError (client) ForbiddenError Access denied
* @apiError (client) ValidationError Validation error * @apiError (client) ValidationError Validation error
@ -202,7 +205,8 @@ public class UserResource extends BaseResource {
@PathParam("username") String username, @PathParam("username") String username,
@FormParam("password") String password, @FormParam("password") String password,
@FormParam("email") String email, @FormParam("email") String email,
@FormParam("storage_quota") String storageQuotaStr) { @FormParam("storage_quota") String storageQuotaStr,
@FormParam("disabled") Boolean disabled) {
if (!authenticate()) { if (!authenticate()) {
throw new ForbiddenClientException(); throw new ForbiddenClientException();
} }
@ -216,7 +220,7 @@ public class UserResource extends BaseResource {
UserDao userDao = new UserDao(); UserDao userDao = new UserDao();
User user = userDao.getActiveByUsername(username); User user = userDao.getActiveByUsername(username);
if (user == null) { if (user == null) {
throw new ClientException("UserNotFound", "The user doesn't exist"); throw new ClientException("UserNotFound", "The user does not exist");
} }
// Update the user // Update the user
@ -227,6 +231,22 @@ public class UserResource extends BaseResource {
Long storageQuota = ValidationUtil.validateLong(storageQuotaStr, "storage_quota"); Long storageQuota = ValidationUtil.validateLong(storageQuotaStr, "storage_quota");
user.setStorageQuota(storageQuota); user.setStorageQuota(storageQuota);
} }
if (disabled != null) {
// Cannot disable the admin user or the guest user
RoleBaseFunctionDao userBaseFuction = new RoleBaseFunctionDao();
Set<String> 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()); user = userDao.update(user, principal.getId());
// Change the password // Change the password
@ -629,6 +649,7 @@ public class UserResource extends BaseResource {
* @apiSuccess {Number} storage_quota Storage quota (in bytes) * @apiSuccess {Number} storage_quota Storage quota (in bytes)
* @apiSuccess {Number} storage_current Quota used (in bytes) * @apiSuccess {Number} storage_current Quota used (in bytes)
* @apiSuccess {String[]} groups Groups * @apiSuccess {String[]} groups Groups
* @apiSuccess {Boolean} disabled True if the user is disabled
* @apiError (client) ForbiddenError Access denied * @apiError (client) ForbiddenError Access denied
* @apiError (client) UserNotFound The user does not exist * @apiError (client) UserNotFound The user does not exist
* @apiPermission user * @apiPermission user
@ -666,7 +687,8 @@ public class UserResource extends BaseResource {
.add("groups", groups) .add("groups", groups)
.add("email", user.getEmail()) .add("email", user.getEmail())
.add("storage_quota", user.getStorageQuota()) .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(); 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_quota Storage quota (in bytes)
* @apiSuccess {Number} users.storage_current Quota used (in bytes) * @apiSuccess {Number} users.storage_current Quota used (in bytes)
* @apiSuccess {Number} users.create_date Create date (timestamp) * @apiSuccess {Number} users.create_date Create date (timestamp)
* @apiSuccess {Number} users.disabled True if the user is disabled
* @apiError (client) ForbiddenError Access denied * @apiError (client) ForbiddenError Access denied
* @apiPermission user * @apiPermission user
* @apiVersion 1.5.0 * @apiVersion 1.5.0
@ -728,7 +751,8 @@ public class UserResource extends BaseResource {
.add("email", userDto.getEmail()) .add("email", userDto.getEmail())
.add("storage_quota", userDto.getStorageQuota()) .add("storage_quota", userDto.getStorageQuota())
.add("storage_current", userDto.getStorageCurrent()) .add("storage_current", userDto.getStorageCurrent())
.add("create_date", userDto.getCreateTimestamp())); .add("create_date", userDto.getCreateTimestamp())
.add("disabled", userDto.getDisableTimestamp() != null));
} }
JsonObjectBuilder response = Json.createObjectBuilder() JsonObjectBuilder response = Json.createObjectBuilder()
@ -902,6 +926,109 @@ public class UserResource extends BaseResource {
return Response.ok().entity(response.build()).build(); 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. * Returns the authentication token value.
* *

View File

@ -13,7 +13,7 @@ angular.module('docs',
/** /**
* Configuring modules. * Configuring modules.
*/ */
.config(function($locationProvider, $urlRouterProvider, $stateProvider, $httpProvider, .config(function($locationProvider, $urlRouterProvider, $stateProvider, $httpProvider, $qProvider,
RestangularProvider, $translateProvider, timeAgoSettings, tmhDynamicLocaleProvider) { RestangularProvider, $translateProvider, timeAgoSettings, tmhDynamicLocaleProvider) {
$locationProvider.hashPrefix(''); $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', { .state('tag', {
url: '/tag', url: '/tag',
abstract: true, abstract: true,
@ -408,6 +417,9 @@ angular.module('docs',
return angular.isObject(data) && String(data) !== '[object File]' ? param(data) : data; return angular.isObject(data) && String(data) !== '[object File]' ? param(data) : data;
}]; }];
// Silence unhandled rejections
$qProvider.errorOnUnhandledRejections(false);
}) })
/** /**

View File

@ -45,23 +45,23 @@ angular.module('docs').controller('Login', function(Restangular, $scope, $rootSc
$scope.openPasswordLost = function () { $scope.openPasswordLost = function () {
$uibModal.open({ $uibModal.open({
templateUrl: 'partial/docs/passwordlost.html', templateUrl: 'partial/docs/passwordlost.html',
controller: 'LoginModalPasswordLost' controller: 'ModalPasswordLost'
}).result.then(function (email) { }).result.then(function (username) {
if (name === null) { if (username === null) {
return; return;
} }
// Send a password lost email // Send a password lost email
Restangular.one('user').post('passwordLost', { Restangular.one('user').post('password_lost', {
email: email username: username
}).then(function () { }).then(function () {
var title = $translate.instant('login.password_lost_sent_title'); 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'}]; var btns = [{result: 'ok', label: $translate.instant('ok'), cssClass: 'btn-primary'}];
$dialog.messageBox(title, msg, btns); $dialog.messageBox(title, msg, btns);
}, function () { }, function () {
var title = $translate.instant('login.password_lost_error_title'); 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'}]; var btns = [{result: 'ok', label: $translate.instant('ok'), cssClass: 'btn-primary'}];
$dialog.messageBox(title, msg, btns); $dialog.messageBox(title, msg, btns);
}); });

View File

@ -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);
}
});

View File

@ -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);
}
});

View File

@ -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);
}
});

View File

@ -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);
});
};
});

View File

@ -3,16 +3,14 @@
/** /**
* Document default controller. * 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 // Load user audit log
Restangular.one('auditlog').get().then(function(data) { Restangular.one('auditlog').get().then(function (data) {
$scope.logs = data.logs; $scope.logs = data.logs;
}); });
/** // Load unlinked files
* Load unlinked files. $scope.loadFiles = function () {
*/
$scope.loadFiles = function() {
Restangular.one('file/list').get().then(function (data) { Restangular.one('file/list').get().then(function (data) {
$scope.files = data.files; $scope.files = data.files;
// TODO Keep currently uploading files // TODO Keep currently uploading files
@ -20,15 +18,12 @@ angular.module('docs').controller('DocumentDefault', function($scope, $rootScope
}; };
$scope.loadFiles(); $scope.loadFiles();
/** // File has been drag & dropped
* File has been drag & dropped. $scope.fileDropped = function (files) {
* @param files
*/
$scope.fileDropped = function(files) {
if (files && files.length) { if (files && files.length) {
// Adding files to the UI // Adding files to the UI
var newfiles = []; var newfiles = [];
_.each(files, function(file) { _.each(files, function (file) {
var newfile = { var newfile = {
progress: 0, progress: 0,
name: file.name, name: file.name,
@ -42,7 +37,7 @@ angular.module('docs').controller('DocumentDefault', function($scope, $rootScope
// Uploading files sequentially // Uploading files sequentially
var key = 0; var key = 0;
var then = function() { var then = function () {
if (files[key]) { if (files[key]) {
$scope.uploadFile(files[key], newfiles[key++]).then(then); $scope.uploadFile(files[key], newfiles[key++]).then(then);
} }
@ -51,12 +46,8 @@ angular.module('docs').controller('DocumentDefault', function($scope, $rootScope
} }
}; };
/** // Upload a file
* Upload a file. $scope.uploadFile = function (file, newfile) {
* @param file
* @param newfile
*/
$scope.uploadFile = function(file, newfile) {
// Upload the file // Upload the file
newfile.status = $translate.instant('document.default.upload_progress'); newfile.status = $translate.instant('document.default.upload_progress');
return Upload.upload({ return Upload.upload({
@ -77,26 +68,22 @@ angular.module('docs').controller('DocumentDefault', function($scope, $rootScope
}) })
.error(function (data) { .error(function (data) {
newfile.status = $translate.instant('document.default.upload_error'); 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'); newfile.status += ' - ' + $translate.instant('document.default.upload_error_quota');
} }
}); });
}; };
/** //Navigate to the selected file
* Navigate to the selected file.
*/
$scope.openFile = function (file) { $scope.openFile = function (file) {
$state.go('document.default.file', { fileId: file.id }) $state.go('document.default.file', { fileId: file.id })
}; };
/** // Delete a file
* Delete a file.
*/
$scope.deleteFile = function ($event, file) { $scope.deleteFile = function ($event, file) {
$event.stopPropagation(); $event.stopPropagation();
Restangular.one('file', file.id).remove().then(function() { Restangular.one('file', file.id).remove().then(function () {
// File deleted, decrease used quota // File deleted, decrease used quota
$rootScope.userInfo.storage_current -= file.size; $rootScope.userInfo.storage_current -= file.size;
@ -106,17 +93,36 @@ angular.module('docs').controller('DocumentDefault', function($scope, $rootScope
return false; return false;
}; };
/** // Returns checked files
* Returns checked files. $scope.checkedFiles = function () {
*/
$scope.checkedFiles = function() {
return _.where($scope.files, { checked: true }); return _.where($scope.files, { checked: true });
}; };
/** // Add a document with checked files
* Add a document with checked files. $scope.addDocument = function () {
*/
$scope.addDocument = function() {
$state.go('document.add', { files: _.pluck($scope.checkedFiles(), 'id') }); $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);
});
});
};
}); });

View File

@ -20,7 +20,7 @@ angular.module('docs').controller('FileView', function($uibModal, $state, $state
$timeout(function () { $timeout(function () {
// After all router transitions are passed, // After all router transitions are passed,
// if we are still on the file route, go back to the document // 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}); $state.go('^', {id: $stateParams.id});
} }
}); });

View File

@ -5,29 +5,29 @@
*/ */
angular.module('docs').controller('SettingsConfig', function($scope, $rootScope, Restangular) { angular.module('docs').controller('SettingsConfig', function($scope, $rootScope, Restangular) {
// Get the app configuration // Get the app configuration
Restangular.one('app').get().then(function(data) { Restangular.one('app').get().then(function (data) {
$scope.app = data; $scope.app = data;
}); });
// Enable/disable guest login // Enable/disable guest login
$scope.changeGuestLogin = function(enabled) { $scope.changeGuestLogin = function (enabled) {
Restangular.one('app').post('guest_login', { Restangular.one('app').post('guest_login', {
enabled: enabled enabled: enabled
}).then(function() { }).then(function () {
$scope.app.guest_login = enabled; $scope.app.guest_login = enabled;
}); });
}; };
// Fetch the current theme configuration // Fetch the current theme configuration
Restangular.one('theme').get().then(function(data) { Restangular.one('theme').get().then(function (data) {
$scope.theme = data; $scope.theme = data;
$rootScope.appName = $scope.theme.name; $rootScope.appName = $scope.theme.name;
}); });
// Update the theme // Update the theme
$scope.update = function() { $scope.update = function () {
$scope.theme.name = $scope.theme.name.length === 0 ? 'Sismics Docs' : $scope.theme.name; $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]; var stylesheet = $('#theme-stylesheet')[0];
stylesheet.href = stylesheet.href.replace(/\?.*|$/, '?' + new Date().getTime()); stylesheet.href = stylesheet.href.replace(/\?.*|$/, '?' + new Date().getTime());
$rootScope.appName = $scope.theme.name; $rootScope.appName = $scope.theme.name;
@ -36,7 +36,7 @@ angular.module('docs').controller('SettingsConfig', function($scope, $rootScope,
// Send an image // Send an image
$scope.sendingImage = false; $scope.sendingImage = false;
$scope.sendImage = function(type, image) { $scope.sendImage = function (type, image) {
// Build the payload // Build the payload
var formData = new FormData(); var formData = new FormData();
formData.append('image', image); 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);
};
}); });

View File

@ -15,7 +15,7 @@ angular.module('share').controller('FileView', function($uibModal, $state, $stat
modal.closed = false; modal.closed = false;
modal.result.then(function() { modal.result.then(function() {
modal.closed = true; modal.closed = true;
},function() { }, function() {
modal.closed = true; modal.closed = true;
$timeout(function () { $timeout(function () {
// After all router transitions are passed, // After all router transitions are passed,

View File

@ -48,7 +48,9 @@
<script src="app/docs/app.js" type="text/javascript"></script> <script src="app/docs/app.js" type="text/javascript"></script>
<script src="app/docs/controller/Main.js" type="text/javascript"></script> <script src="app/docs/controller/Main.js" type="text/javascript"></script>
<script src="app/docs/controller/Login.js" type="text/javascript"></script> <script src="app/docs/controller/Login.js" type="text/javascript"></script>
<script src="app/docs/controller/LoginModalPasswordLost.js" type="text/javascript"></script> <script src="app/docs/controller/ModalPasswordLost.js" type="text/javascript"></script>
<script src="app/docs/controller/ModalFeedback.js" type="text/javascript"></script>
<script src="app/docs/controller/PasswordReset.js" type="text/javascript"></script>
<script src="app/docs/controller/Navigation.js" type="text/javascript"></script> <script src="app/docs/controller/Navigation.js" type="text/javascript"></script>
<script src="app/docs/controller/Footer.js" type="text/javascript"></script> <script src="app/docs/controller/Footer.js" type="text/javascript"></script>
<script src="app/docs/controller/document/Document.js" type="text/javascript"></script> <script src="app/docs/controller/document/Document.js" type="text/javascript"></script>
@ -156,6 +158,10 @@
<div class="row" ng-controller="Footer"> <div class="row" ng-controller="Footer">
<div class="col-md-12 footer text-center text-muted"> <div class="col-md-12 footer text-center text-muted">
<div class="alert alert-danger" ng-show="app.global_storage_quota && app.global_storage_quota * 0.8 < app.global_storage_current"
translate="index.global_quota_warning"
translate-values="{ current: app.global_storage_current / 1000000, percent: app.global_storage_current / app.global_storage_quota * 100, total: app.global_storage_quota / 1000000 }">
</div>
<ul class="list-inline"> <ul class="list-inline">
<li uib-dropdown class="dropdown"> <li uib-dropdown class="dropdown">
<a href uib-dropdown-toggle> <a href uib-dropdown-toggle>

View File

@ -12,15 +12,21 @@
"login_failed_message": "Username or password invalid", "login_failed_message": "Username or password invalid",
"password_lost_btn": "Password lost?", "password_lost_btn": "Password lost?",
"password_lost_sent_title": "Password reset email sent", "password_lost_sent_title": "Password reset email sent",
"password_lost_sent_message": "An email has been sent to <strong>{{ email }}</strong> to reset your password", "password_lost_sent_message": "An email has been sent to <strong>{{ username }}</strong> to reset your password",
"password_lost_error_title": "Password reset error", "password_lost_error_title": "Password reset error",
"password_lost_error_message": "Unable to send a password reset email, please contact your administrator for a manual reset" "password_lost_error_message": "Unable to send a password reset email, please contact your administrator for a manual reset"
}, },
"passwordlost": { "passwordlost": {
"title": "Password lost", "title": "Password lost",
"message": "Please enter your email address to receive a password reset link", "message": "Please enter your username to receive a password reset link. If you don't remember your username, please contact your administrator",
"submit": "Reset my password" "submit": "Reset my password"
}, },
"passwordreset": {
"message": "Please enter a new password",
"submit": "Change my password",
"error_title": "Error changing your password",
"error_message": "Your password recovery request is expired, please ask a new one on the login page"
},
"index": { "index": {
"toggle_navigation": "Toggle navigation", "toggle_navigation": "Toggle navigation",
"nav_documents": "Documents", "nav_documents": "Documents",
@ -29,7 +35,8 @@
"error_info": "{{ count }} new error{{ count > 1 ? 's' : '' }}", "error_info": "{{ count }} new error{{ count > 1 ? 's' : '' }}",
"logged_as": "Logged in as {{ username }}", "logged_as": "Logged in as {{ username }}",
"nav_settings": "Settings", "nav_settings": "Settings",
"logout": "Logout" "logout": "Logout",
"global_quota_warning": "<strong>Warning!</strong> Global quota almost reached at {{ current | number: 0 }}MB ({{ percent | number: 1 }}%) used on {{ total | number: 0 }}MB"
}, },
"document": { "document": {
"search_simple": "Simple search", "search_simple": "Simple search",
@ -139,7 +146,8 @@
"add_new_document": "Add to new document", "add_new_document": "Add to new document",
"latest_activity": "Latest activity", "latest_activity": "Latest activity",
"footer_sismics": "Crafted with <span class=\"glyphicon glyphicon-heart\"></span> by <a href=\"https://www.sismics.com\" target=\"_blank\">Sismics</a>", "footer_sismics": "Crafted with <span class=\"glyphicon glyphicon-heart\"></span> by <a href=\"https://www.sismics.com\" target=\"_blank\">Sismics</a>",
"api_documentation": "API Documentation" "api_documentation": "API Documentation",
"feedback": "Give us a feedback"
}, },
"pdf": { "pdf": {
"export_title": "Export to PDF", "export_title": "Export to PDF",
@ -238,7 +246,8 @@
"storage_quota": "Storage quota", "storage_quota": "Storage quota",
"storage_quota_placeholder": "Storage quota (in MB)", "storage_quota_placeholder": "Storage quota (in MB)",
"password": "Password", "password": "Password",
"password_confirm": "Password (confirm)" "password_confirm": "Password (confirm)",
"disabled": "Disabled user"
} }
}, },
"security": { "security": {
@ -295,7 +304,14 @@
"custom_css_placeholder": "Custom CSS to add after the main stylesheet", "custom_css_placeholder": "Custom CSS to add after the main stylesheet",
"logo": "Logo (squared size)", "logo": "Logo (squared size)",
"background_image": "Background image", "background_image": "Background image",
"uploading_image": "Uploading the image..." "uploading_image": "Uploading the image...",
"title_smtp": "Email <small>configuration</small>",
"smtp_hostname": "SMTP hostname",
"smtp_port": "SMTP port",
"smtp_from": "Sender e-mail",
"smtp_username": "SMTP username",
"smtp_password": "SMTP password",
"smtp_updated": "SMTP configuration updated successfully"
}, },
"log": { "log": {
"title": "Server <small>logs</small>", "title": "Server <small>logs</small>",
@ -324,6 +340,12 @@
"new_entry": "New entry" "new_entry": "New entry"
} }
}, },
"feedback": {
"title": "Give us a feedback",
"message": "Any suggestion or question about Sismics Docs? We listen to you!",
"sent_title": "Feedback sent",
"sent_message": "Thank you for your feedback! It will help us make Sismics Docs even better."
},
"app_share": { "app_share": {
"main": "Ask a shared document link to access it", "main": "Ask a shared document link to access it",
"403": { "403": {

View File

@ -9,7 +9,23 @@
"submit": "Connexion", "submit": "Connexion",
"login_as_guest": "Connexion en invité", "login_as_guest": "Connexion en invité",
"login_failed_title": "Echec de connexion", "login_failed_title": "Echec de connexion",
"login_failed_message": "Nom d'utilisateur ou mot de passe invalide" "login_failed_message": "Nom d'utilisateur ou mot de passe invalide",
"password_lost_btn": "Mot de passe perdu ?",
"password_lost_sent_title": "Email de réinitialisation de mot de passe envoyé",
"password_lost_sent_message": "Un email a été envoyé à <strong>{{ username }}</strong> pour réinitialiser votre mot de passe",
"password_lost_error_title": "Erreur lors de la réinitialisation du mot de passe",
"password_lost_error_message": "Impossible d'envoyer un email de changement de mot de passe, veuillez contacter votre administrateur pour une réinitialisation manuelle"
},
"passwordlost": {
"title": "Mot de passe perdu",
"message": "Veuillez entrer votre nom d'utilisateur pour recevoir un lien de réinitialisation de mot de passe. Si vous ne vous souvenez pas de votre nom d'utilisateur, veuillez contacter votre administrateur",
"submit": "Réinitialiser mon mot de passe"
},
"passwordreset": {
"message": "Veuillez entrer un nouveau mot de passe",
"submit": "Changer mon mot de passe",
"error_title": "Erreur lors du changement de mot de passe",
"error_message": "Votre demande de changement de mot de passe a expiré, veuillez recommencer depuis la page de connexion"
}, },
"index": { "index": {
"toggle_navigation": "Afficher/cacher la navigation", "toggle_navigation": "Afficher/cacher la navigation",
@ -19,7 +35,8 @@
"error_info": "{{ count }} nouvelle{{ count > 1 ? 's' : '' }} erreur{{ count > 1 ? 's' : '' }}", "error_info": "{{ count }} nouvelle{{ count > 1 ? 's' : '' }} erreur{{ count > 1 ? 's' : '' }}",
"logged_as": "Connecté en tant que {{ username }}", "logged_as": "Connecté en tant que {{ username }}",
"nav_settings": "Paramètres", "nav_settings": "Paramètres",
"logout": "Déconnexion" "logout": "Déconnexion",
"global_quota_warning": "<strong>Attention !</strong> Quota global presque atteint à {{ current | number: 0 }}Mo ({{ percent | number: 1 }}%) utilisé sur {{ total | number: 0 }}Mo"
}, },
"document": { "document": {
"search_simple": "Recherche simple", "search_simple": "Recherche simple",
@ -29,7 +46,7 @@
"search_before_date": "Avant cette date", "search_before_date": "Avant cette date",
"search_after_date": "Après cette date", "search_after_date": "Après cette date",
"search_tags": "Tags", "search_tags": "Tags",
"search_clear": "Réinitialiser", "search_clear": "Vider",
"any_language": "Toutes les langues", "any_language": "Toutes les langues",
"add_document": "Ajouter un document", "add_document": "Ajouter un document",
"tags": "Tags", "tags": "Tags",
@ -129,7 +146,8 @@
"add_new_document": "Ajouter à un nouveau document", "add_new_document": "Ajouter à un nouveau document",
"latest_activity": "Activité récente", "latest_activity": "Activité récente",
"footer_sismics": "Conçu avec <span class=\"glyphicon glyphicon-heart\"></span> par <a href=\"https://www.sismics.com\" target=\"_blank\">Sismics</a>", "footer_sismics": "Conçu avec <span class=\"glyphicon glyphicon-heart\"></span> par <a href=\"https://www.sismics.com\" target=\"_blank\">Sismics</a>",
"api_documentation": "Documentation API" "api_documentation": "Documentation API",
"feedback": "Donnez-nous votre avis"
}, },
"pdf": { "pdf": {
"export_title": "Exporter en PDF", "export_title": "Exporter en PDF",
@ -216,6 +234,7 @@
"add_user": "Ajouter un utilisateur", "add_user": "Ajouter un utilisateur",
"username": "Nom d'utilisateur", "username": "Nom d'utilisateur",
"create_date": "Date de création", "create_date": "Date de création",
"totp_enabled": "Authentification en deux étapes activée sur ce compte",
"edit": { "edit": {
"delete_user_title": "Supprimer un utilisateur", "delete_user_title": "Supprimer un utilisateur",
"delete_user_message": "Etes-vous sûr de vouloir supprimer cet utilisateur ? Tous les documents, fichiers et tags associés seront supprimés", "delete_user_message": "Etes-vous sûr de vouloir supprimer cet utilisateur ? Tous les documents, fichiers et tags associés seront supprimés",
@ -227,7 +246,8 @@
"storage_quota": "Quota de stockage", "storage_quota": "Quota de stockage",
"storage_quota_placeholder": "Quota de stockage (en Mo)", "storage_quota_placeholder": "Quota de stockage (en Mo)",
"password": "Mot de passe", "password": "Mot de passe",
"password_confirm": "Mot de passe (confirmation)" "password_confirm": "Mot de passe (confirmation)",
"disabled": "Utilisateur désactivé"
} }
}, },
"security": { "security": {
@ -284,7 +304,14 @@
"custom_css_placeholder": "CSS personnalisée ajoutée après la feuille de style principale", "custom_css_placeholder": "CSS personnalisée ajoutée après la feuille de style principale",
"logo": "Logo (Taille carrée)", "logo": "Logo (Taille carrée)",
"background_image": "Image de fond", "background_image": "Image de fond",
"uploading_image": "Envoi de l'image..." "uploading_image": "Envoi de l'image...",
"title_smtp": "<small>Configuration</small> email",
"smtp_hostname": "Hôte SMTP",
"smtp_port": "Port SMTP",
"smtp_from": "Email d'envoi",
"smtp_username": "Nom d'utilisateur SMTP",
"smtp_password": "Mot de passe SMTP",
"smtp_updated": "Configuration SMTP mise à jour avec succès"
}, },
"log": { "log": {
"title": "<small>Logs</small> serveur", "title": "<small>Logs</small> serveur",
@ -313,6 +340,12 @@
"new_entry": "Nouvelle entrée" "new_entry": "Nouvelle entrée"
} }
}, },
"feedback": {
"title": "Donnez-nous votre avis",
"message": "Vous avez des suggestions ou des questions à propos de Sismics Docs ? Nous vous écoutons !",
"sent_title": "Avis envoyé",
"sent_message": "Merci pour votre avis ! Cela nous aidera à améliorer Sismics Docs."
},
"app_share": { "app_share": {
"main": "Demandez un lien de partage d'un document pour y accéder", "main": "Demandez un lien de partage d'un document pour y accéder",
"403": { "403": {

View File

@ -20,8 +20,8 @@
<span ng-switch-when="Document"> <span ng-switch-when="Document">
<a ng-href="#/document/view/{{ log.target }}">{{ log.message }}</a> <a ng-href="#/document/view/{{ log.target }}">{{ log.message }}</a>
</span> </span>
<span ng-switch-when="File"> <span ng-switch-when="File" ng-init="hasDocument = log.message.substring(0, 36).trim().length > 0">
<a ng-if="log.message" ng-href="#/document/view/{{ log.message | limitTo: 36 }}/content/file/{{ log.target }}"> <a ng-if="log.message" ng-href="#/document/{{ hasDocument ? 'view/' + log.message.substring(0, 36) + '/content/' : '' }}file/{{ log.target }}">
<span ng-if="log.message.length > 36">{{ log.message | limitTo: 1000 : 36 }}</span> <span ng-if="log.message.length > 36">{{ log.message | limitTo: 1000 : 36 }}</span>
<span ng-if="log.message.length == 36">{{ 'open' | translate }}</span> <span ng-if="log.message.length == 36">{{ 'open' | translate }}</span>
</a> </a>

View File

@ -68,3 +68,7 @@
<audit-log logs="logs" /> <audit-log logs="logs" />
</div> </div>
</div> </div>
<a href class="feedback" ng-click="openFeedback()">
{{ 'document.default.feedback' | translate }}
</a>

View File

@ -159,7 +159,7 @@
next-text="{{ 'pagination.next' | translate }}" next-text="{{ 'pagination.next' | translate }}"
first-text="{{ 'pagination.first' | translate }}" first-text="{{ 'pagination.first' | translate }}"
last-text="{{ 'pagination.last' | translate }}" last-text="{{ 'pagination.last' | translate }}"
total-items="totalDocuments" items-per-page="limit" max-size="5" ng-model="$parent.currentPage"></ul> total-items="totalDocuments" items-per-page="$parent.limit" max-size="5" ng-model="$parent.currentPage"></ul>
<label class="sr-only" for="pagesizeSelect">{{ 'document.page_size' | translate }}</label> <label class="sr-only" for="pagesizeSelect">{{ 'document.page_size' | translate }}</label>
<select ng-model="limit" id="pagesizeSelect" class="form-control"> <select ng-model="limit" id="pagesizeSelect" class="form-control">
<option value="10">{{ 'document.page_size_10' | translate }}</option> <option value="10">{{ 'document.page_size_10' | translate }}</option>

View File

@ -0,0 +1,17 @@
<form name="form">
<div class="modal-header">
<h3>{{ 'feedback.title' | translate }}</h3>
</div>
<div class="modal-body">
<p>
<label for="inputContent">{{ 'feedback.message' | translate }}</label>
<textarea name="content" class="form-control" required id="inputContent" ng-model="content"></textarea>
</p>
</div>
<div class="modal-footer">
<button ng-click="close(content)" class="btn btn-primary" ng-disabled="!form.$valid">
<span class="glyphicon glyphicon-send"></span> {{ 'send' | translate }}
</button>
<button ng-click="close(null)" class="btn btn-default">{{ 'cancel' | translate }}</button>
</div>
</form>

View File

@ -5,11 +5,11 @@
<div class="modal-body"> <div class="modal-body">
<p> <p>
<label for="share-result">{{ 'passwordlost.message' | translate }}</label> <label for="share-result">{{ 'passwordlost.message' | translate }}</label>
<input name="email" class="form-control" type="email" required id="share-result" ng-model="email" /> <input name="username" class="form-control" type="text" required id="share-result" ng-model="username" />
</p> </p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button ng-click="close(email)" class="btn btn-primary" ng-disabled="!form.$valid"> <button ng-click="close(username)" class="btn btn-primary" ng-disabled="!form.$valid">
<span class="glyphicon glyphicon-envelope"></span> {{ 'passwordlost.submit' | translate }} <span class="glyphicon glyphicon-envelope"></span> {{ 'passwordlost.submit' | translate }}
</button> </button>
<button ng-click="close(null)" class="btn btn-default">{{ 'cancel' | translate }}</button> <button ng-click="close(null)" class="btn btn-default">{{ 'cancel' | translate }}</button>

View File

@ -0,0 +1,25 @@
<div class="row">
<div class="col-xs-offset-1 col-xs-10 col-sm-offset-2 col-sm-8">
<form class="form-horizontal" name="form" novalidate>
<div class="form-group" ng-class="{ 'has-error': !form.password.$valid && form.$dirty }">
<label for="inputPassword" class="col-sm-4 control-label">{{ 'passwordreset.message' | translate }}</label>
<div class="col-sm-4">
<input type="password" name="password" ng-model="password" required ng-minlength="8" ng-maxlength="50" class="form-control" id="inputPassword">
</div>
<div class="col-sm-4">
<span class="help-block" ng-show="form.password.$error.required && form.$dirty">{{ 'validation.required' | translate }}</span>
<span class="help-block" ng-show="form.password.$error.minlength && form.$dirty">{{ 'validation.too_short' | translate }}</span>
<span class="help-block" ng-show="form.password.$error.maxlength && form.$dirty">{{ 'validation.too_long' | translate }}</span>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-4 col-sm-5">
<button class="btn btn-primary" ng-disabled="!form.$valid" ng-click="submit()">
<span class="glyphicon glyphicon-lock"></span>
{{ 'passwordreset.submit' | translate }}
</button>
</div>
</div>
</form>
</div>
</div>

View File

@ -28,7 +28,7 @@
<div class="form-group"> <div class="form-group">
<div class="col-sm-offset-2 col-sm-10"> <div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary" ng-click="editUser()" ng-disabled="!editUserForm.$valid"> <button type="submit" class="btn btn-primary" ng-click="editUser()" ng-disabled="!editUserForm.$valid">
<span class="glyphicon glyphicon-pencil"></span> {{ 'edit' | translate }} <span class="glyphicon glyphicon-pencil"></span> {{ 'save' | translate }}
</button> </button>
</div> </div>
</div> </div>

View File

@ -74,3 +74,52 @@
</div> </div>
</div> </div>
</form> </form>
<h1 translate="settings.config.title_smtp"></h1>
<form class="form-horizontal" name="smtpForm" novalidate>
<div class="form-group" ng-show="smtp.hasOwnProperty('hostname')" ng-class="{ 'has-error': !smtpForm.hostname.$valid && smtpForm.$dirty }">
<label class="col-sm-2 control-label" for="smtpHostname">{{ 'settings.config.smtp_hostname' | translate }}</label>
<div class="col-sm-7">
<input name="hostname" type="text" ng-disabled="!smtp.hasOwnProperty('hostname')" class="form-control" id="smtpHostname" ng-model="smtp.hostname" />
</div>
</div>
<div class="form-group" ng-show="smtp.hasOwnProperty('port')" ng-class="{ 'has-error': !smtpForm.port.$valid && smtpForm.$dirty }">
<label class="col-sm-2 control-label" for="smtpPort">{{ 'settings.config.smtp_port' | translate }}</label>
<div class="col-sm-7">
<input name="port" type="number" ng-disabled="!smtp.hasOwnProperty('port')" class="form-control" id="smtpPort" ng-model="smtp.port" />
</div>
</div>
<div class="form-group" ng-show="smtp.hasOwnProperty('username')">
<label class="col-sm-2 control-label" for="smtpUsername">{{ 'settings.config.smtp_username' | translate }}</label>
<div class="col-sm-7">
<input name="username" type="text" ng-disabled="!smtp.hasOwnProperty('username')" class="form-control" id="smtpUsername" ng-model="smtp.username" />
</div>
</div>
<div class="form-group" ng-show="smtp.hasOwnProperty('password')">
<label class="col-sm-2 control-label" for="smtpPassword">{{ 'settings.config.smtp_password' | translate }}</label>
<div class="col-sm-7">
<input name="password" type="password" ng-disabled="!smtp.hasOwnProperty('password')" class="form-control" id="smtpPassword" ng-model="smtp.password" />
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': !smtpForm.from.$valid && smtpForm.$dirty }">
<label class="col-sm-2 control-label" for="smtpFrom">{{ 'settings.config.smtp_from' | translate }}</label>
<div class="col-sm-7">
<input name="from" type="email" class="form-control" id="smtpFrom" ng-model="smtp.from" />
</div>
<div class="col-sm-3">
<span class="help-block" ng-show="smtpForm.from.$error.email && smtpForm.$dirty">{{ 'validation.email' | translate }}</span>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary" ng-click="editSmtpConfig()" ng-disabled="!smtpForm.$valid">
<span class="glyphicon glyphicon-pencil"></span> {{ 'save' | translate }}
</button>
</div>
</div>
</form>

View File

@ -33,7 +33,7 @@
<div class="form-group"> <div class="form-group">
<div class="col-sm-offset-2 col-sm-10"> <div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary" ng-click="edit()" ng-disabled="!editGroupForm.$valid"> <button type="submit" class="btn btn-primary" ng-click="edit()" ng-disabled="!editGroupForm.$valid">
<span class="glyphicon glyphicon-pencil"></span> {{ isEdit() ? 'edit' : 'add' | translate }} <span class="glyphicon glyphicon-pencil"></span> {{ isEdit() ? 'save' : 'add' | translate }}
</button> </button>
<button type="button" class="btn btn-danger" ng-click="remove()" ng-show="isEdit()"> <button type="button" class="btn btn-danger" ng-click="remove()" ng-show="isEdit()">
<span class="glyphicon glyphicon-trash"></span> {{ 'delete' | translate }} <span class="glyphicon glyphicon-trash"></span> {{ 'delete' | translate }}

View File

@ -88,12 +88,23 @@
</div> </div>
</div> </div>
<div class="form-group" ng-show="isEdit() && user.username != 'admin' && user.username != 'guest'">
<div class="col-sm-offset-2 col-sm-10">
<div class="checkbox text-danger">
<label>
<input name="disabled" type="checkbox" ng-model="user.disabled" />
<strong>{{ 'settings.user.edit.disabled' | translate }}</strong>
</label>
</div>
</div>
</div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-offset-2 col-sm-10"> <div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary" ng-click="edit()" ng-disabled="!editUserForm.$valid"> <button type="submit" class="btn btn-primary" ng-click="edit()" ng-disabled="!editUserForm.$valid">
<span class="glyphicon glyphicon-pencil"></span> {{ isEdit() ? 'edit' : 'add' | translate }} <span class="glyphicon glyphicon-pencil"></span> {{ isEdit() ? 'save' : 'add' | translate }}
</button> </button>
<button type="button" class="btn btn-danger" ng-click="remove()" ng-show="isEdit() && user.username != 'guest'"> <button type="button" class="btn btn-danger" ng-click="remove()" ng-show="isEdit() && user.username != 'admin' && user.username != 'guest'">
<span class="glyphicon glyphicon-trash"></span> {{ 'delete' | translate }} <span class="glyphicon glyphicon-trash"></span> {{ 'delete' | translate }}
</button> </button>
</div> </div>

View File

@ -16,7 +16,8 @@
<tr ng-repeat="user in users | orderBy: 'username'" ng-click="editUser(user)" <tr ng-repeat="user in users | orderBy: 'username'" ng-click="editUser(user)"
ng-class="{ active: $stateParams.username == user.username }"> ng-class="{ active: $stateParams.username == user.username }">
<td> <td>
{{ user.username }} <span ng-if="!user.disabled">{{ user.username }}</span>
<s ng-if="user.disabled">{{ user.username }}</s>
<span class="glyphicon glyphicon-lock" ng-show="user.totp_enabled" uib-tooltip="{{ 'settings.user.totp_enabled' | translate }}"></span> <span class="glyphicon glyphicon-lock" ng-show="user.totp_enabled" uib-tooltip="{{ 'settings.user.totp_enabled' | translate }}"></span>
</td> </td>
<td>{{ user.create_date | date: dateFormat }}</td> <td>{{ user.create_date | date: dateFormat }}</td>

View File

@ -310,6 +310,35 @@ input[readonly].share-link {
padding-bottom: 0; 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 alignment
.vertical-center { .vertical-center {
min-height: 100vh; min-height: 100vh;

View File

@ -1,3 +1,3 @@
api.current_version=${project.version} api.current_version=${project.version}
api.min_version=1.0 api.min_version=1.0
db.version=12 db.version=14

View File

@ -1,3 +1,3 @@
api.current_version=${project.version} api.current_version=${project.version}
api.min_version=1.0 api.min_version=1.0
db.version=12 db.version=14

View File

@ -36,6 +36,8 @@ public class TestAppResource extends BaseJerseyTest {
Long totalMemory = json.getJsonNumber("total_memory").longValue(); Long totalMemory = json.getJsonNumber("total_memory").longValue();
Assert.assertTrue(totalMemory > 0 && totalMemory > freeMemory); Assert.assertTrue(totalMemory > 0 && totalMemory > freeMemory);
Assert.assertFalse(json.getBoolean("guest_login")); 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 // Rebuild Lucene index
Response response = target().path("/app/batch/reindex").request() Response response = target().path("/app/batch/reindex").request()
@ -163,14 +165,34 @@ public class TestAppResource extends BaseJerseyTest {
// Login admin // Login admin
String adminToken = clientUtil.login("admin", "admin", false); 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 // Change SMTP configuration
target().path("/app/config_smtp").request() target().path("/app/config_smtp").request()
.cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken) .cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken)
.post(Entity.form(new Form() .post(Entity.form(new Form()
.param("hostname", "smtp.sismics.com") .param("hostname", "smtp.sismics.com")
.param("port", "1234") .param("port", "1234")
.param("from", "contact@sismics.com")
.param("username", "sismics") .param("username", "sismics")
.param("from", "contact@sismics.com")
), JsonObject.class); ), 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"));
} }
} }

View File

@ -1,5 +1,6 @@
package com.sismics.docs.rest; package com.sismics.docs.rest;
import com.sismics.docs.core.model.context.AppContext;
import com.sismics.util.filter.TokenBasedSecurityFilter; import com.sismics.util.filter.TokenBasedSecurityFilter;
import com.sismics.util.totp.GoogleAuthenticator; import com.sismics.util.totp.GoogleAuthenticator;
import org.junit.Assert; import org.junit.Assert;
@ -13,6 +14,8 @@ import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.Response.Status;
import java.util.Date; import java.util.Date;
import java.util.Locale; import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** /**
* Exhaustive test of the user resource. * 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("storage_current"));
Assert.assertNotNull(user.getJsonNumber("create_date")); Assert.assertNotNull(user.getJsonNumber("create_date"));
Assert.assertFalse(user.getBoolean("totp_enabled")); Assert.assertFalse(user.getBoolean("totp_enabled"));
Assert.assertFalse(user.getBoolean("disabled"));
// Create a user KO (login length validation) // Create a user KO (login length validation)
Response response = target().path("/user").request() Response response = target().path("/user").request()
@ -259,7 +263,7 @@ public class TestUserResource extends BaseJerseyTest {
Assert.assertEquals("newadminemail@docs.com", json.getString("email")); Assert.assertEquals("newadminemail@docs.com", json.getString("email"));
// User admin update admin_user1 information // User admin update admin_user1 information
json = target().path("/user").request() json = target().path("/user/admin_user1").request()
.cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken) .cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken)
.post(Entity.form(new Form() .post(Entity.form(new Form()
.param("email", " alice2@docs.com ")), JsonObject.class); .param("email", " alice2@docs.com ")), JsonObject.class);
@ -273,6 +277,36 @@ public class TestUserResource extends BaseJerseyTest {
json = response.readEntity(JsonObject.class); json = response.readEntity(JsonObject.class);
Assert.assertEquals("ForbiddenError", json.getString("type")); 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 // User admin deletes user admin_user1
json = target().path("/user/admin_user1").request() json = target().path("/user/admin_user1").request()
.cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken) .cookie(TokenBasedSecurityFilter.COOKIE_NAME, adminToken)
@ -354,4 +388,79 @@ public class TestUserResource extends BaseJerseyTest {
.get(JsonObject.class); .get(JsonObject.class);
Assert.assertFalse(json.getBoolean("totp_enabled")); 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"));
}
} }

View File

@ -6,5 +6,7 @@ log4j.appender.MEMORY=com.sismics.util.log4j.MemoryAppender
log4j.appender.MEMORY.size=1000 log4j.appender.MEMORY.size=1000
log4j.logger.com.sismics=INFO log4j.logger.com.sismics=INFO
log4j.logger.org.hibernate=INFO log4j.logger.com.sismics.util.jpa=ERROR
log4j.logger.org.hibernate=ERROR
log4j.logger.org.apache.pdfbox=INFO log4j.logger.org.apache.pdfbox=INFO
log4j.logger.com.mchange=ERROR

32
pom.xml
View File

@ -19,6 +19,8 @@
<org.apache.commons.commons-compress.version>1.10</org.apache.commons.commons-compress.version> <org.apache.commons.commons-compress.version>1.10</org.apache.commons.commons-compress.version>
<commons-lang.commons-lang.version>2.6</commons-lang.commons-lang.version> <commons-lang.commons-lang.version>2.6</commons-lang.commons-lang.version>
<commons-io.commons-io.version>2.4</commons-io.commons-io.version> <commons-io.commons-io.version>2.4</commons-io.commons-io.version>
<org.apache.commons.commons-email.version>1.5</org.apache.commons.commons-email.version>
<org.freemarker.freemarker.version>2.3.23</org.freemarker.freemarker.version>
<commons-dbcp.version>1.4</commons-dbcp.version> <commons-dbcp.version>1.4</commons-dbcp.version>
<com.google.guava.guava.version>19.0</com.google.guava.guava.version> <com.google.guava.guava.version>19.0</com.google.guava.guava.version>
<log4j.log4j.version>1.2.16</log4j.log4j.version> <log4j.log4j.version>1.2.16</log4j.log4j.version>
@ -27,6 +29,7 @@
<junit.junit.version>4.12</junit.junit.version> <junit.junit.version>4.12</junit.junit.version>
<com.h2database.h2.version>1.4.191</com.h2database.h2.version> <com.h2database.h2.version>1.4.191</com.h2database.h2.version>
<org.glassfish.jersey.version>2.22.2</org.glassfish.jersey.version> <org.glassfish.jersey.version>2.22.2</org.glassfish.jersey.version>
<org.glassfish.javax.json.version>1.0.4</org.glassfish.javax.json.version>
<org.mindrot.jbcrypt>0.3m</org.mindrot.jbcrypt> <org.mindrot.jbcrypt>0.3m</org.mindrot.jbcrypt>
<org.apache.lucene.version>5.5.0</org.apache.lucene.version> <org.apache.lucene.version>5.5.0</org.apache.lucene.version>
<org.imgscalr.imgscalr-lib.version>4.2</org.imgscalr.imgscalr-lib.version> <org.imgscalr.imgscalr-lib.version>4.2</org.imgscalr.imgscalr-lib.version>
@ -40,6 +43,7 @@
<com.twelvemonkeys.imageio.version>3.2.1</com.twelvemonkeys.imageio.version> <com.twelvemonkeys.imageio.version>3.2.1</com.twelvemonkeys.imageio.version>
<com.levigo.jbig2.levigo-jbig2-imageio.version>1.6.5</com.levigo.jbig2.levigo-jbig2-imageio.version> <com.levigo.jbig2.levigo-jbig2-imageio.version>1.6.5</com.levigo.jbig2.levigo-jbig2-imageio.version>
<com.github.jai-imageio.jai-imageio-core.version>1.3.1</com.github.jai-imageio.jai-imageio-core.version> <com.github.jai-imageio.jai-imageio-core.version>1.3.1</com.github.jai-imageio.jai-imageio-core.version>
<org.subethamail.subethasmtp-wiser.version>1.2</org.subethamail.subethasmtp-wiser.version>
<org.eclipse.jetty.jetty-server.version>9.2.13.v20150730</org.eclipse.jetty.jetty-server.version> <org.eclipse.jetty.jetty-server.version>9.2.13.v20150730</org.eclipse.jetty.jetty-server.version>
<org.eclipse.jetty.jetty-webapp.version>9.2.13.v20150730</org.eclipse.jetty.jetty-webapp.version> <org.eclipse.jetty.jetty-webapp.version>9.2.13.v20150730</org.eclipse.jetty.jetty-webapp.version>
@ -109,6 +113,10 @@
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId> <artifactId>maven-surefire-plugin</artifactId>
<version>${org.apache.maven.plugins.maven-surefire-plugin.version}</version> <version>${org.apache.maven.plugins.maven-surefire-plugin.version}</version>
<configuration>
<forkCount>1</forkCount>
<reuseForks>false</reuseForks>
</configuration>
</plugin> </plugin>
<plugin> <plugin>
@ -194,6 +202,12 @@
<version>${commons-io.commons-io.version}</version> <version>${commons-io.commons-io.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-email</artifactId>
<version>${org.apache.commons.commons-email.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.google.guava</groupId> <groupId>com.google.guava</groupId>
<artifactId>guava</artifactId> <artifactId>guava</artifactId>
@ -285,6 +299,12 @@
<version>${org.glassfish.jersey.version}</version> <version>${org.glassfish.jersey.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>javax.json</artifactId>
<version>${org.glassfish.javax.json.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.h2database</groupId> <groupId>com.h2database</groupId>
<artifactId>h2</artifactId> <artifactId>h2</artifactId>
@ -315,6 +335,12 @@
<version>${commons-dbcp.version}</version> <version>${commons-dbcp.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>${org.freemarker.freemarker.version}</version>
</dependency>
<dependency> <dependency>
<groupId>joda-time</groupId> <groupId>joda-time</groupId>
<artifactId>joda-time</artifactId> <artifactId>joda-time</artifactId>
@ -375,6 +401,12 @@
<version>${fr.opensagres.xdocreport.version}</version> <version>${fr.opensagres.xdocreport.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.subethamail</groupId>
<artifactId>subethasmtp-wiser</artifactId>
<version>${org.subethamail.subethasmtp-wiser.version}</version>
</dependency>
<!-- Servlet listener to register SPI ImageIO plugins --> <!-- Servlet listener to register SPI ImageIO plugins -->
<dependency> <dependency>
<groupId>com.twelvemonkeys.servlet</groupId> <groupId>com.twelvemonkeys.servlet</groupId>