diff --git a/.serena/.gitignore b/.serena/.gitignore deleted file mode 100644 index 2e510af..0000000 --- a/.serena/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/cache -/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml deleted file mode 100644 index c620a7b..0000000 --- a/.serena/project.yml +++ /dev/null @@ -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: [] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e02a1c1 --- /dev/null +++ b/AGENTS.md @@ -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` — `ok(data)`, `ok(message, data)`, `error(code, message)`. +- `PagedResponse` — `of(content, page, size, totalElements)`. +- `ErrorCode`, `DomainException`, `ResourceNotFoundException`, `TokenHasher.sha256Hex`. + +## Architecture + +Vertical slices per feature under `aplp.backend.lms.` (currently `course`, `lesson`, `section`); `common/` holds cross-cutting `api`, `config`, `security`. Layers per feature: + +- `api/controller` — `@RestController`, returns `ResponseEntity>` +- `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. diff --git a/pom.xml b/pom.xml index a02711a..3880ae1 100644 --- a/pom.xml +++ b/pom.xml @@ -108,6 +108,11 @@ springdoc-openapi-starter-webmvc-ui 3.1.0 + + io.minio + minio + 8.5.17 + aplp.backend diff --git a/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java b/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java index 0415d91..8cfecd3 100644 --- a/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java +++ b/src/main/java/aplp/backend/lms/common/api/GlobalExceptionHandler.java @@ -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> handleDomainException(DomainException ex) { + return ResponseEntity + .status(ex.code().status()) + .body(ApiResponse.error( + ex.code().name(), + ex.getMessage() + )); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleException( Exception ex diff --git a/src/main/java/aplp/backend/lms/common/storage/FileController.java b/src/main/java/aplp/backend/lms/common/storage/FileController.java new file mode 100644 index 0000000..0cbbddf --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/storage/FileController.java @@ -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> 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))); + } +} diff --git a/src/main/java/aplp/backend/lms/common/storage/FileResDto.java b/src/main/java/aplp/backend/lms/common/storage/FileResDto.java new file mode 100644 index 0000000..0868ba1 --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/storage/FileResDto.java @@ -0,0 +1,8 @@ +package aplp.backend.lms.common.storage; + +public record FileResDto( + String url, + String objectKey, + long size, + String contentType +) {} diff --git a/src/main/java/aplp/backend/lms/common/storage/FileStorageService.java b/src/main/java/aplp/backend/lms/common/storage/FileStorageService.java new file mode 100644 index 0000000..0f1f2d8 --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/storage/FileStorageService.java @@ -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; + } + }; + } +} diff --git a/src/main/java/aplp/backend/lms/common/storage/MinioConfig.java b/src/main/java/aplp/backend/lms/common/storage/MinioConfig.java new file mode 100644 index 0000000..3b8ac94 --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/storage/MinioConfig.java @@ -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; + } +} diff --git a/src/main/java/aplp/backend/lms/common/storage/StorageProperties.java b/src/main/java/aplp/backend/lms/common/storage/StorageProperties.java new file mode 100644 index 0000000..74a822e --- /dev/null +++ b/src/main/java/aplp/backend/lms/common/storage/StorageProperties.java @@ -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 +) {} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 0000000..0b2990f --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -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} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 52b7bd4..84f2341 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -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 \ No newline at end of file diff --git a/src/test/java/aplp/backend/lms/common/storage/FileStorageServiceTest.java b/src/test/java/aplp/backend/lms/common/storage/FileStorageServiceTest.java new file mode 100644 index 0000000..db20706 --- /dev/null +++ b/src/test/java/aplp/backend/lms/common/storage/FileStorageServiceTest.java @@ -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)); + } +}