feat: add Minio
This commit is contained in:
@@ -1,2 +0,0 @@
|
|||||||
/cache
|
|
||||||
/project.local.yml
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
# the name by which the project can be referenced within Serena/when chatting with the LLM.
|
|
||||||
project_name: "aplp.backend.lms"
|
|
||||||
|
|
||||||
# list of language servers to start when using the LSP backend; choose from:
|
|
||||||
# ada al angular ansible bash
|
|
||||||
# bsl clojure cpp cpp_ccls crystal
|
|
||||||
# csharp csharp_omnisharp cue dart deno
|
|
||||||
# elixir elm erlang fortran fsharp
|
|
||||||
# gdscript gleam go groovy haskell
|
|
||||||
# haxe hlsl html java json
|
|
||||||
# julia julia_fatou kotlin latex lean4
|
|
||||||
# lua luau markdown matlab msl
|
|
||||||
# nextflow nix ocaml pascal perl
|
|
||||||
# php php_phpactor php_phpantom powershell python
|
|
||||||
# python_basedpyright python_jedi python_pyrefly python_ty qml
|
|
||||||
# r rego ruby ruby_solargraph rust
|
|
||||||
# scala scss solidity svelte swift
|
|
||||||
# systemverilog terraform toml typescript typescript_vts
|
|
||||||
# vue wolfram yaml zig
|
|
||||||
# (This list may be outdated; generated with scripts/print_language_list.py;
|
|
||||||
# For the current list, see values of the LanguageServerId enum here:
|
|
||||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
|
|
||||||
# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
|
||||||
# Note:
|
|
||||||
# - For C, use cpp
|
|
||||||
# - For JavaScript, use typescript
|
|
||||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
|
||||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
|
||||||
# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH)
|
|
||||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
|
||||||
# - For Free Pascal/Lazarus, use pascal
|
|
||||||
# - External Python adapters may add further registered IDs; install the adapter package first
|
|
||||||
# and then use its ID here, for example: example
|
|
||||||
# Special requirements:
|
|
||||||
# Some language servers require additional setup/installations.
|
|
||||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
|
||||||
# When using multiple language servers, the first language server that supports a given file will be used for that file.
|
|
||||||
# The first language server is the default language and the respective language server will be used as a fallback.
|
|
||||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
|
||||||
language_servers:
|
|
||||||
- java
|
|
||||||
|
|
||||||
# the encoding used by text files in the project
|
|
||||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
|
||||||
encoding: "utf-8"
|
|
||||||
|
|
||||||
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
|
|
||||||
# the command runs in the project root directory and is only executed if the project is trusted
|
|
||||||
# (see trusted_project_path_patterns in the global configuration).
|
|
||||||
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
|
|
||||||
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
|
|
||||||
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
|
|
||||||
# example: activation_command: "npx nx run-many -t build"
|
|
||||||
activation_command:
|
|
||||||
|
|
||||||
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
|
|
||||||
# must be a positive number.
|
|
||||||
activation_command_timeout: 180.0
|
|
||||||
|
|
||||||
# line ending convention to use when writing source files.
|
|
||||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
|
||||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
|
||||||
line_ending:
|
|
||||||
|
|
||||||
# The language backend to use for this project.
|
|
||||||
# If not set, the global setting from serena_config.yml is used.
|
|
||||||
# Valid values: LSP, JetBrains
|
|
||||||
# Note: the backend is fixed at startup. If a project with a different backend
|
|
||||||
# is activated post-init, an error will be returned.
|
|
||||||
language_backend:
|
|
||||||
|
|
||||||
# whether to use project's .gitignore files to ignore files
|
|
||||||
ignore_all_files_in_gitignore: true
|
|
||||||
|
|
||||||
# advanced configuration option allowing to configure language server-specific options.
|
|
||||||
# Maps the language key to the options.
|
|
||||||
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
|
|
||||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
|
|
||||||
ls_specific_settings: {}
|
|
||||||
|
|
||||||
# list of workspace folder paths (LSP backend only).
|
|
||||||
# These folders will be used to build up Serena's symbol index.
|
|
||||||
# Paths must be within the project root and should thus be relative to the project root.
|
|
||||||
# Furthermore, the paths should not be filtered by ignore settings.
|
|
||||||
# Default setting: The entire project root folder (".") is considered.
|
|
||||||
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
|
|
||||||
# ls_workspace_folders:
|
|
||||||
# - "./subproject1"
|
|
||||||
# - "./subproject2"
|
|
||||||
ls_workspace_folders:
|
|
||||||
- "."
|
|
||||||
|
|
||||||
# list of additional workspace folder paths for cross-package reference support.
|
|
||||||
# Paths can be absolute or relative to the project root.
|
|
||||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
|
||||||
# symbols and references across package boundaries, but these folders are not indexed by Serena,
|
|
||||||
# i.e. the respective symbols will not be found using Serena's symbol search tools.
|
|
||||||
# Example:
|
|
||||||
# additional_workspace_folders:
|
|
||||||
# - ../sibling-package
|
|
||||||
# - ../shared-lib
|
|
||||||
ls_additional_workspace_folders: []
|
|
||||||
|
|
||||||
# list of additional paths to ignore in this project.
|
|
||||||
# Same syntax as gitignore, so you can use * and **.
|
|
||||||
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
|
|
||||||
# Example:
|
|
||||||
# ignored_paths:
|
|
||||||
# - "examples/**"
|
|
||||||
# - ".worktrees/**"
|
|
||||||
# - "**/bin/**"
|
|
||||||
# - "**/obj/**"
|
|
||||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
|
||||||
ignored_paths: []
|
|
||||||
|
|
||||||
# whether the project is in read-only mode
|
|
||||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
|
||||||
# Added on 2025-04-18
|
|
||||||
read_only: false
|
|
||||||
|
|
||||||
# list of tool names to exclude.
|
|
||||||
# This extends the existing exclusions (e.g. from the global configuration)
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
excluded_tools: []
|
|
||||||
|
|
||||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
|
||||||
# This extends the existing inclusions (e.g. from the global configuration).
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
included_optional_tools: []
|
|
||||||
|
|
||||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
|
||||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
fixed_tools: []
|
|
||||||
|
|
||||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
|
||||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
|
||||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
|
||||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
|
||||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
|
||||||
# for this project.
|
|
||||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
|
||||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
|
||||||
default_modes:
|
|
||||||
|
|
||||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
|
||||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
|
||||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
|
||||||
added_modes:
|
|
||||||
|
|
||||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
|
||||||
# (contrary to the memories, which are loaded on demand).
|
|
||||||
initial_prompt: ""
|
|
||||||
|
|
||||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
|
||||||
# such as docstrings or parameter information.
|
|
||||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
|
||||||
# If null or missing, use the setting from the global configuration.
|
|
||||||
symbol_info_budget:
|
|
||||||
|
|
||||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
|
||||||
# Extends the list from the global configuration, merging the two lists.
|
|
||||||
read_only_memory_patterns: []
|
|
||||||
|
|
||||||
# list of regex patterns for memories to completely ignore.
|
|
||||||
# Matching memories will not appear in list_memories or activate_project output
|
|
||||||
# and cannot be accessed via read_memory or write_memory.
|
|
||||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
|
||||||
# Extends the list from the global configuration, merging the two lists.
|
|
||||||
# Example: ["_archive/.*", "_episodes/.*"]
|
|
||||||
ignored_memory_patterns: []
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Spring Boot **4.1.0** / Java 17 REST backend for an LMS (PostgreSQL). Root package `aplp.backend.lms`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- Build: `./mvnw -DskipTests package`
|
||||||
|
- Run: `./mvnw spring-boot:run`
|
||||||
|
- All tests: `./mvnw test`
|
||||||
|
- Single test: `./mvnw test -Dtest=LmsApplicationTests`
|
||||||
|
- Requires JDK 17. No linter, formatter, or checkstyle is configured — don't invent one.
|
||||||
|
|
||||||
|
## Build gotcha: GitLab Maven registry
|
||||||
|
|
||||||
|
`aplp.backend:core:1.0-SNAPSHOT` is not built here; it resolves from the GitLab package registry (repository id `gitlab-maven` in `pom.xml`). A fresh checkout needs a token for server id `gitlab-maven` in `~/.m2/settings.xml` (or the artifact cached in `~/.m2/repository`). Credentials are not in the repo.
|
||||||
|
|
||||||
|
## `aplp.backend.core` shared types
|
||||||
|
|
||||||
|
Not defined in this repo; import them instead of recreating:
|
||||||
|
|
||||||
|
- `BaseEntity` — `@MappedSuperclass` for every entity: `id`, `createdAt/By`, `updatedAt/By`, `deletedAt/By`; timestamps set via `@PrePersist`/`@PreUpdate`.
|
||||||
|
- `ApiResponse<T>` — `ok(data)`, `ok(message, data)`, `error(code, message)`.
|
||||||
|
- `PagedResponse<T>` — `of(content, page, size, totalElements)`.
|
||||||
|
- `ErrorCode`, `DomainException`, `ResourceNotFoundException`, `TokenHasher.sha256Hex`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Vertical slices per feature under `aplp.backend.lms.<feature>` (currently `course`, `lesson`, `section`); `common/` holds cross-cutting `api`, `config`, `security`. Layers per feature:
|
||||||
|
|
||||||
|
- `api/controller` — `@RestController`, returns `ResponseEntity<ApiResponse<...>>`
|
||||||
|
- `application/services`, `application/dtos` (Java records), `application/mappers` (MapStruct)
|
||||||
|
- `domain/entities`, `domain/enums`, `domain/repositories` (plain interfaces)
|
||||||
|
- `infrastructure/persistence` — `*RepositoryImpl` implements the domain repo and delegates to a Spring Data `*JpaRepository`
|
||||||
|
|
||||||
|
Conventions:
|
||||||
|
|
||||||
|
- Entities extend `BaseEntity`, use Lombok `@Getter/@Setter/@NoArgsConstructor`, `@Table` snake_case.
|
||||||
|
- Controllers stay thin; services throw `ResourceNotFoundException`; `GlobalExceptionHandler` maps exceptions to `ApiResponse`.
|
||||||
|
- Mappers are interfaces annotated `@Mapper(componentModel = "spring")`; Lombok/MapStruct annotation processing is already wired in `pom.xml`.
|
||||||
|
- Add derived queries to `*JpaRepository` and expose them through the domain repo interface.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
- PostgreSQL, Hibernate default schema `lms`; datasource hardcoded in `src/main/resources/application.yaml`.
|
||||||
|
- **`spring.flyway.enabled: false` + `spring.jpa.hibernate.ddl-auto: update`**: Hibernate maintains the schema at runtime. Scripts in `db/migration` (`V{n}__desc.sql`) are history only and are NOT applied on startup. A schema change needs both the entity field and a migration; don't assume Flyway runs.
|
||||||
|
- jsonb columns use `@JdbcTypeCode(SqlTypes.JSON)` on a `String` field.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- `SecurityConfig` disables CSRF and `permitAll`s every request — endpoints are currently unauthenticated despite OpenAPI declaring a `bearerAuth` JWT scheme (Swagger UI at `/swagger-ui`).
|
||||||
|
- `JpaAuditingConfig` reads the current user from `SecurityContextHolder` and expects `Authentication.getPrincipal()` to be a `Long` userId.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Only `LmsApplicationTests` (`@SpringBootTest contextLoads`) exists. There is no `src/test/resources` or test profile, so tests use the real `application.yaml` datasource and require access to the remote DB — `./mvnw test` fails without it.
|
||||||
@@ -108,6 +108,11 @@
|
|||||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
<version>3.1.0</version>
|
<version>3.1.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.minio</groupId>
|
||||||
|
<artifactId>minio</artifactId>
|
||||||
|
<version>8.5.17</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>aplp.backend</groupId>
|
<groupId>aplp.backend</groupId>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package aplp.backend.lms.common.api;
|
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.ErrorCode;
|
||||||
import aplp.backend.core.common.exception.ResourceNotFoundException;
|
import aplp.backend.core.common.exception.ResourceNotFoundException;
|
||||||
import aplp.backend.core.common.response.ApiResponse;
|
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)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ApiResponse<?>> handleException(
|
public ResponseEntity<ApiResponse<?>> handleException(
|
||||||
Exception ex
|
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:
|
datasource:
|
||||||
url: jdbc:postgresql://pgsql.koda.id.vn:5432/aplp
|
url: jdbc:postgresql://pgsql.koda.id.vn:5432/aplp
|
||||||
username: postgres
|
username: postgres
|
||||||
password: Pa55w0rd
|
password: xxxx
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
@@ -22,6 +22,18 @@ spring:
|
|||||||
schemas: lms
|
schemas: lms
|
||||||
locations: classpath:db/migration
|
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:
|
springdoc:
|
||||||
swagger-ui:
|
swagger-ui:
|
||||||
path: /swagger-ui
|
path: /swagger-ui
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package aplp.backend.lms.common.storage;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class FileStorageServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizesFolderAndUsesContentTypeExtension() {
|
||||||
|
String key = FileStorageService.buildObjectKey("Course Images!", "image/png", "whatever");
|
||||||
|
assertTrue(key.startsWith("courseimages/"), key);
|
||||||
|
assertTrue(key.endsWith(".png"), key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blankOrInvalidFolderFallsBackToGeneral() {
|
||||||
|
assertEquals("general", FileStorageService.sanitizeFolder(" "));
|
||||||
|
assertEquals("general", FileStorageService.sanitizeFolder("!!!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownContentTypeFallsBackToFilenameExtension() {
|
||||||
|
assertEquals("jpg", FileStorageService.extensionFor("application/octet-stream", "photo.JPG"));
|
||||||
|
assertEquals("img", FileStorageService.extensionFor("application/octet-stream", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user