add bone for file system #9
@@ -0,0 +1,172 @@
|
|||||||
|
package ru.soune.nocopy.controller.file;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.UrlResource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import ru.soune.nocopy.dto.file.FileEntityResponse;
|
||||||
|
import ru.soune.nocopy.entity.AuthToken;
|
||||||
|
import ru.soune.nocopy.exception.NotFoundAuthToken;
|
||||||
|
import ru.soune.nocopy.repository.AuthTokenRepository;
|
||||||
|
import ru.soune.nocopy.service.file.FileEntityService;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/files")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FileDownloadController {
|
||||||
|
|
||||||
|
private final FileEntityService fileEntityService;
|
||||||
|
private final AuthTokenRepository authTokenRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скачать файл по ID FileEntity
|
||||||
|
*/
|
||||||
|
@GetMapping("/download/{fileId}")
|
||||||
|
public ResponseEntity<Resource> downloadFile(
|
||||||
|
@PathVariable String fileId,
|
||||||
|
@RequestHeader("Authorization") String tokenHeader) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
Long userId = getUserIdFromToken(tokenHeader);
|
||||||
|
FileEntityResponse fileInfo = fileEntityService.getById(fileId);
|
||||||
|
|
||||||
|
if (!fileInfo.getUserId().equals(userId)) {
|
||||||
|
return ResponseEntity.status(403).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileInfo.isExistsOnDisk()) {
|
||||||
|
return ResponseEntity.status(404)
|
||||||
|
.body(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path filePath = Paths.get(fileInfo.getFilePath());
|
||||||
|
Resource resource = new UrlResource(filePath.toUri());
|
||||||
|
|
||||||
|
if (!resource.exists()) {
|
||||||
|
return ResponseEntity.status(404).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
String contentType = determineContentType(filePath);
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.parseMediaType(contentType))
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"" + fileInfo.getOriginalFileName() + "\"")
|
||||||
|
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(fileInfo.getFileSize()))
|
||||||
|
.body(resource);
|
||||||
|
|
||||||
|
} catch (NotFoundAuthToken e) {
|
||||||
|
return ResponseEntity.status(401).build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error downloading file", e);
|
||||||
|
return ResponseEntity.status(500).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить предпросмотр файла (если поддерживается)
|
||||||
|
*/
|
||||||
|
@GetMapping("/preview/{fileId}")
|
||||||
|
public ResponseEntity<Resource> previewFile(
|
||||||
|
@PathVariable String fileId,
|
||||||
|
@RequestHeader("Authorization") String tokenHeader) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
Long userId = getUserIdFromToken(tokenHeader);
|
||||||
|
FileEntityResponse fileInfo = fileEntityService.getById(fileId);
|
||||||
|
|
||||||
|
if (!fileInfo.getUserId().equals(userId)) {
|
||||||
|
return ResponseEntity.status(403).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPreviewSupported(fileInfo.getMimeType())) {
|
||||||
|
return ResponseEntity.status(415)
|
||||||
|
.body(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path filePath = Paths.get(fileInfo.getFilePath());
|
||||||
|
Resource resource = new UrlResource(filePath.toUri());
|
||||||
|
|
||||||
|
if (!resource.exists()) {
|
||||||
|
return ResponseEntity.status(404).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
String contentType = determineContentType(filePath);
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.parseMediaType(contentType))
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"inline; filename=\"" + fileInfo.getOriginalFileName() + "\"")
|
||||||
|
.body(resource);
|
||||||
|
|
||||||
|
} catch (NotFoundAuthToken e) {
|
||||||
|
return ResponseEntity.status(401).build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error previewing file", e);
|
||||||
|
return ResponseEntity.status(500).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить информацию о файле для фронтенда
|
||||||
|
*/
|
||||||
|
@GetMapping("/info/{fileId}")
|
||||||
|
public ResponseEntity<FileEntityResponse> getFileInfo(
|
||||||
|
@PathVariable String fileId,
|
||||||
|
@RequestHeader("Authorization") String tokenHeader) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
Long userId = getUserIdFromToken(tokenHeader);
|
||||||
|
FileEntityResponse fileInfo = fileEntityService.getById(fileId);
|
||||||
|
|
||||||
|
if (!fileInfo.getUserId().equals(userId)) {
|
||||||
|
return ResponseEntity.status(403).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(fileInfo);
|
||||||
|
|
||||||
|
} catch (NotFoundAuthToken e) {
|
||||||
|
return ResponseEntity.status(401).build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error getting file info", e);
|
||||||
|
return ResponseEntity.status(500).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String determineContentType(Path filePath) throws IOException {
|
||||||
|
String contentType = Files.probeContentType(filePath);
|
||||||
|
if (contentType == null) {
|
||||||
|
contentType = "application/octet-stream";
|
||||||
|
}
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isPreviewSupported(String mimeType) {
|
||||||
|
if (mimeType == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mimeType.startsWith("image/") ||
|
||||||
|
mimeType.startsWith("text/") ||
|
||||||
|
mimeType.equals("application/pdf") ||
|
||||||
|
mimeType.startsWith("video/") ||
|
||||||
|
mimeType.startsWith("audio/");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long getUserIdFromToken(String tokenHeader) {
|
||||||
|
String token = tokenHeader.replace("Bearer ", "");
|
||||||
|
AuthToken authToken = authTokenRepository.findByToken(token)
|
||||||
|
.orElseThrow(() -> new NotFoundAuthToken("Token not found"));
|
||||||
|
return authToken.getUser().getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package ru.soune.nocopy.controller.file;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import ru.soune.nocopy.dto.file.FileApiResponse;
|
||||||
|
import ru.soune.nocopy.dto.file.FileEntityResponse;
|
||||||
|
import ru.soune.nocopy.dto.file.FileListResponse;
|
||||||
|
import ru.soune.nocopy.entity.AuthToken;
|
||||||
|
import ru.soune.nocopy.exception.NotFoundAuthToken;
|
||||||
|
import ru.soune.nocopy.repository.AuthTokenRepository;
|
||||||
|
import ru.soune.nocopy.service.file.FileEntityService;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/file-entities")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FileEntityController {
|
||||||
|
|
||||||
|
private final FileEntityService fileEntityService;
|
||||||
|
private final AuthTokenRepository authTokenRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить информацию о файле по ID
|
||||||
|
*/
|
||||||
|
@GetMapping("/{fileId}")
|
||||||
|
public ResponseEntity<FileApiResponse<FileEntityResponse>> getFileInfo(
|
||||||
|
@PathVariable String fileId,
|
||||||
|
@RequestHeader("Authorization") String tokenHeader) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
Long userId = getUserIdFromToken(tokenHeader);
|
||||||
|
FileEntityResponse fileInfo = fileEntityService.getById(fileId);
|
||||||
|
|
||||||
|
if (!fileInfo.getUserId().equals(userId)) {
|
||||||
|
return ResponseEntity.status(403)
|
||||||
|
.body(FileApiResponse.error("Access denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(FileApiResponse.success(fileInfo));
|
||||||
|
|
||||||
|
} catch (NotFoundAuthToken e) {
|
||||||
|
return ResponseEntity.status(401)
|
||||||
|
.body(FileApiResponse.error("Authentication required"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error getting file info", e);
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(FileApiResponse.error("Failed to get file info: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить информацию о файле по ID сессии загрузки
|
||||||
|
*/
|
||||||
|
@GetMapping("/by-session/{uploadSessionId}")
|
||||||
|
public ResponseEntity<FileApiResponse<FileEntityResponse>> getFileByUploadSession(
|
||||||
|
@PathVariable String uploadSessionId,
|
||||||
|
@RequestHeader("Authorization") String tokenHeader) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
Long userId = getUserIdFromToken(tokenHeader);
|
||||||
|
FileEntityResponse fileInfo = fileEntityService.getByUploadSessionId(uploadSessionId);
|
||||||
|
|
||||||
|
if (!fileInfo.getUserId().equals(userId)) {
|
||||||
|
return ResponseEntity.status(403)
|
||||||
|
.body(FileApiResponse.error("Access denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(FileApiResponse.success(fileInfo));
|
||||||
|
|
||||||
|
} catch (NotFoundAuthToken e) {
|
||||||
|
return ResponseEntity.status(401)
|
||||||
|
.body(FileApiResponse.error("Authentication required"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error getting file by session", e);
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(FileApiResponse.error("Failed to get file: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить все файлы пользователя
|
||||||
|
*/
|
||||||
|
@GetMapping("/my-files")
|
||||||
|
public ResponseEntity<FileApiResponse<FileListResponse>> getUserFiles(
|
||||||
|
@RequestHeader("Authorization") String tokenHeader,
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
Long userId = getUserIdFromToken(tokenHeader);
|
||||||
|
FileListResponse files = fileEntityService.getUserFiles(userId, page, pageSize);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(FileApiResponse.success(files));
|
||||||
|
|
||||||
|
} catch (NotFoundAuthToken e) {
|
||||||
|
return ResponseEntity.status(401)
|
||||||
|
.body(FileApiResponse.error("Authentication required"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error getting user files", e);
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(FileApiResponse.error("Failed to get files: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поиск файлов пользователя
|
||||||
|
*/
|
||||||
|
@GetMapping("/search")
|
||||||
|
public ResponseEntity<FileApiResponse<FileListResponse>> searchFiles(
|
||||||
|
@RequestParam String query,
|
||||||
|
@RequestHeader("Authorization") String tokenHeader,
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
Long userId = getUserIdFromToken(tokenHeader);
|
||||||
|
FileListResponse allFiles = fileEntityService.getUserFiles(userId, 1, 1000);
|
||||||
|
|
||||||
|
var filteredFiles = allFiles.getFiles().stream()
|
||||||
|
.filter(f -> f.getOriginalFileName().toLowerCase().contains(query.toLowerCase()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
int start = (page - 1) * pageSize;
|
||||||
|
int end = Math.min(start + pageSize, filteredFiles.size());
|
||||||
|
|
||||||
|
FileListResponse response = FileListResponse.builder()
|
||||||
|
.files(filteredFiles.subList(start, Math.min(end, filteredFiles.size())))
|
||||||
|
.totalCount(filteredFiles.size())
|
||||||
|
.totalSize(allFiles.getTotalSize())
|
||||||
|
.formattedTotalSize(allFiles.getFormattedTotalSize())
|
||||||
|
.page(page)
|
||||||
|
.pageSize(pageSize)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(FileApiResponse.success(response));
|
||||||
|
|
||||||
|
} catch (NotFoundAuthToken e) {
|
||||||
|
return ResponseEntity.status(401)
|
||||||
|
.body(FileApiResponse.error("Authentication required"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error searching files", e);
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(FileApiResponse.error("Failed to search files: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить общий размер файлов пользователя
|
||||||
|
*/
|
||||||
|
@GetMapping("/storage/usage")
|
||||||
|
public ResponseEntity<FileApiResponse<Long>> getStorageUsage(
|
||||||
|
@RequestHeader("Authorization") String tokenHeader) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
Long userId = getUserIdFromToken(tokenHeader);
|
||||||
|
long usage = fileEntityService.getUserStorageUsed(userId);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(FileApiResponse.success(usage));
|
||||||
|
|
||||||
|
} catch (NotFoundAuthToken e) {
|
||||||
|
return ResponseEntity.status(401)
|
||||||
|
.body(FileApiResponse.error("Authentication required"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error getting storage usage", e);
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(FileApiResponse.error("Failed to get storage usage: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пометить файл как удаленный
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{fileId}")
|
||||||
|
public ResponseEntity<FileApiResponse<Void>> deleteFile(
|
||||||
|
@PathVariable String fileId,
|
||||||
|
@RequestHeader("Authorization") String tokenHeader) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
Long userId = getUserIdFromToken(tokenHeader);
|
||||||
|
FileEntityResponse fileInfo = fileEntityService.getById(fileId);
|
||||||
|
|
||||||
|
if (!fileInfo.getUserId().equals(userId)) {
|
||||||
|
return ResponseEntity.status(403)
|
||||||
|
.body(FileApiResponse.error("Access denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fileEntityService.markAsDeleted(fileId);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(FileApiResponse.success(
|
||||||
|
"File marked as deleted", null));
|
||||||
|
|
||||||
|
} catch (NotFoundAuthToken e) {
|
||||||
|
return ResponseEntity.status(401)
|
||||||
|
.body(FileApiResponse.error("Authentication required"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error deleting file", e);
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(FileApiResponse.error("Failed to delete file: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long getUserIdFromToken(String tokenHeader) {
|
||||||
|
String token = tokenHeader.replace("Bearer ", "");
|
||||||
|
AuthToken authToken = authTokenRepository.findByToken(token)
|
||||||
|
.orElseThrow(() -> new NotFoundAuthToken("Token not found"));
|
||||||
|
return authToken.getUser().getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package ru.soune.nocopy.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public record AuthResponse (
|
||||||
|
boolean success,
|
||||||
|
String message,
|
||||||
|
String token,
|
||||||
|
LocalDateTime expiresAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package ru.soune.nocopy.dto.file;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import ru.soune.nocopy.entity.file.FileStatus;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class FileEntityResponse {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private Long userId;
|
||||||
|
private String originalFileName;
|
||||||
|
private String storedFileName;
|
||||||
|
private String filePath;
|
||||||
|
private Long fileSize;
|
||||||
|
private String mimeType;
|
||||||
|
private String fileExtension;
|
||||||
|
private String checksum;
|
||||||
|
private String uploadSessionId;
|
||||||
|
private FileStatus status;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private String formattedSize;
|
||||||
|
private String downloadUrl;
|
||||||
|
private boolean existsOnDisk;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package ru.soune.nocopy.dto.file;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class FileListResponse {
|
||||||
|
|
||||||
|
private List<FileEntityResponse> files;
|
||||||
|
private int totalCount;
|
||||||
|
private long totalSize;
|
||||||
|
private String formattedTotalSize;
|
||||||
|
private int page;
|
||||||
|
private int pageSize;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package ru.soune.nocopy.entity.file;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.annotation.LastModifiedDate;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Entity
|
||||||
|
@Table(name = "file_entities")
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
public class FileEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(name = "original_file_name", nullable = false)
|
||||||
|
private String originalFileName;
|
||||||
|
|
||||||
|
@Column(name = "stored_file_name", nullable = false)
|
||||||
|
private String storedFileName;
|
||||||
|
|
||||||
|
@Column(name = "file_path", nullable = false, unique = true)
|
||||||
|
private String filePath;
|
||||||
|
|
||||||
|
@Column(name = "file_size", nullable = false)
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
@Column(name = "mime_type")
|
||||||
|
private String mimeType;
|
||||||
|
|
||||||
|
@Column(name = "file_extension")
|
||||||
|
private String fileExtension;
|
||||||
|
|
||||||
|
@Column(name = "checksum")
|
||||||
|
private String checksum;
|
||||||
|
|
||||||
|
@Column(name = "upload_session_id")
|
||||||
|
private String uploadSessionId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status")
|
||||||
|
private FileStatus status;
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
@Column(name = "created_at", updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@LastModifiedDate
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
public void prePersist() {
|
||||||
|
if (this.status == null) {
|
||||||
|
this.status = FileStatus.ACTIVE;
|
||||||
|
}
|
||||||
|
if (this.createdAt == null) {
|
||||||
|
this.createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
public void preUpdate() {
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package ru.soune.nocopy.entity.file;
|
||||||
|
|
||||||
|
public enum FileStatus {
|
||||||
|
ACTIVE,
|
||||||
|
DELETED,
|
||||||
|
PROCESSING,
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package ru.soune.nocopy.exception;
|
||||||
|
|
||||||
|
public class FileEntityNotFoundException extends RuntimeException {
|
||||||
|
public FileEntityNotFoundException(String fileId) {
|
||||||
|
super("FileEntity not found with ID: " + fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FileEntityNotFoundException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package ru.soune.nocopy.repository;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import ru.soune.nocopy.entity.file.FileEntity;
|
||||||
|
import ru.soune.nocopy.entity.file.FileStatus;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface FileEntityRepository extends JpaRepository<FileEntity, String> {
|
||||||
|
|
||||||
|
List<FileEntity> findByUserId(Long userId);
|
||||||
|
|
||||||
|
List<FileEntity> findByUserIdAndStatus(Long userId, FileStatus status);
|
||||||
|
|
||||||
|
Optional<FileEntity> findByFilePath(String filePath);
|
||||||
|
|
||||||
|
Optional<FileEntity> findByUploadSessionId(String uploadSessionId);
|
||||||
|
|
||||||
|
boolean existsByFilePath(String filePath);
|
||||||
|
|
||||||
|
@Query("SELECT SUM(f.fileSize) FROM FileEntity f WHERE f.userId = :userId AND f.status = 'ACTIVE'")
|
||||||
|
Long getTotalSizeByUserId(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
@Query("SELECT f FROM FileEntity f WHERE f.userId = :userId AND f.originalFileName LIKE %:keyword%")
|
||||||
|
List<FileEntity> searchByFileName(@Param("userId") Long userId, @Param("keyword") String keyword);
|
||||||
|
|
||||||
|
long countByUserId(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package ru.soune.nocopy.service.file;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import ru.soune.nocopy.dto.file.FileEntityResponse;
|
||||||
|
import ru.soune.nocopy.dto.file.FileListResponse;
|
||||||
|
import ru.soune.nocopy.entity.file.FileEntity;
|
||||||
|
import ru.soune.nocopy.entity.file.FileStatus;
|
||||||
|
import ru.soune.nocopy.entity.file.FileUploadSession;
|
||||||
|
import ru.soune.nocopy.exception.FileEntityNotFoundException;
|
||||||
|
import ru.soune.nocopy.repository.FileEntityRepository;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FileEntityService {
|
||||||
|
|
||||||
|
private final FileEntityRepository fileEntityRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создает FileEntity на основе завершенной сессии загрузки
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public FileEntity createFromUploadSession(FileUploadSession session, String checksum) {
|
||||||
|
log.info("Creating FileEntity for upload session: {}", session.getUploadId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
Path filePath = Paths.get(session.getFilePath());
|
||||||
|
|
||||||
|
if (!Files.exists(filePath)) {
|
||||||
|
throw new IOException("File not found on disk: " + filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
long fileSize = Files.size(filePath);
|
||||||
|
String originalName = session.getFileName();
|
||||||
|
String extension = extractFileExtension(originalName);
|
||||||
|
String storedName = filePath.getFileName().toString();
|
||||||
|
|
||||||
|
FileEntity fileEntity = FileEntity.builder()
|
||||||
|
.userId(session.getUserId())
|
||||||
|
.originalFileName(originalName)
|
||||||
|
.storedFileName(storedName)
|
||||||
|
.filePath(session.getFilePath())
|
||||||
|
.fileSize(fileSize)
|
||||||
|
.mimeType(session.getFileType())
|
||||||
|
.fileExtension(extension)
|
||||||
|
.checksum(checksum)
|
||||||
|
.uploadSessionId(session.getUploadId())
|
||||||
|
.status(FileStatus.ACTIVE)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
FileEntity saved = fileEntityRepository.save(fileEntity);
|
||||||
|
log.info("FileEntity created successfully: {} (size: {} bytes)",
|
||||||
|
saved.getId(), fileSize);
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to create FileEntity for session {}: {}",
|
||||||
|
session.getUploadId(), e.getMessage(), e);
|
||||||
|
throw new RuntimeException("Failed to create file entity: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Извлекает расширение файла из имени
|
||||||
|
*/
|
||||||
|
private String extractFileExtension(String fileName) {
|
||||||
|
int dotIndex = fileName.lastIndexOf('.');
|
||||||
|
if (dotIndex > 0 && dotIndex < fileName.length() - 1) {
|
||||||
|
return fileName.substring(dotIndex + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает FileEntity по ID
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public FileEntityResponse getById(String fileId) {
|
||||||
|
FileEntity fileEntity = fileEntityRepository.findById(fileId)
|
||||||
|
.orElseThrow(() -> new FileEntityNotFoundException(fileId));
|
||||||
|
|
||||||
|
return convertToResponse(fileEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает FileEntity по ID сессии загрузки
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public FileEntityResponse getByUploadSessionId(String uploadSessionId) {
|
||||||
|
FileEntity fileEntity = fileEntityRepository.findByUploadSessionId(uploadSessionId)
|
||||||
|
.orElseThrow(() -> new FileEntityNotFoundException(
|
||||||
|
"Not found for upload session: " + uploadSessionId));
|
||||||
|
|
||||||
|
return convertToResponse(fileEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает FileEntity по пути файла
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public FileEntityResponse getByFilePath(String filePath) {
|
||||||
|
FileEntity fileEntity = fileEntityRepository.findByFilePath(filePath)
|
||||||
|
.orElseThrow(() -> new FileEntityNotFoundException("Path: " + filePath));
|
||||||
|
|
||||||
|
return convertToResponse(fileEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает все файлы пользователя
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public FileListResponse getAllUserFiles(Long userId) {
|
||||||
|
List<FileEntity> fileEntities = fileEntityRepository.findByUserIdAndStatus(
|
||||||
|
userId, FileStatus.ACTIVE);
|
||||||
|
|
||||||
|
List<FileEntityResponse> files = fileEntities.stream()
|
||||||
|
.map(this::convertToResponse)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
long totalSize = fileEntities.stream()
|
||||||
|
.mapToLong(FileEntity::getFileSize)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
return FileListResponse.builder()
|
||||||
|
.files(files)
|
||||||
|
.totalCount(files.size())
|
||||||
|
.totalSize(totalSize)
|
||||||
|
.formattedTotalSize(formatFileSize(totalSize))
|
||||||
|
.page(1)
|
||||||
|
.pageSize(files.size())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает файлы пользователя с пагинацией
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public FileListResponse getUserFiles(Long userId, int page, int pageSize) {
|
||||||
|
List<FileEntity> allFiles = fileEntityRepository.findByUserIdAndStatus(
|
||||||
|
userId, FileStatus.ACTIVE);
|
||||||
|
|
||||||
|
int start = (page - 1) * pageSize;
|
||||||
|
int end = Math.min(start + pageSize, allFiles.size());
|
||||||
|
|
||||||
|
if (start >= allFiles.size()) {
|
||||||
|
return FileListResponse.builder()
|
||||||
|
.files(List.of())
|
||||||
|
.totalCount(allFiles.size())
|
||||||
|
.totalSize(0)
|
||||||
|
.formattedTotalSize("0 B")
|
||||||
|
.page(page)
|
||||||
|
.pageSize(pageSize)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FileEntity> pageFiles = allFiles.subList(start, end);
|
||||||
|
|
||||||
|
List<FileEntityResponse> files = pageFiles.stream()
|
||||||
|
.map(this::convertToResponse)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
long totalSize = allFiles.stream()
|
||||||
|
.mapToLong(FileEntity::getFileSize)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
return FileListResponse.builder()
|
||||||
|
.files(files)
|
||||||
|
.totalCount(allFiles.size())
|
||||||
|
.totalSize(totalSize)
|
||||||
|
.formattedTotalSize(formatFileSize(totalSize))
|
||||||
|
.page(page)
|
||||||
|
.pageSize(pageSize)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Помечает файл как удаленный (мягкое удаление)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void markAsDeleted(String fileId) {
|
||||||
|
FileEntity fileEntity = fileEntityRepository.findById(fileId)
|
||||||
|
.orElseThrow(() -> new FileEntityNotFoundException(fileId));
|
||||||
|
|
||||||
|
fileEntity.setStatus(FileStatus.DELETED);
|
||||||
|
fileEntity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
fileEntityRepository.save(fileEntity);
|
||||||
|
|
||||||
|
log.info("FileEntity marked as deleted: {}", fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает общий размер файлов пользователя
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long getUserStorageUsed(Long userId) {
|
||||||
|
Long totalSize = fileEntityRepository.getTotalSizeByUserId(userId);
|
||||||
|
return totalSize != null ? totalSize : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поиск файлов пользователя по имени
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<FileEntityResponse> searchFiles(Long userId, String query) {
|
||||||
|
List<FileEntity> files = fileEntityRepository.searchByFileName(userId, query);
|
||||||
|
|
||||||
|
return files.stream()
|
||||||
|
.filter(f -> f.getStatus() == FileStatus.ACTIVE)
|
||||||
|
.map(this::convertToResponse)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, существует ли файл на диске
|
||||||
|
*/
|
||||||
|
private boolean checkFileExistsOnDisk(String filePath) {
|
||||||
|
try {
|
||||||
|
return Files.exists(Paths.get(filePath));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Error checking file existence: {}", filePath, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует FileEntity в DTO
|
||||||
|
*/
|
||||||
|
private FileEntityResponse convertToResponse(FileEntity fileEntity) {
|
||||||
|
boolean existsOnDisk = checkFileExistsOnDisk(fileEntity.getFilePath());
|
||||||
|
|
||||||
|
return FileEntityResponse.builder()
|
||||||
|
.id(fileEntity.getId())
|
||||||
|
.userId(fileEntity.getUserId())
|
||||||
|
.originalFileName(fileEntity.getOriginalFileName())
|
||||||
|
.storedFileName(fileEntity.getStoredFileName())
|
||||||
|
.filePath(fileEntity.getFilePath())
|
||||||
|
.fileSize(fileEntity.getFileSize())
|
||||||
|
.mimeType(fileEntity.getMimeType())
|
||||||
|
.fileExtension(fileEntity.getFileExtension())
|
||||||
|
.checksum(fileEntity.getChecksum())
|
||||||
|
.uploadSessionId(fileEntity.getUploadSessionId())
|
||||||
|
.status(fileEntity.getStatus())
|
||||||
|
.createdAt(fileEntity.getCreatedAt())
|
||||||
|
.updatedAt(fileEntity.getUpdatedAt())
|
||||||
|
.formattedSize(formatFileSize(fileEntity.getFileSize()))
|
||||||
|
.downloadUrl("/api/files/download/" + fileEntity.getId())
|
||||||
|
.existsOnDisk(existsOnDisk)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует размер файла в читаемый вид
|
||||||
|
*/
|
||||||
|
private String formatFileSize(long size) {
|
||||||
|
if (size < 1024) {
|
||||||
|
return size + " B";
|
||||||
|
} else if (size < 1024 * 1024) {
|
||||||
|
return String.format("%.1f KB", size / 1024.0);
|
||||||
|
} else if (size < 1024 * 1024 * 1024) {
|
||||||
|
return String.format("%.1f MB", size / (1024.0 * 1024.0));
|
||||||
|
} else {
|
||||||
|
return String.format("%.1f GB", size / (1024.0 * 1024.0 * 1024.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package ru.soune.nocopy.service.file;
|
|||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -56,6 +57,9 @@ public class FileUploadServiceImpl implements FileUploadService {
|
|||||||
|
|
||||||
private Path storageRoot;
|
private Path storageRoot;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private FileEntityService fileEntityService;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
try {
|
try {
|
||||||
@@ -328,14 +332,46 @@ public class FileUploadServiceImpl implements FileUploadService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// private void assembleFile(FileUploadSession session) throws IOException {
|
||||||
|
// log.info("Starting file assembly for session: {} ({})",
|
||||||
|
// session.getUploadId(), session.getFileName());
|
||||||
|
//
|
||||||
|
// Path finalFilePath = null;
|
||||||
|
//
|
||||||
|
// try {
|
||||||
|
// finalFilePath = prepareFinalFile(session);
|
||||||
|
//
|
||||||
|
// validateAllChunksExist(session);
|
||||||
|
//
|
||||||
|
// mergeChunksToFile(session, finalFilePath);
|
||||||
|
//
|
||||||
|
// validateFinalFile(session, finalFilePath);
|
||||||
|
//
|
||||||
|
// updateSessionOnSuccess(session, finalFilePath);
|
||||||
|
//
|
||||||
|
// cleanupSessionFiles(session);
|
||||||
|
//
|
||||||
|
// log.info("File assembly completed: {} -> {} ({} bytes)",
|
||||||
|
// session.getFileName(), finalFilePath, session.getFileSize());
|
||||||
|
//
|
||||||
|
// } catch (Exception e) {
|
||||||
|
// if (finalFilePath != null) {
|
||||||
|
// Files.deleteIfExists(finalFilePath);
|
||||||
|
// }
|
||||||
|
// throw e;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
private void assembleFile(FileUploadSession session) throws IOException {
|
private void assembleFile(FileUploadSession session) throws IOException {
|
||||||
log.info("Starting file assembly for session: {} ({})",
|
log.info("Starting file assembly for session: {} ({})",
|
||||||
session.getUploadId(), session.getFileName());
|
session.getUploadId(), session.getFileName());
|
||||||
|
|
||||||
Path finalFilePath = null;
|
Path finalFilePath = null;
|
||||||
|
String checksum = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
finalFilePath = prepareFinalFile(session);
|
finalFilePath = prepareFinalFile(session);
|
||||||
|
log.info("Final file path: {}", finalFilePath);
|
||||||
|
|
||||||
validateAllChunksExist(session);
|
validateAllChunksExist(session);
|
||||||
|
|
||||||
@@ -343,18 +379,48 @@ public class FileUploadServiceImpl implements FileUploadService {
|
|||||||
|
|
||||||
validateFinalFile(session, finalFilePath);
|
validateFinalFile(session, finalFilePath);
|
||||||
|
|
||||||
updateSessionOnSuccess(session, finalFilePath);
|
checksum = calculateChecksum(finalFilePath);
|
||||||
|
log.debug("File checksum calculated: {}", checksum);
|
||||||
|
|
||||||
|
session.setFilePath(finalFilePath.toString());
|
||||||
|
session.setChecksum(checksum);
|
||||||
|
session.setStatus(UploadStatus.COMPLETED);
|
||||||
|
session.setCompletedAt(LocalDateTime.now());
|
||||||
|
sessionRepository.save(session);
|
||||||
|
log.info("Upload session updated to COMPLETED: {}", session.getUploadId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
fileEntityService.createFromUploadSession(session, checksum);
|
||||||
|
log.info("FileEntity successfully created for session: {}",
|
||||||
|
session.getUploadId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to create FileEntity for session {} (file uploaded, but metadata not saved): {}",
|
||||||
|
session.getUploadId(), e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
cleanupSessionFiles(session);
|
cleanupSessionFiles(session);
|
||||||
|
|
||||||
log.info("File assembly completed: {} -> {} ({} bytes)",
|
log.info("File assembly completed successfully: {} -> {} ({} bytes)",
|
||||||
session.getFileName(), finalFilePath, session.getFileSize());
|
session.getFileName(), finalFilePath, session.getFileSize());
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
log.error("File assembly failed for session {}: {}",
|
||||||
|
session.getUploadId(), e.getMessage(), e);
|
||||||
|
|
||||||
if (finalFilePath != null) {
|
if (finalFilePath != null) {
|
||||||
|
try {
|
||||||
Files.deleteIfExists(finalFilePath);
|
Files.deleteIfExists(finalFilePath);
|
||||||
|
log.debug("Cleaned up partial file: {}", finalFilePath);
|
||||||
|
} catch (IOException ioException) {
|
||||||
|
log.warn("Failed to cleanup partial file: {}", finalFilePath, ioException);
|
||||||
}
|
}
|
||||||
throw e;
|
}
|
||||||
|
|
||||||
|
session.setStatus(UploadStatus.FAILED);
|
||||||
|
session.setLastError(e.getMessage());
|
||||||
|
sessionRepository.save(session);
|
||||||
|
|
||||||
|
throw new IOException("File assembly failed: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ file:
|
|||||||
temp-ttl-hours: ${TEMP_TTL_HOURS:72} # 3 дня
|
temp-ttl-hours: ${TEMP_TTL_HOURS:72} # 3 дня
|
||||||
session-expiry-hours: ${SESSION_EXPIRY_HOURS:24}
|
session-expiry-hours: ${SESSION_EXPIRY_HOURS:24}
|
||||||
|
|
||||||
|
security:
|
||||||
|
cors:
|
||||||
|
allowed-origins: "*"
|
||||||
|
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
|
||||||
|
allowed-headers: "*"
|
||||||
|
allow-credentials: true
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: ${SERVER_PORT:8080}
|
port: ${SERVER_PORT:8080}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user