Compare commits

...

18 Commits

Author SHA1 Message Date
Leonid
c5f0d5443d - Реализовал возможность "лайкать" сообщения и вывод информации о "лайках".
https://www.youtube.com/watch?v=QhMBtnS6SrI&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=32
2025-03-05 16:57:32 +03:00
Leonid
6d907a725d - Добавил модульные тесты для пользовательского сервиса.
https://www.youtube.com/watch?v=QrSR1fm9JwA&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=29
2025-03-04 12:20:14 +03:00
Leonid
2f2eacbcef - Реализовал доработки, уменьшающие количество ссылок в пагинаторе.
Часто возникает ситуация, когда при разбиении контента на страницы, мы получаем большое количество страниц и ссылки на эти страницы также требуют много места. В такой ситуации стоит сократить вывод ссылок в пагинаторе.

https://www.youtube.com/watch?v=Sc3EQBy0tuk&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=28
2025-03-02 19:43:10 +03:00
Leonid
a217ef0363 - Добавил базовую реализацию пагинации для сообщений.
https://www.youtube.com/watch?v=Sc3EQBy0tuk&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=28
2025-03-02 19:10:13 +03:00
Leonid
3530e9e28d - Добавил интеграционные тесты с помощью Testcontainers
- Убрал сохранение данных сессии в БД, с ним не работало тестирования логина пользователя.
- Изменил стратегию генерации Id для сообщений с 'AUTO' на `IDENTITY`. Без этого ругалось на дублирования id при добавлении нового сообщения в тестах.

https://www.youtube.com/watch?v=Lnc3o8cCwZY&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=26
2025-03-02 11:06:11 +03:00
Leonid
6e4e358dee - Добавил возможность подписаться на пользователя и отписаться от него.
- Добавил вывод списка подписчиков/подписок.

https://www.youtube.com/watch?v=JpF0MwdJzO4&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=19
2025-02-22 14:05:57 +03:00
Leonid
7b7378e30b - Добавил возможность редактирования сообщений
- Улучшил внешний вид страниц.

https://www.youtube.com/watch?v=-gdDklUu03k&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=20
2025-02-20 11:08:31 +03:00
Leonid
fe4932fff5 - Добавил reCaptcha
- Добавил поддержку rememberMe и хранение сессий в БД.

https://www.youtube.com/watch?v=7cDpbAbhyjc&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=14
2025-02-19 20:06:14 +03:00
Leonid
4bf7fb22c6 - Добавил шифрование паролей в БД.
- Добавил валидацию полей ввода.
https://www.youtube.com/watch?v=AdLXmE4rjy4&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=14
2025-02-19 09:41:16 +03:00
Leonid
9a41c4caae - Добавил миграции Flyway.
- Добавил профиль пользователя и возможность его редактирования.
https://www.youtube.com/watch?v=ArM7nCys4hY&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=12
2025-02-16 14:46:28 +03:00
Leonid
2736f3ddf6 - Добавил активацию нового аккаунта по электронной почте.
https://www.youtube.com/watch?v=yBXs_gtSmUc&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=12
2025-02-16 00:19:45 +03:00
Leonid
27441efde7 - Добавил Bootstrap и настроил внешний вид форм приложения.
https://www.youtube.com/watch?v=a51jGwoTNmI&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=13
2025-02-13 15:23:23 +03:00
Leonid
8580d4cfa6 - Реализовал возможность загрузки файлов с клиента в директорию на сервере.
- Настроил доступ к статическим файлам, таким как картинки, стили, и т.д.

https://www.youtube.com/watch?v=bmMWrTMB5uo&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=10
2025-02-10 21:51:41 +03:00
Leonid
8d4c5e1b3a - Добавил возможность назначения ролей пользователям.
- Добавил контроллер для управления пользователями и запрет доступа к нему пользователям без роли ADMIN.

https://www.youtube.com/watch?v=6dteOGWy4uk&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=9

Использовал аннотацию @EnableMethodSecurity вместо deprecated  @EnableGlobalMethodSecurity
2025-02-10 12:17:59 +03:00
Leonid
8d7cbcc165 - Заменил шаблонизатор Mustache на Freemarker.
https://www.youtube.com/watch?v=8MlXahJXLFg&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=7
2025-02-08 22:38:07 +03:00
Leonid
c771dee930 - Добавил поле автор в сообщение.
https://www.youtube.com/watch?v=PpoOoR55Ypw&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=6
2025-02-07 11:43:43 +03:00
Leonid
42a1b7466b - Добавил аутентификацию с помощью Spring Security.
- Реализовал кастомные  CustomUserDetails и CustomUserDetailsService

https://www.youtube.com/watch?v=WDlifgLS8iQ&list=PLU2ftbIeotGoGSEUf54LQH-DgiQPF2XRO&index=3
https://spring.io/guides/gs/securing-web
https://medium.com/@CodeWithTech/spring-security-with-jdbcuserdetailsmanager-and-custom-userdetailsservice-a-complete-guide-248ddce0196c
2025-02-06 23:13:07 +03:00
Leonid
7583c00749 Заменил используемую СУБД Postgres на MySQL
https://spring.io/guides/gs/accessing-data-mysql
2025-02-04 10:23:44 +03:00
52 changed files with 2427 additions and 124 deletions

12
.idea/dataSources.xml generated Normal file
View 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>

View File

@@ -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
View File

@@ -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>

View File

@@ -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";
}
}

View 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/");
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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";
}
}

View File

@@ -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();
}
}

View File

@@ -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";
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
package net.sytes.kashey.sweater.domain;
public enum Role {
ADMIN, USER;
}

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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 +
'}';
}
}

View File

@@ -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 ? "&lt;none&gt;" : author.getUsername();
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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=

View 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);

View 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');

View File

@@ -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;

View File

@@ -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;

View 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; !* Черный при наведении *!*/
/*}*/

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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 />

View 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>

View 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}&amp;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()}&amp;size=${c}"
tabindex="-1">${c}</a>
</li>
</#if>
</#list>
</ul>
</div>
</div>
</#macro>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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());
}
}

View File

@@ -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"));
}
}

View File

@@ -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);
}
}

View 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=

View 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);