From 3fffe798cd91ff7f37700ef54e4881db5b0aaf3a Mon Sep 17 00:00:00 2001 From: _Bastler Date: Sun, 3 Oct 2021 17:17:00 +0200 Subject: [PATCH] initial commit --- .gitignore | 8 + pom.xml | 183 +++++++++++++++ src/main/java/de/bstly/board/Application.java | 25 ++ src/main/java/de/bstly/board/JPAConfig.java | 31 +++ .../de/bstly/board/MessageSourceConfig.java | 39 +++ .../board/businesslogic/CommentManager.java | 185 +++++++++++++++ .../board/businesslogic/EntryManager.java | 158 +++++++++++++ .../board/businesslogic/InstantHelper.java | 83 +++++++ .../board/businesslogic/UserManager.java | 217 +++++++++++++++++ .../board/businesslogic/VoteManager.java | 69 ++++++ .../businesslogic/support/InstantHelper.java | 83 +++++++ .../controller/AuthenticationController.java | 113 +++++++++ .../board/controller/BaseController.java | 48 ++++ .../board/controller/CommentController.java | 132 +++++++++++ .../board/controller/DebugController.java | 202 ++++++++++++++++ .../board/controller/EntryController.java | 152 ++++++++++++ .../board/controller/UserController.java | 79 +++++++ .../board/controller/VoteController.java | 143 +++++++++++ .../support/ControllerExceptionHandler.java | 35 +++ .../EntityResponseStatusException.java | 87 +++++++ .../controller/support/RequestBodyErrors.java | 49 ++++ .../validation/CommentValidator.java | 61 +++++ .../controller/validation/EntryValidator.java | 57 +++++ .../de/bstly/board/events/VotedEvent.java | 28 +++ .../board/i18n/businesslogic/I18nManager.java | 176 ++++++++++++++ .../board/i18n/controller/I18nController.java | 108 +++++++++ .../java/de/bstly/board/i18n/model/I18n.java | 64 +++++ .../board/i18n/repository/I18nRepository.java | 21 ++ .../java/de/bstly/board/model/Comment.java | 152 ++++++++++++ src/main/java/de/bstly/board/model/Entry.java | 222 ++++++++++++++++++ .../de/bstly/board/model/EntryStatus.java | 14 ++ .../java/de/bstly/board/model/EntryType.java | 14 ++ .../java/de/bstly/board/model/LocalUser.java | 206 ++++++++++++++++ .../de/bstly/board/model/PersistentLogin.java | 87 +++++++ .../de/bstly/board/model/RankedEntry.java | 31 +++ src/main/java/de/bstly/board/model/Types.java | 13 + src/main/java/de/bstly/board/model/Vote.java | 108 +++++++++ .../java/de/bstly/board/model/VoteType.java | 12 + .../board/repository/CommentRepository.java | 45 ++++ .../board/repository/EntryRepository.java | 58 +++++ .../board/repository/LocalUserRepository.java | 21 ++ .../board/repository/VoteRepository.java | 20 ++ .../OAuth2AuthenticationSuccessHandler.java | 50 ++++ .../bstly/board/security/SecurityConfig.java | 111 +++++++++ src/main/resources/messages.properties | 46 ++++ 45 files changed, 3846 insertions(+) create mode 100755 .gitignore create mode 100644 pom.xml create mode 100755 src/main/java/de/bstly/board/Application.java create mode 100644 src/main/java/de/bstly/board/JPAConfig.java create mode 100644 src/main/java/de/bstly/board/MessageSourceConfig.java create mode 100644 src/main/java/de/bstly/board/businesslogic/CommentManager.java create mode 100644 src/main/java/de/bstly/board/businesslogic/EntryManager.java create mode 100644 src/main/java/de/bstly/board/businesslogic/InstantHelper.java create mode 100644 src/main/java/de/bstly/board/businesslogic/UserManager.java create mode 100644 src/main/java/de/bstly/board/businesslogic/VoteManager.java create mode 100644 src/main/java/de/bstly/board/businesslogic/support/InstantHelper.java create mode 100755 src/main/java/de/bstly/board/controller/AuthenticationController.java create mode 100644 src/main/java/de/bstly/board/controller/BaseController.java create mode 100644 src/main/java/de/bstly/board/controller/CommentController.java create mode 100644 src/main/java/de/bstly/board/controller/DebugController.java create mode 100644 src/main/java/de/bstly/board/controller/EntryController.java create mode 100644 src/main/java/de/bstly/board/controller/UserController.java create mode 100644 src/main/java/de/bstly/board/controller/VoteController.java create mode 100644 src/main/java/de/bstly/board/controller/support/ControllerExceptionHandler.java create mode 100644 src/main/java/de/bstly/board/controller/support/EntityResponseStatusException.java create mode 100644 src/main/java/de/bstly/board/controller/support/RequestBodyErrors.java create mode 100644 src/main/java/de/bstly/board/controller/validation/CommentValidator.java create mode 100644 src/main/java/de/bstly/board/controller/validation/EntryValidator.java create mode 100644 src/main/java/de/bstly/board/events/VotedEvent.java create mode 100644 src/main/java/de/bstly/board/i18n/businesslogic/I18nManager.java create mode 100644 src/main/java/de/bstly/board/i18n/controller/I18nController.java create mode 100644 src/main/java/de/bstly/board/i18n/model/I18n.java create mode 100644 src/main/java/de/bstly/board/i18n/repository/I18nRepository.java create mode 100644 src/main/java/de/bstly/board/model/Comment.java create mode 100644 src/main/java/de/bstly/board/model/Entry.java create mode 100644 src/main/java/de/bstly/board/model/EntryStatus.java create mode 100644 src/main/java/de/bstly/board/model/EntryType.java create mode 100644 src/main/java/de/bstly/board/model/LocalUser.java create mode 100644 src/main/java/de/bstly/board/model/PersistentLogin.java create mode 100644 src/main/java/de/bstly/board/model/RankedEntry.java create mode 100644 src/main/java/de/bstly/board/model/Types.java create mode 100644 src/main/java/de/bstly/board/model/Vote.java create mode 100644 src/main/java/de/bstly/board/model/VoteType.java create mode 100644 src/main/java/de/bstly/board/repository/CommentRepository.java create mode 100644 src/main/java/de/bstly/board/repository/EntryRepository.java create mode 100644 src/main/java/de/bstly/board/repository/LocalUserRepository.java create mode 100644 src/main/java/de/bstly/board/repository/VoteRepository.java create mode 100644 src/main/java/de/bstly/board/security/OAuth2AuthenticationSuccessHandler.java create mode 100755 src/main/java/de/bstly/board/security/SecurityConfig.java create mode 100644 src/main/resources/messages.properties diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..613c139 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +bin/ +target/ +.settings/ +.project +.classpath +hs_err*.log +application.properties +usernames.txt \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..70f4f20 --- /dev/null +++ b/pom.xml @@ -0,0 +1,183 @@ + + + 4.0.0 + de.bstly.board + bstlboard + ${revision} + + + UTF-8 + 11 + 0.2.2-SNAPSHOT + + + + org.springframework.boot + spring-boot-starter-parent + 2.5.5 + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-starter-webflux + + + + org.springframework.boot + spring-boot-starter-mail + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + + org.springframework.session + spring-session-jdbc + + + + + com.querydsl + querydsl-apt + + + + com.querydsl + querydsl-jpa + + + + + commons-validator + commons-validator + 1.7 + + + + com.google.code.gson + gson + + + + com.googlecode.owasp-java-html-sanitizer + owasp-java-html-sanitizer + 20200713.1 + + + + org.apache.commons + commons-lang3 + + + + org.bouncycastle + bcprov-jdk15on + 1.68 + + + + + + + db-inmemory + + + org.hsqldb + hsqldb + + + + + db-mariadb + + + org.mariadb.jdbc + mariadb-java-client + runtime + + + + + db-mysql + + + mysql + mysql-connector-java + runtime + + + + + db-postgresql + + + org.postgresql + postgresql + runtime + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + de.bstly.board.Application + bstlboard + true + ZIP + + + + build-info + + build-info + + + + + + com.mysema.maven + apt-maven-plugin + 1.1.3 + + + + process + + + target/generated-sources/java + com.querydsl.apt.jpa.JPAAnnotationProcessor + + + + + + + + \ No newline at end of file diff --git a/src/main/java/de/bstly/board/Application.java b/src/main/java/de/bstly/board/Application.java new file mode 100755 index 0000000..2805cd6 --- /dev/null +++ b/src/main/java/de/bstly/board/Application.java @@ -0,0 +1,25 @@ +/** + * + */ +package de.bstly.board; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; + +/** + * + * @author spring-cachet-monitoring@champonthis.de + * + */ +@SpringBootApplication +public class Application extends SpringBootServletInitializer { + + /** + * @param args + */ + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} diff --git a/src/main/java/de/bstly/board/JPAConfig.java b/src/main/java/de/bstly/board/JPAConfig.java new file mode 100644 index 0000000..5934bd6 --- /dev/null +++ b/src/main/java/de/bstly/board/JPAConfig.java @@ -0,0 +1,31 @@ +/** + * + */ +package de.bstly.board; + +import javax.persistence.EntityManager; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +/** + * @author monitoring@bstly.de + * + */ +@Configuration +@EnableJpaAuditing() +public class JPAConfig { + + @Autowired + private EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } + +} diff --git a/src/main/java/de/bstly/board/MessageSourceConfig.java b/src/main/java/de/bstly/board/MessageSourceConfig.java new file mode 100644 index 0000000..151e3d4 --- /dev/null +++ b/src/main/java/de/bstly/board/MessageSourceConfig.java @@ -0,0 +1,39 @@ +/** + * + */ +package de.bstly.board; + +import java.util.Properties; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; + +/** + * @author monitoring@bstly.de + * + */ +@Configuration +public class MessageSourceConfig { + + @Value("${bstly.board.title:bstlboard}") + private String title; + @Value("${bstly.board.url:http://localhost:8080}") + private String baseUrl; + + @Bean + public MessageSource messageSource() { + ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); + + messageSource.setBasename("classpath:messages"); + messageSource.setDefaultEncoding("UTF-8"); + Properties commonMessages = new Properties(); + commonMessages.put("title", title); + commonMessages.put("baseUrl", baseUrl); + + messageSource.setCommonMessages(commonMessages); + return messageSource; + } +} diff --git a/src/main/java/de/bstly/board/businesslogic/CommentManager.java b/src/main/java/de/bstly/board/businesslogic/CommentManager.java new file mode 100644 index 0000000..8005e8a --- /dev/null +++ b/src/main/java/de/bstly/board/businesslogic/CommentManager.java @@ -0,0 +1,185 @@ +/** + * + */ +package de.bstly.board.businesslogic; + +import java.time.Instant; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Order; +import org.springframework.stereotype.Component; + +import de.bstly.board.model.Comment; +import de.bstly.board.model.QComment; +import de.bstly.board.model.QVote; +import de.bstly.board.model.Types; +import de.bstly.board.model.VoteType; +import de.bstly.board.repository.CommentRepository; +import de.bstly.board.repository.VoteRepository; + +/** + * @author Lurkars + * + */ +@Component +public class CommentManager { + + @Autowired + private CommentRepository commentRepository; + @Autowired + private VoteRepository voteRepository; + @Autowired + private VoteManager voteManager; + private QComment qComment = QComment.comment; + private QVote qVote = QVote.vote; + + /** + * + * @param parent + * @param target + * @param page + * @param size + * @return + */ + + public Page fetchByRanking(Long target, Long parent, Instant date, double gravity, + int page, int size) { + if (parent == null) { + return commentRepository.findAllByRankingAndParent(target, date, gravity, + PageRequest.of(page, size)); + } + + return commentRepository.findAllByRankingAndParent(target, parent, date, gravity, + PageRequest.of(page, size)); + } + + /** + * + * @param page + * @param size + * @param order + * @return + */ + public Page fetchByDate(Long target, Long parent, Instant date, int page, int size) { + if (parent == null) { + return commentRepository.findAll( + qComment.target.eq(target).and(qComment.parent.isNull()) + .and(qComment.created.before(date)), + PageRequest.of(page, size, Sort.by(Order.asc("created")))); + } + + return commentRepository.findAll( + qComment.target.eq(target).and(qComment.parent.eq(parent)) + .and(qComment.created.before(date)), + PageRequest.of(page, size, Sort.by(Order.asc("created")))); + } + + /** + * + * @param target + * @param parent + * @return + */ + public Long count(Long target, Long parent) { + if (parent == null) { + return count(target); + } + + return commentRepository.count(qComment.target.eq(target).and(qComment.parent.eq(parent))); + } + + /** + * + * @param target + * @return + */ + public Long count(Long target) { + return commentRepository.count(qComment.target.eq(target)); + } + + /** + * + * @param username + * @param comment + */ + public void applyMetadata(String username, Comment comment) { + if (!comment.getMetadata().containsKey("comments")) { + comment.getMetadata().put("comments", count(comment.getTarget(), comment.getId())); + } + if (!comment.getMetadata().containsKey("points")) { + comment.getMetadata().put("points", + voteManager.getPoints(comment.getId(), Types.entry)); + } + if (!comment.getMetadata().containsKey("vote")) { + comment.getMetadata().put("vote", + !voteRepository.exists(qVote.target.eq(comment.getId()) + .and(qVote.targetType.eq(Types.comment)).and(qVote.type.eq(VoteType.up)) + .and(qVote.author.eq(username)))); + } + if (!comment.getMetadata().containsKey("unvote")) { + comment.getMetadata().put("unvote", + voteRepository.exists(qVote.target.eq(comment.getId()) + .and(qVote.targetType.eq(Types.comment)) + .and(qVote.type.eq(VoteType.up).and(qVote.author.eq(username))))); + } + if (!comment.getMetadata().containsKey("downvote")) { + comment.getMetadata() + .put("downvote", + !voteRepository.exists(qVote.target.eq(comment.getId()) + .and(qVote.targetType.eq(Types.comment)) + .and(qVote.author.eq(username)))); + } + } + + /** + * + * @param entries + */ + public void applyMetadata(String username, List entries) { + for (Comment comment : entries) { + applyMetadata(username, comment); + } + } + + /** + * @param id + * @return + */ + public boolean exists(Long id) { + return commentRepository.existsById(id); + } + + /** + * @param id + * @return + */ + public Comment get(Long id) { + return commentRepository.findById(id).orElse(null); + } + + /** + * @param comment + * @return + */ + public Comment save(Comment comment) { + return commentRepository.save(comment); + } + + /** + * + * @param commentId + * @return + */ + public long getPoints(Long commentId) { + long upvotes = voteRepository.count(qVote.targetType.eq(Types.comment) + .and(qVote.type.eq(VoteType.up)).and(qVote.target.eq(commentId))); + long downvotes = voteRepository.count(qVote.targetType.eq(Types.comment) + .and(qVote.type.eq(VoteType.down)).and(qVote.target.eq(commentId))); + return upvotes - downvotes; + } + +} diff --git a/src/main/java/de/bstly/board/businesslogic/EntryManager.java b/src/main/java/de/bstly/board/businesslogic/EntryManager.java new file mode 100644 index 0000000..45d4fdf --- /dev/null +++ b/src/main/java/de/bstly/board/businesslogic/EntryManager.java @@ -0,0 +1,158 @@ +/** + * + */ +package de.bstly.board.businesslogic; + +import java.time.Instant; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Order; +import org.springframework.stereotype.Component; + +import de.bstly.board.model.Entry; +import de.bstly.board.model.QEntry; +import de.bstly.board.model.QVote; +import de.bstly.board.model.RankedEntry; +import de.bstly.board.model.Types; +import de.bstly.board.model.VoteType; +import de.bstly.board.repository.EntryRepository; +import de.bstly.board.repository.VoteRepository; + +/** + * @author Lurkars + * + */ +@Component +public class EntryManager { + + @Autowired + private EntryRepository entryRepository; + @Autowired + private CommentManager commentManager; + @Autowired + private VoteManager voteManager; + @Autowired + private VoteRepository voteRepository; + private QEntry qEntry = QEntry.entry; + private QVote qVote = QVote.vote; + + @Value("${bstly.board.ranking.gravity:1.8}") + private double GRAVITY; + + /** + * + * @param page + * @param size + * @return + */ + public Page fetchByRanking(int page, int size) { + return entryRepository.findAll( + PageRequest.of(page, size, Sort.by(Order.desc("ranking"), Order.desc("created")))); + } + + /** + * + * @param page + * @param size + * @param order + * @return + */ + public Page fetchByDate(Instant date, int page, int size) { + return entryRepository.findAll(qEntry.created.before(date), + PageRequest.of(page, size, Sort.by(Order.desc("created")))); + } + + /** + * + * @param page + * @param size + * @return + */ + public Page directFetchByRanking(Instant date, double gravity, int page, + int size) { + return entryRepository.findAllByRanking(date, gravity, PageRequest.of(page, size)); + } + + /** + * + * @param entry + */ + public void applyMetadata(String username, Entry entry) { + if (!entry.getMetadata().containsKey("comments")) { + entry.getMetadata().put("comments", commentManager.count(entry.getId())); + } + if (!entry.getMetadata().containsKey("points")) { + entry.getMetadata().put("points", voteManager.getPoints(entry.getId(), Types.entry)); + } + if (!entry.getMetadata().containsKey("vote")) { + entry.getMetadata().put("vote", + !voteRepository.exists(qVote.target.eq(entry.getId()) + .and(qVote.targetType.eq(Types.entry)).and(qVote.type.eq(VoteType.up)) + .and(qVote.author.eq(username)))); + } + if (!entry.getMetadata().containsKey("unvote")) { + entry.getMetadata().put("unvote", + voteRepository.exists(qVote.target.eq(entry.getId()) + .and(qVote.targetType.eq(Types.entry)) + .and(qVote.type.eq(VoteType.up).and(qVote.author.eq(username))))); + } + if (!entry.getMetadata().containsKey("downvote")) { + entry.getMetadata().put("downvote", + !voteRepository.exists(qVote.target.eq(entry.getId()) + .and(qVote.targetType.eq(Types.entry)).and(qVote.author.eq(username)))); + } + } + + /** + * + * @param entries + */ + public void applyMetadata(String username, List entries) { + for (Entry entry : entries) { + applyMetadata(username, entry); + } + } + + /** + * @param id + * @return + */ + public boolean exists(Long id) { + return entryRepository.existsById(id); + } + + /** + * @param id + * @return + */ + public Entry get(Long id) { + return entryRepository.findById(id).orElse(null); + } + + /** + * @param entry + * @return + */ + public Entry save(Entry entry) { + return entryRepository.save(entry); + } + + /** + * + * @param entryId + * @return + */ + public long getPoints(Long entryId) { + long upvotes = voteRepository.count(qVote.targetType.eq(Types.entry) + .and(qVote.type.eq(VoteType.up)).and(qVote.target.eq(entryId))); + long downvotes = voteRepository.count(qVote.targetType.eq(Types.entry) + .and(qVote.type.eq(VoteType.down)).and(qVote.target.eq(entryId))); + return upvotes - downvotes; + } + +} diff --git a/src/main/java/de/bstly/board/businesslogic/InstantHelper.java b/src/main/java/de/bstly/board/businesslogic/InstantHelper.java new file mode 100644 index 0000000..cb67ddc --- /dev/null +++ b/src/main/java/de/bstly/board/businesslogic/InstantHelper.java @@ -0,0 +1,83 @@ +/** + * + */ +package de.bstly.board.businesslogic; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAmount; +import java.time.temporal.TemporalUnit; + +/** + * @author _bastler@bstly.de + * + */ +public class InstantHelper { + + /** + * + * @param instant + * @param amount + * @return + */ + public static Instant plus(Instant instant, TemporalAmount amount) { + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC).plus(amount).toInstant(); + } + + /** + * + * @param instant + * @param amountToAdd + * @param unit + * @return + */ + public static Instant plus(Instant instant, long amountToAdd, TemporalUnit unit) { + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC).plus(amountToAdd, unit).toInstant(); + } + + /** + * + * @param instant + * @param amount + * @return + */ + public static Instant minus(Instant instant, TemporalAmount amount) { + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC).minus(amount).toInstant(); + } + + /** + * + * @param instant + * @param amountToAdd + * @param unit + * @return + */ + public static Instant minus(Instant instant, long amountToAdd, TemporalUnit unit) { + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC).minus(amountToAdd, unit) + .toInstant(); + } + + /** + * + * @param instant + * @param unit + * @return + */ + public static Instant truncate(Instant instant, TemporalUnit unit) { + if (ChronoUnit.YEARS.equals(unit)) { + instant = instant.truncatedTo(ChronoUnit.DAYS); + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC) + .with(ChronoField.DAY_OF_YEAR, 1L).toInstant(); + } else if (ChronoUnit.MONTHS.equals(unit)) { + instant = instant.truncatedTo(ChronoUnit.DAYS); + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC) + .with(ChronoField.DAY_OF_MONTH, 1L).toInstant(); + } + + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC).truncatedTo(unit).toInstant(); + } + +} diff --git a/src/main/java/de/bstly/board/businesslogic/UserManager.java b/src/main/java/de/bstly/board/businesslogic/UserManager.java new file mode 100644 index 0000000..b2b0682 --- /dev/null +++ b/src/main/java/de/bstly/board/businesslogic/UserManager.java @@ -0,0 +1,217 @@ +/** + * + */ +package de.bstly.board.businesslogic; + +import java.util.List; + +import org.apache.commons.lang3.RandomStringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.google.common.collect.Lists; + +import de.bstly.board.model.Entry; +import de.bstly.board.model.LocalUser; +import de.bstly.board.model.QEntry; +import de.bstly.board.model.QLocalUser; +import de.bstly.board.repository.EntryRepository; +import de.bstly.board.repository.LocalUserRepository; + +/** + * @author monitoring@bstly.de + * + */ +@Service +public class UserManager implements UserDetailsService, SmartInitializingSingleton { + + private Logger logger = LoggerFactory.getLogger(UserManager.class); + + @Autowired + private LocalUserRepository localUserRepository; + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired + private EntryManager entryManager; + @Autowired + private EntryRepository entryRepository; + private QLocalUser qLocalUser = QLocalUser.localUser; + private QEntry qEntry = QEntry.entry; + + @Value("${admin.password:}") + private String adminPassword; + + /* + * @see + * de.bstly.board.businesslogic.LocalUserManager#loadUserByUsername(java.lang. + * String) + */ + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + LocalUser localUser = getByUsername(username); + + if (localUser == null) { + throw new UsernameNotFoundException(username); + } + + List authorities = Lists.newArrayList(); + if (localUser.getRoles() != null) { + for (String role : localUser.getRoles()) { + authorities.add(new SimpleGrantedAuthority(role)); + } + } + + return new User(username, localUser.getPasswordHash(), authorities); + } + + /* + * + * @see org.springframework.beans.factory.SmartInitializingSingleton# + * afterSingletonsInstantiated() + */ + @Override + public void afterSingletonsInstantiated() { + if (!localUserRepository.exists(qLocalUser.roles.contains("ROLE_ADMIN"))) { + if (!StringUtils.hasText(adminPassword)) { + adminPassword = RandomStringUtils.random(24, true, true); + logger.error("password for 'admin': " + + adminPassword); + } + LocalUser admin = new LocalUser(); + admin.setUsername("admin"); + admin.setRoles(Lists.newArrayList("ROLE_ADMIN")); + admin.setPasswordHash(passwordEncoder.encode(adminPassword)); + localUserRepository.save(admin); + } + } + + /** + * + * @param username + * @return + */ + public LocalUser getByUsername(String username) { + return localUserRepository.findById(username).orElse(null); + } + + /** + * + * @param externalId + * @return + */ + public LocalUser getByExternalId(String externalId) { + return localUserRepository.findOne(qLocalUser.externalId.eq(externalId)).orElse(null); + } + + /** + * + * @param authentication + * @return + */ + public LocalUser getByAuth(Authentication authentication) { + if (authentication != null) { + if (authentication instanceof UsernamePasswordAuthenticationToken) { + UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication; + return getByUsername(token.getName()); + } else if (authentication instanceof OAuth2AuthenticationToken) { + OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication; + String externalId = token.getAuthorizedClientRegistrationId() + + "-" + + token.getName(); + LocalUser localUser = getByExternalId(externalId); + if (localUser == null) { + localUser = new LocalUser(); + localUser.setExternalId(externalId); + String tmpUsername = token.getPrincipal().getAttribute("preferred_username"); + if (!StringUtils.hasText(tmpUsername)) { + tmpUsername = token.getPrincipal().getAttribute("username"); + } + if (!StringUtils.hasText(tmpUsername)) { + tmpUsername = token.getPrincipal().getAttribute("name"); + } + if (!StringUtils.hasText(tmpUsername)) { + tmpUsername = token.getName(); + } + int count = 1; + String username = tmpUsername; + while (localUserRepository.existsById(username)) { + username = tmpUsername + + "-" + + count; + count++; + } + + localUser.setUsername(username); + localUser.setEmail(token.getPrincipal().getAttribute("email")); + + String locale = token.getPrincipal().getAttribute("locale"); + if (!StringUtils.hasText(locale)) { + locale = "en"; + } + localUser.setLocale(locale); + + String darkTheme = token.getPrincipal().getAttribute("darkTheme"); + if (!StringUtils.hasText(darkTheme)) { + darkTheme = "false"; + } + localUser.setDarkTheme(Boolean.valueOf(darkTheme)); + + localUser = localUserRepository.save(localUser); + } + return localUser; + } + } + return null; + } + + /** + * + * @param user + */ + public void applyMetadata(String username, LocalUser user) { + if (user.getUsername().equals(username) && !user.getMetadata().containsKey("self")) { + user.getMetadata().put("self", true); + } + + if (!user.getMetadata().containsKey("points")) { + long points = 0; + for (Entry entry : entryRepository.findAll(qEntry.author.eq(username))) { + points += entryManager.getPoints(entry.getId()); + } + user.getMetadata().put("points", points); + } + } + + /** + * + * @param localUser + * @return + */ + public LocalUser save(LocalUser localUser) { + return localUserRepository.save(localUser); + } + + /** + * @param username + * @return + */ + public boolean exists(String username) { + return localUserRepository.existsById(username); + } + +} diff --git a/src/main/java/de/bstly/board/businesslogic/VoteManager.java b/src/main/java/de/bstly/board/businesslogic/VoteManager.java new file mode 100644 index 0000000..74b161f --- /dev/null +++ b/src/main/java/de/bstly/board/businesslogic/VoteManager.java @@ -0,0 +1,69 @@ +/** + * + */ +package de.bstly.board.businesslogic; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import de.bstly.board.model.QVote; +import de.bstly.board.model.Types; +import de.bstly.board.model.Vote; +import de.bstly.board.model.VoteType; +import de.bstly.board.repository.VoteRepository; + +/** + * @author Lurkars + * + */ +@Component +public class VoteManager { + + @Autowired + private VoteRepository voteRepository; + private QVote qVote = QVote.vote; + + /** + * + * @param author + * @param targetType + * @param target + * @return + */ + public Vote get(String author, Types targetType, Long target) { + return voteRepository.findOne(qVote.author.eq(author).and(qVote.targetType.eq(targetType)) + .and(qVote.target.eq(target))).orElse(null); + } + + /** + * + * @param vote + * @return + */ + public Vote save(Vote vote) { + return voteRepository.save(vote); + } + + /** + * + * @param vote + */ + public void delete(Vote vote) { + voteRepository.delete(vote); + } + + /** + * + * @param target + * @param targetType + * @return + */ + public Long getPoints(Long target, Types targetType) { + return voteRepository + .count(qVote.target.eq(target) + .and(qVote.targetType.eq(targetType).and(qVote.type.eq(VoteType.up)))) + - voteRepository.count(qVote.target.eq(target) + .and(qVote.targetType.eq(targetType).and(qVote.type.eq(VoteType.down)))); + } + +} diff --git a/src/main/java/de/bstly/board/businesslogic/support/InstantHelper.java b/src/main/java/de/bstly/board/businesslogic/support/InstantHelper.java new file mode 100644 index 0000000..7bb6317 --- /dev/null +++ b/src/main/java/de/bstly/board/businesslogic/support/InstantHelper.java @@ -0,0 +1,83 @@ +/** + * + */ +package de.bstly.board.businesslogic.support; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAmount; +import java.time.temporal.TemporalUnit; + +/** + * @author _bastler@bstly.de + * + */ +public class InstantHelper { + + /** + * + * @param instant + * @param amount + * @return + */ + public static Instant plus(Instant instant, TemporalAmount amount) { + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC).plus(amount).toInstant(); + } + + /** + * + * @param instant + * @param amountToAdd + * @param unit + * @return + */ + public static Instant plus(Instant instant, long amountToAdd, TemporalUnit unit) { + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC).plus(amountToAdd, unit).toInstant(); + } + + /** + * + * @param instant + * @param amount + * @return + */ + public static Instant minus(Instant instant, TemporalAmount amount) { + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC).minus(amount).toInstant(); + } + + /** + * + * @param instant + * @param amountToAdd + * @param unit + * @return + */ + public static Instant minus(Instant instant, long amountToAdd, TemporalUnit unit) { + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC).minus(amountToAdd, unit) + .toInstant(); + } + + /** + * + * @param instant + * @param unit + * @return + */ + public static Instant truncate(Instant instant, TemporalUnit unit) { + if (ChronoUnit.YEARS.equals(unit)) { + instant = instant.truncatedTo(ChronoUnit.DAYS); + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC) + .with(ChronoField.DAY_OF_YEAR, 1L).toInstant(); + } else if (ChronoUnit.MONTHS.equals(unit)) { + instant = instant.truncatedTo(ChronoUnit.DAYS); + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC) + .with(ChronoField.DAY_OF_MONTH, 1L).toInstant(); + } + + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC).truncatedTo(unit).toInstant(); + } + +} diff --git a/src/main/java/de/bstly/board/controller/AuthenticationController.java b/src/main/java/de/bstly/board/controller/AuthenticationController.java new file mode 100755 index 0000000..d85fff1 --- /dev/null +++ b/src/main/java/de/bstly/board/controller/AuthenticationController.java @@ -0,0 +1,113 @@ +/** + * + */ +package de.bstly.board.controller; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.ResolvableType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.google.common.collect.Lists; + +/** + * + * @author _bastler@bstly.de + * + */ +@RestController +@RequestMapping("/auth") +public class AuthenticationController extends BaseController { + + private static String authorizationRequestBaseUri = "oauth2/authorization"; + + @Autowired + private ClientRegistrationRepository clientRegistrationRepository; + + /** + * + * @return + */ + @GetMapping + public Authentication me() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return auth; + } + + /** + * + * @return + */ + @SuppressWarnings("unchecked") + @GetMapping("external") + public List getExternalLoginUrls() { + List clients = Lists.newArrayList(); + Iterable clientRegistrations = null; + ResolvableType type = ResolvableType.forInstance(clientRegistrationRepository) + .as(Iterable.class); + if (type != ResolvableType.NONE + && ClientRegistration.class.isAssignableFrom(type.resolveGenerics()[0])) { + clientRegistrations = (Iterable) clientRegistrationRepository; + } + + clientRegistrations + .forEach(registration -> clients.add(new Client(registration.getRegistrationId(), + authorizationRequestBaseUri + + "/" + + registration.getRegistrationId()))); + + return clients; + } + + protected static class Client { + + private String id; + private String loginUrl; + + /** + * @param id + * @param loginUrl + */ + public Client(String id, String loginUrl) { + super(); + this.id = id; + this.loginUrl = loginUrl; + } + + /** + * @return the id + */ + public String getId() { + return id; + } + + /** + * @param id the id to set + */ + public void setId(String id) { + this.id = id; + } + + /** + * @return the loginUrl + */ + public String getLoginUrl() { + return loginUrl; + } + + /** + * @param loginUrl the loginUrl to set + */ + public void setLoginUrl(String loginUrl) { + this.loginUrl = loginUrl; + } + + } +} diff --git a/src/main/java/de/bstly/board/controller/BaseController.java b/src/main/java/de/bstly/board/controller/BaseController.java new file mode 100644 index 0000000..911c5a2 --- /dev/null +++ b/src/main/java/de/bstly/board/controller/BaseController.java @@ -0,0 +1,48 @@ +/** + * + */ +package de.bstly.board.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import de.bstly.board.businesslogic.UserManager; +import de.bstly.board.model.LocalUser; + +/** + * @author monitoring@bstly.de + * + */ +public class BaseController { + + @Autowired + private UserManager localUserManager; + + /** + * + * @return + */ + protected boolean authenticated() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return auth != null && auth.isAuthenticated(); + } + + /** + * + * @return + */ + protected String getCurrentUsername() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return auth != null ? auth.getName() : null; + } + + /** + * + * @return + */ + protected LocalUser getLocalUser() { + return localUserManager.getByAuth(SecurityContextHolder.getContext().getAuthentication()); + } + +} diff --git a/src/main/java/de/bstly/board/controller/CommentController.java b/src/main/java/de/bstly/board/controller/CommentController.java new file mode 100644 index 0000000..ef84304 --- /dev/null +++ b/src/main/java/de/bstly/board/controller/CommentController.java @@ -0,0 +1,132 @@ +/** + * + */ +package de.bstly.board.controller; + +import java.time.Instant; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import de.bstly.board.businesslogic.CommentManager; +import de.bstly.board.businesslogic.VoteManager; +import de.bstly.board.controller.support.EntityResponseStatusException; +import de.bstly.board.controller.support.RequestBodyErrors; +import de.bstly.board.controller.validation.CommentValidator; +import de.bstly.board.model.Comment; +import de.bstly.board.model.Types; +import de.bstly.board.model.Vote; +import de.bstly.board.model.VoteType; + +/** + * @author Lurkars + * + */ +@RestController +@RequestMapping("/c") +public class CommentController extends BaseController { + + @Autowired + private CommentManager commentManager; + @Autowired + private CommentValidator commentValidator; + @Autowired + private VoteManager voteManager; + + @Value("${bstly.board.size:30}") + private int SIZE; + @Value("${bstly.board.ranking.gravity:1.8}") + private double GRAVITY; + + @PreAuthorize("isAuthenticated()") + @GetMapping({ "/e/{target}", "/e/{target}/{parent}" }) + public Page rankedComments(@PathVariable("target") Long target, + @PathVariable("parent") Optional parent, + @RequestParam("page") Optional pageParameter, + @RequestParam("size") Optional sizeParameter, + @RequestParam("date") Optional dateParameter, + @RequestParam("gravity") Optional gravityParameter) { + + Page comments = newComments(target, parent, pageParameter, sizeParameter, + dateParameter); + commentManager.applyMetadata(getCurrentUsername(), comments.getContent()); + return comments; + +// Page comments = commentManager.fetchByRanking(target, parent.orElse(null), +// dateParameter.orElse(Instant.now()), gravityParameter.orElse(GRAVITY), +// pageParameter.orElse(0), sizeParameter.orElse(SIZE)); +// return comments; + } + + @PreAuthorize("isAuthenticated()") + @GetMapping({ "/c/{target}", "/c/{target}/{parent}" }) + public Long countComments(@PathVariable("target") Long target, + @PathVariable("parent") Optional parent) { + return commentManager.count(target, parent.orElse(null)); + } + + @PreAuthorize("isAuthenticated()") + @GetMapping({ "/e/new/{target}", "/e/new/{target}/{parent}" }) + public Page newComments(@PathVariable("target") Long target, + @PathVariable("parent") Optional parent, + @RequestParam("page") Optional pageParameter, + @RequestParam("size") Optional sizeParameter, + @RequestParam("date") Optional dateParameter) { + Page comments = commentManager.fetchByDate(target, parent.orElse(null), + dateParameter.orElse(Instant.now()), pageParameter.orElse(0), + sizeParameter.orElse(SIZE)); + commentManager.applyMetadata(getCurrentUsername(), comments.getContent()); + return comments; + } + + @PreAuthorize("isAuthenticated()") + @GetMapping("/{id}") + public Comment getComment(@PathVariable("id") Long id) { + Comment comment = commentManager.get(id); + + if (comment == null) { + throw new EntityResponseStatusException(HttpStatus.NOT_FOUND); + } + + commentManager.applyMetadata(getCurrentUsername(), comment); + + return comment; + } + + @PreAuthorize("isAuthenticated()") + @PostMapping() + public Comment createComment(@RequestBody Comment comment) { + RequestBodyErrors bindingResult = new RequestBodyErrors(comment); + commentValidator.validate(comment, bindingResult); + + if (bindingResult.hasErrors()) { + throw new EntityResponseStatusException(bindingResult.getAllErrors(), + HttpStatus.UNPROCESSABLE_ENTITY); + } + + comment.setCreated(Instant.now()); + comment.setAuthor(getCurrentUsername()); + comment = commentManager.save(comment); + + Vote vote = new Vote(); + vote.setTarget(comment.getId()); + vote.setTargetType(Types.comment); + vote.setType(VoteType.up); + vote.setAuthor(getCurrentUsername()); + voteManager.save(vote); + + return comment; + } + +} diff --git a/src/main/java/de/bstly/board/controller/DebugController.java b/src/main/java/de/bstly/board/controller/DebugController.java new file mode 100644 index 0000000..3e57c37 --- /dev/null +++ b/src/main/java/de/bstly/board/controller/DebugController.java @@ -0,0 +1,202 @@ +/** + * + */ +package de.bstly.board.controller; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.RandomUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.bstly.board.businesslogic.EntryManager; +import de.bstly.board.model.Comment; +import de.bstly.board.model.Entry; +import de.bstly.board.model.EntryStatus; +import de.bstly.board.model.EntryType; +import de.bstly.board.model.LocalUser; +import de.bstly.board.model.QLocalUser; +import de.bstly.board.model.Types; +import de.bstly.board.model.Vote; +import de.bstly.board.model.VoteType; +import de.bstly.board.repository.CommentRepository; +import de.bstly.board.repository.LocalUserRepository; +import de.bstly.board.repository.VoteRepository; + +/** + * + * @author _bastler@bstly.de + * + */ +@RestController +@RequestMapping("/debug") +public class DebugController extends BaseController { + + /** + * logger + */ + private Logger logger = LogManager.getLogger(DebugController.class); + + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired + private LocalUserRepository localUserRepository; + @Autowired + private CommentRepository commentRepository; + @Autowired + private VoteRepository voteRepository; + @Autowired + private EntryManager entryManager; + + @Value("${debug.random.users:0}") + private int users; + @Value("${debug.random.minEntries:0}") + private int minEntries; + @Value("${debug.random.maxEntries:10}") + private int maxEntries; + @Value("${debug.random.entryAge:63115200}") + private long entryAge; + @Value("${debug.random.minComments:0}") + private int minComments; + @Value("${debug.random.maxComments:10}") + private int maxComments; + @Value("${debug.random.subCommentsFactor:0.5}") + private double subCommentsFactor; + @Value("${debug.random.subCommentsThresh:0.3}") + private double subCommentsThresh; + @Value("${debug.random.subCommentsDepth:2}") + private int subCommentsDepth; + @Value("${debug.random.minUpvotes:5}") + private int minUpvotes; + @Value("${debug.random.maxUpvotes:10}") + private int maxUpvotes; + @Value("${debug.random.minDownvotes:0}") + private int minDownvotes; + @Value("${debug.random.maxDownvotes:10}") + private int maxDownvotes; + + /** + * + * @return + */ + @GetMapping("/random") + public void random() { + logger.warn("start random generation"); + + long userCount = localUserRepository + .count(QLocalUser.localUser.username.startsWith("user")); + + for (long i = userCount; i < userCount + users; i++) { + LocalUser localUser = new LocalUser(); + String username = "user" + i; + localUser.setUsername(username); + localUser.setPasswordHash(passwordEncoder.encode(username)); + localUserRepository.save(localUser); + logger.trace("Created user: '" + username + "'"); + } + + logger.info("Created " + users + " users"); + + for (long id = 0; id <= userCount; id++) { + entries("user" + id, userCount); + } + + logger.warn("finished random generation"); + } + + protected void entries(String username, long userCount) { + long numEntries = RandomUtils.nextLong(minEntries, maxEntries); + for (int i = 0; i < numEntries; i++) { + Entry entry = new Entry(); + entry.setEntryType(EntryType.INTERN); + entry.setAuthor(username); + entry.setCreated( + Instant.now().minus(RandomUtils.nextLong(0, entryAge), ChronoUnit.SECONDS)); + entry.setTitle(RandomStringUtils.randomAscii(RandomUtils.nextInt(10, 250))); + entry.setText(RandomStringUtils.randomAscii(RandomUtils.nextInt(0, 2500))); + entry.setEntryStatus(EntryStatus.NORMAL); + entry = entryManager.save(entry); + logger.trace("Created entry: '" + entry.getId() + "'"); + comments(entry.getId(), entry.getCreated(), userCount); + votes(entry.getId(), Types.entry, userCount); + } + logger.info("Created " + numEntries + " entries of '" + username + "'"); + } + + protected void comments(Long target, Instant date, long userCount) { + long numComments = RandomUtils.nextLong(minComments, maxComments); + logger.debug("Create " + numComments + " comments for '" + target + "'"); + for (int i = 0; i < numComments; i++) { + Comment comment = new Comment(); + comment.setTarget(target); + comment.setAuthor("user" + RandomUtils.nextLong(0, userCount)); + comment.setText(RandomStringUtils.randomAscii(RandomUtils.nextInt(0, 2500))); + comment.setCreated(Instant.now() + .minus(RandomUtils.nextLong(0, + (Instant.now().toEpochMilli() - date.toEpochMilli()) / 1000), + ChronoUnit.SECONDS)); + comment = commentRepository.save(comment); + logger.trace("Created comment: '" + comment.getId() + "'"); + subComments(target, comment.getId(), comment.getCreated(), subCommentsFactor, + subCommentsThresh, 0, userCount); + } + } + + protected void subComments(Long target, Long parent, Instant date, double factor, double thresh, + int depth, long userCount) { + if (depth < subCommentsDepth && RandomUtils.nextDouble(0, 1) < thresh) { + long numSubComments = RandomUtils.nextLong(0, Math.round(maxComments * factor)); + logger.debug("Create " + numSubComments + " subComments for '" + parent + "'"); + for (int i = 0; i < numSubComments; i++) { + Comment comment = new Comment(); + comment.setTarget(target); + comment.setParent(parent); + comment.setAuthor("user" + RandomUtils.nextLong(0, userCount)); + comment.setText(RandomStringUtils.randomAscii(RandomUtils.nextInt(0, 2500))); + comment.setCreated(Instant.now() + .minus(RandomUtils.nextLong(0, + (Instant.now().toEpochMilli() - date.toEpochMilli()) / 1000), + ChronoUnit.SECONDS)); + comment = commentRepository.save(comment); + logger.trace("Created subComment: '" + comment.getId() + "'"); + subComments(target, comment.getId(), comment.getCreated(), factor * 0.5, + thresh * 0.5, depth++, userCount); + } + } + } + + protected void votes(Long target, Types targetType, long userCount) { + long numUpvotes = RandomUtils.nextLong(minUpvotes, maxUpvotes); + logger.debug("Create " + numUpvotes + " upvotes for '" + target + "'"); + for (int i = 0; i < numUpvotes; i++) { + Vote upvote = new Vote(); + upvote.setTarget(target); + upvote.setType(VoteType.up); + upvote.setTargetType(targetType); + upvote.setAuthor("user" + RandomUtils.nextLong(0, userCount)); + upvote = voteRepository.save(upvote); + logger.trace("Created upvote: '" + upvote.getId() + "'"); + } + + long numDownvotes = RandomUtils.nextLong(minDownvotes, maxDownvotes); + logger.debug("Create " + numDownvotes + " downvotes for '" + target + "'"); + for (int i = 0; i < numDownvotes; i++) { + Vote downvote = new Vote(); + downvote.setTarget(target); + downvote.setType(VoteType.down); + downvote.setTargetType(targetType); + downvote.setAuthor("user" + RandomUtils.nextLong(0, userCount)); + downvote = voteRepository.save(downvote); + logger.trace("Created downvote: '" + downvote.getId() + "'"); + } + } + +} diff --git a/src/main/java/de/bstly/board/controller/EntryController.java b/src/main/java/de/bstly/board/controller/EntryController.java new file mode 100644 index 0000000..555d160 --- /dev/null +++ b/src/main/java/de/bstly/board/controller/EntryController.java @@ -0,0 +1,152 @@ +/** + * + */ +package de.bstly.board.controller; + +import java.time.Instant; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import de.bstly.board.businesslogic.EntryManager; +import de.bstly.board.businesslogic.VoteManager; +import de.bstly.board.controller.support.EntityResponseStatusException; +import de.bstly.board.controller.support.RequestBodyErrors; +import de.bstly.board.controller.validation.EntryValidator; +import de.bstly.board.model.Entry; +import de.bstly.board.model.EntryStatus; +import de.bstly.board.model.RankedEntry; +import de.bstly.board.model.Types; +import de.bstly.board.model.Vote; +import de.bstly.board.model.VoteType; + +/** + * @author Lurkars + * + */ +@RestController +@RequestMapping("/e") +public class EntryController extends BaseController { + + @Autowired + private EntryManager entryManager; + @Autowired + private EntryValidator entryValidator; + @Autowired + private VoteManager voteManager; + + @Value("${bstly.board.size:30}") + private int SIZE; + @Value("${bstly.board.ranking.gravity:1.8}") + private double GRAVITY; + + @PreAuthorize("isAuthenticated()") + @GetMapping() + public Page rankedEntries(@RequestParam("page") Optional pageParameter, + @RequestParam("size") Optional sizeParameter, + @RequestParam("date") Optional dateParameter, + @RequestParam("gravity") Optional gravityParameter) { + + if (sizeParameter.isPresent() && sizeParameter.get() > 100) { + sizeParameter = Optional.of(100); + } + + Page entries = entryManager.directFetchByRanking( + dateParameter.orElse(Instant.now()), gravityParameter.orElse(GRAVITY), + pageParameter.orElse(0), sizeParameter.orElse(SIZE)); + + Page transformed = new PageImpl( + entries.getContent().stream().map(rankedEntry -> Entry.fromRankedEntry(rankedEntry)) + .collect(Collectors.toList()), + entries.getPageable(), entries.getTotalElements()); + + entryManager.applyMetadata(getCurrentUsername(), transformed.getContent()); + return transformed; + } + + @PreAuthorize("isAuthenticated()") + @GetMapping("ranked") + public Page rankedEntries(@RequestParam("page") Optional pageParameter, + @RequestParam("size") Optional sizeParameter) { + + if (sizeParameter.isPresent() && sizeParameter.get() > 100) { + sizeParameter = Optional.of(100); + } + + Page entries = entryManager.fetchByRanking(pageParameter.orElse(0), + sizeParameter.orElse(SIZE)); + + entryManager.applyMetadata(getCurrentUsername(), entries.getContent()); + return entries; + } + + @PreAuthorize("isAuthenticated()") + @GetMapping("/new") + public Page newEntries(@RequestParam("page") Optional pageParameter, + @RequestParam("size") Optional sizeParameter, + @RequestParam("date") Optional dateParameter) { + + if (sizeParameter.isPresent() && sizeParameter.get() > 100) { + sizeParameter = Optional.of(100); + } + + Page entries = entryManager.fetchByDate(dateParameter.orElse(Instant.now()), + pageParameter.orElse(0), sizeParameter.orElse(SIZE)); + entryManager.applyMetadata(getCurrentUsername(), entries.getContent()); + return entries; + } + + @PreAuthorize("isAuthenticated()") + @GetMapping("/{id}") + public Entry getEntry(@PathVariable("id") Long id) { + Entry entry = entryManager.get(id); + + if (entry == null) { + throw new EntityResponseStatusException(HttpStatus.NOT_FOUND); + } + + entryManager.applyMetadata(getCurrentUsername(), entry); + + return entry; + } + + @PreAuthorize("isAuthenticated()") + @PostMapping() + public Entry createEntry(@RequestBody Entry entry) { + RequestBodyErrors bindingResult = new RequestBodyErrors(entry); + entryValidator.validate(entry, bindingResult); + + if (bindingResult.hasErrors()) { + throw new EntityResponseStatusException(bindingResult.getAllErrors(), + HttpStatus.UNPROCESSABLE_ENTITY); + } + + entry.setCreated(Instant.now()); + entry.setAuthor(getCurrentUsername()); + entry.setEntryStatus(EntryStatus.NORMAL); + entry = entryManager.save(entry); + + Vote vote = new Vote(); + vote.setTarget(entry.getId()); + vote.setType(VoteType.up); + vote.setTargetType(Types.entry); + vote.setAuthor(getCurrentUsername()); + voteManager.save(vote); + + return entry; + } + +} diff --git a/src/main/java/de/bstly/board/controller/UserController.java b/src/main/java/de/bstly/board/controller/UserController.java new file mode 100644 index 0000000..9e1f3c0 --- /dev/null +++ b/src/main/java/de/bstly/board/controller/UserController.java @@ -0,0 +1,79 @@ +/** + * + */ +package de.bstly.board.controller; + +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.bstly.board.businesslogic.UserManager; +import de.bstly.board.controller.support.EntityResponseStatusException; +import de.bstly.board.model.LocalUser; + +/** + * @author monitoring@bstly.de + * + */ +@RestController +@RequestMapping("/u") +public class UserController extends BaseController { + + @Autowired + private UserManager userManager; + + @PreAuthorize("isAuthenticated()") + @GetMapping({ "", "/{username}" }) + public LocalUser getUser(@PathVariable("username") Optional usernameParameter) { + String username = usernameParameter.orElse(getCurrentUsername()); + + LocalUser user = userManager.getByUsername(username); + + if (user == null) { + throw new EntityResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY); + } + + if (user.getUsername() != getCurrentUsername()) { + LocalUser otherUser = new LocalUser(); + otherUser.setUsername(user.getUsername()); + otherUser.setAbout(user.getAbout()); + } + + user.setPasswordHash(null); + + userManager.applyMetadata(getCurrentUsername(), user); + + return user; + } + + @PreAuthorize("isAuthenticated()") + @PostMapping() + public LocalUser updateUser(@RequestBody LocalUser user) { + if (!getCurrentUsername().equals(user.getUsername())) { + throw new EntityResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY); + } + + LocalUser orgUser = userManager.getByUsername(user.getUsername()); + + orgUser.setAbout(user.getAbout()); + orgUser.setDarkTheme(user.isDarkTheme()); + orgUser.setEmail(user.getEmail()); + orgUser.setLocale(user.getLocale()); + orgUser.setSettings(user.getSettings()); + + user = userManager.save(orgUser); + + user.setPasswordHash(null); + + return user; + } + +} diff --git a/src/main/java/de/bstly/board/controller/VoteController.java b/src/main/java/de/bstly/board/controller/VoteController.java new file mode 100644 index 0000000..fb5a907 --- /dev/null +++ b/src/main/java/de/bstly/board/controller/VoteController.java @@ -0,0 +1,143 @@ +/** + * + */ +package de.bstly.board.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.bstly.board.businesslogic.CommentManager; +import de.bstly.board.businesslogic.EntryManager; +import de.bstly.board.businesslogic.VoteManager; +import de.bstly.board.controller.support.EntityResponseStatusException; +import de.bstly.board.model.Types; +import de.bstly.board.model.Vote; +import de.bstly.board.model.VoteType; + +/** + * @author monitoring@bstly.de + * + */ +@RestController +@RequestMapping("/v") +public class VoteController extends BaseController { + + @Autowired + private VoteManager voteManager; + @Autowired + private EntryManager entryManager; + @Autowired + private CommentManager commentManager; + + @PreAuthorize("isAuthenticated()") + @GetMapping("/e/{id}") + public long getEntryPoints(@PathVariable("id") Long id) { + if (!entryManager.exists(id)) { + throw new EntityResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY); + } + + return voteManager.getPoints(id, Types.entry); + } + + @PreAuthorize("isAuthenticated()") + @PutMapping("/e/{id}") + public void voteEntryUp(@PathVariable("id") Long id) { + if (!entryManager.exists(id)) { + throw new EntityResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY); + } + + Vote vote = voteManager.get(getCurrentUsername(), Types.entry, id); + if (vote == null) { + vote = new Vote(); + vote.setTarget(id); + vote.setAuthor(getCurrentUsername()); + vote.setType(VoteType.up); + vote.setTargetType(Types.entry); + voteManager.save(vote); + throw new EntityResponseStatusException(HttpStatus.CREATED); + } else if (!VoteType.up.equals(vote.getType())) { + voteManager.delete(vote); + } + } + + @PreAuthorize("isAuthenticated()") + @DeleteMapping("/e/{id}") + public void voteEntryDown(@PathVariable("id") Long id) { + if (!entryManager.exists(id)) { + throw new EntityResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY); + } + + Vote vote = voteManager.get(getCurrentUsername(), Types.entry, id); + if (vote == null) { + vote = new Vote(); + vote.setTarget(id); + vote.setAuthor(getCurrentUsername()); + vote.setType(VoteType.down); + vote.setTargetType(Types.entry); + voteManager.save(vote); + throw new EntityResponseStatusException(HttpStatus.CREATED); + } else if (!VoteType.down.equals(vote.getType())) { + voteManager.delete(vote); + } + } + + @PreAuthorize("isAuthenticated()") + @GetMapping("/c/{id}") + public long getCommentPoints(@PathVariable("id") Long id) { + if (!commentManager.exists(id)) { + throw new EntityResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY); + } + + return voteManager.getPoints(id, Types.comment); + } + + @PreAuthorize("isAuthenticated()") + @PutMapping("/c/{id}") + public void voteCommentUp(@PathVariable("id") Long id) { + if (!commentManager.exists(id)) { + throw new EntityResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY); + } + + Vote vote = voteManager.get(getCurrentUsername(), Types.comment, id); + if (vote == null) { + vote = new Vote(); + vote.setTarget(id); + vote.setAuthor(getCurrentUsername()); + vote.setType(VoteType.up); + vote.setTargetType(Types.comment); + voteManager.save(vote); + throw new EntityResponseStatusException(HttpStatus.CREATED); + } else if (!VoteType.up.equals(vote.getType())) { + voteManager.delete(vote); + } + } + + @PreAuthorize("isAuthenticated()") + @DeleteMapping("/c/{id}") + public void voteCommentDown(@PathVariable("id") Long id) { + if (!commentManager.exists(id)) { + throw new EntityResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY); + } + + Vote vote = voteManager.get(getCurrentUsername(), Types.comment, id); + if (vote == null) { + vote = new Vote(); + vote.setTarget(id); + vote.setAuthor(getCurrentUsername()); + vote.setType(VoteType.down); + vote.setTargetType(Types.comment); + voteManager.save(vote); + throw new EntityResponseStatusException(HttpStatus.CREATED); + } else if (!VoteType.down.equals(vote.getType())) { + voteManager.delete(vote); + } + } + +} diff --git a/src/main/java/de/bstly/board/controller/support/ControllerExceptionHandler.java b/src/main/java/de/bstly/board/controller/support/ControllerExceptionHandler.java new file mode 100644 index 0000000..1e3cdad --- /dev/null +++ b/src/main/java/de/bstly/board/controller/support/ControllerExceptionHandler.java @@ -0,0 +1,35 @@ +/** + * + */ +package de.bstly.board.controller.support; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +/** + * + * @author monitoring@bstly.de + * + */ +@ControllerAdvice +public class ControllerExceptionHandler extends ResponseEntityExceptionHandler { + + /** + * + * @param exception + * @param request + * @return + */ + @ExceptionHandler(value = { EntityResponseStatusException.class }) + protected ResponseEntity handleResponseEntityStatusException(RuntimeException exception, + WebRequest request) { + EntityResponseStatusException entityResponseStatusException = (EntityResponseStatusException) exception; + return handleExceptionInternal(exception, entityResponseStatusException.getBody(), + new HttpHeaders(), entityResponseStatusException.getStatus(), request); + } + +} diff --git a/src/main/java/de/bstly/board/controller/support/EntityResponseStatusException.java b/src/main/java/de/bstly/board/controller/support/EntityResponseStatusException.java new file mode 100644 index 0000000..0207c27 --- /dev/null +++ b/src/main/java/de/bstly/board/controller/support/EntityResponseStatusException.java @@ -0,0 +1,87 @@ +/** + * + */ +package de.bstly.board.controller.support; + +import javax.annotation.Nullable; + +import org.springframework.core.NestedExceptionUtils; +import org.springframework.core.NestedRuntimeException; +import org.springframework.http.HttpStatus; +import org.springframework.util.Assert; + +/** + * + * @author monitoring@bstly.de + * + */ +public class EntityResponseStatusException extends NestedRuntimeException { + + /** + * default serialVersionUID + */ + private static final long serialVersionUID = 1L; + + private final HttpStatus status; + + @Nullable + private final Object body; + + /** + * + * @param status + */ + public EntityResponseStatusException(HttpStatus status) { + this(null, status); + } + + /** + * + * @param body + * @param status + */ + public EntityResponseStatusException(@Nullable Object body, HttpStatus status) { + this(body, status, null); + } + + /** + * + * @param body + * @param status + * @param cause + */ + public EntityResponseStatusException(@Nullable Object body, HttpStatus status, @Nullable Throwable cause) { + super(null, cause); + Assert.notNull(status, "HttpStatus is required"); + this.status = status; + this.body = body; + } + + /** + * + * @return + */ + public HttpStatus getStatus() { + return this.status; + } + + /** + * + * @return + */ + @Nullable + public Object getBody() { + return this.body; + } + + /** + * + * @return + */ + @Override + public String getMessage() { + String msg = this.status + (this.body != null ? " \"" + this.body + "\"" : ""); + return NestedExceptionUtils.buildMessage(msg, getCause()); + } + +} diff --git a/src/main/java/de/bstly/board/controller/support/RequestBodyErrors.java b/src/main/java/de/bstly/board/controller/support/RequestBodyErrors.java new file mode 100644 index 0000000..40ce0d0 --- /dev/null +++ b/src/main/java/de/bstly/board/controller/support/RequestBodyErrors.java @@ -0,0 +1,49 @@ +/** + * + */ +package de.bstly.board.controller.support; + +import org.springframework.lang.Nullable; +import org.springframework.validation.AbstractBindingResult; + +/** + * + * @author _bastler@bstly.de + * + */ +@SuppressWarnings("serial") +public class RequestBodyErrors extends AbstractBindingResult { + + @Nullable + private final Object target; + + /** + * + * @param target + * @param objectName + */ + public RequestBodyErrors(@Nullable Object target) { + super("request-body"); + this.target = target; + } + + /* + * @see org.springframework.validation.AbstractBindingResult#getTarget() + */ + @Override + public Object getTarget() { + return target; + } + + /* + * @see + * org.springframework.validation.AbstractBindingResult#getActualFieldValue(java + * .lang.String) + */ + @Override + protected Object getActualFieldValue(String field) { + // Not necessary + return null; + } + +} diff --git a/src/main/java/de/bstly/board/controller/validation/CommentValidator.java b/src/main/java/de/bstly/board/controller/validation/CommentValidator.java new file mode 100644 index 0000000..50bd350 --- /dev/null +++ b/src/main/java/de/bstly/board/controller/validation/CommentValidator.java @@ -0,0 +1,61 @@ +/** + * + */ +package de.bstly.board.controller.validation; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + +import de.bstly.board.businesslogic.CommentManager; +import de.bstly.board.businesslogic.EntryManager; +import de.bstly.board.model.Comment; + +/** + * @author monitoring@bstly.de + * + */ +@Component +public class CommentValidator implements Validator { + + @Autowired + private CommentManager commentManager; + @Autowired + private EntryManager entryManager; + + /* + * @see org.springframework.validation.Validator#supports(java.lang.Class) + */ + @Override + public boolean supports(Class clazz) { + return clazz.isAssignableFrom(Comment.class); + } + + /* + * @see org.springframework.validation.Validator#validate(java.lang.Object, + * org.springframework.validation.Errors) + */ + @Override + public void validate(Object target, Errors errors) { + Comment comment = (Comment) target; + if (comment.getTarget() == null) { + errors.rejectValue("target", "REQUIRED"); + } else if (!entryManager.exists(comment.getTarget())) { + errors.rejectValue("target", "INVALID"); + } + + if (comment.getParent() != null) { + Comment parent = commentManager.get(comment.getParent()); + if (parent == null || !parent.getTarget().equals(comment.getTarget())) { + errors.rejectValue("parent", "INVALID"); + } + } + + if (!StringUtils.hasText(comment.getText())) { + errors.rejectValue("text", "REQUIRED"); + } + } + +} diff --git a/src/main/java/de/bstly/board/controller/validation/EntryValidator.java b/src/main/java/de/bstly/board/controller/validation/EntryValidator.java new file mode 100644 index 0000000..e0b1b0c --- /dev/null +++ b/src/main/java/de/bstly/board/controller/validation/EntryValidator.java @@ -0,0 +1,57 @@ +/** + * + */ +package de.bstly.board.controller.validation; + +import org.apache.commons.validator.routines.UrlValidator; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + +import de.bstly.board.model.Entry; +import de.bstly.board.model.EntryType; + +/** + * @author monitoring@bstly.de + * + */ +@Component +public class EntryValidator implements Validator { + + private UrlValidator urlValidator = new UrlValidator(); + + /* + * @see org.springframework.validation.Validator#supports(java.lang.Class) + */ + @Override + public boolean supports(Class clazz) { + return clazz.isAssignableFrom(Entry.class); + } + + /* + * @see org.springframework.validation.Validator#validate(java.lang.Object, + * org.springframework.validation.Errors) + */ + @Override + public void validate(Object target, Errors errors) { + Entry entry = (Entry) target; + if (!StringUtils.hasText(entry.getTitle())) { + errors.rejectValue("title", "REQUIRED"); + } + + if (entry.getEntryType() == null) { + errors.rejectValue("entrytype", "REQUIRED"); + } else if (EntryType.LINK.equals(entry.getEntryType())) { + entry.setText(null); + if (!StringUtils.hasText(entry.getUrl())) { + errors.rejectValue("url", "REQUIRED"); + } + } + + if (StringUtils.hasText(entry.getUrl()) && !urlValidator.isValid(entry.getUrl())) { + errors.rejectValue("url", "INVALID"); + } + } + +} diff --git a/src/main/java/de/bstly/board/events/VotedEvent.java b/src/main/java/de/bstly/board/events/VotedEvent.java new file mode 100644 index 0000000..1151e01 --- /dev/null +++ b/src/main/java/de/bstly/board/events/VotedEvent.java @@ -0,0 +1,28 @@ +/** + * + */ +package de.bstly.board.events; + +import org.springframework.context.ApplicationEvent; + +import de.bstly.board.model.Vote; + +/** + * @author Lurkars + * + */ +public class VotedEvent extends ApplicationEvent { + + /** + * + */ + private static final long serialVersionUID = 1L; + + /** + * @param source + */ + public VotedEvent(Vote vote) { + super(vote); + } + +} diff --git a/src/main/java/de/bstly/board/i18n/businesslogic/I18nManager.java b/src/main/java/de/bstly/board/i18n/businesslogic/I18nManager.java new file mode 100644 index 0000000..143ad18 --- /dev/null +++ b/src/main/java/de/bstly/board/i18n/businesslogic/I18nManager.java @@ -0,0 +1,176 @@ +/** + * + */ +package de.bstly.board.i18n.businesslogic; + +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import de.bstly.board.i18n.model.I18n; +import de.bstly.board.i18n.repository.I18nRepository; + +/** + * @author _bastler@bstly.de + * + */ +@Component +public class I18nManager implements SmartInitializingSingleton { + + private Logger logger = LoggerFactory.getLogger(I18nManager.class); + + @Autowired + private I18nRepository i18nRepository; + @Autowired + private ResourceLoader resourceLoader; + private Gson gson = new Gson(); + + /** + * + * @param locale + * @return + */ + public I18n get(String locale) { + return i18nRepository.findById(locale).orElse(null); + } + + /** + * + * @param locale + * @return + */ + public JsonObject getLabel(String locale) { + I18n i18n = get(locale); + if (i18n != null && StringUtils.hasText(i18n.getLabel())) { + JsonElement element = JsonParser.parseString(i18n.getLabel()); + if (element != null && element.isJsonObject()) { + return element.getAsJsonObject(); + } + } + + return null; + } + + /** + * + * @return + */ + public List getLocales() { + return i18nRepository.findAll().stream().map(I18n::getLocale).collect(Collectors.toList()); + } + + /** + * + * @param dest + * @param src + */ + protected void extendJsonObject(JsonObject dest, JsonObject src) { + for (Entry srcEntry : src.entrySet()) { + String srcKey = srcEntry.getKey(); + JsonElement srcValue = srcEntry.getValue(); + if (dest.has(srcKey)) { + JsonElement destValue = dest.get(srcKey); + if (destValue.isJsonObject() && srcValue.isJsonObject()) { + extendJsonObject(destValue.getAsJsonObject(), srcValue.getAsJsonObject()); + } else { + dest.add(srcKey, srcValue); + } + } else { + dest.add(srcKey, srcValue); + } + } + } + + /** + * + * @param locale + * @param newLabel + * @return + */ + public I18n addLabel(String locale, JsonObject newLabel) { + JsonObject label = getLabel(locale); + + if (label == null || label.size() == 0 || label.entrySet().isEmpty()) { + label = newLabel; + } else { + extendJsonObject(label, newLabel); + } + + I18n i18n = new I18n(); + i18n.setLocale(locale); + i18n.setLabel(gson.toJson(label)); + + return i18nRepository.save(i18n); + } + + /** + * + * @param locale + * @param label + * @return + */ + public I18n setLabel(String locale, JsonObject label) { + I18n i18n = new I18n(); + i18n.setLocale(locale); + i18n.setLabel(gson.toJson(label)); + + return i18nRepository.save(i18n); + } + + /** + * + * @param locale + */ + public void deleteLabel(String locale) { + if (i18nRepository.existsById(locale)) { + i18nRepository.deleteById(locale); + } + } + + /* + * @see org.springframework.beans.factory.SmartInitializingSingleton# + * afterSingletonsInstantiated() + */ + @Override + public void afterSingletonsInstantiated() { + try { + Resource resource = resourceLoader.getResource("classpath:label"); + + if (resource.exists()) { + File labelFolder = resource.getFile(); + if (labelFolder.exists() && labelFolder.isDirectory()) { + for (File labelFile : labelFolder.listFiles()) { + JsonObject label = JsonParser + .parseReader(new FileReader(labelFile, StandardCharsets.UTF_8)) + .getAsJsonObject(); + + String locale = labelFile.getName().replace(".json", ""); + addLabel(locale, label); + } + } + } + + } catch (IOException e) { + logger.warn("cannot read in label folder", e.getMessage()); + } + } + +} diff --git a/src/main/java/de/bstly/board/i18n/controller/I18nController.java b/src/main/java/de/bstly/board/i18n/controller/I18nController.java new file mode 100644 index 0000000..7f4ea5a --- /dev/null +++ b/src/main/java/de/bstly/board/i18n/controller/I18nController.java @@ -0,0 +1,108 @@ +/** + * + */ +package de.bstly.board.i18n.controller; + +import java.io.IOException; +import java.util.List; + +import javax.servlet.http.HttpServletResponse; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonIOException; +import com.google.gson.JsonObject; + +import de.bstly.board.controller.BaseController; +import de.bstly.board.i18n.businesslogic.I18nManager; + +/** + * @author _bastler@bstly.de + * + */ +@RestController +@RequestMapping("/i18n") +public class I18nController extends BaseController { + + @Autowired + private I18nManager i18nManager; + private Gson gson = new Gson(); + + /** + * + * @return + */ + @GetMapping + public List getLocales() { + return i18nManager.getLocales(); + } + + /** + * + * @param locale + * @param response + * @throws JsonIOException + * @throws IOException + */ + @GetMapping("/{locale}") + public void getLabel(@PathVariable("locale") String locale, HttpServletResponse response) + throws JsonIOException, IOException { + JsonObject label = i18nManager.getLabel(locale); + if (label != null) { + response.setCharacterEncoding("utf-8"); + gson.toJson(label, response.getWriter()); + } + } + + /** + * + * @param locale + * @param label + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/{locale}") + public void setLabel(@PathVariable("locale") String locale, @RequestBody Object label) { + JsonElement element = gson.toJsonTree(label); + + if (element != null && element.isJsonObject()) { + i18nManager.setLabel(locale, element.getAsJsonObject()); + } + } + + /** + * + * @param locale + * @param label + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PutMapping("/{locale}") + public void addLabel(@PathVariable("locale") String locale, @RequestBody Object label) { + JsonElement element = gson.toJsonTree(label); + + if (element != null && element.isJsonObject()) { + i18nManager.addLabel(locale, element.getAsJsonObject()); + } + } + + /** + * + * @param locale + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @DeleteMapping("/{locale}") + public void deleteLocale(@PathVariable("locale") String locale) { + i18nManager.deleteLabel(locale); + } + +} diff --git a/src/main/java/de/bstly/board/i18n/model/I18n.java b/src/main/java/de/bstly/board/i18n/model/I18n.java new file mode 100644 index 0000000..04d867e --- /dev/null +++ b/src/main/java/de/bstly/board/i18n/model/I18n.java @@ -0,0 +1,64 @@ +/** + * + */ +package de.bstly.board.i18n.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Lob; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * + * @author _bastler@bstly.de + * + */ +@Entity +@Table(name = "i18n", uniqueConstraints = @UniqueConstraint(columnNames = { "locale" })) +public class I18n { + + /** + * + */ + @Id + @Column(name = "locale", unique = true, nullable = false) + private String locale; + + /** + * + */ + @Lob + @Column(name = "label") + private String label; + + /** + * @return the locale + */ + public String getLocale() { + return locale; + } + + /** + * @param locale the locale to set + */ + public void setLocale(String locale) { + this.locale = locale; + } + + /** + * @return the label + */ + public String getLabel() { + return label; + } + + /** + * @param label the label to set + */ + public void setLabel(String label) { + this.label = label; + } + +} diff --git a/src/main/java/de/bstly/board/i18n/repository/I18nRepository.java b/src/main/java/de/bstly/board/i18n/repository/I18nRepository.java new file mode 100644 index 0000000..1ffa754 --- /dev/null +++ b/src/main/java/de/bstly/board/i18n/repository/I18nRepository.java @@ -0,0 +1,21 @@ +/** + * + */ +package de.bstly.board.i18n.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.stereotype.Repository; + +import de.bstly.board.i18n.model.I18n; + +/** + * + * @author _bastler@bstly.de + * + */ +@Repository +public interface I18nRepository + extends JpaRepository, QuerydslPredicateExecutor { + +} diff --git a/src/main/java/de/bstly/board/model/Comment.java b/src/main/java/de/bstly/board/model/Comment.java new file mode 100644 index 0000000..c265893 --- /dev/null +++ b/src/main/java/de/bstly/board/model/Comment.java @@ -0,0 +1,152 @@ +/** + * + */ +package de.bstly.board.model; + +import java.time.Instant; +import java.util.Map; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EntityListeners; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Lob; +import javax.persistence.Table; +import javax.persistence.Transient; + +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import com.google.common.collect.Maps; + +/** + * @author Lurkars + * + */ +@Entity +@Table(name = "comments") +@EntityListeners({ AuditingEntityListener.class }) +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "id", nullable = false) + private Long id; + @Column(name = "author", nullable = false) + private String author; + @Column(name = "created", nullable = false) + private Instant created; + @Column(name = "target", nullable = false) + private Long target; + @Column(name = "parent", nullable = true) + private Long parent; + @Lob + @Column(name = "text", nullable = false) + private String text; + @Transient + private Map metadata; + + /** + * @return the id + */ + public Long getId() { + return id; + } + + /** + * @param id the id to set + */ + public void setId(Long id) { + this.id = id; + } + + /** + * @return the author + */ + public String getAuthor() { + return author; + } + + /** + * @param author the author to set + */ + public void setAuthor(String author) { + this.author = author; + } + + /** + * @return the created + */ + public Instant getCreated() { + return created; + } + + /** + * @param created the created to set + */ + public void setCreated(Instant created) { + this.created = created; + } + + /** + * @return the target + */ + public Long getTarget() { + return target; + } + + /** + * @param target the target to set + */ + public void setTarget(Long target) { + this.target = target; + } + + /** + * @return the parent + */ + public Long getParent() { + return parent; + } + + /** + * @param parent the parent to set + */ + public void setParent(Long parent) { + this.parent = parent; + } + + /** + * @return the text + */ + public String getText() { + return text; + } + + /** + * @param text the text to set + */ + public void setText(String text) { + this.text = text; + } + + /** + * @return the metadata + */ + public Map getMetadata() { + if (metadata == null) { + metadata = Maps.newHashMap(); + } + + return metadata; + } + + /** + * @param metadata the metadata to set + */ + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + +} diff --git a/src/main/java/de/bstly/board/model/Entry.java b/src/main/java/de/bstly/board/model/Entry.java new file mode 100644 index 0000000..0102954 --- /dev/null +++ b/src/main/java/de/bstly/board/model/Entry.java @@ -0,0 +1,222 @@ +/** + * + */ +package de.bstly.board.model; + +import java.time.Instant; +import java.util.Map; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EntityListeners; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Lob; +import javax.persistence.Table; +import javax.persistence.Transient; + +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import com.google.common.collect.Maps; + +/** + * @author Lurkars + * + */ +@Entity +@Table(name = "entries") +@EntityListeners({ AuditingEntityListener.class }) +public class Entry { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "id", nullable = false) + private Long id; + @Column(name = "author", nullable = false) + private String author; + @Column(name = "created", nullable = false) + private Instant created; + @Enumerated(EnumType.STRING) + @Column(name = "entry_type", nullable = false) + private EntryType entryType; + @Enumerated(EnumType.STRING) + @Column(name = "entry_status", nullable = false, columnDefinition = "varchar(255) default 'NORMAL'") + private EntryStatus entryStatus; + @Column(name = "url") + private String url; + @Column(name = "title", nullable = false) + private String title; + @Lob + @Column(name = "text") + private String text; + @Transient + private Double ranking; + @Transient + private Map metadata; + + /** + * @return the id + */ + public Long getId() { + return id; + } + + /** + * @param id the id to set + */ + public void setId(Long id) { + this.id = id; + } + + /** + * @return the author + */ + public String getAuthor() { + return author; + } + + /** + * @param author the author to set + */ + public void setAuthor(String author) { + this.author = author; + } + + /** + * @return the created + */ + public Instant getCreated() { + return created; + } + + /** + * @param created the created to set + */ + public void setCreated(Instant created) { + this.created = created; + } + + /** + * @return the entryType + */ + public EntryType getEntryType() { + return entryType; + } + + /** + * @param entryType the entryType to set + */ + public void setEntryType(EntryType entryType) { + this.entryType = entryType; + } + + /** + * @return the entryStatus + */ + public EntryStatus getEntryStatus() { + return entryStatus; + } + + /** + * @param entryStatus the entryStatus to set + */ + public void setEntryStatus(EntryStatus entryStatus) { + this.entryStatus = entryStatus; + } + + /** + * @return the url + */ + public String getUrl() { + return url; + } + + /** + * @param url the url to set + */ + public void setUrl(String url) { + this.url = url; + } + + /** + * @return the title + */ + public String getTitle() { + return title; + } + + /** + * @param title the title to set + */ + public void setTitle(String title) { + this.title = title; + } + + /** + * @return the text + */ + public String getText() { + return text; + } + + /** + * @param text the text to set + */ + public void setText(String text) { + this.text = text; + } + + /** + * @return the ranking + */ + public Double getRanking() { + return ranking; + } + + /** + * @param ranking the ranking to set + */ + public void setRanking(Double ranking) { + this.ranking = ranking; + } + + /** + * @return the metadata + */ + public Map getMetadata() { + if (metadata == null) { + metadata = Maps.newHashMap(); + } + return metadata; + } + + /** + * @param metadata the metadata to set + */ + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + /** + * + * @param rankedEntry + * @return + */ + public static Entry fromRankedEntry(RankedEntry rankedEntry) { + Entry entry = new Entry(); + entry.setId(rankedEntry.getId()); + entry.setAuthor(rankedEntry.getAuthor()); + entry.setCreated(rankedEntry.getCreated()); + entry.setEntryType(rankedEntry.getEntry_Type()); + entry.setUrl(rankedEntry.getUrl()); + entry.setTitle(rankedEntry.getTitle()); + entry.setText(rankedEntry.getText()); + entry.setRanking(rankedEntry.getRanking()); + entry.getMetadata().put("points", rankedEntry.getPoints()); + return entry; + } + +} diff --git a/src/main/java/de/bstly/board/model/EntryStatus.java b/src/main/java/de/bstly/board/model/EntryStatus.java new file mode 100644 index 0000000..4a3bc82 --- /dev/null +++ b/src/main/java/de/bstly/board/model/EntryStatus.java @@ -0,0 +1,14 @@ +/** + * + */ +package de.bstly.board.model; + +/** + * @author Lurkars + * + */ +public enum EntryStatus { + + NORMAL, ARCHIVED, PINNED + +} diff --git a/src/main/java/de/bstly/board/model/EntryType.java b/src/main/java/de/bstly/board/model/EntryType.java new file mode 100644 index 0000000..9f34630 --- /dev/null +++ b/src/main/java/de/bstly/board/model/EntryType.java @@ -0,0 +1,14 @@ +/** + * + */ +package de.bstly.board.model; + +/** + * @author Lurkars + * + */ +public enum EntryType { + + DISCUSSION, INTERN, LINK, QUESTION + +} diff --git a/src/main/java/de/bstly/board/model/LocalUser.java b/src/main/java/de/bstly/board/model/LocalUser.java new file mode 100644 index 0000000..c790013 --- /dev/null +++ b/src/main/java/de/bstly/board/model/LocalUser.java @@ -0,0 +1,206 @@ +/** + * + */ +package de.bstly.board.model; + +import java.util.List; +import java.util.Map; + +import javax.persistence.CollectionTable; +import javax.persistence.Column; +import javax.persistence.ElementCollection; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Lob; +import javax.persistence.Table; +import javax.persistence.Transient; + +import org.hibernate.annotations.LazyCollection; +import org.hibernate.annotations.LazyCollectionOption; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.google.common.collect.Maps; + +/** + * @author monitoring@bstly.de + * + */ +@Entity +@Table(name = "users") +@JsonInclude(Include.NON_EMPTY) +public class LocalUser { + + @Id + @Column(name = "username", nullable = false) + private String username; + @Column(name = "external_id", nullable = true) + private String externalId; + @JsonIgnore + @Column(name = "password_hash", nullable = true) + private String passwordHash; + @ElementCollection + @LazyCollection(LazyCollectionOption.FALSE) + @CollectionTable(name = "users_roles") + private List roles; + @Lob + @Column(name = "about", nullable = true) + private String about; + @Column(name = "email", nullable = true) + private String email; + @Column(name = "locale", nullable = false, columnDefinition = "varchar(255) default 'en'") + private String locale; + @Column(name = "dark_theme", columnDefinition = "boolean default false") + private boolean darkTheme; + @ElementCollection + @LazyCollection(LazyCollectionOption.FALSE) + @CollectionTable(name = "users_settings") + private Map settings; + @Transient + private Map metadata; + + /** + * @return the username + */ + public String getUsername() { + return username; + } + + /** + * @param username the username to set + */ + public void setUsername(String username) { + this.username = username; + } + + /** + * @return the externalId + */ + public String getExternalId() { + return externalId; + } + + /** + * @param externalId the externalId to set + */ + public void setExternalId(String externalId) { + this.externalId = externalId; + } + + /** + * @return the passwordHash + */ + public String getPasswordHash() { + return passwordHash; + } + + /** + * @param passwordHash the passwordHash to set + */ + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + /** + * @return the roles + */ + public List getRoles() { + return roles; + } + + /** + * @param roles the roles to set + */ + public void setRoles(List roles) { + this.roles = roles; + } + + /** + * @return the about + */ + public String getAbout() { + return about; + } + + /** + * @param about the about to set + */ + public void setAbout(String about) { + this.about = about; + } + + /** + * @return the email + */ + public String getEmail() { + return email; + } + + /** + * @param email the email to set + */ + public void setEmail(String email) { + this.email = email; + } + + /** + * @return the locale + */ + public String getLocale() { + return locale; + } + + /** + * @param locale the locale to set + */ + public void setLocale(String locale) { + this.locale = locale; + } + + /** + * @return the darkTheme + */ + public boolean isDarkTheme() { + return darkTheme; + } + + /** + * @param darkTheme the darkTheme to set + */ + public void setDarkTheme(boolean darkTheme) { + this.darkTheme = darkTheme; + } + + /** + * @return the settings + */ + public Map getSettings() { + return settings; + } + + /** + * @param settings the settings to set + */ + public void setSettings(Map settings) { + this.settings = settings; + } + + /** + * @return the metadata + */ + public Map getMetadata() { + if (metadata == null) { + metadata = Maps.newHashMap(); + } + return metadata; + } + + /** + * @param metadata the metadata to set + */ + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + +} diff --git a/src/main/java/de/bstly/board/model/PersistentLogin.java b/src/main/java/de/bstly/board/model/PersistentLogin.java new file mode 100644 index 0000000..81dd170 --- /dev/null +++ b/src/main/java/de/bstly/board/model/PersistentLogin.java @@ -0,0 +1,87 @@ +/** + * + */ +package de.bstly.board.model; + +import java.time.Instant; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +/** + * @author monitoring@bstly.de + * + */ +@Entity +@Table(name = "persistent_logins") +public class PersistentLogin { + + @Column(name = "username", length = 64, nullable = false) + private String username; + @Id + @Column(name = "series", length = 64) + private String series; + @Column(name = "token", length = 64, nullable = false) + private String token; + @Column(name = "last_used", nullable = false) + private Instant last_used; + + /** + * @return the username + */ + public String getUsername() { + return username; + } + + /** + * @param username the username to set + */ + public void setUsername(String username) { + this.username = username; + } + + /** + * @return the series + */ + public String getSeries() { + return series; + } + + /** + * @param series the series to set + */ + public void setSeries(String series) { + this.series = series; + } + + /** + * @return the token + */ + public String getToken() { + return token; + } + + /** + * @param token the token to set + */ + public void setToken(String token) { + this.token = token; + } + + /** + * @return the last_used + */ + public Instant getLast_used() { + return last_used; + } + + /** + * @param last_used the last_used to set + */ + public void setLast_used(Instant last_used) { + this.last_used = last_used; + } + +} diff --git a/src/main/java/de/bstly/board/model/RankedEntry.java b/src/main/java/de/bstly/board/model/RankedEntry.java new file mode 100644 index 0000000..d0309a8 --- /dev/null +++ b/src/main/java/de/bstly/board/model/RankedEntry.java @@ -0,0 +1,31 @@ +/** + * + */ +package de.bstly.board.model; + +import java.time.Instant; + +/** + * @author Lurkars + * + */ +public interface RankedEntry { + + Long getId(); + + String getAuthor(); + + Instant getCreated(); + + EntryType getEntry_Type(); + + String getUrl(); + + String getTitle(); + + String getText(); + + Double getRanking(); + + Long getPoints(); +} diff --git a/src/main/java/de/bstly/board/model/Types.java b/src/main/java/de/bstly/board/model/Types.java new file mode 100644 index 0000000..5f87133 --- /dev/null +++ b/src/main/java/de/bstly/board/model/Types.java @@ -0,0 +1,13 @@ +/** + * + */ +package de.bstly.board.model; + +/** + * @author Lurkars + * + */ +public enum Types { + + comment, entry, user +} diff --git a/src/main/java/de/bstly/board/model/Vote.java b/src/main/java/de/bstly/board/model/Vote.java new file mode 100644 index 0000000..e855c38 --- /dev/null +++ b/src/main/java/de/bstly/board/model/Vote.java @@ -0,0 +1,108 @@ +/** + * + */ +package de.bstly.board.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EntityListeners; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * @author Lurkars + * + */ +@Entity +@Table(name = "votes") +@EntityListeners({ AuditingEntityListener.class }) +public class Vote { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "id") + private Long id; + @Column(name = "target", nullable = false) + private Long target; + @Column(name = "target_type", nullable = false) + private Types targetType; + @Column(name = "author", nullable = false) + private String author; + @Column(name = "type", nullable = false) + private VoteType type; + + /** + * @return the id + */ + public Long getId() { + return id; + } + + /** + * @param id the id to set + */ + public void setId(Long id) { + this.id = id; + } + + /** + * @return the target + */ + public Long getTarget() { + return target; + } + + /** + * @param target the target to set + */ + public void setTarget(Long target) { + this.target = target; + } + + /** + * @return the targetType + */ + public Types getTargetType() { + return targetType; + } + + /** + * @param targetType the targetType to set + */ + public void setTargetType(Types targetType) { + this.targetType = targetType; + } + + /** + * @return the author + */ + public String getAuthor() { + return author; + } + + /** + * @param author the author to set + */ + public void setAuthor(String author) { + this.author = author; + } + + /** + * @return the type + */ + public VoteType getType() { + return type; + } + + /** + * @param type the type to set + */ + public void setType(VoteType type) { + this.type = type; + } + +} diff --git a/src/main/java/de/bstly/board/model/VoteType.java b/src/main/java/de/bstly/board/model/VoteType.java new file mode 100644 index 0000000..de909a3 --- /dev/null +++ b/src/main/java/de/bstly/board/model/VoteType.java @@ -0,0 +1,12 @@ +/** + * + */ +package de.bstly.board.model; + +/** + * @author Lurkars + * + */ +public enum VoteType { + up, down +} diff --git a/src/main/java/de/bstly/board/repository/CommentRepository.java b/src/main/java/de/bstly/board/repository/CommentRepository.java new file mode 100644 index 0000000..990ff20 --- /dev/null +++ b/src/main/java/de/bstly/board/repository/CommentRepository.java @@ -0,0 +1,45 @@ +/** + * + */ +package de.bstly.board.repository; + +import java.time.Instant; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import de.bstly.board.model.Comment; + +/** + * + * @author monitoring@bstly.de + * + */ +@Repository +public interface CommentRepository + extends JpaRepository, QuerydslPredicateExecutor { + + @Query(value = "SELECT comment.*, ranked.ranking FROM comments AS comment LEFT JOIN (SELECT comment.id, (IFNULL(upvote.count,0) - IFNULL(downvote.count,0)) / POW(TIMESTAMPDIFF(HOUR, comment.created, :date)+2,:gravity) AS ranking FROM comments AS comment LEFT JOIN (SELECT upvote.target,COUNT(upvote.id) AS count FROM votes as upvote WHERE upvote.type = 0 AND upvote.target_type = 0 GROUP BY upvote.target) AS upvote ON upvote.target = comment.id LEFT JOIN (SELECT downvote.target,COUNT(downvote.id) AS count FROM votes as downvote WHERE downvote.type = 1 GROUP BY downvote.target) AS downvote ON downvote.target = comment.id) as ranked on ranked.id = comment.id WHERE comment.target = :target AND parent IS NULL AND comment.created < :date ORDER BY ranked.ranking DESC, comment.created DESC", countQuery = "SELECT count(*) FROM comments as comment WHERE comment.target = :target AND parent IS NULL AND comment.created < :date", nativeQuery = true) + List findAllByRankingAndParent(@Param("target") Long target, + @Param("date") Instant date, @Param("gravity") double gravity); + + @Query(value = "SELECT comment.*, ranked.ranking FROM comments AS comment LEFT JOIN (SELECT comment.id, (IFNULL(upvote.count,0) - IFNULL(downvote.count,0)) / POW(TIMESTAMPDIFF(HOUR, comment.created, :date)+2,:gravity) AS ranking FROM comments AS comment LEFT JOIN (SELECT upvote.target,COUNT(upvote.id) AS count FROM votes as upvote WHERE upvote.type = 0 AND upvote.target_type = 0 GROUP BY upvote.target) AS upvote ON upvote.target = comment.id LEFT JOIN (SELECT downvote.target,COUNT(downvote.id) AS count FROM votes as downvote WHERE downvote.type = 1 GROUP BY downvote.target) AS downvote ON downvote.target = comment.id) as ranked on ranked.id = comment.id WHERE comment.target = :target AND parent = :parent AND comment.created < :date ORDER BY ranked.ranking DESC, comment.created DESC", countQuery = "SELECT count(*) FROM comments as comment WHERE comment.target = :target AND parent = :parent AND comment.created < :date", nativeQuery = true) + List findAllByRankingAndParent(@Param("target") Long target, + @Param("parent") Long parent, @Param("date") Instant date, + @Param("gravity") double gravity); + + @Query(value = "SELECT comment.*, ranked.ranking FROM comments AS comment LEFT JOIN (SELECT comment.id, (IFNULL(upvote.count,0) - IFNULL(downvote.count,0)) / POW(TIMESTAMPDIFF(HOUR, comment.created, :date)+2,:gravity) AS ranking FROM comments AS comment LEFT JOIN (SELECT upvote.target,COUNT(upvote.id) AS count FROM votes as upvote WHERE upvote.type = 0 AND upvote.target_type = 0 GROUP BY upvote.target) AS upvote ON upvote.target = comment.id LEFT JOIN (SELECT downvote.target,COUNT(downvote.id) AS count FROM votes as downvote WHERE downvote.type = 1 GROUP BY downvote.target) AS downvote ON downvote.target = comment.id) as ranked on ranked.id = comment.id WHERE comment.target = :target AND parent IS NULL AND comment.created < :date ORDER BY ranked.ranking DESC, comment.created DESC", countQuery = "SELECT count(*) FROM comments as comment WHERE comment.target = :target AND parent IS NULL AND comment.created < :date", nativeQuery = true) + Page findAllByRankingAndParent(@Param("target") Long target, + @Param("date") Instant date, @Param("gravity") double gravity, Pageable pageable); + + @Query(value = "SELECT comment.*, ranked.ranking FROM comments AS comment LEFT JOIN (SELECT comment.id, (IFNULL(upvote.count,0) - IFNULL(downvote.count,0)) / POW(TIMESTAMPDIFF(HOUR, comment.created, :date)+2,:gravity) AS ranking FROM comments AS comment LEFT JOIN (SELECT upvote.target,COUNT(upvote.id) AS count FROM votes as upvote WHERE upvote.type = 0 AND upvote.target_type = 0 GROUP BY upvote.target) AS upvote ON upvote.target = comment.id LEFT JOIN (SELECT downvote.target,COUNT(downvote.id) AS count FROM votes as downvote WHERE downvote.type = 1 GROUP BY downvote.target) AS downvote ON downvote.target = comment.id) as ranked on ranked.id = comment.id WHERE comment.target = :target AND parent = :parent AND comment.created < :date ORDER BY ranked.ranking DESC, comment.created DESC", countQuery = "SELECT count(*) FROM comments as comment WHERE comment.target = :target AND parent = :parent AND comment.created < :date", nativeQuery = true) + Page findAllByRankingAndParent(@Param("target") Long target, + @Param("parent") Long parent, @Param("date") Instant date, + @Param("gravity") double gravity, Pageable pageable); +} diff --git a/src/main/java/de/bstly/board/repository/EntryRepository.java b/src/main/java/de/bstly/board/repository/EntryRepository.java new file mode 100644 index 0000000..be15c90 --- /dev/null +++ b/src/main/java/de/bstly/board/repository/EntryRepository.java @@ -0,0 +1,58 @@ +/** + * + */ +package de.bstly.board.repository; + +import java.time.Instant; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import de.bstly.board.model.Entry; +import de.bstly.board.model.RankedEntry; + +/** + * + * @author monitoring@bstly.de + * + */ +@Repository +public interface EntryRepository + extends JpaRepository, QuerydslPredicateExecutor { + + static final String UPVOTES_QUERY = "SELECT upvote.target,COUNT(upvote.id) AS count FROM votes as upvote WHERE upvote.type = 0 AND upvote.target_type = 1 GROUP BY upvote.target"; + + static final String DOWNVOTES_QUERY = "SELECT downvote.target,COUNT(downvote.id) AS count FROM votes as downvote WHERE downvote.type = 1 AND downvote.target_type = 1 GROUP BY downvote.target"; + + static final String CALCULATION_QUERY = "SELECT entry.*, (IFNULL(upvote.count,0) - IFNULL(downvote.count,0)) as points, (IFNULL(upvote.count,0) - IFNULL(downvote.count,0)) / POW(TIMESTAMPDIFF(HOUR, entry.created, :before)+2,:gravity) AS ranking FROM entries AS entry LEFT JOIN (" + + UPVOTES_QUERY + + ") AS upvote ON upvote.target = entry.id LEFT JOIN (" + + DOWNVOTES_QUERY + + ") AS downvote ON downvote.target = entry.id WHERE entry.created < :before AND entry.entry_status = 'NORMAL' ORDER BY ranking DESC, entry.created DESC"; + + static final String ARCHIVE_CALCULATION_QUERY = "SELECT entry.*, (IFNULL(upvote.count,0) - IFNULL(downvote.count,0)) as points, (IFNULL(upvote.count,0) - IFNULL(downvote.count,0)) / POW(TIMESTAMPDIFF(HOUR, entry.created, :before)+2,:gravity) AS ranking FROM entries AS entry LEFT JOIN (" + + UPVOTES_QUERY + + ") AS upvote ON upvote.target = entry.id LEFT JOIN (" + + DOWNVOTES_QUERY + + ") AS downvote ON downvote.target = entry.id WHERE entry.created < :before ORDER BY ranking DESC, entry.created DESC"; + + static final String ADDITIONAL_QUERY = "SELECT entry.*, calculation.ranking, calculation.points FROM entries AS entry LEFT JOIN (" + + CALCULATION_QUERY + + ") as calculation on calculation.id = entry.id WHERE entry.created < :before ORDER BY calculation.ranking DESC, entry.created DESC"; + + static final String COUNT_QUERY = "SELECT count(*) FROM entries as entry WHERE entry.created < :before"; + + @Query(value = CALCULATION_QUERY, countQuery = COUNT_QUERY, nativeQuery = true) + Page findAllByRanking(@Param("before") Instant before, + @Param("gravity") double gravity, Pageable pageable); + + @Query(value = ARCHIVE_CALCULATION_QUERY, countQuery = COUNT_QUERY, nativeQuery = true) + Page findAllByRankingArchive(@Param("before") Instant before, + @Param("gravity") double gravity, Pageable pageable); + +} diff --git a/src/main/java/de/bstly/board/repository/LocalUserRepository.java b/src/main/java/de/bstly/board/repository/LocalUserRepository.java new file mode 100644 index 0000000..9f5e1fe --- /dev/null +++ b/src/main/java/de/bstly/board/repository/LocalUserRepository.java @@ -0,0 +1,21 @@ +/** + * + */ +package de.bstly.board.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.stereotype.Repository; + +import de.bstly.board.model.LocalUser; + +/** + * + * @author monitoring@bstly.de + * + */ +@Repository +public interface LocalUserRepository + extends JpaRepository, QuerydslPredicateExecutor { + +} diff --git a/src/main/java/de/bstly/board/repository/VoteRepository.java b/src/main/java/de/bstly/board/repository/VoteRepository.java new file mode 100644 index 0000000..99339b5 --- /dev/null +++ b/src/main/java/de/bstly/board/repository/VoteRepository.java @@ -0,0 +1,20 @@ +/** + * + */ +package de.bstly.board.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.stereotype.Repository; + +import de.bstly.board.model.Vote; + +/** + * + * @author monitoring@bstly.de + * + */ +@Repository +public interface VoteRepository extends JpaRepository, QuerydslPredicateExecutor { + +} diff --git a/src/main/java/de/bstly/board/security/OAuth2AuthenticationSuccessHandler.java b/src/main/java/de/bstly/board/security/OAuth2AuthenticationSuccessHandler.java new file mode 100644 index 0000000..4dcfb4e --- /dev/null +++ b/src/main/java/de/bstly/board/security/OAuth2AuthenticationSuccessHandler.java @@ -0,0 +1,50 @@ +/** + * + */ +package de.bstly.board.security; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import de.bstly.board.businesslogic.UserManager; +import de.bstly.board.model.LocalUser; + +/** + * @author Lurkars + * + */ +@Component +public class OAuth2AuthenticationSuccessHandler + extends SavedRequestAwareAuthenticationSuccessHandler { + + @Autowired + private UserManager localUserManager; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + LocalUser localUser = localUserManager.getByAuth(authentication); + + User user = new User(localUser.getUsername(), "", authentication.getAuthorities()); + + UsernamePasswordAuthenticationToken newAuthentication = new UsernamePasswordAuthenticationToken( + user, null, authentication.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(newAuthentication); + + handle(request, response, newAuthentication); + clearAuthenticationAttributes(request); + } + +} diff --git a/src/main/java/de/bstly/board/security/SecurityConfig.java b/src/main/java/de/bstly/board/security/SecurityConfig.java new file mode 100755 index 0000000..47fc380 --- /dev/null +++ b/src/main/java/de/bstly/board/security/SecurityConfig.java @@ -0,0 +1,111 @@ +/** + * + */ +package de.bstly.board.security; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.RememberMeServices; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; +import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; +import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +import de.bstly.board.businesslogic.UserManager; + +/** + * + * @author monitoring@bstly.de + * + */ +@EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled = true) +public class SecurityConfig extends WebSecurityConfigurerAdapter { + + @Autowired + private UserManager localUserManager; + @Autowired + private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; + @Autowired + private DataSource dataSource; + @Value("${loginUrl:/login}") + private String loginUrl; + @Value("${loginTargetUrl:/}") + private String loginTargetUrl; + + /* + * + * @see org.springframework.security.config.annotation.web.configuration. + * WebSecurityConfigurerAdapter#configure(org.springframework.security.config. + * annotation.web.builders.HttpSecurity) + */ + @Override + protected void configure(HttpSecurity http) throws Exception { + + oAuth2AuthenticationSuccessHandler.setDefaultTargetUrl(loginTargetUrl); + + http + // crsf + .csrf().disable() + // anonymous + .anonymous().disable() + // login + .formLogin().loginPage("/login").defaultSuccessUrl(loginTargetUrl) + .failureHandler(new SimpleUrlAuthenticationFailureHandler(loginUrl + + "?error")) + .and() + // remember me + .rememberMe().rememberMeServices(rememberMeServices()).and() + // logout + .logout().logoutUrl("/logout") + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK)) + .and() + // exception + .exceptionHandling() + .defaultAuthenticationEntryPointFor( + new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), + new AntPathRequestMatcher("/api/**")) + .and() + // oidc + .oauth2Login().successHandler(oAuth2AuthenticationSuccessHandler) + .failureHandler(new SimpleUrlAuthenticationFailureHandler(loginUrl + + "?externalError")) + .loginPage("/login"); + } + + /** + * + * @return + */ + @Bean(name = "passwordEncoder") + public Argon2PasswordEncoder passwordEncoder() { + return new Argon2PasswordEncoder(); + } + + @Bean + public PersistentTokenRepository persistentTokenRepository() { + JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); + tokenRepository.setDataSource(dataSource); + return tokenRepository; + } + + @Bean + public RememberMeServices rememberMeServices() { + PersistentTokenBasedRememberMeServices rememberMeServices = new PersistentTokenBasedRememberMeServices( + "remember-me", localUserManager, persistentTokenRepository()); + return rememberMeServices; + } + +} diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties new file mode 100644 index 0000000..326f99d --- /dev/null +++ b/src/main/resources/messages.properties @@ -0,0 +1,46 @@ +# index +login=Login +login.error=Credentials not valid. +login.external=Login with {0} +logout=Logout +pagination.first=First +pagination.prev=Previous +pagination.next=Next +pagination.last=Last +username=Username +password=Password +rememberMe=Remember Me +settings=Settings +submit=Submit +top=Top +new=New + +comments={0} comments + + +entry.title=title +entry.url=url +entry.text=text +entry.type=type +entry.vote=vote +entry.unvote=unvote +entry.votes={0} points +entry.createdBy=by {0} + +entryType.LINK=link +entryType.DISCUSSION=discussion +entryType.QUESTION=question +entryType.INTERN=intern + +entryType.LINK.bicon=bi-link-45deg +entryType.DISCUSSION.bicon=bi-chat-text +entryType.QUESTION.bicon=bi-question-square +entryType.INTERN.bicon=bi-shield-lock + +REQUIRED.entry.title=title is required +REQUIRED.entry.type=type is required +REQUIRED.entry.url=url is required +INVALID.entry.url=url is invalid + +entries.empty=No entries found. +submit.info=Create a new entry.