├── .gitignore ├── LICENSE.txt ├── README.md ├── pom.xml └── src ├── main ├── java │ └── alexp │ │ └── blog │ │ ├── controller │ │ ├── CommentController.java │ │ ├── ExceptionController.java │ │ ├── ForbiddenException.java │ │ ├── LoginController.java │ │ ├── PostsController.java │ │ ├── ResourceNotFoundException.java │ │ └── UsersController.java │ │ ├── model │ │ ├── Comment.java │ │ ├── CommentRating.java │ │ ├── Post.java │ │ ├── PostEditDto.java │ │ ├── PostRating.java │ │ ├── Rating.java │ │ ├── Role.java │ │ ├── Tag.java │ │ └── User.java │ │ ├── repository │ │ ├── CommentRatingRepository.java │ │ ├── CommentRepository.java │ │ ├── PostRatingRepository.java │ │ ├── PostRepository.java │ │ ├── RoleRepository.java │ │ ├── TagRepository.java │ │ └── UserRepository.java │ │ ├── service │ │ ├── ActionExpiredException.java │ │ ├── AlreadyVotedException.java │ │ ├── AuthException.java │ │ ├── AvatarService.java │ │ ├── AvatarServiceImpl.java │ │ ├── CommentService.java │ │ ├── CommentServiceImpl.java │ │ ├── FileNameGenerator.java │ │ ├── FileNameGeneratorImpl.java │ │ ├── MarkdownConverter.java │ │ ├── PostService.java │ │ ├── PostServiceImpl.java │ │ ├── UnsupportedFormatException.java │ │ ├── UploadedAvatarInfo.java │ │ ├── UserService.java │ │ └── UserServiceImpl.java │ │ ├── utils │ │ ├── JsonUtils.java │ │ ├── LocalDatePersistenceConverter.java │ │ └── LocalDateTimePersistenceConverter.java │ │ └── validator │ │ └── UserValidator.java ├── resources │ ├── META-INF │ │ └── persistence.xml │ ├── Messages.properties │ ├── database.xml │ ├── datasource-dev.xml │ ├── datasource-test.xml │ ├── dummy-data.sql │ ├── security-tables.sql │ ├── uploading-dev.properties │ ├── uploading-prod.properties │ └── uploading-test.properties └── webapp │ ├── WEB-INF │ ├── mvc-dispatcher-servlet.xml │ ├── spring-security.xml │ ├── templates │ │ ├── 403.html │ │ ├── 404.html │ │ ├── about.html │ │ ├── editpost.html │ │ ├── editprofile.html │ │ ├── error.html │ │ ├── fragments │ │ │ ├── comments.html │ │ │ └── loginform.html │ │ ├── layouts │ │ │ └── blog.html │ │ ├── logout.html │ │ ├── post.html │ │ ├── posts.html │ │ ├── profile.html │ │ ├── registration.html │ │ └── settings.html │ └── web.xml │ ├── css │ ├── blog.css │ └── external │ │ ├── fileupload.css │ │ └── pagedown.css │ ├── favicon.ico │ ├── images │ ├── 404.png │ ├── ajax-loader.gif │ ├── cat-working.png │ ├── no-avatar-big.png │ ├── no-avatar-small.png │ └── success-tick.png │ └── js │ ├── admin.js │ ├── comments.js │ ├── common.js │ ├── editpost.js │ ├── editprofile.js │ ├── external │ ├── bootbox.min.js │ ├── fileupload │ │ ├── jquery.fileupload.js │ │ └── jquery.iframe-transport.js │ ├── markdown │ │ ├── Markdown.Converter.js │ │ ├── Markdown.Editor.js │ │ └── Markdown.Sanitizer.js │ └── xregexp-all-min.js │ ├── login.js │ ├── registration.js │ ├── settings.js │ ├── voting.js │ └── wmd-buttons.png └── test ├── java └── alexp │ └── blog │ ├── AbstractIntegrationTest.java │ ├── controller │ ├── CommentControllerIT.java │ ├── LoginControllerIT.java │ ├── PostsControllerIT.java │ ├── UserAvatarUploadIT.java │ └── UsersControllerIT.java │ ├── model │ ├── CommentTest.java │ ├── PostTest.java │ └── UserTest.java │ ├── service │ ├── CommentServiceTest.java │ ├── MarkdownConverterTest.java │ ├── PostServiceTest.java │ └── UserServiceTest.java │ └── utils │ ├── HsqldbSequenceResetter.java │ └── SecurityUtils.java └── resources └── alexp └── blog └── controller ├── data-avatar-added.xml ├── data-comment-added-and-edited.xml ├── data-comment-added-and-marked-deleted.xml ├── data-comment-added.xml ├── data-comment-edited.xml ├── data-comment-marked-deleted.xml ├── data-comment-reply-added-limit-exceeded.xml ├── data-comment-reply-added.xml ├── data-comments-voted.xml ├── data-email-changed.xml ├── data-password-changed.xml ├── data-post-deleted.xml ├── data-post-edited.xml ├── data-post-hidden.xml ├── data-posts-added.xml ├── data-posts-voted.xml ├── data-profile-changed.xml ├── data-user-added.xml ├── data.xml └── img.jpg /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | *.iml 3 | .idea 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 AlexP11223 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A simple blog created during Java/OOP course in university. 2 | 3 | Username/password auth, admin can add/edit/delete blog posts, users (after registration) can add comments, vote (+1/-1) for posts and comments. 4 | 5 | Can be useful for someone who is learning the techonologies/frameworks used here. 6 | 7 | 8 | - Spring MVC, Spring Security. Spring profiles. 9 | - Hibernate, MySQL database. 10 | - Thymeleaf, Bootstrap, jQuery. 11 | - [PageDown](https://code.google.com/archive/p/pagedown/) (Markdown editor), and [Txtmark](https://github.com/rjeschke/txtmark) in backend. 12 | - Tests (unit and integration). JUnit, Mockito, DBUnit with HSQLDB in-memory database (and Spring-Test/Spring-Test-DBUnit of course, since I was using Spring). 13 | 14 | Features: 15 | - Posts and comments support Markdown syntax. 16 | - Post can be split into two parts to show only the first (short) part on the main page and the full content when navigated to the post page. 17 | - Comment hierarchy (up to 6 levels): user can reply to the specified comment instead of just adding comment at the bottom. 18 | - Users can fill some info ("about me" text, website link) and upload avatar image, that will be displayed in their profiles (`/users/username` url or link on each username in the comments section), and small avatar image is also displayed in each comment. 19 | - Users can change e-mail and password ("Edit settings" button). 20 | - Admin can edit posts, hide posts (will not be visible for other users) or completely delete it from the database. (buttons at the bottom of each post, the delete button is shown only after the post is hidden) 21 | - Users can delete their comments during 10 minutes after it was added and edit during 3 hours. Admin can edit/delete any comment. 22 | - Search posts by tag: click on a tag under post title or via input field in the right column, it also allows multiple comma-separated tags ("java, databases" will show posts that have only both of these tags). 23 | - Users can like (+1) and dislike (-1) posts and comments. 24 | - List of 10 latest posts and TOP 10 posts (by user rating sum) in the right column. 25 | 26 | 27 | #Installation 28 | 29 | Requirements: 30 | - JDK 8. 31 | - Maven. 32 | - Database, such as MySQL. 33 | - Web server, such as Apache Tomcat 8.0 (tested only on Tomcat, probably works on other web servers too, maybe requires minor modifications). 34 | 35 | # 36 | 37 | 1. Obtain the project source files (`git clone` or download and extract zip archive). 38 | 2. Modify configuration if needed. 39 | 40 | Spring profiles are used to apply different configuration files for development (**dev**), real server (**prod**) and tests (**test**). 41 | 42 | * **uploading-<profile>.properties** files (in `src\main\resources` directory) contain directory path for user uploaded files (such as avatar images). 43 | 44 | Example (**uploading-prod.properties**): 45 |
uploading.dirpath=/var/blog/upload/
46 | * **datasource-<profile>.xml** files (in `src\main\resources directory`) contain database configuration: database driver, address, name, username/password, ... 47 | 48 |
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
49 |         <property name="driverClassName" value="com.mysql.jdbc.Driver" />
50 |         <property name="url" value="jdbc:mysql://localhost:3306/blog" />
51 |         <property name="username" value="BlogDbUser" />
52 |         <property name="password" value="BlogDbPassword" />
53 |         ...
54 |            <entry key="hibernate.dialect" value="org.hibernate.dialect.MySQL5Dialect"/>
55 |            ...
56 | 57 | The database must contain schema with the specified name (**blog** by default) and user with the specified name/password. 58 | 59 | If you want to use another database instead of MySQL you will need to modify `driverClassName`, `url` and `hibernate.dialect` values (highlighted above) and supply the JDBC driver (such as by adding Maven dependency to pom.xml). 60 | 61 | This line specifies SQL files that will be executed when database is created (by default it fills database with some demo data and creates a table for Spring Security “remember me” feature) 62 | 63 |
<entry key="hibernate.hbm2ddl.import_files" value="/security-tables.sql,/dummy-data.sql" />
64 | 65 | Also note this line: 66 |
<entry key="hibernate.hbm2ddl.auto" value="create"/>
67 | It will drop and create database tables each time you deploy the project. 68 | 69 | See Hibernate, JDBC and Spring documentation for more information about possible configuration parameters. 70 | 71 | By default the same **datasource-dev.xml** file is used for both dev and prod profiles. If needed, you can create a separate file (datasource-prod.xml) and modify <beans profile="prod"> node in **database.xml** to use it: 72 |
<beans profile="prod">
73 |         <import resource="classpath:/datasource-prod.xml"/>
74 | 	</beans>
75 | 76 | 3. Run Maven `verify` goal. This will download all dependencies, run JUnit tests and build WAR file. Check Maven output to see if all tests and build are completed successfully. 77 | 4. Set `-Dspring.profiles.active` system property to specify profile that will be used. If not set the default profile is **dev**. 78 | 79 | For example in `/bin/setenv.sh`: 80 |
JAVA_OPTS="$JAVA_OPTS -Dspring.profiles.active=prod"
81 | 5. Deploy the WAR file to Tomcat (or other web server). 82 | 6. Go to `http://your-server-address/blog` (if deployed with default Tomcat settings) to see if it is working. 83 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/controller/CommentController.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.controller; 2 | 3 | import alexp.blog.model.Comment; 4 | import alexp.blog.model.Post; 5 | import alexp.blog.service.*; 6 | import alexp.blog.utils.JsonUtils; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.security.access.prepost.PreAuthorize; 9 | import org.springframework.stereotype.Controller; 10 | import org.springframework.ui.ModelMap; 11 | import org.springframework.validation.BindingResult; 12 | import org.springframework.web.bind.annotation.*; 13 | 14 | import javax.validation.Valid; 15 | import java.util.List; 16 | 17 | @Controller 18 | public class CommentController { 19 | 20 | @Autowired 21 | private PostService postService; 22 | 23 | @Autowired 24 | private UserService userService; 25 | 26 | @Autowired 27 | private CommentService commentService; 28 | 29 | @RequestMapping(value = "/posts/{postId}/comments", method = RequestMethod.GET) 30 | public String showComments(@PathVariable("postId") Long postId, ModelMap model) { 31 | Post post = postService.getPost(postId); 32 | 33 | if (post == null) 34 | throw new ResourceNotFoundException(); 35 | 36 | if (post.isHidden() && !userService.isAdmin()) 37 | throw new ResourceNotFoundException(); 38 | 39 | List comments = post.topLevelComments(); 40 | 41 | model.addAttribute("comments", comments); 42 | model.addAttribute("post", post); 43 | 44 | return "fragments/comments :: commentList"; 45 | } 46 | 47 | @PreAuthorize("hasRole('ROLE_USER')") 48 | @RequestMapping(value = "/posts/{postId}/comments/create", method = RequestMethod.POST) 49 | public @ResponseBody String addComment(@Valid @ModelAttribute(value = "comment") Comment comment, BindingResult result, 50 | @PathVariable("postId") Long postId, 51 | @RequestParam(value = "parentId", defaultValue = "") Long parentId) { 52 | if (result.hasErrors()) { 53 | return makeCommentAddResponse("error", result.getAllErrors().get(0).getDefaultMessage()); 54 | } 55 | 56 | Post post = postService.getPost(postId); 57 | 58 | if (post == null) 59 | return makeCommentAddResponse("error", "post not found"); 60 | 61 | if (post.isHidden() && !userService.isAdmin()) 62 | return makeCommentAddResponse("error", "post not found"); 63 | 64 | Long addedId = commentService.saveNewComment(comment, post, parentId); 65 | 66 | return makeCommentAddResponse("ok", addedId); 67 | } 68 | 69 | @PreAuthorize("hasRole('ROLE_USER')") 70 | @RequestMapping(value = "/posts/{postId}/comments/{commentId}/delete", method = RequestMethod.POST) 71 | public @ResponseBody String deleteComment(@PathVariable("postId") Long postId, @PathVariable("commentId") Long commentId) { 72 | try { 73 | commentService.deleteComment(commentId); 74 | } catch (ActionExpiredException e) { 75 | return "expired"; 76 | } 77 | 78 | return "ok"; 79 | } 80 | 81 | @PreAuthorize("hasRole('ROLE_USER')") 82 | @RequestMapping(value = "/posts/{postId}/comments/{commentId}/edit", method = RequestMethod.POST) 83 | public @ResponseBody String editComment(@Valid @ModelAttribute(value = "comment") Comment comment, BindingResult result, 84 | @PathVariable("postId") Long postId, @PathVariable("commentId") Long commentId) { 85 | if (result.hasErrors()) { 86 | return result.getAllErrors().get(0).getDefaultMessage(); 87 | } 88 | 89 | try { 90 | commentService.updateComment(comment, commentId); 91 | } catch (ActionExpiredException e) { 92 | return "expired"; 93 | } 94 | 95 | return "ok"; 96 | } 97 | 98 | @PreAuthorize("hasRole('ROLE_USER')") 99 | @RequestMapping(value = "/posts/{postId}/comments/{commentId}/source", method = RequestMethod.GET, 100 | produces = "text/plain; charset=utf-8") 101 | public @ResponseBody String getCommentSource(@PathVariable("postId") Long postId, @PathVariable("commentId") Long commentId) { 102 | Comment comment = commentService.getComment(commentId); 103 | 104 | if (comment == null) 105 | throw new ResourceNotFoundException(); 106 | 107 | return comment.getCommentText(); 108 | } 109 | 110 | @PreAuthorize("hasRole('ROLE_USER')") 111 | @RequestMapping(value = "/posts/{postId}/comments/{commentId}/like", method = RequestMethod.POST) 112 | public @ResponseBody String like(@PathVariable("postId") Long postId, @PathVariable("commentId") Long commentId) { 113 | try { 114 | commentService.vote(commentId, true); 115 | } catch (AlreadyVotedException e) { 116 | return "already_voted"; 117 | } 118 | catch (ForbiddenException e) { 119 | return "own_comment"; 120 | } 121 | 122 | return "ok"; 123 | } 124 | 125 | @PreAuthorize("hasRole('ROLE_USER')") 126 | @RequestMapping(value = "/posts/{postId}/comments/{commentId}/dislike", method = RequestMethod.POST) 127 | public @ResponseBody String dislike(@PathVariable("postId") Long postId, @PathVariable("commentId") Long commentId) { 128 | try { 129 | commentService.vote(commentId, false); 130 | } catch (AlreadyVotedException e) { 131 | return "already_voted"; 132 | } 133 | catch (ForbiddenException e) { 134 | return "own_comment"; 135 | } 136 | 137 | return "ok"; 138 | } 139 | 140 | private String makeCommentAddResponse(String status, String msg, Long id) { 141 | return "{" + JsonUtils.toJsonField("status", status) + 142 | (id == null ? "" : (", " + JsonUtils.toJsonField("id", id.toString()))) + 143 | (msg == null ? "" : (", " + JsonUtils.toJsonField("message", msg))) + 144 | "}"; 145 | } 146 | 147 | private String makeCommentAddResponse(String status, Long id) { 148 | return makeCommentAddResponse(status, null, id); 149 | } 150 | 151 | private String makeCommentAddResponse(String status, String msg) { 152 | return makeCommentAddResponse(status, msg, null); 153 | } 154 | 155 | private String makeCommentAddResponse(String status) { 156 | return makeCommentAddResponse(status, null, null); 157 | } 158 | 159 | } 160 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/controller/ExceptionController.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.controller; 2 | 3 | import org.springframework.stereotype.Controller; 4 | import org.springframework.web.bind.annotation.RequestMapping; 5 | 6 | import javax.servlet.http.HttpServletRequest; 7 | 8 | @Controller 9 | public class ExceptionController { 10 | 11 | @RequestMapping("/404") 12 | public String notFoundErrorHandler(HttpServletRequest request, Exception e) { 13 | return "404"; 14 | } 15 | @RequestMapping("/error") 16 | public String defaultErrorHandler(HttpServletRequest request, Exception e) { 17 | return "error"; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/controller/ForbiddenException.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.controller; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(value = HttpStatus.FORBIDDEN) 7 | public class ForbiddenException extends RuntimeException { 8 | 9 | public ForbiddenException(String message) { 10 | super(message); 11 | } 12 | public ForbiddenException() { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/controller/LoginController.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.controller; 2 | 3 | import alexp.blog.service.UserService; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.stereotype.Controller; 6 | import org.springframework.ui.ModelMap; 7 | import org.springframework.web.bind.annotation.RequestMapping; 8 | import org.springframework.web.bind.annotation.RequestMethod; 9 | import org.springframework.web.bind.annotation.ResponseBody; 10 | 11 | import javax.servlet.http.HttpServletRequest; 12 | 13 | @Controller 14 | public class LoginController { 15 | 16 | @Autowired 17 | private UserService userService; 18 | 19 | @RequestMapping(value = "/login_success", method= RequestMethod.GET) 20 | public @ResponseBody String loginSuccess(HttpServletRequest request) { 21 | return "ok"; 22 | } 23 | 24 | @RequestMapping(value = "/login_error", method= RequestMethod.GET) 25 | public @ResponseBody String loginError(HttpServletRequest request) { 26 | return "failed"; 27 | } 28 | 29 | // POST request must be used for logout if CSRF enabled, so this page contains hidden form to submit via JS 30 | @RequestMapping(value = "/logout", method= RequestMethod.GET) 31 | public String logout() { 32 | if (!userService.isAuthenticated()) { 33 | return "redirect:posts"; 34 | } 35 | 36 | return "logout"; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/controller/ResourceNotFoundException.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.controller; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(value = HttpStatus.NOT_FOUND) 7 | public class ResourceNotFoundException extends RuntimeException { 8 | 9 | } -------------------------------------------------------------------------------- /src/main/java/alexp/blog/model/Comment.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.model; 2 | 3 | import alexp.blog.service.MarkdownConverter; 4 | import alexp.blog.utils.LocalDateTimePersistenceConverter; 5 | import org.hibernate.validator.constraints.NotBlank; 6 | 7 | import javax.persistence.*; 8 | import java.time.LocalDateTime; 9 | import java.time.ZoneId; 10 | import java.util.ArrayList; 11 | import java.util.List; 12 | import java.util.Optional; 13 | 14 | @Entity 15 | @Table(name = "comments") 16 | public class Comment { 17 | 18 | @javax.persistence.Id 19 | @GeneratedValue 20 | private Long Id; 21 | 22 | @Lob 23 | @Column(nullable = false) 24 | @NotBlank 25 | private String commentText; 26 | 27 | @Column(nullable = false) 28 | @Convert(converter = LocalDateTimePersistenceConverter.class) 29 | private LocalDateTime dateTime; 30 | 31 | @Column(nullable = true) 32 | @Convert(converter = LocalDateTimePersistenceConverter.class) 33 | private LocalDateTime modifiedDateTime; 34 | 35 | @ManyToOne(fetch = FetchType.EAGER) 36 | @JoinColumn(name = "user_id", nullable = false) 37 | private User user; 38 | 39 | @ManyToOne(fetch = FetchType.LAZY) 40 | @JoinColumn(name = "post_id", nullable = false) 41 | private Post post; 42 | 43 | @Column(nullable = false) 44 | private boolean deleted = false; 45 | 46 | // simple (adjacency list) comments hierarchy implementation 47 | // probably not the most performance efficient choice since it will have additional DB queries for each level in each subtree 48 | 49 | @ManyToOne(fetch = FetchType.EAGER) 50 | @JoinColumn(name = "parent_id") 51 | private Comment parentComment; 52 | 53 | @OneToMany(mappedBy="parentComment", cascade = CascadeType.ALL) 54 | @OrderBy("dateTime ASC") 55 | private List childrenComments = new ArrayList<>(); 56 | 57 | @OneToMany(cascade = CascadeType.ALL, mappedBy = "comment") 58 | 59 | private List commentRatings = new ArrayList<>(); 60 | 61 | public int commentLevel() { 62 | Comment comment = this; 63 | int level = 0; 64 | while ((comment = comment.getParentComment()) != null) 65 | level++; 66 | return level; 67 | } 68 | 69 | public boolean userCanDelete() { 70 | return LocalDateTime.now().isBefore(maxDeleteTime()); 71 | } 72 | 73 | public LocalDateTime maxDeleteTime() { 74 | return dateTime.plusMinutes(10); 75 | } 76 | 77 | // should refactor to store dates in UTC in database 78 | 79 | public long maxDeleteTimeUnixTimestamp() { 80 | return maxDeleteTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); 81 | } 82 | 83 | public boolean userCanEdit() { 84 | return LocalDateTime.now().isBefore(maxEditTime()); 85 | } 86 | 87 | public LocalDateTime maxEditTime() { 88 | return dateTime.plusMinutes(180); 89 | } 90 | 91 | public long maxEditTimeUnixTimestamp() { 92 | return maxEditTime().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); 93 | } 94 | 95 | public int getRatingSum() { 96 | return commentRatings.stream().mapToInt(Rating::getValue).sum(); 97 | } 98 | 99 | public short getUserVoteValue(Long userId) { 100 | if (userId == null) 101 | return 0; 102 | 103 | Optional rating = commentRatings.stream().filter(r -> r.getUser().getId().equals(userId)).findFirst(); 104 | return rating.isPresent() ? rating.get().getValue() : 0; 105 | } 106 | 107 | public Long getId() { 108 | return Id; 109 | } 110 | 111 | public void setId(Long id) { 112 | Id = id; 113 | } 114 | 115 | public String getCommentText() { 116 | return commentText; 117 | } 118 | 119 | public String getCommentTextHtml() { 120 | return MarkdownConverter.toHtml(getCommentText()); 121 | } 122 | 123 | public LocalDateTime getDateTime() { 124 | return dateTime; 125 | } 126 | 127 | public void setDateTime(LocalDateTime dateTime) { 128 | this.dateTime = dateTime; 129 | } 130 | 131 | public LocalDateTime getModifiedDateTime() { 132 | return modifiedDateTime; 133 | } 134 | 135 | public void setModifiedDateTime(LocalDateTime modifiedDateTime) { 136 | this.modifiedDateTime = modifiedDateTime; 137 | } 138 | 139 | public void setCommentText(String commentText) { 140 | this.commentText = commentText; 141 | } 142 | 143 | public User getUser() { 144 | return user; 145 | } 146 | 147 | public void setUser(User user) { 148 | this.user = user; 149 | } 150 | 151 | public Post getPost() { 152 | return post; 153 | } 154 | 155 | public void setPost(Post post) { 156 | this.post = post; 157 | } 158 | 159 | public boolean isDeleted() { 160 | return deleted; 161 | } 162 | 163 | public void setDeleted(boolean deleted) { 164 | this.deleted = deleted; 165 | } 166 | 167 | public Comment getParentComment() { 168 | return parentComment; 169 | } 170 | 171 | public void setParentComment(Comment parentComment) { 172 | this.parentComment = parentComment; 173 | } 174 | 175 | public List getChildrenComments() { 176 | return childrenComments; 177 | } 178 | 179 | public void setChildrenComments(List childrenComments) { 180 | this.childrenComments = childrenComments; 181 | } 182 | 183 | public List getCommentRatings() { 184 | return commentRatings; 185 | } 186 | 187 | public void setCommentRatings(List commentRatings) { 188 | this.commentRatings = commentRatings; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/model/CommentRating.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.model; 2 | 3 | import javax.persistence.*; 4 | 5 | @Entity 6 | @Table(name = "comment_ratings", 7 | uniqueConstraints = @UniqueConstraint(columnNames = {"comment_id", "user_id"})) 8 | public class CommentRating extends Rating { 9 | 10 | @Id 11 | @GeneratedValue 12 | private Long Id; 13 | 14 | @ManyToOne(fetch = FetchType.LAZY) 15 | @JoinColumn(name = "comment_id", nullable = false) 16 | private Comment comment; 17 | 18 | public CommentRating(User user, short value, Comment comment) { 19 | super(user, value); 20 | this.comment = comment; 21 | } 22 | 23 | public CommentRating() { 24 | } 25 | 26 | public Long getId() { 27 | return Id; 28 | } 29 | 30 | public void setId(Long id) { 31 | Id = id; 32 | } 33 | 34 | public Comment getComment() { 35 | return comment; 36 | } 37 | 38 | public void setComment(Comment comment) { 39 | this.comment = comment; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/model/Post.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.model; 2 | 3 | import alexp.blog.service.MarkdownConverter; 4 | import alexp.blog.utils.LocalDateTimePersistenceConverter; 5 | import org.springframework.util.StringUtils; 6 | 7 | import javax.persistence.*; 8 | import java.time.LocalDateTime; 9 | import java.util.ArrayList; 10 | import java.util.Collection; 11 | import java.util.List; 12 | import java.util.Optional; 13 | import java.util.stream.Collectors; 14 | 15 | @Entity 16 | @Table(name = "posts") 17 | public class Post { 18 | 19 | @Id 20 | @GeneratedValue 21 | private Long Id; 22 | 23 | @Column(length = 250, nullable = false) 24 | private String title; 25 | 26 | @Lob 27 | private String shortTextPart; 28 | 29 | @Lob 30 | @Column(nullable = false) 31 | private String fullPostText; 32 | 33 | @Column(nullable = false) 34 | @Convert(converter = LocalDateTimePersistenceConverter.class) 35 | private LocalDateTime dateTime; 36 | 37 | @Column(nullable = false) 38 | private boolean hidden = false; 39 | 40 | @ManyToMany(cascade = { CascadeType.PERSIST, CascadeType.MERGE}) 41 | @JoinTable(name = "posts_tags", 42 | joinColumns = @JoinColumn(name = "post_id", referencedColumnName = "id"), 43 | inverseJoinColumns = @JoinColumn(name = "tag_id", referencedColumnName = "id")) 44 | @OrderBy("name ASC") 45 | private Collection tags = new ArrayList<>(); 46 | 47 | @OneToMany(cascade = CascadeType.ALL, mappedBy = "post") 48 | @org.hibernate.annotations.LazyCollection(org.hibernate.annotations.LazyCollectionOption.EXTRA) 49 | @OrderBy("dateTime ASC") 50 | private List comments = new ArrayList<>(); 51 | 52 | @OneToMany(cascade = CascadeType.ALL, mappedBy = "post") 53 | private List postRatings = new ArrayList<>(); 54 | 55 | public static String shortPartSeparator() { 56 | return "===cut==="; 57 | } 58 | 59 | public boolean hasShortTextPart() { 60 | return !StringUtils.isEmpty(shortTextPart); 61 | } 62 | 63 | public String shortTextPartHtml() { 64 | return MarkdownConverter.toHtml(getShortTextPart()); 65 | } 66 | 67 | public String fullPostTextHtml() { 68 | return MarkdownConverter.toHtml(getFullPostText().replace(shortPartSeparator(), "")); 69 | } 70 | 71 | public List topLevelComments() { 72 | return comments.stream().filter(c -> c.getParentComment() == null).collect(Collectors.toList()); 73 | } 74 | 75 | public int getRatingSum() { 76 | return postRatings.stream().mapToInt(Rating::getValue).sum(); 77 | } 78 | 79 | public short getUserVoteValue(Long userId) { 80 | if (userId == null) 81 | return 0; 82 | 83 | Optional rating = postRatings.stream().filter(r -> r.getUser().getId().equals(userId)).findFirst(); 84 | return rating.isPresent() ? rating.get().getValue() : 0; 85 | } 86 | 87 | public Long getId() { 88 | return Id; 89 | } 90 | 91 | public void setId(Long id) { 92 | Id = id; 93 | } 94 | 95 | public String getTitle() { 96 | return title; 97 | } 98 | 99 | public void setTitle(String title) { 100 | this.title = title; 101 | } 102 | 103 | public String getShortTextPart() { 104 | return shortTextPart; 105 | } 106 | 107 | public void setShortTextPart(String shortTextPart) { 108 | this.shortTextPart = shortTextPart; 109 | } 110 | 111 | public String getFullPostText() { 112 | return fullPostText; 113 | } 114 | 115 | public void setFullPostText(String fullPostText) { 116 | this.fullPostText = fullPostText; 117 | } 118 | 119 | public Collection getTags() { 120 | return tags; 121 | } 122 | 123 | public void setTags(Collection tags) { 124 | this.tags = tags; 125 | } 126 | 127 | public LocalDateTime getDateTime() { 128 | return dateTime; 129 | } 130 | 131 | public void setDateTime(LocalDateTime dateTime) { 132 | this.dateTime = dateTime; 133 | } 134 | 135 | public List getComments() { 136 | return comments; 137 | } 138 | 139 | public void setComments(List comments) { 140 | this.comments = comments; 141 | } 142 | 143 | public boolean isHidden() { 144 | return hidden; 145 | } 146 | 147 | public void setHidden(boolean hidden) { 148 | this.hidden = hidden; 149 | } 150 | 151 | public List getPostRatings() { 152 | return postRatings; 153 | } 154 | 155 | public void setPostRatings(List postRatings) { 156 | this.postRatings = postRatings; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/model/PostEditDto.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.model; 2 | 3 | import org.hibernate.validator.constraints.NotBlank; 4 | 5 | import javax.validation.constraints.Size; 6 | 7 | public class PostEditDto { 8 | 9 | private Long Id = null; 10 | 11 | // haven't figured out how to specify messages for Size.List in the messages file 12 | @Size.List({ 13 | @Size(min = 3, message = "Title too short"), 14 | @Size(max = 250, message = "Title too long") 15 | }) 16 | @NotBlank 17 | private String title; 18 | 19 | @NotBlank 20 | @Size(min = 50) 21 | private String text; 22 | 23 | @NotBlank 24 | private String tags = ""; 25 | 26 | public Long getId() { 27 | return Id; 28 | } 29 | 30 | public void setId(Long id) { 31 | Id = id; 32 | } 33 | 34 | public String getTitle() { 35 | return title; 36 | } 37 | 38 | public void setTitle(String title) { 39 | this.title = title; 40 | } 41 | 42 | public String getText() { 43 | return text; 44 | } 45 | 46 | public void setText(String text) { 47 | this.text = text; 48 | } 49 | 50 | public String getTags() { 51 | return tags; 52 | } 53 | 54 | public void setTags(String tags) { 55 | this.tags = tags; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/model/PostRating.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.model; 2 | 3 | import javax.persistence.*; 4 | 5 | @Entity 6 | @Table(name = "post_ratings", 7 | uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "user_id"})) 8 | public class PostRating extends Rating { 9 | 10 | @Id 11 | @GeneratedValue 12 | private Long Id; 13 | 14 | @ManyToOne(fetch = FetchType.LAZY) 15 | @JoinColumn(name = "post_id", nullable = false) 16 | private Post post; 17 | 18 | public PostRating(User user, short value, Post post) { 19 | super(user, value); 20 | this.post = post; 21 | } 22 | 23 | public PostRating() { 24 | 25 | } 26 | 27 | public Long getId() { 28 | return Id; 29 | } 30 | 31 | public void setId(Long id) { 32 | Id = id; 33 | } 34 | 35 | public Post getPost() { 36 | return post; 37 | } 38 | 39 | public void setPost(Post post) { 40 | this.post = post; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/model/Rating.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.model; 2 | 3 | import javax.persistence.*; 4 | 5 | @MappedSuperclass 6 | @Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) 7 | public abstract class Rating { 8 | 9 | public static final short LIKE_VALUE = 1; 10 | public static final short DISLIKE_VALUE = -1; 11 | 12 | @ManyToOne(fetch = FetchType.LAZY) 13 | @JoinColumn(name = "user_id", nullable = false) 14 | protected User user; 15 | 16 | protected short value; 17 | 18 | public Rating(User user, short value) { 19 | this.user = user; 20 | this.value = value; 21 | } 22 | 23 | public Rating() { 24 | 25 | } 26 | 27 | public User getUser() { 28 | return user; 29 | } 30 | 31 | public void setUser(User user) { 32 | this.user = user; 33 | } 34 | 35 | public short getValue() { 36 | return value; 37 | } 38 | 39 | public void setValue(short value) { 40 | this.value = value; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/model/Role.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.model; 2 | 3 | import javax.persistence.*; 4 | import java.util.ArrayList; 5 | import java.util.Collection; 6 | 7 | @Entity 8 | @Table(name = "roles") 9 | public class Role { 10 | @Id 11 | @GeneratedValue 12 | private Long Id; 13 | 14 | @Column(unique = true, nullable = false, length = 50) 15 | private String name; 16 | 17 | @ManyToMany(mappedBy = "roles") 18 | private Collection users = new ArrayList<>();; 19 | 20 | public Long getId() { 21 | return Id; 22 | } 23 | 24 | public void setId(Long id) { 25 | Id = id; 26 | } 27 | 28 | public String getName() { 29 | return name; 30 | } 31 | 32 | public void setName(String name) { 33 | this.name = name; 34 | } 35 | 36 | public Collection getUsers() { 37 | return users; 38 | } 39 | 40 | public void setUsers(Collection users) { 41 | this.users = users; 42 | } 43 | 44 | @Override 45 | public String toString() { 46 | return "Role{" + 47 | "name='" + name + '\'' + 48 | '}'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/model/Tag.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.model; 2 | 3 | import javax.persistence.*; 4 | import java.util.ArrayList; 5 | import java.util.Collection; 6 | 7 | @Entity 8 | @Table(name = "tags") 9 | public class Tag { 10 | 11 | @Id 12 | @GeneratedValue 13 | private Long Id; 14 | 15 | @Column(length = 30, nullable = false, unique = true) 16 | private String name; 17 | 18 | @ManyToMany(mappedBy = "tags") 19 | private Collection posts = new ArrayList<>(); 20 | 21 | public Tag() { 22 | } 23 | 24 | public Tag(String name) { 25 | this.name = name; 26 | } 27 | 28 | public Long getId() { 29 | return Id; 30 | } 31 | 32 | public void setId(Long id) { 33 | Id = id; 34 | } 35 | 36 | public String getName() { 37 | return name; 38 | } 39 | 40 | public void setName(String name) { 41 | this.name = name; 42 | } 43 | 44 | public Collection getPosts() { 45 | return posts; 46 | } 47 | 48 | public void setPosts(Collection posts) { 49 | this.posts = posts; 50 | } 51 | 52 | @Override 53 | public String toString() { 54 | return "Tag{" + 55 | "name='" + name + '\'' + 56 | '}'; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/repository/CommentRatingRepository.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.repository; 2 | 3 | import alexp.blog.model.CommentRating; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.data.jpa.repository.Query; 6 | import org.springframework.data.repository.query.Param; 7 | 8 | public interface CommentRatingRepository extends JpaRepository { 9 | 10 | @Query("SELECT r FROM CommentRating r WHERE r.comment.id = :commentId AND r.user.id = :userId") 11 | CommentRating findUserRating(@Param("commentId") Long commentId, @Param("userId") Long userId); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/repository/CommentRepository.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.repository; 2 | 3 | import alexp.blog.model.Comment; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface CommentRepository extends JpaRepository { 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/repository/PostRatingRepository.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.repository; 2 | 3 | import alexp.blog.model.PostRating; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.data.jpa.repository.Query; 6 | import org.springframework.data.repository.query.Param; 7 | 8 | public interface PostRatingRepository extends JpaRepository { 9 | 10 | @Query("SELECT r FROM PostRating r WHERE r.post.id = :postId AND r.user.id = :userId") 11 | PostRating findUserRating(@Param("postId") Long postId, @Param("userId") Long userId); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/repository/PostRepository.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.repository; 2 | 3 | import alexp.blog.model.Post; 4 | import org.springframework.data.domain.Page; 5 | import org.springframework.data.domain.Pageable; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | import org.springframework.data.jpa.repository.Query; 8 | import org.springframework.data.repository.query.Param; 9 | import java.util.Collection; 10 | import java.util.List; 11 | 12 | public interface PostRepository extends JpaRepository { 13 | 14 | Page findByHiddenFalse(Pageable pageable); 15 | 16 | // without count 17 | List findByHiddenIs(boolean hidden, Pageable pageable); 18 | 19 | @Query("SELECT p FROM Post p WHERE :tagCount = (SELECT COUNT(DISTINCT t.id) FROM Post p2 JOIN p2.tags t WHERE LOWER(t.name) in (:tags) and p = p2)") 20 | Page findByTags(@Param("tags") Collection tags, @Param("tagCount") Long tagCount, Pageable pageable); 21 | 22 | @Query("SELECT p FROM Post p WHERE :tagCount = (SELECT COUNT(DISTINCT t.id) FROM Post p2 JOIN p2.tags t WHERE p.hidden = false and LOWER(t.name) in (:tags) and p = p2)") 23 | Page findByTagsAndNotHidden(@Param("tags") Collection tags, @Param("tagCount") Long tagCount, Pageable pageable); 24 | 25 | @Query("SELECT p FROM Post p JOIN p.postRatings r WHERE p.hidden = false GROUP BY p ORDER BY SUM(r.value) DESC") 26 | List findTopPosts(Pageable pageable); 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/repository/RoleRepository.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.repository; 2 | 3 | import alexp.blog.model.Role; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface RoleRepository extends JpaRepository { 7 | 8 | Role findByName(String name); 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/repository/TagRepository.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.repository; 2 | 3 | import alexp.blog.model.Tag; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface TagRepository extends JpaRepository { 7 | 8 | Tag findByNameIgnoreCase(String name); 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/repository/UserRepository.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.repository; 2 | 3 | import alexp.blog.model.User; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface UserRepository extends JpaRepository { 7 | 8 | User findByUsernameIgnoreCase(String username); 9 | 10 | User findByEmailIgnoreCase(String email); 11 | 12 | User findByUsernameOrEmail(String username, String email); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/service/ActionExpiredException.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | public class ActionExpiredException extends Exception { 4 | 5 | public ActionExpiredException(String message) { 6 | super(message); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/service/AlreadyVotedException.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | public class AlreadyVotedException extends Exception { 4 | 5 | public AlreadyVotedException(String message) { 6 | super(message); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/service/AuthException.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | public class AuthException extends Exception { 4 | 5 | public AuthException(String message) { 6 | super(message); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/service/AvatarService.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | import org.springframework.web.multipart.MultipartFile; 4 | 5 | import java.io.IOException; 6 | 7 | public interface AvatarService { 8 | 9 | UploadedAvatarInfo upload(MultipartFile file) throws IOException, UnsupportedFormatException; 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/service/AvatarServiceImpl.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | import org.apache.commons.io.FilenameUtils; 4 | import org.imgscalr.Scalr; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.stereotype.Service; 8 | import org.springframework.web.multipart.MultipartFile; 9 | 10 | import javax.imageio.ImageIO; 11 | import java.awt.image.BufferedImage; 12 | import java.io.*; 13 | import java.lang.reflect.Array; 14 | import java.util.Arrays; 15 | import java.util.List; 16 | 17 | @Service("avatarService") 18 | public class AvatarServiceImpl implements AvatarService { 19 | 20 | @Value("${uploading.dirpath}") 21 | private String uploadingDirPath; 22 | 23 | @Autowired 24 | private FileNameGenerator fileNameGenerator; 25 | 26 | public AvatarServiceImpl() { 27 | ImageIO.setUseCache(false); 28 | } 29 | 30 | public final List SUPPORTED_EXTENSIONS = Arrays.asList("jpg", "jpeg", "png"); 31 | 32 | @Override 33 | public UploadedAvatarInfo upload(MultipartFile file) throws IOException, UnsupportedFormatException { 34 | String fileName = file.getOriginalFilename(); 35 | String ext = FilenameUtils.getExtension(fileName).toLowerCase(); 36 | 37 | if (!SUPPORTED_EXTENSIONS.contains(ext)) 38 | throw new UnsupportedFormatException(fileName); 39 | 40 | String name = fileNameGenerator.getFileName(fileName, "avatar"); 41 | String bigImageName = name + "_big" + "." + ext; 42 | String smallImageName = name + "_small" + "." + ext; 43 | 44 | BufferedImage image = ImageIO.read(file.getInputStream()); 45 | 46 | BufferedImage bigImage = resize(image, 160); 47 | 48 | new File(uploadingDirPath).mkdirs(); 49 | 50 | ImageIO.write(bigImage, ext, new File(uploadingDirPath + bigImageName)); 51 | 52 | BufferedImage smallImage = resize(image, 28); 53 | 54 | ImageIO.write(smallImage, ext, new File(uploadingDirPath + smallImageName)); 55 | 56 | return new UploadedAvatarInfo(bigImageName, smallImageName); 57 | } 58 | 59 | private BufferedImage resize(BufferedImage image, int size) { 60 | BufferedImage result = Scalr.resize(image, Scalr.Mode.FIT_EXACT, size, size); 61 | 62 | // would be better to crop image if not square, instead of just resizing without preserving proportions 63 | 64 | return result; 65 | } 66 | 67 | public FileNameGenerator getFileNameGenerator() { 68 | return fileNameGenerator; 69 | } 70 | 71 | public void setFileNameGenerator(FileNameGenerator fileNameGenerator) { 72 | this.fileNameGenerator = fileNameGenerator; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/service/CommentService.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | import alexp.blog.controller.ForbiddenException; 4 | import alexp.blog.model.Comment; 5 | import alexp.blog.model.Post; 6 | 7 | import java.util.List; 8 | 9 | public interface CommentService { 10 | 11 | Comment getComment(Long id); 12 | 13 | Long saveNewComment(Comment comment, Post post, Long parentId); 14 | 15 | void deleteComment(Long commentId) throws ActionExpiredException; 16 | 17 | void updateComment(Comment newCommentData, Long commentId) throws ActionExpiredException; 18 | 19 | void vote(Long commentId, boolean like) throws AlreadyVotedException, ForbiddenException; 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/service/CommentServiceImpl.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | import alexp.blog.controller.ForbiddenException; 4 | import alexp.blog.model.*; 5 | import alexp.blog.repository.CommentRatingRepository; 6 | import alexp.blog.repository.CommentRepository; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.stereotype.Service; 9 | 10 | import java.time.LocalDateTime; 11 | 12 | @Service("commentService") 13 | public class CommentServiceImpl implements CommentService { 14 | 15 | @Autowired 16 | private UserService userService; 17 | 18 | @Autowired 19 | private CommentRepository commentRepository; 20 | 21 | @Autowired 22 | private CommentRatingRepository commentRatingRepository; 23 | 24 | private static final int MAX_COMMENT_LEVEL = 5; 25 | 26 | @Override 27 | public Comment getComment(Long id) { 28 | return commentRepository.findOne(id); 29 | } 30 | 31 | @Override 32 | public Long saveNewComment(Comment comment, Post post, Long parentId) { 33 | if (parentId != null) { 34 | Comment parentComment = getComment(parentId); 35 | 36 | int level = parentComment.commentLevel(); 37 | 38 | comment.setParentComment(level < MAX_COMMENT_LEVEL ? parentComment : parentComment.getParentComment()); 39 | } 40 | 41 | comment.setDateTime(LocalDateTime.now()); 42 | 43 | comment.setPost(post); 44 | 45 | comment.setUser(userService.currentUser()); 46 | 47 | commentRepository.saveAndFlush(comment); 48 | 49 | return comment.getId(); 50 | } 51 | 52 | @Override 53 | public void deleteComment(Long commentId) throws ActionExpiredException { 54 | Comment comment = getComment(commentId); 55 | 56 | boolean isAdmin = userService.isAdmin(); 57 | 58 | if (!isAdmin && !userService.currentUser().getUsername().equals(comment.getUser().getUsername())) { 59 | throw new ForbiddenException(); 60 | } 61 | 62 | if (!isAdmin && !comment.userCanDelete()) { 63 | throw new ActionExpiredException("delete time exceeded"); 64 | } 65 | 66 | comment.setDeleted(true); 67 | 68 | commentRepository.saveAndFlush(comment); 69 | } 70 | 71 | @Override 72 | public void updateComment(Comment newCommentData, Long commentId) throws ActionExpiredException { 73 | Comment comment = getComment(commentId); 74 | 75 | boolean isAdmin = userService.isAdmin(); 76 | 77 | if (!isAdmin && !userService.currentUser().getUsername().equals(comment.getUser().getUsername())) { 78 | throw new ForbiddenException(); 79 | } 80 | 81 | if (!isAdmin && !comment.userCanEdit()) { 82 | throw new ActionExpiredException("edit time exceeded"); 83 | } 84 | 85 | comment.setCommentText(newCommentData.getCommentText()); 86 | 87 | comment.setModifiedDateTime(LocalDateTime.now()); 88 | 89 | commentRepository.saveAndFlush(comment); 90 | } 91 | 92 | @Override 93 | public void vote(Long commentId, boolean like) throws AlreadyVotedException, ForbiddenException { 94 | User currentUser = userService.currentUser(); 95 | 96 | Comment comment = getComment(commentId); 97 | 98 | if (currentUser.getId().longValue() == comment.getUser().getId().longValue()) { 99 | throw new ForbiddenException("cannot vote for own comments"); 100 | } 101 | 102 | CommentRating rating = commentRatingRepository.findUserRating(commentId, currentUser.getId()); 103 | 104 | if (rating != null) { 105 | throw new AlreadyVotedException("cannot vote more than once"); 106 | } 107 | 108 | rating = new CommentRating(); 109 | 110 | rating.setUser(currentUser); 111 | rating.setValue(like ? Rating.LIKE_VALUE : Rating.DISLIKE_VALUE); 112 | rating.setComment(comment); 113 | 114 | commentRatingRepository.saveAndFlush(rating); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/service/FileNameGenerator.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | public interface FileNameGenerator { 4 | 5 | String getFileName(String filename, String prefix); 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/service/FileNameGeneratorImpl.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | import org.apache.commons.io.FilenameUtils; 4 | import org.springframework.stereotype.Service; 5 | 6 | @Service("fileNameGenerator") 7 | public class FileNameGeneratorImpl implements FileNameGenerator { 8 | @Override 9 | public String getFileName(String filename, String prefix) { 10 | return prefix + filename.hashCode() + System.currentTimeMillis(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/service/MarkdownConverter.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | import com.github.rjeschke.txtmark.Processor; 4 | import org.jsoup.Jsoup; 5 | import org.jsoup.safety.Whitelist; 6 | 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | import java.util.regex.Matcher; 10 | import java.util.regex.Pattern; 11 | 12 | public class MarkdownConverter { 13 | 14 | public static String toHtml(String input) { 15 | String html = Processor.process(input, true); 16 | 17 | String safeHtml = Jsoup.clean(html, Whitelist.basicWithImages()); 18 | 19 | return safeHtml; 20 | } 21 | 22 | public static List extractLinks(String input) { 23 | Matcher m = Pattern.compile("\\s?\\s?\\[([^^\\]]+)\\]:\\s+(.+)$", Pattern.MULTILINE) 24 | .matcher(input); 25 | 26 | List links = new ArrayList<>(); 27 | while (m.find()) { 28 | links.add(m.group()); 29 | } 30 | 31 | return links; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/service/PostService.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | import alexp.blog.model.Post; 4 | import alexp.blog.model.PostEditDto; 5 | import org.springframework.data.domain.Page; 6 | 7 | import java.util.List; 8 | 9 | public interface PostService { 10 | 11 | Page getPostsPage(int pageNumber, int pageSize); 12 | 13 | List getPostsList(int pageNumber, int pageSize); 14 | 15 | List getTopPostsList(); 16 | 17 | Post getPost(Long id); 18 | 19 | PostEditDto getEditablePost(Long id); 20 | 21 | Page findPostsByTag(List tags, int pageNumber, int pageSize); 22 | 23 | Post saveNewPost(PostEditDto postEditDto); 24 | 25 | Post updatePost(PostEditDto postEditDto); 26 | 27 | void setPostVisibility(Long postId, boolean hide); 28 | 29 | void deletePost(Long postId); 30 | 31 | void vote(Long postId, boolean like) throws AlreadyVotedException; 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/service/UnsupportedFormatException.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | public class UnsupportedFormatException extends Exception { 4 | 5 | public UnsupportedFormatException(String message) { 6 | super(message); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/service/UploadedAvatarInfo.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | public class UploadedAvatarInfo { 4 | public final String bigImageLink; 5 | 6 | public final String smallImageLink; 7 | 8 | public UploadedAvatarInfo(String bigImageLink, String smallImageLink) { 9 | this.bigImageLink = bigImageLink; 10 | this.smallImageLink = smallImageLink; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/service/UserService.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | import alexp.blog.model.User; 4 | import org.springframework.security.core.userdetails.UserDetailsService; 5 | 6 | public interface UserService extends UserDetailsService { 7 | 8 | User findByEmail(String email); 9 | 10 | User findByUsername(String username); 11 | 12 | boolean emailExists(String email); 13 | 14 | boolean usernameExists(String username); 15 | 16 | void register(User user); 17 | 18 | void changeEmail(String newEmail, String currentPassword) throws AuthException; 19 | 20 | void changePassword(String newPassword, String currentPassword) throws AuthException; 21 | 22 | void changeProfileInfo(User newProfileInfo); 23 | 24 | void changeAvatar(UploadedAvatarInfo uploadedAvatarInfo); 25 | 26 | void removeAvatar(); 27 | 28 | void authenticate(User user); 29 | 30 | boolean isAuthenticated(); 31 | 32 | boolean isAdmin(); 33 | 34 | User currentUser(); 35 | 36 | } 37 | 38 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/utils/JsonUtils.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.utils; 2 | 3 | public class JsonUtils { 4 | 5 | public static String toJsonField(String name, String value) { 6 | return "\"" + name + "\":\"" + value + "\""; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/alexp/blog/utils/LocalDatePersistenceConverter.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.utils; 2 | 3 | import java.time.LocalDate; 4 | import javax.persistence.AttributeConverter; 5 | import javax.persistence.Converter; 6 | 7 | @Converter 8 | public class LocalDatePersistenceConverter implements AttributeConverter { 9 | 10 | @Override 11 | public java.sql.Date convertToDatabaseColumn(LocalDate entityValue) { 12 | if (entityValue != null) { 13 | return java.sql.Date.valueOf(entityValue); 14 | } 15 | return null; 16 | } 17 | 18 | @Override 19 | public LocalDate convertToEntityAttribute(java.sql.Date databaseValue) { 20 | if (databaseValue != null) { 21 | return databaseValue.toLocalDate(); 22 | } 23 | return null; 24 | } 25 | } -------------------------------------------------------------------------------- /src/main/java/alexp/blog/utils/LocalDateTimePersistenceConverter.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.utils; 2 | 3 | import java.time.LocalDateTime; 4 | import javax.persistence.AttributeConverter; 5 | import javax.persistence.Converter; 6 | 7 | @Converter 8 | public class LocalDateTimePersistenceConverter implements AttributeConverter { 9 | 10 | @Override 11 | public java.sql.Timestamp convertToDatabaseColumn(LocalDateTime entityValue) { 12 | if (entityValue != null) { 13 | return java.sql.Timestamp.valueOf(entityValue); 14 | } 15 | return null; 16 | } 17 | 18 | @Override 19 | public LocalDateTime convertToEntityAttribute(java.sql.Timestamp databaseValue) { 20 | if (databaseValue != null) { 21 | return databaseValue.toLocalDateTime(); 22 | } 23 | return null; 24 | } 25 | } -------------------------------------------------------------------------------- /src/main/java/alexp/blog/validator/UserValidator.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.validator; 2 | 3 | import alexp.blog.model.User; 4 | import alexp.blog.service.UserService; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.stereotype.Component; 7 | import org.springframework.util.StringUtils; 8 | import org.springframework.validation.Errors; 9 | import org.springframework.validation.Validator; 10 | 11 | @Component("userValidator") 12 | public class UserValidator implements Validator { 13 | 14 | @Autowired 15 | UserService userService; 16 | 17 | @Override 18 | public boolean supports(Class aClass) { 19 | return User.class.equals(aClass); 20 | } 21 | 22 | @Override 23 | public void validate(Object o, Errors errors) { 24 | User user = (User) o; 25 | 26 | if (!StringUtils.isEmpty(user.getUsername())) { 27 | if (userService.usernameExists(user.getUsername())) { 28 | errors.rejectValue("username", "Registered"); 29 | } 30 | } 31 | 32 | if (!StringUtils.isEmpty(user.getEmail())) { 33 | User currentUser = userService.currentUser(); 34 | 35 | if (currentUser == null || !currentUser.getEmail().equalsIgnoreCase(user.getEmail())) { 36 | if (userService.emailExists(user.getEmail())) { 37 | errors.rejectValue("email", "Registered"); 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/persistence.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/main/resources/Messages.properties: -------------------------------------------------------------------------------- 1 | NotBlank.user.email=Enter e-mail 2 | Email.user.email=Enter valid e-mail 3 | NotBlank.user.username=Enter username 4 | Pattern.user.username=Only letters, numbers, space, ".", "-" and "_" are allowed 5 | Registered.user.username=Username already registered. Have you already registered? Contact admin if you forgot your password 6 | Registered.user.email=Email already registered. Have you already registered? Contact admin if you forgot your password 7 | NotBlank.user.password=Enter password 8 | NotMatchCurrent.user.password=Current password does not match 9 | Pattern.user.websiteLink=Invalid website link format 10 | Size.user.websiteLink=Too long website link 11 | Pattern.user.aboutMe=Too long text, max 1000 characters 12 | 13 | NotBlank.post.title=Enter title 14 | NotBlank.post.text=Text cannot be empty 15 | NotBlank.post.tags=Add at least one tag 16 | Size.post.text=Too short text 17 | 18 | NotBlank.comment.commentText=Empty comments not allowed -------------------------------------------------------------------------------- /src/main/resources/database.xml: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/main/resources/datasource-dev.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/main/resources/datasource-test.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/main/resources/security-tables.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null); 2 | -------------------------------------------------------------------------------- /src/main/resources/uploading-dev.properties: -------------------------------------------------------------------------------- 1 | uploading.dirpath=c:/blog/upload/ -------------------------------------------------------------------------------- /src/main/resources/uploading-prod.properties: -------------------------------------------------------------------------------- 1 | uploading.dirpath=/var/blog/upload/ -------------------------------------------------------------------------------- /src/main/resources/uploading-test.properties: -------------------------------------------------------------------------------- 1 | uploading.dirpath=c:/blog/upload-test/ -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/mvc-dispatcher-servlet.xml: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/spring-security.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/403.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | Blog — Access denied 7 | 8 | 9 | 10 | 11 | 12 |

Access denied

13 | 14 | 15 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/404.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | Blog — Page not found 7 | 8 | 9 | 10 | 11 | 12 | 13 | 33 | 34 | 35 | 36 |
37 |

Page not found

38 |

Maybe it have been removed or never existed.

39 | 40 |
41 | Go to home page 42 |
43 | 44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | Blog — About 11 | 12 | 13 | 14 | 15 | 16 |
17 |

About

18 | 19 |

20 | github.com/AlexP11223/JavaSpringMvcBlog 21 |

22 | 23 |

24 | Yet another blog about programming and some random cute pictures. 25 | 26 |

27 | 28 |

29 | Feel free to add comments or contact me. 30 |

31 | 32 |

33 | alex.pantec@gmail.com 34 |

35 | 36 |

37 | 38 |

39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/editpost.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | Blog — Create post 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 |

26 | 27 |
28 |
29 |
    30 |
  • 31 |
32 |
33 | 34 |
35 | 36 | 37 |
38 | 39 |
40 |
41 | 42 |
43 |
44 | 45 |
46 | 47 | 48 |
49 | 50 | 51 | 52 |
53 |
54 |
55 | 56 | 57 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/editprofile.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | Blog — Edit profile 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 | 30 | 35 | 36 |
37 | 38 |
Changes successfully saved
39 |
40 | 41 |

Public info

42 | 43 |
44 |
45 |
    46 |
  • Input is incorrect
  • 47 |
48 |
49 | 50 |
51 | 52 |
53 | 54 |
55 |
56 | 57 |
58 | 59 | 60 |
61 | 62 | 63 | 64 |
65 | 66 |
67 |
68 | 71 |
72 | 73 |
74 | 75 |
76 | 77 | Change picture 78 | 79 | 80 | Remove 82 |
83 |
84 | 85 | 88 | 91 |
92 | 93 |
94 | 95 | 96 | 100 |
101 |
102 |
103 | 104 | 105 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | Blog — Error 7 | 8 | 9 | 10 | 33 | 34 | 35 | 36 |
37 |

OOPS! Something went wrong.

38 | 39 |

Please try again later.

40 |
41 | 42 | 43 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/fragments/comments.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 | 15 | 16 | 19 | 20 | 22 | 23 | [deleted] 24 | 25 | 29 | 30 |
32 | 38 | 40 | 44 | 45 | 46 |
47 |
48 | 49 |
50 |
51 |
52 | 53 |
54 | 55 | reply 58 | 59 | edit 64 | 65 | delete 70 | 71 |
72 | 75 |
76 |
77 |
78 |
79 | 80 | 81 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/fragments/loginform.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 18 | 19 | Log in 20 | 21 |
22 |
23 | 24 | 25 |
26 |
27 | 28 | 29 |
30 |
31 | 32 |
33 | 34 |
35 | 36 |
37 |
38 | 39 |
40 |
41 | 42 | 45 |
46 | 47 | 48 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/layouts/blog.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | Blog 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 48 | 49 |
50 | 51 |
52 |
53 | 64 |
65 |
66 | 67 |
68 |
69 |
70 | 71 |
72 | 73 |
74 | 75 |
76 | Logged as 77 | 78 |
79 | Logout 80 |
81 | 82 |
83 | 88 | 93 | 98 |
99 |
100 |
101 |
102 | 103 |
104 | 105 |
106 |
107 | 108 |
109 |
110 |
111 | 112 |
113 |
114 |
115 |
116 | 117 | 118 |
119 | 120 | 121 |
122 | 123 |
124 |
125 |

Latest posts

126 | 127 | 128 |
129 |
130 | 131 |
132 |
133 |

Popular posts

134 | 135 | 136 |
137 |
138 |
139 |
140 |
141 |
142 | 143 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/logout.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | Blog — logout 7 | 8 | 30 | 31 | 32 | 33 | 34 | 35 |
36 | Performing logout... 37 | 38 |
39 | 40 | 43 | 44 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/profile.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |

21 | 22 |
23 | 24 |
25 | 26 | 30 | 31 |
32 | 33 |
34 |
35 | 36 |
37 |
38 |
39 |
40 |
41 | 42 | 43 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/registration.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | Blog — Registration 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 23 | 24 |
25 |

Registration

26 | 27 |
28 |
29 |
    30 |
  • Input is incorrect
  • 31 |
32 |
33 | 34 |
35 | 36 | 37 |
38 |
39 | 40 | 41 |
42 |
43 | 44 | 45 |
46 |
47 | 48 | 49 |
50 | 51 | 52 |
53 |
54 |
55 | 56 | 57 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | Blog — Edit settings 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 | 20 |
21 | 22 |
Email successfully changed
23 |
Password successfully changed
24 |
25 | 26 |
27 |
Change email
28 |
29 |
30 |
31 |
    32 |
  • Input is incorrect
  • 33 |
34 |
35 | 36 |
37 | 38 | 39 |
40 | 41 |
42 | 43 | 44 |
45 | 46 | 47 |
48 |
49 |
50 | 51 |
52 |
Change password
53 |
54 |
55 |
56 |
    57 |
  • Input is incorrect
  • 58 |
59 |
60 | 61 |
62 | 63 | 64 |
65 | 66 |
67 | 68 | 69 |
70 |
71 | 72 | 73 |
74 | 75 | 76 |
77 |
78 |
79 |
80 |
81 | 82 | 83 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/web.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | Blog 7 | 8 | 9 | mvc-dispatcher 10 | org.springframework.web.servlet.DispatcherServlet 11 | 1 12 | 13 | 14 | 15 | mvc-dispatcher 16 | / 17 | 18 | 19 | 20 | org.springframework.web.context.ContextLoaderListener 21 | 22 | 23 | 24 | spring.profiles.default 25 | dev 26 | 27 | 28 | 29 | contextConfigLocation 30 | 31 | classpath:/database.xml, 32 | /WEB-INF/spring-security.xml 33 | 34 | 35 | 36 | 37 | 404 38 | /404 39 | 40 | 41 | 403 42 | /403 43 | 44 | 45 | /error 46 | 47 | 48 | 49 | encodingFilter 50 | org.springframework.web.filter.CharacterEncodingFilter 51 | 52 | encoding 53 | UTF-8 54 | 55 | 56 | forceEncoding 57 | true 58 | 59 | 60 | 61 | encodingFilter 62 | /* 63 | 64 | 65 | 66 | 67 | 68 | springSecurityFilterChain 69 | org.springframework.web.filter.DelegatingFilterProxy 70 | 71 | 72 | 73 | springSecurityFilterChain 74 | /* 75 | 76 | 77 | 78 | 79 | 80 | openEntityManagerInViewFilter 81 | org.springframework.orm.jpa.support.OpenEntityManagerInViewFilter 82 | 83 | 84 | 85 | openEntityManagerInViewFilter 86 | /* 87 | 88 | -------------------------------------------------------------------------------- /src/main/webapp/css/blog.css: -------------------------------------------------------------------------------- 1 | .page-header { 2 | padding-bottom: 10px; 3 | } 4 | 5 | .header-description { 6 | color: #999; 7 | } 8 | 9 | .page-footer { 10 | text-align: center; 11 | padding: 40px 0; 12 | margin-top: 25px; 13 | background-color: #f9f9f9; 14 | border-top: 1px solid #e5e5e5; 15 | } 16 | 17 | .col-padding { 18 | padding-left: 10px; 19 | padding-right: 10px; 20 | } 21 | 22 | .no-padding-col { 23 | padding-left: 0; 24 | padding-right: 0; 25 | } 26 | 27 | .reg-link { 28 | margin-top: 10px; 29 | white-space: nowrap; 30 | } 31 | 32 | .dotted-link { 33 | border-bottom: 1px dotted #337ab7; 34 | } 35 | 36 | .error-line { 37 | color: red; 38 | } 39 | 40 | .list-no-indent { 41 | list-style-position: inside; 42 | padding-left:0; 43 | } 44 | 45 | label.error { 46 | color: red; 47 | margin-top: 5px; 48 | display: inline; 49 | } 50 | 51 | .user-menu { 52 | padding-top: 25px; 53 | } 54 | 55 | .user-menu-item { 56 | margin-bottom: 15px; 57 | } 58 | 59 | .modal { 60 | margin-top: 25%; 61 | } 62 | 63 | .post { 64 | margin-bottom: 50px; 65 | } 66 | 67 | .post-content { 68 | position: relative; 69 | } 70 | 71 | .post-title { 72 | 73 | } 74 | 75 | .post-date { 76 | color: #999; 77 | 78 | } 79 | 80 | .post-tag { 81 | color: #3e6d8e; 82 | background-color: #e4edf4; 83 | border: 1px solid #e4edf4; 84 | white-space: nowrap; 85 | text-align: center; 86 | text-decoration: none; 87 | padding: 3px 10px; 88 | margin: 2px 2px 2px 0; 89 | } 90 | 91 | .post-maincontent { 92 | margin-top: 15px; 93 | } 94 | 95 | .hidden-post { 96 | background-color: rgba(0,0,0,0.5); 97 | position: absolute; 98 | top: 0; 99 | left: 0; 100 | bottom: 0; 101 | right: 0; 102 | text-align: center; 103 | } 104 | 105 | .hidden-post span { 106 | display: inline-block; 107 | font-size: 30px; 108 | font-weight: bold; 109 | color: lightblue; 110 | padding-top: 10px; 111 | padding-bottom: 10px; 112 | } 113 | 114 | .pager { 115 | text-align: left; 116 | } 117 | 118 | .post-actions { 119 | margin-top: 10px; 120 | } 121 | 122 | .post-actions a { 123 | margin-right: 10px; 124 | font-size: 13px; 125 | } 126 | 127 | .post-actions a:not(.comments-link):not(.comment-reply-btn) { 128 | color: #888; 129 | } 130 | 131 | .comments { 132 | } 133 | 134 | .avatar { 135 | margin-right: 10px; 136 | } 137 | 138 | .avatar img { 139 | width: 28px; 140 | height: 28px; 141 | } 142 | 143 | .comment-username { 144 | font-weight: bold; 145 | color: #369; 146 | margin-right: 10px; 147 | vertical-align: middle; 148 | } 149 | 150 | .comment-username-admin { 151 | color: darkred; 152 | } 153 | 154 | .comment-header .post-date { 155 | vertical-align: middle; 156 | } 157 | 158 | .comment-header { 159 | margin-bottom: 10px; 160 | } 161 | 162 | .comment { 163 | padding-bottom: 25px; 164 | } 165 | 166 | .new-comment .comment-header { 167 | background-color: #E3FFD0; 168 | } 169 | 170 | .child-comment-node { 171 | margin-left: 20px; 172 | } 173 | 174 | .comment-content.deleted { 175 | display: inline-block; 176 | background-color: #F0F0F0; 177 | padding: 10px; 178 | } 179 | 180 | .deleted { 181 | color: #808080; 182 | } 183 | 184 | .comment-form button { 185 | margin-right: 10px; 186 | } 187 | 188 | .post-maincontent img { 189 | max-width: 100%; 190 | height: auto; 191 | } 192 | 193 | .settings-success { 194 | padding-top: 15px; 195 | padding-bottom: 15px; 196 | margin-bottom: 15px; 197 | text-align: center; 198 | background-color: #F4FCEE; 199 | border: 2px solid #91E458; 200 | font-weight: bold; 201 | } 202 | 203 | .avatar-big { 204 | margin-top: 15px; 205 | margin-bottom: 15px; 206 | } 207 | 208 | .profile-info h3 { 209 | margin-top: 0; 210 | } 211 | 212 | .website-link a { 213 | word-wrap: break-word; 214 | } 215 | 216 | .reg-date { 217 | margin-top: 15px; 218 | color: #999; 219 | } 220 | 221 | .comments .wmd-input { 222 | height: 160px; 223 | } 224 | 225 | #profileForm .wmd-input { 226 | height: 160px; 227 | } 228 | 229 | .search-header { 230 | margin-bottom: 30px; 231 | } 232 | 233 | .search-header h3 { 234 | display: inline; 235 | } 236 | 237 | .search-result { 238 | margin-top: 10px; 239 | } 240 | 241 | #latestPosts { 242 | margin-top: 30px; 243 | } 244 | 245 | #avatarForm { 246 | margin-top: 25px; 247 | margin-bottom: 15px; 248 | } 249 | 250 | #removeAvatar { 251 | margin-left: 15px; 252 | } 253 | 254 | #avatarUploadProgress { 255 | margin-top: 15px; 256 | } 257 | 258 | .rating-arrow, .rating-arrow:focus, .rating-arrow:hover { 259 | text-decoration: none; 260 | } 261 | 262 | .rating-arrow span { 263 | font-size: 18px; 264 | color: #888888; 265 | cursor: pointer; 266 | } 267 | 268 | .like-arrow:hover span, .like-arrow.voted span { 269 | color: rgb(25, 189, 83); 270 | } 271 | 272 | .dislike-arrow:hover span, .dislike-arrow.voted span { 273 | color: red; 274 | } 275 | 276 | .rating-value { 277 | font-weight: bold; 278 | font-size: 15px; 279 | margin-left: 5px; 280 | margin-right: 5px; 281 | } 282 | 283 | .rating-value-no { 284 | color: #888; 285 | } 286 | 287 | .rating-value-positive { 288 | color: rgb(25, 189, 83); 289 | } 290 | 291 | .rating-value-negative { 292 | color: red; 293 | } 294 | 295 | .post-rating-block { 296 | display: inline-block; 297 | margin-right: 10px; 298 | } 299 | 300 | .post-rating-block a { 301 | margin-right: 0; 302 | } 303 | 304 | .comment-rating-block { 305 | display: inline-block; 306 | float: right; 307 | } -------------------------------------------------------------------------------- /src/main/webapp/css/external/fileupload.css: -------------------------------------------------------------------------------- 1 | .fileinput-button { 2 | position: relative; 3 | overflow: hidden; 4 | } 5 | .fileinput-button input { 6 | position: absolute; 7 | top: 0; 8 | right: 0; 9 | margin: 0; 10 | opacity: 0; 11 | -ms-filter: 'alpha(opacity=0)'; 12 | font-size: 200px; 13 | direction: ltr; 14 | cursor: pointer; 15 | } 16 | 17 | /* Fixes for IE < 8 */ 18 | @media screen\9 { 19 | .fileinput-button input { 20 | filter: alpha(opacity=0); 21 | font-size: 100%; 22 | height: 100%; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/webapp/css/external/pagedown.css: -------------------------------------------------------------------------------- 1 | .wmd-panel { 2 | min-width: 500px; 3 | padding-top: 5px; 4 | } 5 | 6 | .wmd-button-bar { 7 | width: 100%; 8 | background-color: Silver; 9 | } 10 | 11 | .wmd-input { 12 | height: 250px; 13 | width: 100%; 14 | border: 1px solid DarkGray; 15 | } 16 | 17 | .wmd-preview { 18 | border: 1px solid #ccc; 19 | min-height: 15px; 20 | margin-bottom: 15px; 21 | } 22 | 23 | .wmd-button-row { 24 | position: relative; 25 | margin-left: 5px; 26 | margin-right: 5px; 27 | margin-bottom: 5px; 28 | margin-top: 10px; 29 | padding: 0; 30 | height: 20px; 31 | } 32 | 33 | .wmd-spacer { 34 | width: 1px; 35 | height: 20px; 36 | margin-left: 14px; 37 | 38 | position: absolute; 39 | background-color: Silver; 40 | display: inline-block; 41 | list-style: none; 42 | } 43 | 44 | .wmd-button { 45 | width: 20px; 46 | height: 20px; 47 | padding-left: 2px; 48 | padding-right: 3px; 49 | position: absolute; 50 | display: inline-block; 51 | list-style: none; 52 | cursor: pointer; 53 | } 54 | 55 | .wmd-button > span { 56 | background-image: url(../../js/wmd-buttons.png); 57 | background-repeat: no-repeat; 58 | background-position: 0 0; 59 | width: 20px; 60 | height: 20px; 61 | display: inline-block; 62 | } 63 | 64 | .wmd-spacer1 { 65 | left: 50px; 66 | } 67 | .wmd-spacer2 68 | { 69 | left: 175px; 70 | } 71 | .wmd-spacer3 72 | { 73 | left: 300px; 74 | } 75 | 76 | .wmd-prompt-background { 77 | background-color: Black; 78 | } 79 | 80 | .wmd-prompt-dialog { 81 | border: 1px solid #999999; 82 | background-color: #F5F5F5; 83 | } 84 | 85 | .wmd-prompt-dialog > div { 86 | font-size: 0.8em; 87 | font-family: arial, helvetica, sans-serif; 88 | } 89 | 90 | 91 | .wmd-prompt-dialog > form > input[type="text"] { 92 | border: 1px solid #999999; 93 | color: black; 94 | } 95 | 96 | .wmd-prompt-dialog > form > input[type="button"]{ 97 | border: 1px solid #888888; 98 | font-family: trebuchet MS, helvetica, sans-serif; 99 | font-size: 0.8em; 100 | font-weight: bold; 101 | } 102 | 103 | blockquote { 104 | border-left: 2px dotted #888; 105 | padding-left: 5px; 106 | background: #d0f0ff; 107 | } -------------------------------------------------------------------------------- /src/main/webapp/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexP11223/JavaSpringMvcBlog/b4edb967d0da1c62f5367f447220471ff5628e38/src/main/webapp/favicon.ico -------------------------------------------------------------------------------- /src/main/webapp/images/404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexP11223/JavaSpringMvcBlog/b4edb967d0da1c62f5367f447220471ff5628e38/src/main/webapp/images/404.png -------------------------------------------------------------------------------- /src/main/webapp/images/ajax-loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexP11223/JavaSpringMvcBlog/b4edb967d0da1c62f5367f447220471ff5628e38/src/main/webapp/images/ajax-loader.gif -------------------------------------------------------------------------------- /src/main/webapp/images/cat-working.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexP11223/JavaSpringMvcBlog/b4edb967d0da1c62f5367f447220471ff5628e38/src/main/webapp/images/cat-working.png -------------------------------------------------------------------------------- /src/main/webapp/images/no-avatar-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexP11223/JavaSpringMvcBlog/b4edb967d0da1c62f5367f447220471ff5628e38/src/main/webapp/images/no-avatar-big.png -------------------------------------------------------------------------------- /src/main/webapp/images/no-avatar-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexP11223/JavaSpringMvcBlog/b4edb967d0da1c62f5367f447220471ff5628e38/src/main/webapp/images/no-avatar-small.png -------------------------------------------------------------------------------- /src/main/webapp/images/success-tick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexP11223/JavaSpringMvcBlog/b4edb967d0da1c62f5367f447220471ff5628e38/src/main/webapp/images/success-tick.png -------------------------------------------------------------------------------- /src/main/webapp/js/admin.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | var postsContainer = $("#postsContainer"); 3 | 4 | postsContainer.on('click', 'a[data-action="hidePost"]', function(event){ 5 | event.preventDefault(); 6 | 7 | var postTitle = getPostTitle(this); 8 | 9 | var btn = $(this); 10 | 11 | var loadingIndicator = btn.closest('.post').find('.postaction-loading-indicator'); 12 | 13 | var delBtn = btn.closest('.post-actions').find('a[data-action="deletePost"]'); 14 | 15 | bootbox.dialog({ 16 | title: 'Hide post', 17 | message: 'Are you sure you want to hide post ' + postTitle + ' from other users?', 18 | buttons: { 19 | cancel: { 20 | label: 'Cancel' 21 | }, 22 | main: { 23 | label: 'Hide', 24 | className: 'btn-primary', 25 | callback: function() { 26 | loadingIndicator.show(); 27 | 28 | $.ajax({ 29 | type: 'post', 30 | url: btn.attr('data-href'), 31 | success: function (data) { 32 | loadingIndicator.hide(); 33 | 34 | if (data == 'ok') { 35 | btn.attr('data-action', 'unhidePost'); 36 | btn.attr('data-href', btn.attr('data-href').replace('hide', 'unhide')); 37 | btn.html('unhide'); 38 | 39 | btn.closest('.post').find('.post-content').append('
Not visible for users
'); 40 | 41 | delBtn.show(); 42 | } 43 | else { 44 | showErrorDialog('Error: ' + data + '. Try reloading page.'); 45 | } 46 | }, 47 | error: function () { 48 | loadingIndicator.hide(); 49 | 50 | showErrorDialog('Failed to send request. Try reloading page.'); 51 | } 52 | }); 53 | } 54 | } 55 | } 56 | }); 57 | }); 58 | 59 | postsContainer.on('click', 'a[data-action="unhidePost"]', function(event){ 60 | event.preventDefault(); 61 | 62 | var btn = $(this); 63 | 64 | var loadingIndicator = btn.closest('.post').find('.postaction-loading-indicator'); 65 | 66 | var delBtn = btn.closest('.post-actions').find('a[data-action="deletePost"]'); 67 | 68 | loadingIndicator.show(); 69 | 70 | $.ajax({ 71 | type: 'post', 72 | url: btn.attr('data-href'), 73 | success: function (data) { 74 | loadingIndicator.hide(); 75 | 76 | if (data == 'ok') { 77 | btn.attr('data-action', 'hidePost'); 78 | btn.attr('data-href', btn.attr('data-href').replace('unhide', 'hide')); 79 | btn.html('hide'); 80 | 81 | btn.closest('.post').find('.hidden-post').remove(); 82 | 83 | delBtn.hide(); 84 | } 85 | else { 86 | showErrorDialog('Error: ' + data + '. Try reloading page.'); 87 | } 88 | }, 89 | error: function () { 90 | loadingIndicator.hide(); 91 | 92 | showErrorDialog('Failed to send request. Try reloading page.'); 93 | } 94 | }); 95 | }); 96 | 97 | postsContainer.on('click', 'a[data-action="deletePost"]', function(event){ 98 | event.preventDefault(); 99 | 100 | var postTitle = getPostTitle(this); 101 | var postId = getPostId(this); 102 | 103 | var btn = $(this); 104 | 105 | var loadingIndicator = btn.closest('.post').find('.postaction-loading-indicator'); 106 | 107 | bootbox.dialog({ 108 | title: 'Delete post', 109 | message: 'Are you sure you want to delete post ' + postTitle + '? You will not be able to recover it.', 110 | buttons: { 111 | cancel: { 112 | label: 'Cancel' 113 | }, 114 | main: { 115 | label: 'Delete', 116 | className: 'btn-danger', 117 | callback: function() { 118 | loadingIndicator.show(); 119 | 120 | $.ajax({ 121 | type: 'post', 122 | url: btn.attr('data-href'), 123 | success: function (data) { 124 | loadingIndicator.hide(); 125 | 126 | if (data == 'ok') { 127 | btn.closest('.post').remove(); 128 | 129 | if (window.location.href.indexOf('posts/' + postId) > -1) { 130 | window.location.href = window.location.href.replace('/' + postId, ''); 131 | } 132 | } 133 | else { 134 | showErrorDialog('Error: ' + data + '. Try reloading page.'); 135 | } 136 | }, 137 | error: function () { 138 | loadingIndicator.hide(); 139 | 140 | showErrorDialog('Failed to send request. Try reloading page.'); 141 | } 142 | }); 143 | } 144 | } 145 | } 146 | }); 147 | }); 148 | }); 149 | 150 | -------------------------------------------------------------------------------- /src/main/webapp/js/common.js: -------------------------------------------------------------------------------- 1 | function getPostId(el) { 2 | return $(el).closest('.post').attr('data-post-id'); 3 | } 4 | 5 | function getPostTitle(el) { 6 | return $(el).closest('.post').attr('data-post-title'); 7 | } 8 | 9 | function showErrorDialog(text) { 10 | bootbox.dialog({ 11 | message: text, 12 | buttons: { 13 | ok: { 14 | label: 'OK' 15 | } 16 | } 17 | }); 18 | } 19 | 20 | function scrollToViewIfNotVisible(element){ 21 | var offset = element.offset().top; 22 | if(!element.is(":visible")) { 23 | element.css({"visiblity":"hidden"}).show(); 24 | offset = element.offset().top; 25 | element.css({"visiblity":"", "display":""}); 26 | } 27 | 28 | var visible_area_start = $(window).scrollTop(); 29 | var visible_area_end = visible_area_start + window.innerHeight; 30 | 31 | if(offset < visible_area_start || offset > visible_area_end){ 32 | $('html,body').animate({scrollTop: offset - window.innerHeight/3}, 300); 33 | return false; 34 | } 35 | return true; 36 | } 37 | 38 | $(document).ready(function() { 39 | var token = $("meta[name='_csrf']").attr("content"); 40 | var header = $("meta[name='_csrf_header']").attr("content"); 41 | $(document).ajaxSend(function(e, xhr, options) { 42 | xhr.setRequestHeader(header, token); 43 | }); 44 | 45 | 46 | $.ajax({ 47 | dataType: "json", 48 | url: window.postsUrl, 49 | success: function (data) { 50 | var items = []; 51 | $.each(data, function(key, val) { 52 | items.push('
  • ' + val.title + '
  • '); 53 | }); 54 | $("
      ", { 55 | class: 'list-no-indent', 56 | html: items.join('') 57 | }).appendTo('#latestPosts div'); 58 | } 59 | }); 60 | 61 | $.ajax({ 62 | dataType: "json", 63 | url: window.popularPostsUrl, 64 | success: function (data) { 65 | var items = []; 66 | $.each(data, function(key, val) { 67 | items.push('
    • ' + val.title + '
    • '); 68 | }); 69 | $("
        ", { 70 | class: 'list-no-indent', 71 | html: items.join('') 72 | }).appendTo('#popularPosts div'); 73 | } 74 | }); 75 | }); -------------------------------------------------------------------------------- /src/main/webapp/js/editpost.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | var converter = Markdown.getSanitizingConverter(); 3 | 4 | converter.hooks.chain("preConversion", function (text) { 5 | return text.replace(/===cut===/g, ''); 6 | }); 7 | 8 | var editor = new Markdown.Editor(converter); 9 | 10 | editor.run(); 11 | 12 | $("#wmd-button-row").append( 13 | '
      • short/full text separator
      • '); 14 | 15 | jQuery.fn.extend({ 16 | insertAtCaret: function(myValue){ 17 | return this.each(function(i) { 18 | if (document.selection) { 19 | this.focus(); 20 | var sel = document.selection.createRange(); 21 | sel.text = myValue; 22 | this.focus(); 23 | } 24 | else if (this.selectionStart || this.selectionStart == '0') { 25 | var startPos = this.selectionStart; 26 | var endPos = this.selectionEnd; 27 | var scrollTop = this.scrollTop; 28 | this.value = this.value.substring(0, startPos)+myValue+this.value.substring(endPos,this.value.length); 29 | this.focus(); 30 | this.selectionStart = startPos + myValue.length; 31 | this.selectionEnd = startPos + myValue.length; 32 | this.scrollTop = scrollTop; 33 | } else { 34 | this.value += myValue; 35 | this.focus(); 36 | } 37 | }); 38 | } 39 | }); 40 | 41 | $('#insertSeparator').click(function(){ 42 | var $textarea = $('#wmd-input'); 43 | $textarea.insertAtCaret('===cut==='); 44 | }); 45 | 46 | $("#postForm").validate({ 47 | rules: { 48 | title: { 49 | required: true, 50 | minlength: 3 51 | }, 52 | tags: { 53 | required: true 54 | }, 55 | text: { 56 | required: true, 57 | minlength: 50 58 | } 59 | } 60 | }); 61 | 62 | $(window).bind('beforeunload', function(){ 63 | if ($.trim($('#wmd-input').val()) != '') 64 | return 'You have not submitted your post.'; 65 | }); 66 | 67 | $(document).on("submit", "form", function(event){ 68 | $(window).off('beforeunload'); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/main/webapp/js/editprofile.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | var converter = Markdown.getSanitizingConverter(); 3 | 4 | var editor = new Markdown.Editor(converter); 5 | 6 | editor.run(); 7 | 8 | $("#profileForm").validate({ 9 | rules: { 10 | websiteLink: { 11 | url: true 12 | }, 13 | aboutText: { 14 | maxlength: 1000 15 | } 16 | } 17 | }); 18 | 19 | var uploadBtn = $('#uploadAvatar'); 20 | var removeBtn = $('#removeAvatar'); 21 | var pb = $('#avatarUploadProgress').find('.progress-bar'); 22 | var avatarImg = $('#avatarImg'); 23 | var avatarErrorLabel = $('#avatarError'); 24 | var avatarSuccessLabel = $('#avatarSuccess'); 25 | 26 | $('#avatarFileUploadInput').fileupload({ 27 | url: window.avatarUploadUrl, 28 | dataType: "json", 29 | send: function (e, data) { 30 | pb.css('width', '0'); 31 | pb.switchClass('progress-bar-danger', 'progress-bar-success', 0); 32 | pb.parent().show(); 33 | 34 | avatarErrorLabel.hide(); 35 | avatarSuccessLabel.hide(); 36 | 37 | uploadBtn.addClass('disabled'); 38 | removeBtn.addClass('disabled'); 39 | }, 40 | done: function (e, data) { 41 | if (data.result.status == 'ok') { 42 | avatarImg.attr('src', window.imgBaseUrl + data.result.link); 43 | 44 | removeBtn.show(); 45 | 46 | avatarSuccessLabel.show(); 47 | } 48 | else { 49 | pb.switchClass('progress-bar-success', 'progress-bar-danger'); 50 | 51 | var errMsg = 'Failed to upload, ' + data.result.status; 52 | 53 | if (data.result.status == 'invalid_format') { 54 | errMsg = 'Only JPG and PNG allowed.'; 55 | } 56 | 57 | avatarErrorLabel.text(errMsg); 58 | avatarErrorLabel.show(); 59 | } 60 | }, 61 | fail: function (e, data) { 62 | pb.switchClass('progress-bar-success', 'progress-bar-danger'); 63 | 64 | avatarErrorLabel.text('Failed to upload picture. Check that it is PNG or JPG and not exceeds 1 MB'); 65 | avatarErrorLabel.show(); 66 | }, 67 | always: function (e, data) { 68 | uploadBtn.removeClass('disabled'); 69 | removeBtn.removeClass('disabled'); 70 | }, 71 | progressall: function (e, data) { 72 | var progress = parseInt(data.loaded / data.total * 100, 10); 73 | pb.css('width', progress + '%'); 74 | } 75 | }).prop('disabled', !$.support.fileInput) 76 | .parent().addClass($.support.fileInput ? undefined : 'disabled'); 77 | 78 | removeBtn.click(function() { 79 | pb.parent().hide(); 80 | 81 | avatarErrorLabel.hide(); 82 | avatarSuccessLabel.hide(); 83 | 84 | uploadBtn.addClass('disabled'); 85 | removeBtn.addClass('disabled'); 86 | 87 | var loadingIndicator = $('.loading-indicator'); 88 | loadingIndicator.show(); 89 | 90 | $.ajax({ 91 | type: 'post', 92 | url: removeBtn.attr('data-href'), 93 | success: function (data) { 94 | if (data == 'ok') { 95 | avatarImg.attr('src', window.noAvatarImgUrl); 96 | 97 | removeBtn.hide(); 98 | } 99 | else { 100 | avatarErrorLabel.text('Error: ' + data + '. Try reloading page.'); 101 | } 102 | }, 103 | error: function () { 104 | avatarErrorLabel.text('Failed to send request. Try reloading page.'); 105 | }, 106 | complete: function() { 107 | loadingIndicator.hide(); 108 | 109 | uploadBtn.removeClass('disabled'); 110 | removeBtn.removeClass('disabled'); 111 | } 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/main/webapp/js/external/markdown/Markdown.Sanitizer.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | var output, Converter; 3 | if (typeof exports === "object" && typeof require === "function") { // we're in a CommonJS (e.g. Node.js) module 4 | output = exports; 5 | Converter = require("./Markdown.Converter").Converter; 6 | } else { 7 | output = window.Markdown; 8 | Converter = output.Converter; 9 | } 10 | 11 | output.getSanitizingConverter = function () { 12 | var converter = new Converter(); 13 | converter.hooks.chain("postConversion", sanitizeHtml); 14 | converter.hooks.chain("postConversion", balanceTags); 15 | return converter; 16 | } 17 | 18 | function sanitizeHtml(html) { 19 | return html.replace(/<[^>]*>?/gi, sanitizeTag); 20 | } 21 | 22 | // (tags that can be opened/closed) | (tags that stand alone) 23 | var basic_tag_whitelist = /^(<\/?(b|blockquote|code|del|dd|dl|dt|em|h1|h2|h3|i|kbd|li|ol(?: start="\d+")?|p|pre|s|sup|sub|strong|strike|ul)>|<(br|hr)\s?\/?>)$/i; 24 | // | 25 | var a_white = /^(]+")?\s?>|<\/a>)$/i; 26 | 27 | // ]*")?(\stitle="[^"<>]*")?\s?\/?>)$/i; 29 | 30 | function sanitizeTag(tag) { 31 | if (tag.match(basic_tag_whitelist) || tag.match(a_white) || tag.match(img_white)) 32 | return tag; 33 | else 34 | return ""; 35 | } 36 | 37 | /// 38 | /// attempt to balance HTML tags in the html string 39 | /// by removing any unmatched opening or closing tags 40 | /// IMPORTANT: we *assume* HTML has *already* been 41 | /// sanitized and is safe/sane before balancing! 42 | /// 43 | /// adapted from CODESNIPPET: A8591DBA-D1D3-11DE-947C-BA5556D89593 44 | /// 45 | function balanceTags(html) { 46 | 47 | if (html == "") 48 | return ""; 49 | 50 | var re = /<\/?\w+[^>]*(\s|$|>)/g; 51 | // convert everything to lower case; this makes 52 | // our case insensitive comparisons easier 53 | var tags = html.toLowerCase().match(re); 54 | 55 | // no HTML tags present? nothing to do; exit now 56 | var tagcount = (tags || []).length; 57 | if (tagcount == 0) 58 | return html; 59 | 60 | var tagname, tag; 61 | var ignoredtags = "



      • "; 62 | var match; 63 | var tagpaired = []; 64 | var tagremove = []; 65 | var needsRemoval = false; 66 | 67 | // loop through matched tags in forward order 68 | for (var ctag = 0; ctag < tagcount; ctag++) { 69 | tagname = tags[ctag].replace(/<\/?(\w+).*/, "$1"); 70 | // skip any already paired tags 71 | // and skip tags in our ignore list; assume they're self-closed 72 | if (tagpaired[ctag] || ignoredtags.search("<" + tagname + ">") > -1) 73 | continue; 74 | 75 | tag = tags[ctag]; 76 | match = -1; 77 | 78 | if (!/^<\//.test(tag)) { 79 | // this is an opening tag 80 | // search forwards (next tags), look for closing tags 81 | for (var ntag = ctag + 1; ntag < tagcount; ntag++) { 82 | if (!tagpaired[ntag] && tags[ntag] == "") { 83 | match = ntag; 84 | break; 85 | } 86 | } 87 | } 88 | 89 | if (match == -1) 90 | needsRemoval = tagremove[ctag] = true; // mark for removal 91 | else 92 | tagpaired[match] = true; // mark paired 93 | } 94 | 95 | if (!needsRemoval) 96 | return html; 97 | 98 | // delete all orphaned tags from the string 99 | 100 | var ctag = 0; 101 | html = html.replace(re, function (match) { 102 | var res = tagremove[ctag] ? "" : match; 103 | ctag++; 104 | return res; 105 | }); 106 | return html; 107 | } 108 | })(); 109 | -------------------------------------------------------------------------------- /src/main/webapp/js/login.js: -------------------------------------------------------------------------------- 1 | function setupLoginForm(id) { 2 | $(document).ready(function() { 3 | var $toggler = $('#' + 'loginToggler' + id); 4 | var $form = $('#' + 'loginForm' + id); 5 | var $loginErrorLabel = $('#' + 'loginError' + id); 6 | var $loginBtn = $('#' + 'loginbtn' + id); 7 | var $loadingIndicator = $('#' + 'loadingIndicator' + id); 8 | 9 | $form.validate({ 10 | rules: { 11 | username: { 12 | required: true 13 | }, 14 | password: { 15 | required: true 16 | } 17 | }, 18 | messages: { 19 | username: { 20 | required: "Enter username or e-mail" 21 | }, 22 | password: { 23 | required: "Enter password" 24 | } 25 | } 26 | }); 27 | 28 | // show/hide login form 29 | $toggler.click(function(event){ 30 | event.preventDefault(); 31 | 32 | $form.slideToggle(300, function() { 33 | if ($form.is(":visible")) 34 | $('#username' + id).focus(); 35 | }); 36 | }); 37 | 38 | // ajax submit login form 39 | $form.on('submit', function(e) { 40 | e.preventDefault(); 41 | var data = $form.serializeArray(); 42 | 43 | $loginErrorLabel.hide(); 44 | 45 | if ($form.valid()) { 46 | $loginBtn.prop('disabled', true); 47 | $loadingIndicator.show(); 48 | 49 | $.ajax({ 50 | type: 'post', 51 | url: $form.attr('action'), 52 | data: data, 53 | success: function (data) { 54 | if (data == 'ok') { 55 | // reload page 56 | window.location.reload(true); 57 | } 58 | else { 59 | $loginBtn.prop('disabled', false); 60 | $loadingIndicator.hide(); 61 | $loginErrorLabel.text('Failed to log in. Check username/e-mail and password.'); 62 | $loginErrorLabel.show(); 63 | } 64 | }, 65 | error: function () { 66 | $loginBtn.prop('disabled', true); 67 | $loadingIndicator.hide(); 68 | $loginErrorLabel.text('Failed to send request.'); 69 | $loginErrorLabel.show(); 70 | } 71 | }); 72 | } 73 | }); 74 | }); 75 | } -------------------------------------------------------------------------------- /src/main/webapp/js/registration.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | $(document).ready(function() { 4 | $.validator.methods._remote = $.validator.methods.remote; 5 | var timer = 0; 6 | $.validator.methods.remote = function () { 7 | clearTimeout(timer); 8 | 9 | var args = arguments; 10 | 11 | timer = setTimeout(function() { 12 | $.validator.methods._remote.apply(this, args); 13 | }.bind(this), 500); 14 | 15 | return "pending"; 16 | }; 17 | 18 | $.validator.addMethod("username", function(value, element) { 19 | return this.optional( element ) || XRegExp("^[\\p{L}0-9\\._\\- ]+$").test( value ); 20 | }, 'Only letters, numbers, space, ".", "-" and "_" are allowed.'); 21 | 22 | $("#regForm").validate({ 23 | rules: { 24 | username: { 25 | required: true, 26 | minlength: 3, 27 | username: true, 28 | remote: { 29 | url: window.usernameCheckUrl, 30 | type: "get", 31 | data: { 32 | username: function () { 33 | return $("#username").val(); 34 | } 35 | } 36 | } 37 | }, 38 | email: { 39 | required: true, 40 | remote: { 41 | url: window.emailCheckUrl, 42 | type: "get", 43 | data: { 44 | email: function () { 45 | return $("#email").val(); 46 | } 47 | } 48 | } 49 | }, 50 | password: { 51 | required: true, 52 | minlength: 6 53 | }, 54 | password2: { 55 | required: true, 56 | equalTo: "#password" 57 | } 58 | }, 59 | messages: { 60 | username: { 61 | required: "Enter username", 62 | minlength: "Too short username", 63 | remote: function (params) { 64 | return "Username " + params + " already registered. Have you already registered? Contact admin if you forgot your password"; 65 | } 66 | }, 67 | email: { 68 | required: "Enter e-mail", 69 | remote: function (params) { 70 | return "Email " + params + " already registered. Have you already registered? Contact admin if you forgot your password"; 71 | } 72 | }, 73 | password: { 74 | required: "Enter password", 75 | minlength: "Password too short" 76 | }, 77 | password2: { 78 | required: "Enter password", 79 | equalTo: "Passwords do not match" 80 | } 81 | } 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/main/webapp/js/settings.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | $("#emailForm").validate({ 3 | rules: { 4 | email: { 5 | required: true, 6 | email: true 7 | }, 8 | currentPassword: { 9 | required: true 10 | } 11 | }, 12 | messages: { 13 | email: { 14 | required: "Enter e-mail" 15 | }, 16 | currentPassword: { 17 | required: "Enter password" 18 | } 19 | } 20 | }); 21 | 22 | $("#passwordForm").validate({ 23 | rules: { 24 | currentPassword: { 25 | required: true 26 | }, 27 | password: { 28 | required: true, 29 | minlength: 6 30 | }, 31 | password2: { 32 | required: true, 33 | equalTo: "#password" 34 | } 35 | }, 36 | messages: { 37 | password: { 38 | required: "Enter password", 39 | minlength: "Password too short" 40 | }, 41 | password2: { 42 | required: "Enter password", 43 | equalTo: "Passwords do not match" 44 | }, 45 | currentPassword: { 46 | required: "Enter password" 47 | } 48 | } 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/main/webapp/js/voting.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | var container = $("#pageContent"); 3 | 4 | container.on('click', 'a[data-action="vote"]', function(event){ 5 | event.preventDefault(); 6 | 7 | var btn = $(this); 8 | 9 | btn.addClass('voted'); 10 | 11 | var voteType = btn.attr('data-vote-type'); 12 | 13 | $.ajax({ 14 | type: 'post', 15 | url: btn.attr('data-href'), 16 | success: function (data) { 17 | if (data == 'ok') { 18 | btn.parent().find('a[data-action="vote"]').attr('data-action', ''); 19 | 20 | var valElement = btn.siblings('.rating-value'); 21 | 22 | var val = parseInt(valElement.html()); 23 | 24 | if (voteType == 'like') 25 | val++; 26 | else 27 | val--; 28 | 29 | valElement.html(val); 30 | 31 | if (val == 0 || val == 1 || val == -1) { 32 | valElement.removeClass('rating-value-no rating-value-negative rating-value-positive'); 33 | valElement.addClass(val == 0 ? 'rating-value-no' : (val == -1 ? 'rating-value-negative' : 'rating-value-positive')); 34 | } 35 | } 36 | else { 37 | btn.removeClass('voted'); 38 | 39 | if (data == 'own_comment') { 40 | showErrorDialog('Cannot vote for your own comments!'); 41 | } 42 | else { 43 | showErrorDialog('Voting failed. Try reloading page.'); 44 | } 45 | } 46 | }, 47 | error: function () { 48 | btn.removeClass('voted'); 49 | showErrorDialog('Voting failed. Try reloading page.'); 50 | } 51 | }); 52 | }); 53 | }); 54 | 55 | -------------------------------------------------------------------------------- /src/main/webapp/js/wmd-buttons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexP11223/JavaSpringMvcBlog/b4edb967d0da1c62f5367f447220471ff5628e38/src/main/webapp/js/wmd-buttons.png -------------------------------------------------------------------------------- /src/test/java/alexp/blog/AbstractIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package alexp.blog; 2 | 3 | import com.github.springtestdbunit.TransactionDbUnitTestExecutionListener; 4 | import org.junit.Before; 5 | import org.junit.runner.RunWith; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.security.web.FilterChainProxy; 8 | import org.springframework.test.context.ActiveProfiles; 9 | import org.springframework.test.context.ContextConfiguration; 10 | import org.springframework.test.context.TestExecutionListeners; 11 | import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; 12 | import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; 13 | import org.springframework.test.context.support.DirtiesContextTestExecutionListener; 14 | import org.springframework.test.context.web.WebAppConfiguration; 15 | import org.springframework.test.web.servlet.MockMvc; 16 | import org.springframework.web.context.WebApplicationContext; 17 | import javax.transaction.Transactional; 18 | 19 | import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; 20 | 21 | @RunWith(SpringJUnit4ClassRunner.class) 22 | @WebAppConfiguration 23 | @ContextConfiguration({ "file:src/main/webapp/WEB-INF/mvc-dispatcher-servlet.xml", 24 | "classpath:/database.xml", 25 | "file:src/main/webapp/WEB-INF/spring-security.xml" }) 26 | @ActiveProfiles("test") 27 | @TestExecutionListeners({ DependencyInjectionTestExecutionListener.class, 28 | DirtiesContextTestExecutionListener.class, 29 | TransactionDbUnitTestExecutionListener.class}) 30 | @Transactional 31 | public abstract class AbstractIntegrationTest { 32 | 33 | @SuppressWarnings("SpringJavaAutowiringInspection") 34 | @Autowired 35 | protected FilterChainProxy springSecurityFilterChain; 36 | 37 | protected MockMvc mockMvc; 38 | 39 | @SuppressWarnings("SpringJavaAutowiringInspection") 40 | @Autowired 41 | protected WebApplicationContext wac; 42 | 43 | @Before 44 | public void setup() { 45 | this.mockMvc = webAppContextSetup(this.wac).addFilter(springSecurityFilterChain).build(); 46 | 47 | setupTest(); 48 | } 49 | 50 | // child classes should override this instead of using @Before (order of @Before is not guaranteed if names are not unique) 51 | protected void setupTest() 52 | { 53 | 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/test/java/alexp/blog/controller/LoginControllerIT.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.controller; 2 | 3 | import alexp.blog.AbstractIntegrationTest; 4 | import com.github.springtestdbunit.annotation.DatabaseSetup; 5 | import com.github.springtestdbunit.annotation.ExpectedDatabase; 6 | import org.junit.Test; 7 | import org.springframework.http.MediaType; 8 | 9 | import static alexp.blog.utils.SecurityUtils.*; 10 | import static org.hamcrest.Matchers.*; 11 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 12 | import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.*; 13 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 14 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 15 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 16 | 17 | @DatabaseSetup("data.xml") 18 | public class LoginControllerIT extends AbstractIntegrationTest { 19 | 20 | @Test 21 | @ExpectedDatabase("data.xml") 22 | public void shouldNotAuthenticateWithInvalidCredentials() throws Exception { 23 | mockMvc.perform(post("/login_check").with(csrf()) 24 | .contentType(MediaType.APPLICATION_FORM_URLENCODED) 25 | .param("username", "Bob") 26 | .param("password", "invalid_password")) 27 | .andExpect(status().isFound()) 28 | .andExpect(unauthenticated()) 29 | .andExpect(redirectedUrl("/login_error")); 30 | } 31 | 32 | @Test 33 | @ExpectedDatabase("data.xml") 34 | public void shouldAuthenticateByUsername() throws Exception { 35 | mockMvc.perform(post("/login_check").with(csrf()) 36 | .contentType(MediaType.APPLICATION_FORM_URLENCODED) 37 | .param("username", "Bob") 38 | .param("password", "pass123")) 39 | .andExpect(status().isFound()) 40 | .andExpect(authenticated().withUsername("Bob")) 41 | .andExpect(redirectedUrl("/login_success")); 42 | } 43 | 44 | @Test 45 | @ExpectedDatabase("data.xml") 46 | public void shouldAuthenticateByEmail() throws Exception { 47 | mockMvc.perform(post("/login_check").with(csrf()) 48 | .contentType(MediaType.APPLICATION_FORM_URLENCODED) 49 | .param("username", "bob@gmail.com") 50 | .param("password", "pass123")) 51 | .andExpect(status().isFound()) 52 | .andExpect(authenticated().withUsername("Bob")) 53 | .andExpect(redirectedUrl("/login_success")); 54 | } 55 | 56 | @Test 57 | @ExpectedDatabase("data.xml") 58 | public void shouldLogout() throws Exception { 59 | mockMvc.perform(post("/dologout").with(csrf()).with(userBob())) 60 | .andExpect(status().isFound()) 61 | .andExpect(unauthenticated()); 62 | } 63 | 64 | } -------------------------------------------------------------------------------- /src/test/java/alexp/blog/controller/UserAvatarUploadIT.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.controller; 2 | 3 | import alexp.blog.AbstractIntegrationTest; 4 | import alexp.blog.service.AvatarServiceImpl; 5 | import alexp.blog.service.FileNameGenerator; 6 | import com.github.springtestdbunit.annotation.DatabaseSetup; 7 | import com.github.springtestdbunit.annotation.ExpectedDatabase; 8 | import org.apache.commons.io.FileUtils; 9 | import org.aspectj.lang.annotation.After; 10 | import org.junit.Test; 11 | import org.mockito.*; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.beans.factory.annotation.Value; 14 | import org.springframework.http.MediaType; 15 | import org.springframework.mock.web.MockMultipartFile; 16 | 17 | import java.io.File; 18 | import java.io.IOException; 19 | 20 | import static alexp.blog.utils.SecurityUtils.userBob; 21 | import static org.hamcrest.MatcherAssert.assertThat; 22 | import static org.mockito.Mockito.when; 23 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 24 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.fileUpload; 25 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 26 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; 27 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 28 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 29 | import static org.hamcrest.Matchers.*; 30 | 31 | @DatabaseSetup("data.xml") 32 | public class UserAvatarUploadIT extends AbstractIntegrationTest { 33 | 34 | @Autowired 35 | private AvatarServiceImpl avatarService; 36 | 37 | @Value("${uploading.dirpath}") 38 | private String uploadingDirPath; 39 | 40 | @Override 41 | protected void setupTest() { 42 | FileNameGenerator fileNameGenerator = Mockito.mock(FileNameGenerator.class); 43 | avatarService.setFileNameGenerator(fileNameGenerator); 44 | 45 | when(fileNameGenerator.getFileName(Matchers.anyString(), Matchers.anyString())).thenReturn("generatedName"); 46 | } 47 | 48 | @org.junit.After 49 | public void teardown() throws IOException { 50 | FileUtils.deleteDirectory(new File(uploadingDirPath)); 51 | } 52 | 53 | @Test 54 | @ExpectedDatabase("data-avatar-added.xml") 55 | public void shouldUploadAvatar() throws Exception { 56 | MockMultipartFile img = new MockMultipartFile("avatarFile", "img.jpg", "image/jpeg", this.getClass().getResourceAsStream("img.jpg")); 57 | 58 | mockMvc.perform(fileUpload("/upload_avatar").file(img).with(userBob()).with(csrf().asHeader())) 59 | .andExpect(status().isOk()) 60 | .andExpect(jsonPath("$.status", is("ok"))) 61 | .andExpect(jsonPath("$.link", is("generatedName_big.jpg"))); 62 | 63 | assertThat(new File(uploadingDirPath + "generatedName_big.jpg").exists(), is(equalTo(true))); 64 | assertThat(new File(uploadingDirPath + "generatedName_small.jpg").exists(), is(equalTo(true))); 65 | } 66 | 67 | @Test 68 | @ExpectedDatabase("data.xml") 69 | public void shouldNotUploadNotImage() throws Exception { 70 | MockMultipartFile img = new MockMultipartFile("avatarFile", "notimg.exe", "image/jpeg", "something".getBytes()); 71 | 72 | mockMvc.perform(fileUpload("/upload_avatar").file(img).with(userBob()).with(csrf().asHeader())) 73 | .andExpect(status().isOk()) 74 | .andExpect(jsonPath("$.status", is("invalid_format"))); 75 | 76 | assertThat(new File(uploadingDirPath + "generatedName_big.jpg").exists(), is(equalTo(false))); 77 | assertThat(new File(uploadingDirPath + "generatedName_small.jpg").exists(), is(equalTo(false))); 78 | } 79 | 80 | @Test 81 | @ExpectedDatabase("data.xml") 82 | @DatabaseSetup("data-avatar-added.xml") 83 | public void shouldRemoveAvatar() throws Exception { 84 | mockMvc.perform(post("/remove_avatar").with(userBob()).with(csrf().asHeader())) 85 | .andExpect(status().isOk()) 86 | .andExpect(content().string("ok")); 87 | } 88 | 89 | } -------------------------------------------------------------------------------- /src/test/java/alexp/blog/model/CommentTest.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.model; 2 | 3 | import org.junit.Before; 4 | import org.junit.Test; 5 | 6 | import static org.hamcrest.MatcherAssert.assertThat; 7 | import static org.hamcrest.Matchers.equalTo; 8 | import static org.junit.Assert.*; 9 | 10 | public class CommentTest { 11 | 12 | private Comment comment; 13 | 14 | @Before 15 | public void setUp() { 16 | comment = new Comment(); 17 | } 18 | 19 | @Test 20 | public void commentLevel() { 21 | assertThat(comment.commentLevel(), equalTo(0)); 22 | 23 | Comment replyComment = new Comment(); 24 | replyComment.setParentComment(comment); 25 | 26 | assertThat(replyComment.commentLevel(), equalTo(1)); 27 | 28 | Comment replyComment2 = new Comment(); 29 | replyComment2.setParentComment(replyComment); 30 | 31 | assertThat(replyComment2.commentLevel(), equalTo(2)); 32 | } 33 | 34 | @Test 35 | public void testRatingSum() { 36 | assertThat(comment.getRatingSum(), equalTo(0)); 37 | 38 | comment.getCommentRatings().add(new CommentRating(null, Rating.LIKE_VALUE, null)); 39 | 40 | assertThat(comment.getRatingSum(), equalTo(1)); 41 | 42 | comment.getCommentRatings().add(new CommentRating(null, Rating.DISLIKE_VALUE, null)); 43 | comment.getCommentRatings().add(new CommentRating(null, Rating.DISLIKE_VALUE, null)); 44 | 45 | assertThat(comment.getRatingSum(), equalTo(-1)); 46 | } 47 | 48 | @Test 49 | public void testUserVoteValue() { 50 | assertThat(comment.getUserVoteValue(null), equalTo((short) 0)); 51 | assertThat(comment.getUserVoteValue(1L), equalTo((short) 0)); 52 | 53 | User user = new User(); 54 | user.setId(1L); 55 | 56 | comment.getCommentRatings().add(new CommentRating(user, Rating.LIKE_VALUE, null)); 57 | 58 | assertThat(comment.getUserVoteValue(user.getId()), equalTo(Rating.LIKE_VALUE)); 59 | } 60 | } -------------------------------------------------------------------------------- /src/test/java/alexp/blog/model/PostTest.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.model; 2 | 3 | import org.junit.Before; 4 | import org.junit.Test; 5 | 6 | import java.lang.reflect.Array; 7 | import java.util.Arrays; 8 | import java.util.List; 9 | 10 | import static org.hamcrest.MatcherAssert.assertThat; 11 | import static org.hamcrest.Matchers.equalTo; 12 | import static org.hamcrest.Matchers.*; 13 | 14 | public class PostTest { 15 | 16 | private Post post; 17 | 18 | @Before 19 | public void setUp() { 20 | post = new Post(); 21 | } 22 | 23 | @Test 24 | public void shouldGetTopLevelComments() { 25 | Comment comment = new Comment(); 26 | Comment replyComment = new Comment(); 27 | replyComment.setParentComment(comment); 28 | comment.setChildrenComments(Arrays.asList(new Comment(), comment)); 29 | 30 | post.setComments(Arrays.asList(new Comment(), comment)); 31 | 32 | List comments = post.topLevelComments(); 33 | 34 | assertThat(comments.size(), equalTo(2)); 35 | 36 | assertThat(comments, not(hasItem(replyComment))); 37 | } 38 | 39 | @Test 40 | public void shouldReturnEmptyList() { 41 | List comments = post.topLevelComments(); 42 | 43 | assertThat(comments.size(), equalTo(0)); 44 | } 45 | 46 | @Test 47 | public void testRatingSum() { 48 | assertThat(post.getRatingSum(), equalTo(0)); 49 | 50 | post.getPostRatings().add(new PostRating(null, Rating.LIKE_VALUE, null)); 51 | 52 | assertThat(post.getRatingSum(), equalTo(1)); 53 | 54 | post.getPostRatings().add(new PostRating(null, Rating.DISLIKE_VALUE, null)); 55 | post.getPostRatings().add(new PostRating(null, Rating.DISLIKE_VALUE, null)); 56 | 57 | assertThat(post.getRatingSum(), equalTo(-1)); 58 | } 59 | 60 | @Test 61 | public void testUserVoteValue() { 62 | assertThat(post.getUserVoteValue(null), equalTo((short) 0)); 63 | assertThat(post.getUserVoteValue(1L), equalTo((short) 0)); 64 | 65 | User user = new User(); 66 | user.setId(1L); 67 | 68 | post.getPostRatings().add(new PostRating(user, Rating.LIKE_VALUE, null)); 69 | 70 | assertThat(post.getUserVoteValue(user.getId()), equalTo(Rating.LIKE_VALUE)); 71 | } 72 | } -------------------------------------------------------------------------------- /src/test/java/alexp/blog/model/UserTest.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.model; 2 | 3 | import org.junit.Test; 4 | 5 | import static org.hamcrest.CoreMatchers.*; 6 | import static org.hamcrest.MatcherAssert.assertThat; 7 | 8 | public class UserTest { 9 | 10 | @Test 11 | public void testHasRole() throws Exception { 12 | Role role = new Role(); 13 | role.setName("ROLE_ADMIN"); 14 | 15 | User user = new User(); 16 | 17 | assertThat(user.hasRole("ROLE_ADMIN"), is(equalTo(false))); 18 | 19 | user.getRoles().add(role); 20 | 21 | assertThat(user.hasRole("ROLE_ADMIN"), is(equalTo(true))); 22 | assertThat(user.hasRole("admin"), is(equalTo(true))); 23 | } 24 | } -------------------------------------------------------------------------------- /src/test/java/alexp/blog/service/CommentServiceTest.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | import alexp.blog.controller.ForbiddenException; 4 | import alexp.blog.model.Comment; 5 | import alexp.blog.model.CommentRating; 6 | import alexp.blog.model.Post; 7 | import alexp.blog.model.User; 8 | import alexp.blog.repository.CommentRatingRepository; 9 | import alexp.blog.repository.CommentRepository; 10 | import org.junit.Before; 11 | import org.junit.Test; 12 | import org.mockito.*; 13 | 14 | import java.time.LocalDate; 15 | 16 | import static org.hamcrest.MatcherAssert.assertThat; 17 | import static org.hamcrest.Matchers.*; 18 | import static org.mockito.Mockito.times; 19 | import static org.mockito.Mockito.verify; 20 | import static org.mockito.Mockito.when; 21 | 22 | 23 | public class CommentServiceTest { 24 | 25 | @Mock 26 | private UserService userService; 27 | 28 | @Mock 29 | private CommentRepository commentRepository; 30 | 31 | @Mock 32 | private CommentRatingRepository commentRatingRepository; 33 | 34 | @InjectMocks 35 | private CommentServiceImpl commentService; 36 | 37 | @Spy 38 | private Post post; 39 | 40 | 41 | @Before 42 | public void setUp() throws Exception { 43 | MockitoAnnotations.initMocks(this); 44 | } 45 | 46 | @Test 47 | public void shouldAddNewComment() { 48 | User user = new User(); 49 | 50 | when(userService.currentUser()).thenReturn(user); 51 | 52 | Comment comment = new Comment(); 53 | 54 | commentService.saveNewComment(comment, post, null); 55 | 56 | assertThat(comment.getPost(), is(equalTo(post))); 57 | 58 | assertThat(comment.getUser(), is(equalTo(user))); 59 | 60 | assertThat(comment.getDateTime().toLocalDate().equals(LocalDate.now()), is(equalTo(true))); 61 | 62 | verify(commentRepository, times(1)).saveAndFlush(Matchers.any(Comment.class)); 63 | } 64 | 65 | @Test 66 | public void shouldGetComment() { 67 | final long commentId = 1L; 68 | 69 | Comment comment = new Comment(); 70 | 71 | when(commentRepository.findOne(commentId)).thenReturn(comment); 72 | 73 | Comment retrievedComment = commentService.getComment(commentId); 74 | 75 | assertThat(retrievedComment, is(equalTo(comment))); 76 | 77 | verify(commentRepository, times(1)).findOne(commentId); 78 | } 79 | 80 | @Test 81 | public void shouldReturnNullWhenCommentDoesNotExist() { 82 | when(commentRepository.findOne(Matchers.anyLong())).thenReturn(null); 83 | 84 | assertThat(commentService.getComment(1L), is(equalTo(null))); 85 | 86 | verify(commentRepository, times(1)).findOne(Matchers.anyLong()); 87 | } 88 | 89 | @Test 90 | public void shouldVote() throws Exception { 91 | Long commentId = 1L; 92 | 93 | User user = new User(); 94 | user.setId(10L); 95 | 96 | User anotherUser = new User(); 97 | anotherUser.setId(8L); 98 | 99 | Comment comment = new Comment(); 100 | comment.setId(commentId); 101 | comment.setUser(anotherUser); 102 | 103 | when(commentRepository.findOne(commentId)).thenReturn(comment); 104 | 105 | when(userService.currentUser()).thenReturn(user); 106 | 107 | when(commentRatingRepository.findUserRating(commentId, user.getId())).thenReturn(null); 108 | 109 | commentService.vote(commentId, true); 110 | 111 | verify(commentRatingRepository, times(1)).findUserRating(commentId, user.getId()); 112 | verify(commentRatingRepository, times(1)).saveAndFlush(Matchers.any()); 113 | } 114 | 115 | @Test(expected = AlreadyVotedException.class) 116 | public void shouldThrowExceptionWhenAlreadyVoted() throws Exception { 117 | Long commentId = 1L; 118 | 119 | User user = new User(); 120 | user.setId(10L); 121 | 122 | User anotherUser = new User(); 123 | anotherUser.setId(8L); 124 | 125 | Comment comment = new Comment(); 126 | comment.setId(commentId); 127 | comment.setUser(anotherUser); 128 | 129 | when(commentRepository.findOne(commentId)).thenReturn(comment); 130 | 131 | when(userService.currentUser()).thenReturn(user); 132 | 133 | when(commentRatingRepository.findUserRating(commentId, user.getId())).thenReturn(new CommentRating()); 134 | 135 | commentService.vote(commentId, true); 136 | } 137 | 138 | @Test(expected = ForbiddenException.class) 139 | public void shouldThrowExceptionWhenVoteForOwnComment() throws Exception { 140 | Long commentId = 1L; 141 | 142 | User user = new User(); 143 | user.setId(10L); 144 | 145 | Comment comment = new Comment(); 146 | comment.setId(commentId); 147 | comment.setUser(user); 148 | 149 | when(commentRepository.findOne(commentId)).thenReturn(comment); 150 | 151 | when(userService.currentUser()).thenReturn(user); 152 | 153 | when(commentRatingRepository.findUserRating(commentId, user.getId())).thenReturn(new CommentRating()); 154 | 155 | commentService.vote(commentId, true); 156 | } 157 | } -------------------------------------------------------------------------------- /src/test/java/alexp/blog/service/MarkdownConverterTest.java: -------------------------------------------------------------------------------- 1 | package alexp.blog.service; 2 | 3 | import org.junit.Test; 4 | 5 | import java.util.List; 6 | 7 | import static org.hamcrest.CoreMatchers.*; 8 | import static org.hamcrest.MatcherAssert.assertThat; 9 | 10 | public class MarkdownConverterTest { 11 | 12 | @Test 13 | public void shouldReturnHtmlAndEscapeScript() { 14 | String html = MarkdownConverter.toHtml("hello **world**! "); 15 | 16 | assertThat(html, containsString("")); 17 | assertThat(html, not(containsString("