package org.kalipo.service;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import org.kalipo.aop.KalipoExceptionHandler;
import org.kalipo.aop.RateLimit;
import org.kalipo.config.Constants;
import org.kalipo.config.ErrorCode;
import org.kalipo.domain.Comment;
import org.kalipo.domain.Markup;
import org.kalipo.domain.Site;
import org.kalipo.domain.Thread;
import org.kalipo.repository.CommentRepository;
import org.kalipo.repository.SiteRepository;
import org.kalipo.repository.ThreadRepository;
import org.kalipo.repository.UserRepository;
import org.kalipo.security.Privileges;
import org.kalipo.security.SecurityUtils;
import org.kalipo.service.util.Asserts;
import org.kalipo.service.util.NumUtils;
import org.kalipo.web.filter.AnonUtil;
import org.kalipo.web.rest.KalipoException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.AsyncResult;
import org.springframework.stereotype.Service;
import javax.annotation.security.RolesAllowed;
import javax.inject.Inject;
import java.util.concurrent.Future;
@SuppressWarnings("unused")
@Service
@KalipoExceptionHandler
public class CommentService {
private final Logger log = LoggerFactory.getLogger(CommentService.class);
private static final int MAX_LEVEL = 8;
@Inject
private CommentRepository commentRepository;
@Inject
private UserRepository userRepository;
@Inject
private ThreadRepository threadRepository;
@Inject
private SiteRepository siteRepository;
@Inject
private ReputationModifierService reputationModifierService;
@Inject
private NotificationService notificationService;
@Inject
private UserService userService;
@Inject
private BanService banService;
@Inject
private ThreadService threadService;
@Inject
private MarkupService markupService;
@Inject
private WebSocketService webSocketService;
@RateLimit
public Comment create(Comment comment) throws KalipoException {
Asserts.isNotNull(comment, "comment");
Asserts.isNull(comment.getId(), "id");
if (comment.getParentId() == null) {
Asserts.hasPrivilege(Privileges.CREATE_COMMENT_SOLO);
} else {
Asserts.hasPrivilege(Privileges.CREATE_COMMENT_REPLY);
}
return save(comment, null);
}
@RolesAllowed(Privileges.EDIT_COMMENT)
@RateLimit
public Comment update(Comment modified) throws KalipoException {
Asserts.isNotNull(modified, "comment");
Asserts.isNotNull(modified.getId(), "id");
Comment original = commentRepository.findOne(modified.getId());
Asserts.isCurrentLogin(original.getAuthorId());
Asserts.nullOrEqual(modified.getStatus(), original.getStatus(), "status");
modified.setStatus(original.getStatus());
Asserts.nullOrEqual(modified.getParentId(), original.getParentId(), "parentId");
modified.setParentId(original.getParentId());
Asserts.nullOrEqual(modified.getLikes(), original.getLikes(), "likes");
modified.setLikes(original.getLikes());
Asserts.nullOrEqual(modified.getDislikes(), original.getDislikes(), "dislikes");
modified.setDislikes(original.getDislikes());
Asserts.nullOrEqual(modified.getCreatedDate(), original.getCreatedDate(), Constants.PARAM_CREATED_DATE);
modified.setCreatedDate(original.getCreatedDate());
return save(modified, original);
}
@RolesAllowed(Privileges.REVIEW_COMMENT)
@RateLimit
public Comment approve(String id) throws KalipoException {
Asserts.isNotNull(id, "id");
Comment comment = commentRepository.findOne(id);
Asserts.isNotNull(comment, "id");
return approve(comment);
}
@RolesAllowed(Privileges.REVIEW_COMMENT)
@RateLimit
public Comment approve(Comment comment) throws KalipoException {
Asserts.isNotNull(comment, "id");
if (comment.getStatus() == Comment.Status.APPROVED) {
return comment;
}
if (comment.getStatus() != Comment.Status.PENDING) {
throw new KalipoException(ErrorCode.CONSTRAINT_VIOLATED, "must be pending to be approved");
}
final String currentLogin = SecurityUtils.getCurrentLogin();
// todo add status PRE_APPROVE to let CommentAgent do the approval, do handle notifications,
// -- Comment Count
Thread thread = threadRepository.findOne(comment.getThreadId());
Asserts.isNotNull(thread, "threadId");
thread.setCommentCount(thread.getCommentCount() + 1);
threadRepository.save(thread);
// --
log.info(String.format("%s approves comment %s ", currentLogin, comment.getId()));
comment.setStatus(Comment.Status.APPROVED);
comment.setReviewerId(currentLogin);
comment = commentRepository.save(comment);
notificationService.notifyMentionedUsers(comment, currentLogin);
return comment;
}
@RolesAllowed(Privileges.REVIEW_COMMENT)
@RateLimit
public Comment reject(String id) throws KalipoException {
Asserts.isNotNull(id, "id");
Comment comment = commentRepository.findOne(id);
Asserts.isNotNull(comment, "id");
return reject(comment);
}
@RolesAllowed(Privileges.REVIEW_COMMENT)
@RateLimit
public Comment spam(String id) throws KalipoException {
Asserts.isNotNull(id, "id");
Comment comment = commentRepository.findOne(id);
Asserts.isNotNull(comment, "id");
comment.setStatus(Comment.Status.SPAM);
webSocketService.broadcast(comment.getThreadId(), WebSocketService.Type.COMMENT_DELETED, comment.anonymized());
return comment;
}
@RolesAllowed(Privileges.REVIEW_COMMENT)
@RateLimit
public Comment deleteAndBan(String id) throws KalipoException {
Asserts.isNotNull(id, "id");
Comment comment = commentRepository.findOne(id);
Asserts.isNotNull(comment, "id");
delete(comment);
banService.banUser(comment.getAuthorId(), comment.getThreadId());
return comment;
}
@RolesAllowed(Privileges.REVIEW_COMMENT)
@RateLimit
public Comment reject(Comment comment) throws KalipoException {
// todo test, this is new
Asserts.isNotNull(comment, "id");
if (comment.getStatus() == Comment.Status.DELETED) {
return comment;
}
if (comment.getStatus() != Comment.Status.PENDING) {
throw new KalipoException(ErrorCode.CONSTRAINT_VIOLATED, "must be pending to be approved");
}
final String currentLogin = SecurityUtils.getCurrentLogin();
notificationService.notifyAuthorOfParent(comment, currentLogin);
// --
log.info(String.format("%s rejects comment %s ", currentLogin, comment.getId()));
comment.setStatus(Comment.Status.DELETED);
comment.setReviewerId(currentLogin);
comment = commentRepository.save(comment);
return comment;
}
@Async
public Future<Comment> get(String id) throws KalipoException {
return new AsyncResult<>(commentRepository.findOne(id));
}
@RateLimit
public void delete(String id) throws KalipoException {
Asserts.isNotNull(id, "id");
Comment comment = commentRepository.findOne(id);
Asserts.isNotNull(comment, "id");
delete(comment);
}
@RateLimit
public void delete(Comment comment) throws KalipoException {
final String currentLogin = SecurityUtils.getCurrentLogin();
Asserts.isNotNull(comment, "id");
boolean isAuthor = comment.getAuthorId().equals(currentLogin);
Thread thread = threadRepository.findOne(comment.getThreadId());
Asserts.isNotNull(thread, "threadId");
boolean isSiteMod = isSiteMod(thread, currentLogin);
boolean isSuperMod = userService.isSuperMod(currentLogin);
if (!isAuthor && !isSiteMod && !isSuperMod) {
throw new KalipoException(ErrorCode.PERMISSION_DENIED);
}
// punish author if third party is required to delete
if (isSuperMod || isSiteMod) {
if (comment.getStatus() == Comment.Status.PENDING) {
log.info(String.format("Mod '%s' rejects comment %s", currentLogin, comment.getId()));
} else {
log.info(String.format("Mod '%s' deletes %s", currentLogin, comment.getId()));
}
// todo distinguish report approval vs pending (=learning) -> notification
reputationModifierService.onCommentDeletion(comment);
// todo notification will encourage trolls?
notificationService.announceCommentDeleted(comment);
} else {
log.info(String.format("Comment owner '%s' deletes comment %s", currentLogin, comment.getId()));
}
Long replies = commentRepository.countReplies(comment.getId());
// 1. delete if no replies
// 2. clear and leave replies
if (replies > 0) {
// empty comment
log.info(String.format("Comment %s is blanked out due to %s replies", comment.getId(), replies));
comment.setStatus(Comment.Status.DELETED);
comment.setDisplayName("");
comment.setBody("");
comment.setBodyHtml("");
comment.setLikes(0);
comment.setDislikes(0);
comment.setInfluence(0d);
commentRepository.save(comment);
} else {
log.info(String.format("Comment %s is deleted", comment.getId()));
commentRepository.delete(comment);
}
webSocketService.broadcast(comment.getThreadId(), WebSocketService.Type.COMMENT_DELETED, comment.anonymized());
}
// --
private boolean isSiteMod(Thread thread, String currentLogin) {
Site site = siteRepository.findOne(thread.getSiteId());
return site.getModeratorIds().contains(currentLogin);
}
private Comment save(Comment dirty, Comment original) throws KalipoException {
final boolean isNew = original == null;
final String currentLogin = SecurityUtils.getCurrentLogin();
final boolean isSuperMod = userService.isSuperMod(currentLogin);
Asserts.isNotNull(dirty.getThreadId(), "threadId");
// -- Quota
int count = commentRepository.countWithinDateRange(SecurityUtils.getCurrentLogin(), DateTime.now().minusDays(1), DateTime.now());
int dailyLimit = 100; // todo senseful quota, centralize conf params, depending on user level?
if (count >= dailyLimit && !isSuperMod) {
// todo send mail
throw new KalipoException(ErrorCode.METHOD_REQUEST_LIMIT_REACHED, "daily comment quota is " + dailyLimit);
}
// -- Display name
if (BooleanUtils.isTrue(dirty.getAnonymous())) {
dirty.setDisplayName(null);
} else {
dirty.setDisplayName(currentLogin);
}
// --
final Thread thread = threadRepository.findOne(dirty.getThreadId());
Asserts.isNotNull(thread, "threadId");
Asserts.isNotLocked(thread);
final boolean isMod = isSiteMod(thread, currentLogin);
Comment parent = null;
// reply only to approved comments
if (isNew) {
dirty.setCreatedByMod((isSuperMod || isMod) ? true : null);
if (StringUtils.isBlank(dirty.getParentId())) {
dirty.setParentId(null);
dirty.setLevel(0);
} else {
parent = commentRepository.findOne(dirty.getParentId());
if (parent == null) {
throw new KalipoException(ErrorCode.INVALID_PARAMETER, "parentId");
}
/*
fault tolerant: if discussion becomes too deep, dig up until valid
*/
while (parent.getLevel() + 1 > MAX_LEVEL) {
parent = commentRepository.findOne(parent.getParentId());
}
parent.setRepliesCount(NumUtils.nullToZero(parent.getRepliesCount()) + 1);
dirty.setLevel(parent.getLevel() + 1);
if (parent.getStatus() != Comment.Status.APPROVED) {
throw new KalipoException(ErrorCode.CONSTRAINT_VIOLATED, "Invalid status of parent. It is not approved yet");
}
}
if (Boolean.TRUE == dirty.getPinned() && !(isMod || isSuperMod)) {
throw new KalipoException(ErrorCode.CONSTRAINT_VIOLATED, "You must be mod to pin a comment");
}
} else {
if (dirty.getPinned() != original.getPinned() && !(isMod || isSuperMod)) {
throw new KalipoException(ErrorCode.CONSTRAINT_VIOLATED, "You must be mod to change the pin-field of a comment");
}
}
dirty.setAuthorId(currentLogin);
dirty.setFingerprint(getFingerprint(parent, thread));
Markup markup = markupService.toHtml(dirty.getBody());
dirty.setBodyHtml(markup.buffer().toString());
// todo record hashtag usage
// markup.hashtags().forEach();
dirty.setStatus(Comment.Status.NONE);
log.info(String.format("User '%s' creates comment %s", currentLogin, dirty.toString()));
assignSticky(dirty, original, isNew, isMod, isSuperMod);
// --
dirty = commentRepository.save(dirty);
return dirty;
}
private String getFingerprint(Comment parent, Thread thread) {
final String parentFp = parent == null ? "" : parent.getFingerprint();
return parentFp + String.format("%05d", thread.getCommentCount());
}
/**
* Sticky-field may only be set/changed by mods and supermods
*
* @param comment the new comment
* @param original the original comment
* @param isNew helper, TRUE iff original is null
* @param isMod is current user a mod
* @param isSuperMod is current user a supermod
* @throws KalipoException
*/
private void assignSticky(Comment comment, Comment original, boolean isNew, boolean isMod, boolean isSuperMod) throws KalipoException {
if (isNew) {
// only mods may set sticky = true
if (comment.getSticky() != null && comment.getSticky() && !(isMod || isSuperMod)) {
throw new KalipoException(ErrorCode.PERMISSION_DENIED, "You may not set sticky flag");
}
} else {
// only mods may change flag
if (!(isMod || isSuperMod)) {
Asserts.nullOrEqual(comment.getSticky(), original.getSticky(), "sticky");
}
comment.setSticky(original.getSticky());
}
}
@Async
public void logForward(String commentId, String url, String remoteAddr) throws KalipoException {
Comment comment = commentRepository.findOne(commentId);
log.info(String.format("forward %s via %s", AnonUtil.maskIp(remoteAddr), commentId));
for (Comment.Link link : comment.getLinks()) {
if (StringUtils.equals(url, link.getUrl())) {
link.incrImpression();
break;
}
}
commentRepository.save(comment);
}
public Page<Comment> filtered(String userId, Comment.Status status, int page) {
PageRequest pageable = new PageRequest(page, Constants.PAGE_SIZE, Sort.Direction.DESC, Constants.PARAM_CREATED_DATE);
// todo implement a dynamic filter method
// todo allow only owner or admin/mods
if (StringUtils.isNotEmpty(userId)) {
return commentRepository.findByAuthorId(userId, pageable);
}
if (status != null) {
return commentRepository.findByStatus(status, pageable);
}
return null;
}
}