From 13e41f6e3c04e943d5afb1bca5a58914dc793150 Mon Sep 17 00:00:00 2001 From: sdw086 Date: Fri, 8 May 2026 17:21:19 +0900 Subject: [PATCH] =?UTF-8?q?[api]=20=ED=8C=8C=EC=9D=BC=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C,=20tus=ED=8C=8C=EC=9D=BC=20=EC=A3=BC=EC=86=8C?= =?UTF-8?q?=EB=B3=80=EA=B2=BD,=20md=20=ED=8C=8C=EC=9D=BC=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 327 +++++++----------- docs/codebase-notes.md | 6 + docs/project-overview.md | 4 +- docs/runtime-config.md | 19 + docs/security-and-response.md | 5 +- docs/verification-checklist.md | 5 + .../com/alist/api/config/SecurityConfig.java | 2 +- .../config/jwt/JwtAuthenticationFilter.java | 8 + .../admin/auth/AdminAuthController.java | 69 +++- .../admin/auth/service/AdminAuthService.java | 8 + .../api/modules/file/FileController.java | 104 ++++++ .../modules/file/config/FileUploadConfig.java | 10 + .../file/properties/FileUploadProperties.java | 45 +++ .../api/modules/file/service/FileService.java | 240 +++++++++++++ .../file/service/FileUploadImageService.java | 138 ++++++++ .../api/modules/file/vo/FileUploadVo.java | 17 + .../modules/file/vo/SunEditorUploadVo.java | 30 ++ ...Controller.java => TusFileController.java} | 38 +- .../{FileMapper.java => TusFileMapper.java} | 2 +- .../{FileService.java => TusFileService.java} | 76 ++-- src/main/resources/application-local.yaml | 47 ++- src/main/resources/application-pjt.yaml | 47 ++- .../TusFileMapper.xml} | 2 +- 23 files changed, 981 insertions(+), 268 deletions(-) create mode 100644 src/main/java/com/alist/api/modules/file/FileController.java create mode 100644 src/main/java/com/alist/api/modules/file/config/FileUploadConfig.java create mode 100644 src/main/java/com/alist/api/modules/file/properties/FileUploadProperties.java create mode 100644 src/main/java/com/alist/api/modules/file/service/FileService.java create mode 100644 src/main/java/com/alist/api/modules/file/service/FileUploadImageService.java create mode 100644 src/main/java/com/alist/api/modules/file/vo/FileUploadVo.java create mode 100644 src/main/java/com/alist/api/modules/file/vo/SunEditorUploadVo.java rename src/main/java/com/alist/api/modules/tusFile/{FileController.java => TusFileController.java} (87%) rename src/main/java/com/alist/api/modules/tusFile/mapper/{FileMapper.java => TusFileMapper.java} (98%) rename src/main/java/com/alist/api/modules/tusFile/service/{FileService.java => TusFileService.java} (89%) rename src/main/resources/mapper/{file/FileMapper.xml => tusFile/TusFileMapper.xml} (99%) diff --git a/README.md b/README.md index 8d13390..8760c6a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# alist API +# alist API Spring Boot 3 기반의 `alist` 백엔드 API 서버입니다. @@ -7,7 +7,8 @@ Spring Boot 3 기반의 `alist` 백엔드 API 서버입니다. - 그룹: `com.alist` - Java: `21` - Spring Boot: `3.5.10` -- 포트: `8106` +- 기본 포트: `8106` +- 실행 진입점: `src/main/java/com/alist/api/ApiApplication.java` - 빌드 결과물: `build/libs/api.jar` ## 기술 스택 @@ -26,12 +27,14 @@ Spring Boot 3 기반의 `alist` 백엔드 API 서버입니다. ## 주요 기능 -- JWT 발급 및 검증 -- Redis 기반 세션 관리와 쿠키 설정 +- 사용자/관리자 JWT 발급 및 검증 +- Redis 기반 SSO 세션 관리와 쿠키 설정 - Swagger UI Basic 인증 보호 - DB 기반 허용 Origin 캐시를 사용하는 동적 CORS -- TUS 업로드 초기화, 권한 검증, 상태 조회, 완료 처리, 취소 처리 -- 파일 조회/다운로드 API +- TUS 기반 대용량 파일 업로드 초기화, 권한 검증, 상태 조회, hook 처리, 취소 처리 +- DB 기록 없는 단순 파일 업로드와 uploadPath 반환 +- SunEditor 이미지 업로드와 file-domain URL 반환 +- 파일 view/download API - 공통 응답 래퍼 `ApiResponse` 및 `ApiResponseCode` 사용 ## 디렉터리 구조 @@ -39,7 +42,6 @@ Spring Boot 3 기반의 `alist` 백엔드 API 서버입니다. ```text src/main/java/com/alist/api ├── common -│ ├── modules/file # 공통 파일 업로드/다운로드 모듈 │ ├── response # ApiResponse, ApiResponseCode │ └── utils # 공통 유틸리티 ├── config @@ -47,10 +49,16 @@ src/main/java/com/alist/api │ ├── exception # 전역 예외 처리 │ ├── filter # DynamicCorsFilter │ ├── jwt # JWT 인증 관련 구성 +│ ├── migration # 마이그레이션 DB 설정 │ └── properties # 설정 프로퍼티 └── modules - ├── auth # 인증/세션 관련 API - ├── main # 루트 리다이렉트 + ├── admin # 관리자 인증 API + ├── auth # 사용자 인증/SSO API + ├── file # 단순 업로드, SunEditor 업로드, path 기반 view/download + ├── main # 루트 응답 + ├── migration # 레거시 사용자 조회 + ├── tusFile # TUS 업로드 DB 기록, hook, 상태, 파일 목록/view/download/delete + └── user # 사용자 가입/마이그레이션 조회 ``` 리소스 파일은 아래 위치를 사용합니다. @@ -58,6 +66,7 @@ src/main/java/com/alist/api - 설정: `src/main/resources/application*.yaml` - Mapper XML: `src/main/resources/mapper/**/*.xml` - 로그 설정: `src/main/resources/logback-*.xml` +- 상세 문서: `docs/*.md` ## 실행 방법 @@ -79,6 +88,12 @@ src/main/java/com/alist/api java -jar build/libs/api.jar --spring.profiles.active=local ``` +Windows: + +```powershell +.\gradlew.bat bootRun --args='--spring.profiles.active=local' +``` + ## 프로파일 | 프로파일 | 설명 | 설정 파일 | @@ -90,8 +105,6 @@ java -jar build/libs/api.jar --spring.profiles.active=local ## 필수 설정 항목 -실행 전 아래 설정들이 환경에 맞게 준비되어 있어야 합니다. - ### 데이터 저장소 - `spring.datasource.*` @@ -110,14 +123,22 @@ java -jar build/libs/api.jar --spring.profiles.active=local - `swagger.login.id` - `swagger.login.password` -### 파일 업로드 +### TUS 업로드 -- `file.upload.tus-endpoint` -- `file.upload.public-base-url` -- `file.upload.tmp-root` -- `file.upload.final-root` -- `file.upload.interrupt-seconds` -- `file.upload.auth-cache.ttl-seconds` +- `tus-file.upload.tus-endpoint` +- `tus-file.upload.public-base-url` +- `tus-file.upload.tmp-root` +- `tus-file.upload.final-root` +- `tus-file.upload.interrupt-seconds` +- `tus-file.upload.auth-cache.ttl-seconds` + +### 단순 업로드 / 에디터 이미지 + +- `file.upload.root-path` +- `file.upload.view.file-domain` +- `file.upload.max-size` +- `file.upload.allowed-extensions` +- `file.upload.types.*` `pjt` 프로파일은 DB/Redis/JWT/Swagger 값을 환경변수로 주입받도록 작성되어 있습니다. @@ -129,20 +150,30 @@ java -jar build/libs/api.jar --spring.profiles.active=local - 인증 방식: HTTP Basic - 계정 정보: `swagger.login.id`, `swagger.login.password` +### Admin API + +- `/admin/**` 는 별도 SecurityFilterChain을 사용합니다. +- `/admin/auth/**` 는 공개하고, 그 외 `/admin/**` 는 `ADMIN` 권한을 요구합니다. +- `GET /admin/auth/loginChecked` 는 토큰 재발급 없이 현재 관리자 로그인 상태만 확인합니다. + ### API -- 기본 인증 방식: JWT Bearer +- 기본 인증 방식: JWT Bearer 또는 HttpOnly 쿠키 fallback - 세션 저장소: Redis -- 세션 쿠키: `RedisSessionConfig`에서 도메인, Secure, SameSite 제어 +- 쿠키 속성: `cookie.*` 설정으로 제어 ### 공개 경로 - `/` - `/actuator/health` +- `/sso/**` - `/auth/**` -- `/files/tusHook` +- `/user/signup` +- `/user/migrationUserList` +- `/tusFiles/uploadAuth` +- `/tusFiles/tusHook` -루트 `/` 요청은 `/swagger-ui/index.html`로 리다이렉트됩니다. +TUS 업로드 토큰은 일반 access token이 아니므로 `/tusFiles/uploadAuth`, `/tusFiles/tusHook` 은 JWT 필터 제외 경로에도 포함되어야 합니다. ## CORS @@ -153,200 +184,103 @@ java -jar build/libs/api.jar --spring.profiles.active=local - `OPTIONS` preflight 요청은 `200 OK`로 즉시 응답 - DB를 직접 매 요청마다 조회하지 않고 캐시된 목록을 사용 -## 파일 업로드/다운로드 +file-domain nginx 의 TUS 업로드 경로는 별도 `map $http_origin $cors_allow_origin` 설정으로 허용 Origin을 제한합니다. -이 프로젝트는 대용량 업로드를 위해 TUS 서버(`tusd`)와 연동합니다. API는 업로드 메타데이터 관리, 업로드 권한 검증, 상태 저장, 완료/취소 처리를 담당합니다. +## 파일 업로드 -### 주요 업로드 엔드포인트 +파일 업로드는 두 흐름으로 분리되어 있습니다. -- `POST /files/uploadInit` -- `GET /files/uploadAuth` -- `POST /files/uploadStatus` -- `POST /files/tusHook` -- `POST /files/uploadCancel` +### 단순 업로드 -### 파일 조회 엔드포인트 +DB에 기록하지 않고 파일만 저장한 뒤 업무 테이블에 저장하기 좋은 값을 반환합니다. -- `GET /files/list/{fileMasterIdx}` -- `GET /files/view/{fileUuid}` -- `GET /files/download/{fileUuid}` +- `POST /files/upload` +- `POST /admin/files/upload` +- `GET /files/view?path=/uploads/...` +- `GET /admin/files/view?path=/uploads/...` +- `GET /files/download?path=/uploads/...` +- `GET /admin/files/download?path=/uploads/...` -### 운영 메모 - -- TUS 업로드 엔드포인트: `https://file-alist.pjt.kr/tus/files/` -- 업로드 임시 경로: `/srv/project/alist/uploads/tmp` -- 업로드 최종 경로: `/srv/project/alist/uploads` -- 정적 파일 도메인: `https://file-alist.pjt.kr` - -## 주요 인증 엔드포인트 - -- `POST /auth/token` -- `POST /auth/refresh` -- `GET /auth/loginChecked` -- `POST /auth/logout` -- `POST /user/signup` - -## API 예시 요청/응답 - -아래 예시는 실제 컨트롤러의 요청 필드와 `ApiResponse` 응답 구조를 기준으로 정리했습니다. - -### 1. 토큰 발급 - -요청: - -```http -POST /auth/token -Content-Type: application/json +응답 데이터 예시: +```json { - "id": "test" + "uploadPath": "/uploads/notice/2026/05/08/abc.png", + "originalFileName": "sample.png", + "storedFileName": "abc.png", + "fileExtension": "png", + "contentType": "image/png", + "fileSize": 12345, + "width": 800, + "height": 600 } ``` +### SunEditor 이미지 업로드 + +SunEditor 업로드는 API로 저장하되, 에디터 본문에는 token이 필요 없는 file-domain URL을 저장합니다. + +- `POST /files/suneditor/upload` +- `POST /admin/files/suneditor/upload` + 응답 예시: ```json { - "data": { - "id": null, - "accessToken": "eyJhbGciOiJI..." - }, - "code": "CODE_2001", - "message": "임시 토큰 정보 조회에 성공하였습니다." -} -``` - -### 2. 테스트 회원가입 - -요청: - -```http -POST /test/testSignup -Content-Type: application/json - -{ - "id": "testuser01", - "password": "pass1234" -} -``` - -응답 예시: - -```json -{ - "data": { - "userIdx": 101, - "id": "testuser01" - }, - "code": "CODE_2002", - "message": "아이디 등록 되었습니다." -} -``` - -중복일 경우 예시: - -```json -{ - "data": { - "userIdx": null, - "id": null - }, - "code": "CODE_2004", - "message": "중복된 아이디 정보 입니다." -} -``` - -### 3. 파일 업로드 초기화 - -요청: - -```http -POST /files/uploadInit -Content-Type: application/json -Cookie: ALIST_SESSION=... - -{ - "fileCategory": "notice", - "folderPath": "/2026/03", - "itemList": [ + "result": [ { - "originName": "guide.pdf", - "sizeBytes": 102400, - "contentType": "application/pdf" + "url": "https://file-alist.pjt.kr/uploads/editor/2026/05/08/abc.png", + "name": "sample.png", + "size": 12345 } ] } ``` -응답 예시: +### TUS 업로드 -```json -{ - "data": { - "fileMasterIdx": 55, - "tusEndpoint": "https://file-alist.pjt.kr/tus/files/", - "itemList": [ - { - "fileSeq": 1, - "fileUuid": "2f5f3ef1-8a4e-4b2d-84da-1c1111111111", - "uploadToken": "upload-token-sample", - "originName": "guide.pdf", - "sizeBytes": 102400, - "contentType": "application/pdf" - } - ] - }, - "code": "CODE_200", - "message": "성공" -} +대용량 업로드는 tusd와 연동하며 API는 DB 기록, 토큰 발급, 권한 검증, hook 반영을 담당합니다. + +- `POST /tusFiles/uploadInit` +- `GET /tusFiles/uploadAuth` +- `POST /tusFiles/uploadStatus` +- `POST /tusFiles/tusHook` +- `POST /tusFiles/uploadCancel` +- `GET /tusFiles/list/{fileMasterIdx}` +- `GET /tusFiles/view/{fileUuid}` +- `GET /tusFiles/download/{fileUuid}` +- `POST /tusFiles/delete` + +운영 TUS 엔드포인트: + +```text +https://file-alist.pjt.kr/tus/files/ ``` -### 4. 업로드 상태 조회 +## file-domain 운영 메모 -요청: +- 업로드 최종 경로: `/srv/project/alist/uploads` +- tusd 임시 경로: `/srv/project/alist/uploads/tmp` +- 파일 도메인: `https://file-alist.pjt.kr` +- nginx `/uploads/` 는 `/srv/project/alist/uploads/` 를 정적 파일로 제공합니다. +- nginx `/uploads/tmp/` 는 임시 파일 노출 방지를 위해 404 처리합니다. +- nginx `/tus/files/` 는 tusd로 프록시하고 `auth_request /_upload_auth` 로 `/tusFiles/uploadAuth` 를 호출합니다. -```http -POST /files/uploadStatus -Content-Type: application/json +## 주요 인증 엔드포인트 -{ - "fileUuid": "2f5f3ef1-8a4e-4b2d-84da-1c1111111111" -} -``` - -응답 예시: - -```json -{ - "data": { - "fileUuid": "2f5f3ef1-8a4e-4b2d-84da-1c1111111111", - "status": "UPLOADING", - "uploadedBytes": 51200, - "totalBytes": 102400, - "percent": 50, - "updatedAt": "2026-03-11T09:30:00" - }, - "code": "CODE_200", - "message": "성공" -} -``` - -조회 결과가 없을 경우 예시: - -```json -{ - "data": { - "fileUuid": null, - "status": null, - "uploadedBytes": null, - "totalBytes": null, - "percent": null, - "updatedAt": null - }, - "code": "CODE_2003", - "message": "조회된 정보가 없습니다." -} -``` +- `POST /auth/token` +- `POST /auth/access` +- `POST /auth/refresh` +- `POST /auth/apiKeyLogin` +- `GET /sso/loginChecked` +- `POST /sso/login` +- `POST /sso/exchange` +- `POST /sso/logout` +- `POST /admin/auth/login` +- `POST /admin/auth/refresh` +- `GET /admin/auth/loginChecked` +- `POST /admin/auth/logout` +- `POST /user/signup` ## Actuator @@ -362,14 +296,14 @@ Content-Type: application/json ## 응답 규칙 -모든 API 응답은 `ApiResponse` 래퍼를 사용하며, 상태/메시지는 `ApiResponseCode` enum으로 관리합니다. +모든 API 응답은 `ApiResponse` 래퍼를 우선 사용하며, 상태/메시지는 `ApiResponseCode` enum으로 관리합니다. 자주 사용하는 코드 예시는 아래와 같습니다. | 코드 | HTTP Status | 의미 | | --- | --- | --- | | `CODE_200` | `200 OK` | 일반 성공 | -| `CODE_204` | `204 No Contnet` | 일반 성공 | +| `CODE_204` | `204 No Content` | 일반 성공 | | `CODE_2001` | `200 OK` | 단건 조회 성공 | | `CODE_2002` | `201 Created` | 등록 성공 | | `CODE_2003` | `200 OK` | 조회 결과 없음 | @@ -380,6 +314,8 @@ Content-Type: application/json | `CODE_403` | `403 Forbidden` | 권한 없음 | | `CODE_500` | `500 Internal Server Error` | 서버 오류 | +파일 binary view/download 응답은 `ResponseEntity` 로 직접 내려줄 수 있습니다. + ## 배포 관련 - Dockerfile: `Dockerfile` @@ -389,10 +325,13 @@ Content-Type: application/json - 파일 도메인: `https://file-alist.pjt.kr` - Swagger: `https://api-alist.pjt.kr/swagger-ui/index.html` -## 참고 +## 상세 문서 -- Swagger 로컬 접속: [http://localhost:8106/swagger-ui.html](http://localhost:8106/swagger-ui.html) -- 운영 환경에서는 `application-pjt.yaml`의 환경변수 주입 방식 사용을 권장합니다. +- [프로젝트 개요](docs/project-overview.md) +- [보안 및 응답 규칙](docs/security-and-response.md) +- [설정 및 실행 가이드](docs/runtime-config.md) +- [검증 및 체크리스트](docs/verification-checklist.md) +- [현재 코드베이스 메모](docs/codebase-notes.md) ## 라이선스 diff --git a/docs/codebase-notes.md b/docs/codebase-notes.md index 2e9ebd7..7682d82 100644 --- a/docs/codebase-notes.md +++ b/docs/codebase-notes.md @@ -6,3 +6,9 @@ - 테스트 코드가 충분하지 않을 수 있으므로 기능 변경 시 필요한 테스트를 보강한다. - 일부 Java 소스와 주석, Swagger 설명에 인코딩이 깨진 문자열이 있으므로 표시 문자열 수정은 영향 범위를 보고 묶어서 처리한다. - `application-local.yaml` 은 로컬 실행값이 직접 들어가 있으므로 공유하거나 커밋할 때 민감정보 노출 여부를 한 번 더 확인한다. +- 파일 업로드는 두 흐름으로 분리되어 있다. `file` 모듈의 단순 업로드는 DB에 기록하지 않고 `uploadPath`, 파일명, 확장자, contentType, size, image width/height 정도만 반환한다. 각 업무 테이블 저장은 호출 측에서 처리한다. +- SunEditor 이미지 업로드는 API로 파일을 저장하되 응답 URL은 `file.upload.view.file-domain + uploadPath` 로 만든다. 에디터 본문에 저장되는 URL은 admin/user 토큰에 의존하지 않는 file-domain URL이어야 한다. +- TUS 업로드는 `tusFile` 모듈에서 DB master/detail 기록, upload token 발급, tusd hook 반영, 상태 조회, view/download/delete를 담당한다. +- TUS 인증 경로는 `/tusFiles/uploadAuth`, hook 경로는 `/tusFiles/tusHook` 이다. nginx `/_upload_auth` 와 Spring Security 공개 경로, JWT filter 제외 경로를 함께 맞춘다. +- file-domain nginx 는 `/uploads/` 를 공개 정적 파일로 열고 `/uploads/tmp/` 는 404로 막는다. TUS 임시 디렉터리는 파일 시스템에서는 사용하지만 URL로 직접 노출하지 않는다. +- Admin 로그인 상태 확인은 `/admin/auth/loginChecked` 를 사용한다. `/admin/auth/refresh` 는 토큰을 재발급하므로 새로고침 상태 확인용으로 쓰지 않는다. diff --git a/docs/project-overview.md b/docs/project-overview.md index 7c82e4e..cbe3a64 100644 --- a/docs/project-overview.md +++ b/docs/project-overview.md @@ -30,4 +30,6 @@ - MyBatis는 인터페이스와 XML을 함께 사용한다. - Mapper 인터페이스는 `src/main/java/.../mapper`, SQL XML은 `src/main/resources/mapper/...` 경로를 짝으로 맞춘다. - 공통 응답은 `common/response`, 보안은 `config/security, jwt`, 전역 예외 처리는 `config/exception` 아래에 둔다. -- 모듈 패키지는 현재 `auth`, `file`, `main`, `user` 형태로 구성되어 있고, 필요한 모듈만 `dto`, `form`, `mapper`, `service`, `vo`를 둔다. +- 모듈 패키지는 현재 `admin`, `auth`, `file`, `main`, `tusFile`, `user` 형태로 구성되어 있고, 필요한 모듈만 `dto`, `form`, `mapper`, `service`, `vo`를 둔다. +- `file` 모듈은 DB 기록 없는 단순 업로드, SunEditor 이미지 업로드, uploadPath 기반 view/download를 담당한다. +- `tusFile` 모듈은 DB 기록이 필요한 TUS 기반 대용량 업로드 초기화, 업로드 토큰 검증, tusd hook, 상태 조회, 파일 목록/view/download/delete 흐름을 담당한다. diff --git a/docs/runtime-config.md b/docs/runtime-config.md index 9e1dcdc..29712db 100644 --- a/docs/runtime-config.md +++ b/docs/runtime-config.md @@ -17,6 +17,25 @@ - URL: `http://localhost:8106/swagger-ui.html` - 인증: `swagger.login.id` / `swagger.login.password` (환경별 yaml에 설정) +## 파일 업로드 설정 +- `tus-file.upload.final-root` 는 TUS 완료 파일과 단순 업로드 파일이 공유하는 최종 저장 루트다. +- `tus-file.upload.tmp-root` 는 tusd 임시 업로드 루트다. file-domain nginx 에서는 `/uploads/tmp/` 접근을 404로 막는다. +- `file.upload.root-path` 는 보통 `${tus-file.upload.final-root}` 를 사용해 단순 업로드와 TUS 완료 파일의 마운트 루트를 맞춘다. +- `file.upload.view.file-domain` 은 SunEditor 이미지 응답 URL 생성에 사용한다. 예: `https://file-alist.pjt.kr`. +- `file.upload.max-size` 는 단순 업로드 전역 최대 용량이다. `file.upload.types.{folder}.max-size` 가 있으면 폴더별 설정이 우선한다. +- `file.upload.allowed-extensions` 는 단순 업로드 전역 확장자 허용 목록이다. +- `file.upload.types.{key}.folder` 는 실제 저장 폴더명이다. 설정되지 않은 folder 값도 전역 정책을 통과하면 동적 폴더로 저장할 수 있다. +- `file.upload.types.{key}.image-only` 가 `true` 이면 이미지 확장자만 허용한다. +- `file.upload.types.{key}.resize.enabled` 가 `true` 이고 업로드 파일이 이미지이면 resize 함수를 거친다. `width` 와 `height` 가 모두 있으면 중앙 crop 후 고정 크기로 저장하고, `max-width` 만 있으면 비율을 유지해 축소한다. +- `spring.servlet.multipart.max-file-size` 와 `max-request-size` 는 `-1` 로 두고, 실제 제한은 업로드 서비스 정책에서 처리한다. + +## file-domain nginx 기준 +- `/tus/files/` 는 tusd 로 프록시하며 `auth_request /_upload_auth` 로 `/tusFiles/uploadAuth` 를 호출한다. +- `/_upload_auth` 는 내부 location 으로만 열고 `Authorization`, `X-File-Uuid`, 필요 시 `Upload-Metadata`, `Upload-Length` 헤더를 API 로 전달한다. +- `/uploads/` 는 `/srv/project/alist/uploads/` 를 정적 파일로 제공한다. +- `/uploads/tmp/` 는 tusd 임시 파일 노출을 막기 위해 404 처리한다. +- 그 외 경로는 `location /` fallback 에서 차단한다. + ## 실행 명령 - 로컬 실행: `./gradlew bootRun` - 테스트 실행: `./gradlew test` diff --git a/docs/security-and-response.md b/docs/security-and-response.md index b977e4f..5e96fc6 100644 --- a/docs/security-and-response.md +++ b/docs/security-and-response.md @@ -10,7 +10,10 @@ - Swagger: `/v3/api-docs/**`, `/swagger-ui/**` → HTTP Basic 인증 (InMemory) - API: JWT Bearer 토큰 인증 (Stateless) - 세션/쿠키: Redis Session 저장소 사용, 쿠키 속성은 프로파일별 `cookie.*` 설정으로 제어 -- 공개 경로: `/`, `/actuator/health`, `/sso/**`, `/auth/**`, `/user/signup`, `/files/tusHook` +- Admin API: `/admin/**` 는 별도 `SecurityFilterChain` 으로 분리하며 `/admin/auth/**` 만 공개하고 나머지는 `ADMIN` 권한을 요구한다. +- 공개 경로: `/`, `/actuator/health`, `/sso/**`, `/auth/**`, `/user/signup`, `/user/migrationUserList`, `/tusFiles/tusHook`, `/tusFiles/uploadAuth` +- TUS 업로드 토큰은 일반 access token 이 아니므로 `/tusFiles/uploadAuth`, `/tusFiles/tusHook` 은 `JwtAuthenticationFilter.shouldNotFilter(...)` 에서도 제외한다. +- `/admin/auth/loginChecked` 는 admin access token 쿠키의 현재 로그인 상태 확인용이다. 토큰을 재발급하지 않으며 `isAdminAccessToken`, `isAdminRefreshToken`, `loggedIn`, `userId`, `userIdx`, `userTokenIdx`, `userRole` 형태의 값을 반환한다. - Swagger 인증과 API 인증은 `SecurityFilterChain` 을 분리해서 관리한다. ## 응답 코드 규칙 diff --git a/docs/verification-checklist.md b/docs/verification-checklist.md index 3b79588..a6d560c 100644 --- a/docs/verification-checklist.md +++ b/docs/verification-checklist.md @@ -12,9 +12,14 @@ - Mapper 인터페이스 추가/변경 시 XML namespace, id, parameter/result 매핑이 같이 맞는지 확인한다. - 공개 경로나 권한 정책을 바꿨다면 SecurityConfig 와 Swagger 노출 범위를 같이 확인한다. - 파일 업로드/다운로드 기능 수정 시 DB 상태, Redis 상태, 실제 파일 시스템 경로가 같이 맞는지 확인한다. +- TUS 경로를 바꾸면 nginx `/_upload_auth`, tusd hook URL, `SecurityConfig` 공개 경로, `JwtAuthenticationFilter.shouldNotFilter(...)` 를 함께 확인한다. +- SunEditor 또는 단순 업로드 설정을 바꾸면 `file.upload.root-path`, `file.upload.view.file-domain`, nginx `/uploads/` alias 경로가 같은 저장 루트를 가리키는지 확인한다. ## 기능 특성별 점검 포인트 - 스케줄러 코드는 실행 주기, 중복 실행 가능성, 로그량을 반드시 점검한다. - 인증 방식이 섞여 있으므로 세션 기반 처리와 JWT `SecurityContext` 사용 위치를 먼저 구분하고 수정한다. - 파일 경로를 다루는 기능은 상대경로 탈출, 루트 이탈 방지 같은 검증을 같이 본다. - 설정 파일 수정 시 `local`, `pjt`, 공통 설정 간 차이를 함께 확인한다. +- file-domain 정적 파일은 `/uploads/editor/...` 같은 최종 파일 URL이 브라우저에서 직접 열리는지 확인한다. +- `/uploads/tmp/...` 는 404로 막히는지 확인한다. +- SunEditor 업로드는 응답 JSON의 `result[].url` 이 file-domain 절대 URL인지 확인하고, 에디터 본문에 이미지가 실제 삽입되는지 확인한다. diff --git a/src/main/java/com/alist/api/config/SecurityConfig.java b/src/main/java/com/alist/api/config/SecurityConfig.java index 5b5cbb0..6e92219 100644 --- a/src/main/java/com/alist/api/config/SecurityConfig.java +++ b/src/main/java/com/alist/api/config/SecurityConfig.java @@ -78,7 +78,7 @@ public class SecurityConfig { .accessDeniedHandler(new JwtAccessDeniedHandler()) ) .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/actuator/health", "/sso/**", "/auth/**", "/user/signup", "/user/migrationUserList", "/files/tusHook").permitAll() + .requestMatchers("/", "/actuator/health", "/sso/**", "/auth/**", "/user/signup", "/user/migrationUserList", "/tusFiles/tusHook", "/tusFiles/uploadAuth").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java index b5f37ed..5033df2 100644 --- a/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java @@ -98,4 +98,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { return null; } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + + return "/tusFiles/uploadAuth".equals(uri) + || "/tusFiles/tusHook".equals(uri); + } } diff --git a/src/main/java/com/alist/api/modules/admin/auth/AdminAuthController.java b/src/main/java/com/alist/api/modules/admin/auth/AdminAuthController.java index c081302..00559ec 100644 --- a/src/main/java/com/alist/api/modules/admin/auth/AdminAuthController.java +++ b/src/main/java/com/alist/api/modules/admin/auth/AdminAuthController.java @@ -2,16 +2,26 @@ package com.alist.api.modules.admin.auth; import com.alist.api.common.response.ApiResponse; import com.alist.api.common.response.ApiResponseCode; +import com.alist.api.common.utils.SecurityUtil; +import com.alist.api.common.utils.SessionUtil; import com.alist.api.modules.admin.auth.form.AdminLoginForm; import com.alist.api.modules.admin.auth.service.AdminAuthService; import com.alist.api.modules.admin.auth.vo.AdminLoginVo; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.HashMap; import java.util.Map; +@Tag( + name = "03. Admin 로그인" + , description = "ADMIN 사이트 로그인입니다. 로그인시 토큰 발급이 함께됩니다." +) @RestController @RequestMapping("/admin/auth") public class AdminAuthController { @@ -22,6 +32,10 @@ public class AdminAuthController { this.adminAuthService = adminAuthService; } + @Operation( + summary = "admin 로그인" + , description = "admin로그인 sso와 다르게 로그인시 토큰이 함께 발급됩니다." + ) @PostMapping("/login") public ResponseEntity> adminLogin( @Valid @RequestBody AdminLoginForm adminLoginForm @@ -36,10 +50,14 @@ public class AdminAuthController { return ApiResponse.entity(result, ApiResponseCode.CODE_2001, "관리자 로그인"); } + @Operation( + summary = "admin 리프레시토큰 발급" + , description = "엑세스 토큰 만료시 리프레시 토큰 발급 용도 api 입니다." + ) @PostMapping("/refresh") public ResponseEntity>> refresh( - @CookieValue(name = "adminRefreshToken", required = false) String refreshToken, - HttpServletResponse response + @CookieValue(name = "adminRefreshToken", required = false) String refreshToken + , HttpServletResponse response ) { AdminLoginVo result = adminAuthService.adminRefresh(refreshToken, response); @@ -50,10 +68,53 @@ public class AdminAuthController { return ApiResponse.entity(Map.of("refreshed", true), ApiResponseCode.CODE_200); } + @Operation( + summary = "admin 로그인 상태 확인", + description = "admin 쿠키 기준으로 현재 공통 로그인 상태가 유효한지 확인하고, access/refresh 토큰 존재 여부도 함께 반환합니다." + ) + @GetMapping("/loginChecked") + public ResponseEntity>> loginChecked( + HttpServletRequest request + ) { + Map result = new HashMap<>(); + + String accessToken = SessionUtil.resolveSsoCookieValue(request, "adminAccessToken"); + String refreshToken = SessionUtil.resolveSsoCookieValue(request, "adminRefreshToken"); + + result.put("isAdminAccessToken", accessToken != null && !accessToken.trim().isEmpty()); + result.put("isAdminRefreshToken", refreshToken != null && !refreshToken.trim().isEmpty()); + + Integer userTokenIdx = SecurityUtil.getLoginUserTokenIdx(); + + if (userTokenIdx == null) { + result.put("loggedIn", false); + return ApiResponse.entity(result, ApiResponseCode.CODE_200); + } + + AdminLoginVo admin = adminAuthService.selectAdminTokenByUserTokenIdx(userTokenIdx); + + if (admin == null) { + result.put("loggedIn", false); + return ApiResponse.entity(result, ApiResponseCode.CODE_200); + } + + result.put("loggedIn", true); + result.put("userId", admin.getId()); + result.put("userIdx", admin.getUserIdx()); + result.put("userTokenIdx", userTokenIdx); + result.put("userRole", "ADMIN"); + + return ApiResponse.entity(result, ApiResponseCode.CODE_200); + } + + @Operation( + summary = "admin 로그아웃" + , description = "admin 로그아웃" + ) @PostMapping("/logout") public ResponseEntity>> logout( - @CookieValue(name = "adminRefreshToken", required = false) String refreshToken, - HttpServletResponse response + @CookieValue(name = "adminRefreshToken", required = false) String refreshToken + , HttpServletResponse response ) { adminAuthService.adminLogout(refreshToken, response); diff --git a/src/main/java/com/alist/api/modules/admin/auth/service/AdminAuthService.java b/src/main/java/com/alist/api/modules/admin/auth/service/AdminAuthService.java index 400ab6f..843facf 100644 --- a/src/main/java/com/alist/api/modules/admin/auth/service/AdminAuthService.java +++ b/src/main/java/com/alist/api/modules/admin/auth/service/AdminAuthService.java @@ -150,4 +150,12 @@ public class AdminAuthService { } catch (NumberFormatException ignored) { } } + + public AdminLoginVo selectAdminTokenByUserTokenIdx(Integer userTokenIdx) { + if (userTokenIdx == null) { + return null; + } + + return adminAuthMapper.selectAdminTokenByUserTokenIdx(userTokenIdx); + } } diff --git a/src/main/java/com/alist/api/modules/file/FileController.java b/src/main/java/com/alist/api/modules/file/FileController.java new file mode 100644 index 0000000..827ef82 --- /dev/null +++ b/src/main/java/com/alist/api/modules/file/FileController.java @@ -0,0 +1,104 @@ +package com.alist.api.modules.file; + +import com.alist.api.common.response.ApiResponse; +import com.alist.api.common.response.ApiResponseCode; +import com.alist.api.modules.file.service.FileService; +import com.alist.api.modules.file.vo.FileUploadVo; +import com.alist.api.modules.file.vo.SunEditorUploadVo; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; + +import java.util.ArrayList; +import java.util.List; + +@Tag( + name = "98. 파일 업로드", + description = "DB 기록 없이 파일을 저장하고 uploadPath를 반환하는 단순 업로드 API입니다. SunEditor 이미지는 file-domain URL을 반환합니다." +) +@RestController +@RequestMapping({"/files", "/admin/files"}) +public class FileController { + private final FileService fileService; + + public FileController(FileService fileService) { + this.fileService = fileService; + } + + @Operation( + summary = "단순 파일 업로드", + description = """ + DB에 기록하지 않고 파일만 저장합니다. + 반환된 uploadPath는 이후 각 업무 테이블에 저장해서 view/download 경로로 사용할 수 있습니다. + folder 값은 저장 폴더 키이며, 설정된 타입이면 해당 정책을 사용하고 설정되지 않은 값이면 전역 정책만 적용합니다. + """ + ) + @PostMapping("/upload") + public ResponseEntity> upload( + @RequestParam String folder, + @RequestPart MultipartFile file + ) { + FileUploadVo result = fileService.upload(folder, file); + return ApiResponse.entity(result, ApiResponseCode.CODE_200); + } + + @Operation( + summary = "SunEditor 이미지 업로드", + description = """ + SunEditor 이미지 업로드 전용 API입니다. + 업로드 파일을 저장한 뒤 SunEditor가 요구하는 result 배열 형태로 응답합니다. + 응답 URL은 file.upload.view.file-domain 값과 uploadPath를 조합한 공개 file-domain URL입니다. + """ + ) + @PostMapping("/suneditor/upload") + public ResponseEntity sunEditorUpload( + @RequestParam(value = "folder", defaultValue = "editor") String folder, + MultipartHttpServletRequest request + ) { + List result = new ArrayList<>(); + + for (MultipartFile file : request.getFileMap().values()) { + FileUploadVo uploaded = fileService.upload(folder, file); + + String imageUrl = fileService.buildImageUrl(uploaded.getUploadPath()); + + result.add(new SunEditorUploadVo.Item( + imageUrl, + uploaded.getOriginalFileName(), + uploaded.getFileSize() + )); + } + + return ResponseEntity.ok(new SunEditorUploadVo(result)); + } + + @Operation( + summary = "파일 보기", + description = """ + uploadPath 기준으로 파일을 inline 응답합니다. + 예: /uploads/notice/2026/05/08/sample.png + 단순 업로드 결과의 uploadPath를 그대로 path 파라미터에 전달합니다. + """ + ) + @GetMapping("/view") + public ResponseEntity view(@RequestParam String path) { + return fileService.resource(path, true); + } + + @Operation( + summary = "파일 다운로드", + description = """ + uploadPath 기준으로 파일을 attachment 응답합니다. + 예: /uploads/notice/2026/05/08/sample.pdf + 단순 업로드 결과의 uploadPath를 그대로 path 파라미터에 전달합니다. + """ + ) + @GetMapping("/download") + public ResponseEntity download(@RequestParam String path) { + return fileService.resource(path, false); + } +} diff --git a/src/main/java/com/alist/api/modules/file/config/FileUploadConfig.java b/src/main/java/com/alist/api/modules/file/config/FileUploadConfig.java new file mode 100644 index 0000000..d36e197 --- /dev/null +++ b/src/main/java/com/alist/api/modules/file/config/FileUploadConfig.java @@ -0,0 +1,10 @@ +package com.alist.api.modules.file.config; + +import com.alist.api.modules.file.properties.FileUploadProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@EnableConfigurationProperties(FileUploadProperties.class) +@Configuration +public class FileUploadConfig { +} diff --git a/src/main/java/com/alist/api/modules/file/properties/FileUploadProperties.java b/src/main/java/com/alist/api/modules/file/properties/FileUploadProperties.java new file mode 100644 index 0000000..4c11917 --- /dev/null +++ b/src/main/java/com/alist/api/modules/file/properties/FileUploadProperties.java @@ -0,0 +1,45 @@ +package com.alist.api.modules.file.properties; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.unit.DataSize; + +import java.util.List; +import java.util.Map; + +@Getter +@Setter +@ConfigurationProperties(prefix = "file.upload") +public class FileUploadProperties { + private String rootPath; + private DataSize maxSize; + private List allowedExtensions; + private Map types; + private View view = new View(); + + @Getter + @Setter + public static class UploadType { + private String folder; + private DataSize maxSize; + private boolean imageOnly; + private Resize resize = new Resize(); + } + + @Getter + @Setter + public static class Resize { + private boolean enabled; + private Integer width; + private Integer height; + private Integer maxWidth; + private Float quality = 0.9f; + } + + @Getter + @Setter + public static class View { + private String fileDomain; + } +} diff --git a/src/main/java/com/alist/api/modules/file/service/FileService.java b/src/main/java/com/alist/api/modules/file/service/FileService.java new file mode 100644 index 0000000..93166b3 --- /dev/null +++ b/src/main/java/com/alist/api/modules/file/service/FileService.java @@ -0,0 +1,240 @@ +package com.alist.api.modules.file.service; + +import com.alist.api.modules.file.properties.FileUploadProperties; +import com.alist.api.modules.file.vo.FileUploadVo; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.time.LocalDate; +import java.util.Locale; +import java.util.UUID; + +@Service +public class FileService { + private static final String PUBLIC_PREFIX = "/uploads"; + + private final FileUploadProperties properties; + private final FileUploadImageService fileUploadImageService; + + public FileService(FileUploadProperties properties, FileUploadImageService fileUploadImageService) { + this.properties = properties; + this.fileUploadImageService = fileUploadImageService; + } + + public FileUploadVo upload(String folder, MultipartFile file) { + if (folder == null || folder.isBlank()) throw new IllegalArgumentException("invalid folder"); + if (file == null || file.isEmpty()) throw new IllegalArgumentException("empty file"); + + FileUploadProperties.UploadType uploadType = resolveUploadType(folder); + + String originalFileName = cleanFileName(file.getOriginalFilename()); + String ext = extractExt(originalFileName); + validateExtension(ext); + validateSize(file.getSize(), uploadType); + + boolean image = isImageExtension(ext); + if (uploadType.isImageOnly() && !image) throw new IllegalArgumentException("image only"); + + LocalDate now = LocalDate.now(); + String storedFileName = UUID.randomUUID().toString().replace("-", "") + "." + ext; + + Path savePath = Paths.get( + properties.getRootPath(), + uploadType.getFolder(), + String.valueOf(now.getYear()), + "%02d".formatted(now.getMonthValue()), + "%02d".formatted(now.getDayOfMonth()), + storedFileName + ).normalize().toAbsolutePath(); + + ensureUnderRoot(savePath); + + try { + Files.createDirectories(savePath.getParent()); + + FileUploadImageService.ImageSize imageSize = null; + if (image && uploadType.getResize() != null && uploadType.getResize().isEnabled()) { + imageSize = fileUploadImageService.resizeAndSave(file, savePath, uploadType.getResize(), ext); + } else { + file.transferTo(savePath); + if (image) imageSize = fileUploadImageService.readSize(savePath); + } + + FileUploadVo vo = new FileUploadVo(); + vo.setUploadPath(toUploadPath(savePath)); + vo.setOriginalFileName(originalFileName); + vo.setStoredFileName(storedFileName); + vo.setFileExtension(ext); + vo.setContentType(file.getContentType()); + vo.setFileSize(Files.size(savePath)); + vo.setWidth(imageSize == null ? null : imageSize.width()); + vo.setHeight(imageSize == null ? null : imageSize.height()); + + return vo; + } catch (IOException e) { + throw new IllegalStateException("file save failed", e); + } + } + + public ResponseEntity resource(String uploadPath, boolean inline) { + try { + Path path = resolveUploadPath(uploadPath); + + if (!Files.exists(path) || !Files.isRegularFile(path)) { + return ResponseEntity.notFound().build(); + } + + String contentType = Files.probeContentType(path); + if (contentType == null || contentType.isBlank()) { + contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE; + } + + ContentDisposition disposition = inline + ? ContentDisposition.inline().filename(path.getFileName().toString(), StandardCharsets.UTF_8).build() + : ContentDisposition.attachment().filename(path.getFileName().toString(), StandardCharsets.UTF_8).build(); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .contentLength(Files.size(path)) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .body(new InputStreamResource(Files.newInputStream(path))); + } catch (IOException e) { + throw new IllegalStateException("file read failed", e); + } + } + + private Path resolveUploadPath(String uploadPath) { + if (uploadPath == null || uploadPath.isBlank()) throw new IllegalArgumentException("empty path"); + + String path = uploadPath.trim().replace("\\", "/"); + if (!path.startsWith(PUBLIC_PREFIX + "/")) throw new IllegalArgumentException("invalid path"); + + String relative = path.substring((PUBLIC_PREFIX + "/").length()); + if (relative.contains("..") || relative.startsWith("/") || relative.contains(":")) { + throw new IllegalArgumentException("invalid path"); + } + + Path root = rootPath(); + Path resolved = root.resolve(relative).normalize().toAbsolutePath(); + + if (!resolved.startsWith(root)) throw new IllegalArgumentException("invalid path"); + + return resolved; + } + + private String toUploadPath(Path savePath) { + Path root = rootPath(); + Path absolutePath = savePath.normalize().toAbsolutePath(); + + if (!absolutePath.startsWith(root)) throw new IllegalArgumentException("invalid path"); + + return PUBLIC_PREFIX + "/" + root.relativize(absolutePath).toString().replace("\\", "/"); + } + + private void ensureUnderRoot(Path path) { + if (!path.startsWith(rootPath())) throw new IllegalArgumentException("invalid save path"); + } + + private Path rootPath() { + return Paths.get(properties.getRootPath()).normalize().toAbsolutePath(); + } + + private void validateSize(long fileSize, FileUploadProperties.UploadType uploadType) { + long limit = uploadType.getMaxSize() == null + ? properties.getMaxSize().toBytes() + : uploadType.getMaxSize().toBytes(); + + if (fileSize > limit) throw new IllegalArgumentException("file size exceeded"); + } + + private void validateExtension(String ext) { + if (ext.isBlank() || properties.getAllowedExtensions().stream().noneMatch(ext::equalsIgnoreCase)) { + throw new IllegalArgumentException("invalid extension"); + } + } + + private String cleanFileName(String fileName) { + if (fileName == null || fileName.isBlank()) throw new IllegalArgumentException("empty file name"); + return Paths.get(fileName).getFileName().toString(); + } + + private String extractExt(String fileName) { + int idx = fileName.lastIndexOf('.'); + if (idx < 0 || idx == fileName.length() - 1) return ""; + return fileName.substring(idx + 1).trim().toLowerCase(Locale.ROOT); + } + + private boolean isImageExtension(String ext) { + return "jpg".equals(ext) || "jpeg".equals(ext) || "png".equals(ext) || "gif".equals(ext); + } + + private FileUploadProperties.UploadType resolveUploadType(String folder) { + String key = normalizeFolder(folder); + + FileUploadProperties.UploadType configured = properties.getTypes() == null + ? null + : properties.getTypes().get(key); + + if (configured != null) { + configured.setFolder(normalizeFolder(configured.getFolder())); + return configured; + } + + FileUploadProperties.UploadType fallback = new FileUploadProperties.UploadType(); + fallback.setFolder(key); + fallback.setMaxSize(null); + fallback.setImageOnly(false); + fallback.setResize(new FileUploadProperties.Resize()); + fallback.getResize().setEnabled(false); + + return fallback; + } + + private String normalizeFolder(String folder) { + if (folder == null || folder.isBlank()) { + throw new IllegalArgumentException("invalid folder"); + } + + String key = folder.trim().replace("\\", "/"); + + while (key.startsWith("/")) key = key.substring(1); + while (key.endsWith("/")) key = key.substring(0, key.length() - 1); + + if (key.isBlank() + || key.contains("..") + || key.contains(":") + || key.startsWith("http://") + || key.startsWith("https://")) { + throw new IllegalArgumentException("invalid folder"); + } + + if (!key.matches("^[a-zA-Z0-9/_-]+$")) { + throw new IllegalArgumentException("invalid folder"); + } + + return key; + } + + public String buildImageUrl(String uploadPath) { + return joinUrl(properties.getView().getFileDomain(), uploadPath); + } + + private String joinUrl(String domain, String path) { + if (domain == null || domain.isBlank()) { + throw new IllegalStateException("file.upload.view.file-domain is empty"); + } + + String base = domain.endsWith("/") ? domain.substring(0, domain.length() - 1) : domain; + String p = path.startsWith("/") ? path : "/" + path; + + return base + p; + } + +} diff --git a/src/main/java/com/alist/api/modules/file/service/FileUploadImageService.java b/src/main/java/com/alist/api/modules/file/service/FileUploadImageService.java new file mode 100644 index 0000000..7c10a9a --- /dev/null +++ b/src/main/java/com/alist/api/modules/file/service/FileUploadImageService.java @@ -0,0 +1,138 @@ +package com.alist.api.modules.file.service; + +import com.alist.api.modules.file.properties.FileUploadProperties; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.ImageOutputStream; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Iterator; + +@Service +public class FileUploadImageService { + public record ImageSize(int width, int height) { + } + + public ImageSize resizeAndSave( + MultipartFile file, + Path savePath, + FileUploadProperties.Resize resize, + String ext + ) throws IOException { + BufferedImage source = ImageIO.read(file.getInputStream()); + if (source == null) throw new IllegalArgumentException("invalid image"); + + BufferedImage target; + + if (resize.getWidth() != null && resize.getHeight() != null) { + target = cropAndResize(source, resize.getWidth(), resize.getHeight()); + } else if (resize.getMaxWidth() != null && source.getWidth() > resize.getMaxWidth()) { + target = resizeByMaxWidth(source, resize.getMaxWidth()); + } else { + target = source; + } + + writeImage(target, imageFormat(ext), savePath, resize.getQuality()); + + return new ImageSize(target.getWidth(), target.getHeight()); + } + + public ImageSize readSize(Path path) throws IOException { + BufferedImage image = ImageIO.read(path.toFile()); + if (image == null) return null; + return new ImageSize(image.getWidth(), image.getHeight()); + } + + private BufferedImage cropAndResize(BufferedImage source, int targetWidth, int targetHeight) { + double targetRatio = (double) targetWidth / targetHeight; + + int sourceWidth = source.getWidth(); + int sourceHeight = source.getHeight(); + double sourceRatio = (double) sourceWidth / sourceHeight; + + int cropWidth = sourceWidth; + int cropHeight = sourceHeight; + + if (sourceRatio > targetRatio) { + cropWidth = (int) Math.round(sourceHeight * targetRatio); + } else { + cropHeight = (int) Math.round(sourceWidth / targetRatio); + } + + BufferedImage cropped = source.getSubimage( + (sourceWidth - cropWidth) / 2, + (sourceHeight - cropHeight) / 2, + cropWidth, + cropHeight + ); + + return resize(cropped, targetWidth, targetHeight); + } + + private BufferedImage resizeByMaxWidth(BufferedImage source, int maxWidth) { + int targetWidth = maxWidth; + int targetHeight = (int) Math.round((double) source.getHeight() * targetWidth / source.getWidth()); + + return resize(source, targetWidth, targetHeight); + } + + private BufferedImage resize(BufferedImage source, int width, int height) { + BufferedImage target = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = target.createGraphics(); + + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + + graphics.drawImage(source, 0, 0, width, height, null); + graphics.dispose(); + + return target; + } + + private void writeImage(BufferedImage image, String format, Path savePath, Float quality) throws IOException { + if (!"jpeg".equals(format)) { + boolean written = ImageIO.write(image, format, savePath.toFile()); + if (!written) throw new IllegalArgumentException("unsupported image format"); + return; + } + + Iterator writers = ImageIO.getImageWritersByFormatName("jpeg"); + if (!writers.hasNext()) { + throw new IllegalArgumentException("unsupported image format"); + } + + ImageWriter writer = writers.next(); + ImageWriteParam param = writer.getDefaultWriteParam(); + + if (param.canWriteCompressed()) { + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(normalizeQuality(quality)); + } + + try (ImageOutputStream output = ImageIO.createImageOutputStream(savePath.toFile())) { + writer.setOutput(output); + writer.write(null, new IIOImage(image, null, null), param); + } finally { + writer.dispose(); + } + } + + private float normalizeQuality(Float quality) { + if (quality == null) return 0.9f; + if (quality < 0.0f) return 0.0f; + if (quality > 1.0f) return 1.0f; + return quality; + } + + private String imageFormat(String ext) { + if ("jpg".equalsIgnoreCase(ext)) return "jpeg"; + return ext.toLowerCase(); + } +} diff --git a/src/main/java/com/alist/api/modules/file/vo/FileUploadVo.java b/src/main/java/com/alist/api/modules/file/vo/FileUploadVo.java new file mode 100644 index 0000000..b9deb4e --- /dev/null +++ b/src/main/java/com/alist/api/modules/file/vo/FileUploadVo.java @@ -0,0 +1,17 @@ +package com.alist.api.modules.file.vo; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class FileUploadVo { + private String uploadPath; + private String originalFileName; + private String storedFileName; + private String fileExtension; + private String contentType; + private Long fileSize; + private Integer width; + private Integer height; +} diff --git a/src/main/java/com/alist/api/modules/file/vo/SunEditorUploadVo.java b/src/main/java/com/alist/api/modules/file/vo/SunEditorUploadVo.java new file mode 100644 index 0000000..7d19a27 --- /dev/null +++ b/src/main/java/com/alist/api/modules/file/vo/SunEditorUploadVo.java @@ -0,0 +1,30 @@ +package com.alist.api.modules.file.vo; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class SunEditorUploadVo { + private List result; + + public SunEditorUploadVo(List result) { + this.result = result; + } + + @Getter + @Setter + public static class Item { + private String url; + private String name; + private Long size; + + public Item(String url, String name, Long size) { + this.url = url; + this.name = name; + this.size = size; + } + } +} diff --git a/src/main/java/com/alist/api/modules/tusFile/FileController.java b/src/main/java/com/alist/api/modules/tusFile/TusFileController.java similarity index 87% rename from src/main/java/com/alist/api/modules/tusFile/FileController.java rename to src/main/java/com/alist/api/modules/tusFile/TusFileController.java index ef3acb9..509c344 100644 --- a/src/main/java/com/alist/api/modules/tusFile/FileController.java +++ b/src/main/java/com/alist/api/modules/tusFile/TusFileController.java @@ -5,7 +5,7 @@ import com.alist.api.common.response.ApiResponseCode; import com.alist.api.common.utils.SecurityUtil; import com.alist.api.modules.tusFile.dto.*; import com.alist.api.modules.tusFile.form.*; -import com.alist.api.modules.tusFile.service.FileService; +import com.alist.api.modules.tusFile.service.TusFileService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -30,12 +30,12 @@ import java.nio.file.Path; , description = "TUS 기반 파일 업로드 초기화, 권한 검증, 상태 조회, 훅 처리, 취소 API" ) @RestController -@RequestMapping("/files") -public class FileController { - private final FileService fileService; +@RequestMapping("/tusFiles") +public class TusFileController { + private final TusFileService tusFileService; - public FileController(FileService fileService) { - this.fileService = fileService; + public TusFileController(TusFileService tusFileService) { + this.tusFileService = tusFileService; } @Operation( @@ -54,7 +54,7 @@ public class FileController { return ApiResponse.entity(new FileUploadDto(), ApiResponseCode.CODE_401); } - Integer userIdx = fileService.selectUserIdxByUserTokenIdx(userTokenIdx); + Integer userIdx = tusFileService.selectUserIdxByUserTokenIdx(userTokenIdx); if (userIdx == null) { return ApiResponse.entity(new FileUploadDto(), ApiResponseCode.CODE_401); @@ -63,7 +63,7 @@ public class FileController { fileUploadDto.setUserIdx(userIdx); fileUploadDto.setUserTokenIdx(userTokenIdx); - FileUploadDto fileUploadResult = fileService.insertFileInit(fileUploadDto); + FileUploadDto fileUploadResult = tusFileService.insertFileInit(fileUploadDto); return ApiResponse.entity(fileUploadResult, ApiResponseCode.CODE_200); } @@ -92,7 +92,7 @@ public class FileController { uploadTokenDto.setUploadMetadataRaw(uploadMetadata); uploadTokenDto.setUploadLengthRaw(uploadLength); - UploadTokenDto uploadTokenCheck = fileService.isUploadTokenCheck(uploadTokenDto); + UploadTokenDto uploadTokenCheck = tusFileService.isUploadTokenCheck(uploadTokenDto); if (uploadTokenCheck.getResultCode() == 401) { return ApiResponse.entity("", ApiResponseCode.CODE_401); @@ -111,7 +111,7 @@ public class FileController { public ResponseEntity> uploadStatus( @Valid @RequestBody UploadStatusForm uploadStatusForm ) { - UploadStatusDto status = fileService.getUploadStatus(uploadStatusForm.toUploadStatusDto()); + UploadStatusDto status = tusFileService.getUploadStatus(uploadStatusForm.toUploadStatusDto()); if (status == null) { return ApiResponse.entity(new UploadStatusDto(), ApiResponseCode.CODE_2003); } @@ -124,10 +124,10 @@ public class FileController { ) @PostMapping("/tusHook") public ResponseEntity> tusHook(@RequestBody TusHookForm tusHookForm) { - boolean accepted = fileService.updateFileUploadStatus(tusHookForm.toTusHookDto()); + boolean accepted = tusFileService.updateFileUploadStatus(tusHookForm.toTusHookDto()); if (accepted) { - fileService.saveUploadStatusRedisTusHook(tusHookForm.toTusHookDto()); + tusFileService.saveUploadStatusRedisTusHook(tusHookForm.toTusHookDto()); } return ApiResponse.entity("", ApiResponseCode.CODE_200); @@ -152,7 +152,7 @@ public class FileController { uploadCancelDto.setFileUuid(uploadCancelForm.getFileUuid().trim()); uploadCancelDto.setUserTokenIdx(userTokenIdx); - boolean canceled = fileService.updateUploadCancel(uploadCancelDto); + boolean canceled = tusFileService.updateUploadCancel(uploadCancelDto); if (!canceled) { return ApiResponse.entity("", ApiResponseCode.CODE_2005, "업로드 취소"); @@ -172,7 +172,7 @@ public class FileController { FileDownloadDto fileDownloadDto = new FileDownloadDto(); fileDownloadDto.setFileMasterIdx(fileMasterIdx); - FileDownloadListDto fileDownloadList = fileService.selectFileDownloadListByFileMasterIdx(fileDownloadDto); + FileDownloadListDto fileDownloadList = tusFileService.selectFileDownloadListByFileMasterIdx(fileDownloadDto); if (fileDownloadList.getResultCode() == 401) { return ApiResponse.entity(fileDownloadList, ApiResponseCode.CODE_401); @@ -196,13 +196,13 @@ public class FileController { fileDownloadDto.setUserAgent(request.getHeader("User-Agent")); fileDownloadDto.setReferer(request.getHeader("Referer")); - FileDownloadDto target = fileService.selectFileViewOrDownload(fileDownloadDto); + FileDownloadDto target = tusFileService.selectFileViewOrDownload(fileDownloadDto); if (target == null) { return ResponseEntity.notFound().build(); } - Path filePath = fileService.resolveStoredFilePath(target.getSavePath()); + Path filePath = tusFileService.resolveStoredFilePath(target.getSavePath()); if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) { return ResponseEntity.notFound().build(); @@ -239,13 +239,13 @@ public class FileController { fileDownloadDto.setUserAgent(request.getHeader("User-Agent")); fileDownloadDto.setReferer(request.getHeader("Referer")); - FileDownloadDto target = fileService.selectFileViewOrDownload(fileDownloadDto); + FileDownloadDto target = tusFileService.selectFileViewOrDownload(fileDownloadDto); if (target == null) { return ResponseEntity.notFound().build(); } - Path filePath = fileService.resolveStoredFilePath(target.getSavePath()); + Path filePath = tusFileService.resolveStoredFilePath(target.getSavePath()); if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) { return ResponseEntity.notFound().build(); @@ -275,7 +275,7 @@ public class FileController { public ResponseEntity> fileDelete( @Valid @RequestBody FileDeleteForm fileDeleteForm ) { - boolean deleted = fileService.updateFileDelete(fileDeleteForm.toFileDeleteDto()); + boolean deleted = tusFileService.updateFileDelete(fileDeleteForm.toFileDeleteDto()); if (!deleted) { return ApiResponse.entity("", ApiResponseCode.CODE_2005, "파일 삭제"); diff --git a/src/main/java/com/alist/api/modules/tusFile/mapper/FileMapper.java b/src/main/java/com/alist/api/modules/tusFile/mapper/TusFileMapper.java similarity index 98% rename from src/main/java/com/alist/api/modules/tusFile/mapper/FileMapper.java rename to src/main/java/com/alist/api/modules/tusFile/mapper/TusFileMapper.java index 62dd729..09de2d8 100644 --- a/src/main/java/com/alist/api/modules/tusFile/mapper/FileMapper.java +++ b/src/main/java/com/alist/api/modules/tusFile/mapper/TusFileMapper.java @@ -6,7 +6,7 @@ import org.apache.ibatis.annotations.Mapper; import java.util.List; @Mapper -public interface FileMapper { +public interface TusFileMapper { void insertFileMasterInit(FileUploadDto fileUploadDto); void insertFileDetailInit(FileUploadItemDto item); diff --git a/src/main/java/com/alist/api/modules/tusFile/service/FileService.java b/src/main/java/com/alist/api/modules/tusFile/service/TusFileService.java similarity index 89% rename from src/main/java/com/alist/api/modules/tusFile/service/FileService.java rename to src/main/java/com/alist/api/modules/tusFile/service/TusFileService.java index ffcccc0..e6a0068 100644 --- a/src/main/java/com/alist/api/modules/tusFile/service/FileService.java +++ b/src/main/java/com/alist/api/modules/tusFile/service/TusFileService.java @@ -3,7 +3,7 @@ package com.alist.api.modules.tusFile.service; import com.alist.api.common.utils.SecurityUtil; import com.alist.api.config.jwt.JwtTokenProvider; import com.alist.api.modules.tusFile.dto.*; -import com.alist.api.modules.tusFile.mapper.FileMapper; +import com.alist.api.modules.tusFile.mapper.TusFileMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.HashOperations; @@ -26,31 +26,31 @@ import java.util.UUID; @Slf4j @Service -public class FileService { - @Value("${file.upload.tus-endpoint}") +public class TusFileService { + @Value("${tus-file.upload.tus-endpoint}") private String tusEndpoint; - @Value("${file.upload.public-base-url}") + @Value("${tus-file.upload.public-base-url}") private String publicBaseUrl; - @Value("${file.upload.auth-cache.ttl-seconds:20}") + @Value("${tus-file.upload.auth-cache.ttl-seconds:20}") private long uploadAuthCacheTtlSeconds; - @Value("${file.upload.interrupt-seconds:30}") + @Value("${tus-file.upload.interrupt-seconds:30}") private long uploadInterruptSeconds; - @Value("${file.upload.tmp-root}") + @Value("${tus-file.upload.tmp-root}") private String uploadTmpRoot; - @Value("${file.upload.final-root}") + @Value("${tus-file.upload.final-root}") private String uploadFinalRoot; - private final FileMapper fileMapper; + private final TusFileMapper tusFileMapper; private final JwtTokenProvider jwtTokenProvider; private final StringRedisTemplate stringRedisTemplate; - public FileService(FileMapper fileMapper, JwtTokenProvider jwtTokenProvider, StringRedisTemplate stringRedisTemplate) { - this.fileMapper = fileMapper; + public TusFileService(TusFileMapper tusFileMapper, JwtTokenProvider jwtTokenProvider, StringRedisTemplate stringRedisTemplate) { + this.tusFileMapper = tusFileMapper; this.jwtTokenProvider = jwtTokenProvider; this.stringRedisTemplate = stringRedisTemplate; } @@ -61,7 +61,7 @@ public class FileService { fileUploadDto.setStatus(0); fileUploadDto.setTotalCount(fileUploadDto.getItemList().size()); - fileMapper.insertFileMasterInit(fileUploadDto); + tusFileMapper.insertFileMasterInit(fileUploadDto); fileUploadDto.setTusEndpoint(tusEndpoint); if (fileUploadDto.getItemList() == null || fileUploadDto.getItemList().isEmpty()) { @@ -74,7 +74,7 @@ public class FileService { item.setUploadToken(jwtTokenProvider.createUploadToken(fileUploadDto.getUserTokenIdx())); item.setUserIdx(fileUploadDto.getUserIdx()); item.setStatus(0); // PENDING - fileMapper.insertFileDetailInit(item); + tusFileMapper.insertFileDetailInit(item); } return fileUploadDto; @@ -118,7 +118,7 @@ public class FileService { log.warn("uploadAuth cache get fail. fallback DB. key={}", cacheKey, redisGetEx); } - int isOk = fileMapper.selectFileDetailCountByFileUuidUserToKenIdx(uploadTokenDto); + int isOk = tusFileMapper.selectFileDetailCountByFileUuidUserToKenIdx(uploadTokenDto); int resultCode = (isOk > 0) ? 200 : 403; uploadTokenDto.setResultCode(resultCode); @@ -233,14 +233,14 @@ public class FileService { long offset = tusHookDto.getOffset() == null ? 0L : tusHookDto.getOffset(); long size = tusHookDto.getSize() == null ? 0L : tusHookDto.getSize(); - fileMapper.insertFileUploadEventLog(tusHookDto); + tusFileMapper.insertFileUploadEventLog(tusHookDto); - FileUploadMetaDto fileUploadMetaInfo = fileMapper.selectFileUploadMetaByFileUuid(fileUuid); + FileUploadMetaDto fileUploadMetaInfo = tusFileMapper.selectFileUploadMetaByFileUuid(fileUuid); if (fileUploadMetaInfo == null) return true; if (isMetadataMismatch(fileUploadMetaInfo, tusHookDto)) { - fileMapper.updateFileDetailCanceledByFileUuid(fileUuid); - fileMapper.updateFileMasterAggregateByFileUuid(fileUuid); + tusFileMapper.updateFileDetailCanceledByFileUuid(fileUuid); + tusFileMapper.updateFileMasterAggregateByFileUuid(fileUuid); saveCanceledStatusRedis(fileUuid, offset, size); return false; } @@ -250,14 +250,14 @@ public class FileService { FileUploadMetaDto fileUploadMetaDto = new FileUploadMetaDto(); fileUploadMetaDto.setFileUuid(fileUuid); fileUploadMetaDto.setTusUploadId(uploadId); - fileMapper.updateTusUploadIdIfNull(fileUploadMetaDto); + tusFileMapper.updateTusUploadIdIfNull(fileUploadMetaDto); - fileUploadMetaInfo = fileMapper.selectFileUploadMetaByFileUuid(fileUuid); + fileUploadMetaInfo = tusFileMapper.selectFileUploadMetaByFileUuid(fileUuid); } if (fileUploadMetaInfo.getTusUploadId() == null || fileUploadMetaInfo.getTusUploadId().isBlank() || !fileUploadMetaInfo.getTusUploadId().equals(uploadId)) { - fileMapper.updateFileDetailCanceledByFileUuid(fileUuid); - fileMapper.updateFileMasterAggregateByFileUuid(fileUuid); + tusFileMapper.updateFileDetailCanceledByFileUuid(fileUuid); + tusFileMapper.updateFileMasterAggregateByFileUuid(fileUuid); saveCanceledStatusRedis(fileUuid, offset, size); return false; } @@ -319,11 +319,11 @@ public class FileService { boolean shouldUpdateDetail = !"post-receive".equals(type); if (shouldUpdateDetail) { - fileMapper.updateFileDetailStatus(tusHookDto); + tusFileMapper.updateFileDetailStatus(tusHookDto); } if ("post-finish".equals(type) || "post-terminate".equals(type)) { - fileMapper.updateFileMasterAggregateByFileUuid(tusHookDto.getFileUuid()); + tusFileMapper.updateFileMasterAggregateByFileUuid(tusHookDto.getFileUuid()); } if ("post-finish".equals(type)) { @@ -352,7 +352,7 @@ public class FileService { @Transactional public void tryMoveNowByFileUuid(String fileUuid) throws IOException { - FileMoveTaskDto task = fileMapper.selectMoveTaskByFileUuid(fileUuid); + FileMoveTaskDto task = tusFileMapper.selectMoveTaskByFileUuid(fileUuid); if (task == null) return; if (task.getMoveYn() != null && !"N".equalsIgnoreCase(task.getMoveYn())) return; @@ -373,7 +373,7 @@ public class FileService { task.setSavePath(toSavePath(finalPath)); task.setSaveName(finalPath.getFileName().toString()); task.setExt(extractExt(task.getSaveName())); - fileMapper.updateFileMoveSuccess(task); + tusFileMapper.updateFileMoveSuccess(task); } @Transactional @@ -381,7 +381,7 @@ public class FileService { FileMoveTaskDto task = new FileMoveTaskDto(); task.setFileUuid(fileUuid); task.setMoveLastError(errMsg == null ? "move failed" : errMsg); - fileMapper.updateFileMovePendingByFileUuid(task); // MOVE_YN='N', TRY_COUNT+1, LAST_ERROR + tusFileMapper.updateFileMovePendingByFileUuid(task); // MOVE_YN='N', TRY_COUNT+1, LAST_ERROR } private String truncateErr(String s) { @@ -567,21 +567,21 @@ public class FileService { return false; } - int allowed = fileMapper.selectFileDetailCountByFileUuidAndUserToKenIdx(uploadCancelDto); + int allowed = tusFileMapper.selectFileDetailCountByFileUuidAndUserToKenIdx(uploadCancelDto); if (allowed <= 0) { return false; } - UploadCancelDto progress = fileMapper.selectUploadProgressByFileUuid(uploadCancelDto); + UploadCancelDto progress = tusFileMapper.selectUploadProgressByFileUuid(uploadCancelDto); long uploaded = (progress != null && progress.getUploadedBytes() != null) ? progress.getUploadedBytes() : 0L; long total = (progress != null && progress.getSizeBytes() != null) ? progress.getSizeBytes() : 0L; - int updated = fileMapper.updateFileDetailCanceledByFileUuidAndUserTokenIdx(uploadCancelDto); + int updated = tusFileMapper.updateFileDetailCanceledByFileUuidAndUserTokenIdx(uploadCancelDto); if (updated <= 0) { return false; } - fileMapper.updateFileMasterAggregateByFileUuid(uploadCancelDto.getFileUuid()); + tusFileMapper.updateFileMasterAggregateByFileUuid(uploadCancelDto.getFileUuid()); saveCanceledStatusRedis(uploadCancelDto.getFileUuid(), uploaded, total); // 필요하면 기존 bytes 조회해서 넣어도 됨 return true; } @@ -600,7 +600,7 @@ public class FileService { fileDownloadList.setFileMasterIdx(fileDownloadDto.getFileMasterIdx()); - List itemList = fileMapper.selectFileDownloadListByFileMasterIdx(fileDownloadDto); + List itemList = tusFileMapper.selectFileDownloadListByFileMasterIdx(fileDownloadDto); for (FileDownloadItemDto item : itemList) { item.setViewUrl("/files/view/" + item.getFileUuid()); @@ -621,7 +621,7 @@ public class FileService { fileDownloadDto.setUserTokenIdx(userTokenIdx); - FileDownloadDto fileDetailInfo = fileMapper.selectFileDetailByFileUuid(fileDownloadDto); + FileDownloadDto fileDetailInfo = tusFileMapper.selectFileDetailByFileUuid(fileDownloadDto); if (fileDetailInfo == null) { return null; @@ -633,7 +633,7 @@ public class FileService { fileDetailInfo.setUserAgent(fileDownloadDto.getUserAgent()); fileDetailInfo.setReferer(fileDownloadDto.getReferer()); - fileMapper.insertFileDownloadEventLog(fileDetailInfo); + tusFileMapper.insertFileDownloadEventLog(fileDetailInfo); return fileDetailInfo; } @@ -668,17 +668,17 @@ public class FileService { fileDeleteDto.setUserTokenIdx(userTokenIdx); fileDeleteDto.setFileUuid(fileDeleteDto.getFileUuid().trim()); - int allowed = fileMapper.selectFileDeleteTargetCount(fileDeleteDto); + int allowed = tusFileMapper.selectFileDeleteTargetCount(fileDeleteDto); if (allowed <= 0) { return false; } - int detailUpdated = fileMapper.updateFileDetailDeleteByFileUuid(fileDeleteDto); + int detailUpdated = tusFileMapper.updateFileDetailDeleteByFileUuid(fileDeleteDto); if (detailUpdated <= 0) { return false; } - fileMapper.updateFileMasterDeleteByFileUuid(fileDeleteDto); + tusFileMapper.updateFileMasterDeleteByFileUuid(fileDeleteDto); return true; } @@ -687,6 +687,6 @@ public class FileService { if (userTokenIdx == null) { return null; } - return fileMapper.selectUserIdxByUserTokenIdx(userTokenIdx); + return tusFileMapper.selectUserIdxByUserTokenIdx(userTokenIdx); } } diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index b124463..29cd175 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -7,15 +7,19 @@ spring: password: 1qaz2wsx!@ driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy hikari: - maximum-pool-size: 10 # 줄이기 - minimum-idle: 1 # 최소로 + maximum-pool-size: 10 + minimum-idle: 5 connection-timeout: 10000 - idle-timeout: 30000 + idle-timeout: 600000 data: redis: host: 121.160.234.222 port: 3001 password: 1qaz2wsx!@ + servlet: + multipart: + max-file-size: -1 + max-request-size: -1 migration: datasource: @@ -55,7 +59,7 @@ swagger: id: alist password: "1qaz2wsx!@" -file: +tus-file: upload: tus-endpoint: https://file-alist.pjt.kr/tus/files/ public-base-url: https://file-alist.pjt.kr @@ -65,6 +69,41 @@ file: auth-cache: ttl-seconds: 20 +# types: +# notice: +# folder: notice 저장폴더 +# max-size: 20MB 최대 용량 +# image-only: false +# resize: +# enabled: true +# width: 800 고정넓이 +# height: 600 고정높이 +# max-width: 1200 최대 넓이 + +file: + upload: + view: + file-domain: http://localhost:8110 + root-path: ${tus-file.upload.final-root} + max-size: 10MB + allowed-extensions: [jpg, jpeg, png, gif, pdf, hwp, hwpx, doc, docx, xls, xlsx, ppt, pptx, txt, csv, zip] + types: + profile: + folder: profile + max-size: 5MB + image-only: true + resize: + enabled: true + width: 400 + height: 400 + notice: + folder: notice + max-size: 20MB + image-only: false + resize: + enabled: true + max-width: 1200 + springdoc: api-docs: enabled: true diff --git a/src/main/resources/application-pjt.yaml b/src/main/resources/application-pjt.yaml index b2b7f8e..e4d7f08 100644 --- a/src/main/resources/application-pjt.yaml +++ b/src/main/resources/application-pjt.yaml @@ -9,15 +9,19 @@ spring: password: ${DB_PASSWORD} driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy hikari: - maximum-pool-size: 10 # 줄이기 - minimum-idle: 1 # 최소로 + maximum-pool-size: 10 + minimum-idle: 5 connection-timeout: 10000 - idle-timeout: 30000 + idle-timeout: 600000 data: redis: host: ${REDIS_HOST} port: ${REDIS_PORT} password: ${REDIS_PASSWORD} + servlet: + multipart: + max-file-size: -1 + max-request-size: -1 migration: datasource: @@ -57,7 +61,7 @@ swagger: id: ${SWAGGER_ID} password: ${SWAGGER_PASSWORD} -file: +tus-file: upload: tus-endpoint: https://file-alist.pjt.kr/tus/files/ public-base-url: https://file-alist.pjt.kr @@ -67,6 +71,41 @@ file: auth-cache: ttl-seconds: 20 +# types: +# notice: +# folder: notice 저장폴더 +# max-size: 20MB 최대 용량 +# image-only: false +# resize: +# enabled: true +# width: 800 고정넓이 +# height: 600 고정높이 +# max-width: 1200 최대 넓이 + +file: + upload: + view: + file-domain: https://file-alist.pjt.kr + root-path: ${tus-file.upload.final-root} + max-size: 10MB + allowed-extensions: [jpg, jpeg, png, gif, pdf, hwp, hwpx, doc, docx, xls, xlsx, ppt, pptx, txt, csv, zip] + types: + profile: + folder: profile + max-size: 5MB + image-only: true + resize: + enabled: true + width: 400 + height: 400 + notice: + folder: notice + max-size: 20MB + image-only: false + resize: + enabled: true + max-width: 1200 + springdoc: api-docs: enabled: true diff --git a/src/main/resources/mapper/file/FileMapper.xml b/src/main/resources/mapper/tusFile/TusFileMapper.xml similarity index 99% rename from src/main/resources/mapper/file/FileMapper.xml rename to src/main/resources/mapper/tusFile/TusFileMapper.xml index f8cb850..e66575c 100644 --- a/src/main/resources/mapper/file/FileMapper.xml +++ b/src/main/resources/mapper/tusFile/TusFileMapper.xml @@ -1,7 +1,7 @@ - + /*FileMapper.insertFileMasterInit*/