Add demo logic for landing
Test Workflow / test (push) Has been cancelled

This commit is contained in:
2026-07-11 09:53:57 -05:00
parent ccb57c3805
commit 1f46043a1d
5 changed files with 76 additions and 19 deletions
@@ -3,6 +3,7 @@ package ru.soune.nocopy.controller.demo;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -19,6 +20,14 @@ public class DemoController {
private final DemoSessionService demoSessionService; private final DemoSessionService demoSessionService;
private final HttpServletRequest httpServletRequest; private final HttpServletRequest httpServletRequest;
/**
* X-Forwarded-For/X-Real-IP — клиентские заголовки, их можно подделать в запросе.
* Доверять им можно, только если приложение стоит за доверенным reverse-proxy/LB,
* который сам их проставляет и не пропускает клиентские значения напрямую.
*/
@Value("${demo.trust-forwarded-header:false}")
private boolean trustForwardedHeader;
/** /**
* Загрузка файла для демонстрации * Загрузка файла для демонстрации
* Требуется файл в формате multipart/form-data * Требуется файл в формате multipart/form-data
@@ -161,14 +170,16 @@ public class DemoController {
* Учитывает различные прокси и заголовки * Учитывает различные прокси и заголовки
*/ */
private String getClientIp() { private String getClientIp() {
String xForwardedFor = httpServletRequest.getHeader("X-Forwarded-For"); if (trustForwardedHeader) {
if (xForwardedFor != null && !xForwardedFor.isEmpty()) { String xForwardedFor = httpServletRequest.getHeader("X-Forwarded-For");
return xForwardedFor.split(",")[0].trim(); if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
} return xForwardedFor.split(",")[0].trim();
}
String xRealIp = httpServletRequest.getHeader("X-Real-IP"); String xRealIp = httpServletRequest.getHeader("X-Real-IP");
if (xRealIp != null && !xRealIp.isEmpty()) { if (xRealIp != null && !xRealIp.isEmpty()) {
return xRealIp; return xRealIp;
}
} }
return httpServletRequest.getRemoteAddr(); return httpServletRequest.getRemoteAddr();
@@ -61,7 +61,9 @@ public class DemoSession {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now();
expiresAt = LocalDateTime.now().plusHours(24); expiresAt = LocalDateTime.now().plusHours(24);
status = DemoSessionStatus.CREATED; if (status == null) {
status = DemoSessionStatus.CREATED;
}
} }
@PreUpdate @PreUpdate
@@ -19,6 +19,9 @@ public interface DemoSessionRepository extends JpaRepository<DemoSession, String
@Query("SELECT d FROM DemoSession d WHERE d.expiresAt <= :now AND d.status != 'CLEANED'") @Query("SELECT d FROM DemoSession d WHERE d.expiresAt <= :now AND d.status != 'CLEANED'")
List<DemoSession> findExpiredSessions(LocalDateTime now); List<DemoSession> findExpiredSessions(LocalDateTime now);
@Query("SELECT d FROM DemoSession d WHERE d.expiresAt > :now AND d.status != 'CLEANED'")
List<DemoSession> findActiveSessions(LocalDateTime now);
@Query("SELECT COUNT(d) FROM DemoSession d WHERE d.ipAddress = :ipAddress AND d.createdAt >= :since") @Query("SELECT COUNT(d) FROM DemoSession d WHERE d.ipAddress = :ipAddress AND d.createdAt >= :since")
Integer countSessionsByIpAndTime(String ipAddress, LocalDateTime since); Integer countSessionsByIpAndTime(String ipAddress, LocalDateTime since);
@@ -126,10 +126,7 @@ public class DemoCleanupService {
*/ */
public long getActiveDemoSessionsCount() { public long getActiveDemoSessionsCount() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
return demoSessionRepository.findExpiredSessions(now) return demoSessionRepository.findActiveSessions(now).size();
.stream()
.filter(s -> !s.getStatus().equals(DemoSessionStatus.CLEANED))
.count();
} }
/** /**
@@ -138,9 +135,8 @@ public class DemoCleanupService {
*/ */
public long getActiveDemoDataSize() { public long getActiveDemoDataSize() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
return demoSessionRepository.findExpiredSessions(now) return demoSessionRepository.findActiveSessions(now)
.stream() .stream()
.filter(s -> !s.getStatus().equals(DemoSessionStatus.CLEANED))
.mapToLong(DemoSession::getFileSize) .mapToLong(DemoSession::getFileSize)
.sum(); .sum();
} }
@@ -21,7 +21,6 @@ import ru.soune.nocopy.repository.SimilarImageProjection;
import ru.soune.nocopy.service.FileSimilarityService; import ru.soune.nocopy.service.FileSimilarityService;
import ru.soune.nocopy.service.ImageHashService; import ru.soune.nocopy.service.ImageHashService;
import ru.soune.nocopy.service.file.cloud.CloudStorageService; import ru.soune.nocopy.service.file.cloud.CloudStorageService;
import ru.soune.nocopy.util.FileUtil;
import java.io.*; import java.io.*;
import java.nio.file.Files; import java.nio.file.Files;
@@ -44,7 +43,9 @@ public class DemoSessionService {
@Lazy @Lazy
private final NoCopyFileService noCopyFileService; private final NoCopyFileService noCopyFileService;
private final FileUtil fileUtil; private static final Set<String> ALLOWED_IMAGE_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "bmp", "webp");
private static final Set<String> ALLOWED_AUDIO_EXTENSIONS = Set.of("wav");
private static final Set<String> ALLOWED_DOCUMENT_EXTENSIONS = Set.of("pdf", "doc", "docx");
@Value("${demo.max-file-size:52428800}") // 50 MB default @Value("${demo.max-file-size:52428800}") // 50 MB default
private Long maxFileSize; private Long maxFileSize;
@@ -67,15 +68,17 @@ public class DemoSessionService {
validateDemoUpload(file, ipAddress); validateDemoUpload(file, ipAddress);
String safeFileName = sanitizeFileName(file.getOriginalFilename());
DemoSession session = DemoSession.builder() DemoSession session = DemoSession.builder()
.sessionId(UUID.randomUUID().toString()) .sessionId(UUID.randomUUID().toString())
.ipAddress(ipAddress) .ipAddress(ipAddress)
.fileName(file.getOriginalFilename()) .fileName(safeFileName)
.fileSize(file.getSize()) .fileSize(file.getSize())
.fileType(detectFileType(file.getContentType())) .fileType(detectFileType(file.getContentType()))
.mimeType(file.getContentType()) .mimeType(file.getContentType())
.status(DemoSessionStatus.CREATED) .status(DemoSessionStatus.CREATED)
.s3Path(demoS3Path + UUID.randomUUID() + "/" + file.getOriginalFilename()) .s3Path(demoS3Path + UUID.randomUUID() + "/" + safeFileName)
.build(); .build();
String tempFilePath = null; String tempFilePath = null;
@@ -247,9 +250,33 @@ public class DemoSessionService {
throw new RuntimeException("File type not supported for demo"); throw new RuntimeException("File type not supported for demo");
} }
// Content-Type присылается клиентом и может быть подделан, поэтому дополнительно
// сверяем расширение файла с ожидаемым для заявленной категории
String detectedType = detectFileType(contentType);
String extension = getFileExtension(file.getOriginalFilename());
if (!isExtensionAllowedForType(detectedType, extension)) {
throw new RuntimeException("File extension does not match declared file type");
}
log.info("Validation passed for IP: {}", ipAddress); log.info("Validation passed for IP: {}", ipAddress);
} }
private String getFileExtension(String fileName) {
if (fileName == null) return "";
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex < 0 || dotIndex == fileName.length() - 1) return "";
return fileName.substring(dotIndex + 1).toLowerCase();
}
private boolean isExtensionAllowedForType(String fileType, String extension) {
return switch (fileType) {
case "image" -> ALLOWED_IMAGE_EXTENSIONS.contains(extension);
case "audio" -> ALLOWED_AUDIO_EXTENSIONS.contains(extension);
case "document" -> ALLOWED_DOCUMENT_EXTENSIONS.contains(extension);
default -> false;
};
}
private String detectFileType(String mimeType) { private String detectFileType(String mimeType) {
if (mimeType == null) return "unknown"; if (mimeType == null) return "unknown";
if (mimeType.contains("image")) return "image"; if (mimeType.contains("image")) return "image";
@@ -301,7 +328,7 @@ public class DemoSessionService {
Path tempDir = Paths.get(basePath).resolve("temp").resolve("demo").resolve(sessionId); Path tempDir = Paths.get(basePath).resolve("temp").resolve("demo").resolve(sessionId);
Files.createDirectories(tempDir); Files.createDirectories(tempDir);
String fileName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file"; String fileName = sanitizeFileName(file.getOriginalFilename());
Path tempFilePath = tempDir.resolve(fileName); Path tempFilePath = tempDir.resolve(fileName);
file.transferTo(tempFilePath.toFile()); file.transferTo(tempFilePath.toFile());
@@ -310,6 +337,24 @@ public class DemoSessionService {
return tempFilePath.toString(); return tempFilePath.toString();
} }
/**
* Убирает любые компоненты пути из имени файла, чтобы исключить path traversal
* (например, "../../etc/passwd" или абсолютные пути) при записи временного файла
* и формировании ключа в S3.
*/
private String sanitizeFileName(String rawFileName) {
String name = (rawFileName == null || rawFileName.isBlank()) ? "file" : rawFileName;
name = name.replace('\\', '/');
int lastSlash = name.lastIndexOf('/');
if (lastSlash >= 0) {
name = name.substring(lastSlash + 1);
}
if (name.isBlank() || name.equals(".") || name.equals("..")) {
name = "file";
}
return name;
}
private void performProtectionCheck(String tempFilePath, String fileType) throws IOException, DuplicateImageException { private void performProtectionCheck(String tempFilePath, String fileType) throws IOException, DuplicateImageException {
File file = new File(tempFilePath); File file = new File(tempFilePath);