Compare commits
18 Commits
a44391dcf7
...
c5f0d5443d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5f0d5443d | ||
|
|
6d907a725d | ||
|
|
2f2eacbcef | ||
|
|
a217ef0363 | ||
|
|
3530e9e28d | ||
|
|
6e4e358dee | ||
|
|
7b7378e30b | ||
|
|
fe4932fff5 | ||
|
|
4bf7fb22c6 | ||
|
|
9a41c4caae | ||
|
|
2736f3ddf6 | ||
|
|
27441efde7 | ||
|
|
8580d4cfa6 | ||
|
|
8d4c5e1b3a | ||
|
|
8d7cbcc165 | ||
|
|
c771dee930 | ||
|
|
42a1b7466b | ||
|
|
7583c00749 |
12
.idea/dataSources.xml
generated
Normal file
12
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="sweater_db@localhost" uuid="df35570d-4519-439c-b57d-a357161e7809">
|
||||
<driver-ref>mysql.8</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:mysql://localhost:3306/sweater_db</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
17
compose.yml
17
compose.yml
@@ -1,10 +1,11 @@
|
||||
services:
|
||||
postgres:
|
||||
restart: on-failure
|
||||
image: postgres:13
|
||||
ports:
|
||||
- "5454:5432"
|
||||
mysql:
|
||||
image: 'mysql:latest'
|
||||
environment:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: 123456
|
||||
POSTGRES_DB: sweater_db
|
||||
- 'MYSQL_DATABASE=sweater_db'
|
||||
- 'MYSQL_PASSWORD=123456'
|
||||
- 'MYSQL_ROOT_PASSWORD=verysecret'
|
||||
- 'MYSQL_USER=user'
|
||||
ports:
|
||||
- '3306:3306'
|
||||
|
||||
|
||||
56
pom.xml
56
pom.xml
@@ -24,14 +24,12 @@
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mustache</artifactId>
|
||||
<artifactId>spring-boot-starter-freemarker</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
@@ -43,7 +41,26 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>mysql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-testcontainers</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<version>RELEASE</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
@@ -55,12 +72,36 @@
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-core</artifactId>
|
||||
<version>6.3.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-mysql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
@@ -69,5 +110,4 @@
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package net.sytes.kashey.sweater;
|
||||
|
||||
import net.sytes.kashey.sweater.domain.Message;
|
||||
import net.sytes.kashey.sweater.repository.MessageRepository;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
public class GreetingController {
|
||||
|
||||
private final MessageRepository messageRepository;
|
||||
|
||||
public GreetingController(MessageRepository messageRepository) {
|
||||
this.messageRepository = messageRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/greeting")
|
||||
public String greeting(@RequestParam(name = "name", required = false, defaultValue = "World") String name,
|
||||
Map<String, Object> model) {
|
||||
model.put("name", name);
|
||||
return "greeting";
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String findAllMessages(Map<String, Object> model) {
|
||||
|
||||
model.put("messages", messageRepository.findAll());
|
||||
return "main";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String addMessage(@RequestParam String text, @RequestParam String tag, Map<String, Object> model) {
|
||||
|
||||
messageRepository.save(new Message(text, tag));
|
||||
model.put("messages", messageRepository.findAll());
|
||||
return "main";
|
||||
}
|
||||
|
||||
@PostMapping("filter")
|
||||
public String filterByTag(@RequestParam String tag, Map<String, Object> model) {
|
||||
|
||||
if (tag == null || tag.isEmpty()) {
|
||||
model.put("messages", messageRepository.findAll());
|
||||
} else {
|
||||
model.put("messages", messageRepository.findByTag(tag));
|
||||
}
|
||||
return "main";
|
||||
}
|
||||
}
|
||||
34
src/main/java/net/sytes/kashey/sweater/config/MvcConfig.java
Normal file
34
src/main/java/net/sytes/kashey/sweater/config/MvcConfig.java
Normal file
@@ -0,0 +1,34 @@
|
||||
package net.sytes.kashey.sweater.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class MvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Value("${upload.path}")
|
||||
private String uploadPath;
|
||||
|
||||
@Bean
|
||||
public RestTemplate restTemplate() {
|
||||
return new RestTemplate();
|
||||
}
|
||||
|
||||
public void addViewControllers(ViewControllerRegistry registry) {
|
||||
|
||||
registry.addViewController("/login").setViewName("login");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/img/**")
|
||||
.addResourceLocations("file://" + uploadPath + "/");
|
||||
registry.addResourceHandler("/static/**")
|
||||
.addResourceLocations("classpath:/static/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package net.sytes.kashey.sweater.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
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.configurers.LogoutConfigurer;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
public class WebSecurityConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder(8);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.authorizeHttpRequests((requests) -> requests
|
||||
.requestMatchers("/", "/registration", "/static/**", "/activate/*").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.formLogin((form) -> form
|
||||
.loginPage("/login")
|
||||
.defaultSuccessUrl("/main", true)
|
||||
.permitAll()
|
||||
)
|
||||
.logout(LogoutConfigurer::permitAll);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package net.sytes.kashey.sweater.controller;
|
||||
|
||||
import net.sytes.kashey.sweater.domain.Message;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collector;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class ControllerUtils {
|
||||
static Map<String, String> getErrors(BindingResult bindingResult) {
|
||||
|
||||
Collector<FieldError, ?, Map<String, String>> collector = Collectors.toMap(
|
||||
fieldError -> fieldError.getField() + "Error",
|
||||
FieldError::getDefaultMessage
|
||||
);
|
||||
return bindingResult.getFieldErrors().stream().collect(collector);
|
||||
}
|
||||
|
||||
static void saveFile(Message message, MultipartFile file, String uploadPath) throws IOException {
|
||||
if (file != null && !Objects.requireNonNull(file.getOriginalFilename()).isEmpty()) {
|
||||
File uploadDir = new File(uploadPath); // Директория на сервере, куда помещаем пришедший файл
|
||||
if (!uploadDir.exists()) {
|
||||
uploadDir.mkdirs();
|
||||
}
|
||||
String uuidFile = UUID.randomUUID().toString();
|
||||
String resultFilename = uuidFile + "." + file.getOriginalFilename(); // случайный уид + имя файла с клиента
|
||||
file.transferTo(new File(uploadPath + "/" + resultFilename)); // помещаем переименованный файл с клиента в директорию сервера
|
||||
message.setFilename(resultFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package net.sytes.kashey.sweater.controller;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import net.sytes.kashey.sweater.domain.Message;
|
||||
import net.sytes.kashey.sweater.domain.User;
|
||||
import net.sytes.kashey.sweater.domain.dto.MessageDto;
|
||||
import net.sytes.kashey.sweater.repository.MessageRepository;
|
||||
import net.sytes.kashey.sweater.security.CustomUserDetails;
|
||||
import net.sytes.kashey.sweater.service.MessageService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
public class MainController {
|
||||
|
||||
private final MessageRepository messageRepository;
|
||||
private final MessageService messageService;
|
||||
|
||||
@Value("${upload.path}")
|
||||
private String uploadPath;
|
||||
|
||||
public MainController(MessageRepository messageRepository, MessageService messageService) {
|
||||
this.messageRepository = messageRepository;
|
||||
this.messageService = messageService;
|
||||
|
||||
}
|
||||
|
||||
@GetMapping("/")
|
||||
public String greeting() {
|
||||
return "greeting";
|
||||
}
|
||||
|
||||
@GetMapping("/main")
|
||||
public String findAllMessages(@RequestParam(required = false, defaultValue = "") String tag,
|
||||
Model model,
|
||||
@PageableDefault(sort = {"id"}, direction = Sort.Direction.DESC) Pageable pageable,
|
||||
@AuthenticationPrincipal CustomUserDetails customUserDetails) {
|
||||
|
||||
Page<MessageDto> page = messageService.getFilteredMessagePage(tag, pageable, customUserDetails);
|
||||
model.addAttribute("page", page);
|
||||
model.addAttribute("tag", tag);
|
||||
model.addAttribute("url", "/main");
|
||||
return "main";
|
||||
}
|
||||
|
||||
@PostMapping("/main")
|
||||
public String addMessage(@AuthenticationPrincipal CustomUserDetails customUserDetails,
|
||||
@Valid Message message,
|
||||
BindingResult bindingResult,
|
||||
Model model,
|
||||
@PageableDefault(sort = {"id"}, direction = Sort.Direction.DESC) Pageable pageable,
|
||||
@RequestParam("file") MultipartFile file
|
||||
) throws IOException {
|
||||
|
||||
User user = customUserDetails.getUser();
|
||||
message.setAuthor(user);
|
||||
|
||||
if (bindingResult.hasErrors()) {
|
||||
Map<String, String> errorsMap = ControllerUtils.getErrors(bindingResult);
|
||||
model.mergeAttributes(errorsMap);
|
||||
model.addAttribute("message", message);
|
||||
} else {
|
||||
ControllerUtils.saveFile(message, file, uploadPath);
|
||||
messageRepository.save(message);
|
||||
model.addAttribute("message", null);
|
||||
}
|
||||
model.addAttribute("page", messageRepository.findAll(pageable, user));
|
||||
model.addAttribute("url", "/main");
|
||||
model.addAttribute("tag", "");
|
||||
return "main";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package net.sytes.kashey.sweater.controller;
|
||||
|
||||
|
||||
import jakarta.transaction.Transactional;
|
||||
import net.sytes.kashey.sweater.domain.Message;
|
||||
import net.sytes.kashey.sweater.domain.User;
|
||||
import net.sytes.kashey.sweater.domain.dto.MessageDto;
|
||||
import net.sytes.kashey.sweater.repository.MessageRepository;
|
||||
import net.sytes.kashey.sweater.security.CustomUserDetails;
|
||||
import net.sytes.kashey.sweater.service.MessageService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
import org.springframework.web.util.UriComponents;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Set;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/")
|
||||
public class MessageController {
|
||||
|
||||
private final MessageRepository messageRepository;
|
||||
private final MessageService messageService;
|
||||
|
||||
@Value("${upload.path}")
|
||||
private String uploadPath;
|
||||
|
||||
public MessageController(MessageRepository messageRepository, MessageService messageService) {
|
||||
|
||||
this.messageRepository = messageRepository;
|
||||
this.messageService = messageService;
|
||||
}
|
||||
|
||||
@GetMapping("/user-messages/{author}")
|
||||
public String getUserMessages(
|
||||
@PathVariable User author,
|
||||
@RequestParam(required = false) Message message,
|
||||
@AuthenticationPrincipal CustomUserDetails customUserDetails,
|
||||
@PageableDefault(sort = {"id"}, direction = Sort.Direction.DESC) Pageable pageable,
|
||||
Model model) {
|
||||
|
||||
User currentUser = customUserDetails.getUser();
|
||||
model.addAttribute("isCurrentUser", author.equals(currentUser));
|
||||
model.addAttribute("message", message);
|
||||
model.addAttribute("userChannel", author);
|
||||
model.addAttribute("subscribersCount", author.getSubscribers().size());
|
||||
model.addAttribute("subscriptionsCount", author.getSubscriptions().size());
|
||||
model.addAttribute("isSubscriber", author.getSubscribers().contains(currentUser));
|
||||
model.addAttribute("url", "/user-messages/" + author.getId());
|
||||
Page<MessageDto> page = messageService.getMessagesByUserPage(currentUser, pageable, author);
|
||||
model.addAttribute("page", page);
|
||||
|
||||
return "user_messages";
|
||||
}
|
||||
|
||||
@PostMapping("/user-messages/{userId}")
|
||||
public String getUserMessage(@AuthenticationPrincipal CustomUserDetails customUserDetails,
|
||||
@PathVariable(name = "userId") Long userId,
|
||||
@ModelAttribute Message message,
|
||||
@RequestParam String text,
|
||||
@RequestParam String tag,
|
||||
@RequestParam("file") MultipartFile file
|
||||
) throws IOException {
|
||||
|
||||
message.setAuthor(customUserDetails.getUser());
|
||||
|
||||
if (!ObjectUtils.isEmpty(text)) {
|
||||
message.setText(text);
|
||||
}
|
||||
|
||||
if (!ObjectUtils.isEmpty(tag)) {
|
||||
message.setTag(tag);
|
||||
}
|
||||
|
||||
ControllerUtils.saveFile(message, file, uploadPath);
|
||||
messageRepository.save(message);
|
||||
return "redirect:/user-messages/" + userId;
|
||||
}
|
||||
|
||||
@GetMapping("/messages/{message}/like")
|
||||
@Transactional
|
||||
public String like(@AuthenticationPrincipal CustomUserDetails customUserDetails,
|
||||
@PathVariable Message message,
|
||||
RedirectAttributes redirectAttributes,
|
||||
@RequestHeader(required = false) String referer) {
|
||||
|
||||
User currentUser = customUserDetails.getUser();
|
||||
Set<User> likes = message.getLikes();
|
||||
|
||||
if (likes.contains(currentUser)) {
|
||||
likes.remove(currentUser);
|
||||
} else {
|
||||
likes.add(currentUser);
|
||||
}
|
||||
|
||||
UriComponents components = UriComponentsBuilder.fromHttpUrl(referer).build();
|
||||
|
||||
components.getQueryParams()
|
||||
.entrySet()
|
||||
.forEach(pair -> redirectAttributes.addAttribute(pair.getKey(), pair.getValue()));
|
||||
|
||||
return "redirect:" + components.getPath();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package net.sytes.kashey.sweater.controller;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import net.sytes.kashey.sweater.domain.User;
|
||||
import net.sytes.kashey.sweater.domain.dto.CaptchaResponseDto;
|
||||
import net.sytes.kashey.sweater.service.CustomUserDetailsService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Objects;
|
||||
|
||||
@Controller
|
||||
public class RegistrationController {
|
||||
|
||||
private final static String CAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify?secret=%s&response=%s";
|
||||
private final CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
@Value("${recaptcha.secret}")
|
||||
private String recaptchaSecret;
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public RegistrationController(CustomUserDetailsService customUserDetailsService, RestTemplate restTemplate) {
|
||||
this.customUserDetailsService = customUserDetailsService;
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
@GetMapping("/registration")
|
||||
public String registration() {
|
||||
|
||||
return "registration";
|
||||
}
|
||||
|
||||
@PostMapping("/registration")
|
||||
public String addUser(@RequestParam(name = "g-recaptcha-response") String captchaResponse,
|
||||
@RequestParam(name = "password2") String passwordConfirmation,
|
||||
@ModelAttribute @Valid User user,
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
String url = String.format(CAPTCHA_URL, recaptchaSecret, captchaResponse);
|
||||
CaptchaResponseDto responseDto = restTemplate.postForObject(url, Collections.EMPTY_LIST, CaptchaResponseDto.class);
|
||||
|
||||
boolean isCaptchaEmpty = !Objects.requireNonNull(responseDto).isSuccess();
|
||||
|
||||
if (isCaptchaEmpty) {
|
||||
model.addAttribute("captchaError", "Fill the captcha");
|
||||
}
|
||||
|
||||
if (user.getPassword() != null && !user.getPassword().equals(passwordConfirmation)) {
|
||||
model.addAttribute("passwordError", "Passwords do not match!");
|
||||
}
|
||||
|
||||
boolean isConfirmationEmpty = ObjectUtils.isEmpty(passwordConfirmation);
|
||||
|
||||
if (isConfirmationEmpty) {
|
||||
model.addAttribute("password2Error", "Password confirmation must not be empty.");
|
||||
}
|
||||
|
||||
if (bindingResult.hasErrors() || isConfirmationEmpty || isCaptchaEmpty) {
|
||||
model.mergeAttributes(ControllerUtils.getErrors(bindingResult));
|
||||
return "registration";
|
||||
}
|
||||
|
||||
String result = customUserDetailsService.addUser(user);
|
||||
if (!result.isEmpty()) {
|
||||
model.addAttribute("usernameError", result);
|
||||
return "registration";
|
||||
} else {
|
||||
return "redirect:/login";
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/activate/{activateCode}")
|
||||
public String activateAccount(Model model, @PathVariable String activateCode) {
|
||||
|
||||
if (customUserDetailsService.activateUser(activateCode)) {
|
||||
model.addAttribute("messageType", "success");
|
||||
model.addAttribute("message", "User successfully activated!");
|
||||
} else {
|
||||
model.addAttribute("messageType", "danger");
|
||||
model.addAttribute("message", "User activation is failed!");
|
||||
}
|
||||
|
||||
return "login";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package net.sytes.kashey.sweater.controller;
|
||||
|
||||
import net.sytes.kashey.sweater.domain.Role;
|
||||
import net.sytes.kashey.sweater.domain.User;
|
||||
import net.sytes.kashey.sweater.security.CustomUserDetails;
|
||||
import net.sytes.kashey.sweater.service.CustomUserDetailsService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/users")
|
||||
|
||||
public class UserController {
|
||||
|
||||
private final CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
public UserController(CustomUserDetailsService customUserDetailsService) {
|
||||
|
||||
this.customUserDetailsService = customUserDetailsService;
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('ADMIN')")
|
||||
@GetMapping
|
||||
public String getAllUsers(Model model) {
|
||||
|
||||
model.addAttribute("users", customUserDetailsService.findAll());
|
||||
return "userList";
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('ADMIN')")
|
||||
@GetMapping("/{user}")
|
||||
public String editUser(@PathVariable User user, Model model) {
|
||||
|
||||
model.addAttribute("user", user);
|
||||
model.addAttribute("roles", Role.values());
|
||||
return "userEdit";
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('ADMIN')")
|
||||
@PostMapping
|
||||
public String updateUser(@RequestParam String username,
|
||||
@RequestParam Map<String, String> form,
|
||||
@RequestParam("userId") User user) {
|
||||
|
||||
customUserDetailsService.updateUser(username, form, user);
|
||||
return "redirect:/users";
|
||||
}
|
||||
|
||||
@GetMapping("/profile")
|
||||
public String getProfile(Model model, @AuthenticationPrincipal CustomUserDetails customUserDetails) {
|
||||
model.addAttribute("username", customUserDetails.getUsername());
|
||||
model.addAttribute("email", customUserDetails.getEmail());
|
||||
model.addAttribute("password", customUserDetails.getPassword());
|
||||
customUserDetails.getPassword();
|
||||
return "profile";
|
||||
}
|
||||
|
||||
@PostMapping("/profile")
|
||||
public String updateProfile(@AuthenticationPrincipal CustomUserDetails customUserDetails,
|
||||
@RequestParam String password,
|
||||
@RequestParam String email) {
|
||||
|
||||
customUserDetailsService.updateProfile(customUserDetails.getUser(), password, email);
|
||||
return "redirect:/users/profile";
|
||||
}
|
||||
|
||||
@GetMapping("/{type}/{user}/list")
|
||||
public String getSubscribtions(@PathVariable User user,
|
||||
@PathVariable String type,
|
||||
Model model) {
|
||||
model.addAttribute("userChannel", user);
|
||||
model.addAttribute("type", type);
|
||||
|
||||
if ("subscribers".equals(type)) {
|
||||
model.addAttribute("users", user.getSubscribers());
|
||||
} else if ("subscriptions".equals(type)) {
|
||||
model.addAttribute("users", user.getSubscriptions());
|
||||
}
|
||||
|
||||
return "subscriptions";
|
||||
}
|
||||
|
||||
@GetMapping("/subscribe/{user}")
|
||||
public String subscribe(@AuthenticationPrincipal CustomUserDetails customUserDetails,
|
||||
@PathVariable User user) {
|
||||
|
||||
User currentUser = customUserDetails.getUser();
|
||||
customUserDetailsService.subscribe(currentUser, user);
|
||||
return "redirect:/user-messages/" + user.getId();
|
||||
}
|
||||
|
||||
@GetMapping("/unsubscribe/{user}")
|
||||
public String unsubscribe(@AuthenticationPrincipal CustomUserDetails customUserDetails,
|
||||
@PathVariable User user) {
|
||||
|
||||
User currentUser = customUserDetails.getUser();
|
||||
customUserDetailsService.unsubscribe(currentUser, user);
|
||||
return "redirect:/user-messages/" + user.getId();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,53 @@
|
||||
package net.sytes.kashey.sweater.domain;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import net.sytes.kashey.sweater.domain.util.MessageHelper;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Entity
|
||||
public class Message {
|
||||
@Id
|
||||
@GeneratedValue(strategy= GenerationType.AUTO)
|
||||
private Integer id;
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@NotBlank(message = "Please fill in the field.")
|
||||
@Length(max = 2048, message = "Message too long.")
|
||||
private String text;
|
||||
@Length(max = 255, message = "Tag too long.")
|
||||
private String tag;
|
||||
|
||||
private String filename;
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User author;
|
||||
|
||||
@ManyToMany
|
||||
@JoinTable(
|
||||
name = "message_likes",
|
||||
joinColumns = {@JoinColumn(name = "message_id")},
|
||||
inverseJoinColumns = {@JoinColumn(name = "user_id")}
|
||||
)
|
||||
private Set<User> likes = new HashSet<>();
|
||||
|
||||
public Message() {
|
||||
}
|
||||
|
||||
public Message(String text, String tag) {
|
||||
public Message(String text, String tag, User author) {
|
||||
this.text = text;
|
||||
this.tag = tag;
|
||||
this.author = author;
|
||||
}
|
||||
|
||||
public Integer getId() {
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Integer id) {
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@@ -44,4 +66,33 @@ public class Message {
|
||||
public void setTag(String tag) {
|
||||
this.tag = tag;
|
||||
}
|
||||
|
||||
public User getAuthor() {
|
||||
return author;
|
||||
}
|
||||
|
||||
public void setAuthor(User author) {
|
||||
this.author = author;
|
||||
}
|
||||
|
||||
public String getAuthorName() {
|
||||
|
||||
return MessageHelper.getAuthorName(author);
|
||||
}
|
||||
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
public void setFilename(String filename) {
|
||||
this.filename = filename;
|
||||
}
|
||||
|
||||
public Set<User> getLikes() {
|
||||
return likes;
|
||||
}
|
||||
|
||||
public void setLikes(Set<User> likes) {
|
||||
this.likes = likes;
|
||||
}
|
||||
}
|
||||
|
||||
5
src/main/java/net/sytes/kashey/sweater/domain/Role.java
Normal file
5
src/main/java/net/sytes/kashey/sweater/domain/Role.java
Normal file
@@ -0,0 +1,5 @@
|
||||
package net.sytes.kashey.sweater.domain;
|
||||
|
||||
public enum Role {
|
||||
ADMIN, USER;
|
||||
}
|
||||
155
src/main/java/net/sytes/kashey/sweater/domain/User.java
Normal file
155
src/main/java/net/sytes/kashey/sweater/domain/User.java
Normal file
@@ -0,0 +1,155 @@
|
||||
package net.sytes.kashey.sweater.domain;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Table(name = "usr")
|
||||
public class User implements Serializable {
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
private Long id;
|
||||
@NotBlank(message = "Username must not be empty.")
|
||||
private String username;
|
||||
@NotBlank(message = "Password must not be empty.")
|
||||
private String password;
|
||||
|
||||
private boolean enabled;
|
||||
|
||||
@Email(message = "E-mail is not valid.")
|
||||
@NotBlank(message = "E-mail must not be empty.")
|
||||
private String email;
|
||||
|
||||
private String activationCode;
|
||||
@ElementCollection(targetClass = Role.class, fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "user_role", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@Enumerated(EnumType.STRING)
|
||||
private Set<Role> roles;
|
||||
|
||||
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||
private Set<Message> messages;
|
||||
|
||||
@ManyToMany
|
||||
@JoinTable(name = "user_subscriptions", joinColumns = { @JoinColumn(name = "channel_id")},
|
||||
inverseJoinColumns = { @JoinColumn(name = "subscriber_id")})
|
||||
private Set<User> subscribers = new HashSet<>();
|
||||
|
||||
@ManyToMany
|
||||
@JoinTable(name = "user_subscriptions", joinColumns = { @JoinColumn(name = "subscriber_id")},
|
||||
inverseJoinColumns = { @JoinColumn(name = "channel_id")})
|
||||
private Set<User> subscribtions = new HashSet<>();
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
User user = (User) o;
|
||||
return Objects.equals(id, user.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id);
|
||||
}
|
||||
|
||||
public User() {
|
||||
}
|
||||
|
||||
public User(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public User(String username, String password) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String name) {
|
||||
this.username = name;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public Set<Role> getRoles() {
|
||||
return roles;
|
||||
}
|
||||
|
||||
public void setRoles(Set<Role> roles) {
|
||||
this.roles = roles;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getActivationCode() {
|
||||
return activationCode;
|
||||
}
|
||||
|
||||
public void setActivationCode(String activationCode) {
|
||||
this.activationCode = activationCode;
|
||||
}
|
||||
|
||||
public Set<Message> getMessages() {
|
||||
return messages;
|
||||
}
|
||||
|
||||
public void setMessages(Set<Message> messages) {
|
||||
this.messages = messages;
|
||||
}
|
||||
|
||||
public Set<User> getSubscribers() {
|
||||
return subscribers;
|
||||
}
|
||||
|
||||
public void setSubscribers(Set<User> subscribers) {
|
||||
this.subscribers = subscribers;
|
||||
}
|
||||
|
||||
public Set<User> getSubscriptions() {
|
||||
return subscribtions;
|
||||
}
|
||||
|
||||
public void setSubscriptions(Set<User> subscriptions) {
|
||||
this.subscribtions = subscriptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package net.sytes.kashey.sweater.domain.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class CaptchaResponseDto {
|
||||
|
||||
private boolean success;
|
||||
|
||||
@JsonAlias("error-codes")
|
||||
private Set<String> errorCodes;
|
||||
|
||||
public boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
public void setSuccess(boolean success) {
|
||||
this.success = success;
|
||||
}
|
||||
|
||||
public Set<String> getErrorCodes() {
|
||||
return errorCodes;
|
||||
}
|
||||
|
||||
public void setErrorCodes(Set<String> errorCodes) {
|
||||
this.errorCodes = errorCodes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package net.sytes.kashey.sweater.domain.dto;
|
||||
|
||||
import net.sytes.kashey.sweater.domain.Message;
|
||||
import net.sytes.kashey.sweater.domain.User;
|
||||
import net.sytes.kashey.sweater.domain.util.MessageHelper;
|
||||
|
||||
public class MessageDto {
|
||||
private final Long id;
|
||||
private final String text;
|
||||
private final String tag;
|
||||
private final User author;
|
||||
private final String filename;
|
||||
private final Long likes;
|
||||
private final Boolean meLiked;
|
||||
|
||||
public MessageDto(Message message, Long likes, Boolean meLiked) {
|
||||
this.id = message.getId();
|
||||
this.text = message.getText();
|
||||
this.tag = message.getTag();
|
||||
this.author = message.getAuthor();
|
||||
this.filename = message.getFilename();
|
||||
this.likes = likes;
|
||||
this.meLiked = meLiked;
|
||||
}
|
||||
|
||||
public String getAuthorName() {
|
||||
return MessageHelper.getAuthorName(author);
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
public String getTag() {
|
||||
return tag;
|
||||
}
|
||||
|
||||
public User getAuthor() {
|
||||
return author;
|
||||
}
|
||||
|
||||
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
public Long getLikes() {
|
||||
return likes;
|
||||
}
|
||||
|
||||
public Boolean getMeLiked() {
|
||||
return meLiked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "MessageDto{" +
|
||||
"id=" + id +
|
||||
", author=" + author +
|
||||
", likes=" + likes +
|
||||
", meLiked=" + meLiked +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package net.sytes.kashey.sweater.domain.util;
|
||||
|
||||
import net.sytes.kashey.sweater.domain.User;
|
||||
|
||||
public abstract class MessageHelper {
|
||||
public static String getAuthorName(User author) {
|
||||
return author == null ? "<none>" : author.getUsername();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,45 @@
|
||||
package net.sytes.kashey.sweater.repository;
|
||||
|
||||
import net.sytes.kashey.sweater.domain.Message;
|
||||
import net.sytes.kashey.sweater.domain.User;
|
||||
import net.sytes.kashey.sweater.domain.dto.MessageDto;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.CrudRepository;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface MessageRepository extends CrudRepository<Message, Integer> {
|
||||
List<Message> findByTag(String tag);
|
||||
|
||||
@Query("select new net.sytes.kashey.sweater.domain.dto.MessageDto(\n" +
|
||||
" m, \n" +
|
||||
" count(ml), \n" +
|
||||
" sum(case when ml = :user then 1 else 0 end) > 0\n" +
|
||||
") \n" +
|
||||
"from Message as m " +
|
||||
"left join m.likes as ml \n" +
|
||||
"where m.tag = :tag \n" +
|
||||
"group by m")
|
||||
Page<MessageDto> findByTag(@Param("tag") String tag, Pageable pageable, @Param("user") User user);
|
||||
|
||||
@Query("select new net.sytes.kashey.sweater.domain.dto.MessageDto(\n" +
|
||||
" m, \n" +
|
||||
" count(ml), \n" +
|
||||
" sum(case when ml = :user then 1 else 0 end) > 0\n" +
|
||||
") \n" +
|
||||
"from Message as m \n" +
|
||||
"left join m.likes as ml \n" +
|
||||
"group by m")
|
||||
Page<MessageDto> findAll(Pageable pageable, @Param("user") User user);
|
||||
|
||||
@Query("select new net.sytes.kashey.sweater.domain.dto.MessageDto(\n" +
|
||||
" m, \n" +
|
||||
" count(ml), \n" +
|
||||
" sum(case when ml = :user then 1 else 0 end) > 0\n" +
|
||||
") \n" +
|
||||
"from Message as m " +
|
||||
"left join m.likes as ml \n" +
|
||||
"where m.author = :author\n" +
|
||||
"group by m")
|
||||
Page<MessageDto> findByUser(@Param("author") User author, Pageable pageable, @Param("user") User user);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package net.sytes.kashey.sweater.repository;
|
||||
|
||||
import net.sytes.kashey.sweater.domain.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
Optional<User> findByUsername(String name);
|
||||
|
||||
User findByActivationCode(String activationCode);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package net.sytes.kashey.sweater.security;
|
||||
|
||||
import net.sytes.kashey.sweater.domain.Role;
|
||||
import net.sytes.kashey.sweater.domain.User;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class CustomUserDetails implements UserDetails {
|
||||
|
||||
private final User user;
|
||||
|
||||
public CustomUserDetails(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return user.getRoles().stream()
|
||||
.map(role -> new SimpleGrantedAuthority(role.name()))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return user.getPassword();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return user.getUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return user.isEnabled();
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public boolean isAdmin() {
|
||||
return user.getRoles().contains(Role.ADMIN);
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return user.getEmail();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return user.getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package net.sytes.kashey.sweater.service;
|
||||
|
||||
import net.sytes.kashey.sweater.domain.Role;
|
||||
import net.sytes.kashey.sweater.domain.User;
|
||||
import net.sytes.kashey.sweater.repository.UserRepository;
|
||||
import net.sytes.kashey.sweater.security.CustomUserDetails;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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.stereotype.Service;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class CustomUserDetailsService implements UserDetailsService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final EmailService emailService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Value("${hosting.path}")
|
||||
private String hostingPath;
|
||||
|
||||
public CustomUserDetailsService(UserRepository userRepository, EmailService emailService,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
|
||||
this.userRepository = userRepository;
|
||||
this.emailService = emailService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
|
||||
|
||||
if (name == null) {
|
||||
throw new UsernameNotFoundException("Username is empty!");
|
||||
}
|
||||
|
||||
User user = userRepository.findByUsername(name)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + name));
|
||||
return new CustomUserDetails(user);
|
||||
}
|
||||
|
||||
public String addUser(User user) {
|
||||
|
||||
if (user.getUsername() == null || user.getUsername().isEmpty() || user.getUsername().isBlank()) {
|
||||
return "User name must not be empty!";
|
||||
}
|
||||
|
||||
if (userRepository.findByUsername(user.getUsername()).isPresent()) {
|
||||
return "User already exists!";
|
||||
}
|
||||
|
||||
user.setEnabled(true);
|
||||
user.setActivationCode(UUID.randomUUID().toString());
|
||||
user.setRoles(Collections.singleton(Role.USER));
|
||||
user.setPassword(passwordEncoder.encode(user.getPassword()));
|
||||
userRepository.save(user);
|
||||
|
||||
sendMessage(user);
|
||||
return "";
|
||||
}
|
||||
|
||||
private void sendMessage(User user) {
|
||||
|
||||
if (!ObjectUtils.isEmpty(user.getEmail())) {
|
||||
|
||||
String message = String.format(
|
||||
"Hello, %s! \n" +
|
||||
"Welcome to Sweater. Please, visit next link: %s/activate/%s",
|
||||
user.getUsername(),
|
||||
hostingPath,
|
||||
user.getActivationCode()
|
||||
);
|
||||
|
||||
emailService.send(user.getEmail(), "Activation code", message);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean activateUser(String activationCode) {
|
||||
|
||||
User user = userRepository.findByActivationCode(activationCode);
|
||||
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
user.setActivationCode(null);
|
||||
userRepository.save(user);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void updateUser(String username, Map<String, String> form, User user) {
|
||||
|
||||
user.setUsername(username);
|
||||
user.getRoles().clear();
|
||||
Set<String> roles = Arrays.stream(Role.values()).map(Enum::name).collect(Collectors.toSet());
|
||||
for (String role : form.keySet()) {
|
||||
if (roles.contains(role)) {
|
||||
user.getRoles().add(Role.valueOf(role));
|
||||
}
|
||||
}
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
public void updateProfile(User user, String password, String email) {
|
||||
|
||||
String userEmail = user.getEmail();
|
||||
|
||||
boolean isEmailChanged = (email != null && !email.equals(userEmail)) ||
|
||||
(userEmail != null && !userEmail.equals(email));
|
||||
|
||||
if (isEmailChanged) {
|
||||
user.setEmail(email);
|
||||
|
||||
if (!ObjectUtils.isEmpty(email)) {
|
||||
user.setActivationCode(UUID.randomUUID().toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (!ObjectUtils.isEmpty(password)) {
|
||||
user.setPassword(password);
|
||||
}
|
||||
|
||||
userRepository.save(user);
|
||||
|
||||
if (isEmailChanged) {
|
||||
sendMessage(user);
|
||||
}
|
||||
}
|
||||
|
||||
public List<User> findAll() {
|
||||
|
||||
return userRepository.findAll();
|
||||
}
|
||||
|
||||
public void subscribe(User currentUser, User user) {
|
||||
|
||||
user.getSubscribers().add(currentUser);
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
public void unsubscribe(User currentUser, User user) {
|
||||
|
||||
user.getSubscribers().remove(currentUser);
|
||||
userRepository.save(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package net.sytes.kashey.sweater.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class EmailService {
|
||||
private final JavaMailSender javaMailSender;
|
||||
|
||||
@Value("${spring.mail.username}")
|
||||
private String username;
|
||||
|
||||
@Autowired
|
||||
public EmailService(JavaMailSender javaMailSender) {
|
||||
this.javaMailSender = javaMailSender;
|
||||
}
|
||||
|
||||
public void send(String mailTo, String subject, String text) {
|
||||
|
||||
SimpleMailMessage mailMessage = new SimpleMailMessage();
|
||||
mailMessage.setFrom(username);
|
||||
mailMessage.setTo(mailTo);
|
||||
mailMessage.setSubject(subject);
|
||||
mailMessage.setText(text);
|
||||
|
||||
javaMailSender.send(mailMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package net.sytes.kashey.sweater.service;
|
||||
|
||||
import net.sytes.kashey.sweater.domain.User;
|
||||
import net.sytes.kashey.sweater.domain.dto.MessageDto;
|
||||
import net.sytes.kashey.sweater.repository.MessageRepository;
|
||||
import net.sytes.kashey.sweater.security.CustomUserDetails;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class MessageService {
|
||||
|
||||
private final MessageRepository messageRepository;
|
||||
|
||||
public MessageService(MessageRepository messageRepository) {
|
||||
this.messageRepository = messageRepository;
|
||||
}
|
||||
|
||||
public Page<MessageDto> getMessagesByUserPage(User user, Pageable pageable, User author) {
|
||||
|
||||
return messageRepository.findByUser(user, pageable, author);
|
||||
}
|
||||
|
||||
public Page<MessageDto> getFilteredMessagePage(String tag, Pageable pageable, CustomUserDetails customUserDetails) {
|
||||
User user = customUserDetails.getUser();
|
||||
if (tag == null || tag.isEmpty()) {
|
||||
return messageRepository.findAll(pageable, user);
|
||||
} else {
|
||||
return messageRepository.findByTag(tag, pageable, user);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
spring.jpa.hibernate.ddl-auto = update
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5454/sweater_db
|
||||
spring.jpa.hibernate.ddl-auto=validate
|
||||
spring.jpa.show-sql=true
|
||||
spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:3306/sweater_db
|
||||
spring.datasource.username=user
|
||||
spring.datasource.password=123456
|
||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||
spring.jpa.show-sql: true
|
||||
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
|
||||
|
||||
spring.freemarker.expose-request-attributes=true
|
||||
spring.freemarker.expose-session-attributes=true
|
||||
spring.freemarker.suffix=.ftl
|
||||
upload.path=/home/kashey/soft/sweater/uploads
|
||||
hosting.path=http://localhost:8080
|
||||
|
||||
spring.mail.host=smtp.yandex.ru
|
||||
spring.mail.username=username@yandex.ru
|
||||
spring.mail.password=pa$$word
|
||||
spring.mail.port=465
|
||||
spring.mail.protocol=smtps
|
||||
mail.debug=true
|
||||
|
||||
recaptcha.secret=
|
||||
|
||||
47
src/main/resources/db/migration/V1__Init_DB.sql
Normal file
47
src/main/resources/db/migration/V1__Init_DB.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
CREATE TABLE IF NOT EXISTS message
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
filename VARCHAR(255),
|
||||
tag VARCHAR(255),
|
||||
text VARCHAR(2048) NOT NULL
|
||||
) ENGINE = InnoDB;
|
||||
|
||||
CREATE TABLE message_seq
|
||||
(
|
||||
next_val BIGINT
|
||||
) ENGINE = InnoDB;
|
||||
|
||||
INSERT INTO message_seq
|
||||
VALUES (1);
|
||||
|
||||
CREATE TABLE user_role
|
||||
(
|
||||
user_id BIGINT NOT NULL,
|
||||
roles ENUM ('ADMIN','USER')
|
||||
) ENGINE = InnoDB;
|
||||
|
||||
CREATE TABLE usr
|
||||
(
|
||||
enabled BIT NOT NULL,
|
||||
id BIGINT NOT NULL,
|
||||
activation_code VARCHAR(255),
|
||||
email VARCHAR(255),
|
||||
password VARCHAR(255) NOT NULL,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE = InnoDB;
|
||||
|
||||
CREATE TABLE usr_seq
|
||||
(
|
||||
next_val BIGINT
|
||||
) ENGINE = InnoDB;
|
||||
|
||||
INSERT INTO usr_seq
|
||||
VALUES (1);
|
||||
|
||||
ALTER TABLE message
|
||||
ADD CONSTRAINT message_user_fk FOREIGN KEY (user_id) REFERENCES usr (id);
|
||||
|
||||
ALTER TABLE user_role
|
||||
ADD CONSTRAINT user_role_user_fk FOREIGN KEY (user_id) REFERENCES usr (id);
|
||||
5
src/main/resources/db/migration/V2__Add_admin.sql
Normal file
5
src/main/resources/db/migration/V2__Add_admin.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
insert into usr (id, username, password, enabled)
|
||||
values (1, 'admin', '$2a$12$.XB1cxFOs6F8vgouixcTP.TaY2GXwFspYHID12JpLwotvoYI0JwnC', true);
|
||||
|
||||
insert into user_role (user_id, roles)
|
||||
values (1, 'USER'), (1, 'ADMIN');
|
||||
@@ -0,0 +1,8 @@
|
||||
create table if not exists user_subscriptions
|
||||
(
|
||||
channel_id bigint not null references usr,
|
||||
subscriber_id bigint not null references usr,
|
||||
primary key (channel_id, subscriber_id),
|
||||
FOREIGN KEY (channel_id) REFERENCES usr(id),
|
||||
FOREIGN KEY (subscriber_id) REFERENCES usr(id)
|
||||
) engine = InnoDB;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS message_likes
|
||||
(
|
||||
message_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (message_id, user_id),
|
||||
CONSTRAINT fk_message_likes_message FOREIGN KEY (message_id) REFERENCES message (id),
|
||||
CONSTRAINT fk_message_likes_user FOREIGN KEY (user_id) REFERENCES usr (id)
|
||||
) ENGINE = InnoDB;
|
||||
106
src/main/resources/static/custom.css
Normal file
106
src/main/resources/static/custom.css
Normal file
@@ -0,0 +1,106 @@
|
||||
|
||||
:root {
|
||||
--yellow-1c: #ffe100; /* Основной желтый */
|
||||
--light-yellow-1c: #FDF5C9; /* Светло-желтый фон */
|
||||
--dark-1c: #333333; /* Темный цвет для текста */
|
||||
--light-yellow-header-1c: #fbed9e;
|
||||
--light-grey-1c:#f2f2f2;
|
||||
--brand-red-1c: #B22222;
|
||||
--begie: #F5F5DC;
|
||||
--light-y:#F5F5F5;
|
||||
--header: #F5DEB3;
|
||||
--brown-1c: #663300;
|
||||
--gray-1c: #CCCCCC;
|
||||
}
|
||||
|
||||
/*!* Фон приложения *!*/
|
||||
body {
|
||||
background-color: var(--light-y) !important;
|
||||
color: var(--dark-1c);
|
||||
}
|
||||
|
||||
/* Навигационная панель */
|
||||
.navbar {
|
||||
background-color: var(--gray-1c) !important;
|
||||
color: var(--dark-1c) !important;
|
||||
}
|
||||
|
||||
/*.navbar .nav-link {*/
|
||||
/* color: var(--brown-1c) !important;*/
|
||||
/*}*/
|
||||
|
||||
/*.navbar .nav-link:hover {*/
|
||||
/* color: black !important;*/
|
||||
/*}*/
|
||||
|
||||
/* Кнопки */
|
||||
/*.btn-primary {*/
|
||||
/* background-color: var(--yellow-1c) !important;*/
|
||||
/* border-color: var(--yellow-1c) !important;*/
|
||||
/* color: var(--dark-1c) !important;*/
|
||||
/*}*/
|
||||
|
||||
/*.btn-primary:hover {*/
|
||||
/* background-color: #E6C200 !important; !* Чуть темнее *!*/
|
||||
/* border-color: #E6C200 !important;*/
|
||||
/*}*/
|
||||
|
||||
/*!* Карточки и панели *!*/
|
||||
/*.card, .panel {*/
|
||||
/* background-color: var(--white-1c) !important;*/
|
||||
/* border: 1px solid var(--gray-1c) !important;*/
|
||||
/*}*/
|
||||
|
||||
/*!* Таблицы *!*/
|
||||
/*.table {*/
|
||||
/* background-color: var(--white-1c);*/
|
||||
/* color: var(--dark-1c);*/
|
||||
/*}*/
|
||||
|
||||
/*.table thead th {*/
|
||||
/* background-color: var(--yellow-1c);*/
|
||||
/* color: var(--dark-1c);*/
|
||||
/*}*/
|
||||
|
||||
/*.table-striped tbody tr:nth-of-type(odd) {*/
|
||||
/* background-color: var(--gray-1c);*/
|
||||
/*}*/
|
||||
|
||||
/*!* Поля ввода *!*/
|
||||
/*.form-control {*/
|
||||
/* background-color: var(--white-1c);*/
|
||||
/* border: 1px solid var(--gray-1c);*/
|
||||
/* color: var(--dark-1c);*/
|
||||
/*}*/
|
||||
|
||||
/*.form-control:focus {*/
|
||||
/* border-color: var(--yellow-1c);*/
|
||||
/* box-shadow: 0 0 0 0.2rem rgba(255, 215, 0, 0.5);*/
|
||||
/*}*/
|
||||
|
||||
/*!* Выделение чекбоксов *!*/
|
||||
/*.form-check-input:checked {*/
|
||||
/* background-color: var(--yellow-1c);*/
|
||||
/* border-color: var(--yellow-1c);*/
|
||||
/*}*/
|
||||
|
||||
/*!* Бейджи *!*/
|
||||
/*.badge-primary {*/
|
||||
/* background-color: var(--yellow-1c);*/
|
||||
/* color: var(--dark-1c);*/
|
||||
/*}*/
|
||||
|
||||
/*!* Тени для элементов *!*/
|
||||
/*.box-shadow {*/
|
||||
/* box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1);*/
|
||||
/*}*/
|
||||
|
||||
/*!* Стили для названия бренда (Sweater) *!*/
|
||||
/*.navbar-brand {*/
|
||||
/* color: var(--brown-1c) !important; !* Темный цвет текста *!*/
|
||||
/* font-weight: bold; !* Сделаем жирным *!*/
|
||||
/*}*/
|
||||
|
||||
/*.navbar-brand:hover {*/
|
||||
/* color: black !important; !* Черный при наведении *!*/
|
||||
/*}*/
|
||||
8
src/main/resources/templates/greeting.ftl
Normal file
8
src/main/resources/templates/greeting.ftl
Normal file
@@ -0,0 +1,8 @@
|
||||
<#import "parts/common.ftl" as com>
|
||||
<#include "parts/security.ftl">
|
||||
<@com.page>
|
||||
<h5>Hello, <#if user??> ${name} <#else> guest </#if> </h5>
|
||||
<div>
|
||||
This is a simple version of Twitter
|
||||
</div>
|
||||
</@com.page>
|
||||
@@ -1,10 +0,0 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
<head>
|
||||
<title>Getting Started: Serving Web Content</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<div>Hello {{name}}!!!</div>
|
||||
</body>
|
||||
</html>
|
||||
16
src/main/resources/templates/login.ftl
Normal file
16
src/main/resources/templates/login.ftl
Normal file
@@ -0,0 +1,16 @@
|
||||
<#import "parts/common.ftl" as com>
|
||||
<#import "parts/login.ftl" as l>
|
||||
|
||||
<@com.page>
|
||||
<#if SPRING_SECURITY_LAST_EXCEPTION??>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
${SPRING_SECURITY_LAST_EXCEPTION.message}
|
||||
</div>
|
||||
</#if>
|
||||
<#if message??>
|
||||
<div <#if messageType??>class="alert alert-${messageType}" role="alert"</#if> >
|
||||
${message}
|
||||
</div>
|
||||
</#if>
|
||||
<@l.login "/login" false/>
|
||||
</@com.page>
|
||||
12
src/main/resources/templates/main.ftl
Normal file
12
src/main/resources/templates/main.ftl
Normal file
@@ -0,0 +1,12 @@
|
||||
<#import "parts/common.ftl" as com>
|
||||
<@com.page>
|
||||
<link href="/static/custom.css" rel="stylesheet"/>
|
||||
<div class="input-group mb-3">
|
||||
<form method="get" action="/main" class="form-inline">
|
||||
<input type="text" name="tag" value="${tag}" placeholder="Tag to search"/>
|
||||
<button type="submit" class="btn btn-primary ml-2">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
<#include "parts/messageEditor.ftl">
|
||||
<#include "parts/messageList.ftl">
|
||||
</@com.page>
|
||||
@@ -1,29 +0,0 @@
|
||||
<html>
|
||||
<body>
|
||||
<div>
|
||||
<form method="post">
|
||||
<input type="text" name="text" placeholder="Enter message"> </input>
|
||||
<input type="text" name="tag" placeholder="tag"></input>
|
||||
<button type="submit">Add message</button>
|
||||
</form>
|
||||
</div
|
||||
<div>
|
||||
<form method="post" action="filter">
|
||||
Tag to filer
|
||||
<br>
|
||||
<input type="text" name="tag">
|
||||
<button>Find</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>Messages list</div>
|
||||
{{#messages}}
|
||||
<div>
|
||||
<b>{{id}}</b>
|
||||
<span>{{text}}</span>
|
||||
<i>{{tag}}</i>
|
||||
</div>
|
||||
{{/messages}}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
35
src/main/resources/templates/parts/common.ftl
Normal file
35
src/main/resources/templates/parts/common.ftl
Normal file
@@ -0,0 +1,35 @@
|
||||
<#macro page>
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
<head>
|
||||
<title>Sweater</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
|
||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.3.1/css/all.css"
|
||||
integrity="sha384-mzrmE5qonljUremFsqc01SB46JvROS7bZs3IO2EmfFsd15uHvIt+Y8vEf7N7fWAU"
|
||||
crossorigin="anonymous"/>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/css/bootstrap.min.css"
|
||||
integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"
|
||||
crossorigin="anonymous"/>
|
||||
<script src="https://www.google.com/recaptcha/api.js" async="async" defer="defer"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<#include "navbar.ftl">
|
||||
<div class="container mt-5">
|
||||
<#nested>
|
||||
</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
|
||||
integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.14.3/dist/umd/popper.min.js"
|
||||
integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/js/bootstrap.min.js"
|
||||
integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</#macro>
|
||||
80
src/main/resources/templates/parts/login.ftl
Normal file
80
src/main/resources/templates/parts/login.ftl
Normal file
@@ -0,0 +1,80 @@
|
||||
<#include "security.ftl">
|
||||
<#macro login path isRegistrationForm=false>
|
||||
<form action="${path}" method="post" xmlns="http://www.w3.org/1999/html">
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-2 col-form-label"> User Name:</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="text" name="username" value="<#if user??>${user.username}</#if>"
|
||||
class="form-control ${(usernameError??)?string('is-invalid', '')}"
|
||||
placeholder="User name"/>
|
||||
<#if usernameError??>
|
||||
<div class="invalid-feedback">
|
||||
${usernameError}
|
||||
</div>
|
||||
</#if>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label for="inputPassword3" class="col-sm-2 col-form-label"> Password: </label>
|
||||
<div class="col-sm-6">
|
||||
<input type="password" name="password"
|
||||
class="form-control ${(passwordError??)?string('is-invalid', '')}"
|
||||
placeholder="Password"/>
|
||||
<#if passwordError??>
|
||||
<div class="invalid-feedback">
|
||||
${passwordError}
|
||||
</div>
|
||||
</#if>
|
||||
</div>
|
||||
</div>
|
||||
<#if isRegistrationForm>
|
||||
<div class="form-group row">
|
||||
<label class="col-sm-2 col-form-label">Password:</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="password" name="password2"
|
||||
class="form-control ${(password2Error??)?string('is-invalid', '')}"
|
||||
placeholder="Confirm password"/>
|
||||
<#if password2Error??>
|
||||
<div class="invalid-feedback">
|
||||
${password2Error}
|
||||
</div>
|
||||
</#if>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-2 col-form-label"> E-mail:</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="email" name="email" value="<#if user??>${user.email}</#if>"
|
||||
class="form-control ${(emailError??)?string('is-invalid', '')}"
|
||||
placeholder="E-m@il"/>
|
||||
<#if emailError??>
|
||||
<div class="invalid-feedback">
|
||||
${emailError}
|
||||
</div>
|
||||
</#if>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="g-recaptcha" data-sitekey="6LcB49sqAAAAAIvXz46_hEGqtYAzFYYGuY_Y9Rlg"></div>
|
||||
<#if captchaError??>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
${captchaError}
|
||||
</div>
|
||||
</#if>
|
||||
</div>
|
||||
</#if>
|
||||
<#if !isRegistrationForm>
|
||||
<a href="/registration" class="btn btn-primary">Add new user</a>
|
||||
</#if>
|
||||
<input class="btn btn-primary"
|
||||
type="submit" <#if isRegistrationForm> value="Create" <#else> value="Sign in"</#if>/>
|
||||
<input type="hidden" name="_csrf" value="${_csrf.token}"/>
|
||||
</form>
|
||||
</#macro>
|
||||
|
||||
<#macro logout>
|
||||
<form action="/logout" method="post">
|
||||
<input type="hidden" name="_csrf" value="${_csrf.token}"/>
|
||||
<button class="btn btn-primary" type="submit"><#if user??>Sign Out<#else>Log in</#if></button>
|
||||
</form>
|
||||
</#macro>
|
||||
41
src/main/resources/templates/parts/messageEditor.ftl
Normal file
41
src/main/resources/templates/parts/messageEditor.ftl
Normal file
@@ -0,0 +1,41 @@
|
||||
<#include "security.ftl">
|
||||
|
||||
<a class="btn btn-primary mb-2" data-toggle="collapse" href="#collapseExample" role="button" aria-expanded="false"
|
||||
aria-controls="collapseExample">
|
||||
Message editor
|
||||
</a>
|
||||
<div class="collapse <#if message??>show</#if>" id="collapseExample">
|
||||
<div class="form-group mt-3">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control ${(textError??)?string('is-invalid', '')}"
|
||||
value="<#if message??>${message.text}</#if>" name="text" placeholder="Text"/>
|
||||
<#if textError??>
|
||||
<div class="invalid-feedback">
|
||||
${textError}
|
||||
</div>
|
||||
</#if>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control ${(tagError??)?string('is-invalid', '')}"
|
||||
value="<#if message??>${message.tag}</#if>" name="tag" placeholder="Tag"/>
|
||||
<#if tagError??>
|
||||
<div class="invalid-feedback">
|
||||
${tagError}
|
||||
</div>
|
||||
</#if>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="custom-file">
|
||||
<input type="file" name="file" id="customFile"/>
|
||||
<label class="custom-file-label" for="customFile">Choose file</label>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="_csrf" value="${_csrf.token}"/>
|
||||
<input type="hidden" name="id" value="<#if message??>${message.id}</#if>" />
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Save message</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
39
src/main/resources/templates/parts/messageList.ftl
Normal file
39
src/main/resources/templates/parts/messageList.ftl
Normal file
@@ -0,0 +1,39 @@
|
||||
<#include "security.ftl">
|
||||
<#import "pager.ftl" as p>
|
||||
|
||||
<@p.pager url page! />
|
||||
<div class="card-columns" id="message-list">
|
||||
<#list page.content as message>
|
||||
<div class="card my-3" data-id="${message.id}">
|
||||
<#if message.filename??>
|
||||
<img src="/img/${message.filename}" class="card-img-top"/>
|
||||
</#if>
|
||||
<div class="m-2">
|
||||
<span>${message.text}</span><br/>
|
||||
<i>#${message.tag}</i>
|
||||
</div>
|
||||
<div class="card-footer text-muted container">
|
||||
<div class="row">
|
||||
<a class="col align-self-center"
|
||||
href="/user-messages/${message.author.id}">${message.authorName}</a>
|
||||
<a class="col align-self-center" href="/messages/${message.id}/like">
|
||||
<#if message.meLiked>
|
||||
<i class="fas fa-heart"></i>
|
||||
<#else>
|
||||
<i class="far fa-heart"></i>
|
||||
</#if>
|
||||
${message.likes}
|
||||
</a>
|
||||
<#if message.author.id == currentUserId>
|
||||
<a class="col btn btn-primary" href="/user-messages/${message.author.id}?message=${message.id}">
|
||||
Edit
|
||||
</a>
|
||||
</#if>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<#else>
|
||||
No message
|
||||
</#list>
|
||||
</div>
|
||||
<@p.pager url page />
|
||||
40
src/main/resources/templates/parts/navbar.ftl
Normal file
40
src/main/resources/templates/parts/navbar.ftl
Normal file
@@ -0,0 +1,40 @@
|
||||
<#include "security.ftl">
|
||||
<#import "login.ftl" as l>
|
||||
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
||||
<link href="/static/custom.css" rel="stylesheet"/>
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/">Sweater</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
|
||||
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" aria-current="page" href="/">Home</a>
|
||||
</li>
|
||||
<#if user??>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" aria-current="page" href="/main">Messages</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" aria-current="page" href="/user-messages/${currentUserId}">My messages</a>
|
||||
</li>
|
||||
</#if>
|
||||
<#if isAdmin>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" aria-current="page" href="/users">Users</a>
|
||||
</li>
|
||||
</#if>
|
||||
<#if user??>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" aria-current="page" href="/users/profile">Profile</a>
|
||||
</li>
|
||||
</#if>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-text mr-3"><#if user??>${name}<#else>Please, login</#if></div>
|
||||
<@l.logout />
|
||||
</div>
|
||||
</nav>
|
||||
60
src/main/resources/templates/parts/pager.ftl
Normal file
60
src/main/resources/templates/parts/pager.ftl
Normal file
@@ -0,0 +1,60 @@
|
||||
<#macro pager url page>
|
||||
<#if page.getTotalPages() gt 7>
|
||||
<#assign
|
||||
totalPages = page.getTotalPages()
|
||||
pageNumber = page.getNumber() + 1
|
||||
|
||||
head = (pageNumber > 4)?then([1, -1], [1, 2, 3])
|
||||
tail = (pageNumber < totalPages - 3)?then([-1, totalPages], [totalPages - 2, totalPages - 1, totalPages])
|
||||
bodyBefore = (pageNumber > 4 && pageNumber < totalPages - 1)?then([pageNumber - 2, pageNumber - 1], [])
|
||||
bodyAfter = (pageNumber > 2 && pageNumber < totalPages - 3)?then([pageNumber + 1, pageNumber + 2], [])
|
||||
|
||||
body = head + bodyBefore + (pageNumber > 3 && pageNumber < totalPages - 2)?then([pageNumber], []) + bodyAfter + tail
|
||||
>
|
||||
<#else>
|
||||
<#assign body = 1..page.getTotalPages()>
|
||||
</#if>
|
||||
<div class="container mt-3">
|
||||
<div class="row" >
|
||||
<ul class="pagination col justify-content-center">
|
||||
<li class="page-item disabled">
|
||||
<a class="page-link" href="#" tabindex="-1">Pages</a>
|
||||
</li>
|
||||
<#list body as p>
|
||||
<#if (p - 1) == page.getNumber()>
|
||||
<li class="page-item active">
|
||||
<a class="page-link" href="#" tabindex="-1">${p}</a>
|
||||
</li>
|
||||
<#elseif p == -1>
|
||||
<li class="page-item disabled">
|
||||
<a class="page-link" href="#" tabindex="-1">...</a>
|
||||
</li>
|
||||
<#else>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="${url}?page=${p - 1}&size=${page.getSize()}"
|
||||
tabindex="-1">${p}</a>
|
||||
</li>
|
||||
</#if>
|
||||
</#list>
|
||||
</ul>
|
||||
|
||||
<ul class="pagination col justify-content-center" >
|
||||
<li class="page-item disabled">
|
||||
<a class="page-link" href="#" tabindex="-1">Items per page</a>
|
||||
</li>
|
||||
<#list [5, 10, 25, 50] as c>
|
||||
<#if c == page.getSize()>
|
||||
<li class="page-item active">
|
||||
<a class="page-link" href="#" tabindex="-1">${c}</a>
|
||||
</li>
|
||||
<#else>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="${url}?page=${page.getNumber()}&size=${c}"
|
||||
tabindex="-1">${c}</a>
|
||||
</li>
|
||||
</#if>
|
||||
</#list>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</#macro>
|
||||
18
src/main/resources/templates/parts/security.ftl
Normal file
18
src/main/resources/templates/parts/security.ftl
Normal file
@@ -0,0 +1,18 @@
|
||||
<#assign
|
||||
known = SPRING_SECURITY_CONTEXT??
|
||||
>
|
||||
|
||||
<#if known>
|
||||
<#assign
|
||||
user = SPRING_SECURITY_CONTEXT.authentication.principal
|
||||
name = user.getUsername()
|
||||
isAdmin = user.isAdmin()
|
||||
currentUserId = user.getId()
|
||||
>
|
||||
<#else>
|
||||
<#assign
|
||||
name = "unknown"
|
||||
isAdmin = false
|
||||
currentUserId = -1
|
||||
>
|
||||
</#if>
|
||||
24
src/main/resources/templates/profile.ftl
Normal file
24
src/main/resources/templates/profile.ftl
Normal file
@@ -0,0 +1,24 @@
|
||||
<#import "parts/common.ftl" as com>
|
||||
|
||||
<@com.page>
|
||||
<h5>${username}</h5>
|
||||
<#if message??>
|
||||
<div class="mb-3"> ${message} </div>
|
||||
</#if>
|
||||
<form method="post" xmlns="http://www.w3.org/1999/html">
|
||||
<div class="row mb-3">
|
||||
<label for="inputPassword3" class="col-sm-2 col-form-label"> Password: </label>
|
||||
<div class="col-sm-6">
|
||||
<input type="password" class="form-control" name="password" value="${password}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-2 col-form-label"> E-mail:</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="email" class="form-control" name="email" value="${email!''}"/>
|
||||
</div>
|
||||
</div>
|
||||
<input class="btn btn-primary" type="submit" value="Save"/>
|
||||
<input type="hidden" name="_csrf" value="${_csrf.token}"/>
|
||||
</form>
|
||||
</@com.page>
|
||||
11
src/main/resources/templates/registration.ftl
Normal file
11
src/main/resources/templates/registration.ftl
Normal file
@@ -0,0 +1,11 @@
|
||||
<#import "parts/common.ftl" as com>
|
||||
<#import "parts/login.ftl" as l>
|
||||
|
||||
<@com.page>
|
||||
<div class="mb-1"> Add new user </div>
|
||||
<br>
|
||||
<#if message??>
|
||||
<div class="mb-3"> ${message} </div>
|
||||
</#if>
|
||||
<@l.login "/registration" true/>
|
||||
</@com.page>
|
||||
12
src/main/resources/templates/subscriptions.ftl
Normal file
12
src/main/resources/templates/subscriptions.ftl
Normal file
@@ -0,0 +1,12 @@
|
||||
<#import "parts/common.ftl" as com>
|
||||
<@com.page>
|
||||
<h3>${userChannel.username}</h3>
|
||||
<div>${type}</div>
|
||||
<ul class="list-group">
|
||||
<#list users as user>
|
||||
<li class="list-group-item">
|
||||
<a href="/user-messages/${user.id}">${user.getUsername()}</a>
|
||||
</li>
|
||||
</#list>
|
||||
</ul>
|
||||
</@com.page>
|
||||
30
src/main/resources/templates/userEdit.ftl
Normal file
30
src/main/resources/templates/userEdit.ftl
Normal file
@@ -0,0 +1,30 @@
|
||||
<#import "parts/common.ftl" as com>
|
||||
<@com.page>
|
||||
<div class="container mt-4">
|
||||
<form action="/users" method="post" class="needs-validation" novalidate>
|
||||
<div class="form-group">
|
||||
<label for="username">Username:</label>
|
||||
<input type="text" class="form-control" id="username" name="username" value="${user.username}" required/>
|
||||
<div class="invalid-feedback">
|
||||
Please provide a username.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Roles:</label>
|
||||
<#list roles as role>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="${role}"
|
||||
id="${role}" ${user.roles?seq_contains(role)?string("checked", "")}/>
|
||||
<label class="form-check-label" for="${role}">
|
||||
${role}
|
||||
</label>
|
||||
</div>
|
||||
</#list>
|
||||
</div>
|
||||
<input type="hidden" value="${user.id}" name="userId"/>
|
||||
<input type="hidden" value="${_csrf.token}" name="_csrf"/>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</@com.page>
|
||||
30
src/main/resources/templates/userList.ftl
Normal file
30
src/main/resources/templates/userList.ftl
Normal file
@@ -0,0 +1,30 @@
|
||||
<#import "parts/common.ftl" as com>
|
||||
<@com.page>
|
||||
<div class="container mt-4">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-sub-heading-color">
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Role</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<#list users as user>
|
||||
<tr>
|
||||
<td>${user.username}</td>
|
||||
<td>
|
||||
<#list user.roles as role>
|
||||
<span class="badge badge-primary">${role}</span>
|
||||
<#sep> </#list>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/users/${user.id}" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
</#list>
|
||||
</tbody>
|
||||
</table>
|
||||
<a href="/main" class="btn btn-primary">Main page</a>
|
||||
</div>
|
||||
</@com.page>
|
||||
41
src/main/resources/templates/user_messages.ftl
Normal file
41
src/main/resources/templates/user_messages.ftl
Normal file
@@ -0,0 +1,41 @@
|
||||
<#import "parts/common.ftl" as com>
|
||||
<@com.page>
|
||||
<h3>${userChannel.username}</h3>
|
||||
<#if !isCurrentUser>
|
||||
<div class="form-group">
|
||||
<#if isSubscriber>
|
||||
<a class="btn btn-info" href="/users/unsubscribe/${userChannel.id}">Unsubscibe</a>
|
||||
<#else>
|
||||
<a class="btn btn-info" href="/users/subscribe/${userChannel.id}">Subscribe</a>
|
||||
</#if>
|
||||
</div>
|
||||
</#if>
|
||||
<div class="container my-2">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Subscriptions</div>
|
||||
<h3 class="card-text">
|
||||
<a href="/users/subscriptions/${userChannel.id}/list">${subscriptionsCount}</a>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Subscribers</div>
|
||||
<h3 class="card-text">
|
||||
<a href="/users/subscribers/${userChannel.id}/list">${subscribersCount}</a>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<#if isCurrentUser>
|
||||
<#include "parts/messageEditor.ftl">
|
||||
</#if>
|
||||
<#include "parts/messageList.ftl">
|
||||
</@com.page>
|
||||
@@ -0,0 +1,80 @@
|
||||
package net.sytes.kashey.sweater.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
import org.springframework.test.context.jdbc.SqlConfig;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.testcontainers.containers.MySQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Testcontainers
|
||||
@TestPropertySource("/application-test.properties")
|
||||
public class LoginTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Container
|
||||
private static final MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
|
||||
.withDatabaseName("sweater_db_test")
|
||||
.withUsername("user")
|
||||
.withPassword("123456");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", mysql::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", mysql::getUsername);
|
||||
registry.add("spring.datasource.password", mysql::getPassword);
|
||||
}
|
||||
|
||||
@Test
|
||||
void guestPageShouldContainLoginMessage() throws Exception {
|
||||
mockMvc.perform(get("/"))
|
||||
.andDo(print())
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("Hello, guest")))
|
||||
.andExpect(content().string(containsString("Log in")));
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void unauthorizedUserShouldBeRedirectedToLogin() throws Exception {
|
||||
mockMvc.perform(get("/main"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("http://localhost/login"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Sql(
|
||||
scripts = "/import.sql",
|
||||
config = @SqlConfig(encoding = "UTF-8")
|
||||
)
|
||||
void authorizedUserShouldBeRedirectedToMainPage() throws Exception {
|
||||
mockMvc.perform(formLogin().user("user").password("user"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("/main"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnForbiddenWhenLoginWithInvalidCredentials() throws Exception {
|
||||
mockMvc.perform(post("/login").param("username", "Dummy"))
|
||||
.andDo(print())
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package net.sytes.kashey.sweater.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.security.test.context.support.WithUserDetails;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
import org.springframework.test.context.jdbc.SqlConfig;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
import org.testcontainers.containers.MySQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.xpath;
|
||||
|
||||
@SpringBootTest
|
||||
@Testcontainers
|
||||
@AutoConfigureMockMvc
|
||||
@WithUserDetails(value = "admin")
|
||||
@TestPropertySource("/application-test.properties")
|
||||
@Sql(
|
||||
scripts = {"/import.sql"},
|
||||
config = @SqlConfig(encoding = "UTF-8")
|
||||
|
||||
)
|
||||
public class MainControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
@Container
|
||||
private static final MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
|
||||
.withDatabaseName("sweater_db_test")
|
||||
.withUsername("user")
|
||||
.withPassword("123456");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", mysql::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", mysql::getUsername);
|
||||
registry.add("spring.datasource.password", mysql::getPassword);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDisplayUsernameInNavbarWhenUserIsAuthenticated() throws Exception {
|
||||
|
||||
mockMvc.perform(get("/main"))
|
||||
.andDo(print())
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(authenticated())
|
||||
.andExpect(xpath("/html/body/nav/div/div[2]").string("admin"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDisplayAllMessagesOnMainPage() throws Exception {
|
||||
|
||||
mockMvc.perform(get("/main"))
|
||||
.andDo(print())
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(authenticated())
|
||||
.andExpect(xpath("//*[@id='message-list']/div").nodeCount(4));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithUserDetails(value = "admin")
|
||||
void shouldFilterMessagesByTag() throws Exception {
|
||||
|
||||
mockMvc.perform(get("/main").param("tag", "my-tag"))
|
||||
.andDo(print())
|
||||
.andExpect(authenticated())
|
||||
.andExpect(xpath("//*[@id='message-list']/div").nodeCount(2))
|
||||
.andExpect(xpath("/html/body/div/div[4]/div[@data-id='1']").exists())
|
||||
.andExpect(xpath("/html/body/div/div[4]/div[@data-id='3']").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldAddNewMessageAndDisplayItOnMainPage() throws Exception {
|
||||
MockHttpServletRequestBuilder multipart = multipart("/main")
|
||||
.file("file", "123".getBytes())
|
||||
.param("text", "fifth")
|
||||
.param("tag", "new one")
|
||||
.with(csrf());
|
||||
|
||||
this.mockMvc.perform(multipart)
|
||||
.andDo(print())
|
||||
.andExpect(authenticated())
|
||||
.andExpect(xpath("//*[@id='message-list']/div").nodeCount(5))
|
||||
.andExpect(xpath("/html/body/div/div[4]/div[@data-id='5']").exists())
|
||||
.andExpect(xpath("/html/body/div/div[4]/div[@data-id='5']/div/span").string("fifth"))
|
||||
.andExpect(xpath("/html/body/div/div[4]/div[@data-id='5']/div/i").string("#new one"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package net.sytes.kashey.sweater.service;
|
||||
|
||||
import net.sytes.kashey.sweater.domain.Role;
|
||||
import net.sytes.kashey.sweater.domain.User;
|
||||
import net.sytes.kashey.sweater.repository.UserRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CustomUserDetailsServiceTest {
|
||||
|
||||
@Mock
|
||||
UserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
EmailService emailService;
|
||||
|
||||
@Mock
|
||||
PasswordEncoder passwordEncoder;
|
||||
|
||||
@InjectMocks
|
||||
CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
@Test
|
||||
public void shouldAddUserWhenNewUserProvided() {
|
||||
|
||||
User user = new User();
|
||||
user.setUsername("Dummy");
|
||||
user.setEmail("test@gmail.com");
|
||||
|
||||
String isUserAdded = customUserDetailsService.addUser(user);
|
||||
|
||||
assertThat(isUserAdded).isEmpty();
|
||||
assertThat(user.getActivationCode()).isNotNull();
|
||||
assertThat(user.getRoles()).contains(Role.USER);
|
||||
verify(userRepository, times(1))
|
||||
.save(user);
|
||||
verify(emailService, times(1))
|
||||
.send(eq(user.getEmail()), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotAddUserWhenUsernameIsEmpty() {
|
||||
|
||||
User user = new User();
|
||||
|
||||
String isUserAdded = customUserDetailsService.addUser(user);
|
||||
|
||||
assertThat(isUserAdded).contains("User name must not be empty!");
|
||||
verify(userRepository, times(0))
|
||||
.save(any(User.class));
|
||||
verify(emailService, times(0))
|
||||
.send(anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotAddUserWhenUserAlreadyExists() {
|
||||
|
||||
User user = new User("Dummy");
|
||||
when(userRepository.findByUsername(user.getUsername()))
|
||||
.thenReturn(Optional.of(user));
|
||||
|
||||
String isUserAdded = customUserDetailsService.addUser(user);
|
||||
|
||||
assertThat(isUserAdded).contains("User already exists!");
|
||||
verify(userRepository, times(0))
|
||||
.save(user);
|
||||
verify(emailService, times(0))
|
||||
.send(anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldActivateUserWhenValidActivationCodeProvided() {
|
||||
|
||||
User user = new User();
|
||||
user.setActivationCode("dummy_activation_code");
|
||||
when(userRepository.findByActivationCode(user.getActivationCode()))
|
||||
.thenReturn(user);
|
||||
|
||||
boolean isUserActivated = customUserDetailsService.activateUser(user.getActivationCode());
|
||||
|
||||
assertThat(isUserActivated).isTrue();
|
||||
assertThat(user.getActivationCode()).isNull();
|
||||
verify(userRepository, times(1))
|
||||
.save(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotActivateUserWhenInvalidActivationCodeProvided() {
|
||||
|
||||
boolean isUserActivated = customUserDetailsService.activateUser("dummy_activation_code");
|
||||
|
||||
assertThat(isUserActivated).isFalse();
|
||||
verify(userRepository, times(0))
|
||||
.save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldUpdateUsernameAndRolesWhenValidDataProvided() {
|
||||
|
||||
User user = new User();
|
||||
user.setUsername("oldUser");
|
||||
user.setRoles(new HashSet<>(Collections.singleton(Role.USER)));
|
||||
|
||||
Map<String, String> form = new HashMap<>();
|
||||
form.put("ADMIN", "true");
|
||||
form.put("USER", "true");
|
||||
|
||||
customUserDetailsService.updateUser("newUser", form, user);
|
||||
|
||||
assertThat(user.getUsername()).isEqualTo("newUser");
|
||||
assertThat(user.getRoles()).containsExactlyInAnyOrder(Role.ADMIN, Role.USER);
|
||||
verify(userRepository, times(1)).save(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldUpdateEmailAndGenerateActivationCodeWhenEmailChanged() {
|
||||
|
||||
User user = new User();
|
||||
user.setEmail("old@gmail.com");
|
||||
|
||||
customUserDetailsService.updateProfile(user, null, "new@gmail.com");
|
||||
|
||||
assertThat(user.getEmail()).isEqualTo("new@gmail.com");
|
||||
assertThat(user.getActivationCode()).isNotNull();
|
||||
verify(userRepository, times(1)).save(user);
|
||||
verify(emailService, times(1)).send(anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldUpdatePasswordWhenProvided() {
|
||||
|
||||
User user = new User();
|
||||
user.setPassword("oldPassword");
|
||||
|
||||
customUserDetailsService.updateProfile(user, "newPassword", null);
|
||||
|
||||
assertThat(user.getPassword()).isEqualTo("newPassword");
|
||||
verify(userRepository, times(1)).save(user);
|
||||
verify(emailService, times(0)).send(anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldUpdateBothEmailAndPasswordWhenBothProvided() {
|
||||
|
||||
User user = new User();
|
||||
user.setEmail("old@gmail.com");
|
||||
user.setPassword("oldPassword");
|
||||
|
||||
customUserDetailsService.updateProfile(user, "newPassword", "new@gmail.com");
|
||||
|
||||
assertThat(user.getEmail()).isEqualTo("new@gmail.com");
|
||||
assertThat(user.getPassword()).isEqualTo("newPassword");
|
||||
assertThat(user.getActivationCode()).isNotNull();
|
||||
verify(userRepository, times(1)).save(user);
|
||||
verify(emailService, times(1)).send(anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturnAllUsers() {
|
||||
|
||||
User user1 = new User("Dummy1");
|
||||
User user2 = new User("Dummy2");
|
||||
List<User> users = Arrays.asList(user1, user2);
|
||||
when(userRepository.findAll()).thenReturn(users);
|
||||
|
||||
List<User> result = customUserDetailsService.findAll();
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result).contains(user1, user2);
|
||||
verify(userRepository, times(1)).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturnEmptyListWhenNoUsersFound() {
|
||||
|
||||
when(userRepository.findAll()).thenReturn(Collections.emptyList());
|
||||
|
||||
List<User> result = customUserDetailsService.findAll();
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
verify(userRepository, times(1)).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldSubscribeUser() {
|
||||
|
||||
User subscriber = new User("Subscriber");
|
||||
User userChannel = new User("UserChannel");
|
||||
|
||||
customUserDetailsService.subscribe(subscriber, userChannel);
|
||||
|
||||
assertThat(userChannel.getSubscribers()).contains(subscriber);
|
||||
verify(userRepository, times(1)).save(userChannel);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldUnsubscribeUser() {
|
||||
|
||||
User subscriber = new User("Subscriber");
|
||||
User userChannel = new User("UserChannel");
|
||||
userChannel.getSubscribers().add(subscriber);
|
||||
|
||||
customUserDetailsService.unsubscribe(subscriber, userChannel);
|
||||
|
||||
assertThat(userChannel.getSubscribers()).doesNotContain(subscriber);
|
||||
verify(userRepository, times(1)).save(userChannel);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotFailUnsubscribeIfUserIsNotSubscribed() {
|
||||
|
||||
User subscriber = new User("Subscriber");
|
||||
User userChannel = new User("UserChannel");
|
||||
|
||||
customUserDetailsService.unsubscribe(subscriber, userChannel);
|
||||
|
||||
assertThat(userChannel.getSubscribers()).isEmpty();
|
||||
verify(userRepository, times(1)).save(userChannel);
|
||||
}
|
||||
}
|
||||
19
src/test/resources/application-test.properties
Normal file
19
src/test/resources/application-test.properties
Normal file
@@ -0,0 +1,19 @@
|
||||
spring.jpa.hibernate.ddl-auto=validate
|
||||
spring.jpa.show-sql=true
|
||||
spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:3306/sweater_db_test
|
||||
spring.datasource.username=user
|
||||
spring.datasource.password=123456
|
||||
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
|
||||
spring.freemarker.expose-request-attributes=true
|
||||
spring.freemarker.expose-session-attributes=true
|
||||
spring.freemarker.suffix=.ftl
|
||||
upload.path=/home/kashey/soft/sweater/uploads
|
||||
hosting.path=http://localhost:8080
|
||||
spring.mail.host=smtp.yandex.ru
|
||||
spring.mail.username=username@yandex.ru
|
||||
spring.mail.password=pa$$word
|
||||
spring.mail.port=465
|
||||
spring.mail.protocol=smtps
|
||||
mail.debug=true
|
||||
|
||||
recaptcha.secret=
|
||||
60
src/test/resources/import.sql
Normal file
60
src/test/resources/import.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
DROP TABLE IF EXISTS message_likes;
|
||||
DROP TABLE IF EXISTS message;
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
filename VARCHAR(255),
|
||||
tag VARCHAR(255),
|
||||
text VARCHAR(2048) NOT NULL
|
||||
) ENGINE = InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_role
|
||||
(
|
||||
user_id BIGINT NOT NULL,
|
||||
roles ENUM ('ADMIN', 'USER')
|
||||
) ENGINE = InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usr
|
||||
(
|
||||
enabled bit not null,
|
||||
id bigint not null,
|
||||
activation_code varchar(255),
|
||||
email varchar(255),
|
||||
password varchar(255) not null,
|
||||
username varchar(255) not null,
|
||||
primary key (id)
|
||||
) engine = InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_likes
|
||||
(
|
||||
message_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (message_id, user_id),
|
||||
CONSTRAINT fk_message_likes_message FOREIGN KEY (message_id) REFERENCES message (id),
|
||||
CONSTRAINT fk_message_likes_user FOREIGN KEY (user_id) REFERENCES usr (id)
|
||||
) ENGINE = InnoDB;
|
||||
|
||||
DELETE
|
||||
FROM user_role;
|
||||
|
||||
DELETE
|
||||
FROM usr;
|
||||
|
||||
INSERT INTO usr (id, username, password, enabled)
|
||||
VALUES (1, 'admin', '$2a$12$.XB1cxFOs6F8vgouixcTP.TaY2GXwFspYHID12JpLwotvoYI0JwnC', true);
|
||||
|
||||
INSERT INTO usr (id, username, password, enabled)
|
||||
VALUES (2, 'user', '$2a$12$K3ShbpKTnDVozTH2HJ.mbu7jXoiy2oqAoVTEF0iRGFxUfJvse3oke', true);
|
||||
|
||||
INSERT INTO user_role (user_id, roles)
|
||||
VALUES (1, 'USER'),
|
||||
(1, 'ADMIN');
|
||||
|
||||
INSERT INTO message (text, tag, user_id)
|
||||
VALUES ('first', 'my-tag', 1),
|
||||
('second', 'more', 1),
|
||||
('third', 'my-tag', 1),
|
||||
('fourth', 'another', 2);
|
||||
Reference in New Issue
Block a user