changed jdk on 25 springBoot on 3.5.6 and fix code

This commit is contained in:
SlimusMinus
2026-09-30 00:27:10 +03:00
parent 1efb67824f
commit 015b68d96d
19 changed files with 2226 additions and 71 deletions

View File

@@ -34,10 +34,12 @@ public class FileStorageController {
return fileStorageService.downloadFile(key);
}
@DeleteMapping("/{objectName}")
@DeleteMapping("/{*objectName}")
public ResponseEntity<Void> deleteFile(@PathVariable String objectName) {
log.info("deleteFile {}", objectName);
fileStorageService.deleteFile(objectName);
// objectName может прийти как "/avatars/cf80aebe-...jpg" — обрежем ведущий слэш
String key = objectName.startsWith("/") ? objectName.substring(1) : objectName;
fileStorageService.deleteFileAsCurrentUser(key);
return ResponseEntity.noContent().build();
}
}

View File

@@ -6,6 +6,7 @@ import com.krylov.refound.ai.dto.ModerationResponse;
import com.krylov.refound.service.SchedulerService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -16,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/v1/test")
@RequiredArgsConstructor
@Slf4j
@PreAuthorize("hasRole('ADMIN')")
public class TestController {
private final SchedulerService schedulerService;

View File

@@ -41,7 +41,10 @@ public class Post {
private Double longitude;
private LocalDateTime createdAt;
private String phone;
private String rulesAccepted;
// В БД колонка boolean NOT NULL (changelog 013). Строка была рассинхронизирована
// со схемой и ломала ddl-auto: validate.
@Column(name = "rules_accepted", nullable = false)
private Boolean rulesAccepted = false;
private Boolean isReward;
private String reward;

View File

@@ -15,6 +15,7 @@ public interface PostMapper {
@Mapping(source = "category", target = "category", qualifiedByName = "stringToPostCategory")
@Mapping(source = "rewardText", target = "reward")
@Mapping(source = "rulesAccepted", target = "rulesAccepted", qualifiedByName = "stringToBoolean")
Post toEntity(PostRequest request);
@Mapping(source = "user.id", target = "userId")
@@ -23,8 +24,23 @@ public interface PostMapper {
@Mapping(target = "category", expression = "java(post.getCategory().getDisplayName())")
@Mapping(source = "isReward", target = "reward")
@Mapping(source = "reward", target = "rewardText")
@Mapping(source = "rulesAccepted", target = "rulesAccepted", qualifiedByName = "booleanToString")
PostResponse toResponse(Post post);
/** Фронт присылает правила как строку ("true"), в БД колонка boolean. */
@Named("stringToBoolean")
default Boolean stringToBoolean(String value) {
if (value == null || value.isBlank()) {
return false;
}
return Boolean.parseBoolean(value.trim());
}
@Named("booleanToString")
default String booleanToString(Boolean value) {
return String.valueOf(Boolean.TRUE.equals(value));
}
@Named("imagesToUrls")
default List<String> imagesToUrls(List<Image> images) {
if (images == null) return List.of();

View File

@@ -11,6 +11,11 @@ public interface ChatRepository extends JpaRepository<Chat, Long> {
@Query("SELECT c FROM Chat c WHERE c.userOneId = :u1 AND c.userTwoId = :u2")
Optional<Chat> findByUsers(Long u1, Long u2);
/** Есть ли пользователь среди участников чата. Имя не следует конвенции Spring Data, потому что запрос задан явно. */
@Query("SELECT CASE WHEN COUNT(c) > 0 THEN true ELSE false END FROM Chat c "
+ "WHERE c.id = :chatId AND (c.userOneId = :userId OR c.userTwoId = :userId)")
boolean isParticipantOfChat(Long chatId, Long userId);
@Query("SELECT c FROM Chat c WHERE c.userOneId = :userId OR c.userTwoId = :userId ORDER BY c.createdAt DESC")
List<Chat> findAllByUserId(Long userId); // Changed from Optional to List
}

View File

@@ -49,7 +49,6 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
// публичные эндпоинты
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/test/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/files/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/ads-media/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll()
@@ -74,9 +73,13 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.POST, "/api/ads/click").permitAll()
.requestMatchers(HttpMethod.GET, "/api/fullscreen-ad").permitAll()
// пример разграничения по ролям — раскомментировать и адаптировать под свои admin-эндпоинты
// пример разграничения по ролям
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
// служебные endpoints: только для администратора
// (в методах стоит @PreAuthorize, здесь — первый рубеж)
.requestMatchers("/api/v1/test/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

View File

@@ -115,6 +115,18 @@ public class ChatService {
}
/**
* Проверка для STOMP-интерцептора: пользователь состоит в чате или нет.
* Не бросает исключений — вызывается на этапе авторизации подписки.
*/
@Transactional(readOnly = true)
public boolean isParticipant(Long chatId, Long userId) {
if (chatId == null || userId == null) {
return false;
}
return chatRepository.isParticipantOfChat(chatId, userId);
}
private Chat getChatOrThrow(Long chatId) {
return chatRepository.findById(chatId).orElseThrow(() -> new IllegalArgumentException("Чат не найден"));
}

View File

@@ -3,21 +3,26 @@ package com.krylov.refound.service;
import com.krylov.refound.config.MinioProperties;
import com.krylov.refound.dto.DownloadedFile;
import com.krylov.refound.enums.ErrorCode;
import com.krylov.refound.enums.Role;
import com.krylov.refound.exception.ApiException;
import java.io.IOException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriUtils;
@@ -40,11 +45,20 @@ import software.amazon.awssdk.core.exception.SdkClientException;
public class FileStorageService {
private final S3Client s3Client;
private final MinioProperties properties;
private final StringRedisTemplate redisTemplate;
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of("image/jpeg", "image/png", "image/webp");
private static final String ORIGINAL_FILENAME_METADATA_KEY = "original-filename";
/**
* Сколько живёт запись о владельце файла. Совпадает с правилом жизненного цикла бакета
* (180 дней), который ставит StorageStructureService: раньше файл исчезнет из MinIO,
* чем протухнет отметка о нём.
*/
private static final Duration OWNER_TTL = Duration.ofDays(180);
private static final String OWNER_KEY_PREFIX = "files:owner:";
/**
* Загружает файл в MinIO в корень бакета.
*/
@@ -79,6 +93,7 @@ public class FileStorageService {
.build();
s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
registerOwner(objectName);
log.info("Файл '{}' успешно загружен.", objectName);
return objectName;
@@ -122,6 +137,8 @@ public class FileStorageService {
/**
* Удаляет файл из MinIO по его имени.
* Служебный метод без проверки прав: вызывается только из кода, который сам
* проверил, что файл можно удалять (удаление поста, аватара, модерация).
*/
public void deleteFile(String objectName) {
try {
@@ -132,6 +149,7 @@ public class FileStorageService {
.build();
s3Client.deleteObject(request);
forgetOwner(objectName);
log.info("Файл '{}' успешно удален.", objectName);
} catch (S3Exception | SdkClientException e) {
log.error("Ошибка при удалении файла '{}': {}", objectName, e.getMessage(), e);
@@ -139,6 +157,81 @@ public class FileStorageService {
}
}
/**
* Удаляет файл по запросу пользователя: свой файл удалить можно всегда,
* чужой — только администратору. Отсутствие отметки о владельце означает,
* что сервис не знает, кому файл принадлежит, поэтому удалять его нельзя.
*/
public void deleteFileAsCurrentUser(String objectName) {
assertCanDeleteFile(objectName);
deleteFile(objectName);
}
private void assertCanDeleteFile(String objectName) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || authentication.getPrincipal() == null) {
throw new ApiException(ErrorCode.UNAUTHORIZED, "Пользователь не авторизован", HttpStatus.UNAUTHORIZED);
}
boolean isAdmin = authentication.getAuthorities().stream()
.anyMatch(authority -> Role.ADMIN.name().equals(authority.getAuthority()));
if (isAdmin) {
return;
}
String owner = readOwner(objectName);
if (owner == null) {
log.warn("Удаление файла '{}': владелец неизвестен, доступ запрещён.", objectName);
throw new ApiException(ErrorCode.FORBIDDEN, "Удалить этот файл нельзя", HttpStatus.FORBIDDEN);
}
if (!owner.equals(authentication.getPrincipal())) {
log.warn("Попытка удалить чужой файл '{}' пользователем '{}'.", objectName, authentication.getPrincipal());
throw new ApiException(ErrorCode.FORBIDDEN, "Удалить можно только свои файлы", HttpStatus.FORBIDDEN);
}
}
// ---------- владение файлами ----------
private void registerOwner(String objectName) {
String login = currentLogin();
if (login == null) {
// Служебная загрузка без HTTP-контекста (модерация, сидер) — владельца нет.
return;
}
try {
redisTemplate.opsForValue().set(OWNER_KEY_PREFIX + objectName, login, OWNER_TTL);
} catch (RuntimeException e) {
// Файл уже загружен; не роняем запрос из-за вторичной операции.
log.warn("Не удалось сохранить владельца файла '{}': {}", objectName, e.getMessage());
}
}
private void forgetOwner(String objectName) {
try {
redisTemplate.delete(OWNER_KEY_PREFIX + objectName);
} catch (RuntimeException e) {
log.warn("Не удалось удалить отметку о владельце файла '{}': {}", objectName, e.getMessage());
}
}
private String readOwner(String objectName) {
try {
return redisTemplate.opsForValue().get(OWNER_KEY_PREFIX + objectName);
} catch (RuntimeException e) {
log.warn("Не удалось прочитать владельца файла '{}': {}", objectName, e.getMessage());
return null;
}
}
private String currentLogin() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return null;
}
Object principal = authentication.getPrincipal();
return principal instanceof String login && authentication.isAuthenticated() ? login : null;
}
// ---------- вспомогательные методы ----------
private DownloadedFile fetchFile(String objectName) {

View File

@@ -4,9 +4,9 @@ import com.krylov.refound.dto.user.UserResponseDto;
import com.krylov.refound.dto.user.UserUpdateDto;
import com.krylov.refound.entity.User;
import com.krylov.refound.enums.ErrorCode;
import com.krylov.refound.enums.Role;
import com.krylov.refound.exception.ApiException;
import com.krylov.refound.repository.UserRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
@@ -35,15 +35,48 @@ public class UserService {
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND));
}
/** Текущий запрос сделал администратор. */
public boolean isCurrentUserAdmin() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return false;
}
return authentication.getAuthorities().stream()
.anyMatch(authority -> Role.ADMIN.name().equals(authority.getAuthority()));
}
/**
* Проверяет, что текущий пользователь имеет право менять профиль {@code targetUserId}.
* Своим профилем управляет сам пользователь, чужим — только администратор.
*/
public void assertCanModifyUser(Long targetUserId) {
if (isCurrentUserAdmin()) {
return;
}
User current = getCurrentUser();
if (!current.getId().equals(targetUserId)) {
log.warn("Попытка изменить чужой профиль: current={} target={}", current.getId(), targetUserId);
throw new ApiException(ErrorCode.FORBIDDEN,
"Изменять можно только свой профиль", HttpStatus.FORBIDDEN);
}
}
public UserResponseDto getUserById(Long id) {
User user = repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND));
return getUserResponseDto(user);
}
public UserResponseDto updateUser(Long id, UserUpdateDto dto, Boolean removeAvatar) {
assertCanModifyUser(id);
User user = repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND));
if (dto.getLogin() != null && !dto.getLogin().equals(user.getLogin())
&& repository.existsByLogin(dto.getLogin())) {
throw new ApiException(ErrorCode.VALIDATION_ERROR, "Логин уже занят", HttpStatus.BAD_REQUEST);
}
user.setLogin(dto.getLogin());
user.setName(dto.getFirstName());
@@ -92,7 +125,13 @@ public class UserService {
public Long getUserIdByLogin(String login) {
return repository.findUserByLogin(login)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND));
}
public String getRoleByLogin(String login) {
return repository.findByLogin(login)
.map(user -> user.getRole() != null ? user.getRole().name() : Role.USER.name())
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND));
}
private static UserResponseDto getUserResponseDto(User user) {

View File

@@ -1,53 +1,169 @@
package com.krylov.refound.util;
import com.krylov.refound.security.JwtService;
import com.krylov.refound.service.ChatService;
import com.krylov.refound.service.UserService;
import lombok.RequiredArgsConstructor;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessagingException;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class WsStompInterceptor implements ChannelInterceptor {
private static final String USER_ID_ATTR = "userId";
private static final String ROLE_ATTR = "role";
/** /topic/chat/{chatId} */
private static final Pattern CHAT_TOPIC = Pattern.compile("^/topic/chat/(\\d+)$");
/** /topic/user/{userId}/unread */
private static final Pattern UNREAD_TOPIC = Pattern.compile("^/topic/user/(\\d+)/unread$");
/** /user/{userId}/queue/** — личная очередь одного пользователя */
private static final Pattern USER_QUEUE = Pattern.compile("^/user/[^/]+/queue/.+$");
private final JwtService jwtService;
private final UserService userService;
// ChatService тянет SimpMessagingTemplate, а тот — конфигурацию WebSocket,
// в которую регистрируется этот же интерцептор. Без @Lazy получается цикл.
private final ChatService chatService;
public WsStompInterceptor(JwtService jwtService, UserService userService,
@Lazy ChatService chatService) {
this.jwtService = jwtService;
this.userService = userService;
this.chatService = chatService;
}
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor =
StompHeaderAccessor.wrap(message);
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String authorization = accessor.getFirstNativeHeader("Authorization");
if (authorization == null || !authorization.startsWith("Bearer ")) {
throw new IllegalArgumentException("Missing Authorization header");
}
String token = authorization.substring(7);
if (!jwtService.isTokenValid(token)) {
throw new IllegalArgumentException("Invalid JWT token");
}
String login = jwtService.extractLogin(token);
Long userId = userService.getUserIdByLogin(login);
if (userId == null) {
throw new IllegalArgumentException("User not found");
}
accessor.getSessionAttributes().put("userId", userId);
handleConnect(accessor);
} else if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
handleSubscribe(accessor);
}
return message;
}
private void handleConnect(StompHeaderAccessor accessor) {
String authorization = accessor.getFirstNativeHeader("Authorization");
if (authorization == null || !authorization.startsWith("Bearer ")) {
throw new IllegalArgumentException("Missing Authorization header");
}
String token = authorization.substring(7);
if (!jwtService.isTokenValid(token)) {
throw new IllegalArgumentException("Invalid JWT token");
}
String login = jwtService.extractLogin(token);
// getUserIdByLogin бросает 404, если пользователя нет
Long userId = userService.getUserIdByLogin(login);
String role = jwtService.extractRole(token);
if (role == null) {
role = userService.getRoleByLogin(login);
}
accessor.getSessionAttributes().put(USER_ID_ATTR, userId);
accessor.getSessionAttributes().put(ROLE_ATTR, role);
log.info("WS connected: userId={} role={}", userId, role);
}
/**
* Авторизация подписки. Без неё любой авторизованный пользователь может читать
* чужие чаты и счётчики непрочитанных сообщений, подписавшись на их топики.
*/
private void handleSubscribe(StompHeaderAccessor accessor) {
String destination = accessor.getDestination();
Long userId = userIdFromSession(accessor);
String role = roleFromSession(accessor);
if (userId == null) {
log.warn("Subscribe без CONNECT: destination={}", destination);
throw new MessagingException("Ошибка авторизации");
}
if (isAdminsOnly(destination)) {
if (!isAdmin(role)) {
log.warn("Попытка подписки на служебный канал '{}' пользователем с ролью {}", destination, role);
throw new MessagingException("Доступ запрещён");
}
return;
}
Matcher unread = UNREAD_TOPIC.matcher(destination);
if (unread.matches()) {
Long targetUserId = Long.valueOf(unread.group(1));
if (!userId.equals(targetUserId)) {
log.warn("Попытка подписки на чужой счётчик непрочитанных: userId={} target={}", userId, targetUserId);
throw new MessagingException("Доступ запрещён");
}
return;
}
if (USER_QUEUE.matcher(destination).matches()) {
String[] parts = destination.split("/");
if (parts.length < 3 || !userId.toString().equals(parts[2])) {
log.warn("Попытка подписки на чужую личную очередь: userId={} destination={}", userId, destination);
throw new MessagingException("Доступ запрещён");
}
return;
}
Matcher chat = CHAT_TOPIC.matcher(destination);
if (chat.matches()) {
Long chatId = Long.valueOf(chat.group(1));
if (!chatService.isParticipant(chatId, userId)) {
log.warn("Пользователь {} не участник чата {} — подписка запрещена", userId, chatId);
throw new MessagingException("Доступ запрещён");
}
return;
}
// Неизвестные топики закрыты по умолчанию: иначе появится канал,
// который случайно разрешит чужие данные.
log.warn("Подписка на неизвестный канал '{}' пользователем {}", destination, userId);
throw new MessagingException("Доступ запрещён");
}
private boolean isAdminsOnly(String destination) {
return destination != null
&& (destination.startsWith("/topic/ads") || destination.startsWith("/topic/fullscreen-ads"));
}
private boolean isAdmin(String role) {
return "ADMIN".equalsIgnoreCase(role);
}
private Long userIdFromSession(StompHeaderAccessor accessor) {
if (accessor.getSessionAttributes() == null) {
return null;
}
Object value = accessor.getSessionAttributes().get(USER_ID_ATTR);
return value instanceof Long id ? id : null;
}
private String roleFromSession(StompHeaderAccessor accessor) {
if (accessor.getSessionAttributes() == null) {
return null;
}
Object value = accessor.getSessionAttributes().get(ROLE_ATTR);
return value instanceof String role ? role : null;
}
}