diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt index 8c43115..50ff46d 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/security/SecurityConfig.kt @@ -126,8 +126,29 @@ class SecurityConfig( // handling.authenticationEntryPoint(jwtAuthenticationEntryPoint()) handling.accessDeniedHandler(accessDeniedHandler2) } - // 모든 API 요청 전에 JWT 토큰을 검증하는 필터 추가 - .addFilterBefore(JwtAuthenticationFilter(jwtUtil, userManager), UsernamePasswordAuthenticationFilter::class.java) + + // [수정 포인트] 필터 추가 순서 변경 + // 1. JWT 필터 인스턴스 생성 + val jwtFilter = JwtAuthenticationFilter(jwtUtil, userManager) + + // 2. 디버깅 필터를 UsernamePasswordAuthenticationFilter 앞에 추가 + // (체인 상태: ... -> DebugFilter -> UsernamePasswordAuthenticationFilter) + http.addFilterBefore(object : OncePerRequestFilter() { + override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { + if (request.requestURI.startsWith("/api/synology")) { + val auth = SecurityContextHolder.getContext().authentication + println(">>> SECURITY DEBUG: [${request.method}] ${request.requestURI}") + println(" User: ${auth?.name ?: "Anonymous"}") + println(" Authorities: ${auth?.authorities}") + } + filterChain.doFilter(request, response) + } + }, UsernamePasswordAuthenticationFilter::class.java) + + // 3. JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가 + // addFilterBefore는 타겟 바로 앞에 끼워넣으므로, + // 결과 체인 순서는: DebugFilter -> JwtAuthenticationFilter -> UsernamePasswordAuthenticationFilter 가 됩니다. + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java) return http.build() } diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SynologyProxyController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SynologyProxyController.kt index 1083710..8c5738b 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SynologyProxyController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/SynologyProxyController.kt @@ -1,174 +1,170 @@ -package kr.lunaticbum.back.lun.controllers.api +package kr.lunaticbum.back.lun.controllers -import io.netty.handler.ssl.SslContextBuilder -import io.netty.handler.ssl.util.InsecureTrustManagerFactory import kotlinx.coroutines.reactor.awaitSingle -import org.slf4j.LoggerFactory import org.springframework.core.io.buffer.DataBuffer +import org.springframework.core.io.buffer.DataBufferUtils import org.springframework.http.MediaType import org.springframework.http.ResponseEntity -import org.springframework.http.client.reactive.ReactorClientHttpConnector import org.springframework.web.bind.annotation.* import org.springframework.web.reactive.function.client.WebClient -import org.springframework.web.reactive.function.client.awaitBody +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody import reactor.core.publisher.Flux -import reactor.netty.http.client.HttpClient +import java.nio.charset.StandardCharsets + +// DTO 정의 +data class SynologyListRequest( + val nasAddress: String, + val sid: String, + val offset: Int, + val limit: Int +) @RestController @RequestMapping("/api/synology") class SynologyProxyController { - private val logger = LoggerFactory.getLogger(SynologyProxyController::class.java) - - // SSL 검증 무시 설정 (HTTPS 사용 시 필요) - private val httpClient = HttpClient.create() - .secure { t -> t.sslContext( - SslContextBuilder.forClient() - .trustManager(InsecureTrustManagerFactory.INSTANCE) - .build() - )} - - // [수정됨] WebClient 생성 시 버퍼 크기 제한 해제 (256KB -> 20MB) private val webClient = WebClient.builder() - .clientConnector(ReactorClientHttpConnector(httpClient)) .codecs { configurer -> - configurer.defaultCodecs().maxInMemorySize(20 * 1024 * 1024) // 20MB로 증설 + configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024) // 16MB 버퍼 } .build() private fun buildBaseUrl(nasAddress: String): String { - if (nasAddress.startsWith("http://") || nasAddress.startsWith("https://")) { - return nasAddress - } - return if (nasAddress.contains(":5000")) "http://$nasAddress" else "https://$nasAddress" + return if (nasAddress.startsWith("http")) nasAddress else "https://$nasAddress" } @PostMapping("/login") - suspend fun login(@RequestBody payload: Map): String { - try { - val nasAddress = payload["nasAddress"].toString() - val username = payload["username"].toString() - val password = payload["password"].toString() + suspend fun login(@RequestBody request: Map): ResponseEntity { + val nasAddress = request["nasAddress"] ?: return ResponseEntity.badRequest().body("No nasAddress") + val username = request["username"] ?: return ResponseEntity.badRequest().body("No username") + val password = request["password"] ?: return ResponseEntity.badRequest().body("No password") - val baseUrl = buildBaseUrl(nasAddress) - val url = "$baseUrl/webapi/auth.cgi?api=SYNO.API.Auth&version=7&method=login&account=$username&passwd=$password&session=SynologyPhotos&format=sid" + val baseUrl = buildBaseUrl(nasAddress) + val uri = "$baseUrl/webapi/auth.cgi?api=SYNO.API.Auth&version=3&method=login&account=$username&passwd=$password&session=Foto&format=cookie" - logger.info("Login Request URL: $url") - return webClient.get().uri(url).retrieve().awaitBody() + return try { + val response = webClient.get() + .uri(uri) + .retrieve() + .bodyToMono(String::class.java) + .awaitSingle() + ResponseEntity.ok(response) } catch (e: Exception) { - logger.error("Login Error: ", e) - throw e + ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message)) } } - // 1. [수정] list 메서드: additional 제거 (에러 해결) @PostMapping("/list") - suspend fun list(@RequestBody payload: Map): String { - try { - val nasAddress = payload["nasAddress"].toString() - val sid = payload["sid"].toString() - val offset = payload["offset"].toString() - val limit = payload["limit"].toString() + suspend fun getList( + @RequestBody request: SynologyListRequest + ): ResponseEntity { + val baseUrl = buildBaseUrl(request.nasAddress) - val baseUrl = buildBaseUrl(nasAddress) + val uri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl) + .path("/webapi/entry.cgi") + .queryParam("api", "SYNO.Foto.Browse.Item") + .queryParam("version", "1") + .queryParam("method", "list") + .queryParam("offset", request.offset) + .queryParam("limit", request.limit) + // [중요] 썸네일 키(cache_key)를 받아오기 위해 필수 + .queryParam("additional", "[\"thumbnail\"]") + .queryParam("_sid", request.sid) + .build() + .toUri() - // 순수하게 목록만 요청 - val url = "$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Browse.Item&version=1&method=list&limit=$limit&offset=$offset&_sid=$sid" - - logger.info("List Request URL: $url") - return webClient.get().uri(url).retrieve().awaitBody() + return try { + val response = webClient.get() + .uri(uri) + .retrieve() + .bodyToMono(String::class.java) + .awaitSingle() + ResponseEntity.ok(response) } catch (e: Exception) { - logger.error("List Error: ", e) - throw e - } - } - - // 2. [추가] info 메서드: 사진 1장의 상세 정보(날짜, 위치) 조회 - // [수정] 유효한 additional 항목("exif", "gps")만 요청하도록 변경 - @PostMapping("/info") - suspend fun getInfo(@RequestBody payload: Map): String { - try { - val nasAddress = payload["nasAddress"].toString() - val sid = payload["sid"].toString() - val id = payload["id"].toString() - - val baseUrl = buildBaseUrl(nasAddress) - - // [핵심 변경] "address", "taken_time"은 유효하지 않음 -> "exif", "gps"로 변경 - // ["exif","gps"] -> %5B%22exif%22%2C%22gps%22%5D - val additionalEncoded = "%5B%22exif%22%2C%22gps%22%5D" - - val urlString = "$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Browse.Item&version=1&method=get&id=$id&additional=$additionalEncoded&_sid=$sid" - - val uri = java.net.URI(urlString) - - return webClient.get().uri(uri).retrieve().awaitBody() - } catch (e: Exception) { - logger.error("Info Error: ", e) - throw e + ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message)) } } @GetMapping("/geocode") - suspend fun geocode( - @RequestParam lat: String, - @RequestParam lon: String - ): String { - // OpenStreetMap API URL - val url = "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=10&accept-language=ko" + suspend fun reverseGeocode( + @RequestParam lat: Double, + @RequestParam lon: Double + ): ResponseEntity { + val uri = "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=10&accept-language=ko" - // Nominatim은 User-Agent 헤더가 필수입니다. - return webClient.get() - .uri(url) - .header("User-Agent", "MyNasSlideshow/1.0 (contact@example.com)") // 임의의 식별 문자열 - .retrieve() - .awaitBody() + return try { + val response = webClient.get() + .uri(uri) + .header("User-Agent", "SynoPhotoSlideshow/1.0") + .retrieve() + .bodyToMono(String::class.java) + .awaitSingle() + + ResponseEntity.ok(response) + } catch (e: Exception) { + ResponseEntity.ok("{}") + } } @GetMapping("/image") - suspend fun getImage( + fun getImage( @RequestParam nasAddress: String, @RequestParam sid: String, - @RequestParam id: String - ): ResponseEntity { + @RequestParam id: String, + @RequestParam(defaultValue = "download") mode: String, + @RequestParam(required = false) cacheKey: String? + ): ResponseEntity { val baseUrl = buildBaseUrl(nasAddress) - // [핵심] 문자열 더하기 대신 UriComponentsBuilder 사용 - // Spring이 '[' 문자를 자동으로 알맞게(%5B) 인코딩해줍니다. (이중 인코딩 방지) - val uri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl) - .path("/webapi/entry.cgi") - .queryParam("api", "SYNO.Foto.Download") - .queryParam("version", "1") - .queryParam("method", "download") - .queryParam("force_download", "true") - .queryParam("item_id", "[$id]") // 여기에 대괄호 []를 직접 넣습니다. - .queryParam("_sid", sid) - .build() - .toUri() + val rawUrl = if (mode == "thumbnail") { + val cacheKeyParam = if (!cacheKey.isNullOrEmpty()) "&cache_key=$cacheKey" else "" + "$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Thumbnail&version=1&method=get&type=unit&size=xl&id=$id$cacheKeyParam&_sid=$sid" + } else { + "$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Download&version=1&method=download&force_download=true&item_id=[$id]&_sid=$sid" + } + val uri = java.net.URI.create(rawUrl) try { - val response = webClient.get() - .uri(uri) // String url 대신 URI 객체를 전달 - .accept(MediaType.IMAGE_JPEG, MediaType.IMAGE_PNG, MediaType.ALL) + // [해결] retrieve() + toEntityFlux() 사용 + // exchangeToMono의 복잡한 스코프 문제를 피하고, 헤더와 바디 스트림을 안전하게 가져옵니다. + val responseEntity = webClient.get() + .uri(uri) + .accept(MediaType.ALL) .retrieve() - .toEntity(ByteArray::class.java) - .awaitSingle() + .toEntityFlux(DataBuffer::class.java) + .block() ?: return ResponseEntity.notFound().build() - val contentType = response.headers.contentType + val contentType = responseEntity.headers.contentType ?: MediaType.APPLICATION_OCTET_STREAM + val contentLength = responseEntity.headers.contentLength - // 에러 체크 - if (contentType != null && !contentType.toString().startsWith("image")) { - val errorBody = String(response.body ?: ByteArray(0)) - logger.error("NAS returned non-image content ($contentType): $errorBody") - logger.error("Requested URI: $uri") // 디버깅을 위해 요청한 URI도 출력 + // 1. 에러 체크 (JSON 응답이 오면 에러로 간주) + if (contentType.includes(MediaType.APPLICATION_JSON)) { + // 스트림을 문자열로 읽어서 로그 출력 + val errorBody = responseEntity.body?.collectList()?.block()?.joinToString("") { + it.toString(StandardCharsets.UTF_8) + } ?: "Unknown Error" + + println(">>> NAS Error (ID: $id): $errorBody") + return ResponseEntity.notFound().build() } - return ResponseEntity.ok() - .contentType(contentType ?: MediaType.IMAGE_JPEG) - .body(response.body) + // 2. 정상 스트리밍 (DataBufferUtils.write 사용) + // Flux를 OutputStream에 바로 씁니다. (메모리 효율 최적) + val streamingBody = StreamingResponseBody { outputStream -> + val flux = responseEntity.body ?: Flux.empty() + DataBufferUtils.write(flux, outputStream) + .blockLast() // 쓰기가 완료될 때까지 대기 + } + + val builder = ResponseEntity.ok().contentType(contentType) + if (contentLength > 0) { + builder.contentLength(contentLength) + } + + return builder.body(streamingBody) } catch (e: Exception) { - logger.error("Image Fetch Error: ", e) + e.printStackTrace() return ResponseEntity.notFound().build() } } diff --git a/src/main/resources/templates/content/slideshow.html b/src/main/resources/templates/content/slideshow.html index 8abf65a..4e88af9 100644 --- a/src/main/resources/templates/content/slideshow.html +++ b/src/main/resources/templates/content/slideshow.html @@ -6,53 +6,208 @@ BUM's NAS Slideshow + + - +
✕ Close -
+ +
+
+
+
+
- -
+
+
+
+ + +
+
+
+
Loading...
+
+
+ +
+
+
- -
+
+
+
+ + +
+
+
+
Loading...
+
+
+
+ +
+
EXIF INFO
Loading...
+ +
+ + + +
+
+ 10s +
+
+
+ 1.5s +
+
+
+ +
+
+ + +
+
+ +
+ + +
+ +

Synology Login

- + @@ -60,198 +215,450 @@
-
- -
-
-
- - -
-
- - - -
- Speed: 5 sec - -
- \ No newline at end of file