/* * Copyright 1998-2017 Linux.org.ru * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package ru.org.linux.comment; import com.google.common.base.Preconditions; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.Errors; import org.springframework.web.bind.WebDataBinder; import ru.org.linux.auth.CaptchaService; import ru.org.linux.auth.FloodProtector; import ru.org.linux.auth.IPBlockDao; import ru.org.linux.auth.IPBlockInfo; import ru.org.linux.csrf.CSRFProtectionService; import ru.org.linux.edithistory.EditHistoryDto; import ru.org.linux.edithistory.EditHistoryObjectTypeEnum; import ru.org.linux.edithistory.EditHistoryService; import ru.org.linux.site.MessageNotFoundException; import ru.org.linux.site.ScriptErrorException; import ru.org.linux.site.Template; import ru.org.linux.spring.dao.DeleteInfoDao; import ru.org.linux.spring.dao.MessageText; import ru.org.linux.spring.dao.MsgbaseDao; import ru.org.linux.topic.Topic; import ru.org.linux.topic.TopicDao; import ru.org.linux.topic.TopicPermissionService; import ru.org.linux.topic.TopicService; import ru.org.linux.user.*; import ru.org.linux.util.ExceptionBindingErrorProcessor; import ru.org.linux.util.StringUtil; import ru.org.linux.util.bbcode.LorCodeService; import ru.org.linux.util.formatter.ToLorCodeFormatter; import ru.org.linux.util.formatter.ToLorCodeTexFormatter; import javax.annotation.Nonnull; import javax.servlet.http.HttpServletRequest; import java.beans.PropertyEditorSupport; import java.sql.SQLException; import java.sql.Timestamp; import java.util.*; @Service public class CommentService { private static final Logger logger = LoggerFactory.getLogger(CommentService.class); @Autowired private CommentDao commentDao; @Autowired private TopicDao messageDao; @Autowired private TopicService topicService; @Autowired private UserDao userDao; @Autowired private UserService userService; @Autowired private ToLorCodeFormatter toLorCodeFormatter; @Autowired private ToLorCodeTexFormatter toLorCodeTexFormatter; @Autowired private CaptchaService captcha; @Autowired private CommentPrepareService commentPrepareService; @Autowired private FloodProtector floodProtector; @Autowired private LorCodeService lorCodeService; @Autowired private UserEventService userEventService; @Autowired private MsgbaseDao msgbaseDao; @Autowired private IgnoreListDao ignoreListDao; @Autowired private EditHistoryService editHistoryService; @Autowired private TopicDao topicDao; @Autowired private DeleteInfoDao deleteInfoDao; @Autowired private TopicPermissionService permissionService; private final Cache<Integer, CommentList> cache = CacheBuilder.newBuilder() .maximumSize(10000) .build(); public void requestValidator(WebDataBinder binder) { binder.setValidator(new CommentRequestValidator(lorCodeService)); binder.setBindingErrorProcessor(new ExceptionBindingErrorProcessor()); } public void initBinder(WebDataBinder binder) { binder.registerCustomEditor(Topic.class, new PropertyEditorSupport() { @Override public void setAsText(String text) throws IllegalArgumentException { try { setValue(messageDao.getById(Integer.parseInt(text.split(",")[0]))); } catch (MessageNotFoundException e) { throw new IllegalArgumentException(e); } } }); binder.registerCustomEditor(Comment.class, new PropertyEditorSupport() { @Override public void setAsText(String text) throws IllegalArgumentException { if (text.isEmpty() || "0".equals(text)) { setValue(null); return; } try { setValue(commentDao.getById(Integer.parseInt(text))); } catch (MessageNotFoundException e) { throw new IllegalArgumentException(e); } } }); binder.registerCustomEditor(User.class, new UserPropertyEditor(userService)); } /** * Проверка валидности данных запроса. * * @param commentRequest WEB-форма, содержащая данные * @param user пользователь, добавляющий или изменяющий комментарий * @param ipBlockInfo информация о банах * @param request данные запроса от web-клиента * @param errors обработчик ошибок ввода для формы */ public void checkPostData( CommentRequest commentRequest, User user, IPBlockInfo ipBlockInfo, HttpServletRequest request, Errors errors ) { if (commentRequest.getMsg() == null) { errors.rejectValue("msg", null, "комментарий не задан"); commentRequest.setMsg(""); } Template tmpl = Template.getTemplate(request); if (commentRequest.getMode() == null) { commentRequest.setMode(tmpl.getFormatMode()); } if (!commentRequest.isPreviewMode() && (!tmpl.isSessionAuthorized() || ipBlockInfo.isCaptchaRequired())) { captcha.checkCaptcha(request, errors); } if (!commentRequest.isPreviewMode() && !errors.hasErrors()) { CSRFProtectionService.checkCSRF(request, errors); } user.checkBlocked(errors); IPBlockDao.checkBlockIP(ipBlockInfo, errors, user); if (!commentRequest.isPreviewMode() && !errors.hasErrors()) { floodProtector.checkDuplication(FloodProtector.Action.ADD_COMMENT, request.getRemoteAddr(), user.getScore() >= 100, errors); } } /** * Получить текст комментария. * * @param commentRequest WEB-форма, содержащая данные * @param user пользователь, добавляющий или изменяющий комментарий * @param errors обработчик ошибок ввода для формы * @return текст комментария */ public String getCommentBody( CommentRequest commentRequest, User user, Errors errors ) { String commentBody = processMessage(commentRequest.getMsg(), commentRequest.getMode()); if (user.isAnonymous()) { if (commentBody.length() > 4096) { errors.rejectValue("msg", null, "Слишком большое сообщение"); } } else { if (commentBody.length() > 8192) { errors.rejectValue("msg", null, "Слишком большое сообщение"); } } return commentBody; } /** * Получить объект комментария из WEB-запроса. * * @param commentRequest WEB-форма, содержащая данные * @param user пользователь, добавляющий или изменяющий комментарий * @param request данные запроса от web-клиента * @return объект комментария из WEB-запроса */ public Comment getComment( CommentRequest commentRequest, User user, HttpServletRequest request ) { Comment comment = null; if (commentRequest.getTopic() != null) { String title = commentRequest.getTitle(); if (title == null) { title = ""; } Integer replyto = commentRequest.getReplyto() != null ? commentRequest.getReplyto().getId() : null; int commentId = commentRequest.getOriginal() == null ? 0 : commentRequest.getOriginal().getId(); comment = new Comment( replyto, StringUtil.escapeHtml(title), commentRequest.getTopic().getId(), commentId, user.getId(), request.getRemoteAddr() ); } return comment; } /** * Получить объект пользователя, добавляющего или изменяющего комментарий * * @param commentRequest WEB-форма, содержащая данные * @param request данные запроса от web-клиента * @param errors обработчик ошибок ввода для формы * @return объект пользователя */ @Nonnull public User getCommentUser( CommentRequest commentRequest, HttpServletRequest request, Errors errors ) { Template tmpl = Template.getTemplate(request); User currentUser = tmpl.getCurrentUser(); if (currentUser!=null) { return currentUser; } if (commentRequest.getNick() != null) { if (commentRequest.getPassword() == null) { errors.reject(null, "Требуется авторизация"); } return commentRequest.getNick(); } else { return userService.getAnonymous(); } } public void prepareReplyto( CommentRequest add, Map<String, Object> formParams, HttpServletRequest request ) throws UserNotFoundException { if (add.getReplyto() != null) { formParams.put("onComment", commentPrepareService.prepareCommentForReplayto(add.getReplyto(), request.isSecure())); } } /** * Создание нового комментария. * * * @param comment объект комментария * @param commentBody текст комментария * @param remoteAddress IP-адрес, с которого был добавлен комментарий * @param xForwardedFor IP-адрес через шлюз, с которого был добавлен комментарий * @param userAgent заголовок User-Agent запроса * @return идентификационный номер нового комментария */ @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED) public int create( @Nonnull User author, @Nonnull Comment comment, String commentBody, String remoteAddress, String xForwardedFor, Optional<String> userAgent) throws MessageNotFoundException { Preconditions.checkArgument(comment.getUserid() == author.getId()); int commentId = commentDao.saveNewMessage(comment, userAgent); msgbaseDao.saveNewMessage(commentBody, commentId); /* кастование пользователей */ if (permissionService.isUserCastAllowed(author)) { Set<User> userRefs = lorCodeService.getReplierFromMessage(commentBody); userEventService.addUserRefEvent(userRefs, comment.getTopicId(), commentId); } /* оповещение об ответе на коммент */ if (comment.getReplyTo() != 0) { Comment parentComment = commentDao.getById(comment.getReplyTo()); if (parentComment.getUserid() != comment.getUserid()) { User parentAuthor = userDao.getUserCached(parentComment.getUserid()); if (!parentAuthor.isAnonymous()) { Set<Integer> ignoreList = ignoreListDao.get(parentAuthor); if (!ignoreList.contains(comment.getUserid())) { userEventService.addReplyEvent( parentAuthor, comment.getTopicId(), commentId ); } } } } String logMessage = makeLogString("Написан комментарий " + commentId, remoteAddress, xForwardedFor); logger.info(logMessage); return commentId; } /** * Редактирование комментария. * * @param oldComment данные старого комментария * @param newComment данные нового комментария * @param commentBody текст нового комментария * @param remoteAddress IP-адрес, с которого был добавлен комментарий * @param xForwardedFor IP-адрес через шлюз, с которого был добавлен комментарий */ @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED) public void edit( Comment oldComment, Comment newComment, String commentBody, String remoteAddress, String xForwardedFor, @Nonnull User editor, String originalMessageText ) { commentDao.changeTitle(oldComment, newComment.getTitle()); msgbaseDao.updateMessage(oldComment.getId(), commentBody); /* кастование пользователей */ Set<User> newUserRefs = lorCodeService.getReplierFromMessage(commentBody); MessageText messageText = msgbaseDao.getMessageText(oldComment.getId()); Set<User> oldUserRefs = lorCodeService.getReplierFromMessage(messageText.getText()); Set<User> userRefs = new HashSet<>(); /* кастовать только тех, кто добавился. Существующие ранее не кастуются */ for (User user : newUserRefs) { if (!oldUserRefs.contains(user)) { userRefs.add(user); } } if (permissionService.isUserCastAllowed(editor)) { userEventService.addUserRefEvent(userRefs, oldComment.getTopicId(), oldComment.getId()); } /* Обновление времени последнего изменения топика для того, чтобы данные в кеше автоматически обновились */ topicDao.updateLastmod(oldComment.getTopicId(), false); addEditHistoryItem(editor, oldComment, originalMessageText, newComment, commentBody); updateLatestEditorInfo(editor, oldComment, newComment); String logMessage = makeLogString("Изменён комментарий " + oldComment.getId(), remoteAddress, xForwardedFor); logger.info(logMessage); } /** * Проверка, имеет ли указанный комментарий ответы. * * @param comment объект комментария * @return true если есть ответы, иначе false */ public boolean isHaveAnswers(@Nonnull Comment comment) { return commentDao.getReplaysCount(comment.getId())>0; } /** * Получить объект комментария по идентификационному номеру * * @param id идентификационный номер комментария * @return объект комментария * @throws MessageNotFoundException если комментарий не найден */ public Comment getById(int id) throws MessageNotFoundException { return commentDao.getById(id); } /** * Добавить элемент истории для комментария. * * @param editor пользователь, изменивший комментарий * @param original оригинал (старый комментарий) * @param originalMessageText старое содержимое комментария * @param comment изменённый комментарий * @param messageText новое содержимое комментария */ private void addEditHistoryItem(User editor, Comment original, String originalMessageText, Comment comment, String messageText) { EditHistoryDto editHistoryDto = new EditHistoryDto(); editHistoryDto.setMsgid(original.getId()); editHistoryDto.setObjectType(EditHistoryObjectTypeEnum.COMMENT); editHistoryDto.setEditor(editor.getId()); boolean modified = false; if (!original.getTitle().equals(comment.getTitle())) { editHistoryDto.setOldtitle(original.getTitle()); modified = true; } if (!originalMessageText.equals(messageText)) { editHistoryDto.setOldmessage(originalMessageText); modified = true; } if (modified) { editHistoryService.insert(editHistoryDto); } } /** * Обновление информации о последнем изменении коммента. * * @param editor пользователь, изменивший комментарий * @param original оригинал (старый комментарий) * @param comment изменённый комментарий */ private void updateLatestEditorInfo(User editor, Comment original, Comment comment) { int editCount = editHistoryService.editCount(original.getId(), EditHistoryObjectTypeEnum.COMMENT); commentDao.updateLatestEditorInfo( original.getId(), editor.getId(), comment.getPostdate(), editCount ); } /** * Удаляем коментарий, если на комментарий есть ответы - генерируем исключение * * @param msgid id удаляемого сообщения * @param reason причина удаления * @param user модератор который удаляет * @throws ScriptErrorException генерируем исключение если на комментарий есть ответы */ @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED) public boolean deleteComment(int msgid, String reason, User user) throws ScriptErrorException { if (commentDao.getReplaysCount(msgid) != 0) { throw new ScriptErrorException("Нельзя удалить комментарий с ответами"); } boolean deleted = doDeleteComment(msgid, reason, user); if (deleted) { commentDao.updateStatsAfterDelete(msgid, 1); userEventService.processCommentsDeleted(ImmutableList.of(msgid)); } return deleted; } /** * Удалить комментарий. * * @param msgid идентификационнай номер комментария * @param reason причина удаления * @param user пользователь, удаляющий комментарий * @return true если комментарий был удалён, иначе false */ private boolean doDeleteComment(int msgid, String reason, User user) { boolean deleted = commentDao.deleteComment(msgid, reason, user); if (deleted) { deleteInfoDao.insert(msgid, user, reason, 0); } return deleted; } /** * Удалить комментарий. * * @param comment удаляемый комментарий * @param reason причина удаления * @param user пользователь, удаляющий комментарий * @param scoreBonus сколько снять скора у автора комментария * @return true если комментарий был удалён, иначе false */ private boolean deleteComment(Comment comment, String reason, User user, int scoreBonus) { Preconditions.checkArgument(scoreBonus<=0, "Score bonus on delete must be non-positive"); boolean del = commentDao.deleteComment(comment.getId(), reason, user); if (del && scoreBonus!=0) { userDao.changeScore(comment.getUserid(), scoreBonus); } return del; } /** * Список комментариев топика. * * @param topic топик * @param showDeleted вместе с удаленными * @return список комментариев топика */ @Nonnull public CommentList getCommentList(@Nonnull Topic topic, boolean showDeleted) { if (showDeleted) { return new CommentList(commentDao.getCommentList(topic.getId(), true), topic.getLastModified().getTime()); } else { CommentList commentList = cache.getIfPresent(topic.getId()); if (commentList == null || commentList.getLastmod() < topic.getLastModified().getTime()) { commentList = new CommentList(commentDao.getCommentList(topic.getId(), false), topic.getLastModified().getTime()); cache.put(topic.getId(), commentList); } return commentList; } } /** * Удаление ответов на комментарии. * * @param comment удаляемый комментарий * @param user пользователь, удаляющий комментарий * @param scoreBonus сколько снять скора у автора комментария * @return список идентификационных номеров удалённых комментариев */ @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED) public List<Integer> deleteWithReplys(Topic topic, Comment comment, String reason, User user, int scoreBonus) { CommentList commentList = getCommentList(topic, false); CommentNode node = commentList.getNode(comment.getId()); List<CommentAndDepth> replys = getAllReplys(node, 0); List<Integer> deleted = deleteReplys(comment, reason, replys, user, -scoreBonus); userEventService.processCommentsDeleted(deleted); return deleted; } /** * Удалить рекурсивно ответы на комментарий * * @param replys список ответов * @param user пользователь, удаляющий комментарий * @param rootBonus сколько снять скора у автора корневого комментария * @return список идентификационных номеров удалённых комментариев */ private List<Integer> deleteReplys(Comment root, String rootReason, List<CommentAndDepth> replys, User user, int rootBonus) { boolean score = rootBonus < -2; List<Integer> deleted = new ArrayList<>(replys.size()); List<DeleteInfoDao.InsertDeleteInfo> deleteInfos = new ArrayList<>(replys.size()); for (CommentAndDepth cur : replys) { Comment child = cur.getComment(); DeleteInfoDao.InsertDeleteInfo info = cur.deleteInfo(score, user); boolean del = deleteComment(child, info.getReason(), user, info.getBonus()); if (del) { deleteInfos.add(info); deleted.add(child.getId()); } } boolean deletedMain = deleteComment(root, rootReason, user, rootBonus); if (deletedMain) { deleteInfos.add(new DeleteInfoDao.InsertDeleteInfo(root.getId(), rootReason, rootBonus, user.getId())); deleted.add(root.getId()); } deleteInfoDao.insert(deleteInfos); if (!deleted.isEmpty()) { commentDao.updateStatsAfterDelete(root.getId(), deleted.size()); } return deleted; } /** * Удаление топиков, сообщений по ip и за определнный период времени, те комментарии на которые существуют ответы пропускаем * * @param ip ip для которых удаляем сообщения (не проверяется на корректность) * @param timeDelta врменной промежуток удаления (не проверяется на корректность) * @param moderator экзекутор-можератор * @param reason причина удаления, которая будет вписана для удаляемых топиков * @return список id удаленных сообщений */ @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED) public DeleteCommentResult deleteCommentsByIPAddress( String ip, Timestamp timeDelta, final User moderator, final String reason) { List<Integer> deletedTopics = topicService.deleteByIPAddress(ip, timeDelta, moderator, reason); Map<Integer, String> deleteInfo = new HashMap<>(); for (int msgid : deletedTopics) { deleteInfo.put(msgid, "Топик " + msgid + " удален"); } // Удаляем комментарии если на них нет ответа List<Integer> commentIds = commentDao.getCommentsByIPAddressForUpdate(ip, timeDelta); List<Integer> deletedCommentIds = new ArrayList<>(); for (int msgid : commentIds) { if (commentDao.getReplaysCount(msgid) == 0) { if (doDeleteComment(msgid, reason, moderator)) { deletedCommentIds.add(msgid); deleteInfo.put(msgid, "Комментарий " + msgid + " удален"); } else { deleteInfo.put(msgid, "Комментарий " + msgid + " уже был удален"); } } else { deleteInfo.put(msgid, "Комментарий " + msgid + " пропущен"); } } for (int msgid : deletedCommentIds) { commentDao.updateStatsAfterDelete(msgid, 1); } userEventService.processCommentsDeleted(deletedCommentIds); return new DeleteCommentResult(deletedTopics, deletedCommentIds, deleteInfo); } /** * Получить список последних удалённых комментариев пользователя. * * @param user объект пользователя * @return список удалённых комментариев пользователя */ public List<CommentDao.DeletedListItem> getDeletedComments(User user) { return commentDao.getDeletedComments(user.getId()); } /** * Блокировка и массивное удаление всех топиков и комментариев пользователя со всеми ответами на комментарии * * @param user пользователь для экзекуции * @param moderator экзекутор-модератор * @param reason прична блокировки * @return список удаленных комментариев */ @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED) public DeleteCommentResult deleteAllCommentsAndBlock(User user, final User moderator, String reason) { userDao.block(user, moderator, reason); List<Integer> deletedTopicIds = topicService.deleteAllByUser(user, moderator); List<Integer> deletedCommentIds = deleteAllCommentsByUser(user, moderator); return new DeleteCommentResult(deletedTopicIds, deletedCommentIds, null); } /** * Массовое удаление комментариев пользователя со всеми ответами на комментарии. * * @param user пользователь для экзекуции * @param moderator экзекутор-модератор * @return список удаленных комментариев */ private List<Integer> deleteAllCommentsByUser(User user, final User moderator) { final List<Integer> deletedCommentIds = new ArrayList<>(); // Удаляем все комментарии List<Integer> commentIds = commentDao.getAllByUserForUpdate(user); for (int msgid : commentIds) { if (commentDao.getReplaysCount(msgid) == 0) { doDeleteComment(msgid, "Блокировка пользователя с удалением сообщений", moderator); commentDao.updateStatsAfterDelete(msgid, 1); deletedCommentIds.add(msgid); } } userEventService.processCommentsDeleted(deletedCommentIds); return deletedCommentIds; } /** * Формирование строки в лог-файл. * * @param message сообщение * @param remoteAddress IP-адрес, с которого был добавлен комментарий * @param xForwardedFor IP-адрес через шлюз, с которого был добавлен комментарий * @return строка, готовая для добавления в лог-файл */ private static String makeLogString(String message, String remoteAddress, String xForwardedFor) { StringBuilder logMessage = new StringBuilder(); logMessage .append(message) .append("; ip: ") .append(remoteAddress); if (xForwardedFor != null) { logMessage .append(" XFF:") .append(xForwardedFor); } return logMessage.toString(); } /** * Обработать тект комментария посредством парсеров (LorCode или Tex). * * @param msg текст комментария * @param mode режим обработки * @return обработанная строка */ private String processMessage(String msg, String mode) { if ("ntobr".equals(mode)) { return toLorCodeFormatter.format(msg); } else { return toLorCodeTexFormatter.format(msg); } } @Nonnull public Set<Integer> makeHideSet( CommentList comments, int filterChain, @Nonnull Set<Integer> ignoreList ) throws SQLException, UserNotFoundException { if (filterChain == CommentFilter.FILTER_NONE) { return ImmutableSet.of(); } Set<Integer> hideSet = new HashSet<>(); /* hide anonymous */ if ((filterChain & CommentFilter.FILTER_ANONYMOUS) > 0) { comments.getRoot().hideAnonymous(userDao, hideSet); } /* hide ignored */ if ((filterChain & CommentFilter.FILTER_IGNORED) > 0) { if (!ignoreList.isEmpty()) { comments.getRoot().hideIgnored(hideSet, ignoreList); } } return hideSet; } private static List<CommentAndDepth> getAllReplys(CommentNode node, int depth) { List<CommentAndDepth> replys = new LinkedList<>(); for (CommentNode r : node.childs()) { replys.addAll(getAllReplys(r, depth + 1)); replys.add(new CommentAndDepth(r.getComment(), depth)); } return replys; } private static class CommentAndDepth { private final Comment comment; private final int depth; private CommentAndDepth(Comment comment, int depth) { this.comment = comment; this.depth = depth; } public Comment getComment() { return comment; } private DeleteInfoDao.InsertDeleteInfo deleteInfo(boolean score, User user) { int bonus; String reason; if (score) { switch (depth) { case 0: reason = "7.1 Ответ на некорректное сообщение (авто, уровень 0)"; bonus = -2; break; case 1: reason = "7.1 Ответ на некорректное сообщение (авто, уровень 1)"; bonus = -1; break; default: reason = "7.1 Ответ на некорректное сообщение (авто, уровень >1)"; bonus = 0; break; } } else { reason = "7.1 Ответ на некорректное сообщение (авто)"; bonus = 0; } return new DeleteInfoDao.InsertDeleteInfo(comment.getId(), reason, bonus, user.getId()); } } }