feat: add Minio
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package aplp.backend.lms.common.api;
|
||||
|
||||
import aplp.backend.core.common.exception.DomainException;
|
||||
import aplp.backend.core.common.exception.ErrorCode;
|
||||
import aplp.backend.core.common.exception.ResourceNotFoundException;
|
||||
import aplp.backend.core.common.response.ApiResponse;
|
||||
@@ -27,6 +28,16 @@ public class GlobalExceptionHandler {
|
||||
));
|
||||
}
|
||||
|
||||
@ExceptionHandler(DomainException.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleDomainException(DomainException ex) {
|
||||
return ResponseEntity
|
||||
.status(ex.code().status())
|
||||
.body(ApiResponse.error(
|
||||
ex.code().name(),
|
||||
ex.getMessage()
|
||||
));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleException(
|
||||
Exception ex
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package aplp.backend.lms.common.storage;
|
||||
|
||||
import aplp.backend.core.common.response.ApiResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/files")
|
||||
public class FileController {
|
||||
|
||||
private final FileStorageService fileStorageService;
|
||||
|
||||
public FileController(FileStorageService fileStorageService) {
|
||||
this.fileStorageService = fileStorageService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<FileResDto>> upload(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "folder", required = false, defaultValue = "general") String folder) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.ok(fileStorageService.upload(file, folder)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package aplp.backend.lms.common.storage;
|
||||
|
||||
public record FileResDto(
|
||||
String url,
|
||||
String objectKey,
|
||||
long size,
|
||||
String contentType
|
||||
) {}
|
||||
@@ -0,0 +1,81 @@
|
||||
package aplp.backend.lms.common.storage;
|
||||
|
||||
import aplp.backend.core.common.exception.DomainException;
|
||||
import aplp.backend.core.common.exception.ErrorCode;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class FileStorageService {
|
||||
|
||||
private final MinioClient minioClient;
|
||||
private final StorageProperties properties;
|
||||
|
||||
public FileStorageService(MinioClient minioClient, StorageProperties properties) {
|
||||
this.minioClient = minioClient;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
public FileResDto upload(MultipartFile file, String folder) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new DomainException(ErrorCode.INVALID_ARGUMENT, "File is required");
|
||||
}
|
||||
|
||||
String contentType = file.getContentType();
|
||||
if (contentType == null || !contentType.startsWith("image/")) {
|
||||
throw new DomainException(ErrorCode.INVALID_ARGUMENT, "Only image files are allowed");
|
||||
}
|
||||
|
||||
String objectKey = buildObjectKey(folder, contentType, file.getOriginalFilename());
|
||||
try {
|
||||
minioClient.putObject(PutObjectArgs.builder()
|
||||
.bucket(properties.bucket())
|
||||
.object(objectKey)
|
||||
.stream(file.getInputStream(), file.getSize(), -1)
|
||||
.contentType(contentType)
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
throw new DomainException(ErrorCode.INTERNAL_ERROR, "Failed to upload file: " + e.getMessage());
|
||||
}
|
||||
|
||||
return new FileResDto(
|
||||
properties.publicUrl() + "/" + properties.bucket() + "/" + objectKey,
|
||||
objectKey,
|
||||
file.getSize(),
|
||||
contentType
|
||||
);
|
||||
}
|
||||
|
||||
static String buildObjectKey(String folder, String contentType, String originalFilename) {
|
||||
return sanitizeFolder(folder) + "/" + UUID.randomUUID() + "." + extensionFor(contentType, originalFilename);
|
||||
}
|
||||
|
||||
static String sanitizeFolder(String folder) {
|
||||
if (folder == null || folder.isBlank()) {
|
||||
return "general";
|
||||
}
|
||||
String clean = folder.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "");
|
||||
return clean.isEmpty() ? "general" : clean;
|
||||
}
|
||||
|
||||
static String extensionFor(String contentType, String originalFilename) {
|
||||
return switch (contentType) {
|
||||
case "image/jpeg", "image/jpg" -> "jpg";
|
||||
case "image/png" -> "png";
|
||||
case "image/gif" -> "gif";
|
||||
case "image/webp" -> "webp";
|
||||
case "image/svg+xml" -> "svg";
|
||||
default -> {
|
||||
String name = originalFilename == null ? "" : originalFilename;
|
||||
int dot = name.lastIndexOf('.');
|
||||
String ext = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
|
||||
yield ext.isEmpty() ? "img" : ext;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package aplp.backend.lms.common.storage;
|
||||
|
||||
import io.minio.BucketExistsArgs;
|
||||
import io.minio.MakeBucketArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.SetBucketPolicyArgs;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(StorageProperties.class)
|
||||
public class MinioConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MinioConfig.class);
|
||||
|
||||
private static final String PUBLIC_READ_POLICY = """
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": ["*"]},
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private final StorageProperties properties;
|
||||
|
||||
public MinioConfig(StorageProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public MinioClient minioClient() {
|
||||
MinioClient client = MinioClient.builder()
|
||||
.endpoint(properties.endpoint())
|
||||
.credentials(properties.accessKey(), properties.secretKey())
|
||||
.build();
|
||||
|
||||
try {
|
||||
if (!client.bucketExists(BucketExistsArgs.builder().bucket(properties.bucket()).build())) {
|
||||
client.makeBucket(MakeBucketArgs.builder().bucket(properties.bucket()).build());
|
||||
log.info("Created MinIO bucket '{}'", properties.bucket());
|
||||
}
|
||||
client.setBucketPolicy(SetBucketPolicyArgs.builder()
|
||||
.bucket(properties.bucket())
|
||||
.config(PUBLIC_READ_POLICY.formatted(properties.bucket()))
|
||||
.build());
|
||||
} catch (Exception e) {
|
||||
log.warn("MinIO bucket init skipped ({}): {}", properties.endpoint(), e.getMessage());
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package aplp.backend.lms.common.storage;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "minio")
|
||||
public record StorageProperties(
|
||||
String endpoint,
|
||||
String accessKey,
|
||||
String secretKey,
|
||||
String bucket,
|
||||
String publicUrl
|
||||
) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: prod
|
||||
|
||||
minio:
|
||||
endpoint: ${MINIO_ENDPOINT}
|
||||
access-key: ${MINIO_ACCESS_KEY}
|
||||
secret-key: ${MINIO_SECRET_KEY}
|
||||
bucket: ${MINIO_BUCKET}
|
||||
public-url: ${MINIO_PUBLIC_URL}
|
||||
@@ -5,7 +5,7 @@ spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://pgsql.koda.id.vn:5432/aplp
|
||||
username: postgres
|
||||
password: Pa55w0rd
|
||||
password: xxxx
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
@@ -22,6 +22,18 @@ spring:
|
||||
schemas: lms
|
||||
locations: classpath:db/migration
|
||||
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 5MB
|
||||
max-request-size: 5MB
|
||||
|
||||
minio:
|
||||
endpoint: https://minio.koda.id.vn
|
||||
access-key: CHANGE_ME
|
||||
secret-key: CHANGE_ME
|
||||
bucket: lms
|
||||
public-url: https://minio.koda.id.vn
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
path: /swagger-ui
|
||||
Reference in New Issue
Block a user