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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -19,6 +20,14 @@ public class DemoController {
private final DemoSessionService demoSessionService;
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
@@ -161,6 +170,7 @@ public class DemoController {
* Учитывает различные прокси и заголовки
*/
private String getClientIp() {
if (trustForwardedHeader) {
String xForwardedFor = httpServletRequest.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
@@ -170,6 +180,7 @@ public class DemoController {
if (xRealIp != null && !xRealIp.isEmpty()) {
return xRealIp;
}
}
return httpServletRequest.getRemoteAddr();
}
@@ -61,8 +61,10 @@ public class DemoSession {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
expiresAt = LocalDateTime.now().plusHours(24);
if (status == null) {
status = DemoSessionStatus.CREATED;
}
}
@PreUpdate
protected void onUpdate() {
@@ -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'")
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")
Integer countSessionsByIpAndTime(String ipAddress, LocalDateTime since);
@@ -126,10 +126,7 @@ public class DemoCleanupService {
*/
public long getActiveDemoSessionsCount() {
LocalDateTime now = LocalDateTime.now();
return demoSessionRepository.findExpiredSessions(now)
.stream()
.filter(s -> !s.getStatus().equals(DemoSessionStatus.CLEANED))
.count();
return demoSessionRepository.findActiveSessions(now).size();
}
/**
@@ -138,9 +135,8 @@ public class DemoCleanupService {
*/
public long getActiveDemoDataSize() {
LocalDateTime now = LocalDateTime.now();
return demoSessionRepository.findExpiredSessions(now)
return demoSessionRepository.findActiveSessions(now)
.stream()
.filter(s -> !s.getStatus().equals(DemoSessionStatus.CLEANED))
.mapToLong(DemoSession::getFileSize)
.sum();
}
@@ -21,7 +21,6 @@ import ru.soune.nocopy.repository.SimilarImageProjection;
import ru.soune.nocopy.service.FileSimilarityService;
import ru.soune.nocopy.service.ImageHashService;
import ru.soune.nocopy.service.file.cloud.CloudStorageService;
import ru.soune.nocopy.util.FileUtil;
import java.io.*;
import java.nio.file.Files;
@@ -44,7 +43,9 @@ public class DemoSessionService {
@Lazy
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
private Long maxFileSize;
@@ -67,15 +68,17 @@ public class DemoSessionService {
validateDemoUpload(file, ipAddress);
String safeFileName = sanitizeFileName(file.getOriginalFilename());
DemoSession session = DemoSession.builder()
.sessionId(UUID.randomUUID().toString())
.ipAddress(ipAddress)
.fileName(file.getOriginalFilename())
.fileName(safeFileName)
.fileSize(file.getSize())
.fileType(detectFileType(file.getContentType()))
.mimeType(file.getContentType())
.status(DemoSessionStatus.CREATED)
.s3Path(demoS3Path + UUID.randomUUID() + "/" + file.getOriginalFilename())
.s3Path(demoS3Path + UUID.randomUUID() + "/" + safeFileName)
.build();
String tempFilePath = null;
@@ -247,9 +250,33 @@ public class DemoSessionService {
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);
}
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) {
if (mimeType == null) return "unknown";
if (mimeType.contains("image")) return "image";
@@ -301,7 +328,7 @@ public class DemoSessionService {
Path tempDir = Paths.get(basePath).resolve("temp").resolve("demo").resolve(sessionId);
Files.createDirectories(tempDir);
String fileName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
String fileName = sanitizeFileName(file.getOriginalFilename());
Path tempFilePath = tempDir.resolve(fileName);
file.transferTo(tempFilePath.toFile());
@@ -310,6 +337,24 @@ public class DemoSessionService {
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 {
File file = new File(tempFilePath);