/**
* Copyright (C) 2011 JTalks.org Team
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
package org.jtalks.jcommune.web.controller;
import org.apache.commons.lang.StringUtils;
import org.jtalks.jcommune.model.entity.*;
import org.jtalks.jcommune.service.*;
import org.jtalks.jcommune.plugin.api.exceptions.NotFoundException;
import org.jtalks.jcommune.service.dto.EntityToDtoConverter;
import org.jtalks.jcommune.service.nontransactional.BBCodeService;
import org.jtalks.jcommune.service.nontransactional.LocationService;
import org.jtalks.jcommune.plugin.api.web.dto.PostDto;
import org.jtalks.jcommune.plugin.api.web.dto.PostDraftDto;
import org.jtalks.jcommune.plugin.api.web.dto.TopicDto;
import org.jtalks.jcommune.plugin.api.web.dto.json.JsonResponse;
import org.jtalks.jcommune.plugin.api.web.dto.json.JsonResponseStatus;
import org.jtalks.jcommune.plugin.api.web.util.BreadcrumbBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.propertyeditors.StringTrimmerEditor;
import org.springframework.data.domain.Page;
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.util.WebUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;
/**
* Controller for post-related actions
*
* @author Pavel Vervenko
* @author Osadchuck Eugeny
* @author Kravchenko Vitaliy
* @author Kirill Afonin
* @author Alexandre Teterin
* @author Evgeniy Naumenko
* @author Andrey Ivanov
*/
@Controller
public class PostController {
public static final String TOPIC_ID = "topicId";
public static final String POST_ID = "postId";
public static final String POST_DTO = "postDto";
public static final String TOPIC_TITLE = "topicTitle";
public static final String BREADCRUMB_LIST = "breadcrumbList";
private static final Logger LOGGER = LoggerFactory.getLogger(PostController.class);
private PostService postService;
private LastReadPostService lastReadPostService;
private BreadcrumbBuilder breadcrumbBuilder;
private TopicFetchService topicFetchService;
private TopicModificationService topicModificationService;
private BBCodeService bbCodeService;
private UserService userService;
private LocationService locationService;
private EntityToDtoConverter converter;
private RetryTemplate retryTemplate;
/**
* This method turns the trim binder on. Trim binder
* removes leading and trailing spaces from the submitted fields.
* So, it ensures, that all validations will be applied to
* trimmed field values only.
*
* @param binder Binder object to be injected
*/
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
}
/**
* @param postService {@link PostService} instance to be injected
* @param breadcrumbBuilder the object which provides actions on {@link BreadcrumbBuilder} entity
* @param topicFetchService to retrieve topics from a database
* @param topicModificationService to update topics with new posts
* @param bbCodeService to create valid quotes
* @param lastReadPostService not to track user posts as updates for himself
* @param userService to get the current user information
* @param converter instance of {@link EntityToDtoConverter} needed to
* obtain link to the topic
* @param retryTemplate retry mechanism
*/
@Autowired
public PostController(PostService postService, BreadcrumbBuilder breadcrumbBuilder,
TopicFetchService topicFetchService, TopicModificationService topicModificationService,
BBCodeService bbCodeService, LastReadPostService lastReadPostService,
UserService userService, LocationService locationService, EntityToDtoConverter converter,
RetryTemplate retryTemplate) {
this.postService = postService;
this.breadcrumbBuilder = breadcrumbBuilder;
this.topicFetchService = topicFetchService;
this.topicModificationService = topicModificationService;
this.bbCodeService = bbCodeService;
this.lastReadPostService = lastReadPostService;
this.userService = userService;
this.locationService = locationService;
this.converter = converter;
this.retryTemplate = retryTemplate;
}
/**
* Delete post by given id
*
* @param postId post
* @return redirect to post next to deleted one. Redirects to previous post in case if it's last post in topic.
* @throws NotFoundException when post was not found
*/
@RequestMapping(method = RequestMethod.DELETE, value = "/posts/{postId}")
public ModelAndView delete(@PathVariable(POST_ID) final Long postId)
throws NotFoundException {
final Post post = this.postService.get(postId);
Post nextPost = post.getTopic().getNeighborPost(post);
retryTemplate.execute(new RetryCallback<Object, NotFoundException>() {
@Override
public Object doWithRetry(RetryContext context) throws NotFoundException {
Post post = postService.get(postId);
postService.deletePost(post);
return null;
}
});
return new ModelAndView("redirect:/posts/" + nextPost.getId());
}
/**
* Edit post page filled with data from post with given id
*
* @param postId post id
* @return redirect to post form page
* @throws NotFoundException when topic or post not found
*/
@RequestMapping(value = "/posts/{postId}/edit", method = RequestMethod.GET)
public ModelAndView editPage(@PathVariable(POST_ID) Long postId) throws NotFoundException {
Post post = postService.get(postId);
return new ModelAndView("topic/editPost")
.addObject(POST_DTO, PostDto.getDtoFor(post))
.addObject(TOPIC_ID, post.getTopic().getId())
.addObject(POST_ID, postId)
.addObject(TOPIC_TITLE, post.getTopic().getTitle())
.addObject("breadcrumbList", breadcrumbBuilder.getForumBreadcrumb(post.getTopic()));
}
/**
* Update existing post
*
* @param postDto Dto populated in form
* @param result validation result
* @param postId the current postId
* @return {@code ModelAndView} object which will be redirect to topic page
* if saved successfully or show form with error message
* @throws NotFoundException when topic, branch or post not found
*/
@RequestMapping(value = "/posts/{postId}/edit", method = RequestMethod.POST)
public ModelAndView update(@Valid @ModelAttribute PostDto postDto, BindingResult result,
@PathVariable(POST_ID) Long postId) throws NotFoundException {
Post post = postService.get(postId);
if (result.hasErrors()) {
return new ModelAndView("topic/editPost")
.addObject(TOPIC_ID, post.getTopic().getId())
.addObject(POST_ID, postId);
}
postService.updatePost(post, postDto.getBodyText());
return new ModelAndView("redirect:/posts/" + postId);
}
/**
* Get quote text.
* If user select nothing JS will substitute whole post contents here
* <p/>
* Supports post method to pass large quotations.
*
* @param postId identifier os the post we're quoting
* @param selection text selected by user for the quotation
* @throws NotFoundException when topic was not found
*/
@RequestMapping(method = RequestMethod.POST, value = "/posts/{postId}/quote")
@ResponseBody
public JsonResponse getQuote(@PathVariable(POST_ID) Long postId,
@RequestParam("selection") String selection) throws NotFoundException {
Post source = postService.get(postId);
String content = StringUtils.defaultString(selection, source.getPostContent());
return new JsonResponse(JsonResponseStatus.SUCCESS, bbCodeService.quote(content, source.getUserCreated()));
}
/**
* Process the reply form. Adds new post to the specified topic and redirects to the
* topic view page.
*
* @param postDto dto that contains data entered in form
* @param result validation result
* @return redirect to the topic or back to answer pae if validation failed
* @throws NotFoundException when topic or branch not found
*/
@RequestMapping(method = RequestMethod.POST, value = "/topics/{topicId}") //
public ModelAndView create(@RequestParam(value = "page", defaultValue = "1", required = false) String page,
@PathVariable(TOPIC_ID) Long topicId,
@Valid @ModelAttribute final PostDto postDto,
BindingResult result, RedirectAttributes attr) throws NotFoundException {
postDto.setTopicId(topicId);
if (result.hasErrors()) {
attr.addFlashAttribute("postDto", postDto);
return new ModelAndView("redirect:/topics/error/" + topicId + "?page=" + page);
}
final Topic topic = topicFetchService.get(topicId);
final long branchId = topic.getBranch().getId();
Post newbie = retryTemplate.execute(new RetryCallback<Post, NotFoundException>() {
@Override
public Post doWithRetry(RetryContext context) throws NotFoundException {
return topicModificationService.replyToTopic(
postDto.getTopicId(), postDto.getBodyText(), branchId);
}
});
lastReadPostService.markTopicAsRead(newbie.getTopic());
return new ModelAndView(this.redirectToPageWithPost(newbie.getId()));
}
/**
* Gets validation errors from 'create' methods to redirect them to the view. We need it
* to implement POST/redirect/GET pattern, which leads to preventing of repeating POST request
* on browser refresh.
*
* @param page page of the current post
* @param topicId ID of a topic
* @param postDto Dto with failed validation
* @param result validation result
*
* @return {@code ModelAndView} object which shows form with an error message
* @throws NotFoundException when topic, branch or post not found
*/
@RequestMapping(method = RequestMethod.GET, value = "/topics/error/{topicId}")
public ModelAndView errorRedirect(@RequestParam(value = "page", required = false) String page,
@PathVariable(TOPIC_ID) Long topicId, @ModelAttribute @Valid PostDto postDto,
BindingResult result) throws NotFoundException {
JCUser currentUser = userService.getCurrentUser();
Topic topic = topicFetchService.get(topicId);
PostDraft draft = topic.getDraftForUser(currentUser);
if (draft != null) {
// If we create new dto object instead of using already existing
// we lose error messages linked with it
postDto.fillFrom(draft);
}
postDto.setTopicId(topicId);
Page<Post> postsPage = postService.getPosts(topic, page);
return new ModelAndView("topic/postList")
.addObject("viewList", locationService.getUsersViewing(topic))
.addObject("postsPage", postsPage)
.addObject("topic", topic)
.addObject(POST_DTO, postDto)
.addObject("subscribed", topic.getSubscribers().contains(currentUser))
.addObject(BREADCRUMB_LIST, breadcrumbBuilder.getForumBreadcrumb(topic));
}
/**
* Redirects user to the topic view with the appropriate page selected.
* Method clients should not wary about paging at all, post id
* is enough to be transferred to the proper page.
*
* If post belongs to plugable topic and appropriated plugin is enabled redirects
* to plugable topic view.
*
* @param postId unique post identifier
* @return redirect view to the certain topic page
* @throws NotFoundException is the is no post for the identifier given
*/
@RequestMapping(method = RequestMethod.GET, value = "/posts/{postId}")
public String redirectToPageWithPost(@PathVariable Long postId) throws NotFoundException {
Post post = postService.get(postId);
int page = postService.calculatePageForPost(post);
String topicUrl = converter.convertTopicToDto(post.getTopic()).getTopicUrl();
return "redirect:" + topicUrl + "?page=" + page + "#" + postId;
}
/**
* Converts post with bb codes to HTML for client-side
* preview in bbEditor
*
* @param postDto Current post dto
* @param result Spring MVC binding result
* @return HTML content for post
* @throws Exception
*/
@RequestMapping(method = RequestMethod.POST, value = "/posts/bbToHtml")
public ModelAndView preview(@Valid @ModelAttribute PostDto postDto, BindingResult result) throws Exception {
return getPreviewModelAndView(result).addObject("content", postDto.getBodyText());
}
/**
* Converts topic with bb codes to HTML for client-side
* preview in bbEditor
*
* @param topicDto Current topic dto
* @param result Spring MVC binding result
* @return HTML content for topic
* @throws Exception
*/
@RequestMapping(method = RequestMethod.POST, value = "/topics/bbToHtml")
public ModelAndView preview(@Valid @ModelAttribute TopicDto topicDto, BindingResult result) throws Exception {
return getPreviewModelAndView(result).addObject("content", topicDto.getBodyText());
}
/**
* Votes up for post with specified id
*
* @param postId id of a post to vote up
* @param request HttpServletRequest
*
* @return response in JSON format
*
* @throws NotFoundException if post with specified id not found
*/
@RequestMapping(method = RequestMethod.GET, value = "/posts/{postId}/voteup")
@ResponseBody
public JsonResponse voteUp(@PathVariable Long postId, HttpServletRequest request) throws NotFoundException {
PostVote vote = new PostVote(true);
voteWithSessionLocking(postId, vote, request);
return new JsonResponse(JsonResponseStatus.SUCCESS);
}
/**
* Votes down for post with specified id
*
* @param postId id of a post to vote down
* @param request HttpServletRequest
*
* @return response in JSON format
*
* @throws NotFoundException if post with specified id not found
*/
@RequestMapping(method = RequestMethod.GET, value = "/posts/{postId}/votedown")
@ResponseBody
public JsonResponse voteDown(@PathVariable Long postId, HttpServletRequest request) throws NotFoundException {
PostVote vote = new PostVote(false);
voteWithSessionLocking(postId, vote, request);
return new JsonResponse(JsonResponseStatus.SUCCESS);
}
/**
* Saves new draft or update if it already exist
*
* @param postDraftDto post draft dto populated in form
* @param result validation result
*
* @return response in JSON format
*
* @throws NotFoundException if topic to store draft not exist
*/
@RequestMapping(value = "/posts/savedraft", method = RequestMethod.POST)
@ResponseBody
public JsonResponse saveDraft(@Valid @RequestBody PostDraftDto postDraftDto, BindingResult result) throws NotFoundException {
if (result.hasErrors()) {
return new JsonResponse(JsonResponseStatus.FAIL);
}
Topic topic = topicFetchService.getTopicSilently(postDraftDto.getTopicId());
PostDraft saved = postService.saveOrUpdateDraft(topic, postDraftDto.getBodyText());
return new JsonResponse(JsonResponseStatus.SUCCESS, saved.getId());
}
/**
* Deletes draft
*
* @param draftId id of draft to delete
*
* @return response in JSON format
*
* @throws NotFoundException if post with specified id not exist
*/
@RequestMapping(value = "drafts/{draftId}/delete", method = RequestMethod.GET)
@ResponseBody
public JsonResponse deleteDraft(@PathVariable Long draftId) throws NotFoundException {
postService.deleteDraft(draftId);
return new JsonResponse(JsonResponseStatus.SUCCESS);
}
/**
* Prepare ModelAndView for showing preview
*
* @return prepared ModelAndView for preview
*/
private ModelAndView getPreviewModelAndView(BindingResult result) {
return new ModelAndView("ajax/postPreview")
.addObject("isInvalid", result.hasFieldErrors("bodyText"))
.addObject("errors", result.getFieldErrors("bodyText"));
}
/**
* Performs vote with session locking to prevent handling of concurrent requests from same user
*
* @param postId id of a post to vote
* @param vote {@link PostVote} object
* @param request HttpServletRequest
*
* @throws NotFoundException if post with specified id not found
*/
private void voteWithSessionLocking(Long postId, PostVote vote, HttpServletRequest request) throws NotFoundException {
/**
* We should not create session here to prevent possibility of creating multiplier sessions for same user in
* concurrent requests
*/
HttpSession session = request.getSession(false);
if (session != null) {
Object mutex = WebUtils.getSessionMutex(session);
/**
* Next operations performed in synchronized block to prevent handling of concurrent requests from same
* user. We use session mutex as the lock object. In many cases, the HttpSession reference itself is a safe
* mutex as well, since it will always be the same object reference for the same active logical session.
* However, this is not guaranteed across different servlet containers; the only 100% safe way is a session
* mutex.
*/
synchronized (mutex) {
Post post = postService.get(postId);
postService.vote(post, vote);
}
} else {
/**
* If <code>HttpSession</code> is <code>null</code> we have no mutex object, so we perform operations
* without synchronization
*/
Post post = postService.get(postId);
postService.vote(post, vote);
}
}
}